Display

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, Copy, TriangleAlert } from "lucide-react"
import { cn } from "@/lib/utils"

/** How long the ✓ stays on a copy button before it turns back into the copy icon. */
const COPY_RESET_MS = 2000

/** Container width in px under which "auto" stacks the label above the value. */
const DEFAULT_COLLAPSE_AT = 420

const ROW_DENSITY = {
  comfortable: "py-3",

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/data-list.json

Prompt

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 ResizeObserver on 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.

On This Page