Files
siftlode/frontend/src/components/ui/Skeleton.tsx
T
peter 7ed2ac4e95 build(frontend): knip also checks what ships; strict indexing in every project
`npm run knip` now runs a second, --production pass, which asks whether the
code that ships uses an export, not just whether anything does. Of the 47
exports only tests reached:
- 34 are exported for their test and say so with @internal, naming the test.
- 11 were module-private names with a stray `export`. Two of them were not
  used at all and are deleted.
- The unused alreadyReported re-export is gone.
Test kits, the e2e suite and the two build-tooling modules are excluded from
the production project, each named in knip.jsonc.

noUncheckedIndexedAccess is now on for the node and e2e tsconfigs too (0
errors), matching the app project. tsconfig.node.json listed a
tailwind.config.js that has not existed since Tailwind 4. That entry is now
playwright.config.ts, which the typed lint finds through this project.
2026-10-03 16:44:28 +02:00

99 lines
3.6 KiB
TypeScript

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 page shapes built from it (plus a
// paragraph's, for prose that arrives after its page). The
// shapes are shown through `LoadingState shape=…`, which owns the 300 ms reveal and the status role.
function Skeleton({ className, inline = false }: { className?: string; inline?: boolean }) {
// Inline: a span, for a bone inside phrasing content (a chip is a button, which may not hold a div).
const Tag = inline ? "span" : "div";
return (
<Tag
aria-hidden="true"
className={clsx(
"rounded-lg bg-fg/8 animate-pulse motion-reduce:animate-none",
inline && "inline-block align-middle",
className,
)}
/>
);
}
/** A chip whose name is still on its way: a name-sized bone, not the raw value (a channel's UC… id).
* A screen reader still gets the value, so two such chips stay told apart. */
export function ChipLabelPending({ value }: { value: string }) {
return (
<>
<Skeleton inline className="h-3 w-20" />
<span className="sr-only">{value}</span>
</>
);
}
export type SkeletonShape = "grid" | "poster" | "list" | "table" | "text";
const COUNT: Record<SkeletonShape, number> = { grid: 8, poster: 12, list: 6, table: 8, text: 5 };
/** 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 === "text")
// A paragraph still on its way (a biography): lines at prose height, the last one short.
return (
<div className="measure flex w-full flex-col gap-2.5 py-1">
{n.map((i) => (
<Skeleton key={i} className={`h-3 ${i === n.length - 1 ? "w-2/5" : "w-full"}`} />
))}
</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>
);
}