import { useState, useEffect, useRef, useCallback } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { cn } from '@/lib/utils'; export interface DropdownProps { trigger: React.ReactNode; children: React.ReactNode; align?: 'left' | 'right' | 'center'; className?: string; /** Controlled open state */ open?: boolean; /** Uncontrolled default open */ defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; } /** * Composant Dropdown réutilisable avec menu et gestion du clavier. */ export function Dropdown({ trigger, children, align = 'left', className, open: openProp, defaultOpen = false, onOpenChange, }: DropdownProps) { const [internalOpen, setInternalOpen] = useState(defaultOpen); const isControlled = openProp !== undefined; const open = isControlled ? openProp : internalOpen; const containerRef = useRef(null); const menuRef = useRef(null); const triggerRef = useRef(null); const focusedIndexRef = useRef(-1); const handleOpenChange = useCallback( (newOpen: boolean) => { if (!isControlled) { setInternalOpen(newOpen); } onOpenChange?.(newOpen); if (!newOpen) { focusedIndexRef.current = -1; } }, [onOpenChange, isControlled], ); // 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 (
{open && (
handleOpenChange(false)} aria-hidden="true" /> )} {open && ( {children} )}
); }