벨로퍼트 리액트 - 투두리스트 (예제코드 없이 혼자 연습)
목표
- 예제 코드 없이 혼자 만들기
- SCSS, React-icons 사용
- 기존 예제 코드에 무언가 새로 더해보기
- 할일을 모두 완료하면 나타나는 스티커! (조건부 렌더링)
- 할일 내용 수정 기능 (수정 기능)
구조
TodoTemplate
투두리스트의 전체적인 레이아웃
TodoHead
오늘 날짜, 요일, 남은 할일 출력. 할일 완료시 스티커가 나타나는 부분
TodoList
할일(TodoItem)이 렌더링 되는 부분. 할일들이 리스트 형태로 나타나게 됨
TodoItem
각각의 할일 아이템. 완료여부가 나타나는 체크 표시와 할일 삭제 버튼이 있음
TodoCreate
할일을 등록하는 부분. TodoItem의 텍스트 클릭시 수정하기로 바뀜.
App.js
import './App.scss';
import TodoTemplate from './components/TodoTemplate';
import TodoHead from './components/TodoHead';
import TodoList from './components/TodoList';
import TodoCreate from './components/TodoCreate';
function App() {
return (
<TodoTemplate>
<TodoHead />
<TodoList />
<TodoCreate />
</TodoTemplate>
);
}
export default App;
body {
background: #ccc;
}
.template {
width: 500px;
height: 700px;
margin: 50px auto;
background: #fff;
border-radius: 20px;
box-sizing: border-box;
display: flex;
flex-direction: column;
position: relative;
}
TodoTemplate.js
import React from 'react';
function TodoTemplate({ children }) {
return <div className="template">{ children }</div>;
}
export default TodoTemplate;
TodoHead.js
import React from 'react';
import './TodoHead.scss';
function TodoHead() {
const d = new Date();
const date = d.getFullYear() + '년 ' + d.getMonth() + '월 ' + d.getDate() + '일';
const day = d.toLocaleDateString('ko-KR', { weekday: 'long' });
return (
<div className="head">
<div className='head-left'>
<div className='date'>
<h2>{date}</h2>
<h3>{day}</h3>
</div>
<p className='tasks-left'>남은 할일 5개</p>
</div>
{/* 모든 할일이 완료되면 heade-right 나타나게 함 */}
<div className='head-right'>
Perfect!
<span role="img" aria-label='Clapping Hands'>👏</span>
</div>
</div>
);
}
export default TodoHead;
TodoHead.scss
.head {
display: flex;
flex-flow: row wrap;
align-items: center;
padding: 20px;
border-bottom: 1px solid #ccc;
// 부모이름이 앞에 붙는 하위 컴포넌트 : &(부모이름)나머지이름
&-left {
width: 60%;
// 하위에 오는 컴포넌트
// = .head .head-left .tasks-left
.tasks-left {
color: #8727e2;
font-size: 20px;
font-weight: bold;
}
}
&-right {
width: 40%;
font-size: 20px;
font-weight: bold;
text-align: right;
align-self: flex-end;
}
}
TodoList.js
import React from 'react';
import TodoItem from './TodoItem';
import './TodoList.scss';
function TodoList() {
return (
<div className='list'>
<TodoItem addClass={'done'} />
<TodoItem addClass={'done'} />
<TodoItem />
<TodoItem />
</div>
);
}
export default TodoList;
TodoList.scss
.list {
flex: 1;
padding: 20px;
overflow-y: auto;
}
TodoItem.js
import React from 'react';
import { MdDone, MdDelete } from 'react-icons/md';
import './TodoItem.scss';
function TodoItem({addClass}) {
return (
// addClass = 애니메이션 처리 등으로 추가되는 클래스 이름 props
<div className={`item ${addClass}`}>
<div className='item-check'>
<MdDone />
</div>
<p className='item-text'>할일 내용</p>
<button type='button' className='item-remove'>
<MdDelete />
</button>
</div>
);
}
TodoItem.defaultProps = {
addClass: ''
}
export default TodoItem;
TodoItem.scss
.item {
display: flex;
align-items: center;
&-check {
width: 35px;
height: 35px;
border-radius: 50%;
border: 2px solid lighten(#8727e2, 30%);
font-size: 24px;
display: flex;
justify-content: center;
align-items: center;
margin-right: 20px;
color: lighten(#8727e2, 30%);
cursor: pointer;
}
&-text {
flex: 1;
font-size: 21px;
color: #495057;
cursor: pointer;
}
&-remove {
display: none;
align-items: center;
justify-content: center;
color: #dee2e6;
font-size: 24px;
cursor: pointer;
border: none;
outline: none;
background: none;
&:hover {
color: #ff6b6b;
}
}
// .item:hover
&:hover {
.item-remove {
display: flex;
}
}
// .item.done
&.done {
.item-check {
border: 2px solid #8727e2;
color: #8727e2;
}
.item-text {
color: #ced4da;
}
}
}
TodoCreate.js
import React, { useState } from 'react';
import { MdAdd } from 'react-icons/md';
import './TodoCreate.scss';
function TodoCreate() {
const [open, setOpen] = useState(false);
const onToggle = () => {
setOpen(!open);
}
return (
<>
{/* open이 참이면 = 토글 on 상태이면 입력폼이 나타남 */}
{ open &&
<div className='form-box'>
<form className='insert-form'>
<input type='text' placeholder="할일을 입력해주세요"/>
<button type='submit' className='btn-add'>등록</button>
</form>
</div>
}
<button
type='button'
// open이 참일때만 open class 붙게 함
className={['create-circle', open ? 'open' : null].join(' ')}
onClick={onToggle}
>
<MdAdd />
</button>
</>
);
}
export default TodoCreate;
TodoCreate.scss
.create-circle {
width: 80px;
height: 80px;
background: #8727e2;
font-size: 60px;
color: #fff;
border: none;
outline: none;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
position: absolute;
bottom: 0;
left: 50%;
transform: translate(-50%, 50%);
z-index: 5;
transition: 0.125s all ease-in;
&:hover {
background: lighten(#8727e2, 10%);
}
&:active {
background: darken(#8727e2, 10%);
}
&.open {
background: #ff6b6b;
&:hover {
background: #ff8787;
}
&:active {
background: #fa5252;
}
transform: translate(-50%, 50%) rotate(45deg);
}
}
.form-box {
width: 100%;
position: absolute;
bottom:0;
left: 0;
.insert-form {
background: #f8f9fa;
padding: 30px 30px 70px;
border-radius: 0 0 20px 20px;
border-top: 1px solid #ccc;
}
input {
padding: 10px;
border-radius: 4px;
border: 1px solid #dee2e6;
width: 86%;
outline: none;
font-size: 18px;
box-sizing: border-box;
margin-right: 10px;
}
button {
border: 1px solid #8727e2;
color: #8727e2;
font-weight: bold;
background: none;
border-radius: 4px;
padding: 10px;
box-sizing: border-box;
cursor: pointer;
transition: 0.25s;
&:hover {
background: #8727e2;
color: #fff;
}
}
}
상태관리
22.10.18 (Context API 상태관리, 추가기능 구현)
ㄴ input 태그 사용시 value 속성이 고정값이 아니라 나타나는 에러.
⇒ value 속성을 defaultValue 로 변경하면 에러가 사라진다
18일 수정내용
App.js
import './App.scss';
import TodoTemplate from './components/TodoTemplate';
import TodoHead from './components/TodoHead';
import TodoList from './components/TodoList';
import TodoCreate from './components/TodoCreate';
import { TodoProvider } from './TodoContext';
function App() {
return (
<TodoProvider>
<TodoTemplate>
<TodoHead />
<TodoList />
<TodoCreate />
</TodoTemplate>
</TodoProvider>
);
}
export default App;
body {
background: #ccc;
}
.template {
width: 500px;
height: 700px;
margin: 50px auto;
background: #fff;
border-radius: 20px;
box-sizing: border-box;
display: flex;
flex-direction: column;
position: relative;
}
TodoTemplate.js
import React from 'react';
function TodoTemplate({ children }) {
return <div className="template">{ children }</div>;
}
export default TodoTemplate;
TodoHead.js
import React from 'react';
import './TodoHead.scss';
import { useTodoState } from '../TodoContext'
function TodoHead() {
const d = new Date();
const date = d.getFullYear() + '년 ' + (d.getMonth() + 1) + '월 ' + d.getDate() + '일';
const day = d.toLocaleDateString('ko-KR', { weekday: 'long' });
const todos = useTodoState();
const undoneTasks = todos.filter(todo=> !todo.done);
return (
<div className="head">
<div className='head-left'>
<div className='date'>
<h2>{date}</h2>
<h3>{day}</h3>
</div>
<p className='tasks-left'>남은 할일 {undoneTasks.length}개</p>
</div>
{/* 모든 할일이 완료되면 heade-right 나타나게 함 */}
{undoneTasks.length === 0 ?
<div className='head-right'>
Perfect!
<span role="img" aria-label='Clapping Hands'>👏</span>
</div>
: null
}
</div>
);
}
export default TodoHead;
TodoHead.scss
.head {
display: flex;
flex-flow: row wrap;
align-items: center;
padding: 20px;
border-bottom: 1px solid #ccc;
// 부모이름이 앞에 붙는 하위 컴포넌트 : &(부모이름)나머지이름
&-left {
width: 60%;
// 하위에 오는 컴포넌트
// = .head .head-left .tasks-left
.tasks-left {
color: #8727e2;
font-size: 20px;
font-weight: bold;
}
}
&-right {
width: 40%;
font-size: 20px;
font-weight: bold;
text-align: right;
align-self: flex-end;
}
}
TodoList.js
import React from 'react';
import { useTodoState } from '../TodoContext';
import TodoItem from './TodoItem';
import './TodoList.scss';
function TodoList() {
const todos = useTodoState();
return (
<div className='list'>
{todos.map(todo=>(
<TodoItem
key={todo.id}
id={todo.id}
text={todo.text}
done={todo.done}
addClass={todo.done ? 'done' : null}
/>
))}
{/* <TodoItem text={'운동하기'} addClass={'done'} />
<TodoItem text={'과제하기'} addClass={'done'} />
<TodoItem text={'고양이 사료 사기'} />
<TodoItem text={'리액트 공부'} /> */}
</div>
);
}
export default TodoList;
TodoList.scss
.list {
flex: 1;
padding: 20px;
overflow-y: auto;
}
TodoItem.js
import React, { useState } from 'react';
import { MdDone, MdDelete, MdEdit } from 'react-icons/md';
import './TodoItem.scss';
import { useTodoDispatch } from '../TodoContext'
function TodoItem({addClass, id, text}) {
const [editYN, setEditYN] = useState('N'); // 수정모드/입력모드 상태 나타냄.
const [inpEdit, setInpEdit] = useState(text); // 수정 인풋의 상태와 함수. 기존 값은 유지해야하니까 useState의 기본값은 text(수정이전값)을 넣어줌
const dispatch = useTodoDispatch();
const onToggle = () => dispatch({type:'TOGGLE', id});
const onRemove = () => dispatch({type:'REMOVE', id});
const onEdit = () => setEditYN('Y');
const onChange = (e) => {
setInpEdit(e.target.value); // 수정 인풋의 내용이 바뀌면 수정 인풋의 상태를 변경해줌
}
const onUpdate = () => {
dispatch({type:'UPDATE', id, inpEdit}); // 수정된 텍스트를 가져감
setEditYN('N');
}
return (
// addClass = 애니메이션 처리 등으로 추가되는 클래스 이름 props
<div className={`item ${addClass}`}>
<div className='item-check' onClick={onToggle}>
<MdDone />
</div>
{editYN === 'N' ?
<>
<p className='item-text' onClick={onEdit}>{text}</p>
<button type='button'
className='item-remove'
onClick={onRemove}>
<MdDelete />
</button>
</>
:
<>
<input type='text' defaultValue={text} onChange={onChange}/>
<button type='button'
className='item-update'
onClick={onUpdate}>
<MdEdit />
</button>
</>
}
</div>
);
}
TodoItem.defaultProps = {
addClass: ''
}
export default React.memo(TodoItem);
// React.memo() : 다른항목이 업데이트 될때 불필요한 리렌더링 방지
TodoItem.scss
.item {
display: flex;
align-items: center;
&-check {
width: 35px;
height: 35px;
border-radius: 50%;
border: 2px solid lighten(#8727e2, 30%);
font-size: 24px;
display: flex;
justify-content: center;
align-items: center;
margin-right: 20px;
color: lighten(#8727e2, 30%);
cursor: pointer;
}
&-text {
flex: 1;
font-size: 21px;
color: #495057;
cursor: pointer;
}
input {
width: 365px;
font-size: 21px;
color: #495057;
padding: 10px;
border-radius: 4px;
border: 1px solid #dee2e6;
outline: none;
box-sizing: border-box;
}
&-remove {
display: none;
align-items: center;
justify-content: center;
color: #dee2e6;
font-size: 24px;
cursor: pointer;
border: none;
outline: none;
background: none;
&:hover {
color: #ff6b6b;
}
}
&-update {
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
cursor: pointer;
border: none;
outline: none;
background: none;
color: #555;
&:hover {
color: lighten(#8727e2, 15%);
}
}
// .item:hover
&:hover {
.item-remove {
display: flex;
}
}
// .item.done
&.done {
.item-check {
border: 2px solid #8727e2;
color: #8727e2;
}
.item-text {
color: #ced4da;
}
}
}
TodoCreate.js
import React, { useState } from 'react';
import { MdAdd } from 'react-icons/md';
import { useTodoDispatch, useTodoNextId } from '../TodoContext';
import './TodoCreate.scss';
function TodoCreate() {
const [open, setOpen] = useState(false);
const [input, setInput] = useState(''); // input의 value 관리
const dispatch = useTodoDispatch();
const nextId = useTodoNextId();
const onToggle = () => {
setOpen(!open);
}
const onChange = (e) => setInput(e.target.value);
const onSubmit = (e) => {
e.preventDefault(); // 새로고침 방지
dispatch({
type: 'CREATE',
todo: {
id: nextId.current,
text: input,
done: false,
}
});
nextId.current += 1;
setOpen(!open);
}
// 추가(생성) 기능
// input의 바뀌는 내용 = input, setInput 으로 관리
// btn-add가 클릭되면 input의 값으로 dispatch(type:'CREATE')가 실행되어야 함
// 내부에 들어가는 내용은 id(nextId), text(input의 value), done: false
return (
<>
{/* open이 참이면 = 토글 on 상태이면 입력폼이 나타남 */}
{ open &&
<div className='form-box'>
<form className='insert-form'>
<input type='text' placeholder="할일을 입력해주세요" onChange={onChange}/>
<button type='submit' className='btn-add' onClick={onSubmit}>등록</button>
</form>
</div>
}
<button
type='button'
// open이 참일때만 open class 붙게 함
className={['create-circle', open ? 'open' : null].join(' ')}
onClick={onToggle}
>
<MdAdd />
</button>
</>
);
}
export default TodoCreate;
TodoCreate.scss
.create-circle {
width: 80px;
height: 80px;
background: #8727e2;
font-size: 60px;
color: #fff;
border: none;
outline: none;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
position: absolute;
bottom: 0;
left: 50%;
transform: translate(-50%, 50%);
z-index: 5;
transition: 0.125s all ease-in;
&:hover {
background: lighten(#8727e2, 10%);
}
&:active {
background: darken(#8727e2, 10%);
}
&.open {
background: #ff6b6b;
&:hover {
background: #ff8787;
}
&:active {
background: #fa5252;
}
transform: translate(-50%, 50%) rotate(45deg);
}
}
.form-box {
width: 100%;
position: absolute;
bottom:0;
left: 0;
.insert-form {
background: #f8f9fa;
padding: 30px 30px 70px;
border-radius: 0 0 20px 20px;
border-top: 1px solid #ccc;
}
input {
padding: 10px;
border-radius: 4px;
border: 1px solid #dee2e6;
width: 86%;
outline: none;
font-size: 18px;
box-sizing: border-box;
margin-right: 10px;
}
button {
border: 1px solid #8727e2;
color: #8727e2;
font-weight: bold;
background: none;
border-radius: 4px;
padding: 10px;
box-sizing: border-box;
cursor: pointer;
transition: 0.25s;
&:hover {
background: #8727e2;
color: #fff;
}
}
}