import { useState, useMemo, useRef, useEffect } from 'react'; import { Dropdown } from './dropdown'; import { Input } from './input'; import { Button } from './button'; import { cn } from '@/lib/utils'; import { ChevronDown, X, Check } from 'lucide-react'; /** * SelectOption - Option d'un composant Select * * @interface SelectOption */ export interface SelectOption { /** * Valeur de l'option */ value: string; /** * Label affiché de l'option */ label: string; /** * Si `true`, l'option est désactivée */ disabled?: boolean; /** * Nom du groupe auquel appartient l'option (optionnel) */ group?: string; } /** * SelectGroup - Groupe d'options pour un Select * * @interface SelectGroup */ export interface SelectGroup { /** * Label du groupe */ label: string; /** * Options du groupe */ options: SelectOption[]; } /** * SelectProps - Propriétés du composant Select * * @interface SelectProps */ export interface SelectProps { /** * Tableau d'options disponibles */ options: SelectOption[]; /** * Valeur(s) sélectionnée(s) * String pour single select, array pour multi-select */ value?: string | string[]; /** * Fonction appelée lorsque la sélection change * * @param {string | string[]} value - Nouvelle(s) valeur(s) sélectionnée(s) */ onChange: (value: string | string[]) => void; /** * Si `true`, permet la sélection multiple * * @default false */ multiple?: boolean; /** * Si `true`, active la recherche dans les options * * @default false */ searchable?: boolean; /** * Texte du placeholder * * @default 'Select an option...' */ placeholder?: string; /** * Si `true`, désactive le select * * @default false */ disabled?: boolean; /** * Classes CSS personnalisées */ className?: string; /** * Attribut name pour les formulaires */ name?: string; /** * CRITIQUE FIX #42: Label accessible pour le select (aria-label) */ 'aria-label'?: string; /** * CRITIQUE FIX #42: ID d'un élément qui décrit le select (aria-labelledby) */ 'aria-labelledby'?: string; } /** * Select - Composant de sélection avec recherche, multi-select et groupes * * Composant de sélection avancé avec support pour : * - Sélection simple ou multiple * - Recherche dans les options * - Groupes d'options * - Validation et états désactivés * * @example * ```tsx * // Select simple * * ``` * * @component * @param {SelectProps} props - Propriétés du composant * @returns {JSX.Element} Select avec dropdown et recherche */ export function Select({ options, value, onChange, multiple = false, searchable = false, placeholder = 'Select an option...', disabled = false, className, name, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, }: SelectProps) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(''); const searchInputRef = useRef(null); // Grouper les options par groupe const groupedOptions = useMemo(() => { const groups: Record = {}; const ungrouped: SelectOption[] = []; options.forEach((option) => { if (option.group) { if (!groups[option.group]) { groups[option.group] = []; } groups[option.group].push(option); } else { ungrouped.push(option); } }); return { groups, ungrouped }; }, [options]); // Filtrer les options selon la recherche const filteredOptions = useMemo(() => { if (!searchable || !search) { return { groups: groupedOptions.groups, ungrouped: groupedOptions.ungrouped, }; } const searchLower = search.toLowerCase(); const filteredGroups: Record = {}; const filteredUngrouped: SelectOption[] = []; Object.entries(groupedOptions.groups).forEach( ([groupLabel, groupOptions]) => { const filtered = groupOptions.filter((opt) => opt.label.toLowerCase().includes(searchLower), ); if (filtered.length > 0) { filteredGroups[groupLabel] = filtered; } }, ); filteredUngrouped.push( ...groupedOptions.ungrouped.filter((opt) => opt.label.toLowerCase().includes(searchLower), ), ); return { groups: filteredGroups, ungrouped: filteredUngrouped }; }, [searchable, search, groupedOptions]); // Obtenir les labels des valeurs sélectionnées const getSelectedLabels = () => { if (!value) return []; const values = Array.isArray(value) ? value : [value]; return values .map((v) => options.find((opt) => opt.value === v)?.label) .filter(Boolean) as string[]; }; const selectedLabels = getSelectedLabels(); const displayValue = multiple ? selectedLabels.length > 0 ? `${selectedLabels.length} selected` : placeholder : selectedLabels[0] || placeholder; const isSelected = (optionValue: string) => { if (!value) return false; if (multiple) { return Array.isArray(value) && value.includes(optionValue); } return value === optionValue; }; const handleSelect = (optionValue: string) => { if (multiple) { const currentValues = Array.isArray(value) ? value : []; const newValues = currentValues.includes(optionValue) ? currentValues.filter((v) => v !== optionValue) : [...currentValues, optionValue]; onChange(newValues); } else { onChange(optionValue); setOpen(false); setSearch(''); } }; const handleClear = (e: React.MouseEvent) => { e.stopPropagation(); onChange(multiple ? [] : ''); }; // Focus sur le champ de recherche quand le dropdown s'ouvre useEffect(() => { if (open && searchable && searchInputRef.current) { searchInputRef.current.focus(); } }, [open, searchable]); // Réinitialiser la recherche quand le dropdown se ferme useEffect(() => { if (!open) { setSearch(''); } }, [open]); const trigger = ( ); const dropdownContent = (
{/* Champ de recherche */} {searchable && (
setSearch(e.target.value)} onClick={(e) => e.stopPropagation()} className="w-full" />
)} {/* Options non groupées */} {filteredOptions.ungrouped.length > 0 && (
{filteredOptions.ungrouped.map((option) => ( ))}
)} {/* Options groupées */} {Object.entries(filteredOptions.groups).map( ([groupLabel, groupOptions]) => (
{groupLabel}
{groupOptions.map((option) => ( ))}
), )} {/* Message si aucune option */} {filteredOptions.ungrouped.length === 0 && Object.keys(filteredOptions.groups).length === 0 && (
No options found
)}
); return ( <> {dropdownContent} {/* Input caché pour les formulaires */} {name && ( )} ); } interface SelectOptionItemProps { option: SelectOption; isSelected: boolean; multiple: boolean; onSelect: (value: string) => void; } function SelectOptionItem({ option, isSelected, multiple, onSelect, }: SelectOptionItemProps) { // CRITIQUE FIX #49: Gestion du clavier pour les options const handleKeyDown = (e: React.KeyboardEvent) => { if (option.disabled) return; // Enter ou Space pour sélectionner if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(option.value); } // Les flèches sont gérées par le composant Select parent }; return (
!option.disabled && onSelect(option.value)} onKeyDown={handleKeyDown} tabIndex={option.disabled ? -1 : 0} > {multiple && (
{isSelected && }
)} {option.label} {!multiple && isSelected && }
); }