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

ModeUse it for
SingleMore than ~10 options, where typing beats scrolling: assignees, regions, projects.
Single + allowClearOptional fields, so the user can return to "nothing selected".
MultiTag-like selections: labels, recipients, columns. Backspace removes the last pill.
Plain .soma-selectShort, fixed lists (under ~10) — no JS needed at all.

Examples

Single, searchable

Click, then type to filter. Arrows move, Enter selects, Esc closes.

allowClear + placeholder

The × button returns to "nothing selected"; the placeholder shows while empty.

Multi, with pills

Backspace in the empty search removes the last pill.

Options from JS

The data option:

The markup is an empty <select>; data: [{id, text}] supplies the options.

Custom rendering

formatResult / formatSelection:

Rows and the chosen value render with a leading folder icon.

Disabled

.enable(false):

— flips .enable(bool); the native select's disabled follows.

Read and write

.val() get / set:

Value: (not read yet)

Change events + disabled option

soma-select-change and the native change:

Last event: (none). The disabled option renders muted, is skipped by the arrows and ignores clicks.

Keyboard navigation

KeyAction
Enter / Space / on the controlOpen the dropdown (single mode; focus moves to the search field).
Any printable key on the controlOpen and seed the search with that character — type-ahead from the closed state (single mode).
Typing in the searchFilter 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.
EnterSelect the active option. Single: close and refocus the control. Multi: toggle it, keep the dropdown open, clear the search.
EscClose (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 searchRemove 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.

ClassEffect
.soma-select2The control replacing the native select (-multi for pill mode). role="combobox", aria-haspopup="listbox", aria-expanded; aria-disabled="true" while disabled.
.soma-select2-value / -placeholderChosen-value text; placeholder styling when empty.
.soma-select2-arrow / -clearChevron; optional × clear button (allowClear, hidden while empty).
.soma-select2-choices / -pill / -pill-remove / -inline-inputMulti-mode pill row: pills, their × buttons, and the inline search field.
.soma-select2-dropdownThe 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

MemberDescription
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.placeholderPrompt 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.allowClearSingle 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.formatSelectionSame contract, for the chosen value (single) / pill labels (multi).

Instance methods

MemberDescription
.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.
.isOpenBoolean dropdown state, readable at any time.

val(v), open, close, toggle and enable return the instance, so calls chain.

Events

EventDescription
soma-select-changeBubbling CustomEvent on the native select; e.detail.value is the current value (string, or array in multi mode).
changeA 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)}`;
});