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
| Situation | Reach for |
|---|---|
| Free text where known values are likely but not required: a city, a tag, an owner field that accepts new names | Combobox |
| A constrained choice: the value must be one of the options, submitted by id | Select (Soma.select2 over a native <select>) |
| Search where you render rich results (links, snippets) | Quicksearch |
Examples
Keyboard
| Key | Effect |
|---|---|
| typing | Updates 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). |
| Enter | With a highlight: commits it (consumed). Without one: closes the list and lets the form see the key — the free-text contract. |
| Esc | Closes 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
| Class | Effect |
|---|---|
.soma-combobox-list | The generated suggestions listbox: overlay surface, top-layer popover, max-height with scroll. |
.soma-combobox-option | One suggestion row. |
.soma-combobox-option.soma-active | The highlighted row (primary fill; aria-selected="true" travels with it). |
.soma-combobox-empty | The localised no-suggestions row (combobox.noSuggestions). |
The input itself needs no combobox class; style it like any form input.
JavaScript
Constructor + options
| Option | Meaning |
|---|---|
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. |
suggestions | string[] (the component filters it, case-insensitive substring) or (query) => string[] | Promise<string[]>, which owns its filtering; whatever it resolves with is rendered. Default []. |
minChars | Minimum 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). |
debounce | ms between typing settling and the suggestions call. Default 200 for function sources, 0 for arrays. |
Instance API
| Member | Effect |
|---|---|
.input / .listbox / .isOpen | The 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
| Event | Detail | When |
|---|---|---|
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}`;
});