dnd kit でソート可能アイテム内にボタンがある時にボタンのクリックとアイテムのソートを両方できるようにする

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

今回は、ドラッグ&ドロップでソートするコンポーネントを実装できる React 用ライブラリの dnd kit で、ソート可能アイテム内にボタンがある時にボタンのクリックとアイテムのソートを両方できるようにする方法についてまとめてみます。

前回の記事の応用的な部分もありますので、そちらも併せてお読みいただければと思います。

やりたいこと

前回の記事では、ソート可能アイテムの中にボタンがある時、以下のようにドラッグイベント or クリックイベントのどちらかを発火するようにイベントの発火領域を切り分ける方法を取っていました。

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

これでも解決は出来るのですが、時には以下のようにソート可能アイテムのほぼ全体にボタンを置きたいケースがあるかと思います。

カードをドラッグした時はドラッグ&ドロップでソートできるようにしたいが、中の画像をクリックした時は画像をモーダルで拡大表示できるようにしたい

こういったケースの場合は dnd kit に用意されている、マウスやキーボードなどのデバイスからのイベントを検知できるユーティリティの useSensor を使うことで、ドラッグイベントの発生を遅らせて、クリックした時はクリックイベントを、ドラッグした時はドラッグイベントをそれぞれ発火させることが可能になります。

実装

ソート可能アイテムの実装

import { useCallback, useState } from "react";

import {
  DndContext,
  KeyboardSensor,
  MouseSensor,
  useSensor,
  useSensors,
} from "@dnd-kit/core";
import { arrayMove, SortableContext, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Box, Button, Modal } from "@mui/material";

const items = [
  "http://placekitten.com/g/200/300",
  "http://placekitten.com/g/300/300",
  "http://placekitten.com/g/400/300",
  "http://placekitten.com/g/500/300",
];

const contents = items.map((item) => ({
  id: item,
  content: item,
}));

interface SortableItemProps {
  id: string;
  imgSrc: string;
  index: number;
}

function SortableItem({ id, imgSrc, index }: SortableItemProps) {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({ id });
  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    zIndex: isDragging ? 1 : 0,
    // スタイル調整用
    display: "flex",
    alignItems: "center",
    justifyContent: "center",
    margin: 4,
    borderRadius: 4,
    width: "150px",
    height: "150px",
    border: "1px solid black",
    backgroundColor: "white",
    padding: 16,
  };
  const [open, setOpen] = useState(false);
  const handleOpen = () => setOpen(true);
  const handleClose = () => setOpen(false);

  return (
    // eslint-disable-next-line react/jsx-props-no-spreading
    <div ref={setNodeRef} style={style} {...attributes} {...listeners}>
      <Button onClick={handleOpen} sx={{ width: "100%", height: "100%" }}>
        <img
          src={imgSrc}
          alt={`${index + 1}番目の画像のサムネイル`}
          style={{
            height: "100%",
            width: "100%",
            objectFit: "contain",
          }}
        />
      </Button>
      <Modal open={open} onClose={handleClose}>
        <Box
          sx={{
            position: "absolute",
            top: "50%",
            left: "50%",
            transform: "translate(-50%, -50%)",
            width: 400,
            bgcolor: "background.paper",
            border: "2px solid #000",
            boxShadow: 24,
            p: 4,
          }}
        >
          <img
            src={imgSrc}
            alt={`${index + 1}番目の画像`}
            style={{
              height: "100%",
              width: "100%",
              objectFit: "contain",
            }}
          />
        </Box>
      </Modal>
    </div>
  );
}

ソート可能アイテムの実装はほぼ前回作成したコンポーネントの流用です。

SortableItem 内で、MUI の Modal コンポーネントを使用し、サムネイルをクリックした際にモーダルで画像を表示する実装を追加しています。

ソート可能領域の実装

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

  const mouseSensor = useSensor(MouseSensor, {
    activationConstraint: {
      distance: 5, // 5px ドラッグした時にソート機能を有効にする
    },
  });
  const keyboardSensor = useSensor(KeyboardSensor);
  const sensors = useSensors(mouseSensor, keyboardSensor);

  return (
    <DndContext onDragEnd={handleDragEnd} sensors={sensors}>{/* DndContext に sensors を渡して有効化 */}
      <SortableContext items={state}>
        {state.map((item, index) => (
          <SortableItem
            key={item.id}
            id={item.id}
            imgSrc={item.content}
            index={index}
          />
        ))}
      </SortableContext>
    </DndContext>
  );
}

ポイントとしては、mouseSensoractivationConstraint: {distance: 5} を渡しています。

これは 5px ドラッグしたらソート機能を有効にする という実装です。

これでクリックした時 = ドラッグしていない時にはソートせずにボタンのクリックイベントを発火させることが可能になります。

ドラッグしていない時にはソートせずにボタンがクリックでき、ドラッグ時にはソートできる

まとめ

今回はドラッグ&ドロップでソートするコンポーネントを実装できる React 用ライブラリの dnd kit で、ソート可能アイテム内にボタンがある時にボタンのクリックとアイテムのソートを両方できるようにする方法についてまとめてみました。

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

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

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

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

求人応募してみる!

投稿者 Ishigaki Shotaro

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