"use client"; import { motion, useReducedMotion } from "motion/react"; import { type ButtonHTMLAttributes, createContext, type KeyboardEvent, type ReactNode, type RefObject, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { cn } from "@/lib/utils"; const EASE = [0.23, 1, 0.32, 1] as const; // Matches Select: opens in 150ms, closes a little faster. const OPEN = { duration: 0.15, ease: EASE } as const; const CLOSE = { duration: 0.1, ease: EASE } as const; interface DropdownMenuContextValue { open: boolean; setOpen: (open: boolean) => void; triggerRef: RefObject; listRef: RefObject; triggerId: string; listId: string; } const DropdownMenuContext = createContext(null); function useDropdownMenu(part: string) { const ctx = useContext(DropdownMenuContext); if (!ctx) throw new Error(`${part} must be used inside `); return ctx; } function enabledItems(list: HTMLElement | null) { return Array.from( list?.querySelectorAll('[role="menuitem"]:not(:disabled)') ?? [], ); } export interface DropdownMenuProps { /** Controlled open state. */ open?: boolean; /** Initial open state when uncontrolled. Default false. */ defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; className?: string; children: ReactNode; } export function DropdownMenu({ open: openProp, defaultOpen = false, onOpenChange, className, children, }: DropdownMenuProps) { const id = useId(); const rootRef = useRef(null); const triggerRef = useRef(null); const listRef = useRef(null); const [innerOpen, setInnerOpen] = useState(defaultOpen); const open = openProp ?? innerOpen; const setOpen = useCallback( (next: boolean) => { if (openProp === undefined) setInnerOpen(next); onOpenChange?.(next); }, [openProp, onOpenChange], ); // A press anywhere outside the menu closes it. useEffect(() => { if (!open) return; const onPointerDown = (event: PointerEvent) => { if (!rootRef.current?.contains(event.target as Node)) setOpen(false); }; window.addEventListener("pointerdown", onPointerDown); return () => window.removeEventListener("pointerdown", onPointerDown); }, [open, setOpen]); const ctx = useMemo( () => ({ open, setOpen, triggerRef, listRef, triggerId: `${id}-trigger`, listId: `${id}-menu`, }), [open, setOpen, id], ); return (
{children}
); } export type DropdownMenuTriggerProps = Omit< ButtonHTMLAttributes, "type" | "id" | "onClick" | "onKeyDown" >; export function DropdownMenuTrigger({ className, children, ...props }: DropdownMenuTriggerProps) { const m = useDropdownMenu("DropdownMenuTrigger"); const onKeyDown = (event: KeyboardEvent) => { if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); m.setOpen(true); } }; return ( ); } export interface DropdownMenuContentProps { /** Which trigger edge the menu hangs from. Default "start". */ align?: "start" | "end"; className?: string; children: ReactNode; } export function DropdownMenuContent({ align = "start", className, children, }: DropdownMenuContentProps) { const m = useDropdownMenu("DropdownMenuContent"); const reduce = useReducedMotion(); const [placement, setPlacement] = useState<"top" | "bottom">("bottom"); const { open, triggerRef, listRef } = m; // Open upward when there is not enough room below the trigger. useLayoutEffect(() => { if (!open) return; const trigger = triggerRef.current; const list = listRef.current; if (!trigger || !list) return; const rect = trigger.getBoundingClientRect(); const below = window.innerHeight - rect.bottom; setPlacement(below < list.offsetHeight + 16 && rect.top > below ? "top" : "bottom"); }, [open, triggerRef, listRef]); useEffect(() => { if (!open) return; enabledItems(listRef.current)[0]?.focus({ preventScroll: true }); }, [open, listRef]); const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); m.setOpen(false); triggerRef.current?.focus(); return; } if (event.key === "Tab") { m.setOpen(false); return; } const items = enabledItems(listRef.current); if (items.length === 0) return; const index = items.indexOf(document.activeElement as HTMLButtonElement); const next = event.key === "ArrowDown" ? index + 1 : event.key === "ArrowUp" ? index - 1 : event.key === "Home" ? 0 : event.key === "End" ? items.length - 1 : null; if (next === null) return; event.preventDefault(); items[(next + items.length) % items.length].focus(); }; const isTop = placement === "top"; // Items stay mounted while closed so the exit transition can play. return ( {children} ); } export interface DropdownMenuItemProps { onSelect?: () => void; disabled?: boolean; /** Styles the item for a destructive action, such as Delete. Default false. */ destructive?: boolean; className?: string; children: ReactNode; } export function DropdownMenuItem({ onSelect, disabled = false, destructive = false, className, children, }: DropdownMenuItemProps) { const m = useDropdownMenu("DropdownMenuItem"); return ( ); } export function DropdownMenuSeparator({ className }: { className?: string }) { return
; }