import { ComponentPropsWithoutRef, CSSProperties } from 'react';
import {
SPACING_2XL,
SPACING_3XL,
SPACING_4XL,
SPACING_5XL,
SPACING_6XL,
SPACING_L,
SPACING_M,
SPACING_S,
SPACING_XL,
SPACING_XS,
} from '@pantheon-systems/pds-design-tokens/build/js/variables.global.js';
import {
TYPOGRAPHY_SIZE_M,
TYPOGRAPHY_SIZE_S,
TYPOGRAPHY_SIZE_XS,
} from '@pantheon-systems/pds-design-tokens/build/js/variables.typography.js';
import './spinner.css';
export type SpinnerSize =
's' | 'm' | 'l' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl';
export interface SpinnerProps extends ComponentPropsWithoutRef<'span'> {
/**
* Additional class names
*/
className?: string;
/**
* Arc color. Accepts any CSS color value or `var(--token)` reference, or
* `'agentic'` to paint the arc and track with the AI gradient
* (`--pds-gradient-ai-active`). The track ring renders at the same color
* with reduced opacity.
*/
color?: 'agentic' | (string & {});
/**
* Should the spinner be an inline element? If true, it will be displayed inline-block without a visible label.
*/
isInline?: boolean;
/**
* Spinner label. Will be displayed below the Spinner if `showLabel` is true.
*/
label?: string;
/**
* Should the label be visible? If false, it will render for screen readers only.
*/
showLabel?: boolean;
/**
* Spinner size
*/
size?: SpinnerSize;
}
const spinnerSizes: Record<SpinnerSize, string> = {
s: SPACING_S,
m: SPACING_M,
l: SPACING_L,
xl: SPACING_XL,
'2xl': SPACING_2XL,
'3xl': SPACING_3XL,
'4xl': SPACING_4XL,
'5xl': SPACING_5XL,
'6xl': SPACING_6XL,
};
const labelFontSizes: Record<SpinnerSize, string> = {
s: TYPOGRAPHY_SIZE_XS,
m: TYPOGRAPHY_SIZE_XS,
l: TYPOGRAPHY_SIZE_XS,
xl: TYPOGRAPHY_SIZE_XS,
'2xl': TYPOGRAPHY_SIZE_XS,
'3xl': TYPOGRAPHY_SIZE_S,
'4xl': TYPOGRAPHY_SIZE_S,
'5xl': TYPOGRAPHY_SIZE_M,
'6xl': TYPOGRAPHY_SIZE_M,
};
const labelMargins: Record<SpinnerSize, string> = {
s: SPACING_XS,
m: SPACING_XS,
l: SPACING_XS,
xl: SPACING_XS,
'2xl': SPACING_XS,
'3xl': SPACING_S,
'4xl': SPACING_S,
'5xl': SPACING_M,
'6xl': SPACING_L,
};
// Stand-ins for the v2 `mergeClasses` util and `HiddenLabel`, which the
// published package does not export. The port uses the real ones.
const mergeClasses = (classes: Array<string | null>) =>
classes.filter(Boolean).join(' ');
/**
* Spinner UI component
*/
export const Spinner = ({
className = '',
color,
isInline = false,
label = 'Loading',
showLabel = false,
size = '3xl',
...props
}: SpinnerProps) => {
const baseClass = 'pds-spinner';
const isAgentic = color === 'agentic';
const displayClass = isInline ? `${baseClass}--inline` : null;
const agenticClass = isAgentic ? `${baseClass}--agentic` : null;
const spinnerStyles = {
'--spinner-size': spinnerSizes[size],
...(color && !isAgentic ? { '--spinner-color': color } : {}),
} as CSSProperties;
const labelStyles = {
fontSize: labelFontSizes[size],
marginBlockStart: labelMargins[size],
} as CSSProperties;
return (
<span
aria-hidden='true'
className={mergeClasses([
baseClass,
displayClass,
agenticClass,
className,
])}
role='status'
style={spinnerStyles}
{...props}
>
{isAgentic ? (
<span aria-hidden='true' className={`${baseClass}__icon`}>
<span className={`${baseClass}__gradient-track`} />
<span className={`${baseClass}__gradient-rotator`}>
<span className={`${baseClass}__gradient-arc`} />
</span>
</span>
) : (
<svg
aria-hidden='true'
className={`${baseClass}__icon`}
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
>
<circle className={`${baseClass}__track`} cx='12' cy='12' r='10.5' />
<g className={`${baseClass}__rotator`}>
<circle className={`${baseClass}__arc`} cx='12' cy='12' r='10.5' />
</g>
</svg>
)}
{showLabel && !isInline ? (
<span className={`${baseClass}__label`} style={labelStyles}>
{label}
</span>
) : (
<span className='visually-hidden'>{label}</span>
)}
</span>
);
};
Spinner: agentic color variant
Extend the existing PDS v2 Spinner (not a new component).
Extends a PDS componentOpen in Storybook