{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tree-view","type":"registry:ui","title":"Tree View","description":"Nested items that open and close: files, categories or permissions.","dependencies":["lucide-react"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json"],"files":[{"path":"registry/dashral/tree-view.tsx","type":"registry:file","target":"components/dashral/tree-view.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { CheckIcon, ChevronRightIcon, MinusIcon } from \"lucide-react\"\n\nimport { useLocale } from \"./lib/locale\"\nimport { cn } from \"./lib/utils\"\n\ntype TreeItem = {\n  /** Unique in the whole tree: value, expanded and checked hold these. */\n  id: string\n  label: React.ReactNode\n  /** The label as plain text, for filter and type-to-jump, when label isn't a string. */\n  textValue?: string\n  icon?: React.ReactNode\n  /** Shown in place of icon while the branch is open, e.g. an open folder. */\n  openIcon?: React.ReactNode\n  /** At the end of the row, e.g. a count or a size. */\n  suffix?: React.ReactNode\n  children?: TreeItem[]\n  disabled?: boolean\n}\n\ntype CheckState = boolean | \"mixed\"\n\ntype TreeViewProps = Omit<React.ComponentProps<\"ul\">, \"defaultValue\" | \"defaultChecked\" | \"onSelect\"> & {\n  items: TreeItem[]\n  /** The open branches, controlled. */\n  expanded?: string[]\n  /** The branches open at first; \"all\" opens every one. */\n  defaultExpanded?: string[] | \"all\"\n  onExpandedChange?: (expanded: string[]) => void\n  /** The selected item, controlled. */\n  value?: string | null\n  defaultValue?: string | null\n  onValueChange?: (value: string) => void\n  /** A checkbox on every row; a branch is ticked when all of its items are, and half-ticked when some are. */\n  checkable?: boolean\n  /** The ticked leaves (items without children), controlled. */\n  checked?: string[]\n  defaultChecked?: string[]\n  onCheckedChange?: (checked: string[]) => void\n  /** Shows the items whose text holds this (and the branches leading to them, opened), with the match marked. */\n  filter?: string\n  /** Shown when the filter matches nothing. */\n  empty?: React.ReactNode\n  /** Guide lines down each open branch. */\n  lines?: boolean\n  classNames?: { item?: string; row?: string; icon?: string; label?: string; suffix?: string; group?: string; empty?: string }\n}\n\nconst isBranch = (item: TreeItem) => (item.children?.length ?? 0) > 0\nconst textOf = (item: TreeItem) => item.textValue ?? (typeof item.label === \"string\" ? item.label : \"\")\n\nfunction useControllable<T>(value: T | undefined, initial: T, onChange?: (value: T) => void) {\n  const [own, setOwn] = React.useState(initial)\n  const current = value === undefined ? own : value\n  const set = (next: T) => {\n    if (value === undefined) setOwn(next)\n    onChange?.(next)\n  }\n  return [current, set] as const\n}\n\n/** Everything the tree needs to know about its items, worked out once per items. */\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/** Which items the filter keeps: those that match, their ancestors (opened) and their descendants. */\nfunction filterItems(items: TreeItem[], query: string) {\n  const shown = new Set<string>()\n  const opened = new Set<string>()\n  const showAll = (list: TreeItem[]) => list.forEach((item) => (shown.add(item.id), item.children && showAll(item.children)))\n  const walk = (list: TreeItem[]): boolean => {\n    let any = false\n    for (const item of list) {\n      if (textOf(item).toLowerCase().includes(query)) {\n        shown.add(item.id)\n        if (item.children) showAll(item.children)\n        if (item.children && walk(item.children)) opened.add(item.id)\n        any = true\n      } else if (item.children && walk(item.children)) {\n        shown.add(item.id)\n        opened.add(item.id)\n        any = true\n      }\n    }\n    return any\n  }\n  walk(items)\n  return { shown, opened }\n}\n\n/** The label with the filter's match marked, when it's plain text. */\nfunction Highlight({ text, query }: { text: React.ReactNode; query: string }) {\n  if (typeof text !== \"string\" || !query) return text\n  const at = text.toLowerCase().indexOf(query)\n  if (at < 0) return text\n  return (\n    <>\n      {text.slice(0, at)}\n      <mark className=\"rounded-[3px] bg-warning/30 text-current\">{text.slice(at, at + query.length)}</mark>\n      {text.slice(at + query.length)}\n    </>\n  )\n}\n\n/**\n * Nested items that open and close, in one tag: a file explorer, categories or permissions. Pick one item, or tick\n * several with checkable; filter narrows it down. Follows the WAI-ARIA tree pattern.\n */\nfunction TreeView({\n  items,\n  expanded: expandedProp,\n  defaultExpanded,\n  onExpandedChange,\n  value: valueProp,\n  defaultValue = null,\n  onValueChange,\n  checkable = false,\n  checked: checkedProp,\n  defaultChecked = [],\n  onCheckedChange,\n  filter = \"\",\n  empty: emptyProp,\n  lines = true,\n  className,\n  classNames,\n  onKeyDown,\n  ...props\n}: TreeViewProps) {\n  const { t, dir } = useLocale()\n  const empty = emptyProp ?? t(\"noMatches\")\n  const { byId, parentOf, leavesOf } = React.useMemo(() => indexItems(items), [items])\n  const [expanded, setExpanded] = useControllable(\n    expandedProp,\n    defaultExpanded === \"all\" ? [...byId.values()].filter(isBranch).map((item) => item.id) : (defaultExpanded ?? []),\n    onExpandedChange\n  )\n  const [value, setValue] = useControllable<string | null>(valueProp, defaultValue, onValueChange as (value: string | null) => void)\n  const [checked, setChecked] = useControllable(checkedProp, defaultChecked, onCheckedChange)\n  const [focused, setFocused] = React.useState<string | null>(null)\n  const baseId = React.useId()\n\n  const query = filter.trim().toLowerCase()\n  const filtered = React.useMemo(() => (query ? filterItems(items, query) : null), [items, query])\n  // While filtering, the branches leading to a match are open; closing one is remembered until the filter changes.\n  const [closedWhileFiltering, setClosedWhileFiltering] = React.useState({ query, ids: new Set<string>() })\n  if (closedWhileFiltering.query !== query) setClosedWhileFiltering({ query, ids: new Set() })\n\n  const expandedSet = new Set(expanded)\n  const isOpen = (id: string) =>\n    filtered?.opened.has(id) ? !closedWhileFiltering.ids.has(id) : expandedSet.has(id)\n  const isShown = (id: string) => !filtered || filtered.shown.has(id)\n\n  const toggle = (id: string, open = !isOpen(id)) => {\n    if (filtered?.opened.has(id)) {\n      const ids = new Set(closedWhileFiltering.ids)\n      if (open) ids.delete(id)\n      else ids.add(id)\n      setClosedWhileFiltering({ query, ids })\n    } else if (open !== expandedSet.has(id)) {\n      setExpanded(open ? [...expanded, id] : expanded.filter((other) => other !== id))\n    }\n  }\n\n  const checkedSet = new Set(checked)\n  const checkState = (id: string): CheckState => {\n    const leaves = leavesOf.get(id) ?? []\n    if (!isBranch(byId.get(id)!)) return checkedSet.has(id)\n    const ticked = leaves.filter((leaf) => checkedSet.has(leaf)).length\n    return ticked === 0 ? false : ticked === leaves.length ? true : \"mixed\"\n  }\n  const toggleChecked = (id: string) => {\n    const leaves = leavesOf.get(id) ?? []\n    const next = new Set(checkedSet)\n    if (checkState(id) === true) leaves.forEach((leaf) => next.delete(leaf))\n    else leaves.forEach((leaf) => next.add(leaf))\n    setChecked([...next])\n  }\n\n  // The rows on screen, top to bottom: what the arrow keys move through.\n  const visible: string[] = []\n  const collect = (list: TreeItem[]) =>\n    list.forEach((item) => {\n      if (!isShown(item.id)) return\n      visible.push(item.id)\n      if (isBranch(item) && isOpen(item.id)) collect(item.children!)\n    })\n  collect(items)\n\n  // One row takes Tab (roving tabindex): the focused one, else the selected one, else the first.\n  const tabStop = [focused, value].find((id) => id && visible.includes(id)) ?? visible[0]\n\n  const rows = React.useRef(new Map<string, HTMLLIElement>())\n  const focusRow = (id: string | undefined) => {\n    if (!id) return\n    setFocused(id)\n    rows.current.get(id)?.focus()\n  }\n\n  // Branches are rendered once first opened, then kept so they can slide closed.\n  const rendered = React.useRef(new Set<string>())\n\n  const activate = (item: TreeItem) => {\n    if (item.disabled) return\n    if (checkable) toggleChecked(item.id)\n    else setValue(item.id)\n  }\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLUListElement>) => {\n    onKeyDown?.(event)\n    const id = (event.target as HTMLElement).closest<HTMLElement>(\"[role=treeitem]\")?.dataset.id\n    if (event.defaultPrevented || !id || event.altKey || event.ctrlKey || event.metaKey) return\n    const item = byId.get(id)!\n    const at = visible.indexOf(id)\n    const handled = () => event.preventDefault()\n    // Right to left, the arrow pointing into a branch is the left one.\n    const key = dir === \"rtl\" && event.key === \"ArrowLeft\" ? \"ArrowRight\" : dir === \"rtl\" && event.key === \"ArrowRight\" ? \"ArrowLeft\" : event.key\n    switch (key) {\n      case \"ArrowDown\":\n        handled()\n        return focusRow(visible[at + 1])\n      case \"ArrowUp\":\n        handled()\n        return focusRow(visible[at - 1])\n      case \"Home\":\n        handled()\n        return focusRow(visible[0])\n      case \"End\":\n        handled()\n        return focusRow(visible.at(-1))\n      case \"ArrowRight\":\n        handled()\n        if (!isBranch(item)) return\n        return isOpen(id) ? focusRow(item.children!.find((child) => isShown(child.id))?.id) : toggle(id, true)\n      case \"ArrowLeft\":\n        handled()\n        if (isBranch(item) && isOpen(id)) return toggle(id, false)\n        return focusRow(parentOf.get(id) ?? undefined)\n      case \"Enter\":\n      case \" \":\n        handled()\n        return activate(item)\n      case \"*\": {\n        // Opens every branch beside this one.\n        handled()\n        const parent = parentOf.get(id)\n        const siblings = (parent ? byId.get(parent)!.children! : items).filter((sibling) => isBranch(sibling) && isShown(sibling.id))\n        const ids = siblings.filter((sibling) => !isOpen(sibling.id)).map((sibling) => sibling.id)\n        const plain = ids.filter((other) => !filtered?.opened.has(other))\n        if (plain.length) setExpanded([...expanded, ...plain])\n        if (plain.length < ids.length) {\n          const closed = new Set(closedWhileFiltering.ids)\n          ids.forEach((other) => closed.delete(other))\n          setClosedWhileFiltering({ query, ids: closed })\n        }\n        return\n      }\n      default:\n        // Type a letter to jump to the next row that starts with it.\n        if (event.key.length === 1 && /\\S/.test(event.key)) {\n          handled()\n          const key = event.key.toLowerCase()\n          const order = [...visible.slice(at + 1), ...visible.slice(0, at)]\n          focusRow(order.find((other) => textOf(byId.get(other)!).toLowerCase().startsWith(key)))\n        }\n    }\n  }\n\n  const renderItems = (list: TreeItem[], level: number): React.ReactNode => {\n    const shown = list.filter((item) => isShown(item.id))\n    return shown.map((item, index) => {\n      const branch = isBranch(item)\n      const open = branch && isOpen(item.id)\n      if (open) rendered.current.add(item.id)\n      const state = checkable ? checkState(item.id) : undefined\n      const selected = !checkable && value === item.id\n      const labelId = `${baseId}-${item.id}`\n      return (\n        <li\n          key={item.id}\n          ref={(node) => {\n            if (node) rows.current.set(item.id, node)\n            else rows.current.delete(item.id)\n          }}\n          role=\"treeitem\"\n          data-id={item.id}\n          aria-labelledby={labelId}\n          aria-level={level}\n          aria-setsize={shown.length}\n          aria-posinset={index + 1}\n          aria-expanded={branch ? open : undefined}\n          aria-selected={checkable ? undefined : selected}\n          aria-checked={state}\n          aria-disabled={item.disabled || undefined}\n          tabIndex={item.id === tabStop ? 0 : -1}\n          onFocus={(event) => event.target === event.currentTarget && setFocused(item.id)}\n          className={cn(\"outline-none [&:focus-visible>[data-slot=tree-row]]:ring-2 [&:focus-visible>[data-slot=tree-row]]:ring-ring/50\", classNames?.item)}\n        >\n          <div\n            data-slot=\"tree-row\"\n            data-selected={selected || undefined}\n            data-disabled={item.disabled || undefined}\n            onClick={() => {\n              focusRow(item.id)\n              if (branch) toggle(item.id)\n              if (!checkable || !branch) activate(item)\n            }}\n            className={cn(\n              \"relative flex h-8 cursor-default items-center gap-1.5 rounded-md px-1.5 text-sm transition-colors select-none hover:bg-accent/60 data-disabled:pointer-events-none data-disabled:opacity-50 data-selected:bg-accent data-selected:font-medium data-selected:text-accent-foreground\",\n              // On a guide line, the selected row marks its stretch of the line.\n              lines && level > 1 && \"data-selected:before:absolute data-selected:before:inset-y-1 data-selected:before:-start-[7px] data-selected:before:w-px data-selected:before:bg-primary\",\n              classNames?.row\n            )}\n          >\n            <span aria-hidden className=\"flex size-4 shrink-0 items-center justify-center text-muted-foreground\">\n              {branch && (\n                <ChevronRightIcon\n                  className={cn(\"size-4 transition-transform duration-(--duration-standard) ease-spring rtl:-scale-x-100\", open && \"rotate-90 rtl:-rotate-90\")}\n                />\n              )}\n            </span>\n            {checkable && (\n              <span\n                aria-hidden\n                data-state={state === \"mixed\" ? \"indeterminate\" : state ? \"checked\" : \"unchecked\"}\n                onClick={(event) => {\n                  // The box ticks a branch without opening it.\n                  event.stopPropagation()\n                  focusRow(item.id)\n                  activate(item)\n                }}\n                className=\"flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input shadow-xs transition-[color,background-color,border-color] data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground\"\n              >\n                {state === true && <CheckIcon className=\"size-3.5 animate-in zoom-in-50 fade-in-0 duration-(--duration-standard) ease-spring motion-reduce:animate-none\" />}\n                {state === \"mixed\" && <MinusIcon className=\"size-3.5\" />}\n              </span>\n            )}\n            {(item.icon || item.openIcon) && (\n              <span aria-hidden className={cn(\"flex shrink-0 text-muted-foreground [&_svg]:size-4\", classNames?.icon)}>\n                {open ? (item.openIcon ?? item.icon) : item.icon}\n              </span>\n            )}\n            <span id={labelId} className={cn(\"min-w-0 flex-1 truncate\", classNames?.label)}>\n              <Highlight text={item.label} query={query} />\n            </span>\n            {item.suffix != null && (\n              <span className={cn(\"shrink-0 text-xs text-muted-foreground tabular-nums\", classNames?.suffix)}>{item.suffix}</span>\n            )}\n          </div>\n          {branch && (open || rendered.current.has(item.id)) && (\n            // Opens and closes by sliding grid rows between 0fr and 1fr, so it follows the content's height.\n            <div\n              inert={!open}\n              className={cn(\n                \"grid transition-[grid-template-rows,opacity] duration-(--duration-standard) ease-standard motion-reduce:transition-none\",\n                open ? \"grid-rows-[1fr] opacity-100\" : \"grid-rows-[0fr] opacity-0\"\n              )}\n            >\n              <div className=\"min-h-0 overflow-hidden\">\n                <ul role=\"group\" className={cn(\"ms-3.5 border-s ps-1.5\", lines ? \"border-border\" : \"border-transparent\", classNames?.group)}>\n                  {renderItems(item.children!, level + 1)}\n                </ul>\n              </div>\n            </div>\n          )}\n        </li>\n      )\n    })\n  }\n\n  if (visible.length === 0 && query) {\n    return (\n      <p data-slot=\"tree-view-empty\" className={cn(\"px-2 py-6 text-center text-sm text-muted-foreground\", className, classNames?.empty)}>\n        {empty}\n      </p>\n    )\n  }\n\n  return (\n    <ul data-slot=\"tree-view\" role=\"tree\" onKeyDown={handleKeyDown} className={cn(\"text-sm\", className)} {...props}>\n      {renderItems(items, 1)}\n    </ul>\n  )\n}\n\nexport { TreeView, type TreeViewProps, type TreeItem }\n"}],"cssVars":{"theme":{"color-warning":"var(--warning)","color-warning-foreground":"var(--warning-foreground)","color-warning-text":"var(--warning-text)","ease-standard":"var(--ease-standard)","ease-enter":"var(--ease-enter)","ease-exit":"var(--ease-exit)","ease-spring":"var(--ease-spring)"},"light":{"warning":"#f59200","warning-foreground":"oklch(0.3 0.07 60)","warning-text":"#964e00","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":{"warning":"#f59200","warning-foreground":"oklch(0.25 0.06 60)","warning-text":"#ffba42"}}}