Closed25
React + dnd-kit で要素をドラッグ&ドロップするテスト
ピン留めされたアイテム
これから作るアプリのイメージ
- 3つのカンバン (New, Active, Closed) にチケットを登録する
- チケットをドラッグ&ドロップしてステータスを更新する
ピン留めされたアイテム
最終的なコードはここ
とりあえず create-react-app
npx create-react-app dnd-sample --template typescript
cd dnd-sample
このあたりを参考に進める
Material UI を使うのでインストールする(dnd-kit とは関係ない)
npm install @mui/material @emotion/react @emotion/styled
チケットの id を採番するのに uuid v7 を使う
npm i uuidv7
dnd-kit もインストールしておく
npm i @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
いらないファイルを削除して定番のフォルダを作成
❯ rm -rf src/App.{css,test.tsx}
❯ rm -rf src/index.css
❯ rm -rf src/logo.svg
❯ mkdir src/{components,types,hooks,providers}
型定義
Ticket.ts
export interface Ticket {
id: string;
title: string;
}
Kanban.ts
export const KanbanStatuses = ['New', 'Active', 'Closed'] as const;
export type KanbanStatus = typeof KanbanStatuses[number];
export function isKanbanStatus(item: unknown): item is KanbanStatus {
if (typeof item === 'string') {
return KanbanStatuses.some((status) => status === item);
}
return false;
}
チケットを管理する Context
を作る
とりあえずチケットを state
に保持しておく
DataProvider.tsx
import { createContext, useCallback, useState } from 'react';
import { uuidv7 as uuid } from 'uuidv7';
import { KanbanStatus, KanbanStatuses } from '../types/Kanban';
import { Ticket } from '../types/Ticket';
/**
* カンバンのステータスごとにチケットを管理する
*/
type KanbanTickets = Record<KanbanStatus, Ticket[]>;
interface IDataContext {
tickets: KanbanTickets;
addTicket: (ticket: string) => void;
}
export const DataContext = createContext<IDataContext>(undefined!);
interface Props {
children: React.ReactNode;
}
/**
* 初期データ
*/
const emptyTickets: KanbanTickets = {
New: [],
Active: [],
Closed: [],
};
export default function DataProvider(props: Props) {
const [tickets, setTickets] = useState<KanbanTickets>(emptyTickets);
/**
* チケットの追加
*/
const addTicket = useCallback((ticket: string) => {
const newTicket: Ticket = {
id: uuid(),
title: ticket,
};
setTickets((prev) => ({
...prev,
New: [...prev['New'], newTicket],
}));
}, []);
return (
<DataContext.Provider
value={{
tickets,
addTicket,
}}
>
{props.children}
</DataContext.Provider>
);
}
チケットのカスタム hook
とりあえず DataContext の内容をそのまま返す
ticket.ts
import { useContext } from "react";
import { DataContext } from "../providers/DataProvider";
export default function useTicket() {
return useContext(DataContext);
}
チケットのコンポーネント
とりあえず dnd kit 使わないで実装
Ticket.tsx
import { Paper, Typography } from '@mui/material';
import { Ticket as TicketProps } from '../types/Ticket';
export function Ticket(props: TicketProps) {
return (
<Paper
variant="outlined"
sx={{ p: 1 }}
>
<Typography variant="body2">{props.title}</Typography>
</Paper>
);
}
TicketForm.tsx
import { Button, Stack, TextField } from '@mui/material';
import { ChangeEvent, FormEvent, useCallback, useId, useState } from 'react';
import useTicket from '../hooks/ticket';
export default function TicketForm() {
const { addTicket } = useTicket();
const [ticket, setTicket] = useState('');
const id = useId();
const handleChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setTicket(event.target.value);
}, []);
const handleSubmit = useCallback(
(event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (ticket) {
addTicket(ticket);
}
setTicket('');
},
[addTicket, ticket]
);
return (
<Stack
direction="row"
sx={{ py: 2 }}
spacing={1}
component="form"
noValidate
autoComplete="off"
onSubmit={handleSubmit}
>
<TextField
id={id}
label="チケット"
variant="outlined"
sx={{ width: 320 }}
value={ticket}
onChange={handleChange}
/>
<Button type="submit" variant="contained">
追加
</Button>
</Stack>
);
}
カンバンのコンポーネント
Kanban.tsx
import { Paper, Stack, Typography } from '@mui/material';
import { KanbanStatus } from '../types/Kanban';
interface Props {
status: KanbanStatus;
children: React.ReactNode;
}
export default function Kanban(props: Props) {
return (
<Paper
variant="outlined"
sx={{
p: 2,
flexGrow: 1,
minHeight: '100%',
display: 'flex',
flexDirection: 'column',
}}
>
<Typography variant="h6" component="h3">
{props.status}
</Typography>
<Stack direction="column" spacing={1} sx={{ mt: 1 }}>
{props.children}
</Stack>
</Paper>
);
}
作ったコンポーネントを結合して土台を作る
App.tsx
import { Container, CssBaseline, Stack } from '@mui/material';
import Kanban from './components/Kanban';
import { Ticket } from './components/Ticket';
import TicketForm from './components/TicketForm';
import useTicket from './hooks/ticket';
import { KanbanStatuses } from './types/Kanban';
export default function App() {
const { tickets } = useTicket();
return (
<>
<CssBaseline />
<Container component="main" maxWidth="xl">
<TicketForm />
<Stack
direction="row"
sx={{ minHeight: 'calc(100dvh - 110px)' }}
spacing={2}
alignItems="stretch"
>
{KanbanStatuses.map((status) => (
<Kanban key={status} status={status}>
{tickets[status].map((ticket) => (
<Ticket key={ticket.id} {...ticket} />
))}
</Kanban>
))}
</Stack>
</Container>
</>
);
}
index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import DataProvider from './providers/DataProvider';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
<React.StrictMode>
<DataProvider>
<App />
</DataProvider>
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
ここまでの成果
Ticket コンポーネントをドラッグ可能にする
Ticket.tsx
import { useDraggable } from '@dnd-kit/core';
import { Paper, SxProps, Theme, Typography } from '@mui/material';
import { useMemo } from 'react';
import { Ticket as TicketProps } from '../types/Ticket';
export function Ticket(props: TicketProps) {
const { attributes, listeners, setNodeRef, transform } = useDraggable({ id: props.id });
const style: SxProps<Theme> | undefined = useMemo(() => {
if (transform) {
return {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
};
}
}, [transform]);
return (
<Paper
ref={setNodeRef}
variant="outlined"
sx={{ p: 1, touchAction: 'none', ...style }}
{...attributes}
{...listeners}
>
<Typography variant="body2">{props.title}</Typography>
</Paper>
);
}
touchAction: 'none'
をつけないとタッチデバイスでうまく操作できない
カンバンにドロップ可能にする
Kanban.tsx
import { useDroppable } from '@dnd-kit/core';
import { Paper, Stack, Typography } from '@mui/material';
import { KanbanStatus } from '../types/Kanban';
interface Props {
status: KanbanStatus;
children: React.ReactNode;
}
export default function Kanban(props: Props) {
const { isOver, setNodeRef } = useDroppable({ id: props.status });
return (
<Paper
ref={setNodeRef}
variant="outlined"
sx={{
p: 2,
backgroundColor: isOver ? 'grey.300' : undefined,
flexGrow: 1,
minHeight: '100%',
display: 'flex',
flexDirection: 'column',
}}
>
<Typography variant="h6" component="h3">
{props.status}
</Typography>
<Stack direction="column" spacing={1} sx={{ mt: 1 }}>
{props.children}
</Stack>
</Paper>
);
}
チケットの移動処理を追加
DataProvider.tsx
import { createContext, useCallback, useState } from 'react';
import { uuidv7 as uuid } from 'uuidv7';
import { KanbanStatus, KanbanStatuses } from '../types/Kanban';
import { Ticket } from '../types/Ticket';
/**
* カンバンのステータスごとにチケットを管理する
*/
export type KanbanTickets = Record<KanbanStatus, Ticket[]>;
interface IDataContext {
tickets: KanbanTickets;
addTicket: (ticket: string) => void;
moveTicket: (ticketId: string, status: KanbanStatus) => void;
}
export const DataContext = createContext<IDataContext>(undefined!);
interface Props {
children: React.ReactNode;
}
/**
* 初期データ
*/
const emptyTickets: KanbanTickets = {
New: [],
Active: [],
Closed: [],
};
export default function DataProvider(props: Props) {
const [tickets, setTickets] = useState<KanbanTickets>(emptyTickets);
/**
* チケットの追加
*/
const addTicket = useCallback((ticket: string) => {
const newTicket: Ticket = {
id: uuid(),
title: ticket,
};
setTickets((prev) => ({
...prev,
New: [...prev['New'], newTicket],
}));
}, []);
/**
* チケットの移動
*/
const moveTicket = useCallback(
(ticketId: string, status: KanbanStatus) => {
const newTickets = JSON.parse(JSON.stringify(tickets)) as KanbanTickets;
for (const ks of KanbanStatuses) {
// 該当チケットを探す
const index = newTickets[ks].findIndex((ticket) => ticket.id === ticketId);
if (index !== -1) {
// ステータスが変わっていなければ何もしない
if (ks === status) {
return;
}
// 該当チケットを削除
const [ticket] = newTickets[ks].splice(index, 1);
// 新しいステータスに追加
newTickets[status].push(ticket);
break;
}
}
setTickets(newTickets);
},
[tickets]
);
return (
<DataContext.Provider
value={{
tickets,
addTicket,
moveTicket,
}}
>
{props.children}
</DataContext.Provider>
);
}
移動処理の実装
あ、Ticket コンポーネントに default
付けるの忘れてた
まあいいか
App.tsx
import { DndContext, DragEndEvent } from '@dnd-kit/core';
import { Container, CssBaseline, Stack } from '@mui/material';
import { useCallback } from 'react';
import Kanban from './components/Kanban';
import { Ticket } from './components/Ticket';
import TicketForm from './components/TicketForm';
import useTicket from './hooks/ticket';
import { KanbanStatuses, isKanbanStatus } from './types/Kanban';
export default function App() {
const { tickets, moveTicket } = useTicket();
/**
* ドラッグ完了時に発生するイベント
*/
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (over === null) {
// ドラッグ先の要素がなければ終了
return;
}
// コンテナの id は KanbanStatus になっている
const status = over.id;
const ticketId = active.id;
if (isKanbanStatus(status) && typeof ticketId === 'string') {
// チケットの移動
moveTicket(ticketId, status);
}
},
[moveTicket]
);
return (
<>
<CssBaseline />
<Container component="main" maxWidth="xl">
<TicketForm />
<DndContext onDragEnd={handleDragEnd}>
<Stack
direction="row"
sx={{ minHeight: 'calc(100dvh - 110px)' }}
spacing={2}
alignItems="stretch"
>
{KanbanStatuses.map((status) => (
<Kanban key={status} status={status}>
{tickets[status].map((ticket) => (
<Ticket key={ticket.id} {...ticket} />
))}
</Kanban>
))}
</Stack>
</DndContext>
</Container>
</>
);
}
ここまでの成果
チケットをドラッグしてカンバンを移動できる
これだとカンバンを移動したらかならず末尾に追加されるので、順番を入れ替えられるようにする
ソートできるように Ticket コンポーネントを修正
チケットの核部分を抜き出したコンポーネント
Ticket.tsx
import { Paper, PaperProps, Typography } from '@mui/material';
import { forwardRef } from 'react';
import { Ticket as TicketProps } from '../types/Ticket';
type Props = PaperProps & TicketProps;
const Ticket = forwardRef<HTMLDivElement, Props>(({ id, title, sx = {}, ...props }, ref) => {
return (
<Paper
ref={ref}
variant="outlined"
sx={{ mb: 1, p: 1, touchAction: 'none', ...sx }}
{...props}
>
<Typography variant="body2">{title}</Typography>
</Paper>
);
});
export default Ticket;
チケットの各部分に useSortable
フックで取得したプロパティを追加したラッパー
SortableTicket.tsx
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { SxProps, Theme } from '@mui/material';
import { useMemo } from 'react';
import { Ticket as TicketProps } from '../types/Ticket';
import Ticket from './Ticket';
export default function SortableTicket(props: TicketProps) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
id: props.id,
});
const style: SxProps<Theme> | undefined = useMemo(() => {
return {
transform: CSS.Transform.toString(transform),
transition,
};
}, [transform, transition]);
return (
<Ticket
ref={setNodeRef}
variant="outlined"
sx={style}
{...attributes}
{...listeners}
{...props}
/>
);
}
カンバンに SortableContext
を追加する
Kanban.tsx
import { useDroppable } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { Paper, Typography } from '@mui/material';
import { KanbanStatus } from '../types/Kanban';
import { Ticket } from '../types/Ticket';
interface Props {
status: KanbanStatus;
items: Ticket[];
children: React.ReactNode;
}
export default function Kanban(props: Props) {
const { isOver, setNodeRef } = useDroppable({ id: props.status });
return (
<Paper
ref={setNodeRef}
variant="outlined"
sx={{
p: 2,
backgroundColor: isOver ? 'grey.300' : undefined,
flexGrow: 1,
minHeight: '100%',
display: 'flex',
flexDirection: 'column',
}}
>
<Typography variant="h6" component="h3">
{props.status}
</Typography>
<SortableContext items={props.items} strategy={verticalListSortingStrategy}>
{props.children}
</SortableContext>
</Paper>
);
}
DataProvider.tsx
の moveTicket
にチケット位置の考慮を追加する
DataProvider.tsx
import { createContext, useCallback, useState } from 'react';
import { uuidv7 as uuid } from 'uuidv7';
import { KanbanStatus, KanbanStatuses } from '../types/Kanban';
import { Ticket } from '../types/Ticket';
/**
* カンバンのステータスごとにチケットを管理する
*/
export type KanbanTickets = Record<KanbanStatus, Ticket[]>;
/**
* チケットの移動/並び替え関数
*/
type MoveTicketFunction = {
(status: KanbanStatus, ticketId: string, index: number): void;
(status: KanbanStatus, oldIndex: number, newIndex: number): void;
(status: KanbanStatus, ticketId: string): void;
};
/**
* カンバンの末尾に移動
* @param tickets
* @param status
* @param ticketId
* @returns
*/
function moveTicketKanbanTail(
tickets: KanbanTickets,
status: KanbanStatus,
ticketId: string
): KanbanTickets {
const newTickets = JSON.parse(JSON.stringify(tickets)) as KanbanTickets;
for (const ks of KanbanStatuses) {
// 該当チケットを探す
const index = newTickets[ks].findIndex((ticket) => ticket.id === ticketId);
if (index !== -1) {
// 該当チケットを削除
const [ticket] = newTickets[ks].splice(index, 1);
// 新しいステータスに追加
newTickets[status].push(ticket);
break;
}
}
return newTickets;
}
/**
* カンバンの指定位置にチケットを挿入
* @param tickets
* @param status
* @param ticketId
* @param insertOrder
* @returns
*/
function moveTicketAt(
tickets: KanbanTickets,
status: KanbanStatus,
ticketId: string,
insertOrder: number
): KanbanTickets {
const newTickets = JSON.parse(JSON.stringify(tickets)) as KanbanTickets;
for (const ks of KanbanStatuses) {
// 該当チケットを探す
const index = newTickets[ks].findIndex((ticket) => ticket.id === ticketId);
if (index !== -1) {
// 該当チケットを削除
const [ticket] = newTickets[ks].splice(index, 1);
// 指定位置に挿入
newTickets[status].splice(insertOrder, 0, ticket);
break;
}
}
return newTickets;
}
/**
* チケットの並び替え
* @param tickets
* @param status
* @param oldIndex
* @param newIndex
* @returns
*/
function replaceTicket(
tickets: KanbanTickets,
status: KanbanStatus,
oldIndex: number,
newIndex: number
): KanbanTickets {
const newTickets = JSON.parse(JSON.stringify(tickets)) as KanbanTickets;
// 該当チケットを探す
const [ticket] = newTickets[status].splice(oldIndex, 1);
// 指定位置に挿入
newTickets[status].splice(newIndex, 0, ticket);
return newTickets;
}
interface IDataContext {
tickets: KanbanTickets;
addTicket: (ticket: string) => void;
moveTicket: MoveTicketFunction;
}
export const DataContext = createContext<IDataContext>(undefined!);
interface Props {
children: React.ReactNode;
}
/**
* 初期データ
*/
const emptyTickets: KanbanTickets = {
New: [],
Active: [],
Closed: [],
};
export default function DataProvider(props: Props) {
const [tickets, setTickets] = useState<KanbanTickets>(emptyTickets);
/**
* チケットの追加
*/
const addTicket = useCallback((ticket: string) => {
const newTicket: Ticket = {
id: uuid(),
title: ticket,
};
setTickets((prev) => ({
...prev,
New: [...prev['New'], newTicket],
}));
}, []);
/**
* チケットの移動
*/
const moveTicket: MoveTicketFunction = useCallback(
(status: KanbanStatus, arg2: string | number, arg3?: number) => {
let newTickets: KanbanTickets | undefined = undefined;
if (typeof arg2 === 'string') {
if (typeof arg3 === 'number') {
// 指定位置に移動
newTickets = moveTicketAt(tickets, status, arg2, arg3);
} else {
// 末尾に移動
newTickets = moveTicketKanbanTail(tickets, status, arg2);
}
} else if (typeof arg2 === 'number' && typeof arg3 === 'number') {
// 並び替え
newTickets = replaceTicket(tickets, status, arg2, arg3);
}
if (newTickets) {
setTickets(newTickets);
}
},
[tickets]
);
return (
<DataContext.Provider
value={{
tickets,
addTicket,
moveTicket,
}}
>
{props.children}
</DataContext.Provider>
);
}
App.tsx
に以下の考慮を追加
- チケットの id を元にチケットのステータス、位置を取得する
findTicket
メソッドを追加 -
SortContext
をまたがってドラッグした際にチケットが適切に描画できるようにDragOverlay
を追加 -
DragOverlay
で使用するので、ドラッグしているチケットを state に保持する
App.tsx
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
closestCenter,
} from '@dnd-kit/core';
import { Container, CssBaseline, Stack } from '@mui/material';
import React, { useCallback, useState } from 'react';
import Kanban from './components/Kanban';
import SortableTicket from './components/SortableTicket';
import Ticket from './components/Ticket';
import TicketForm from './components/TicketForm';
import useTicket from './hooks/ticket';
import { KanbanTickets } from './providers/DataProvider';
import { KanbanStatus, KanbanStatuses, isKanbanStatus } from './types/Kanban';
import { Ticket as TicketProps } from './types/Ticket';
interface FindTicketResponse {
status: KanbanStatus;
ticket: TicketProps;
index: number;
}
/**
* チケットの id をもとにチケットを探す
* @param tickets
* @param ticketId
* @returns
*/
function findTicket(tickets: KanbanTickets, ticketId: string): FindTicketResponse | undefined {
for (const status of KanbanStatuses) {
const index = tickets[status].findIndex((ticket) => ticket.id === ticketId);
if (index !== -1) {
return {
status,
ticket: tickets[status][index],
index,
};
}
}
}
export default function App() {
const { tickets, moveTicket } = useTicket();
// ドラッグ中のチケット
const [draggingTicket, setDraggingTicket] = useState<TicketProps>();
/**
* ドラッグ開始時にドラッグ中のチケットを取得する
*/
const handleDragStart = useCallback(
(event: DragStartEvent) => {
const { active } = event;
if (typeof active.id === 'string') {
const data = findTicket(tickets, active.id);
if (data) {
setDraggingTicket(data.ticket);
}
}
},
[tickets]
);
/**
* ドラッグ完了時に発生するイベント
*/
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
// ドラッグ中のチケットをクリア
setDraggingTicket(undefined);
if (over === null) {
// ドラッグ先の要素がなければ終了
return;
}
const overId = over.id;
const ticketId = active.id;
if (typeof ticketId === 'string') {
if (isKanbanStatus(overId)) {
// 衝突要素の id がステータスの場合はカンバン間の移動
moveTicket(overId, ticketId);
} else if (typeof overId === 'string') {
// 衝突要素がチケットの場合
// id からチケットを探す
const activeTicket = findTicket(tickets, ticketId);
const overTicket = findTicket(tickets, overId);
if (activeTicket && overTicket) {
if (activeTicket.status === overTicket.status) {
// 同じステータス内での移動
moveTicket(activeTicket.status, activeTicket.index, overTicket.index);
} else {
// 異なるステータス間での移動
moveTicket(overTicket.status, ticketId, overTicket.index);
}
}
}
}
},
[moveTicket, tickets]
);
return (
<>
<CssBaseline />
<Container component="main" maxWidth="xl">
<TicketForm />
<DndContext
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<Stack
direction="row"
sx={{ minHeight: 'calc(100dvh - 110px)' }}
spacing={2}
alignItems="stretch"
>
{KanbanStatuses.map((status) => (
<Kanban key={status} status={status} items={tickets[status]}>
{tickets[status].map((ticket) => (
<React.Fragment key={ticket.id}>
{/* ドラッグ中のチケットはリストから非表示とする */}
{ticket.id !== draggingTicket?.id && (
<SortableTicket {...ticket} />
)}
</React.Fragment>
))}
</Kanban>
))}
</Stack>
{/* SortableContext をまたがるドラッグを行う場合、DragOverlay を使用しないと表示が崩れる */}
<DragOverlay>{draggingTicket && <Ticket {...draggingTicket} />}</DragOverlay>
</DndContext>
</Container>
</>
);
}
このスクラップは2023/11/02にクローズされました