한 걸음씩
[React] Drag and Drop 본문
npm i react-beautiful-dnd
npm i --save-dev @types/react-beautiful-dnd
드래그 앤 드롭: https://github.com/atlassian/react-beautiful-dnd
드래그 앤 드롭이 동작하지 않을 경우: https://github.com/atlassian/react-beautiful-dnd/issues/2350
// index.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import { RecoilRoot } from "recoil";
import { createGlobalStyle, ThemeProvider } from "styled-components";
import App from "./App";
import { darkTheme } from "./theme";
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
const GlobalStyle = createGlobalStyle`
@import url('https://fonts.googleapis.com/css2?family=Source+Sans+Pro:wght@300;400&display=swap');
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, menu, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
main, menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, main, menu, nav, section {
display: block;
}
/* HTML5 hidden-attribute fix for newer browsers */
*[hidden] {
display: none;
}
body {
line-height: 1;
}
menu, ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
* {
box-sizing: border-box;
}
body {
font-weight: 300;
font-family: 'Source Sans Pro', sans-serif;
background-color:${(props) => props.theme.bgColor};
color:${(props) => props.theme.textColor};
line-height: 1.2;
}
a {
text-decoration:none;
color:inherit;
}
`;
root.render(
// <React.StrictMode>
// recoil 상태 관리 라이브러리의 루트 컴포넌트
<RecoilRoot>
// styled-components의 테마를 설정하는 역할
<ThemeProvider theme={darkTheme}>
// styled-components을 사용하여 정의한 전역 스타일을 실제로 적용하는 컴포넌트
<GlobalStyle />
// 실제 애플리케이션의 메인 컴포넌트
<App />
</ThemeProvider>
</RecoilRoot>
// </React.StrictMode>
);
// App.tsx
import { DragDropContext, DropResult } from "react-beautiful-dnd";
import styled from "styled-components";
import { useRecoilState } from "recoil";
import { toDoState } from "./atoms";
import Board from "./components/Board";
const Wrapper = styled.div`
display: flex;
max-width: 680px;
width: 100vw;
margin: 0 auto;
justify-content: center;
align-items: center;
height: 100vh;
`;
const Boards = styled.div`
display: flex;
justify-content: center;
align-items: flex-start;
width: 100%;
gap: 10px;
grid-template-columns: repeat(3, 1fr);
`;
// const toDos = ["a", "b", "c", "d", "e", "f"];
function App() {
// Recoil 상태를 사용하여 할 일들의 상태를 관리
const [toDos, setToDos] = useRecoilState(toDoState);
// 드래그가 끝났을 때 실행되는 함수
const onDragEnd = (info: DropResult) => {
const { destination, source, draggableId } = info;
// 드래그를 취소한 경우 또는 이동 위치가 없는 경우 종료
if (!destination) return;
if (destination?.droppableId === source.droppableId) {
// 같은 보드 내에서의 이동인 경우
setToDos((allBoards) => {
const boardCopy = [...allBoards[source.droppableId]];
const taskObj = boardCopy[source.index];
boardCopy.splice(source.index, 1);
boardCopy.splice(destination?.index, 0, taskObj);
return {
...allBoards,
[source.droppableId]: boardCopy
};
});
}
if (destination.droppableId !== source.droppableId) {
// 다른 보드로 이동한 경우
setToDos((allBoards) => {
const sourceBoard = [...allBoards[source.droppableId]];
const destinationBoard = [...allBoards[destination.droppableId]];
const taskObj = sourceBoard[source.index];
sourceBoard.splice(source.index, 1);
destinationBoard.splice(destination?.index, 0, taskObj);
return {
...allBoards,
[source.droppableId]: sourceBoard,
[destination.droppableId]: destinationBoard
};
});
}
};
// 드래그 앤 드롭 컨텍스트를 사용하여 보드 및 할 일 항목을 렌더링
return (
<DragDropContext onDragEnd={onDragEnd}>
<Wrapper>
<Boards>
{/* toDos 객체의 각 키(보드 아이디)에 대해 보드 컴포넌트 생성 */}
{Object.keys(toDos).map((boardId) => (
<Board boardId={boardId} key={boardId} toDos={toDos[boardId]} />
))}
</Boards>
</Wrapper>
</DragDropContext>
);
}
export default App;
// DraggableCard.tsx
import React from "react";
import { Draggable } from "react-beautiful-dnd";
import styled from "styled-components";
const Card = styled.div<{ isDragging: boolean }>`
padding: 10px 10px;
border-radius: 5px;
background-color: ${(props) => props.theme.cardColor};
margin-bottom: 5px;
padding: 10px;
background-color: ${(props) =>
props.isDragging ? "#e4f2ff" : props.theme.cardColor};
box-shadow: ${(props) =>
props.isDragging ? "0px 2px 5px rgba(0, 0, 0, 0.05)" : "none"};
`;
interface IDraggableCardProps {
toDoId: number;
toDoText: string;
index: number;
}
function DraggableCard({ toDoId, toDoText, index }: IDraggableCardProps) {
return (
// Draggable 컴포넌트로 감싸진 카드
<Draggable draggableId={toDoId + ""} index={index}>
{(magic, snapshot) => (
<Card
isDragging={snapshot.isDragging}
ref={magic.innerRef}
{...magic.draggableProps}
{...magic.dragHandleProps}
>
{toDoText}
</Card>
)}
</Draggable>
);
}
// React에게 prop이 변하지 않았다면 DraggableCard를 다시 렌더링 하지 말라고 알림
// 마지막 리스트를 처음 리스트로 옮기면 전체가 렌더링 됨
// 전체 컴포넌트가 재렌더링된다면 애플리케이션이 무거워지고 느려짐
export default React.memo(DraggableCard);
// Board.tsx
import { Droppable } from "react-beautiful-dnd";
import DraggableCard from "./DraggableCard";
import styled from "styled-components";
// import { useRef } from "react";
import { useForm } from "react-hook-form";
import { ITodo, toDoState } from "../atoms";
import { useSetRecoilState } from "recoil";
const Wrapper = styled.div`
width: 300px;
padding-top: 10px;
background-color: ${(props) => props.theme.boardColor};
border-radius: 5px;
min-height: 300px;
display: flex;
flex-direction: column;
overflow: hidden;
`;
const Title = styled.h2`
text-align: center;
font-weight: 600;
margin-bottom: 10px;
font-size: 18px;
`;
interface IAreaProps {
isDraggingFromThis: boolean;
isDraggingOver: boolean;
}
const Area = styled.div<IAreaProps>`
background-color: ${(props) =>
props.isDraggingOver
? "#dfe6e9"
: props.isDraggingFromThis
? "#b2bec3"
: "transparent"};
flex-grow: 1;
transition: background-color 0.3s ease-in-out;
padding: 20px;
`;
const Form = styled.form`
width: 100%;
input {
width: 100%;
}
`;
interface IBoardProps {
toDos: ITodo[];
boardId: string;
}
interface IForm {
toDo: string;
}
function Board({ toDos, boardId }: IBoardProps) {
// Recoil Hook을 사용하여 상태 변경 함수 가져오기
const setToDos = useSetRecoilState(toDoState);
// react-hook-form을 사용하여 폼 처리를 위한 필요한 메소드 가져오기
const { register, setValue, handleSubmit } = useForm<IForm>();
// 폼 제출 시 호출되는 함수
const onValid = ({ toDo }: IForm) => {
// 새로운 할 일 객체 생성
const newToDo = {
id: Date.now(),
text: toDo
};
// 상태 업데이트
setToDos((allBoards) => {
return {
...allBoards,
[boardId]: [newToDo, ...allBoards[boardId]]
};
});
// 입력 필드 초기화
setValue("toDo", "");
};
return (
<Wrapper>
<Title>{boardId}</Title>
<Form onSubmit={handleSubmit(onValid)}>
<input
{...register("toDo", { required: true })}
type="text"
placeholder={`Add task on ${boardId}`}
/>
</Form>
<Droppable droppableId={boardId}>
{(magic, info) => (
<Area
isDraggingOver={info.isDraggingOver}
isDraggingFromThis={Boolean(info.draggingFromThisWith)}
// 자바스크립트로부터 HTML요소를 가져오고 수정하는 방법
ref={magic.innerRef}
{...magic.droppableProps}
>
{toDos.map((toDo, index) => (
<DraggableCard
key={toDo.id}
index={index}
toDoId={toDo.id}
toDoText={toDo.text}
/>
))}
{magic.placeholder}
</Area>
)}
</Droppable>
</Wrapper>
);
}
export default Board;
// atoms.tsx
import { atom } from "recoil";
// 할 일 정보를 나타내는 인터페이스 정의
export interface ITodo {
id: number; // 할 일의 고유 ID
text: string; // 할 일의 내용
}
// 할 일 상태를 나타내는 인터페이스 정의
interface IToDoState {
[key: string]: ITodo[]; // 보드 별로 할 일 배열을 포함하는 객체
}
// Recoil atom을 사용하여 할 일 상태를 관리하는 "toDoState" 정의
export const toDoState = atom<IToDoState>({
key: "toDo", // 고유한 식별자 키
default: {
"To Do": [], // "To Do" 보드의 초기 값은 빈 배열
Doing: [], // "Doing" 보드의 초기 값은 빈 배열
Done: [] // "Done" 보드의 초기 값은 빈 배열
}
});
// theme.tsx
import { DefaultTheme } from "styled-components";
export const darkTheme: DefaultTheme = {
bgColor: "#3F8CF2",
boardColor: "#DADFE9",
cardColor: "white"
};
// styled.d.ts
declare module "styled-components" {
export interface DefaultTheme {
bgColor: string;
boardColor: string;
cardColor: string;
}
}
◆ 추가 요구 사항
1. Input 스타일 완성하기
2. task 저장 (localStorage)
3. task 삭제 (드롭하여 삭제)
4. board의 순서 변경
5. board 생성 (react-hook-form을 이용하여 board 이름을 입력하고 엔터치면 보드가 생성)
'React' 카테고리의 다른 글
[React] Gatsby (0) | 2023.08.16 |
---|---|
[React] Animations (0) | 2023.08.14 |
[React] atom, selector를 사용한 minutes, hours 변환기 (0) | 2023.08.11 |
[React] To Do (0) | 2023.08.11 |
[React] react hook form (0) | 2023.08.10 |