Folder Tree

PreviousNext

A customizable folder tree component for displaying hierarchical data with expandable nodes and file icons.

Docs
scrollxuicomponent

Preview

Loading preview…
components/ui/folder-tree.tsx
"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;

Installation

npx shadcn@latest add @scrollxui/folder-tree

Usage

import { FolderTree } from "@/components/folder-tree"
<FolderTree />