3. 컴포넌트 스타일링

src/components/Modal

가장 보통의 UI - Modal 에서 구현한 Modal 컴포넌트를 가져와 src/components 하위에 붙여 넣습니다.

// src/components/modal.css

.modal-enter {
    opacity: 0;
}

.modal-enter-active {
    opacity: 1;
    transition: opacity 200ms;
}

.modal-exit {
    opacity: 1;
}

.modal-exit-active {
    opacity: 0;
    transition: opacity 200ms;
}
// src/components/Modal.tsx
import React from 'react';
import styled from "@emotion/styled/macro";
import { CSSTransition } from 'react-transition-group';
import './modal.css';

import Portal from "./Portal";

const Overlay = styled.div`
  position: fixed;
  z-index: 10;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
`;

const Dim = styled.div`
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  background-color: rgba(0, 0, 0, 0.5);
`;

const Container = styled.div`
  max-width: 456px;
  position: relative;
  width: 100%;
`;

interface Props {
  isOpen: boolean;
  onClose: () => void;
  selector?: string;
}

const Modal: React.FC<Props> = ({ children, isOpen, onClose, selector }) => {
  return (
    <CSSTransition in={isOpen} timeout={300} classNames="modal" unmountOnExit>
      <Portal selector={selector}>
        <Overlay>
          <Dim onClick={onClose} />
          <Container>{children}</Container>
        </Overlay>
      </Portal>
    </CSSTransition>
  )
}

export default Modal;

src/features/Calendar

가장 보통의 UI - Calendar 에서 구현한 Calendar 컴포넌트를 가져와 src/features/Calendar/index.tsx 에 붙여 넣습니다.

// src/utils.ts
export const pad = (time: number) => {
  return `0${time}`.slice(-2);
}

export const getSimpleDateFormat = (d: Date, separator: string = '-') => {
  return [d.getFullYear(), pad(d.getMonth() + 1), pad(d.getDate())].join(separator);
}

export const isSameDay = (a: Date, b: Date): boolean => {
  return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}


// src/components/Calendar.tsx
import React, { useMemo, useState } from 'react';
import styled from '@emotion/styled/macro';
import { BiChevronLeft, BiChevronRight } from 'react-icons/bi';

import { isSameDay } from '../utils';

const Header = styled.div`
  width: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
`;

const Title = styled.h1`
  margin: 0;
  padding: 8px 24px;
  font-size: 24px;
  font-weight: normal;
  text-align: center;
  color: #F8F7FA;
`;

const ArrowButton = styled.button<{ pos: 'left' | 'right' }>`
  border: none;
  border-radius: 4px;
  padding: 8px 12px;
  background-color: transparent;
  font-size: 18px;
  cursor: pointer;
  color: #F8F7FA;
`;

const ButtonContainer = styled.div`
  display: flex;
  align-items: center;
  justify-content: center;
`;

const Table = styled.table`
  border-collapse: collapse;
  width: 100%;
  height: 100%;
  border-spacing: 0;
`;

const TableHeader = styled.thead`
  padding-block: 12px;
  > tr {
    > th {
      padding-block: 12px;
      font-weight: normal;
      color: #F8F7FA;
    }
  }
`;

const TableBody = styled.tbody``;

const TableData = styled.td`
  text-align: center;
  color: #C9C8CC;
  padding: 8px;
  position: relative;
`;

const DisplayDate = styled.div<{ isToday?: boolean; isSelected?: boolean; }>`
  color: ${({ isToday }) => isToday && '#F8F7FA'};
  background-color: ${({ isToday, isSelected }) => isSelected ? '#7047EB' : isToday ? '#313133' : ''};
  display: flex;
  justify-content: center;
  align-items: center;
  border-radius: 50%;
  align-self: flex-end;
  position: absolute;
  top: 8px;
  right: 8px;
  width: 36px;
  height: 36px;
  cursor: pointer;
`;

const Base = styled.div`
  width: 100%;
  height: 100vh;
  padding: 24px 12px;
  display: flex;
  flex-direction: column;
  align-items: center;
  box-sizing: border-box;
  background-color: #28272A;
  ${Header} + ${Table} {
    margin-top: 36px;
  }
`;

const DAYS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];

const MONTHS = ["January", "February", "March", "April", "May", "June",
  "July", "August", "September", "October", "November", "December"
];

const Calendar: React.FC = () => {
  const [selectedDate, setSelectedDate] = useState<Date>(new Date()); // 선택한 날짜 상태

  const { year, month, firstDay, lastDay } = useMemo(() => { // 선택한 날짜를 기준으로 연, 월, 일, 해당 월의 첫째 날짜, 해달 월의 마지막 날짜 가져온다.
    const year = selectedDate.getFullYear();
    const month = selectedDate.getMonth();

    return ({
      year,
      month,
      firstDay: new Date(year, month, 1),
      lastDay: new Date(year, month + 1, 0)
    })
  }, [selectedDate]);

  const selectDate = (date: Date) => {
    setSelectedDate(date);
  }

  const pad = () => [...Array(firstDay.getDay()).keys()].map((p: number) => <TableData key={`pad_${p}`} />);

  const range = () => [...Array(lastDay.getDate()).keys()].map((d: number) => {
    const thisDay = new Date(year, month, d + 1);
    const today = new Date();

    return (
      <TableData key={d} onClick={() => selectDate(thisDay)}>
        <DisplayDate
          isSelected={isSameDay(selectedDate, thisDay)}
          isToday={isSameDay(today, thisDay)}
        >{new Date(year, month, d + 1).getDate()}</DisplayDate>
      </TableData>
    )
  });

  const render = () => {
    const items = [...pad(), ...range()];

    const weeks = Math.ceil(items.length / 7);

    return [...Array(weeks).keys()].map((week: number) => (
      <tr key={`week_${week}`}>
        {items.slice(week * 7, week * 7 + 7)}
      </tr>
    ));
  }

  return (
    <Base>
      <Header>
        <ButtonContainer>
          <ArrowButton pos="left" onClick={() => selectDate(new Date(selectedDate.setMonth(selectedDate.getMonth() - 1)))}>
            <BiChevronLeft />
          </ArrowButton>
          <Title>{`${MONTHS[month]} ${year}`}</Title>
          <ArrowButton pos="right" onClick={() => selectDate(new Date(selectedDate.setMonth(selectedDate.getMonth() + 1)))}>
            <BiChevronRight />
          </ArrowButton>
        </ButtonContainer>
      </Header>
      <Table>
        <TableHeader>
          <tr>
            {
              DAYS.map((day, index) => (
                <th key={day} align="center">{day}</th>
              ))
            }
          </tr>
        </TableHeader>
        <TableBody>
          {render()}
        </TableBody>
      </Table>
    </Base>
  )
}

export default Calendar;

src/features/TodoFormModal

: 선택한 날짜에 할 일을 추가 시킬 수 있는 모달

import React, { useRef, useState } from 'react';
import styled from '@emotion/styled/macro';

import Modal from '../../components/Modal';

const Container = styled.div`
  width: 100vw;
  max-width: 386px;
  padding: 8px;
`;

const Date = styled.small`
  display: block;
  color: #C9C8CC;
`;

const InputTodo = styled.input`
  padding: 16px 24px;
  border: none;
  width: 100%;
  box-sizing: border-box;
  background-color: transparent;
  color: #C9C8CC;
  caret-color: #C9C8CC;
`;

const Card = styled.div`
  width: 100%;
  max-width: 370px;
  border-radius: 16px;
  box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
  padding: 24px;
  box-sizing: border-box;
  background-color: #19181A;
  ${Date} + ${InputTodo} {
    margin-top: 24px;
  };
`;

const TodoFormModal: React.FC = () => {
  const [isOpen, setIsOpen] = useState<boolean>(false);

  const handleClose = () => setIsOpen(false);

  return (
    <Modal isOpen={isOpen} onClose={handleClose}>
      <Container>
        <Card>
          <Date>'2021-08-30'</Date>
          <InputTodo placeholder="새로운 이벤트" />
        </Card>
      </Container>
    </Modal>
  )
}

export default TodoFormModal;

src/features/TodoList

: 할 일 목록

import React from 'react';
import styled from '@emotion/styled/macro';

interface Todo {
  id: string;
  content: string;
  done: boolean;
  date: Date;
}

const EtcItem = styled.li`
  padding: 2px 4px;
  margin: 0;
  font-size: 10px;
  cursor: pointer;
`;

const TodoItem = styled.li<{ done?: boolean; selected?: boolean; }>`
  max-width: 100px;
  width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  background-color: ${({ done, selected }) => selected ? 'rgba(112, 71, 235, 1)' : done ? 'transparent' :  'rgba(112, 71, 235, 0.4)'};
  padding: 2px 4px;
  margin: 0;
  border-radius: 8px;
  font-size: 10px;
  text-decoration: ${({ done }) => done && 'line-through'};
  cursor: pointer;
`;

const Base = styled.ul`
  list-style: none;
  margin: 36px 0 0 0;
  padding: 0;
  width: 100%;
  height: 60px;
  ${TodoItem} + ${TodoItem} {
    margin-top: 1px;
  }
  ${TodoItem} + ${EtcItem} {
    margin-top: 1px;
  }
`;

interface Props {
  items: Array<Todo>;
}

const MAX_TODO_LIST_LENGTH = 3;

const TodoList: React.FC<Props> = ({ items }) => (
  <Base>
    {items.slice(0, MAX_TODO_LIST_LENGTH).map((item, index) => (
      <TodoItem
        key={item.id}
        done={item.done}
      >
        {item.content}
      </TodoItem>
    ))}
    {items.length > MAX_TODO_LIST_LENGTH && (
      <EtcItem>{`그 외 ${items.length - MAX_TODO_LIST_LENGTH}개...`}</EtcItem>
    )}
  </Base>
);

export default TodoList;

src/features/TodoStatisticsModal

: 선택한 날짜의 할 일 통계(Statistics)

import React, { useState } from 'react';
import styled from '@emotion/styled/macro';
import { HiOutlineTrash } from 'react-icons/hi';

import Modal from '../../components/Modal';

const Container = styled.div`
  width: 100vw;
  max-width: 386px;
  padding: 8px;
`;

const Date = styled.small`
  display: block;
  color: #C9C8CC;
`;

const TodoActionButton = styled.button<{ secondary?: boolean; }>`
  border: none;
  background-color: transparent;
  color: ${({ secondary }) => secondary && '#ff6b6b'};
  cursor: pointer;
`;

const TodoActions = styled.span`
  flex: 1 0 5%;
`;

const Content = styled.span`
  flex: 1 0 95%;
`;

const TodoItem = styled.li`
  width: 100%;
  display: flex;
  color: #C9C8CC;
  align-items: center;
  border-radius: 8px;
`;

const TodoList = styled.ul`
  list-style: circle;
  margin: 0;
  padding: 0;
  width: 100%;
  ${TodoItem} + ${TodoItem} {
    margin-top: 8px;
  }
`;

const Statistics = styled.p`
  color: #7047EB;
  font-size: 16px;
  font-weight: bold;
`;

const Card = styled.div`
  width: 100%;
  max-width: 370px;
  border-radius: 16px;
  box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
  padding: 24px;
  box-sizing: border-box;
  background-color: #19181A;
  ${Date} + ${TodoList} {
    margin-top: 24px;
  }
;
`;

const TodoStatisticsModal: React.FC = () => {
  const [isOpen, setIsOpen] = useState<boolean>(false);

  const handleClose = () => setIsOpen(false);

  return (
    <Modal isOpen={isOpen} onClose={handleClose}>
      <Container>
        <Card>
          <Date>2021-08-30</Date>
          <Statistics>할 일 0개 남음</Statistics>
          <TodoList>
            <TodoItem key={todo.id}>
              <Content>{todo.content}</Content>
              <TodoActions>
                <TodoActionButton secondary onClick={() => removeTodo(todo.id)}>
                  <HiOutlineTrash />
                </TodoActionButton>
              </TodoActions>
            </TodoItem>
          </TodoList>
        </Card>
      </Container>
    </Modal>
  )
}

export default TodoStatisticsModal;

Last updated