우아한 타입스크립트와 컴파운드패턴 등 최근 배운 내용을 연습 및 실습하고자 드롭다운(select)컴퍼넌트를 구현하였습니다. 패스트캠퍼스의 강의를 들으면서 스토리북, jest등 얕게나마 클론코딩은 해보았지만, 막상 혼자 힘으로 구현해본적은 없었습니다. 이를 타입스크립트도 연습할 겸 한번 구현 및 테스트까지 해보려고 합니다.
import useDropDown from '../shared/dropdown/hook/useDropDown';
import { DropDown_Data } from '../constant/data';
import { DropDown } from '../shared/dropdown';
const Select = () => {
const [isOpen, toggleOpen, value, selectValue] = useDropDown();
return (
<DropDown isOpen={isOpen} toggleOpen={toggleOpen}>
<DropDown.Trigger isOpen={isOpen} toggleOpen={toggleOpen} value={value} />
<DropDown.Menu
isOpen={isOpen}
value={value}
setValue={selectValue}
list={DropDown_Data}
toggleOpen={toggleOpen}
>
{DropDown_Data.map((item) => {
return (
<DropDown.Item
key={item.id}
value={item.value}
selected={item.value === value}
toggleOpen={toggleOpen}
setValue={selectValue}
></DropDown.Item>
);
})}
</DropDown.Menu>
</DropDown>
);
};
export default Select;
드롭다운 컴퍼넌트에는 Trigger(보이는 부분) , Menu (열었을 떄 펼쳐지는 영역), Item(하위에 있는 하나하나의 옵션)이 하위 컴퍼넌트로 존재합니다.
또한 구현하려는 기능은 드롭다운이 열렸을 때, 키보드를 통해 위아래 옵션으로 이동 및 선택, enter 및 esc로 드롭다운 닫기, 마우스로 영역 밖을 클릭했을 때 닫기를 추가 기능으로 적용하려고 합니다.
이후 테스트는 스토리북으로 UI를 확인하고 jest를 활용해서 아래 3가지 내용을 확인해보려고 합니다.
1. 드롭다운이 제대로 열리는가
2. 열렸을 때, 키보드 이벤트가 제대로 작동하는가.
3. 마우스 이벤트(아이템 선택 및 영역밖 클릭시 닫기) 가 제대로 작동하는가
스타일 구현
우선 UI는 아래와 같습니다.
dropdown 컴퍼넌터의 container 레이아웃을 relative로 두었고, menu는 해당 컴퍼넌트 아래에 붙도록(absolute) 하였습니다.
trigger에는 마우스 올렸을 때, pointer효과를 주고, 현재 선택된 아이템은 글자가 두꺼워지게 마우스가 올려지면 배경색이 변경되게 하였습니다.
//dropdown.css
.dropdown {
position: relative;
display: inline-block;
border: 2px solid black;
border-radius: 4px;
width: 200px;
}
.trigger {
cursor: pointer;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
.text {
padding: 8px 12px;
text-align: center;
}
.open {
background-color: #f0f0f0;
}
.menu {
position: absolute;
top: 103%;
left: 0;
z-index: 100;
width: 100%;
background-color: #fff;
border: 2px solid black;
border-top: none;
border-radius: 0 0 8px 8px;
}
.item {
margin-block: 4px;
}
.item:hover {
background-color: #f0f0f0;
}
.current {
font-weight: 700;
}
이후 기능 구현을 할 차례입니다. 코드 전체는 아래와 같습니다.
import React, { useRef } from 'react';
import './DropDown.css';
import useDropKeyboard from './hook/useDropKeyboard';
import useDropClick from './hook/useDropClick';
interface DropDownProps {
isOpen: boolean;
toggleOpen: () => void;
children: React.ReactNode;
value?: string;
setValue: React.Dispatch<React.SetStateAction<string | undefined>>;
}
type DropDownContainerProps = React.FC<
Omit<DropDownProps, 'value' | 'setValue'>
>;
const DropDownContainer: DropDownContainerProps = ({
children,
isOpen,
toggleOpen,
}) => {
const dropdownRef = useRef<HTMLDivElement | null>(null);
useDropClick(isOpen, toggleOpen, dropdownRef);
return (
<div className="dropdown" ref={dropdownRef}>
{children}
</div>
);
};
type TriggerProps = Omit<DropDownProps, 'setValue' | 'children'>;
const Trigger: React.FC<TriggerProps> = ({ isOpen, toggleOpen, value }) => {
return (
<div
className={`trigger text ${isOpen ? 'open' : ''}`}
onClick={toggleOpen}
>
{value ? value : '선택해주세요'}
</div>
);
};
interface MenuProps extends DropDownProps {
list: { id: number; value: string }[];
}
const Menu: React.FC<MenuProps> = ({
children,
isOpen,
setValue,
toggleOpen,
value,
list,
}) => {
useDropKeyboard(isOpen, value, setValue, list, toggleOpen);
return <>{isOpen && <div className="menu">{children}</div>}</>;
};
type ItemProps =
| Omit<DropDownProps, 'isOpen' | 'children'> & { selected: boolean };
const Item: React.FC<ItemProps> = ({
value,
setValue,
toggleOpen,
selected,
}) => {
const onClickValue = () => {
setValue(value);
toggleOpen();
};
return (
<div
className={`text item ${selected ? 'current' : ' '}`}
onClick={onClickValue}
>
{value}
</div>
);
};
export const DropDown = Object.assign(DropDownContainer, {
Trigger,
Menu,
Item,
});
컴파운드 패턴이란 코드의 변형을 쉽게 하기 위해서 (추가 및 삭제가 용이하게) 컴퍼넌트를 합성(컴파운드)해서 구현하는 것 입니다.
DropDown을 세분화하면 초기에 보여주는 영역(Header혹은 trigger라고 하더군요) 이후 클릭해서 열리는 영역(Menu)과 그 안에 각각의 item들 이렇게 3가지로 나눌 수 있습니다.
이를 저는 각각 Trigger, Menu, Item으로 명명했습니다. (Trigger라는 이름은 아래 영상을 참고하였습니다)
https://www.youtube.com/watch?v=fR8tsJ2r7Eg&t=869s
타입 정의
우선 type부터 정리하자면, 이전 프로젝트에서는 children 여부 및 특정 값의 추가에 따라서 너무 많은 파생 타입을 만들었었습니다. 이를 피드백하여 유틸함수를 좀 적극적으로 쓰자고 생각했습니다.
그러기 위해서 우선 기본적인 props의 타입을 정의하였습니다.
interface DropDownProps {
isOpen: boolean;
toggleOpen: () => void;
children: React.ReactNode;
value?: string;
setValue: React.Dispatch<React.SetStateAction<string | undefined>>;
}
이후 해당 타입을 Omit 및 Pick을 활용하여 확장하여 Menu, Trigger, Item의 타입을 정의했습니다.
type ItemProps =
| Omit<DropDownProps, 'isOpen' | 'children'> & { selected: boolean };
interface MenuProps extends DropDownProps {
list: { id: number; value: string }[];
}
초기에는 합성 컴퍼넌트를 사용하기 위해서 DropDown컴퍼넌트 하위에 키값으로 각각의 하위 컴퍼넌트를 등록하였습니다. 다만 이렇게 할 경우, 새로운 하위 컴퍼넌트가 늘떄마다 Container에도 동일하게 등록하고 해당 타입도 별개로 설정해줘야 했습니다.
interface DropDownContainer
extends React.FC<Omit<DropDownProps, 'value' & 'setValue'>> {
Trigger: React.FC<TriggerProps>;
Menu: React.FC<MenuProps>;
Item: React.FC<ItemProps>;
}
이를 보완하려고 고민하다가 카카오팀에서 사용한 합성 컴퍼넌트를 보게 되었습니다.
합성 컴포넌트로 재사용성 극대화하기 | 카카오엔터테인먼트 FE 기술블로그
방경민(Kai) 사용자들에게 보이는 부분을 개발한다는 데서 프론트엔드 개발자의 매력을 듬뿍 느끼고 있습니다.
fe-developers.kakaoent.com
내보낼 때 추가하면 타입을 미리 선언할 필요가 없더군요.. 따라서 Container는 아래와 같이 완성되고, export만 default만 변경하였습니다.
type DropDownContainerProps = React.FC<
Omit<DropDownProps, 'value' | 'setValue'>
>;
const DropDownContainer: DropDownContainerProps = ({
children,
isOpen,
toggleOpen,
}) => {
const dropdownRef = useRef<HTMLDivElement | null>(null);
useDropClick(isOpen, toggleOpen, dropdownRef);
return (
<div className="dropdown" ref={dropdownRef}>
{children}
</div>
);
};
export const DropDown = Object.assign(DropDownContainer, {
Trigger,
Menu,
Item,
});
(초기에는 2번 사용해서 타입 별칭을 사용했는데, 이러면 굳이 별칭을 만들 필요도 없다 느꼈습니다.)
기능 구현
이제 UI이와 컴퍼넌트 분리는 끝났고, 내부에 기능들을 추가할 차례입니다.
해당 컴퍼넌트에서는 결국 선택된 값과 열렸는지 여부를 확인할 필요가 있습니다.
import { useReducer, useState } from 'react';
const useDropDown = () => {
const [open, toggleOpen] = useReducer((v) => !v, false);
const [value, setValue] = useState<string | undefined>();
return [open, toggleOpen, value, setValue] as const;
};
export default useDropDown;
여닫는건 토글형태의 기능이기에, useReducer를 통해서 선언했습니다.(초기값은 닫힘)
그리고 현재 선택된 값은 useState로 지연변수로 선언했습니다.
합성 컴포넌트에서 depth가 깊지 않기 때문에, 굳이 context Api를 사용할 필요성을 느끼지 못했습니다.
이후 Item 컴퍼넌트에서 클릭하면 해당 값이 선택되면서 닫히게 하였고, 현재 선택된 것과 값이 같다면 글씨가 두꺼워 지도록 하였습니다.
const Item: React.FC<ItemProps> = ({
value,
setValue,
toggleOpen,
selected,
}) => {
const onClickValue = () => {
setValue(value);
toggleOpen();
};
return (
<div
className={`text item ${selected ? 'current' : ' '}`}
onClick={onClickValue}
>
{value}
</div>
);
};
영역밖을 클릭했을 때, 닫히게 하기 위해서는 ref로 가상돔에 접근할 필요성이 있습니다. 클릭했을 때 일어나는 효과이므로 useDropClick으로 명명하였고( 아직 네이밍이 어렵네요)
const DropDownContainer: DropDownContainerProps = ({
children,
isOpen,
toggleOpen,
}) => {
const dropdownRef = useRef<HTMLDivElement | null>(null);
useDropClick(isOpen, toggleOpen, dropdownRef);
return (
<div className="dropdown" ref={dropdownRef}>
{children}
</div>
);
};
import { useEffect } from 'react';
const useDropClick = (
isOpen: boolean,
toggleOpen: () => void,
dropdownRef: React.RefObject<HTMLDivElement>,
) => {
useEffect(() => {
const handleGlobalClick = (event: MouseEvent) => {
// 클릭한 위치가 드롭다운 영역 내부인지 확인
if (
isOpen &&
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
toggleOpen();
}
};
document.addEventListener('click', handleGlobalClick);
return () => {
document.removeEventListener('click', handleGlobalClick);
};
}, [isOpen, toggleOpen, dropdownRef]);
};
export default useDropClick;
컴퍼넌트가 마운트 됐을때 클릭 이벤트를 등록하고, 이때 창이 열려있고,영역이 내부가 아니라면 toggleOpen을 호출하여 닫게 하였습니다.
마지막으로 menu에서 키보드 이벤트를 제어하도록 하였는데, 키보드 위아래 클릭시 선택된 값이 바뀌며 enter와 esc시 닫혀야 하므로 필요한 변수는 데이터 리스트와 DropDownProps 전부가 필요합니다.
interface MenuProps extends DropDownProps {
list: { id: number; value: string }[];
}
const Menu: React.FC<MenuProps> = ({
children,
isOpen,
toggleOpen,
value,
setValue,
list,
}) => {
useDropKeyboard(isOpen, value, setValue, list, toggleOpen);
return <>{isOpen && <div className="menu">{children}</div>}</>;
};
이후 위아래 클릭시 현재 값을 list에서 찾아서 index변경, enter나 esc시 닫히도록 하였습니다.
import { useEffect } from 'react';
const useDropKeyboard = (
isOpen: boolean,
value: string | undefined,
setValue: React.Dispatch<React.SetStateAction<string | undefined>>,
list: { id: number; value: string }[],
toggleOpen: () => void,
) => {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
event.preventDefault();
const itemCount = list.length;
let newIndex = list.findIndex((item) => item.value === value);
if (event.key === 'ArrowUp') {
if (newIndex === -1) {
newIndex = itemCount - 1;
} else {
newIndex = (newIndex - 1 + itemCount) % itemCount;
}
} else if (event.key === 'ArrowDown') {
if (newIndex === -1) {
newIndex = 0;
} else {
newIndex = (newIndex + 1) % itemCount;
}
}
setValue(list[newIndex].value);
}
if (event.key === 'Enter' || event.key === 'Escape') {
event.preventDefault();
toggleOpen();
}
};
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}
}, [isOpen, value, setValue, list, toggleOpen]);
};
export default useDropKeyboard;
'프론트엔드' 카테고리의 다른 글
전역상태관리 라이브러리 Jotai의 WeakMap을 활용한 메모리 최적화 (0) | 2024.09.19 |
---|---|
Three.js 튜토리얼 - 기본 구조와 사용 방법 (0) | 2024.04.10 |
React에 StoryBook 도입하기(tailwind) (0) | 2024.02.23 |
DOM과 Virtual DOM (0) | 2024.02.22 |
Yarn Berry PnP를 NPM대신 사용하면 좋은 이유 (0) | 2023.11.20 |