[React] HTML Drag and Drop API を使ってテーブルの列を入れ替える

2021.07.27

最終的な動作の画像と完成物は以下になります。

drag and dropできるテーブル

ここでは少しずつ、作業手順をおって解説していきます。

導入

上記のテンプレートを使用しています。ここでは導入についての解説しません。

また、このページ内で記載しているコードについては一部簡略化のため省略している箇所があります。すべてのコードについてはそれぞれリンクにある GitHub のコミットログで確認できます。テストコードはこの記事では長くなってしまうため記載していませんが、GitHub で確認できます。

ドラッグ機能は未実装状態の DraggableTable コンポーネントを追加する

まずはドラッグ機能のない状態の 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 の動作のみを追加します。ここではまだ実際に値の入替えなどは行いません。

DraggableTable コンポーネント ドラッグしかできない

// 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;
};

useDnDuseReducer をカスタムフックとして切り出しているだけになります。dndState の持つ値については以下のとおりです。

  • draggedId: ドラッグ起点となったセルがもつ ID
  • hoveredId: ドラッグ中にホバーされているセルの ID
  • droppedId: ドロップされたセルの ID

それぞれの関数について、想定される実行タイミングとその内容は以下のとおりです。

  • dragStart: ドラッグが開始されたとき実行し、draggedId を設定する
  • dragEnter: ドロップ対象のセルにホバーしたとき実行し、hoveredId を設定する
  • dragLeave: ドロップ対象のセルに対するホバーが解除されたとき実行し、hoveredId を未設定にする
  • drop: ドロップされたとき実行し、droppedId を設定する
  • dragEnd: ドロップされた、されていないに関わらず、ドラックが終了したとき実行し、すべての状態をクリアする

実際にコンポーネントのコードを見ていきます。<td> をドラッグするわけにはいきませんので、その子要素に draggable 属性をもつ <div> を追加しています。そしてその <div> にそれぞれのイベントハンドラを追加しています。

注意すべきは onDragEnter を使用せず、onDragOver を使用している点となります。以下は onDragOver ではなく onDragEnter を使用したときの挙動になります。

DraggableTable コンポーネント 想定してない挙動

これは onDragEnteronDragLeave の実行順序が、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 のタイミングにしてみます(実は本来これが完成形のつもりでした)。

DraggableTable コンポーネント ドロップで入れ替え

// 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.droppedIddrop イベントで設定しているため、そのあとで実行される dragEndchangeOrder による並び替えを実行しています。

入替えはドロップではなくホバーで行う

これまでのセクションで完成でも良いかなと思っていたのですが、作っている過程でドラッグ中に実際に配列の swap を実行したほうがわかりやすそうだったので試してみました。

drag and dropできるテーブル

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>
    );
  }
);

changeOrderswap 対象を引数で指定できるように修正をしています。その他の変更は実行タイミングを dragEnd から dragOver に変更しただけです。

さいごに

以上でテーブルの列を入れ替えるコンポーネントが作成できました。ただし実際にプロジェクトで使うには以下の点に注意する必要があります。

  • a11y
  • データが巨大になったときのパフォーマンス
  • ホバー時にドラッグ対象が draggable なアイテムであれば何であっても色が変わってしまう