Ansora detail fields
The key-facts block at the top of a record — a real <dl> of label/value pairs, each declaring how its value is typeset. Read-only by design.
primitivedata
Install
pnpm dlx shadcn@4.23.0 add @ansora/detail-fieldsExamples
Usage
items and label are both required. label names the list for a screen reader — a record screen usually carries more than one of these blocks, and an unnamed <dl> is an anonymous group in the rotor. <DetailFields items={fields} /> does not compile — the missing label is a type error, not a runtime surprise.
items={[]}, by contrast, compiles perfectly well and renders a correctly named but EMPTY <dl>. That is deliberate: this component does not decide what an empty record means. An empty list is your cue to render @ansora/empty-state instead, with the sentence only your screen knows.
It renders a real <dl>: a <div> per pair, then <dt> and <dd>. That is what makes "label: value" a PAIR to assistive technology; the <div> grid of the first hand-rolled version reads as N unrelated strings, which is the entire content of the block. Do not flatten the wrapper — with it the pair is the grid item, without it a two-column track can put a value in a different column from its label.
Each field declares how its value is typeset, because the component cannot infer it from a ReactNode:
format: "identifier"→.identifier— tabular figures and a slashed zero. Rodné číslo, číslo pojištěnce, ICD codes, batch numbers: strings read digit by digit.format: "number"→.num— tabular figures, no slashed zero. ISMP's error list records a slashed zero being misread as 4, 6, 8 and 9; on a dose that is a medication error, so it never goes on a value.format: "text"(the default) → nothing. Names, sentences, phone numbers.
There is deliberately no "date" format. Choosing between formatDate and formatDateTime is a decision about the record, not about typography, and @ansora/core owns both — pass the already-formatted string. Every number you pass should have come out of @ansora/core (formatNumber, formatUnit, formatDose, formatDuration); this component typesets, it does not format. Pick formatUnit for a quantity with a unit that is not a medication dose — a duration, a temperature, a percentage — and formatDose only for an actual dose, which refuses a value it cannot render exactly.
hint is an optional clarifier under the value. It renders as a second <dd> rather than inside the first one, so the hint does not inherit the value's tabular figures or slashed zero.
id is optional and is nothing but React's key for the row. The label is deliberately not used as one: two rows may legitimately share a label ("Poznámka" twice on one record), which as a key is a duplicate-key warning and a mis-reconciliation on reorder. Without an id the index is the key — right for the static read-only list this component is for. Pass an id when the list can reorder, stream in or be filtered.
columns is 1 | 2, default 2, and two is the ceiling at the type level — this is the obvious place for a component to grow into a layout system. The second column appears on a container query (@md), not a viewport breakpoint: the same block renders in a ~300 px panel docked beside a video stream and in a full-width card, at one and the same viewport. That choice has one structural cost, worth knowing before you place the block: container-type: inline-size zeroes the root's max-content contribution, so as an auto-sized flex or grid item — a photo beside the key facts in a patient header — it collapses to zero width. Give it a width there (flex-1, a basis, a named grid track); do not strip the container to get the width back, or the column count starts answering the viewport's question again.
data-slot="ansora-detail-fields" sits on that root wrapper, not on the inner <dl>: consumer CSS aimed at the slot should move the block, not the grid inside it. className lands on that same root, merged through cn — which is how you give the block the width the container query needs, without a wrapper <div> the slot cannot see.
Read-only by design. There is no editable prop and there never will be — it turns a read-only block into a form and drags validation, dirty state and submission along with it. When one screen's version of this block does become editable (scribe, after the AI draft), it passes its own control as the value, which is a ReactNode for exactly that reason.
import { formatUnit } from "@ansora/core"
import { DetailFields } from "@/components/ansora/detail-fields"
<DetailFields
label="Detail rezervace"
items={[
{ label: "Pacient", value: "Jana Nováková" },
{ label: "Rodné číslo", value: "905612/3049", format: "identifier" },
{ label: "Lékař", value: "MUDr. Petr Doležal" },
{ label: "Trvání", value: formatUnit(45, "min"), format: "number", hint: "včetně přípravy" },
]}
/>
Files
registry/ansora/components/detail-fields.tsx→@components/ansora/detail-fields.tsx
Dependencies
@ansora/base
Ansora data table
A thin rendering layer over TanStack Table v9. Sorting, filtering, pagination and selection are wired up by you through columns/data — the component only renders.
Event timeline
A read-only, ordered record of what happened to one record — a pre-formatted time, a title, who did it and an optional body, grouped by consecutive day labels, in a scroll region of its own.