"use client"; import { Check, ChevronRight } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { createContext, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { EASE_OUT, SPRING_LAYOUT, SPRING_PANEL } from "@/lib/ease"; import { cn } from "@/lib/utils"; // Submenus close on a short grace delay so the pointer can cut the corner // between the trigger row and the panel without the panel vanishing. const SUB_CLOSE_GRACE_MS = 150; // Space (px) required below the trigger before the panel flips upward. const FLIP_MARGIN = 16; type Placement = "bottom" | "top"; type Align = "start" | "center" | "end"; interface MenuContextValue { open: boolean; setOpen: (open: boolean) => void; /** Close the whole menu; optionally hand focus back to the trigger. */ close: (focusTrigger?: boolean) => void; reduce: boolean; triggerId: string; menuId: string; } const MenuContext = createContext(null); function useMenuContext(component: string) { const ctx = useContext(MenuContext); if (!ctx) throw new Error(`${component} must be used within `); return ctx; } // Each panel (root content and every submenu) owns one gliding focus surface. // Hover and keyboard focus write the same `activeKey`, so there is a single // highlight that slides between rows instead of per-row backgrounds. interface PanelContextValue { surfaceId: string; activeKey: string | null; setActiveKey: (key: string | null) => void; } const PanelContext = createContext(null); function usePanelContext(component: string) { const ctx = useContext(PanelContext); if (!ctx) throw new Error(`${component} must be used within a dropdown panel`); return ctx; } /** Enabled menu items belonging to this panel only (submenu items excluded). */ function panelItems(panel: HTMLElement) { return Array.from( panel.querySelectorAll("[data-menu-item]"), ).filter( (el) => el.closest("[data-menu-panel]") === panel && !el.disabled, ); } // Shared roving-focus keyboard handling for the root panel and submenus. // Real focus moves between item buttons; Enter/Space stay native button // activation, so only navigation keys are handled here. function handlePanelKeys(event: ReactKeyboardEvent) { const { key } = event; if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(key)) return; const panel = event.currentTarget; const items = panelItems(panel); if (items.length === 0) return; event.preventDefault(); event.stopPropagation(); const current = items.indexOf(document.activeElement as HTMLButtonElement); let next = 0; if (key === "Home") next = 0; else if (key === "End") next = items.length - 1; else if (key === "ArrowDown") next = current < 0 ? 0 : (current + 1) % items.length; else next = current < 0 ? items.length - 1 : (current - 1 + items.length) % items.length; items[next]?.focus(); } export interface DropdownMenuProps { open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; className?: string; children: ReactNode; } export function DropdownMenu({ open: openProp, defaultOpen = false, onOpenChange, className, children, }: DropdownMenuProps) { const reduce = useReducedMotion() ?? false; const baseId = useId(); const rootRef = useRef(null); const [internalOpen, setInternalOpen] = useState(defaultOpen); const controlled = openProp !== undefined; const open = controlled ? openProp : internalOpen; const setOpen = useCallback( (next: boolean) => { if (!controlled) setInternalOpen(next); onOpenChange?.(next); }, [controlled, onOpenChange], ); const triggerId = `${baseId}-trigger`; const close = useCallback( (focusTrigger = false) => { setOpen(false); if (focusTrigger) document.getElementById(triggerId)?.focus(); }, [setOpen, triggerId], ); // Outside pointer closes; Escape closes and restores trigger focus. useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(true); }; const onPointer = (e: PointerEvent) => { if (rootRef.current && !rootRef.current.contains(e.target as Node)) close(); }; window.addEventListener("keydown", onKey); window.addEventListener("pointerdown", onPointer); return () => { window.removeEventListener("keydown", onKey); window.removeEventListener("pointerdown", onPointer); }; }, [open, close]); const ctx = useMemo( () => ({ open, setOpen, close, reduce, triggerId, menuId: `${baseId}-menu`, }), [open, setOpen, close, reduce, triggerId, baseId], ); return ( {/* h-fit defends against flex parents with default align-items: stretch — a stretched root would anchor the top-full panel far below the trigger. */}
{children}
); } export interface DropdownMenuTriggerProps { className?: string; children: ReactNode; } export function DropdownMenuTrigger({ className, children, }: DropdownMenuTriggerProps) { const ctx = useMenuContext("DropdownMenuTrigger"); return ( ); } export interface DropdownMenuContentProps { align?: Align; className?: string; children: ReactNode; } export function DropdownMenuContent({ align = "start", className, children, }: DropdownMenuContentProps) { const ctx = useMenuContext("DropdownMenuContent"); const panelRef = useRef(null); const surfaceId = useId(); const [activeKey, setActiveKey] = useState(null); const [placement, setPlacement] = useState("bottom"); const open = ctx.open; // On open, flip upward when there isn't room below and there's more above // (same viewport check as Select). useLayoutEffect(() => { if (!open) return; const trigger = document.getElementById(ctx.triggerId); const panel = panelRef.current; if (!trigger || !panel) return; const rect = trigger.getBoundingClientRect(); const h = panel.offsetHeight; const below = window.innerHeight - rect.bottom; setPlacement(below < h + FLIP_MARGIN && rect.top > below ? "top" : "bottom"); }, [open, ctx.triggerId]); // Focus the panel on open so navigation keys work immediately; clear the // gliding surface whenever the menu closes. Skip the focus grab when nothing // on the page holds focus yet (e.g. a `defaultOpen` menu on first paint) so // an open-by-default demo never steals focus on load. useEffect(() => { if (open) { const raf = requestAnimationFrame(() => { if (document.activeElement === document.body) return; panelRef.current?.focus(); }); return () => cancelAnimationFrame(raf); } setActiveKey(null); }, [open]); const panelCtx = useMemo( () => ({ surfaceId, activeKey, setActiveKey }), [surfaceId, activeKey], ); const isTop = placement === "top"; const originY = isTop ? "bottom" : "top"; const originX = align === "center" ? "center" : align === "end" ? "right" : "left"; return ( { // Keep the highlight when focus is inside (keyboard user just // happens to move the pointer away). const panel = panelRef.current; if (panel?.contains(document.activeElement)) return; setActiveKey(null); }} initial={false} animate={{ opacity: open ? 1 : 0, scale: ctx.reduce ? 1 : open ? 1 : 0.94, y: ctx.reduce ? 0 : open ? 0 : isTop ? 4 : -4, x: align === "center" ? "-50%" : 0, }} transition={ open ? ctx.reduce ? { duration: 0.15, ease: EASE_OUT } : SPRING_PANEL : { duration: 0.12, ease: EASE_OUT } } style={{ transformOrigin: `${originY} ${originX}`, pointerEvents: open ? "auto" : "none", }} className={cn( "absolute z-50 min-w-56 rounded-xl border border-border bg-background p-1 shadow-lg outline-none", isTop ? "bottom-full mb-1.5" : "top-full mt-1.5", align === "start" && "left-0", align === "center" && "left-1/2", align === "end" && "right-0", className, )} > {children} ); } export interface DropdownMenuLabelProps { className?: string; children: ReactNode; } export function DropdownMenuLabel({ className, children, }: DropdownMenuLabelProps) { return (
{children}
); } export interface DropdownMenuSeparatorProps { className?: string; } export function DropdownMenuSeparator({ className, }: DropdownMenuSeparatorProps) { return (
); } // Row shell shared by items, checkbox items and submenu triggers: a relative // wrapper hosting the gliding focus surface behind the real button. interface ItemShellProps { active: boolean; children: ReactNode; } function ItemShell({ active, children }: ItemShellProps) { const panel = usePanelContext("DropdownMenu item"); const reduce = useReducedMotion() ?? false; return (
{active ? ( ) : null} {children}
); } // Hover and focus funnel into the panel's single activeKey. function useItemActivation(itemKey: string) { const panel = usePanelContext("DropdownMenu item"); return { active: panel.activeKey === itemKey, handlers: { onMouseEnter: () => panel.setActiveKey(itemKey), onFocus: () => panel.setActiveKey(itemKey), }, }; } const ITEM_BUTTON_CLASSES = "relative z-10 flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-sm outline-none transition-colors disabled:pointer-events-none disabled:opacity-50"; export interface DropdownMenuItemProps { icon?: ReactNode; description?: string; shortcut?: string; disabled?: boolean; destructive?: boolean; onSelect?: () => void; className?: string; children: ReactNode; } export function DropdownMenuItem({ icon, description, shortcut, disabled = false, destructive = false, onSelect, className, children, }: DropdownMenuItemProps) { const menu = useMenuContext("DropdownMenuItem"); const itemKey = useId(); const { active, handlers } = useItemActivation(itemKey); return ( ); } export interface DropdownMenuCheckboxItemProps { checked: boolean; onCheckedChange: (checked: boolean) => void; disabled?: boolean; className?: string; children: ReactNode; } export function DropdownMenuCheckboxItem({ checked, onCheckedChange, disabled = false, className, children, }: DropdownMenuCheckboxItemProps) { const reduce = useReducedMotion() ?? false; const itemKey = useId(); const { active, handlers } = useItemActivation(itemKey); return ( ); } interface SubContextValue { open: boolean; openSub: () => void; scheduleClose: () => void; closeNow: (focusTrigger?: boolean) => void; subTriggerId: string; subMenuId: string; } const SubContext = createContext(null); function useSubContext(component: string) { const ctx = useContext(SubContext); if (!ctx) throw new Error(`${component} must be used within `); return ctx; } export interface DropdownMenuSubProps { className?: string; children: ReactNode; } export function DropdownMenuSub({ className, children }: DropdownMenuSubProps) { const menu = useMenuContext("DropdownMenuSub"); const baseId = useId(); const [open, setOpen] = useState(false); const closeTimer = useRef | null>(null); const clearTimer = useCallback(() => { if (closeTimer.current) { clearTimeout(closeTimer.current); closeTimer.current = null; } }, []); const openSub = useCallback(() => { clearTimer(); setOpen(true); }, [clearTimer]); const subTriggerId = `${baseId}-subtrigger`; const closeNow = useCallback( (focusTrigger = false) => { clearTimer(); setOpen(false); if (focusTrigger) document.getElementById(subTriggerId)?.focus(); }, [clearTimer, subTriggerId], ); const scheduleClose = useCallback(() => { clearTimer(); closeTimer.current = setTimeout(() => setOpen(false), SUB_CLOSE_GRACE_MS); }, [clearTimer]); // Collapse with the root menu and never leak the grace timer. useEffect(() => { if (!menu.open) setOpen(false); }, [menu.open]); useEffect(() => clearTimer, [clearTimer]); const ctx = useMemo( () => ({ open, openSub, scheduleClose, closeNow, subTriggerId, subMenuId: `${baseId}-submenu`, }), [open, openSub, scheduleClose, closeNow, subTriggerId, baseId], ); return ( {/* motion.div (not a static div) hosts the hover-intent handlers — same pattern as SharedLayoutBg's container. */} {children} ); } export interface DropdownMenuSubTriggerProps { icon?: ReactNode; className?: string; children: ReactNode; } export function DropdownMenuSubTrigger({ icon, className, children, }: DropdownMenuSubTriggerProps) { const sub = useSubContext("DropdownMenuSubTrigger"); const itemKey = useId(); const { active, handlers } = useItemActivation(itemKey); return ( ); } export interface DropdownMenuSubContentProps { className?: string; children: ReactNode; } export function DropdownMenuSubContent({ className, children, }: DropdownMenuSubContentProps) { const menu = useMenuContext("DropdownMenuSubContent"); const sub = useSubContext("DropdownMenuSubContent"); const panelRef = useRef(null); const surfaceId = useId(); const [activeKey, setActiveKey] = useState(null); // Which side of the parent panel the submenu opens toward; flips to the // left when the right edge would leave the viewport. const [side, setSide] = useState<"right" | "left">("right"); useLayoutEffect(() => { if (!sub.open) return; const panel = panelRef.current; if (!panel) return; const rect = panel.getBoundingClientRect(); if (side === "right" && rect.right > window.innerWidth - 8) setSide("left"); }, [sub.open, side]); useEffect(() => { if (!sub.open) setActiveKey(null); }, [sub.open]); const panelCtx = useMemo( () => ({ surfaceId, activeKey, setActiveKey }), [surfaceId, activeKey], ); return ( {sub.open ? ( { if (e.key === "ArrowLeft") { e.preventDefault(); e.stopPropagation(); sub.closeNow(true); return; } handlePanelKeys(e); }} onMouseLeave={() => { const panel = panelRef.current; if (panel?.contains(document.activeElement)) return; setActiveKey(null); }} initial={ menu.reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, x: side === "right" ? -4 : 4 } } animate={ menu.reduce ? { opacity: 1 } : { opacity: 1, scale: 1, x: 0 } } exit={{ opacity: 0, transition: { duration: 0.1, ease: EASE_OUT } }} transition={menu.reduce ? { duration: 0.15 } : SPRING_PANEL} style={{ transformOrigin: side === "right" ? "top left" : "top right", }} className={cn( "absolute -top-1 z-50 min-w-44 rounded-xl border border-border bg-background p-1 shadow-lg outline-none", side === "right" ? "left-full ml-1" : "right-full mr-1", className, )} > {children} ) : null} ); }