dnd kit を使って実装したドラッグ&ドロップ可能なアイテムの中に削除ボタンを追加する

こんにちは、Gaji-Labo アシスタントエンジニアの石垣です。

今回は、前回ご紹介した React 用ライブラリの dnd kit でドラッグ&ドロップのコンポーネントを実装していた時に、移動可能アイテムの中に削除ボタンを追加するところで少しハマったのでそのハマりどころと解決方法についてまとめたいと思います。

dnd kit の概要については前回の記事をお読みいただけると幸いです。

やりたいこと

前回の記事では以下のような移動可能なカードリストを作成してみました。(前回の記事からスタイルを SortableItem に移動しています)

import { ReactElement, useCallback, useState } from "react";

import { DndContext } from "@dnd-kit/core";
import { arrayMove, SortableContext, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";

const items = ["1", "2", "3", "4", "5", "6", "7", "8"];
const contents = items.map((item) => ({
  id: item,
  content: item,
}));

interface SortableItemProps {
  id: string;
  children: ReactElement;
}

function SortableItem({ id, children }: SortableItemProps) {
  const { attributes, listeners, setNodeRef, transform, transition } =
    useSortable({ id });
  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    // スタイル調整用
    display: "flex",
    alignItems: "center",
    justifyContent: "center",
    margin: 4,
    borderRadius: 4,
    width: "150px",
    height: "150px",
    border: "1px solid black",
    backgroundColor: "white",
  };
  return (
    // eslint-disable-next-line react/jsx-props-no-spreading
    <div ref={setNodeRef} style={style} {...attributes} {...listeners}>
      {children}
    </div>
  );
}

function DndSample(): JSX.Element {
  const [state, setState] =
    useState<{ id: string; content: string }[]>(contents);
  const handleDragEnd = useCallback(
    (event) => {
      const { active, over } = event;
      if (over === null) {
        return;
      }
      if (active.id !== over.id) {
        const oldIndex = state
          .map((item) => {
            return item.id;
          })
          .indexOf(active.id);
        const newIndex = state
          .map((item) => {
            return item.id;
          })
          .indexOf(over.id);
        const newState = arrayMove(state, oldIndex, newIndex);
        setState(newState);
      }
    },
    [state]
  );

  return (
    <DndContext onDragEnd={handleDragEnd}>
      <SortableContext items={state}>
        <div style={{ display: "flex", flexWrap: "wrap" }}>{/* スタイル調整用 */}
          {state.map((item) => (
            <SortableItem key={item.id} id={item.id}>
              <div>
                {item.content}
              </div>
            </SortableItem>
          ))}
        </div>
      </SortableContext>
    </DndContext>
  );
}
8つの移動可能なカードアイテムを持つリストコンポーネント

このカードの中に、カードを削除するボタンを追加してみます。

function DndSample(): JSX.Element {

  ...

  const handleClick = (index: string) => {
    // item.id と渡ってきた index が合致している時にアイテムを削除する
    setState(state.filter((item) => item.id !== index));
  };

  return (
    <DndContext onDragEnd={handleDragEnd}>
      <SortableContext items={state}>
        <div style={{ display: "flex", flexWrap: "wrap" }}>
          {state.map((item) => (
            <SortableItem key={item.id} id={item.id}>
              <div>
                {/* 削除ボタンを追加 */}
                <button
                  type="button"
                  onClick={() => {
                    handleClick(item.id);
                  }}
                >
                  Remove Item
                </button>
                {item.content}
              </div>
            </SortableItem>
          ))}
        </div>
      </SortableContext>
    </DndContext>
  );
}

これでボタンをクリックした時にリストからカードが削除されるというのが理想の動作ですが、この実装だけで普通にボタンを押してもドラッグ&ドロップの挙動が優先されるので、ボタンに紐付いたイベントを発火させることができません。

ドラッグ&ドロップが優先されてしまい削除することができない

これを解決するには2つの解決方法があります。

1. ドラッグ可能領域を指定する

1つ目は、ドラッグ可能領域をカード全体にするのではなくカード内の要素に指定するやり方です。

function SortableItem({ id, children }: SortableItemProps) {
  const { attributes, listeners, setNodeRef, transform, transition } =
    useSortable({ id });

  ...

  return (
    <div ref={setNodeRef} style={style}>
      {/* 親の div ではなく button を追加して attributes と listeners を渡す */}
      {/* eslint-disable-next-line react/jsx-props-no-spreading */}
      <button type="button" {...attributes} {...listeners}>
        Drag Handle
      </button>
      {children}
    </div>
  );
}

useSortable から渡ってくる attributes と listeners を特定の要素に渡すことで、その要素をドラッグすることで移動できるようになります。

Drag Handleでドラッグ&ドロップすることで削除ボタンも問題なく動いている

しかし、移動可能領域はカード全体にして、ボタンをクリックした時だけボタンに紐付いたイベントを発火させたいときがあるかと思います。

それを解決するのが次の方法です。

2. カスタムデータ属性 data-dndkit-disabled-dnd-flag が渡されている要素はドラッグ&ドロップを無効にする

カスタムデータ属性が指定されている要素はドラッグ&ドロップを無効にするというやり方でも解決ができます。

import { ReactElement, useCallback, useState } from "react";
import type { MouseEvent, KeyboardEvent } from "react";

import {
  DndContext,
  MouseSensor as LibMouseSensor,
  KeyboardSensor as LibKeyboardSensor,
  useSensor,
  useSensors,
} from "@dnd-kit/core";
import { arrayMove, SortableContext, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";

// data-dndkit-disabled-dnd-flag="true" が指定されている要素はドラッグ無効にする
function shouldHandleEvent(element: HTMLElement | null) {
  let cur = element;

  while (cur) {
    if (cur.dataset && cur.dataset.dndkitDisabledDndFlag) {
      return false;
    }
    cur = cur.parentElement;
  }

  return true;
}

// LibMouseSensor を override してドラッグ無効にする
class MouseSensor extends LibMouseSensor {
  static activators = [
    {
      eventName: "onMouseDown" as const,
      handler: ({ nativeEvent: event }: MouseEvent): boolean => {
        return shouldHandleEvent(event.target as HTMLElement);
      },
    },
  ];
}

// LibKeyboardSensor を override してドラッグ無効にする
class KeyboardSensor extends LibKeyboardSensor {
  static activators = [
    {
      eventName: "onKeyDown" as const,
      handler: ({ nativeEvent: event }: KeyboardEvent<Element>): boolean => {
        return shouldHandleEvent(event.target as HTMLElement);
      },
    },
  ];
}

...

function DndSample(): JSX.Element {

  ...

  // useSensor と useSensors を使って上書きした Sensor を DndContext に紐付ける
  const mouseSensor = useSensor(MouseSensor);
  const keyboardSensor = useSensor(KeyboardSensor);
  const sensors = useSensors(mouseSensor, keyboardSensor);

  return (
    {/* useSensors でまとめた sensors を DndContext に渡す */}
    <DndContext onDragEnd={handleDragEnd} sensors={sensors}>
      <SortableContext items={state}>
        <div style={{ display: "flex", flexWrap: "wrap" }}>{/* スタイル調整用 */}
          {state.map((item) => (
            <SortableItem key={item.id} id={item.id}>
              <div>
                <button
                  type="button"
                  onClick={() => {
                    handleClick(item.id);
                  }}
                  data-dndkit-disabled-dnd-flag="true"
                >
                  Remove Item
                </button>
                {item.content}
              </div>
            </SortableItem>
          ))}
        </div>
      </SortableContext>
    </DndContext>
  );
}

このやり方では dnd kit の持つ Sensor という機能を使用します。

Sensor は、その名の通りマウスやキーボード等の入力を検知するための機能です。ビルトインではPointerSensor, MouseSensor, TouchSensor, KeyboardSensor が用意されており、それらを useSensors でまとめて DndContext に渡すことで入力を検知することができます。

この Sensor をイベントハンドラとして特定のイベントを渡す(この場合は onClick もしくは onKeyDown 時にフォーカスしている要素のカスタムデータ属性をチェックする)ことで、カスタムデータ属性を持っている時にドラッグ&ドロップを無効にすることができます。

削除ボタン上ではドラッグ&ドロップが無効化され、なおかつそれ以外ではドラッグ&ドロップが可能になっている

これで、カード全体を移動可能にし、なおかつボタンをクリックした時だけ削除することができるようになりました。

まとめ

今日は dnd kit を使って実装したドラッグ&ドロップ可能なアイテムの中に削除ボタンを追加する時のハマりどころとその解決方法についてまとめました。

dnd kit を使っている方の参考にしていただければと思います。

Gaji-Laboでは、React経験が豊富なフロントエンドエンジニアを募集しています

弊社ではReactの知見で事業作りに貢献したいフロントエンドエンジニアを募集しています。大きな制作会社や事業会社とはひと味もふた味も違うGaji-Laboを味わいに来ませんか?

もちろん、一緒にお仕事をしてくださるパートナーさんも随時募集中です。まずはお気軽に声をかけてください。お仕事お問い合わせや採用への応募、共に大歓迎です!

求人応募してみる!

投稿者 Ishigaki Shotaro

アシスタントエンジニアとしてHTML/CSS/JavaScriptの実装やRailsの組み込み、スタイルガイドの構築などを担当しています。 業務の中でさまざまな学びを吸収しながら、文書構造やアクセシビリティに目を向けたマークアップの学習やJavaScriptの学習などを行っています。チームに貢献できるエンジニアとなるために日々奮闘中です。