[Project] React-custom-component

유슬기·2023년 2월 23일
0

프론트엔드

목록 보기
47/64
post-thumbnail

React-custom-component 과제: React, Styled-Component, Storybook을 활용해 UI 컴포넌트 개발

Bare minimum

  • Modal, Toggle, Tab, Tag 컴포넌트를 구현

요구사항

  • Modal 창을 띄워 줄 Open Modal 버튼이 있어야 합니다.
  • 버튼을 클릭하면 Modal 컴포넌트 내부에 Modal배경, Modal창 div 엘리먼트가 렌더링되어야 합니다.
  • Modal창이 렌더링 된 상태에서 버튼을 다시 클릭하면 Modal배경, Modal창 div 엘리먼트가 사라집니다.
  • Modal 창 밖을 클릭하면, Modal배경, Modal창 div 엘리먼트가 사라집니다.

Modal.js

import { useState } from "react";
import styled from "styled-components";

export const ModalContainer = styled.div`
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100%;
  position: relative;
`;

export const ModalBackdrop = styled.div`
  position: fixed;
  display: flex;
  justify-content: center;
  align-items: center;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  margin: auto;
  background-color: rgba(0, 0, 0, 0.5);
  z-index: 1;
`;

export const ModalBtn = styled.button`
  background-color: var(--coz-purple-600);
  text-decoration: none;
  border: 4px solid var(--coz-purple-600);
  padding: 20px;
  color: white;
  border-radius: 30px;
  font-size: 20px;
  cursor: pointer;
  &:hover{
    background-color: white;
    color: var(--coz-purple-600);
  }
`;

export const ModalView = styled.div.attrs((props) => ({
  // attrs 메소드를 이용해서 아래와 같이 div 엘리먼트에 속성을 추가할 수 있습니다.
  role: "dialog",
}))`
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  background-color: white;
  width: 400px;
  height: 200px;
  border-radius: 30px;
  position: relative;
  > .desc {
    font-size: 30px;
    color: #475ed4;
    margin: 50px;
    justify-content: center;
    align-items: center;
  }
  > .close {
    padding: 5px 10px;
  }
`;


export const Modal = () => {
  const [isOpen, setIsOpen] = useState(false);

  const openModalHandler = (e) => {
    setIsOpen(!isOpen);
  };

  return (
    <>
      <ModalContainer>
        {/* 클릭하면 isOpen 상태 변경 함수 실행 */}
        <ModalBtn onClick={openModalHandler}>
          {/* Modal이 열린 상태(isOpen이 true인 상태)일 때는 'Opened!' Modal이 닫힌 상태(isOpen이 false인 상태)일 때는 'Open Modal' */}
          {isOpen ? "Opened!" : "Open Modal"}
        </ModalBtn>
        {isOpen ? (
          // 모달 창 밖을 클릭하면 Modal배경, Modal창 div 엘리먼트가 사라지게 하기
          // 부모 컴포넌트에 이벤트 핸들러가 걸려있을 때 자식 컴포넌트에도 같은 핸들러가 작동이 되는데, 
          // 이때 자식 컴포넌트에서는 작동을 안하게 해주려면 그 해당 이벤트 핸들러에 stopPropagation()를 활용해주면 된다.
          <ModalBackdrop onClick={openModalHandler}>
            <ModalView onClick={e => e.stopPropagation()}>
              <div className="desc">Good!</div>
              <ModalBtn className="close" onClick={openModalHandler}>Close</ModalBtn>
            </ModalView>
          </ModalBackdrop>
        ) : null}
      </ModalContainer>
    </>
  );
};

Toggle Component

요구사항

  • Toggle container를 클릭하면 'toggle--checked' class가 추가되어야 합니다.
  • Toggle switch를 클릭하면 'toggle--checked' class가 추가되어야 합니다.

Toggle.js

import { useState } from 'react';
import styled from 'styled-components';

// styled components에서 '>' 는 자식 선택자, '&' 는 자기 자신을 참조
const ToggleContainer = styled.div`
  position: relative;
  margin-top: 8rem;
  left: 47%;
  cursor: pointer;

  > .toggle-container {
    width: 50px;
    height: 24px;
    border-radius: 30px;
    background-color: #8b8b8b;

    &.toggle--checked { 
      background-color: pink;
    }
  }

  > .toggle-circle {
    position: absolute;
    top: 2px;
    left: 2px;
    width: 20px;
    height: 20px;
    border-radius: 50%;
    background-color: #ffffff;
    transition: .5s;
    &.toggle--checked {
      left: 28px;
      transition: .5s;
    }
  }
`;
const Desc = styled.div`
  text-align: center;
  margin: 20px;
`;

export const Toggle = () => {
  const [isOn, setisOn] = useState(false);

  const toggleHandler = () => {
    setisOn(!isOn);
  };

  return (
    <>
      {/* 클릭하면 isOn 상태 변경 함수 실행 */}
      <ToggleContainer onClick={toggleHandler}>
        {/* isOn 상태에 따라 클래스명 추가하여 토글이 움직이게 */}
        <div className={`toggle-container ${isOn && 'toggle--checked'}`}/>
        <div className={`toggle-circle ${isOn && 'toggle--checked'}`}/>
      </ToggleContainer>
      {/* 삼항연산자로 textContent 조건부 렌더링 */}
      <Desc>Toggle Switch {isOn ? 'ON' : 'OFF'}</Desc>
    </>
  );
};

Tab Component

요구사항

  • Tab Menu는 map을 이용한 반복을 통해 보여야 합니다.
    • ul 엘리먼트 아래에는 li 엘리먼트가 3개 있어야 합니다.
  • Tab Menu 조작을 위한 currentTab 상태가 존재해야 합니다.
    • currentTab 초기값은 0번째 인덱스여야 합니다.
  • Tab Menu를 클릭하면 currentTab 상태가 변경되어야 합니다.
    • Tab 메뉴를 클릭하면 selectMenuHandler 함수가 실행되고, 해당 Tab 메뉴의 index 값이 인자로 전달됩니다.
    • 클릭한 Tab 메뉴만 className 이 "submenu focused"로 변경됩니다.

Tab.js

import { useState } from "react";
import styled from "styled-components";

const TabMenu = styled.ul`
  font-weight: bold;
  display: flex;
  flex-direction: row;
  justify-items: center;
  justify-content: center;
  align-items: center;
  list-style: none;
  margin-top: 2rem;
  margin-bottom: 7rem;
  
  > li:hover {
    background-color: #686868;
    color: white;
    transition: 1s;
  }
  
  .submenu {
    background-color: #dcdcdc;
    color: rgba(73, 73, 73, 0.5);
    border-radius: 20px;
    display: flex;
    justify-content: center;
    align-items: center;
    width: 100px;
    height: 30px;
    margin: 0 5px;
    cursor: pointer;
  }

  .focused {
    background-color: #686868;
    color: white;
  }

  & div.desc {
    text-align: center;
  }
  `;

const Desc = styled.div`
  text-align: center;
  font-size: 20px;
  font-weight: 700;
`;

export const Tab = () => {

  const menuArr = [
    { name: "Tab1", content: "Tab menu ONE" },
    { name: "Tab2", content: "Tab menu TWO" },
    { name: "Tab3", content: "Tab menu THREE" },
  ];

  const [currentTab, setCurrentTab] = useState(0);
  const selectMenuHandler = (idx) => {
    setCurrentTab(idx); // 인덱스를 전달받아 currentTab 상태 업데이트
  };

  return (
    <>
      <div>
        <TabMenu>
          {/* map을 사용하여 menuArr 배열 내 모든 요소를 li 엘리먼트로 렌더링 */}
          {menuArr.map((tab, idx) => {
            return (
              <li
                key={idx}
                onClick={() => selectMenuHandler(idx)}
                className={currentTab === idx ? "submenu focused" : "submenu"}
              >
                {tab.name}
              </li>
            );
          })}
        </TabMenu>
        <Desc>
          {/* 현재 선택된 탭에 따라 content를 표시 */}
          <p className='desc'>{menuArr[currentTab].content}</p>
        </Desc>
      </div>
    </>
  );
};

Tag Component

요구사항

  • Enter 키 테스트
    • 새로운 태그를 추가하는 기능은 Enter 키에 의해 실행되어야 합니다.
    • Enter 키를 누르면 tag 를 추가하는 addTags 함수가 실행되어야 합니다.
    • Enter키를 누르면 실제 태그가 추가되어야 합니다.
    • 아무것도 입력하지 않은 경우, Enter를 눌러도 태그가 추가되지 않아야 합니다.
    • 중복된 값이 이미 존재하는 경우, Enter를 눌러도 태그가 추가되지 않아야 합니다.
    • 새로운 태그가 추가되면 입력창은 초기화되어야 합니다.
  • tags의 화면 출력과 제거 기능 테스트
    • tags 배열의 모든 태그가 화면에 보여져야 합니다.
    • tag 를 삭제할 수 있는 아이콘(x)이 있어야 하며, 해당 아이콘(x)을 클릭하면 removeTags 함수가 실행되어야 합니다.
    • 삭제 아이콘을 누르면 화면에서 Tag가 삭제되어야 합니다.

Tag.js

import { useState } from 'react';
import styled from 'styled-components';

export const TagsInput = styled.div`
  margin: 8rem auto;
  display: flex;
  align-items: flex-start;
  flex-wrap: wrap;
  min-height: 48px;
  width: 480px;
  padding: 0 8px;
  border: 1px solid rgb(214, 216, 218);
  border-radius: 6px;

  > ul {
    display: flex;
    flex-wrap: wrap;
    padding: 0;
    margin: 8px 0 0 0;

    > .tag {
      width: auto;
      height: 32px;
      display: flex;
      align-items: center;
      justify-content: center;
      /* color: #fff; */
      padding: 0 8px;
      font-size: 14px;
      list-style: none;
      border-radius: 6px;
      margin: 0 8px 8px 0;
      background: var(--coz-purple-600);
      > .tag-title {
        color: #fff; // app.css 에서 전체 선택자가 적용되어 있음 -> 특정 요소의 속성을 변경하고 싶다면, 해당 요소를 정확히 선택해야만 적용됨
      }
      > .tag-close-icon {
        display: block;
        width: 16px;
        height: 16px;
        line-height: 16px;
        text-align: center;
        font-size: 14px;
        margin-left: 8px;
        color: var(--coz-purple-600);
        border-radius: 50%;
        background: #fff;
        cursor: pointer;
      }
    }
  }

  > input {
    flex: 1;
    border: none;
    height: 46px;
    font-size: 14px;
    padding: 4px 0 0 0;
    :focus {
      outline: transparent;
    }
  }

  &:focus-within {
    border: 1px solid var(--coz-purple-600);
  }
`;

export const Tag = () => {
  const initialTags = ['CodeStates', 'kimcoding'];

  const [tags, setTags] = useState(initialTags);
  
  // 태그 삭제
  const removeTags = (indexToRemove) => {
    const filter = tags.filter((el, idx) => idx !== indexToRemove);
    setTags(filter);
  };
  
  // 태그 추가
  const addTags = (e) => {
    // 이미 입력되어 있는 태그인지 검사하여 이미 있는 태그라면 추가하지 말기
    // 아무것도 입력하지 않은 채 Enter 키 입력시 메소드 실행하지 말기
    if (e.key === 'Enter' && e.target.value !== '' && !tags.includes(e.target.value)) {
      setTags([...tags, e.target.value]);
      e.target.value = ''; // 태그가 추가되면 input 창 비우기
    }
  };

  return (
    <>
      <TagsInput>
        <ul id="tags">
          {tags.map((tag, index) => (
            <li key={index} className="tag">
              <span className="tag-title">{tag}</span>
              {/* 삭제 아이콘을 클릭하면 removeTags 함수 실행 */}
              <span className="tag-close-icon" onClick={() => removeTags(index)}>
                {/* HTML 엔티티로 X 아이콘 넣기 */}
                &times;            
              </span>
            </li>
          ))}
        </ul>
        {/* input 창에서 엔터 키 누르면 addTags 함수 실행 */}
        <input
          className="tag-input"
          type="text"
          onKeyUp={(e) => {addTags(e)}}
          placeholder="Press enter to add tags"
        />
      </TagsInput>
    </>
  );
};

Advanced Challenge

  • Autocomplete, ClickToEdit 컴포넌트를 구현

Autocomplete Component

요구사항

  • input 기능 테스트
    • input 엘리먼트에 onChange 이벤트 핸들러가 불려와야 합니다.
    • input 값을 삭제할 수 있는 버튼이 있어야 합니다.
    • 삭제 버튼 클릭 시 input value가 삭제되어야 합니다.
  • drop down 기능 테스트
    • input 값이 포함 된 자동 완성 추천 drop down 리스트가 보여야 합니다.
    • drop down 항목을 마우스로 클릭 시, input 값 변경에 따라 drop down 목록이 변경되어야 합니다.
    • drop down 항목을 마우스로 클릭 시, input 값이 변경되어야 합니다.
    • drop down 항목을 마우스로 클릭 시, input 값이 이미 있어도 input 값이 drop down 항목의 값으로 변경되어야 합니다.

Autocomplete.js

import { useState, useEffect } from "react";
import styled from "styled-components";

const deselectedOptions = [ // 자동완성 더미 데이터
  "rustic",
  "antique",
  "vinyl",
  "vintage",
  "refurbished",
  "신품",
  "빈티지",
  "중고A급",
  "중고B급",
  "골동품",
];

const boxShadow = "0 4px 6px rgb(32 33 36 / 28%)";
const activeBorderRadius = "1rem 1rem 0 0";
const inactiveBorderRadius = "1rem 1rem 1rem 1rem";

export const InputContainer = styled.div`
  margin-top: 8rem;
  background-color: #ffffff;
  display: flex;
  flex-direction: row;
  padding: 1rem;
  border: 1px solid rgb(223, 225, 229);
  border-radius: ${inactiveBorderRadius};
  z-index: 3;
  box-shadow: 0;

  &:focus-within {
    border-radius: ${activeBorderRadius};
    box-shadow: ${boxShadow};
  }

  > input {
    flex: 1 0 0;
    background-color: transparent;
    border: none;
    margin: 0;
    padding: 0;
    outline: none;
    font-size: 16px;
  }

  > div.delete-button {
    cursor: pointer;
  }
`;

export const DropDownContainer = styled.ul`
  background-color: #ffffff;
  display: block;
  margin-left: auto;
  margin-right: auto;
  list-style-type: none;
  margin-block-start: 0;
  margin-block-end: 0;
  margin-inline-start: 0px;
  margin-inline-end: 0px;
  padding-inline-start: 0px;
  margin-top: -1px;
  padding: 0.5rem 0;
  border: 1px solid rgb(223, 225, 229);
  border-radius: 0 0 1rem 1rem;
  box-shadow: ${boxShadow};
  z-index: 3;

  > li {
    padding: 0 1rem;

    &.selected {
      background-color: lightgray;
    }
    &:hover {
      background-color: lightgray;
    }
  }
`;

export const Autocomplete = () => {

  const [hasText, setHasText] = useState(false); // input값의 유무
  const [inputValue, setInputValue] = useState(""); // input값의 상태
  const [options, setOptions] = useState(deselectedOptions); // input값을 포함하는 autocomplete 추천 항목 리스트
  const [selectedIdx, setSelectedIdx] = useState(-1); // 키보드로 option 선택할때 필요한 selected 상태. 처음엔 아무것도 선택되지 않도록 인덱스 -1로 초기값 설정

  useEffect(() => {
    setSelectedIdx(-1); // 방향키로 선택한 옵션 초기화
    if (inputValue === "") {
      //처음 렌더링 됐을 때의 상태와, input값을 모두 지워줬을 때
      setHasText(false); // input값의 유무 상태를 false로 변경
      setOptions([]); // option은 빈 배열로 만들어 아래에 리스트가 나오지 않도록
    }

    if (inputValue !== "") {
      // input값에 무언가 입력하면
      setOptions(
        deselectedOptions.filter((option) => option.includes(inputValue))
      ); // 입력된 값을 포함하는 option만 필터링
    }
  }, [inputValue]);

  const handleInputChange = (e) => {
    setInputValue(e.target.value); // inputValue를 입력된 값으로 변경
    setHasText(true); // input값 유무 상태를 true로 변경
  };

  const handleDropDownClick = (clickedOption) => {
    setInputValue(clickedOption); // 전달받은 option으로 input값 변경
  };

  const handleDeleteButtonClick = (e) => {
    setInputValue(""); // x 버튼을 누르면 input값을 비워준다
  };

  /* Advanced Challenge: 상하 화살표 키 입력 시 dropdown 항목을 선택하고, 
  Enter 키 입력 시 input값을 선택된 dropdown 항목의 값으로 변경 */
  const handleKeyUp = (e) => {
    if (hasText) {
      // (e.nativeEvent.isComposing === false) : 맨 처음 ArrowDown 키 두번 인식되는 문제 해결
      if (e.key === "ArrowDown" && selectedIdx < options.length - 1 && e.nativeEvent.isComposing === false) {
        setSelectedIdx(selectedIdx + 1);
      } else if (e.key === "ArrowUp" && selectedIdx >= 0) {
        setSelectedIdx(selectedIdx - 1);
      }
      // 엔터 키로 옵션 선택 => handleDropDownClick()로 전달해 input값 변경
      if (e.key === "Enter" && selectedIdx >= 0) {
        handleDropDownClick(options[selectedIdx]); 
      }
    }
  };

  return (
    <div className="autocomplete-wrapper">
      <InputContainer>
        {/* value를 state와 연결, handleInputChange 함수와 handleKeyUp 함수 각각 연결 */}
        <input
          type="text"
          value={inputValue}
          onChange={handleInputChange}
          onKeyUp={handleKeyUp}
        ></input>
        {/*  */}
        <div className="delete-button" onClick={handleDeleteButtonClick}>
          &times;
        </div>
      </InputContainer>
      {/* input 값 유무에 따라 dropdown 보이게 조건부 렌더링 */}
      {hasText ? (
        <DropDown
          options={options}
          handleComboBox={handleDropDownClick}
          selectedIdx={selectedIdx}
        />
      ) : null}
    </div>
  );
};

export const DropDown = ({ options, handleComboBox, selectedIdx }) => {
  return (
    <DropDownContainer>
      {/* input 값에 맞는 autocomplete 선택 옵션이 보여지게 */}
      {/* 현재 선택된 항목에 클래스명 넣어주어 스타일 적용 */}
      {options.map((option, idx) => {
        return (
          <li
            key={idx}
            onClick={() => handleComboBox(option)}
            className={selectedIdx === idx ? "selected" : ""}
          >
            {option}
          </li>
        );
      })}
    </DropDownContainer>
  );
};

ClickToEdit Component

요구사항

  • 입력 가능 상태로 변경할 수 있는 onClick 이벤트 핸들러가 span 엘리먼트에 있어야 합니다.
  • 포커스가 제외되는 이벤트 onBlur의 핸들러가 input 엘리먼트에 있어야 합니다.
  • 텍스트 영역을 클릭하면 입력 가능 상태로 변경되어야 합니다.
  • 입력 가능 상태일 때 변화가 감지되면 새로운 값을 설정하는 메소드가 실행되어야 합니다.
  • 입력 가능 상태일 때 input이 아닌 다른 곳을 클릭하면 입력 불가 상태가 되어야 합니다.
  • 입력 가능 상태일 때 input이 아닌 다른 곳을 클릭하면 input의 값이 span에 담겨야 합니다.

ClickToEdit.js

import { useEffect, useState, useRef } from 'react';
import styled from 'styled-components';

export const InputViewContainer = styled.div`
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  height: 100%;
`

export const InputBox = styled.div`
  display: flex;
  justify-content: center;
  align-items: center;
  width: 150px;
  height: 30px;
  border: 1px #bbb dashed;
  border-radius: 10px;
  margin-left: 1rem;
`;

export const InputEdit = styled.input`
  display: inline-block;
  justify-content: center;
  align-items: center;
  text-align: center;
  width: 150px;
  height: 30px;
`;

export const InputView = styled.div`
  display: flex;
  justify-content: center;
  text-align: center;
  align-items: center;
  margin: 1rem;

  div.view {
    margin-top: 3rem;
  }
`;

export const MyInput = ({ value, handleValueChange }) => {
  const inputEl = useRef(null); // 특정 요소(input)에 포커싱 해주기 위해 useRef 사용하여 주소값 가져오기
  const [isEditMode, setEditMode] = useState(false);
  const [newValue, setNewValue] = useState(value);

  useEffect(() => {
    if (isEditMode) {
      inputEl.current.focus();
    }
  }, [isEditMode]);

  useEffect(() => {
    setNewValue(value);
  }, [value]);

  const handleClick = () => {
    // span 요소 클릭 이벤트 발생 시 EditMode 상태를 true로 변경 => 위의 useEffect 실행 => input창에 포커싱
    setEditMode(true); 
  };

  const handleBlur = () => {
    handleValueChange(newValue); // input 창에 입력되어있는 값으로 newValue를 바꿔준다.
    setEditMode(false); // input 창 외의 곳을 클릭 시(포커스를 잃으면) EditMode 상태를 false로 변경
  };

  const handleInputChange = (e) => {
    setNewValue(e.target.value); // newValue의 상태를 input에 입력한 값으로 변경
  };

  return (
    <InputBox>
      {isEditMode ? (
        <InputEdit
          type='text'
          value={newValue}
          ref={inputEl}
          onBlur={handleBlur}
          onChange={handleInputChange}
        />
      ) : (
        <span onClick={handleClick}>{newValue}</span>
      )}
    </InputBox>
  );
}

const cache = {
  name: '김코딩',
  age: 20
};

export const ClickToEdit = () => {
  const [name, setName] = useState(cache.name);
  const [age, setAge] = useState(cache.age);

  return (
    <>
      <InputViewContainer>
        <InputView>
          <label>이름</label>
          <MyInput value={name} handleValueChange={(newValue) => setName(newValue)} />
        </InputView>
        <InputView>
          <label>나이</label>
          <MyInput value={age} handleValueChange={(newValue) => setAge(newValue)} />
        </InputView>
        <InputView>
          <div className='view'>이름 {name} 나이 {age}</div>
        </InputView>
      </InputViewContainer>
    </>
  );
};
profile
아무것도 모르는 코린이

0개의 댓글