116 lines
7.5 KiB
Markdown
116 lines
7.5 KiB
Markdown
# 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)
|
||
1. **Shared recipes** `ListSkeleton` + `FormSkeleton` (+ `AppShellSkeleton` for boot), built on
|
||
`Skeleton`, each an `aria` live region.
|
||
2. **require-auth → an app-shell-shaped skeleton** (no blank flash, no shift when the real shell mounts).
|
||
3. 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`)
|
||
with `rows` × `<Skeleton className={rowClassName} />`.
|
||
- **`FormSkeleton({ fields = 5, className })`** — `space-y-4 p-4` (+ `className`); `fields` × a
|
||
`space-y-1` group (`<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`** — mirrors `AppShell`: `<div className="flex min-h-screen">` → an `aside
|
||
w-44 border-r p-3 space-y-2` with ~5 `<Skeleton className="h-8 w-full" />` nav rows, and a
|
||
`flex-1 flex-col` with a `header border-b px-4 py-2` containing a `<Skeleton className="h-6 w-40" />`
|
||
and a `<main className="flex-1">` containing `<ListSkeleton />`. Single top-level `role="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 the
|
||
`isError`/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 />` when `isLoading`
|
||
instead of the loading `<li>` (keep the `<ul>` for the loaded/empty/error branches).
|
||
- `authorities-page.tsx`: same — `<ListSkeleton />` when `isLoading`.
|
||
(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` (outer `ObjectEditForm`, `isLoading` branch): `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 container `mx-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} />` (was `space-y-2 p-3` + 6 × `h-9`). Identical output.
|
||
- `search-panel.tsx`: the `hasQuery && search.isLoading` block → `<ListSkeleton rows={5} rowClassName="h-12 w-full" />`.
|
||
- **Keep inline:** `objects-table.tsx` (a `<tbody>` of `<tr>`/`<td>` skeleton rows — table-specific) and
|
||
`object-detail.tsx` (single `h-40` block) — 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>`.
|
||
- `AppShellSkeleton` is pre-shell (require-auth) — it must not import anything that assumes the shell/
|
||
router context beyond `t()` (it only needs `useTranslation`).
|
||
- Multiple `role="status"` regions on one screen are acceptable; `AppShellSkeleton` uses ONE outer
|
||
region (not one per nested skeleton) to avoid SR noise.
|
||
- `FormSkeleton` in 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`: render `ListSkeleton`/`FormSkeleton`/`AppShellSkeleton`; a `play` asserts a
|
||
`role="status"` is present (smoke).
|
||
- Existing suite stays green: no test asserts the old "…"/empty-div markup; tests `findBy` content,
|
||
which still resolves after loading. (If any test was implicitly relying on the empty `role="status"`
|
||
via `getByRole("status")` — none found — update it.)
|
||
- Gate: `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors`; en/sv parity (one new key
|
||
`common.loading`); no codename; no new dependency.
|
||
|
||
## Acceptance criteria
|
||
1. `ListSkeleton`, `FormSkeleton`, `AppShellSkeleton` exist (built on `Skeleton`, each a
|
||
`role="status" aria-label={t("common.loading")}` live region) with a story.
|
||
2. The three "…" placeholders are replaced by `ListSkeleton`; the two empty `role="status"` divs are
|
||
replaced (object-edit-form → `FormSkeleton`, require-auth → `AppShellSkeleton`); the lazy
|
||
`FormFallback` is replaced by per-route skeleton fallbacks (no full-pane "Loading…").
|
||
3. `field-list` + `search-panel` use the shared `ListSkeleton`; objects-table/object-detail keep their
|
||
fitting inline skeletons.
|
||
4. Loading visuals mirror the loaded layout (no obvious shift); screen readers announce loading.
|
||
5. `typecheck`/`lint`/`test`/`build`/`check:colors` green; `check:size` reported; en/sv parity; no
|
||
codename; no new dependency.
|
||
|
||
## Out of scope → follow-ups
|
||
- A `Spinner` primitive (Skeleton-only here).
|
||
- Reworking objects-table / object-detail inline skeletons.
|
||
- Additional route-level Suspense boundaries or data-router `HydrateFallback`s.
|