{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "tree-table",
  "title": "Tree Table",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-svelte/components/tree-table/TreeTable.svelte",
      "content": "<script lang=\"ts\" module>\n  import type { HTMLAttributes } from 'svelte/elements'\n  import type { Snippet } from 'svelte'\n  import type { TreeTableColumn, TreeTableRow } from './types'\n\n  // Omit the DOM event prop shadowed by the component callback (ids payload, not an event).\n  export interface TreeTableProps<T extends TreeTableRow = TreeTableRow>\n    extends Omit<HTMLAttributes<HTMLDivElement>, 'onselect'> {\n    /** Tree-structured row data. */\n    data: TreeTableRow<T>[]\n    /** Column configuration. */\n    columns: TreeTableColumn<T>[]\n    /** Indent per nesting level in pixels. Default 24. */\n    indent?: number\n    /** Expand all rows on mount. Default false. */\n    defaultExpanded?: boolean\n    /** Show row selection checkboxes. Default false. */\n    selectable?: boolean\n    /** Loading state — shows a spinner overlay. Default false. */\n    loading?: boolean\n    /** Empty state message. Default 'No data.'. */\n    emptyText?: string\n    /** Controlled selected row ids (bind:selected). */\n    selected?: string[]\n    /** Replaces the default expand chevron. */\n    expandIcon?: Snippet<[{ expanded: boolean }]>\n    /** Replaces default cell text. Branch on `column.key` for per-column cells. */\n    cell?: Snippet<[{ row: TreeTableRow<T>; column: TreeTableColumn<T>; depth: number }]>\n    onSelectedChange?: (ids: string[]) => void\n    onselect?: (ids: string[]) => void\n    onexpand?: (id: string, expanded: boolean) => void\n    ref?: HTMLDivElement | null\n  }\n</script>\n\n<script lang=\"ts\" generics=\"T extends TreeTableRow\">\n  import { onMount, untrack } from 'svelte'\n  import { ChevronRight, FileBox } from '@lucide/svelte'\n  import { cn } from '$lib/utils'\n  import { Checkbox } from '$lib/components/ui/checkbox'\n  import { Spinner } from '$lib/components/ui/spinner'\n  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '$lib/components/ui/table'\n\n  let {\n    data,\n    columns,\n    indent = 24,\n    defaultExpanded = false,\n    selectable = false,\n    loading = false,\n    emptyText = 'No data.',\n    selected = $bindable(),\n    expandIcon,\n    cell,\n    onSelectedChange,\n    onselect,\n    onexpand,\n    class: className,\n    ref = $bindable(null),\n    ...restProps\n  }: TreeTableProps<T> = $props()\n\n  function collectExpandedIds(rows: TreeTableRow<T>[]): Set<string> {\n    const s = new Set<string>()\n    const walk = (items: TreeTableRow<T>[]) => {\n      for (const row of items) {\n        if (row.children?.length) {\n          s.add(row.id)\n          walk(row.children as TreeTableRow<T>[])\n        }\n      }\n    }\n    walk(rows)\n    return s\n  }\n\n  let expanded = $state<Set<string>>(untrack(() => (defaultExpanded ? collectExpandedIds(data) : new Set())))\n\n  const selectedSet = $derived(new Set(selected ?? []))\n\n  interface FlatRow {\n    row: TreeTableRow<T>\n    depth: number\n    hasChildren: boolean\n    parentId: string | null\n  }\n\n  const flatRows = $derived.by((): FlatRow[] => {\n    const out: FlatRow[] = []\n    const walk = (rows: TreeTableRow<T>[], depth: number, parentId: string | null) => {\n      for (const row of rows) {\n        const hasChildren = !!row.children?.length\n        out.push({ row, depth, hasChildren, parentId })\n        if (hasChildren && expanded.has(row.id)) {\n          walk(row.children as TreeTableRow<T>[], depth + 1, row.id)\n        }\n      }\n    }\n    walk(data, 0, null)\n    return out\n  })\n\n  function toggleExpand(row: TreeTableRow<T>) {\n    const next = new Set(expanded)\n    if (next.has(row.id)) next.delete(row.id)\n    else next.add(row.id)\n    expanded = next\n    onexpand?.(row.id, next.has(row.id))\n  }\n\n  function toggleSelect(row: TreeTableRow<T>) {\n    const next = new Set(selectedSet)\n    if (next.has(row.id)) next.delete(row.id)\n    else next.add(row.id)\n    const ids = [...next]\n    selected = ids\n    onSelectedChange?.(ids)\n    onselect?.(ids)\n  }\n\n  function isSelected(id: string): boolean {\n    return selectedSet.has(id)\n  }\n\n  function isExpanded(id: string): boolean {\n    return expanded.has(id)\n  }\n\n  function getTreeRows(from: HTMLElement): HTMLElement[] {\n    const root = from.closest('[data-slot=\"tree-table\"]')\n    if (!root) return []\n    return Array.from(root.querySelectorAll<HTMLElement>('[data-tree-row]'))\n  }\n\n  function focusRow(row: HTMLElement | null | undefined) {\n    row?.focus()\n  }\n\n  function onRowKeydown(e: KeyboardEvent, fr: FlatRow) {\n    const target = e.currentTarget as HTMLElement\n\n    if (e.key === 'Enter' || e.key === ' ') {\n      if (selectable) {\n        e.preventDefault()\n        toggleSelect(fr.row)\n      } else if (fr.hasChildren) {\n        e.preventDefault()\n        toggleExpand(fr.row)\n      }\n      return\n    }\n\n    if (e.key === 'ArrowRight') {\n      e.preventDefault()\n      if (fr.hasChildren && !isExpanded(fr.row.id)) {\n        toggleExpand(fr.row)\n      } else if (fr.hasChildren && isExpanded(fr.row.id)) {\n        const rows = getTreeRows(target)\n        const idx = rows.indexOf(target)\n        if (idx >= 0 && idx < rows.length - 1) focusRow(rows[idx + 1])\n      }\n      return\n    }\n\n    if (e.key === 'ArrowLeft') {\n      e.preventDefault()\n      if (fr.hasChildren && isExpanded(fr.row.id)) {\n        toggleExpand(fr.row)\n      } else if (fr.parentId) {\n        const root = target.closest('[data-slot=\"tree-table\"]')\n        const parent = root?.querySelector<HTMLElement>(\n          `[data-tree-row][data-tree-id=\"${CSS.escape(fr.parentId)}\"]`,\n        )\n        focusRow(parent)\n      }\n      return\n    }\n\n    if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {\n      e.preventDefault()\n      const rows = getTreeRows(target)\n      const idx = rows.indexOf(target)\n      if (idx < 0) return\n      focusRow(e.key === 'ArrowDown' ? rows[idx + 1] : rows[idx - 1])\n      return\n    }\n\n    if (e.key === 'Home') {\n      e.preventDefault()\n      focusRow(getTreeRows(target)[0])\n      return\n    }\n\n    if (e.key === 'End') {\n      e.preventDefault()\n      const rows = getTreeRows(target)\n      focusRow(rows[rows.length - 1])\n    }\n  }\n\n  function onRowClick(e: MouseEvent, fr: FlatRow) {\n    if ((e.target as HTMLElement).closest('button, input, a')) return\n    if (selectable) {\n      toggleSelect(fr.row)\n    } else if (fr.hasChildren) {\n      toggleExpand(fr.row)\n    }\n  }\n\n  function expandAll() {\n    const next = new Set<string>()\n    const walk = (rows: TreeTableRow<T>[]) => {\n      for (const row of rows) {\n        if (row.children?.length) {\n          next.add(row.id)\n          walk(row.children as TreeTableRow<T>[])\n        }\n      }\n    }\n    walk(data)\n    expanded = next\n  }\n\n  onMount(() => {\n    if (defaultExpanded) expandAll()\n  })\n\n  // Reset expanded state when data identity changes.\n  let prevData = untrack(() => data)\n  $effect(() => {\n    if (data !== prevData) {\n      prevData = data\n      if (defaultExpanded) expandAll()\n    }\n  })\n\n  const isEmpty = $derived(flatRows.length === 0)\n</script>\n\n<div\n  bind:this={ref}\n  data-uipkge\n  data-slot=\"tree-table\"\n  role=\"treegrid\"\n  class={cn('relative w-full', className)}\n  style=\"--tree-indent: {indent}px\"\n  {...restProps}\n>\n  <Table>\n    <TableHeader>\n      <TableRow>\n        {#if selectable}\n          <TableHead class=\"w-10\">\n            <span class=\"sr-only\">Select</span>\n          </TableHead>\n        {/if}\n        {#each columns as col (col.key)}\n          <TableHead class={cn(col.headerClass)}>\n            {col.label}\n          </TableHead>\n        {/each}\n      </TableRow>\n    </TableHeader>\n    <TableBody>\n      {#each flatRows as fr (fr.row.id)}\n        <TableRow\n          data-tree-row\n          data-tree-id={fr.row.id}\n          data-tree-parent={fr.parentId ?? undefined}\n          data-depth={fr.depth}\n          data-expanded={fr.hasChildren ? isExpanded(fr.row.id) : undefined}\n          data-selected={isSelected(fr.row.id) ? '' : undefined}\n          role=\"row\"\n          aria-expanded={fr.hasChildren ? isExpanded(fr.row.id) : undefined}\n          aria-selected={selectable ? isSelected(fr.row.id) : undefined}\n          tabindex={0}\n          class=\"focus-visible:bg-muted/50 focus-visible:outline-none\"\n          onkeydown={(e: KeyboardEvent) => onRowKeydown(e, fr)}\n          onclick={(e: MouseEvent) => onRowClick(e, fr)}\n        >\n          <!-- Selection checkbox -->\n          {#if selectable}\n            <TableCell class=\"w-10\">\n              <Checkbox\n                checked={isSelected(fr.row.id)}\n                aria-label={`Select ${String(fr.row[columns[0]?.key ?? 'id'] ?? fr.row.id)}`}\n                onCheckedChange={() => toggleSelect(fr.row)}\n                onclick={(e: MouseEvent) => e.stopPropagation()}\n              />\n            </TableCell>\n          {/if}\n\n          <!-- Data cells -->\n          {#each columns as col, ci (col.key)}\n            <TableCell class={cn(ci === 0 && 'font-medium', col.cellClass)}>\n              <div\n                class=\"flex items-center\"\n                style={ci === 0 ? `padding-left: calc(${fr.depth} * var(--tree-indent))` : undefined}\n              >\n                <!-- Expand toggle on the first column -->\n                {#if ci === 0 && fr.hasChildren}\n                  <button\n                    type=\"button\"\n                    class=\"mr-1.5 flex size-6 shrink-0 cursor-pointer items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\"\n                    aria-label={isExpanded(fr.row.id) ? 'Collapse' : 'Expand'}\n                    aria-expanded={isExpanded(fr.row.id)}\n                    tabindex=\"-1\"\n                    onclick={(e) => {\n                      e.stopPropagation()\n                      toggleExpand(fr.row)\n                    }}\n                  >\n                    {#if expandIcon}\n                      {@render expandIcon({ expanded: isExpanded(fr.row.id) })}\n                    {:else}\n                      <ChevronRight\n                        class=\"size-4 transition-transform duration-150 {isExpanded(fr.row.id)\n                          ? 'rotate-90'\n                          : ''}\"\n                      />\n                    {/if}\n                  </button>\n                {:else if ci === 0}\n                  <span class=\"mr-1.5 w-5 shrink-0\"></span>\n                {/if}\n\n                {#if cell}\n                  {@render cell({ row: fr.row, column: col, depth: fr.depth })}\n                {:else}\n                  {col.render ? col.render(fr.row as T) : fr.row[col.key]}\n                {/if}\n              </div>\n            </TableCell>\n          {/each}\n        </TableRow>\n      {/each}\n\n      <!-- Empty state -->\n      {#if isEmpty && !loading}\n        <TableRow>\n          <TableCell colspan={columns.length + (selectable ? 1 : 0)} class=\"h-24 text-center\">\n            <div class=\"flex flex-col items-center gap-2 text-muted-foreground\">\n              <FileBox class=\"size-8\" />\n              <span class=\"text-sm\">{emptyText}</span>\n            </div>\n          </TableCell>\n        </TableRow>\n      {/if}\n    </TableBody>\n  </Table>\n\n  <!-- Loading overlay -->\n  {#if loading}\n    <div class=\"absolute inset-0 flex items-center justify-center bg-background/60 backdrop-blur-sm\">\n      <Spinner size=\"lg\" />\n    </div>\n  {/if}\n</div>\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/tree-table/TreeTable.svelte"
    },
    {
      "path": "packages/registry-svelte/components/tree-table/types.ts",
      "content": "export interface TreeTableColumn<T = any> {\n  /** Unique key matching a field on the row data. */\n  key: string\n  /** Header label. */\n  label: string\n  /** Optional class for the header cell. */\n  headerClass?: string\n  /** Optional class for body cells in this column. */\n  cellClass?: string\n  /**\n   * Custom cell renderer: receives the row and returns a display value. For\n   * rich markup use the `cell` snippet instead — the renderer output is\n   * interpolated as text.\n   */\n  render?: (row: T) => any\n}\n\nexport interface TreeTableRow<T = any> {\n  /** Unique id for the row. */\n  id: string\n  /** Row data fields keyed by column key. */\n  [key: string]: any\n  /** Child rows. */\n  children?: TreeTableRow<T>[]\n}\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/tree-table/types.ts"
    },
    {
      "path": "packages/registry-svelte/components/tree-table/index.ts",
      "content": "export { default as TreeTable, type TreeTableProps } from './TreeTable.svelte'\nexport type { TreeTableColumn, TreeTableRow } from './types'\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/tree-table/index.ts"
    }
  ],
  "dependencies": [
    "@lucide/svelte"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/svelte/table.json",
    "https://uipkge.dev/r/svelte/checkbox.json",
    "https://uipkge.dev/r/svelte/spinner.json"
  ],
  "description": "Hierarchical data table with expandable parent/child rows. Supports tree-structured data, column configuration, expand/collapse with per-level indent, row selection checkboxes, a loading overlay, and an empty state. Built on the existing table primitives.",
  "categories": [
    "data",
    "display"
  ]
}