代表的な面接トピック

フロントエンド面接:アクセシブルなドラッグ&ドロップ並べ替えリストをどう構築するか?

フロントエンド難しい
Offer.cc 編集チーム公開日 更新日

質問

ドラッグを唯一の並べ替え手段とすることなく、マウスやタッチによるドラッグをサポートする並べ替え可能なタスクリストを構築してください。状態モデル、安定したID(stable ID)による並べ替え操作、キーボードおよびシングルポインターの代替手段、フォーカスとスクリーンリーダーの挙動、キャンセル処理、永続化の失敗対応、およびアクセシビリティの検証計画について説明してください。

プロンプトと適用されるコンテキスト

項目の順序を並べ替えられるタスクリストを構築します。マウスおよびタッチユーザーは項目をドラッグできますが、すべての並べ替えはキーボードや、ドラッグ動作を必要としないクリックまたはタップ操作でも実行可能でなければなりません。スクリーンリーダーのユーザーには、どの項目が移動し、新しい位置がどこになったかが通知される必要があります。この設計では、フォーカスの維持、キャンセルのサポート、項目の消失や重複の防止、および保存失敗時に表示上の順序が曖昧なまま残らないように処理しなければなりません。

ベースとなるリストはすべてレンダリングされており、各項目には一意で安定したIDがあり、一度に移動する項目は1つだけであると仮定します。仮想化リスト(virtualized list)内での並べ替え、選択された複数項目の移動、および同時編集の競合解決はフォローアップの対象です。フレームワーク自体が主な論点ではありません。Reactでビューを描画することはあっても、面接で評価されるのは入力のモデリング、アクセシブルなセマンティクス、状態の所有権、および検証手法です。

ドラッグは拡張機能(エンハンスメント)であり、ビジネス操作そのものではありません。ビジネス操作は「項目Xを最終位置Yに移動する」ことです。ポインタードラッグ、キーボードコマンド、表示された移動コントロールのすべてが、その同じ操作を呼び出す必要があります。この分離により、3つの入力経路によって微妙に異なる3通りの順序が生成されるのを防ぎます。

面接官が評価するポイント

1つ目のシグナルは、候補者がID(アイデンティティ)と位置(ポジション)を分離しているかどうかです。配列のインデックスは一時的な位置にすぎないため、Reactのkeyや永続的な項目の識別子として使用してはなりません。安定した項目IDで「何を移動するか」を選択し、最終インデックスまたは隣接する安定したIDで「どこへ移動するか」を選択します。

2つ目のシグナルは、アクセシビリティの正確性です。WCAGのキーボード操作と、WCAG 2.2のドラッグ動作(Dragging Movements)の要件は関連していますが独立しています。キーボード専用のショートカットだけでは、クリックやタップはできるものの長押ししてドラッグすることができないユーザーに対して、シングルポインターの代替手段を提供したことにはなりません。表示された「上へ移動」「下へ移動」または「指定位置へ移動」コントロールを提供することで、両方の入力経路を満たすことができます。

3つ目のシグナルは、インタラクション状態の規律です。優れた回答では、待機(idle)、アクティブな並べ替え(active reorder)、コミット(commit)、キャンセル(cancel)を明確に区別し、ロールバックのために元の順序を保持し、pointercancel、Escape、無効なドロップを適切に処理し、ホバーごとのすべての位置をビジネス上の更新として永続化しないようにします。

4つ目のシグナルは、支援技術の挙動です。多数のARIA属性を追加するよりも、ネイティブなリストとボタンのセマンティクス、明示的なドラッグハンドル、簡潔な操作説明、安定したフォーカス、politeなステータス通知、および目に見えるドロップインジケーターの方が重要です。aria-grabbedaria-dropeffectは非推奨(deprecated)であり、解決策として提示すべきではありません。

最後に、面接官はエビデンスに基づく検証を重視します。純粋な並べ替えロジックのテスト、マウスおよびタッチのテスト、キーボードのみの操作、スクリーンリーダーの確認、フォーカスのアサーション、視覚効果の抑制(reduced-motion)やハイコントラスト(forced-colors)での挙動、および保存失敗からの回復などです。自動化されたアクセシビリティスキャンだけでは、音声による並べ替えフローが理解しやすいかどうかを証明することはできません。

回答前に確認すべき質問

  • 並べ替えは単一のリスト内ですか、それとも複数のリスト間ですか? 単一リストの場合は最終位置のみが必要ですが、リスト間の移動では移動元と移動先の所有権、許可されるドロップタイプ、およびアトミックな更新も必要になります。
  • 順序はリモートに保存する必要がありますか? ローカルのみの順序であれば即座にコミットできます。リモート永続化を行う場合は、保留中(pending)、成功、失敗、再試行、およびバージョンの競合に関する挙動が必要になります。
  • どの入力が対象範囲ですか? タッチ、キーボード、スイッチ、音声入力、またはスクリーンリーダーのユーザーが対象となる場合、マウス専用のHTMLドラッグイベントでは不十分です。
  • クリックまたはタップの代替手段は表示されていますか? キーボードショートカットは異なるニーズを満たすものです。必須ではない並べ替え可能リストの場合、ポインターを押し続けながら移動させることなく、単一のポインターで操作できるコントロールを提供してください。
  • 移動のプレビューは即座に表示されますか、それともドロップ時のみですか? リアルタイムプレビューはより強い視覚的フィードバックを提供しますが、キャンセル時には元の順序を復元する必要があり、ポインターの1ピクセルごとの移動で通知が過剰に発生してはなりません。
  • 複数の項目を選択できますか? 複数項目の移動では、モデルが単一の項目IDから順序付けられたセットへと変更され、移動可能な項目とロックされた項目が混在する場合のルールが必要になります。
  • リストは仮想化されていますか? 画面外の位置はDOMのドロップターゲットとして存在しないため、当たり判定、読み上げ、合計カウント、およびキーボードによる移動はデータモデル上で動作しなければなりません。
  • 他のクライアントが同時に並べ替えを行う可能性はありますか? その場合、保存契約にはリストのバージョンまたは同等の事前条件と、明示的な競合ポリシーが必要になります。

30秒の回答フレームワーク

「私は並べ替えを1つの純粋なコマンドとしてモデル化します。それは『安定した項目IDを最終インデックスに移動する』というものです。ポインタードラッグ、キーボード操作、および表示された移動ボタンのすべてがこれを呼び出します。ネイティブなリストとボタンのセマンティクスを使用し、移動したハンドルにフォーカスを維持し、その新しい位置を通知します。セッションには元の順序が保存されるため、Escape、pointercancel、無効なドロップ、または保存失敗時に順序を復元できます。その後、置換の不変性を検証し、マウス、タッチ、クリックのみ、キーボード、スクリーンリーダー、フォーカス、および失敗フローをテストします。」

ステップごとの詳細解説

まずは正規化された単一のデータ操作から始めます。入力には安定したIDと0始まりの最終インデックスを使用し、出力は新しい順序になります。この操作では、すべてのIDを正確に1回ずつ維持し、入力配列を変更(mutate)してはなりません。

typescript
interface SortableItem {
  id: string
  label: string
}

function moveItem(
  items: ReadonlyArray<SortableItem>,
  itemId: string,
  targetIndex: number,
): ReadonlyArray<SortableItem> {
  const fromIndex = items.findIndex((item) => item.id === itemId)

  if (fromIndex === -1 || items.length < 2) return items

  const finalIndex = Math.max(0, Math.min(targetIndex, items.length - 1))

  if (fromIndex === finalIndex) return items

  const next = [...items]
  const [movedItem] = next.splice(fromIndex, 1)

  if (!movedItem) return items

  next.splice(finalIndex, 0, movedItem)
  return next
}

このコマンドのコストは、配列の削除、挿入、およびコピーによって項目がシフトするため、時間計算量O(n)、空間計算量O(n)となります。これは、完全にレンダリングされる通常のタスクリストには適切です。非常に大規模な仮想化リストでは、ソート可能なランクキーを保存したり、隣接するIDによるサーバー側の移動を適用したりする場合もありますが、そうした複雑さを追加するにはスケール要件からの根拠が必要です。

Reactのkeyには安定したIDを使用します。key={item.id}を使用することで、Reactは新しいインデックスを新しいIDとして扱うのではなく、既存の項目ノードを移動できます。これにより、フォーカスされたハンドルと項目のローカル状態を維持しやすくなります。非同期の更新によってフォーカスが失われる場合は、項目IDをキーとしたrefを保持し、コミットされたレンダリング後にその同じハンドルにフォーカスを復元します。リストの先頭にフォーカスを移動させてはならず、ポインターユーザーに対して強制的にフォーカスを変更してはなりません。

ドラッグがなくても理解できるベースのマークアップを作成します。順序付きリスト(ordered list)とリスト項目によってコレクション構造を公開します。各項目には、例えば「Reviewの並べ替え」というアクセシブルな名前を持つネイティブボタンを配置します。各アクセシブルな名前に項目名を含めた、表示可能な「上へ移動」「下へ移動」ボタン、または「指定位置へ移動」アクションを提供します。不可能な境界アクションは無効化(disable)します。これらのコントロールはクリック、タップ、キーボード操作で動作するため、発見しやすい代替手段であると同時に、ドラッグが失敗した際のシンプルな復旧パスにもなります。

2つのアクセシビリティ要件の違いが設計を変えます。キーボード操作とは、ポインティングデバイスなしで完全な並べ替えを実行できることを意味します。これとは別に、ドラッグ動作が必須でない限り、ポインターを押し続けながら移動させることを必要としないシングルポインターの手段が提供されていなければなりません。矢印キーによるドラッグモードはキーボードユーザーを満足させるかもしれませんが、そのコントロールがクリックやタップでも操作できない限り、2つ目の要件を満たすことはできません。表示された移動ボタンであれば両方を満たします。

オプションのコンパクトなキーボードドラッグモードを使用すると、Tabキーの連打を減らすことができます。ドラッグハンドルにフォーカスを合わせ、EnterまたはSpaceキーを押して開始し、上矢印と下矢印で候補位置を変更し、EnterまたはSpaceで確定し、Escapeでキャンセルしてスナップショットを復元します。ユーザーがこれらの操作を発見できるように、ハンドルから参照されるテキストに説明を記載します。項目が選択操作もサポートしている場合は、同じフォーカスターゲット上でSpaceキーが2つの意味を持たないように、選択コマンドと並べ替えコマンドを明確に分けてください。

インタラクションは短命なセッションとして表現します。開始時にitemId、入力タイプ、元の順序、および現在の候補インデックスを保存します。ポインターまたはキーボードの移動は、moveItemを通じて候補のみを更新します。コミットにより、1回の永続化リクエストと1回の通知が生成されます。キャンセルは元の順序を復元し、キャンセルを通知します。単にリクエストの完了が遅かったという理由だけで、新しいサーバーレスポンスがより新しいセッションを上書きすることは許されません。

ポインター入力では、行全体をドラッグ可能にするのではなく、明示的なハンドルを使用します。これにより、ドラッグ操作がリンク、チェックボックス、テキスト選択、またはスクロールのクリックを奪うのを防ぎます。Pointer Eventsを使用してページ内ソートを実装する場合は、わずかな移動しきい値を待ち、ポインターをキャプチャし、項目の中央値から候補位置を計算し、色だけに依存しない挿入インジケーターを表示し、pointeruppointercancel、キャプチャの喪失、スクロール、およびリスト外へのドロップを処理します。タッチセンサー、自動スクロール、衝突判定、ネストされたスクロールコンテナ、および支援技術の挙動がすべて要件となる場合は、テスト済みのドラッグライブラリを使用するのが望ましいです。

ネイティブのHTMLドラッグ&ドロップは、デスクトップやアプリケーション間のデータ転送には適していますが、キーボードやスクリーンリーダーによる並べ替えワークフローは提供されません。また、そのイベントモデルでは明示的で有効なドロップターゲットが必要であり、ドラッグ中に他のデバイス入力イベントが抑制されます。これを選択したとしても、移動コントロール、フォーカスの挙動、通知、またはタッチ検証の必要性がなくなるわけではありません。

視覚とセマンティクスの両方を通じて結果を伝えます。候補の挿入位置は色だけでなく形状や境界線でも示し、目に見えるフォーカスインジケーターを維持し、視覚効果の抑制(reduced-motion)設定を尊重します。意図的なキーボード移動、コミット、キャンセル、および失敗の後の短いメッセージには、永続的なpoliteステータス領域(live region)を使用します。「Reviewを5件中2番目の位置に移動しました」のようなメッセージには、項目、結果、位置、および合計件数が含まれます。ポインターのホバーごとに通知を出してはならず、メッセージと同時にライブ領域を再作成してはなりません。

時代遅れのドラッグセマンティクスは避けてください。WAI-ARIA 1.2ではaria-grabbedaria-dropeffectは非推奨とされています。ネイティブ要素、アクセシブルな名前、説明的な手順、テキストで伝えられる現在の状態、およびテスト済みの読み上げ機能を使用する方が、より信頼性の高い契約を提供できます。ARIAを追加しても、失われているキーボードの挙動やクリックの代替手段が補われるわけではありません。

リモート永続化の場合は、安定した順序付きIDの配列、または移動コマンドとクライアントが編集したバージョンを送信します。結果を楽観的にプレビューし、キーボードフォーカスをブロックすることなく保存状態を表示し、選択したプロダクトのルールに基づいてのみ通知をコミットします。ネットワーク障害が発生した場合は、再試行オプションを付けてローカルの保留順序を維持するか、スナップショットにロールバックしてロールバックを通知します。バージョン競合が発生した場合は、現在の順序を取得してユーザーに再試行を促すか、定義されたマージを適用します。別のクライアントの順序をサイレントに上書きすることは回復戦略ではありません。

検証は純粋なコマンドから始まります。先頭、末尾、隣接、同一位置、不明なID、および範囲外への移動をテストします。生成された入力に対して、出力が同じIDを同じ個数で含み、要求された相対移動のみが行われていることをアサートします。その後、インタラクションマトリックスを実行します。

  • マウスドラッグ、タッチドラッグ、クリック専用コントロール、およびリスト外へのドロップ。
  • キーボードのみでの移動、コミット、境界アクション、およびEscapeによるキャンセル。
  • 各レンダリングおよびロールバック後も、移動した項目のハンドルにフォーカスが維持されること。
  • Safari環境のVoiceOverおよびFirefoxやChrome環境のNVDAが、項目、操作説明、新しい位置、キャンセル、および保存失敗を1回だけ読み上げること。
  • 200%ズーム、強制カラー(forced colors)、ハイコントラスト、視覚効果の抑制(reduced motion)、長いラベル、スクロール、およびRTL(右から左)レイアウト。
  • 遅延した成功、リクエストの順序の入れ替わり、オフライン障害、再試行、およびバージョン競合。

自動化されたアクセシビリティツールは、名前の欠落、無効な属性、および一部のフォーカス可能性の問題を検出できます。しかし、キーボードモデルが発見しやすいか、音声シーケンスが一貫しているか、あるいはタッチスクリーンリーダーでタスクを完了できるかを判断することはできません。これらには手動での検証が必要です。

質の高い回答例

「私は項目のIDを配列の位置から独立させて管理します。リデューサーは項目IDと最終インデックスを受け取り、新しい順列を返します。順序を変更できるのはこのコードのみです。ポインタードラッグ、『上へ移動』や『下へ移動』ボタン、およびキーボードドラッグモードはすべて、その同じコマンドをディスパッチします。

セマンティクスのベースラインは、ネイティブボタンを備えた順序付きリストになります。各行には明示的な並べ替えハンドルと、アクセシブルな名前に項目ラベルが含まれる表示可能な移動コントロールを配置します。キーボードサポートと、ドラッグに対するクリックまたはタップの代替手段は別々の要件であるため、移動コントロールが重要になります。aria-grabbedaria-dropeffectには依存しません。どちらも非推奨です。

並べ替えが開始されると、元の順序とアクティブな項目IDを保持します。ポインターの当たり判定や矢印キーは、候補位置のみを更新します。コミット時に1回の保存を送信し、例えば『Reviewを5件中2番目の位置に移動しました』と通知します。Escape、pointercancel、無効なドロップ、または選択された保存失敗ルールによってスナップショットが復元されます。DOMの順序が変更された後も、安定したReactのkeyによって同じハンドルにフォーカスが維持されます。

ポインターによる並べ替えには、専用のハンドル、移動しきい値、ポインターキャプチャ、明確な挿入インジケーター、自動スクロールの挙動、およびキャンセル時のクリーンアップを使用します。タッチ操作、ネストされたスクロール、およびクロスブラウザの衝突処理が必要な場合は、キーボードとスクリーンリーダーの挙動を確認した上でのみ、テスト済みのライブラリを選択します。マウスでのデモだけでは不十分です。

置換の不変性を単体テストした上で、マウス、タッチ、キーボード、クリック専用コントロール、VoiceOver、およびNVDAを使用して手動でタスクを完了できるかテストします。また、フォーカス、Escape、枠外ドロップ、視覚効果の抑制、強制カラー、遅延した保存、古いレスポンス、およびバージョン競合もテストします。これによって実際のインタラクション契約が検証されます。自動スキャンだけでは不十分です。」

よくある落とし穴

  • 配列のインデックスをkeyとして使用する → 並べ替えによってID、フォーカス、および項目のローカル状態が変わってしまう → keyとコマンドには安定した項目IDを使用する。
  • ドラッグを唯一のインタラクションにする → ポインターを押し続けて移動できないユーザーが並べ替えできなくなる → 同等の結果をもたらす、目に見えるクリックまたはタップコントロールを提供する。
  • 矢印キーを追加しただけで全要件を満たしたと主張する → キーボードと同等であることだけでは、ドラッグを伴わないシングルポインター手段が欠けている場合がある → キーボード要件とドラッグ動作の要件を個別に評価する。
  • 行全体にドラッグリスナーをアタッチする → リンク、選択、チェックボックス、およびスクロールが並べ替えと競合する → 明示的なフォーカス可能ハンドルを使用する。
  • 入力ごと個別の並べ替えアルゴリズムを実装する → マウス、タッチ、キーボードで異なるエッジケースが発生する → すべての入力を単一の安定したIDコマンドにルーティングする。
  • 配列またはDOMを直接変更(mutate)する → レンダリングされた状態と保存された状態が乖離する → 状態の所有者から新しい順列を返す。
  • aria-grabbedaria-dropeffectをアクセシビリティ計画として使用する → これらの属性は非推奨であり、インタラクションを追加するものではない → ネイティブコントロール、操作説明、状態、フォーカス、およびテスト済みの読み上げを使用する。
  • ポインターのすべての動きを通知する → ライブ領域がノイズで溢れ、遅延が発生する → ポインターのピクセルごとではなく、意図的なキーボード操作のステップと最終結果を通知する。
  • 並べ替え後にフォーカスをリストの先頭に移動する → ユーザーが操作位置を見失う → 安定した項目IDによってフォーカスを維持または復元する。
  • ホバーごとのすべての位置を保存する → ネットワークの競合により、古い中間順序が適用される可能性がある → バージョンの事前条件を付けて、コミットされた1回の移動を永続化する。
  • 自動監査ツールのみに依存する → 音声による説明や完全な入力フローを検証することはできない → 実際のキーボード、タッチ、スクリーンリーダーでテストする。

フォローアップの質問と回答

フォローアップ1:リストが仮想化されている場合、何が変わりますか?

マウントされたDOM行を完全なコレクションとして扱わないでください。移動、合計件数、および候補位置はデータモデル内で保持します。キーボードコマンドは、移動先が画面外であっても論理インデックスによって移動でき、その後、移動した項目のフォーカスターゲットを失うことなく画面内にスクロールさせます。ポインターの移動には、モデルを認識した当たり判定と自動スクロールが必要です。ライブラリが仮想化を通じて正確なコレクションセマンティクスや安定したフォーカスを提供できない場合、測定された規模においては、非仮想化のアクセシブルモードを選択する方が安全なトレードオフになることがあります。

フォローアップ2:選択された複数の項目をどのように移動しますか?

安定したIDの順序付きセットを保存し、それらの相対的な順序を維持し、1つのグループとして削除し、そのグループを1つの最終境界位置に挿入します。読み上げには項目数と移動先を含めます。特にSpaceキーがすでに選択の切り替えに使用されている場合、選択操作とドラッグハンドルには別々のコマンドが必要です。ロックされた項目を含む混在した選択を拒否するか、どの項目が残るかを正確に定義します。選択の一部だけをサイレントに移動することは曖昧さを招きます。

フォローアップ3:2つのクライアントが同じリストを同時に並べ替えた場合はどうなりますか?

移動リクエストにリストのバージョンまたはETagを付与します。サーバーはバージョンが一致する場合にのみ移動を受け入れます。競合が発生した場合は、現在の順序を取得し、ユーザーが意図した項目と移動先をコンテキストとして保持した上で、再試行を促すか、文書化されたマージルールを適用します。Last-write-wins(後勝ち)が許容されるのは、プロダクトとして他のユーザーによる順序決定が失われることを明示的に許容している場合のみです。

フォローアップ4:ネイティブのHTMLドラッグ&ドロップ、Pointer Events、ライブラリのどれを使用しますか?

ネイティブのドラッグ&ドロップはデスクトップや外部へのデータ転送には便利ですが、完全なキーボードや支援技術のワークフローは提供されません。Pointer Eventsはページ内のタッチやマウスによる並べ替えを細かく制御できますが、衝突判定、キャプチャ、自動スクロール、およびクリーンアップの実装が必要です。それらの動作がすでにテストされている場合はライブラリが最良の選択肢となりますが、それには実際のキーボード、タッチスクリーンリーダー、フォーカス、およびDOMセマンティクスがプロダクトの検証マトリックスに合格していることが条件です。センサーの選択にかかわらず、移動コントロールと正規化された並べ替えコマンドは共通して必要です。

フォローアップ5:ユーザーが作業を続けた後に保存が失敗した場合はどうなりますか?

コミットされた各並べ替えにクライアントのシーケンス番号と、それが基づいていたサーバーバージョンをタグ付けします。遅れて発生した障害によってロールバックされるのは、そのリクエストに由来する状態のみであるべきです。古いスナップショットでより新しい確認済み順序を置き換えてはなりません。最もシンプルで安全なポリシーは、保存をシリアル化(直列化)し、明示的な保留順序を1つ保持し、フォーカスと読み取りを許可しながら2回目のコミットをブロックすることです。より応答性の高いキューを導入するには、リベースと競合のテストを行い、その妥当性を証明する必要があります。

公開情報ソース

関連する質問