- Created automated script (scripts/replace-decorative-cyan.py) to systematically replace decorative/informational kodo-cyan instances with kodo-steel variants - Script intelligently preserves active/functional states, design system variants, semantic indicators, and interactive states - Modified 85 files, replaced 145 decorative instances, preserved 47 functional instances - No linter errors, type safety maintained - Action 11.3.1.3 significantly advanced (total: ~302 instances replaced across ~229 files including previous batches)
466 lines
12 KiB
TypeScript
466 lines
12 KiB
TypeScript
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[] = [];
|
|
|
|
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<string, SelectOption[]> = {};
|
|
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 = (
|
|
<Button
|
|
variant="outline"
|
|
disabled={disabled}
|
|
className={cn(
|
|
'w-full justify-between',
|
|
!value || (Array.isArray(value) && value.length === 0)
|
|
? 'text-muted-foreground'
|
|
: '',
|
|
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 &&
|
|
((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}
|
|
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">
|
|
{filteredOptions.ungrouped.map((option) => (
|
|
<SelectOptionItem
|
|
key={option.value}
|
|
option={option}
|
|
isSelected={isSelected(option.value)}
|
|
multiple={multiple}
|
|
onSelect={handleSelect}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Options groupées */}
|
|
{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">
|
|
{groupLabel}
|
|
</div>
|
|
{groupOptions.map((option) => (
|
|
<SelectOptionItem
|
|
key={option.value}
|
|
option={option}
|
|
isSelected={isSelected(option.value)}
|
|
multiple={multiple}
|
|
onSelect={handleSelect}
|
|
/>
|
|
))}
|
|
</div>
|
|
),
|
|
)}
|
|
|
|
{/* 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',
|
|
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>
|
|
);
|
|
}
|