{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tree-08",
  "title": "Filterable tree",
  "description": "Only the matches and their folders stay in the tree.",
  "dependencies": [
    "@headless-tree/core",
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/animated-size.json",
    "https://ui.ndekocode.dev/r/icons.json",
    "https://ui.ndekocode.dev/r/input.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/tree.json",
    "https://ui.ndekocode.dev/r/use-prefers-reduced-motion.json",
    "https://ui.ndekocode.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/tree/tree-08.tsx",
      "content": "'use client';\n\nimport {\n  expandAllFeature,\n  hotkeysCoreFeature,\n  searchFeature,\n  selectionFeature,\n  syncDataLoaderFeature,\n  type ItemInstance,\n  type TreeState,\n} from '@headless-tree/core';\nimport {\n  AnimatePresence,\n  motion,\n  type Transition,\n  type Variants,\n} from 'motion/react';\nimport { useRef, useState, type ChangeEvent } from 'react';\n\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport {\n  CircleXIcon,\n  FilterIcon,\n  FolderIcon,\n  FolderOpenIcon,\n} from '@/registry/ndeko/lib/icons';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { AnimatedSize } from '@/registry/ndeko/ui/animated-size';\nimport { Input } from '@/registry/ndeko/ui/input';\nimport {\n  Tree,\n  TreeItem,\n  TreeItemLabel,\n  useTree,\n} from '@/registry/ndeko/ui/tree';\n\ntype Item = {\n  name: string;\n  children?: string[];\n};\n\nconst ITEMS: Record<string, Item> = {\n  company: {\n    name: 'Company',\n    children: ['engineering', 'marketing', 'operations'],\n  },\n  engineering: {\n    name: 'Engineering',\n    children: ['frontend', 'backend', 'platform-team'],\n  },\n  frontend: { name: 'Frontend', children: ['design-system', 'web-platform'] },\n  'design-system': {\n    name: 'Design System',\n    children: ['components', 'tokens', 'guidelines'],\n  },\n  components: { name: 'Components' },\n  tokens: { name: 'Tokens' },\n  guidelines: { name: 'Guidelines' },\n  'web-platform': { name: 'Web Platform' },\n  backend: { name: 'Backend', children: ['apis', 'infrastructure'] },\n  apis: { name: 'APIs' },\n  infrastructure: { name: 'Infrastructure' },\n  'platform-team': { name: 'Platform Team' },\n  marketing: { name: 'Marketing', children: ['content', 'seo'] },\n  content: { name: 'Content' },\n  seo: { name: 'SEO' },\n  operations: { name: 'Operations', children: ['hr', 'finance'] },\n  hr: { name: 'HR' },\n  finance: { name: 'Finance' },\n};\n\nconst INDENT = 20;\nconst ROOT_ID = 'company';\nconst EXPANDED_ITEMS = ['engineering', 'frontend', 'design-system'];\n\nconst CLEAR_BUTTON =\n  'text-muted-foreground hover:bg-foreground/5 hover:text-foreground focus-visible:ring-ring/25 absolute inset-y-0 end-1 my-auto flex size-6 items-center justify-center rounded-md outline-none transition-[color,background-color,box-shadow] focus-visible:ring-[3px]';\n\nconst SWAP_TRANSITION: Transition = { duration: 0.15, ease: [0.2, 0, 0, 1] };\n\nconst SWAP_VARIANTS: Variants = {\n  enter: { opacity: 0, scale: 0.6, filter: 'blur(2px)' },\n  visible: { opacity: 1, scale: 1, filter: 'blur(0px)' },\n  exit: { opacity: 0, scale: 0.6, filter: 'blur(2px)' },\n};\n\nconst FADE_VARIANTS: Variants = {\n  enter: { opacity: 0 },\n  visible: { opacity: 1 },\n  exit: { opacity: 0 },\n};\n\nconst GLYPH =\n  'text-muted-foreground col-start-1 row-start-1 size-4 transition-[opacity,scale,filter] duration-150 ease-out-strong';\n\nconst SHOWN_WHEN_CLOSED =\n  'in-aria-expanded:scale-60 in-aria-expanded:opacity-0 in-aria-expanded:blur-[2px]';\n\nconst SHOWN_WHEN_OPEN =\n  'scale-60 opacity-0 blur-[2px] in-aria-expanded:scale-100 in-aria-expanded:opacity-100 in-aria-expanded:blur-none';\n\nfunction FolderGlyph() {\n  return (\n    <span className='grid'>\n      <FolderIcon className={cn(GLYPH, SHOWN_WHEN_CLOSED)} />\n      <FolderOpenIcon className={cn(GLYPH, SHOWN_WHEN_OPEN)} />\n    </span>\n  );\n}\n\nfunction nameOf(item: ItemInstance<Item>) {\n  return item.getItemData().name;\n}\n\nfunction hasChildren(item: ItemInstance<Item>) {\n  const children = item.getItemData().children ?? [];\n  return children.length > 0;\n}\n\nfunction itemOf(itemId: string) {\n  return ITEMS[itemId];\n}\n\nfunction childrenOf(itemId: string) {\n  return ITEMS[itemId].children ?? [];\n}\n\nfunction swapVariantsOf(shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return FADE_VARIANTS;\n  }\n  return SWAP_VARIANTS;\n}\n\nfunction parentsOf(items: Record<string, Item>) {\n  const parents: Record<string, string> = {};\n  for (const [itemId, item] of Object.entries(items)) {\n    for (const childId of item.children ?? []) {\n      parents[childId] = itemId;\n    }\n  }\n  return parents;\n}\n\nconst PARENTS = parentsOf(ITEMS);\n\nfunction ancestorsOf(itemId: string) {\n  const ancestors: string[] = [];\n  let parentId = PARENTS[itemId];\n  while (parentId) {\n    ancestors.push(parentId);\n    parentId = PARENTS[parentId];\n  }\n  return ancestors;\n}\n\nfunction descendantsOf(itemId: string) {\n  const descendants: string[] = [];\n  for (const childId of childrenOf(itemId)) {\n    descendants.push(childId);\n    descendants.push(...descendantsOf(childId));\n  }\n  return descendants;\n}\n\nfunction matches(itemId: string, normalizedQuery: string) {\n  const name = ITEMS[itemId].name.toLowerCase();\n  return name.includes(normalizedQuery);\n}\n\nfunction visibleIdsFor(query: string) {\n  const normalizedQuery = query.toLowerCase();\n  const visibleIds = new Set<string>();\n  for (const itemId of descendantsOf(ROOT_ID)) {\n    if (!matches(itemId, normalizedQuery)) {\n      continue;\n    }\n    const related = [itemId, ...ancestorsOf(itemId), ...descendantsOf(itemId)];\n    related.forEach((relatedId) => visibleIds.add(relatedId));\n  }\n  return visibleIds;\n}\n\nexport default function Tree08() {\n  const [state, setState] = useState<Partial<TreeState<Item>>>({});\n  const [query, setQuery] = useState('');\n  const inputRef = useRef<HTMLInputElement>(null);\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const swapVariants = swapVariantsOf(shouldReduceMotion);\n  const hasQuery = query.length > 0;\n  const visibleIds = visibleIdsFor(query);\n  const hasNoMatch = hasQuery && visibleIds.size === 0;\n\n  const tree = useTree<Item>({\n    state,\n    setState,\n    initialState: { expandedItems: EXPANDED_ITEMS },\n    indent: INDENT,\n    rootItemId: ROOT_ID,\n    getItemName: nameOf,\n    isItemFolder: hasChildren,\n    dataLoader: { getItem: itemOf, getChildren: childrenOf },\n    features: [\n      syncDataLoaderFeature,\n      hotkeysCoreFeature,\n      selectionFeature,\n      searchFeature,\n      expandAllFeature,\n    ],\n  });\n\n  function isShown(itemId: string) {\n    if (!hasQuery) {\n      return true;\n    }\n    return visibleIds.has(itemId);\n  }\n\n  function applyQuery(value: string) {\n    setQuery(value);\n    tree.setSearch(value);\n    if (value.length > 0) {\n      void tree.expandAll();\n      return;\n    }\n    setState((current) => ({ ...current, expandedItems: EXPANDED_ITEMS }));\n  }\n\n  function changeQuery(event: ChangeEvent<HTMLInputElement>) {\n    applyQuery(event.target.value);\n  }\n\n  function clearQuery() {\n    applyQuery('');\n    inputRef.current?.focus();\n  }\n\n  return (\n    <div className='flex w-full flex-col gap-2'>\n      <div className='relative'>\n        <Input\n          ref={inputRef}\n          value={query}\n          onChange={changeQuery}\n          type='search'\n          placeholder='Filter teams'\n          aria-label='Filter teams'\n          className='peer ps-8 pe-8'\n        />\n        <span className='text-muted-foreground pointer-events-none absolute inset-y-0 start-0 flex w-8 items-center justify-center peer-disabled:opacity-50'>\n          <FilterIcon className='size-4' />\n        </span>\n        <AnimatePresence initial={false}>\n          {hasQuery && (\n            <motion.button\n              key='clear'\n              type='button'\n              className={CLEAR_BUTTON}\n              onClick={clearQuery}\n              aria-label='Clear filter'\n              variants={swapVariants}\n              initial='enter'\n              animate='visible'\n              exit='exit'\n              transition={SWAP_TRANSITION}\n            >\n              <CircleXIcon className='size-4' />\n            </motion.button>\n          )}\n        </AnimatePresence>\n      </div>\n      <AnimatedSize>\n        <Tree aria-label='Project files' indent={INDENT} tree={tree}>\n          {tree.getItems().map((item) => (\n            <TreeItem\n              key={item.getId()}\n              item={item}\n              data-visible={isShown(item.getId())}\n              className='data-[visible=false]:hidden'\n            >\n              <TreeItemLabel>\n                <span className='flex items-center gap-2'>\n                  {item.isFolder() && <FolderGlyph />}\n                  {item.getItemName()}\n                </span>\n              </TreeItemLabel>\n            </TreeItem>\n          ))}\n        </Tree>\n        {hasNoMatch && (\n          <p className='text-muted-foreground px-3 py-4 text-center text-[13px]'>\n            No teams match &ldquo;{query}&rdquo;.\n          </p>\n        )}\n      </AnimatedSize>\n    </div>\n  );\n}\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "components",
    "tree"
  ],
  "type": "registry:component"
}