Forms

Async Select

A single-value combobox whose options are loaded from a remote source: an inline search input opens the dropdown on focus, typing re-queries the source live, and scrolling loads more. Picking an option shows its label and closes the menu; a trailing ✕ clears it. Styled with the quebi dark surface and brand-teal selection.

selectsingle-selectasynccomboboxforminputsearchpaginationinteractive

Default

Focus the control to open the list; the first page loads from the source. Type to re-query live and scroll to load more.

With label & description

Pair the control with field primitives and seed an initial selection.

Options are fetched on demand as you search.

Controlled

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

Selected: Alan Lovelace #2

Disabled

The whole control can be disabled.

Source

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

"use client"

import { Check, ChevronDown, Loader2, X } from "lucide-react"
import { useEffect, useId, useRef, useState } from "react"
import { useAsyncList } from "react-stately"
import { PopoverContent } from "@/components/popover"
import { cn } from "@/lib/utils"

/**
 * Async Select — quebi design system
 *
 * A single-value combobox whose options are loaded from a remote source. 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. Picking an option commits it, shows its label in the input,
 * and closes the menu; a trailing ✕ clears the selection. Built as an ARIA 1.2
 * combobox with `aria-activedescendant` keyboard navigation over the results
 * listbox.
 */

export interface AsyncSelectOption {
  id: string | number
  name: string
}

export interface AsyncSelectLoadParams {
  /** 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 AsyncSelectLoadResult<T> {
  items: T[]
  /** Return a cursor to enable "load more" on scroll; omit when exhausted. */
  cursor?: string
}

export interface AsyncSelectProps<T extends AsyncSelectOption> {
  /** Loads a page of options for a given search string / cursor. */
  load: (params: AsyncSelectLoadParams) => Promise<AsyncSelectLoadResult<T>>
  /** Controlled selection (a full option object, so the input can show its label). */
  value?: T | null
  /** Uncontrolled initial selection. */
  defaultValue?: T | null
  onChange?: (value: T | null) => 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 a hidden input for form submission. */
  name?: string
  className?: string
  id?: string
  "aria-label"?: string
}

const keyOf = (option: AsyncSelectOption) => String(option.id)

export function AsyncSelect<T extends AsyncSelectOption>({
  load,
  value,
  defaultValue,
  onChange,
  placeholder = "Select…",
  searchDelay = 250,
  isDisabled,
  isInvalid,
  name,
  className,
  id,
  "aria-label": ariaLabel = "Select an item",
}: AsyncSelectProps<T>) {
  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 debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)

  const [open, setOpen] = useState(false)
  const [activeKey, setActiveKey] = useState<string | null>(null)

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

  const isControlled = value !== undefined
  const [internal, setInternal] = useState<T | null>(defaultValue ?? null)
  const selected = isControlled ? (value ?? null) : internal
  const selectedName = selected?.name ?? ""

  // The input shows the committed selection's label while closed, and free
  // search text while open.
  const [inputValue, setInputValue] = useState<string>(() => (value ?? defaultValue)?.name ?? "")

  // Whenever the menu is closed, the input reflects the committed selection —
  // this reverts any abandoned search text and tracks controlled changes.
  useEffect(() => {
    if (!open) setInputValue(selectedName)
  }, [open, selectedName])

  const openMenu = () => {
    if (!isDisabled) setOpen(true)
  }

  const handleOpenChange = (next: boolean) => {
    setOpen(next)
    if (!next) setActiveKey(null)
  }

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

  const commit = (next: T | null) => {
    if (!isControlled) setInternal(next)
    onChange?.(next)
  }

  const selectKey = (key: string) => {
    const item = list.items.find((i) => keyOf(i) === key)
    if (!item) return
    commit(item)
    setActiveKey(null)
    setOpen(false) // the close effect sets inputValue to the new label
  }

  const clearSelection = () => {
    commit(null)
    setInputValue("")
    if (debounceRef.current) clearTimeout(debounceRef.current)
    list.setFilterText("")
    setActiveKey(null)
    inputRef.current?.focus()
    setOpen(true)
  }

  // Keep the keyboard-highlighted option valid as the list filters/paginates.
  useEffect(() => {
    if (!open) return
    if (list.items.length === 0) {
      setActiveKey(null)
      return
    }
    setActiveKey((prev) =>
      prev && list.items.some((i) => keyOf(i) === prev) ? prev : keyOf(list.items[0]),
    )
  }, [open, list.items])

  // 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) => {
    const items = list.items
    if (items.length === 0) return
    const idx = activeKey ? items.findIndex((i) => keyOf(i) === activeKey) : -1
    const nextIdx = idx === -1 ? (dir === 1 ? 0 : items.length - 1) : (idx + dir + items.length) % items.length
    setActiveKey(keyOf(items[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) {
          e.preventDefault()
          selectKey(activeKey)
        }
        break
      case "Escape":
        if (open) {
          e.preventDefault()
          handleOpenChange(false)
        }
        break
    }
  }

  const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
    const el = e.currentTarget
    const distanceToBottom = el.scrollHeight - el.scrollTop - el.clientHeight
    if (distanceToBottom < 60 && list.items.length > 0 && list.loadingState === "idle") {
      list.loadMore()
    }
  }

  const isLoading = list.loadingState === "loading" || list.loadingState === "filtering"

  return (
    <div className={cn("w-full", className)}>
      {/** biome-ignore lint/a11y/noStaticElementInteractions: the control surface forwards bare-surface clicks to the combobox input; all real semantics live on the input/options */}
      <div
        ref={containerRef}
        onMouseDown={(e) => {
          // Clicking the bare surface (padding/chevron) — not the ✕ or the input
          // — focuses the input without stealing it from those controls.
          if (e.target === e.currentTarget && !isDisabled) {
            e.preventDefault()
            inputRef.current?.focus()
            openMenu()
          }
        }}
        data-invalid={isInvalid || undefined}
        className={cn(
          "flex w-full items-center gap-1 rounded-quebi-sm border border-quebi-line/10 bg-quebi-surface/[0.02] p-1.5 pe-2",
          "transition-colors duration-150 focus-within:border-quebi-brand",
          isInvalid && "border-red-500",
          isDisabled ? "cursor-not-allowed opacity-50" : "cursor-text",
        )}
      >
        <input
          ref={inputRef}
          id={id}
          type="text"
          // biome-ignore lint/a11y/useSemanticElements: ARIA 1.2 combobox is authored on the text input
          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}
          disabled={isDisabled}
          value={inputValue}
          placeholder={placeholder}
          onChange={(e) => {
            runSearch(e.target.value)
            openMenu()
          }}
          onFocus={(e) => {
            e.currentTarget.select()
            openMenu()
          }}
          onClick={openMenu}
          onKeyDown={handleKeyDown}
          className={cn(
            "min-w-0 flex-1 bg-transparent px-1.5 py-0.5 text-sm text-quebi-fg outline-none",
            "placeholder:text-quebi-fg-subtle placeholder:italic",
          )}
        />

        {selected && !isDisabled && (
          // biome-ignore lint/correctness/noRestrictedElements: needs onMouseDown preventDefault to keep focus on the combobox input, and stopPropagation so the click does not reopen the listbox — react-aria's Button exposes onPress, which is neither DOM event.
          <button
            type="button"
            aria-label="Clear selection"
            tabIndex={-1}
            onMouseDown={(e) => e.preventDefault()}
            onClick={(e) => {
              e.stopPropagation()
              clearSelection()
            }}
            className={cn(
              "flex size-5 shrink-0 items-center justify-center rounded-full text-quebi-fg-subtle outline-none transition-colors duration-150",
              "hover:bg-cyan-500/10 hover:text-quebi-fg focus-visible:ring-2 focus-visible:ring-quebi-brand/50",
            )}
          >
            <X className="size-3.5" strokeWidth={2.5} aria-hidden="true" />
          </button>
        )}

        <ChevronDown
          aria-hidden="true"
          className={cn(
            "pointer-events-none size-4 shrink-0 text-quebi-fg-subtle transition-transform duration-150",
            open && "rotate-180",
          )}
        />
      </div>

      <PopoverContent
        ref={popoverRef}
        triggerRef={containerRef}
        isOpen={open && !isDisabled}
        onOpenChange={handleOpenChange}
        isNonModal
        placement="bottom start"
        className="w-(--trigger-width) p-0"
      >
        <div
          // biome-ignore lint/a11y/useFocusableInteractive: the listbox uses virtual focus via the combobox input's aria-activedescendant
          role="listbox"
          id={listboxId}
          aria-label={ariaLabel}
          onScroll={handleScroll}
          className="quebi-scrollbar max-h-72 overflow-y-auto overscroll-contain p-1"
        >
          {list.items.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…
                </>
              ) : (
                "No results"
              )}
            </div>
          ) : (
            list.items.map((item) => {
              const k = keyOf(item)
              const isSel = selected ? keyOf(selected) === k : false
              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 selects 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={() => selectKey(k)}
                  className={cn(
                    "flex cursor-pointer items-center gap-2 rounded-quebi-sm px-2.5 py-1.5 text-sm text-quebi-fg outline-none transition-colors duration-150",
                    isActive && "bg-quebi-surface/[0.05]",
                    isSel && "text-quebi-brand",
                  )}
                >
                  <Check
                    className={cn("size-4 shrink-0", isSel ? "opacity-100" : "opacity-0")}
                    aria-hidden="true"
                  />
                  <span className="flex-1 truncate">{item.name}</span>
                </div>
              )
            })
          )}
          {list.loadingState === "loadingMore" && (
            <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 && (
        <input type="hidden" name={name} value={selected ? keyOf(selected) : ""} />
      )}
    </div>
  )
}