Init AxoLoadingIndicator and AxoProgressIndicator

Co-authored-by: Jamie <113370520+jamiebuilds-signal@users.noreply.github.com>
This commit is contained in:
automated-signal
2026-08-12 17:44:43 +00:00
committed by GitHub
co-authored by Jamie
parent 56f2962e13
commit cc2de5d5ed
11 changed files with 567 additions and 68 deletions
+29 -34
View File
@@ -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<Variant, SpinnerVariant>(
const SpinnerVariants = variants<Variant, AxoBaseSpinner.Variant>(
'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<Size, SpinnerSizeConfig>('AxoButton.Size', {
lg: { size: 20, strokeWidth: 2 },
md: { size: 20, strokeWidth: 2 },
sm: { size: 16, strokeWidth: 1.5 },
const SpinnerSizes = variants<Size, number>('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 (
<span className={tw('absolute inset-0 flex items-center justify-center')}>
<SpinnerV2
size={sizeConfig.size}
strokeWidth={sizeConfig.strokeWidth}
<AxoBaseSpinner.Root
size={size}
weight="regular"
variant={variant}
value="indeterminate"
track={false}
/>
</span>
);
+26 -28
View File
@@ -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<Variant, SpinnerVariant>(
const SpinnerVariants = variants<Variant, AxoBaseSpinner.Variant>(
'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<Size, SpinnerSizeConfig>('AxoIconButton.Size', {
lg: { size: 20, strokeWidth: 2 },
md: { size: 20, strokeWidth: 2 },
sm: { size: 16, strokeWidth: 1.5 },
const SpinnerSizes = variants<Size, number>('AxoIconButton.Size', {
lg: 18,
md: 18,
sm: 16,
});
/** @internal */
type SpinnerProps = Readonly<{
buttonVariant: Variant;
buttonSize: Size;
buttonVariant: Variant;
}>;
/** @internal */
const Spinner: FC<SpinnerProps> = memo(props => {
const size = SpinnerSizes.get(props.buttonSize);
const variant = SpinnerVariants.get(props.buttonVariant);
const sizeConfig = SpinnerSizes.get(props.buttonSize);
return (
<span className={tw('absolute inset-0 flex items-center justify-center')}>
<SpinnerV2
size={sizeConfig.size}
strokeWidth={sizeConfig.strokeWidth}
<AxoBaseSpinner.Root
size={size}
weight="regular"
variant={variant}
value="indeterminate"
track={false}
/>
</span>
);
+6 -5
View File
@@ -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 (
<span className={tw('absolute inset-0 flex items-center justify-center')}>
<SpinnerV2
size={20}
strokeWidth={2}
variant="axo-button-spinner-secondary"
<AxoBaseSpinner.Root
size={18}
weight="regular"
variant="default"
value="indeterminate"
track={false}
/>
</span>
);
+36
View File
@@ -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] <min> <value> <max>)` */
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));
}
}
+33
View File
@@ -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);
}
}
+1 -1
View File
@@ -23,5 +23,5 @@
@import './_tailwind-theme/z-index.css';
@import './AxoDragRegions.css';
@import './AxoSelect.css';
@import './AxoScrollArea.css';
@import './AxoSelect.css';
@@ -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 <AxoLoadingIndicator.Root size="md" />;
}
export function Sizes(): ReactNode {
return (
<div className={tw('flex gap-2')}>
<AxoLoadingIndicator.Root size="sm" />
<AxoLoadingIndicator.Root size="md" />
<AxoLoadingIndicator.Root size="lg" />
</div>
);
}
export function Weights(): ReactNode {
return (
<div className={tw('flex gap-2')}>
<AxoLoadingIndicator.Root size="md" weight="thin" />
<AxoLoadingIndicator.Root size="md" weight="light" />
<AxoLoadingIndicator.Root size="md" weight="regular" />
<AxoLoadingIndicator.Root size="md" weight="medium" />
</div>
);
}
+44
View File
@@ -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 {
/**
* <AxoLoadingIndicator.Root>
* --------------------------------------------------------------------------
*/
/**
* 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<RootProps> = memo(props => {
return (
<AxoBaseSpinner.Root
size={props.size}
weight={props.weight ?? 'regular'}
variant="default"
value="indeterminate"
track={false}
/>
);
});
Root.displayName = 'AxoLoadingIndicator.Root';
}
@@ -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<typeof setTimeout> | 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 (
<AxoProgressIndicator.Root size="md" total={total} completed={completed} />
);
}
export function NoTrack(): ReactNode {
const progress = useFakeProgress();
return <AxoProgressIndicator.Root size="md" noTrack {...progress} />;
}
type CheckboxProps = Readonly<{
label: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
}>;
function Checkbox(props: CheckboxProps) {
const id = useId();
return (
<div className={tw('flex gap-2')}>
<AxoCheckbox.Root
id={id}
variant="square"
checked={props.checked}
onCheckedChange={props.onCheckedChange}
/>
<label htmlFor={id}>{props.label}</label>
</div>
);
}
export function Indeterminate(): ReactNode {
const [completed, setCompleted] = useState(false);
const [total, setTotal] = useState(true);
return (
<>
<Checkbox
checked={completed}
onCheckedChange={setCompleted}
label="Known completed amount"
/>
<Checkbox
checked={total}
onCheckedChange={setTotal}
label="Known total amount"
/>
<AxoProgressIndicator.Root
size="md"
noTrack
completed={completed ? 30 : 'indeterminate'}
total={total ? 100 : 'indeterminate'}
/>
</>
);
}
export function Sizes(): ReactNode {
const progress = useFakeProgress();
return (
<div className={tw('flex gap-2')}>
<AxoProgressIndicator.Root size="sm" {...progress} />
<AxoProgressIndicator.Root size="md" {...progress} />
<AxoProgressIndicator.Root size="lg" {...progress} />
</div>
);
}
export function Weights(): ReactNode {
const progress = useFakeProgress();
return (
<div className={tw('flex gap-2')}>
<AxoProgressIndicator.Root size="md" weight="thin" {...progress} />
<AxoProgressIndicator.Root size="md" weight="light" {...progress} />
<AxoProgressIndicator.Root size="md" weight="regular" {...progress} />
<AxoProgressIndicator.Root size="md" weight="medium" {...progress} />
</div>
);
}
@@ -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 {
/**
* <AxoProgressIndicator.Root>
* --------------------------------------------------------------------------
*/
/**
* 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<RootProps> = memo(props => {
const { completed, total } = props;
const value = useMemo((): AxoBaseSpinner.Value => {
if (completed === 'indeterminate' || total === 'indeterminate') {
return 'indeterminate';
}
return { completed, total };
}, [completed, total]);
return (
<AxoBaseSpinner.Root
size={props.size}
weight={props.weight ?? 'regular'}
variant="default"
value={value}
track={!props.noTrack}
/>
);
});
Root.displayName = 'AxoProgressIndicator.Root';
}
+162
View File
@@ -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<Size, number>('AxoBaseSpinner.Size', {
sm: 16,
md: 24,
lg: 36,
});
const Weights = variants<Weight, number>('AxoBaseSpinner.Weight', {
medium: 2,
regular: 1.8,
light: 1.5,
thin: 1.2,
});
const TrackVariants = variants<Variant>('AxoBaseSpinner.Variant', {
default: tw('stroke-(--axo-color-fill-secondary)'),
oncolor: tw('stroke-(--axo-color-label-disabled-oncolor)'),
});
const ProgressVariants = variants<Variant>('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<RootProps> = 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<SVGSVGElement> => {
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<SVGCircleElement> => {
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 (
<svg
{...svgProps}
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{/* Track is only allowed for progress values */}
{value !== 'indeterminate' && props.track && (
<circle
cx={center}
cy={center}
r={radius}
className={TrackVariants.get(props.variant)}
strokeWidth={strokeWidth}
/>
)}
<g
className={tw(
'origin-center',
value === 'indeterminate'
? 'animate-axo-spinner-rotate'
: '-rotate-90'
)}
>
<circle
{...progressProps}
className={tw(
ProgressVariants.get(props.variant),
'fill-none',
progressProps.className
)}
cx={center}
cy={center}
r={radius}
strokeLinecap="round"
strokeWidth={strokeWidth}
/>
</g>
</svg>
);
});
Root.displayName = 'AxoBaseSpinner.Root';
}