アクセシビリティが考慮された React Aria のドラッグアンドドロップ
React Aria は Adobe により提供されている React 用のコンポーネントライブラリであり、アクセシビリティを最優先した設計となっています。本記事では、React Aria により提供されているドラッグアンドドロップ機能を紹介します。
ドラッグアンドドロップは、ユーザーが UI の要素をドラッグして別の場所に移動する操作です。Web アプリケーションにおいて、ドラッグアンドドロップはユーザーが直感的に操作できるため、多くの場面で利用されています。例えばタスク管理アプリケーションにおいて、タスクをドラッグして進行状況を変更したり、ファイル管理アプリケーションにおいてファイルをドラッグしてフォルダを移動する機能などがあります。
従来のドラッグアンドドロップ機能はマウス以外での操作が考慮されていない実装が多く、キーボードやスクリーンリーダーを利用するユーザーにとっては機能を利用することが難しくなっていました。また、ARIA Authoring Practices Guide にもドラッグアンドドロップに関するガイドラインの記述が存在しないため、キーボードやスクリーンリーダーを利用するユーザーに対して代替手段を提供したとしても、その実装方法については開発者の裁量に委ねられ Web アプリケーション間での実装の差異が生じてしまいまうという課題も存在します。
React Aria は Adobe により提供されている React 用のコンポーネントライブラリであり、アクセシビリティを最優先した設計となっています。上記のとおりにアクセシビリティ上の課題を抱えるドラッグアンドドロップ機能についても、キーボードやスクリーンリーダーを利用するユーザーに対してもサポートすることを目指しています。
React Aria により作成された ドラッグアンドドロップの RFC に基づき各コンポーネントやフックが提供されています。実際に React Aria により提供されているドラッグアンドドロップ機能を備えたコンポーネントを見ていきましょう。
useDrag
と useDrop
useDrag
と useDrop
は、それぞれドラッグ可能な要素とデータを受け入れるドロップゾーン要素を作成するためのフックです。これらのフックを使用することで、キーボードやスクリーンリーダー向けの操作をサポートしたドラッグアンドドロップ機能を実装できます。
まずは、useDrag
を使用してドラッグ可能な要素を作成する例を見てみましょう。
import { useDrag } from "react-aria";
export const Draggable = () => {
const { isDragging, dragProps } = useDrag({
getItems: () => [{ "text/plain": "Hello, world!" }],
});
return (
<button {...dragProps} type="button">
className={isDragging ? "dragging" : ""}
{isDragging ? "Dragging" : "Drag me"}
</button>
);
};
useDrag
フックは、getItems
という関数を引数に取ります。この関数は、どのようなデータがドロップされたときに渡されるかを定義します。上記の例では、"Hello, world!"
というテキストデータがドロップされたときにドロップゾーンに渡されます。
useDrag
は isDragging
と dragProps
という 2 つのプロパティを返します。isDragging
は、現在ドラッグ中かどうかを表す真偽値です。dragProps
は、ドラッグ可能な要素に適用するプロパティを含むオブジェクトです。この例では、button
要素に dragProps
を適用しています。dragProps
を渡すことにより、対象の要素がドラッグアンドドロップ操作をサポートするようになります。
なおキーボードとスクリーンリーダーによる操作を可能にするためには、dragProps
を渡す要素がフォーカス可能であり、ARIA ロールを持つ必要があります。
次に、useDrop
を使用してドロップゾーンを作成します。
import { useDrop } from "react-aria";
import { useState, useRef } from "react";
export const DropTarget = () => {
const [dropped, setDropped] = useState<string | null>(null);
const ref = useRef<HTMLButtonElement | null>(null);
const { dropProps, isDropTarget } = useDrop({
ref,
async onDrop(e) {
const items = await Promise.all(
e.items
.filter((item) => item.kind === "text")
.map((item) => item.getText("text/plain"))
);
setDropped(items.join("\n"));
},
});
return (
<button
{...dropProps}
type="button"
ref={ref}
className={`drop-zone ${
isDropTarget ? "target" : dropped ? "dropped" : ""
}`}
>
{dropped || "Drop here"}
</button>
);
};
useDrop
フックは、ref
と onDrop
という 2 つのプロパティを引数に取ります。ref
は、ドロップゾーンとなる要素を参照するための useRef
フックで作成したオブジェクトを渡します。onDrop
は、ドロップされたデータを処理するための関数です。この関数はドロップが完了したタイミングで呼びされ、useDrag
の getItems
で定義したデータが引数として渡されます。item.kind
が "text"
の場合は getText()
メソッドを使用してテキストデータを取得できます。
ここでは取得したテキストデータを dropped
という状態に保存し、この状態にデータが渡された場合には Drop here
という文字列の代わりに表示するようにしています。
useDrop
は dropProps
と isDropTarget
という 2 つのプロパティを返します。dropProps
は、ドロップゾーンに適用するプロパティを含むオブジェクトです。dragProps
と同様に、キーボードやスクリーンリーダーによる操作を可能にするためには、dropProps
を渡す要素がフォーカス可能であり、ARIA ロールを持つ必要があります。isDropTarget
は、現在ドロップゾーンにデータがドラッグ中かどうかを表す真偽値です。
なお、useDrop
フックを使用する代わりに <DropZone>
コンポーネントを使用することもできます。<DropZone>
コンポーネントは内部で useDrop
フックを使用しており、onDrop
Props に渡した関数がドロップされたデータを処理するために使用されます。
<DropZone onDrop={(e) => {
const items = await Promise.all(
e.items
.filter((item) => item.kind === "text")
.map((item) => item.getText("text/plain"))
);
setDropped(items.join("\n"));
}}>
Drop here
</DropZone>
実際にマウス操作によるドラッグアンドドロップ操作が可能であることが確認できます。
キーボード操作についても確認してみましょう。ドラッグ可能な要素にフォーカスがあるときに、Enter
キーを押すことでドラッグ操作を開始できます。続いて Tab
キーを押すことでドロップゾーンにフォーカスを移動します。ドロップゾーンにフォーカスがあるときに Enter
キーを押すことでドロップ操作を完了できます。またドラッグ操作中に Escape
キーを押すことでドラッグ操作をキャンセルできます。
最後にスクリーンリーダーを使用した操作を見てみます。ここでは macOS に標準搭載されている VoiceOver を使用しています。ドラッグ可能な要素にフォーカスがあるときは「Enter キーを押してドラッグを開始してください」と読み上げられます。
Enter キーもしくは Space キーをクリックしドラッグ操作を開始したタイミングで live region によって「ドラッグを開始しました。ドロップのターゲットに異動し、クリックまたは Enter キー押してドロップします」と読み上げられました。
ドロップターゲットにフォーカスを移動すると「Enter キーを押してドロップします。Esc キーを押してドラッグをキャンセルします」と読み上げられます。
最後に、ドラッグ操作が完了したタイミングで「ドロップ完了しました」と読み上げられました。
このようにスクリーンリーダーを使用している場合にはどのような操作を行うことができるのか適切に読み上げられていることが確認できます。
GridList
<ListBox>
, <Table>
, <GridList>
のようなデータのコレクションコンポーネントにおいてもドラッグアンドドロップ機能が提供されています。これらのコンポーネントは useDraggableCollection フックと useDroppableCollection フックを使用して実装されています。
ここでは <GridList>
コンポーネント能を見てみましょう。<GridList>
は行の項目を選択できるインタラクティブなリストです。ARIA Authoring Practices Guide の Grid パターンに準拠しており、キーボード操作やスクリーンリーダーによる操作をサポートしています。
<GridList>
コンポーネントはドラッグアンドドロップにより以下の操作をサポートしています。
- リスト全体もしくはリスト内の項目にデータをドロップする
- 項目をドラッグして並び替える
- 既存の項目の間に新しい項目を挿入する
ドラッグアンドドロップによる並び替え
以下の例では、<GridList>
コンポーネントを使用してドラッグアンドドロップによる並び替えを実装しています。
import {
GridList,
GridListItem,
Button,
useDragAndDrop,
} from "react-aria-components";
import { useListData } from "react-stately";
export const MyGridList = () => {
const list = useListData({
initialItems: [
{ id: 1, name: "Charizard" },
{ id: 2, name: "Blastoise" },
{ id: 3, name: "Venusaur" },
{ id: 4, name: "Pikachu" },
{ id: 5, name: "Adobe Connect" },
],
});
const { dragAndDropHooks } = useDragAndDrop({
getItems: (keys) =>
[...keys].map((key) => ({ "text/plain": list.getItem(key).name })),
onReorder(e) {
if (e.target.dropPosition === "before") {
list.moveBefore(e.target.key, e.keys);
} else if (e.target.dropPosition === "after") {
list.moveAfter(e.target.key, e.keys);
}
},
});
return (
<GridList
aria-label="Favorite pokemon"
items={list.items}
dragAndDropHooks={dragAndDropHooks}
>
{(item) => (
<GridListItem textValue={item.name}>
<Button slot="drag" className="drag">
☰
</Button>
{item.name}
</GridListItem>
)}
</GridList>
);
};
リストの状態管理のために react-stately の useListData
フックを使用しています。useListData
フックを使用することで、要素の並び替えといった状態管理の詳細を気にすることなく、リストのデータを管理できます。
グリッドに対するドラッグアンドドロップ操作を有効にするためには、<GridList>
コンポーネントに dragAndDropHooks
Props を渡します。dragAndDropHooks
にわたすオブジェクトは、useDragAndDrop
フックから取得した dragAndDropHooks
です。useDragAndDrop
フックの引数には、getItems
と onReorder
という 2 つのプロパティを持つオブジェクトを渡します。getItems
は、ドラッグアンドドロップ操作によって移動されるデータを定義します。onReorder
は、ドラッグアンドドロップ操作による並び替えが完了したときに呼び出される関数です。ここで実際にリストのデータの並び替えを行っています。
ドラッグ可能なグリッドの項目には必ずフォーカス可能なドラッグハンドルを提供する必要があります。ドラッグハンドルにより、キーボードやスクリーンリーダーを利用しているユーザーがドラッグアンドドロップ操作を開始できるようになります。ここでは Button
コンポーネントに slot="drag"
を指定することで、ドラッグハンドルとして機能するようになります。
以下のように、グリッドの項目をドラッグして並び替えることができることが確認できます。
キーボード操作ではドラッグハンドルにフォーカスがあるときに Enter
キーを押すことでドラッグ操作を開始できます。また、ArrowUp
キーと ArrowDown
キーを押すことで項目の移動が可能です。ドラッグアンドドロップ操作中のスタイルは .react-aria-DropIndicator
クラスを使用してカスタマイズできます。ドロップ対象の要素には data-drop-target
属性が追加されるため、この要素を対象にスタイルを適用することで、現在のドロップ位置を示すインジケータを表示できます。
.react-aria-DropIndicator {
&[data-drop-target] {
outline: 1px solid lightblue;
}
}
ユーザーのポインタの下に表示されるドラッグのプレビューは、useDragAndDrop
フックの renderDragPreview
プロパティを使用してカスタマイズできます。
const { dragAndDropHooks } = useDragAndDrop({
renderDragPreview: (items) => {
return (
<div className="drag-preview">
{items[0]["text/plain"]}
{items.length > 1 && <span> 他 {items.length - 1} 件</span>}
</div>
);
},
});
グリッド間のドラッグアンドドロップ
続いて複数のグリッド間でドラッグアンドドロップにより項目を移動する例を見てみましょう。タスクリストで ToDo
、In Progress
、Done
の 3 つのグリッドを持つアプリケーションを想定しています。
import { useId } from "react";
import {
GridList,
GridListItem,
Button,
useDragAndDrop,
isTextDropItem,
} from "react-aria-components";
import { useListData } from "react-stately";
type Item = {
id: number;
name: string;
};
type GridListProps = {
initialItems: Item[];
title: string;
};
const MyGridList = ({ initialItems, title }: GridListProps) => {
const list = useListData({
initialItems,
});
const { dragAndDropHooks } = useDragAndDrop({
getItems(keys) {
return [...keys].map((key) => {
const item = list.getItem(key);
return {
"custom-app-type": JSON.stringify(item),
"text/plain": item.name,
};
});
},
// カスタム要素がドロップされるのを許可する
acceptedDragTypes: ["custom-app-type"],
// アイテムがコピーされるのではなく、常に移動されるようにする
getDropOperation: () => "move",
// 項目が他のリストからドロップされたときの処理
async onInsert(e) {
const processedItems = await Promise.all(
e.items
.filter(isTextDropItem)
.map(async (item) =>
JSON.parse(await item.getText("custom-app-type"))
)
);
if (e.target.dropPosition === "before") {
list.insertBefore(e.target.key, ...processedItems);
} else if (e.target.dropPosition === "after") {
list.insertAfter(e.target.key, ...processedItems);
}
},
// グリッドの項目が空のリストにドロップされたときの処理
async onRootDrop(e) {
const processedItems = await Promise.all(
e.items
.filter(isTextDropItem)
.map(async (item) =>
JSON.parse(await item.getText("custom-app-type"))
)
);
list.append(...processedItems);
},
// 同じリスト内での項目の移動
onReorder(e) {
if (e.target.dropPosition === "before") {
list.moveBefore(e.target.key, e.keys);
} else if (e.target.dropPosition === "after") {
list.moveAfter(e.target.key, e.keys);
}
},
// 他のリストに項目がドロップされたとき、元のリストから削除する
onDragEnd(e) {
if (e.dropOperation === "move" && !e.isInternal) {
list.remove(...e.keys);
}
},
});
const titleId = useId();
return (
<div>
<h2 id={titleId}>{title}</h2>
<GridList
aria-labelledby={titleId}
selectionMode="multiple"
items={list.items}
dragAndDropHooks={dragAndDropHooks}
className="grid-list"
>
{(item) => (
<GridListItem textValue={item.name}>
<Button slot="drag" className="drag">
☰
</Button>
{item.name}
</GridListItem>
)}
</GridList>
</div>
);
};
export const MultiGridList = () => {
return (
<div className="multi-grid-list">
<div>
<MyGridList
title="Todo"
initialItems={[
{
id: 1,
name: "buy milk",
},
{
id: 2,
name: "learn react",
},
{
id: 3,
name: "learn react-dnd",
},
]}
/>
</div>
<div>
<MyGridList
title="In Progress"
initialItems={[
{
id: 4,
name: "learn opentelemetry",
},
]}
/>
</div>
<div>
<MyGridList title="Done" initialItems={[]} />
</div>
</div>
);
};
まとめ
- ドラッグアンドドロップはユーザーが UI の要素をドラッグして別の場所に移動する操作であり、多くの場面で利用されているが、キーボードやスクリーンリーダーを利用するユーザーに対しては機能を利用することが難しかった
- React Aria はアクセシビリティを最優先した設計となっており、ドラッグアンドドロップ機能においてもキーボードやスクリーンリーダーを利用するユーザーに対してサポートすることを目指している
useDrag
とuseDrop
フックを使用することで、ドラッグ可能な要素とドロップゾーンを作成することができる<ListBox>
,<Table>
,<GridList>
などのデータのコレクションコンポーネンできる<できる<できる<トにおいてもドラッグアンドドロップ機能が提供されている<GridList>
コンポーネントを使用してドラッグアンドドロップによる並び替えやグリッド間のドラッグアンドドロップを実装することができる