import { cloneElement, createContext, isValidElement, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, type ReactElement, type ReactNode, } from "react"; import "@shared/components/Dropdown.css"; type Alignment = "start" | "end"; interface DropdownContextValue { open: boolean; setOpen: (open: boolean) => void; triggerRef: React.RefObject; menuId: string; align: Alignment; } const DropdownContext = createContext(null); function useDropdownCtx(): DropdownContextValue { const ctx = useContext(DropdownContext); if (!ctx) throw new Error( "Dropdown subcomponents must be used inside ", ); return ctx; } export interface DropdownRootProps { /** Controlled open state. Omit for uncontrolled. */ open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; /** Alignment of the menu relative to the trigger. */ align?: Alignment; children: ReactNode; className?: string; } function Root({ open: openProp, defaultOpen, onOpenChange, align = "end", children, className, }: DropdownRootProps) { const [uncontrolled, setUncontrolled] = useState(defaultOpen ?? false); const isControlled = openProp !== undefined; const open = isControlled ? openProp : uncontrolled; const setOpen = useCallback( (next: boolean) => { if (!isControlled) setUncontrolled(next); onOpenChange?.(next); }, [isControlled, onOpenChange], ); const triggerRef = useRef(null); const containerRef = useRef(null); const menuId = useId(); // Click-outside + Escape close. useEffect(() => { if (!open) return; function onDocClick(e: MouseEvent) { if ( containerRef.current && !containerRef.current.contains(e.target as Node) ) { setOpen(false); } } function onKey(e: KeyboardEvent) { if (e.key === "Escape") { setOpen(false); triggerRef.current?.focus(); } } document.addEventListener("mousedown", onDocClick); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDocClick); document.removeEventListener("keydown", onKey); }; }, [open, setOpen]); const value = useMemo( () => ({ open, setOpen, triggerRef, menuId, align }), [open, setOpen, menuId, align], ); return (
{children}
); } export interface DropdownTriggerProps { /** A single button-like element. Receives onClick + aria props. */ children: ReactElement<{ onClick?: (e: React.MouseEvent) => void; "aria-haspopup"?: string; "aria-expanded"?: boolean; "aria-controls"?: string; ref?: React.Ref; }>; } function Trigger({ children }: DropdownTriggerProps) { const { open, setOpen, triggerRef, menuId } = useDropdownCtx(); if (!isValidElement(children)) { throw new Error( "Dropdown.Trigger requires exactly one React element child", ); } return cloneElement(children, { ref: triggerRef as React.Ref, onClick: (e: React.MouseEvent) => { children.props.onClick?.(e); setOpen(!open); }, "aria-haspopup": "menu", "aria-expanded": open, "aria-controls": menuId, }); } export interface DropdownMenuProps { children: ReactNode; className?: string; /** Optional min-width override (px or CSS length). */ width?: string | number; } function Menu({ children, className, width }: DropdownMenuProps) { const { open, menuId, align } = useDropdownCtx(); if (!open) return null; const style = width !== undefined ? { minWidth: typeof width === "number" ? `${width}px` : width } : undefined; return ( ); } export interface DropdownItemProps { onSelect?: () => void; /** Active visual state (e.g. current value in a switcher). */ active?: boolean; disabled?: boolean; /** Optional leading visual. */ leading?: ReactNode; /** Optional trailing visual (kbd hint, badge, etc). */ trailing?: ReactNode; children?: ReactNode; className?: string; } function Item({ onSelect, active, disabled, leading, trailing, children, className, }: DropdownItemProps) { const { setOpen } = useDropdownCtx(); return ( ); } function Divider() { return
; } export const Dropdown = { Root, Trigger, Menu, Item, Divider, };