Combobox

Summary

Free-text autocomplete over a normal text <input>. The input's own value is the value — the suggestions listbox offers hints, not a constrained choice: anything typed stands, with or without a suggestion behind it. Suggestions come from a static array (the component filters) or an async function (you filter, it renders, with debounce and a stale-response guard). The listbox is a top-layer popover walked with aria-activedescendant, so focus never leaves the input.

When to use

SituationReach for
Free text where known values are likely but not required: a city, a tag, an owner field that accepts new namesCombobox
A constrained choice: the value must be one of the options, submitted by idSelect (Soma.select2 over a native <select>)
Search where you render rich results (links, snippets)Quicksearch

Examples

Static suggestions

An array source; the component filters it (case-insensitive substring) from the first typed character. Try ma; anything without a match shows the localised no-suggestions row, and free text like Wellington is a perfectly valid value:

Async source with debounce

A function source owns its filtering and may return a promise: here a simulated server with 300 ms latency, called at most every 250 ms while you type. A slow answer never overwrites a newer query's (stale responses are discarded):

min-chars + browsing

With minChars: 2 nothing suggests until two characters are typed. open() bypasses both the threshold and the debounce, wired here to the button (or press in the empty field) to browse everything:

Select event

Committing a suggestion (Enter on a highlighted row or a click) fires soma-combobox-select and a native change. Typed-only values fire neither (they are just the input's value, read whenever you need it):

Nothing selected yet.

Keyboard

KeyEffect
typingUpdates suggestions (after minChars and the debounce); below the threshold the list closes.
Opens the list when closed (suggesting for the current value); moves the highlight down, wrapping.
Moves the highlight up, wrapping (from none: to the last row).
EnterWith a highlight: commits it (consumed). Without one: closes the list and lets the form see the key — the free-text contract.
EscCloses the list (layer manager: one press closes one overlay).

ARIA & top layer

The wiring is the APG editable combobox with list autocomplete pattern: the input gets role="combobox", aria-expanded, aria-controls, aria-autocomplete="list" and autocomplete="off"; the generated role="listbox" holds role="option" rows and the highlight travels as aria-activedescendant + aria-selected; DOM focus never leaves the input. All of it is restored to the author's pre-init values by destroy().

Where the platform has the Popover API the listbox is a native popover="manual" in the top layer, so a combobox inside an open <dialog> works. Manual, not auto, deliberately: focus lives in the input, outside the popover, so auto's light dismiss would close the list on the very keystroke that filters it (the same reasoning as select2). Esc and outside-click stay Soma behaviour via the layer manager on both paths; placement is CSS anchor positioning where supported, the JS positioner elsewhere, and the list closes when the page scrolls.

HTML

A combobox is a normal labelled text input; the listbox is generated and appended to <body>:

<div class="soma-field">
  <label class="soma-field-label" for="city">City</label>
  <input class="soma-input" id="city" type="text" />
</div>

<!-- generated, body-appended:
<ul class="soma-combobox-list" role="listbox" popover="manual">
  <li class="soma-combobox-option" role="option">Seattle</li>
  …
</ul> -->

CSS classes

ClassEffect
.soma-combobox-listThe generated suggestions listbox: overlay surface, top-layer popover, max-height with scroll.
.soma-combobox-optionOne suggestion row.
.soma-combobox-option.soma-activeThe highlighted row (primary fill; aria-selected="true" travels with it).
.soma-combobox-emptyThe localised no-suggestions row (combobox.noSuggestions).

The input itself needs no combobox class; style it like any form input.

JavaScript

Constructor + options

OptionMeaning
Soma.combobox(inputOrSelector, options?)Get or create the singleton for an <input>. Throws when nothing matches or the target is not an input. Later calls return the existing instance (options are not re-read). No auto-init — the suggestions source is JavaScript by nature.
suggestionsstring[] (the component filters it, case-insensitive substring) or (query) => string[] | Promise<string[]>, which owns its filtering; whatever it resolves with is rendered. Default [].
minCharsMinimum typed characters before suggesting; below it the list closes. Default 1. Use 0 to keep suggesting while the field is empty ( and open() bypass the threshold at any setting).
debouncems between typing settling and the suggestions call. Default 200 for function sources, 0 for arrays.

Instance API

MemberEffect
.input / .listbox / .isOpenThe enhanced input, the generated listbox, and the open state.
.open()Suggest for the current value, bypassing minChars and the debounce. Chainable.
.close()Close the list and discard any in-flight suggestions response. Chainable.
.on(event, fn) / .off(event, fn)Sugar for the bubbling 'select' event. Chainable.
.destroy()Close, unbind, restore the input's pre-init attributes, remove the listbox.

Events

EventDetailWhen
soma-combobox-select{ value }A suggestion was committed (Enter or click). Bubbles from the input.
change (native, on the input)Fired alongside the select event, so existing form listeners see committed suggestions like any other edit.

A server-backed source is just a fetch — the component already debounces it and discards stale answers:

Soma.combobox('#owner', {
  suggestions: async (query) => {
    const res = await fetch(`/api/users?q=${encodeURIComponent(query)}`);
    return (await res.json()).map((u) => u.name);
  },
  minChars: 2,      // don't hit the API for single letters
  debounce: 250,
});

The live examples above, exactly as this page wires them:

// Static suggestions
const CITIES = ['Madison', 'Memphis', 'Mesa', 'Miami', 'Milwaukee',
  'Minneapolis', 'Montreal', 'Nashville', 'Ottawa', 'Phoenix', 'Portland', 'Plano'];
Soma.combobox('#cb-city', { suggestions: CITIES });

// Async source with debounce — a simulated 300 ms server
const PACKAGES = ['@nware/soma', '@nware/soma-charts', '@nsys/neura',
  'vite', 'vitest', 'sass', 'playwright', 'highlight.js'];
Soma.combobox('#cb-pkg', {
  suggestions: (query) => new Promise((resolve) => {
    setTimeout(() => {
      resolve(PACKAGES.filter((p) => p.includes(query.toLowerCase())));
    }, 300);
  }),
  debounce: 250,
});

// min-chars + imperative browsing
const env = Soma.combobox('#cb-env', {
  suggestions: ['development', 'staging', 'production', 'preview'],
  minChars: 2,
});
document.getElementById('cb-env-browse')
  .addEventListener('click', () => env.open());

// Select event
const log = document.getElementById('cb-lang-log');
Soma.combobox('#cb-lang', {
  suggestions: ['Czech', 'English', 'French', 'German', 'Japanese'],
}).on('select', (e) => {
  log.textContent = `Selected: ${e.detail.value}`;
});