{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "dropdown", "title": "Dropdown", "description": "Dropdown menu with proximity hover, animated selection indicator, inline panel or triggered popup with collision-aware positioning, typeahead, and close-on-select. Includes MenuItem. Base UI flavor.", "dependencies": [ "@base-ui/react", "framer-motion" ], "registryDependencies": [ "https://zeron-ui.vercel.app/r/surfaces.json", "utils", "https://zeron-ui.vercel.app/r/springs.json", "https://zeron-ui.vercel.app/r/shape-context.json", "https://zeron-ui.vercel.app/r/surface-context.json", "https://zeron-ui.vercel.app/r/surface-classes.json", "https://zeron-ui.vercel.app/r/icon-context.json", "https://zeron-ui.vercel.app/r/use-proximity-hover.json", "https://zeron-ui.vercel.app/r/elevated.json", "https://zeron-ui.vercel.app/r/portal-container-context.json" ], "files": [ { "path": "src/components/ui/dropdown.tsx", "content": "\"use client\";\n\nimport {\n useRef,\n useState,\n useEffect,\n useCallback,\n useMemo,\n createContext,\n useContext,\n forwardRef,\n type ReactNode,\n type HTMLAttributes,\n type ComponentProps,\n} from \"react\";\nimport { motion, AnimatePresence } from \"framer-motion\";\nimport { Menu } from \"@base-ui/react/menu\";\nimport type { MenuTriggerProps } from \"@base-ui/react/menu\";\nimport {\n DropdownContext,\n useDropdown,\n useDropdownMaybe,\n type DropdownContextValue,\n type MenuItemRenderOptions,\n} from \"@/components/ui/menu-item\";\nimport { cn } from \"@/lib/utils\";\nimport { spring, exitFallbackMs } from \"@/lib/springs\";\nimport { useProximityHover } from \"@/hooks/use-proximity-hover\";\nimport { shapeMap } from \"@/lib/shape-context\";\nimport { Elevated } from \"@/lib/elevated\";\nimport { usePortalContainer } from \"@/lib/portal-container-context\";\n\n// Dropdown opts out of the global pill/rounded shape context — popover surfaces\n// look cleaner with the smaller \"rounded\" radii regardless of how the rest of\n// the UI is shaped (the heavy pill bubbling distorts perceived padding at this\n// scale and produces the corner-shadow asymmetry).\nconst shape = shapeMap.rounded;\n\n// ---------------------------------------------------------------------------\n// Panel context — shared by the inline Dropdown and the popup DropdownContent.\n//\n// The context object itself lives in menu-item.tsx so MenuItem resolves\n// whichever dropdown provider actually wraps it, even when dropdowns built\n// on different primitives render side by side. Re-exported here so the\n// public dropdown API is unchanged.\n// ---------------------------------------------------------------------------\n\nexport { useDropdown, useDropdownMaybe };\nexport type { DropdownContextValue, MenuItemRenderOptions };\n\n// ---------------------------------------------------------------------------\n// Dropdown (inline panel)\n//\n// An always-rendered panel — no trigger, positioning, or dismissal. Because it\n// sits statically in the page it does NOT claim popup menu semantics: the\n// container is a plain role=\"group\" (pass `aria-label` to name it). The real\n// role=\"menu\" lives on the popup DropdownContent below, which Base UI wires to\n// a trigger. Consumers who hand-roll a trigger around the inline panel get\n// grouping semantics rather than a falsely-announced popup menu.\n// ---------------------------------------------------------------------------\n\ninterface DropdownProps extends HTMLAttributes {\n children: ReactNode;\n checkedIndex?: number;\n}\n\nconst Dropdown = forwardRef(\n ({ children, checkedIndex, className, ...props }, ref) => {\n const containerRef = useRef(null);\n const {\n activeIndex,\n setActiveIndex,\n itemRects,\n sessionRef,\n handlers,\n registerItem,\n measureItems,\n } = useProximityHover(containerRef);\n\n useEffect(() => {\n measureItems();\n }, [measureItems, children]);\n\n const [focusedIndex, setFocusedIndex] = useState(null);\n\n const activeRect = activeIndex !== null ? itemRects[activeIndex] : null;\n const checkedRect =\n checkedIndex != null ? itemRects[checkedIndex] : null;\n const focusRect = focusedIndex !== null ? itemRects[focusedIndex] : null;\n return (\n \n {\n (containerRef as React.MutableRefObject).current = node;\n if (typeof ref === \"function\") ref(node);\n else if (ref) (ref as React.MutableRefObject).current = node;\n }}\n onMouseEnter={handlers.onMouseEnter}\n onMouseMove={handlers.onMouseMove}\n onMouseLeave={handlers.onMouseLeave}\n onFocus={(e) => {\n const indexAttr = (e.target as HTMLElement)\n .closest(\"[data-proximity-index]\")\n ?.getAttribute(\"data-proximity-index\");\n if (indexAttr != null) {\n const idx = Number(indexAttr);\n setActiveIndex(idx);\n setFocusedIndex(\n (e.target as HTMLElement).matches(\":focus-visible\") ? idx : null\n );\n }\n }}\n onBlur={(e) => {\n if (containerRef.current?.contains(e.relatedTarget as Node)) return;\n setFocusedIndex(null);\n setActiveIndex(null);\n }}\n onKeyDown={(e) => {\n const items = Array.from(\n containerRef.current?.querySelectorAll(\n '[role=\"menuitem\"], [role=\"menuitemradio\"]'\n ) ?? []\n ) as HTMLElement[];\n const currentIdx = items.indexOf(e.target as HTMLElement);\n if (currentIdx === -1) return;\n\n if ([\"ArrowDown\", \"ArrowUp\", \"ArrowRight\", \"ArrowLeft\"].includes(e.key)) {\n e.preventDefault();\n const next = [\"ArrowDown\", \"ArrowRight\"].includes(e.key)\n ? (currentIdx + 1) % items.length\n : (currentIdx - 1 + items.length) % items.length;\n items[next].focus();\n } else if (e.key === \"Home\") {\n e.preventDefault();\n items[0]?.focus();\n } else if (e.key === \"End\") {\n e.preventDefault();\n items[items.length - 1]?.focus();\n }\n }}\n role=\"group\"\n className={cn(\n `relative flex flex-col gap-0.5 w-72 max-w-full ${shape.container} p-1 select-none`,\n className\n )}\n {...props}\n >\n {/* Selected background */}\n \n {checkedRect && (\n \n )}\n \n\n {/* Hover background */}\n \n {activeRect && (\n \n )}\n \n\n {/* Focus ring */}\n \n {focusRect && (\n \n )}\n \n\n {children}\n \n \n );\n }\n);\n\nDropdown.displayName = \"Dropdown\";\n\n// ---------------------------------------------------------------------------\n// DropdownMenu (popup root)\n//\n// Built on Base UI's Menu primitive, which owns the trigger wiring,\n// positioning (collision flipping, anchor tracking), dismissal (outside\n// press, focus-out, Escape), roving highlight, typeahead, and close-on-select.\n// This layer keeps the proximity-hover overlays and the\n// spring open/close animation (via actionsRef deferred unmount) — the same\n// verified pattern as select.tsx.\n// ---------------------------------------------------------------------------\n\ninterface DropdownMenuActions {\n unmount: () => void;\n close: () => void;\n}\n\ninterface DropdownMenuContextValue {\n open: boolean;\n actionsRef: React.RefObject;\n}\n\nconst DropdownMenuContext = createContext(null);\n\nfunction useDropdownMenuContext() {\n const ctx = useContext(DropdownMenuContext);\n if (!ctx)\n throw new Error(\n \"DropdownMenu compound components must be inside \"\n );\n return ctx;\n}\n\ninterface DropdownMenuProps {\n children: ReactNode;\n open?: boolean;\n defaultOpen?: boolean;\n onOpenChange?: (open: boolean) => void;\n disabled?: boolean;\n}\n\nfunction DropdownMenu({\n children,\n open: openProp,\n defaultOpen = false,\n onOpenChange,\n disabled = false,\n}: DropdownMenuProps) {\n const [internalOpen, setInternalOpen] = useState(defaultOpen);\n const open = openProp !== undefined ? openProp : internalOpen;\n const actionsRef = useRef(null);\n\n const handleOpenChange = useCallback(\n (next: boolean) => {\n if (openProp === undefined) setInternalOpen(next);\n onOpenChange?.(next);\n },\n [openProp, onOpenChange]\n );\n\n const ctx = useMemo(() => ({ open, actionsRef }), [open]);\n\n return (\n \n \n {children}\n \n \n );\n}\n\nDropdownMenu.displayName = \"DropdownMenu\";\n\n// ---------------------------------------------------------------------------\n// DropdownTrigger\n//\n// Base UI's Menu.Trigger, re-exported under the library name. Composes via\n// the `render` prop, so any element can be the trigger:\n//\n// Open} />\n// ---------------------------------------------------------------------------\n\ntype DropdownTriggerProps = MenuTriggerProps;\n\nconst DropdownTrigger = Menu.Trigger;\n\n// ---------------------------------------------------------------------------\n// DropdownContent (popup panel)\n//\n// Portal > Positioner > Popup carrying the exact inline-panel visuals:\n// Elevated surface, proximity-hover overlays, animated selected background,\n// and animated focus ring. Children are wrapped in a Menu.RadioGroup so\n// radio-style MenuItems (boolean `checked`) get correct aria-checked from\n// `checkedIndex`.\n// ---------------------------------------------------------------------------\n\ntype MenuPositionerProps = ComponentProps;\n\ninterface DropdownContentProps\n extends Omit<\n ComponentProps,\n \"children\" | \"className\" | \"render\"\n > {\n children: ReactNode;\n className?: string;\n /** Index of the checked item. Drives the animated selected background and\n * the radio-group value announced to assistive tech. */\n checkedIndex?: number;\n side?: MenuPositionerProps[\"side\"];\n align?: MenuPositionerProps[\"align\"];\n sideOffset?: number;\n alignOffset?: number;\n}\n\nconst DropdownContent = forwardRef(\n (\n {\n className,\n children,\n checkedIndex,\n side = \"bottom\",\n align = \"start\",\n sideOffset = 6,\n alignOffset = 0,\n ...popupProps\n },\n ref\n ) => {\n const { open, actionsRef } = useDropdownMenuContext();\n const portalContainer = usePortalContainer();\n const containerRef = useRef(null);\n\n const {\n activeIndex,\n setActiveIndex,\n itemRects,\n sessionRef,\n handlers,\n registerItem,\n measureItems,\n } = useProximityHover(containerRef);\n\n const [focusedIndex, setFocusedIndex] = useState(null);\n\n // Release Base UI's deferred unmount once the exit tween has played.\n // onAnimationComplete on the motion.div is the primary signal; this\n // timeout is a fallback for throttled/background tabs where rAF-driven\n // animation callbacks can stall. The popup exits with spring.fast, so the\n // fallback tracks that tier's exit duration plus a safety buffer.\n useEffect(() => {\n if (open) return;\n const id = setTimeout(\n () => actionsRef.current?.unmount(),\n exitFallbackMs(spring.fast)\n );\n return () => clearTimeout(id);\n }, [open, actionsRef]);\n\n // Measure items once the popup has mounted.\n useEffect(() => {\n if (!open) return;\n // Double rAF: first waits for React commit, second for layout\n let inner: number;\n const outer = requestAnimationFrame(() => {\n inner = requestAnimationFrame(() => {\n measureItems();\n });\n });\n return () => {\n cancelAnimationFrame(outer);\n cancelAnimationFrame(inner);\n };\n }, [open, measureItems]);\n\n const activeRect = activeIndex !== null ? itemRects[activeIndex] : null;\n const checkedRect = checkedIndex != null ? itemRects[checkedIndex] : null;\n const focusRect = focusedIndex !== null ? itemRects[focusedIndex] : null;\n // Inside the popup, Base UI's Menu.Item / Menu.RadioItem own the role,\n // aria-checked, tabIndex, roving highlight, typeahead, and Enter/Space/\n // click activation (activation synthesizes a click, so the row div's\n // onClick also fires for keyboard). The render div carries the Fluid\n // Functionalism visuals and the proximity-hover registration.\n const renderMenuItem = useCallback(\n ({\n radio,\n value,\n disabled,\n label,\n closeOnClick,\n element,\n children,\n }: MenuItemRenderOptions) =>\n radio ? (\n \n {children}\n \n ) : (\n \n {children}\n \n ),\n []\n );\n\n const contentCtx = useMemo(\n () => ({\n registerItem,\n activeIndex,\n checkedIndex,\n inMenu: true,\n renderMenuItem,\n }),\n [registerItem, activeIndex, checkedIndex, renderMenuItem]\n );\n\n return (\n \n \n {\n if (!open) actionsRef.current?.unmount();\n }}\n >\n \n {\n (\n containerRef as React.MutableRefObject\n ).current = node;\n if (typeof ref === \"function\") ref(node);\n else if (ref)\n (\n ref as React.MutableRefObject\n ).current = node;\n }}\n />\n }\n onMouseEnter={() => {\n handlers.onMouseEnter();\n setFocusedIndex(null);\n }}\n onMouseMove={handlers.onMouseMove}\n onMouseLeave={handlers.onMouseLeave}\n onFocus={(e) => {\n const indexAttr = (e.target as HTMLElement)\n .closest(\"[data-proximity-index]\")\n ?.getAttribute(\"data-proximity-index\");\n if (indexAttr != null) {\n const idx = Number(indexAttr);\n setActiveIndex(idx);\n setFocusedIndex(\n (e.target as HTMLElement).matches(\":focus-visible\")\n ? idx\n : null\n );\n }\n }}\n onBlur={(e) => {\n if (containerRef.current?.contains(e.relatedTarget as Node))\n return;\n setFocusedIndex(null);\n setActiveIndex(null);\n }}\n className={cn(\n // min-w tracks the trigger via the Positioner's\n // --anchor-width var.\n `relative flex flex-col gap-0.5 w-72 max-w-full min-w-[var(--anchor-width)] max-h-[min(480px,var(--available-height))] overflow-y-auto ${shape.container} p-1 select-none outline-none`,\n className\n )}\n >\n {/* Selected background */}\n \n {checkedRect && (\n \n )}\n \n\n {/* Hover background */}\n \n {activeRect && (\n \n )}\n \n\n {/* Focus ring */}\n \n {focusRect && (\n \n )}\n \n\n {/* display: contents keeps items direct flex children of the\n popup so proximity measurement and gap layout still work,\n while the group provides the radio value context. */}\n \n {children}\n \n \n \n \n \n \n );\n }\n);\n\nDropdownContent.displayName = \"DropdownContent\";\n\n// ---------------------------------------------------------------------------\n// DropdownLabel\n// ---------------------------------------------------------------------------\n\nconst DropdownLabel = forwardRef>(\n ({ className, ...props }, ref) => (\n \n )\n);\n\nDropdownLabel.displayName = \"DropdownLabel\";\n\n// ---------------------------------------------------------------------------\n// DropdownSeparator\n// ---------------------------------------------------------------------------\n\nconst DropdownSeparator = forwardRef<\n HTMLDivElement,\n HTMLAttributes\n>(({ className, ...props }, ref) => (\n \n));\n\nDropdownSeparator.displayName = \"DropdownSeparator\";\n\nexport {\n Dropdown,\n DropdownLabel,\n DropdownSeparator,\n DropdownMenu,\n DropdownTrigger,\n DropdownContent,\n};\n// DropdownContextValue and MenuItemRenderOptions are already re-exported\n// above next to their import — repeating them here is a duplicate-export\n// build error.\nexport type {\n DropdownProps,\n DropdownMenuProps,\n DropdownTriggerProps,\n DropdownContentProps,\n};\nexport default Dropdown;\n", "type": "registry:ui", "target": "components/ui/dropdown.tsx" }, { "path": "src/components/ui/menu-item.tsx", "content": "\"use client\";\n\nimport {\n createContext,\n useContext,\n useRef,\n useEffect,\n forwardRef,\n type HTMLAttributes,\n type ReactElement,\n type ReactNode,\n} from \"react\";\nimport type { IconComponent } from \"@/lib/icon-context\";\nimport { motion, AnimatePresence } from \"framer-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { shapeMap } from \"@/lib/shape-context\";\nimport { spring } from \"@/lib/springs\";\n\n// MenuItem is only used inside Dropdown, which opts out of the global pill\n// shape — see dropdown.tsx for the rationale.\nconst shape = shapeMap.rounded;\n\n// ---------------------------------------------------------------------------\n// Dropdown context — the single shared context for every Dropdown build.\n//\n// It lives here rather than in the dropdown module so that (a) MenuItem stays\n// primitive-free and self-contained. The dropdown module re-exports\n// useDropdown from here, keeping its public API unchanged.\n// ---------------------------------------------------------------------------\n\n/** What MenuItem hands to the popup's primitive wrapper. `element` is the\n * styled row div (visuals + proximity registration, no children); `children`\n * is the row content (icon, label, check). The dropdown wraps them in its\n * own Item / RadioItem primitive, so MenuItem itself stays primitive-free. */\nexport interface MenuItemRenderOptions {\n /** Radio-style option (boolean `checked` on MenuItem) vs plain action item. */\n radio: boolean;\n /** The item's index — doubles as the radio value. */\n value: number;\n disabled?: boolean;\n label: string;\n closeOnClick: boolean;\n element: ReactElement;\n children: ReactNode;\n}\n\nexport interface DropdownContextValue {\n registerItem: (index: number, element: HTMLElement | null) => void;\n activeIndex: number | null;\n checkedIndex?: number;\n /** True when items render inside a Menu popup (DropdownContent), where the\n * primitive's Item / RadioItem own roles, roving highlight, typeahead,\n * and activation. MenuItem switches its rendering accordingly. */\n inMenu?: boolean;\n /** Popup-only: wraps a MenuItem's styled div in the dropdown's menu-item\n * primitive. Absent in the inline Dropdown panel, where MenuItem renders\n * its own ARIA menuitem div. */\n renderMenuItem?: (opts: MenuItemRenderOptions) => ReactElement;\n}\n\nexport const DropdownContext = createContext(null);\n\nexport function useDropdown() {\n const ctx = useContext(DropdownContext);\n if (!ctx) throw new Error(\"useDropdown must be used within a Dropdown\");\n return ctx;\n}\n\n/** Null-safe context read for callers that render outside a provider. */\nexport function useDropdownMaybe() {\n return useContext(DropdownContext);\n}\n\ninterface MenuItemProps extends HTMLAttributes {\n /** Optional leading icon. When omitted, the row renders text-only with no\n * reserved icon column. */\n icon?: IconComponent;\n label: string;\n index: number;\n /** When a boolean, the item is a radio-style option (role=\"menuitemradio\"\n * with aria-checked). When undefined, it is a plain action item\n * (role=\"menuitem\", no checked state announced). */\n checked?: boolean;\n onSelect?: () => void;\n disabled?: boolean;\n /** Popup-only (inside DropdownContent): whether activating the item closes\n * the menu. Ignored in the inline Dropdown panel. @default true */\n closeOnClick?: boolean;\n}\n\nconst MenuItem = forwardRef(\n (\n {\n icon: Icon,\n label,\n index,\n checked,\n onSelect,\n disabled,\n closeOnClick,\n className,\n onClick,\n ...props\n },\n ref\n ) => {\n const internalRef = useRef(null);\n const hasMounted = useRef(false);\n const { registerItem, activeIndex, checkedIndex, renderMenuItem } =\n useDropdown();\n\n useEffect(() => {\n registerItem(index, internalRef.current);\n return () => registerItem(index, null);\n }, [index, registerItem]);\n\n useEffect(() => {\n hasMounted.current = true;\n }, []);\n\n const isActive = activeIndex === index;\n const skipAnimation = !hasMounted.current;\n\n const mergeRef = (node: HTMLDivElement | null) => {\n (internalRef as React.MutableRefObject).current = node;\n if (typeof ref === \"function\") ref(node);\n else if (ref) (ref as React.MutableRefObject).current = node;\n };\n\n const handleActivate = disabled\n ? undefined\n : (e: React.MouseEvent) => {\n onClick?.(e);\n onSelect?.();\n };\n\n const itemClassName = cn(\n // Keep rows at 32px and prevent max-height popup columns from\n // compressing a long list instead of scrolling it.\n `relative z-content flex h-control-sm shrink-0 items-center gap-2 ${shape.item} px-2 cursor-pointer outline-none`,\n disabled && \"opacity-50 pointer-events-none\",\n className\n );\n\n const content = (\n <>\n {Icon && (\n \n \n \n \n \n \n )}\n {/* The invisible bold copy reserves width so weight changes do not reflow. */}\n \n \n {label}\n \n \n {label}\n \n \n \n {checked && (\n \n \n \n )}\n \n \n );\n\n if (renderMenuItem) {\n // Inside DropdownContent, the menu-item primitive (supplied by the\n // surrounding DropdownContent through context) owns the role,\n // aria-checked, tabIndex, roving highlight, typeahead, and Enter/Space/\n // click activation (activation synthesizes a click, so handleActivate\n // also fires for keyboard). The styled div carries the Fluid\n // Functionalism visuals and the proximity-hover registration; MenuItem\n // itself imports no primitive.\n return renderMenuItem({\n radio: typeof checked === \"boolean\",\n value: index,\n disabled,\n label,\n closeOnClick: closeOnClick ?? true,\n element: (\n \n ),\n children: content,\n });\n }\n\n return (\n {\n if (disabled) return;\n if (e.key === \" \" || e.key === \"Enter\") {\n e.preventDefault();\n onSelect?.();\n }\n }}\n className={itemClassName}\n {...props}\n >\n {content}\n \n );\n }\n);\n\nMenuItem.displayName = \"MenuItem\";\n\nexport { MenuItem };\nexport default MenuItem;\n", "type": "registry:ui", "target": "components/ui/menu-item.tsx" } ], "type": "registry:ui" }