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
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

CLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:

"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}

Preview

Nothing selected

Select an item to view details.

Demo source

demo.tsxtsx
"use client"; import { useCallback, useState } from "react";import { Pencil, Pin, RefreshCw } from "lucide-react";import { Badge } from "@/components/ui/badge";import { Button } from "@/components/ui/button";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { DetailPanel, DetailPanelEmptyState } from "./detail-panel";import { useDetailPanel } from "./parts/detail-panel-context";import {  DEMO_ENTITIES,  findEntity,  type DemoEntity,} from "./dummy-data";import type { DetailPanelSelection } from "./types"; function entitySelection(entity: DemoEntity | undefined): DetailPanelSelection | null {  if (!entity) return null;  return { type: entity.kind, id: entity.id };} function EntityHeader({ entity }: { entity: DemoEntity }) {  return (    <DetailPanel.Header>      <div className="flex flex-col">        <span className="text-base font-semibold text-foreground">          {entity.label}        </span>        <span className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">          {entity.kind}          {entity.kind === "node" ? ` · ${entity.nodeType}` : null}        </span>      </div>      {entity.kind === "node" && entity.pinned ? (        <Badge variant="secondary" className="gap-1">          <Pin aria-hidden="true" className="size-3" />          Pinned        </Badge>      ) : null}    </DetailPanel.Header>  );} function EntityReadView({ entity }: { entity: DemoEntity }) {  switch (entity.kind) {    case "node":      return (        <dl className="grid grid-cols-[100px_1fr] gap-y-2 text-sm">          <dt className="text-muted-foreground">Type</dt>          <dd>{entity.nodeType}</dd>          <dt className="text-muted-foreground">Created</dt>          <dd className="font-mono">{entity.createdAt}</dd>          <dt className="text-muted-foreground">Pinned</dt>          <dd>{entity.pinned ? "Yes" : "No"}</dd>          <dt className="text-muted-foreground">Notes</dt>          <dd className="whitespace-pre-wrap">{entity.description}</dd>        </dl>      );    case "edge":      return (        <dl className="grid grid-cols-[100px_1fr] gap-y-2 text-sm">          <dt className="text-muted-foreground">Source</dt>          <dd className="font-mono text-xs">{entity.source}</dd>          <dt className="text-muted-foreground">Target</dt>          <dd className="font-mono text-xs">{entity.target}</dd>          <dt className="text-muted-foreground">Weight</dt>          <dd className="font-mono tabular-nums">{entity.weight.toFixed(2)}</dd>        </dl>      );    case "group":      return (        <dl className="grid grid-cols-[100px_1fr] gap-y-2 text-sm">          <dt className="text-muted-foreground">Members</dt>          <dd className="font-mono tabular-nums">{entity.memberCount}</dd>          <dt className="text-muted-foreground">Hull color</dt>          <dd className="font-mono">{entity.color}</dd>        </dl>      );    case "file":      return (        <dl className="grid grid-cols-[100px_1fr] gap-y-2 text-sm">          <dt className="text-muted-foreground">MIME</dt>          <dd className="font-mono text-xs">{entity.mime}</dd>          <dt className="text-muted-foreground">Size</dt>          <dd className="font-mono tabular-nums">            {entity.size.toLocaleString()} bytes          </dd>          <dt className="text-muted-foreground">Uploaded by</dt>          <dd className="font-mono text-xs">{entity.uploadedBy}</dd>        </dl>      );  }} function EmptyDemo() {  return (    <div className="h-96">      <DetailPanel selection={null} ariaLabel="Empty">        {null}      </DetailPanel>    </div>  );} function ReadDemo() {  const entity = DEMO_ENTITIES[0];  return (    <div className="h-96">      <DetailPanel        selection={entitySelection(entity)}        ariaLabel={entity.label}      >        <EntityHeader entity={entity} />        <DetailPanel.Body>          <EntityReadView entity={entity} />        </DetailPanel.Body>      </DetailPanel>    </div>  );} function ModeAwareNodeBody({  entity,  draftLabel,  draftDesc,  onDraftLabelChange,  onDraftDescChange,}: {  entity: DemoEntity;  draftLabel: string;  draftDesc: string;  onDraftLabelChange: (s: string) => void;  onDraftDescChange: (s: string) => void;}) {  const { mode } = useDetailPanel();  if (mode === "read") return <EntityReadView entity={entity} />;  return (    <div className="flex flex-col gap-3 text-sm">      <label className="flex flex-col gap-1">        <span className="text-xs text-muted-foreground">Label</span>        <input          type="text"          value={draftLabel}          onChange={(e) => onDraftLabelChange(e.target.value)}          className="h-8 rounded-md border border-input bg-transparent px-2.5 py-1 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"        />      </label>      {entity.kind === "node" ? (        <label className="flex flex-col gap-1">          <span className="text-xs text-muted-foreground">Notes</span>          <textarea            value={draftDesc}            onChange={(e) => onDraftDescChange(e.target.value)}            rows={4}            className="rounded-md border border-input bg-transparent px-2.5 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"          />        </label>      ) : null}      <p className="text-xs text-muted-foreground">        In a real host, slot a Tier-1 properties-form here.      </p>    </div>  );} function ModeToggleDemo() {  const entity = DEMO_ENTITIES[0];  const [draftLabel, setDraftLabel] = useState(entity.label);  const [draftDesc, setDraftDesc] = useState(    entity.kind === "node" ? entity.description : "",  );   return (    <div className="h-96">      <DetailPanel selection={entitySelection(entity)} ariaLabel={entity.label}>        <EntityHeader entity={entity} />        <DetailPanel.Body>          <ModeAwareNodeBody            entity={entity}            draftLabel={draftLabel}            draftDesc={draftDesc}            onDraftLabelChange={setDraftLabel}            onDraftDescChange={setDraftDesc}          />        </DetailPanel.Body>        <DetailPanel.Actions>          {({ mode, setMode, canEdit }) => {            if (mode === "read") {              return (                <Button                  id="mode-toggle-edit-btn"                  size="sm"                  variant="outline"                  disabled={!canEdit}                  onClick={() => setMode("edit")}                >                  <Pencil aria-hidden="true" className="size-3" />                  Edit                </Button>              );            }            return (              <>                <Button size="sm" variant="ghost" onClick={() => setMode("read")}>                  Cancel                </Button>                <Button size="sm" onClick={() => setMode("read")}>                  Save                </Button>              </>            );          }}        </DetailPanel.Actions>      </DetailPanel>    </div>  );} function LoadingDemo() {  return (    <div className="h-96">      <DetailPanel        selection={{ type: "node", id: "loading-fixture" }}        loading        ariaLabel="Loading fixture"      >        {null}      </DetailPanel>    </div>  );} function ErrorDemo() {  const [retryCount, setRetryCount] = useState(0);  const retry = useCallback(() => setRetryCount((n) => n + 1), []);  return (    <div className="flex h-96 flex-col gap-2">      <DetailPanel        selection={{ type: "node", id: "error-fixture" }}        error={{          message: "Couldn't load this entity. Network error (offline?).",          retry,        }}        ariaLabel="Error fixture"      >        {null}      </DetailPanel>      <p className="text-xs text-muted-foreground">        Retry clicked {retryCount} time{retryCount === 1 ? "" : "s"} (handler is host-supplied).      </p>    </div>  );} function SelectionSwitcherDemo() {  const [selectedId, setSelectedId] = useState<string | null>(DEMO_ENTITIES[0].id);  const entity = selectedId ? findEntity(selectedId) : undefined;  const selection = entitySelection(entity);   return (    <div className="grid h-96 grid-cols-[180px_1fr] gap-4">      <div className="flex flex-col gap-1 overflow-y-auto rounded-md border border-border bg-card p-2">        <button          type="button"          onClick={() => setSelectedId(null)}          className={`rounded-md px-2 py-1.5 text-left text-xs transition-colors ${            selectedId === null              ? "bg-primary/10 font-medium text-foreground"              : "text-muted-foreground hover:bg-muted/50"          }`}        >          (clear)        </button>        {DEMO_ENTITIES.map((e) => (          <button            key={e.id}            type="button"            onClick={() => setSelectedId(e.id)}            className={`flex flex-col rounded-md px-2 py-1.5 text-left text-xs transition-colors ${              selectedId === e.id                ? "bg-primary/10 font-medium text-foreground"                : "text-muted-foreground hover:bg-muted/50"            }`}          >            <span className="truncate text-foreground">{e.label}</span>            <span className="font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">              {e.kind}            </span>          </button>        ))}      </div>      <DetailPanel        selection={selection}        ariaLabel={entity?.label ?? "No selection"}      >        {entity ? (          <>            <EntityHeader entity={entity} />            <DetailPanel.Body>              <EntityReadView entity={entity} />            </DetailPanel.Body>          </>        ) : null}      </DetailPanel>    </div>  );} function CustomEmptyDemo() {  return (    <div className="h-96">      <DetailPanel        selection={null}        ariaLabel="Custom empty"        emptyState={          <div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">            <DetailPanelEmptyState              title="Pick a node"              description="Click a graph node or use the search palette."            />            <Button size="sm" variant="outline">              <RefreshCw aria-hidden="true" className="size-3" />              Reload graph            </Button>          </div>        }      >        {null}      </DetailPanel>    </div>  );} export default function DetailPanelDemo() {  return (    <Tabs defaultValue="empty">      <SwipeTabsList>        <TabsTrigger value="empty">Empty</TabsTrigger>        <TabsTrigger value="read">Read</TabsTrigger>        <TabsTrigger value="mode">Mode toggle</TabsTrigger>        <TabsTrigger value="loading">Loading</TabsTrigger>        <TabsTrigger value="error">Error</TabsTrigger>        <TabsTrigger value="switcher">Selection switcher</TabsTrigger>        <TabsTrigger value="custom-empty">Custom empty</TabsTrigger>      </SwipeTabsList>      <TabsContent value="empty" className="mt-4">        <EmptyDemo />      </TabsContent>      <TabsContent value="read" className="mt-4">        <ReadDemo />      </TabsContent>      <TabsContent value="mode" className="mt-4">        <ModeToggleDemo />      </TabsContent>      <TabsContent value="loading" className="mt-4">        <LoadingDemo />      </TabsContent>      <TabsContent value="error" className="mt-4">        <ErrorDemo />      </TabsContent>      <TabsContent value="switcher" className="mt-4">        <SelectionSwitcherDemo />      </TabsContent>      <TabsContent value="custom-empty" className="mt-4">        <CustomEmptyDemo />      </TabsContent>    </Tabs>  );} 

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