mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-08-28 17:05:18 +01:00
Init AxoLoadingIndicator and AxoProgressIndicator
Co-authored-by: Jamie <113370520+jamiebuilds-signal@users.noreply.github.com>
This commit is contained in:
co-authored by
Jamie
parent
56f2962e13
commit
cc2de5d5ed
+29
-34
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user