/* Copyright (C) 2023-2026 QuantumNous This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import * as React from 'react' import { Add01Icon } from '@hugeicons/core-free-icons' import { HugeiconsIcon } from '@hugeicons/react' import { useTranslation } from 'react-i18next' import { cn } from '@/lib/utils' import { Combobox, ComboboxChip, ComboboxChips, ComboboxChipsInput, ComboboxCollection, ComboboxContent, ComboboxEmpty, ComboboxItem, ComboboxList, ComboboxValue, useComboboxAnchor, } from '@/components/ui/combobox' export type Option = { label: string value: string } interface MultiSelectProps { options: Option[] selected: string[] onChange: (values: string[]) => void placeholder?: string className?: string allowCreate?: boolean /** * Label shown for the "create" item in the dropdown. * Supports the `{{value}}` placeholder which is replaced with the typed input. * Falls back to `Add "{{value}}"` when omitted. */ createLabel?: string /** Empty state text. Defaults to "No matching items". */ emptyText?: string /** Optional `id` to wire labels/aria-describedby to the input. */ id?: string /** Disable the entire control. */ disabled?: boolean /** * Limits rendered chips while keeping all values selected. * Hidden values remain searchable/removable from the dropdown. */ maxVisibleChips?: number } const COMMA_REGEX = /[,,\n]/ function splitDraft(value: string): { completed: string[]; draft: string } { if (!COMMA_REGEX.test(value)) { return { completed: [], draft: value } } const normalized = value.replaceAll(',', ',').replaceAll('\n', ',') const parts = normalized.split(',') const draft = parts.at(-1) ?? '' const completed = parts .slice(0, -1) .map((part) => part.trim()) .filter(Boolean) return { completed, draft } } /** * MultiSelect — tags/chips style multi-select built on Base UI Combobox. * * Behaviour: * - Search filters built-in options (Base UI handles fuzzy filtering). * - When `allowCreate` is true, custom values can be added inline: * - Type and press Enter / "," to add a single value. * - Paste a comma- (or newline-) separated list to add many at once. * - A "Add \"\"" item appears at the top of the dropdown when the * typed text doesn't match any option. * - Backspace on an empty input removes the last selected chip (Base UI default). * - `maxVisibleChips` can cap large selections and show a compact "+N more" * summary so forms do not grow vertically without bound. * * Focus/border styling is inherited from `ComboboxChips`, which uses the same * tokens as `Input` so it stays visually consistent with other form fields. */ export function MultiSelect(props: MultiSelectProps) { const { t } = useTranslation() const placeholder = props.placeholder ?? t('Select items...') // Anchor the popup to the chips container so its width tracks the entire // input row, not just the leftover space at the end of wrapped chips. const chipsAnchorRef = useComboboxAnchor() const [inputValue, setInputValue] = React.useState('') const [open, setOpen] = React.useState(false) const selectedSet = React.useMemo( () => new Set(props.selected), [props.selected] ) // Lookup of value -> display label so chips and items can show friendly names // even when the underlying option list changes (e.g. custom-added values). const labelMap = React.useMemo(() => { const map = new Map() for (const option of props.options) { map.set(option.value, option.label) } return map }, [props.options]) const trimmedInput = inputValue.trim() const inputMatchesExisting = trimmedInput.length > 0 && (selectedSet.has(trimmedInput) || props.options.some( (option) => option.value.toLowerCase() === trimmedInput.toLowerCase() || option.label.toLowerCase() === trimmedInput.toLowerCase() )) const canCreate = props.allowCreate === true && trimmedInput.length > 0 && !inputMatchesExisting // We expose all known option values + every currently selected value to Base // UI's items list. This way Base UI filters them by the search query and the // user can still see the chip labels mapped correctly. const items = React.useMemo(() => { const set = new Set(props.options.map((option) => option.value)) for (const value of props.selected) { set.add(value) } if (canCreate) { set.add(trimmedInput) } return Array.from(set) }, [props.options, props.selected, canCreate, trimmedInput]) const addValues = React.useCallback( (values: string[]) => { const next: string[] = [] const seen = new Set(props.selected) for (const raw of values) { const value = raw.trim() if (!value) continue if (seen.has(value)) continue seen.add(value) next.push(value) } if (next.length === 0) return props.onChange([...props.selected, ...next]) }, [props] ) const handleInputValueChange = (value: string) => { if (!props.allowCreate) { setInputValue(value) return } const parsed = splitDraft(value) if (parsed.completed.length > 0) { addValues(parsed.completed) setInputValue(parsed.draft) return } setInputValue(value) } const handleValueChange = (next: string[]) => { props.onChange(next) // When an item is picked (multiple mode), Base UI keeps the input but most // UX patterns clear it. Clearing once a value is added makes batch picking // feel snappier and matches popular chip-style multiselects. if (next.length > props.selected.length) { setInputValue('') } } const handleKeyDown = (event: React.KeyboardEvent) => { // Enter without a highlighted option commits the typed value. if (event.key === 'Enter' && props.allowCreate && canCreate) { // Only fire when Base UI has no highlighted item to select. We rely on // the highlighted item's data attribute on the popup. If the popup is // closed or empty, manually commit the typed value. const popup = document.querySelector( '[data-slot="combobox-content"][data-open]' ) const hasHighlight = popup?.querySelector('[data-highlighted]') != null if (!hasHighlight) { event.preventDefault() addValues([trimmedInput]) setInputValue('') } } } return ( {(values: string[]) => { const visibleValues = typeof props.maxVisibleChips === 'number' ? values.slice(0, props.maxVisibleChips) : values const hiddenCount = values.length - visibleValues.length return ( <> {visibleValues.map((value) => ( {labelMap.get(value) ?? value} ))} {hiddenCount > 0 && ( {t('+{{count}} more', { count: hiddenCount })} )} ) }} {(item: string) => { const isCreate = canCreate && item === trimmedInput const label = labelMap.get(item) ?? item return ( {isCreate ? ( <> ) }} {props.emptyText ?? t('No matching items')} ) }