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>
293 lines
6.7 KiB
TypeScript
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;
|