{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tree-select","type":"registry:ui","title":"Tree Select","description":"A field that picks from nested options: province, city and district, or a product's category, shown as its whole path.","dependencies":["lucide-react"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json","https://dashral.com/r/field.json","https://dashral.com/r/popover.json","https://dashral.com/r/tree-view.json"],"files":[{"path":"registry/dashral/tree-select.tsx","type":"registry:file","target":"components/dashral/tree-select.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronRightIcon, ChevronsUpDownIcon, SearchIcon, XIcon } from \"lucide-react\"\n\nimport { useLocale } from \"./lib/locale\"\nimport { cn } from \"./lib/utils\"\nimport { FieldWrapper, splitFieldProps, useFieldControl, type FieldClassNames, type FieldControlProps, type FieldOwnProps } from \"./field\"\nimport { PopoverRoot, PopoverAnchor, PopoverContent, PopoverTrigger } from \"./popover\"\nimport { TreeView, type TreeItem } from \"./tree-view\"\n\ntype TreeSelectBase = FieldOwnProps & {\n  /** The nested options; each id is unique in the whole tree. Children make an item a branch. */\n  items: TreeItem[]\n  placeholder?: string\n  searchPlaceholder?: string\n  /** Shown when the search matches nothing. */\n  emptyText?: string\n  /** A search box above the tree. On by default. */\n  searchable?: boolean\n  /** The branches open the first time it opens; \"all\" opens every one. The branches leading to the value open too. */\n  defaultExpanded?: string[] | \"all\"\n  /** Sends the value with a form: one hidden input, or one per value with multiple. */\n  name?: string\n  id?: string\n  disabled?: boolean\n  required?: boolean\n  \"aria-invalid\"?: React.AriaAttributes[\"aria-invalid\"]\n  \"aria-label\"?: string\n  /** The field's classes. */\n  className?: string\n  classNames?: FieldClassNames & { trigger?: string; content?: string; tree?: string }\n}\n\ntype TreeSelectSingle = TreeSelectBase & {\n  multiple?: false\n  /** The picked item's id, controlled; null for none. */\n  value?: string | null\n  defaultValue?: string | null\n  onValueChange?: (value: string | null) => void\n  /** \"leaf\": only items without children can be picked, and a branch only opens. Any item by default. */\n  selectable?: \"any\" | \"leaf\"\n}\n\ntype TreeSelectMultiple = TreeSelectBase & {\n  /** A checkbox on every row: a branch ticks all of its items, and is half-ticked when some are. */\n  multiple: true\n  /** The ticked items without children, controlled. */\n  value?: string[]\n  defaultValue?: string[]\n  onValueChange?: (value: string[]) => void\n  /** Chips shown before the rest collapse into \"+3\". */\n  maxShown?: number\n  selectable?: never\n}\n\ntype TreeSelectProps = TreeSelectSingle | TreeSelectMultiple\n\nconst isBranch = (item: TreeItem) => (item.children?.length ?? 0) > 0\nconst textOf = (item: TreeItem) => item.textValue ?? (typeof item.label === \"string\" ? item.label : \"\")\n\n/** Each item by id, its parent, and the enabled items without children under it. */\nfunction indexItems(items: TreeItem[]) {\n  const byId = new Map<string, TreeItem>()\n  const parentOf = new Map<string, string | null>()\n  const leavesOf = new Map<string, string[]>()\n  const walk = (list: TreeItem[], parent: string | null): string[] =>\n    list.flatMap((item) => {\n      byId.set(item.id, item)\n      parentOf.set(item.id, parent)\n      const leaves = isBranch(item) ? walk(item.children!, item.id) : item.disabled ? [] : [item.id]\n      leavesOf.set(item.id, leaves)\n      return leaves\n    })\n  walk(items, null)\n  return { byId, parentOf, leavesOf }\n}\n\n/** The ids from the top of the tree down to this one. */\nfunction pathTo(id: string, parentOf: Map<string, string | null>) {\n  const path: string[] = []\n  for (let at: string | null | undefined = id; at; at = parentOf.get(at)) path.unshift(at)\n  return path\n}\n\n/**\n * A field that picks from nested options, in one tag: a province, city and district, or a product's category. It\n * shows the whole path of what's picked; the list opens as a tree with a search. With multiple, every row has a\n * checkbox and what's ticked shows as chips, a whole ticked branch as one.\n */\nfunction TreeSelect(props: TreeSelectProps) {\n  const [field, { required: _required, ...control }] = splitFieldProps(props)\n  return (\n    <FieldWrapper {...field}>\n      {/* The label as text names the tree inside the list too. */}\n      <TreeSelectControl {...(control as ControlProps)} treeLabel={typeof props.label === \"string\" ? props.label : undefined} />\n    </FieldWrapper>\n  )\n}\n\ntype ControlProps = Omit<TreeSelectProps, keyof FieldOwnProps | \"required\">\n\nfunction TreeSelectControl({ treeLabel, items, ...props }: ControlProps & { treeLabel?: string }) {\n  const {\n    placeholder: placeholderProp,\n    searchPlaceholder: searchPlaceholderProp,\n    emptyText: emptyTextProp,\n    searchable = true,\n    defaultExpanded,\n    name,\n    className,\n    classNames,\n    \"aria-label\": ariaLabel,\n  } = props\n  const multiple = props.multiple === true\n  const leafOnly = !multiple && (props as TreeSelectSingle).selectable === \"leaf\"\n  const maxShown = multiple ? ((props as TreeSelectMultiple).maxShown ?? 3) : 0\n\n  const { t } = useLocale()\n  const placeholder = placeholderProp ?? t(multiple ? \"selectOptions\" : \"selectOption\")\n  const searchPlaceholder = searchPlaceholderProp ?? t(\"search\")\n  const emptyText = emptyTextProp ?? t(\"noMatches\")\n  const { id, disabled, ...aria } = useFieldControl<FieldControlProps>({ id: props.id, disabled: props.disabled, \"aria-invalid\": props[\"aria-invalid\"] })\n  const { byId, parentOf, leavesOf } = React.useMemo(() => indexItems(items), [items])\n\n  // The value, controlled or not: a single id (or null), or the ticked ids.\n  const [ownSingle, setOwnSingle] = React.useState<string | null>(multiple ? null : ((props as TreeSelectSingle).defaultValue ?? null))\n  const [ownMultiple, setOwnMultiple] = React.useState<string[]>(multiple ? ((props as TreeSelectMultiple).defaultValue ?? []) : [])\n  const single = multiple ? null : (props as TreeSelectSingle).value !== undefined ? ((props as TreeSelectSingle).value ?? null) : ownSingle\n  const ticked = multiple ? ((props as TreeSelectMultiple).value ?? ownMultiple) : []\n  // Ids that aren't in the tree (any more) are left out of what's shown.\n  const picked = single !== null && byId.has(single) ? single : null\n  const tickedKnown = ticked.filter((leaf) => byId.has(leaf))\n\n  function setSingle(next: string | null) {\n    if ((props as TreeSelectSingle).value === undefined) setOwnSingle(next)\n    ;(props as TreeSelectSingle).onValueChange?.(next)\n  }\n  function setTicked(next: string[]) {\n    if ((props as TreeSelectMultiple).value === undefined) setOwnMultiple(next)\n    ;(props as TreeSelectMultiple).onValueChange?.(next)\n  }\n\n  const [open, setOpen] = React.useState(false)\n  const [query, setQuery] = React.useState(\"\")\n  const contentRef = React.useRef<HTMLDivElement>(null)\n  const searchRef = React.useRef<HTMLInputElement>(null)\n\n  // The open branches, kept between openings. Each time it opens, the branches leading to what's picked open too.\n  const [expanded, setExpanded] = React.useState<string[]>(() =>\n    defaultExpanded === \"all\" ? [...byId.values()].filter(isBranch).map((item) => item.id) : (defaultExpanded ?? [])\n  )\n  function openChange(next: boolean) {\n    if (disabled && next) return\n    setOpen(next)\n    if (!next) return setQuery(\"\")\n    const toReveal = multiple ? tickedKnown : picked ? [picked] : []\n    const ancestors = new Set(expanded)\n    toReveal.forEach((leaf) => pathTo(leaf, parentOf).slice(0, -1).forEach((branch) => ancestors.add(branch)))\n    setExpanded([...ancestors])\n  }\n\n  /** Moves focus from the search into the tree, onto its current row. */\n  function focusTree() {\n    const row = contentRef.current?.querySelector<HTMLElement>('[role=treeitem][tabindex=\"0\"]')\n    row?.focus()\n  }\n\n  // Chips: a branch whose items are all ticked shows as one chip, the highest such branch.\n  // Worked out on each render: it walks the tree once, which is cheap next to drawing it.\n  const chips = ((): string[] => {\n    if (!multiple) return []\n    const set = new Set(tickedKnown)\n    const full = (branchId: string) => {\n      const leaves = leavesOf.get(branchId) ?? []\n      return leaves.length > 0 && leaves.every((leaf) => set.has(leaf))\n    }\n    const out: string[] = []\n    const walk = (list: TreeItem[]) =>\n      list.forEach((item) => {\n        if (isBranch(item)) {\n          if (full(item.id)) out.push(item.id)\n          else walk(item.children!)\n        } else if (set.has(item.id)) out.push(item.id)\n      })\n    walk(items)\n    return out\n  })()\n\n  const removeChip = (chipId: string) => {\n    const leaves = isBranch(byId.get(chipId)!) ? (leavesOf.get(chipId) ?? []) : [chipId]\n    setTicked(ticked.filter((leaf) => !leaves.includes(leaf)))\n  }\n\n  const path = picked ? pathTo(picked, parentOf).map((step) => byId.get(step)!) : []\n  const hasValue = multiple ? tickedKnown.length > 0 : picked !== null\n\n  const trigger = (\n    <PopoverTrigger asChild>\n      <button\n        type=\"button\"\n        id={id}\n        role=\"combobox\"\n        aria-expanded={open}\n        aria-haspopup=\"tree\"\n        aria-label={ariaLabel}\n        aria-labelledby={ariaLabel ? undefined : aria[\"aria-labelledby\"]}\n        aria-describedby={aria[\"aria-describedby\"]}\n        aria-invalid={aria[\"aria-invalid\"]}\n        disabled={disabled}\n        onKeyDown={(event) => {\n          if (multiple && event.key === \"Backspace\" && chips.length > 0) {\n            event.preventDefault()\n            removeChip(chips.at(-1)!)\n          }\n        }}\n        className={cn(\n          multiple\n            ? \"flex h-7 min-w-16 flex-1 items-center justify-between gap-2 rounded-sm px-1.5 text-start outline-none\"\n            : \"flex h-9 w-full min-w-0 items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 text-start text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/15 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/15\",\n          !multiple && classNames?.trigger\n        )}\n      >\n        {multiple ? (\n          <span className={cn(\"truncate\", hasValue ? \"sr-only\" : \"text-muted-foreground\")}>\n            {hasValue ? t(\"selectedCount\", { count: tickedKnown.length }) : placeholder}\n          </span>\n        ) : path.length > 0 ? (\n          // The path, the picked item last and in full; the ones before it give way first when it's narrow.\n          <span data-slot=\"tree-select-value\" className=\"flex min-w-0 flex-1 items-center gap-1\">\n            {path.length > 1 && (\n              <span className=\"flex min-w-0 shrink items-center gap-1 text-muted-foreground\">\n                <span className=\"truncate\">{path.slice(0, -1).map(textOf).join(\" › \")}</span>\n                <ChevronRightIcon aria-hidden className=\"size-3.5 shrink-0 opacity-60 rtl:-scale-x-100\" />\n                <span className=\"sr-only\">, </span>\n              </span>\n            )}\n            <span className=\"max-w-full min-w-0 shrink-0 truncate\">{textOf(path.at(-1)!)}</span>\n          </span>\n        ) : (\n          <span className=\"truncate text-muted-foreground\">{placeholder}</span>\n        )}\n        <ChevronsUpDownIcon className=\"ms-auto size-4 shrink-0 opacity-50\" aria-hidden />\n      </button>\n    </PopoverTrigger>\n  )\n\n  return (\n    <PopoverRoot open={open} onOpenChange={openChange}>\n      {multiple ? (\n        <PopoverAnchor asChild>\n          <div\n            data-slot=\"tree-select\"\n            data-disabled={disabled || undefined}\n            className={cn(\n              \"flex min-h-9 w-full items-center gap-1 rounded-md border border-input bg-transparent py-1 pe-1 ps-1.5 text-sm shadow-xs transition-[color,box-shadow]\",\n              \"focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/15 data-disabled:pointer-events-none data-disabled:opacity-50\",\n              \"has-[[aria-invalid=true]]:border-destructive has-[[aria-invalid=true]]:ring-destructive/15\",\n              className,\n              classNames?.trigger\n            )}\n          >\n            {chips.length > 0 && (\n              <ul aria-label={t(\"selected\")} className=\"flex min-w-0 flex-wrap items-center gap-1\">\n                {chips.slice(0, maxShown).map((chipId) => {\n                  const label = textOf(byId.get(chipId)!)\n                  return (\n                    <li\n                      key={chipId}\n                      className=\"inline-flex h-6 max-w-48 animate-in items-center gap-0.5 rounded bg-secondary ps-2 pe-0.5 text-xs font-medium text-secondary-foreground duration-(--duration-fast) ease-enter fade-in-0 zoom-in-95 motion-reduce:animate-none\"\n                    >\n                      <span className=\"truncate\">{label}</span>\n                      <button\n                        type=\"button\"\n                        aria-label={t(\"remove\", { name: label })}\n                        className=\"flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground outline-none hover:bg-background/60 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/15\"\n                        onClick={() => removeChip(chipId)}\n                      >\n                        <XIcon className=\"size-3\" aria-hidden />\n                      </button>\n                    </li>\n                  )\n                })}\n                {chips.length > maxShown && <li className=\"px-1 text-xs text-muted-foreground tabular-nums\">+{chips.length - maxShown}</li>}\n              </ul>\n            )}\n            {trigger}\n          </div>\n        </PopoverAnchor>\n      ) : (\n        <div data-slot=\"tree-select\" className={cn(\"w-full\", className)}>\n          {trigger}\n        </div>\n      )}\n      {name &&\n        (multiple ? (\n          tickedKnown.map((leaf) => <input key={leaf} type=\"hidden\" name={name} value={leaf} />)\n        ) : (\n          <input type=\"hidden\" name={name} value={picked ?? \"\"} />\n        ))}\n      <PopoverContent\n        ref={contentRef}\n        align=\"start\"\n        // As tall as the room on screen allows; the tree scrolls inside it.\n        className={cn(\"flex max-h-(--radix-popover-content-available-height) w-(--radix-popover-trigger-width) min-w-64 flex-col overflow-hidden p-0\", classNames?.content)}\n        // Focus goes to the search, or straight to the tree without one.\n        onOpenAutoFocus={(event) => {\n          event.preventDefault()\n          requestAnimationFrame(() => {\n            // What's picked is scrolled into view, in a long tree.\n            contentRef.current?.querySelector('[role=treeitem][aria-selected=true], [role=treeitem][aria-checked=true]')?.scrollIntoView({ block: \"nearest\" })\n            if (searchable) searchRef.current?.focus()\n            else focusTree()\n          })\n        }}\n      >\n        {searchable && (\n          <div className=\"flex h-10 shrink-0 items-center gap-2 border-b px-3\">\n            <SearchIcon className=\"size-4 shrink-0 opacity-50\" aria-hidden />\n            <input\n              ref={searchRef}\n              type=\"text\"\n              role=\"searchbox\"\n              value={query}\n              onChange={(event) => setQuery(event.target.value)}\n              onKeyDown={(event) => {\n                if (event.key === \"ArrowDown\") {\n                  event.preventDefault()\n                  focusTree()\n                }\n              }}\n              placeholder={searchPlaceholder}\n              aria-label={searchPlaceholder}\n              autoComplete=\"off\"\n              spellCheck={false}\n              className=\"h-10 w-full bg-transparent text-sm outline-hidden placeholder:text-muted-foreground\"\n            />\n            {query && (\n              <button\n                type=\"button\"\n                aria-label={t(\"clearFilter\")}\n                onClick={() => {\n                  setQuery(\"\")\n                  searchRef.current?.focus()\n                }}\n                className=\"flex size-6 shrink-0 items-center justify-center rounded-sm text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50\"\n              >\n                <XIcon className=\"size-3.5\" aria-hidden />\n              </button>\n            )}\n          </div>\n        )}\n        <div\n          className=\"max-h-72 min-h-0 flex-1 overflow-y-auto p-1\"\n          // When a branch can be picked, its arrow only opens or closes it, so you can reach what's inside without\n          // picking the branch (its label picks it). The arrow is the row's first part.\n          onClickCapture={(event) => {\n            if (multiple || leafOnly) return\n            const target = event.target as HTMLElement\n            const row = target.closest<HTMLElement>(\"[data-slot=tree-row]\")\n            const arrow = row?.firstElementChild\n            const rowId = row?.closest<HTMLElement>(\"[role=treeitem]\")?.dataset.id\n            const item = rowId ? byId.get(rowId) : undefined\n            if (!arrow || !item || !isBranch(item) || !arrow.contains(target)) return\n            event.stopPropagation()\n            setExpanded(expanded.includes(item.id) ? expanded.filter((other) => other !== item.id) : [...expanded, item.id])\n          }}\n        >\n          <TreeView\n            items={items}\n            aria-label={ariaLabel ?? treeLabel ?? placeholder}\n            expanded={expanded}\n            onExpandedChange={setExpanded}\n            filter={query}\n            empty={emptyText}\n            checkable={multiple}\n            checked={multiple ? tickedKnown : undefined}\n            onCheckedChange={multiple ? setTicked : undefined}\n            value={multiple ? undefined : picked}\n            onValueChange={\n              multiple\n                ? undefined\n                : (next) => {\n                    const item = byId.get(next)\n                    if (!item || (leafOnly && isBranch(item))) return\n                    // Picking the current item again clears it, as in Combobox.\n                    setSingle(next === picked ? null : next)\n                    setOpen(false)\n                    setQuery(\"\")\n                  }\n            }\n            onKeyDown={(event) => {\n              // With selectable=\"leaf\", Enter or Space on a branch opens or closes it instead of picking it.\n              const rowId = (event.target as HTMLElement).closest<HTMLElement>(\"[role=treeitem]\")?.dataset.id\n              const item = rowId ? byId.get(rowId) : undefined\n              if (leafOnly && item && isBranch(item) && (event.key === \"Enter\" || event.key === \" \")) {\n                event.preventDefault()\n                setExpanded(expanded.includes(item.id) ? expanded.filter((other) => other !== item.id) : [...expanded, item.id])\n              }\n            }}\n            className={classNames?.tree}\n          />\n        </div>\n        {hasValue && (\n          <div className=\"shrink-0 border-t p-1\">\n            <button\n              type=\"button\"\n              onClick={() => {\n                if (multiple) setTicked([])\n                else {\n                  setSingle(null)\n                  setOpen(false)\n                }\n              }}\n              className=\"flex h-8 w-full items-center justify-center rounded-md text-sm text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50\"\n            >\n              {t(\"clearSelection\")}\n            </button>\n          </div>\n        )}\n      </PopoverContent>\n    </PopoverRoot>\n  )\n}\n\nexport { TreeSelect, type TreeSelectProps }\n"}],"cssVars":{"theme":{"ease-standard":"var(--ease-standard)","ease-enter":"var(--ease-enter)","ease-exit":"var(--ease-exit)","ease-spring":"var(--ease-spring)"},"light":{"duration-fast":"160ms","duration-standard":"240ms","duration-considered":"480ms","ease-standard":"cubic-bezier(0.2, 0, 0, 1)","ease-enter":"cubic-bezier(0, 0, 0, 1)","ease-exit":"cubic-bezier(0.3, 0, 1, 1)","ease-spring":"linear(0, 0.071 4%, 0.234 8%, 0.433 12%, 0.626 17%, 0.791 21%, 0.917 25%, 1.004 29%, 1.055 33%, 1.079 38%, 1.083 42%, 1.075 46%, 1.06 50%, 1.043 54%, 1.027 58%, 1.015 62%, 1.005 67%, 0.998 71%, 0.995 75%, 0.993 79%, 0.993 83%, 0.994 88%, 0.995 92%, 0.997 96%, 1)"},"dark":{}}}