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

308 lines
8.3 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';
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>
);
}