diff --git a/packages/ui/src/components/spinner-variants.test.ts b/packages/ui/src/components/spinner-variants.test.ts new file mode 100644 index 00000000000..3915cc85da6 --- /dev/null +++ b/packages/ui/src/components/spinner-variants.test.ts @@ -0,0 +1,32 @@ +import { expect, test } from "bun:test" +import { spinnerDots, spinnerKeyframes, spinnerVariants } from "./spinner-variants" + +test("every variation's dots fit the viewBox and light within one loop", () => { + Object.values(spinnerVariants).forEach((variant) => { + const dots = spinnerDots(variant) + + expect(dots.length).toBe(variant.layout === "orb" ? 37 : variant.span ** 2) + dots.forEach((dot) => { + expect(dot.x).toBeGreaterThanOrEqual(0) + expect(dot.x + dot.size).toBeLessThanOrEqual(16) + expect(dot.at).toBeGreaterThanOrEqual(0) + expect(dot.at).toBeLessThan(1) + expect(dot.still).toBeGreaterThanOrEqual(variant.dim) + expect(dot.still).toBeLessThanOrEqual(1) + }) + }) +}) + +test("a border snake rests its middle dots and visits each edge dot once", () => { + const dots = spinnerDots(spinnerVariants.snake) + const edge = dots.filter((dot) => !dot.hold) + + expect(dots.filter((dot) => dot.hold === "rest")).toHaveLength(4) + expect(new Set(edge.map((dot) => dot.at)).size).toBe(12) +}) + +test("keyframes run from the dim rest to fully lit", () => { + expect(spinnerKeyframes("spinner-radar", spinnerVariants.radar)).toBe( + "@keyframes spinner-radar{0%{opacity:0.2;animation-timing-function:cubic-bezier(.45,0,.2,1)}3%{opacity:1;animation-timing-function:cubic-bezier(.45,0,.2,1)}51%{opacity:0.2;animation-timing-function:cubic-bezier(.45,0,.2,1)}100%{opacity:0.2;animation-timing-function:cubic-bezier(.45,0,.2,1)}}", + ) +}) diff --git a/packages/ui/src/components/spinner-variants.ts b/packages/ui/src/components/spinner-variants.ts new file mode 100644 index 00000000000..0a2021f3923 --- /dev/null +++ b/packages/ui/src/components/spinner-variants.ts @@ -0,0 +1,320 @@ +// Dot-matrix variations of the activity spinner, tried from Knobs → Experiments → "Activity spinner". Each variation +// is plain data: a dot layout, a keyframe shape, and a pattern that turns each dot's place (angle, distance, path +// index…) into its animation delay. Animation is CSS only; every dot shares the variation's loop, so pinning start +// times with syncSpinPhase keeps every spinner on screen in phase. Paste replacements from the prototype's +// "Try in app" list. + +export type SpinnerPattern = + | "sweep" + | "bar" + | "diagonal" + | "ripple" + | "twinkle" + | "border" + | "breathe" + | "spiral" + | "rain" + | "pulse" + +export type SpinnerVariant = { + label: string + /** "grid" is span × span; "orb" is the 37-dot circle (rows of 3, 5, 7, 7, 7, 5, 3). */ + layout: "grid" | "orb" + span: number + /** Dot side and gap, in a 16-unit viewBox. */ + dot: number + gap: number + /** Seconds per loop at 1×. */ + loop: number + pattern: SpinnerPattern + /** How far apart neighbouring dots light, for the patterns that spread outward. */ + spread?: number + /** The centre dot stays lit (radars and scan lines). */ + steady?: boolean + /** Resting opacity of an unlit dot. */ + dim: number + /** The keyframe shape: [percent of the loop, level from 0 (dim) to 1 (lit)]. */ + stops: readonly (readonly [number, number])[] +} + +export const spinnerVariants = { + radar: { + label: "Soft Radar", + layout: "grid", + span: 5, + dot: 2, + gap: 1, + loop: 1.8, + pattern: "sweep", + steady: true, + dim: 0.2, + stops: [ + [0, 0], + [3, 1], + [51, 0], + [100, 0], + ], + }, + orbRadar: { + label: "Orb Radar", + layout: "orb", + span: 7, + dot: 1.5, + gap: 0.72, + loop: 2.2, + pattern: "sweep", + steady: true, + dim: 0.2, + stops: [ + [0, 0], + [3, 1], + [62, 0], + [100, 0], + ], + }, + ripple: { + label: "Centre Ripple", + layout: "grid", + span: 5, + dot: 2, + gap: 1, + loop: 1.5, + pattern: "ripple", + spread: 0.28, + dim: 0.2, + stops: [ + [0, 0], + [3, 1], + [37, 0], + [100, 0], + ], + }, + snake: { + label: "Border Snake", + layout: "grid", + span: 4, + dot: 2.6, + gap: 1.3, + loop: 1.2, + pattern: "border", + dim: 0.2, + stops: [ + [0, 0], + [3, 1], + [33, 0], + [100, 0], + ], + }, + spiral: { + label: "Spiral Fill", + layout: "grid", + span: 5, + dot: 2, + gap: 1, + loop: 3, + pattern: "spiral", + dim: 0.2, + stops: [ + [0, 0], + [3, 1], + [48, 1], + [54, 0], + [100, 0], + ], + }, + breathe: { + label: "Breathing Grid", + layout: "grid", + span: 3, + dot: 3.6, + gap: 2, + loop: 2, + pattern: "breathe", + spread: 0.05, + dim: 0.2, + stops: [ + [0, 0], + [8, 0], + [50, 1], + [92, 0], + [100, 0], + ], + }, +} satisfies Record + +export type SpinnerDot = { + x: number + y: number + size: number + /** Where in the loop the dot lights, 0–1. */ + at: number + /** The dot's loop as a multiple of the variation's (twinkle varies it). */ + scale: number + /** Always lit, or never animated (the resting middle of a border pattern). */ + hold?: "lit" | "rest" + /** Opacity for reduced motion: the pattern frozen a fifth of the way into its loop. */ + still: number +} + +type Cell = { c: number; r: number; gx: number; gy: number } + +/** The dots of a variation, positioned in a 16-unit viewBox. */ +export function spinnerDots(variant: SpinnerVariant): SpinnerDot[] { + const cells = variant.layout === "orb" ? orb() : grid(variant.span) + const extent = variant.span * variant.dot + (variant.span - 1) * variant.gap + const fit = Math.min(1, 15 / extent) + const size = variant.dot * fit + const pitch = (variant.dot + variant.gap) * fit + const order = variant.pattern === "spiral" ? spiralOrder(variant.span) : [] + const ring = variant.pattern === "border" ? borderOrder(variant.span) : [] + const random = seeded(4242) + const offsets = Array.from({ length: variant.span }, () => random()) + + return cells.map((cell, index) => { + const centre = cell.gx === 0 && cell.gy === 0 + + const twinkle = + variant.pattern === "twinkle" ? { at: random(), scale: [1, 1.35, 1.7][Math.floor(random() * 3)]! } : undefined + + const at = (() => { + const spread = variant.spread ?? 1 + const dist = Math.hypot(cell.gx, cell.gy) + + switch (variant.pattern) { + case "sweep": + return angle(cell) + case "bar": + return frac(angle(cell) * 2) + case "diagonal": + return (cell.c + cell.r) / (2 * variant.span - 1) + case "ripple": + case "breathe": + case "pulse": + return frac(dist * spread) + case "twinkle": + return twinkle!.at + case "border": + return Math.max(0, ring.indexOf(cell.r * variant.span + cell.c)) / ring.length + case "spiral": + return order.indexOf(cell.r * variant.span + cell.c) / (2 * order.length) + case "rain": + return frac(offsets[cell.c]! + cell.r * spread * 0.11) + } + })() + + const hold = + variant.steady && centre + ? "lit" + : variant.pattern === "border" && !ring.includes(cell.r * variant.span + cell.c) + ? "rest" + : undefined + + const level = hold === "lit" ? 1 : hold === "rest" ? 0 : levelAt(variant.stops, frac(0.22 - at) * 100) + + return { + x: round(8 + cell.gx * pitch - size / 2), + y: round(8 + cell.gy * pitch - size / 2), + size: round(size), + at: round(at), + scale: twinkle?.scale ?? 1, + hold, + still: round(variant.dim + (1 - variant.dim) * level), + } + }) +} + +/** The variation's keyframes, named `name`. */ +export function spinnerKeyframes(name: string, variant: SpinnerVariant) { + const frames = variant.stops + .map( + ([percent, level]) => + `${round(percent)}%{opacity:${round(variant.dim + (1 - variant.dim) * level)};animation-timing-function:cubic-bezier(.45,0,.2,1)}`, + ) + .join("") + + return `@keyframes ${name}{${frames}}` +} + +function grid(span: number): Cell[] { + return Array.from({ length: span * span }, (_, index) => { + const c = index % span + const r = Math.floor(index / span) + + return { c, r, gx: c - (span - 1) / 2, gy: r - (span - 1) / 2 } + }) +} + +function orb(): Cell[] { + return [3, 5, 7, 7, 7, 5, 3].flatMap((count, r) => + Array.from({ length: count }, (_, j) => { + const c = (7 - count) / 2 + j + + return { c, r, gx: c - 3, gy: r - 3 } + }), + ) +} + +// Clockwise from the top-left corner, as indices into a span × span grid. +function borderOrder(span: number) { + const top = Array.from({ length: span }, (_, c) => c) + const right = Array.from({ length: span - 2 }, (_, r) => (r + 1) * span + span - 1) + const bottom = Array.from({ length: span }, (_, c) => (span - 1) * span + span - 1 - c) + const left = Array.from({ length: span - 2 }, (_, r) => (span - 2 - r) * span) + + return [...top, ...right, ...bottom, ...left] +} + +// Inward from the top-left corner, clockwise. +function spiralOrder(span: number) { + const order: number[] = [] + const edges = { top: 0, left: 0, bottom: span - 1, right: span - 1 } + + while (edges.top <= edges.bottom && edges.left <= edges.right) { + for (let c = edges.left; c <= edges.right; c++) order.push(edges.top * span + c) + edges.top++ + + for (let r = edges.top; r <= edges.bottom; r++) order.push(r * span + edges.right) + edges.right-- + + if (edges.top <= edges.bottom) for (let c = edges.right; c >= edges.left; c--) order.push(edges.bottom * span + c) + edges.bottom-- + + if (edges.left <= edges.right) for (let r = edges.bottom; r >= edges.top; r--) order.push(r * span + edges.left) + edges.left++ + } + + return order +} + +// Clockwise from 12 o'clock, 0–1. +function angle(cell: Cell) { + return frac(Math.atan2(cell.gx, -cell.gy) / (2 * Math.PI)) +} + +function levelAt(stops: SpinnerVariant["stops"], percent: number) { + const next = stops.findIndex(([at]) => at >= percent) + + if (next <= 0) return stops[0]?.[1] ?? 0 + const [p0, l0] = stops[next - 1]! + const [p1, l1] = stops[next]! + + return l0 + ((l1 - l0) * (percent - p0)) / (p1 - p0 || 1) +} + +function seeded(seed: number) { + const state = { value: seed } + + return () => { + state.value = (state.value * 16807) % 2147483647 + + return (state.value - 1) / 2147483646 + } +} + +function frac(value: number) { + return value - Math.floor(value) +} + +function round(value: number) { + return Math.round(value * 1000) / 1000 +} diff --git a/packages/ui/src/components/spinner.css b/packages/ui/src/components/spinner.css index c2652c219d8..ab25a90aaae 100644 --- a/packages/ui/src/components/spinner.css +++ b/packages/ui/src/components/spinner.css @@ -32,3 +32,19 @@ display: block; } } + +/* Dot-matrix variations (Knobs → Experiments → Activity spinner). Each dot rests at its frozen pose, so reduced + motion only has to stop the animation. */ +[data-slot="spinner-dots"] rect { + opacity: var(--still); +} + +/* syncSpinPhase marks spinners out of view; `!important` beats the inline animation each dot carries. */ +[data-slot="spinner-dots"][data-offscreen] rect { + animation-play-state: paused !important; +} + +:root[data-motion="reduce"] [data-slot="spinner-dots"] rect { + /* Beats the inline animation each dot carries. */ + animation: none !important; +} diff --git a/packages/ui/src/components/spinner.stories.tsx b/packages/ui/src/components/spinner.stories.tsx index be6106d1486..c3a32d24b73 100644 --- a/packages/ui/src/components/spinner.stories.tsx +++ b/packages/ui/src/components/spinner.stories.tsx @@ -1,36 +1,42 @@ -// @ts-nocheck -import * as mod from "./spinner" -import { create } from "../storybook/scaffold" +import { Spinner, type SpinnerVariantName } from "./spinner" +import { spinnerVariants } from "./spinner-variants" const docs = `### Overview -Animated loading indicator for inline or page-level loading states. - -Use with \`Button\` or in empty states. - -### API -- Accepts standard SVG props (class, style). +Animated loading indicator for inline or page-level loading states. Tabs, \`Loader\`, \`ActivityRing\` and the +titlebar update button all render it. ### Variants and states -- Single default animation style. +- Today: the 5×5 radar sweep from an animated PNG mask. +- Dot-matrix variations to try from Knobs → Experiments → Activity spinner; a \`variant\` prop pins one. ### Behavior -- Animation is CSS-driven via data attributes. +- CSS animation only. Every dot of a variation shares one loop, and \`syncSpinPhase\` pins every instance to the + same start, so spinners on screen stay in phase. +- Reduced motion freezes each variation on a still pose. ### Accessibility - Use alongside text or aria-live regions to convey loading state. - -### Theming/tokens -- Uses \`data-component="spinner"\` for styling hooks. - ` -const story = create({ title: "UI/Spinner", mod }) +type Args = { variant: SpinnerVariantName; speed: number; size: number } export default { title: "UI/Spinner", id: "components-spinner", - component: story.meta.component, + component: Spinner, tags: ["autodocs"], + args: { variant: "today", speed: 1, size: 16 }, + argTypes: { + variant: { control: "select", options: ["today", ...Object.keys(spinnerVariants)] }, + speed: { control: { type: "range", min: 0.25, max: 2, step: 0.05 } }, + size: { control: { type: "range", min: 12, max: 48, step: 1 } }, + }, + render: (args: Args) => ( + + ), parameters: { docs: { description: { @@ -40,14 +46,16 @@ export default { }, } -export const Basic = story.Basic +export const Today = { args: { variant: "today" } } -export const Sizes = { - render: () => ( -
- - - -
- ), -} +export const SoftRadar = { args: { variant: "radar" } } + +export const OrbRadar = { args: { variant: "orbRadar" } } + +export const CentreRipple = { args: { variant: "ripple" } } + +export const BorderSnake = { args: { variant: "snake" } } + +export const SpiralFill = { args: { variant: "spiral" } } + +export const BreathingGrid = { args: { variant: "breathe" } } diff --git a/packages/ui/src/components/spinner.tsx b/packages/ui/src/components/spinner.tsx index 7c79575a5a9..07e04cc1de3 100644 --- a/packages/ui/src/components/spinner.tsx +++ b/packages/ui/src/components/spinner.tsx @@ -1,19 +1,100 @@ -import { type ComponentProps } from "solid-js" +import { createSignal, For, Show, splitProps, type ComponentProps } from "solid-js" +import { syncSpinPhase } from "../hooks/spin-phase" +import { motionTuning } from "../motion-tuning" +import { spinnerDots, spinnerKeyframes, spinnerVariants, type SpinnerVariant } from "./spinner-variants" + +export type SpinnerVariantName = "today" | keyof typeof spinnerVariants + +// Experiment: dot-matrix variations to try in place of today's spinner, everywhere it shows (tabs, loaders, the +// update button), before one ships. +const choice = motionTuning.choice("spinner.variant", { + label: "Activity spinner", + group: "Experiments", + value: "today", + // SAFETY: built from spinnerVariants' own keys, plus "today". + labels: Object.fromEntries([ + ["today", "Today"], + ...Object.entries(spinnerVariants).map(([name, variant]) => [name, variant.label]), + ]) as Record, +}) + +const [chosen, setChosen] = createSignal(choice()) + +motionTuning.subscribe(() => setChosen(choice())) + +// Each variation's keyframes are added to the document once, the first time one renders. +const added = new Set() + +export function Spinner(props: ComponentProps<"svg"> & { "data-component"?: string; variant?: SpinnerVariantName }) { + const [local, rest] = splitProps(props, ["variant", "class", "classList", "data-component"]) + const name = () => local.variant ?? chosen() + // SAFETY: "today" is the only name that isn't a key of spinnerVariants, and it maps to undefined. + const variant = (): SpinnerVariant | undefined => spinnerVariants[name() as keyof typeof spinnerVariants] + const classList = () => ({ ...local.classList, [local.class ?? ""]: !!local.class }) -export function Spinner(props: ComponentProps<"svg"> & { "data-component"?: string }) { return ( - + + + } > - - + {(current) => { + const keyframes = `spinner-${name()}` + + if (!added.has(keyframes)) { + added.add(keyframes) + const style = document.createElement("style") + style.textContent = spinnerKeyframes(keyframes, current) + document.head.append(style) + } + + return ( + + + {(dot) => { + const loop = current.loop * dot.scale + + return ( + + ) + }} + + + ) + }} + ) } diff --git a/packages/ui/src/hooks/spin-phase.ts b/packages/ui/src/hooks/spin-phase.ts index ee0132e2fb4..89623d4e273 100644 --- a/packages/ui/src/hooks/spin-phase.ts +++ b/packages/ui/src/hooks/spin-phase.ts @@ -2,17 +2,43 @@ import { onCleanup } from "solid-js" // ResizeObserver delivers after style and layout but before paint, so a spinner that mounts or leaves `display: none` // (which restarts its CSS animation) paints its first frame already in phase. -const observer = +const resize = typeof ResizeObserver === "undefined" ? undefined - : new ResizeObserver((entries) => entries.forEach((entry) => pinSpinPhase(entry.target))) + : new ResizeObserver((entries) => entries.forEach((entry) => pinVisible(entry.target))) -/** Use as a `ref` on a looping indicator so every instance on screen shares one phase. */ +// Spinners scrolled or clipped out of view, under a `display: none` or `content-visibility: hidden` ancestor, or sized +// to nothing get `data-offscreen`, which their CSS uses to pause. Coming back, they resume and re-pin to the shared +// phase. `visibility: hidden` and `opacity: 0` still count as visible. +const visibility = + typeof IntersectionObserver === "undefined" + ? undefined + : new IntersectionObserver((entries) => + entries.forEach((entry) => { + entry.target.toggleAttribute("data-offscreen", !entry.isIntersecting) + pinVisible(entry.target) + }), + ) + +/** Use as a `ref` on a looping indicator so every instance on screen shares one phase and pauses while hidden. */ export function syncSpinPhase(el: Element) { - observer?.observe(el) + resize?.observe(el) + visibility?.observe(el) // Turning reduced motion back off restarts the animation without resizing anything. - el.addEventListener("animationstart", () => pinSpinPhase(el)) - onCleanup(() => observer?.unobserve(el)) + el.addEventListener("animationstart", () => pinVisible(el)) + onCleanup(() => { + resize?.unobserve(el) + visibility?.unobserve(el) + }) +} + +// Setting the start time of a paused CSS animation resumes it and makes it ignore `animation-play-state` from then on, +// so a paused spinner is left alone until it is visible. `getAnimations` flushes style, so a spinner that has just +// lost `data-offscreen` is running again by the time it is pinned. +function pinVisible(el: Element) { + if (el.hasAttribute("data-offscreen")) return + + pinSpinPhase(el) } // Starting every infinite loop at the document timeline's origin puts all instances of one animation in phase.