diff --git a/ts/axo/AxoButton.dom.tsx b/ts/axo/AxoButton.dom.tsx index 7968cdfc3f..34db17ad5c 100644 --- a/ts/axo/AxoButton.dom.tsx +++ b/ts/axo/AxoButton.dom.tsx @@ -4,11 +4,10 @@ import { memo, useCallback } from 'react'; import type { FC, ReactNode, JSX, MouseEvent, Ref } from 'react'; import { tw } from './tw.dom.tsx'; import { AxoSymbol } from './AxoSymbol.dom.tsx'; -import type { SpinnerVariant } from '../components/SpinnerV2.dom.tsx'; -import { SpinnerV2 } from '../components/SpinnerV2.dom.tsx'; import { useAxoIntl } from './_internal/AxoIntl.dom.tsx'; import { variants } from './_internal/variants.dom.tsx'; import { forwardExtraPropsForRadix } from './_internal/props.dom.tsx'; +import { AxoBaseSpinner } from './status/_AxoBaseSpinner.dom.tsx'; /** * A text button with optional leading icon and trailing arrow. @@ -399,57 +398,53 @@ export namespace AxoButton { * ------------------- */ - const SpinnerVariants = variants( + const SpinnerVariants = variants( 'AxoButton.Variant', { - 'strong-primary': 'axo-button-spinner-oncolor', - 'strong-secondary': 'axo-button-spinner-secondary', - 'strong-affirmative': 'axo-button-spinner-oncolor', - 'strong-warning': 'axo-button-spinner-oncolor', - 'strong-destructive': 'axo-button-spinner-oncolor', - 'subtle-primary': 'axo-button-spinner-primary', - 'subtle-secondary': 'axo-button-spinner-secondary', - 'subtle-affirmative': 'axo-button-spinner-affirmative', - 'subtle-warning': 'axo-button-spinner-warning', - 'subtle-destructive': 'axo-button-spinner-destructive', - 'elevated-secondary': 'axo-button-spinner-secondary', - 'implied-primary': 'axo-button-spinner-primary', - 'implied-secondary': 'axo-button-spinner-secondary', - 'implied-affirmative': 'axo-button-spinner-affirmative', - 'implied-destructive': 'axo-button-spinner-destructive', - 'message-incoming-primary': 'axo-button-spinner-oncolor', - 'message-outgoing-primary': 'axo-button-spinner-secondary', + 'strong-primary': 'oncolor', + 'strong-secondary': 'default', + 'strong-affirmative': 'oncolor', + 'strong-warning': 'oncolor', + 'strong-destructive': 'oncolor', + 'subtle-primary': 'default', + 'subtle-secondary': 'default', + 'subtle-affirmative': 'default', + 'subtle-warning': 'default', + 'subtle-destructive': 'default', + 'elevated-secondary': 'default', + 'implied-primary': 'default', + 'implied-secondary': 'default', + 'implied-affirmative': 'default', + 'implied-destructive': 'default', + 'message-incoming-primary': 'default', + 'message-outgoing-primary': 'default', } ); - type SpinnerSizeConfig = Readonly<{ - size: number; - strokeWidth: number; - }>; - - const SpinnerSizes = variants('AxoButton.Size', { - lg: { size: 20, strokeWidth: 2 }, - md: { size: 20, strokeWidth: 2 }, - sm: { size: 16, strokeWidth: 1.5 }, + const SpinnerSizes = variants('AxoButton.Size', { + lg: 18, + md: 18, + sm: 16, }); /** @internal */ type SpinnerProps = Readonly<{ - buttonVariant: Variant; buttonSize: Size; + buttonVariant: Variant; }>; /** @internal */ function Spinner(props: SpinnerProps): JSX.Element { + const size = SpinnerSizes.get(props.buttonSize); const variant = SpinnerVariants.get(props.buttonVariant); - const sizeConfig = SpinnerSizes.get(props.buttonSize); return ( - ); diff --git a/ts/axo/AxoIconButton.dom.tsx b/ts/axo/AxoIconButton.dom.tsx index a838b55237..ea3e1435a6 100644 --- a/ts/axo/AxoIconButton.dom.tsx +++ b/ts/axo/AxoIconButton.dom.tsx @@ -4,12 +4,11 @@ import type { FC, Ref, MouseEvent, FocusEvent } from 'react'; import { memo, useCallback, useMemo } from 'react'; import { AxoSymbol } from './AxoSymbol.dom.tsx'; import { tw } from './tw.dom.tsx'; -import type { SpinnerVariant } from '../components/SpinnerV2.dom.tsx'; -import { SpinnerV2 } from '../components/SpinnerV2.dom.tsx'; import { AxoTooltip } from './AxoTooltip.dom.tsx'; import { useAxoIntl } from './_internal/AxoIntl.dom.tsx'; import { variants } from './_internal/variants.dom.tsx'; import { forwardExtraPropsForRadix } from './_internal/props.dom.tsx'; +import { AxoBaseSpinner } from './status/_AxoBaseSpinner.dom.tsx'; /** * A circular icon-only button with an accessible label and built-in tooltip. @@ -360,51 +359,50 @@ export namespace AxoIconButton { * -------------------------------------------------------------------------- */ - const SpinnerVariants = variants( + const SpinnerVariants = variants( 'AxoIconButton.Variant', { - 'strong-primary': 'axo-button-spinner-oncolor', - 'strong-secondary': 'axo-button-spinner-secondary', - 'strong-affirmative': 'axo-button-spinner-oncolor', - 'strong-warning': 'axo-button-spinner-onbright', - 'strong-destructive': 'axo-button-spinner-oncolor', - 'subtle-primary': 'axo-button-spinner-primary', - 'subtle-secondary': 'axo-button-spinner-secondary', - 'subtle-affirmative': 'axo-button-spinner-affirmative', - 'subtle-warning': 'axo-button-spinner-warning', - 'subtle-destructive': 'axo-button-spinner-destructive', - 'elevated-secondary': 'axo-button-spinner-secondary', - 'material-subtle': 'axo-button-spinner-secondary', - 'material-strong': 'axo-button-spinner-secondary', - 'implied-secondary': 'axo-button-spinner-secondary', + 'strong-primary': 'oncolor', + 'strong-secondary': 'default', + 'strong-affirmative': 'oncolor', + 'strong-warning': 'oncolor', + 'strong-destructive': 'oncolor', + 'subtle-primary': 'default', + 'subtle-secondary': 'default', + 'subtle-affirmative': 'default', + 'subtle-warning': 'default', + 'subtle-destructive': 'default', + 'elevated-secondary': 'default', + 'material-subtle': 'default', + 'material-strong': 'default', + 'implied-secondary': 'default', } ); - type SpinnerSizeConfig = { size: number; strokeWidth: number }; - - const SpinnerSizes = variants('AxoIconButton.Size', { - lg: { size: 20, strokeWidth: 2 }, - md: { size: 20, strokeWidth: 2 }, - sm: { size: 16, strokeWidth: 1.5 }, + const SpinnerSizes = variants('AxoIconButton.Size', { + lg: 18, + md: 18, + sm: 16, }); /** @internal */ type SpinnerProps = Readonly<{ - buttonVariant: Variant; buttonSize: Size; + buttonVariant: Variant; }>; /** @internal */ const Spinner: FC = memo(props => { + const size = SpinnerSizes.get(props.buttonSize); const variant = SpinnerVariants.get(props.buttonVariant); - const sizeConfig = SpinnerSizes.get(props.buttonSize); return ( - ); diff --git a/ts/axo/AxoStackedButton.dom.tsx b/ts/axo/AxoStackedButton.dom.tsx index 0dc08e2d5c..36c164ead1 100644 --- a/ts/axo/AxoStackedButton.dom.tsx +++ b/ts/axo/AxoStackedButton.dom.tsx @@ -7,8 +7,8 @@ import { tw } from './tw.dom.tsx'; import { FlexWrapDetector } from './_internal/FlexWrapDetector.dom.tsx'; import { variants } from './_internal/variants.dom.tsx'; import { useAxoIntl } from './_internal/AxoIntl.dom.tsx'; -import { SpinnerV2 } from '../components/SpinnerV2.dom.tsx'; import { forwardExtraPropsForRadix } from './_internal/props.dom.tsx'; +import { AxoBaseSpinner } from './status/_AxoBaseSpinner.dom.tsx'; /** * An icon-button + text displayed as a stack, and can be placed in a row. @@ -197,11 +197,12 @@ export namespace AxoStackedButton { const Spinner: FC = memo(() => { return ( - ); diff --git a/ts/axo/_internal/AxoMath.dom.tsx b/ts/axo/_internal/AxoMath.dom.tsx new file mode 100644 index 0000000000..ecd1930a2a --- /dev/null +++ b/ts/axo/_internal/AxoMath.dom.tsx @@ -0,0 +1,36 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from './assert.std.tsx'; + +export namespace AxoMath { + export function clamp(value: number, min: number, max: number): number { + if (value < min) { + return min; + } + if (value > max) { + return max; + } + return value; + } + + /** Works the same way as CSS `progress([no-clamp] )` */ + export function progress( + value: number, + min: number, + max: number, + noClamp?: boolean + ): number { + assert(max > min, 'max must be greater than min'); + const result = (value - min) / (max - min); + return noClamp ? result : clamp(result, 0, 1); + } + + export function circumference(radius: number): number { + return radius * 2 * Math.PI; + } + + export function pseudoRandomInt(min: number, max: number): number { + return min + Math.floor(Math.random() * (max - min + 1)); + } +} diff --git a/ts/axo/_tailwind-theme/animations.css b/ts/axo/_tailwind-theme/animations.css index 899bd17da7..2fa710c791 100644 --- a/ts/axo/_tailwind-theme/animations.css +++ b/ts/axo/_tailwind-theme/animations.css @@ -8,8 +8,12 @@ --default-animation-timing-function: var(--ease-out-cubic); --animate-*: initial; /* reset defaults */ + --animate-spinner-v2-rotate: animate-spinner-v2-rotate 2s linear infinite; --animate-spinner-v2-dash: animate-spinner-v2-dash 1.5s ease-in-out infinite; + + --animate-axo-spinner-rotate: animate-axo-spinner-rotate 2s linear infinite; + --animate-axo-spinner-dash: animate-axo-spinner-dash 1.5s ease-in-out infinite; } @layer base { @@ -37,3 +41,32 @@ } } } + +@keyframes axo-spinner-rotate { + 0% { + transform: rotate(-180deg); + } + 100% { + transform: rotate(180deg); + } +} + +/* Note: 9999 is just a large gap to prevent the sequence from repeating */ +/* prettier-ignore */ +@keyframes axo-spinner-dash { + 0% { + stroke-dashoffset: 0; + stroke-dasharray: 1, 9999; + transform: rotate(0deg); + } + 50% { + stroke-dashoffset: 0; + stroke-dasharray: 180%, 9999; + transform: rotate(90deg); + } + 100% { + stroke-dashoffset: 0; + stroke-dasharray: 1, 9999; + transform: rotate(360deg); + } +} diff --git a/ts/axo/_tailwind.css b/ts/axo/_tailwind.css index c52465e53b..0c89839c1f 100644 --- a/ts/axo/_tailwind.css +++ b/ts/axo/_tailwind.css @@ -23,5 +23,5 @@ @import './_tailwind-theme/z-index.css'; @import './AxoDragRegions.css'; -@import './AxoSelect.css'; @import './AxoScrollArea.css'; +@import './AxoSelect.css'; diff --git a/ts/axo/status/AxoLoadingIndicator.dom.stories.tsx b/ts/axo/status/AxoLoadingIndicator.dom.stories.tsx new file mode 100644 index 0000000000..6cf96860af --- /dev/null +++ b/ts/axo/status/AxoLoadingIndicator.dom.stories.tsx @@ -0,0 +1,35 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import type { ReactNode } from 'react'; +import type { Meta } from '@storybook/react'; +import { AxoLoadingIndicator } from './AxoLoadingIndicator.dom.tsx'; +import { tw } from '../tw.dom.tsx'; + +export default { + title: 'Axo/Status/AxoLoadingIndicator', +} satisfies Meta; + +export function Basic(): ReactNode { + return ; +} + +export function Sizes(): ReactNode { + return ( +
+ + + +
+ ); +} + +export function Weights(): ReactNode { + return ( +
+ + + + +
+ ); +} diff --git a/ts/axo/status/AxoLoadingIndicator.dom.tsx b/ts/axo/status/AxoLoadingIndicator.dom.tsx new file mode 100644 index 0000000000..52cc557739 --- /dev/null +++ b/ts/axo/status/AxoLoadingIndicator.dom.tsx @@ -0,0 +1,44 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import type { FC } from 'react'; +import { memo } from 'react'; +import { AxoBaseSpinner } from './_AxoBaseSpinner.dom.tsx'; + +export namespace AxoLoadingIndicator { + /** + * + * -------------------------------------------------------------------------- + */ + + /** + * Preset sizes for standalone loading indicators, the base spinner can be + * customized to any size, but we should primarily use that internally in + * other Axo components. + */ + export type Size = 'sm' | 'md' | 'lg'; + + /** + * The stroke width of the spinner. + * TODO: We might automate this based on size. + */ + export type Weight = AxoBaseSpinner.Weight; + + export type RootProps = Readonly<{ + size: Size; + weight?: Weight; + }>; + + export const Root: FC = memo(props => { + return ( + + ); + }); + + Root.displayName = 'AxoLoadingIndicator.Root'; +} diff --git a/ts/axo/status/AxoProgressIndicator.dom.stories.tsx b/ts/axo/status/AxoProgressIndicator.dom.stories.tsx new file mode 100644 index 0000000000..7e2950f575 --- /dev/null +++ b/ts/axo/status/AxoProgressIndicator.dom.stories.tsx @@ -0,0 +1,140 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import type { ReactNode } from 'react'; +import { useEffect, useId, useState } from 'react'; +import type { Meta } from '@storybook/react'; +import { AxoProgressIndicator } from './AxoProgressIndicator.dom.tsx'; +import { tw } from '../tw.dom.tsx'; +import { AxoMath } from '../_internal/AxoMath.dom.tsx'; +import { AxoCheckbox } from '../AxoCheckbox.dom.tsx'; + +export default { + title: 'Axo/Status/AxoProgressIndicator', +} satisfies Meta; + +function useFakeProgress(): { total: number; completed: number } { + const [completed, setCompleted] = useState(0); + const total = 100; + + useEffect(() => { + let timer: ReturnType | undefined; + let current = 0; + + function set(value: number) { + current = value; + setCompleted(value); + } + + function next() { + if (current >= total) { + // reset + timer = setTimeout(() => { + set(0); + timer = setTimeout(() => { + next(); + }, 500); + }, 500); + return; + } + + const wait = AxoMath.pseudoRandomInt(100, 500); + const increment = AxoMath.pseudoRandomInt(5, 20); + + timer = setTimeout(() => { + set(AxoMath.clamp(current + increment, 0, total)); + next(); + }, wait); + } + + next(); + + return () => { + clearTimeout(timer); + }; + }, [total]); + + return { completed, total }; +} + +export function Basic(): ReactNode { + const { total, completed } = useFakeProgress(); + return ( + + ); +} + +export function NoTrack(): ReactNode { + const progress = useFakeProgress(); + return ; +} + +type CheckboxProps = Readonly<{ + label: string; + checked: boolean; + onCheckedChange: (checked: boolean) => void; +}>; + +function Checkbox(props: CheckboxProps) { + const id = useId(); + + return ( +
+ + +
+ ); +} + +export function Indeterminate(): ReactNode { + const [completed, setCompleted] = useState(false); + const [total, setTotal] = useState(true); + + return ( + <> + + + + + ); +} + +export function Sizes(): ReactNode { + const progress = useFakeProgress(); + return ( +
+ + + +
+ ); +} + +export function Weights(): ReactNode { + const progress = useFakeProgress(); + return ( +
+ + + + +
+ ); +} diff --git a/ts/axo/status/AxoProgressIndicator.dom.tsx b/ts/axo/status/AxoProgressIndicator.dom.tsx new file mode 100644 index 0000000000..e18a5108f2 --- /dev/null +++ b/ts/axo/status/AxoProgressIndicator.dom.tsx @@ -0,0 +1,55 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import type { FC } from 'react'; +import { memo, useMemo } from 'react'; +import { AxoBaseSpinner } from './_AxoBaseSpinner.dom.tsx'; + +export namespace AxoProgressIndicator { + /** + * + * -------------------------------------------------------------------------- + */ + + /** + * Preset sizes for standalone spinners, the base spinner can be customized + * to any size, but we should primarily use that internally in Axo components. + */ + export type Size = 'sm' | 'md' | 'lg'; + + /** + * The stroke width of the spinner. + * TODO: We might automate this based on size. + */ + export type Weight = AxoBaseSpinner.Weight; + + export type RootProps = Readonly<{ + size: Size; + weight?: Weight; + completed: number | 'indeterminate'; + total: number | 'indeterminate'; + noTrack?: boolean; + }>; + + export const Root: FC = memo(props => { + const { completed, total } = props; + + const value = useMemo((): AxoBaseSpinner.Value => { + if (completed === 'indeterminate' || total === 'indeterminate') { + return 'indeterminate'; + } + return { completed, total }; + }, [completed, total]); + + return ( + + ); + }); + + Root.displayName = 'AxoProgressIndicator.Root'; +} diff --git a/ts/axo/status/_AxoBaseSpinner.dom.tsx b/ts/axo/status/_AxoBaseSpinner.dom.tsx new file mode 100644 index 0000000000..1293109b46 --- /dev/null +++ b/ts/axo/status/_AxoBaseSpinner.dom.tsx @@ -0,0 +1,162 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { FC, SVGProps } from 'react'; +import { memo, useMemo } from 'react'; +import { tw } from '../tw.dom.tsx'; +import { variants } from '../_internal/variants.dom.tsx'; +import { AxoMath } from '../_internal/AxoMath.dom.tsx'; + +export namespace AxoBaseSpinner { + /** + * Preset sizes for standalone spinners, the base spinner can be customized + * to any size, but we should primarily use that internally in Axo components. + */ + export type Size = 'sm' | 'md' | 'lg'; + + /** + * The stroke width of the spinner. + * TODO: We might automate this based on size. + */ + export type Weight = 'medium' | 'regular' | 'light' | 'thin'; + + /** + * The color variants + */ + export type Variant = 'default' | 'oncolor'; + + const Sizes = variants('AxoBaseSpinner.Size', { + sm: 16, + md: 24, + lg: 36, + }); + + const Weights = variants('AxoBaseSpinner.Weight', { + medium: 2, + regular: 1.8, + light: 1.5, + thin: 1.2, + }); + + const TrackVariants = variants('AxoBaseSpinner.Variant', { + default: tw('stroke-(--axo-color-fill-secondary)'), + oncolor: tw('stroke-(--axo-color-label-disabled-oncolor)'), + }); + + const ProgressVariants = variants('AxoBaseSpinner.Variant', { + default: tw('stroke-(--axo-color-label-primary)'), + oncolor: tw('stroke-(--axo-color-label-primary-oncolor)'), + }); + + export type ProgressValue = Readonly<{ + completed: number; + total: number; + }>; + + export type IndeterminateValue = 'indeterminate'; + + export type Value = ProgressValue | IndeterminateValue; + + export type RootProps = Readonly<{ + size: Size | number; + weight: Weight; + variant: Variant; + value: Value; + track: boolean; + }>; + + export const Root: FC = memo(props => { + const { value } = props; + + const size = + typeof props.size === 'number' ? props.size : Sizes.get(props.size); + const strokeWidth = Weights.get(props.weight); + const center = size / 2; + const radius = size / 2 - strokeWidth / 2; + + const svgProps = useMemo((): SVGProps => { + if (value === 'indeterminate') { + return { + role: 'progressbar', + // without aria-valuenow/etc this is indeterminate + }; + } + + return { + role: 'progressbar', + 'aria-valuenow': value.completed, + 'aria-valuemin': 0, + 'aria-valuemax': value.total, + }; + }, [value]); + + const progressProps = useMemo((): SVGProps => { + if (value === 'indeterminate') { + return { + className: tw('animate-axo-spinner-dash'), + }; + } + + const circumference = AxoMath.circumference(radius); + const progress = AxoMath.progress(value.completed, 0, value.total); + + return { + className: tw( + 'transition-[stroke-dashoffset] duration-500 ease-out-cubic' + ), + // setting the strokeDashArray to be the circumference of the ring + // means each dash will cover the whole ring + strokeDasharray: circumference, + // offsetting the dash as a fraction of the circumference allows + // showing the progress + strokeDashoffset: (1 - progress) * circumference, + }; + }, [value, radius]); + + return ( + + {/* Track is only allowed for progress values */} + {value !== 'indeterminate' && props.track && ( + + )} + + + + + ); + }); + + Root.displayName = 'AxoBaseSpinner.Root'; +}