/** * Dropdown - Dropdown menu component with positioning * G-011: Accessible dropdown menus and popovers */ import { useState, useRef, useEffect, ReactNode, createContext, useContext } from 'react'; import clsx from 'clsx'; // Context for dropdown state interface DropdownContextValue { isOpen: boolean; close: () => void; } const DropdownContext = createContext(null); function useDropdownContext() { const context = useContext(DropdownContext); if (!context) { throw new Error('Dropdown components must be used within a Dropdown'); } return context; } // Types type DropdownPlacement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end'; interface DropdownProps { children: ReactNode; className?: string; } interface DropdownTriggerProps { children: ReactNode; asChild?: boolean; className?: string; } interface DropdownContentProps { children: ReactNode; placement?: DropdownPlacement; align?: 'start' | 'end'; className?: string; minWidth?: number; } interface DropdownItemProps { children: ReactNode; onClick?: () => void; disabled?: boolean; danger?: boolean; icon?: ReactNode; className?: string; } /** * Dropdown container component */ export function Dropdown({ children, className }: DropdownProps) { const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); const close = () => setIsOpen(false); const toggle = () => setIsOpen((prev) => !prev); // Close on click outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { close(); } }; if (isOpen) { document.addEventListener('mousedown', handleClickOutside); } return () => { document.removeEventListener('mousedown', handleClickOutside); }; }, [isOpen]); // Close on escape key useEffect(() => { const handleEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') { close(); } }; if (isOpen) { document.addEventListener('keydown', handleEscape); } return () => { document.removeEventListener('keydown', handleEscape); }; }, [isOpen]); return (
{/* Inject toggle function to trigger */} {Array.isArray(children) ? children.map((child, index) => { if (index === 0 && typeof child === 'object' && child !== null) { return (
{child}
); } return child; }) : children}
); } /** * Dropdown trigger button */ export function DropdownTrigger({ children, className }: DropdownTriggerProps) { return (
{children}
); } /** * Dropdown content container */ export function DropdownContent({ children, placement = 'bottom-start', className, minWidth = 180, }: DropdownContentProps) { const { isOpen } = useDropdownContext(); if (!isOpen) return null; const placementClasses: Record = { 'bottom-start': 'top-full left-0 mt-1', 'bottom-end': 'top-full right-0 mt-1', 'top-start': 'bottom-full left-0 mb-1', 'top-end': 'bottom-full right-0 mb-1', }; return (
{children}
); } /** * Dropdown menu item */ export function DropdownItem({ children, onClick, disabled = false, danger = false, icon, className, }: DropdownItemProps) { const { close } = useDropdownContext(); const handleClick = () => { if (disabled) return; onClick?.(); close(); }; return ( ); } /** * Dropdown separator */ export function DropdownSeparator({ className }: { className?: string }) { return (
); } /** * Dropdown label (non-interactive) */ export function DropdownLabel({ children, className, }: { children: ReactNode; className?: string; }) { return (
{children}
); } /** * Simple Menu component (pre-composed dropdown) */ interface MenuProps { trigger: ReactNode; items: Array<{ label: string; onClick: () => void; icon?: ReactNode; disabled?: boolean; danger?: boolean; separator?: boolean; }>; placement?: DropdownPlacement; className?: string; } export function Menu({ trigger, items, placement = 'bottom-end', className }: MenuProps) { return ( {trigger} {items.map((item, index) => (
{item.separator && } {!item.separator && ( {item.label} )}
))}
); } export default Dropdown;