単一リストの並べ替え
stimeo--sortable
並べ替えを、キーボードとスクリーンリーダーでも同じようにできるようにします。
一覧の並べ替えを、マウスでもキーボードでも同じようにできるようにします。ポインタでは、隣の項目の真ん中を越えるたびにその場で入れ替わるので、どこに落ちるかが常に見えています。キーボードでは Space でつかみ、矢印キーで 1 つずつ動かし、もう一度 Space で置きます。Esc を押せば元の位置へ戻ります。どの操作をしたかは読み上げにも届きます。「カード A をつかみました。5 件中 2 番目です」のように、文言は言語ごとに用意できます。ハンドルの列は Tab で 1 回止まるだけです。並び順が実際に変わったときだけ、どこからどこへ移ったかがイベントで分かります。
ハンドルをドラッグするか、キーボードで操作します。Tab でハンドルへ移り、ハンドルの間は矢印キーで動きます。Space でつかみ、上下キーで 1 つずつ動かし、もう一度 Space で置きます。Esc で取り消せます。
- リリースノートを書く
- ログインのリダイレクトを直す
- デザイン案をレビューする
- 依存パッケージを更新する
キーボード操作
| キー | 動作 |
|---|---|
| Space / Enter | フォーカス中ハンドルのアイテムをグラブ、またはドロップ(位置が変わっていれば reorder を発火)。 |
| ↑ / ↓ | グラブ中、アイテムを 1 ポジション移動(縦向き。端でクランプ)。 |
| Esc | キャンセル — アイテムは拾い上げた位置に戻る。 |
<%# sortable: markup-level composition — pointer-drag on each item emits the drag
signal (with its keyboard alternative), roving keeps the handles one Tab stop,
and sortable interprets the signal: it live-reorders the DOM, hands each step
to the page's shared announcer (wording from the announce-* values), and
dispatches reorder on a drop that changed the position. demo.js mirrors that
reorder event into the "saved" line — the pattern a real app uses to POST the
new position — and echoes what the announcer heard into a muted line. %>
<div class="sortable-demo">
<p class="sortable-demo__hint"><%= t("components.sortable.demo.hint") %></p>
<% announce = t("components.sortable.demo.announce") %>
<div data-controller="stimeo--sortable"
data-stimeo--sortable-announce-grabbed-text-value="<%= announce[:grabbed] %>"
data-stimeo--sortable-announce-moved-text-value="<%= announce[:moved] %>"
data-stimeo--sortable-announce-dropped-text-value="<%= announce[:dropped] %>"
data-stimeo--sortable-announce-canceled-text-value="<%= announce[:canceled] %>">
<ul class="sortable-demo__list" data-stimeo--sortable-target="list"
data-controller="stimeo--roving"
data-stimeo--roving-orientation-value="vertical"
aria-label="<%= t('components.sortable.demo.list_label') %>">
<% t("components.sortable.demo.items").each do |label| %>
<li class="sortable-demo__item" data-stimeo--sortable-target="item"
data-stimeo--sortable-name="<%= label %>"
data-controller="stimeo--pointer-drag"
data-stimeo--pointer-drag-axis-value="y">
<button type="button" class="sortable-demo__handle"
aria-label="<%= t('components.sortable.demo.handle_label', name: label) %>"
data-stimeo--pointer-drag-target="handle"
data-stimeo--roving-target="item">
<svg class="demo-icon" viewBox="0 0 24 24" fill="currentColor" stroke="none"
aria-hidden="true">
<circle cx="9" cy="6" r="1.5"></circle><circle cx="15" cy="6" r="1.5"></circle>
<circle cx="9" cy="12" r="1.5"></circle><circle cx="15" cy="12" r="1.5"></circle>
<circle cx="9" cy="18" r="1.5"></circle><circle cx="15" cy="18" r="1.5"></circle>
</svg>
</button>
<span class="sortable-demo__label"><%= label %></span>
</li>
<% end %>
</ul>
</div>
<p class="sortable-demo__status" data-sortable-heard aria-hidden="true"></p>
<p class="sortable-demo__saved" data-sortable-saved aria-live="polite"
data-saved-template="<%= t("components.sortable.demo.saved_template") %>"></p>
</div>
/*
* Presentation-only styles for the sortable demo. The library moves the real DOM
* nodes and flips the data hooks; this CSS lays the rows out and makes the two
* transient states visible — data-dragging / data-grabbed on the item (from
* pointer-drag) highlight the row being moved.
*/
.sortable-demo {
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: flex-start;
}
.sortable-demo__hint {
margin: 0;
color: var(--muted);
}
.sortable-demo__list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
min-width: 16rem;
}
.sortable-demo__item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background: var(--bg);
}
/* The row in flight: pointer drag (data-dragging) or keyboard grab (data-grabbed). */
.sortable-demo__item[data-dragging],
.sortable-demo__item[data-grabbed] {
border-color: var(--accent);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.sortable-demo__handle {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
border: 1px solid transparent;
border-radius: 0.25rem;
background: transparent;
color: var(--muted);
cursor: grab;
}
.sortable-demo__handle:hover {
color: var(--accent-700);
}
.sortable-demo__handle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.sortable-demo__item[data-dragging] .sortable-demo__handle {
cursor: grabbing;
}
.sortable-demo__handle .demo-icon {
width: 1rem;
height: 1rem;
}
.sortable-demo__status,
.sortable-demo__saved {
margin: 0;
min-height: 1.25rem;
font-size: 0.9rem;
color: var(--muted);
}
// sortable dispatches reorder only when a drop changed the position — the hook a
// real app uses to persist (POST the new index, or requestSubmit a form). This
// demo mirrors it into the "saved" line instead; copy comes from the localized
// data-* template (values only, never words).
const sortable = document.querySelector("[data-controller~='stimeo--sortable']");
const saved = document.querySelector("[data-sortable-saved]");
const heard = document.querySelector("[data-sortable-heard]");
sortable?.addEventListener("stimeo--sortable:reorder", (event) => {
const { from, to } = event.detail;
const template = saved?.dataset.savedTemplate || "{from} -> {to}";
if (saved) {
saved.textContent = template
.replace("{from}", String(from + 1))
.replace("{to}", String(to + 1));
}
});
// The library carries no live region of its own: each step is handed to the
// page's shared stimeo--announcer, which is what a screen reader reads. This
// line shows the same message so the demo is followable by eye; aria-hidden on
// it keeps assistive tech from hearing the text twice.
window.addEventListener("stimeo--announcer:announce", (event) => {
if (heard) heard.textContent = event.detail.message;
});
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--sortable"
ターゲット
| 名前 | 説明 | 属性 |
|---|---|---|
list
|
並べ替えコンテナ。任意 — 無ければコントローラ要素がコンテナになる。 | data-stimeo--sortable-target="list" |
item
必須
|
並べ替え対象のアイテム。各アイテムに pointer-drag コントローラを張る(axis は orientation に合わせる)。 |
data-stimeo--sortable-target="item" |
値(Values)
| 名前 | 説明 | 属性 |
|---|---|---|
orientation
|
リストの軸(既定 vertical / horizontal)。中点判定とキーボードの主軸。 |
data-stimeo--sortable-orientation-value |
announceGrabbedText
|
data-stimeo--sortable-announce-grabbed-text-value |
|
announceMovedText
|
data-stimeo--sortable-announce-moved-text-value |
|
announceDroppedText
|
data-stimeo--sortable-announce-dropped-text-value |
|
announceCanceledText
|
data-stimeo--sortable-announce-canceled-text-value |
イベント
| 名前 | 説明 | イベント |
|---|---|---|
reorder
|
位置が変わったドロップで { item, from, to }(0 始まり)と共に発火。購読して永続化する。 |
stimeo--sortable:reorder |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
data-sortable-dragging |
コントローラ要素 | 並べ替えセッション中(ポインタ / キーボード)に付与。 |
data-dragging / data-grabbed |
アイテム(pointer-drag 由来) | 移動中のアイテム。行のハイライトに使う CSS フック。 |