diff --git a/web/default/src/components/multi-select.tsx b/web/default/src/components/multi-select.tsx index f4bd0c69..d8309680 100644 --- a/web/default/src/components/multi-select.tsx +++ b/web/default/src/components/multi-select.tsx @@ -8,21 +8,31 @@ 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 +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 . +along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import * as React from 'react' -import { Command as CommandPrimitive } from 'cmdk' -import { X } from 'lucide-react' +import { Add01Icon } from '@hugeicons/core-free-icons' +import { HugeiconsIcon } from '@hugeicons/react' import { useTranslation } from 'react-i18next' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { Command, CommandGroup, CommandItem } from '@/components/ui/command' +import { cn } from '@/lib/utils' +import { + Combobox, + ComboboxChip, + ComboboxChips, + ComboboxChipsInput, + ComboboxCollection, + ComboboxContent, + ComboboxEmpty, + ComboboxItem, + ComboboxList, + ComboboxValue, +} from '@/components/ui/combobox' export type Option = { label: string @@ -35,116 +45,232 @@ interface MultiSelectProps { 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 } -export function MultiSelect({ - options, - selected, - onChange, - placeholder, - className, -}: MultiSelectProps) { - const { t } = useTranslation() - const resolvedPlaceholder = placeholder ?? t('Select items...') - const inputRef = React.useRef(null) - const [open, setOpen] = React.useState(false) - const [inputValue, setInputValue] = React.useState('') +const COMMA_REGEX = /[,,\n]/ - const handleUnselect = (value: string) => { - onChange(selected.filter((s) => s !== value)) +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). + * + * 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...') + + 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 handleKeyDown = (e: React.KeyboardEvent) => { - const input = inputRef.current - if (input) { - if (e.key === 'Delete' || e.key === 'Backspace') { - if (input.value === '' && selected.length > 0) { - onChange(selected.slice(0, -1)) - } - } - if (e.key === 'Escape') { - input.blur() + 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('') } } } - const selectables = options.filter( - (option) => !selected.includes(option.value) - ) - return ( - -
-
- {selected.map((value) => { - const option = options.find((o) => o.value === value) - return ( - - {option?.label || value} - - - ) - })} - setOpen(false)} - onFocus={() => setOpen(true)} - placeholder={selected.length === 0 ? resolvedPlaceholder : ''} - className='placeholder:text-muted-foreground flex-1 bg-transparent outline-none' - /> -
-
-
- {open && selectables.length > 0 ? ( -
- - {selectables.map((option) => { - return ( - { - e.preventDefault() - e.stopPropagation() - }} - onSelect={() => { - setInputValue('') - onChange([...selected, option.value]) - }} - className='cursor-pointer' - > - {option.label} - - ) - })} - -
- ) : null} -
-
+ {isCreate ? ( + <> +