{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "components-primitives-tree-filter",
  "title": "Tree Filter",
  "description": "Animated tree-structured filter with nested checkboxes, search, and group cycling.",
  "dependencies": [
    "motion",
    "@hugeicons/react",
    "@hugeicons/core-free-icons"
  ],
  "registryDependencies": [
    "collapsible"
  ],
  "files": [
    {
      "path": "registry/components/primitives/tree-filter/index.tsx",
      "content": "'use client';\n\nimport {\n  useState,\n  useContext,\n  createContext,\n  ReactNode,\n  Dispatch,\n  SetStateAction,\n} from 'react';\nimport { motion, AnimatePresence } from 'motion/react';\nimport { HugeiconsIcon } from '@hugeicons/react';\nimport {\n  Search01Icon,\n  ArrowRight01Icon,\n  Cancel01Icon,\n} from '@hugeicons/core-free-icons';\nimport { Collapsible, CollapsibleTrigger } from '@/components/ui/collapsible';\n\ninterface TreeNode {\n  id: string;\n  label: string;\n  count?: number;\n  children?: TreeNode[];\n}\n\ntype CheckedState = 'checked' | 'unchecked' | 'indeterminate';\n\ninterface TreeFilterContextValue {\n  checked: Set<string>;\n  toggle: (ids: string[], value: boolean) => void;\n  search: string;\n  setSearch: Dispatch<SetStateAction<string>>;\n}\n\nconst TreeFilterContext = createContext<TreeFilterContextValue | null>(null);\n\nfunction useTreeFilter(): TreeFilterContextValue {\n  const ctx = useContext(TreeFilterContext);\n  if (!ctx) throw new Error('Must be used within <TreeFilter>');\n  return ctx;\n}\n\nfunction getAllLeafIds(node: TreeNode): string[] {\n  if (!node.children || node.children.length === 0) return [node.id];\n  return node.children.flatMap(getAllLeafIds);\n}\n\nfunction getCheckedState(node: TreeNode, checked: Set<string>): CheckedState {\n  const leaves = getAllLeafIds(node);\n  if (leaves.length === 0)\n    return checked.has(node.id) ? 'checked' : 'unchecked';\n  const count = leaves.filter((id: string) => checked.has(id)).length;\n  if (count === 0) return 'unchecked';\n  if (count === leaves.length) return 'checked';\n  return 'indeterminate';\n}\n\nfunction filterTree(nodes: TreeNode[], query: string): TreeNode[] {\n  if (!query) return nodes;\n  const result: TreeNode[] = [];\n  for (const node of nodes) {\n    const match = node.label.toLowerCase().includes(query.toLowerCase());\n    const filteredChildren = node.children\n      ? filterTree(node.children, query)\n      : [];\n    if (match || filteredChildren.length > 0)\n      result.push({ ...node, children: filteredChildren });\n  }\n  return result;\n}\n\nfunction TreeFilter({\n  children,\n  defaultChecked = [],\n}: {\n  children: ReactNode;\n  defaultChecked?: string[];\n}) {\n  const [checked, setChecked] = useState<Set<string>>(new Set(defaultChecked));\n  const [search, setSearch] = useState('');\n\n  const toggle = (ids: string[], value: boolean) => {\n    setChecked((prev) => {\n      const next = new Set(prev);\n      ids.forEach((id: string) => (value ? next.add(id) : next.delete(id)));\n      return next;\n    });\n  };\n\n  return (\n    <TreeFilterContext.Provider value={{ checked, toggle, search, setSearch }}>\n      <div className=\"w-72 h-[420px] bg-card border border-border rounded-xl shadow-lg text-sm flex flex-col overflow-hidden\">\n        {children}\n      </div>\n    </TreeFilterContext.Provider>\n  );\n}\n\nfunction TreeFilterSearch({\n  placeholder = 'Search...',\n}: {\n  placeholder?: string;\n}) {\n  const { search, setSearch } = useTreeFilter();\n  return (\n    <div className=\"flex items-center gap-2 px-3 py-2.5 border-b border-border sticky top-0 bg-card z-10 shrink-0\">\n      <HugeiconsIcon\n        icon={Search01Icon}\n        size={14}\n        className=\"shrink-0 text-muted-foreground\"\n      />\n      <input\n        value={search}\n        onChange={(e) => setSearch(e.target.value)}\n        placeholder={placeholder}\n        className=\"flex-1 bg-transparent border-none outline-none text-[13px] text-foreground placeholder:text-muted-foreground tracking-tight\"\n      />\n      <div className=\"flex items-center gap-1.5\">\n        <AnimatePresence>\n          {search && (\n            <motion.button\n              initial={{ opacity: 0, scale: 0.7 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={{ opacity: 0, scale: 0.7 }}\n              transition={{ duration: 0.12 }}\n              onClick={() => setSearch('')}\n              className=\"bg-transparent border-none text-muted-foreground hover:text-foreground cursor-pointer p-0 leading-none flex items-center\"\n            >\n              <HugeiconsIcon icon={Cancel01Icon} size={12} />\n            </motion.button>\n          )}\n        </AnimatePresence>\n        <span className=\"text-[11px] text-muted-foreground/40 font-medium\">\n          ESC\n        </span>\n      </div>\n    </div>\n  );\n}\n\nfunction TreeFilterGroup({\n  label,\n  children,\n}: {\n  label?: string;\n  children: ReactNode;\n}) {\n  return (\n    <div className=\"flex-1 overflow-y-auto py-2\">\n      {label && (\n        <div className=\"px-3 pt-0.5 pb-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-widest\">\n          {label}\n        </div>\n      )}\n      {children}\n    </div>\n  );\n}\n\nfunction TreeFilterList({ items }: { items: TreeNode[] }) {\n  const { search } = useTreeFilter();\n  const visible = filterTree(items, search);\n\n  if (visible.length === 0) {\n    return (\n      <div className=\"py-4 px-3 text-[13px] text-muted-foreground text-center\">\n        No results\n      </div>\n    );\n  }\n\n  return (\n    <>\n      {visible.map((node: TreeNode) => (\n        <TreeFilterNode key={node.id} node={node} depth={0} />\n      ))}\n    </>\n  );\n}\n\nfunction Checkbox({\n  state,\n  onChange,\n}: {\n  state: CheckedState;\n  onChange: () => void;\n}) {\n  const isChecked = state === 'checked';\n  const isIndet = state === 'indeterminate';\n\n  return (\n    <button\n      onClick={onChange}\n      className={`w-4.5 h-4.5 rounded-[5px] flex items-center justify-center shrink-0 cursor-pointer transition-colors duration-150 ${\n        isChecked || isIndet\n          ? 'bg-primary border-0'\n          : 'border border-border bg-transparent'\n      }`}\n    >\n      <AnimatePresence mode=\"wait\">\n        {isChecked && (\n          <motion.svg\n            key=\"check\"\n            width=\"10\"\n            height=\"8\"\n            viewBox=\"0 0 10 8\"\n            fill=\"none\"\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0 }}\n            transition={{ duration: 0.1 }}\n          >\n            <motion.path\n              d=\"M1 4L3.8 7L9 1\"\n              stroke=\"currentColor\"\n              className=\"text-primary-foreground\"\n              strokeWidth=\"1.8\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              initial={{ pathLength: 0 }}\n              animate={{ pathLength: 1 }}\n              transition={{ duration: 0.2, ease: 'easeOut' }}\n            />\n          </motion.svg>\n        )}\n\n        {isIndet && (\n          <motion.div\n            key=\"indet\"\n            className=\"h-0.5 rounded-sm bg-primary-foreground\"\n            style={{ width: 8 }}\n            initial={{ scaleX: 0 }}\n            animate={{ scaleX: 1 }}\n            exit={{ scaleX: 0 }}\n            transition={{ duration: 0.15 }}\n          />\n        )}\n      </AnimatePresence>\n    </button>\n  );\n}\n\nfunction TreeFilterNode({\n  node,\n  depth = 0,\n}: {\n  node: TreeNode;\n  depth?: number;\n}) {\n  const { checked, toggle } = useTreeFilter();\n  const [expanded, setExpanded] = useState(depth < 1);\n\n  const hasChildren = node.children && node.children.length > 0;\n  const checkedState = getCheckedState(node, checked);\n\n  const handleCheck = () => {\n    const leaves = getAllLeafIds(node);\n    toggle(leaves.length ? leaves : [node.id], checkedState !== 'checked');\n  };\n\n  return (\n    <Collapsible open={expanded} onOpenChange={setExpanded}>\n      <div\n        className=\"flex items-center gap-1.5 pr-2.5 py-1.25 mx-1 rounded-md cursor-pointer hover:bg-accent transition-colors duration-100\"\n        style={{ paddingLeft: `${8 + depth * 10}px` }}\n      >\n        {hasChildren ? (\n          <CollapsibleTrigger asChild>\n            <button className=\"bg-transparent border-none p-0 cursor-pointer flex items-center w-4 shrink-0 text-muted-foreground\">\n              <motion.span\n                animate={{ rotate: expanded ? 90 : 0 }}\n                transition={{ duration: 0.2, ease: 'easeInOut' }}\n                className=\"flex items-center\"\n              >\n                <HugeiconsIcon icon={ArrowRight01Icon} size={14} />\n              </motion.span>\n            </button>\n          </CollapsibleTrigger>\n        ) : (\n          <div className=\"w-4 shrink-0\" />\n        )}\n\n        <Checkbox state={checkedState} onChange={handleCheck} />\n\n        <span\n          onClick={handleCheck}\n          className={`flex-1 text-[13px] cursor-pointer select-none transition-colors duration-150 tracking-tight ${\n            checkedState === 'checked'\n              ? 'font-semibold text-foreground'\n              : 'font-normal text-muted-foreground'\n          }`}\n        >\n          {node.label}\n        </span>\n\n        {node.count != null && (\n          <span className=\"text-[11.5px] text-muted-foreground/50 tabular-nums\">\n            {node.count}\n          </span>\n        )}\n      </div>\n\n      <AnimatePresence initial={false}>\n        {expanded && hasChildren && (\n          <motion.div\n            initial={{ height: 0, opacity: 0 }}\n            animate={{ height: 'auto', opacity: 1 }}\n            exit={{ height: 0, opacity: 0 }}\n            transition={{ duration: 0.2, ease: 'easeInOut' }}\n            className=\"overflow-hidden\"\n          >\n            {node.children!.map((child: TreeNode) => (\n              <TreeFilterNode key={child.id} node={child} depth={depth + 1} />\n            ))}\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </Collapsible>\n  );\n}\n\nexport {\n  TreeFilter,\n  TreeFilterSearch,\n  TreeFilterGroup,\n  TreeFilterList,\n  TreeFilterNode,\n};\n",
      "type": "registry:ui",
      "target": "components/odysseyui/tree-filter.tsx"
    }
  ],
  "type": "registry:ui"
}