티스토리 뷰
개발냥이/타입스크립트(Typescript)
[리액트+타입스크립트] 커스텀 셀렉트 박스(custom select box) 만들어보기)_리팩토링 추가(2023-07-29)
데브캣_DevCat 2023. 7. 6. 16:19개요
- 페이지 곳곳에서 사용되는 정렬 기능을 select box를 이용해 구현하고자 하였습니다.
- 피그마에서 보면 대충 위와 같은 select box입니다.
- 페이지마다 사용되는 정렬 내용이 다르기 때문에 재사용이 가능한 컴포넌트로 만들기 위해서는 정렬 옵션을 props로 받아야 할 것 같습니다.
- 그리고 옵션에 대한 value를 저장해야하므로 setValue 또한 받아야 할 것 같습니다.
요구사항
- select box를 누르면 선택 옵션이 아래로 펼쳐지고 선택을 하면 해당 옵션으로 value가 바뀌어야 합니다.
- 선택 옵션이 펼쳐졌을 때 외부를 클릭하면 현재 옵션 그대로 저장하면서 박스가 닫혀야 합니다.
구현
- 먼저 select box 외부를 클릭했을 때 옵션 리스트가 닫히도록 구현을 하려면 useRef를 이용해서 현재 선택된 element가 아닌 경우에 창을 닫도록 구현해야 합니다.
- 따라서 이 부분은 이전에 구현해놓은 useSelector 훅을 이용할 생각입니다.
useSelector
import React, { useRef } from "react";
import { useEffect, useState } from "react";
type useSelect = [
boolean,
React.MutableRefObject<HTMLDivElement | null>,
() => void
];
const useSelector = (): useSelect => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const ref = useRef<HTMLDivElement | HTMLInputElement | null>(null);
const toggleHandler = () => setIsOpen(!isOpen);
const handleClickOutside = (e: React.BaseSyntheticEvent | MouseEvent) => {
if (ref.current && !ref.current.contains(e.target)) setIsOpen(!isOpen);
};
useEffect(() => {
if (isOpen) {
window.addEventListener("click", handleClickOutside);
return () => window.removeEventListener("click", handleClickOutside);
}
}, [isOpen]);
return [isOpen, ref, toggleHandler];
};
export default useSelector;
- 드롭다운이나 select box 등 해당 요소가 선택되었는지(열렸는지) 여부를 확인하는 state와 해당 요소의 엘리먼트를 기억하는 ref, 그리고 해당 요소의 행동을 제어하는 handler 세 가지를 반환합니다.
- 외부를 클릭했을 때 선택을 해제하거나 창을 닫아야 하는 경우 등 매우 많은 경우에 사용되는 유용한 훅입니다!
- select 태그와 option 태그는 기본적으로 모바일에서 해당 모바일 환경에 맞는 UI를 제공합니다.
- 따로 반응형으로 제작하지 않아도 편안한 UI가 노출된다는 장점이 있지만 커스텀하고자 할 때는 불편함이 있습니다.
- 그래서 select 태그와 option 태그 대신 ul 태그와 li 태그를 이용해 직접 커스텀하는 레퍼런스가 많았습니다.
- 저 역시 PC와 모바일에서 같은 UI를 보여주고 싶었고 반응형으로 제작해보는 경험도 해보고 싶었기 때문에 ul 태그와 li 태그를 이용해 커스텀 해보았습니다.
주요 UI
SelectBox.tsx
<S.SelectOptions isOpen={isSelected}>
{options.map(option => (
<S.Option key={option} value={option} onClick={handleSelectValue}>
{option}
</S.Option>
))}
</S.SelectOptions>
style.ts
export const SelectOptions = styled.ul<{ isOpen: boolean }>`
position: absolute;
top: 34px;
left: 0;
width: 100%;
overflow: hidden;
height: max-content;
max-height: ${props => (props.isOpen ? "none" : "0")};
padding: 0;
border: ${props =>
props.isOpen ? "2px solid var(--color-darkblue)" : "none"};
border-radius: 8px;
background: radial-gradient(
190.97% 141.42% at 100% 100%,
rgba(247, 247, 247, 0.7) 0%,
rgba(247, 247, 247, 0.7) 100%
);
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
color: #111;
appearance: none;
`;
- 로직이 들어간 부분은 여기 한 곳입니다.
- option들을 담고 있는 전체 박스는 처음에 보이지 않다가 select box가 open된 경우(isSelected = true) 노출이 되게끔 만들었습니다.
주요 기능
SelectBox.tsx
interface Props {
options: string[];
setOption: React.Dispatch<React.SetStateAction<string>>;
}
const SelectBox = ({ options, setOption }: Props) => {
const [isSelected, selectRef, selectHandler] = useDetectClose();
const [viewValue, setViewValue] = useState("정렬");
const handleSelectValue = (e: any) => {
setViewValue(e.target.getAttribute("value"));
};
return (
<S.SelectBox ref={selectRef} onClick={selectHandler}>
<S.Label>{viewValue}</S.Label>
<S.SelectOptions isOpen={isSelected}>
{options.map(option => (
<S.Option key={option} value={option} onClick={handleSelectValue}>
{option}
</S.Option>
))}
</S.SelectOptions>
<SelectArrow />
</S.SelectBox>
);
};
- 맨 처음에 이야기했듯이 정렬 옵션들인 options 배열과 value를 설정하는 setOption 두 개를 props로 받게 됩니다.
- 그리고 option이 클릭될 때 li 태그의 value 속성의 값을 가져와서 저장하게 됩니다.
고민거리
- 우선 options로 받는 문자열은 ["최신순", "가격낮은순", "좋아요순"] 과 같은 형식으로 사용자에게 보여지는 문구 그대로입니다.
- 그러나 우리가 반환해야 하는 값은 "newest", "priceasc", "mostlike" 와 같은 API 요청시 필요한 options 값입니다.
- options로 반환할 값을 props로 입력받는다고 하여도 사용자에게 저대로 보여줄 수는 없으므로 어찌되었건 문자열을 변환해야만 했습니다.
const handleSelectValue = (e: any) => {
const current = e.target.getAttribute("value");
setViewValue(current);
switch (current) {
case "최신순":
setOption("newest");
break;
case "오래된순":
setOption("oldest");
break;
case "좋아요순":
setOption("mostlike");
break;
case "조회수순":
setOption("mostview");
break;
case "가격낮은순":
setOption("priceasc");
break;
case "가격높은순":
setOption("pricedesc");
break;
}
};
- 그래서 위와 같은 로직이 추가되었습니다...
- 옵션의 value 속성은 props로 받는 문자열이 되고
- 옵션이 선택되었을 때 해당 li 태그의 value 속성값을 가져와서 저장한 뒤 사용자에게 노출됩니다.
- 그리고 그 과정에서 문자열 변환 작업을 합니다.
- 이 로직의 문제점은 정확히 입력받아야 하는 문자열이 정해져있다는 것입니다. 조회수순이 아니라 조회수높은순 혹은 최신순 대신 최신으로 입력이 오면 정확한 값을 반환받을 수 없습니다.
- 또 한가지는 정렬 옵션이 추가될 때마다 case 또한 추가로 입력해주어야 한다는 것입니다.
리팩토링(2023-07-29)
if (usage === "정렬") {
switch (current) {
case "최신순":
setOption("newest");
break;
case "오래된순":
setOption("oldest");
break;
case "좋아요순":
setOption("mostlike");
break;
case "조회수순":
setOption("mostview");
break;
case "가격낮은순":
setOption("priceasc");
break;
case "가격높은순":
setOption("pricedesc");
break;
}
} else if (usage === "상태") {
switch (current) {
case "판매완료":
setOption("productst");
break;
case "판매중":
setOption("productsf");
break;
case "등록대기":
setOption("productwait");
break;
case "등록거절":
setOption("productdeny");
break;
}
} else {
setOption(current);
}
- 기존에 정렬용으로만 사용하던 셀렉트 박스를 상태와 카테고리용으로도 사용하게 되면서 위 로직이 더 길어지고 가독성이 안 좋아지게 되었습니다.
interface Props {
usage: "정렬" | "카테고리" | "상태";
options: string[];
setOption: React.Dispatch<React.SetStateAction<string>>;
}
- 입력받는 props도 usage라는 옵션이 추가되었습니다.
- 사실 정렬과 상태는 굳이 용도를 구분할 필요가 없이 기존 switch문에 추가를 해주어도 상관은 없습니다.
- 이것은 공통되는 부분으로 묶어줄 수 있다는 이야기가 될 수 있죠!
- 따라서 입력받는 문자열과 대체되어야 하는 문자열 두 가지의 키를 갖는 객체로 관리할 수 있을 거라고 생각하였습니다.
const replaceValue = {
정렬: [
{ view: "최신순", replace: "newest" },
{ view: "오래된순", replace: "oldest" },
{ view: "좋아요순", replace: "mostlike" },
{ view: "조회수순", replace: "mostview" },
{ view: "가격낮은순", replace: "priceasc" },
{ view: "가격높은순", replace: "pricedesc" },
],
상태: [
{ view: "판매완료", replace: "productst" },
{ view: "판매중", replace: "productsf" },
{ view: "등록대기", replace: "productwait" },
{ view: "등록거절", replace: "productdeny" },
],
카테고리: [],
};
- 카테고리의 경우 빈 배열로 둔 이유는 사용할 때 정렬이나 상태처럼 문자열이 변환되어야 하는 경우와 구분하기 위함입니다.
const handleSelectValue = (e: BaseSyntheticEvent) => {
const current = e.target.getAttribute("value");
setViewValue(current);
try {
if (replaceValue[usage].length > 0) {
const temp = replaceValue[usage].filter(
option => option.view === current
);
setOption(temp[0].replace);
} else setOption(current);
} catch (e) {
console.error("올바른 옵션인지 확인해주세요", e);
}
};
- switch문으로 지저분하게 있던 로직은 위와 같이 리팩토링할 수 있었습니다.
- 카테고리의 경우는 length === 0 이기 때문에 입력받은 문자열 그대로 setOption을 하게 됩니다.
- 그 외 경우에는 current값과 같은 view를 가지는 객체의 replace값을 setOption하게 되는 것이죠!
사용 방법
- 사용할 페이지에서 우선 value를 반환받아서 저장할 state와 정렬 옵션 배열을 만들어야 합니다.
- 페이지마다 정렬에 사용할 option을 입력해주면 되고 위에서 이야기했지만 옵션의 문자열은 정해진대로만 입력해야 합니다.
- 사용할 페이지에서 SelectBox 컴포넌트를 import 한 뒤 props를 넘겨줍니다.
결과물
- 모양도 이쁘장하게 잘 나왔고 화면 크기에 따라 적당하게 조절이 됩니다.
- 콘솔창을 보면 value 값이 변환되어서 전달되는 것을 알 수 있습니다!
전체 코드
SelectBox
import { BaseSyntheticEvent, useState } from "react";
import * as S from "./style";
import SelectArrow from "../../assets/icons/SelectArrow";
import useDetectClose from "../../hooks/useDetectClose";
interface Props {
usage: "정렬" | "카테고리" | "상태";
options: string[];
setOption: React.Dispatch<React.SetStateAction<string>>;
}
const SelectBox = ({ usage, options, setOption }: Props) => {
const [isSelected, selectRef, selectHandler] = useDetectClose();
const [viewValue, setViewValue] = useState(usage);
const replaceValue = {
정렬: [
{ view: "최신순", replace: "newest" },
{ view: "오래된순", replace: "oldest" },
{ view: "좋아요순", replace: "mostlike" },
{ view: "조회수순", replace: "mostview" },
{ view: "가격낮은순", replace: "priceasc" },
{ view: "가격높은순", replace: "pricedesc" },
],
상태: [
{ view: "판매완료", replace: "productst" },
{ view: "판매중", replace: "productsf" },
{ view: "등록대기", replace: "productwait" },
{ view: "등록거절", replace: "productdeny" },
],
카테고리: [],
};
const handleSelectValue = (e: BaseSyntheticEvent) => {
const current = e.target.getAttribute("value");
setViewValue(current);
try {
if (replaceValue[usage].length > 0) {
const temp = replaceValue[usage].filter(
option => option.view === current
);
setOption(temp[0].replace);
} else setOption(current);
} catch (e) {
console.error("올바른 옵션인지 확인해주세요", e);
}
};
return (
<S.SelectBox ref={selectRef} onClick={selectHandler}>
<S.Label>{viewValue}</S.Label>
<S.SelectOptions isOpen={isSelected}>
{options.map(option => (
<S.Option key={option} value={option} onClick={handleSelectValue}>
{option}
</S.Option>
))}
</S.SelectOptions>
<SelectArrow />
</S.SelectBox>
);
};
export default SelectBox;
style.ts
import styled from "styled-components";
export const SelectBox = styled.div`
position: relative;
width: 100px;
height: 24px;
flex-shrink: 0;
border: 3px solid var(--color-darkblue);
border-radius: 40px;
background: radial-gradient(
190.97% 141.42% at 100% 100%,
rgba(247, 247, 247, 0.7) 0%,
rgba(247, 247, 247, 0.7) 100%
);
margin: 0 10px 12px 0;
backdrop-filter: blur(5px);
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
cursor: pointer;
display: flex;
align-items: center;
font-size: var(--font-size-12);
font-weight: 700;
left: 10px;
top: 10px;
& svg {
position: absolute;
right: 3px;
@media (max-width: 767px) {
right: 0;
width: 16px;
height: 16px;
}
}
@media (max-width: 767px) {
width: 80px;
height: 20px;
border: 2px solid var(--color-darkblue);
font-weight: 500;
}
@media (min-width: 768px) and (max-width: 1023px) {
width: 90px;
height: 22px;
border: 2px solid var(--color-darkblue);
}
`;
export const Label = styled.span`
width: 90%;
text-align: center;
`;
export const SelectOptions = styled.ul<{ isOpen: boolean }>`
position: absolute;
top: 34px;
left: 0;
width: 100%;
overflow: hidden;
height: max-content;
max-height: ${props => (props.isOpen ? "none" : "0")};
padding: 0;
border: ${props =>
props.isOpen ? "2px solid var(--color-darkblue)" : "none"};
border-radius: 8px;
background: radial-gradient(
190.97% 141.42% at 100% 100%,
rgba(247, 247, 247, 0.7) 0%,
rgba(247, 247, 247, 0.7) 100%
);
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
color: #111;
appearance: none;
`;
export const Option = styled.li`
text-align: center;
padding: 6px 0;
transition: background-color 0.2s ease-in;
border-bottom: 1px solid var(--color-lightivory);
&:hover {
background-color: var(--color-darkgreen);
}
`;
반응형
'개발냥이 > 타입스크립트(Typescript)' 카테고리의 다른 글
[리액트+타입스크립트] 이미지 업로드 구현 & 이미지와 콘텐츠 하나의 객체로 관리하기 (0) | 2023.07.12 |
---|---|
[Next.js + typescript] 페이지네이션(Pagination) 구현해보기 (0) | 2023.07.10 |
[리액트+타입스크립트] 경과 시간(날짜) 표시하기 & Intl API 알아보기 (0) | 2023.07.07 |
[리액트+타입스크립트] 커스텀 훅(Custom hook) 만들어보기(useInput) (0) | 2023.06.22 |
[타입스크립트] 인터페이스(Interface) (0) | 2023.05.31 |
댓글
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
- Total
- Today
- Yesterday
링크
TAG
- 알고리즘
- Queue
- 자바스크립트
- 스프링
- DP
- SQLD
- 리액트
- dfs
- 프로그래머스
- 해시맵
- Comparator
- JavaScript
- java
- CS
- Algorithm
- 자바
- 자바dp
- BFS
- 자바bfs
- 정렬
- 이분탐색
- JPA
- 백준
- Nest
- SQL
- 자바트리
- 스프링부트
- Spring
- 형변환
- 타입스크립트
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
글 보관함