{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "details",
  "type": "registry:ui",
  "title": "Details",
  "description": "An information card of labelled facts about one thing, such as a deployment or a client: rows with the labels in one column, or a grid with each label above its value. Values can be copied, a missing one says Not set, and a loading card keeps its height.",
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/button.json",
    "https://littmd.vercel.app/r/icons.json",
    "https://littmd.vercel.app/r/states.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/details.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/details 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Details: an information card of labelled facts about one thing, such as a deployment, an invoice or a\n * client: Status, Domain, Branch, Region, Created. Compose it from Details, DetailsItem and DetailsLink.\n *\n * - Surface (components.md, Details): `.litt-raised`, so the surface radius, surface-1, an inset hairline and the\n *   top highlight, and no outer shadow (R3). Never glass (R3), never an accent rail. `bare` drops the surface for\n *   a list that already sits on one, such as a dialog or a side panel.\n * - Header: an optional title (body size, title weight, fg), a description (small, fg-subtle) and an action at the\n *   end, such as an sm secondary Button. The action sits in a one-line-tall cell (`1lh`) on the title's line, so a\n *   32px button hangs evenly around it. A hairline separates the header from the facts.\n * - Rows (the default): the label sits beside the value, split by 1px hairlines, each row space-3 above and below so\n *   a line of text makes a 44px row. The labels share one column, as wide as the longest label up to 40% of the\n *   card, so the values line up. The label and the value's first line share a baseline, so a Tag or a wrapped\n *   value still reads on one line. Under 20rem wide (a container query) the label moves above the value.\n * - Grid: the label sits above the value, in columns that measure the card: one under 20rem, two from there, and up\n *   to `columns` once it is wide. `wide` lets an item, such as a note, take the whole row.\n * - Type (R8): the label is small in fg-subtle, the value is body in fg. `mono` sets a value in Geist Mono at the\n *   small size, for an identifier a person reads character by character: a hash, an ID, a path. A version, a\n *   region or a number stays in the sans.\n *   A long value wraps anywhere rather than truncating, so nothing is ever hidden.\n * - Empty: an item with no value says \"Not set\" in fg-subtle (or its own `empty` words), never a dash.\n * - Loading: `loading` swaps every value for a still surface-2 bar one line tall, so the card keeps its height when\n *   the facts arrive. No pulse, no shimmer. The card sets aria-busy, and copy buttons wait.\n * - Copy: `copy` adds an icon-only ghost button at the end of the row that copies that exact string. It swaps its\n *   icon for a check at once, and a polite live region says \"Domain copied\". No toast. It grows to 44px on a\n *   coarse pointer, and pulls into the row with negative margins so the row keeps its height.\n * - Semantics: a description list. Each label is a term and its value the description, so a screen reader reads\n *   \"Region, fra1\". The card links nowhere, so it has no hover and no focus stop of its own.\n * - Motion: none. A value that changes just changes, and the copy icon swaps at once.\n * - Flat: no rules between rows or under the header, and the card casts the float shadow in place of its hairline.\n */\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useRef,\n  useState,\n  type ComponentProps,\n  type HTMLAttributes,\n  type ReactNode,\n} from \"react\";\nimport { Button } from \"./button\";\nimport { CheckIcon, CopyIcon } from \"./icons\";\nimport { Skeleton } from \"./states\";\n\n/** Rows put each label beside its value; grid puts it above, in columns. */\nexport type DetailsLayout = \"rows\" | \"grid\";\n\nconst DetailsContext = createContext<{ layout: DetailsLayout; loading: boolean }>({ layout: \"rows\", loading: false });\n\n/* -------------------------------------------------------------------------- */\n/* Details                                                                    */\n/* -------------------------------------------------------------------------- */\n\n/* The list itself. Rows use a subgrid, so every label shares one column and the values line up. */\nconst LIST: Record<DetailsLayout, string> = {\n  rows:\n    \"grid grid-cols-[fit-content(40%)_minmax(0,1fr)] gap-x-6 divide-y divide-litt-line in-[[data-material=flat]]:divide-y-0 \" +\n    \"@max-[20rem]:grid-cols-1\",\n  grid: \"grid grid-cols-1 gap-x-8 gap-y-6 @min-[20rem]:grid-cols-2\",\n};\n\n/* Grid only: how many columns once the card is wide. Two is the narrow default. */\nconst COLUMNS: Record<2 | 3 | 4, string> = {\n  2: \"\",\n  3: \"@min-[36rem]:grid-cols-3\",\n  4: \"@min-[36rem]:grid-cols-3 @min-[48rem]:grid-cols-4\",\n};\n\n/* The card's padding. Rows pad space-3 top and bottom themselves, so the card pads the rest of space-6. */\nconst PADDING: Record<DetailsLayout, { header: string; plain: string }> = {\n  rows: { header: \"px-6 pt-6 pb-3\", plain: \"px-6 py-3\" },\n  grid: { header: \"p-6\", plain: \"p-6\" },\n};\n\nexport type DetailsProps = Omit<HTMLAttributes<HTMLElement>, \"title\"> & {\n  /** What the facts are about, such as \"Deployment\". It is a real heading, so a screen reader can list the cards. */\n  title?: ReactNode;\n  /** One short line under the title, in small fg-subtle. */\n  description?: ReactNode;\n  /** One action at the end of the header, such as an sm secondary Button: Visit, Edit. */\n  action?: ReactNode;\n  /** Rows put the label beside the value, split by hairlines. Grid puts it above, in columns, for a few short facts. */\n  layout?: DetailsLayout;\n  /** Grid only: the most columns once the card is wide. It always starts at one under 20rem and two from there. */\n  columns?: 2 | 3 | 4;\n  /** Shows a still bar in place of every value and sets aria-busy, so the card keeps its height while the facts load. */\n  loading?: boolean;\n  /** Drops the raised surface and its padding, for a list that already sits on a surface such as a dialog. */\n  bare?: boolean;\n  /** The title's heading level. Pick the one that fits the page's outline; it changes the semantics only, never the size. */\n  headingLevel?: \"h2\" | \"h3\" | \"h4\";\n  /** The element. Keep section for a card that stands alone, and use li when it sits in a list. */\n  as?: \"section\" | \"article\" | \"div\" | \"aside\" | \"li\";\n  /** The facts: one DetailsItem each. */\n  children?: ReactNode;\n  /** Extra classes for the card, such as a max width. */\n  className?: string;\n};\n\n/** An information card: a header, then labelled facts in rows or a grid. It links nowhere, so it has no hover. */\nexport function Details({\n  title,\n  description,\n  action,\n  layout = \"rows\",\n  columns = 2,\n  loading = false,\n  bare = false,\n  headingLevel: Heading = \"h3\",\n  as: Tag = \"section\",\n  children,\n  className = \"\",\n  ...rest\n}: DetailsProps) {\n  const header = title !== undefined || action !== undefined;\n  const surface = bare ? \"\" : `litt-raised rounded-litt-surface in-[[data-material=flat]]:shadow-(--litt-shadow-float) ${header ? PADDING[layout].header : PADDING[layout].plain}`;\n  return (\n    <DetailsContext.Provider value={{ layout, loading }}>\n      <Tag aria-busy={loading || undefined} className={`@container min-w-0 ${surface} ${className}`} {...rest}>\n        {header && (\n          <div className=\"flex items-start justify-between gap-4 pb-4\">\n            <div className=\"grid min-w-0 gap-1\">\n              {title !== undefined && <Heading className=\"text-litt-body font-litt-title text-litt-fg\">{title}</Heading>}\n              {description !== undefined && <p className=\"text-litt-small text-litt-fg-subtle\">{description}</p>}\n            </div>\n            {action !== undefined && <div className=\"flex h-lh shrink-0 items-center text-litt-body\">{action}</div>}\n          </div>\n        )}\n        <dl\n          className={[\n            LIST[layout],\n            layout === \"grid\" ? COLUMNS[columns] : \"\",\n            header ? (layout === \"grid\" ? \"border-t border-litt-line in-[[data-material=flat]]:border-t-0 pt-6\" : \"border-t border-litt-line in-[[data-material=flat]]:border-t-0\") : \"\",\n          ].join(\" \")}\n        >\n          {children}\n        </dl>\n      </Tag>\n    </DetailsContext.Provider>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/* DetailsItem                                                                */\n/* -------------------------------------------------------------------------- */\n\n/** The older route for pages without the async clipboard API (plain http). It must run inside the click. */\nfunction copyBySelection(text: string): boolean {\n  const area = document.createElement(\"textarea\");\n  area.value = text;\n  area.setAttribute(\"readonly\", \"\");\n  area.style.position = \"fixed\";\n  area.style.opacity = \"0\";\n  document.body.appendChild(area);\n  area.select();\n  try {\n    return document.execCommand(\"copy\");\n  } catch {\n    return false;\n  } finally {\n    area.remove();\n  }\n}\n\ntype CopyState = \"idle\" | \"copied\" | \"failed\";\n/* How long the check stays up. It is feedback, not animation. */\nconst FEEDBACK_MS = 2000;\n\n/** Copy with feedback that needs no toast. `copy(text)` is called straight from the click, so Safari keeps the gesture. */\nfunction useCopy(): [CopyState, (text: string) => void] {\n  const [state, setState] = useState<CopyState>(\"idle\");\n  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n  useEffect(() => () => clearTimeout(timer.current), []);\n  const copy = useCallback((text: string) => {\n    const settle = (next: CopyState) => {\n      setState(next);\n      clearTimeout(timer.current);\n      timer.current = setTimeout(() => setState(\"idle\"), FEEDBACK_MS);\n    };\n    if (navigator.clipboard?.writeText) {\n      navigator.clipboard.writeText(text).then(\n        () => settle(\"copied\"),\n        () => settle(\"failed\"),\n      );\n    } else settle(copyBySelection(text) ? \"copied\" : \"failed\");\n  }, []);\n  return [state, copy];\n}\n\n/* One fact. In rows it joins the list's subgrid, so its label lands in the shared column. */\nconst ITEM: Record<DetailsLayout, string> = {\n  rows:\n    \"col-span-full grid grid-cols-subgrid items-baseline py-3 \" +\n    \"@max-[20rem]:flex @max-[20rem]:flex-col @max-[20rem]:items-stretch @max-[20rem]:gap-1\",\n  grid: \"grid min-w-0 content-start gap-1\",\n};\n\nexport type DetailsItemProps = {\n  /** What the fact is, in sentence case with no colon: \"Region\", \"Created\". */\n  label: string;\n  /** The value: text, a Tag, an Avatar with a name, or a DetailsLink. Leave it out and the item says it is not set. */\n  children?: ReactNode;\n  /** Sets the value in Geist Mono at the small size, for a hash, an ID or a path. */\n  mono?: boolean;\n  /** Adds a copy button at the end of the row that puts exactly this text on the clipboard. */\n  copy?: string;\n  /** Grid only: the item takes the whole row, for a longer value such as a note. */\n  wide?: boolean;\n  /** The words for a missing value. */\n  empty?: string;\n  /** Extra classes for the item. */\n  className?: string;\n};\n\n/** One labelled fact. The label is the term, the value its description. */\nexport function DetailsItem({ label, children, mono = false, copy, wide = false, empty = \"Not set\", className = \"\" }: DetailsItemProps) {\n  const { layout, loading } = useContext(DetailsContext);\n  const [state, copyText] = useCopy();\n  const missing = children === undefined || children === null || children === false || children === \"\";\n\n  let value: ReactNode;\n  if (loading) value = <Skeleton text={mono ? \"small\" : \"body\"} className=\"w-32 max-w-full\" />;\n  else if (missing) value = <span className=\"text-litt-fg-subtle\">{empty}</span>;\n  else value = children;\n\n  return (\n    <div className={`${ITEM[layout]} ${layout === \"grid\" && wide ? \"col-span-full\" : \"\"} ${className}`}>\n      <dt className=\"text-litt-small text-litt-fg-subtle\">{label}</dt>\n      <dd className=\"m-0 flex min-w-0 items-baseline gap-2 text-litt-body text-litt-fg\">\n        <div className={\"min-w-0 grow [overflow-wrap:anywhere] \" + (mono && !missing ? \"font-litt-mono text-litt-small font-normal\" : \"\")}>\n          {value}\n        </div>\n        {copy !== undefined && !loading && (\n          <Button\n            iconOnly\n            variant=\"ghost\"\n            size=\"sm\"\n            aria-label={`Copy ${label}`}\n            className=\"-my-1.5 self-center pointer-coarse:-my-3 pointer-coarse:h-litt-control-md pointer-coarse:w-litt-control-md\"\n            onClick={() => copyText(copy)}\n          >\n            {state === \"copied\" ? <CheckIcon /> : <CopyIcon />}\n          </Button>\n        )}\n        {copy !== undefined && (\n          <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n            {state === \"copied\" ? `${label} copied` : state === \"failed\" ? \"Copy failed. Select the text and copy it by hand.\" : \"\"}\n          </span>\n        )}\n      </dd>\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/* DetailsLink                                                                */\n/* -------------------------------------------------------------------------- */\n\nexport type DetailsLinkProps = Omit<ComponentProps<\"a\">, \"ref\"> & {\n  /** Where the value goes, such as the live site or the commit. */\n  href: string;\n};\n\n/** A value that is also a link: accent text with a soft underline that firms up on hover. It is the only focus stop in a row. */\nexport function DetailsLink({ className = \"\", children, ...rest }: DetailsLinkProps) {\n  return (\n    <a\n      className={\n        \"rounded-litt-control text-litt-accent-text underline decoration-[color-mix(in_oklab,var(--litt-accent-text)_40%,transparent)] \" +\n        \"underline-offset-2 transition-colors duration-(--litt-dur-instant) ease-litt hover:decoration-litt-accent-text \" +\n        \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-litt-focus \" +\n        className\n      }\n      {...rest}\n    >\n      {children}\n    </a>\n  );\n}\n",
      "target": "components/litt/details.tsx"
    }
  ],
  "docs": "Import Details, DetailsItem and DetailsLink from components/litt/details. Give Details a title and one DetailsItem per fact, with copy on the values people paste. Use layout=\"grid\" for a few short facts, and bare inside a dialog or a panel.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
