単一リストの並べ替え
stimeo--sortable
キーボードとスクリーンリーダーで本当に操作できる、単一リストの並べ替え。
stimeo--sortable はアクセシブルなドラッグ&ドロップ並べ替えで、pointer-drag プリミティブの旗艦的な消費者です。合成はマークアップレベルで行います。各アイテムの pointer-drag が(キーボード代替込みの)正規化ドラッグ信号を発火し、リストの roving がハンドル群を単一 Tab 停止に保ち、sortable がその信号を解釈します。ポインタでは兄弟要素の中点を跨ぐたびにアイテムがライブで移動します。キーボードでは Space でグラブ、矢印 1 押下で 1 ポジション移動、Space でドロップ、Esc で拾い上げ位置へ復元 — 各ステップは status ライブリージョンでアナウンスされ(「Card A を掴みました、 5 件中 2 件目」)、data-* テンプレートでローカライズできます。位置が変わったドロップでは { item, from, to }(0 始まり)と共に reorder が発火します — 実アプリはこれを購読して順序を永続化します(Turbo Streams で broadcast すれば協調ボードに)。実際の DOM ノードが動くため、見た目の順序と読み上げ順序が乖離しません。マルチリストボード・ネストツリー・端の自動スクロール・仮想化は、この無料の単一リスト版のスコープ外です。
ハンドルをドラッグするか、キーボードで操作します。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, announces each
step through the status live region (localized via the data-* templates), and
dispatches reorder on a drop that changed the position. demo.js only mirrors
that reorder event into the "saved" line — the pattern a real app uses to POST
the new position. %>
<div class="sortable-demo">
<p class="sortable-demo__hint"><%= t("components.sortable.demo.hint") %></p>
<div data-controller="stimeo--sortable">
<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>
<p class="sortable-demo__status" role="status"
data-stimeo--sortable-target="status"
data-grabbed="<%= t("components.sortable.demo.announce.grabbed") %>"
data-moved="<%= t("components.sortable.demo.announce.moved") %>"
data-dropped="<%= t("components.sortable.demo.announce.dropped") %>"
data-canceled="<%= t("components.sortable.demo.announce.canceled") %>"></p>
</div>
<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);
}
.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]");
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));
}
});
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。 共通スタイルも一緒にコピーし、ルート要素の 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" |
status
|
各ステップをアナウンスするライブリージョン。role="status"(または aria-live)は作者が付与。data-grabbed / data-moved / data-dropped / data-canceled テンプレートでローカライズ。 |
data-stimeo--sortable-target="status" |
値(Values)
| 名前 | 説明 | 属性 |
|---|---|---|
orientation
|
リストの軸(既定 vertical / horizontal)。中点判定とキーボードの主軸。 |
data-stimeo--sortable-orientation-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 フック。 |