Detail Panel
alphav0.1.2Selection-aware detail container with read and edit modes, lifecycle states, sticky header and footer actions, and a slot-based body.
Context
Tier 1 pro-component for the graph-system. Pairs with properties-form as the inline editing surface. Useful standalone wherever a selection-driven side panel is needed (file inspector, item drawer, settings detail). Generic over entity type via host-supplied children. Three mode configurations (controlled / uncontrolled / locked); composite re-key on `${type}:${id}` change so slotted forms remount cleanly without state bleed. Detail-panel does NOT import properties-form at the registry level (decision #35); composition lives at the host level.
Installation
pnpm dlx shadcn@latest init"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}pnpm dlx shadcn@latest add @ilinxa/detail-panelAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/detail-panel-fixturesPreview
Nothing selected
Select an item to view details.
Demo source
Usage
When to use
Reach for DetailPanel when a host has a selection-driven secondary surface — a side panel paired with a list or canvas that shows the focused entity in detail, with read/edit/loading/error states. The component handles compound layout (sticky header + scrollable body + sticky footer actions), composite re-keying on selection change so slotted forms remount cleanly, mode toggling under three configurations, focus management, and ARIA wiring. Hosts own the data, the slotted content per entity type, and the actions.
Basic example
import { DetailPanel } from "@/components/detail-panel";
export function NodeDetail({ node }) {
return (
<DetailPanel
selection={node ? { type: "node", id: node.id } : null}
ariaLabel={node?.label}
>
<DetailPanel.Header>
<span className="font-semibold">{node.label}</span>
</DetailPanel.Header>
<DetailPanel.Body>
<NodeReadView node={node} />
</DetailPanel.Body>
<DetailPanel.Actions>
{({ mode, setMode, canEdit }) =>
mode === "read" ? (
<Button disabled={!canEdit} onClick={() => setMode("edit")}>
Edit
</Button>
) : (
<>
<Button variant="ghost" onClick={() => setMode("read")}>
Cancel
</Button>
<Button onClick={handleSave}>Save</Button>
</>
)
}
</DetailPanel.Actions>
</DetailPanel>
);
}Compound API
<DetailPanel.Header sticky?>— sticky-top by default; opt out withsticky={false}.<DetailPanel.Body>— scrollable content area; receives focus viafocusBody().<DetailPanel.Actions position?>— sticky-bottom by default (position="footer"); setposition="header"to render inline inside the header band (place it right after<DetailPanel.Header>in children for visual alignment).- Children
<Actions>may be a render-fn that receives{ mode, setMode, canEdit }for read/edit-aware buttons.
Mode configurations
- Uncontrolled — omit both
modeandonModeChange. Panel manages mode state internally; auto-resets to"read"onselection.id/typechange. - Controlled — supply both
modeandonModeChange. Host owns mode; panel callsonModeChange("read")on selection change so the auto-reset contract still holds. - Locked (anti-pattern) — supply
modewithoutonModeChange. Dev-onlyconsole.warnfires; panel cannot auto-reset; setMode is a no-op.
Re-key on selection
Children remount whenever selection.type or selection.id changes — composite key `${type}:${id}`. This wipes any internal state in slotted forms (controlled values are unaffected because the host owns them). The mechanism is an invisible <div className="contents"> wrapper that holds a React key without injecting layout.
Composing with properties-form (the showcase)
import { DetailPanel } from "@/components/detail-panel";
import {
PropertiesForm,
type PropertiesFormHandle,
} from "@/components/properties-form";
const formRef = useRef<PropertiesFormHandle>(null);
function handleSelectionChange(next) {
if (formRef.current?.isDirty()) {
if (!confirm("Discard unsaved changes?")) return;
}
setSelection(next); // detail-panel re-keys; properties-form remounts clean
}
<DetailPanel selection={selection} ariaLabel={entity?.label}>
<DetailPanel.Header>...</DetailPanel.Header>
<DetailPanel.Body>
<PropertiesForm
ref={formRef}
schema={schemaFor(entity.type)}
values={entity.values}
onChange={setValues}
mode="edit"
showSubmitActions={false} // host renders Save/Cancel via Actions
/>
</DetailPanel.Body>
<DetailPanel.Actions>
{({ mode, setMode }) => (
mode === "read" ? <EditButton onClick={() => setMode("edit")} /> : (
<SaveCancelButtons formRef={formRef} setMode={setMode} />
)
)}
</DetailPanel.Actions>
</DetailPanel>Two contracts to honor: (1) host intercepts selection change BEFORE propagating, calls formRef.current?.isDirty(); (2) save button calls formRef.current?.submit() and switches mode on success. Detail-panel does not import properties-form at the registry level — composition is host code only.
Lifecycle states
Precedence is error → loading → content → empty. When error is set the error UI wins regardless of loading or selection. Set error.retry to render a Try-again button.
Sticky positioning
Header sticky-top and footer-Actions sticky-bottom both rely on the panel's outer container having a constrained height. Wrap the panel in h-full inside a flex parent (or any height: X ancestor); without it, sticky collapses to static.
Imperative handle
const panelRef = useRef<DetailPanelHandle>(null);
panelRef.current?.focusBody(); // moves focus into body's first focusable
panelRef.current?.resetMode(); // forces back to "read"What ships in v0.2+
<DetailPanel.MultiSelection>companion for multi-select.<DetailPanel.Skeleton>custom slot for layouts that deviate from the default.selectionLabel?for richer selection-change ARIA announcements.- Selection-change cross-fade animation.
Features
- Compound API — DetailPanel.Header / .Body / .Actions via React Context
- Composite re-key on selection.type / selection.id change (host-form remount)
- Three mode configurations — controlled / uncontrolled / locked (dev-warned anti-pattern)
- Sticky header (top:0) + sticky footer actions (bottom:0); header-positioned actions opt-in
- Lifecycle precedence — error > loading > content > empty
- Render-fn Actions context — { mode, setMode, canEdit }
- Built-in skeleton mirrors panel layout; built-in error UI with optional retry
- Built-in empty state with sibling export for composition
- Focus management — selection-change focuses panel root; mode→edit focuses body's first focusable; mode→read restores focus to triggering action by id
- ARIA — role region, aria-busy on loading, aria-live polite selection announcements
- labels.region default ('Detail panel') guarantees role=region always has an accessible name; ariaLabel wins per render
- Imperative handle — focusBody() + resetMode()