Data List
A read-only key/value list that folds from two columns to stacked pairs on container width, with per-row copy, focus-revealed truncation and an explicit em dash for empty fields.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/data-list.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "DataList" component (lucide-react for the
copy / check / warning icons, plus the cn() class-merge helper — no other runtime
dependencies).
Contract
- export interface DataListItem { label: string; value?: ReactNode; id?: string;
copyable?: boolean; copyValue?: string; truncate?: boolean;
valueClassName?: string }.
- export interface DataListProps extends
Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
items: DataListItem[]; layout?: "auto" | "columns" | "stacked";
collapseAt?: number; labelWidth?: string;
density?: "comfortable" | "compact"; divided?: boolean; emptyText?: string }.
- Defaults: layout "auto", collapseAt 420, labelWidth "10rem", density
"comfortable", divided true, emptyText an em dash. forwardRef to the root
<div>, className merged through cn(), remaining native props spread on it.
- Every row is reduced to four facts before anything renders:
empty = value is undefined | null | false | a string that trims to "".
text = String(value) when value is a string or a number, else null.
copyText = copyValue ?? text, kept only when !empty && copyable === true &&
it trims to something; otherwise null and the row has no button.
foldable = !empty && truncate === true && text !== null.
Row identity is id ?? String(index) and keys React, the unfold state and the
copy state alike.
Behavior
- Layout. "columns" and "stacked" are pinned and no measurement happens at all.
"auto" observes the component's own box with a ResizeObserver and compares
entry.contentRect.width < collapseAt — container width, not viewport width, so
one list is correct in a full-page detail view and in a 320px drawer on the
same screen. Before the first measurement (server render, first paint, no
ResizeObserver) the answer is "stacked", the one layout that cannot overflow.
- Two-column rows are a grid of labelWidth + minmax(0, 1fr) with items-baseline;
stacked rows are grid-cols-1 with the label above the value. Nothing else
changes between the two: one markup tree, two templates.
- Empty values never leave a blank cell. The row paints emptyText, marks that
glyph aria-hidden and puts the words "No value" in an sr-only span, so a
screen reader hears an empty field instead of silence.
- Truncation folds a long value to one line with an ellipsis, and it is visual
only: the whole string stays in the DOM, so assistive tech, browser find and
the clipboard all still see it in full. A foldable value is a button carrying
two flags — revealed = pinned || focused. Focus reveals it (never hover
alone), a click pins it open past the blur, and folding clears both flags at
once: dropping only the pin would leave the value open under a focus it still
holds and the toggle would look broken. Hover merely underlines it to
advertise the affordance.
- Copy writes copyText with navigator.clipboard.writeText. On success the icon
becomes a check for 2000ms and a second press inside that window simply copies
again: the button is never natively disabled, so it can never go inert under
the caret. At most one row holds a copy state, so copying B resets A for free.
- Refusals are first class. No clipboard API, an insecure context or a denied
permission all reject: the icon becomes a warning triangle, the row's value is
selected as a best-effort fallback, and the failure stays on screen until the
next attempt. A write that did not happen is never reported as one.
- One polite live region for the whole list announces "<label> copied" or
"Could not copy <label>. Select the value to copy it manually." — one region
rather than one per row, so a burst of copies cannot talk over itself.
- Keyboard map. Tab / Shift+Tab reach exactly two kinds of control, in reading
order: a foldable value and a copy button. Landing on a foldable value already
reveals it. Enter or Space (the same path as a click) folds a revealed value
and unfolds a folded one; Escape folds a revealed value, with preventDefault
plus stopPropagation so a row folding back never also closes the dialog the
list sits in. Focus never moves on its own — no control in this component
unmounts under the caret. Copy buttons are ordinary buttons: Enter and Space
fire them.
- ARIA contract. The rows are a real <dl>; each row is a <div> holding one <dt>
and one <dd>, the only wrapper HTML allows inside a <dl>. The live region is a
sibling of the <dl>, never a child, because a bare <span> inside a <dl> is
invalid. A folded value is a <button aria-expanded> whose accessible name is
the full value. A copy button is icon-only with aria-label "Copy <label>" and
an aria-hidden icon; its name never shifts under focus because the live region
carries the state change.
- Cleanup. The ResizeObserver is disconnected on unmount and rebuilt whenever
layout or collapseAt changes. The copy reset timer is read and cleared
synchronously at the top of the copy handler, so a second copy can never
inherit the previous row's countdown, and it is cleared again on unmount. A
mounted ref — re-armed on mount, because StrictMode replays mount, cleanup,
mount — guards both clipboard callbacks.
- Edge cases: items: [] renders an empty <dl> and the surrounding surface owns
the empty state; copyable on a row whose value is an arbitrary node is refused
because there is no honest text for it; truncate on such a row is ignored and
the node wraps normally; a whitespace-only string is empty, not a value.
Rendering & styling
- Semantic tokens only: text-muted-foreground for labels and for the em dash,
divide-border for the hairlines, hover:bg-accent with
hover:text-accent-foreground and focus-visible:ring-ring on both buttons,
text-primary for a copied tick, text-destructive for a failed one. No hex, no
rgb, no palette classes — dark mode comes from the host theme.
- Rows: grid gap-x-6, py-3 for comfortable or py-2 for compact, plus gap-y-1
when stacked. The <dd> is a flex row of "value, then copy button"; the value
box is min-w-0 so the ellipsis can actually happen and break-words so a value
nobody opted into folding still wraps rather than escaping its cell. The
button carries -my-1 so its 28px hit area cancels against the 20px text line
and a copyable row ends up exactly as tall as a plain one.
- The only animation is a colour transition, paired with
motion-reduce:transition-none. Nothing slides, folds or fades, so a
reduced-motion visitor loses no information whatsoever.
Customization levers
- Layout: pin it with layout="columns" or "stacked" when the surface width is
known, or move collapseAt (420 by default) to fold earlier or later.
labelWidth takes any CSS length and is the single number to tune when labels
run long.
- Density: density="compact" for credential panels and drawers, "comfortable"
for detail pages; divided={false} drops the hairlines on a card that already
has enough structure.
- Empty fields: emptyText takes any string — "Not set", "Unknown", "n/a" — while
the sr-only wording stays "No value", so the announcement is stable whatever
the glyph becomes.
- Copy: opt in per row with copyable, and reach for copyValue whenever what you
show is not what should land on the clipboard (a formatted date against its
ISO form, a short sha against the full one). Change COPY_RESET_MS to lengthen
the tick, or fire a toast from the success branch instead of leaning on the
live region.
- Value styling: valueClassName is per row — font-mono for identifiers, text-xs
for digests — and value is a ReactNode, so a badge, an avatar row or a link
drops in without the list knowing anything about it.
- Copy button placement: it is the last child of the <dd>. Move it ahead of the
value, or swap the icon set, without touching the state machine.Concepts
- Container-width collapse — the fold decision comes from a
ResizeObserveron the component's own box, not from a viewport media query, so the same list is right in a full-page detail view and in a 320px drawer on one screen; before the first measurement it renders stacked, the layout that can never overflow. - Focus-revealed folding — a folded value is a real button: focus alone unfolds it, a click pins it open and Escape puts it back. Hover-only reveals lock out keyboards and touch, so hover here does nothing but underline the affordance.
- Visual-only truncation — the ellipsis is CSS; the full string never leaves the DOM, so screen readers, browser find and the copy action all keep the whole value.
- Explicit empty — a missing field paints an em dash for the eye and an sr-only "No value" for assistive tech, instead of a blank cell that reads as a rendering bug.
- Refused affordance — a row with nothing honest to copy (no value, or a value that is an arbitrary node) gets no copy button at all, rather than a button that fails after the click.
- Single copy slot — one row, one timer and one live region hold the copy state, so a second copy resets the first for free and the announcements can never overlap.
Env Diff
A key × environment configuration diff — every cell judged against one baseline column, secrets compared by fingerprint so no value is ever rendered, and a live count of what promoting one environment into another would change.
Regex Tester
A regex workbench: flag toggles, matches tinted in place, a group table, the engine's own error text, and a worker-backed budget that survives catastrophic backtracking.