drag-and-drop について
drag-and-dropは、@atlaskit/pragmatic-drag-and-dropでドラッグ&ドロップ画面を実装するコーディングスキルです。Reactでのドラッグ項目、リスト単位のスコープ、ドロップ表示、並べ替えの保存方法を示します。
主な機能
- 01ドラッグ項目とドロップ先の設定
- 02refによる変化しやすい状態の管理
- 03リストインスタンスIDによる操作範囲の限定
- 04エッジに応じたドロップ表示
- 05楽観的更新による項目の並べ替え
活用シーン
- 01Reactでのソート可能なリスト作成
- 02ドラッグハンドルとカスタムプレビューの追加
- 03項目の順序変更と位置の保存
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ wodsmith/ thewodapp --skill drag-and-dropインストール後、Claude Code で/drag-and-dropを入力して読み込まれたか確認します。
詳細
Pragmatic DnD を使ったドラッグ&ドロップ
このプロジェクトでは、ドラッグ&ドロップ機能に @atlaskit/pragmatic-drag-and-drop を使用します。
必須のインポート
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"
import {
draggable,
dropTargetForElements,
type ElementDropTargetEventBasePayload,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter"
import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview"
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview"
import {
attachClosestEdge,
type Edge,
extractClosestEdge,
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"
import { DropIndicator } from "@atlaskit/pragmatic-drag-and-drop-react-drop-indicator/box"
重要なパターン:変化しやすい状態には Ref を使う
変化しやすいドラッグ状態を useEffect の依存配列に絶対に入れないでください。 状態が変化するたびにハンドラーが再登録されます。
// BAD - re-registers handlers on every edge change
const [closestEdge, setClosestEdge] = useState<Edge | null>(null)
useEffect(() => {
// ...handlers using closestEdge
}, [closestEdge]) // Re-runs on every drag movement!
// GOOD - use ref + useCallback for volatile state
const [closestEdge, setClosestEdge] = useState<Edge | null>(null)
const closestEdgeRef = useRef<Edge | null>(null)
// Wrap in useCallback for lint compliance (exhaustive-deps)
const updateClosestEdge = useCallback((edge: Edge | null) => {
closestEdgeRef.current = edge
setClosestEdge(edge) // Still update state for rendering
}, [])
useEffect(() => {
// ...handlers read closestEdgeRef.current instead
}, [/* stable deps */, updateClosestEdge]) // Include updateClosestEdge
useCallback をインポートします:
import { useCallback, useEffect, useRef, useState } from "react"
ドラッグ可能なアイテムの基本パターン
function DraggableItem({ item, index, instanceId, onDrop }) {
const ref = useRef<HTMLDivElement>(null)
const dragHandleRef = useRef<HTMLButtonElement>(null)
const [isDragging, setIsDragging] = useState(false)
const [closestEdge, setClosestEdge] = useState<Edge | null>(null)
const closestEdgeRef = useRef<Edge | null>(null)
const updateClosestEdge = useCallback((edge: Edge | null) => {
closestEdgeRef.current = edge
setClosestEdge(edge)
}, [])
useEffect(() => {
const element = ref.current
const dragHandle = dragHandleRef.current
if (!element || !dragHandle) return
const itemData = { id: item.id, index, instanceId }
return combine(
draggable({
element: dragHandle,
getInitialData: () => itemData,
onDragStart: () => setIsDragging(true),
onDrop: () => setIsDragging(false),
onGenerateDragPreview({ nativeSetDragImage }) {
setCustomNativeDragPreview({
nativeSetDragImage,
getOffset: pointerOutsideOfPreview({ x: "16px", y: "8px" }),
render({ container }) {
const preview = document.createElement("div")
preview.style.cssText = `
background: hsl(var(--background));
border: 2px solid hsl(var(--border));
border-radius: 6px;
padding: 8px 12px;
font-size: 14px;
color: hsl(var(--foreground));
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
`
preview.textContent = item.label
container.appendChild(preview)
},
})
},
}),
dropTargetForElements({
element,
canDrop: ({ source }) =>
source.data.instanceId === instanceId && source.data.index !== index,
getData({ input }) {
return attachClosestEdge(itemData, {
element,
input,
allowedEdges: ["top", "bottom"],
})
},
onDrag({ source, self }: ElementDropTargetEventBasePayload) {
if (source.data.index === index) {
updateClosestEdge(null)
return
}
const edge = extractClosestEdge(self.data)
const sourceIndex = source.data.index
if (typeof sourceIndex !== "number") return
// Hide indicator when it would be redundant
const isItemBeforeSource = index === sourceIndex - 1
const isItemAfterSource = index === sourceIndex + 1
const isDropIndicatorHidden =
(isItemBeforeSource && edge === "bottom") ||
(isItemAfterSource && edge === "top")
updateClosestEdge(isDropIndicatorHidden ? null : edge)
},
onDragLeave: () => updateClosestEdge(null),
onDrop({ source }) {
const sourceIndex = source.data.index
if (typeof sourceIndex === "number" && sourceIndex !== index) {
const edge = closestEdgeRef.current // Read from ref!
const targetIndex = edge === "top" ? index : index + 1
const adjustedTargetIndex =
sourceIndex < targetIndex ? targetIndex - 1 : targetIndex
onDrop(sourceIndex, adjustedTargetIndex)
}
updateClosestEdge(null)
},
}),
)
}, [item.id, item.label, index, instanceId, onDrop, updateClosestEdge])
return (
<div ref={ref} className="relative">
{closestEdge && <DropIndicator edge={closestEdge} gap="2px" />}
<div className={isDragging ? "opacity-50" : ""}>
<button ref={dragHandleRef} type="button" aria-label="Drag to reorder">
<GripVertical />
</button>
{/* Item content */}
</div>
</div>
)
}
複数のリストで使うインスタンス ID
ドラッグ操作を単一のリストに限定するには、Symbol を使います:
function SortableList({ items }) {
const [instanceId] = useState(() => Symbol("list"))
// Pass instanceId to each item
}
並べ替えハンドラー
const handleDrop = async (sourceIndex: number, targetIndex: number) => {
const newItems = [...items]
const [movedItem] = newItems.splice(sourceIndex, 1)
if (movedItem) {
newItems.splice(targetIndex, 0, movedItem)
const updated = newItems.map((item, i) => ({ ...item, position: i }))
setItems(updated) // Optimistic update
await saveOrder(updated) // Persist
}
}
チェックリスト
- 変化しやすい状態には Ref を使う(closestEdge など)
- updateClosestEdge を useCallback でラップする(Lint への準拠)
- useEffect の依存配列に updateClosestEdge を含める
- リストのスコープ設定にインスタンス ID を使う
- エッジ検出を使ったドロップインジケーター
- カスタムのドラッグプレビュー
- 楽観的 UI 更新
コメント
Markdown 対応