import { useState, useEffect, useRef, useCallback } from 'react'; import { cn } from '@/lib/utils'; export interface DropdownProps { trigger: React.ReactNode; children: React.ReactNode; align?: 'left' | 'right' | 'center'; className?: string; onOpenChange?: (open: boolean) => void; } /** * Composant Dropdown réutilisable avec menu et gestion du clavier. */ export function Dropdown({ trigger, children, align = 'left', className, onOpenChange, }: DropdownProps) { const [open, setOpen] = useState(false); const containerRef = useRef(null); const menuRef = useRef(null); const triggerRef = useRef(null); const focusedIndexRef = useRef(-1); const handleOpenChange = useCallback( (newOpen: boolean) => { setOpen(newOpen); onOpenChange?.(newOpen); if (!newOpen) { focusedIndexRef.current = -1; } }, [onOpenChange] ); // Fermer le dropdown quand on clique en dehors useEffect(() => { if (!open) return; const handleClickOutside = (event: MouseEvent) => { if ( containerRef.current && !containerRef.current.contains(event.target as Node) ) { handleOpenChange(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => { document.removeEventListener('mousedown', handleClickOutside); }; }, [open, handleOpenChange]); // Gestion du clavier useEffect(() => { if (!open) return; const handleKeyDown = (event: KeyboardEvent) => { if (!menuRef.current) return; const focusableElements = menuRef.current.querySelectorAll( 'button, [href], input, select, textarea, [role="menuitem"], [tabindex]:not([tabindex="-1"])' ); const elements = Array.from(focusableElements); switch (event.key) { case 'Escape': event.preventDefault(); handleOpenChange(false); triggerRef.current?.focus(); break; case 'ArrowDown': event.preventDefault(); focusedIndexRef.current = focusedIndexRef.current < elements.length - 1 ? focusedIndexRef.current + 1 : 0; elements[focusedIndexRef.current]?.focus(); break; case 'ArrowUp': event.preventDefault(); focusedIndexRef.current = focusedIndexRef.current > 0 ? focusedIndexRef.current - 1 : elements.length - 1; elements[focusedIndexRef.current]?.focus(); break; case 'Enter': case ' ': event.preventDefault(); if (focusedIndexRef.current >= 0 && elements[focusedIndexRef.current]) { elements[focusedIndexRef.current].click(); } break; case 'Home': event.preventDefault(); focusedIndexRef.current = 0; elements[0]?.focus(); break; case 'End': event.preventDefault(); focusedIndexRef.current = elements.length - 1; elements[elements.length - 1]?.focus(); break; } }; document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('keydown', handleKeyDown); }; }, [open, handleOpenChange]); // Focuser le premier élément quand le menu s'ouvre useEffect(() => { if (open && menuRef.current) { const focusableElements = menuRef.current.querySelectorAll( 'button, [href], input, select, textarea, [role="menuitem"], [tabindex]:not([tabindex="-1"])' ); if (focusableElements.length > 0) { focusedIndexRef.current = 0; // Petit délai pour s'assurer que le menu est rendu setTimeout(() => { (focusableElements[0] as HTMLElement)?.focus(); }, 0); } } }, [open]); const alignClasses = { left: 'left-0', right: 'right-0', center: 'left-1/2 -translate-x-1/2', }; return (
handleOpenChange(!open)} role="button" aria-haspopup="true" aria-expanded={open} tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleOpenChange(!open); } else if (e.key === 'ArrowDown') { e.preventDefault(); handleOpenChange(true); } }} style={{ display: 'inline-block' }} > {trigger}
{open && ( <> {/* Backdrop pour fermer en cliquant en dehors */}
handleOpenChange(false)} aria-hidden="true" /> {/* Menu */}
{children}
)}
); }