{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"An ExpandableNavbar component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type FocusEvent,\n  type KeyboardEvent,\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\n\nexport type ExpandableNavbarItem = {\n  id: string;\n  label: string;\n  panel: ReactNode;\n};\n\nexport type ExpandableNavbarProps = {\n  className?: string;\n  closeDelay?: number;\n  items: ExpandableNavbarItem[];\n  onOpenChange?: (id: string | null) => void;\n  openId?: string | null;\n  openOnHover?: boolean;\n  width?: number | string;\n};\n\nconst DEFAULT_CLOSE_DELAY = 200;\nconst SLIDE_DISTANCE = 16;\nconst HEIGHT_SPRING = { bounce: 0.08, duration: 0.3, type: \"spring\" as const };\nconst PANEL_TRANSITION = {\n  duration: 0.2,\n  ease: [0.645, 0.045, 0.355, 1] as [number, number, number, number],\n};\n\nexport default function ExpandableNavbar({\n  items,\n  openId: controlledOpenId,\n  onOpenChange,\n  openOnHover = true,\n  closeDelay = DEFAULT_CLOSE_DELAY,\n  width = 640,\n  className,\n}: ExpandableNavbarProps) {\n  const shouldReduceMotion = useReducedMotion();\n  const generatedId = useId();\n  const navRef = useRef<HTMLDivElement>(null);\n  const measureRef = useRef<HTMLDivElement>(null);\n  const visiblePanelRef = useRef<HTMLDivElement>(null);\n  const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const previousIndexRef = useRef(-1);\n\n  const [internalOpenId, setInternalOpenId] = useState<string | null>(null);\n  const [height, setHeight] = useState<number | \"auto\">(0);\n  const [direction, setDirection] = useState(1);\n\n  const isControlled = controlledOpenId !== undefined;\n  const openId = isControlled ? controlledOpenId : internalOpenId;\n  const activeIndex = items.findIndex((item) => item.id === openId);\n  const activeItem = activeIndex >= 0 ? items[activeIndex] : null;\n\n  const setOpenId = useCallback(\n    (id: string | null) => {\n      if (!isControlled) {\n        setInternalOpenId(id);\n      }\n      onOpenChange?.(id);\n    },\n    [isControlled, onOpenChange]\n  );\n\n  useEffect(() => {\n    if (activeIndex >= 0) {\n      if (previousIndexRef.current >= 0) {\n        setDirection(activeIndex > previousIndexRef.current ? 1 : -1);\n      }\n      previousIndexRef.current = activeIndex;\n    }\n  }, [activeIndex]);\n\n  useLayoutEffect(() => {\n    const measureNode = measureRef.current;\n    if (!measureNode) {\n      return;\n    }\n    const nextHeight = activeItem ? measureNode.scrollHeight : 0;\n\n    setHeight((previousHeight) => {\n      if (previousHeight === \"auto\" && visiblePanelRef.current) {\n        const snapshot = visiblePanelRef.current.getBoundingClientRect();\n        requestAnimationFrame(() => setHeight(nextHeight));\n        return snapshot.height;\n      }\n      return nextHeight;\n    });\n  }, [activeItem]);\n\n  const clearCloseTimeout = useCallback(() => {\n    if (closeTimeoutRef.current) {\n      clearTimeout(closeTimeoutRef.current);\n      closeTimeoutRef.current = null;\n    }\n  }, []);\n\n  const scheduleClose = useCallback(() => {\n    clearCloseTimeout();\n    closeTimeoutRef.current = setTimeout(() => {\n      setOpenId(null);\n    }, closeDelay);\n  }, [clearCloseTimeout, closeDelay, setOpenId]);\n\n  useEffect(() => clearCloseTimeout, [clearCloseTimeout]);\n\n  const handleItemEnter = (id: string) => {\n    if (!openOnHover) {\n      return;\n    }\n    clearCloseTimeout();\n    setOpenId(id);\n  };\n\n  const handlePanelLeave = () => {\n    if (!openOnHover) {\n      return;\n    }\n    scheduleClose();\n  };\n\n  const handleItemClick = (id: string) => {\n    clearCloseTimeout();\n    setOpenId(openId === id ? null : id);\n  };\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (event.key === \"Escape\" && openId !== null) {\n      event.preventDefault();\n      setOpenId(null);\n    }\n  };\n\n  const handleBlurCapture = (event: FocusEvent<HTMLDivElement>) => {\n    const nextFocused = event.relatedTarget as Node | null;\n    if (!(nextFocused && navRef.current?.contains(nextFocused))) {\n      setOpenId(null);\n    }\n  };\n\n  useEffect(() => {\n    if (!openId) {\n      return;\n    }\n\n    const handlePointerDown = (event: PointerEvent) => {\n      if (!navRef.current?.contains(event.target as Node)) {\n        setOpenId(null);\n      }\n    };\n\n    document.addEventListener(\"pointerdown\", handlePointerDown);\n    return () => document.removeEventListener(\"pointerdown\", handlePointerDown);\n  }, [openId, setOpenId]);\n\n  return (\n    <nav\n      aria-label=\"Expandable navigation\"\n      className={cn(\n        \"relative rounded-2xl border border-foreground/10 bg-background shadow-lg\",\n        className\n      )}\n      onBlurCapture={handleBlurCapture}\n      onKeyDown={handleKeyDown}\n      onPointerLeave={handlePanelLeave}\n      ref={navRef}\n      style={{ width }}\n    >\n      <ul className=\"flex items-center gap-1 p-2\">\n        {items.map((item) => {\n          const isOpen = item.id === openId;\n          const panelId = `expandable-navbar-${generatedId}-${item.id}`;\n          return (\n            <li key={item.id}>\n              <button\n                aria-controls={panelId}\n                aria-expanded={isOpen}\n                className={cn(\n                  \"rounded-lg px-3 py-2 font-medium text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-brand\",\n                  isOpen\n                    ? \"bg-foreground/10 text-foreground\"\n                    : \"text-muted-foreground hover:text-foreground\"\n                )}\n                onClick={() => handleItemClick(item.id)}\n                onFocus={() => handleItemEnter(item.id)}\n                onPointerEnter={() => handleItemEnter(item.id)}\n                type=\"button\"\n              >\n                {item.label}\n              </button>\n            </li>\n          );\n        })}\n      </ul>\n\n      {/* Hidden mirror used to measure the target panel height without\n          waiting for the visible cross-fade animation to settle. */}\n      <div\n        aria-hidden=\"true\"\n        className=\"invisible absolute inset-x-0 top-0\"\n        ref={measureRef}\n        style={{ pointerEvents: \"none\" }}\n      >\n        <div className=\"p-4\">{activeItem?.panel}</div>\n      </div>\n\n      <motion.div\n        animate={{\n          height: shouldReduceMotion ? (activeItem ? \"auto\" : 0) : height,\n        }}\n        className=\"overflow-hidden\"\n        initial={false}\n        onAnimationComplete={() => {\n          if (activeItem) {\n            setHeight(\"auto\");\n          }\n        }}\n        transition={shouldReduceMotion ? { duration: 0 } : HEIGHT_SPRING}\n      >\n        <div className=\"relative\" ref={visiblePanelRef}>\n          <AnimatePresence custom={direction} initial={false} mode=\"wait\">\n            {activeItem ? (\n              <motion.div\n                animate={{ opacity: 1, x: 0 }}\n                exit={\n                  shouldReduceMotion\n                    ? { opacity: 0, transition: { duration: 0 } }\n                    : { opacity: 0, x: direction * -SLIDE_DISTANCE }\n                }\n                id={`expandable-navbar-${generatedId}-${activeItem.id}`}\n                initial={\n                  shouldReduceMotion\n                    ? { opacity: 0 }\n                    : { opacity: 0, x: direction * SLIDE_DISTANCE }\n                }\n                key={activeItem.id}\n                role=\"region\"\n                transition={\n                  shouldReduceMotion ? { duration: 0 } : PANEL_TRANSITION\n                }\n              >\n                <div className=\"p-4\">{activeItem.panel}</div>\n              </motion.div>\n            ) : null}\n          </AnimatePresence>\n        </div>\n      </motion.div>\n    </nav>\n  );\n}\n","path":"index.tsx","target":"components/smoothui/expandable-navbar/index.tsx","type":"registry:ui"}],"name":"expandable-navbar","registryDependencies":["https://smoothui.dev/r/tokens.json"],"title":"Expandable Navbar","type":"registry:ui"}