"use client"; import type { CSSProperties } from "react"; import { ProgressBar as AriaProgressBar } from "react-aria-components"; import { cx } from "@/utils/cx"; /** * Spinner — an orbiting light for work of unknown length. * * // 20px, accent * // inside a primary button * * The light is drawn the way Composer Loader draws its band: the same arc * at three scales, a crisp line with a rounded head, a tight glow and a wide * bloom, each on its own blur. The soft layers carry shorter arcs and turn a * few degrees behind, so the sharp head always leads and the glow gathers * just behind it like the tail of a comet. A faint track completes the * circle. Everything is drawn in `currentColor`, so a text colour sets it. * * Announced as an indeterminate progress bar labelled "Loading" (override * with `aria-label`). Pass `aria-hidden` where the surrounding control * already says it is busy, e.g. a pending button. Reduced motion slows the * orbit rather than stopping it: the motion is the message. */ export type SpinnerSize = "xs" | "sm" | "md" | "lg" | "xl"; /** Box size and line width in px. */ const SIZES: Record = { xs: { box: 12, line: 1.5 }, sm: { box: 16, line: 2 }, md: { box: 20, line: 2 }, lg: { box: 24, line: 2.5 }, xl: { box: 32, line: 3 }, }; /** One scale of the light: a conic arc masked to a ring, blurred by its wrapper. */ function Arc({ box, line, width, blur, opacity, arc, lag, }: { box: number; line: number; /** Ring thickness, px. */ width: number; blur: number; opacity: number; /** Arc length in degrees; the head sits at 12 o'clock before the turn. */ arc: number; /** Degrees this layer trails the crisp head. */ lag: number; }) { const center = box / 2 - line / 2; const inner = center - width / 2; const outer = center + width / 2; // The wrapper is grown to hold the ring plus its blur, so nothing clips. const bleed = Math.max(0, outer + blur * 2 - box / 2); const ring = `radial-gradient(circle, transparent ${inner}px, #000 ${inner + 0.5}px, #000 ${outer - 0.5}px, transparent ${outer}px)`; const style: CSSProperties = { inset: -bleed, filter: blur > 0 ? `blur(${blur}px)` : undefined, opacity, rotate: `${-lag}deg`, }; return ( ); } export interface SpinnerProps { /** 12, 16, 20 (default), 24, or 32px. */ size?: SpinnerSize; /** Draw the faint full circle under the light. Default true. */ track?: boolean; /** Accessible name. Default "Loading". */ "aria-label"?: string; /** Hide it from assistive technology when a parent already announces the wait. */ "aria-hidden"?: boolean; /** Colour comes from the text colour, e.g. `text-white`. Default accent. */ className?: string; } export function SpinnerGlyph({ size = "md", track = true, className }: Omit) { const { box, line } = SIZES[size]; return ( {track ? ( ) : null} {/* The crisp line's rounded head, at the arc's leading end. */} ); } export function Spinner({ size = "md", track = true, "aria-label": ariaLabel = "Loading", "aria-hidden": ariaHidden, className }: SpinnerProps) { if (ariaHidden) return ; return ( ); }