veza/apps/web/src/components/ui/select.tsx

467 lines
12 KiB
TypeScript
Raw Normal View History

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
* <Select
* options={[
* { value: '1', label: 'Option 1' },
* { value: '2', label: 'Option 2' }
* ]}
* value={selected}
* onChange={setSelected}
* />
* ```
*
* @example
* ```tsx
* // Multi-select avec recherche et groupes
* <Select
* options={options}
* value={selectedValues}
* onChange={setSelectedValues}
* multiple
* searchable
* placeholder="Sélectionner..."
* />
* ```
*
* @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<HTMLInputElement>(null);
// Grouper les options par groupe
const groupedOptions = useMemo(() => {
const groups: Record<string, SelectOption[]> = {};
const ungrouped: SelectOption[] = [];
2025-12-13 02:34:34 +00:00
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) {
2025-12-13 02:34:34 +00:00
return {
groups: groupedOptions.groups,
ungrouped: groupedOptions.ungrouped,
};
}
const searchLower = search.toLowerCase();
const filteredGroups: Record<string, SelectOption[]> = {};
const filteredUngrouped: SelectOption[] = [];
2025-12-13 02:34:34 +00:00
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(
2025-12-13 02:34:34 +00:00
...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
2025-12-13 02:34:34 +00:00
.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)
2025-12-13 02:34:34 +00:00
? 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 = (
<Button
variant="outline"
disabled={disabled}
className={cn(
'w-full justify-between',
!value || (Array.isArray(value) && value.length === 0)
? 'text-muted-foreground'
: '',
2025-12-13 02:34:34 +00:00
className,
)}
type="button"
// CRITIQUE FIX #42: Ajouter aria-label et aria-labelledby pour l'accessibilité
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-haspopup="listbox"
aria-expanded={open}
>
<span className="truncate">{displayValue}</span>
<div className="flex items-center gap-1 ml-2">
{value &&
2025-12-13 02:34:34 +00:00
((Array.isArray(value) && value.length > 0) ||
!Array.isArray(value)) && (
<X
className="h-4 w-4 shrink-0 opacity-50 hover:opacity-100"
onClick={handleClear}
/>
)}
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
</div>
</Button>
);
const dropdownContent = (
<div
className="w-full min-w-[200px] max-h-[300px] overflow-y-auto"
role="listbox" // CRITIQUE FIX #54: Ajouter role="listbox" pour le conteneur des options
aria-label={ariaLabel || name || placeholder}
>
{/* Champ de recherche */}
{searchable && (
<div className="p-2 border-b">
<Input
ref={searchInputRef}
type="text"
placeholder="Search..."
value={search}
2025-12-13 02:34:34 +00:00
onChange={(e) => setSearch(e.target.value)}
onClick={(e) => e.stopPropagation()}
className="w-full"
/>
</div>
)}
{/* Options non groupées */}
{filteredOptions.ungrouped.length > 0 && (
<div className="py-1">
2025-12-13 02:34:34 +00:00
{filteredOptions.ungrouped.map((option) => (
<SelectOptionItem
key={option.value}
option={option}
isSelected={isSelected(option.value)}
multiple={multiple}
onSelect={handleSelect}
/>
))}
</div>
)}
{/* Options groupées */}
2025-12-13 02:34:34 +00:00
{Object.entries(filteredOptions.groups).map(
([groupLabel, groupOptions]) => (
<div key={groupLabel} className="py-1">
<div className="px-3 py-1.5 text-xs font-semibold text-kodo-content-dim uppercase">
2025-12-13 02:34:34 +00:00
{groupLabel}
</div>
{groupOptions.map((option) => (
<SelectOptionItem
key={option.value}
option={option}
isSelected={isSelected(option.value)}
multiple={multiple}
onSelect={handleSelect}
/>
))}
</div>
2025-12-13 02:34:34 +00:00
),
)}
{/* Message si aucune option */}
{filteredOptions.ungrouped.length === 0 &&
Object.keys(filteredOptions.groups).length === 0 && (
<div className="px-3 py-2 text-sm text-kodo-content-dim text-center">
No options found
</div>
)}
</div>
);
return (
<>
<Dropdown
trigger={trigger}
align="left"
onOpenChange={setOpen}
className="w-full"
>
{dropdownContent}
</Dropdown>
{/* Input caché pour les formulaires */}
{name && (
<input
type="hidden"
name={name}
value={Array.isArray(value) ? value.join(',') : value || ''}
/>
)}
</>
);
}
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 (
<div
role="option"
aria-selected={isSelected}
className={cn(
'relative flex items-center px-3 py-2 text-sm cursor-pointer',
'hover:bg-white/5 hover:text-white',
'focus:bg-white/5 focus:text-white',
'transition-colors text-kodo-text-main',
isSelected && 'bg-kodo-steel/10 text-kodo-steel',
2025-12-13 02:34:34 +00:00
option.disabled && 'opacity-50 cursor-not-allowed pointer-events-none',
)}
onClick={() => !option.disabled && onSelect(option.value)}
onKeyDown={handleKeyDown}
tabIndex={option.disabled ? -1 : 0}
>
{multiple && (
<div
className={cn(
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-kodo-steel',
isSelected && 'bg-kodo-cyan border-kodo-steel text-kodo-void',
)}
>
{isSelected && <Check className="h-3 w-3" />}
</div>
)}
<span className="flex-1">{option.label}</span>
{!multiple && isSelected && <Check className="h-4 w-4 text-kodo-steel" />}
</div>
);
}