"use client";
import React, { useState, useCallback, createContext, useContext, useRef, useEffect } from "react";
import { motion, AnimatePresence, Variants } from "framer-motion";
import { ChevronRight, Folder, FolderOpen, File, 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<string>;
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<ExpansionContextType | null>(null);
const SelectionContext = createContext<SelectionContextType | null>(null);
const TreeContext = createContext<TreeContextType | null>(null);
const LevelContext = createContext<LevelContextType>({ 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<number, string> = {
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;
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<RootProps> = ({
defaultExpanded = [],
defaultSelected,
onSelect,
className = "",
children,
id = "folder-tree",
}) => {
const [expandedIds, setExpandedIds] = useState<Set<string>>(
new Set(defaultExpanded)
);
const [selectedId, setSelectedId] = useState<string | null>(
defaultSelected || null
);
const [focusedId, setFocusedId] = useState<string | null>(null);
const [keyboardMode, setKeyboardMode] = useState(false);
const treeRef = useRef<HTMLDivElement>(null);
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) => {
setSelectedId(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 (
<ExpansionContext.Provider value={expansionValue}>
<SelectionContext.Provider value={selectionValue}>
<TreeContext.Provider value={treeValue}>
<LevelContext.Provider value={{ level: 0 }}>
<motion.div
ref={treeRef}
variants={animationVariants}
initial="rootInitial"
animate="rootAnimate"
transition={transitions.root}
className={cn(
"bg-white dark:bg-slate-900 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden",
className
)}
role="tree"
aria-labelledby={`${id}-label`}
tabIndex={0}
onKeyDown={handleKeyDown}
onFocus={handleTreeFocus}
onBlur={handleTreeBlur}
>
<div className="w-full overflow-y-auto bg-background text-sm">
{children}
</div>
</motion.div>
</LevelContext.Provider>
</TreeContext.Provider>
</SelectionContext.Provider>
</ExpansionContext.Provider>
);
};
const ItemContext = createContext<{
itemId: string;
hasChildren: boolean;
isExpanded: boolean;
toggleExpanded: () => void;
} | null>(null);
const Item: React.FC<ItemProps> = ({
id,
label,
icon,
badge,
modified,
untracked,
className = "",
children,
}) => {
const expansionContext = useExpansion();
const selectionContext = useSelection();
const treeContext = useTree();
const { level } = useLevel();
const itemRef = useRef<HTMLDivElement>(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 (
<span
className={defaultClassName}
aria-label={`${defaultContent} status`}
>
{defaultContent}
</span>
);
}
return (
<span
className={cn("ml-auto text-xs px-2 py-0.5 rounded-full", badgeData.className)}
aria-label={badgeData.ariaLabel || `Custom badge: ${badgeData.content}`}
>
{badgeData.content}
</span>
);
};
return (
<ItemContext.Provider value={itemContextValue}>
<LevelContext.Provider value={{ level: level + 1 }}>
<div>
<motion.div
ref={itemRef}
variants={animationVariants}
initial="itemInitial"
animate="itemAnimate"
transition={{ ...transitions.item, delay: level * 0.05 }}
data-selected={isSelected ? "true" : "false"}
data-id={id}
className={cn(
"flex items-center gap-2 py-1.5 text-sm transition-colors cursor-pointer select-none",
getPaddingClass(level),
className,
isSelected
? "bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 border-r-2 border-blue-600"
: "",
!isSelected && "hover:bg-gray-100 dark:hover:bg-slate-700/50",
keyboardMode && isFocused
? "focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset"
: "focus:outline-none"
)}
onClick={(e: React.MouseEvent) => {
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 && (
<motion.span
className="flex-shrink-0 cursor-pointer"
variants={animationVariants}
animate={isExpanded ? "chevronOpen" : "chevronClosed"}
transition={transitions.chevron}
aria-hidden="true"
>
<ChevronRight size={14} className="text-gray-500 dark:text-gray-400" />
</motion.span>
)}
{!hasChildren && <span className="w-3 mr-2" aria-hidden="true" />}
{IconComponent && (
<IconComponent
size={16}
data-selected={isSelected ? "true" : "false"}
data-child={hasChildren ? "true" : "false"}
className={cn(
"mr-1 flex-shrink-0 text-gray-500 data-[child=true]:text-blue-500 data-[selected=true]:text-blue-600 dark:data-[selected=true]:text-blue-400"
)}
aria-hidden="true"
/>
)}
<span className="flex-1">{label}</span>
{badge && (
<span
className="ml-auto text-xs bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 px-2 py-0.5 rounded-full"
aria-label={`Badge: ${badge}`}
>
{badge}
</span>
)}
{renderBadge(
modified,
"M",
"ml-auto text-xs bg-yellow-200 dark:bg-yellow-700 text-yellow-800 dark:text-yellow-200 px-2 py-0.5 rounded-full"
)}
{renderBadge(
untracked,
"U",
"ml-auto text-xs bg-green-200 dark:bg-green-700 text-green-800 dark:text-green-200 px-2 py-0.5 rounded-full"
)}
</motion.div>
{children}
</div>
</LevelContext.Provider>
</ItemContext.Provider>
);
};
const Trigger: React.FC<TriggerProps> = ({ className = "" }) => {
const itemContext = useContext(ItemContext);
if (!itemContext || !itemContext.hasChildren) {
return null;
}
return (
<motion.span
className={cn("mr-2 flex-shrink-0 cursor-pointer", className)}
variants={animationVariants}
animate={itemContext.isExpanded ? "chevronOpen" : "chevronClosed"}
transition={transitions.chevron}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
itemContext.toggleExpanded();
}}
role="button"
aria-label={itemContext.isExpanded ? "Collapse" : "Expand"}
tabIndex={-1}
>
<ChevronRight size={14} className="text-gray-500 dark:text-gray-400" />
</motion.span>
);
};
const Content: React.FC<ContentProps> = ({ children, className = "" }) => {
const itemContext = useContext(ItemContext);
if (!itemContext) {
return <>{children}</>;
}
const hasContent = React.Children.count(children) > 0;
return (
<AnimatePresence>
{hasContent && itemContext.isExpanded && (
<motion.div
variants={animationVariants}
initial="contentHidden"
animate="contentVisible"
exit="contentHidden"
transition={transitions.content}
style={{ overflow: "hidden" }}
className={className}
role="group"
>
{children}
</motion.div>
)}
</AnimatePresence>
);
};
const FolderTree = {
Root,
Item,
Trigger,
Content,
};
export default FolderTree;