Skip to content
ilinxa/pro-ui

Detail Panel

alphav0.1.2

Selection-aware detail container with read and edit modes, lifecycle states, sticky header and footer actions, and a slot-based body.

Category: FeedbackUpdated: 2026-08-11Created: 2026-04-29Author: ilinxa

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

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Register the @ilinxa namespace (once per project)Add to your components.json. Merge with existing config.
"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}
Install the component
pnpm dlx shadcn@latest add @ilinxa/detail-panel

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/detail-panel-fixtures

Preview

Nothing selected

Select an item to view details.

Demo source

demo.tsxtsx

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 with sticky={false}.
  • <DetailPanel.Body> — scrollable content area; receives focus via focusBody().
  • <DetailPanel.Actions position?> — sticky-bottom by default (position="footer"); set position="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 mode and onModeChange. Panel manages mode state internally; auto-resets to "read" on selection.id / type change.
  • Controlled — supply both mode and onModeChange. Host owns mode; panel calls onModeChange("read") on selection change so the auto-reset contract still holds.
  • Locked (anti-pattern) — supply mode without onModeChange. Dev-only console.warn fires; 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()

Tags

detail-panelfeedbackcompoundselectiongraph-system

Dependencies

shadcn primitives: button, skeleton
npm peer deps: lucide-react@^1.11.0