"use client"; import { Check, ChevronRight } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { cloneElement, createContext, isValidElement, type MutableRefObject, type ReactElement, type FocusEvent as ReactFocusEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode, type PointerEvent as ReactPointerEvent, type Ref, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, } from "react"; import { createPortal } from "react-dom"; import { type PlacementAlign, type PlacementSide, resolvePlacement, } from "@/components/motion/anchored-placement"; import { usePopoverPortalPosition } from "@/components/motion/popover-position"; import { EASE_OUT, SPRING_LAYOUT, SPRING_PANEL } from "@/lib/ease"; import { useDismiss } from "@/lib/hooks/use-dismiss"; import { cn } from "@/lib/utils"; /* -------------------------------------------------------------------------- */ /* Levels */ /* -------------------------------------------------------------------------- */ /** Where focus lands when a panel opens. */ type FocusIntent = "first" | "last" | "content" | "none"; // Hovering a sub trigger waits this long before opening, so a pointer just // passing over the row on its way down the list doesn't flash a submenu. const SUB_OPEN_DELAY = 100; // Leaving the sub trigger (or hovering a sibling) closes the submenu only // after this grace, so a diagonal move toward the submenu that clips a // neighbouring row doesn't snap it shut. const SUB_CLOSE_DELAY = 220; const TYPEAHEAD_RESET = 500; /** * One open panel: the root menu or a submenu. Items, the active-row * highlight and keyboard navigation all read the nearest level, so a submenu * is the same machinery one step down. */ interface MenuLevel { /** Shared by every panel and the trigger of one menu tree (dismissal scope). */ rootId: string; /** Scope for this panel's layoutId highlight. */ levelId: string; open: boolean; isSub: boolean; triggerId: string; contentId: string; triggerRef: MutableRefObject; contentRef: MutableRefObject; focusIntent: MutableRefObject; activeId: string | null; setActiveId: (id: string | null) => void; /** Id of the child submenu currently open from this panel. */ openSub: string | null; setOpenSub: (id: string | null) => void; scheduleSubClose: () => void; cancelSubClose: () => void; /** Close this panel only (root: the whole menu). */ close: (restoreFocus: boolean) => void; /** Close the whole menu tree. */ closeAll: (restoreFocus: boolean) => void; /** Open the root menu (used by the trigger). */ openRoot: () => void; reduce: boolean; } const MenuLevelContext = createContext(null); function useMenuLevel(component: string) { const level = useContext(MenuLevelContext); if (!level) throw new Error(`${component} must be used within `); return level; } /** Per-panel state shared by the root and every submenu. */ function useLevelCore(open: boolean) { const [activeId, setActiveId] = useState(null); const [openSub, setOpenSubState] = useState(null); const subCloseTimer = useRef | null>(null); const cancelSubClose = useCallback(() => { if (subCloseTimer.current) clearTimeout(subCloseTimer.current); subCloseTimer.current = null; }, []); const setOpenSub = useCallback( (id: string | null) => { cancelSubClose(); setOpenSubState(id); }, [cancelSubClose], ); const scheduleSubClose = useCallback(() => { cancelSubClose(); subCloseTimer.current = setTimeout(() => { subCloseTimer.current = null; setOpenSubState(null); }, SUB_CLOSE_DELAY); }, [cancelSubClose]); // Reset the highlight when the panel opens, not when it closes: removing // the layoutId highlight while the panel is still animating out leaves // motion waiting on it forever, so the closed panel never unmounts. useEffect(() => { if (open) { setActiveId(null); return; } cancelSubClose(); setOpenSubState(null); }, [open, cancelSubClose]); useEffect(() => cancelSubClose, [cancelSubClose]); return useMemo( () => ({ activeId, setActiveId, openSub, setOpenSub, scheduleSubClose, cancelSubClose, }), [activeId, openSub, setOpenSub, scheduleSubClose, cancelSubClose], ); } function getItems(container: HTMLElement | null) { if (!container) return []; return Array.from( container.querySelectorAll( '[data-dropdown-menu-item]:not([data-disabled="true"])', ), ); } function focusByIntent(container: HTMLElement | null, intent: FocusIntent) { if (!container || intent === "none") return; const items = getItems(container); const target = intent === "first" ? items[0] : intent === "last" ? items[items.length - 1] : container; (target ?? container).focus({ preventScroll: true }); } function assignRef(ref: Ref | undefined, value: T | null) { if (typeof ref === "function") ref(value); else if (ref) (ref as MutableRefObject).current = value; } /* -------------------------------------------------------------------------- */ /* Root */ /* -------------------------------------------------------------------------- */ export interface DropdownMenuProps { children: ReactNode; /** Controlled open state. */ open?: boolean; /** Uncontrolled initial open state. */ defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; className?: string; } /** * Composable dropdown menu following the WAI-ARIA menu button pattern. The * panel scales out of the trigger side, an active-row highlight glides * between items, and submenus open on hover intent or ArrowRight. */ export function DropdownMenu({ children, open: controlledOpen, defaultOpen = false, onOpenChange, className, }: DropdownMenuProps) { const [internalOpen, setInternalOpen] = useState(defaultOpen); const controlled = controlledOpen !== undefined; const open = controlled ? controlledOpen : internalOpen; const baseId = useId(); const triggerRef = useRef(null); const contentRef = useRef(null); const focusIntent = useRef("content"); const reduce = useReducedMotion() ?? false; const core = useLevelCore(open); const setOpen = useCallback( (next: boolean) => { if (!controlled) setInternalOpen(next); onOpenChange?.(next); }, [controlled, onOpenChange], ); const closeAll = useCallback( (restoreFocus: boolean) => { setOpen(false); if (restoreFocus) triggerRef.current?.focus({ preventScroll: true }); }, [setOpen], ); const dismiss = useCallback(() => setOpen(false), [setOpen]); const openRoot = useCallback(() => setOpen(true), [setOpen]); const insideTree = useCallback( (target: Element) => Boolean(target.closest(`[data-dropdown-menu="${baseId}"]`)), [baseId], ); // Escape is handled by the focused panel, which knows whether it is a // submenu (close one level) or the root (close and refocus the trigger). useDismiss(open, dismiss, null, { escape: false, ignore: insideTree }); // Focus moves into the panel on open: the first/last item for keyboard // opens, the panel itself for pointer opens so arrows work immediately. useEffect(() => { if (!open) return; const frame = requestAnimationFrame(() => focusByIntent(contentRef.current, focusIntent.current), ); return () => cancelAnimationFrame(frame); }, [open]); const level = useMemo( () => ({ rootId: baseId, levelId: `${baseId}-root`, open, isSub: false, triggerId: `${baseId}-trigger`, contentId: `${baseId}-content`, triggerRef, contentRef, focusIntent, ...core, close: closeAll, closeAll, openRoot, reduce, }), [baseId, open, core, closeAll, openRoot, reduce], ); return (
{children}
); } /* -------------------------------------------------------------------------- */ /* Trigger */ /* -------------------------------------------------------------------------- */ type TriggerChildProps = React.HTMLAttributes & { ref?: Ref; [key: `data-${string}`]: string | undefined; }; export interface DropdownMenuTriggerProps { /** A single element, normally a ` ); } function Shortcut({ children }: { children: ReactNode }) { return ( ); } export interface DropdownMenuItemProps { children: ReactNode; /** Leading icon (sized to 16px). */ icon?: ReactNode; /** Trailing shortcut hint, e.g. "⌘K" or a `KbdCombo`. Decorative. */ shortcut?: ReactNode; variant?: ItemVariant; disabled?: boolean; /** Indent to line up with checkbox/radio rows. */ inset?: boolean; /** Text used by typeahead when children aren't plain text. */ textValue?: string; /** Call `event.preventDefault()` to keep the menu open. */ onSelect?: (event: ReactMouseEvent) => void; /** Default true. */ closeOnSelect?: boolean; className?: string; } export function DropdownMenuItem({ children, icon, shortcut, onSelect, closeOnSelect = true, ...props }: DropdownMenuItemProps) { return ( {icon} {children} {shortcut ? {shortcut} : null} ); } function Indicator({ checked, kind, reduce, }: { checked: boolean; kind: "check" | "dot"; reduce: boolean; }) { return ( ); } export interface DropdownMenuCheckboxItemProps extends Omit { checked?: boolean; defaultChecked?: boolean; onCheckedChange?: (checked: boolean) => void; } /** A toggle row. Stays open on select by default so several can be flipped. */ export function DropdownMenuCheckboxItem({ children, checked: controlledChecked, defaultChecked = false, onCheckedChange, shortcut, closeOnSelect = false, ...props }: DropdownMenuCheckboxItemProps) { const level = useMenuLevel("DropdownMenuCheckboxItem"); const [internal, setInternal] = useState(defaultChecked); const checked = controlledChecked ?? internal; return ( { if (controlledChecked === undefined) setInternal(!checked); onCheckedChange?.(!checked); }} > {children} {shortcut ? {shortcut} : null} ); } interface RadioGroupContextValue { value: string | undefined; select: (value: string) => void; } const RadioGroupContext = createContext(null); export interface DropdownMenuRadioGroupProps { children: ReactNode; value?: string; defaultValue?: string; onValueChange?: (value: string) => void; /** Accessible name for the group. */ ariaLabel?: string; className?: string; } export function DropdownMenuRadioGroup({ children, value: controlledValue, defaultValue, onValueChange, ariaLabel, className, }: DropdownMenuRadioGroupProps) { const [internal, setInternal] = useState(defaultValue); const value = controlledValue ?? internal; const ctx = useMemo( () => ({ value, select: (next) => { if (controlledValue === undefined) setInternal(next); onValueChange?.(next); }, }), [value, controlledValue, onValueChange], ); return (
{children}
); } export interface DropdownMenuRadioItemProps extends Omit { value: string; } export function DropdownMenuRadioItem({ children, value, shortcut, closeOnSelect = false, ...props }: DropdownMenuRadioItemProps) { const level = useMenuLevel("DropdownMenuRadioItem"); const group = useContext(RadioGroupContext); if (!group) { throw new Error( "DropdownMenuRadioItem must be used within ", ); } const checked = group.value === value; return ( group.select(value)} > {children} {shortcut ? {shortcut} : null} ); } export interface DropdownMenuLabelProps { children: ReactNode; inset?: boolean; className?: string; } export function DropdownMenuLabel({ children, inset = false, className, }: DropdownMenuLabelProps) { return (
{children}
); } export interface DropdownMenuGroupProps { children: ReactNode; ariaLabel?: string; className?: string; } export function DropdownMenuGroup({ children, ariaLabel, className, }: DropdownMenuGroupProps) { return (
{children}
); } export function DropdownMenuSeparator({ className }: { className?: string }) { return
; } /* -------------------------------------------------------------------------- */ /* Submenus */ /* -------------------------------------------------------------------------- */ interface SubContextValue { level: MenuLevel; parent: MenuLevel; triggerItemId: string; openTimer: MutableRefObject | null>; openWith: (intent: FocusIntent) => void; } const SubContext = createContext(null); function useSub(component: string) { const sub = useContext(SubContext); if (!sub) throw new Error(`${component} must be used within `); return sub; } export interface DropdownMenuSubProps { children: ReactNode; } /** A nested menu opened from a `DropdownMenuSubTrigger` row. */ export function DropdownMenuSub({ children }: DropdownMenuSubProps) { const parent = useMenuLevel("DropdownMenuSub"); const subId = useId(); const open = parent.open && parent.openSub === subId; const triggerRef = useRef(null); const contentRef = useRef(null); const focusIntent = useRef("none"); const openTimer = useRef | null>(null); const core = useLevelCore(open); const triggerItemId = `${subId}-trigger`; useEffect( () => () => { if (openTimer.current) clearTimeout(openTimer.current); }, [], ); // Open submenus take focus only when asked to (keyboard); a hover open // leaves it on the trigger row. useEffect(() => { if (!open) return; const frame = requestAnimationFrame(() => focusByIntent(contentRef.current, focusIntent.current), ); return () => cancelAnimationFrame(frame); }, [open]); const { setOpenSub } = parent; const close = useCallback( (restoreFocus: boolean) => { setOpenSub(null); if (restoreFocus) triggerRef.current?.focus({ preventScroll: true }); }, [setOpenSub], ); const openWith = useCallback( (intent: FocusIntent) => { if (openTimer.current) clearTimeout(openTimer.current); openTimer.current = null; focusIntent.current = intent; if (open) focusByIntent(contentRef.current, intent); else setOpenSub(subId); }, [open, setOpenSub, subId], ); const level = useMemo( () => ({ rootId: parent.rootId, levelId: `${subId}-level`, open, isSub: true, triggerId: triggerItemId, contentId: `${subId}-content`, triggerRef, contentRef, focusIntent, ...core, close, closeAll: parent.closeAll, openRoot: parent.openRoot, reduce: parent.reduce, }), [ parent.rootId, parent.closeAll, parent.openRoot, parent.reduce, subId, open, triggerItemId, core, close, ], ); const ctx = useMemo( () => ({ level, parent, triggerItemId, openTimer, openWith }), [level, parent, triggerItemId, openWith], ); return {children}; } export interface DropdownMenuSubTriggerProps { children: ReactNode; icon?: ReactNode; disabled?: boolean; inset?: boolean; textValue?: string; className?: string; } export function DropdownMenuSubTrigger({ children, icon, ...props }: DropdownMenuSubTriggerProps) { const { level, parent, triggerItemId, openTimer, openWith } = useSub( "DropdownMenuSubTrigger", ); const clearOpenTimer = () => { if (openTimer.current) clearTimeout(openTimer.current); openTimer.current = null; }; return ( openWith(event.detail === 0 ? "first" : "none")} extraProps={{ ref: (node: HTMLButtonElement | null) => { level.triggerRef.current = node; }, "aria-haspopup": "menu", "aria-expanded": level.open, "aria-controls": level.open ? level.contentId : undefined, "data-state": level.open ? "open" : "closed", onPointerEnter: (event) => { if (event.pointerType === "touch" || props.disabled) return; parent.cancelSubClose(); if (level.open) return; clearOpenTimer(); openTimer.current = setTimeout(() => { openTimer.current = null; openWith("none"); }, SUB_OPEN_DELAY); }, onPointerLeave: (event) => { if (event.pointerType === "touch") return; clearOpenTimer(); parent.scheduleSubClose(); }, onKeyDown: (event) => { if (event.key === "ArrowRight") { event.preventDefault(); openWith("first"); } }, }} > {icon} {children} ); } export interface DropdownMenuSubContentProps { children: ReactNode; /** Preferred side; flips when the viewport lacks room. Default "right". */ side?: "right" | "left"; /** Gap from the parent panel, in px. Default 8. */ sideOffset?: number; /** Shift so the first row lines up with the trigger row. Default -7. */ alignOffset?: number; ariaLabel?: string; className?: string; } export function DropdownMenuSubContent({ children, side = "right", sideOffset = 8, alignOffset = -7, ariaLabel, className, }: DropdownMenuSubContentProps) { const { level, parent, triggerItemId } = useSub("DropdownMenuSubContent"); const { cancelSubClose, setActiveId } = parent; // While the pointer or focus is in the submenu, its trigger row keeps the // parent's highlight and the pending close is called off. const onPanelEnter = useCallback(() => { cancelSubClose(); setActiveId(triggerItemId); }, [cancelSubClose, setActiveId, triggerItemId]); return ( {children} ); }