{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "tree-view",
  "title": "Tree View",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-svelte/components/tree-view/TreeView.svelte",
      "content": "<script lang=\"ts\" module>\n  import type { HTMLAttributes } from 'svelte/elements'\n  import type { TreeViewItem } from './types'\n\n  // Omit DOM event props shadowed by component callbacks (item payloads, not events).\n  export interface TreeViewProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onselect' | 'ontoggle'> {\n    items: TreeViewItem[]\n    showIcons?: boolean\n    showCheckboxes?: boolean\n    defaultExpanded?: boolean\n    selectedId?: string | null\n    onselect?: (item: TreeViewItem) => void\n    ontoggle?: (item: TreeViewItem) => void\n    onSelectedIdChange?: (id: string | null) => void\n    ref?: HTMLDivElement | null\n  }\n</script>\n\n<script lang=\"ts\">\n  import { onMount } from 'svelte'\n  import { cn } from '$lib/utils'\n  import { setTreeViewContext } from './context'\n  import TreeViewNode from './TreeViewNode.svelte'\n\n  let {\n    items,\n    showIcons = true,\n    showCheckboxes = false,\n    defaultExpanded = false,\n    selectedId = $bindable(null),\n    onselect,\n    ontoggle,\n    onSelectedIdChange,\n    class: className,\n    ref = $bindable(null),\n    ...restProps\n  }: TreeViewProps = $props()\n\n  let expandedIds = $state<Set<string>>(new Set())\n\n  function toggle(item: TreeViewItem) {\n    if (item.disabled) return\n    const next = new Set(expandedIds)\n    if (next.has(item.id)) next.delete(item.id)\n    else next.add(item.id)\n    expandedIds = next\n    ontoggle?.(item)\n  }\n\n  function select(item: TreeViewItem) {\n    if (item.disabled) return\n    selectedId = item.id\n    onselect?.(item)\n    onSelectedIdChange?.(item.id)\n  }\n\n  // Getters keep the child rows reactive to parent state without a .svelte.ts store.\n  setTreeViewContext({\n    get expandedIds() {\n      return expandedIds\n    },\n    get selectedId() {\n      return selectedId\n    },\n    get showIcons() {\n      return showIcons\n    },\n    get showCheckboxes() {\n      return showCheckboxes\n    },\n    toggle,\n    select,\n  })\n\n  onMount(() => {\n    if (!defaultExpanded) return\n    const next = new Set<string>()\n    const walk = (list: TreeViewItem[]) => {\n      for (const it of list) {\n        if (it.children?.length) {\n          next.add(it.id)\n          walk(it.children)\n        }\n      }\n    }\n    walk(items)\n    expandedIds = next\n  })\n</script>\n\n<div\n  bind:this={ref}\n  data-uipkge\n  data-slot=\"tree-view\"\n  role=\"tree\"\n  class={cn('text-sm', className)}\n  {...restProps}\n>\n  {#each items as item, i (item.id)}\n    <TreeViewNode {item} depth={0} isLast={i === items.length - 1} />\n  {/each}\n</div>\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/tree-view/TreeView.svelte"
    },
    {
      "path": "packages/registry-svelte/components/tree-view/TreeViewNode.svelte",
      "content": "<script lang=\"ts\" module>\n  import type { HTMLAttributes } from 'svelte/elements'\n  import type { TreeViewItem } from './types'\n\n  export interface TreeViewNodeProps extends HTMLAttributes<HTMLDivElement> {\n    item: TreeViewItem\n    depth: number\n    isLast?: boolean\n    parentId?: string | null\n    ref?: HTMLDivElement | null\n  }\n</script>\n\n<script lang=\"ts\">\n  import { ChevronRight, File, Folder, FolderOpen } from '@lucide/svelte'\n  import { cn } from '$lib/utils'\n  import { getTreeViewContext } from './context'\n  import TreeViewNode from './TreeViewNode.svelte'\n\n  let { item, depth, isLast = false, parentId = null, ref = $bindable(null) }: TreeViewNodeProps = $props()\n\n  const ctx = getTreeViewContext()\n\n  const isExpanded = $derived(ctx.expandedIds.has(item.id))\n  const isSelected = $derived(ctx.selectedId === item.id)\n  const hasChildren = $derived(!!(item.children && item.children.length))\n\n  const Icon = $derived(\n    !ctx.showIcons ? null : (item.icon ?? (!hasChildren ? File : isExpanded ? FolderOpen : Folder)),\n  )\n\n  function handleToggle(e?: Event) {\n    e?.stopPropagation()\n    ctx.toggle(item)\n  }\n\n  function handleSelect() {\n    if (item.disabled) return\n    ctx.select(item)\n  }\n\n  function getTreeRows(from: HTMLElement): HTMLElement[] {\n    const tree = from.closest('[role=\"tree\"]')\n    if (!tree) return []\n    return Array.from(tree.querySelectorAll<HTMLElement>('[data-tree-row]:not([data-disabled=\"true\"])'))\n  }\n\n  function focusRow(row: HTMLElement | null | undefined) {\n    row?.focus()\n  }\n\n  function handleRowKeydown(e: KeyboardEvent) {\n    if (item.disabled) return\n    const target = e.currentTarget as HTMLElement\n\n    if (e.key === 'Enter' || e.key === ' ') {\n      e.preventDefault()\n      handleSelect()\n      return\n    }\n\n    if (e.key === 'ArrowRight') {\n      e.preventDefault()\n      if (hasChildren && !isExpanded) {\n        handleToggle()\n      } else if (hasChildren && isExpanded) {\n        // Move into first visible child (next row in flattened list).\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 (hasChildren && isExpanded) {\n        handleToggle()\n      } else if (parentId) {\n        const tree = target.closest('[role=\"tree\"]')\n        const parent = tree?.querySelector<HTMLElement>(`[data-tree-row][data-tree-id=\"${CSS.escape(parentId)}\"]`)\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  // 20px per level. Connector lives in parent's gutter (depth - 1).\n  const rowPadLeft = $derived(`calc(${depth} * var(--tree-indent) + var(--tree-row-offset))`)\n  const connectorLeft = $derived(`calc((${depth - 1}) * var(--tree-indent) + var(--tree-connector-offset))`)\n</script>\n\n<div\n  bind:this={ref}\n  role=\"treeitem\"\n  aria-expanded={hasChildren ? isExpanded : undefined}\n  aria-selected={isSelected}\n  class=\"relative\"\n  style=\"--tree-indent: 20px; --tree-row-offset: 4px; --tree-connector-offset: 10px\"\n>\n  <!-- Discord-style elbow + trunk for non-root nodes. The elbow points\n       from the parent's chevron column down to this row's center; the\n       trunk continues to the next sibling at this depth (omitted on the\n       last sibling). -->\n  {#if depth > 0}\n    <span\n      aria-hidden=\"true\"\n      class=\"pointer-events-none absolute top-0 h-4 w-3 rounded-bl-md border-b border-l border-border\"\n      style=\"left: {connectorLeft}\"\n    ></span>\n  {/if}\n  {#if depth > 0 && !isLast}\n    <span aria-hidden=\"true\" class=\"pointer-events-none absolute top-4 bottom-0 w-px bg-border\" style=\"left: {connectorLeft}\"\n    ></span>\n  {/if}\n\n  <!-- Row -->\n  <div\n    data-tree-row\n    data-tree-id={item.id}\n    data-tree-parent={parentId ?? undefined}\n    data-disabled={item.disabled ? 'true' : undefined}\n    class={cn(\n      'group relative flex h-8 cursor-pointer items-center gap-1.5 rounded-md pr-2 text-sm transition-colors',\n      'hover:bg-accent hover:text-accent-foreground',\n      'focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none',\n      item.disabled && 'cursor-not-allowed opacity-50',\n      isSelected && 'bg-accent text-accent-foreground font-medium',\n    )}\n    style=\"padding-left: {rowPadLeft}\"\n    tabindex={item.disabled ? -1 : 0}\n    onclick={handleSelect}\n    onkeydown={handleRowKeydown}\n  >\n    <!-- Chevron (or 16px spacer for leaves so labels align) -->\n    {#if hasChildren}\n      <button\n        type=\"button\"\n        class={cn(\n          'flex size-4 shrink-0 items-center justify-center rounded transition-transform duration-150 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none',\n          'hover:bg-foreground/10',\n          isExpanded && 'rotate-90',\n        )}\n        aria-label={isExpanded ? 'Collapse' : 'Expand'}\n        tabindex=\"-1\"\n        onclick={handleToggle}\n      >\n        <ChevronRight class=\"size-3.5 text-muted-foreground\" aria-hidden=\"true\" />\n      </button>\n    {:else}\n      <span class=\"size-4 shrink-0\"></span>\n    {/if}\n\n    <!-- Checkbox: reflect live selection, not the static item.selected seed field -->\n    {#if ctx.showCheckboxes}\n      <input\n        type=\"checkbox\"\n        checked={isSelected}\n        disabled={item.disabled}\n        aria-label={item.label}\n        class=\"size-3.5 shrink-0 rounded border-input bg-background text-primary focus:ring-1 focus:ring-ring focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\"\n        onchange={handleSelect}\n        onclick={(e) => e.stopPropagation()}\n      />\n    {/if}\n\n    <!-- Icon -->\n    {#if Icon}\n      <Icon class={cn('size-4 shrink-0', hasChildren ? 'text-primary' : 'text-muted-foreground')} />\n    {/if}\n\n    <!-- Label -->\n    <span class=\"flex-1 truncate\">{item.label}</span>\n  </div>\n\n  <!-- Children -->\n  {#if hasChildren && isExpanded}\n    <div role=\"group\">\n      {#each item.children ?? [] as child, j (child.id)}\n        <TreeViewNode\n          item={child}\n          depth={depth + 1}\n          parentId={item.id}\n          isLast={j === (item.children?.length ?? 0) - 1}\n        />\n      {/each}\n    </div>\n  {/if}\n</div>\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/tree-view/TreeViewNode.svelte"
    },
    {
      "path": "packages/registry-svelte/components/tree-view/context.ts",
      "content": "import { getContext, setContext } from 'svelte'\nimport type { TreeViewItem } from './types'\n\nexport interface TreeViewContext {\n  readonly expandedIds: Set<string>\n  readonly selectedId: string | null\n  readonly showIcons: boolean\n  readonly showCheckboxes: boolean\n  toggle: (item: TreeViewItem) => void\n  select: (item: TreeViewItem) => void\n}\n\nconst TREE_VIEW_CONTEXT_KEY = Symbol('uipkge-tree-view')\n\nexport function setTreeViewContext(ctx: TreeViewContext): void {\n  setContext(TREE_VIEW_CONTEXT_KEY, ctx)\n}\n\nexport function getTreeViewContext(): TreeViewContext {\n  const ctx = getContext<TreeViewContext | undefined>(TREE_VIEW_CONTEXT_KEY)\n  if (!ctx) throw new Error('TreeViewNode must be used inside <TreeView>')\n  return ctx\n}\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/tree-view/context.ts"
    },
    {
      "path": "packages/registry-svelte/components/tree-view/types.ts",
      "content": "import type { Component } from 'svelte'\n\nexport interface TreeViewItem {\n  id: string\n  label: string\n  icon?: Component\n  children?: TreeViewItem[]\n  disabled?: boolean\n  selected?: boolean\n  expanded?: boolean\n  [key: string]: unknown\n}\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/tree-view/types.ts"
    },
    {
      "path": "packages/registry-svelte/components/tree-view/index.ts",
      "content": "export { default as TreeView, type TreeViewProps } from './TreeView.svelte'\nexport { default as TreeViewNode, type TreeViewNodeProps } from './TreeViewNode.svelte'\nexport type { TreeViewItem } from './types'\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/tree-view/index.ts"
    }
  ],
  "dependencies": [
    "@lucide/svelte"
  ],
  "devDependencies": [],
  "registryDependencies": [],
  "description": "Indented tree of expandable nodes — file browsers, taxonomy editors, nested settings. Discord-style elbow connectors, lazy-load branches, and full keyboard navigation.",
  "categories": [
    "data-display"
  ]
}