{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion","lucide-react"],"description":"A FileTree component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { ChevronRight, File, Folder, FolderOpen } from \"lucide-react\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type ReactNode,\n  useCallback,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\nexport type FileTreeItem = {\n  id: string;\n  name: string;\n  type: \"file\" | \"folder\";\n  children?: FileTreeItem[];\n  icon?: ReactNode;\n  badge?: ReactNode;\n};\n\nexport type FileTreeProps = {\n  /** Additional CSS classes */\n  className?: string;\n  /** Ids expanded by default (uncontrolled) */\n  defaultExpanded?: string[];\n  /** Controlled list of expanded folder ids */\n  expanded?: string[];\n  /** Indentation per level, in pixels */\n  indent?: number;\n  /** Recursive tree data */\n  items: FileTreeItem[];\n  /** Called when the expanded set changes */\n  onExpandedChange?: (expanded: string[]) => void;\n  /** Called when the selected item changes */\n  onSelectedChange?: (id: string) => void;\n  /** Controlled selected item id */\n  selected?: string;\n  /** Draw vertical guide lines connecting nested levels */\n  showLines?: boolean;\n};\n\nconst DEFAULT_INDENT = 16;\nconst ROTATE_OPEN = 90;\nconst ROTATE_CLOSED = 0;\nconst TYPEAHEAD_PATTERN = /^[a-z0-9]$/i;\n\ntype FlatNode = {\n  item: FileTreeItem;\n  level: number;\n  parentId: string | null;\n};\n\nconst flattenVisible = (\n  nodes: FileTreeItem[],\n  expandedSet: Set<string>,\n  level: number,\n  parentId: string | null\n): FlatNode[] => {\n  const result: FlatNode[] = [];\n  for (const node of nodes) {\n    result.push({ item: node, level, parentId });\n    if (\n      node.type === \"folder\" &&\n      node.children &&\n      node.children.length > 0 &&\n      expandedSet.has(node.id)\n    ) {\n      result.push(\n        ...flattenVisible(node.children, expandedSet, level + 1, node.id)\n      );\n    }\n  }\n  return result;\n};\n\ntype FileTreeRowProps = {\n  expandedSet: Set<string>;\n  focusedId: string | undefined;\n  indent: number;\n  level: number;\n  node: FileTreeItem;\n  onKeyDown: (event: KeyboardEvent<HTMLDivElement>, id: string) => void;\n  onSelect: (id: string) => void;\n  onToggle: (id: string, force?: boolean) => void;\n  registerRef: (id: string, el: HTMLDivElement | null) => void;\n  selectedId: string | undefined;\n  shouldReduceMotion: boolean;\n  showLines: boolean;\n};\n\nconst FileTreeRow = ({\n  node,\n  level,\n  indent,\n  showLines,\n  expandedSet,\n  selectedId,\n  focusedId,\n  shouldReduceMotion,\n  onToggle,\n  onSelect,\n  onKeyDown,\n  registerRef,\n}: FileTreeRowProps) => {\n  const isFolder = node.type === \"folder\";\n  const hasChildren = isFolder && Boolean(node.children?.length);\n  const isOpen = hasChildren && expandedSet.has(node.id);\n  const isSelected = selectedId === node.id;\n  const isFocused = focusedId === node.id;\n  const contentRef = useRef<HTMLDivElement>(null);\n  const wasOpenRef = useRef(isOpen);\n  const [height, setHeight] = useState<number | \"auto\">(isOpen ? \"auto\" : 0);\n\n  useLayoutEffect(() => {\n    if (!hasChildren) {\n      return;\n    }\n    if (wasOpenRef.current === isOpen) {\n      return;\n    }\n    wasOpenRef.current = isOpen;\n    const el = contentRef.current;\n    if (!el) {\n      return;\n    }\n\n    if (isOpen) {\n      setHeight(el.scrollHeight);\n      return;\n    }\n\n    setHeight(el.scrollHeight);\n    const raf = requestAnimationFrame(() => setHeight(0));\n    return () => cancelAnimationFrame(raf);\n  }, [isOpen, hasChildren]);\n\n  const defaultIcon = isFolder ? (\n    isOpen ? (\n      <FolderOpen className=\"text-brand\" size={16} />\n    ) : (\n      <Folder className=\"text-brand\" size={16} />\n    )\n  ) : (\n    <File\n      className={isSelected ? \"text-foreground\" : \"text-muted-foreground\"}\n      size={16}\n    />\n  );\n\n  return (\n    <div role=\"none\">\n      <div\n        aria-expanded={isFolder ? isOpen : undefined}\n        aria-level={level}\n        aria-selected={isSelected}\n        className={cn(\n          \"flex cursor-pointer select-none items-center gap-1.5 rounded-md px-1.5 py-1 outline-none\",\n          \"transition-[background-color,box-shadow] duration-150 ease-out\",\n          \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n          // A left rail would sit exactly on the indentation guide, so the\n          // selected row lifts instead: a solid surface with a hairline edge\n          // and a shallow shadow, reading along the whole row. Dark mode lifts\n          // with light, because a shadow has nothing to fall on there.\n          isSelected\n            ? \"bg-background shadow-[0_0_0_1px_rgb(0_0_0/0.08),0_1px_2px_-1px_rgb(0_0_0/0.10)] dark:bg-foreground/[0.07] dark:shadow-[0_0_0_1px_rgb(255_255_255/0.10)]\"\n            : \"hover:bg-foreground/[0.04]\"\n        )}\n        onClick={() => {\n          onSelect(node.id);\n          if (isFolder) {\n            onToggle(node.id);\n          }\n        }}\n        onKeyDown={(event) => onKeyDown(event, node.id)}\n        ref={(el) => registerRef(node.id, el)}\n        role=\"treeitem\"\n        style={{ paddingLeft: level * indent }}\n        tabIndex={isFocused ? 0 : -1}\n      >\n        <span className=\"flex h-4 w-4 shrink-0 items-center justify-center\">\n          {isFolder && (\n            <motion.span\n              animate={{ rotate: isOpen ? ROTATE_OPEN : ROTATE_CLOSED }}\n              className=\"flex\"\n              transition={\n                shouldReduceMotion\n                  ? { duration: 0 }\n                  : { bounce: 0.1, duration: 0.25, type: \"spring\" }\n              }\n            >\n              <ChevronRight size={14} />\n            </motion.span>\n          )}\n        </span>\n        <span className=\"flex shrink-0 items-center\">\n          {node.icon ?? defaultIcon}\n        </span>\n        <span\n          className={cn(\n            \"flex-1 truncate text-foreground text-sm\",\n            isSelected && \"font-medium\"\n          )}\n        >\n          {node.name}\n        </span>\n        {node.badge ? (\n          <span className=\"ml-auto shrink-0 text-muted-foreground text-xs\">\n            {node.badge}\n          </span>\n        ) : null}\n      </div>\n\n      {hasChildren ? (\n        // The subtree stays mounted so the height can animate, which would\n        // otherwise leave collapsed children exposed as reachable `treeitem`s:\n        // `height: 0` hides them visually but not from assistive tech, so a\n        // screen reader walking the DOM meets rows the widget considers hidden.\n        // `aria-hidden` + `inert` take them out of both the a11y tree and the\n        // tab order while collapsed, without giving up the animation.\n        <motion.div\n          animate={{ height }}\n          aria-hidden={isOpen ? undefined : \"true\"}\n          inert={isOpen ? undefined : true}\n          initial={false}\n          onAnimationComplete={() => {\n            if (isOpen) {\n              setHeight(\"auto\");\n            }\n          }}\n          style={{ overflow: \"hidden\" }}\n          transition={\n            shouldReduceMotion\n              ? { duration: 0 }\n              : { duration: 0.25, ease: [0.645, 0.045, 0.355, 1] }\n          }\n        >\n          <div\n            className={cn(\n              \"relative\",\n              showLines &&\n                \"before:absolute before:inset-y-0 before:left-0 before:w-px before:bg-border before:content-['']\"\n            )}\n            ref={contentRef}\n            role=\"group\"\n            style={{ marginLeft: (level - 1) * indent + indent / 2 }}\n          >\n            {node.children?.map((child) => (\n              <FileTreeRow\n                expandedSet={expandedSet}\n                focusedId={focusedId}\n                indent={indent}\n                key={child.id}\n                level={level + 1}\n                node={child}\n                onKeyDown={onKeyDown}\n                onSelect={onSelect}\n                onToggle={onToggle}\n                registerRef={registerRef}\n                selectedId={selectedId}\n                shouldReduceMotion={shouldReduceMotion}\n                showLines={showLines}\n              />\n            ))}\n          </div>\n        </motion.div>\n      ) : null}\n    </div>\n  );\n};\n\nconst FileTree = ({\n  items,\n  defaultExpanded,\n  expanded: expandedProp,\n  onExpandedChange,\n  selected: selectedProp,\n  onSelectedChange,\n  indent = DEFAULT_INDENT,\n  showLines = false,\n  className,\n}: FileTreeProps) => {\n  const shouldReduceMotion = Boolean(useReducedMotion());\n  const isExpandedControlled = expandedProp !== undefined;\n  const [internalExpanded, setInternalExpanded] = useState<string[]>(\n    defaultExpanded ?? []\n  );\n  const expandedList = isExpandedControlled ? expandedProp : internalExpanded;\n  const expandedSet = useMemo(() => new Set(expandedList), [expandedList]);\n\n  const isSelectedControlled = selectedProp !== undefined;\n  const [internalSelected, setInternalSelected] = useState<string | undefined>(\n    undefined\n  );\n  const selectedId = isSelectedControlled ? selectedProp : internalSelected;\n\n  const flatList = useMemo(\n    () => flattenVisible(items, expandedSet, 1, null),\n    [items, expandedSet]\n  );\n  const [focusedId, setFocusedId] = useState<string | undefined>(undefined);\n  const activeFocusId = focusedId ?? selectedId ?? flatList[0]?.item.id;\n\n  const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());\n  const registerRef = useCallback((id: string, el: HTMLDivElement | null) => {\n    if (el) {\n      itemRefs.current.set(id, el);\n    } else {\n      itemRefs.current.delete(id);\n    }\n  }, []);\n  const focusItem = useCallback((id: string | undefined) => {\n    if (id) {\n      itemRefs.current.get(id)?.focus();\n    }\n  }, []);\n\n  const setExpandedList = useCallback(\n    (next: string[]) => {\n      if (!isExpandedControlled) {\n        setInternalExpanded(next);\n      }\n      onExpandedChange?.(next);\n    },\n    [isExpandedControlled, onExpandedChange]\n  );\n\n  const toggleExpanded = useCallback(\n    (id: string, force?: boolean) => {\n      const isOpen = expandedSet.has(id);\n      const shouldOpen = force ?? !isOpen;\n      if (shouldOpen === isOpen) {\n        return;\n      }\n      setExpandedList(\n        shouldOpen\n          ? [...expandedList, id]\n          : expandedList.filter((existing) => existing !== id)\n      );\n    },\n    [expandedSet, expandedList, setExpandedList]\n  );\n\n  const handleSelect = useCallback(\n    (id: string) => {\n      if (!isSelectedControlled) {\n        setInternalSelected(id);\n      }\n      onSelectedChange?.(id);\n      setFocusedId(id);\n    },\n    [isSelectedControlled, onSelectedChange]\n  );\n\n  const flatIndexById = useMemo(() => {\n    const map = new Map<string, number>();\n    for (const [index, entry] of flatList.entries()) {\n      map.set(entry.item.id, index);\n    }\n    return map;\n  }, [flatList]);\n\n  const moveFocusTo = useCallback(\n    (id: string | undefined) => {\n      if (!id) {\n        return;\n      }\n      setFocusedId(id);\n      focusItem(id);\n    },\n    [focusItem]\n  );\n\n  const typeahead = useCallback(\n    (letter: string, fromIndex: number) => {\n      const lower = letter.toLowerCase();\n      const count = flatList.length;\n      for (let offset = 1; offset <= count; offset++) {\n        const index = (fromIndex + offset) % count;\n        if (flatList[index].item.name.toLowerCase().startsWith(lower)) {\n          moveFocusTo(flatList[index].item.id);\n          return;\n        }\n      }\n    },\n    [flatList, moveFocusTo]\n  );\n\n  const handleKeyDown = useCallback(\n    (event: KeyboardEvent<HTMLDivElement>, id: string) => {\n      const index = flatIndexById.get(id);\n      if (index === undefined) {\n        return;\n      }\n      const entry = flatList[index];\n      const node = entry.item;\n\n      switch (event.key) {\n        case \"ArrowDown\":\n          event.preventDefault();\n          moveFocusTo(flatList[index + 1]?.item.id);\n          break;\n        case \"ArrowUp\":\n          event.preventDefault();\n          moveFocusTo(flatList[index - 1]?.item.id);\n          break;\n        case \"ArrowRight\":\n          event.preventDefault();\n          if (node.type === \"folder\") {\n            if (expandedSet.has(node.id)) {\n              const next = flatList[index + 1];\n              if (next && next.parentId === node.id) {\n                moveFocusTo(next.item.id);\n              }\n            } else {\n              toggleExpanded(node.id, true);\n            }\n          }\n          break;\n        case \"ArrowLeft\":\n          event.preventDefault();\n          if (node.type === \"folder\" && expandedSet.has(node.id)) {\n            toggleExpanded(node.id, false);\n          } else if (entry.parentId) {\n            moveFocusTo(entry.parentId);\n          }\n          break;\n        case \"Home\":\n          event.preventDefault();\n          moveFocusTo(flatList[0]?.item.id);\n          break;\n        case \"End\":\n          event.preventDefault();\n          moveFocusTo(flatList.at(-1)?.item.id);\n          break;\n        case \"Enter\":\n        case \" \":\n          event.preventDefault();\n          handleSelect(node.id);\n          break;\n        default:\n          if (TYPEAHEAD_PATTERN.test(event.key)) {\n            event.preventDefault();\n            typeahead(event.key, index);\n          }\n          break;\n      }\n    },\n    [\n      flatIndexById,\n      flatList,\n      expandedSet,\n      toggleExpanded,\n      moveFocusTo,\n      handleSelect,\n      typeahead,\n    ]\n  );\n\n  return (\n    <div className={cn(\"select-none text-sm\", className)} role=\"tree\">\n      {items.map((node) => (\n        <FileTreeRow\n          expandedSet={expandedSet}\n          focusedId={activeFocusId}\n          indent={indent}\n          key={node.id}\n          level={1}\n          node={node}\n          onKeyDown={handleKeyDown}\n          onSelect={handleSelect}\n          onToggle={toggleExpanded}\n          registerRef={registerRef}\n          selectedId={selectedId}\n          shouldReduceMotion={shouldReduceMotion}\n          showLines={showLines}\n        />\n      ))}\n    </div>\n  );\n};\n\nexport default FileTree;\n","path":"index.tsx","target":"components/smoothui/file-tree/index.tsx","type":"registry:ui"}],"name":"file-tree","registryDependencies":["https://smoothui.dev/r/tokens.json"],"title":"File Tree","type":"registry:ui"}