Select
Summary
A searchable single/multi select built over a native
<select>. The native element stays in the DOM
(hidden) and is kept in sync, so form submission and existing
change listeners keep working — you can enhance a
server-rendered form without touching its backend. Multi mode
renders removable pills with inline search. The generated
control inherits its accessible name from the native select's
label automatically.
Not to be confused with .soma-select, the CSS-only
chrome for a plain native select documented on the
Forms page. Use that when you don't need
search; use this when the option list is long.
When to use
| Mode | Use it for |
|---|---|
| Single | More than ~10 options, where typing beats scrolling: assignees, regions, projects. |
Single + allowClear | Optional fields, so the user can return to "nothing selected". |
| Multi | Tag-like selections: labels, recipients, columns. Backspace removes the last pill. |
Plain .soma-select | Short, fixed lists (under ~10) — no JS needed at all. |
Examples
Keyboard navigation
| Key | Action |
|---|---|
| Enter / Space / ↓ on the control | Open the dropdown (single mode; focus moves to the search field). |
| Any printable key on the control | Open and seed the search with that character — type-ahead from the closed state (single mode). |
| Typing in the search | Filter options live (case-insensitive substring). Multi mode: focusing or typing in the inline search opens the dropdown. |
| ↓ / ↑ | Move the active option; clamped at the ends, disabled options skipped. Tracked via aria-activedescendant; focus stays in the search field. |
| Enter | Select the active option. Single: close and refocus the control. Multi: toggle it, keep the dropdown open, clear the search. |
| Esc | Close (a Soma behaviour: the menu is a manual popover, so the platform doesn't handle Esc for it). Focus returns to the control / inline search instead of being dropped. |
| Backspace in an empty multi search | Remove the last pill. |
Pointer input shares the model: hovering an option re-anchors the same active highlight, clicking selects, and an outside click closes without stealing focus. The active option is kept scrolled into view.
Layers
The menu is a native popover="manual" in the
browser's top layer. It paints above every
z-index on the page and above an open modal
<dialog>, where it stays interactive (a
select2 inside a dialog just works). Manual,
deliberately: in multi mode the search input lives in the
control (outside the menu), so an auto
popover's light dismiss would close the menu on every press
into the very field that filters it. Esc and outside-click
therefore stay Soma behaviour (identical on the no-popover
fallback path); only the stacking is platform.
Accessible name
The generated control replaces a hidden select, so the page's
<label for="…"> would no longer name anything
a screen reader can reach. The component re-points the name at
the control, first match wins: an explicit
aria-label on the native element → its
aria-labelledby → the <label>
associated with its id (matched via the htmlFor
property, so ids containing selector metacharacters work),
falling back to a wrapping <label> when no
for-label matches (id or not) →
the placeholder text.
Component-rendered strings (the search fields' labels, the ×
clear button, "No matches") are localised via
Soma.i18n (keys select.searchOptions,
select.clearSelection, select.noMatches);
each pill's remove button is labelled "Remove option".
HTML
Author a plain native select; the component builds the rest:
<label for="assignee">Assignee</label>
<select id="assignee">
<option value="anna">Anna Nelson</option>
<option value="marcus">Marcus Delgado</option>
<option value="peter" disabled>Peter Collins (on leave)</option>
</select>
<script>
Soma.select2('#assignee', { placeholder: 'Choose…', allowClear: true });
</script>
The placeholder can live in the markup instead of the options
object — data-placeholder is the fallback when
opts.placeholder isn't passed:
<select id="reviewer" data-placeholder="No reviewer">
<option value=""></option>
<option value="anna">Anna Nelson</option>
</select>
An empty-valued <option value=""> represents
"nothing selected": it is never shown in the dropdown, and
selected options with an empty value don't render as a chosen
value or pill.
Multi mode is declared the native way, via the
multiple attribute; pre-selected options become
pills:
<label for="envs">Environments</label>
<select id="envs" multiple>
<option value="prod" selected>production</option>
<option value="staging">staging</option>
</select>
Or start from an empty select and supply options from JS via
data:
<select id="region"></select>
<script>
Soma.select2('#region', {
data: [
{ id: 'us-east', text: 'EU Central (Frankfurt)' },
{ id: 'us-east', text: 'US East (Virginia)' },
],
});
</script>
CSS classes
All generated by the component; listed for theming reference.
| Class | Effect |
|---|---|
.soma-select2 | The control replacing the native select (-multi for pill mode). role="combobox", aria-haspopup="listbox", aria-expanded; aria-disabled="true" while disabled. |
.soma-select2-value / -placeholder | Chosen-value text; placeholder styling when empty. |
.soma-select2-arrow / -clear | Chevron; optional × clear button (allowClear, hidden while empty). |
.soma-select2-choices / -pill / -pill-remove / -inline-input | Multi-mode pill row: pills, their × buttons, and the inline search field. |
.soma-select2-dropdown | The panel (body-appended; a popover="manual" in the top layer): -search, -options (the role="listbox" list), -option, -no-results. |
.soma-active (on an option) | The active option: keyboard cursor and hover share it. Selected options carry aria-selected="true" (rendered with a check); disabled ones aria-disabled="true". |
JavaScript
Constructor + options
| Member | Description |
|---|---|
Soma.select2(elOrSelector, opts?) | Get or create the singleton for a native <select> (throws for any other element, or when nothing matches). Single vs multi follows the element's multiple attribute. Options are read only on first creation. |
opts.placeholder | Prompt shown while nothing is selected (single: the value slot; multi: the inline search, hidden once pills exist). Falls back to the data-placeholder attribute. Also the last-resort accessible name. |
opts.allowClear | Single mode: adds an × button that clears back to "nothing selected" (.val('')). Default false. |
opts.data | [{ id, text }] — replaces the native options before the control is built. Ids are stringified. |
opts.formatResult | (item) => html for each dropdown row. item is { id, text, disabled, selected }. Default escapes item.text; your return value is raw HTML — trusted markup only. |
opts.formatSelection | Same contract, for the chosen value (single) / pill labels (multi). |
Instance methods
| Member | Description |
|---|---|
.val() | Read: the selected value as a string (single) or an array of strings (multi). |
.val(v) | Write: a value string, or for multi a scalar or array (values are stringified; options not in the list are deselected). Syncs the native select, re-renders, and fires both change events; programmatic writes are observable exactly like user picks. .val('') clears a single select. |
.open() / .close() / .toggle() | Imperative dropdown control (layer-managed: Esc and outside-click close; close returns focus to the control when it was inside the dropdown). |
.enable(bool?) | .enable(false) disables (native disabled + aria-disabled on the control); .enable() / .enable(true) re-enables. |
.destroy() | Close, remove the generated control and dropdown, un-hide the native select, drop the singleton. The element behaves as if never enhanced, and a later Soma.select2(el) re-enhances it. |
.isOpen | Boolean dropdown state, readable at any time. |
val(v), open, close,
toggle and enable return the instance,
so calls chain.
Events
| Event | Description |
|---|---|
soma-select-change | Bubbling CustomEvent on the native select; e.detail.value is the current value (string, or array in multi mode). |
change | A plain bubbling Event on the native select; existing listeners and framework bindings written for the un-enhanced form keep working. |
Both fire on every selection, deselection, pill removal, ×
clear, and programmatic .val(v).
Listen on the element (either event) or delegated at the document, since both bubble:
const sel = document.getElementById('assignee');
sel.addEventListener('soma-select-change', (e) => {
console.log('value:', e.detail.value); // 'anna' — or ['prod','dev'] in multi
});
sel.addEventListener('change', () => {
console.log('legacy listener still fires'); // reads sel.value as always
});
document.addEventListener('soma-select-change', (e) => {
console.log('any select on the page:', e.target.id, e.detail.value);
});
Read and write values (arrays in multi mode):
const envs = Soma.select2('#envs'); // a <select multiple>
envs.val(); // → ['prod']
envs.val(['prod', 'dev']); // replace the selection (fires change)
envs.val('staging'); // scalar works too → ['staging']
envs.val([]); // clear
const one = Soma.select2('#assignee');
one.val(); // → 'anna'
one.val('marcus'); // set
one.val(''); // clear (what the allowClear × does)
Drive the dropdown and disabled state imperatively — calls chain:
const sel = Soma.select2('#assignee');
sel.open(); // focus lands in the search field
sel.close().enable(false); // close, then disable
sel.enable(); // re-enable
Enhance a select rendered after page load, and tear the
enhancement down again with .destroy():
container.insertAdjacentHTML('beforeend', renderedFormRow);
const sel = Soma.select2(container.querySelector('select'));
// Later — restore the plain native select (form value is preserved):
sel.destroy();
Forms need no special handling. The native select stays in the form and stays in sync, so ordinary submission just works:
document.getElementById('deploy-form').addEventListener('submit', (e) => {
// FormData reads the hidden native select — no extra wiring
const data = new FormData(e.target);
console.log(data.getAll('envs')); // ['prod', 'staging']
});
The live examples above, exactly as this page wires them:
// Single, searchable
Soma.select2('#sel-single', { placeholder: 'Choose an assignee…' });
// allowClear + placeholder — the × returns to "nothing selected"
Soma.select2('#sel-clear', { placeholder: 'No reviewer', allowClear: true });
// Multi with pills
Soma.select2('#sel-multi', { placeholder: 'Add environments…' });
// Options from JS — the markup select is empty
Soma.select2('#sel-data', {
data: [
{ id: 'us-east', text: 'EU Central (Frankfurt)' },
{ id: 'us-west', text: 'EU West (Dublin)' },
{ id: 'us-east', text: 'US East (Virginia)' },
],
});
// Custom rendering — trusted markup only; item is {id, text, disabled, selected}
const withIcon = (item) =>
`<span class="soma-icon soma-icon-folder"></span> ${item.text}`;
Soma.select2('#sel-format', { formatResult: withIcon, formatSelection: withIcon });
// Disabled toggle — the button flips .enable(bool)
const cluster = Soma.select2('#sel-disabled');
const toggle = document.getElementById('sel-disabled-toggle');
let enabled = true;
toggle.addEventListener('click', () => {
enabled = !enabled;
cluster.enable(enabled);
toggle.textContent = enabled ? 'Disable' : 'Enable';
});
// val() get / set (arrays in multi mode)
const env = Soma.select2('#sel-val');
const valOut = document.getElementById('sel-val-out');
document.getElementById('sel-val-read').addEventListener('click', () => {
valOut.textContent = JSON.stringify(env.val());
});
document.getElementById('sel-val-set').addEventListener('click', () => {
env.val('staging');
valOut.textContent = JSON.stringify(env.val());
});
// Change events — the native select carries both
Soma.select2('#sel-events', { placeholder: 'Pick a level…' });
const eventsOut = document.getElementById('sel-events-out');
document.getElementById('sel-events').addEventListener('soma-select-change', (e) => {
eventsOut.textContent = `soma-select-change — ${JSON.stringify(e.detail.value)}`;
});