Files
krow_talent_app/src/components/ds/MultiSelect.tsx

176 lines
6.4 KiB
TypeScript

import type { DsProps } from './props';
import * as React from 'react';
import { Check, ChevronDown, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Badge } from '@/components/ui/badge';
import { SearchInput } from '@/components/ds/SearchInput';
import { useTranslation } from 'react-i18next';
/**
* MultiSelect — pick several values from a list.
*
* Selections render as removable chips in the trigger. Above `searchThreshold`
* options a filter field appears, so short lists stay uncluttered and long ones
* stay usable. Options may be strings or `{ value, label, count }`.
*/
/** @param {any} props */
export function MultiSelect({
options = [],
value = [],
onChange,
placeholder = 'Select...',
/** Chips shown before collapsing into "+N more". */
maxChips = 2,
searchThreshold = 8,
disabled = false,
className = '',
align = 'start',
emptyMessage = 'No options',
}: DsProps) {
const { t } = useTranslation();
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState('');
const normalized = React.useMemo(
() => options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o)),
[options]
);
const filtered = React.useMemo(() => {
if (!query) return normalized;
const q = query.toLowerCase();
return normalized.filter((o) => o.label.toLowerCase().includes(q));
}, [normalized, query]);
const labelFor = (v) => normalized.find((o) => o.value === v)?.label ?? v;
const toggle = (optionValue) => {
const next = value.includes(optionValue)
? value.filter((v) => v !== optionValue)
: [...value, optionValue];
onChange?.(next);
};
const remove = (optionValue, event) => {
// The chip's X sits inside the trigger — don't open the popover.
event.stopPropagation();
onChange?.(value.filter((v) => v !== optionValue));
};
const visible = value.slice(0, maxChips);
const overflow = value.length - visible.length;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
'flex min-h-10 w-full items-center gap-1.5 rounded-lg border border-border bg-surface px-3 py-1.5 text-left shadow-xs',
'transition-all duration-base ease-out',
'outline-none focus-visible:border-krow-blue focus-visible:ring-2 focus-visible:ring-krow-blue/20',
'disabled:cursor-not-allowed disabled:bg-surface-sunken disabled:opacity-60',
open && 'border-krow-blue ring-2 ring-krow-blue/20',
className
)}
>
<span className="flex flex-1 flex-wrap items-center gap-1 min-w-0">
{value.length === 0 && <span className="text-body-sm text-ink-4">{placeholder}</span>}
{visible.map((v) => (
<Badge key={v} variant="soft" size="lg" className="max-w-[160px] pr-1">
<span className="truncate">{labelFor(v)}</span>
<span
role="button"
tabIndex={-1}
aria-label={`Remove ${labelFor(v)}`}
onClick={(e) => remove(v, e)}
className="grid place-items-center w-3.5 h-3.5 rounded-full hover:bg-krow-blue/20 shrink-0"
>
<X className="w-2.5 h-2.5" aria-hidden="true" />
</span>
</Badge>
))}
{overflow > 0 && (
<Badge variant="neutral" size="lg">+{overflow} more</Badge>
)}
</span>
<ChevronDown
className={cn(
'w-4 h-4 text-ink-4 shrink-0 transition-transform duration-base',
open && 'rotate-180'
)}
aria-hidden="true"
/>
</button>
</PopoverTrigger>
<PopoverContent align={align} className="w-[--radix-popover-trigger-width] min-w-52 p-0">
{normalized.length >= searchThreshold && (
<div className="p-2 border-b border-border">
<SearchInput
value={query}
onChange={setQuery}
placeholder={t('multiSelect.filterOptions')}
size="sm"
debounce={0}
autoFocus
/>
</div>
)}
<div className="max-h-64 overflow-y-auto p-1.5">
{filtered.length === 0 && (
<p className="px-2 py-6 text-center text-body-sm text-ink-3">{emptyMessage}</p>
)}
{filtered.map((option) => {
const selected = value.includes(option.value);
return (
<button
key={option.value}
type="button"
role="option"
aria-selected={selected}
onClick={() => toggle(option.value)}
className={cn(
'flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left text-body-sm transition-colors duration-fast',
'hover:bg-krow-blue-tint focus-ring-inset',
selected ? 'text-krow-blue font-medium' : 'text-ink-2'
)}
>
<span
className={cn(
'grid place-items-center w-4 h-4 rounded border shrink-0 transition-colors duration-fast',
selected ? 'bg-krow-blue border-krow-blue text-white' : 'border-border'
)}
>
{selected && <Check className="w-3 h-3" aria-hidden="true" />}
</span>
<span className="flex-1 truncate">{option.label}</span>
{option.count != null && (
<span className="text-caption text-ink-4 tabular-nums">{option.count}</span>
)}
</button>
);
})}
</div>
{value.length > 0 && (
<div className="border-t border-border p-1.5">
<button
type="button"
onClick={() => onChange?.([])}
className="w-full rounded-md px-2 py-1.5 text-body-sm text-ink-3 hover:bg-surface-sunken hover:text-ink-1 transition-colors focus-ring-inset"
>
{t('multiSelect.clearSelection')}
</button>
</div>
)}
</PopoverContent>
</Popover>
);
}