{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "table-row-expander",
  "title": "Table Row Expander",
  "description": "Opens one row of a table and slides the detail for that row in underneath it, spanning the full width of the table. Reach for it wherever a row is a summary of something with more inside it: an orders or invoices table where the row is the order and the detail is the line items, a logs or audit-trail view where the detail is the stack trace or the raw payload, a billing history with a per-charge breakdown, an admin users or accounts table showing a person's roles and permissions, an incidents or alerts list showing the affected services, a deployments or CI runs table showing the failing step, a transactions table with the fee split, an API request log with the response body, a shipments table with the tracking events, a subscriptions table with the plan changes, and any table where the alternative is sending people to a detail page and back. Common asks it answers: \"expandable table row react\", \"react table expandable rows\", \"table row expand to show details\", \"shadcn expandable table row\", \"shadcn table row detail\", \"shadcn collapsible table row\", \"shadcn data table expand row\", \"expandable rows without tanstack table\", \"tanstack table getCanExpand alternative\", \"react-table is too much for just expanding a row\", \"master detail table react\", \"drill down table row react\", \"table row accordion\", \"nested row table react\", \"sub row react table\", \"expand row to show more info\", \"detail row colspan react\", \"colspan for detail row\", \"what colspan should the detail row be\", \"table columns jump when a row expands\", \"column width changes when expanding row\", \"last column shrinks when row expands\", \"detail row breaks table layout\", \"zebra striping breaks with expanded rows\", \"nth-child striping counts the detail row\", \"striped table wrong rows shaded\", \"animate table row expand\", \"tr height transition not working\", \"cannot animate table row collapse\", \"collapse tr css does not work\", \"overflow hidden not working on tr\", \"height 0 on table row ignored\", \"aria-expanded table row\", \"accessible expandable table row\", \"expandable row screen reader\", \"chevron button has no accessible name\", \"icon button announces just button\", \"aria-controls expandable row\", \"row expander accessibility\", \"only one row open at a time table\", \"expand all rows table react\", \"collapse all rows\", \"keep expanded row after sorting\", \"expanded row state lost when table re-orders\", \"テーブル 行 展開 react\", \"行を開いて詳細を表示\", \"アコーディオン テーブル 行\", \"詳細行 colspan 列数\". Official shadcn/ui has the styling for this and not the component, which is unusually direct evidence that the hole is real: its `TableRow` ships `has-aria-expanded:bg-muted/50`, so it already knows an expandable row is a thing people build on top of it — it simply does not build one. Measured across its whole registry today (63 items listed, 62 fetchable, `questionnaire` is indexed and 404s on both style tracks, 223,287 bytes of `files[].content` concatenated): `colSpan`, `colspan`, `aria-controls`, `expandable`, `subrow`, `getCanExpand`, `rowSpan` and `disclosure` are every one of them a zero hit, and the single `aria-expanded` hit is that one styling hook on `TableRow`. Its `table` is 2,468 bytes of styling wrappers. So every expandable row is hand-rolled, and four things go wrong each time — three of them measured in a browser rather than remembered, because the usual advice about each is either wrong or backwards. The first is the detail cell's `colSpan`, and the measurement inverts the instinct. On a 400px three-column table whose detail holds a long stack trace, the correct span of 3 gives column widths of 105.7 / 210.8 / 106.1. A span of 9 — far more columns than exist — gives 105.7 / 210.8 / 106.1: identical to the hundredth of a pixel, because the surplus becomes a phantom zero-width column that takes no space and draws nothing. A span of 2, one short, gives 141.1 / 281.5 / 26.6 — the last column, the one the detail never reaches, collapses from 100px to 26.6px. A span of 1 crushes two columns instead of one. So over-claiming is free and under-claiming is the whole bug, which means the safe fallback when the count is unknown is a large number rather than a small one, and 1 is the worst available answer rather than the cautious one. It is also why this is slippery in practice: the distortion is proportional to the detail's content width, so a short fixture looks perfect and the table bends only once a real stack trace arrives, in the column nobody touched. The second is that the detail row is not a row, and everything that counts rows disagrees. Measured on three rows with two open and both rows sharing one `<tbody>`, `tbody tr:nth-child(even)` does not shift the stripes by one — it lands them on both detail rows and no data row at all, so the table reads as having lost its striping and grown two shaded panels. This component is therefore a `<tbody>`, one per logical row, which is valid HTML and makes `tbody:nth-of-type(even) tr` correct again while shading each pair together. The same correction is what keeps a \"3 of 12\" counter, a roving tabindex walking rows with Up and Down, and the shift-click range of a row-selection checkbox from counting the panel as data. The third is that a table row cannot be collapsed, so the transition everyone writes is attached to the one element that cannot honour it — and it fails silently, with the row appearing instantly and the CSS sitting there looking correct. Asking each candidate to be zero-tall with `height: 0; overflow: hidden` around two lines of text: a `<tr>` measures 33px, a `<td>` measures 33px, a `<div>` inside the cell measures 0. `height` on a table row is a minimum rather than a size and `overflow` does not apply to `table-row` or `table-cell` at all, so that `<tr>` reports `height: 33px` and `overflow: hidden` together, the second simply having no effect. The detail content is therefore wrapped in a panel inside the cell — the only place a collapse can live — the cell itself carries no padding (padding cannot collapse either), and the panel carries `data-state=\"open\"`, which is the attribute official's own collapsing components key their keyframes off, so `panelClassName=\"data-[state=open]:animate-accordion-down\"` works in any project that has installed `accordion`. It is also where `grid-template-rows: 0fr → 1fr` works, the one transition that interpolates to the content's own height without measuring it in JavaScript. The fourth is the control. `aria-expanded` goes on a `<button>` inside the row and not on the `<tr>`: ARIA permits it on a row, so this is not a validity question — the point is that a row is not operable, takes no focus, answers no key and gives a screen reader no hint that pressing it does anything, so the state would be describing something nobody can press. Official settles it from the other side, since the registry's compiled stylesheet expands `has-aria-expanded:bg-muted/50` to `:has([aria-expanded=true])`, a descendant match — put the attribute on the row and that selector cannot see it, and the open row silently loses the one piece of styling official ships for this pattern. The button is also where the accessible name has to come from, which is the failure that survives every visual review: the control is a chevron with no text, so a table of forty of them is forty announcements of the word \"button\" with no way to tell which row is which. A `label` naming the row is required rather than optional here, the name becomes \"Show details for invoice INV-2048\", it flips to \"Hide\" when open, the chevron is `aria-hidden` so the same fact is not told three times, and `aria-controls` is set only while the detail cell actually exists, because a dangling reference offers a jump that lands nowhere. What you get: `TableRowExpander`, the `<tbody>` holding a row and its panel, which measures the real column count from the live table at press time so the detail row's first render already carries the true span — no first paint at the fallback and no frame in which the columns jump, and it keeps working when the number of columns is variable, as it is under `column-resizer`. `useRowExpansion` holds which rows are open for the whole table, keyed by id so a panel stays with its row when the table is re-sorted rather than with the row's old position, with `multiple: false` for the accordion case and `collapseAll()` for when a filter or the next page replaces the rows. `TableRowExpanderTrigger` is the chevron on its own, for when it has to sit inside a cell you are already rendering, and `trigger=\"start\" | \"end\" | \"none\"` chooses whether this adds the chevron's column at all. `TableRowExpanderDetail` is the detail row by itself, `countTableColumns` and `resolveDetailColSpan` are the measuring and the span rule as plain functions for a table that renders its own rows. Everything stays in your `<table>`: the markup, the rows and the data are yours, and this works the same inside official's `<Table>` or a hand-written one. It renders nothing at all while closed, because a row that is present but empty is not cheaper than one that is absent and it leaves a hairline under every row in the table. Distinct from the neighbours: `tree-view` is a nested tree you walk with the keyboard, `nested-checkbox` is the tri-state roll-up of a parent over its children, and `sort-header`, `sticky-table-header` and `column-resizer` all act on the header rather than the body — none of them puts a panel under a row.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/table-row-expander.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronRight } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * The `colSpan` a detail cell gets when the real column count is not known yet.\n *\n * Deliberately far larger than any real table, and deliberately not `1`. The instinct, when the\n * count is unknown, is to pick a small safe-looking number — and small is the one direction that\n * breaks the table. Measured in Chrome on a 400px three-column table whose detail cell holds a long\n * stack trace, with the correct span as the baseline:\n *\n * | detail `colSpan` | column widths          | table width |\n * | ---------------- | ---------------------- | ----------- |\n * | 3 (correct)      | 105.7 / 210.8 / 106.1  | 423.6       |\n * | 9 (over-claim)   | 105.7 / 210.8 / 106.1  | 423.6       |\n * | 2 (under by one) | 141.1 / 281.5 /  26.6  | 450.2       |\n * | 1                | 422.6 /  52.8 /  26.6  | 502.9       |\n *\n * An over-claim is not merely survivable, it is *identical* to the correct span down to the\n * hundredth of a pixel: the surplus becomes a phantom zero-width column that takes no space and\n * draws nothing. An under-claim charges the detail cell's content width to the columns it does\n * reach, and the columns it does not reach collapse — 100px to 26.6px here. So the failure shows up\n * in the column you never touched, as a heading squeezed down to nothing, which is why it gets\n * investigated as a bug in that column.\n *\n * HTML clamps `colspan` to 1000, so this is the largest value that means \"all of them\".\n */\nexport const DETAIL_COLSPAN_WHEN_UNKNOWN = 1000\n\n/**\n * The `colSpan` for a detail cell, given what is known about the table's width.\n *\n * Separate from the components so the rule can be asserted on its own, and so a table that renders\n * its own detail row can still get the rule right.\n */\nexport function resolveDetailColSpan(columnCount: number | null | undefined): number {\n  if (typeof columnCount !== \"number\" || !Number.isFinite(columnCount)) {\n    return DETAIL_COLSPAN_WHEN_UNKNOWN\n  }\n  // Rounded up rather than down, and floored at 1: a fractional or zero count can only come from a\n  // table mid-measurement, and rounding that *down* is the under-claim this whole function exists\n  // to avoid.\n  return Math.max(1, Math.ceil(columnCount))\n}\n\n/** Marks a cell as a detail cell, so {@link countTableColumns} does not count its span. */\nexport const DETAIL_CELL_ATTRIBUTE = \"data-table-row-detail\"\n\n/**\n * How many columns a table actually has, or `null` if it cannot be told.\n *\n * The widest row wins, rather than the header row. A header that is missing the narrow chevron\n * column — the single most common way to build this, because the column is added to the body and\n * forgotten in the `<thead>` — would otherwise report one column too few, and one too few is\n * exactly the direction that breaks (see {@link DETAIL_COLSPAN_WHEN_UNKNOWN}).\n *\n * Detail cells are skipped. They are the cells carrying an over-claimed span, so counting them\n * would feed the fallback back into the measurement and pin the answer at 1000 forever.\n *\n * `rowSpan` is not modelled: a cell spanning three rows occupies a column in each of them, and\n * reconstructing that needs the whole grid rather than a per-row sum. Tables built that way should\n * pass `colSpan` explicitly.\n */\nexport function countTableColumns(table: HTMLTableElement | null | undefined): number | null {\n  if (!table || !table.rows) return null\n  let widest = 0\n  for (const row of Array.from(table.rows)) {\n    let span = 0\n    for (const cell of Array.from(row.cells)) {\n      if (cell.hasAttribute(DETAIL_CELL_ATTRIBUTE)) continue\n      // `colSpan` is 1 when the attribute is absent, and the DOM already clamps it.\n      span += cell.colSpan || 1\n    }\n    if (span > widest) widest = span\n  }\n  return widest > 0 ? widest : null\n}\n\n/**\n * The column count of the table enclosing `ref`, measured on demand.\n *\n * `measure` is called from the press that opens a row rather than from an effect after it, which is\n * the whole reason this is a function and not a `useEffect`. At press time the table is already on\n * the page and can simply be read, so the detail row's *first* render carries the true span — there\n * is no first paint at the fallback and no correction afterwards. An effect would reverse that\n * order, and the one frame in between is the frame in which the columns jump.\n */\nexport function useTableColumnCount<T extends HTMLElement>(\n  ref: React.RefObject<T | null>\n): { columnCount: number | null; measure: () => number | null } {\n  const [columnCount, setColumnCount] = React.useState<number | null>(null)\n\n  const measure = React.useCallback(() => {\n    const node = ref.current\n    // `closest` rather than a second ref the caller has to thread through the table: the element is\n    // inside the table by construction, so the table is always findable from it.\n    const table = node?.closest?.(\"table\") as HTMLTableElement | null | undefined\n    const next = countTableColumns(table)\n    if (next !== null) setColumnCount(next)\n    return next\n  }, [ref])\n\n  return { columnCount, measure }\n}\n\nexport interface UseRowExpansionOptions {\n  /** Controlled set of open row ids. Pair with `onExpandedChange`. */\n  expandedIds?: readonly string[]\n  defaultExpandedIds?: readonly string[]\n  onExpandedChange?: (ids: string[]) => void\n  /**\n   * Whether more than one row may be open at once. Defaults to `true`.\n   *\n   * `false` is right when the detail is tall or expensive — a chart, a log tail, a fetch per row —\n   * and wrong for a table people open several rows of to compare them.\n   */\n  multiple?: boolean\n}\n\nexport interface UseRowExpansionResult {\n  expandedIds: readonly string[]\n  isExpanded: (id: string) => boolean\n  toggle: (id: string) => void\n  expand: (id: string) => void\n  collapse: (id: string) => void\n  collapseAll: () => void\n}\n\n/**\n * Which rows are open, for a table that has more than one expandable row.\n *\n * Held for the table rather than per row on purpose. A `useState(false)` inside each row is the\n * shorter version and it cannot answer any of the questions a table actually asks: close the others\n * when this one opens, collapse everything when the rows are replaced by the next page or a new\n * filter, or persist what was open across a refetch. Rows keyed by id also survive re-ordering,\n * which per-row state does not — sort the table and the open panel stays with the row index rather\n * than with the row.\n *\n * ```tsx\n * const rows = useRowExpansion({ multiple: false })\n * // …and when the filter changes:\n * React.useEffect(() => rows.collapseAll(), [query])\n * ```\n */\nexport function useRowExpansion({\n  expandedIds,\n  defaultExpandedIds,\n  onExpandedChange,\n  multiple = true,\n}: UseRowExpansionOptions = {}): UseRowExpansionResult {\n  const controlled = expandedIds !== undefined\n  const [internal, setInternal] = React.useState<readonly string[]>(\n    () => (controlled ? expandedIds : defaultExpandedIds) ?? []\n  )\n  const current = controlled ? expandedIds : internal\n\n  // Always written, controlled or not, and the controlled prop always wins on read. Guarding the\n  // write with `if (!controlled)` is the version that looks tidier and strands a component whose\n  // parent passes `expandedIds` one render and drops it the next — the internal copy is then still\n  // whatever it was before the parent took over, so the rows silently revert.\n  const commit = React.useCallback(\n    (next: string[]) => {\n      setInternal(next)\n      onExpandedChange?.(next)\n    },\n    [onExpandedChange]\n  )\n\n  const isExpanded = React.useCallback((id: string) => current.includes(id), [current])\n\n  const expand = React.useCallback(\n    (id: string) => {\n      if (current.includes(id)) return\n      commit(multiple ? [...current, id] : [id])\n    },\n    [commit, current, multiple]\n  )\n\n  const collapse = React.useCallback(\n    (id: string) => {\n      if (!current.includes(id)) return\n      commit(current.filter((open) => open !== id))\n    },\n    [commit, current]\n  )\n\n  const toggle = React.useCallback(\n    (id: string) => {\n      if (current.includes(id)) collapse(id)\n      else expand(id)\n    },\n    [collapse, current, expand]\n  )\n\n  const collapseAll = React.useCallback(() => {\n    if (current.length === 0) return\n    commit([])\n  }, [commit, current])\n\n  return { expandedIds: current, isExpanded, toggle, expand, collapse, collapseAll }\n}\n\nexport interface RowExpanderLabels {\n  /** Accessible name while the row is closed. */\n  expand: (label: string) => string\n  /** Accessible name while the row is open. */\n  collapse: (label: string) => string\n}\n\nexport const defaultRowExpanderLabels: RowExpanderLabels = {\n  expand: (label) => `Show details for ${label}`,\n  collapse: (label) => `Hide details for ${label}`,\n}\n\nexport interface TableRowExpanderTriggerProps\n  extends Omit<React.ComponentPropsWithoutRef<\"button\">, \"aria-expanded\" | \"aria-controls\" | \"children\"> {\n  expanded: boolean\n  onExpandedChange: (expanded: boolean) => void\n  /**\n   * What this row *is* — the order number, the filename, the person's name.\n   *\n   * Required, and it is the prop that decides whether this component is usable by anyone not\n   * looking at the screen. The control is a chevron, so it has no text of its own; left to itself a\n   * table of forty of them is forty identical announcements of the word \"button\", with the row they\n   * belong to knowable only by exploring the grid. The row's own name is already on screen one cell\n   * away, so this costs the caller nothing and is the difference between \"button\" and \"Show details\n   * for invoice INV-2048, collapsed\".\n   */\n  label: string\n  /** The `id` of the detail cell this opens, for `aria-controls`. */\n  controls?: string\n  labels?: Partial<RowExpanderLabels>\n}\n\n/**\n * The chevron that opens one row.\n *\n * Exported separately because the cell it belongs in is often not an empty one — the trigger may\n * have to sit beside the row's own link, or at the end of the row, or wrap the first cell's text.\n * {@link TableRowExpander} renders one of these for the common case.\n *\n * **`aria-expanded` belongs here, not on the `<tr>`.** ARIA does allow it on a row, so this is not\n * a validity question, and the attribute is not the point — the point is that nothing else in the\n * table is the control. A row is not operable: it takes no focus, answers no key, and offers a\n * screen reader no hint that pressing it would do anything. Putting the state on it describes a\n * thing the user cannot press. official shadcn/ui settles it from the other direction: its\n * `TableRow` ships `has-aria-expanded:bg-muted/50`, which the registry's own compiled stylesheet\n * expands to `:has([aria-expanded=true])` — a *descendant* match. Put the attribute on the row\n * itself and that selector cannot see it, so the open row quietly loses the one piece of styling\n * official ships for this pattern. Put it on a button inside and the highlight arrives for free.\n */\nexport function TableRowExpanderTrigger({\n  expanded,\n  onExpandedChange,\n  label,\n  controls,\n  labels: labelOverrides,\n  className,\n  onClick,\n  disabled,\n  ...props\n}: TableRowExpanderTriggerProps) {\n  const labels = React.useMemo(\n    () => ({ ...defaultRowExpanderLabels, ...labelOverrides }),\n    [labelOverrides]\n  )\n\n  return (\n    <button\n      type=\"button\"\n      aria-expanded={expanded}\n      // Only while open. `aria-controls` must point at an element that exists, and the detail cell\n      // is not rendered while the row is closed — a dangling reference is worse than none, because\n      // the assistive technology offers a jump that lands nowhere.\n      aria-controls={expanded ? controls : undefined}\n      aria-label={expanded ? labels.collapse(label) : labels.expand(label)}\n      disabled={disabled}\n      className={cn(\n        \"inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors\",\n        \"hover:bg-muted hover:text-foreground\",\n        \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        className\n      )}\n      onClick={(event) => {\n        onClick?.(event)\n        if (event.defaultPrevented) return\n        onExpandedChange(!expanded)\n      }}\n      {...props}\n    >\n      <ChevronRight\n        // Decorative: the button's name already says \"Show\"/\"Hide\", and `aria-expanded` already\n        // says which way round it is. Reading the arrow as well adds a third telling of the same\n        // fact to every row in the table.\n        aria-hidden=\"true\"\n        className={cn(\n          \"size-4 transition-transform duration-200 motion-reduce:transition-none\",\n          expanded && \"rotate-90\"\n        )}\n      />\n    </button>\n  )\n}\n\nexport interface TableRowExpanderDetailProps\n  extends Omit<React.ComponentPropsWithoutRef<\"tr\">, \"children\"> {\n  children: React.ReactNode\n  /**\n   * The table's column count. Leave unset only when it cannot be known — see\n   * {@link DETAIL_COLSPAN_WHEN_UNKNOWN} for what the fallback does and why it is safe.\n   */\n  colSpan?: number | null\n  /** `id` for the cell, so a trigger's `aria-controls` can point at it. */\n  cellId?: string\n  /** Classes for the `<td>`. */\n  cellClassName?: string\n  /** Classes for the panel inside the `<td>` — the element a collapse animation belongs on. */\n  panelClassName?: string\n}\n\n/**\n * The detail row: one `<tr>`, one `<td>` across the whole table, and a panel inside it.\n *\n * **The panel is not decoration.** A row cannot be collapsed, so the panel is the only place an\n * open/close animation can live. Measured in Chrome, asking each candidate to be zero-tall with\n * `height: 0; overflow: hidden` around two lines of text:\n *\n * | element                     | height when asked to be 0 |\n * | --------------------------- | ------------------------- |\n * | `<tr>`                      | 33px                      |\n * | `<td>`                      | 33px                      |\n * | a `<div>` inside the `<td>` | 0px                       |\n *\n * `height` on a table row is a minimum rather than a size, and `overflow` does not apply to\n * `table-row` or `table-cell` at all — the computed style on that `<tr>` reads back `height: 33px`\n * and `overflow: hidden` together, the second simply having no effect. So the transition everyone\n * writes first is attached to the one element in the tree that cannot honour it, and it does not\n * fail loudly: the row appears instantly, and the CSS sits there looking correct.\n *\n * The panel carries `data-state=\"open\"`, which is the attribute official shadcn/ui's own collapsing\n * components key their animations off, so a project that has installed `accordion` already has the\n * keyframes:\n *\n * ```tsx\n * <TableRowExpander panelClassName=\"data-[state=open]:animate-accordion-down\" … />\n * ```\n *\n * For a collapse that animates to the content's own height without measuring it in JavaScript, the\n * panel is also where `grid-template-rows: 0fr → 1fr` works — the one transition that interpolates\n * to `auto`. It collapses to 0px here and cannot anywhere in the table around it.\n */\nexport function TableRowExpanderDetail({\n  children,\n  colSpan,\n  cellId,\n  className,\n  cellClassName,\n  panelClassName,\n  ...props\n}: TableRowExpanderDetailProps) {\n  return (\n    <tr\n      // Not `aria-hidden`, not `role=\"presentation\"`: the row is a real row and saying otherwise\n      // breaks the grid the table exposes. What keeps it from being mistaken for data is that the\n      // trigger points at it with `aria-controls` and announces its own state.\n      data-state=\"open\"\n      className={cn(\"border-b bg-muted/30\", className)}\n      {...props}\n    >\n      <td\n        id={cellId}\n        colSpan={resolveDetailColSpan(colSpan)}\n        // Read by countTableColumns, which must not count this cell's span as a column. Written as a\n        // literal rather than through DETAIL_CELL_ATTRIBUTE because a computed key in a JSX spread\n        // is not checked against the element's attributes — the one place a typo here would go\n        // unnoticed is the place where the counter stops skipping this cell and pins itself at 1000.\n        data-table-row-detail=\"\"\n        className={cn(\"p-0 align-top\", cellClassName)}\n      >\n        <div data-state=\"open\" className={cn(\"px-4 py-3\", panelClassName)}>\n          {children}\n        </div>\n      </td>\n    </tr>\n  )\n}\n\nexport interface TableRowExpanderProps\n  extends Omit<React.ComponentPropsWithoutRef<\"tbody\">, \"children\"> {\n  /** The row's own cells. The trigger's cell is added by this component unless `trigger` is `\"none\"`. */\n  children: React.ReactNode\n  /** What goes under the row once it is open. Rendered only while open. */\n  detail: React.ReactNode\n  expanded: boolean\n  onExpandedChange: (expanded: boolean) => void\n  /** What this row is, for the trigger's accessible name. See {@link TableRowExpanderTriggerProps.label}. */\n  label: string\n  /** Where the chevron goes, or `\"none\"` to place a {@link TableRowExpanderTrigger} yourself. */\n  trigger?: \"start\" | \"end\" | \"none\"\n  /**\n   * The table's column count. Left unset it is measured from the real table at press time, which is\n   * what a table with a variable number of columns needs.\n   */\n  colSpan?: number | null\n  disabled?: boolean\n  labels?: Partial<RowExpanderLabels>\n  /** Classes for the data `<tr>`. */\n  rowClassName?: string\n  /** Classes for the detail `<tr>`. */\n  detailClassName?: string\n  /** Classes for the panel inside the detail cell — where an animation belongs. */\n  panelClassName?: string\n}\n\n/**\n * One row of a table, plus the detail panel that opens beneath it.\n *\n * ```tsx\n * const rows = useRowExpansion()\n *\n * <table className=\"w-full\">\n *   <thead>\n *     <tr>\n *       <th className=\"w-8\" />\n *       <th>Order</th><th>Customer</th><th className=\"text-right\">Total</th>\n *     </tr>\n *   </thead>\n *   {orders.map((order) => (\n *     <TableRowExpander\n *       key={order.id}\n *       label={`order ${order.number}`}\n *       expanded={rows.isExpanded(order.id)}\n *       onExpandedChange={() => rows.toggle(order.id)}\n *       detail={<OrderLines order={order} />}\n *     >\n *       <td>{order.number}</td>\n *       <td>{order.customer}</td>\n *       <td className=\"text-right\">{order.total}</td>\n *     </TableRowExpander>\n *   ))}\n * </table>\n * ```\n *\n * **It is a `<tbody>`, one per row, and that is the feature.** A table may hold any number of\n * `<tbody>` elements, and grouping each row with its own detail is what keeps the detail row from\n * being counted as data by everything that counts rows. Striping is the visible case. With both\n * rows in one shared `<tbody>`, `tbody tr:nth-child(even)` does not merely shift by one — measured\n * in Chrome on three rows with two open, the stripe lands on *both detail rows and no data row at\n * all*, which reads as a table that has lost its stripes and grown two shaded panels. Grouped, the\n * same table stripes correctly with `tbody:nth-of-type(even) tr`, and the pair shades together.\n * The same correction applies to anything else keyed on position — \"3 of 12\" counters, a roving\n * tabindex walking rows with Up and Down, and the row-selection checkbox that shift-click extends\n * a range of.\n *\n * Both rows stay in the caller's own table, so this drops into one that already exists — official\n * shadcn/ui's `<Table>`, a hand-written `<table>`, or one whose columns are being dragged wider by\n * `column-resizer`. It renders nothing while closed: a row that is present but empty is not cheaper\n * than one that is absent, and it leaves a hairline under every row in the table.\n */\nexport function TableRowExpander({\n  children,\n  detail,\n  expanded,\n  onExpandedChange,\n  label,\n  trigger = \"start\",\n  colSpan,\n  disabled,\n  labels,\n  className,\n  rowClassName,\n  detailClassName,\n  panelClassName,\n  ...props\n}: TableRowExpanderProps) {\n  const bodyRef = React.useRef<HTMLTableSectionElement | null>(null)\n  const { columnCount, measure } = useTableColumnCount(bodyRef)\n  const reactId = React.useId()\n  const detailId = `${reactId}-detail`\n  const triggerId = `${reactId}-trigger`\n\n  const handleExpandedChange = React.useCallback(\n    (next: boolean) => {\n      // Measured here rather than in an effect: the table is on the page now, so the detail row's\n      // first render already has the real span. See useTableColumnCount.\n      if (next) measure()\n      onExpandedChange(next)\n    },\n    [measure, onExpandedChange]\n  )\n\n  // Built unconditionally and placed conditionally below. A `trigger === \"none\" ? null : …` guard\n  // here as well would be dead: an unplaced element is never rendered, so the two conditions can\n  // only ever agree, and the second one is just a branch nothing can take.\n  const triggerCell = (\n    <td className=\"w-8 p-0 text-center align-middle\">\n      <TableRowExpanderTrigger\n        id={triggerId}\n        expanded={expanded}\n        onExpandedChange={handleExpandedChange}\n        label={label}\n        controls={detailId}\n        disabled={disabled}\n        labels={labels}\n      />\n    </td>\n  )\n\n  return (\n    <tbody ref={bodyRef} className={className} {...props}>\n      <tr\n        data-state={expanded ? \"open\" : \"closed\"}\n        className={cn(\"border-b transition-colors hover:bg-muted/50\", rowClassName)}\n      >\n        {trigger === \"start\" && triggerCell}\n        {children}\n        {trigger === \"end\" && triggerCell}\n      </tr>\n      {expanded && (\n        <TableRowExpanderDetail\n          cellId={detailId}\n          // The caller's explicit count wins; otherwise what was measured at press time; otherwise\n          // the over-claim, which is what a row rendered open on the first paint gets.\n          colSpan={colSpan ?? columnCount}\n          className={detailClassName}\n          panelClassName={panelClassName}\n        >\n          {detail}\n        </TableRowExpanderDetail>\n      )}\n    </tbody>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
