Workflow
The chrome of an edit/detail screen, composed on the Surface primitive. TitleBar is the header strip (title + breadcrumbs slot + status badge + primary/secondary actions, with the secondary actions folding into a “⋮” overflow on mobile); Breadcrumbs is the hierarchy trail; SaveBar is the sticky bottom bar that rises when the form is dirty; and useUnsavedChanges guards against losing edits on the way out. Everything is presentational — props + callbacks, no server actions. Flip the theme in the top bar to confirm every surface re-skins from the tokens.
- TitleBar
- Surface header strip. title (+ as: h1|h2|h3), breadcrumbs slot, status badge slot, primaryAction (always visible) + secondaryActions[] (inline on sm+, overflow Menu on mobile), free-form actions slot. Client (the overflow is a Base UI Menu).
- Breadcrumbs
- items: { label, href? }[] → <nav aria-label='Breadcrumb'> / <ol>; the last (or any href-less) crumb is a non-link aria-current='page'. separator (default chevron), maxItems (collapse the middle to '…'). Server-safe.
- SaveBar
- Sticky bottom Surface (overlay elevation). visible (drive from dirty), onSave / onDiscard, saving (busy → Save spins, bar locks), saveDisabled, message, children slot. Collapses to zero height when hidden (no reserved gap); reduced-motion aware. Server-safe.
- useUnsavedChanges(dirty)
- Route-leave guard. Registers beforeunload only while dirty (hard nav), and returns { confirmLeave, onNavigate } for soft nav — onNavigate drops onto <Link onNavigate=…>, confirmLeave() gates a programmatic router.push. Options: { message, enabled }.
The edit screen
All four pieces wired together exactly as a real edit page would: a TitleBar (breadcrumbs + live status badge + a Save primary and Preview/Duplicate/Archive/Delete secondaries that collapse to a “⋮” on a narrow screen) over a form, with the SaveBar rising from the bottom the moment any field changes. useUnsavedChanges is active throughout — dirty the form and try to reload the tab. Narrow your window (or the frame) to watch the secondary actions fold into the overflow menu.
Spring Adoption Drive
TitleBar
The header strip in isolation: with a status badge + a single secondary, with an icon primary + a full secondary set (resize down to see the overflow), in a raised surface-2 tone, and bare (title only). The action cluster is a labelled role='group'; the inline desktop buttons and the mobile overflow menu render the same action descriptors, so nothing is lost on small screens.
- TitleBarAction
- { key, label, onClick? | href?, icon?, variant?, disabled?, loading? }. href renders a LinkButton (no spinner); otherwise a Button. The same descriptor drives the inline button and its overflow-menu row.
- responsive overflow
- secondaryActions render inline (hidden below sm), and a sm:hidden Menu holds the identical list behind a labelled ⋮ trigger. The Base UI Menu brings roving-tabindex, type-ahead, arrow keys and Esc for free.
- slots
- breadcrumbs (above the title), status (beside it), actions (free-form, after the structured actions). tone / padding / elevation / bordered pass straight to Surface.
SaveBar
The sticky save/discard bar. Toggle its visibility to see it collapse to zero height (no reserved gap) and animate back open, and toggle the busy state to see Save turn into a spinner while the whole bar locks. In a real edit page you'd drive visible from your dirty flag (see the edit-screen demo above).
The bar collapses to zero height when hidden (no reserved gap) and animates open — try “Hide bar”. Reduced-motion users get an instant toggle.
- visible
- Show/hide (drive from dirty). Hidden = a CSS grid-rows collapse to 0fr, so it leaves no blank strip; motion-reduce:transition-none honors reduced motion, and the collapsed bar is aria-hidden + out of the tab order.
- saving
- Busy state: the Save button shows its loading spinner, Discard + Save disable, and the region is aria-busy. saveDisabled disables Save independently (e.g. an invalid form).
- slots & copy
- message (left status copy), children (extra controls left of Discard/Save), saveLabel / discardLabel, saveVariant, tone. onDiscard is omitted → no Discard button.
MergeWorkbench
The generic 'two records into one' reconciler (src/components/ds/MergeWorkbench): Record A · Record B · a live RESULT preview. Each differing field is a choose-one ToggleButton pair; conflicts (both sides non-empty and different) are highlighted with a one-line 'why', read-only provenance is shown but not selectable, and identical fields collapse away. It's purely presentational + controlled — the smart per-field defaults (gap-fill, prefer the newer correct contact value) live in the pure src/lib/merge-resolve. Powers the non-destructive /admin/duplicates merge.
Differing (3)
Kept the address with a valid (non-typo) domain
Filled from the older record (newer was empty)
Kept the newer value
Provenance
Identical (2) — click to expand
- columns / fields
- columns: { key, label, badge? }[] (the records). fields: { key, label, values[] (one per column), conflict?, hint?, readOnly? }[]. Differing fields are selectable; readOnly fields render as provenance; all-equal fields collapse into 'Identical (n)'.
- selection / onSelect
- Controlled: selection maps fieldKey -> chosen column index; onSelect(fieldKey, columnIndex) updates it. The caller seeds the defaults (e.g. from src/lib/merge-resolve resolveRecords) and owns the merge action.
- RESULT preview
- Each differing row shows the currently-chosen value in an accent RESULT cell — the live 'record you'll get'. Conflicts get a warn-tinted row + the hint text; gap-fills surface their 'filled from the older record' hint.
useUnsavedChanges
The route-leave guard. Arm it (the toggle stands in for a real dirty form), then exercise both paths: confirmLeave() gates a programmatic leave (returns true/false), and the guarded link wires guard.onNavigate onto a next/link so a soft navigation confirms first. While armed, reloading or closing the tab also triggers the browser's native beforeunload prompt.
While the guard is armed, reloading or closing this tab also prompts (the native beforeunload dialog). The custom message only shows on the in-app confirms — browsers force their own text on the native one.
- approach
- Next 16's App Router has no global route-change blocker (Pages-router router.events is gone). So the hook owns the one guard it can — a beforeunload listener attached only while dirty (hard navigations: reload, tab close, external/typed URLs) — and hands back helpers for in-app (soft) navigation.
- onNavigate
- Matches next/link's OnNavigateEventHandler — drop it onto <Link onNavigate={guard.onNavigate}>. It confirms when dirty and calls e.preventDefault() to cancel the client-side navigation if the user declines. For app-wide blocking, lift dirty into a context and feed every link's onNavigate from it.
- confirmLeave
- Call before an imperative leave: if (guard.confirmLeave()) router.push('/elsewhere'). Returns true when it's safe (not dirty, or the user confirmed). Options: { message } (the soft-nav prompt copy), { enabled } (master off-switch, e.g. right after a save).