{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "tree-select",
  "title": "Tree Select",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-svelte/components/tree-select/TreeSelect.svelte",
      "content": "<script lang=\"ts\" module>\n  import type { HTMLAttributes } from 'svelte/elements'\n  import type { TreeSelectNode as TreeNode } from './types'\n\n  // Omit DOM event props shadowed by component callbacks (payloads, not events).\n  export interface TreeSelectProps extends Omit<HTMLAttributes<HTMLButtonElement>, 'onchange' | 'onselect'> {\n    /** Renamed from Vue's `modelValue`: the Svelte twin binds `value`. */\n    value?: string | string[] | null\n    data: TreeNode[]\n    multiple?: boolean\n    placeholder?: string\n    searchable?: boolean\n    disabled?: boolean\n    loading?: boolean\n    clearable?: boolean\n    defaultExpandAll?: boolean\n    size?: 'sm' | 'default' | 'lg'\n    emptyText?: string\n    searchPlaceholder?: string\n    onselect?: (node: TreeNode) => void\n    onchange?: (value: string | string[] | null) => void\n    /** React parity alias for `onchange` — both fire on every commit. */\n    onValueChange?: (value: string | string[] | null) => void\n    onclear?: () => void\n    ref?: HTMLButtonElement | null\n  }\n</script>\n\n<script lang=\"ts\">\n  import { untrack } from 'svelte'\n  import { ChevronDown, Loader2, Search, X } from '@lucide/svelte'\n  import { Popover, PopoverContent, PopoverTrigger } from '$lib/components/ui/popover'\n  import { cn } from '$lib/utils'\n  import { treeSelectTriggerVariants } from './tree-select.variants'\n  import TreeSelectNode from './TreeSelectNode.svelte'\n\n  let {\n    value = $bindable(),\n    data,\n    multiple = false,\n    placeholder = 'Select...',\n    searchable = true,\n    disabled = false,\n    loading = false,\n    clearable = true,\n    defaultExpandAll = false,\n    size = 'default',\n    emptyText = 'No results found.',\n    searchPlaceholder = 'Search...',\n    onselect,\n    onchange,\n    onValueChange,\n    onclear,\n    class: className,\n    ref = $bindable(null),\n    ...restProps\n  }: TreeSelectProps = $props()\n\n  function emitChange(next: string | string[] | null) {\n    onchange?.(next)\n    onValueChange?.(next)\n  }\n\n  let isOpen = $state(false)\n  let search = $state('')\n  let expandedIds = $state<Set<string>>(new Set())\n\n  function collectAllExpandable(nodes: TreeNode[]): string[] {\n    const ids: string[] = []\n    const walk = (list: TreeNode[]) => {\n      for (const n of list) {\n        if (n.children?.length) {\n          ids.push(n.value)\n          walk(n.children)\n        }\n      }\n    }\n    walk(nodes)\n    return ids\n  }\n\n  $effect(() => {\n    const expand = defaultExpandAll\n    // Mirror the Vue watcher: react to the flag only, not to data identity.\n    if (expand) expandedIds = new Set(collectAllExpandable(untrack(() => data)))\n  })\n\n  const selectedValues = $derived.by((): Set<string> => {\n    if (value == null) return new Set()\n    if (Array.isArray(value)) return new Set(value)\n    return new Set([value])\n  })\n\n  function findNode(nodes: TreeNode[], val: string): TreeNode | undefined {\n    for (const n of nodes) {\n      if (n.value === val) return n\n      if (n.children) {\n        const found = findNode(n.children, val)\n        if (found) return found\n      }\n    }\n    return undefined\n  }\n\n  function findLabels(nodes: TreeNode[], values: string[]): string[] {\n    return values.map((v) => findNode(nodes, v)?.label ?? v)\n  }\n\n  const displayLabel = $derived.by(() => {\n    if (multiple) {\n      const vals = Array.isArray(value) ? value : []\n      if (vals.length === 0) return placeholder\n      const labels = findLabels(data, vals)\n      if (labels.length <= 3) return labels.join(', ')\n      return `${labels.slice(0, 3).join(', ')} +${labels.length - 3}`\n    }\n    if (value == null) return placeholder\n    const node = findNode(data, value as string)\n    return node?.label ?? String(value)\n  })\n\n  const hasValue = $derived.by(() => {\n    if (multiple) return Array.isArray(value) && value.length > 0\n    return value != null\n  })\n\n  // Search filtering: a node is visible if it or any descendant matches.\n  // Expand-on-match is applied in a separate effect — never mutate state inside a derived.\n  const filteredIds = $derived.by((): Set<string> | null => {\n    const q = search.trim().toLowerCase()\n    if (!q) return null\n    const visible = new Set<string>()\n    const walk = (nodes: TreeNode[]): boolean => {\n      let anyMatch = false\n      for (const n of nodes) {\n        const selfMatch = n.label.toLowerCase().includes(q)\n        let childMatch = false\n        if (n.children?.length) {\n          childMatch = walk(n.children)\n        }\n        if (selfMatch || childMatch) {\n          visible.add(n.value)\n          anyMatch = true\n        }\n      }\n      return anyMatch\n    }\n    walk(data)\n    return visible\n  })\n\n  // Auto-expand ancestors of search matches without side effects in the filter derived.\n  $effect(() => {\n    const visible = filteredIds\n    if (!visible || visible.size === 0) return\n    const q = search.trim().toLowerCase()\n    if (!q) return\n    const next = new Set(expandedIds)\n    let changed = false\n    const walk = (nodes: TreeNode[]): boolean => {\n      let anyMatch = false\n      for (const n of nodes) {\n        const selfMatch = n.label.toLowerCase().includes(q)\n        let childMatch = false\n        if (n.children?.length) childMatch = walk(n.children)\n        if (selfMatch || childMatch) {\n          anyMatch = true\n          if (childMatch && !next.has(n.value)) {\n            next.add(n.value)\n            changed = true\n          }\n        }\n      }\n      return anyMatch\n    }\n    walk(untrack(() => data))\n    if (changed) expandedIds = next\n  })\n\n  function toggleNode(node: TreeNode) {\n    if (node.disabled) return\n    const next = new Set(expandedIds)\n    if (next.has(node.value)) next.delete(node.value)\n    else next.add(node.value)\n    expandedIds = next\n  }\n\n  function collectLeafValues(node: TreeNode): string[] {\n    if (!node.children?.length) return [node.value]\n    const vals: string[] = []\n    for (const c of node.children) vals.push(...collectLeafValues(c))\n    return vals\n  }\n\n  function selectNode(node: TreeNode) {\n    if (node.disabled) return\n    if (!multiple) {\n      value = node.value\n      emitChange(node.value)\n      onselect?.(node)\n      isOpen = false\n      return\n    }\n    // Multi-select: toggle. For parent nodes, toggle all leaf descendants.\n    const current = Array.isArray(value) ? [...value] : []\n    const leaves = collectLeafValues(node)\n    const allSelected = leaves.every((v) => current.includes(v))\n    let next: string[]\n    if (allSelected) {\n      next = current.filter((v) => !leaves.includes(v))\n    } else {\n      next = [...current, ...leaves.filter((v) => !current.includes(v))]\n    }\n    value = next\n    emitChange(next)\n    onselect?.(node)\n  }\n\n  function clearAll(event?: Event) {\n    event?.stopPropagation()\n    if (disabled) return\n    onclear?.()\n    if (multiple) {\n      value = []\n      emitChange([])\n    } else {\n      value = null\n      emitChange(null)\n    }\n  }\n\n  function onClearKeydown(e: KeyboardEvent) {\n    if (e.key === 'Enter' || e.key === ' ') {\n      e.preventDefault()\n      clearAll()\n    }\n  }\n\n  $effect(() => {\n    if (!isOpen) search = ''\n  })\n\n  // Match the panel width to the trigger (the Vue twin reads the popover's\n  // trigger-width CSS var; measuring locally keeps this port self-contained).\n  let triggerWidth = $state<number | null>(null)\n  $effect(() => {\n    const el = ref\n    if (!el || typeof ResizeObserver === 'undefined') return\n    const ro = new ResizeObserver(() => {\n      triggerWidth = el.getBoundingClientRect().width\n    })\n    ro.observe(el)\n    triggerWidth = el.getBoundingClientRect().width\n    return () => ro.disconnect()\n  })\n  const contentStyle = $derived(triggerWidth ? `width: ${triggerWidth}px` : undefined)\n\n  const triggerClasses = $derived(cn(treeSelectTriggerVariants({ size }), className))\n</script>\n\n<Popover bind:open={isOpen}>\n  <PopoverTrigger>\n    <button\n      bind:this={ref}\n      type=\"button\"\n      role=\"combobox\"\n      aria-expanded={isOpen}\n      disabled={disabled || loading}\n      data-uipkge\n      data-slot=\"tree-select\"\n      class={triggerClasses}\n      {...restProps}\n    >\n      <span class=\"flex-1 truncate text-left {hasValue ? 'text-foreground' : 'text-muted-foreground'}\">\n        {displayLabel}\n      </span>\n      <span class=\"flex shrink-0 items-center gap-1\">\n        {#if loading}\n          <Loader2 class=\"size-4 animate-spin text-muted-foreground\" />\n        {:else if clearable && hasValue && !disabled}\n          <span\n            role=\"button\"\n            tabindex=\"0\"\n            aria-label=\"Clear\"\n            class=\"flex size-4 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none\"\n            onclick={clearAll}\n            onkeydown={onClearKeydown}\n          >\n            <X class=\"size-4\" />\n          </span>\n        {:else}\n          <ChevronDown\n            class=\"size-4 shrink-0 text-muted-foreground transition-transform duration-200 {isOpen\n              ? 'rotate-180'\n              : ''}\"\n          />\n        {/if}\n      </span>\n    </button>\n  </PopoverTrigger>\n\n  <PopoverContent class=\"p-0\" align=\"start\" sideOffset={4} style={contentStyle}>\n    <div class=\"flex max-h-80 flex-col\">\n      {#if searchable}\n        <div class=\"border-b p-2\">\n          <div class=\"relative\">\n            <Search class=\"absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground\" />\n            <input\n              bind:value={search}\n              placeholder={searchPlaceholder}\n              aria-label=\"Search tree\"\n              class=\"h-9 w-full rounded-md border border-input bg-transparent pl-8 text-sm shadow-xs outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n            />\n          </div>\n        </div>\n      {/if}\n\n      {#if loading}\n        <div class=\"flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground\">\n          <Loader2 class=\"size-4 animate-spin\" />\n          Loading...\n        </div>\n      {:else if data.length === 0 || (filteredIds && filteredIds.size === 0)}\n        <div class=\"py-6 text-center text-sm text-muted-foreground\">\n          {emptyText}\n        </div>\n      {:else}\n        <div class=\"flex-1 overflow-y-auto p-1\" role=\"tree\">\n          {#each data as node (node.value)}\n            <TreeSelectNode\n              {node}\n              depth={0}\n              {multiple}\n              {expandedIds}\n              {selectedValues}\n              {filteredIds}\n              ontoggle={toggleNode}\n              onselect={selectNode}\n            />\n          {/each}\n        </div>\n      {/if}\n\n      {#if multiple && Array.isArray(value) && value.length}\n        <div class=\"flex items-center justify-between border-t px-2 py-1.5 text-xs\">\n          <span class=\"text-muted-foreground\">{value.length} selected</span>\n          <button\n            type=\"button\"\n            class=\"rounded text-muted-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none\"\n            onclick={() => clearAll()}\n          >\n            Clear all\n          </button>\n        </div>\n      {/if}\n    </div>\n  </PopoverContent>\n</Popover>\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/tree-select/TreeSelect.svelte"
    },
    {
      "path": "packages/registry-svelte/components/tree-select/TreeSelectNode.svelte",
      "content": "<script lang=\"ts\" module>\n  import type { TreeSelectNode } from './types'\n\n  export interface TreeSelectNodeRowProps {\n    node: TreeSelectNode\n    depth: number\n    multiple: boolean\n    expandedIds: Set<string>\n    selectedValues: Set<string>\n    filteredIds: Set<string> | null\n    parentValue?: string | null\n    ontoggle?: (node: TreeSelectNode) => void\n    onselect?: (node: TreeSelectNode) => void\n  }\n</script>\n\n<script lang=\"ts\">\n  import { ChevronRight } from '@lucide/svelte'\n  import { cn } from '$lib/utils'\n  import TreeSelectNodeRow from './TreeSelectNode.svelte'\n\n  let {\n    node,\n    depth,\n    multiple,\n    expandedIds,\n    selectedValues,\n    filteredIds,\n    parentValue = null,\n    ontoggle,\n    onselect,\n  }: TreeSelectNodeRowProps = $props()\n\n  const hasChildren = $derived(!!(node.children && node.children.length))\n  const isExpanded = $derived(expandedIds.has(node.value))\n  const isChecked = $derived.by(() => {\n    if (!multiple) return selectedValues.has(node.value)\n    if (selectedValues.has(node.value)) return true\n    // Indeterminate: some (not all) descendants selected\n    if (!hasChildren) return false\n    const descendants = collectValues(node)\n    const selected = descendants.filter((v) => selectedValues.has(v))\n    return selected.length > 0 && selected.length < descendants.length\n  })\n  const isFullyChecked = $derived.by(() => {\n    if (!multiple) return false\n    if (selectedValues.has(node.value)) return true\n    if (!hasChildren) return false\n    const descendants = collectValues(node)\n    return descendants.length > 0 && descendants.every((v) => selectedValues.has(v))\n  })\n  const isVisible = $derived(!filteredIds || filteredIds.has(node.value))\n  const isSelected = $derived.by(() => {\n    if (!multiple) return selectedValues.has(node.value)\n    return isFullyChecked || isChecked\n  })\n\n  function collectValues(root: TreeSelectNode): string[] {\n    const vals: string[] = []\n    const walk = (n: TreeSelectNode) => {\n      if (n.children?.length) {\n        for (const c of n.children) walk(c)\n      } else {\n        vals.push(n.value)\n      }\n    }\n    walk(root)\n    return vals\n  }\n\n  function handleToggle(e: Event) {\n    e.stopPropagation()\n    ontoggle?.(node)\n  }\n\n  function handleSelect() {\n    if (node.disabled) return\n    onselect?.(node)\n  }\n\n  function handleCheckboxChange(e: Event) {\n    e.stopPropagation()\n    if (node.disabled) return\n    onselect?.(node)\n  }\n\n  function indeterminate(el: HTMLInputElement, value: boolean) {\n    el.indeterminate = value\n    return {\n      update(value: boolean) {\n        el.indeterminate = value\n      },\n    }\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 (node.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        ontoggle?.(node)\n      } else if (hasChildren && isExpanded) {\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        ontoggle?.(node)\n      } else if (parentValue) {\n        const tree = target.closest('[role=\"tree\"]')\n        const parent = tree?.querySelector<HTMLElement>(\n          `[data-tree-row][data-tree-id=\"${CSS.escape(parentValue)}\"]`,\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  const indent = $derived(`calc(${depth} * var(--tree-indent) + var(--tree-indent-offset))`)\n</script>\n\n{#if isVisible}\n  <div\n    role=\"treeitem\"\n    aria-expanded={hasChildren ? isExpanded : undefined}\n    aria-selected={isSelected}\n    style=\"--tree-indent: 20px; --tree-indent-offset: 8px\"\n  >\n    <div\n      data-tree-row\n      data-tree-id={node.value}\n      data-tree-parent={parentValue ?? undefined}\n      data-disabled={node.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 focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none',\n        node.disabled && 'cursor-not-allowed opacity-50',\n        !multiple && selectedValues.has(node.value) && 'bg-accent text-accent-foreground font-medium',\n      )}\n      style=\"padding-left: {indent}\"\n      tabindex={node.disabled ? -1 : 0}\n      onclick={handleSelect}\n      onkeydown={handleRowKeydown}\n    >\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      {#if multiple}\n        <input\n          type=\"checkbox\"\n          checked={isFullyChecked || isChecked}\n          use:indeterminate={isChecked && !isFullyChecked}\n          disabled={node.disabled}\n          class=\"size-3.5 shrink-0 rounded border-input text-primary focus:ring-1 focus:ring-ring\"\n          onchange={handleCheckboxChange}\n          onclick={(e) => e.stopPropagation()}\n        />\n      {/if}\n\n      <span class=\"flex-1 truncate\">{node.label}</span>\n    </div>\n\n    {#if hasChildren && isExpanded}\n      <div role=\"group\">\n        {#each node.children ?? [] as child (child.value)}\n          <TreeSelectNodeRow\n            node={child}\n            depth={depth + 1}\n            {multiple}\n            {expandedIds}\n            {selectedValues}\n            {filteredIds}\n            parentValue={node.value}\n            {ontoggle}\n            {onselect}\n          />\n        {/each}\n      </div>\n    {/if}\n  </div>\n{/if}\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/tree-select/TreeSelectNode.svelte"
    },
    {
      "path": "packages/registry-svelte/components/tree-select/tree-select.variants.ts",
      "content": "import type { VariantProps } from 'class-variance-authority'\nimport { cva } from 'class-variance-authority'\n\nexport const treeSelectTriggerVariants = cva(\n  'flex w-full items-center justify-between gap-2 rounded-md border border-input bg-transparent text-sm shadow-xs transition-[color,box-shadow] outline-none hover:border-ring/50 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',\n  {\n    variants: {\n      size: {\n        sm: 'h-8 text-xs px-2.5',\n        default: 'h-9 text-sm px-3',\n        lg: 'h-11 text-base px-4',\n      },\n    },\n    defaultVariants: {\n      size: 'default',\n    },\n  },\n)\n\nexport type TreeSelectVariants = VariantProps<typeof treeSelectTriggerVariants>\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/tree-select/tree-select.variants.ts"
    },
    {
      "path": "packages/registry-svelte/components/tree-select/types.ts",
      "content": "export interface TreeSelectNode {\n  value: string\n  label: string\n  disabled?: boolean\n  children?: TreeSelectNode[]\n  [key: string]: unknown\n}\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/tree-select/types.ts"
    },
    {
      "path": "packages/registry-svelte/components/tree-select/index.ts",
      "content": "export { default as TreeSelect, type TreeSelectProps } from './TreeSelect.svelte'\n// The data type gets the canonical `TreeSelectNode` name (consumers type their\n// trees with it); the internal row component stays reachable under an alias.\nexport {\n  default as TreeSelectNodeComponent,\n  type TreeSelectNodeRowProps,\n  type TreeSelectNodeRowProps as TreeSelectNodeProps,\n} from './TreeSelectNode.svelte'\nexport { treeSelectTriggerVariants, type TreeSelectVariants } from './tree-select.variants'\nexport type { TreeSelectNode } from './types'\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/tree-select/index.ts"
    }
  ],
  "dependencies": [
    "@lucide/svelte",
    "class-variance-authority"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/svelte/popover.json"
  ],
  "description": "Select from tree-structured data with expand/collapse nodes, single or multi-select with checkboxes, and search filtering. Dropdown shows a nested tree; parent selection cascades to leaf descendants.",
  "categories": [
    "control",
    "form"
  ]
}