{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "tree-select",
  "title": "Tree Select",
  "type": "registry:ui",
  "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"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/tree-select/tree-select.ts",
      "content": "import { LitElement, css, html, nothing, type TemplateResult } from 'lit'\nimport { styleMap } from 'lit/directives/style-map.js'\nimport { ChevronDown, ChevronRight, Loader2, Search, X } from 'lucide'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport { icon } from '../../lib/icon'\nimport { computePosition } from '../../lib/position'\nimport { treeSelectTriggerVariants } from './tree-select.variants'\n\nexport interface TreeSelectNode {\n  value: string\n  label: string\n  disabled?: boolean\n  children?: TreeSelectNode[]\n  [key: string]: unknown\n}\n\nfunction collectAllExpandable(nodes: TreeSelectNode[]): string[] {\n  const ids: string[] = []\n  const walk = (list: TreeSelectNode[]) => {\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\nfunction findNode(nodes: TreeSelectNode[], value: string): TreeSelectNode | undefined {\n  for (const n of nodes) {\n    if (n.value === value) return n\n    if (n.children) {\n      const found = findNode(n.children, value)\n      if (found) return found\n    }\n  }\n  return undefined\n}\n\nfunction findLabels(nodes: TreeSelectNode[], values: string[]): string[] {\n  return values.map((v) => findNode(nodes, v)?.label ?? v)\n}\n\nfunction collectLeafValues(node: TreeSelectNode): 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\nfunction collectValues(node: 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(node)\n  return vals\n}\n\nconst jsonConverter = {\n  fromAttribute: (v: string | null) => {\n    if (!v) return undefined\n    try {\n      return JSON.parse(v)\n    } catch {\n      return v\n    }\n  },\n  toAttribute: (v: unknown) => (v ? (typeof v === 'string' ? v : JSON.stringify(v)) : null),\n}\n\nconst trueUnlessFalse = {\n  fromAttribute: (v: string | null) => v !== 'false',\n}\n\n/**\n * <uip-tree-select> — the registry TreeSelect as a web component.\n *\n * Form-associated: submits `name=val` or `name[]=val` with its <form>.\n */\nexport class UipTreeSelect extends LitElement {\n  static formAssociated = true\n  static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }\n  static styles = [tailwind, css`:host { display: block; width: 100%; }`]\n\n  static properties = {\n    value: { converter: jsonConverter },\n    defaultValue: { attribute: 'default-value', converter: jsonConverter },\n    data: { converter: jsonConverter },\n    multiple: { type: Boolean, reflect: true },\n    placeholder: {},\n    searchable: { converter: trueUnlessFalse },\n    disabled: { type: Boolean, reflect: true },\n    loading: { type: Boolean, reflect: true },\n    clearable: { converter: trueUnlessFalse },\n    defaultExpandAll: { type: Boolean, attribute: 'default-expand-all' },\n    size: { reflect: true },\n    emptyText: { attribute: 'empty-text' },\n    searchPlaceholder: { attribute: 'search-placeholder' },\n    name: { reflect: true },\n    accessibleLabel: { attribute: 'aria-label' },\n    isOpen: { state: true },\n    search: { state: true },\n    expandedIds: { state: true },\n    pos: { state: true },\n  }\n\n  value: string | string[] | null = null\n  defaultValue: string | string[] | null = null\n  data: TreeSelectNode[] = []\n  multiple = false\n  placeholder = 'Select...'\n  searchable = true\n  disabled = false\n  loading = false\n  clearable = true\n  defaultExpandAll = false\n  size: 'sm' | 'default' | 'lg' = 'default'\n  emptyText = 'No results found.'\n  searchPlaceholder = 'Search...'\n  name?: string\n  accessibleLabel?: string\n\n  private isOpen = false\n  private search = ''\n  private expandedIds: Set<string> = new Set()\n  private pos: Record<string, string> = {}\n  private initialValue: string | string[] | null = null\n  private internals = this.attachInternals()\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'tree-select')\n    if (this.defaultValue !== undefined && this.getAttribute('value') === null) {\n      this.value = this.defaultValue\n    }\n    this.initialValue = this.value\n    if (this.defaultExpandAll) {\n      this.expandedIds = new Set(collectAllExpandable(this.data))\n    }\n    document.addEventListener('pointerdown', this.onDocumentPointerDown)\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    document.removeEventListener('pointerdown', this.onDocumentPointerDown)\n  }\n\n  private onDocumentPointerDown = (e: PointerEvent) => {\n    if (!this.isOpen) return\n    const path = e.composedPath()\n    if (!path.includes(this)) {\n      this.close()\n    }\n  }\n\n  protected willUpdate(changed: Map<string, unknown>) {\n    if (changed.has('value') || changed.has('name')) {\n      if (!this.name || this.value == null) {\n        this.internals.setFormValue(null)\n      } else if (Array.isArray(this.value)) {\n        const fd = new FormData()\n        this.value.forEach((v) => fd.append(`${this.name}[]`, v))\n        this.internals.setFormValue(fd)\n      } else {\n        this.internals.setFormValue(String(this.value))\n      }\n    }\n    if (changed.has('defaultExpandAll') || changed.has('data')) {\n      if (this.defaultExpandAll) {\n        this.expandedIds = new Set(collectAllExpandable(this.data))\n      }\n    }\n  }\n\n  formResetCallback() {\n    this.commit(this.initialValue)\n  }\n\n  formDisabledCallback(disabled: boolean) {\n    this.disabled = disabled\n  }\n\n  private get trigger(): HTMLElement | null {\n    return this.renderRoot?.querySelector('[role=combobox]')\n  }\n\n  private get popoverEl(): HTMLElement | null {\n    return this.renderRoot?.querySelector('[data-slot=tree-select-popover]')\n  }\n\n  private toggleOpen() {\n    if (this.disabled || this.loading) return\n    if (this.isOpen) this.close()\n    else this.open()\n  }\n\n  private open() {\n    this.isOpen = true\n    this.search = ''\n    this.place()\n  }\n\n  private close() {\n    this.isOpen = false\n    this.search = ''\n  }\n\n  private place() {\n    this.updateComplete.then(() => {\n      const t = this.trigger\n      const p = this.popoverEl\n      if (!t || !p) return\n      const { style } = computePosition(t, p, { side: 'bottom', align: 'start', sideOffset: 4, matchWidth: true })\n      this.pos = style\n    })\n  }\n\n  private commit(next: string | string[] | null) {\n    this.value = next\n    this.dispatchEvent(new Event('input', { bubbles: true, composed: true }))\n    this.dispatchEvent(new Event('change', { bubbles: true, composed: true }))\n    this.dispatchEvent(new CustomEvent('value-change', { detail: { value: next }, bubbles: true, composed: true }))\n  }\n\n  private get selectedValues(): Set<string> {\n    if (this.value == null) return new Set()\n    if (Array.isArray(this.value)) return new Set(this.value)\n    return new Set([this.value])\n  }\n\n  private toggleNode(node: TreeSelectNode) {\n    if (node.disabled) return\n    const next = new Set(this.expandedIds)\n    if (next.has(node.value)) next.delete(node.value)\n    else next.add(node.value)\n    this.expandedIds = next\n  }\n\n  private selectNode(node: TreeSelectNode) {\n    if (node.disabled) return\n    if (!this.multiple) {\n      this.commit(node.value)\n      this.dispatchEvent(new CustomEvent('select', { detail: { node }, bubbles: true, composed: true }))\n      this.close()\n      return\n    }\n\n    const current = Array.isArray(this.value) ? [...this.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    this.commit(next)\n    this.dispatchEvent(new CustomEvent('select', { detail: { node }, bubbles: true, composed: true }))\n  }\n\n  private clearAll(e?: Event) {\n    e?.stopPropagation()\n    if (this.disabled) return\n    if (this.multiple) {\n      this.commit([])\n    } else {\n      this.commit(null)\n    }\n    this.dispatchEvent(new CustomEvent('clear', { bubbles: true, composed: true }))\n  }\n\n  private get filteredIds(): Set<string> | null {\n    const q = this.search.trim().toLowerCase()\n    if (!q) return null\n    const visible = new Set<string>()\n    const walk = (nodes: TreeSelectNode[]): 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(this.data)\n    return visible\n  }\n\n  private renderTreeNode(node: TreeSelectNode, depth: number, parentValue: string | null = null): TemplateResult | null {\n    const filteredIds = this.filteredIds\n    if (filteredIds && !filteredIds.has(node.value)) return null\n\n    const hasChildren = !!(node.children && node.children.length)\n    const isExpanded = this.expandedIds.has(node.value)\n    const selectedValues = this.selectedValues\n\n    const isChecked = !this.multiple\n      ? selectedValues.has(node.value)\n      : selectedValues.has(node.value) ||\n        (hasChildren && collectValues(node).filter((v) => selectedValues.has(v)).length > 0)\n\n    const isFullyChecked = !this.multiple\n      ? false\n      : selectedValues.has(node.value) ||\n        (hasChildren && collectValues(node).every((v) => selectedValues.has(v)))\n\n    const isIndeterminate = isChecked && !isFullyChecked\n\n    const indent = `${depth * 20 + 8}px`\n\n    return html`\n      <div role=\"treeitem\" aria-expanded=${hasChildren ? (isExpanded ? 'true' : 'false') : nothing}>\n        <div\n          data-tree-row\n          data-tree-id=${node.value}\n          class=${cn(\n            'group relative flex h-8 cursor-pointer items-center gap-1.5 rounded-sm pr-2 text-sm transition-colors select-none',\n            'hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none',\n            node.disabled && 'cursor-not-allowed opacity-50',\n            !this.multiple && selectedValues.has(node.value) && 'bg-accent text-accent-foreground font-medium',\n          )}\n          style=${styleMap({ paddingLeft: indent })}\n          tabindex=${node.disabled ? -1 : 0}\n          @click=${() => this.selectNode(node)}\n        >\n          ${hasChildren\n            ? html`\n                <button\n                  type=\"button\"\n                  tabindex=\"-1\"\n                  aria-label=${isExpanded ? 'Collapse' : 'Expand'}\n                  class=${cn(\n                    'focus-visible:ring-ring flex size-4 shrink-0 items-center justify-center rounded transition-transform duration-150 focus-visible:ring-2 focus-visible:outline-none',\n                    'hover:bg-foreground/10',\n                    isExpanded && 'rotate-90',\n                  )}\n                  @click=${(e: Event) => {\n                    e.stopPropagation()\n                    this.toggleNode(node)\n                  }}\n                >\n                  ${icon(ChevronRight, 'chevron-right', 'text-muted-foreground size-3.5')}\n                </button>\n              `\n            : html`<span class=\"size-4 shrink-0\"></span>`}\n\n          ${this.multiple\n            ? html`\n                <input\n                  type=\"checkbox\"\n                  .checked=${isFullyChecked || isChecked}\n                  .indeterminate=${isIndeterminate}\n                  ?disabled=${node.disabled}\n                  class=\"border-input text-primary focus:ring-ring size-3.5 shrink-0 rounded focus:ring-1\"\n                  @click=${(e: Event) => e.stopPropagation()}\n                  @change=${() => this.selectNode(node)}\n                />\n              `\n            : nothing}\n\n          <span class=\"flex-1 truncate\">${node.label}</span>\n        </div>\n\n        ${hasChildren && isExpanded\n          ? html`\n              <div role=\"group\">\n                ${node.children!.map((child) => this.renderTreeNode(child, depth + 1, node.value))}\n              </div>\n            `\n          : nothing}\n      </div>\n    `\n  }\n\n  render() {\n    const data = this.data ?? []\n    const multiple = this.multiple\n    const currentValue = this.value\n\n    let displayLabel = this.placeholder\n    let hasVal = false\n\n    if (multiple) {\n      const vals = Array.isArray(currentValue) ? currentValue : []\n      if (vals.length > 0) {\n        hasVal = true\n        const labels = findLabels(data, vals)\n        if (labels.length <= 3) displayLabel = labels.join(', ')\n        else displayLabel = `${labels.slice(0, 3).join(', ')} +${labels.length - 3}`\n      }\n    } else if (currentValue != null) {\n      hasVal = true\n      const node = findNode(data, currentValue as string)\n      displayLabel = node?.label ?? String(currentValue)\n    }\n\n    const filteredIds = this.filteredIds\n\n    return html`\n      <div part=\"base\" class=\"relative w-full\">\n        <button\n          part=\"trigger\"\n          type=\"button\"\n          role=\"combobox\"\n          aria-expanded=${this.isOpen ? 'true' : 'false'}\n          aria-label=${this.accessibleLabel ?? nothing}\n          ?disabled=${this.disabled || this.loading}\n          data-slot=\"tree-select\"\n          class=${cn(treeSelectTriggerVariants({ size: this.size }))}\n          @click=${this.toggleOpen}\n        >\n          <span class=${cn('flex-1 truncate text-left', hasVal ? 'text-foreground' : 'text-muted-foreground')}>\n            ${displayLabel}\n          </span>\n          <span class=\"flex shrink-0 items-center gap-1\">\n            ${this.loading\n              ? icon(Loader2, 'loader-2', 'text-muted-foreground size-4 animate-spin')\n              : this.clearable && hasVal && !this.disabled\n                ? html`\n                    <span\n                      role=\"button\"\n                      tabindex=\"0\"\n                      aria-label=\"Clear selection\"\n                      class=\"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 flex size-4 items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:outline-none cursor-pointer\"\n                      @click=${(e: Event) => this.clearAll(e)}\n                    >\n                      ${icon(X, 'x', 'size-4')}\n                    </span>\n                  `\n                : html`\n                    <span class=${cn('text-muted-foreground size-4 shrink-0 transition-transform duration-200', this.isOpen && 'rotate-180')}>\n                      ${icon(ChevronDown, 'chevron-down', 'size-4')}\n                    </span>\n                  `}\n          </span>\n        </button>\n\n        ${this.isOpen\n          ? html`\n              <div\n                part=\"content\"\n                data-slot=\"tree-select-popover\"\n                style=${styleMap(this.pos)}\n                class=\"bg-popover text-popover-foreground fixed inset-auto m-0 z-50 max-h-80 min-w-44 overflow-hidden rounded-md border shadow-md\"\n              >\n                <div class=\"flex max-h-80 flex-col\">\n                  ${this.searchable\n                    ? html`\n                        <div class=\"border-b p-2\">\n                          <div class=\"relative\">\n                            <span class=\"text-muted-foreground absolute top-1/2 left-2.5 size-4 -translate-y-1/2 pointer-events-none\">\n                              ${icon(Search, 'search', 'size-4')}\n                            </span>\n                            <input\n                              .value=${this.search}\n                              placeholder=${this.searchPlaceholder}\n                              aria-label=\"Search tree\"\n                              class=\"border-input focus-visible:ring-ring/50 h-9 w-full rounded-md border bg-transparent pl-8 text-sm shadow-xs outline-none focus-visible:ring-[3px] text-foreground placeholder:text-muted-foreground\"\n                              @input=${(e: Event) => (this.search = (e.target as HTMLInputElement).value)}\n                            />\n                          </div>\n                        </div>\n                      `\n                    : nothing}\n\n                  ${this.loading\n                    ? html`\n                        <div class=\"text-muted-foreground flex items-center justify-center gap-2 py-6 text-sm\">\n                          ${icon(Loader2, 'loader-2', 'size-4 animate-spin')}\n                          Loading...\n                        </div>\n                      `\n                    : data.length === 0 || (filteredIds && filteredIds.size === 0)\n                      ? html`<div class=\"text-muted-foreground py-6 text-center text-sm\">${this.emptyText}</div>`\n                      : html`\n                          <div class=\"flex-1 overflow-y-auto p-1 max-h-64\" role=\"tree\">\n                            ${data.map((node) => this.renderTreeNode(node, 0))}\n                          </div>\n                        `}\n                  ${multiple && Array.isArray(currentValue) && currentValue.length > 0\n                    ? html`\n                        <div class=\"flex items-center justify-between border-t px-2 py-1.5 text-xs\">\n                          <span class=\"text-muted-foreground\">${currentValue.length} selected</span>\n                          <button\n                            type=\"button\"\n                            class=\"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 rounded focus-visible:ring-2 focus-visible:outline-none\"\n                            @click=${() => this.clearAll()}\n                          >\n                            Clear all\n                          </button>\n                        </div>\n                      `\n                    : nothing}\n                </div>\n              </div>\n            `\n          : nothing}\n      </div>\n    `\n  }\n}\n\ncustomElements.get('uip-tree-select') || customElements.define('uip-tree-select', UipTreeSelect)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-tree-select': UipTreeSelect\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/tree-select/tree-select.ts"
    },
    {
      "path": "packages/elements-lit/src/components/tree-select/tree-select.variants.ts",
      "content": "import { 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 = ReturnType<typeof treeSelectTriggerVariants>\n",
      "type": "registry:file",
      "target": "src/uip/components/tree-select/tree-select.variants.ts"
    }
  ],
  "dependencies": [
    "class-variance-authority",
    "lit",
    "lucide"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
