{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "expandable-rows",
  "type": "registry:ui",
  "title": "Expandable Rows",
  "description": "Rows open a detail panel that grows from beneath them and scrolls itself into view.",
  "categories": [
    "tables"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/expandable-rows.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Fragment, useCallback, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type ExpandableColumn<T> = {\n  key: string;\n  header: React.ReactNode;\n  cell?: (row: T) => React.ReactNode;\n  numeric?: boolean;\n  width?: number | string;\n  className?: string;\n};\n\nexport type ExpandableRowsProps<T> = Omit<React.ComponentProps<\"div\">, \"children\" | \"defaultValue\"> & {\n  columns: ExpandableColumn<T>[];\n  rows: T[];\n  getRowId: (row: T) => string;\n  /** Names each toggle: \"Show details for {label}\". Defaults to the row id. */\n  getRowLabel?: (row: T) => string;\n  /** The panel under an open row. Return null for rows with nothing more to show; they get no toggle. */\n  renderDetail: (row: T) => React.ReactNode;\n  caption: string;\n  expanded?: string[];\n  defaultExpanded?: string[];\n  onExpandedChange?: (expanded: string[]) => void;\n  /** Only one row open at a time: opening one closes the last. */\n  single?: boolean;\n  /** Pressing anywhere on a row toggles it. Links and buttons inside the row keep their own click. */\n  rowClickExpands?: boolean;\n  empty?: React.ReactNode;\n  size?: \"sm\" | \"md\";\n  minWidth?: number;\n};\n\nconst interactive = \"a,button,input,select,textarea,label,[role=checkbox],[data-row-expand-ignore]\";\n\nexport function ExpandableRows<T>({\n  columns,\n  rows,\n  getRowId,\n  getRowLabel,\n  renderDetail,\n  caption,\n  expanded: expandedProp,\n  defaultExpanded = [],\n  onExpandedChange,\n  single = false,\n  rowClickExpands = true,\n  empty = \"Nothing here yet\",\n  size = \"md\",\n  minWidth,\n  className,\n  ...rest\n}: ExpandableRowsProps<T>) {\n  const [expanded, setExpanded] = useControllableState({ value: expandedProp, defaultValue: defaultExpanded, onChange: onExpandedChange });\n  const open = useMemo(() => new Set(expanded), [expanded]);\n  // Rows whose panel is on screen, including while it closes. The row's own divider waits for the\n  // panel to finish collapsing, so the two never show a double line.\n  const [mounted, setMounted] = useState<Set<string>>(() => new Set(defaultExpanded.concat(expandedProp ?? [])));\n  const captionId = useId();\n  const baseId = useId();\n  const reduce = useReducedMotion();\n  const h = size === \"sm\" ? \"h-8\" : \"h-10\";\n  const root = useRef<HTMLDivElement>(null);\n  // The visible width of the table. Panels pin to it, so on a phone that scrolls the table\n  // sideways the details stay readable instead of running off past the edge.\n  const [visible, setVisible] = useState<number>();\n  const [edges, setEdges] = useState({ left: false, right: false });\n  const measure = (el: HTMLDivElement) => {\n    const left = el.scrollLeft > 1;\n    const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 1;\n    setEdges((e) => (e.left === left && e.right === right ? e : { left, right }));\n  };\n  useEffect(() => {\n    const el = root.current;\n    if (!el) return;\n    const ro = new ResizeObserver(() => {\n      setVisible(el.clientWidth);\n      measure(el);\n    });\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  // Panels opened by a person (not on first paint), which should be brought into view once they finish growing.\n  const reveal = useRef(new Set<string>());\n\n  // If an opened panel ends below the fold of a scrolling table, scroll just enough to show it,\n  // never so far that its own row slides under the header.\n  const bringIntoView = (panel: HTMLElement) => {\n    const box = root.current;\n    if (!box || box.scrollHeight <= box.clientHeight) return;\n    const b = box.getBoundingClientRect();\n    const p = panel.getBoundingClientRect();\n    const head = box.querySelector(\"thead\")?.getBoundingClientRect().height ?? 0;\n    const row = (panel.closest(\"tr\")?.previousElementSibling as HTMLElement | null)?.getBoundingClientRect().height ?? 0;\n    const overflow = p.bottom - b.bottom + 8;\n    const room = p.top - row - (b.top + head);\n    const delta = Math.min(overflow, room);\n    if (delta > 0) box.scrollBy({ top: delta, behavior: reduce ? \"auto\" : \"smooth\" });\n  };\n\n  const details = useMemo(() => new Map(rows.map((r) => [getRowId(r), renderDetail(r)])), [rows, getRowId, renderDetail]);\n  const expandable = useMemo(() => rows.map(getRowId).filter((id) => details.get(id) != null), [rows, getRowId, details]);\n  const allOpen = expandable.length > 0 && expandable.every((id) => open.has(id));\n\n  const toggle = useCallback(\n    (id: string, force?: boolean) => {\n      const next = force ?? !open.has(id);\n      if (next === open.has(id)) return;\n      if (next) {\n        reveal.current.add(id);\n        setMounted((m) => new Set(m).add(id));\n      }\n      setExpanded(next ? (single ? [id] : [...expanded, id]) : expanded.filter((e) => e !== id));\n    },\n    [open, expanded, single, setExpanded],\n  );\n\n  const toggleAll = () => {\n    if (allOpen) setExpanded([]);\n    else {\n      setMounted((m) => new Set([...m, ...expandable]));\n      setExpanded(expandable);\n    }\n  };\n\n  const unmount = (id: string) =>\n    setMounted((m) => {\n      if (!m.has(id)) return m;\n      const next = new Set(m);\n      next.delete(id);\n      return next;\n    });\n\n  return (\n    <div data-size={size} className={cn(\"relative flex flex-col overflow-hidden rounded-xl border border-line bg-frame\", className)} {...rest}>\n      <div ref={root} onScroll={(e) => measure(e.currentTarget)} className=\"max-h-[inherit] min-h-0 flex-1 overflow-auto overscroll-contain\">\n        <table aria-labelledby={captionId} style={{ minWidth }} className=\"w-full border-separate border-spacing-0 text-[13px]\">\n          <caption id={captionId} className=\"sr-only\">\n            {caption}\n          </caption>\n          <colgroup>\n            <col style={{ width: 40 }} />\n            {columns.map((c) => (\n              <col key={c.key} style={c.width != null ? { width: c.width } : undefined} />\n            ))}\n          </colgroup>\n          <thead>\n            <tr>\n              <th scope=\"col\" className={cn(\"sticky top-0 z-(--z-sticky) border-b border-line bg-frame p-0\", size === \"sm\" ? \"h-8\" : \"h-9\")}>\n                {!single && expandable.length > 1 ? (\n                  <span className=\"flex justify-center\">\n                    <Toggle open={allOpen} onPress={toggleAll} label={allOpen ? \"Collapse all rows\" : \"Expand all rows\"} />\n                  </span>\n                ) : (\n                  <span className=\"sr-only\">Details</span>\n                )}\n              </th>\n              {columns.map((col) => (\n                <th\n                  key={col.key}\n                  scope=\"col\"\n                  className={cn(\n                    \"sticky top-0 z-(--z-sticky) whitespace-nowrap border-b border-line bg-frame px-3 text-[12px] font-medium text-fg-3\",\n                    size === \"sm\" ? \"h-8\" : \"h-9\",\n                    col.numeric ? \"text-right\" : \"text-left\",\n                  )}\n                >\n                  {col.header}\n                </th>\n              ))}\n            </tr>\n          </thead>\n          <tbody>\n            {rows.map((row) => {\n              const id = getRowId(row);\n              const detail = details.get(id);\n              const isOpen = open.has(id);\n              const panelId = `${baseId}-${id}`;\n              const hasPanel = isOpen || mounted.has(id);\n              return (\n                <Fragment key={id}>\n                  <tr\n                    data-state={isOpen ? \"open\" : \"closed\"}\n                    onClick={(e) => {\n                      if (!rowClickExpands || detail == null || (e.target as HTMLElement).closest(interactive)) return;\n                      if (window.getSelection()?.toString()) return;\n                      toggle(id);\n                      // Focus follows to the toggle, without a ring, so the arrow keys carry on from here.\n                      e.currentTarget.querySelector<HTMLElement>(\"[aria-expanded]\")?.focus({ preventScroll: true, focusVisible: false } as FocusOptions);\n                    }}\n                    className={cn(\n                      \"group/row transition-colors duration-150\",\n                      isOpen ? \"bg-hover\" : \"hover:bg-hover\",\n                    )}\n                  >\n                    <td className={cn(\"border-line p-0 text-center\", h, hasPanel ? \"border-b-0\" : \"border-b group-last/row:border-b-0\")}>\n                      {detail != null && (\n                        <span className=\"flex justify-center\">\n                          <Toggle\n                            open={isOpen}\n                            onPress={() => toggle(id)}\n                            onKey={(key) => (key === \"ArrowRight\" ? toggle(id, true) : toggle(id, false))}\n                            controls={panelId}\n                            label={`${isOpen ? \"Hide\" : \"Show\"} details for ${getRowLabel ? getRowLabel(row) : id}`}\n                          />\n                        </span>\n                      )}\n                    </td>\n                    {columns.map((col) => (\n                      <td\n                        key={col.key}\n                        className={cn(\n                          \"border-line px-3 text-fg\",\n                          h,\n                          hasPanel ? \"border-b-0\" : \"border-b group-last/row:border-b-0\",\n                          col.numeric ? \"whitespace-nowrap text-right tabular\" : \"text-left\",\n                          col.className,\n                        )}\n                      >\n                        {col.cell ? col.cell(row) : String((row as Record<string, unknown>)[col.key] ?? \"\")}\n                      </td>\n                    ))}\n                  </tr>\n                  <AnimatePresence initial={false} onExitComplete={() => unmount(id)}>\n                    {isOpen && detail != null && (\n                      <tr key=\"detail\" className=\"group/detail bg-hover\">\n                        <td colSpan={columns.length + 1} className=\"border-b border-line p-0 [tr:last-child>&]:border-b-0\">\n                          <motion.div\n                            id={panelId}\n                            role=\"region\"\n                            aria-label={`Details for ${getRowLabel ? getRowLabel(row) : id}`}\n                            initial={{ height: 0 }}\n                            animate={{ height: \"auto\" }}\n                            exit={{ height: 0, transition: { duration: reduce ? 0 : 0.2, ease: ease.inOut } }}\n                            transition={{ duration: reduce ? 0 : 0.28, ease: ease.inOut }}\n                            onAnimationComplete={() => {\n                              if (!reveal.current.delete(id)) return;\n                              const panel = document.getElementById(panelId);\n                              if (panel) bringIntoView(panel);\n                            }}\n                            className=\"overflow-hidden\"\n                          >\n                            {/* The content settles a beat behind the height, so it reads as revealed rather than squeezed. */}\n                            <motion.div\n                              initial={reduce ? { opacity: 0 } : { opacity: 0, y: -6 }}\n                              animate={{ opacity: 1, y: 0 }}\n                              exit={{ opacity: 0, transition: { duration: 0.12 } }}\n                              transition={{ duration: reduce ? 0.15 : 0.26, ease: ease.out, delay: reduce ? 0 : 0.06 }}\n                              style={{ maxWidth: visible }}\n                              className=\"sticky left-0 pb-3.5 pl-[52px] pr-4 pt-0.5\"\n                            >\n                              {detail}\n                            </motion.div>\n                          </motion.div>\n                        </td>\n                      </tr>\n                    )}\n                  </AnimatePresence>\n                </Fragment>\n              );\n            })}\n            {rows.length === 0 && (\n              <tr>\n                <td colSpan={columns.length + 1} className=\"h-32 px-4 text-center text-[13px] text-fg-3\">\n                  {empty}\n                </td>\n              </tr>\n            )}\n          </tbody>\n        </table>\n      </div>\n      {/* More columns past the edge: a soft shadow says so. */}\n      <div aria-hidden className={cn(\"pointer-events-none absolute inset-y-0 left-0 z-(--z-sticky) w-4 bg-linear-to-r from-overlay to-transparent transition-opacity duration-200\", edges.left ? \"opacity-50\" : \"opacity-0\")} />\n      <div aria-hidden className={cn(\"pointer-events-none absolute inset-y-0 right-0 z-(--z-sticky) w-4 bg-linear-to-l from-overlay to-transparent transition-opacity duration-200\", edges.right ? \"opacity-50\" : \"opacity-0\")} />\n    </div>\n  );\n}\n\nfunction Toggle({\n  open,\n  onPress,\n  onKey,\n  controls,\n  label,\n}: {\n  open: boolean;\n  onPress: () => void;\n  onKey?: (key: \"ArrowRight\" | \"ArrowLeft\") => void;\n  controls?: string;\n  label: string;\n}) {\n  return (\n    <button\n      type=\"button\"\n      aria-expanded={open}\n      aria-controls={open ? controls : undefined}\n      aria-label={label}\n      data-state={open ? \"open\" : \"closed\"}\n      onClick={onPress}\n      onKeyDown={(e) => {\n        // Right opens and Left closes, as in a tree; Enter and Space toggle as any button does.\n        if (onKey && (e.key === \"ArrowRight\" || e.key === \"ArrowLeft\")) {\n          e.preventDefault();\n          onKey(e.key);\n        }\n      }}\n      className={cn(\n        \"group/toggle relative grid size-6 place-items-center rounded-md text-fg-3 outline-none\",\n        \"transition-[background-color,color,scale] duration-150 hover:bg-fg/5 hover:text-fg active:scale-[0.9] data-[state=open]:text-fg\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n        \"after:absolute after:-inset-2.5 after:content-['']\",\n      )}\n    >\n      <svg\n        width=\"14\"\n        height=\"14\"\n        viewBox=\"0 0 16 16\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={1.5}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        aria-hidden\n        className=\"transition-transform duration-200 ease-out-expo group-data-[state=open]/toggle:rotate-90 motion-reduce:transition-none\"\n      >\n        <path d=\"m6.25 4.5 3.5 3.5-3.5 3.5\" />\n      </svg>\n    </button>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/expandable-rows"
}
