# ドラッグでしか動かせないか、動かしても何も伝えない並べ替えリスト

Source: https://easeweb.dev/learn/sortable-lists
Topics: ドラッグ&ドロップ > ドラッグしなくても並べ替えができ、新しい位置が読み上げられる (WCAG 2.1.1, 2.5.7, 4.1.3)
The fix: ドラッグは残したまま、ドラッグしなくても動かせる方法を各行に加えます。「上へ移動」「下へ移動」のボタンか、「順位」のリストと「移動」ボタンのどちらかです。移動のたびに、最初からページにあるステータス領域へ項目の名前と新しい位置を書き込み、フォーカスは操作要素に残します。矢印キーで動かすキーボードドラッグはキーボード利用者の助けになりますが、それだけでは、ポインターだけで操作する人が取り残されます。
Test with: keyboard, mouse, touch, screen reader
References: https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements.html https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html https://www.w3.org/WAI/WCAG22/Techniques/failures/F108 https://www.w3.org/WAI/WCAG22/Techniques/failures/F103

よくある問題を再現した学習用のサンプルです。実在のクライアントの診断結果ではありません。コードは出発点となる実装例のため、実際の製品でも確認してください。ここでは支援技術でのテスト結果は報告していません。

## 場面

注文の後、ショップが1つだけ質問をします。「注文するときに大切なものを、順位の高い順に並べてください。」「価格」「配送の速さ」「返品のしやすさ」「レビュー」の4行があり、どの行も、6つの点が並んだつまみで始まります。マウスなら「配送の速さ」を押し、「価格」の上まで運んで離すと、左の番号もそれに合わせて変わります。Tabキーを押すと、フォーカスはリストを素通りして「送信」ボタンへ移ります。行はTabの順序になく、つまみは絵で、操作要素ではありません。スマートフォンでは、行に指を置いて動かすと、行が運ばれずにページがスクロールすることがあります。ドラッグできない人は、ショップが書いた順番のまま送るしかなく、回答としては意味がありません。

ショップの2つ目の実装では、各行に「上へ移動」「下へ移動」のボタンを加えています。今度はキーボードでもボタンに届き、「配送の速さ」の「上へ移動」を押すと、行は先頭へ移ります。見える人には、行が動いたことが分かります。スクリーンリーダーの利用者に聞こえるのは、押す前と同じ「上へ移動、ボタン、配送の速さ」です。フォーカスが同じボタンに戻されたからです。項目が今1番目にあることも、そもそも動いたことも伝わりません。確かめるには、ボタンを離れてリストを先頭から読み、また戻ってきます。

## 確認できる問題

1つ目の実装では、順番を変える方法がドラッグしかなく、押したままポインターを動かす必要があります。達成基準2.5.7では、ドラッグが本質的でない限り、クリックやタップだけで同じことができる方法を求めています。4つの言葉を並べるのに、ドラッグは本質的ではありません。その方法がないため、失敗例F108にあたります。行はリスト項目で、飾りのつまみがあるだけです。フォーカスできるものがないため、キーボードでも並べ替えられず、2.1.1も満たしません。つまみは持ち手に見えるだけで、絵は押せません。

2つ目の実装はこの2つの基準を満たしますが、問題はボタンを押した後にあります。画面では、押すと行の横の番号が変わります。「配送の速さ」は2から1に、「価格」は1から2になります。この番号は、フォーカスを受け取らずに操作の結果を伝えるもので、達成基準4.1.3についてのW3C自身の例にあるカートの件数と同じです。4.1.3では、こうしたメッセージを、ロールやプロパティによって、フォーカスを移さなくても支援技術に伝えられるようにすることを求めています。このガイドでは、番号がどれもライブリージョンにないことを失敗例F103と読んでいます。言葉のメッセージがページに加わる場合に限って4.1.3を当てはめる評価者もいますが、どちらにしても観察できることは同じです。フォーカスが戻るボタンは名前も説明も変わらないため、スクリーンリーダーには新しく伝えることがなく、行がどこへ移ったかを伝えるものもページにはありません。DOMの中で要素を動かしても何も読まれず、中のボタンからはフォーカスが外れます。スクリプトがフォーカスを戻しているのはそのためです。

## 利用者への影響

このリストに答えられるのは、全体を一目で見られ、楽にドラッグできる人です。それ以外の人は、順番を変えられないか、今どんな順番か分かりません。並びの変わっていない回答は、結果を読む人には本当の回答に見えます。

## 自動チェックとAIの限界

マークアップには誤りがありません。リスト項目に名前は不要で、`draggable="true"` も正しいHTMLで、移動ボタンにも名前があります。自動チェックは行をドラッグすることもボタンを押すこともないため、順番を変えられないことにも、変えても何も読まれないことにも気付きません。EaseWebの診断は、ドラッグできる項目が並ぶリストで、項目に自前のボタンやselectがないものを、手作業で確認する項目として報告します。また、どの項目もドラッグせずに動かせても、リストの外にライブリージョンがない場合や、リストがライブリージョンの中にある場合は、もう1つの確認項目として報告します。リストのアクセシビリティ改善をAIに頼むと、つまみに `tabindex="0"` と `role="button"` を付けることがよくあります。Tabの順序には入りますが、キーボードでできることは何もありません。リストに `aria-live` を付けることもありますが、動いた行のテキストとボタンがもう一度読まれるだけで、位置は伝わりません。

## 修正前：ドラッグでしか動かない行

```html
<h2 id="rank-title">注文するときに大切なものの順位</h2>
<ol id="ranking" aria-labelledby="rank-title">
  <li draggable="true" data-item="speed">
    <svg class="grip" aria-hidden="true">…</svg>
    <span id="item-speed">配送の速さ</span>
  </li>
  …
</ol>
```

```js
// ドラッグ：行を落とすと、ポインターの下にあった行の位置に入る。
ranking.addEventListener('drop', (event) => { … placeItem(dragged, index); });

// 項目を、0から数えた位置に置く。
function placeItem(item, index) {
  const others = [...ranking.children].filter((li) => li !== item);
  ranking.insertBefore(item, others[index] ?? null);
}
```

行を動かす方法はドラッグだけです。つまみは支援技術から隠され、フォーカスもできず、リスト項目は押せません。

## 修正前：何も伝えない「上へ移動」「下へ移動」

```html
<h2 id="rank-title">注文するときに大切なものの順位</h2>
<ol id="ranking" aria-labelledby="rank-title">
  <li draggable="true" data-item="speed">
    <svg class="grip" aria-hidden="true">…</svg>
    <span id="item-speed">配送の速さ</span>
    <button type="button" data-move="-1" aria-describedby="item-speed">上へ移動</button>
    <button type="button" data-move="1" aria-describedby="item-speed">下へ移動</button>
  </li>
  …
</ol>
```

```js
// ドラッグ：行を落とすと、ポインターの下にあった行の位置に入る。
ranking.addEventListener('drop', (event) => { … placeItem(dragged, index); });

ranking.addEventListener('click', (event) => {
  const button = event.target.closest('button[data-move]');
  if (!button) return;
  const item = button.closest('li');
  const index = [...ranking.children].indexOf(item) + Number(button.dataset.move);
  if (index >= 0 && index < ranking.children.length) placeItem(item, index, button);
});

// 項目を、0から数えた位置に置き、フォーカスを操作要素に戻す。
function placeItem(item, index, control) {
  const others = [...ranking.children].filter((li) => li !== item);
  ranking.insertBefore(item, others[index] ?? null);
  control?.focus();
}
```

ボタンは、クリック、タップ、キー操作で行を動かし、フォーカスもボタンに残りますが、新しい位置は画面に出るだけです。スクリーンリーダーは同じボタンをもう一度読み、それ以外は何も読みません。

## 修正後：移動のたびに伝える「上へ移動」「下へ移動」

```html
<h2 id="rank-title">注文するときに大切なものの順位</h2>
<ol id="ranking" aria-labelledby="rank-title">
  <li draggable="true" data-item="speed">
    <svg class="grip" aria-hidden="true">…</svg>
    <span id="item-speed">配送の速さ</span>
    <button type="button" data-move="-1" aria-describedby="item-speed">上へ移動</button>
    <button type="button" data-move="1" aria-describedby="item-speed">下へ移動</button>
  </li>
  …
</ol>
<p id="rank-status" role="status"></p>
```

```js
// ドラッグ：行を落とすと、ポインターの下にあった行の位置に入る。
ranking.addEventListener('drop', (event) => { … placeItem(dragged, index); });

ranking.addEventListener('click', (event) => {
  const button = event.target.closest('button[data-move]');
  if (!button) return;
  const item = button.closest('li');
  const index = [...ranking.children].indexOf(item) + Number(button.dataset.move);
  if (index >= 0 && index < ranking.children.length) placeItem(item, index, button);
  else rankStatus.textContent = `${itemName(item)}はすでに${index < 0 ? '先頭' : '最後'}です。`;
});

// 項目を、0から数えた位置に置き、フォーカスを操作要素に戻し、移動先を伝える。
function placeItem(item, index, control) {
  const others = [...ranking.children].filter((li) => li !== item);
  ranking.insertBefore(item, others[index] ?? null);
  control?.focus();
  rankStatus.textContent = `${itemName(item)}を${others.length + 1}件中${index + 1}番目に移動しました。`;
}
```

どのボタンも、1回のクリック、タップ、キー操作で行を1段動かすので、ドラッグは要らなくなり、音声操作では「上へ移動をクリック」と言えます。項目の名前は各ボタンの説明として読まれるため、表示テキストを変えずに、どの行を動かすボタンか伝わります。移動後、フォーカスはボタンに戻り、最初からページにあるステータス領域が「配送の速さを4件中1番目に移動しました。」と伝えます。ドラッグして離したときも同じ関数を呼ぶので、マウスで動かしても同じ言葉で読まれます。もう一度「上へ移動」を押しても、ボタンはフォーカスの下から消えずにその場に残り、ステータス領域が「配送の速さはすでに先頭です。」と伝えます。この方法は短いリスト向きです。6件中6番目の項目を先頭へ動かすには、5回押す必要があります。

## 修正後：各行に付けた「順位」のリスト

```html
<h2 id="rank-title">注文するときに大切なものの順位</h2>
<ol id="ranking" aria-labelledby="rank-title">
  <li draggable="true" data-item="speed">
    <svg class="grip" aria-hidden="true">…</svg>
    <span id="item-speed">配送の速さ</span>
    <label for="place-speed">順位</label>
    <select id="place-speed" aria-describedby="item-speed">
      <option value="0">1</option>
      <option value="1" selected>2</option>
      <option value="2">3</option>
      <option value="3">4</option>
    </select>
    <button type="button" data-place aria-describedby="item-speed">移動</button>
  </li>
  …
</ol>
<p id="rank-status" role="status"></p>
```

```js
// ドラッグ：行を落とすと、ポインターの下にあった行の位置に入る。
ranking.addEventListener('drop', (event) => { … placeItem(dragged, index); });

ranking.addEventListener('click', (event) => {
  const button = event.target.closest('button[data-place]');
  if (!button) return;
  const item = button.closest('li');
  placeItem(item, Number(item.querySelector('select').value), button);
});

// 項目を、0から数えた位置に置き、フォーカスを操作要素に戻し、移動先を伝える。
function placeItem(item, index, control) {
  const others = [...ranking.children].filter((li) => li !== item);
  ranking.insertBefore(item, others[index] ?? null);
  showPositions(); // 各行のリストを、その行の新しい位置にする
  control?.focus();
  rankStatus.textContent = `${itemName(item)}を${others.length + 1}件中${index + 1}番目に移動しました。`;
}
```

リストで位置を選び、ボタンで動かすので、リストがどれほど長くても、どの行も2段階で好きな位置へ移せます。ボタンは分けたままにしてください。ブラウザーによっては、閉じたselectの値が矢印キーですぐ変わるため、`change` で動かすと、矢印キーを押すたびに行が動いてしまいます。移動後は、各行のリストがその行の新しい位置を示し、フォーカスは「移動」ボタンに戻り、ボタンの場合と同じように、ステータス領域が移動先を伝えます。

## 修正後：矢印キーで動かすキーボードドラッグ

```html
<h2 id="rank-title">注文するときに大切なものの順位</h2>
<p id="rank-help">項目を動かすには、Spaceで持ち上げ、上下の矢印キーで動かし、
  Spaceで置きます。Escapeで元に戻ります。</p>
<ol id="ranking" aria-labelledby="rank-title">
  <li draggable="true" data-item="speed">
    <button type="button" class="grip" aria-describedby="rank-help">
      <svg aria-hidden="true">…</svg>
      <span id="item-speed">配送の速さ</span>
    </button>
  </li>
  …
</ol>
<p id="rank-status" role="status"></p>
```

```js
// ドラッグ：行を落とすと、ポインターの下にあった行の位置に入る。
ranking.addEventListener('drop', (event) => { … placeItem(dragged, index); });

let lifted = null; // キーボードで持ち上げた項目と、元の位置
ranking.addEventListener('keydown', (event) => {
  const button = event.target.closest('button.grip');
  if (!button) return;
  const item = button.closest('li');
  const index = [...ranking.children].indexOf(item);
  const count = ranking.children.length;
  if (lifted?.item !== item) lifted = null; // 持ち上げた行からフォーカスが離れた
  if (event.key === ' ' || event.key === 'Enter') {
    event.preventDefault();
    lifted = lifted ? null : { item, from: index };
    rankStatus.textContent = lifted
      ? `${itemName(item)}、持ち上げました。${count}件中${index + 1}番目です。`
      : `${itemName(item)}を${count}件中${index + 1}番目に置きました。`;
  } else if (lifted && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
    event.preventDefault();
    const to = index + (event.key === 'ArrowUp' ? -1 : 1);
    if (to >= 0 && to < count) placeItem(item, to, button);
    else rankStatus.textContent = `${itemName(item)}はすでに${to < 0 ? '先頭' : '最後'}です。`;
  } else if (lifted && event.key === 'Escape') {
    const from = lifted.from;
    lifted = null;
    placeItem(item, from, button);
    rankStatus.textContent = `${itemName(item)}を${count}件中${from + 1}番目に戻しました。`;
  }
});

// 項目を、0から数えた位置に置き、フォーカスを操作要素に戻し、移動先を伝える。
function placeItem(item, index, control) {
  const others = [...ranking.children].filter((li) => li !== item);
  ranking.insertBefore(item, others[index] ?? null);
  control?.focus();
  rankStatus.textContent = `${itemName(item)}を${others.length + 1}件中${index + 1}番目に移動しました。`;
}
```

行の上でSpaceキーを押すと持ち上がり、上下の矢印キーで運び、もう一度Spaceキーを押すと置けます。Escapeキーを押すと元の位置に戻ります。どの段階もステータス領域で伝えます。キーボードとスクリーンリーダーの利用者にとっては2.1.1と4.1.3を満たし、多くの並べ替えライブラリーがキーボード向けに用意している方法でもあります。ただし、これだけでは2.5.7を満たしません。タッチスクリーン、ヘッドポインター、シングルスイッチの利用者はドラッグするしかなく、音声操作の利用者は矢印キーを1回ずつ声で指示する必要があります。多くのスクリーンリーダーは、モードを切り替えない限り矢印キーを読み上げに使うため、持ち上げた行が動かないこともあります。ほかの方法の代わりではなく、追加として使ってください。

## どの方法を選ぶか

「上へ移動」「下へ移動」と「順位」のリストは、どちらも単独で基準を満たし、キーボード、ポインター、スクリーンリーダーのどの利用者にも使えます。ボタンは1段動かすごとに1回押す方法で、見つけやすく、音声でも言いやすいため、5〜6件までの短いリストに向いています。「順位」のリストは、どの移動も2段階で、リストが長くなっても変わりません。長いリストや、項目が何段も動くことが多いときは、こちらを選んでください。キーボードドラッグは、キーボード利用者がもっと速い方法を求めるときに加える価値がありますが、必ずほかの方法と組み合わせてください。

## 実装の判断

ドラッグは残してください。ドラッグできる人にとっては速い方法です。2.5.7が求めているのはドラッグ以外の方法を加えることで、ドラッグをなくすことではありません。ドラッグは行に、操作要素はその中に置き、ボタンを押してもドラッグが始まらないようにします。ボタンの処理は押し下げた時点ではなくクリックで実行し、押し間違いを取り消せるようにします。

**移動後のフォーカス。** DOMの中で要素を動かすと、その中の操作要素からフォーカスが外れるため、スクリプトでフォーカスを戻します。フレームワークがリストを更新すると、行は移動されるか新しく作られ、どちらの場合も操作要素からフォーカスが外れます。リストが更新された後で、その行の操作要素にフォーカスを移してください。項目が先頭まで来たときの「上へ移動」のように、フォーカスのあるボタンを無効化したり、隠したり、取り除いたりしないでください。Chromeではフォーカスがページ全体に移ってしまい、利用者はリストのどこにいたかを見失います。ボタンは残して「配送の速さはすでに先頭です。」と伝えます。デザインで薄く表示するなら、`aria-disabled="true"` を付けると、使えないことが読まれ、Tabの順序にも残ります。

**メッセージで伝えること。** 項目の名前と新しい位置を、リストに表示している番号で伝えます（例：「配送の速さを4件中1番目に移動しました。」）。フォーカスを戻した後で書き込むと、スクリーンリーダーはボタンを読んでからメッセージを読みます。`role="alert"` ではなく、読み終えるのを待つ `role="status"` を使います。新しい並び全体を読ませないでください。リスト自体をライブリージョンにするのも避けてください。リストをライブリージョンにすると、動いた行のテキストとボタンがもう一度読まれ、`aria-atomic="true"` ならすべての行が読まれますが、項目がどこへ移ったかは伝わりません。

**グリッドと長いリスト。** 折り返す写真のグリッドでは、上下は前後の意味になるため、ボタンは「前へ移動」「後ろへ移動」にするか、「順位」のリストを用意します。長いリストでは、「先頭へ移動」「末尾へ移動」のボタンがあると、押す回数を大きく減らせます。

**並びの保存。** 移動のたびにすぐ保存する場合は、サーバーが受け付けなかった移動も、項目を元に戻したうえで伝えます。「配送の速さを移動できませんでした。4件中2番目のままです。」フォームで並びを送る場合は、送信する欄を行と同じ順番に保ちます。

**ドラッグのライブラリー。** 並べ替えのライブラリーの多くは、キーボードドラッグと独自のライブリージョンを持っています。読み上げる言葉は、項目をIDで呼んでいることがあるので確認してください。そのうえで、2.5.7のためにボタンか「順位」のリストを加えます。1回の移動に使うのは、ライブラリーの領域か自分の領域のどちらか一方にしてください。両方を使うと、移動のたびに2回読まれます。

1つのリストの中ではなく、リストや列の間で項目を動かすことは「[マウスでしか動かせないドラッグ＆ドロップのボード](/ja/learn/drag-and-drop)」で、その移動を読み上げることは「[カードを移動しても何も読み上げないタスクボード](/ja/learn/move-announcements)」で扱っています。

## 修正の確認

1. マウスを使わずにTabキーを押し、すべての行の操作要素に、読む順序で到達でき、どれもキーボードで行を動かせるか確認する。
2. クリックかタップだけで、1つの項目をリストの先頭へ、別の項目を末尾へ動かす。押したままポインターを動かす手順がないことを確認する。
3. スクリーンリーダーを起動した状態で、リストにある各操作要素で、できればドラッグでも項目を動かす。移動のたびに、項目の名前と新しい位置が1回読まれ、フォーカスは操作要素に残り、リストのほかの部分は読まれないことを確認する。
4. リストの先頭で「上へ移動」を押す（または1番目の項目で順位1を選ぶ）。フォーカスが操作要素に残って何かが伝えられ、フォーカスの下から何も消えないことを確認する。末尾でも同じように確かめる。
5. タッチスクリーンで、タップだけでリストを並べ替える。音声操作で、操作要素の表示テキストを言って項目を動かす。

OS、ブラウザーと支援技術のバージョン、ビルド、実施日、各手順の実際の結果を記録してください。リストのコンポーネントやドラッグのライブラリーを変更した際は、再度確認します。

## 制限

このサンプルは、どのアプリケーションでも同じ結果になることを保証するものではありません。入れ子のリスト、項目の階層を変えられるツリー、スクロールすると項目が増えるリストには、それぞれ別の配慮が必要で、ここでは扱いません。並べ替えのライブラリーによって、フォーカス、キー、読み上げの扱いは異なります。利用者が使う支援技術で、操作全体を確認してください。

---

このガイドをコーディングエージェントで使う: MCP https://easeweb.dev/mcp（設定は https://easeweb.dev/ja/services/developers）
