308 lines
8.3 KiB
TypeScript
308 lines
8.3 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';
|
||
|
|
|
||
|
|
export interface SelectOption {
|
||
|
|
value: string;
|
||
|
|
label: string;
|
||
|
|
disabled?: boolean;
|
||
|
|
group?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface SelectGroup {
|
||
|
|
label: string;
|
||
|
|
options: SelectOption[];
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface SelectProps {
|
||
|
|
options: SelectOption[];
|
||
|
|
value?: string | string[];
|
||
|
|
onChange: (value: string | string[]) => void;
|
||
|
|
multiple?: boolean;
|
||
|
|
searchable?: boolean;
|
||
|
|
placeholder?: string;
|
||
|
|
disabled?: boolean;
|
||
|
|
className?: string;
|
||
|
|
name?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Composant Select avec recherche, multi-select, et groupes d'options.
|
||
|
|
*/
|
||
|
|
export function Select({
|
||
|
|
options,
|
||
|
|
value,
|
||
|
|
onChange,
|
||
|
|
multiple = false,
|
||
|
|
searchable = false,
|
||
|
|
placeholder = 'Select an option...',
|
||
|
|
disabled = false,
|
||
|
|
className,
|
||
|
|
name,
|
||
|
|
}: 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"
|
||
|
|
>
|
||
|
|
<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">
|
||
|
|
{/* 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-muted-foreground 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-muted-foreground 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) {
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
role="menuitem"
|
||
|
|
className={cn(
|
||
|
|
'relative flex items-center px-3 py-2 text-sm cursor-pointer',
|
||
|
|
'hover:bg-accent hover:text-accent-foreground',
|
||
|
|
'focus:bg-accent focus:text-accent-foreground',
|
||
|
|
'transition-colors',
|
||
|
|
isSelected && 'bg-accent text-accent-foreground',
|
||
|
|
option.disabled && 'opacity-50 cursor-not-allowed pointer-events-none'
|
||
|
|
)}
|
||
|
|
onClick={() => !option.disabled && onSelect(option.value)}
|
||
|
|
tabIndex={option.disabled ? -1 : 0}
|
||
|
|
>
|
||
|
|
{multiple && (
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border',
|
||
|
|
isSelected && 'bg-primary border-primary text-primary-foreground'
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{isSelected && <Check className="h-3 w-3" />}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
<span className="flex-1">{option.label}</span>
|
||
|
|
{!multiple && isSelected && (
|
||
|
|
<Check className="h-4 w-4 text-primary" />
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|