The Detail* family is a kit: every entity detail sidebar (a transaction, a contact,
a purchase order…) is assembled from the same five concepts — hero size,
section, field def, action button, and (once, app-wide) editor
injection. Parts docs cover each block; this page shows the assembly.
| Concept | Block | The one decision you make |
|---|---|---|
| Hero size | Detail Hero | size="S" | "M" | "L" — a matched set (container + type scale + extras change together) |
| Section | Accordion (via the DetailSection preset) | icon + label — count / onAdd are base AccordionTrigger options |
| Field def | Detail Field | a FieldDef per field — kind, state, and inline affordances |
| Action | Button (via the DetailActionButton preset) | primary / confirm / danger → Button variants inverse / confirm / destructive-outline |
| Editors | DetailFieldEditors | injected ONCE in an app wrapper, never per panel |
import {
Accordion,
DetailSection,
DetailActionButton,
DetailHero,
SHADER_STYLE,
} from "@saturation-ui/react"
// App wrapper: the kit DetailField with your entity editors baked in
// (date / ref / tags / unit) — see "Editor injection" below.
import { DetailField, type FieldDef } from "@/components/ui/detail-field"
import { BuildingIcon, TagIcon, CalendarIcon, CheckIcon, InfoIcon } from "lucide-react"
const FIELDS: { id: string; def: FieldDef }[] = [
{ id: "merchant", def: { label: "Merchant", icon: <BuildingIcon />, kind: "ref",
navTo: "contact", entities: contacts } }, // entity editor, app-injected
{ id: "category", def: { label: "Category", icon: <TagIcon />, kind: "select",
options: CATEGORY_OPTS,
source: { kind: "ai", tooltip: "Suggested from the receipt scan" } } }, // AI marker
{ id: "settled", def: { label: "Settled", icon: <CalendarIcon />, kind: "date",
state: "readonly", lock: { reason: "From bank feed" } } }, // locked, read-only
]
export function BankingTransactionPanel({ rec, set, onOpen }: Props) {
return (
<>
<DetailHero
size="M"
variant="shader"
background={<GrainShader style={SHADER_STYLE} />} {/* slot — shader deps stay app-side */}
title={rec.merchant}
value={money(rec.amount)}
replayKey={rec.id}
/>
<Accordion type="multiple" defaultValue={["details"]}>
<DetailSection value="details" icon={<InfoIcon />} label="Details">
{FIELDS.map((f) => (
<DetailField key={f.id} def={f.def} value={rec[f.id]} onChange={set(f.id)} onOpen={onOpen} />
))}
</DetailSection>
</Accordion>
<div className="px-4 pt-3">
<DetailActionButton primary icon={<CheckIcon size={15} />}>
Actualize
</DetailActionButton>
</div>
</>
)
}No provider, no context, no per-panel wiring. The panel owns the record and passes
value + onChange — every field is controlled.
A field's kind + state decide its full treatment, so the rules apply in every
panel automatically:
onOpen(def.navTo)), with
the pick-list editor injected by the apptint, plus a derived source markerCopy-on-hover derives automatically (email/phone always; non-editable scalars).
lock renders the hover lock with an optional click-to-unlock; source renders a
provenance marker (ai / derived / integration) with a tooltip and optional
popover.
The kit has no data layer. The four data-coupled kinds (date, ref, tags,
unit) render through editors you inject — typically baked into one app wrapper so
panels never see them:
// components/ui/detail-field.tsx (your app)
import { DetailField as KitDetailField, type DetailFieldProps } from "@saturation-ui/react"
import { appEditors } from "./detail-field-editors" // date/ref/tags/unit wired to your data
export function DetailField(props: DetailFieldProps) {
if (props.def) return <KitDetailField {...props} editors={props.editors ?? appEditors} />
return <KitDetailField {...props} />
}Without an injected editor the kit renders the formatted read-only value and warns in dev — a panel never crashes on a missing editor.
size changes the container, avatar box, type scale, and extras together — you
never mix a big value into a small hero:
actionsArrangement (bottom-left inline vs centered profile) is entity-constant: a
contact hero is always centered, a transaction hero always inline. Set the
centered flag once in your entity's hero wrapper — it is not a per-instance axis.
useCompactOnScroll)DetailListCard (entity lists), DetailTimeline / DetailStepper (lifecycle readouts), DetailMeter (limits), DetailDropZone (attachments) — see them composed in the Detail Panel blockDetailSection option (empty={{ icon, label, hint }})