Frontend UX: standardize loading states on Skeleton (retire "…" and empty role=status divs) #53

Closed
opened 2026-06-06 18:52:21 +00:00 by logaritmisk · 1 comment
Owner

Severity: Medium. From a frontend UX audit.

Problem

Loading is rendered (at least) three incompatible ways:

  • Skeletonobject-list.tsx:32, object-detail.tsx:36, search-panel.tsx:89, field-list.tsx:27 (good).
  • Bare "…" textvocabulary-list.tsx:55, vocabulary-terms.tsx:57, authorities-page.tsx:66 (looks like a half-rendered bug; no sense of how much is loading).
  • Empty <div role="status"> with no visible contentrequire-auth.tsx:8 (blank app on first load), object-edit-form.tsx:30 (edit form looks broken/blank while the object loads).

Plus the lazy-route FormFallback (app.tsx:30) replaces the whole main pane with bare "Loading…" text → full-pane flash + layout shift on first navigation to /objects/new and /objects/:id/edit.

Suggested fix

Standardize on the existing Skeleton component (or a shared Spinner) for all list/detail/form/auth loading; make form fallbacks/skeletons mirror the real layout to avoid layout shift. Retire the "…" placeholders and empty role="status" divs.

Source: frontend UX/design audit, 2026-06-06.

**Severity: Medium.** _From a frontend UX audit._ ## Problem Loading is rendered (at least) three incompatible ways: - **Skeleton** — `object-list.tsx:32`, `object-detail.tsx:36`, `search-panel.tsx:89`, `field-list.tsx:27` (good). - **Bare "…" text** — `vocabulary-list.tsx:55`, `vocabulary-terms.tsx:57`, `authorities-page.tsx:66` (looks like a half-rendered bug; no sense of how much is loading). - **Empty `<div role="status">` with no visible content** — `require-auth.tsx:8` (blank app on first load), `object-edit-form.tsx:30` (edit form looks broken/blank while the object loads). Plus the lazy-route `FormFallback` (`app.tsx:30`) replaces the whole main pane with bare "Loading…" text → full-pane flash + layout shift on first navigation to `/objects/new` and `/objects/:id/edit`. ## Suggested fix Standardize on the existing `Skeleton` component (or a shared `Spinner`) for all list/detail/form/auth loading; make form fallbacks/skeletons mirror the real layout to avoid layout shift. Retire the "…" placeholders and empty `role="status"` divs. _Source: frontend UX/design audit, 2026-06-06._
Author
Owner

Done — merged to main (53c9810).

Added web/src/components/ui/skeletons.tsx with three shared recipes built on the existing Skeleton, each a role="status" aria-busy aria-label={t("common.loading")} live region (so screen readers now announce loading — the old empty role="status" divs announced nothing):

  • ListSkeleton({ rows, rowClassName, className })
  • FormSkeleton({ fields }) — label+input pairs + a button, mirroring the object form
  • AppShellSkeleton — a faux sidebar + header + content, mirroring AppShell

Applied everywhere loading was inconsistent:

  • Retired the bare "…"ListSkeleton at vocabulary-list, vocabulary-terms, authorities-page (rendered in place of the <ul> so it stays valid HTML).
  • Retired the empty role="status" divs → object-edit-form loading uses FormSkeleton; the whole-app first-load gate (require-auth) uses AppShellSkeleton, so the app no longer flashes blank and doesn't shift when the real shell mounts.
  • Replaced the lazy-route FormFallback ("Loading…" text) with per-route skeleton fallbacks — FormSkeleton for /objects/new + /objects/:id/edit, ListSkeleton for /fields — removing the full-pane flash + layout shift.
  • Retrofitted field-list and search-panel to the shared ListSkeleton; objects-table (table tbody rows) and object-detail (single block) keep their fitting inline skeletons.

New i18n common.loading (en/sv); en/sv parity; 207 tests green; typecheck/lint/build/check:size (214.7 KB gz)/check:colors clean; no codename; no new dependency.

Follow-up (out of scope): a Spinner primitive if ever wanted (this is Skeleton-only).

Done — merged to `main` (`53c9810`). Added **`web/src/components/ui/skeletons.tsx`** with three shared recipes built on the existing `Skeleton`, each a `role="status" aria-busy aria-label={t("common.loading")}` live region (so screen readers now announce loading — the old empty `role="status"` divs announced nothing): - `ListSkeleton({ rows, rowClassName, className })` - `FormSkeleton({ fields })` — label+input pairs + a button, mirroring the object form - `AppShellSkeleton` — a faux sidebar + header + content, mirroring `AppShell` Applied everywhere loading was inconsistent: - **Retired the bare "…"** → `ListSkeleton` at vocabulary-list, vocabulary-terms, authorities-page (rendered in place of the `<ul>` so it stays valid HTML). - **Retired the empty `role="status"` divs** → object-edit-form loading uses `FormSkeleton`; the whole-app first-load gate (require-auth) uses `AppShellSkeleton`, so the app no longer flashes blank and doesn't shift when the real shell mounts. - **Replaced the lazy-route `FormFallback`** ("Loading…" text) with per-route skeleton fallbacks — `FormSkeleton` for `/objects/new` + `/objects/:id/edit`, `ListSkeleton` for `/fields` — removing the full-pane flash + layout shift. - **Retrofitted** field-list and search-panel to the shared `ListSkeleton`; objects-table (table `tbody` rows) and object-detail (single block) keep their fitting inline skeletons. New i18n `common.loading` (en/sv); en/sv parity; 207 tests green; typecheck/lint/build/check:size (214.7 KB gz)/check:colors clean; no codename; no new dependency. Follow-up (out of scope): a `Spinner` primitive if ever wanted (this is Skeleton-only).
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: logaritmisk/biggus-dickus#53