// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file Spinner.tsx * @input Uses React, i18n (useTranslator), StyleX, SVG rendering * @output Exports Spinner component, SpinnerProps, SpinnerSize, SpinnerShade types * @position Core implementation of spinner loading indicator * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/Spinner/Spinner.doc.mjs * - /packages/core/src/Spinner/Spinner.test.tsx * - /packages/core/src/Spinner/index.ts * - /apps/storybook/stories/Spinner.stories.tsx * - /packages/cli/assets/templates/blocks/components/Spinner/ (showcase blocks) */ import { useId, type ReactNode } from 'react'; import * as stylex from "../style-runtime.js"; import "../theme/tokens.stylex.js"; import { colorVars, durationVars, spacingVars } from "../theme/tokens.stylex.js"; import type { BaseProps } from "../BaseProps.js"; import { Text } from "../Text/Text.js"; import { mergeProps } from "../utils/index.js"; import { useTranslator } from "../i18n/index.js"; import { themeProps } from "../utils/themeProps.js"; // ============================================================================= // Constants // ============================================================================= /** * Default fraction of the ring the moving arc covers. The canvas ring this * replaces swept 135deg, not the 270deg its constant's comment claimed. * * Themeable via `--spinner-arc-fraction`, declared alongside the other public * vars in `sizeStyles` below. Only the inline `strokeDasharray` attribute * (the pre-stylesheet render — see its own comment) still reads this * constant directly; the CSS side composes the dash from the live var. */ const ARC_FRACTION = 0.375; const PI = 3.141592653589793; const SIZES = { sm: { diameter: 10, border: 2 }, md: { diameter: 14, border: 3 }, lg: { diameter: 18, border: 3 }, xl: { diameter: 28, border: 4 } }; /** * Opacity the track is drawn at, per shade. `77 / 255` is the `4D` the onMedia * track used to append to the token's hex — the same composite, but applied as * `stroke-opacity` to a color off the cascade, so it no longer depends on the * token being hex notation and it applies to a themed color too. */ const TRACK_OPACITY: Record = { default: 1, subtle: 1, onMedia: 77 / 255, inherit: 0.3 }; /** * Where the resolved geometry lands: the public var, resolved into a registered * `` the `calc()`s below can do arithmetic on. The box and the ring * read these; the public vars are declared once, on the element carrying the * theme target, by `sizeStyles`. */ const RESOLVED_DIAMETER = '--_spinner-ring-diameter'; const RESOLVED_STROKE = '--_spinner-ring-stroke'; const RESOLVED_GEOMETRY_VARS = [RESOLVED_DIAMETER, RESOLVED_STROKE]; /** * The composed box size: diameter plus a stroke width on each side. * * It is deliberately NOT registered, unlike the pair above. The element reads * it through an inline `width`/`height` with the size's own default as the * `var()` fallback, so a render with no stylesheet — where nothing declares * it — still sizes the box the way it always did. A registered property always * has a value (its `initial-value`), which would swallow that fallback and * collapse the box to zero. */ const BOX_SIZE = '--_spinner-box-size'; /** * Register the resolved geometry vars as ``. * * Both are consumed inside `calc()` — the box adds two stroke widths to a diameter, * the circle halves one. Unregistered, a custom property substitutes as text, * so whatever a theme wrote lands in the expression verbatim and a bare `0` * (a valid length on its own, a `` inside `calc()`) poisons the sum: * `calc(28px + 0 * 2)` is invalid at computed-value time and the box loses its * size. Registered, the value is already an absolute length by the time the * `calc()` sees it, so `0` means `0px` — a zero-width stroke that paints * nothing — rather than a bare `0` that invalidates the sum and leaves the box * with no size at all. One `stroke-width` drives both circles, so a themed * stroke width of `0` hides the arc along with the track; an arc with no track behind * it is `--spinner-track-color: transparent`. * * Only these private vars are registered. The four public ones deliberately * are not: a registered property has an `initial-value`, so every element in * the document would report a value for it — and * `.github/scripts/theme-var-reachability.js` finds a var's declaring element * by exactly that test, so registering them would point the guard at `` * and report a var no theme can select. */ function registerSpinnerVars(): void { if (typeof CSS === 'undefined' || typeof CSS.registerProperty !== 'function') { return; } for (const name of RESOLVED_GEOMETRY_VARS) { try { CSS.registerProperty({ name, syntax: '', inherits: true, initialValue: '0px' }); } catch { // Already registered — a second copy of the package on the page, or a // fast-refresh re-evaluation. registerProperty throws rather than // replacing, and the existing registration is this same one. } } } // Registering an inherited property with an `initial-value` invalidates style // for the whole document, so this runs when the module is evaluated rather // than when a spinner mounts. A spinner is the loading indicator: it mounts // onto a page that is already rendered, with someone already waiting, and the // recalc it triggers there is paid on the full tree. At import the tree is // whatever has rendered so far, which for a bundle loaded in the head is // nothing. // // It is safe at module scope in both directions. The `typeof CSS` guard above // keeps it out of the server render, and tree-shaking cannot strip it from a // build that renders a spinner: core's `sideEffects` allowlist does not name // this file, so a bundle that never imports `Spinner` drops the module whole — // registration and all, which is the outcome you want — while one that does // import it keeps the module, and a bare call is not something a bundler may // elide. registerSpinnerVars(); /** * Pin every ring's dash motion to the document timeline's origin instead of * its own start time, so spinners mounted seconds apart stay in phase. * * Setting `startTime` is exact where arithmetic on a clock read is not: a * negative `animation-delay` computed at mount is only as good as the gap * between reading the clock and the frame the animation starts in, which at * 10x CPU throttling measured 116deg of drift (from when this animated a * literal rotation; the same drift risk applies to the dash offset now). * * Rings are collected and pinned in one frame because `getAnimations()` * resolves style and `startTime` dirties it again, so pinning them one at a * time makes each mount re-force what the previous one invalidated — 53 style * recalcs for 38 spinners against 19 batched. */ const pendingRings = new Set(); let flushScheduled = false; function pinRingsToTimelineOrigin(): void { flushScheduled = false; const animations: Animation[] = []; for (const svg of pendingRings) { // The animation now runs on the arc , a descendant of the ring // this ref sits on (#6253), so it needs subtree:true to be found here. animations.push(...svg.getAnimations({ subtree: true })); } pendingRings.clear(); for (const animation of animations) { animation.startTime = 0; } } /** * Ref callback for the ring: the one place a mounted ring touches the DOM. * * It reads nothing back — the geometry is resolved by the cascade, not in JS. */ function syncRotationPhase(svg: SVGSVGElement | null): (() => void) | undefined { if (svg == null) { return undefined; } // jsdom implements no Web Animations, and this runs in every consumer's // component tests. if (typeof svg.getAnimations !== 'function') { return undefined; } pendingRings.add(svg); if (!flushScheduled) { flushScheduled = true; requestAnimationFrame(pinRingsToTimelineOrigin); } return () => { pendingRings.delete(svg); }; } // ============================================================================= // Animation // ============================================================================= /** * Moves the dash around a stationary circle instead of rotating the whole * ring. WebKit re-antialiases a rotating stroked shape's rounded cap * (`strokeLinecap: 'round'`, below) slightly differently at each intermediate * angle, which reads as a visible wobble on iOS Safari independent of * compositing (#6253) — confirmed by a real-device test against this exact * fix, since `willChange`/layer promotion alone (the earlier fix for #3617) * only smooths the rotation's motion, not the per-frame stroke rasterization. * A static circle with an animated `stroke-dashoffset` never rotates, so * WebKit never re-rasterizes the cap at an intermediate angle at all. */ const dashMotion = "x1wbvqyn-B"; // ============================================================================= // Styles // ============================================================================= const styles = { wrapper: { k1xSpc: "x3nfvp2", kXwgrk: "xdt5ytf", kGNEyG: "x6s0dn4", kOIVth: "x1txdalj", $$css: true as const }, spinner: { k1xSpc: "xwz0xwf", kgQiWS: "x1ku5rj1", kXLuUW: "xxymvpz", "--_spinner-ring-diameter": "x2lq4xu", "--_spinner-ring-stroke": "x10qssua", "--_spinner-box-size": "x69vvuq", kmuXW: "x2lah0s", $$css: true as const }, circle: { kDwRjp: "xbh8q5q", kR8GR0: "x1764fhq", k3nNDw: "x1g0ag68", kU5bRw: "x1owpc8m", kPFa82: "xio8zfp", kfJifR: "xgw3ha0", $$css: true as const }, track: { kjVXCG: "xalkhop", $$css: true as const } }; // What each named `size` and `shade` resolve to. Both groups DECLARE the four // public vars, on the element that carries the `spinner` theme target, and // everything downstream reads them — so a theme's `@layer astryx-theme` rule // against `.astryx-spinner[data-size="xl"]` overrides the default the same way it does for // `--tree-list-indent` or `--button-focus-offset`, e.g. // spinner: { 'size:xl': { '--spinner-diameter': '40px' } }. // // Declaring is only safe because #5410 moved the compiled StyleX CSS inside // `@layer astryx-base`. Before it, StyleX emitted custom-property // declarations at priority 0 and therefore OUTSIDE its layers, and an // unlayered declaration beats every layer — so a StyleX-declared // `--spinner-diameter: 10px` shadowed the theme's own rule no matter how // specific the theme got. An earlier revision of this component worked around // that by never declaring the public var and reading it with the default as a // `var()` fallback; that is no longer necessary, and the fallback shape has a // cost of its own — with nothing declaring the var, // `theme-var-reachability.js` cannot find an element to check, so a documented // var reads as unreachable. // Arc fraction is not itself size-dependent, but it declares alongside the // two vars that are, on the same per-size condition (`!hasLabel` gate at the // call site) — it needs a home on whichever element carries the theme // target, and this is the object already wired to be there. const sizeStyles = { sm: { "--spinner-diameter": "x11wm0hx", "--spinner-stroke-width": "xls98ul", "--spinner-arc-fraction": "x7o5821", $$css: true as const }, md: { "--spinner-diameter": "x15pu9g6", "--spinner-stroke-width": "xr0wkrm", "--spinner-arc-fraction": "x7o5821", $$css: true as const }, lg: { "--spinner-diameter": "x1w424tr", "--spinner-stroke-width": "xr0wkrm", "--spinner-arc-fraction": "x7o5821", $$css: true as const }, xl: { "--spinner-diameter": "x1orj1z9", "--spinner-stroke-width": "x7y2bof", "--spinner-arc-fraction": "x7o5821", $$css: true as const } }; const shadeStyles = { default: { "--spinner-color": "xt1b8mc", "--spinner-track-color": "xspt9s2", $$css: true as const }, subtle: { "--spinner-color": "x1jevo6s", "--spinner-track-color": "xspt9s2", $$css: true as const }, onMedia: { "--spinner-color": "x13u6jys", "--spinner-track-color": "x1ufpcf6", $$css: true as const }, inherit: { "--spinner-color": "x1uzk0gl", "--spinner-track-color": "xbfzqbu", $$css: true as const } }; // The track's alpha is a property, not a color: it composites over whatever // color the shade or the theme supplies. `77 / 255` is the `4D` the onMedia // token's hex used to carry. const trackOpacityStyles = { default: { kDd8S0: "x1g350g8", $$css: true as const }, subtle: { kDd8S0: "x1g350g8", $$css: true as const }, onMedia: { kDd8S0: "x1smxkh6", $$css: true as const }, inherit: { kDd8S0: "x7bo2k", $$css: true as const } }; // ============================================================================= // Types // ============================================================================= export type SpinnerSize = keyof typeof SIZES; export type SpinnerShade = 'default' | 'onMedia' | 'subtle' | 'inherit'; export interface SpinnerProps extends BaseProps { /** Ref forwarded to the root element */ ref?: React.Ref; /** * Spinner size. The diameter is the ring itself; the rendered box adds the * stroke width on each side (xl draws a 28px ring in a 36px box). A theme can * redefine what each named size resolves to — see `--spinner-diameter`. * - 'sm': 10px diameter * - 'md': 14px diameter * - 'lg': 18px diameter * - 'xl': 28px diameter * @default 'md' */ size?: SpinnerSize; /** * Color shade. * - 'default': accent color on light backgrounds * - 'onMedia': white on dark/accent backgrounds * - 'subtle': secondary text color, less prominent — for inline use in lists * - 'inherit': inherits the parent's `currentColor` (with a translucent * track) — use inside colored elements like buttons so the ring matches * the resolved foreground regardless of theme/variant * @default 'default' */ shade?: SpinnerShade; /** * Visible content displayed below the spinner. * Accepts a string or ReactNode for rich content. * * When `label` is a string, the visible text also provides the accessible * name of the status element (via aria-labelledby, avoiding a duplicate * announcement) unless `aria-label` is explicitly set. * * @example * ``` * * Fetching data
This may take a moment} aria-label="Fetching data" /> * ``` */ label?: ReactNode; /** * Test ID for the root element. */ 'data-testid'?: string; } // ============================================================================= // Component // ============================================================================= /** * An animated loading indicator. Available in four sizes and four color shades. * * @example * ``` * * * * * * ``` */ export function Spinner({ size = 'md', shade = 'default', label, xstyle, className, style, 'aria-label': ariaLabel, 'data-testid': testId, ref, ...restProps }: SpinnerProps) { const { border, diameter } = SIZES[size]; const frameSize = diameter + border * 2; const circumference = Math.PI * diameter; const arcLength = circumference * ARC_FRACTION; const hasLabel = label != null; const labelId = useId(); const t = useTranslator(); // When a visible string label renders (and no explicit aria-label is set), // name the status element from the visible Text via aria-labelledby instead // of duplicating the same string as aria-label — the duplicate would be // announced twice by screen readers (WCAG 4.1.2). const namedByVisibleLabel = hasLabel && typeof label === 'string' && ariaLabel == null; // Resolve accessible name: explicit aria-label > string label > the // localized default. The fallback is AT-facing text, so it goes through the // translation runtime like visible text does. const resolvedAriaLabel = ariaLabel ?? (typeof label === 'string' ? label : undefined) ?? t('@astryx.spinner.loading'); const spinner = ; if (!hasLabel) { return spinner; } return
} data-testid={testId} {...restProps} {...mergeProps(themeProps('spinner', { size, shade }), stylex.props(styles.wrapper, sizeStyles[size], shadeStyles[shade], xstyle), className, style)}> {spinner} {typeof label === 'string' ? {label} : label}
; } Spinner.displayName = 'Spinner';