merge: standardize loading states on shared Skeleton recipes (#53)
CI / web (push) Has been cancelled
CI / web (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
# Standardize Loading States on Skeleton — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the "…" text and empty `role="status"` divs with shared `Skeleton`-based loading recipes that mirror the loaded layout and announce loading to screen readers.
|
||||
|
||||
**Architecture:** A new `ui/skeletons.tsx` exports `ListSkeleton`, `FormSkeleton`, `AppShellSkeleton` (each a `role="status" aria-label={t("common.loading")}` live region built on the existing `Skeleton`). Apply them at every inconsistent loading site; retrofit the two good list-like skeletons to `ListSkeleton`.
|
||||
|
||||
**Tech Stack:** React 19 + TS + pnpm, react-i18next, Vitest + RTL + Storybook. Test runner: `pnpm test` (single pass).
|
||||
|
||||
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; en/sv parity (one new key); **ui/ files = no-semicolon** (match `ui/skeleton.tsx`); app source = double-quote+semicolon; stories single-quote/no-semicolon; token classes only; never nest `<div>` inside `<ul>`.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-08-loading-skeletons-design.md`
|
||||
|
||||
**Key facts:**
|
||||
- `ui/skeleton.tsx`: `function Skeleton({ className, ...props }: React.ComponentProps<"div">) { return <div data-slot="skeleton" className={cn("animate-pulse rounded-md bg-muted", className)} {...props} /> }` (no-semicolon).
|
||||
- "…" sites render `<li>…</li>` inside a `<ul>` — `vocabulary-list.tsx` (`<ul className="flex-1 overflow-auto">`, loading `<li className="p-3 text-sm text-muted-foreground">…</li>`), `vocabulary-terms.tsx` (`<ul className="mb-4">`), `authorities-page.tsx` (`<ul className="mb-4">`).
|
||||
- empty status divs: `require-auth.tsx` `if (isLoading) return <div role="status" aria-label="loading" />;` (pre-shell); `object-edit-form.tsx` `if (isLoading) return <div className="p-4" role="status" aria-label="loading" />;`.
|
||||
- `app.tsx`: `function FormFallback() { return <div role="status" className="p-4 text-sm text-muted-foreground">Loading…</div> }` used in 3 Suspense fallbacks (ObjectNewPage, ObjectEditForm, FieldsPage).
|
||||
- retrofits: `field-list.tsx` (`space-y-2 p-3` + 6 × `<Skeleton className="h-9 w-full" />`), `search-panel.tsx` (`space-y-2 p-3` + 5 × `<Skeleton className="h-12 w-full" />`).
|
||||
- i18n `common` namespace exists in both locales: `{ "yes", "no", "close" }` — add `"loading"`.
|
||||
|
||||
---
|
||||
|
||||
# Task 1: Shared skeleton recipes + i18n + story
|
||||
|
||||
**Files:** `web/src/components/ui/skeletons.tsx` (new), `web/src/components/ui/skeletons.stories.tsx` (new), `web/src/i18n/en.json`, `web/src/i18n/sv.json`.
|
||||
|
||||
- [ ] **Step 1: i18n** — add `"loading"` to the `common` namespace in BOTH locales (keep parity):
|
||||
- en: `"common": { "yes": "Yes", "no": "No", "close": "Close", "loading": "Loading" },`
|
||||
- sv: `"common": { "yes": "Ja", "no": "Nej", "close": "Stäng", "loading": "Laddar" },`
|
||||
|
||||
- [ ] **Step 2: Implement `web/src/components/ui/skeletons.tsx`** (no-semicolon, ui/* style):
|
||||
```tsx
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
function ListSkeleton({
|
||||
rows = 6,
|
||||
rowClassName = "h-9 w-full",
|
||||
className,
|
||||
}: {
|
||||
rows?: number
|
||||
rowClassName?: string
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div role="status" aria-busy="true" aria-label={t("common.loading")} className={cn("space-y-2 p-3", className)}>
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<Skeleton key={i} className={rowClassName} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FormSkeleton({ fields = 5, className }: { fields?: number; className?: string }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div role="status" aria-busy="true" aria-label={t("common.loading")} className={cn("space-y-4 p-4", className)}>
|
||||
{Array.from({ length: fields }).map((_, i) => (
|
||||
<div key={i} className="space-y-1">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
))}
|
||||
<Skeleton className="h-8 w-28" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AppShellSkeleton() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div role="status" aria-busy="true" aria-label={t("common.loading")} className="flex min-h-screen">
|
||||
<aside className="w-44 space-y-2 border-r p-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</aside>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex items-center border-b px-4 py-2">
|
||||
<Skeleton className="h-6 w-40" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2 p-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-9 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { ListSkeleton, FormSkeleton, AppShellSkeleton }
|
||||
```
|
||||
(`AppShellSkeleton` inlines its content rows rather than nesting `ListSkeleton`, so there's ONE `role="status"` for the whole boot screen. Token classes only.)
|
||||
|
||||
- [ ] **Step 3: Story `web/src/components/ui/skeletons.stories.tsx`** (single-quote, no-semicolon; match an existing story, e.g. `menu.stories.tsx`/`page-title.stories.tsx`). Export three stories (`List`, `Form`, `AppShellLoading`) each rendering the respective recipe; one `play` test (on `List`) asserting a `role="status"` region is present:
|
||||
```tsx
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { expect } from 'storybook/test'
|
||||
|
||||
import { AppShellSkeleton, FormSkeleton, ListSkeleton } from './skeletons'
|
||||
|
||||
const meta = { title: 'ui/Skeletons', tags: ['ai-generated'] } satisfies Meta
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj
|
||||
|
||||
export const List: Story = {
|
||||
render: () => <ListSkeleton rows={4} />,
|
||||
play: async ({ canvas }) => {
|
||||
await expect(canvas.getByRole('status')).toBeInTheDocument()
|
||||
},
|
||||
}
|
||||
|
||||
export const Form: Story = { render: () => <FormSkeleton /> }
|
||||
|
||||
export const AppShellLoading: Story = { render: () => <AppShellSkeleton /> }
|
||||
```
|
||||
(Adjust the `Meta`/`StoryObj` typing to the house pattern if `satisfies Meta` without a component arg complains — these are render-only stories; mirror how an existing component-less story is typed, or pass `component: ListSkeleton`.)
|
||||
|
||||
- [ ] **Step 4: Verify (vitest ONCE):**
|
||||
`cd web && pnpm vitest run src/components/ui/skeletons.stories.tsx && pnpm typecheck && pnpm lint`
|
||||
Expected: PASS, clean. (If a storybook cache flake appears — `Cannot read properties of null (reading 'useEffect')` — `rm -rf node_modules/.cache/storybook node_modules/.vite` and re-run ONCE.)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add web/src/components/ui/skeletons.tsx web/src/components/ui/skeletons.stories.tsx web/src/i18n/en.json web/src/i18n/sv.json
|
||||
git commit -m "feat(web): shared loading skeleton recipes (List/Form/AppShell) + common.loading (#53)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Task 2: Apply skeletons across all loading sites + gate
|
||||
|
||||
**Files (modify):** `web/src/vocab/vocabulary-list.tsx`, `web/src/vocab/vocabulary-terms.tsx`, `web/src/authorities/authorities-page.tsx`, `web/src/objects/object-edit-form.tsx`, `web/src/auth/require-auth.tsx`, `web/src/app.tsx`, `web/src/fields/field-list.tsx`, `web/src/search/search-panel.tsx`.
|
||||
|
||||
Add `import { ListSkeleton } from "@/components/ui/skeletons";` (and `FormSkeleton`/`AppShellSkeleton` where used) to each. NEVER nest a `<div>` (the recipe) inside a `<ul>` — render the skeleton in place of the `<ul>`.
|
||||
|
||||
- [ ] **Step 1: vocabulary-list.tsx.** Read the loading region. The list is `<ul className="flex-1 overflow-auto">` with a loading `<li>…</li>`. Render the skeleton in place of the `<ul>` during load:
|
||||
```tsx
|
||||
{isLoading ? (
|
||||
<ListSkeleton className="flex-1 overflow-auto" />
|
||||
) : (
|
||||
<ul className="flex-1 overflow-auto">
|
||||
{isError && (<li className="p-3 text-sm text-destructive">{t("vocab.loadError")}</li>)}
|
||||
{data?.length === 0 && (/* keep existing empty state */)}
|
||||
{data?.map(/* keep existing rows */)}
|
||||
</ul>
|
||||
)}
|
||||
```
|
||||
Keep the `isError`/empty/rows branches exactly as they are now (just move them under the `!isLoading` `<ul>`; remove the loading `<li>`). Preserve the `flex-1 overflow-auto` layout via the skeleton's `className`.
|
||||
|
||||
- [ ] **Step 2: vocabulary-terms.tsx.** The `<ul className="mb-4">` has a loading `<li>`. Same approach:
|
||||
```tsx
|
||||
{isLoading ? (
|
||||
<ListSkeleton className="mb-4" rows={5} />
|
||||
) : (
|
||||
<ul className="mb-4">
|
||||
{isError && (/* keep */)}
|
||||
{terms?.length === 0 && (/* keep */)}
|
||||
{terms?.map(/* keep TermRow */)}
|
||||
</ul>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: authorities-page.tsx.** The `<ul className="mb-4">` has a loading `<li>`. Same:
|
||||
```tsx
|
||||
{isLoading ? (
|
||||
<ListSkeleton className="mb-4" rows={5} />
|
||||
) : (
|
||||
<ul className="mb-4">
|
||||
{isError && (/* keep */)}
|
||||
{authorities?.length === 0 && (/* keep */)}
|
||||
{authorities?.map(/* keep AuthorityRow */)}
|
||||
</ul>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: object-edit-form.tsx.** Replace the outer loading branch:
|
||||
`if (isLoading) return <div className="p-4" role="status" aria-label="loading" />;`
|
||||
→
|
||||
`if (isLoading) return <FormSkeleton />;`
|
||||
(Import `FormSkeleton`. The not-found branch stays unchanged.)
|
||||
|
||||
- [ ] **Step 5: require-auth.tsx.** Replace:
|
||||
`if (isLoading) return <div role="status" aria-label="loading" />;`
|
||||
→
|
||||
`if (isLoading) return <AppShellSkeleton />;`
|
||||
(Import `AppShellSkeleton`. It uses only `useTranslation` — safe pre-shell.)
|
||||
|
||||
- [ ] **Step 6: app.tsx lazy fallbacks.** Remove the `FormFallback` function. Import `FormSkeleton` and `ListSkeleton` from `@/components/ui/skeletons`. Replace the three Suspense fallbacks:
|
||||
- ObjectNewPage: `fallback={<div className="mx-auto max-w-2xl"><FormSkeleton /></div>}`
|
||||
- ObjectEditForm: `fallback={<div className="mx-auto max-w-2xl"><FormSkeleton /></div>}`
|
||||
- FieldsPage: `fallback={<ListSkeleton />}`
|
||||
Keep the `<Suspense>` wrappers + lazy imports; only the `fallback` prop changes (and `FormFallback` is deleted).
|
||||
|
||||
- [ ] **Step 7: field-list.tsx (retrofit).** Replace the inline `isLoading` block:
|
||||
`return (<div className="space-y-2 p-3">{Array.from({length:6}).map(... <Skeleton h-9 w-full/>)}</div>)`
|
||||
→ `return <ListSkeleton rows={6} />;`
|
||||
Remove the now-unused `Skeleton` import if nothing else uses it (check).
|
||||
|
||||
- [ ] **Step 8: search-panel.tsx (retrofit).** Replace the `hasQuery && search.isLoading` block's inline skeleton:
|
||||
`<div className="space-y-2 p-3">{Array.from({length:5}).map(... <Skeleton h-12 w-full/>)}</div>`
|
||||
→ `<ListSkeleton rows={5} rowClassName="h-12 w-full" />`
|
||||
Remove the now-unused `Skeleton` import if nothing else uses it (check).
|
||||
|
||||
- [ ] **Step 9: FULL GATE (run tests EXACTLY ONCE):**
|
||||
```bash
|
||||
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors
|
||||
```
|
||||
All green. The existing suite must stay green (no test asserts the old "…"/empty-div markup; tests `findBy` content). If a test fails because it queried `getByRole("status")` and now finds a labelled region (or multiple), update it minimally without weakening. Report test totals, largest chunk (KB gz), check:colors line.
|
||||
|
||||
- [ ] **Step 10: Codename + status:**
|
||||
```bash
|
||||
cd /Users/olsson/Laboratory/biggus-dickus
|
||||
git grep -in 'biggus\|dickus' -- web/src; echo "codename-exit=$?"
|
||||
git status --short
|
||||
```
|
||||
|
||||
- [ ] **Step 11: Manual smoke (recommended).** `pnpm dev`: first load shows the app-shell skeleton (no blank flash); navigating to /vocabularies, /authorities, /search, /fields shows list skeletons (no "…"); opening /objects/:id/edit and /objects/new shows a form skeleton (no full-pane "Loading…"); all transition into content without an obvious jump.
|
||||
|
||||
- [ ] **Step 12: Commit**
|
||||
```bash
|
||||
git add web/src/vocab/vocabulary-list.tsx web/src/vocab/vocabulary-terms.tsx web/src/authorities/authorities-page.tsx web/src/objects/object-edit-form.tsx web/src/auth/require-auth.tsx web/src/app.tsx web/src/fields/field-list.tsx web/src/search/search-panel.tsx
|
||||
git commit -m "feat(web): standardize loading on shared skeleton recipes; retire '…' + empty status divs (#53)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (completed)
|
||||
|
||||
**Spec coverage:** recipes List/Form/AppShell as `role="status"` live regions + `common.loading` + story (T1); three "…" → ListSkeleton, object-edit-form → FormSkeleton, require-auth → AppShellSkeleton, per-route lazy fallbacks replacing FormFallback (T2 S1–S6); field-list + search-panel retrofit (T2 S7–S8); gate (T2 S9). Acceptance criteria 1–5 mapped. ✓
|
||||
|
||||
**Placeholder scan:** the per-site replacements show the exact before→after and say "keep the existing error/empty/row branches" with the files/lines named — concrete, not vague. The story typing has an explicit fallback note. No TODOs. ✓
|
||||
|
||||
**Type/consistency:** `ListSkeleton({rows,rowClassName,className})`, `FormSkeleton({fields,className})`, `AppShellSkeleton()` defined in T1, consumed with matching props in T2; import path `@/components/ui/skeletons` uniform. ✓
|
||||
|
||||
## Notes
|
||||
- No new dependency; one new i18n key (`common.loading`, en+sv).
|
||||
- HTML validity: list skeletons replace the `<ul>` during load (never `<div>`-in-`<ul>`).
|
||||
- `check:size` ≈ unchanged (small components) — report it.
|
||||
- Retrofitting field-list/search-panel may leave an unused `Skeleton` import — remove it (lint will catch).
|
||||
@@ -0,0 +1,115 @@
|
||||
# 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.
|
||||
+4
-7
@@ -12,6 +12,7 @@ import { VocabulariesPage } from "./vocab/vocabularies-page";
|
||||
import { VocabularyTerms } from "./vocab/vocabulary-terms";
|
||||
import { SelectVocabularyPrompt } from "./vocab/select-vocabulary-prompt";
|
||||
import { AuthoritiesPage } from "./authorities/authorities-page";
|
||||
import { FormSkeleton, ListSkeleton } from "@/components/ui/skeletons";
|
||||
|
||||
const ObjectNewPage = lazy(() =>
|
||||
import("./objects/object-new-page").then((m) => ({ default: m.ObjectNewPage })),
|
||||
@@ -25,10 +26,6 @@ const FieldsPage = lazy(() =>
|
||||
import("./fields/fields-page").then((m) => ({ default: m.FieldsPage })),
|
||||
);
|
||||
|
||||
function FormFallback() {
|
||||
return <div role="status" className="p-4 text-sm text-muted-foreground">Loading…</div>;
|
||||
}
|
||||
|
||||
const router = createBrowserRouter(
|
||||
createRoutesFromElements(
|
||||
<>
|
||||
@@ -38,7 +35,7 @@ const router = createBrowserRouter(
|
||||
<Route
|
||||
path="/objects/new"
|
||||
element={
|
||||
<Suspense fallback={<FormFallback />}>
|
||||
<Suspense fallback={<div className="mx-auto max-w-2xl"><FormSkeleton /></div>}>
|
||||
<ObjectNewPage />
|
||||
</Suspense>
|
||||
}
|
||||
@@ -48,7 +45,7 @@ const router = createBrowserRouter(
|
||||
<Route
|
||||
path=":id/edit"
|
||||
element={
|
||||
<Suspense fallback={<FormFallback />}>
|
||||
<Suspense fallback={<div className="mx-auto max-w-2xl"><FormSkeleton /></div>}>
|
||||
<ObjectEditForm />
|
||||
</Suspense>
|
||||
}
|
||||
@@ -67,7 +64,7 @@ const router = createBrowserRouter(
|
||||
<Route
|
||||
path="/fields"
|
||||
element={
|
||||
<Suspense fallback={<FormFallback />}>
|
||||
<Suspense fallback={<ListSkeleton />}>
|
||||
<FieldsPage />
|
||||
</Suspense>
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Navigate, Outlet } from "react-router-dom";
|
||||
|
||||
import { useMe } from "../api/queries";
|
||||
import { AppShellSkeleton } from "@/components/ui/skeletons";
|
||||
|
||||
export function RequireAuth() {
|
||||
const { data: user, isLoading } = useMe();
|
||||
|
||||
if (isLoading) return <div role="status" aria-label="loading" />;
|
||||
if (isLoading) return <AppShellSkeleton />;
|
||||
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useAuthorities, useCreateAuthority } from "../api/queries";
|
||||
import { LabelEditor } from "../components/label-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageTitle } from "@/components/ui/page-title";
|
||||
import { ListSkeleton } from "@/components/ui/skeletons";
|
||||
import { AuthorityRow } from "./authority-row";
|
||||
import { useDocumentTitle } from "../lib/use-document-title";
|
||||
import { useBreadcrumb } from "../shell/use-breadcrumb";
|
||||
@@ -68,20 +69,21 @@ export function AuthoritiesPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ul className="mb-4">
|
||||
{isLoading && (
|
||||
<li className="text-sm text-muted-foreground">…</li>
|
||||
)}
|
||||
{isError && (
|
||||
<li className="text-sm text-destructive">{t("authorities.loadError")}</li>
|
||||
)}
|
||||
{!isLoading && !isError && authorities?.length === 0 && (
|
||||
<li className="text-sm text-muted-foreground">{t("authorities.empty")}</li>
|
||||
)}
|
||||
{authorities?.map((a) => (
|
||||
<AuthorityRow key={a.id} authority={a} kind={currentKind} lang={lang} />
|
||||
))}
|
||||
</ul>
|
||||
{isLoading ? (
|
||||
<ListSkeleton className="mb-4" rows={5} />
|
||||
) : (
|
||||
<ul className="mb-4">
|
||||
{isError && (
|
||||
<li className="text-sm text-destructive">{t("authorities.loadError")}</li>
|
||||
)}
|
||||
{!isError && authorities?.length === 0 && (
|
||||
<li className="text-sm text-muted-foreground">{t("authorities.empty")}</li>
|
||||
)}
|
||||
{authorities?.map((a) => (
|
||||
<AuthorityRow key={a.id} authority={a} kind={currentKind} lang={lang} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<form onSubmit={onCreate} className="space-y-2 border-t pt-3">
|
||||
<div className="text-sm font-medium">
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { expect } from 'storybook/test'
|
||||
|
||||
import { AppShellSkeleton, FormSkeleton, ListSkeleton } from './skeletons'
|
||||
|
||||
const meta = {
|
||||
component: ListSkeleton,
|
||||
tags: ['ai-generated'],
|
||||
} satisfies Meta<typeof ListSkeleton>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const List: Story = {
|
||||
render: () => <ListSkeleton rows={4} />,
|
||||
play: async ({ canvas }) => {
|
||||
await expect(canvas.getByRole('status')).toBeInTheDocument()
|
||||
},
|
||||
}
|
||||
|
||||
export const Form: Story = { render: () => <FormSkeleton /> }
|
||||
|
||||
export const AppShellLoading: Story = { render: () => <AppShellSkeleton /> }
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
function ListSkeleton({
|
||||
rows = 6,
|
||||
rowClassName = "h-9 w-full",
|
||||
className,
|
||||
}: {
|
||||
rows?: number
|
||||
rowClassName?: string
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div role="status" aria-busy="true" aria-label={t("common.loading")} className={cn("space-y-2 p-3", className)}>
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<Skeleton key={i} className={rowClassName} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FormSkeleton({ fields = 5, className }: { fields?: number; className?: string }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div role="status" aria-busy="true" aria-label={t("common.loading")} className={cn("space-y-4 p-4", className)}>
|
||||
{Array.from({ length: fields }).map((_, i) => (
|
||||
<div key={i} className="space-y-1">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
))}
|
||||
<Skeleton className="h-8 w-28" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AppShellSkeleton() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div role="status" aria-busy="true" aria-label={t("common.loading")} className="flex min-h-screen">
|
||||
<aside className="w-44 space-y-2 border-r p-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</aside>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex items-center border-b px-4 py-2">
|
||||
<Skeleton className="h-6 w-40" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2 p-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-9 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { ListSkeleton, FormSkeleton, AppShellSkeleton }
|
||||
@@ -4,7 +4,7 @@ import type { components } from "../api/schema";
|
||||
import { useFieldDefinitions, useDeleteFieldDefinition } from "../api/queries";
|
||||
import { labelText } from "../lib/labels";
|
||||
import { DeleteConfirmDialog } from "../components/delete-confirm-dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ListSkeleton } from "@/components/ui/skeletons";
|
||||
|
||||
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
|
||||
|
||||
@@ -20,15 +20,7 @@ export function FieldList({
|
||||
const deleteField = useDeleteFieldDefinition();
|
||||
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-2 p-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-9 w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isLoading) return <ListSkeleton rows={6} />;
|
||||
|
||||
if (isError) return <p className="p-4 text-sm text-destructive">{t("fields.loadError")}</p>;
|
||||
if (!data || data.length === 0)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"common": { "yes": "Yes", "no": "No", "close": "Close" },
|
||||
"common": { "yes": "Yes", "no": "No", "close": "Close", "loading": "Loading" },
|
||||
"nav": { "objects": "Objects", "vocabularies": "Vocabularies", "authorities": "Authorities", "fields": "Fields", "search": "Search", "collapseSidebar": "Collapse sidebar", "expandSidebar": "Expand sidebar" },
|
||||
"auth": { "email": "Email", "password": "Password", "signIn": "Sign in", "signOut": "Sign out", "invalid": "Invalid email or password", "networkError": "Could not reach the server" },
|
||||
"objects": { "title": "Objects", "empty": "No objects yet", "loadError": "Could not load objects", "notFound": "Object not found", "prev": "Previous", "next": "Next", "of": "of", "new": "New object", "filter": "Filter objects…", "pageSize": "Per page", "columns": { "number": "Object №", "name": "Name", "visibility": "Visibility", "location": "Location", "count": "#", "updated": "Updated" }, "unknownRef": "(unknown)" },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"common": { "yes": "Ja", "no": "Nej", "close": "Stäng" },
|
||||
"common": { "yes": "Ja", "no": "Nej", "close": "Stäng", "loading": "Laddar" },
|
||||
"nav": { "objects": "Föremål", "vocabularies": "Vokabulär", "authorities": "Auktoriteter", "fields": "Fält", "search": "Sök", "collapseSidebar": "Fäll ihop sidofältet", "expandSidebar": "Fäll ut sidofältet" },
|
||||
"auth": { "email": "E-post", "password": "Lösenord", "signIn": "Logga in", "signOut": "Logga ut", "invalid": "Fel e-post eller lösenord", "networkError": "Kunde inte nå servern" },
|
||||
"objects": { "title": "Föremål", "empty": "Inga föremål ännu", "loadError": "Kunde inte ladda föremål", "notFound": "Föremålet hittades inte", "prev": "Föregående", "next": "Nästa", "of": "av", "new": "Nytt föremål", "filter": "Filtrera föremål…", "pageSize": "Per sida", "columns": { "number": "Föremålsnr", "name": "Namn", "visibility": "Synlighet", "location": "Plats", "count": "Antal", "updated": "Uppdaterad" }, "unknownRef": "(okänd)" },
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { components } from "../api/schema";
|
||||
import { useObject, useUpdateObject, useSetFields, FieldRejection } from "../api/queries";
|
||||
import { useBreadcrumb } from "../shell/use-breadcrumb";
|
||||
import { ObjectForm, type ObjectCore, type ObjectFormValues } from "./object-form";
|
||||
import { FormSkeleton } from "@/components/ui/skeletons";
|
||||
|
||||
type AdminObjectView = components["schemas"]["AdminObjectView"];
|
||||
|
||||
@@ -15,7 +16,7 @@ export function ObjectEditForm() {
|
||||
|
||||
const { data: object, isLoading } = useObject(id!);
|
||||
|
||||
if (isLoading) return <div className="p-4" role="status" aria-label="loading" />;
|
||||
if (isLoading) return <FormSkeleton />;
|
||||
|
||||
if (!object) return <p className="p-4 text-sm text-muted-foreground">{t("objects.notFound")}</p>;
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useDebouncedValue } from "../lib/use-debounced-value";
|
||||
import { SearchResultRow } from "./search-result-row";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ListSkeleton } from "@/components/ui/skeletons";
|
||||
|
||||
const VIS = ["all", "draft", "internal", "public"] as const;
|
||||
|
||||
@@ -84,11 +84,7 @@ export function SearchPanel() {
|
||||
{!hasQuery && <p className="p-4 text-sm text-muted-foreground">{t("search.prompt")}</p>}
|
||||
|
||||
{hasQuery && search.isLoading && (
|
||||
<div className="space-y-2 p-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
<ListSkeleton rows={5} rowClassName="h-12 w-full" />
|
||||
)}
|
||||
|
||||
{hasQuery && search.isError && (
|
||||
|
||||
@@ -7,6 +7,7 @@ import { DeleteConfirmDialog } from "../components/delete-confirm-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ListSkeleton } from "@/components/ui/skeletons";
|
||||
|
||||
export function VocabularyList() {
|
||||
const { t } = useTranslation();
|
||||
@@ -50,17 +51,17 @@ export function VocabularyList() {
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
<ul className="flex-1 overflow-auto">
|
||||
{isLoading && (
|
||||
<li className="p-3 text-sm text-muted-foreground">…</li>
|
||||
)}
|
||||
{isError && (
|
||||
<li className="p-3 text-sm text-destructive">{t("vocab.loadError")}</li>
|
||||
)}
|
||||
{data?.length === 0 && (
|
||||
<li className="p-3 text-sm text-muted-foreground">{t("vocab.empty")}</li>
|
||||
)}
|
||||
{data?.map((v) => (
|
||||
{isLoading ? (
|
||||
<ListSkeleton className="flex-1 overflow-auto" />
|
||||
) : (
|
||||
<ul className="flex-1 overflow-auto">
|
||||
{isError && (
|
||||
<li className="p-3 text-sm text-destructive">{t("vocab.loadError")}</li>
|
||||
)}
|
||||
{data?.length === 0 && (
|
||||
<li className="p-3 text-sm text-muted-foreground">{t("vocab.empty")}</li>
|
||||
)}
|
||||
{data?.map((v) => (
|
||||
<li key={v.id} className="flex items-center gap-1 border-b pr-2">
|
||||
{editingId === v.id ? (
|
||||
<form
|
||||
@@ -115,8 +116,9 @@ export function VocabularyList() {
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { TermRow } from "./term-row";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ListSkeleton } from "@/components/ui/skeletons";
|
||||
|
||||
type LabelInput = components["schemas"]["LabelInput"];
|
||||
|
||||
@@ -62,20 +63,21 @@ export function VocabularyTerms() {
|
||||
<div className="mb-2 label-caption">
|
||||
{t("vocab.terms")}
|
||||
</div>
|
||||
<ul className="mb-4">
|
||||
{isLoading && (
|
||||
<li className="text-sm text-muted-foreground">…</li>
|
||||
)}
|
||||
{isError && (
|
||||
<li className="text-sm text-destructive">{t("vocab.loadError")}</li>
|
||||
)}
|
||||
{!isLoading && !isError && terms?.length === 0 && (
|
||||
<li className="text-sm text-muted-foreground">{t("vocab.noTerms")}</li>
|
||||
)}
|
||||
{terms?.map((term) => (
|
||||
<TermRow key={term.id} vocabularyId={id} term={term} lang={lang} />
|
||||
))}
|
||||
</ul>
|
||||
{isLoading ? (
|
||||
<ListSkeleton className="mb-4" rows={5} />
|
||||
) : (
|
||||
<ul className="mb-4">
|
||||
{isError && (
|
||||
<li className="text-sm text-destructive">{t("vocab.loadError")}</li>
|
||||
)}
|
||||
{!isError && terms?.length === 0 && (
|
||||
<li className="text-sm text-muted-foreground">{t("vocab.noTerms")}</li>
|
||||
)}
|
||||
{terms?.map((term) => (
|
||||
<TermRow key={term.id} vocabularyId={id} term={term} lang={lang} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<form onSubmit={onAdd} className="space-y-2 border-t pt-3">
|
||||
<div className="text-sm font-medium">{t("vocab.addTerm")}</div>
|
||||
<LabelEditor value={labels} onChange={setLabels} />
|
||||
|
||||
Reference in New Issue
Block a user