← All proposals

Spinner: agentic color variant

Extend the existing PDS v2 Spinner (not a new component).

Extends a PDS componentOpen in Storybook

proposals/Spinner/Spinner.tsx167 lines · View on GitHub
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>
	);
};