複数選択
stimeo--multi-select
候補を絞り込みながら複数選ぶ入力。選んだものは消せるタグになります。
打ちながら候補を絞り込み、複数を選べます。フォーカスは入力欄に残ったまま、いまどの候補を指しているかが読み上げに伝わります。上下キーで巡回し、Home と End で両端へ動きます。Enter で選び外し、そのあいだも一覧は開いたままです。選んだものは消せるタグになって並びます。タグの列は Tab で 1 回止まるだけで、その中は左右キーで移動し、Delete で消せます。入力欄が空のときに Backspace を押すと、最後のタグが消えます。打った内容が変わるたびにイベントが出るので、候補をサーバから取り直す作りにもできます。
- りんご
- バナナ
- さくらんぼ
- ぶどう
- オレンジ
- メロン
キーボード操作
| キー | 動作 |
|---|---|
| 入力 | 候補を部分一致で絞り込み、リストを開く。 |
| ↓ / ↑ | アクティブ候補を移動(ループ)。 |
| Home / End | 先頭 / 末尾の可視候補へ。 |
| Enter | アクティブ候補をトグル(リストは開いたまま)。 |
| Backspace(空入力) | 入力フォーカスを保って直前の選択チップを削除。 |
| Esc | リストを閉じる。 |
<%# 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, 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 %>">
<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>
<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>
/*
* 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;
}
/* 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);
}
.multi-select[data-stimeo--multi-select-empty] .multi-select__list::after {
content: "—";
display: block;
padding: 0.45rem 0.6rem;
color: var(--color-text-subtle);
}
このデモに固有の消費側 JS はありません(挙動はコントローラが担います)。
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--multi-select"
ターゲット
| 名前 | 説明 | 属性 |
|---|---|---|
input
必須
|
フィルタを駆動し aria-activedescendant でアクティブ選択肢を追跡するコンボボックスのテキスト input。 |
data-stimeo--multi-select-target="input" |
list
必須
|
hidden で開閉する role=listbox(aria-multiselectable)のポップアップ。 |
data-stimeo--multi-select-target="list" |
option
|
role=option。hidden でフィルタされ aria-selected で選択を切り替える。 |
data-stimeo--multi-select-target="option" |
tags
必須
|
選択済みチップを保持するコンテナ。1 つの roving Tab 停止位置として操作する。 | data-stimeo--multi-select-target="tags" |
tag
|
選択中の選択肢 1 件を表す削除可能なチップ。 | data-stimeo--multi-select-target="tag" |
tagTemplate
|
各チップ(ラベルと削除ボタン)を生成する複製元の <template>。 |
data-stimeo--multi-select-target="tagTemplate" |
label
|
選択肢の表示ラベルを書き込む template 内の要素。 | data-stimeo--multi-select-target="label" |
remove
|
削除ボタン。利用側が記述した aria-label の {label} / {value} を展開する。 |
data-stimeo--multi-select-target="remove" |
fields
|
選択値を name 付き hidden input として同期する任意コンテナ。 | data-stimeo--multi-select-target="fields" |
値(Values)
| 名前 | 説明 | 属性 |
|---|---|---|
max
|
常時適用する選択上限。0 以下は無制限、正の小数は切り捨て。 | data-stimeo--multi-select-max-value |
name
|
生成する hidden input の name(既定 options[])。 |
data-stimeo--multi-select-name-value |
form
|
生成する hidden input に付与する任意の form id。 | data-stimeo--multi-select-form-value |
announceText
|
選択時に共有 Announcer へ送る文言。{label} / {value} / {count} を展開。 |
data-stimeo--multi-select-announce-text-value |
announceRemovedText
|
解除時に共有 Announcer へ送る文言。同じプレースホルダーを展開。 | data-stimeo--multi-select-announce-removed-text-value |
アクション
| 名前 | 説明 | アクション |
|---|---|---|
close
|
リストを閉じ、アクティブ選択肢を解除する。 | stimeo--multi-select#close |
filter
|
input の部分一致で選択肢を絞り込み、リストを開き、アクティブ選択肢を再設定して filter を発火する。 |
stimeo--multi-select#filter |
onKeydown
|
input のキーボード操作(上下矢印の循環、Home/End、Enter で切替、Escape、Backspace/ArrowLeft でチップへ、Tab)を処理する。 | stimeo--multi-select#onKeydown |
open
|
リストを開き、アクティブが無ければ先頭の可視選択肢をアクティブにする。 | stimeo--multi-select#open |
toggleOption
|
クリックされた選択肢の選択を切り替え、チップを追加/削除する。 | stimeo--multi-select#toggleOption |
イベント
| 名前 | 説明 | イベント |
|---|---|---|
change
|
利用者が選択を追加・解除したとき 1 回発火。detail は { values }(選択肢順)。 |
stimeo--multi-select:change |
filter
|
フィルタ時に発火。detail は { query }(入力された部分文字列。非同期候補取得向け)。 |
stimeo--multi-select:filter |
reconcile
|
option の増減や max 引き下げで選択集合が変わったときに発火。detail は change と同じ形。 | stimeo--multi-select:reconcile |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
aria-expanded |
入力 | リストの開閉状態。 |
aria-activedescendant |
入力 | アクティブ候補の id(無いときは除去)。 |
aria-selected |
候補 | 選択中の候補に "true"、ほかには "false"。サーバー側で初期選択を書いておくと、タグと送信用の値もそこから作られる。 |
data-active |
候補 | アクティブ候補に付与(CSS ハイライト用)。 |
data-stimeo--multi-select-empty |
コントローラ要素 | フィルタが 0 件のとき付与。 |