/*
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.createLabel
? t(props.createLabel, { value: item })
: t('Add "{{value}}"', { value: item })}
>
) : (
{label}
)}
)
}}
{props.emptyText ?? t('No matching items')}
)
}