Multi-Select
stimeo--multi-select
A combobox that filters options and selects several, shown as removable chips.
The stimeo--multi-select controller implements the WAI-ARIA Combobox pattern in its list-autocomplete, multi-select form. Typing filters options by substring and opens the list; focus stays on the input while the active option is tracked with aria-activedescendant. ArrowUp/ArrowDown (wrapping) and Home/End move the active option, Enter toggles it (the list stays open), and Escape/Tab/outside click close. Toggling syncs aria-selected, adds or removes a Remove {label} chip, mirrors the live region, and dispatches stimeo--multi-select:change with values; filtering dispatches stimeo--multi-select:filter for async candidates. The chips are one roving Tab stop (ArrowLeft/ArrowRight, Delete/Backspace; Backspace on an empty input removes the last). max caps the selection. For single selection use Listbox or Combobox; for free-text tags use Tags Input.
- Apple
- Banana
- Cherry
- Grape
- Orange
- Melon
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. |
| 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, live announcements, and dismiss. Placement
lives in demo.css. %>
<div class="multi-select" data-controller="stimeo--multi-select">
<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>
<span
role="status"
aria-live="polite"
class="multi-select__status visually-hidden"
data-stimeo--multi-select-target="status"></span>
<template data-stimeo--multi-select-target="tagTemplate">
<li class="multi-select__tag" data-stimeo--multi-select-target="tag">
<span data-multi-select-slot="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">×</button>
</li>
</template>
</div>
/*
* Presentation-only styles for the multi-select demo.
* The library toggles the list'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;
}
.multi-select__option[data-active] {
background: var(--vital-100);
}
.multi-select__option[aria-selected="true"]::after {
content: "✓";
color: var(--accent, var(--color-primary));
}
.multi-select[data-stimeo--multi-select-empty] .multi-select__list::after {
content: "—";
display: block;
padding: 0.45rem 0.6rem;
color: var(--color-text-subtle);
}
This demo needs no consumer-side JS (the controller handles the behavior).
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" |
status
|
Live region announcing selection/removal of options. | data-stimeo--multi-select-target="status" |
Values
| Name | Description | Attribute |
|---|---|---|
max
|
Maximum number of selectable options (0 = unlimited; default 0). | data-stimeo--multi-select-max-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 when the selection changes; detail { values } (selected values in option order). |
stimeo--multi-select:change |
filter
|
Fires on filtering; detail { query } (the typed substring, for async candidates). |
stimeo--multi-select:filter |
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. |
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. |