"use client" import { Check, ChevronsUpDown, X } from 'lucide-react'; import * as React from 'react'; import { useAppT } from '@djangocfg/i18n'; import { useStoredValue, type StorageType, type UseStoredValueOptions } from '../../hooks'; import { cn } from '../../lib/utils'; import { Badge } from '../data/badge'; import { Button } from '../forms/button'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '../navigation/command'; import { Popover, PopoverContent, PopoverTrigger } from '../overlay/popover'; export interface MultiSelectOption { value: string label: string description?: string disabled?: boolean } export interface MultiSelectProps { options: MultiSelectOption[] value?: string[] onChange?: (value: string[]) => void placeholder?: string searchPlaceholder?: string emptyText?: string className?: string disabled?: boolean maxDisplay?: number /** * When provided, the selected values array is persisted under this key. * * On mount: stored values that are present in the current options are * restored. Values that no longer exist in options are silently dropped * (safe against stale data from removed options). * * @example storageKey="selected-tags" */ storageKey?: string /** @default 'local' */ storageType?: StorageType /** TTL in ms */ storageTtl?: number /** * When true AND storageKey is set: after options load/change, the stored * selection is filtered to only include values present in the current * options. Values not in options are removed from the selection and * from storage. * * When false (default): stored values are only used to seed the initial * selection. Subsequent option changes don't re-filter. * * @default false */ autoSelectFromOptions?: boolean } const EMPTY_ARRAY: string[] = []; export function MultiSelect({ options, value: controlledValue, onChange, placeholder, searchPlaceholder, emptyText, className, disabled = false, maxDisplay = 3, storageKey, storageType, storageTtl, autoSelectFromOptions = false, }: MultiSelectProps) { const t = useAppT() const [open, setOpen] = React.useState(false) const [search, setSearch] = React.useState("") const scrollRef = React.useRef(null) const storageOptions: UseStoredValueOptions | undefined = storageKey ? { storage: storageType ?? 'local', ttl: storageTtl } : undefined; const [storedValue, setStoredValue] = useStoredValue( storageKey, controlledValue ?? EMPTY_ARRAY, storageOptions, ); // Internal selection state for uncontrolled mode const [internalValue, setInternalValue] = React.useState( controlledValue !== undefined ? controlledValue : storedValue, ); const value = controlledValue !== undefined ? controlledValue : internalValue; // Resolve translations const resolvedPlaceholder = placeholder ?? t('ui.select.placeholder') const resolvedSearchPlaceholder = searchPlaceholder ?? t('ui.select.search') const resolvedEmptyText = emptyText ?? t('ui.select.noResults') // autoSelectFromOptions: filter stored selection to valid options only const autoSelectApplied = React.useRef(false); React.useEffect(() => { if (!storageKey || !autoSelectFromOptions || options.length === 0) return; if (autoSelectApplied.current) return; autoSelectApplied.current = true; const saved = storedValue; if (!saved || saved.length === 0) return; const validOptionValues = new Set( options.filter((o) => !o.disabled).map((o) => o.value) ); const filtered = saved.filter((v) => validOptionValues.has(v)); // Only update if something actually changed const changed = filtered.length !== saved.length || filtered.some((v, i) => v !== saved[i]); if (changed) { setStoredValue(filtered); if (controlledValue === undefined) setInternalValue(filtered); onChange?.(filtered); } else if (filtered.length > 0) { // Restore valid selection to parent if (controlledValue === undefined) setInternalValue(filtered); onChange?.(filtered); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [options, storageKey, autoSelectFromOptions]); React.useEffect(() => { autoSelectApplied.current = false; }, [storageKey]); React.useEffect(() => { if (scrollRef.current && open) { const el = scrollRef.current el.style.cssText = ` max-height: 300px !important; overflow-y: scroll !important; overflow-x: hidden !important; -webkit-overflow-scrolling: touch !important; overscroll-behavior: contain !important; ` } }, [open]) const selectedOptions = React.useMemo( () => options.filter((option) => value.includes(option.value)), [options, value] ) const filteredOptions = React.useMemo(() => { if (!search) return options const searchLower = search.toLowerCase() return options.filter( (option) => option.label.toLowerCase().includes(searchLower) || option.value.toLowerCase().includes(searchLower) || option.description?.toLowerCase().includes(searchLower) ) }, [options, search]) const handleSelect = React.useCallback((optionValue: string) => { const newValue = value.includes(optionValue) ? value.filter((v) => v !== optionValue) : [...value, optionValue]; if (storageKey) setStoredValue(newValue); if (controlledValue === undefined) setInternalValue(newValue); onChange?.(newValue); }, [value, storageKey, setStoredValue, controlledValue, onChange]); const handleRemove = React.useCallback((optionValue: string, e: React.MouseEvent) => { e.stopPropagation(); const newValue = value.filter((v) => v !== optionValue); if (storageKey) setStoredValue(newValue); if (controlledValue === undefined) setInternalValue(newValue); onChange?.(newValue); }, [value, storageKey, setStoredValue, controlledValue, onChange]); const displayValue = React.useMemo(() => { if (selectedOptions.length === 0) { return {resolvedPlaceholder} } const displayed = selectedOptions.slice(0, maxDisplay) const remaining = selectedOptions.length - maxDisplay return (
{displayed.map((option) => ( {option.label} ))} {remaining > 0 && ( {t('ui.select.moreItems', { count: remaining })} )}
) }, [selectedOptions, maxDisplay, resolvedPlaceholder, disabled, t, handleRemove]) return ( { setOpen(isOpen) if (!isOpen) { setSearch("") } }} >
{ e.stopPropagation() }} > {filteredOptions.length === 0 ? ( {resolvedEmptyText} ) : ( {filteredOptions.map((option) => { const isSelected = value.includes(option.value) return ( { if (!option.disabled) { handleSelect(option.value) } }} disabled={option.disabled} >
{option.label} {option.description && ( {option.description} )}
) })}
)}
) }