Closed25

React + dnd-kit で要素をドラッグ&ドロップするテスト

ピン留めされたアイテム
Kazunori KimuraKazunori Kimura

これから作るアプリのイメージ

  • 3つのカンバン (New, Active, Closed) にチケットを登録する
  • チケットをドラッグ&ドロップしてステータスを更新する
Kazunori KimuraKazunori Kimura

Material UI を使うのでインストールする(dnd-kit とは関係ない)

npm install @mui/material @emotion/react @emotion/styled

チケットの id を採番するのに uuid v7 を使う

npm i uuidv7
Kazunori KimuraKazunori Kimura

dnd-kit もインストールしておく

npm i @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
Kazunori KimuraKazunori Kimura

いらないファイルを削除して定番のフォルダを作成

❯ rm -rf src/App.{css,test.tsx}
❯ rm -rf src/index.css
❯ rm -rf src/logo.svg 
❯ mkdir src/{components,types,hooks,providers}
Kazunori KimuraKazunori Kimura

型定義

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;
}
Kazunori KimuraKazunori Kimura

チケットを管理する 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>
    );
}
Kazunori KimuraKazunori Kimura

チケットのカスタム hook
とりあえず DataContext の内容をそのまま返す

ticket.ts
import { useContext } from "react";
import { DataContext } from "../providers/DataProvider";

export default function useTicket() {
    return useContext(DataContext);
}
Kazunori KimuraKazunori Kimura

チケットのコンポーネント
とりあえず 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>
    );
}
Kazunori KimuraKazunori Kimura

カンバンのコンポーネント

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>
    );
}
Kazunori KimuraKazunori Kimura

作ったコンポーネントを結合して土台を作る

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();
Kazunori KimuraKazunori Kimura

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' をつけないとタッチデバイスでうまく操作できない

Kazunori KimuraKazunori Kimura

カンバンにドロップ可能にする

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>
    );
}
Kazunori KimuraKazunori Kimura

チケットの移動処理を追加

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>
    );
}
Kazunori KimuraKazunori Kimura

移動処理の実装

あ、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>
        </>
    );
}
Kazunori KimuraKazunori Kimura

これだとカンバンを移動したらかならず末尾に追加されるので、順番を入れ替えられるようにする

Kazunori KimuraKazunori Kimura

ソートできるように 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}
        />
    );
}
Kazunori KimuraKazunori Kimura

カンバンに 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>
    );
}
Kazunori KimuraKazunori Kimura

DataProvider.tsxmoveTicket にチケット位置の考慮を追加する

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>
    );
}
Kazunori KimuraKazunori Kimura

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にクローズされました