Reactでdnd-kitを使い、ドラッグで並べ替える一覧を作る方法

​​React​dnd-kit,TypeScript,アクセシビリティ

dnd-kit の @dnd-kit/sortable を使うと、ドラッグで並べ替えられる一覧を、React のコンポーネントに少し書き足すだけで作れます。マウスだけでなく、キーボードでも並べ替えられます。

検証環境で、5行の一覧をマウスとキーボードで並べ替え、どちらでも並び順が変わることを確かめました。あわせて、行の中に置いたボタンが押せなくなる場合と、その直し方を2つ調べました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@dnd-kit/core 6.3.1、@dnd-kit/sortable 10.0.0、@dnd-kit/utilities 3.2.2、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild で本番用にビルドし、Chromium 141(Playwright 1.56.1 で操作)で動かしました。StrictMode では包んでいません。

スマホなどのタッチ操作、横に並べる一覧やグリッド、複数の一覧のあいだでの行の移動、ドラッグ中の行を別の要素で描く DragOverlay の4つは、この記事では試していません。

入れるのは core と sortable と utilities の3つ

dnd-kit の土台は @dnd-kit/core です。dnd-kit の README によると、並べ替えのための @dnd-kit/sortable は、その上に作られた薄い層です。@dnd-kit/sortable の README では、中心になるのは SortableContext と useSortable の2つだと説明されています。使い方の詳しい説明は、公式ドキュメントのサイトにあると README に書かれています。

text
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities

@dnd-kit/utilities は、動かしている行の位置を CSS の transform の文字列にする CSS.Transform.toString のために入れました。

npm には、これとは別の新しいパッケージ @dnd-kit/react もあります。検証の時点の最新版は 0.5.1 でした。この記事では使っていないため、書き方がどう違うかは確かめていません。

1行ぶんを useSortable で作る

並べ替える一覧は、果物の名前を5つ並べたものにしました。各行には、'apple' のような、変わらない id を持たせています。

ts
export type Fruit = { id: string; label: string };

export const initialFruits: Fruit[] = [
  { id: 'apple', label: 'りんご' },
  { id: 'orange', label: 'みかん' },
  { id: 'grape', label: 'ぶどう' },
  { id: 'peach', label: 'もも' },
  { id: 'pear', label: 'なし' },
];

1行ぶんのコンポーネントでは、useSortable にその行の id を渡します。

tsx
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { Fruit } from './items';

// 行全体をつかんで動かせる1行
export function SortableItem({ fruit }: { fruit: Fruit }) {
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
    useSortable({ id: fruit.id });

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    opacity: isDragging ? 0.6 : 1,
  };

  return (
    <li ref={setNodeRef} style={style} className="row" {...attributes} {...listeners}>
      {fruit.label}
    </li>
  );
}

useSortable から受け取ったものは、次のように使います。

受け取るもの 使い方
setNodeRef 動かす要素の ref に渡す
listeners ドラッグを始めるためのイベントの処理。つかむ要素にスプレッド構文({...listeners})で渡す
attributes キーボードや読み上げのための属性。つかむ要素にスプレッド構文({...attributes})で渡す
transform、transition 動かしている間の位置とアニメーション。style にして渡す

検証環境で表示した1行目の li には、attributes から次の属性が付いていました(className で付けた class は除いています)。tabindex="0" があるので、Tab キーでフォーカスできます。

text
role="button" tabindex="0" aria-disabled="false" aria-roledescription="sortable" aria-describedby="DndDescribedBy-0"

DndContext と SortableContext で一覧を包む

一覧のコンポーネントでは、並び順を useState で持ちます。DndContext と SortableContext で包み、ドラッグが終わったときの onDragEnd で並び順を入れ替えます。

tsx
import { useState } from 'react';
import {
  DndContext,
  closestCenter,
  KeyboardSensor,
  PointerSensor,
  useSensor,
  useSensors,
  type DragEndEvent,
} from '@dnd-kit/core';
import {
  SortableContext,
  arrayMove,
  sortableKeyboardCoordinates,
  verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { initialFruits } from './items';
import { SortableItem } from './SortableItem';

// ドラッグで並べ替えられる縦の一覧
export function SortableList() {
  const [fruits, setFruits] = useState(initialFruits);

  const sensors = useSensors(
    useSensor(PointerSensor),
    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
  );

  function handleDragEnd(event: DragEndEvent) {
    const { active, over } = event;
    if (over && active.id !== over.id) {
      setFruits((prev) => {
        const oldIndex = prev.findIndex((f) => f.id === active.id);
        const newIndex = prev.findIndex((f) => f.id === over.id);
        return arrayMove(prev, oldIndex, newIndex);
      });
    }
  }

  return (
    <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
      <SortableContext items={fruits} strategy={verticalListSortingStrategy}>
        <ul className="list">
          {fruits.map((fruit) => (
            <SortableItem key={fruit.id} fruit={fruit} />
          ))}
        </ul>
      </SortableContext>
    </DndContext>
  );
}

sensors には、マウスなどのポインターで動かす PointerSensor と、キーボードで動かす KeyboardSensor を入れました。SortableContext の items には、id の配列か、id を持つオブジェクトの配列を渡せます。型定義で、そう決められています。ここでは fruits をそのまま渡しました。

onDragEnd では、つかんだ行(active)と、離したときに重なっていた行(over)の id が受け取れます。2つの位置を探して arrayMove で入れ替え、setFruits で新しい並び順にします。

Chromium で、1行目の「りんご」をマウスで 112px(2行ぶん)下へドラッグしました。

text
前: りんご / みかん / ぶどう / もも / なし
後: みかん / ぶどう / りんご / もも / なし

「りんご」が3番目に移りました。2回続けてドラッグしたときの様子を、GIF にしました。

マウスで、りんごを3番目へ、なしを1番目へドラッグして並べ替える様子。下の枠に、何回目のドラッグか、マウスの状態、並び順が出ている
マウスで2回ドラッグして並べ替えたときの、行の動きと並び順

下の枠は、撮影用のスクリプトが書き足した表示です。マウスのボタンを押している間は、カーソルにピンクの輪を付けています。押した瞬間は「押した」を、離して並び順が変わった瞬間は「離した」と並び順を、ピンクにしました。

キーボードでも並べ替えられる

KeyboardSensor を入れてあるので、キーボードでも並べ替えられます。Tab キーで「りんご」にフォーカスし、スペースキー、↓キーを2回、スペースキーの順に押しました。

text
後: みかん / ぶどう / りんご / もも / なし

マウスと同じく、「りんご」が3番目に移りました。スペースキー、↓キー、Escape キーの順に押したときは、並び順は元のままでした。

npm で配布されている @dnd-kit/core 6.3.1 のコード(dist)では、持ち上げるキーはスペースと Enter、置くキーはスペースと Enter と Tab、取り消すキーは Escape になっていました。

読み上げの文は、日本語にできる

dnd-kit は、ドラッグ中の動きを、スクリーンリーダー向けの領域(aria-live の付いた要素)に文で書き込みます。何も指定しないと英語で、行の id がそのまま入ります。キーボードで並べ替えたときに、この領域に入った文を記録しました。

text
aria-live に入った文: [
 "Draggable item apple was moved over droppable area apple.",
 "Draggable item apple was moved over droppable area orange.",
 "Draggable item apple was moved over droppable area grape.",
 "Draggable item apple was dropped over droppable area grape"
]

DndContext の accessibility に、announcements(動きを伝える文)と screenReaderInstructions(操作の説明)を渡すと、文を差し替えられます。id から表示名を引いて、日本語の文にしました。文を用意するファイルと、DndContext に渡す部分(抜粋)は次のとおりです。

ts
import type { Announcements, ScreenReaderInstructions } from '@dnd-kit/core';
import { initialFruits } from './items';

// id から表示名を引く(読み上げに id の 'apple' ではなく「りんご」を使うため)
const labelOf = (id: string | number) => initialFruits.find((f) => f.id === id)?.label ?? String(id);

export const screenReaderInstructions: ScreenReaderInstructions = {
  draggable:
    'スペースキーで持ち上げます。上下の矢印キーで動かし、もう一度スペースキーで置きます。Escape キーで取り消します。',
};

export const announcements: Announcements = {
  onDragStart({ active }) {
    return `${labelOf(active.id)}を持ち上げました。`;
  },
  onDragOver({ active, over }) {
    return over ? `${labelOf(active.id)}は、${labelOf(over.id)}の位置にあります。` : undefined;
  },
  onDragEnd({ active, over }) {
    return over ? `${labelOf(active.id)}を、${labelOf(over.id)}の位置に置きました。` : undefined;
  },
  onDragCancel({ active }) {
    return `${labelOf(active.id)}の移動を取り消しました。`;
  },
};
tsx
<DndContext
  sensors={sensors}
  collisionDetection={closestCenter}
  onDragEnd={handleDragEnd}
  accessibility={{ announcements, screenReaderInstructions }}
>

同じキー操作をしたところ、読み上げの領域には次の文が入りました。screenReaderInstructions の文は、行の aria-describedby が指す要素に入っていました。

text
aria-live に入った文: [
 "りんごは、りんごの位置にあります。",
 "りんごは、みかんの位置にあります。",
 "りんごは、ぶどうの位置にあります。",
 "りんごを、ぶどうの位置に置きました。"
]
キーボードで、Tab、スペース、下矢印2回、スペースの順に押して、りんごを3番目へ動かす様子。下の枠に、押したキーと、読み上げの領域に入った文が出ている
キーボードで並べ替えたときの、押したキーと読み上げの領域の文

GIF の下の枠も、撮影用のスクリプトが書き足した表示です。キーを押した瞬間はキーの名前を、読み上げの文が変わった瞬間はその文を、ピンクにしています。

記録した文の中に、onDragStart で返した「りんごを持ち上げました。」はありませんでした。持ち上げた直後から、onDragOver の文が入っていました。既定の英語の文でも、持ち上げたときの文は記録されていません。すぐに onDragOver の文で上書きされたのか、そもそも書き込まれていないのかは確かめていません。なお、実際のスクリーンリーダーでの読み上げは試していません。

行の中のボタンが押せなくなるとき

行全体に listeners を付けたまま、行の中に「削除」ボタンを置くと、ボタンが効かなくなりました。次は、その1行のコンポーネントの return の部分(抜粋)です。onDelete は、一覧から props で受け取る削除の関数です。

tsx
<li ref={setNodeRef} style={style} className="row" {...attributes} {...listeners}>
  <span>{fruit.label}</span>
  <button type="button" onClick={() => onDelete(fruit.id)}>
    削除
  </button>
</li>

2行目の「みかん」の「削除」をクリックすると、「みかん」は消えず、代わりに onDragEnd が1回呼ばれていました。

text
後: りんご / みかん / ぶどう / もも / なし  onDragEnd の回数: 1

dist のコードを見ると、PointerSensor は、ドラッグを始める条件(activationConstraint)がなければ、押した瞬間にドラッグを始めます。始めるときに、document に click を止める処理を付けます。クリックがドラッグとして扱われ、ボタンの onClick まで届かなかったと考えられます。

直し方1:少し動かしてからドラッグを始める

PointerSensor に activationConstraint を渡し、5px 動かすまではドラッグを始めないようにしました。検証のコードでは、条件のあるなしを比べるために、一覧のコンポーネントが props で distance を受け取るようにして、5 を渡しました。値を直接書く形にすると、次のようになります。

tsx
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),

dist のコードでは、動かした距離がこの distance を超えたところでドラッグが始まります。

この設定では、「削除」のクリックで「みかん」が消え、onDragEnd は呼ばれませんでした。行を 112px 下へドラッグしたときは、これまでどおり並び順が変わりました。

text
後: りんご / ぶどう / もも / なし  onDragEnd の回数: 0

なお、activationConstraint には、押し続けた時間で始める delay の条件もあります。こちらは型定義にあることだけを確かめ、動かしてはいません。

直し方2:つまみだけでドラッグを始める

もう1つは、行の左につまみ(ハンドル)を置き、listeners と attributes をそのつまみにだけ渡す方法です。つまみの要素は、setActivatorNodeRef で useSortable に伝えます。setNodeRef は、これまでどおり行全体に渡します。

tsx
function HandleRow({ fruit, onDelete }: { fruit: Fruit; onDelete: (id: string) => void }) {
  const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition } =
    useSortable({ id: fruit.id });
  const style = { transform: CSS.Transform.toString(transform), transition };

  return (
    <li ref={setNodeRef} style={style} className="row">
      <button type="button" className="handle" ref={setActivatorNodeRef} {...attributes} {...listeners} aria-label={`${fruit.label}を動かす`}>
        ⠿
      </button>
      <span>{fruit.label}</span>
      <button type="button" onClick={() => onDelete(fruit.id)}>
        削除
      </button>
    </li>
  );
}

PointerSensor には条件を付けていませんが、「削除」のクリックで「みかん」が消えました。つまみをつかんで 112px 下へドラッグすると「りんご」が3番目に移り、名前の文字の上から同じようにドラッグしたときは、並び順は変わりませんでした。

ポイント

行の中にボタンを置くなら、activationConstraint で少し動かしてからドラッグを始めるか、つまみにだけ listeners を付けてください。

​Posted by 86