docs(specs): responsive master/detail for vocab/search/fields (#58)
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
# Responsive Master/Detail for Vocabularies, Search, Fields — Design
|
||||
|
||||
**Date:** 2026-06-09
|
||||
**Status:** Approved (brainstorming) — ready for implementation planning.
|
||||
**Issue:** #58 (master/detail + sidebar layout has no responsive/small-screen handling — remaining half).
|
||||
|
||||
## Context
|
||||
|
||||
#58 is partially done (commit `0a88a86`, #44): the sidebar collapses to an icon rail, the **Objects** master/detail
|
||||
is responsive (wide right-pane / narrow Base UI `Drawer`), and a reusable `lib/use-media-query.ts` exists. The
|
||||
**remaining** master/detail screens still use fixed `grid-cols-[20rem_1fr]` / `[24rem_1fr]` with no breakpoints —
|
||||
`vocabularies-page.tsx`, `search-page.tsx`, `fields-page.tsx` — so on a small laptop / tablet / split window the
|
||||
fixed list + sidebar leave a cramped detail pane, and below ~640px the panes can't coexist.
|
||||
|
||||
**Decision (brainstorming): keep the wide side-by-side layout (it's useful for curators); fix only narrow.** Reuse
|
||||
one shared drawer + the existing `useMediaQuery` hook. Breakpoint **1024px (`lg`)**, matching Objects. Authorities
|
||||
is single-pane → no change. The "resizable splitter" the issue *suggests considering* is out of scope.
|
||||
|
||||
The three pages differ: vocabularies + search are **route-driven** (`<Outlet/>`, an index-prompt route + `:id`);
|
||||
fields is **`useState`-driven** (FieldList → FieldForm, with the form always present for "create").
|
||||
|
||||
## Components
|
||||
|
||||
### 1. `components/detail-drawer.tsx` (new) — generalize the Objects drawer
|
||||
Today `objects/object-detail-drawer.tsx` is objects-specific (Base UI `Drawer` + close button + a hardcoded
|
||||
`<Outlet/>`). Generalize to a reusable component taking `children` + `ariaLabel`:
|
||||
```tsx
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/** A right-sliding Base UI Drawer for a master/detail "detail" on narrow viewports. */
|
||||
export function DetailDrawer({
|
||||
open,
|
||||
onClose,
|
||||
ariaLabel,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
ariaLabel: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={(next) => { if (!next) onClose(); }} swipeDirection="right">
|
||||
<DrawerContent aria-label={ariaLabel}>
|
||||
<div className="flex justify-end border-b p-2">
|
||||
<DrawerClose aria-label={t("actions.closeDetail")} render={<Button variant="ghost" size="icon-sm" />}>
|
||||
<X className="size-4" aria-hidden="true" />
|
||||
</DrawerClose>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">{children}</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
```
|
||||
- Delete `objects/object-detail-drawer.tsx`.
|
||||
- **Drop the `lazy()`/`Suspense`** that objects-page used to wrap the drawer: it kept Base UI's drawer code out of
|
||||
the entry chunk, but #67 already split `@base-ui/react` into its own `base-ui` vendor chunk (loaded app-wide via
|
||||
the menu/select/etc.), so the lazy boundary no longer saves anything. `DetailDrawer` is a normal import.
|
||||
- The close-button label keeps the existing generic `actions.closeDetail` key.
|
||||
|
||||
### 2. `vocab/vocabularies-page.tsx` + `search/search-page.tsx` — `useMediaQuery` + route drawer
|
||||
Mirror the Objects pattern. Add `useMediaQuery("(min-width: 1024px)")` (`isWide`) and a `useMatch` for the detail
|
||||
route (`"/vocabularies/:id"` / `"/search/:id"`), `open = Boolean(match)`, `close = () => navigate("/vocabularies")`
|
||||
(resp. `"/search"`).
|
||||
- **Wide:** the current side-by-side grid with `<Outlet/>` inline (prompt when no `:id`, detail when selected) —
|
||||
unchanged.
|
||||
- **Narrow:** the master full-width (`VocabularyList` / `SearchPanel` under the existing `PageTitle`), plus
|
||||
`{open && <DetailDrawer open={open} onClose={close} ariaLabel={…}><Outlet/></DetailDrawer>}`. With no `:id`, just
|
||||
the master (the index-prompt is a wide-only affordance).
|
||||
- Drawer `ariaLabel`: vocabularies → `t("vocab.terms")` ("Terms"); search → `t("objects.detailTitle")` ("Object
|
||||
detail", since a search result's detail IS an object). **No new i18n keys.**
|
||||
- The `<Outlet/>` is rendered in exactly one place per branch (the `isWide` ternary), so no double-mount.
|
||||
|
||||
### 3. `fields/fields-page.tsx` — pure-CSS responsive stack
|
||||
`fields-page` is `useState`-driven and its right pane is *always* a form (create when nothing selected), so a
|
||||
drawer would need a new "New field" trigger. Instead make it a responsive **stack** (the issue comment explicitly
|
||||
allows "or stack"): change the grid container from `grid grid-cols-[20rem_1fr]` to
|
||||
`grid grid-cols-1 lg:grid-cols-[20rem_1fr]`, and give the form pane a top border that only shows when stacked
|
||||
(`border-t lg:border-t-0`) while the list keeps its `border-r` (which reads as a bottom divider when stacked — or
|
||||
add `border-b lg:border-b-0 lg:border-r` for a clean stacked divider). No JS, no drawer, no new trigger, no
|
||||
element duplication — the same `FieldForm` reflows from below the list (narrow) to beside it (wide).
|
||||
|
||||
### 4. `objects/objects-page.tsx` — retrofit onto `DetailDrawer`
|
||||
Replace the `lazy(ObjectDetailDrawer)` import + `<Suspense>` with a direct `DetailDrawer` import; in the narrow
|
||||
branch render `{open && <DetailDrawer open={open} onClose={closeDetail} ariaLabel={t("objects.detailTitle")}><Outlet/></DetailDrawer>}`.
|
||||
Behavior-preserving — its existing "narrow: detail renders inside a portaled drawer" test stays green.
|
||||
|
||||
## Data flow / behaviour
|
||||
No data/routing changes. Each page picks layout via `useMediaQuery("(min-width:1024px)")` (vocab/search/objects) or
|
||||
pure CSS (fields). The detail content is identical to today; only its container (inline pane vs Drawer) changes by
|
||||
width. The detail route/state, breadcrumbs, and titles are unchanged.
|
||||
|
||||
## Error handling / edges
|
||||
- `useMediaQuery` is SSR-safe (returns `false` server-side / pre-mount → narrow-first, then corrects on mount).
|
||||
- Drawer `open` is derived from the route match (`:id`) / nothing on the index, so the Outlet only has content when
|
||||
open; rendering `{open && <DetailDrawer …>}` mounts it only when active (matches the current objects behaviour).
|
||||
- Fields stack: `FieldList` is `overflow-hidden` in a `grid-cols-1` row — ensure the stacked list has a sensible
|
||||
height (it's in a flex/grid row that can scroll); the form below scrolls in its own pane. Keep each pane's
|
||||
`overflow` as today.
|
||||
- Drawer accessible name comes from `ariaLabel` (required prop) so every detail drawer is a named dialog (the #62
|
||||
a11y fix, preserved + generalized).
|
||||
|
||||
## Testing
|
||||
- **`components/detail-drawer.test.tsx`** (new): with `open`, the children render inside a dialog whose accessible
|
||||
name is the `ariaLabel`; clicking the close button (labelled `actions.closeDetail`) calls `onClose`.
|
||||
- **`vocab/vocabularies-page` + `search/search-page` tests** (new or extended): reuse the `setViewport(wide)`
|
||||
matchMedia mock from `objects-page.test.tsx`. Narrow + a `:id` route → the detail renders in a portaled drawer
|
||||
(`getByRole("dialog", { name })` within `document.body`); wide + `:id` → the detail is the inline pane (no
|
||||
dialog). Closing the drawer navigates back to the index route.
|
||||
- **`fields/fields-page` test**: the grid container carries the responsive classes (`grid-cols-1` +
|
||||
`lg:grid-cols-[20rem_1fr]`); both the list and the form render (jsdom can't measure layout, so assert structure).
|
||||
- **`objects/objects-page` tests**: stay green unchanged (the drawer is now the shared `DetailDrawer`).
|
||||
- **Gate:** `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors` green; no new dependency; no new i18n
|
||||
keys; no codename; en/sv parity unaffected. `check:size` unchanged-or-smaller (dropping the objects drawer's
|
||||
separate lazy chunk folds it into base-ui).
|
||||
|
||||
## Acceptance criteria
|
||||
1. `components/detail-drawer.tsx` exists (Base UI drawer + close button + `children`/`ariaLabel`); `object-detail-drawer.tsx`
|
||||
is deleted; Objects uses the shared `DetailDrawer` (no `lazy`/`Suspense`); its tests stay green.
|
||||
2. Vocabularies + Search: wide = current side-by-side (unchanged); narrow (<1024) = master full-width + the detail
|
||||
in a `DetailDrawer` when a `:id` is active; close returns to the index route.
|
||||
3. Fields: responsive grid (`grid-cols-1 lg:grid-cols-[20rem_1fr]`) — stacked on narrow, side-by-side on wide.
|
||||
4. New tests for `DetailDrawer`, vocabularies-page, search-page (narrow drawer + wide pane), fields-page (responsive
|
||||
structure); all existing tests pass unchanged.
|
||||
5. `typecheck`/`lint`/`build`/`check:colors` green; `check:size` reported (unchanged-or-smaller); no new
|
||||
dependency; no new i18n keys; no codename.
|
||||
|
||||
## Out of scope → follow-ups
|
||||
- A resizable master/detail splitter (issue "consider"); a per-user pane-width preference.
|
||||
- Converting `fields` to a route-driven master/detail (it stays `useState`-driven + stacked).
|
||||
Reference in New Issue
Block a user