176 lines
6.4 KiB
TypeScript
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>
|
|
);
|
|
}
|