136 lines
3.3 KiB
TypeScript
136 lines
3.3 KiB
TypeScript
// Copyright 2024 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import React from 'react';
|
|
|
|
import { tw, type TailwindStyles } from '../axo/tw.js';
|
|
import { roundFractionForProgressBar } from '../util/numbers.js';
|
|
|
|
export type Props = {
|
|
value?: number | 'indeterminate'; // default: 'indeterminate'
|
|
min?: number; // default: 0
|
|
max?: number; // default: 1
|
|
variant?: SpinnerVariant;
|
|
ariaLabel?: string;
|
|
marginRatio?: number;
|
|
size: number;
|
|
strokeWidth: number;
|
|
};
|
|
|
|
type SpinnerVariantStyles = Readonly<{
|
|
fg: TailwindStyles;
|
|
bg: TailwindStyles;
|
|
}>;
|
|
|
|
const SpinnerVariants = {
|
|
normal: {
|
|
bg: tw('stroke-label-disabled-on-color'),
|
|
fg: tw('stroke-label-primary-on-color'),
|
|
},
|
|
'no-background': {
|
|
bg: tw('stroke-none'),
|
|
fg: tw('stroke-label-primary-on-color'),
|
|
},
|
|
'no-background-incoming': {
|
|
bg: tw('stroke-none'),
|
|
fg: tw('stroke-label-primary'),
|
|
},
|
|
brand: {
|
|
bg: tw('stroke-fill-secondary'),
|
|
fg: tw('stroke-border-selected'),
|
|
},
|
|
} as const satisfies Record<string, SpinnerVariantStyles>;
|
|
|
|
export type SpinnerVariant = keyof typeof SpinnerVariants;
|
|
|
|
export function SpinnerV2({
|
|
value = 'indeterminate',
|
|
min = 0,
|
|
max = 1,
|
|
variant = 'normal',
|
|
marginRatio,
|
|
size,
|
|
strokeWidth,
|
|
ariaLabel,
|
|
}: Props): JSX.Element {
|
|
const sizeInPixels = `${size}px`;
|
|
|
|
const radius = Math.min(
|
|
size / 2 - strokeWidth / 2,
|
|
(size / 2) * (marginRatio ?? 0.8)
|
|
);
|
|
const circumference = radius * 2 * Math.PI;
|
|
|
|
const { bg, fg } = SpinnerVariants[variant];
|
|
|
|
const bgElem = (
|
|
<circle
|
|
className={tw(bg, 'fill-none')}
|
|
strokeWidth={strokeWidth}
|
|
r={radius}
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
/>
|
|
);
|
|
|
|
if (value === 'indeterminate') {
|
|
return (
|
|
<svg
|
|
className={tw('fill-none')}
|
|
width={sizeInPixels}
|
|
height={sizeInPixels}
|
|
>
|
|
{bgElem}
|
|
<g className={tw('origin-center animate-spinner-v2-rotate')}>
|
|
<circle
|
|
className={tw(fg, 'animate-spinner-v2-dash fill-none')}
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={radius}
|
|
style={{
|
|
strokeLinecap: 'round',
|
|
}}
|
|
strokeWidth={strokeWidth}
|
|
/>
|
|
</g>
|
|
</svg>
|
|
);
|
|
}
|
|
const fractionComplete = roundFractionForProgressBar(
|
|
(value - min) / (max - min)
|
|
);
|
|
|
|
return (
|
|
<svg
|
|
className={tw('fill-none')}
|
|
width={sizeInPixels}
|
|
height={sizeInPixels}
|
|
role="progressbar"
|
|
aria-label={ariaLabel}
|
|
aria-valuenow={value}
|
|
aria-valuemin={min}
|
|
aria-valuemax={max}
|
|
>
|
|
{bgElem}
|
|
<g className={tw('origin-center -rotate-90')}>
|
|
<circle
|
|
className={tw(
|
|
fg,
|
|
'fill-none transition-[stroke-dashoffset] duration-500 ease-out-cubic'
|
|
)}
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={radius}
|
|
style={{ strokeLinecap: 'round' }}
|
|
strokeWidth={strokeWidth}
|
|
// 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 - fractionComplete) * circumference}
|
|
/>
|
|
</g>
|
|
</svg>
|
|
);
|
|
}
|