{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"file-tree","type":"registry:component","title":"File Tree","description":"Composable file and folder primitives with springing branches, a gliding selection, and complete keyboard navigation.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/file-tree.tsx","type":"registry:component","target":"@components/motion/file-tree.tsx","content":"\"use client\";\n// beui.dev/components/motion/file-tree\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { ChevronRight, File, Folder, FolderOpen } from \"lucide-react\";\nimport {\n  Children,\n  Fragment,\n  useCallback,\n  isValidElement,\n  useMemo,\n  useRef,\n  useState,\n  type KeyboardEvent,\n  type ReactNode,\n} from \"react\";\nimport { SharedLayoutBg } from \"@/components/motion/shared-layout-bg\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\ntype FileTreeItem = {\n  value: string;\n  name: string;\n  type: \"file\" | \"folder\";\n  children?: FileTreeItem[];\n  icon?: ReactNode;\n  disabled?: boolean;\n  className?: string;\n};\n\nexport interface FileTreeFolderProps {\n  value: string;\n  name: string;\n  icon?: ReactNode;\n  disabled?: boolean;\n  children?: ReactNode;\n  className?: string;\n}\n\nexport interface FileTreeFileProps {\n  value: string;\n  name: string;\n  icon?: ReactNode;\n  disabled?: boolean;\n  className?: string;\n}\n\nexport type FileTreeClassNames = {\n  tree?: string;\n  item?: string;\n  icon?: string;\n  label?: string;\n};\n\nexport interface FileTreeProps {\n  children: ReactNode;\n  value?: string | null;\n  defaultValue?: string | null;\n  onValueChange?: (value: string) => void;\n  expandedIds?: string[];\n  defaultExpandedIds?: string[];\n  onExpandedChange?: (expandedIds: string[]) => void;\n  ariaLabel?: string;\n  indent?: number;\n  className?: string;\n  classNames?: FileTreeClassNames;\n}\n\ntype FlatFileTreeItem = {\n  item: FileTreeItem;\n  depth: number;\n  parentId: string | null;\n  position: number;\n  setSize: number;\n};\n\n// These declarative parts are read by FileTree and turned into one flattened,\n// keyboard-navigable collection. They intentionally render nothing alone.\nexport function FileTreeFolder(_props: FileTreeFolderProps) {\n  return null;\n}\n\nexport function FileTreeFile(_props: FileTreeFileProps) {\n  return null;\n}\n\nconst ROW_ENTER = { duration: 0.22, ease: EASE_OUT } as const;\nconst BRANCH_DRAW = { duration: 0.3, ease: EASE_OUT } as const;\n\nfunction flattenItems(\n  items: FileTreeItem[],\n  expanded: ReadonlySet<string>,\n  depth = 0,\n  parentId: string | null = null,\n): FlatFileTreeItem[] {\n  return items.flatMap((item, index) => {\n    const row = {\n      item,\n      depth,\n      parentId,\n      position: index + 1,\n      setSize: items.length,\n    };\n\n    if (\n      item.type !== \"folder\" ||\n      !expanded.has(item.value) ||\n      !item.children?.length\n    ) {\n      return [row];\n    }\n\n    return [\n      row,\n      ...flattenItems(item.children, expanded, depth + 1, item.value),\n    ];\n  });\n}\n\nfunction itemsFromChildren(children: ReactNode): FileTreeItem[] {\n  const items: FileTreeItem[] = [];\n\n  Children.forEach(children, (child) => {\n    if (!isValidElement(child)) return;\n\n    if (child.type === Fragment) {\n      const props = child.props as { children?: ReactNode };\n      items.push(...itemsFromChildren(props.children));\n      return;\n    }\n\n    if (child.type === FileTreeFolder) {\n      const props = child.props as FileTreeFolderProps;\n      items.push({\n        value: props.value,\n        name: props.name,\n        type: \"folder\",\n        icon: props.icon,\n        disabled: props.disabled,\n        className: props.className,\n        children: itemsFromChildren(props.children),\n      });\n      return;\n    }\n\n    if (child.type === FileTreeFile) {\n      const props = child.props as FileTreeFileProps;\n      items.push({\n        value: props.value,\n        name: props.name,\n        type: \"file\",\n        icon: props.icon,\n        disabled: props.disabled,\n        className: props.className,\n      });\n    }\n  });\n\n  return items;\n}\n\nfunction DefaultIcon({\n  item,\n  open,\n  reduce,\n}: {\n  item: FileTreeItem;\n  open: boolean;\n  reduce: boolean;\n}) {\n  if (item.type === \"file\") return <File className=\"size-4\" />;\n  if (reduce) {\n    return open ? (\n      <FolderOpen className=\"size-4\" />\n    ) : (\n      <Folder className=\"size-4\" />\n    );\n  }\n\n  return (\n    <AnimatePresence initial={false} mode=\"popLayout\">\n      <motion.span\n        key={open ? \"open\" : \"closed\"}\n        initial={{ opacity: 0, scale: 0.75, rotate: open ? -8 : 8 }}\n        animate={{ opacity: 1, scale: 1, rotate: 0 }}\n        exit={{ opacity: 0, scale: 0.75, rotate: open ? 8 : -8 }}\n        transition={SPRING_SWAP}\n        className=\"absolute inset-0 grid place-items-center\"\n      >\n        {open ? (\n          <FolderOpen className=\"size-4\" />\n        ) : (\n          <Folder className=\"size-4\" />\n        )}\n      </motion.span>\n    </AnimatePresence>\n  );\n}\n\nexport function FileTree({\n  children,\n  value,\n  defaultValue = null,\n  onValueChange,\n  expandedIds,\n  defaultExpandedIds = [],\n  onExpandedChange,\n  ariaLabel = \"Files\",\n  indent = 18,\n  className,\n  classNames,\n}: FileTreeProps) {\n  const reduce = useReducedMotion() ?? false;\n  const [internalValue, setInternalValue] = useState(defaultValue);\n  const [internalExpandedIds, setInternalExpandedIds] = useState(\n    defaultExpandedIds,\n  );\n  const [focusedId, setFocusedId] = useState<string | null>(\n    value ?? defaultValue,\n  );\n  const rowRefs = useRef(new Map<string, HTMLButtonElement>());\n  const selectedId = value === undefined ? internalValue : value;\n  const currentExpandedIds = expandedIds ?? internalExpandedIds;\n  const expanded = useMemo(\n    () => new Set(currentExpandedIds),\n    [currentExpandedIds],\n  );\n  const items = useMemo(() => itemsFromChildren(children), [children]);\n  const rows = useMemo(() => flattenItems(items, expanded), [expanded, items]);\n\n  // Keep a real row tabbable in the first commit and immediately after a\n  // collapse removes the previously focused descendant.\n  const focusedRow =\n    focusedId !== null && rows.some(({ item }) => item.value === focusedId)\n      ? focusedId\n      : (rows[0]?.item.value ?? null);\n  if (focusedId !== focusedRow) setFocusedId(focusedRow);\n\n  const focusRow = useCallback((id: string) => {\n    setFocusedId(id);\n    const row = rowRefs.current.get(id);\n    if (row) row.focus();\n    else requestAnimationFrame(() => rowRefs.current.get(id)?.focus());\n  }, []);\n\n  const selectItem = useCallback(\n    (item: FileTreeItem) => {\n      if (item.disabled) return;\n      if (value === undefined) setInternalValue(item.value);\n      onValueChange?.(item.value);\n    },\n    [onValueChange, value],\n  );\n\n  const setExpanded = useCallback(\n    (next: string[]) => {\n      if (expandedIds === undefined) setInternalExpandedIds(next);\n      onExpandedChange?.(next);\n    },\n    [expandedIds, onExpandedChange],\n  );\n\n  const toggleFolder = useCallback(\n    (id: string) => {\n      const next = new Set(currentExpandedIds);\n      if (next.has(id)) next.delete(id);\n      else next.add(id);\n      setExpanded(Array.from(next));\n    },\n    [currentExpandedIds, setExpanded],\n  );\n\n  const handleKeyDown = useCallback(\n    (event: KeyboardEvent<HTMLButtonElement>, row: FlatFileTreeItem) => {\n      const index = rows.findIndex(({ item }) => item.value === row.item.value);\n      const previous = rows[index - 1];\n      const next = rows[index + 1];\n      const isFolder = row.item.type === \"folder\";\n      const isOpen = expanded.has(row.item.value);\n\n      if (event.key === \"ArrowDown\" && next) {\n        event.preventDefault();\n        focusRow(next.item.value);\n      } else if (event.key === \"ArrowUp\" && previous) {\n        event.preventDefault();\n        focusRow(previous.item.value);\n      } else if (event.key === \"Home\" && rows[0]) {\n        event.preventDefault();\n        focusRow(rows[0].item.value);\n      } else if (event.key === \"End\" && rows.at(-1)) {\n        event.preventDefault();\n        focusRow(rows.at(-1)?.item.value ?? row.item.value);\n      } else if (event.key === \"ArrowRight\" && isFolder) {\n        event.preventDefault();\n        if (!isOpen && !row.item.disabled) toggleFolder(row.item.value);\n        else if (next?.parentId === row.item.value) focusRow(next.item.value);\n      } else if (event.key === \"ArrowLeft\") {\n        event.preventDefault();\n        if (isFolder && isOpen && !row.item.disabled)\n          toggleFolder(row.item.value);\n        else if (row.parentId) focusRow(row.parentId);\n      } else if (event.key === \"Enter\" || event.key === \" \") {\n        event.preventDefault();\n        if (row.item.disabled) return;\n        selectItem(row.item);\n        if (isFolder) toggleFolder(row.item.value);\n      }\n    },\n    [expanded, focusRow, rows, selectItem, toggleFolder],\n  );\n\n  return (\n    <SharedLayoutBg\n      role=\"tree\"\n      aria-label={ariaLabel}\n      aria-multiselectable=\"false\"\n      inset={0}\n      pillClassName=\"rounded-xl bg-muted\"\n      pillContainerClassName=\"inset-y-auto top-0 h-9\"\n      className={cn(\"min-w-0\", className, classNames?.tree)}\n    >\n      {rows.map((row) => {\n          const isFolder = row.item.type === \"folder\";\n          const isOpen = isFolder && expanded.has(row.item.value);\n          const isSelected = selectedId === row.item.value;\n\n          return (\n            <motion.div\n              layout={reduce ? false : \"position\"}\n              key={row.item.value}\n              initial={reduce ? false : { opacity: 0, y: -6 }}\n              animate={{\n                opacity: row.item.disabled ? 0.42 : 1,\n                y: 0,\n                transition: reduce\n                  ? { duration: 0 }\n                  : {\n                      ...ROW_ENTER,\n                      delay: Math.min(row.position * 0.025, 0.1),\n                    },\n              }}\n              transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n            >\n              <button\n                ref={(node) => {\n                  if (node) rowRefs.current.set(row.item.value, node);\n                  else rowRefs.current.delete(row.item.value);\n                }}\n                type=\"button\"\n                role=\"treeitem\"\n                aria-level={row.depth + 1}\n                aria-posinset={row.position}\n                aria-setsize={row.setSize}\n                aria-selected={isSelected}\n                aria-expanded={isFolder ? isOpen : undefined}\n                aria-disabled={row.item.disabled || undefined}\n                tabIndex={focusedRow === row.item.value ? 0 : -1}\n                onFocus={() => setFocusedId(row.item.value)}\n                onKeyDown={(event) => handleKeyDown(event, row)}\n                onClick={() => {\n                  if (row.item.disabled) return;\n                  selectItem(row.item);\n                  if (isFolder) toggleFolder(row.item.value);\n                }}\n                className={cn(\n                  \"group/file-tree relative flex h-9 w-full items-center gap-2 overflow-hidden rounded-lg pr-2 text-left text-sm text-muted-foreground outline-none\",\n                  \"transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\",\n                  \"aria-disabled:cursor-not-allowed\",\n                  isSelected && \"bg-muted font-medium text-foreground\",\n                  classNames?.item,\n                  row.item.className,\n                )}\n                style={{ paddingLeft: 8 + row.depth * indent }}\n              >\n                {row.depth > 0 ? (\n                  <motion.span\n                    aria-hidden=\"true\"\n                    initial={reduce ? false : { opacity: 0, scaleY: 0 }}\n                    animate={{ opacity: 1, scaleY: 1 }}\n                    exit={{ opacity: 0, scaleY: 0 }}\n                    transition={reduce ? { duration: 0 } : BRANCH_DRAW}\n                    className=\"absolute top-0 bottom-0 w-px origin-top bg-border/70\"\n                    style={{ left: 16 + (row.depth - 1) * indent }}\n                  />\n                ) : null}\n\n                <motion.span\n                  aria-hidden=\"true\"\n                  animate={{ rotate: isOpen ? 90 : 0 }}\n                  transition={reduce ? { duration: 0 } : SPRING_SWAP}\n                  className={cn(\n                    \"relative z-10 grid size-4 shrink-0 place-items-center\",\n                    !isFolder && \"opacity-0\",\n                  )}\n                >\n                  <ChevronRight className=\"size-3.5\" />\n                </motion.span>\n\n                <span\n                  aria-hidden=\"true\"\n                  className={cn(\n                    \"relative z-10 grid size-4 shrink-0 place-items-center text-muted-foreground transition-colors group-hover/file-tree:text-foreground\",\n                    isFolder && isOpen && \"text-foreground\",\n                    classNames?.icon,\n                  )}\n                >\n                  {row.item.icon ?? (\n                    <DefaultIcon\n                      item={row.item}\n                      open={isOpen}\n                      reduce={reduce}\n                    />\n                  )}\n                </span>\n\n                <span\n                  className={cn(\n                    \"relative z-10 min-w-0 flex-1 truncate\",\n                    classNames?.label,\n                  )}\n                >\n                  {row.item.name}\n                </span>\n              </button>\n            </motion.div>\n          );\n        })}\n    </SharedLayoutBg>\n  );\n}\n"},{"path":"components/motion/shared-layout-bg.tsx","type":"registry:component","target":"@components/motion/shared-layout-bg.tsx","content":"\"use client\";\n\nimport {\n  AnimatePresence,\n  type HTMLMotionProps,\n  motion,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport {\n  Children,\n  cloneElement,\n  forwardRef,\n  type HTMLAttributes,\n  isValidElement,\n  type MouseEvent,\n  type ReactElement,\n  type ReactNode,\n  type Ref,\n  useId,\n  useState,\n} from \"react\";\nimport { SPRING_LAYOUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface SharedLayoutBgProps\n  extends Omit<HTMLAttributes<HTMLElement>, \"children\"> {\n  children: ReactNode;\n  /** Semantic container used for the children. */\n  as?: \"div\" | \"ul\";\n  /** Tailwind class applied to the moving pill. Defaults to a subtle foreground tint. */\n  pillClassName?: string;\n  /** Horizontal inset of the pill relative to each row (px). Default 20. */\n  inset?: number;\n  /** Optional positioning override for the pill wrapper inside each item. */\n  pillContainerClassName?: string;\n}\n\nconst variants: Variants = {\n  initial: { opacity: 0, filter: \"blur(6px)\" },\n  animate: { opacity: 1, filter: \"blur(0px)\" },\n  exit: (isActive: boolean) =>\n    !isActive ? { opacity: 0, filter: \"blur(6px)\" } : {},\n};\n\nconst reducedVariants: Variants = {\n  initial: { opacity: 0 },\n  animate: { opacity: 1 },\n  exit: (isActive: boolean) => (!isActive ? { opacity: 0 } : {}),\n};\n\nexport const SharedLayoutBg = forwardRef<HTMLElement, SharedLayoutBgProps>(\n  function SharedLayoutBg(\n    {\n      children,\n      as = \"div\",\n      className,\n      onMouseLeave,\n      pillClassName,\n      pillContainerClassName,\n      inset = 20,\n      ...props\n    },\n    forwardedRef,\n  ) {\n  const [activeId, setActiveId] = useState<string | null>(null);\n  const uid = useId();\n  const reduce = useReducedMotion();\n\n    const renderedChildren = Children.toArray(children)\n      .filter(isValidElement)\n      .map((child, index) => {\n        const el = child as ReactElement<{\n          className?: string;\n          onMouseEnter?: () => void;\n          children?: ReactNode;\n        }>;\n        const childKey = el.key ? String(el.key) : `item-${index}`;\n        return cloneElement(\n          el,\n          {\n            key: childKey,\n            className: cn(\"relative\", el.props.className),\n            onMouseEnter: () => {\n              el.props.onMouseEnter?.();\n              setActiveId(childKey);\n            },\n          },\n          <>\n            <AnimatePresence custom={activeId !== null}>\n              {activeId !== null ? (\n                <motion.div\n                  variants={reduce ? reducedVariants : variants}\n                  initial=\"initial\"\n                  animate=\"animate\"\n                  exit=\"exit\"\n                  custom={activeId !== null}\n                  className={cn(\n                    \"pointer-events-none absolute inset-y-0\",\n                    pillContainerClassName,\n                  )}\n                  style={{ left: -inset, right: -inset }}\n                >\n                  {activeId === childKey ? (\n                    <motion.div\n                      layoutId={`shared-bg-${uid}`}\n                      transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n                      className={cn(\n                        \"pointer-events-none h-full w-full rounded-2xl bg-primary/[0.06]\",\n                        pillClassName,\n                      )}\n                    />\n                  ) : null}\n                </motion.div>\n              ) : null}\n            </AnimatePresence>\n            <div className=\"relative z-10\">{el.props.children}</div>\n          </>,\n        );\n      });\n\n    const handleMouseLeave = (event: MouseEvent<HTMLElement>) => {\n      setActiveId(null);\n      onMouseLeave?.(event);\n    };\n\n    // layoutRoot scopes the pill's layout projection to this list, so fixed or\n    // scrolled ancestors can't smear scroll offsets into its movement.\n    return as === \"ul\" ? (\n      <motion.ul\n        {...(props as HTMLMotionProps<\"ul\">)}\n        ref={forwardedRef as Ref<HTMLUListElement>}\n        layoutRoot\n        onMouseLeave={handleMouseLeave}\n        className={cn(\"flex w-full flex-col\", className)}\n      >\n        {renderedChildren}\n      </motion.ul>\n    ) : (\n      <motion.div\n        {...(props as HTMLMotionProps<\"div\">)}\n        ref={forwardedRef as Ref<HTMLDivElement>}\n        layoutRoot\n        onMouseLeave={handleMouseLeave}\n        className={cn(\"flex w-full flex-col\", className)}\n      >\n        {renderedChildren}\n      </motion.div>\n    );\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"}]}