"use client"; import React, { useState, useCallback, createContext, useContext, useRef, useEffect, } from "react"; import { motion, AnimatePresence, easeInOut, type Variants, } from "motion/react"; import { ChevronRight, Folder, FolderOpen, File, type LucideIcon, } from "lucide-react"; import { cn } from "@/lib/utils"; const animationVariants: Variants = { rootInitial: { opacity: 0, y: 20 }, rootAnimate: { opacity: 1, y: 0 }, itemInitial: { opacity: 0, x: -10 }, itemAnimate: { opacity: 1, x: 0 }, contentHidden: { opacity: 0, height: 0 }, contentVisible: { opacity: 1, height: "auto" }, chevronClosed: { rotate: 0 }, chevronOpen: { rotate: 90 }, }; const transitions = { root: { duration: 0.4 }, item: { duration: 0.2 }, content: { duration: 0.3, ease: easeInOut }, chevron: { duration: 0.2 }, }; interface ExpansionContextType { expandedIds: Set; toggleExpanded: (id: string) => void; } interface SelectionContextType { selectedId: string | null; setSelected: (id: string) => void; onSelect?: (id: string, label: string) => void; } interface TreeContextType { focusedId: string | null; setFocusedId: (id: string | null) => void; treeId: string; setKeyboardMode: (mode: boolean) => void; keyboardMode: boolean; } interface LevelContextType { level: number; } const ExpansionContext = createContext(null); const SelectionContext = createContext(null); const TreeContext = createContext(null); const LevelContext = createContext({ level: 0 }); const useExpansion = () => { const context = useContext(ExpansionContext); if (!context) { throw new Error( "FolderTree components must be used within FolderTree.Root", ); } return context; }; const useSelection = () => { const context = useContext(SelectionContext); if (!context) { throw new Error( "FolderTree components must be used within FolderTree.Root", ); } return context; }; const useTree = () => { const context = useContext(TreeContext); if (!context) { throw new Error( "FolderTree components must be used within FolderTree.Root", ); } return context; }; const useLevel = () => { return useContext(LevelContext); }; const getPaddingClass = (level: number): string => { const paddingMap: Record = { 0: "pl-3", 1: "pl-8", 2: "pl-12", 3: "pl-16", 4: "pl-20", 5: "pl-24", 6: "pl-28", 7: "pl-32", }; return paddingMap[level] || `pl-[${Math.min(level * 4 + 12, 48)}px]`; }; interface CustomBadge { content: React.ReactNode; className?: string; ariaLabel?: string; } interface RootProps { defaultExpanded?: string[]; defaultSelected?: string; selectedId?: string | null; expandedIds?: Set; onSelect?: (id: string, label: string) => void; className?: string; children: React.ReactNode; id?: string; } interface ItemProps { id: string; label: string; icon?: LucideIcon; badge?: string | number; modified?: boolean | CustomBadge; untracked?: boolean | CustomBadge; className?: string; children?: React.ReactNode; } interface TriggerProps { className?: string; } interface ContentProps { children: React.ReactNode; className?: string; } const Root: React.FC = ({ defaultExpanded = [], defaultSelected, selectedId: controlledSelectedId, expandedIds: additionalExpandedIds, onSelect, className = "", children, id = "folder-tree", }) => { const [expandedIds, setExpandedIds] = useState>( new Set(defaultExpanded), ); const [internalSelectedId, setInternalSelectedId] = useState( defaultSelected || null, ); const selectedId = controlledSelectedId !== undefined ? controlledSelectedId : internalSelectedId; const [focusedId, setFocusedId] = useState(null); const [keyboardMode, setKeyboardMode] = useState(false); const treeRef = useRef(null); useEffect(() => { if (!additionalExpandedIds || additionalExpandedIds.size === 0) return; setExpandedIds((prev) => { const merged = new Set(prev); let changed = false; for (const id of additionalExpandedIds) { if (!merged.has(id)) { merged.add(id); changed = true; } } return changed ? merged : prev; }); }, [additionalExpandedIds]); const toggleExpanded = useCallback((id: string) => { setExpandedIds((prev) => { const newSet = new Set(prev); if (newSet.has(id)) { newSet.delete(id); } else { newSet.add(id); } return newSet; }); }, []); const setSelected = useCallback((id: string) => { setInternalSelectedId(id); }, []); const getVisibleItemIds = useCallback(() => { const items = Array.from( treeRef.current?.querySelectorAll('[role="treeitem"]') || [], ); return items .filter((item) => { const element = item as HTMLElement; return element.offsetHeight > 0 && element.offsetWidth > 0; }) .map((item) => item.getAttribute("data-id")) .filter(Boolean) as string[]; }, []); const getAllItemIds = useCallback(() => { const items = Array.from( treeRef.current?.querySelectorAll('[role="treeitem"]') || [], ); return items .map((item) => item.getAttribute("data-id")) .filter(Boolean) as string[]; }, []); const [treeHasFocus, setTreeHasFocus] = useState(false); const handleTreeFocus = useCallback(() => { if (!treeHasFocus) { setTreeHasFocus(true); setKeyboardMode(true); } }, [treeHasFocus]); const handleTreeBlur = useCallback((e: React.FocusEvent) => { if (!treeRef.current?.contains(e.relatedTarget as Node)) { setTreeHasFocus(false); setFocusedId(null); setKeyboardMode(false); } }, []); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { const getVisibleItems = () => { return Array.from( treeRef.current?.querySelectorAll('[role="treeitem"]') || [], ).filter((item) => { const element = item as HTMLElement; return element.offsetHeight > 0 && element.offsetWidth > 0; }); }; if (e.key === "Tab") { if (treeHasFocus && !focusedId) { const visibleItemIds = getVisibleItemIds(); if (visibleItemIds.length > 0) { setFocusedId(visibleItemIds[0]); e.preventDefault(); return; } } if (focusedId) { const visibleItems = getVisibleItems(); const currentIndex = visibleItems.findIndex( (item) => item.getAttribute("data-id") === focusedId, ); if (e.shiftKey) { if (currentIndex === 0) { setFocusedId(null); setTreeHasFocus(false); setKeyboardMode(false); return; } const nextIndex = Math.max(0, currentIndex - 1); const nextItem = visibleItems[nextIndex] as HTMLElement; const nextId = nextItem?.getAttribute("data-id"); if (nextId) { setFocusedId(nextId); e.preventDefault(); } } else { if (currentIndex === visibleItems.length - 1) { setFocusedId(null); setTreeHasFocus(false); setKeyboardMode(false); return; } const nextIndex = Math.min( visibleItems.length - 1, currentIndex + 1, ); const nextItem = visibleItems[nextIndex] as HTMLElement; const nextId = nextItem?.getAttribute("data-id"); if (nextId) { setFocusedId(nextId); e.preventDefault(); } } } return; } if (!keyboardMode || !focusedId) return; const visibleItems = getVisibleItems(); const currentIndex = visibleItems.findIndex( (item) => item.getAttribute("data-id") === focusedId, ); switch (e.key) { case "ArrowDown": e.preventDefault(); if (currentIndex < visibleItems.length - 1) { const nextItem = visibleItems[currentIndex + 1] as HTMLElement; const nextId = nextItem.getAttribute("data-id"); if (nextId) setFocusedId(nextId); } break; case "ArrowUp": e.preventDefault(); if (currentIndex > 0) { const prevItem = visibleItems[currentIndex - 1] as HTMLElement; const prevId = prevItem.getAttribute("data-id"); if (prevId) setFocusedId(prevId); } break; case "ArrowRight": e.preventDefault(); if (!expandedIds.has(focusedId)) { toggleExpanded(focusedId); } break; case "ArrowLeft": e.preventDefault(); if (expandedIds.has(focusedId)) { toggleExpanded(focusedId); } break; case "Enter": case " ": e.preventDefault(); setSelected(focusedId); if (onSelect) { const currentItem = visibleItems[currentIndex] as HTMLElement; const label = currentItem.querySelector("span:nth-of-type(2)")?.textContent || ""; onSelect(focusedId, label); } break; } }, [ focusedId, keyboardMode, expandedIds, toggleExpanded, setSelected, onSelect, getVisibleItemIds, treeHasFocus, ], ); useEffect(() => { const handleMouseDown = () => setKeyboardMode(false); document.addEventListener("mousedown", handleMouseDown); return () => { document.removeEventListener("mousedown", handleMouseDown); }; }, []); const expansionValue: ExpansionContextType = { expandedIds, toggleExpanded, }; const selectionValue: SelectionContextType = { selectedId, setSelected, onSelect, }; const treeValue: TreeContextType = { focusedId, setFocusedId, treeId: id, setKeyboardMode, keyboardMode, }; return (
{children}
); }; const ItemContext = createContext<{ itemId: string; hasChildren: boolean; isExpanded: boolean; toggleExpanded: () => void; } | null>(null); const Item: React.FC = ({ id, label, icon, badge, modified, untracked, className = "", children, }) => { const expansionContext = useExpansion(); const selectionContext = useSelection(); const treeContext = useTree(); const { level } = useLevel(); const itemRef = useRef(null); const keyboardMode = treeContext.keyboardMode; const hasChildren = React.Children.count(children) > 0; const isExpanded = expansionContext.expandedIds.has(id); const isSelected = selectionContext.selectedId === id; const isFocused = treeContext.focusedId === id; const handleItemClick = useCallback(() => { treeContext.setKeyboardMode(false); selectionContext.setSelected(id); treeContext.setFocusedId(id); if (selectionContext.onSelect) { selectionContext.onSelect(id, label); } }, [id, label, selectionContext, treeContext]); const toggleExpanded = useCallback(() => { if (hasChildren) { expansionContext.toggleExpanded(id); } }, [id, hasChildren, expansionContext]); const handleFocus = useCallback(() => { treeContext.setFocusedId(id); }, [id, treeContext]); useEffect(() => { if (isFocused && itemRef.current) { itemRef.current.focus(); } }, [isFocused]); const IconComponent = icon || (hasChildren ? (isExpanded ? FolderOpen : Folder) : File); const itemContextValue = { itemId: id, hasChildren, isExpanded, toggleExpanded, }; const renderBadge = ( badgeData: boolean | CustomBadge | undefined, defaultContent: string, defaultClassName: string, ) => { if (!badgeData) return null; if (typeof badgeData === "boolean") { return ( {defaultContent} ); } return ( {badgeData.content} ); }; return (
{ handleItemClick(); e.stopPropagation(); toggleExpanded(); }} onFocus={handleFocus} role="treeitem" tabIndex={isFocused ? 0 : -1} aria-expanded={hasChildren ? isExpanded : undefined} aria-selected={isSelected} aria-label={`${hasChildren ? "Folder" : "File"}: ${label}`} aria-level={level + 1} > {hasChildren && ( )} {!hasChildren && {children}
); }; const Trigger: React.FC = ({ className = "" }) => { const itemContext = useContext(ItemContext); if (!itemContext || !itemContext.hasChildren) { return null; } return ( { e.stopPropagation(); itemContext.toggleExpanded(); }} role="button" aria-label={itemContext.isExpanded ? "Collapse" : "Expand"} tabIndex={-1} > ); }; const Content: React.FC = ({ children, className = "" }) => { const itemContext = useContext(ItemContext); if (!itemContext) { return <>{children}; } const hasContent = React.Children.count(children) > 0; return ( {hasContent && itemContext.isExpanded && ( {children} )} ); }; const FolderTree = { Root, Item, Trigger, Content, }; export default FolderTree;