Multi Select
stimeo--multi-select
An input that filters choices and takes several of them, shown as chips you can remove.
Filters choices as you type and lets you take several. Focus stays in the field while screen readers are told which choice is current. The arrow keys wrap around and Home and End jump to the ends. Enter selects and deselects while the list stays open. What you took becomes a row of chips you can remove. That row is a single Tab stop, moved through with the left and right keys, and Delete removes one. Backspace in an empty field removes the last chip. Every change to what you typed is reported, so the choices can be fetched from your server instead.
- Apple
- Banana
- Cherry
- Grape
- Orange
- Melon
No fruits match
Keyboard
| Key | Action |
|---|---|
| Type | Filter options by substring and open the list. |
| ↓ / ↑ | Move the active option (wrapping). |
| Home / End | Move to the first / last visible option. |
| Enter | Toggle the active option (the list stays open). |
| Backspace (empty input) | Remove the last selected chip while retaining input focus. |
| Esc | Close the list. |
<%# Markup for the multi-select (multiple-selection combobox) demo.
Filter candidates as you type, select several, and show the selected ones as chips.
Focus stays on the input; the active candidate is shown via aria-activedescendant
and selection via aria-selected. The library handles filtering, multi-select
toggling, chip create/remove, roving, dismiss, the no-match message's hidden, and the
selection announcements read by the shared stimeo--announcer your app seats once, in
its layout (selecting and removing a value are the transitions worth reading; the
multi-select owns no live region of its own). Placement lives in demo.css. %>
<% announce_selected = t("components.multi_select.demo.announce_selected") %>
<% announce_removed = t("components.multi_select.demo.announce_removed") %>
<% remove_label = t("components.multi_select.demo.remove_label") %>
<div
class="multi-select"
data-controller="stimeo--multi-select"
data-stimeo--multi-select-announce-text-value="<%= announce_selected %>"
data-stimeo--multi-select-announce-removed-text-value="<%= announce_removed %>">
<%# The container the selected values are generated into as hidden inputs;
it is optional. %>
<div data-stimeo--multi-select-target="fields"></div>
<span id="multi-select-label" class="multi-select__label">
<%= t("components.multi_select.demo.label") %>
</span>
<ul
class="multi-select__tags"
aria-label="<%= t("components.multi_select.demo.selected_label") %>"
data-stimeo--multi-select-target="tags"></ul>
<input
type="text"
class="multi-select__input"
role="combobox"
aria-expanded="false"
aria-autocomplete="list"
aria-controls="multi-select-list"
aria-labelledby="multi-select-label"
placeholder="<%= t("components.multi_select.demo.placeholder") %>"
data-stimeo--multi-select-target="input"
data-action="input->stimeo--multi-select#filter
keydown->stimeo--multi-select#onKeydown
focus->stimeo--multi-select#open" />
<ul
id="multi-select-list"
class="multi-select__list"
role="listbox"
aria-multiselectable="true"
aria-label="<%= t("components.multi_select.demo.options_label") %>"
hidden
data-stimeo--multi-select-target="list">
<% %w[apple banana cherry grape orange melon].each_with_index do |fruit, index| %>
<li
id="multi-select-opt-<%= index %>"
class="multi-select__option"
role="option"
aria-selected="false"
data-value="<%= fruit %>"
data-stimeo--multi-select-target="option"
data-action="click->stimeo--multi-select#toggleOption">
<%= t("components.multi_select.demo.options.#{fruit}") %>
</li>
<% end %>
</ul>
<%# The no-match message. It is a sibling of the listbox, whose children are all
options, and the library owns its hidden: it stands only while the open list
has nothing left to offer. %>
<p class="multi-select__empty" data-stimeo--multi-select-target="empty" hidden>
<%= t("components.multi_select.demo.empty") %>
</p>
<template data-stimeo--multi-select-target="tagTemplate">
<li class="multi-select__tag" data-stimeo--multi-select-target="tag">
<span data-stimeo--multi-select-target="label"></span>
<%# Removal is handled by a delegated listener on the tags container, so it
works instantly without waiting on Stimulus wiring a data-action onto the
dynamically-added button. %>
<button
type="button"
class="multi-select__remove"
tabindex="-1"
aria-label="<%= remove_label %>"
data-stimeo--multi-select-target="remove">×</button>
</li>
</template>
</div>
<%# Readout: what the component just reported. Purely visual — the component
already announces what a screen reader needs, so this line is aria-hidden to
keep it from being read twice. The value is a code fragment (API identifiers
and values), so only the label is translated. %>
<p class="demo-readout" aria-hidden="true">
<span><%= t("catalog.readout.field") %></span>
<span class="demo-readout__value" data-readout
data-empty-text="<%= t("catalog.readout.empty") %>">—</span>
</p>
/*
* Presentation-only styles for the multi-select demo.
* The library toggles the list's hidden, the no-match message's hidden, options'
* aria-selected, and the active candidate's data-active. Creating/removing chips and
* roving are the library's job too. The list's static placement (directly below the
* input) is the consumer's CSS; dynamic flip is via stimeo-ui/positioning.
*/
.multi-select {
position: relative;
display: inline-flex;
flex-direction: column;
gap: 0.35rem;
min-width: 18rem;
}
.multi-select__label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--fg, var(--color-text));
}
.multi-select__tags {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin: 0;
padding: 0;
list-style: none;
}
.multi-select__tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: var(--vital-100);
color: var(--vital-800);
font-size: 0.8125rem;
}
.multi-select__remove {
border: 0;
background: transparent;
color: inherit;
font: inherit;
line-height: 1;
cursor: pointer;
}
.multi-select__remove:focus-visible {
outline: 2px solid var(--accent, var(--color-primary));
outline-offset: 2px;
border-radius: 50%;
}
.multi-select__input {
padding: 0.5rem 0.65rem;
border: 1px solid var(--border-strong);
border-radius: 0.375rem;
background: var(--surface, var(--surface-card));
color: var(--fg, var(--color-text));
font: inherit;
}
.multi-select__input[aria-expanded="true"] {
border-color: var(--accent, var(--color-primary));
}
.multi-select__list {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 10;
max-height: 14rem;
overflow-y: auto;
margin: 0.25rem 0 0;
padding: 0.25rem;
list-style: none;
background: var(--surface, var(--surface-card));
border: 1px solid var(--border-strong);
border-radius: 0.375rem;
box-shadow: 0 8px 24px rgb(15 23 42 / 0.12);
}
.multi-select__option {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.45rem 0.6rem;
border-radius: 0.25rem;
cursor: pointer;
}
.multi-select__option[hidden] {
display: none;
}
/* A theme-aware soft accent surface, not the raw `--vital-100`: this rule sets a
background but lets the text inherit `--color-text`, which flips with the theme.
A fixed light mint therefore paired light text on light in dark mode — measured
1.02:1, i.e. the row the keyboard is on was effectively invisible. */
.multi-select__option[data-active] {
background: var(--color-primary-soft);
}
/* `--accent-800`, one step stronger than the plain accent text token: this text can
land on the tinted active/selected surface as well as the card, and on the tinted
one in dark `--accent-700` measures 4.44:1 — just under the floor. */
.multi-select__option[aria-selected="true"]::after {
content: "✓";
color: var(--accent-800);
}
/* The no-match message. It is a sibling of the list, not a child: the list is the
`role="listbox"`, so only options belong inside it. It therefore takes the popup's
own place, over the frame the emptied list leaves behind. The library owns its
`hidden`, and the wording is a translated string in the ERB. */
.multi-select__empty {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 11;
margin: 0.25rem 0 0;
padding: 0.45rem 0.6rem;
color: var(--color-text-subtle);
background: var(--surface, var(--surface-card));
border: 1px solid var(--border-strong);
border-radius: 0.375rem;
box-shadow: 0 8px 24px rgb(15 23 42 / 0.12);
}
// The hidden field mirrors the value, and for a move the user made it emits the
// native `change` a form already listens to — so `stimeo--auto-submit`, form
// validation, or any `change` handler on the `<form>` reacts without knowing what
// this component is. A refresh that follows a morph or a repair is silent, and
// the component reports a value it moved itself — a repair — as
// `stimeo--multi-select:reconcile`. This demo reads both events, so the
// readout follows the user's moves and the component's repairs.
// The page chrome uses some of these controllers too (the nav, the tab strip),
// so everything below is scoped to the demo itself.
const demoStage = document.querySelector(".demo-stage") ?? document;
const fieldReadout = demoStage.querySelector("[data-readout]");
const fieldSources = demoStage.querySelectorAll("[data-stimeo--multi-select-target='fields']");
// An input without a `name` is not a submitted control, so it is left out.
const submitted = () =>
Array.from(demoStage.querySelectorAll("[data-stimeo--multi-select-target='fields'] input"))
.filter((input) => input.name)
.map((input) => `${input.name}=${input.value}`)
.join(" & ") || fieldReadout?.dataset.emptyText || "";
const showSubmitted = () => {
if (fieldReadout) fieldReadout.textContent = submitted();
};
for (const source of fieldSources) source.addEventListener("change", showSubmitted);
demoStage.addEventListener("stimeo--multi-select:reconcile", showSubmitted);
showSubmitted();
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--multi-select"
Targets
| Name | Description | Attribute |
|---|---|---|
input
required
|
The combobox text input driving filtering and tracking the active option via aria-activedescendant. |
data-stimeo--multi-select-target="input" |
list
required
|
The role=listbox (aria-multiselectable) popup, toggled via hidden. |
data-stimeo--multi-select-target="list" |
option
|
A role=option; filtered via hidden and toggled selected via aria-selected. |
data-stimeo--multi-select-target="option" |
tags
required
|
Container holding the selected-option chips, navigated as one roving Tab stop. | data-stimeo--multi-select-target="tags" |
tag
|
A removable chip mirroring one selected option. | data-stimeo--multi-select-target="tag" |
tagTemplate
|
<template> cloned to build each chip (label and remove button). |
data-stimeo--multi-select-target="tagTemplate" |
label
|
The template element that receives the selected option's display label. | data-stimeo--multi-select-target="label" |
remove
|
The remove button; its authored localized aria-label expands {label} and {value}. |
data-stimeo--multi-select-target="remove" |
fields
|
Optional container where selected values are mirrored as named hidden inputs. | data-stimeo--multi-select-target="fields" |
empty
|
A region shown only while the open list has no visible option; the controller owns its hidden. Place it outside the list target, whose children are options. |
data-stimeo--multi-select-target="empty" |
Values
| Name | Description | Attribute |
|---|---|---|
max
|
Permanent selection cap; non-positive values are unlimited and positive fractions are floored. | data-stimeo--multi-select-max-value |
name
|
Name for generated hidden inputs (default options[]). |
data-stimeo--multi-select-name-value |
form
|
Optional form id applied to generated hidden inputs. | data-stimeo--multi-select-form-value |
announceText
|
Localized selection template for the shared announcer; supports {label}, {value}, and {count}. |
data-stimeo--multi-select-announce-text-value |
announceRemovedText
|
Localized removal template for the shared announcer; supports the same placeholders. | data-stimeo--multi-select-announce-removed-text-value |
Actions
| Name | Description | Action |
|---|---|---|
close
|
Closes the list and clears the active option. | stimeo--multi-select#close |
filter
|
Filters options by the input substring, opens the list, re-seeds the active option, and dispatches filter. |
stimeo--multi-select#filter |
onKeydown
|
Handles input keyboard interaction (ArrowUp/Down wrapping, Home/End, Enter to toggle, Escape, Backspace/ArrowLeft into chips, Tab). | stimeo--multi-select#onKeydown |
open
|
Opens the list and activates the first visible option when none is active. | stimeo--multi-select#open |
toggleOption
|
Toggles the clicked option's selection, adding or removing its chip. | stimeo--multi-select#toggleOption |
Events
| Name | Description | Event |
|---|---|---|
change
|
Fires once when the reader adds or removes a selection; detail { values, reason } (option order). detail.reason reports user / focus / pointer / api. |
stimeo--multi-select:change |
filter
|
Fires on filtering; detail { query } (the typed substring, for async candidates). |
stimeo--multi-select:filter |
reconcile
|
Fires when option churn or a lowered max moves the selection; { values } without reason. |
stimeo--multi-select:reconcile |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
aria-expanded |
Input | Open/closed state of the listbox. |
aria-activedescendant |
Input | The id of the active option (removed when none). |
aria-selected |
Option | "true" on selected options, "false" on the rest. Render the initial selection server-side and the chips and submitted values are derived from it. |
data-active |
Option | Present on the active option (a CSS highlight hook). |
data-stimeo--multi-select-empty |
Controller element | Present when the filter matches no options. |