erp-construccion-frontend-v2/web/src/components/common/Dropdown.tsx
Adrian Flores Cortes 816d591115 [GAPS] feat(components): Implement remaining frontend gaps G-003 to G-013
Components added:
- G-003: ErrorBoundary with withErrorBoundary HOC and PageErrorFallback
- G-010: Pagination with SimplePagination variant
- G-011: Dropdown system (Dropdown, DropdownItem, Menu, etc.)
- G-012: FileUpload with drag & drop and preview
- G-013: DatePicker with DateRangePicker

Hooks added:
- G-006: useDebounce, useDebouncedCallback, useDebounceWithImmediate
- G-007: useLocalStorage, useSessionStorage

Services added:
- G-004: API Client with request/response interceptors, token refresh

Tests: 49 passing (14 new tests for utility hooks)
TypeScript: All types validated

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 01:34:21 -06:00

293 lines
6.7 KiB
TypeScript

/**
* 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<DropdownContextValue | null>(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<HTMLDivElement>(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 (
<DropdownContext.Provider value={{ isOpen, close }}>
<div ref={dropdownRef} className={clsx('relative inline-block', className)}>
{/* Inject toggle function to trigger */}
{Array.isArray(children)
? children.map((child, index) => {
if (index === 0 && typeof child === 'object' && child !== null) {
return (
<div key={index} onClick={toggle}>
{child}
</div>
);
}
return child;
})
: children}
</div>
</DropdownContext.Provider>
);
}
/**
* Dropdown trigger button
*/
export function DropdownTrigger({ children, className }: DropdownTriggerProps) {
return (
<div className={clsx('cursor-pointer', className)} role="button" tabIndex={0}>
{children}
</div>
);
}
/**
* Dropdown content container
*/
export function DropdownContent({
children,
placement = 'bottom-start',
className,
minWidth = 180,
}: DropdownContentProps) {
const { isOpen } = useDropdownContext();
if (!isOpen) return null;
const placementClasses: Record<DropdownPlacement, string> = {
'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 (
<div
className={clsx(
'absolute z-dropdown',
placementClasses[placement],
'bg-surface-popover dark:bg-surface-popover',
'border border-border dark:border-border',
'rounded-lg shadow-lg',
'py-1',
'animate-in fade-in-0 zoom-in-95 duration-150',
className
)}
style={{ minWidth }}
role="menu"
aria-orientation="vertical"
>
{children}
</div>
);
}
/**
* 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 (
<button
type="button"
onClick={handleClick}
disabled={disabled}
role="menuitem"
className={clsx(
'w-full flex items-center gap-2 px-3 py-2 text-sm text-left',
'transition-colors duration-150',
disabled
? 'text-foreground-subtle cursor-not-allowed opacity-50'
: danger
? 'text-danger hover:bg-danger/10 dark:hover:bg-danger/20'
: 'text-foreground dark:text-foreground hover:bg-background-muted dark:hover:bg-background-muted',
className
)}
>
{icon && <span className="w-4 h-4 flex-shrink-0">{icon}</span>}
{children}
</button>
);
}
/**
* Dropdown separator
*/
export function DropdownSeparator({ className }: { className?: string }) {
return (
<div
className={clsx('my-1 border-t border-border dark:border-border', className)}
role="separator"
/>
);
}
/**
* Dropdown label (non-interactive)
*/
export function DropdownLabel({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div
className={clsx(
'px-3 py-1.5 text-xs font-semibold uppercase tracking-wider',
'text-foreground-muted dark:text-foreground-muted',
className
)}
>
{children}
</div>
);
}
/**
* 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 (
<Dropdown className={className}>
<DropdownTrigger>{trigger}</DropdownTrigger>
<DropdownContent placement={placement}>
{items.map((item, index) => (
<div key={index}>
{item.separator && <DropdownSeparator />}
{!item.separator && (
<DropdownItem
onClick={item.onClick}
icon={item.icon}
disabled={item.disabled}
danger={item.danger}
>
{item.label}
</DropdownItem>
)}
</div>
))}
</DropdownContent>
</Dropdown>
);
}
export default Dropdown;