[React] HTML Drag and Drop API を使ってテーブルの列を入れ替える
最終的な動作の画像と完成物は以下になります。
ここでは少しずつ、作業手順をおって解説していきます。
導入
上記のテンプレートを使用しています。ここでは導入についての解説しません。
また、このページ内で記載しているコードについては一部簡略化のため省略している箇所があります。すべてのコードについてはそれぞれリンクにある GitHub のコミットログで確認できます。テストコードはこの記事では長くなってしまうため記載していませんが、GitHub で確認できます。
ドラッグ機能は未実装状態の DraggableTable コンポーネントを追加する
まずはドラッグ機能のない状態の DraggableTable コンポーネントを追加していきます。
// src/components/DraggableTable/DraggableTable.tsx import React, { ComponentPropsWithoutRef, forwardRef } from "react"; export type Props = Readonly< { columns: { id: string; value: string }[]; rows: string[][]; } & Omit<ComponentPropsWithoutRef<"table">, "className"> >; export const DraggableTable = forwardRef<HTMLTableElement, Props>( (props, ref) => { const { columns, rows, ...rest } = props; return ( <table ref={ref} {...rest}> <thead> <tr> {columns.map(({ id, value }) => ( <td key={id}>{value}</td> ))} </tr> </thead> <tbody> {rows.map((row, i) => ( <tr key={i}> {row.map((item, j) => ( <td key={j}>{item}</td> ))} </tr> ))} </tbody> </table> ); } ); DraggableTable.displayName = "DraggableTable";
forwardRef
を使っていますが、今回の要件に必須ではありません。
props.colmuns
は <thead>
のセルの配列で、値である value
の他に一意の ID として id
を持ちます(以下 ID と書かれていた場合にはこの ID を指します)。props.rows
は <tbody>
のセルの 2 次元配列になっており、こちらは値だけを持ちます。
Drag and Drop の動作のみを追加する
次に HTML Drag and Drop API を使って、Drag and Drop の動作のみを追加します。ここではまだ実際に値の入替えなどは行いません。
// src/components/DraggableTable/DraggableTable.tsx import React, { ComponentPropsWithoutRef, DragEventHandler, forwardRef, useCallback, useEffect, useMemo, } from "react"; import throttle from "lodash.throttle"; import { classnames } from "tailwindcss-classnames"; import { useDnD } from "./useDnD"; export const DraggableTable = forwardRef<HTMLTableElement, Props>( (props, ref) => { const { columns, rows, ...rest } = props; const [ dndState, { dragStart, dragEnter, dragLeave, drop, dragEnd }, ] = useDnD(); const dragOver = useMemo(() => { return throttle<DragEventHandler<HTMLDivElement>>((e) => { if (!(e.target instanceof HTMLDivElement)) { return; } const { id } = e.target.dataset; if (id) { dragEnter(id); } }, 300); }, [dragEnter]); const handleDragEnd = () => { if ( dndState.droppedId !== undefined && dndState.draggedId !== dndState.droppedId ) { console.log(dndState); } dragEnd(); }; useEffect(() => { return () => { dragOver.cancel(); }; }, [dragOver]); return ( <table ref={ref} {...rest}> <thead> <tr> {colmuns.map(({ id, value }) => ( <td key={id}> <div draggable onDragStart={() => dragStart(id)} onDragOver={(e) => { // preventDefault しなければ、drop イベントが発火しない e.preventDefault(); dragOver(e); }} onDragLeave={() => dragLeave()} onDrop={() => drop(id)} onDragEnd={handleDragEnd} className={classnames("p-2", { ["bg-blue-200"]: dndState.draggedId === id, ["bg-red-200"]: dndState.hoveredId === id, })} data-id={id} > {value} </div> </td> ))} </tr> </thead> </table> ); } ); DraggableTable.displayName = "DraggableTable";
// src/components/DraggableTable/usetsx import { useCallback, useReducer } from "react"; export type DnDState = { draggedId: string | undefined; hoveredId: string | undefined; droppedId: string | undefined; }; const initialState: DnDState = { draggedId: undefined, hoveredId: undefined, droppedId: undefined, }; type Action = | { type: "dragStart"; id: string } | { type: "dragEnter"; id: string } | { type: "dragLeave" } | { type: "drop"; id: string } | { type: "dragEnd" }; const reducer = (state: DnDState, action: Action): DnDState => { switch (action.type) { case "dragStart": { return { ...state, draggedId: action.id, }; } case "dragEnter": { return { ...state, hoveredId: action.id, }; } case "dragLeave": { return { ...state, hoveredId: undefined, }; } case "drop": { return { ...state, droppedId: action.id, }; } case "dragEnd": { return { ...initialState }; } } }; export const useDnD = (state = initialState) => { const [dndState, dispatch] = useReducer(reducer, state); const dragStart = useCallback((id: string) => { dispatch({ type: "dragStart", id }); }, []); const dragEnter = useCallback((id: string) => { dispatch({ type: "dragEnter", id }); }, []); const dragLeave = useCallback(() => { dispatch({ type: "dragLeave" }); }, []); const drop = useCallback((id: string) => { dispatch({ type: "drop", id }); }, []); const dragEnd = useCallback(() => { dispatch({ type: "dragEnd" }); }, []); return [ dndState, { dragStart, dragEnter, dragLeave, drop, dragEnd }, ] as const; };
useDnD
は useReducer
をカスタムフックとして切り出しているだけになります。dndState
の持つ値については以下のとおりです。
draggedId
: ドラッグ起点となったセルがもつ IDhoveredId
: ドラッグ中にホバーされているセルの IDdroppedId
: ドロップされたセルの ID
それぞれの関数について、想定される実行タイミングとその内容は以下のとおりです。
dragStart
: ドラッグが開始されたとき実行し、draggedId
を設定するdragEnter
: ドロップ対象のセルにホバーしたとき実行し、hoveredId
を設定するdragLeave
: ドロップ対象のセルに対するホバーが解除されたとき実行し、hoveredId
を未設定にするdrop
: ドロップされたとき実行し、droppedId
を設定するdragEnd
: ドロップされた、されていないに関わらず、ドラックが終了したとき実行し、すべての状態をクリアする
実際にコンポーネントのコードを見ていきます。<td>
をドラッグするわけにはいきませんので、その子要素に draggable
属性をもつ <div>
を追加しています。そしてその <div>
にそれぞれのイベントハンドラを追加しています。
注意すべきは onDragEnter
を使用せず、onDragOver
を使用している点となります。以下は onDragOver
ではなく onDragEnter
を使用したときの挙動になります。
これは onDragEnter
と onDragLeave
の実行順序が、Enter -> Leave の順に実行されるとは限らないため発生するものです。そのためこの解決策として onDragOver
のイベントを lodash.throttle
で間引きして使用しています。
イベントの間引きに useMemo
を使用するアイデアは下記ページを参考にしました。
コメントアウトにも書いてありますが、drop イベント は dragOver イベントを preventDefault しなければ発火しないので注意が必要です。
HTML Drag and Drop API については以下の記事が参考になります。
配列のアイテムを swap する関数を作る
ドラッグが完了すると配列を並び替える必要があるため、配列のアイテムとアイテムを swap させる関数を作ります。
export const swap = <T>(array: T[], fromIndex: number, toIndex: number) => { if (fromIndex >= array.length || toIndex >= array.length) { throw new RangeError("fromIndex/toIndex must be less than array.length"); } if (fromIndex < 0 || toIndex < 0) { throw new RangeError("fromIndex/toIndex must be greater than 0"); } [array[fromIndex], array[toIndex]] = [array[toIndex], array[fromIndex]]; return array; };
並び替えの実装
実際に並び替えをしてみましょう。並び替えるタイミングは、ひとまず DROP のタイミングにしてみます(実は本来これが完成形のつもりでした)。
// src/components/DraggableTable/DraggableTable.tsx import React, { ComponentPropsWithoutRef, DragEventHandler, forwardRef, useEffect, useMemo, } from "react"; import throttle from "lodash.throttle"; import { classnames } from "tailwindcss-classnames"; import { useDnD } from "./useDnD"; import { useOrderedCells } from "./useOrderedCells"; export const DraggableTable = forwardRef<HTMLTableElement, Props>( (props, ref) => { const { columns,rows, ...rest } = props; const [ordered changeOrder] = useOrderedCells(columns, rows); const [dndState, { dragStart, dragEnter, dragLeave, drop, dragEnd }] = useDnD(); const dragOver = useMemo(() => { // 前セクションと同じなので省略しています }, [dragEnter]); const handleDragEnd: DragEventHandler<HTMLDivElement> = () => { changeOrder(dndState); dragEnd(); }; useEffect(() => { return () => { dragOver.cancel(); }; }, [dragOver]); return ( <table ref={ref} {...rest}> <thead> <tr> {ordered.columns.map(({ id, value }) => ( <td key={id}> {* 前セクションと同じなので省略しています *} </td> ))} </tr> </thead> <tbody> {ordered.rows.map((row, i) => ( <tr key={i}> {row.map((item, j) => ( <td key={j}>{item}</td> ))} </tr> ))} </tbody> </table> ); } );
// src/components/DraggableTable/useOrderedCells.ts import { useCallback, useState } from "react"; import { Props } from "."; import { swap } from "../../utils/swap"; export const useOrderedCells = ( columns: Props["columns"], rows: Props["rows"] ) => { const [orderedColumns, setOrderedColumns] = useState(columns); const [orderedRows, setOrderedRows] = useState(rows); const changeOrder = useCallback( (fromId: string | undefined, toId: string | undefined) => { if (fromId === undefined || toId === undefined || fromId === toId) { return; } const fromIndex = columns.findIndex(({ id }) => id === fromId); const toIndex = columns.findIndex(({ id }) => id === toId); // columns を desiredSort の順に並び替える const resultColumns = swap(columns, fromIndex, toIndex); // rows の中の配列を desiredSort の順に並び替える const resultRows = rows.map((row) => swap(row, fromIndex, toIndex)); setOrderedColumns(resultColumns); setOrderedRows(resultRows); }, [columns, rows] ); return [{ columns: orderedColumns, rows: orderedRows }, changeOrder] as const; };
useOrderedCells
ではテーブルを生成するための配列を、先程作成した swap
で入れ替えています。changeOrder
を実行することで、配列を並び変えます。ドラッグしたアイテムとドロップ先が同じだった場合には何もしません。
そして、DraggableTable
側では useOrderedCells
で並び替えられた配列をただ表示するだけです。
drop
イベントは dragEnd
イベントの前にドラッグの結果によらず(ドロップしなくても)発火します。ここでは dndState.droppedId
は drop
イベントで設定しているため、そのあとで実行される dragEnd
で changeOrder
による並び替えを実行しています。
入替えはドロップではなくホバーで行う
これまでのセクションで完成でも良いかなと思っていたのですが、作っている過程でドラッグ中に実際に配列の swap を実行したほうがわかりやすそうだったので試してみました。
import React, { ComponentPropsWithoutRef, DragEventHandler, forwardRef, useEffect, useMemo, } from "react"; import throttle from "lodash.throttle"; import { classnames } from "tailwindcss-classnames"; import { useDnD } from "./useDnD"; import { useOrderedCells } from "./useOrderedCells"; export const DraggableTable = forwardRef<HTMLTableElement, Props>( (props, ref) => { const { columns, rows, ...rest } = props; const [ordered, changeOrder] = useOrderedCells(columns, rows); const [dndState, { dragStart, dragEnter, dragLeave, drop, dragEnd }] = useDnD(); const dragOver = useMemo(() => { return throttle<DragEventHandler<HTMLDivElement>>((e) => { if (!(e.target instanceof HTMLDivElement)) { return; } const { id } = e.target.dataset; if (id) { changeOrder(dndState.draggedId, dndState.hoveredId); dragEnter(id); } }, 300); }, [changeOrder, dnd, dndState]); const handleDragEnd: DragEventHandler<HTMLDivElement> = () => { dragEnd(); }; useEffect(() => { return () => { dragOver.cancel(); }; }, [dragOver]); return ( <table ref={ref} {...rest}> {* 前セクションと同じなので省略しています *} </table> ); } );
changeOrder
は swap
対象を引数で指定できるように修正をしています。その他の変更は実行タイミングを dragEnd
から dragOver
に変更しただけです。
さいごに
以上でテーブルの列を入れ替えるコンポーネントが作成できました。ただし実際にプロジェクトで使うには以下の点に注意する必要があります。
- a11y
- データが巨大になったときのパフォーマンス
- ホバー時にドラッグ対象が draggable なアイテムであれば何であっても色が変わってしまう