{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tree-view","type":"registry:component","title":"Tree View","description":"Keyboard-first WAI-ARIA tree with spring-rotating chevrons, height-morphing nested groups, indent guide lines, a selection pill that glides between rows via shared layout, typeahead, and controlled or uncontrolled selection and expansion.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/tree-view.tsx","type":"registry:component","target":"@components/motion/tree-view.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/motion/tree-view\n\nimport { ChevronRight } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  LayoutGroup,\n  motion,\n  type Transition,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type MouseEvent,\n  type ReactNode,\n  useCallback,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type TreeNode = {\n  id: string;\n  label: ReactNode;\n  icon?: ReactNode;\n  /** Icon shown instead of `icon` while the node is expanded (open folder). */\n  expandedIcon?: ReactNode;\n  children?: TreeNode[];\n  disabled?: boolean;\n  /** Plain text used for typeahead when `label` is not a string. */\n  textValue?: string;\n};\n\nexport interface TreeViewProps {\n  nodes: TreeNode[];\n  /** Selected node id (controlled). */\n  value?: string | null;\n  defaultValue?: string | null;\n  onValueChange?: (id: string | null, node: TreeNode | null) => void;\n  /** Expanded node ids (controlled). */\n  expanded?: string[];\n  defaultExpanded?: string[];\n  onExpandedChange?: (expanded: string[]) => void;\n  /** Horizontal indent per level, px. */\n  indent?: number;\n  /** Show vertical guide lines for nested groups. */\n  guides?: boolean;\n  \"aria-label\"?: string;\n  \"aria-labelledby\"?: string;\n  className?: string;\n}\n\ntype FlatNode = {\n  node: TreeNode;\n  depth: number;\n  parentId: string | null;\n};\n\n// Height morph for opening groups. Close is quicker than open (exits faster\n// than entrances); opacity rides a shorter tween so rows never smear.\nconst GROUP_OPEN: Transition = {\n  height: { type: \"spring\", stiffness: 420, damping: 38, mass: 0.6 },\n  opacity: { duration: 0.18, ease: EASE_OUT },\n};\nconst GROUP_CLOSE: Transition = {\n  height: { duration: 0.2, ease: EASE_OUT },\n  opacity: { duration: 0.12, ease: EASE_OUT },\n};\nconst INSTANT: Transition = { duration: 0 };\nconst FADE_ONLY: Transition = { duration: 0.15, ease: EASE_OUT };\n\nconst TYPEAHEAD_RESET_MS = 500;\n\nfunction textOf(node: TreeNode) {\n  if (node.textValue) return node.textValue;\n  return typeof node.label === \"string\" || typeof node.label === \"number\"\n    ? String(node.label)\n    : \"\";\n}\n\nfunction useControllable<T>(\n  value: T | undefined,\n  defaultValue: T,\n  onChange?: (next: T) => void,\n) {\n  const [internal, setInternal] = useState(defaultValue);\n  const controlled = value !== undefined;\n  const current = controlled ? value : internal;\n  const set = useCallback(\n    (next: T) => {\n      if (!controlled) setInternal(next);\n      onChange?.(next);\n    },\n    [controlled, onChange],\n  );\n  return [current, set] as const;\n}\n\nexport function TreeView({\n  nodes,\n  value,\n  defaultValue = null,\n  onValueChange,\n  expanded: expandedProp,\n  defaultExpanded = [],\n  onExpandedChange,\n  indent = 16,\n  guides = true,\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledby,\n  className,\n}: TreeViewProps) {\n  const reduce = useReducedMotion();\n  const baseId = useId();\n  const [selected, setSelectedRaw] = useControllable<string | null>(\n    value,\n    defaultValue,\n  );\n  const [expandedList, setExpanded] = useControllable<string[]>(\n    expandedProp,\n    defaultExpanded,\n    onExpandedChange,\n  );\n  const expandedSet = useMemo(() => new Set(expandedList), [expandedList]);\n  const [focusedId, setFocusedId] = useState<string | null>(null);\n  const itemRefs = useRef(new Map<string, HTMLDivElement>());\n  const typeahead = useRef({ buffer: \"\", timer: 0 });\n\n  // Index of every node (visible or not) for parent lookups.\n  const index = useMemo(() => {\n    const map = new Map<string, FlatNode>();\n    const walk = (list: TreeNode[], depth: number, parentId: string | null) => {\n      for (const node of list) {\n        map.set(node.id, { node, depth, parentId });\n        if (node.children) walk(node.children, depth + 1, node.id);\n      }\n    };\n    walk(nodes, 0, null);\n    return map;\n  }, [nodes]);\n\n  // Visible rows in document order — the keyboard moves along this list.\n  const visible = useMemo(() => {\n    const out: FlatNode[] = [];\n    const walk = (list: TreeNode[], depth: number, parentId: string | null) => {\n      for (const node of list) {\n        out.push({ node, depth, parentId });\n        if (node.children?.length && expandedSet.has(node.id)) {\n          walk(node.children, depth + 1, node.id);\n        }\n      }\n    };\n    walk(nodes, 0, null);\n    return out;\n  }, [nodes, expandedSet]);\n\n  const visibleIds = useMemo(\n    () => new Set(visible.map((entry) => entry.node.id)),\n    [visible],\n  );\n\n  // Roving tabindex target: last focused row if still visible, else the\n  // selection, else the first row.\n  const tabStop =\n    (focusedId && visibleIds.has(focusedId) && focusedId) ||\n    (selected && visibleIds.has(selected) && selected) ||\n    visible[0]?.node.id ||\n    null;\n\n  const select = useCallback(\n    (id: string) => {\n      const entry = index.get(id);\n      if (!entry || entry.node.disabled) return;\n      setSelectedRaw(id);\n      onValueChange?.(id, entry.node);\n    },\n    [index, onValueChange, setSelectedRaw],\n  );\n\n  const setOpen = useCallback(\n    (id: string, open: boolean) => {\n      if (expandedSet.has(id) === open) return;\n      setExpanded(\n        open\n          ? [...expandedList, id]\n          : expandedList.filter((existing) => existing !== id),\n      );\n    },\n    [expandedList, expandedSet, setExpanded],\n  );\n\n  const focusNode = useCallback((id: string | undefined) => {\n    if (!id) return;\n    setFocusedId(id);\n    itemRefs.current.get(id)?.focus();\n  }, []);\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>, id: string) => {\n    // Nested treeitems bubble — only the row that owns focus reacts.\n    if (event.target !== event.currentTarget) return;\n    const entry = index.get(id);\n    if (!entry) return;\n    const position = visible.findIndex((item) => item.node.id === id);\n    const hasChildren = Boolean(entry.node.children?.length);\n    const isOpen = expandedSet.has(id);\n\n    switch (event.key) {\n      case \"ArrowDown\":\n        event.preventDefault();\n        focusNode(visible[position + 1]?.node.id);\n        return;\n      case \"ArrowUp\":\n        event.preventDefault();\n        focusNode(visible[position - 1]?.node.id);\n        return;\n      case \"ArrowRight\":\n        event.preventDefault();\n        if (!hasChildren) return;\n        if (!isOpen) setOpen(id, true);\n        else focusNode(entry.node.children?.[0]?.id);\n        return;\n      case \"ArrowLeft\":\n        event.preventDefault();\n        if (hasChildren && isOpen) setOpen(id, false);\n        else focusNode(entry.parentId ?? undefined);\n        return;\n      case \"Home\":\n        event.preventDefault();\n        focusNode(visible[0]?.node.id);\n        return;\n      case \"End\":\n        event.preventDefault();\n        focusNode(visible[visible.length - 1]?.node.id);\n        return;\n      case \"Enter\":\n        event.preventDefault();\n        select(id);\n        if (hasChildren) setOpen(id, !isOpen);\n        return;\n      case \" \":\n        event.preventDefault();\n        select(id);\n        return;\n      case \"*\": {\n        // Expand every sibling of the focused node (WAI-ARIA optional key).\n        event.preventDefault();\n        const siblings = entry.parentId\n          ? (index.get(entry.parentId)?.node.children ?? [])\n          : nodes;\n        const toOpen = siblings\n          .filter((sibling) => sibling.children?.length)\n          .map((sibling) => sibling.id)\n          .filter((sibling) => !expandedSet.has(sibling));\n        if (toOpen.length) setExpanded([...expandedList, ...toOpen]);\n        return;\n      }\n      default:\n        break;\n    }\n\n    // Typeahead: printable characters jump to the next matching visible row.\n    if (\n      event.key.length === 1 &&\n      !event.ctrlKey &&\n      !event.metaKey &&\n      !event.altKey\n    ) {\n      const state = typeahead.current;\n      window.clearTimeout(state.timer);\n      state.buffer += event.key.toLowerCase();\n      state.timer = window.setTimeout(() => {\n        state.buffer = \"\";\n      }, TYPEAHEAD_RESET_MS);\n      const ordered = [\n        ...visible.slice(position + 1),\n        ...visible.slice(0, position + 1),\n      ];\n      const repeated = state.buffer.split(\"\").every((c) => c === state.buffer[0]);\n      const query = repeated ? state.buffer[0] : state.buffer;\n      const match = ordered.find((item) =>\n        textOf(item.node).toLowerCase().startsWith(query),\n      );\n      if (match) focusNode(match.node.id);\n    }\n  };\n\n  const handleClick = (event: MouseEvent<HTMLDivElement>, id: string) => {\n    event.stopPropagation();\n    const entry = index.get(id);\n    if (!entry || entry.node.disabled) return;\n    setFocusedId(id);\n    select(id);\n    if (entry.node.children?.length) setOpen(id, !expandedSet.has(id));\n  };\n\n  const renderList = (list: TreeNode[], depth: number): ReactNode =>\n    list.map((node, i) => {\n      const hasChildren = Boolean(node.children?.length);\n      const isOpen = hasChildren && expandedSet.has(node.id);\n      const isSelected = selected === node.id;\n      const groupId = `${baseId}-${node.id}-group`;\n\n      return (\n        <div\n          key={node.id}\n          ref={(el) => {\n            if (el) itemRefs.current.set(node.id, el);\n            else itemRefs.current.delete(node.id);\n          }}\n          role=\"treeitem\"\n          aria-level={depth + 1}\n          aria-setsize={list.length}\n          aria-posinset={i + 1}\n          aria-expanded={hasChildren ? isOpen : undefined}\n          aria-selected={isSelected}\n          aria-disabled={node.disabled || undefined}\n          tabIndex={tabStop === node.id ? 0 : -1}\n          onFocus={(event) => {\n            if (event.target === event.currentTarget) setFocusedId(node.id);\n          }}\n          onKeyDown={(event) => handleKeyDown(event, node.id)}\n          onClick={(event) => handleClick(event, node.id)}\n          className=\"outline-none\"\n        >\n          <div\n            data-selected={isSelected || undefined}\n            className={cn(\n              \"relative flex h-8 cursor-default select-none items-center gap-1.5 rounded-md pr-2 text-sm transition-colors\",\n              \"text-muted-foreground hover:text-foreground\",\n              \"data-selected:text-foreground\",\n              \"[:focus-visible>&]:ring-2 [:focus-visible>&]:ring-ring/60 [:focus-visible>&]:ring-inset\",\n              node.disabled && \"pointer-events-none opacity-50\",\n            )}\n            style={{ paddingLeft: depth * indent + 6 }}\n          >\n            {isSelected ? (\n              <motion.span\n                aria-hidden\n                layoutId={`${baseId}-tree-pill`}\n                transition={reduce ? INSTANT : SPRING_LAYOUT}\n                className=\"absolute inset-0 rounded-md bg-muted\"\n              />\n            ) : null}\n            <span className=\"relative grid size-4 shrink-0 place-items-center\">\n              {hasChildren ? (\n                <motion.span\n                  aria-hidden\n                  initial={false}\n                  animate={{ rotate: isOpen ? 90 : 0 }}\n                  transition={reduce ? INSTANT : SPRING_SWAP}\n                  className=\"grid place-items-center\"\n                >\n                  <ChevronRight className=\"size-3.5\" />\n                </motion.span>\n              ) : null}\n            </span>\n            {node.icon ? (\n              <span\n                aria-hidden\n                className=\"relative grid size-4 shrink-0 place-items-center [&>svg]:size-4\"\n              >\n                {isOpen && node.expandedIcon ? node.expandedIcon : node.icon}\n              </span>\n            ) : null}\n            <span className=\"relative min-w-0 flex-1 truncate\">\n              {node.label}\n            </span>\n          </div>\n\n          {hasChildren ? (\n            <AnimatePresence initial={false}>\n              {isOpen ? (\n                <motion.div\n                  key=\"group\"\n                  id={groupId}\n                  role=\"group\"\n                  initial={{ height: 0, opacity: 0 }}\n                  animate={{\n                    height: \"auto\",\n                    opacity: 1,\n                    transition: reduce ? FADE_ONLY : GROUP_OPEN,\n                  }}\n                  exit={{\n                    height: 0,\n                    opacity: 0,\n                    transition: reduce ? FADE_ONLY : GROUP_CLOSE,\n                  }}\n                  className=\"relative overflow-hidden\"\n                >\n                  {guides ? (\n                    <span\n                      aria-hidden\n                      className=\"pointer-events-none absolute inset-y-1 w-px bg-border\"\n                      style={{ left: depth * indent + 6 + 7.5 }}\n                    />\n                  ) : null}\n                  {renderList(node.children ?? [], depth + 1)}\n                </motion.div>\n              ) : null}\n            </AnimatePresence>\n          ) : null}\n        </div>\n      );\n    });\n\n  return (\n    <LayoutGroup id={baseId}>\n      <div\n        role=\"tree\"\n        aria-label={ariaLabel}\n        aria-labelledby={ariaLabelledby}\n        className={cn(\"flex w-full flex-col\", className)}\n      >\n        {renderList(nodes, 0)}\n      </div>\n    </LayoutGroup>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}