8.7 KiB
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:
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()/Suspensethat 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/reactinto its ownbase-uivendor chunk (loaded app-wide via the menu/select/etc.), so the lazy boundary no longer saves anything.DetailDraweris a normal import. - The close-button label keeps the existing generic
actions.closeDetailkey.
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/SearchPanelunder the existingPageTitle), 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 (theisWideternary), 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
useMediaQueryis SSR-safe (returnsfalseserver-side / pre-mount → narrow-first, then corrects on mount).- Drawer
openis 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:
FieldListisoverflow-hiddenin agrid-cols-1row — 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'soverflowas 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): withopen, the children render inside a dialog whose accessible name is theariaLabel; clicking the close button (labelledactions.closeDetail) callsonClose.vocab/vocabularies-page+search/search-pagetests (new or extended): reuse thesetViewport(wide)matchMedia mock fromobjects-page.test.tsx. Narrow + a:idroute → the detail renders in a portaled drawer (getByRole("dialog", { name })withindocument.body); wide +:id→ the detail is the inline pane (no dialog). Closing the drawer navigates back to the index route.fields/fields-pagetest: 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-pagetests: stay green unchanged (the drawer is now the sharedDetailDrawer).- Gate:
typecheck/lint/test/build/check:size/check:colorsgreen; no new dependency; no new i18n keys; no codename; en/sv parity unaffected.check:sizeunchanged-or-smaller (dropping the objects drawer's separate lazy chunk folds it into base-ui).
Acceptance criteria
components/detail-drawer.tsxexists (Base UI drawer + close button +children/ariaLabel);object-detail-drawer.tsxis deleted; Objects uses the sharedDetailDrawer(nolazy/Suspense); its tests stay green.- Vocabularies + Search: wide = current side-by-side (unchanged); narrow (<1024) = master full-width + the detail
in a
DetailDrawerwhen a:idis active; close returns to the index route. - Fields: responsive grid (
grid-cols-1 lg:grid-cols-[20rem_1fr]) — stacked on narrow, side-by-side on wide. - New tests for
DetailDrawer, vocabularies-page, search-page (narrow drawer + wide pane), fields-page (responsive structure); all existing tests pass unchanged. typecheck/lint/build/check:colorsgreen;check:sizereported (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
fieldsto a route-driven master/detail (it staysuseState-driven + stacked).