"use client"; import { X } from "lucide-react"; import { AnimatePresence, LayoutGroup, motion, useReducedMotion, type Transition, } from "motion/react"; import { useCallback, useEffect, useId, useRef, useState, type ReactNode, } from "react"; import { createPortal } from "react-dom"; import { SPRING_LAYOUT, SPRING_PRESS } from "@/lib/ease"; import { useHoverCapable } from "@/lib/hooks/use-hover-capable"; import { cn } from "@/lib/utils"; export type ProjectFolderPreview = { id: string; content: ReactNode; }; export interface ProjectFolderProps { title: string; description?: string; previews?: ProjectFolderPreview[]; count?: number; itemLabel?: string; open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; expanded?: boolean; defaultExpanded?: boolean; onExpandedChange?: (expanded: boolean) => void; onClick?: () => void; disabled?: boolean; ariaLabel?: string; className?: string; } const MAX_PREVIEWS = 5; const FOCUSABLE_SELECTOR = [ "a[href]", "button:not([disabled])", "input:not([disabled])", "select:not([disabled])", "textarea:not([disabled])", '[tabindex]:not([tabindex="-1"])', ].join(","); function getPreviewTransform(index: number, count: number) { const offset = index - (count - 1) / 2; const distance = Math.abs(offset); const centerLift = Math.max(0, 2 - distance) * 8; return { x: offset * 44, y: 8 - centerLift, rotate: offset * 6, scale: distance === 0 ? 1.04 : distance === 1 ? 0.95 : 0.88, opacity: distance === 0 ? 1 : distance === 1 ? 0.78 : 0.58, zIndex: 10 - distance, }; } export function ProjectFolder({ title, description = "Updated recently", previews = [], count = previews.length, itemLabel = "file", open, defaultOpen = false, onOpenChange, expanded, defaultExpanded = false, onExpandedChange, onClick, disabled = false, ariaLabel, className, }: ProjectFolderProps) { const reduce = useReducedMotion(); const canHover = useHoverCapable(); const layoutGroupId = useId(); const dialogTitleId = `${layoutGroupId}-title`; const hoveredRef = useRef(false); const focusedRef = useRef(false); const restoringFocusRef = useRef(false); const folderButtonRef = useRef(null); const closeButtonRef = useRef(null); const dialogRef = useRef(null); const [mounted, setMounted] = useState(false); const [internalOpen, setInternalOpen] = useState(defaultOpen); const [internalExpanded, setInternalExpanded] = useState(defaultExpanded); const [isClosing, setIsClosing] = useState(false); const openControlled = open !== undefined; const expandedControlled = expanded !== undefined; const isExpanded = expanded ?? internalExpanded; const isOpen = (open ?? internalOpen) || isExpanded; const previewItems = previews.slice(0, MAX_PREVIEWS); const transition: Transition = reduce ? { duration: 0 } : SPRING_LAYOUT; const countText = `${count} ${itemLabel}${count === 1 ? "" : "s"}`; const setOpen = useCallback( (next: boolean) => { if (disabled) return; if (!openControlled) setInternalOpen(next); onOpenChange?.(next); }, [disabled, onOpenChange, openControlled], ); const setExpanded = useCallback( (next: boolean) => { if (disabled || previewItems.length === 0) return; if (!expandedControlled) setInternalExpanded(next); onExpandedChange?.(next); }, [disabled, expandedControlled, onExpandedChange, previewItems.length], ); const finishClose = useCallback(() => { setIsClosing(false); restoringFocusRef.current = true; requestAnimationFrame(() => folderButtonRef.current?.focus()); }, []); const closeOverlay = useCallback(() => { setIsClosing(true); setOpen(false); setExpanded(false); }, [setExpanded, setOpen]); useEffect(() => setMounted(true), []); useEffect(() => { if (reduce && isClosing) finishClose(); }, [finishClose, isClosing, reduce]); useEffect(() => { if (!isExpanded) return; const previousOverflow = document.body.style.overflow; const focusFrame = requestAnimationFrame(() => closeButtonRef.current?.focus()); document.body.style.overflow = "hidden"; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); closeOverlay(); return; } if (event.key !== "Tab" || !dialogRef.current) return; const focusable = Array.from( dialogRef.current.querySelectorAll(FOCUSABLE_SELECTOR), ).filter((element) => element.tabIndex >= 0); const first = focusable[0]; const last = focusable.at(-1); if (!first || !last) return; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } }; document.addEventListener("keydown", handleKeyDown); return () => { cancelAnimationFrame(focusFrame); document.body.style.overflow = previousOverflow; document.removeEventListener("keydown", handleKeyDown); }; }, [closeOverlay, isExpanded]); const handleFolderClick = () => { setIsClosing(false); setExpanded(true); setOpen(true); onClick?.(); }; const overlay = isExpanded || isClosing ? (
{isExpanded ? ( ) : null}
{isExpanded ? (

{title}

{countText}

) : null}
{isExpanded ? previewItems.map((preview) => ( {preview.content} )) : null}
) : null; return ( { if (!canHover) return; hoveredRef.current = true; setOpen(true); }} onPointerLeave={() => { if (!canHover) return; hoveredRef.current = false; if (!isExpanded && !isClosing) setOpen(focusedRef.current); }} onFocus={() => { if (restoringFocusRef.current) { restoringFocusRef.current = false; focusedRef.current = false; return; } focusedRef.current = true; setOpen(true); }} onBlur={() => { focusedRef.current = false; if (!isExpanded && !isClosing) setOpen(hoveredRef.current); }} onClick={handleFolderClick} whileTap={reduce || disabled ? undefined : { scale: 0.98 }} transition={reduce ? { duration: 0 } : SPRING_PRESS} className={cn( "relative block h-56 w-72 select-none rounded-2xl text-left outline-none [perspective:1200px] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50", className, )} > {mounted ? createPortal(overlay, document.body) : null} ); }