diff --git a/.changeset/afraid-buckets-build.md b/.changeset/afraid-buckets-build.md new file mode 100644 index 00000000000..a58e16be291 --- /dev/null +++ b/.changeset/afraid-buckets-build.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +Add feature flag to control whether Spinner animations are synchronized diff --git a/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts b/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts index a7f8d2efe39..d5c1cf6ab92 100644 --- a/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts +++ b/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts @@ -7,4 +7,5 @@ export const DefaultFeatureFlags = FeatureFlagScope.create({ primer_react_select_panel_fullscreen_on_narrow: false, primer_react_select_panel_order_selected_at_top: false, primer_react_select_panel_remove_active_descendant: false, + primer_react_spinner_synchronize_animations: false, }) diff --git a/packages/react/src/Spinner/Spinner.examples.stories.tsx b/packages/react/src/Spinner/Spinner.examples.stories.tsx index 2b43b526711..e0f6fe7fcdd 100644 --- a/packages/react/src/Spinner/Spinner.examples.stories.tsx +++ b/packages/react/src/Spinner/Spinner.examples.stories.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, {useEffect, useState} from 'react' import type {Meta} from '@storybook/react-vite' import Spinner from './Spinner' import {Button} from '..' @@ -95,3 +95,32 @@ export const FullLifecycleVisibleLoadingText = () => { ) } + +export const SynchronizedSpinners = () => ( + <> + + + + + + + + + + + + + + +) + +function Delay({children, ms}: {children: React.ReactNode; ms: number}) { + const [show, setShow] = useState(false) + + useEffect(() => { + const timeout = setTimeout(() => setShow(true), ms) + return () => clearTimeout(timeout) + }, [ms]) + + return show ? <>{children} : null +} diff --git a/packages/react/src/Spinner/Spinner.tsx b/packages/react/src/Spinner/Spinner.tsx index fffb57684c8..73945964036 100644 --- a/packages/react/src/Spinner/Spinner.tsx +++ b/packages/react/src/Spinner/Spinner.tsx @@ -1,10 +1,12 @@ +import {clsx} from 'clsx' import type React from 'react' -import {useState, useEffect} from 'react' +import {useCallback, useEffect, useRef, useState, useSyncExternalStore} from 'react' import {VisuallyHidden} from '../VisuallyHidden' import type {HTMLDataAttributes} from '../internal/internal-types' import {useId} from '../hooks' import classes from './Spinner.module.css' -import {clsx} from 'clsx' +import {useMedia} from '../hooks/useMedia' +import {useFeatureFlag} from '../FeatureFlags' const sizeMap = { small: '16px', @@ -34,6 +36,8 @@ function Spinner({ delay = false, ...props }: SpinnerProps) { + const syncAnimationsEnabled = useFeatureFlag('primer_react_spinner_synchronize_animations') + const animationRef = useSpinnerAnimation() const size = sizeMap[sizeKey] const hasHiddenLabel = srText !== null && ariaLabel === undefined const labelId = useId() @@ -58,6 +62,7 @@ function Spinner({ /* inline-flex removes the extra line height */ void + +type AnimationTimingValue = { + startTime: CSSNumberish | null +} + +type AnimationTimingStore = { + subscribers: Set + value: AnimationTimingValue + update(startTime: CSSNumberish): void + subscribe(subscriber: Subscriber): () => void + getSnapshot(): AnimationTimingValue + getServerSnapshot(): AnimationTimingValue +} + +const animationTimingStore: AnimationTimingStore = { + subscribers: new Set<() => void>(), + value: { + startTime: null, + }, + update(startTime) { + const value = { + startTime, + } + animationTimingStore.value = value + for (const subscriber of animationTimingStore.subscribers) { + subscriber() + } + }, + subscribe(subscriber) { + animationTimingStore.subscribers.add(subscriber) + return () => { + animationTimingStore.subscribers.delete(subscriber) + } + }, + getSnapshot() { + return animationTimingStore.value + }, + getServerSnapshot() { + return animationTimingStore.value + }, +} + +/** + * A utility hook for reading a common `startTime` value so that all animations + * are in sync. This is a global value and is coordinated through `useSyncExternalStore`. + */ +function useAnimationTiming() { + return useSyncExternalStore( + animationTimingStore.subscribe, + animationTimingStore.getSnapshot, + animationTimingStore.getServerSnapshot, + ) +} + +/** + * Uses a technique from Spectrum to coordinate animations: + * @see https://github.com/adobe/react-spectrum/blob/ab5e6f3dba4235dafab9f81f8b5c506ce5f11230/packages/%40react-spectrum/s2/src/Skeleton.tsx#L21 + */ +function useSpinnerAnimation() { + const ref = useRef(null) + const noMotionPreference = useMedia('(prefers-reduced-motion: no-preference)', false) + const animationTiming = useAnimationTiming() + return useCallback( + (element: HTMLElement | SVGSVGElement | null) => { + if (!element) { + return + } + + if (ref.current !== null) { + return + } + + if (noMotionPreference) { + const cssAnimation = element.getAnimations().find((animation): animation is CSSAnimation => { + if (animation instanceof CSSAnimation) { + return animation.animationName.startsWith('Spinner') && animation.animationName.endsWith('rotate-keyframes') + } + return false + }) + // If we can find a CSS Animation, pause it and we will use the Web + // Animations API to pick up from where it left off + cssAnimation?.pause() + + ref.current = element.animate( + [ + { + transform: 'rotate(0deg)', + }, + { + transform: 'rotate(360deg)', + }, + ], + { + // var(--base-duration-1000) + duration: 1000, + // var(--base-easing-linear) + easing: 'cubic-bezier(0,0,1,1)', + iterations: Infinity, + }, + ) + + // When the `startTime` value from `animationTimingStore` is `null` we + // are currently hydrating on the client. In this case, the first + // spinner to mount will set the `startTime` for all other spinners. + if (animationTiming.startTime === null) { + const startTime = cssAnimation?.startTime ?? 0 + + animationTimingStore.update(startTime) + + // We use `startTime` to sync different animations. When all animations + // have the same startTime they will be in sync. + // @see https://developer.mozilla.org/en-US/docs/Web/API/Animation/startTime#syncing_different_animations + ref.current.startTime = startTime + } else { + ref.current.startTime = animationTiming.startTime + } + } + }, + [noMotionPreference, animationTiming], + ) +} + export default Spinner