feat(design): skeleton first loads by shape on the shared reveal clock (E28 S3d2, B8)

This commit is contained in:
2026-09-25 00:37:45 +02:00
parent c47c193ad1
commit 6354454cc9
21 changed files with 134 additions and 50 deletions
@@ -75,3 +75,15 @@ describe("LoadingState reveal", () => {
expect(revealDelay(container)).toBe(150);
});
});
describe("LoadingState shape (B8)", () => {
it("shows the content's skeleton on the same clock, the label for a screen reader only", () => {
now = 90_000;
const { container } = render(<LoadingState shape="list" label="Loading playlists" />);
const status = container.querySelector<HTMLElement>("[role=status]")!;
expect(revealDelay(container)).toBe(300);
expect(status.querySelector(".animate-spin")).toBeNull();
expect(status.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
expect(status.querySelector(".sr-only")?.textContent).toBe("Loading playlists");
});
});
+16 -3
View File
@@ -2,6 +2,7 @@ import { useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, Loader2 } from "lucide-react";
import { Button } from "./Button";
import { SkeletonShapes, type SkeletonShape } from "./Skeleton";
// A loading state fades in only after a delay: most loads here answer in tens of milliseconds, and a
// spinner shown for two frames before the content replaces it reads as a flicker, not as feedback. A
@@ -22,15 +23,19 @@ function revealWait(since: number | undefined, delayMs: number): number {
}
// Centered loading state: a spinning accent ring + an optional label. The single shared loading
// visual for a page/section's first load, so all of them read the same (and can be swapped for
// skeletons later in one place). `role=status`/`aria-live` announces it to screen readers.
// visual for a page/section's first load, so all of them read the same. A grid, a list or a table
// passes its `shape` instead (B8): the skeleton of what is coming, on the same reveal clock, with the
// label kept for a screen reader only. `role=status`/`aria-live` announces it either way.
export function LoadingState({
label,
className = "",
since,
delayMs = LOADING_REVEAL_MS,
shape,
}: {
label?: ReactNode;
/** The content's shape: its skeleton replaces the spinner (B8). */
shape?: SkeletonShape;
className?: string;
/** When the wait began (a `performance.now()` time), if earlier than this mount — the boot screen
* passes 0, navigation start. Only used when this state starts a new clock. */
@@ -46,10 +51,18 @@ export function LoadingState({
reveal.lastGone = performance.now();
};
}, []);
const style = wait > 0 ? { animation: `fadeIn 0.2s ease ${Math.round(wait)}ms both` } : undefined;
if (shape)
return (
<div className={`w-full ${className}`} style={style} role="status" aria-live="polite">
<span className="sr-only">{label ?? t("common.loading")}</span>
<SkeletonShapes shape={shape} />
</div>
);
return (
<div
className={`flex flex-col items-center gap-3 py-10 text-sm text-muted ${className}`}
style={wait > 0 ? { animation: `fadeIn 0.2s ease ${Math.round(wait)}ms both` } : undefined}
style={style}
role="status"
aria-live="polite"
>
+71
View File
@@ -0,0 +1,71 @@
import clsx from "clsx";
import { SURFACE } from "./surfaces";
// B8 (docs/DESIGN.md): a first load of a grid, list or table shows the SHAPE of what is coming, so
// nothing jumps when the data lands. One block primitive (a tonal fill, the Badge's 8% of the text
// colour, pulsing unless the user asked for less motion) and the four page shapes built from it. The
// shapes are shown through `LoadingState shape=…`, which owns the 300 ms reveal and the status role.
export function Skeleton({ className }: { className?: string }) {
return (
<div
aria-hidden="true"
className={clsx("rounded-lg bg-fg/8 animate-pulse motion-reduce:animate-none", className)}
/>
);
}
export type SkeletonShape = "grid" | "poster" | "list" | "table";
const COUNT: Record<SkeletonShape, number> = { grid: 8, poster: 12, list: 6, table: 8 };
/** The placeholder for a page's first load, by the shape of its content. */
export function SkeletonShapes({ shape }: { shape: SkeletonShape }) {
const n = Array.from({ length: COUNT[shape] }, (_, i) => i);
if (shape === "grid")
return (
<div className="grid w-full grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-4">
{n.map((i) => (
// The video card's own surface, bones inside it, as the card will stand.
<div key={i} className={`${SURFACE.card} flex flex-col gap-2 p-1.5`}>
<Skeleton className="aspect-video w-full" />
<Skeleton className="h-3.5 w-4/5" />
<Skeleton className="mb-1 h-3 w-1/2" />
</div>
))}
</div>
);
if (shape === "poster")
return (
<div className="grid w-full grid-cols-[repeat(auto-fill,minmax(8.5rem,1fr))] gap-4">
{n.map((i) => (
<div key={i} className="flex flex-col gap-2">
<Skeleton className="aspect-2/3 w-full rounded-xl" />
<Skeleton className="h-3 w-3/4" />
</div>
))}
</div>
);
if (shape === "list")
return (
<div className="flex w-full flex-col gap-2">
{n.map((i) => (
<div key={i} className="flex items-center gap-3 p-2">
<Skeleton className="h-10 w-16 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<Skeleton className="h-3.5 w-2/3" />
<Skeleton className="h-3 w-1/3" />
</div>
</div>
))}
</div>
);
return (
<div className="flex w-full flex-col gap-1">
<Skeleton className="mb-1 h-8 w-full" />
{n.map((i) => (
<Skeleton key={i} className="h-9 w-full opacity-70" />
))}
</div>
);
}