mcpField
K
kanban-dnd

kanban-dnd

github.com/blink-newデザイン・UI70 回インストール
リポジトリ

kanban-dnd について

kanban-dnd は、@dnd-kit を使ったカンバンやパイプラインボードのドラッグ&ドロップ実装を支援するスキルです。列間のカード移動、衝突判定、デスクトップとタッチ操作の細部を扱います。

主な機能

  1. 01列を優先する衝突判定
  2. 02列をまたぐカード移動
  3. 03カーソルに合わせるドラッグオーバーレイ
  4. 04マウスとタッチセンサーの設定
  5. 05取り消し可能なドラッグ操作

活用シーン

  1. 01カードをドラッグできるカンバンボードの構築
  2. 02列間移動でドロップ先の誤認を防止
  3. 03即時のマウスドラッグとタッチスクロールに対応
  4. 04移動や並べ替えに取り消しとやり直しを追加

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/blink-new/claude --skill kanban-dnd

インストール後、Claude Code で/kanban-dndを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

assets/components/
kanban-board.tsx
kanban-card.tsx
kanban-column.tsx
assets/lib/
collision-detection.ts

詳細

このスキルを使う場面

以下のような場合に使用します:

  • ドラッグ&ドロップに対応したカンバン/パイプラインボードを構築する
  • 列間でカードを移動できるようにする
  • 列をカードより優先する適切な衝突判定が必要な場合
  • カーソルに追従するオーバーレイを使った、Linear/Notion風のドラッグ操作を実現する

技術スタック

パッケージバージョン用途
@dnd-kit/core^6.xDnD のコアコンテキストとフック
@dnd-kit/modifiers^9.xカーソルのスナップ用モディファイア
@dnd-kit/utilities^3.xCSS トランスフォーム用ユーティリティ

インストール

bun add @dnd-kit/core @dnd-kit/modifiers @dnd-kit/utilities

アーキテクチャの概要

┌─────────────────────────────────────────────────────────────┐
│  DndContext (sensors, collision detection, event handlers) │
├─────────────────────────────────────────────────────────────┤
│  ┌─────────┐  ┌─────────┐  ┌─────────┐  ┌─────────┐        │
│  │ Column  │  │ Column  │  │ Column  │  │ Column  │        │
│  │(droppa- │  │(droppa- │  │(droppa- │  │(droppa- │        │
│  │  ble)   │  │  ble)   │  │  ble)   │  │  ble)   │        │
│  │ ┌─────┐ │  │ ┌─────┐ │  │ ┌─────┐ │  │         │        │
│  │ │Card │ │  │ │Card │ │  │ │Card │ │  │  Empty  │        │
│  │ │drag │ │  │ │drag │ │  │ │drag │ │  │         │        │
│  │ └─────┘ │  │ └─────┘ │  │ └─────┘ │  │         │        │
│  │ ┌─────┐ │  │ ┌─────┐ │  │         │  │         │        │
│  │ │Card │ │  │ │Card │ │  │         │  │         │        │
│  │ └─────┘ │  │ └─────┘ │  │         │  │         │        │
│  └─────────┘  └─────────┘  └─────────┘  └─────────┘        │
├─────────────────────────────────────────────────────────────┤
│  DragOverlay (follows cursor with snapCenterToCursor)       │
└─────────────────────────────────────────────────────────────┘

重要なパターン

1. カスタム衝突判定(最重要)

問題: デフォルトの衝突判定では、カード上にドロップした際に列 ID ではなくカード ID が返されることがあります。

解決策: 列のドロップ可能要素を優先するカスタム衝突判定を使用します。

// lib/dnd/collision-detection.ts
import {
  pointerWithin,
  rectIntersection,
  type CollisionDetection,
} from "@dnd-kit/core";

export const columnPriorityCollision: CollisionDetection = (args) => {
  // First, check pointer intersections
  const pointerCollisions = pointerWithin(args);
  
  // Filter to only column collisions (columns have data.type === 'column')
  const columnCollisions = pointerCollisions.filter(
    (collision) => collision.data?.droppableContainer?.data?.current?.type === "column"
  );

  if (columnCollisions.length > 0) {
    return columnCollisions;
  }

  // Fallback to rect intersection
  const rectCollisions = rectIntersection(args);
  const columnRectCollisions = rectCollisions.filter(
    (collision) => collision.data?.droppableContainer?.data?.current?.type === "column"
  );

  if (columnRectCollisions.length > 0) {
    return columnRectCollisions;
  }

  return rectCollisions;
};
2. データ型を指定した列の設定

衝突判定のフィルタリングに使うため、列には data.type を含める必要があります。

const { setNodeRef } = useDroppable({
  id: column.id,
  data: {
    type: "column",  // CRITICAL: Enables collision filtering
    columnId: column.id,
  },
});
3. useDraggable を使ったカードの設定(useSortable は使わない)

列内での並べ替えをせずに列間でカードを移動する場合は、useDraggable を使用します。

const {
  attributes,
  listeners,
  setNodeRef,
  isDragging,
} = useDraggable({
  id: card.id,
  data: {
    type: "card",
    card,
  },
});

// Apply to entire card for drag-anywhere behavior
<Card
  ref={setNodeRef}
  {...attributes}
  {...listeners}
  className="cursor-grab active:cursor-grabbing touch-none select-none"
>
4. ドロップ先を列 ID に解決する

列へのドロップとカードへのドロップの両方に対応します。

const findTargetColumnId = useCallback(
  (overId: UniqueIdentifier | undefined): string | null => {
    if (!overId) return null;
    
    const overIdStr = String(overId);
    
    // Check if it's a column ID directly
    if (columnIds.has(overIdStr)) {
      return overIdStr;
    }
    
    // Otherwise it's a card ID - find which column that card is in
    const columnId = cardColumnMap.get(overIdStr);
    return columnId || null;
  },
  [columnIds, cardColumnMap]
);
5. カーソルにスナップする DragOverlay

問題: デフォルトの DragOverlay はカーソルからずれて表示されます。

解決策: snapCenterToCursor モディファイアを使用し、ドロップアニメーションを無効にします。

import { snapCenterToCursor } from "@dnd-kit/modifiers";

<DragOverlay
  modifiers={[snapCenterToCursor]}
  dropAnimation={null}  // Prevents fly-out effect on drop
>
  {activeCard ? (
    <div className="w-[272px] pointer-events-none">
      <Card card={activeCard} isDragging />
    </div>
  ) : null}
</DragOverlay>
6. センサーの設定 — 即座にドラッグを開始し、モバイルでのスクロールにも対応

delay ではなく、distance 制約を使用してください。 distance 制約では、ポインターが数 px 動いた時点でドラッグが始まるため、操作がすぐに反応します。また、動かさずにクリックした場合は、カードの onClick が通常どおり実行されます。delay 制約では、カードをドラッグできるようになる前にユーザーが長押しする必要があり、遅延が終わる前に素早くフリックするとドラッグが完全にキャンセルされます。その場合、カードはまったく動きません。これは「長押ししないとドラッグできない」という、非常によくあるバグです。

// ✅ Instant on mouse, still scrolls on touch.
// MouseSensor (mouse only) + TouchSensor (touch only) beats a single
// PointerSensor, which can't both start instantly on mouse AND let a finger
// swipe scroll the board on mobile.
const sensors = useSensors(
  useSensor(MouseSensor, {
    activationConstraint: { distance: 4 }, // drag starts after ~4px, no hold
  }),
  useSensor(TouchSensor, {
    // A short press-and-hold on touch so a vertical swipe scrolls the board
    // instead of grabbing a card. Touch is the one place a small delay is right.
    activationConstraint: { delay: 200, tolerance: 8 },
  }),
  useSensor(KeyboardSensor, {
    coordinateGetter: sortableKeyboardCoordinates,
  })
);

モバイルでのスクロールが不要な場合は、単一の PointerSensor に { distance: 4 } を設定すれば問題なく、ドラッグもすぐに開始します。重要なルールは、delay ではなく distance を使うことです。

7. 視覚的なフィードバック状態
// Board tracks active states
const [activeCard, setActiveCard] = useState<Card | null>(null);
const [activeOverColumn, setActiveOverColumn] = useState<string | null>(null);

// Pass to columns for visual feedback
<Column
  isOver={activeOverColumn === column.id}
  isDragging={!!activeCard}
/>
8. 列内での並べ替えと列のソート(上級)

上記のパターンでは、useDraggable を使った列をまたぐ移動を扱っています。列内でカードを並べ替える必要がある場合や、列自体を並べ替える必要がある場合は、useSortable とネストした SortableContext に切り替えてください(すべて 1 つの DndContext 内に配置します)。

<SortableContext items={columnIds} strategy={horizontalListSortingStrategy}>
  {columns.map((col) => <Column key={col.id} column={col} />)}
</SortableContext>

// Inside each Column, its cards get their own vertical context:
<SortableContext items={cardIds} strategy={verticalListSortingStrategy}>
  {cards.map((c) => <Card key={c.id} card={c} />)}
</SortableContext>

onDragEnd では、active.data.current.type("column" と "card")に応じて分岐し、列を並べ替えるのか、カードを移動するのかを判断します。

注意点 — ソート可能な列はドロップターゲットも兼ねています。両方を登録しないでください。 useSortable を使うと、ノードはすでにドロップ可能になります。同じ列に対してさらに useDroppable({ id: column.id }) を呼び出すと、同じ ID のドロップ可能要素が 2 つ登録され、衝突判定が壊れます(カードが安定してドロップされなくなります)。useSortable だけを使用してください。これにより、setNodeRef(ドロップターゲット)、attributes/listeners(ドラッグハンドル)、isOver が得られます。

// ✅ One registration is both the card drop target and the column drag source
const { setNodeRef, attributes, listeners, transform, transition, isOver } =
  useSortable({ id: column.id, data: { type: "column", columnId: column.id } });
// ❌ Do NOT also do: useDroppable({ id: column.id })

ドロップインジケーター:常時表示される隙間ではなく、ホバー中のカードに線を表示します。 ポインターが置かれているカード 1 枚にだけ(isOver)、細い挿入線を表示します。ドラッグ中のアイテムのインデックスとこのカードのインデックスに基づいて、カードの上または下に配置してください。ソート可能なリストで生じる隙間だけに頼らないでください。はっきりした線のほうがずっと見やすく、関係する場所にだけ表示されます。

const { isOver, active, index } = useSortable({ id: card.id, data: { ... } });
const showIndicator = isOver && active?.id !== card.id;
let above = false, below = false;
if (showIndicator) {
  if (active?.data.current?.columnId !== card.columnId) {
    above = true; // cross-column: insert before the hovered card
  } else {
    const activeIndex = active?.data.current?.sortable?.index ?? -1;
    above = activeIndex > index;             // dragging up → line above
    below = activeIndex < index && activeIndex !== -1; // dragging down → below
  }
}
// Card root must be `relative`. Lines are absolutely positioned so DOM order
// doesn't matter:
{above && <div className="absolute -top-1 left-0 right-0 h-0.5 rounded-full bg-primary z-10" />}
{below && <div className="absolute -bottom-1 left-0 right-0 h-0.5 rounded-full bg-primary z-10" />}
// Dragged original is just dimmed in place:
className={cn("relative", isDragging && "opacity-30 z-50")}

「カードが元に戻ってから、リリース時に所定の位置へ移動する」ちらつきは、ほとんどの場合、DragOverlay に dropAnimation={null} が設定されていないことが原因です(#5 を参照)。デフォルトのドロップアニメーションでは、リストが新しい順序に落ち着く前に、浮動クローンが元の位置へ戻ります。必ず dropAnimation={null} を設定してください。

ドラッグハンドルはホバー時に表示します。ただし、不透明度だけでなく、幅も折りたたんでください。 常に表示されるグリップ(⠿)は視覚的なノイズになるため、列のルートに group/col を指定し、ホバー時だけ表示します。落とし穴は、opacity-0 group-hover/col:opacity-100 だけではハンドルのレイアウト領域が残ることです。幅と隣接する flex の gap がそのまま残るため、グリップが見えない間も、タイトルは存在しない余白によって常に内側にずれます。(ハンドルを絶対配置にすると、タイトルに重なったり、列の端からはみ出したりして、別の問題が生じます。)

必要なのは次の状態です。

idle  → [no space][Title]            handle reserves nothing, title flush
hover → [⠿ space ][Title]            handle expands in-flow, title slides right

アイドル時はハンドルの幅をゼロにし、ホバー時に広げて実現します。また、ヘッダーから flex の gap を削除してください(幅がゼロの子要素の隣にも gap は適用されます)。ハンドル自身にマージンを持たせることで、ホバー時だけ表示されるようにします。

{/* header: NO `gap` — the handle manages its own spacing */}
<div className="flex items-center px-1">
  <button
    {...attributes} {...listeners}
    className="flex w-0 shrink-0 items-center overflow-hidden opacity-0
               transition-all duration-150
               group-hover/col:mr-1 group-hover/col:w-4 group-hover/col:opacity-100
               cursor-grab active:cursor-grabbing touch-none"
  >
    <GripVertical className="h-4 w-4 shrink-0" />
  </button>
  <span className="font-semibold">{name}</span>
  <span className="ml-2 …">{count}</span>   {/* space the rest explicitly */}
</div>

w-0 → w-4(および mr)をトランジションさせると、グリップがフェードインするのに合わせてタイトルが滑らかに右へ移動し、アイドル時は下のカードとぴったり揃います。

列をドラッグする場合は、列全体をオーバーレイに表示し、カードのクローンは 静的 にします。 オーバーレイにはヘッダーとカードの両方を表示し、タイトルだけではなく、実物に忠実なコピーをドラッグできるようにします。ただし、実際の useSortable カードコンポーネントをオーバーレイ内に描画しないでください。カードはリスト内にもマウントされたままなので、ソート可能な ID が重複して登録され、計測や衝突判定が壊れます。列の最大高さで切り取った、軽量な静的クローン(カードと同じ見た目の通常の div)を描画してください。

<DragOverlay dropAnimation={null}>
  {activeColumn ? (
    <div className="w-[272px] rotate-[2deg] rounded-xl border bg-muted/95 p-2.5 shadow-xl ...">
      <Header name={activeColumn.name} count={cards.length} />
      <div className="flex max-h-[440px] flex-col gap-2 overflow-hidden">
        {cards.map((c) => (
          <div key={c.id} className="rounded-lg border bg-card px-3 py-2.5">
            {c.title}            {/* static clone — NOT <SortableCard/> */}
          </div>
        ))}
      </div>
    </div>
  ) : activeCard ? (
    <Card card={activeCard} isDragging />
  ) : null}
</DragOverlay>
9. onDragEnd でキャッシュから列のアイテムを読み取る(注意点)

ドロップ時に新しい順序を計算するため、React Query のキャッシュから列のアイテムを読み取ることがよくあります。クエリーキーを正確に再構築しないでください。列のデータは追加パラメーター(limit、sort、includeX など)付きで取得されている可能性が高く、手作業で組み立てたキーは一致しません。そのため、getQueryData は undefined を返し、並べ替えが何もせずに終わります(「列によってドラッグが動作したり、しなかったりする」という典型的なバグです)。キャッシュキーに依存しない方法で読み取ります。すべてのリストクエリーを走査し、その列に属するものを選びます。

// ❌ Misses the cache if the real query was { columnId, limit: 200 }
const items = queryClient.getQueryData(itemKeys.list({ columnId }));

// ✅ Match by the column field, ignore other filter params
function getColumnItems(columnId: string): Item[] {
  const byId = new Map<string, Item>();
  for (const [key, data] of queryClient.getQueriesData<Item[]>({
    queryKey: itemKeys.lists(),
  })) {
    const filters = (key as unknown[])[2] as { columnId?: string } | undefined;
    if (data && filters?.columnId === columnId) {
      for (const it of data) if (!byId.has(it.id)) byId.set(it.id, it);
    }
  }
  return [...byId.values()];
}
10. 元に戻せるドラッグ操作

ドラッグ操作を Cmd+Z で元に戻せるようにするには、移動や並べ替えが成功するたびに逆操作を記録します。注意が必要なのは、列をまたいで移動した場合、移動元と移動先の両方、つまり影響を受けたすべてのバケットの以前の順序を復元する必要がある点です。移動先だけを復元すると、カードは新しい列に残るため、元に戻しても何も起きなかったように見えます。

// Capture the *pre-move* order of every affected bucket: the destination
// plus each source column a card moved out of.
record({
  label: "Move card",
  undo: async () => {
    for (const [columnId, order] of Object.entries(prevOrders)) {
      // Restoring a source column (whose saved order still includes the moved
      // card) sends the card home; restoring the destination drops it.
      await api.reorder({ columnId, itemIds: order });
    }
  },
  redo: async () => api.reorder({ columnId: destColumnId, itemIds: newOrder }),
});

各呼び出し元ではなく、ミューテーションフック内で記録してください。そうすれば、ドラッグ、キーボードショートカット、メニューなど、どのエントリーポイントからの操作も一か所で元に戻せるようになります。元に戻す/やり直しでは、履歴を適用するたびに新しいエントリーが記録されないよう、記録用フックではなく API を直接呼び出してください。

イベントハンドラーの全体像

const handleDragStart = useCallback((event: DragStartEvent) => {
  const { active } = event;
  const card = cards?.find((c) => c.id === active.id);
  if (card) {
    setActiveCard(card);
    setActiveOverColumn(card.columnId);
  }
}, [cards]);

const handleDragOver = useCallback((event: DragOverEvent) => {
  const { over } = event;
  if (!over) {
    setActiveOverColumn(null);
    return;
  }
  const targetColumnId = findTargetColumnId(over.id);
  setActiveOverColumn(targetColumnId);
}, [findTargetColumnId]);

const handleDragEnd = useCallback((event: DragEndEvent) => {
  const { active, over } = event;
  
  setActiveCard(null);
  setActiveOverColumn(null);

  if (!over) return;

  const cardId = String(active.id);
  const targetColumnId = findTargetColumnId(over.id);

  if (!targetColumnId) return;

  const card = cards?.find((c) => c.id === cardId);
  if (!card || card.columnId === targetColumnId) return;

  // Optimistic update via mutation
  updateCard.mutate({
    cardId,
    data: { columnId: targetColumnId },
  });
}, [cards, findTargetColumnId, updateCard]);

const handleDragCancel = useCallback(() => {
  setActiveCard(null);
  setActiveOverColumn(null);
}, []);

スタイリングのパターン

カードの状態
// Original card being dragged
isCurrentlyDragging && "opacity-30"

// DragOverlay card
isDragging && "shadow-xl ring-2 ring-primary/20 rotate-[1deg] cursor-grabbing"

// Entire card is draggable
"cursor-grab active:cursor-grabbing touch-none select-none"
列の状態
// Column during any drag
isDragging && "bg-muted/30"

// Column being hovered
isOver && "bg-primary/5 ring-2 ring-primary/20 ring-inset"
空の列の状態

ドロップ用のプレースホルダーは、ドラッグ中のみ表示します。ドラッグしていない空の列はスペースをまったく占有しないようにしてください。常時表示される「ここにドラッグ」ボックスを配置する必要はありません。列にある「追加」操作だけで十分です。プレースホルダーを常時表示すると、ボードが使われていないときでも見た目が煩雑になるため、よくある見苦しい問題です。

{/* ✅ Only while a card is being dragged. Idle empty column collapses. */}
{isDragging && (
  <div className={cn(
    "rounded-lg border-2 border-dashed px-2.5 py-4 text-center",
    isOver ? "border-primary/40 bg-primary/5" : "border-border/50"
  )}>
    Drop here
  </div>
)}

{/* ❌ Don't: a permanent box that reserves space when nothing is dragging */}
// <div className="border-2 border-dashed ...">Drag items here</div>

よくある間違い

❌ 列をまたぐ移動に useSortable を使う
// WRONG - useSortable is for within-list reordering
import { useSortable } from "@dnd-kit/sortable";
const { ... } = useSortable({ id: card.id });
✅ 列をまたぐ移動には useDraggable を使う
// CORRECT - useDraggable for simple drag to droppable
import { useDraggable } from "@dnd-kit/core";
const { ... } = useDraggable({ id: card.id });
❌ over.id が常に列 ID だと想定する
// WRONG - over.id could be a card ID if you drop on a card
const handleDragEnd = (event) => {
  const newColumnId = over.id as string;  // Might be a card ID!
};
✅ over.id から列 ID を特定する
// CORRECT - Handle both column and card drop targets
const targetColumnId = findTargetColumnId(over.id);
❌ snapCenterToCursor とドロップアニメーションを併用する
// WRONG - Causes fly-out effect on drop
<DragOverlay
  modifiers={[snapCenterToCursor]}
  dropAnimation={{ duration: 200, easing: "ease-out" }}
>
✅ ドロップアニメーションを無効にする
// CORRECT - Instant disappear on drop
<DragOverlay
  modifiers={[snapCenterToCursor]}
  dropAnimation={null}
>
❌ 独立したドラッグハンドルを使う
// WRONG - Creates offset issues, worse UX
<Card>
  <div {...listeners}>
    <GripIcon /> {/* Only this area is draggable */}
  </div>
  <Content />
</Card>
✅ カード全体をドラッグ可能にする
// CORRECT - Linear-style drag anywhere
<Card {...listeners} {...attributes} className="cursor-grab">
  <Content />
</Card>
❌ マウスのアクティベーションに delay を使う
// WRONG - the card only drags after a hold, and a quick flick is cancelled
useSensor(PointerSensor, { activationConstraint: { delay: 100, tolerance: 5 } })
✅ マウスのアクティベーションに distance を使う
// CORRECT - drag starts instantly on a few px of movement; click still works
useSensor(MouseSensor, { activationConstraint: { distance: 4 } })
❌ ドロップ時に列アイテムを読み取るため、クエリキーを正確に再構築する
// WRONG - won't match a cache fetched with extra params (limit/sort/…)
const items = queryClient.getQueryData(itemKeys.list({ columnId })); // undefined
✅ すべてのリストクエリから列フィールドが一致するものを探してキャッシュを読み取る

パターン #9(getColumnItems)を参照してください。並べ替えが何も起きずに失敗する場合、その原因はほとんどの場合これです。

❌ useSortable を使った列に、別途 useDroppable を登録する
// WRONG - two droppables share column.id, breaking collision detection
useSortable({ id: column.id, ... });
useDroppable({ id: column.id }); // duplicate id!
❌ Enter/Space も処理するフォーカス可能なカードに KeyboardSensor を設定する

マウスでドラッグした後、dnd-kit はドラッグしたカードに DOM フォーカスを残します。KeyboardSensor のデフォルトのアクティベーターは Space/Enter なので、そのカードにフォーカスがある状態で次に Space/Enter を押すと、「ドラッグ開始」として扱われます。そのため、別のショートカットを実行しようと Enter を押しただけで、カードが再びドラッグ状態になることがあります。カードがフォーカス可能で、アプリ内の別の操作に Enter/Space を割り当てている場合は、KeyboardSensor を削除して、明示的なショートカット(上下に移動、後回しにする、など)でキーボードによる並べ替えを実装するか、keyboardCodes を変更してください。

// ✅ Mouse + touch only; no accidental Enter/Space drags
const sensors = useSensors(
  useSensor(MouseSensor, { activationConstraint: { distance: 4 } }),
  useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } })
);

React Query を使った楽観的更新

export function useUpdateCard(teamId: string) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ cardId, data }) => updateCard(teamId, cardId, data),

    // Optimistic update
    onMutate: async ({ cardId, data }) => {
      await queryClient.cancelQueries({ queryKey: cardKeys.lists() });

      const previousCards = queryClient.getQueriesData<Card[]>({
        queryKey: cardKeys.lists(),
      });

      queryClient.setQueriesData<Card[]>(
        { queryKey: cardKeys.lists() },
        (old) => old?.map((card) =>
          card.id === cardId ? { ...card, ...data } : card
        )
      );

      return { previousCards };
    },

    // Rollback on error
    onError: (_err, _variables, context) => {
      context?.previousCards?.forEach(([queryKey, data]) => {
        queryClient.setQueryData(queryKey, data);
      });
    },

    // Refetch after settle
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: cardKeys.all });
    },
  });
}

ファイル構成

src/
├── components/
│   └── kanban/
│       ├── kanban-board.tsx       # DndContext wrapper
│       ├── kanban-column.tsx      # Droppable column
│       ├── kanban-card.tsx        # Draggable card
│       └── kanban-skeleton.tsx    # Loading state
├── lib/
│   └── dnd/
│       └── collision-detection.ts # Custom collision detection
└── hooks/
    └── use-cards.ts               # React Query with optimistic updates

関連するアセットファイル

アセット説明
assets/lib/collision-detection.ts列を優先する衝突検出
assets/components/kanban-board.tsxボードの完全な実装
assets/components/kanban-column.tsx視覚的なフィードバックを備えたドロップ可能な列
assets/components/kanban-card.tsxドラッグ可能なカードコンポーネント

チェックリスト

  • @dnd-kit/core、@dnd-kit/modifiers、@dnd-kit/utilities をインストールしている
  • センサーでは delay ではなく、distance のアクティベーション制約を使用している(ドラッグが即座に開始されます)
  • モバイルでボードをスクロールする必要がある場合は、MouseSensor(distance)と TouchSensor(delay)を使用している
  • カラムを優先するカスタムの衝突判定を使用している
  • カラムでは data.type: "column" を指定した useDroppable を使用している(カラムを並べ替える場合は useSortable を使用)
  • カードでは、カラム間の移動にのみ useDraggable を使用している。カラム内でも並べ替える場合は useSortable と SortableContext を使用している
  • useSortable を使用するカラムで、別途 useDroppable も登録していない(ID が重複するため)
  • findTargetColumnId がカラムとカードの両方のドロップ先を解決する
  • onDragEnd で、再構築したキーではなく、キャッシュキーに依存しない方法でキャッシュからカラムアイテムを読み取っている
  • DragOverlay で snapCenterToCursor モディファイアを使用している
  • DragOverlay に dropAnimation={null} を指定している(指定しないと、カードが「元に戻って」から、リリース時に所定の位置へ移動します)
  • カラム内へのドロップ時、常に表示される隙間ではなく、ホバー中のカード上(isOver)に挿入線を表示する
  • 空のカラムのドロップ用プレースホルダーはドラッグ中にのみ表示し、アイドル状態の空のカラムはスペースを占有しない
  • DragOverlay のラッパーの幅を固定し、カードの幅と一致させている
  • カードに touch-none select-none cursor-grab クラスを指定している
  • isOver 状態と isDragging 状態に対する視覚的なフィードバックがある
  • エラー時のロールバックを伴う楽観的更新を行っている
  • 取り消し可能な移動では、影響を受けるすべてのバケット(移動元+移動先)の移動前の順序を復元する

コメント