7.5 KiB
Standardize Loading States on Skeleton — Design
Date: 2026-06-08 Status: Approved (brainstorming) — ready for implementation planning. Issue: #53.
Context
Loading is rendered three incompatible ways: good Skeleton (objects-table, object-detail,
search-panel, field-list); bare "…" text in a <li> (vocabulary-list, vocabulary-terms,
authorities-page) — looks half-rendered; and empty role="status" divs (require-auth → blank app on
first load; object-edit-form → blank form pane). Plus the lazy-route FormFallback renders full-pane
"Loading…" text → flash + layout shift on first nav to /objects/new, /objects/:id/edit, /fields.
Skeleton (ui/skeleton.tsx) is a simple animate-pulse rounded-md bg-muted div. No shared
skeleton recipes or Spinner exist. No tests assert loading markup (they findBy content), so retiring
the placeholders won't break tests. The three "…" sites render a <li> inside a <ul> (so a
<div>-based recipe must replace the <ul> when loading, not nest inside it). AppShell layout:
<div flex min-h-screen><aside w-44 border-r>…nav…</aside><div flex-1 flex-col><header border-b px-4 py-2/><main flex-1><Outlet/></main></div></div>.
Decisions (from brainstorming)
- Shared recipes
ListSkeleton+FormSkeleton(+AppShellSkeletonfor boot), built onSkeleton, each anarialive region. - require-auth → an app-shell-shaped skeleton (no blank flash, no shift when the real shell mounts).
- Skeleton-only (no Spinner primitive).
Components
web/src/components/ui/skeletons.tsx (new — ui/* no-semicolon style)
All recipes wrap content in a status region: <div role="status" aria-label={t("common.loading")} aria-busy="true" className={…}>. New i18n common.loading (en "Loading", sv "Laddar").
ListSkeleton({ rows = 6, rowClassName = "h-9 w-full", className })—space-y-2 p-3(+className) withrows×<Skeleton className={rowClassName} />.FormSkeleton({ fields = 5, className })—space-y-4 p-4(+className);fields× aspace-y-1group (<Skeleton className="h-3 w-24" />label +<Skeleton className="h-8 w-full" />input) + a trailing<Skeleton className="h-8 w-28" />(button). Mirrors the object form to avoid shift.AppShellSkeleton— mirrorsAppShell:<div className="flex min-h-screen">→ anaside w-44 border-r p-3 space-y-2with ~5<Skeleton className="h-8 w-full" />nav rows, and aflex-1 flex-colwith aheader border-b px-4 py-2containing a<Skeleton className="h-6 w-40" />and a<main className="flex-1">containing<ListSkeleton />. Single top-levelrole="status"on the outer div (one live region for the whole boot screen).
A skeletons.stories.tsx renders the three (visual check + a smoke play asserting a status region).
Apply across sites
Retire "…" (render ListSkeleton in place of the <ul> when loading — valid HTML):
vocabulary-list.tsx: replace the loading<li>…</li>. The list is<ul className="flex-1 overflow-auto">; render{isLoading ? <ListSkeleton className="flex-1" /> : null}and keep theisError/empty/data branches in the<ul>(rendered when not loading). (Keep the column layout — the skeleton takes the<ul>'s place during load.)vocabulary-terms.tsx: replace the loading<li>— render<ListSkeleton />whenisLoadinginstead of the loading<li>(keep the<ul>for the loaded/empty/error branches).authorities-page.tsx: same —<ListSkeleton />whenisLoading. (Concretely:{isLoading ? <ListSkeleton/> : <ul>…error/empty/rows…</ul>}, or render the skeleton as a sibling and gate the<ul>on!isLoading. Implementer picks the cleaner of the two; do not nest a<div>inside<ul>.)
Retire empty role="status" divs:
object-edit-form.tsx(outerObjectEditForm,isLoadingbranch):return <FormSkeleton />(wrap to match the edit form's container if needed — it renders inside the objects edit pane).require-auth.tsx(isLoading):return <AppShellSkeleton />.
app.tsx lazy fallbacks — remove FormFallback; give each Suspense a tailored fallback:
ObjectNewPage,ObjectEditForm→fallback={<div className="mx-auto max-w-2xl"><FormSkeleton /></div>}(mirrors the new/edit page containermx-auto max-w-2xl).FieldsPage→fallback={<ListSkeleton />}(a simple page skeleton; FieldsPage is a 2-col page, but a list skeleton in the pane is a fine, shift-light placeholder for the brief lazy load).
Retrofit the good list-like skeletons to ListSkeleton (consistency):
field-list.tsx:isLoading→<ListSkeleton rows={6} />(wasspace-y-2 p-3+ 6 ×h-9). Identical output.search-panel.tsx: thehasQuery && search.isLoadingblock →<ListSkeleton rows={5} rowClassName="h-12 w-full" />.- Keep inline:
objects-table.tsx(a<tbody>of<tr>/<td>skeleton rows — table-specific) andobject-detail.tsx(singleh-40block) — both already fitting; not worth forcing into a recipe.
Data flow / accessibility
Each recipe is a role="status" aria-busy live region labelled common.loading → screen readers
announce "Loading" (the empty role="status" divs announced nothing). Visual skeletons mirror the
loaded layout so there's no jump when content arrives.
Error handling / edges
- Don't nest
<div>inside<ul>— render the list skeleton in place of the<ul>. AppShellSkeletonis pre-shell (require-auth) — it must not import anything that assumes the shell/ router context beyondt()(it only needsuseTranslation).- Multiple
role="status"regions on one screen are acceptable;AppShellSkeletonuses ONE outer region (not one per nested skeleton) to avoid SR noise. FormSkeletonin the lazy fallback vs the object-edit-form loading branch: both render the same recipe, so the Suspense fallback → loaded-but-fetching → loaded transition stays visually stable.
Testing
skeletons.stories.tsx: renderListSkeleton/FormSkeleton/AppShellSkeleton; aplayasserts arole="status"is present (smoke).- Existing suite stays green: no test asserts the old "…"/empty-div markup; tests
findBycontent, which still resolves after loading. (If any test was implicitly relying on the emptyrole="status"viagetByRole("status")— none found — update it.) - Gate:
typecheck/lint/test/build/check:size/check:colors; en/sv parity (one new keycommon.loading); no codename; no new dependency.
Acceptance criteria
ListSkeleton,FormSkeleton,AppShellSkeletonexist (built onSkeleton, each arole="status" aria-label={t("common.loading")}live region) with a story.- The three "…" placeholders are replaced by
ListSkeleton; the two emptyrole="status"divs are replaced (object-edit-form →FormSkeleton, require-auth →AppShellSkeleton); the lazyFormFallbackis replaced by per-route skeleton fallbacks (no full-pane "Loading…"). field-list+search-paneluse the sharedListSkeleton; objects-table/object-detail keep their fitting inline skeletons.- Loading visuals mirror the loaded layout (no obvious shift); screen readers announce loading.
typecheck/lint/test/build/check:colorsgreen;check:sizereported; en/sv parity; no codename; no new dependency.
Out of scope → follow-ups
- A
Spinnerprimitive (Skeleton-only here). - Reworking objects-table / object-detail inline skeletons.
- Additional route-level Suspense boundaries or data-router
HydrateFallbacks.