diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index f884c61..8cd51c8 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,2 +1,3 @@ export { AppShell } from "./AppShell"; export { Pill, SegmentedToggle, ProgressBar, FloatingActionBar, CourseCover, coverFits, monogram } from "./primitives"; +export { reducedMotion, viewTransition, holdDecision, useHeldValue, type HoldDecision } from "./motion"; diff --git a/packages/ui/src/motion.ts b/packages/ui/src/motion.ts new file mode 100644 index 0000000..e8bae8f --- /dev/null +++ b/packages/ui/src/motion.ts @@ -0,0 +1,55 @@ +import { useEffect, useLayoutEffect, useState } from "react"; +import { flushSync } from "react-dom"; + +/** Whether the user asked for reduced motion (false outside a browser). */ +export function reducedMotion(): boolean { + return typeof window !== "undefined" && typeof window.matchMedia === "function" + && window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +type VtDocument = { startViewTransition?: (update: () => void) => unknown; visibilityState?: string }; + +/** + * Runs a React state update inside a view transition when the browser supports one and motion is allowed, and + * directly otherwise. The update is synchronous (flushSync), so the new state is captured at once and nothing + * waits on it. The look of the transition is in tokens.css. + */ +export function viewTransition(update: () => void): void { + const doc = (typeof document === "undefined" ? null : document) as VtDocument | null; + if (!doc || typeof doc.startViewTransition !== "function" || doc.visibilityState === "hidden" || reducedMotion()) { + update(); + return; + } + doc.startViewTransition(() => flushSync(update)); +} + +export type HoldDecision = "show-next" | "hold" | "swap" | "replace"; + +/** + * What `useHeldValue` does with the value on screen (`shown`) when the wanted one is `next` (null while loading): + * show it, keep the old one while loading ("hold"), swap with a transition (a different key) or replace it + * plainly (the same key reloaded, or an old value that may not be held, such as another course's lesson). + */ +export function holdDecision(shown: T | null, next: T | null, keyOf: (v: T) => string, holdable: (prev: T) => boolean = () => true): HoldDecision { + if (shown === null || shown === next) return "show-next"; + if (!holdable(shown)) return "replace"; + if (next === null) return "hold"; + return keyOf(shown) === keyOf(next) ? "replace" : "swap"; +} + +const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; + +/** + * Keeps the previous value on screen while the next one loads, then swaps to it inside a view transition, so a + * page never blanks between two steps. `stale` is true while the old value is shown. + */ +export function useHeldValue(next: T | null, keyOf: (v: T) => string, holdable?: (prev: T) => boolean): { value: T | null; stale: boolean } { + const [shown, setShown] = useState(next); + const decision = holdDecision(shown, next, keyOf, holdable); + useIsoLayoutEffect(() => { + if (decision === "swap") viewTransition(() => setShown(next)); + else if (shown !== next && decision !== "hold") setShown(next); + }, [next, decision]); + const value = decision === "hold" || decision === "swap" ? shown : next; + return { value, stale: value !== next }; +} diff --git a/packages/ui/test/motion.test.ts b/packages/ui/test/motion.test.ts new file mode 100644 index 0000000..a28bdd3 --- /dev/null +++ b/packages/ui/test/motion.test.ts @@ -0,0 +1,56 @@ +import { afterEach, expect, test } from "bun:test"; +import { holdDecision, reducedMotion, viewTransition } from "../src/motion"; + +const g = globalThis as Record; +afterEach(() => { delete g.document; delete g.window; }); + +function env({ reduce = false, vt = true, hidden = false } = {}) { + const calls: string[] = []; + g.window = { matchMedia: (q: string) => ({ matches: q.includes("prefers-reduced-motion: reduce") && reduce }) }; + g.document = { + visibilityState: hidden ? "hidden" : "visible", + ...(vt ? { startViewTransition: (cb: () => void) => { calls.push("vt"); cb(); return { finished: Promise.resolve() }; } } : {}), + }; + return calls; +} + +test("reducedMotion follows the media query and is false without a window", () => { + expect(reducedMotion()).toBe(false); + env({ reduce: true }); + expect(reducedMotion()).toBe(true); + env({ reduce: false }); + expect(reducedMotion()).toBe(false); +}); + +test("viewTransition runs the update inside startViewTransition when it can", () => { + const calls = env(); + viewTransition(() => calls.push("update")); + expect(calls).toEqual(["vt", "update"]); +}); + +test("viewTransition runs the update directly with reduced motion, without support, or in a hidden tab", () => { + for (const o of [{ reduce: true }, { vt: false }, { hidden: true }]) { + const calls = env(o); + viewTransition(() => calls.push("update")); + expect([o, calls]).toEqual([o, ["update"]]); + } + delete g.document; delete g.window; + const plain: string[] = []; + viewTransition(() => plain.push("update")); + expect(plain).toEqual(["update"]); +}); + +test("holdDecision: show the first value, hold while loading, swap to a new key, replace the same key", () => { + type L = { id: string; course: string }; + const key = (l: L) => l.id; + const sameCourse = (c: string) => (l: L) => l.course === c; + const a = { id: "a", course: "x" }, b = { id: "b", course: "x" }, a2 = { id: "a", course: "x" }; + expect(holdDecision(null, null, key)).toBe("show-next"); + expect(holdDecision(null, a, key)).toBe("show-next"); + expect(holdDecision(a, a, key)).toBe("show-next"); + expect(holdDecision(a, null, key, sameCourse("x"))).toBe("hold"); + expect(holdDecision(a, null, key, sameCourse("y"))).toBe("replace"); + expect(holdDecision(a, b, key)).toBe("swap"); + expect(holdDecision(a, a2, key)).toBe("replace"); + expect(holdDecision(a, b, key, sameCourse("y"))).toBe("replace"); +});