mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
feat(ui): try activity spinner variations from Knobs (#57903)
This commit is contained in:
committed by
GitHub
Unverified
parent
4d38fcda8c
commit
db7968d6bb
@@ -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)}}",
|
||||
)
|
||||
})
|
||||
@@ -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<string, SpinnerVariant>
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) => (
|
||||
<Spinner
|
||||
variant={args.variant}
|
||||
style={{ width: `${args.size}px`, height: `${args.size}px`, "--spinner-time": String(1 / args.speed) }}
|
||||
/>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
@@ -40,14 +46,16 @@ export default {
|
||||
},
|
||||
}
|
||||
|
||||
export const Basic = story.Basic
|
||||
export const Today = { args: { variant: "today" } }
|
||||
|
||||
export const Sizes = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: "16px", "align-items": "center" }}>
|
||||
<mod.Spinner style={{ width: "12px", height: "12px" }} />
|
||||
<mod.Spinner style={{ width: "20px", height: "20px" }} />
|
||||
<mod.Spinner style={{ width: "28px", height: "28px" }} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
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" } }
|
||||
|
||||
@@ -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<SpinnerVariantName, string>,
|
||||
})
|
||||
|
||||
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<string>()
|
||||
|
||||
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 (
|
||||
<svg
|
||||
{...props}
|
||||
viewBox="0 0 16 16"
|
||||
data-component={props["data-component"] ?? "spinner"}
|
||||
data-slot="spinner-grid"
|
||||
classList={{
|
||||
...props.classList,
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
fill="currentColor"
|
||||
// Keyed, so switching variations builds a fresh SVG: its dots, keyframes and phase pin all come from the new one.
|
||||
<Show
|
||||
when={variant()}
|
||||
keyed
|
||||
fallback={
|
||||
<svg
|
||||
{...rest}
|
||||
viewBox="0 0 16 16"
|
||||
data-component={local["data-component"] ?? "spinner"}
|
||||
data-slot="spinner-grid"
|
||||
classList={classList()}
|
||||
fill="currentColor"
|
||||
>
|
||||
<rect data-reduced-motion x={7} y={7} width={2} height={2} />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
<rect data-reduced-motion x={7} y={7} width={2} height={2} />
|
||||
</svg>
|
||||
{(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 (
|
||||
<svg
|
||||
{...rest}
|
||||
ref={syncSpinPhase}
|
||||
viewBox="0 0 16 16"
|
||||
data-component={local["data-component"] ?? "spinner"}
|
||||
data-slot="spinner-dots"
|
||||
data-variant={name()}
|
||||
classList={classList()}
|
||||
fill="currentColor"
|
||||
>
|
||||
<For each={spinnerDots(current)}>
|
||||
{(dot) => {
|
||||
const loop = current.loop * dot.scale
|
||||
|
||||
return (
|
||||
<rect
|
||||
x={dot.x}
|
||||
y={dot.y}
|
||||
width={dot.size}
|
||||
height={dot.size}
|
||||
rx={dot.size * 0.24}
|
||||
style={{
|
||||
"--still": dot.hold === "lit" ? 1 : dot.hold === "rest" ? current.dim : dot.still,
|
||||
animation: dot.hold
|
||||
? undefined
|
||||
: `${keyframes} calc(${loop}s * var(--spinner-time, 1)) linear calc(${-loop * (1 - dot.at)}s * var(--spinner-time, 1)) infinite`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</svg>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user