component — selection

Async Multiple Select

A tokenizer combobox whose options are loaded from a remote source: an inline search input opens the dropdown on focus or on a click anywhere in the box, typing re-queries the source live, and scrolling loads more. Selected options render as removable chips beside the input. Same control as Multiple Select — the only difference is who supplies the options: a remote load here, a local collection there. Underlined like every quebi field, with pill chips.

  • select
  • multi-select
  • async
  • combobox
  • tags
  • form
  • input
  • search
  • pagination
  • interactive

04 examples

Default

Focus the control — or click anywhere in the box — to open the list; the first page loads from the source. Type to re-query live and scroll to load more. This is the same control Multiple Select draws; only the source of the options differs.

With label & description

Pair the control with field primitives and seed an initial selection. 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`.

Ada Lovelace #1Linus Lovelace #4
Options are fetched on demand as you search.

Controlled

Drive selection from state; the live selection is shown below.

Alan Lovelace #2

Selected: Alan Lovelace #2

Disabled

The whole control can be disabled.

Grace Lovelace #3

source

async-multiple-select.tsx

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import { useMemo, useRef, useState } from "react"
import { useAsyncList } from "react-stately"
import {
  MultiSelectControl,
  type MultiSelectOption,
  multiSelectKey,
} from "@/components/multiple-select"

/**
 * Async Multiple Select — quebi design system
 *
 * A tokenizer combobox whose options are loaded from a remote source. Selected
 * values render as removable chips *inline* with a single text input — the input
 * is the one focus/click target: focusing or typing opens the dropdown and
 * re-queries the source live (debounced), and scrolling the results loads the
 * next page. There is no separate search box and no add button, so nothing
 * double-fires; removing a chip (✕ or Backspace on an empty input) never opens
 * the menu. Built as an ARIA 1.2 combobox with `aria-activedescendant` keyboard
 * navigation over the results listbox.
 *
 * The control it draws is `MultiSelectControl`, shared with Multiple Select
 * (task #157). The two components are the same field; the only thing that
 * chooses between them is who supplies the options — a local collection there,
 * the `load` below here. Everything async lives in this file: the list, the
 * debounce, and the "load the next page" the control reports by scroll.
 */

/** An option: an id and a label for its chip — the same shape Multiple Select takes. */
export type AsyncMultipleSelectOption = MultiSelectOption

export interface AsyncMultipleSelectLoadParams {
  /** Current search string; empty on the initial page. */
  search: string
  /** Opaque pagination cursor returned by the previous page, if any. */
  cursor?: string
  /** Aborted automatically when a newer request supersedes this one. */
  signal: AbortSignal
}

export interface AsyncMultipleSelectLoadResult<T> {
  items: T[]
  /** Return a cursor to enable "load more" on scroll; omit when exhausted. */
  cursor?: string
}

export interface AsyncMultipleSelectProps<T extends AsyncMultipleSelectOption> {
  /** Loads a page of options for a given search string / cursor. */
  load: (params: AsyncMultipleSelectLoadParams) => Promise<AsyncMultipleSelectLoadResult<T>>
  /** Controlled selection (full option objects, so chips can render labels). */
  value?: T[]
  /** Uncontrolled initial selection. */
  defaultValue?: T[]
  onChange?: (value: T[]) => void
  placeholder?: string
  /** Debounce (ms) before a keystroke re-queries the source. */
  searchDelay?: number
  isDisabled?: boolean
  isInvalid?: 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
  id?: string
  "aria-label"?: string
  /** ids of the elements describing this control — a hint, an error message. */
  "aria-describedby"?: string
}

const toMap = <T extends AsyncMultipleSelectOption>(items: T[]) =>
  new Map(items.map((item) => [multiSelectKey(item), item]))

export function AsyncMultipleSelect<T extends AsyncMultipleSelectOption>({
  load,
  value,
  defaultValue,
  onChange,
  placeholder = "Select…",
  searchDelay = 250,
  isDisabled,
  isInvalid,
  name,
  form,
  className,
  id,
  "aria-label": ariaLabel,
  "aria-describedby": ariaDescribedBy,
}: AsyncMultipleSelectProps<T>) {
  const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
  const [search, setSearch] = useState("")

  const list = useAsyncList<T>({
    async load({ signal, cursor, filterText }) {
      const result = await load({ search: filterText ?? "", cursor, signal })
      return { items: result.items, cursor: result.cursor }
    },
  })

  // Selection is tracked as a map of full option objects so chips stay labelled
  // even when the option scrolls out of the currently loaded page.
  const isControlled = value !== undefined
  const [internal, setInternal] = useState<Map<string, T>>(() => toMap(defaultValue ?? []))
  const selected = isControlled ? toMap(value ?? []) : internal
  const selectedItems = useMemo(() => Array.from(selected.values()), [selected])

  const commit = (next: Map<string, T>) => {
    if (!isControlled) setInternal(next)
    onChange?.(Array.from(next.values()))
  }

  const runSearch = (text: string) => {
    setSearch(text)
    if (debounceRef.current) clearTimeout(debounceRef.current)
    debounceRef.current = setTimeout(() => list.setFilterText(text), searchDelay)
  }

  const toggle = (item: T) => {
    const key = multiSelectKey(item)
    const next = new Map(selected)
    if (next.has(key)) next.delete(key)
    else next.set(key, item)
    commit(next)
  }

  const remove = (item: T) => {
    const key = multiSelectKey(item)
    if (!selected.has(key)) return
    const next = new Map(selected)
    next.delete(key)
    commit(next)
  }

  return (
    <MultiSelectControl<T>
      options={list.items}
      selected={selectedItems}
      onToggle={toggle}
      onRemove={remove}
      search={search}
      onSearchChange={runSearch}
      isLoading={list.loadingState === "loading" || list.loadingState === "filtering"}
      isLoadingMore={list.loadingState === "loadingMore"}
      onLoadMore={() => {
        if (list.loadingState === "idle") list.loadMore()
      }}
      placeholder={placeholder}
      isDisabled={isDisabled}
      isInvalid={isInvalid}
      name={name}
      form={form}
      className={className}
      id={id}
      aria-label={ariaLabel}
      aria-describedby={ariaDescribedBy}
    />
  )
}