component — selection
Multiple Select
A multi-value picker over a local option list: chosen options are removable chips inline with a text input, typing filters the list, and the results open in a non-modal popover navigated with aria-activedescendant so focus never leaves the field. Clicking anywhere in the box opens it; Backspace on an empty input removes the last chip. Same control as Async Multiple Select — the only difference is who supplies the options. Underlined like every quebi field, with pill chips.
- select
- multi-select
- combobox
- tags
- chips
- form
- input
- search
- interactive
05 examples
Default
A tokenizer combobox: type to filter, Enter or a click chooses, and each choice becomes a removable chip inline with the input. Clicking anywhere in the box — the padding or a chip's label — puts focus in the input and opens the list; Backspace on an empty input removes the last chip.
Sizes
xs (30px), sm (38px), and the default md (42px) — Input's three steps, so a chip picker and the select beside it are the same height. The text size rides along, so a small field's chips and caret shrink with the box.
With label & description
Pair the control with field primitives. This is a hand-built combobox rather than a react-aria field, so the ids are yours: point the label at the input with `htmlFor` and the input at the hint with `aria-describedby`.
Controlled
Drive selection from state; the live selection is shown below.
Selected: React
Disabled
The whole control can be disabled; chips lose their ✕ with it.
source
multiple-select.tsxCopy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.
"use client"
import { Check, Loader2, X } from "lucide-react"
import {
Children,
isValidElement,
type ReactNode,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react"
import { type Key, useFilter } from "react-aria-components"
import { useFieldSizing } from "@/lib/field-size"
import { cn } from "@/lib/utils"
import { PopoverContent } from "@/components/popover"
/**
* Multiple Select — quebi design system
*
* A tokenizer combobox over a local option list: chosen options render as
* removable chips *inline* with a single text input, the input is the one
* focus/click target, typing filters the list, and the results are a non-modal
* popover navigated with `aria-activedescendant` so focus never leaves the
* field.
*
* That is the same control Async Multiple Select draws, and it is the same
* code: `MultiSelectControl` below is the shared part, and the two components
* are two assemblies of it that differ only in who supplies the options — a
* local collection here, a remote `load` there. Nothing else should choose
* between them, the way `DataTable` and `ServerTable` differ only in who owns
* the query.
*
* Before task #157 this control was a different shape: a tag list plus a `+`
* button opening a *modal* popover with its own search box. Every guard in it
* existed because that popover took focus and handed it back — "open on focus"
* had to tell a real arrival from the overlay's focus restore, and removing a
* tag had to be told apart from focus shuffling inside the box. An inline input
* has nowhere to hand focus to, so none of those questions come up and none of
* those guards are here. The `+` is gone with them: the input is the trigger.
*/
/** The minimum an option must carry: a key and a label for its chip. */
export interface MultiSelectOption {
id: string | number
name: string
}
/** The DOM/selection key of an option. Ids may be numbers; keys are strings. */
export const multiSelectKey = (option: MultiSelectOption) => String(option.id)
/**
* The field size scale, as this control expresses it.
*
* A chips-and-input box has no single line to pad, so the height is the box's
* own padding plus the input row inside it: `xs` is 4 + 20 + 4 + 2px of border
* (top and bottom; the top goes transparent inside `quebi-editorial`) = 30px, `sm` 38px and `md` 42px,
* which are `Input`'s three steps and `Button`'s `xs` and `sm` to the pixel.
* Inline padding is `Input`'s `--q-field-px` — 0 on an underline, so the
* first chip starts under the label.
* The text size rides along and the input inherits it, so a small field's
* chips and caret shrink with the box.
*
* `md` was 38px before task #186 — the default box was a size too short to
* stand beside an `Input`, which is the whole complaint the scale answers.
*
* Written out here rather than imported, as in `input.tsx` and `select.tsx`:
* three strings are not worth making `Input` a registry dependency of this
* file.
*/
const multiSelectControlSizeStyles = {
xs: "px-(--q-field-px) py-1 text-xs",
sm: "px-(--q-field-px) py-1.5 text-sm",
md: "px-(--q-field-px) py-2 text-sm",
} as const
type MultiSelectControlSize = keyof typeof multiSelectControlSizeStyles
export interface MultiSelectControlProps<T extends MultiSelectOption> {
/** The options to offer right now — already filtered or paged by the caller. */
options: T[]
/** The current selection, as full options so chips stay labelled. */
selected: T[]
/** Clicking or pressing Enter on an option, selected or not. */
onToggle: (option: T) => void
/** The chip's ✕, or Backspace on an empty input. */
onRemove: (option: T) => void
/** The text in the input. The caller decides what it does with it. */
search: string
onSearchChange: (search: string) => void
/** The body of an option row, beside its check mark. Defaults to `name`. */
renderOption?: (option: T) => ReactNode
/** Shows the spinner in place of the (empty) list. */
isLoading?: boolean
/** Shows a spinner under the list while the next page arrives. */
isLoadingMore?: boolean
/** Called when the results are scrolled near the bottom. */
onLoadMore?: () => void
/** Shown when there are no options and nothing is loading. */
emptyMessage?: string
placeholder?: string
isDisabled?: boolean
isInvalid?: boolean
isRequired?: boolean
/** When set, the selection is mirrored into hidden inputs for form submission. */
name?: string
/** The `<form>` to associate the hidden inputs with, by id. */
form?: string
className?: string
/** Lands on the input — so a `<Label htmlFor>` points at the real control. */
id?: string
/**
* The control's accessible name. Leave it off when a `<Label htmlFor>` names
* the input — an `aria-label` would win over it and hide the visible text
* from assistive technology.
*/
"aria-label"?: string
/** ids of the elements describing this control — a hint, an error message. */
"aria-describedby"?: string
/**
* Control height. Matches `Input`'s scale and `Button`'s `xs` / `sm`. Left
* out, it is whatever the surrounding surface asked for — a table cell being
* the one that does. See `@/lib/field-size`.
*/
size?: MultiSelectControlSize
}
/**
* The control both multi-value pickers draw: chips + input in one box, and the
* results listbox under it.
*
* It owns the open state, the keyboard, the virtual focus and the DOM; it owns
* no data. Where the options come from, what the search text does to them, and
* what the selection is are all the caller's, which is the seam that lets the
* local and the remote picker be the same control.
*/
export function MultiSelectControl<T extends MultiSelectOption>({
options,
selected,
onToggle,
onRemove,
search,
onSearchChange,
renderOption,
isLoading,
isLoadingMore,
onLoadMore,
emptyMessage = "No results",
placeholder = "Select…",
isDisabled,
isInvalid,
isRequired,
name,
form,
className,
id,
"aria-label": ariaLabel,
"aria-describedby": ariaDescribedBy,
size: sizeProp,
}: MultiSelectControlProps<T>) {
const { size } = useFieldSizing({ size: sizeProp })
const reactId = useId()
const listboxId = `${reactId}-listbox`
const optionId = (key: string) => `${reactId}-opt-${key}`
const containerRef = useRef<HTMLDivElement | null>(null)
const popoverRef = useRef<HTMLDivElement | null>(null)
const inputRef = useRef<HTMLInputElement | null>(null)
const [open, setOpen] = useState(false)
const [activeKey, setActiveKey] = useState<string | null>(null)
const selectedKeys = useMemo(() => new Set(selected.map(multiSelectKey)), [selected])
const openMenu = () => {
if (!isDisabled) setOpen(true)
}
const closeMenu = () => {
setOpen(false)
setActiveKey(null)
}
// Keep the keyboard-highlighted option valid as the list filters/paginates.
useEffect(() => {
if (!open) return
if (options.length === 0) {
setActiveKey(null)
return
}
setActiveKey((prev) =>
prev && options.some((option) => multiSelectKey(option) === prev)
? prev
: multiSelectKey(options[0]),
)
}, [open, options])
// Scroll the active option into view during keyboard navigation.
useEffect(() => {
if (!open || !activeKey) return
document.getElementById(`${reactId}-opt-${activeKey}`)?.scrollIntoView({ block: "nearest" })
}, [activeKey, open, reactId])
// Close on outside press. The popover is non-modal (so focus stays in the
// input), which means react-aria's usePopover runs with isDismissable=false
// and won't dismiss on outside interaction — so we own that here. Capture
// phase runs before any child handler that might stop propagation.
useEffect(() => {
if (!open) return
const onPointerDown = (e: PointerEvent) => {
const target = e.target as Node | null
if (!target) return
if (containerRef.current?.contains(target)) return
if (popoverRef.current?.contains(target)) return
setOpen(false)
setActiveKey(null)
}
document.addEventListener("pointerdown", onPointerDown, true)
return () => document.removeEventListener("pointerdown", onPointerDown, true)
}, [open])
const moveActive = (dir: 1 | -1) => {
if (options.length === 0) return
const idx = activeKey ? options.findIndex((o) => multiSelectKey(o) === activeKey) : -1
const nextIdx =
idx === -1 ? (dir === 1 ? 0 : options.length - 1) : (idx + dir + options.length) % options.length
setActiveKey(multiSelectKey(options[nextIdx]))
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault()
if (open) moveActive(1)
else openMenu()
break
case "ArrowUp":
e.preventDefault()
if (open) moveActive(-1)
else openMenu()
break
case "Enter": {
if (!open || !activeKey) break
const active = options.find((o) => multiSelectKey(o) === activeKey)
if (!active) break
e.preventDefault()
onToggle(active)
break
}
case "Escape":
if (open) {
e.preventDefault()
closeMenu()
}
break
case "Backspace":
if (search === "" && selected.length > 0) {
e.preventDefault()
onRemove(selected[selected.length - 1])
}
break
}
}
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
if (!onLoadMore) return
const el = e.currentTarget
const distanceToBottom = el.scrollHeight - el.scrollTop - el.clientHeight
if (distanceToBottom < 60 && options.length > 0 && !isLoading && !isLoadingMore) onLoadMore()
}
return (
<div data-slot="control" className={cn("w-full", className)}>
{/** biome-ignore lint/a11y/noStaticElementInteractions: the control surface forwards bare-surface and chip-body clicks to the combobox input; all real semantics live on the input/options */}
<div
ref={containerRef}
onMouseDown={(e) => {
// The whole box is the way in (task #116): a press on the padding or
// on a chip's label puts focus in the input and opens the list. A
// press on the input, or on a chip's ✕, is that control's own
// business — opening underneath them is the double-fire to avoid.
if (isDisabled) return
const target = e.target
if (target instanceof Element && target.closest("button, input")) return
e.preventDefault()
inputRef.current?.focus()
openMenu()
}}
data-invalid={isInvalid || undefined}
className={cn(
// `Input`'s frame, doubled while the input has focus.
"flex w-full flex-wrap items-center gap-1.5",
"quebi-field",
multiSelectControlSizeStyles[size],
"transition-[border-color,box-shadow] duration-150",
"focus-within:shadow-(--q-field-focus)",
isInvalid &&
"[--q-field-edge:var(--q-danger)] focus-within:shadow-(--q-field-focus-danger)",
isDisabled ? "cursor-not-allowed opacity-50" : "cursor-text",
)}
>
{selected.map((item) => {
const k = multiSelectKey(item)
return (
<span
key={k}
data-slot="chip"
className="inline-flex items-center gap-x-1 rounded-full bg-quebi-raised py-0.5 pe-1 ps-2.5 text-quebi-fg-muted text-quebi-tag"
>
{item.name}
{!isDisabled && (
// biome-ignore lint/correctness/noRestrictedElements: as in async-select — onMouseDown preventDefault holds focus on the input and stopPropagation stops chip removal reopening the listbox; onPress is neither DOM event.
<button
type="button"
aria-label={`Remove ${item.name}`}
tabIndex={-1}
// Keep focus in the input so removing a chip never opens/closes
// the menu or blurs the field.
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation()
onRemove(item)
}}
className={cn(
"flex size-4 shrink-0 items-center justify-center rounded-full text-quebi-fg-subtle outline-none transition-colors duration-150",
"hover:bg-quebi-pressed hover:text-quebi-fg focus-visible:ring-2 focus-visible:ring-quebi-focus focus-visible:ring-offset-0",
)}
>
<X className="size-3" strokeWidth={2.5} aria-hidden="true" />
</button>
)}
</span>
)
})}
<input
ref={inputRef}
id={id}
type="text"
role="combobox"
aria-expanded={open}
aria-controls={open ? listboxId : undefined}
aria-autocomplete="list"
aria-activedescendant={open && activeKey ? optionId(activeKey) : undefined}
aria-label={ariaLabel}
aria-invalid={isInvalid || undefined}
aria-required={isRequired || undefined}
aria-describedby={ariaDescribedBy}
disabled={isDisabled}
value={search}
placeholder={selected.length === 0 ? placeholder : undefined}
onChange={(e) => {
onSearchChange(e.target.value)
openMenu()
}}
onFocus={openMenu}
onClick={openMenu}
onKeyDown={handleKeyDown}
className={cn(
// No text size of its own: it inherits the box's, so the caret and
// the placeholder shrink with the field.
// `first:ps-0`: with no chips before it, the text starts on the edge.
"min-w-24 flex-1 bg-transparent px-1.5 py-0.5 text-quebi-fg outline-none first:ps-0",
"placeholder:text-quebi-fg-subtle",
)}
/>
</div>
<PopoverContent
ref={popoverRef}
triggerRef={containerRef}
isOpen={open && !isDisabled}
onOpenChange={(next) => {
if (!next) closeMenu()
}}
isNonModal
placement="bottom start"
className="w-(--trigger-width) p-0"
>
<div
role="listbox"
id={listboxId}
aria-multiselectable="true"
aria-label={ariaLabel ?? "Options"}
onScroll={handleScroll}
className="quebi-scrollbar max-h-72 overflow-y-auto overscroll-contain p-1"
>
{options.length === 0 ? (
<div className="flex items-center justify-center gap-2 py-6 text-quebi-fg-subtle text-sm">
{isLoading ? (
<>
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
Loading…
</>
) : (
emptyMessage
)}
</div>
) : (
options.map((item) => {
const k = multiSelectKey(item)
const isSel = selectedKeys.has(k)
const isActive = activeKey === k
return (
// biome-ignore lint/a11y/useFocusableInteractive: options use virtual focus via the combobox input's aria-activedescendant
// biome-ignore lint/a11y/useKeyWithClickEvents: keyboard is handled centrally on the combobox input (Enter toggles the active option)
<div
key={k}
id={optionId(k)}
role="option"
aria-selected={isSel}
// Keep focus in the input while clicking an option.
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setActiveKey(k)}
onClick={() => onToggle(item)}
className={cn(
"flex cursor-pointer items-center gap-2 px-2.5 py-1.5 text-sm text-quebi-fg outline-none transition-colors duration-150",
isActive && "bg-quebi-raised",
)}
>
<Check
className={cn("size-4 shrink-0 text-quebi-signal", isSel ? "opacity-100" : "opacity-0")}
aria-hidden="true"
/>
{renderOption ? (
renderOption(item)
) : (
<span className="flex-1 truncate">{item.name}</span>
)}
</div>
)
})
)}
{isLoadingMore && (
<div className="flex items-center justify-center py-2">
<Loader2 className="size-4 animate-spin text-quebi-fg-subtle" aria-hidden="true" />
</div>
)}
</div>
</PopoverContent>
{name &&
selected.map((item) => (
<input
key={multiSelectKey(item)}
type="hidden"
name={name}
form={form}
value={multiSelectKey(item)}
/>
))}
</div>
)
}
/**
* The props are the option-independent half of the control: the selection is
* ids (`Key[]`), not option objects, so this interface carries no item type —
* the items themselves arrive through `<MultipleSelectContent>`.
*/
interface MultipleSelectProps {
/** Controlled selection, as the ids of the chosen options. */
value?: Key[]
/** Uncontrolled initial selection. */
defaultValue?: Key[]
onChange?: (value: Key[]) => void
placeholder?: string
isDisabled?: boolean
isInvalid?: boolean
isRequired?: boolean
/** When set, the selection is mirrored into hidden inputs for form submission. */
name?: string
/** The `<form>` to associate the hidden inputs with, by id. */
form?: string
/** Lands on the input — so a `<Label htmlFor>` points at the real control. */
id?: string
className?: string
/** A `<MultipleSelectContent>` holding the options; anything else is rendered around the control. */
children?: ReactNode
"aria-label"?: string
/** ids of the elements describing this control — a hint, an error message. */
"aria-describedby"?: string
/** Control height. Matches `Input`'s scale — see `@/lib/field-size`. */
size?: MultiSelectControlSize
}
interface MultipleSelectContentProps<T extends MultiSelectOption> {
items: Iterable<T>
children: (item: T) => ReactNode
}
/**
* The option list, as data. Renders nothing itself: `MultipleSelect` reads its
* `items` and its render function and draws the rows, so the selection key and
* the chip labels can come from the same collection.
*/
function MultipleSelectContent<T extends MultiSelectOption>(_props: MultipleSelectContentProps<T>) {
return null
}
;(MultipleSelectContent as unknown as { displayName: string }).displayName = "MultipleSelectContent"
interface MultipleSelectItemProps {
/** The selection key, when it is not the option's own `id`. */
id?: Key
/** The text the search filters on, when the row is not plain text. */
textValue?: string
className?: string
children?: ReactNode
}
/**
* One option row's body. It is a label, not a list-box item: the row around it
* — the check mark, the virtual-focus id, the press handling — belongs to the
* control, so this renders only what the consumer wrote inside it.
*/
function MultipleSelectItem({ className, children }: MultipleSelectItemProps) {
return <span className={cn("flex-1 truncate", className)}>{children}</span>
}
/** One option: the row the consumer rendered, plus the key and text it implies. */
interface Entry<T extends MultiSelectOption> {
option: T
node: ReactNode
text: string
}
function MultipleSelect<T extends MultiSelectOption>({
value,
defaultValue,
onChange,
placeholder,
isDisabled,
isInvalid,
isRequired,
name,
form,
id,
className,
children,
"aria-label": ariaLabel,
"aria-describedby": ariaDescribedBy,
size,
}: MultipleSelectProps) {
const { before, after, list } = useMemo(() => {
const arr = Children.toArray(children)
const idx = arr.findIndex(
(c) =>
isValidElement(c) &&
(c.type as unknown as { displayName?: string })?.displayName === "MultipleSelectContent",
)
if (idx === -1) {
return { before: arr, after: [], list: null as null | MultipleSelectContentProps<T> }
}
const el = arr[idx] as React.ReactElement<MultipleSelectContentProps<T>>
return { before: arr.slice(0, idx), after: arr.slice(idx + 1), list: el.props }
}, [children])
// Each option is rendered once, here, so that an `id` or `textValue` written
// on a MultipleSelectItem still decides the selection key and what the search
// matches — as it did when the row was a react-aria ListBoxItem.
const entries = useMemo<Entry<T>[]>(() => {
if (!list) return []
return Array.from(list.items, (item) => {
const node = list.children(item)
const props = isValidElement<MultipleSelectItemProps>(node) ? node.props : undefined
const option =
props?.id != null && props.id !== item.id ? ({ ...item, id: props.id } as T) : item
return { option, node, text: props?.textValue ?? item.name }
})
}, [list])
const [internal, setInternal] = useState<Key[]>(() => defaultValue ?? [])
const selectedKeys = value ?? internal
const commit = (next: Key[]) => {
if (value === undefined) setInternal(next)
onChange?.(next)
}
const [search, setSearch] = useState("")
const { contains } = useFilter({ sensitivity: "base" })
const options = useMemo(
() =>
(search === "" ? entries : entries.filter((entry) => contains(entry.text, search))).map(
(entry) => entry.option,
),
[entries, search, contains],
)
// Chips are drawn from the collection, so a selected key that is no longer an
// option still shows — as itself, rather than vanishing from the field.
const selected = useMemo(
() =>
selectedKeys.map((key) => {
const match = entries.find((entry) => multiSelectKey(entry.option) === String(key))
return match ? match.option : ({ id: key, name: String(key) } as T)
}),
[entries, selectedKeys],
)
const nodeFor = (option: T) =>
entries.find((entry) => multiSelectKey(entry.option) === multiSelectKey(option))?.node
const remove = (option: T) =>
commit(selectedKeys.filter((key) => String(key) !== multiSelectKey(option)))
const toggle = (option: T) => {
const key = multiSelectKey(option)
if (selectedKeys.some((k) => String(k) === key)) remove(option)
else commit([...selectedKeys, option.id])
}
return (
<div data-slot="control" className={cn("w-full", className)}>
{before}
<MultiSelectControl<T>
size={size}
options={options}
selected={selected}
onToggle={toggle}
onRemove={remove}
search={search}
onSearchChange={setSearch}
renderOption={(option) => nodeFor(option) ?? option.name}
emptyMessage="No results"
placeholder={placeholder}
isDisabled={isDisabled}
isInvalid={isInvalid}
isRequired={isRequired}
name={name}
form={form}
id={id}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
/>
{after}
</div>
)
}
export type { MultipleSelectProps, MultipleSelectContentProps, MultipleSelectItemProps }
export { MultipleSelect, MultipleSelectContent, MultipleSelectItem }