← All proposals

SpinnerSequence

A new component, SpinnerSequence, that composes the existing PDS v2 Spinner and Illustration (the Spinner is not changed).

Net-new componentOpen in Storybook

proposals/SpinnerSequence/SpinnerSequence.tsx219 lines · View on GitHub
import {
	ComponentPropsWithoutRef,
	CSSProperties,
	ReactNode,
	useEffect,
	useRef,
	useState,
} from 'react';

import {
	Illustration,
	type PDSIllustration,
} from '@pantheon-systems/pds-toolkit-react';

import { Spinner, SpinnerSize } from '../Spinner/Spinner';

import './spinner-sequence.css';

/**
 * Illustration names added by this proposal. Everything else is a published
 * PDS illustration and is rendered by the real `Illustration`.
 */
export type NewIllustrationName = 'task';

/**
 * `Illustration=TASK` from the PDS Design Library v2, exported as SVG. The
 * published package has no `task` yet. The port adds the SVG to the
 * illustration registry and deletes this data and the wrapper below.
 */
const newIllustrations: Record<
	NewIllustrationName,
	{ height: number; svgPathData: string; width: number }
> = {
	task: {
		height: 121,
		svgPathData:
			'M60.1689 0C93.3674 0.00025947 120.337 27.021 120.337 60.209C120.337 93.3969 93.3347 120.417 60.1689 120.417C27.003 120.417 0.000169209 93.397 0 60.209C0 27.0208 27.0029 0 60.1689 0ZM60.1689 2.93652C28.6009 2.93652 2.93457 28.6198 2.93457 60.209C2.93474 91.798 28.601 117.48 60.1689 117.48C91.7367 117.48 117.402 91.7978 117.402 60.209C117.402 28.62 91.7368 2.93678 60.1689 2.93652ZM90.3027 40.6289L58.4404 84.7168L35.4824 64.3213L44.1572 54.5635L56.3213 65.3652L79.7363 32.96L90.3027 40.6289Z',
		width: 121,
	},
};

export interface SpinnerSequenceStep {
	/**
	 * How long this step shows, in milliseconds. Falls back to
	 * `defaultDuration`. Ignored when `activeStep` is set.
	 */
	duration?: number;
	/**
	 * Text shown under the spinner during this step.
	 */
	label: string;
}

export interface SpinnerSequenceProps extends Omit<
	ComponentPropsWithoutRef<'div'>,
	'children'
> {
	/**
	 * Current step, for when the real work decides (a controlled sequence).
	 * `0` is the first step and a number equal to or greater than the number
	 * of `steps` shows the final state. When set, the timers are off.
	 */
	activeStep?: number;
	/**
	 * Additional class names
	 */
	className?: string;
	/**
	 * How long each step shows, in milliseconds, unless the step has its own
	 * `duration`.
	 */
	defaultDuration?: number;
	/**
	 * Text shown with the illustration once the last step is done.
	 */
	finalLabel: string;
	/**
	 * Your own illustration for the final state. Takes over from
	 * `illustrationName`. It is sized to the spinner and is decorative: the
	 * text next to it says what it means.
	 */
	illustration?: ReactNode;
	/**
	 * Which illustration to show in the final state.
	 */
	illustrationName?: NewIllustrationName | PDSIllustration;
	/**
	 * Called once, when the final state shows.
	 */
	onComplete?: () => void;
	/**
	 * Spinner size. The illustration is drawn at the same size, so nothing
	 * moves when the final state shows.
	 */
	size?: SpinnerSize;
	/**
	 * The loading steps, in order. Add as many as you need.
	 */
	steps: SpinnerSequenceStep[];
}

// Same scale as Spinner, kept here so Spinner stays untouched.
const sizeTokens: Record<SpinnerSize, string> = {
	s: 'var(--pds-spacing-s)',
	m: 'var(--pds-spacing-m)',
	l: 'var(--pds-spacing-l)',
	xl: 'var(--pds-spacing-xl)',
	'2xl': 'var(--pds-spacing-2xl)',
	'3xl': 'var(--pds-spacing-3xl)',
	'4xl': 'var(--pds-spacing-4xl)',
	'5xl': 'var(--pds-spacing-5xl)',
	'6xl': 'var(--pds-spacing-6xl)',
};

// Stand-in for the v2 `mergeClasses` util, which the published package does
// not export. The port uses the real one.
const mergeClasses = (classes: Array<string | null>) =>
	classes.filter(Boolean).join(' ');

/**
 * SpinnerSequence UI component. A Spinner that walks through a list of steps,
 * each with its own text, then ends on an illustration and a final text.
 */
export const SpinnerSequence = ({
	activeStep,
	className = '',
	defaultDuration = 2000,
	finalLabel,
	illustration,
	illustrationName = 'task',
	onComplete,
	size = '5xl',
	steps,
	style,
	...props
}: SpinnerSequenceProps) => {
	const baseClass = 'pds-spinner-sequence';
	const isControlled = activeStep !== undefined;
	const [timedStep, setTimedStep] = useState(0);
	const step = isControlled ? activeStep : timedStep;
	const isComplete = step >= steps.length;
	const completeClass = isComplete ? `${baseClass}--complete` : null;

	// Timed mode: move on after the current step's time, and stop at the end.
	// It depends on the time, not on `steps`, so a new array each render does
	// not restart the timer.
	const stepDuration = steps[step]?.duration ?? defaultDuration;
	useEffect(() => {
		if (isControlled || isComplete) {
			return undefined;
		}
		const timer = setTimeout(() => setTimedStep(step + 1), stepDuration);
		return () => clearTimeout(timer);
	}, [isComplete, isControlled, step, stepDuration]);

	// Keep the latest callback without restarting anything when it changes.
	const onCompleteRef = useRef(onComplete);
	useEffect(() => {
		onCompleteRef.current = onComplete;
	}, [onComplete]);

	const hasCompletedRef = useRef(false);
	useEffect(() => {
		if (isComplete && !hasCompletedRef.current) {
			onCompleteRef.current?.();
		}
		hasCompletedRef.current = isComplete;
	}, [isComplete]);

	const label = isComplete ? finalLabel : (steps[step]?.label ?? '');

	const customIllustration = illustrationName in newIllustrations;
	const newData = customIllustration
		? newIllustrations[illustrationName as NewIllustrationName]
		: null;

	const stageStyles = {
		'--sequence-size': sizeTokens[size],
		...style,
	} as CSSProperties;

	return (
		<div
			className={mergeClasses([baseClass, completeClass, className])}
			style={stageStyles}
			{...props}
		>
			<div aria-hidden='true' className={`${baseClass}__stage`}>
				<div className={`${baseClass}__spinner`}>
					<Spinner size={size} />
				</div>
				<div className={`${baseClass}__illustration`}>
					{illustration ??
						(newData ? (
							<svg
								aria-hidden='true'
								className='pds-illustration'
								fill='none'
								focusable='false'
								viewBox={`0 0 ${newData.width} ${newData.height}`}
								xmlns='http://www.w3.org/2000/svg'
							>
								<path d={newData.svgPathData} fill='currentColor' />
							</svg>
						) : (
							<Illustration name={illustrationName as PDSIllustration} />
						))}
				</div>
			</div>
			{/* One polite live region, so each new text is read out once. */}
			<div className={`${baseClass}__text`} role='status'>
				<p className={`${baseClass}__label pds-heading-m`} key={label}>
					{label}
				</p>
			</div>
		</div>
	);
};