{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tree-table",
  "type": "registry:ui",
  "title": "Tree table",
  "description": "A table with nested rows, tri-state selection, hierarchy-preserving sort, and lazy-loaded children.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json",
    "https://mcp.xzero.co.uk/r/checkbox.json"
  ],
  "files": [
    {
      "path": "registry/components/tree-table/tree-table.tsx",
      "type": "registry:component",
      "target": "@components/xzero/tree-table/tree-table.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, ReactNode, Ref } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport type { Transition } from \"motion/react\";\nimport { ArrowUp, ChevronRight, ChevronsDownUp, ChevronsUpDown, LoaderCircle, RotateCw } from \"lucide-react\";\nimport { Checkbox } from \"../checkbox/checkbox\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./tree-table.module.css\";\n\nexport interface TreeTableRow<T> {\n  id: string;\n  data: T;\n  /** Nested rows. Leave undefined and set hasChildren to load them on first expand with loadChildren. */\n  children?: TreeTableRow<T>[];\n  /** Marks a row whose children are not loaded yet. */\n  hasChildren?: boolean;\n}\n\nexport interface TreeTableCellContext<T> {\n  row: TreeTableRow<T>;\n  /** One for top level rows. */\n  depth: number;\n  expanded: boolean;\n}\n\nexport interface TreeTableColumn<T> {\n  id: string;\n  header: string;\n  cell: (data: T, context: TreeTableCellContext<T>) => ReactNode;\n  /** Makes the column sortable. Siblings are sorted within their parent, so the hierarchy never breaks. */\n  sortValue?: (data: T) => string | number;\n  /** A grid track, such as \"120px\" or \"minmax(0, 2fr)\". Defaults to \"minmax(0, 1fr)\". */\n  width?: string;\n  align?: \"start\" | \"end\";\n  /** Hides the column while the table is narrower than this many px. */\n  hideBelow?: number;\n}\n\nexport interface TreeTableSort { columnId: string; direction: \"ascending\" | \"descending\" }\n\n/**\n * A table whose rows nest. Rows expand and collapse on a spring with indent guides, parents carry tri-state checkboxes,\n * sorting reorders siblings without breaking the hierarchy, and children can load on first expand. The first column\n * holds the tree. Keyboard follows the treegrid pattern: arrows move and expand, Space selects, Enter activates, * expands siblings.\n */\nexport interface TreeTableProps<T> {\n  rows: TreeTableRow<T>[];\n  columns: TreeTableColumn<T>[];\n  /** Accessible name of the table. */\n  label: string;\n  expanded?: string[];\n  defaultExpanded?: string[];\n  onExpandedChange?: (ids: string[]) => void;\n  /** Adds a checkbox column. Checking a parent checks everything under it. */\n  selectable?: boolean;\n  selected?: string[];\n  defaultSelected?: string[];\n  onSelectedChange?: (ids: string[]) => void;\n  sort?: TreeTableSort | null;\n  defaultSort?: TreeTableSort | null;\n  onSortChange?: (sort: TreeTableSort | null) => void;\n  /** Loads the children of a row with hasChildren the first time it expands. A rejected promise shows a retry row. */\n  loadChildren?: (row: TreeTableRow<T>) => Promise<TreeTableRow<T>[]>;\n  /** Names a row for its checkbox, such as the file or team name. */\n  rowLabel?: (data: T) => string;\n  /** Called when a row without children is clicked or gets Enter. Parent rows toggle instead. */\n  onRowActivate?: (row: TreeTableRow<T>) => void;\n  /** Caps the height; the header stays pinned while the body scrolls. */\n  maxHeight?: number | string;\n  emptyState?: ReactNode;\n  className?: string;\n  ref?: Ref<HTMLDivElement>;\n}\n\ntype Line =\n  | { kind: \"row\"; key: string; row: TreeTableRow<unknown>; depth: number; parentId: string | null; ancestors: string[]; position: number; size: number; expandable: boolean; expanded: boolean }\n  | { kind: \"loading\" | \"error\"; key: string; parentId: string; depth: number; ancestors: string[]; position: number; size: number };\n\nconst ROW = 44, INDENT = 20, CHEVRON = 24;\nconst { spring, duration, ease } = motionTokens;\nconst cx = (...names: (string | false | undefined)[]) => names.filter(Boolean).join(\" \");\n\nconst subscribe = () => () => {};\nfunction useReducedFlag() {\n  const hydrated = useSyncExternalStore(subscribe, () => true, () => false);\n  return !!useReducedMotion() && hydrated;\n}\n\nfunction useControllable<V>(value: V | undefined, initial: V, onChange?: (next: V) => void) {\n  const [inner, setInner] = useState(initial);\n  const current = value !== undefined ? value : inner;\n  const set = (next: V) => { if (value === undefined) setInner(next); onChange?.(next); };\n  return [current, set] as const;\n}\n\n/** Ids of every expandable row whose children are known, for expand all. */\nexport function collectExpandableIds<T>(rows: TreeTableRow<T>[], loaded?: Record<string, TreeTableRow<T>[]>): string[] {\n  return rows.flatMap(row => {\n    const kids = row.children ?? loaded?.[row.id];\n    return kids?.length ? [row.id, ...collectExpandableIds(kids, loaded)] : [];\n  });\n}\n\nexport function TreeTable<T>({\n  rows, columns, label, expanded: expandedProp, defaultExpanded = [], onExpandedChange, selectable = false,\n  selected: selectedProp, defaultSelected = [], onSelectedChange, sort: sortProp, defaultSort = null, onSortChange,\n  loadChildren, rowLabel, onRowActivate, maxHeight, emptyState, className, ref,\n}: TreeTableProps<T>) {\n  const uid = useId().replace(/[^a-zA-Z0-9-]/g, \"\");\n  const reduced = useReducedFlag();\n  const rootRef = useRef<HTMLDivElement | null>(null);\n  const rowRefs = useRef(new Map<string, HTMLDivElement>());\n  const [expandedList, setExpandedList] = useControllable(expandedProp, defaultExpanded, onExpandedChange);\n  const [selectedList, setSelectedList] = useControllable(selectedProp, defaultSelected, onSelectedChange);\n  const [sort, setSort] = useControllable<TreeTableSort | null>(sortProp, defaultSort, onSortChange);\n  const [loaded, setLoaded] = useState<Record<string, TreeTableRow<T>[]>>({});\n  const [status, setStatus] = useState<Record<string, \"loading\" | \"error\">>({});\n  const [arrived, setArrived] = useState<{ ids: string[]; at: string[] | null }>({ ids: [], at: null });\n  const [focusId, setFocusId] = useState<string | null>(null);\n  const [hoverId, setHoverId] = useState<string | null>(null);\n  const [width, setWidth] = useState<number | null>(null);\n  const [live, setLive] = useState(\"\");\n  const expanded = useMemo(() => new Set(expandedList), [expandedList]);\n  const selected = useMemo(() => new Set(selectedList), [selectedList]);\n\n  const kidsOf = (row: TreeTableRow<T>) => row.children ?? loaded[row.id];\n  const canExpand = (row: TreeTableRow<T>) => !!kidsOf(row)?.length || (!!row.hasChildren && !!loadChildren && !kidsOf(row));\n\n  useLayoutEffect(() => {\n    const node = rootRef.current;\n    if (!node) return;\n    const observer = new ResizeObserver(() => setWidth(node.offsetWidth));\n    observer.observe(node);\n    setWidth(node.offsetWidth);\n    return () => observer.disconnect();\n  }, []);\n\n  const shown = columns.filter(column => width === null || !column.hideBelow || width >= column.hideBelow);\n  const template = [selectable ? \"44px\" : \"\", ...shown.map(column => column.width ?? \"minmax(0, 1fr)\")].filter(Boolean).join(\" \");\n\n  // Siblings sort inside their parent, so a sorted tree still reads top down.\n  const sortColumn = sort ? columns.find(column => column.id === sort.columnId && column.sortValue) : undefined;\n  const order = (list: TreeTableRow<T>[]) => {\n    if (!sortColumn?.sortValue || !sort) return list;\n    const value = sortColumn.sortValue, sign = sort.direction === \"ascending\" ? 1 : -1;\n    return [...list].sort((a, b) => {\n      const x = value(a.data), y = value(b.data);\n      return sign * (typeof x === \"number\" && typeof y === \"number\" ? x - y : String(x).localeCompare(String(y), \"en-US\", { numeric: true }));\n    });\n  };\n\n  // Every row in view, plus a loading or retry line under a parent that is still fetching.\n  const lines: Line[] = [];\n  const walk = (list: TreeTableRow<T>[], depth: number, parentId: string | null, ancestors: string[]) => {\n    const sorted = order(list);\n    sorted.forEach((row, index) => {\n      const open = expanded.has(row.id);\n      const expandable = canExpand(row);\n      lines.push({ kind: \"row\", key: row.id, row: row as TreeTableRow<unknown>, depth, parentId, ancestors, position: index + 1, size: sorted.length, expandable, expanded: open && expandable });\n      if (!open || !expandable) return;\n      const kids = kidsOf(row);\n      const next = [...ancestors, row.id];\n      if (kids) walk(kids, depth + 1, row.id, next);\n      else lines.push({ kind: status[row.id] === \"error\" ? \"error\" : \"loading\", key: `${row.id}:pending`, parentId: row.id, depth: depth + 1, ancestors: next, position: 1, size: 1 });\n    });\n  };\n  walk(rows, 1, null, []);\n  const rowLines = lines.filter((line): line is Extract<Line, { kind: \"row\" }> => line.kind === \"row\");\n  const byId = new Map(rowLines.map(line => [line.key, line]));\n\n  // When children arrive, the first one takes over the loading row's slot instead of both squeezing past each other.\n  // The handoff only holds until the expanded set changes, so a later collapse and expand grows rows as usual.\n  const handoff = new Set(arrived.at === expandedList ? arrived.ids.filter(id => byId.get(id)?.expanded) : []);\n\n  // Tri-state: a parent is checked when all its known children are, and mixed when only some are.\n  const index = useMemo(() => {\n    const parent = new Map<string, string | null>(), children = new Map<string, string[]>(), all: string[] = [];\n    const visit = (list: TreeTableRow<T>[], parentId: string | null) => list.forEach(row => {\n      parent.set(row.id, parentId); all.push(row.id);\n      const kids = row.children ?? loaded[row.id];\n      if (kids?.length) { children.set(row.id, kids.map(kid => kid.id)); visit(kids, row.id); }\n    });\n    visit(rows, null);\n    return { parent, children, all };\n  }, [loaded, rows]);\n  const mixed = useMemo(() => {\n    const some = new Set<string>();\n    const visit = (id: string): boolean => {\n      const kids = index.children.get(id);\n      let any = selected.has(id);\n      kids?.forEach(kid => { if (visit(kid)) any = true; });\n      if (any && !selected.has(id)) some.add(id);\n      return any;\n    };\n    rows.forEach(row => visit(row.id));\n    return some;\n  }, [index, rows, selected]);\n\n  function normalize(next: Set<string>) {\n    const settle = (id: string): boolean => {\n      const kids = index.children.get(id);\n      if (!kids) return next.has(id);\n      const states = kids.map(settle);\n      if (states.every(Boolean)) next.add(id); else next.delete(id);\n      return next.has(id);\n    };\n    rows.forEach(row => settle(row.id));\n    return next;\n  }\n  function descendants(id: string): string[] { return (index.children.get(id) ?? []).flatMap(kid => [kid, ...descendants(kid)]); }\n  function toggleSelect(id: string, on = !selected.has(id)) {\n    const next = new Set(selected);\n    [id, ...descendants(id)].forEach(member => on ? next.add(member) : next.delete(member));\n    setSelectedList([...normalize(next)]);\n  }\n  const allOn = rows.length > 0 && rows.every(row => selected.has(row.id));\n  const someOn = !allOn && index.all.some(id => selected.has(id));\n  function toggleAll() {\n    setSelectedList(allOn ? [] : [...normalize(new Set(index.all))]);\n    setLive(allOn ? \"Selection cleared\" : \"All rows selected\");\n  }\n\n  function fetchChildren(row: TreeTableRow<T>) {\n    if (!loadChildren || status[row.id] === \"loading\") return;\n    setStatus(current => ({ ...current, [row.id]: \"loading\" }));\n    setLive(\"Loading\");\n    loadChildren(row).then(kids => {\n      setLoaded(current => ({ ...current, [row.id]: kids }));\n      const at = expandedRef.current;\n      setArrived(current => ({ ids: current.at === at ? [...current.ids, row.id] : [row.id], at }));\n      setStatus(current => { const next = { ...current }; delete next[row.id]; return next; });\n      setLive(`${kids.length} ${kids.length === 1 ? \"row\" : \"rows\"} loaded`);\n      // Children that arrive under a checked parent start checked.\n      if (selectedRef.current.has(row.id)) {\n        const add = (list: TreeTableRow<T>[]): string[] => list.flatMap(kid => [kid.id, ...(kid.children ? add(kid.children) : [])]);\n        selectRef.current([...new Set([...selectedRef.current, ...add(kids)])]);\n      }\n    }, () => {\n      setStatus(current => ({ ...current, [row.id]: \"error\" }));\n      setLive(\"Couldn't load rows\");\n    });\n  }\n  const selectedRef = useRef(selected);\n  const selectRef = useRef(setSelectedList);\n  const fetchRef = useRef(fetchChildren);\n  const byIdRef = useRef(byId);\n  const expandedRef = useRef(expandedList);\n  useEffect(() => { expandedRef.current = expandedList; selectedRef.current = selected; selectRef.current = setSelectedList; fetchRef.current = fetchChildren; byIdRef.current = byId; });\n\n  // Rows opened from outside, such as a controlled expand all, still fetch their children.\n  const waiting = lines.filter(line => line.kind === \"loading\" && !status[line.parentId]).map(line => line.parentId).join(\" \");\n  useEffect(() => {\n    if (!waiting) return;\n    waiting.split(\" \").forEach(id => { const line = byIdRef.current.get(id); if (line) fetchRef.current(line.row as TreeTableRow<T>); });\n  }, [waiting]);\n\n  function setOpen(row: TreeTableRow<T>, open: boolean) {\n    if (open === expanded.has(row.id)) return;\n    const next = new Set(expanded);\n    if (open) next.add(row.id); else next.delete(row.id);\n    setExpandedList([...next]);\n    if (open && !kidsOf(row) && row.hasChildren) fetchChildren(row);\n  }\n\n  const expandable = collectExpandableIds(rows, loaded);\n  const everyOpen = expandable.length > 0 && expandable.every(id => expanded.has(id));\n  function toggleEverything() {\n    setExpandedList(everyOpen ? [] : [...new Set([...expandedList, ...expandable])]);\n    setLive(everyOpen ? \"Collapsed all rows\" : \"Expanded all rows\");\n  }\n\n  function cycleSort(column: TreeTableColumn<T>) {\n    const next: TreeTableSort | null = sort?.columnId !== column.id ? { columnId: column.id, direction: \"ascending\" }\n      : sort.direction === \"ascending\" ? { columnId: column.id, direction: \"descending\" } : null;\n    setSort(next);\n    setLive(next ? `Sorted by ${column.header.toLowerCase()}, ${next.direction}` : \"Sort cleared\");\n  }\n\n  // Keep a real row focusable, and pull focus up to the nearest ancestor when its row collapses away.\n  const current = focusId && byId.has(focusId) ? focusId : null;\n  const fallback = focusId && !current ? (() => {\n    let id: string | null | undefined = focusId;\n    while (id && !byId.has(id)) id = index.parent.get(id);\n    return id ?? rowLines[0]?.key ?? null;\n  })() : null;\n  const tabId = current ?? fallback ?? rowLines[0]?.key ?? null;\n  useEffect(() => {\n    if (!fallback || !rootRef.current?.contains(document.activeElement)) return;\n    setFocusId(fallback);\n    rowRefs.current.get(fallback)?.focus({ preventScroll: true });\n  }, [fallback]);\n\n  function focusRow(id: string | undefined) {\n    if (!id) return;\n    setFocusId(id);\n    const node = rowRefs.current.get(id);\n    node?.focus({ preventScroll: true });\n    node?.scrollIntoView({ block: \"nearest\" });\n  }\n\n  function onRowKey(event: ReactKeyboardEvent<HTMLDivElement>, line: Extract<Line, { kind: \"row\" }>) {\n    if (event.target !== event.currentTarget) return;\n    const at = rowLines.indexOf(line);\n    const row = line.row as TreeTableRow<T>;\n    const go = (to: number) => { event.preventDefault(); focusRow(rowLines[Math.max(0, Math.min(rowLines.length - 1, to))]?.key); };\n    switch (event.key) {\n      case \"ArrowDown\": return go(at + 1);\n      case \"ArrowUp\": return go(at - 1);\n      case \"Home\": return go(0);\n      case \"End\": return go(rowLines.length - 1);\n      case \"PageDown\": return go(at + 10);\n      case \"PageUp\": return go(at - 10);\n      case \"ArrowRight\":\n        event.preventDefault();\n        if (line.expandable && !line.expanded) setOpen(row, true);\n        else if (line.expanded) focusRow(rowLines[at + 1]?.parentId === line.key ? rowLines[at + 1]!.key : undefined);\n        return;\n      case \"ArrowLeft\":\n        event.preventDefault();\n        if (line.expanded) setOpen(row, false);\n        else if (line.parentId) focusRow(line.parentId);\n        return;\n      case \"*\": {\n        event.preventDefault();\n        const siblings = rowLines.filter(other => other.parentId === line.parentId && other.expandable).map(other => other.row as TreeTableRow<T>);\n        setExpandedList([...new Set([...expandedList, ...siblings.map(sibling => sibling.id)])]);\n        siblings.forEach(sibling => { if (!kidsOf(sibling) && sibling.hasChildren) fetchChildren(sibling); });\n        return;\n      }\n      case \" \":\n        if (!selectable) return;\n        event.preventDefault();\n        toggleSelect(line.key);\n        return;\n      case \"Enter\":\n        event.preventDefault();\n        if (status[line.key] === \"error\") fetchChildren(row);\n        else if (line.expandable) setOpen(row, !line.expanded);\n        else onRowActivate?.(row);\n        return;\n      case \"a\":\n        if (selectable && (event.metaKey || event.ctrlKey)) { event.preventDefault(); toggleAll(); }\n        return;\n    }\n  }\n\n  // The branch around the row under the pointer or focus lights its guide, so a deep row shows which parent it belongs to.\n  const active = hoverId ?? current;\n  const activeBranch = active ? (byId.get(active)?.expanded ? active : byId.get(active)?.parentId) : null;\n  const sortKey = sort ? `${sort.columnId}:${sort.direction}` : \"none\";\n  const height: Transition = reduced ? { duration: 0 } : spring.smooth;\n  const fade = (delay: number): Transition => reduced ? { duration: duration.instant } : { ...height, opacity: { duration: duration.standard, ease: [...ease.enter], delay } };\n  const exit = reduced ? { opacity: 0, transition: { duration: duration.instant } } : { height: 0, opacity: 0, transition: { height, opacity: { duration: duration.fast, ease: [...ease.standard] } } };\n  // A loading row whose children just arrived leaves at once; its first child already fills the slot.\n  const pendingVariants = (parentId: string) => ({\n    leave: (taken?: Set<string>) => taken?.has(parentId) ? { height: 0, opacity: 0, transition: { duration: 0 } } : exit,\n  });\n  const style = { \"--tt-columns\": template, \"--tt-row\": `${ROW}px`, \"--tt-indent\": `${INDENT}px`, \"--tt-chevron\": `${CHEVRON}px`, \"--tt-max\": maxHeight === undefined ? \"none\" : typeof maxHeight === \"number\" ? `${maxHeight}px` : maxHeight } as CSSProperties;\n  const [treeColumn, ...restColumns] = shown;\n\n  const guides = (ancestors: string[], depth: number) => ancestors.slice(0, depth - 1).map((id, level) =>\n    <span key={id} className={styles.guide} data-active={id === activeBranch || undefined} style={{ left: level * INDENT + CHEVRON / 2 }} aria-hidden=\"true\" />);\n\n  return <div ref={node => { rootRef.current = node; if (typeof ref === \"function\") ref(node); else if (ref) ref.current = node; }}\n    className={cx(styles.root, className)} style={style}>\n    <div className={styles.scroller}>\n      <div role=\"treegrid\" aria-label={label} aria-multiselectable={selectable || undefined} aria-rowcount={rowLines.length + 1} className={styles.grid}>\n        <div role=\"row\" className={styles.header}>\n          {selectable && <div role=\"columnheader\" className={styles.checkCell}>\n            <Checkbox aria-label={allOn ? \"Clear selection\" : \"Select all rows\"} tabIndex={-1} checked={allOn ? true : someOn ? \"indeterminate\" : false} onCheckedChange={toggleAll} />\n          </div>}\n          {shown.map(column => {\n            const sorted = sort?.columnId === column.id ? sort.direction : undefined;\n            return <div key={column.id} role=\"columnheader\" aria-sort={column.sortValue ? sorted ?? \"none\" : undefined} className={styles.headerCell} data-align={column.align} data-tree={column === treeColumn || undefined}>\n              {column === treeColumn && expandable.length === 0 && <span className={styles.leaf} aria-hidden=\"true\" />}\n              {column === treeColumn && expandable.length > 0 && <button type=\"button\" className={styles.expandAll} onClick={toggleEverything} aria-label={everyOpen ? \"Collapse all\" : \"Expand all\"} title={everyOpen ? \"Collapse all\" : \"Expand all\"}>\n                <AnimatePresence mode=\"popLayout\" initial={false}>\n                  <motion.span key={everyOpen ? \"in\" : \"out\"} className={styles.glyph} initial={reduced ? { opacity: 0 } : { opacity: 0, scale: .7, rotate: everyOpen ? -90 : 90 }} animate={{ opacity: 1, scale: 1, rotate: 0 }}\n                    exit={reduced ? { opacity: 0 } : { opacity: 0, scale: .7, rotate: everyOpen ? 90 : -90 }} transition={reduced ? { duration: duration.instant } : spring.snappy}>\n                    {everyOpen ? <ChevronsDownUp size={16} strokeWidth={1.75} aria-hidden=\"true\" /> : <ChevronsUpDown size={16} strokeWidth={1.75} aria-hidden=\"true\" />}\n                  </motion.span>\n                </AnimatePresence>\n              </button>}\n              {column.sortValue ? <button type=\"button\" className={styles.sort} data-sorted={sorted ? \"\" : undefined} onClick={() => cycleSort(column)}>\n                <span className={styles.headerLabel}>{column.header}</span>\n                <motion.span className={styles.sortIcon} initial={false} aria-hidden=\"true\"\n                  animate={{ opacity: sorted ? 1 : 0, rotate: sorted === \"descending\" ? 180 : 0, y: sorted || reduced ? 0 : 2 }}\n                  transition={reduced ? { duration: 0 } : spring.snappy}><ArrowUp size={14} strokeWidth={1.75} /></motion.span>\n              </button> : <span className={styles.headerLabel}>{column.header}</span>}\n            </div>;\n          })}\n        </div>\n\n        {rowLines.length === 0 && <div role=\"row\" className={styles.empty}><div role=\"gridcell\">{emptyState ?? \"Nothing to show\"}</div></div>}\n\n        <AnimatePresence initial={false} custom={handoff}>\n          {lines.map(line => {\n            const takesOver = line.kind === \"row\" && line.position === 1 && !!line.parentId && handoff.has(line.parentId);\n            const enter = reduced ? { opacity: 0 } : { height: takesOver ? ROW : 0, opacity: 0 };\n            const delay = reduced ? 0 : Math.min(line.position - 1, 8) * .018;\n            if (line.kind !== \"row\") {\n              const parent = byId.get(line.parentId);\n              return <motion.div key={line.key} role=\"row\" aria-level={line.depth} className={styles.row} data-pending={line.kind}\n                initial={enter} animate={{ height: ROW, opacity: 1 }} variants={pendingVariants(line.parentId)} exit=\"leave\" transition={fade(0)}>\n                {selectable && <div role=\"gridcell\" className={styles.checkCell} />}\n                <div role=\"gridcell\" className={styles.treeCell} style={{ paddingLeft: (line.depth - 1) * INDENT }}>\n                  {guides(line.ancestors, line.depth)}\n                  {line.kind === \"loading\" ? <span className={styles.pending}><LoaderCircle className={styles.spin} size={16} strokeWidth={1.75} aria-hidden=\"true\" />Loading</span>\n                    : <span className={styles.pending} data-tone=\"danger\">Couldn&rsquo;t load\n                      <button type=\"button\" className={styles.retry} onClick={() => parent && fetchChildren(parent.row as TreeTableRow<T>)}><RotateCw size={14} strokeWidth={1.75} aria-hidden=\"true\" />Retry</button>\n                    </span>}\n                </div>\n                {restColumns.map(column => <div key={column.id} role=\"gridcell\" className={styles.cell} data-align={column.align}>\n                  {line.kind === \"loading\" && <span className={styles.skeleton} aria-hidden=\"true\" />}\n                </div>)}\n              </motion.div>;\n            }\n            const row = line.row as TreeTableRow<T>;\n            const context: TreeTableCellContext<T> = { row, depth: line.depth, expanded: line.expanded };\n            const checked = selected.has(line.key) ? true : mixed.has(line.key) ? \"indeterminate\" as const : false;\n            const loading = status[line.key] === \"loading\";\n            return <motion.div key={line.key} ref={node => { if (node) rowRefs.current.set(line.key, node); else rowRefs.current.delete(line.key); }}\n              role=\"row\" aria-level={line.depth} aria-posinset={line.position} aria-setsize={line.size} aria-expanded={line.expandable ? line.expanded : undefined}\n              aria-selected={selectable ? checked === true : undefined} aria-busy={loading || undefined}\n              tabIndex={tabId === line.key ? 0 : -1} className={styles.row} data-selected={checked === true || undefined} data-expandable={line.expandable || undefined}\n              initial={enter} animate={{ height: ROW, opacity: 1 }} exit={exit} transition={fade(delay)}\n              layout=\"position\" layoutDependency={sortKey}\n              onFocus={event => { if (event.target === event.currentTarget) setFocusId(line.key); }}\n              onPointerEnter={() => setHoverId(line.key)} onPointerLeave={() => setHoverId(id => id === line.key ? null : id)}\n              onClick={() => { setFocusId(line.key); if (line.expandable) setOpen(row, !line.expanded); else onRowActivate?.(row); }}\n              onKeyDown={event => onRowKey(event, line)}>\n              {selectable && <div role=\"gridcell\" className={styles.checkCell} onClick={event => event.stopPropagation()}>\n                <Checkbox aria-label={rowLabel ? `Select ${rowLabel(row.data)}` : `Select row ${line.position} of ${line.size}`} tabIndex={-1} checked={checked} onCheckedChange={() => toggleSelect(line.key)} />\n              </div>}\n              {treeColumn && <div role=\"gridcell\" className={styles.treeCell} style={{ paddingLeft: (line.depth - 1) * INDENT }} data-align={treeColumn.align}>\n                {guides(line.ancestors, line.depth)}\n                {line.expandable ? <button type=\"button\" tabIndex={-1} aria-hidden=\"true\" className={styles.chevron}\n                  onClick={event => { event.stopPropagation(); setFocusId(line.key); setOpen(row, !line.expanded); }}>\n                  {loading ? <LoaderCircle className={styles.spin} size={16} strokeWidth={1.75} />\n                    : <motion.span className={styles.glyph} initial={false} animate={{ rotate: line.expanded ? 90 : 0 }} transition={reduced ? { duration: 0 } : spring.snappy}>\n                      <ChevronRight size={16} strokeWidth={1.75} />\n                    </motion.span>}\n                </button> : <span className={styles.leaf} aria-hidden=\"true\" />}\n                <span className={styles.treeContent}>{treeColumn.cell(row.data, context)}</span>\n              </div>}\n              {restColumns.map(column => <div key={column.id} role=\"gridcell\" className={styles.cell} data-align={column.align}>{column.cell(row.data, context)}</div>)}\n            </motion.div>;\n          })}\n        </AnimatePresence>\n      </div>\n    </div>\n    <p id={`${uid}-live`} className={styles.srOnly} aria-live=\"polite\">{live}</p>\n  </div>;\n}\n\nexport default TreeTable;\n"
    },
    {
      "path": "registry/components/tree-table/tree-table.module.css",
      "type": "registry:component",
      "target": "@components/xzero/tree-table/tree-table.module.css",
      "content": "/* One bordered surface. The header pins while the body scrolls; every row shares one grid template so columns line up through the tree. */\n.root {\n  position: relative;\n  box-sizing: border-box;\n  width: 100%;\n  min-width: 0;\n  overflow: hidden;\n  border: 1px solid var(--border);\n  border-radius: var(--radius-panel);\n  background: var(--surface);\n  color: var(--foreground);\n  font-family: var(--font-body);\n  font-size: var(--text-sm);\n  letter-spacing: var(--tracking-body);\n  line-height: var(--leading-body);\n}\n.root *, .root *::before, .root *::after { box-sizing: border-box; }\n.scroller { max-height: var(--tt-max); overflow: auto; overscroll-behavior: contain; scrollbar-width: thin; }\n.grid { display: flex; min-width: 0; flex-direction: column; padding-bottom: 6px; }\n\n.header {\n  position: sticky;\n  z-index: 2;\n  top: 0;\n  display: grid;\n  height: 40px;\n  grid-template-columns: var(--tt-columns);\n  align-items: center;\n  border-bottom: 1px solid var(--border);\n  background: var(--surface);\n  color: var(--text-muted);\n  font-size: var(--text-xs);\n}\n.headerCell { display: flex; min-width: 0; height: 100%; align-items: center; gap: 2px; padding: 0 12px; }\n.headerCell[data-tree] { padding-left: 8px; }\n/* The header label lines up with the row names: chevron column, then the same 4px gap the rows use. */\n.headerCell[data-tree] .leaf { margin-right: 4px; }\n.headerCell[data-align=\"end\"] { justify-content: flex-end; }\n.headerLabel { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.sort {\n  display: inline-flex; min-width: 0; height: 28px; align-items: center; gap: 4px; margin: 0 -8px; padding: 0 8px; border: 0; border-radius: 10px;\n  background: transparent; color: inherit; font: inherit; cursor: pointer; -webkit-tap-highlight-color: transparent;\n  transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard);\n}\n.headerCell[data-align=\"end\"] .sort { flex-direction: row-reverse; }\n.sort[data-sorted] { color: var(--foreground); }\n.sortIcon { display: grid; flex: none; place-items: center; }\n.expandAll {\n  display: grid; flex: none; width: 28px; height: 28px; place-items: center; margin: 0; padding: 0; border: 0; border-radius: 10px;\n  background: transparent; color: var(--text-muted); cursor: pointer; -webkit-tap-highlight-color: transparent;\n  transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), transform var(--duration-fast) var(--ease-standard);\n}\n.expandAll:active { transform: scale(.94); }\n.glyph { display: grid; place-items: center; }\n\n/* Rows grow from zero height, so cells sit at the top with a fixed height and never re-center while a row opens. */\n.row {\n  position: relative;\n  display: grid;\n  grid-template-columns: var(--tt-columns);\n  align-items: start;\n  overflow: hidden;\n  scroll-margin-top: 40px;\n  box-shadow: inset 0 -1px 0 var(--border-subtle);\n  cursor: default;\n  transition: background-color var(--duration-fast) var(--ease-standard);\n}\n.row[data-expandable] { cursor: pointer; }\n.row:focus-visible { background: var(--surface-muted); }\n.row[data-selected] { background: var(--accent-subtle); }\n.row[data-selected]:focus-visible { background: color-mix(in oklab, var(--accent-subtle), var(--foreground) 5%); }\n\n.checkCell { display: flex; height: var(--tt-row); align-items: center; justify-content: center; padding-left: 6px; }\n.cell { display: flex; min-width: 0; height: var(--tt-row); align-items: center; padding: 0 12px; color: var(--text-secondary); white-space: nowrap; }\n.cell[data-align=\"end\"] { justify-content: flex-end; font-variant-numeric: tabular-nums; }\n.treeCell { position: relative; display: flex; min-width: 0; height: var(--tt-row); align-items: center; gap: 4px; margin-left: 10px; padding-right: 12px; }\n.treeContent { display: flex; min-width: 0; flex: 1; align-items: center; gap: 8px; overflow: hidden; white-space: nowrap; }\n\n/* Guides run through each ancestor's chevron column; the branch you are in darkens. */\n.guide { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--border); transition: background-color var(--duration-standard) var(--ease-standard); }\n.guide[data-active] { background: var(--border-strong); }\n\n.chevron {\n  display: grid; flex: none; width: var(--tt-chevron); height: var(--tt-chevron); place-items: center; padding: 0; border: 0; border-radius: 8px;\n  background: transparent; color: var(--text-muted); cursor: pointer; -webkit-tap-highlight-color: transparent;\n  transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard);\n}\n.row[aria-expanded=\"true\"] > .treeCell .chevron { color: var(--foreground); }\n.leaf { flex: none; width: var(--tt-chevron); }\n\n.pending { display: inline-flex; min-width: 0; align-items: center; gap: 8px; padding-left: calc(var(--tt-chevron) + 4px); color: var(--text-muted); white-space: nowrap; }\n.pending[data-tone=\"danger\"] { color: var(--danger); }\n.retry {\n  display: inline-flex; height: 28px; align-items: center; gap: 6px; padding: 0 10px; border: 0; border-radius: var(--radius-pill);\n  background: var(--surface-muted); color: var(--foreground); font: inherit; font-size: var(--text-xs); font-weight: 500; cursor: pointer;\n  transition: transform var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard);\n}\n.retry:active { transform: scale(.97); }\n.skeleton { width: min(72px, 80%); height: 10px; border-radius: var(--radius-pill); background: var(--surface-muted); animation: breathe 1.2s var(--ease-in-out) 4 alternate; }\n.row[data-pending] .cell[data-align=\"end\"] { justify-content: flex-end; }\n.spin { animation: spin .8s linear infinite; }\n@keyframes spin { to { transform: rotate(360deg); } }\n@keyframes breathe { to { opacity: .45; } }\n\n.empty { display: grid; place-items: center; padding: 32px 16px; color: var(--text-muted); }\n.srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }\n\n@media (hover: hover) and (pointer: fine) {\n  .row:not([data-pending]):hover { background: var(--surface-muted); }\n  .row[data-selected]:hover { background: color-mix(in oklab, var(--accent-subtle), var(--foreground) 4%); }\n  .sort:hover, .expandAll:hover { background: var(--surface-muted); color: var(--foreground); }\n  .chevron:hover { background: color-mix(in oklab, var(--surface-muted), var(--foreground) 6%); color: var(--foreground); }\n  .retry:hover { background: color-mix(in oklab, var(--surface-muted), var(--foreground) 6%); }\n}\n@media (prefers-contrast: more) { .guide { background: var(--border-strong); } .row { box-shadow: inset 0 -1px 0 var(--border); } }\n@media (prefers-reduced-motion: reduce) {\n  .row, .guide, .chevron, .sort, .expandAll, .retry { transition: none; }\n  .expandAll:active, .retry:active { transform: none; }\n  .spin { animation-duration: 1.6s; }\n  .skeleton { animation: none; }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/tree-table",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "data",
      "new"
    ],
    "docs": "https://ui.xzero.co.uk/components/tree-table",
    "markdown": "https://ui.xzero.co.uk/components/tree-table/markdown"
  }
}