feat(ui): view-transition helper and held-value hook

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 20:32:57 +01:00
co-authored by Claude Opus 5.5
parent 17b6ae3828
commit b1d17956f4
3 changed files with 112 additions and 0 deletions
+1
View File
@@ -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";
+55
View File
@@ -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<T>(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<T>(next: T | null, keyOf: (v: T) => string, holdable?: (prev: T) => boolean): { value: T | null; stale: boolean } {
const [shown, setShown] = useState<T | null>(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 };
}
+56
View File
@@ -0,0 +1,56 @@
import { afterEach, expect, test } from "bun:test";
import { holdDecision, reducedMotion, viewTransition } from "../src/motion";
const g = globalThis as Record<string, unknown>;
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<L>(null, null, key)).toBe("show-next");
expect(holdDecision<L>(null, a, key)).toBe("show-next");
expect(holdDecision<L>(a, a, key)).toBe("show-next");
expect(holdDecision<L>(a, null, key, sameCourse("x"))).toBe("hold");
expect(holdDecision<L>(a, null, key, sameCourse("y"))).toBe("replace");
expect(holdDecision<L>(a, b, key)).toBe("swap");
expect(holdDecision<L>(a, a2, key)).toBe("replace");
expect(holdDecision<L>(a, b, key, sameCourse("y"))).toBe("replace");
});