{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"project-folder","type":"registry:block","title":"Project Folder","description":"An interactive project folder that opens its file fan on hover or focus, expands into a focus-managed overlay, then retraces the complete path when closed.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/project-folder.tsx","type":"registry:component","target":"@components/motion/project-folder.tsx","content":"\"use client\";\n// beui.dev/components/blocks/project-folder\n\nimport { X } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  LayoutGroup,\n  motion,\n  useReducedMotion,\n  type Transition,\n} from \"motion/react\";\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type ReactNode,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { SPRING_LAYOUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ProjectFolderPreview = {\n  id: string;\n  content: ReactNode;\n};\n\nexport interface ProjectFolderProps {\n  title: string;\n  description?: string;\n  previews?: ProjectFolderPreview[];\n  count?: number;\n  itemLabel?: string;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  expanded?: boolean;\n  defaultExpanded?: boolean;\n  onExpandedChange?: (expanded: boolean) => void;\n  onClick?: () => void;\n  disabled?: boolean;\n  ariaLabel?: string;\n  className?: string;\n}\n\nconst MAX_PREVIEWS = 5;\nconst FOCUSABLE_SELECTOR = [\n  \"a[href]\",\n  \"button:not([disabled])\",\n  \"input:not([disabled])\",\n  \"select:not([disabled])\",\n  \"textarea:not([disabled])\",\n  '[tabindex]:not([tabindex=\"-1\"])',\n].join(\",\");\n\nfunction getPreviewTransform(index: number, count: number) {\n  const offset = index - (count - 1) / 2;\n  const distance = Math.abs(offset);\n  const centerLift = Math.max(0, 2 - distance) * 8;\n\n  return {\n    x: offset * 44,\n    y: 8 - centerLift,\n    rotate: offset * 6,\n    scale: distance === 0 ? 1.04 : distance === 1 ? 0.95 : 0.88,\n    opacity: distance === 0 ? 1 : distance === 1 ? 0.78 : 0.58,\n    zIndex: 10 - distance,\n  };\n}\n\nexport function ProjectFolder({\n  title,\n  description = \"Updated recently\",\n  previews = [],\n  count = previews.length,\n  itemLabel = \"file\",\n  open,\n  defaultOpen = false,\n  onOpenChange,\n  expanded,\n  defaultExpanded = false,\n  onExpandedChange,\n  onClick,\n  disabled = false,\n  ariaLabel,\n  className,\n}: ProjectFolderProps) {\n  const reduce = useReducedMotion();\n  const canHover = useHoverCapable();\n  const layoutGroupId = useId();\n  const dialogTitleId = `${layoutGroupId}-title`;\n  const hoveredRef = useRef(false);\n  const focusedRef = useRef(false);\n  const restoringFocusRef = useRef(false);\n  const folderButtonRef = useRef<HTMLButtonElement>(null);\n  const closeButtonRef = useRef<HTMLButtonElement>(null);\n  const dialogRef = useRef<HTMLDivElement>(null);\n  const [mounted, setMounted] = useState(false);\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);\n  const [isClosing, setIsClosing] = useState(false);\n  const openControlled = open !== undefined;\n  const expandedControlled = expanded !== undefined;\n  const isExpanded = expanded ?? internalExpanded;\n  const isOpen = (open ?? internalOpen) || isExpanded;\n  const previewItems = previews.slice(0, MAX_PREVIEWS);\n  const transition: Transition = reduce ? { duration: 0 } : SPRING_LAYOUT;\n  const countText = `${count} ${itemLabel}${count === 1 ? \"\" : \"s\"}`;\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (disabled) return;\n      if (!openControlled) setInternalOpen(next);\n      onOpenChange?.(next);\n    },\n    [disabled, onOpenChange, openControlled],\n  );\n\n  const setExpanded = useCallback(\n    (next: boolean) => {\n      if (disabled || previewItems.length === 0) return;\n      if (!expandedControlled) setInternalExpanded(next);\n      onExpandedChange?.(next);\n    },\n    [disabled, expandedControlled, onExpandedChange, previewItems.length],\n  );\n\n  const finishClose = useCallback(() => {\n    setIsClosing(false);\n    restoringFocusRef.current = true;\n    requestAnimationFrame(() => folderButtonRef.current?.focus());\n  }, []);\n\n  const closeOverlay = useCallback(() => {\n    setIsClosing(true);\n    setOpen(false);\n    setExpanded(false);\n  }, [setExpanded, setOpen]);\n\n  useEffect(() => setMounted(true), []);\n\n  useEffect(() => {\n    if (reduce && isClosing) finishClose();\n  }, [finishClose, isClosing, reduce]);\n\n  useEffect(() => {\n    if (!isExpanded) return;\n\n    const previousOverflow = document.body.style.overflow;\n    const focusFrame = requestAnimationFrame(() => closeButtonRef.current?.focus());\n\n    document.body.style.overflow = \"hidden\";\n\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        event.preventDefault();\n        closeOverlay();\n        return;\n      }\n      if (event.key !== \"Tab\" || !dialogRef.current) return;\n\n      const focusable = Array.from(\n        dialogRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),\n      ).filter((element) => element.tabIndex >= 0);\n      const first = focusable[0];\n      const last = focusable.at(-1);\n      if (!first || !last) return;\n\n      if (event.shiftKey && document.activeElement === first) {\n        event.preventDefault();\n        last.focus();\n      } else if (!event.shiftKey && document.activeElement === last) {\n        event.preventDefault();\n        first.focus();\n      }\n    };\n\n    document.addEventListener(\"keydown\", handleKeyDown);\n    return () => {\n      cancelAnimationFrame(focusFrame);\n      document.body.style.overflow = previousOverflow;\n      document.removeEventListener(\"keydown\", handleKeyDown);\n    };\n  }, [closeOverlay, isExpanded]);\n\n  const handleFolderClick = () => {\n    setIsClosing(false);\n    setExpanded(true);\n    setOpen(true);\n    onClick?.();\n  };\n\n  const overlay = isExpanded || isClosing ? (\n    <div\n      ref={dialogRef}\n      role=\"dialog\"\n      aria-modal=\"true\"\n      aria-labelledby={dialogTitleId}\n      aria-hidden={isExpanded ? undefined : \"true\"}\n      className={cn(\n        \"fixed inset-0 z-50 flex items-start justify-center overflow-y-auto sm:items-center\",\n        isClosing && \"pointer-events-none\",\n      )}\n    >\n      <AnimatePresence initial={false}>\n        {isExpanded ? (\n          <motion.button\n            key=\"project-files-backdrop\"\n            type=\"button\"\n            tabIndex={-1}\n            aria-label=\"Close file overlay\"\n            onClick={closeOverlay}\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0 }}\n            transition={reduce ? { duration: 0 } : { duration: 0.18 }}\n            className=\"absolute inset-0 cursor-default bg-background/80 backdrop-blur-xl\"\n          />\n        ) : null}\n      </AnimatePresence>\n\n      <div className=\"relative z-10 w-full max-w-5xl px-6 py-8\">\n        <AnimatePresence initial={false}>\n          {isExpanded ? (\n            <motion.div\n              key=\"project-files-header\"\n              initial={{ opacity: 0, y: reduce ? 0 : 8 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, y: reduce ? 0 : -8 }}\n              transition={reduce ? { duration: 0 } : { duration: 0.18 }}\n              className=\"mb-6 flex items-center justify-between gap-4\"\n            >\n              <div>\n                <h2 id={dialogTitleId} className=\"text-xl font-medium text-foreground\">\n                  {title}\n                </h2>\n                <p className=\"mt-1 text-sm text-muted-foreground\">{countText}</p>\n              </div>\n              <button\n                ref={closeButtonRef}\n                type=\"button\"\n                onClick={closeOverlay}\n                aria-label={`Close ${title}`}\n                className=\"flex size-10 items-center justify-center rounded-full border border-foreground/10 bg-background/50 text-muted-foreground backdrop-blur-xl transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n              >\n                <X className=\"size-4\" aria-hidden=\"true\" />\n              </button>\n            </motion.div>\n          ) : null}\n        </AnimatePresence>\n\n        <div className=\"grid grid-cols-2 place-items-center gap-3 sm:grid-cols-3 lg:grid-cols-5\">\n          {isExpanded\n            ? previewItems.map((preview) => (\n                <motion.div\n                  key={preview.id}\n                  layoutId={`file-${preview.id}`}\n                  transition={transition}\n                  className=\"aspect-[3/4] w-full max-w-40 overflow-hidden rounded-xl border border-foreground/10 bg-background/50 backdrop-blur-xl\"\n                >\n                  {preview.content}\n                </motion.div>\n              ))\n            : null}\n        </div>\n      </div>\n    </div>\n  ) : null;\n\n  return (\n    <LayoutGroup id={layoutGroupId}>\n      <motion.button\n        ref={folderButtonRef}\n        type=\"button\"\n        disabled={disabled}\n        aria-label={ariaLabel}\n        aria-haspopup=\"dialog\"\n        aria-expanded={isExpanded}\n        data-open={isOpen ? \"true\" : \"false\"}\n        data-expanded={isExpanded ? \"true\" : \"false\"}\n        tabIndex={isExpanded ? -1 : undefined}\n        onPointerEnter={() => {\n          if (!canHover) return;\n          hoveredRef.current = true;\n          setOpen(true);\n        }}\n        onPointerLeave={() => {\n          if (!canHover) return;\n          hoveredRef.current = false;\n          if (!isExpanded && !isClosing) setOpen(focusedRef.current);\n        }}\n        onFocus={() => {\n          if (restoringFocusRef.current) {\n            restoringFocusRef.current = false;\n            focusedRef.current = false;\n            return;\n          }\n          focusedRef.current = true;\n          setOpen(true);\n        }}\n        onBlur={() => {\n          focusedRef.current = false;\n          if (!isExpanded && !isClosing) setOpen(hoveredRef.current);\n        }}\n        onClick={handleFolderClick}\n        whileTap={reduce || disabled ? undefined : { scale: 0.98 }}\n        transition={reduce ? { duration: 0 } : SPRING_PRESS}\n        className={cn(\n          \"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\",\n          className,\n        )}\n      >\n        <motion.span\n          aria-hidden=\"true\"\n          animate={{ rotateX: isOpen && !reduce ? 15 : 0 }}\n          transition={transition}\n          className=\"absolute inset-0 rounded-2xl border border-foreground/10 bg-background/25 backdrop-blur-xl [transform-origin:center_bottom]\"\n        />\n\n        <span\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0\"\n        >\n          <span className=\"absolute left-1/2 top-0 block h-0 w-0\">\n            <AnimatePresence initial={false}>\n              {!isExpanded\n                  ? previewItems.map((preview, index) => {\n                    const opened = getPreviewTransform(\n                      index,\n                      previewItems.length,\n                    );\n                    return (\n                      <motion.span\n                        key={preview.id}\n                        layoutId={`file-${preview.id}`}\n                        initial={false}\n                        animate={\n                          isOpen && !reduce\n                            ? {\n                                x: opened.x * 1.4,\n                                y: opened.y - 8,\n                                rotate: opened.rotate * 1.3,\n                                scale: opened.scale * 1.02,\n                                opacity: Math.min(1, opened.opacity + 0.18),\n                              }\n                            : {\n                                x: opened.x,\n                                y: opened.y,\n                                rotate: opened.rotate,\n                                scale: opened.scale,\n                                opacity: opened.opacity,\n                              }\n                        }\n                        transition={transition}\n                        onLayoutAnimationComplete={() => {\n                          if (isClosing && index === 0) finishClose();\n                        }}\n                        className=\"absolute left-0 top-0 -ml-12 block h-40 w-24 overflow-hidden rounded-lg border border-foreground/10 bg-background/45 backdrop-blur-lg\"\n                        style={{ zIndex: opened.zIndex }}\n                      >\n                        {preview.content}\n                      </motion.span>\n                    );\n                  })\n                : null}\n            </AnimatePresence>\n          </span>\n        </span>\n\n        <motion.span\n          initial={false}\n          animate={{ rotateX: isOpen && !reduce ? -25 : 0 }}\n          transition={transition}\n          className=\"absolute inset-x-0 bottom-0 z-20 overflow-hidden rounded-2xl border border-foreground/10 bg-background/60 backdrop-blur-2xl [backface-visibility:hidden] [transform-origin:center_bottom]\"\n        >\n          <span className=\"flex h-16 items-center px-4\">\n            <span className=\"line-clamp-2 text-xl font-medium leading-tight text-foreground\">\n              {title}\n            </span>\n          </span>\n          <span className=\"flex h-12 items-center justify-between gap-3 border-t border-foreground/10 px-4\">\n            <span className=\"shrink-0 text-sm font-medium text-foreground/70\">\n              {countText}\n            </span>\n            <span className=\"truncate text-sm text-muted-foreground\">\n              {description}\n            </span>\n          </span>\n        </motion.span>\n      </motion.button>\n\n      {mounted ? createPortal(overlay, document.body) : null}\n    </LayoutGroup>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/hooks/use-hover-capable.ts","type":"registry:hook","target":"@lib/hooks/use-hover-capable.ts","content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/**\n * Returns true only on devices that have a true hover (mouse / trackpad).\n * Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere\n * — gate hover-only effects (scale lifts, magnetic pulls) behind this.\n */\nexport function useHoverCapable() {\n  const [canHover, setCanHover] = useState(false);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\" || !window.matchMedia) return;\n    const mq = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    const update = () => setCanHover(mq.matches);\n    update();\n    mq.addEventListener?.(\"change\", update);\n    return () => mq.removeEventListener?.(\"change\", update);\n  }, []);\n\n  return canHover;\n}\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}