-
[React] dropdown(selectBox) 시멘틱하게 만들기React 2022. 1. 24. 22:59
시행착오
select컴포넌트를 만들어야겠다고 마음먹은 후에 어떻게 만들어야 될지 많은 삽질을 했습니다.
하지만 가장 중요하게 생각했던 부분은
보이스오버가 잘 되는지
,시맨틱한 마크업
인가? 를 생각하면서 개발하는 것이었습니다.하지만 어디서부터 시작해야할지 감도 잡히지 않았고, 계속 만들고 갈아엎기의 연속이되었습니다.
왜 계속 갈아엎게 되나 생각을 하다보니 bottom-up 방식으로 만들다 보니 정작 내가 사용을 할 때 불편한 부분들이 계속 발견되었기 때문입니다.
top-down으로 생각해보기
계속 selectBox먼저 만들다 보니 이런 문제가 생겼다고 생각이 들어 어떻게 내가 이 컴포넌트를 사용하고 싶은지부터 생각해보고, 그려보기로 했습니다.
어떤 Option이 선택되었는지 상위 컴포넌트에서 알아야 하기 때문에 위와 같이selectedOption
과setSelectedOption
을 넘겨주었습니다.<사용하는 곳>
<SelectBox options={['test1', 'test2']} selectedOption={selecteOption} setSelectedOption={setSelecteOption} width={250} />
SelectBox.tsx 마크업하기
다음으로 위의 코드를 바탕으로 최소한의 마크업만을 만들어주었습니다.
<Label width={width}> <Select value={selectedOption}> {options.map((option) => ( <option key={option} value={option}> {option} </option> ))} </Select> </Label>
style.ts
const Label = styled.label<{ width: number | string }>` position: relative; display: inline-block; width: ${({ width }) => (typeof width === 'string' ? width : `${width}px`)}; &::after { content: ''; display: inline-block; position: absolute; right: 32px; top: 45%; border: solid 10px transparent; border-top-color: rgb(0, 0, 0); border-radius: 0.125rem; cursor: pointer; } `; const Select = styled.select` padding: 16px 32px; width: 100%; outline: none; border-radius: 4px; appearance: none; -webkit-appearance: none; -moz-appearance: none; `;
이렇게 한다면 아래와 같은 화면을 만나게 됩니다.
[참고]
커스텀 화살표를 만들게 하기 위해서는 기본으로 화살표를 제거해야합니다.
appearance: none -webkit-appearance: none; //(사파리, 크롬) -moz-appearance: none; //(파이어폭스)
그 다음 label의 ::after 요소를 통해 화살표를 만들어 주었습니다.
useSelect 훅 만들기
selectBox를 눌렀다면 selectBox가 열리고, 그 외에 다른곳을 눌렀다면 selectBox가 닫히는 함수가 필요합니다.
다른곳을 눌렀을 떄 selectBox가 닫히는 훅은 document에 이벤트를 설정해야 합니다.
따라서 컴포넌트가 렌더링이 될 때 이벤트를 등록해주고, 사라질 때 이벤트를 해제해주어야합니다.
또한 selectbox가 클릭이 되었는지, 닫혀있는지 컴포넌트에서 알아야하기 때문에 return값으로 그 상태를 넘겨 주었습니다.
훅을 적용시켜준 다음 label에 clickEvent를 달아주었습니다.useSelect.ts
const useSelect = <T extends HTMLElement>( selectBox: RefObject<T>, ): [boolean, Dispatch<SetStateAction<boolean>>] => { const [clickSelectedBox, setClickSelectedBox] = useState(false); useEffect(() => { const handleSelect = (e: MouseEvent) => { if (!e.target) return; if (!selectBox.current?.contains(e.target as HTMLElement)) { setClickSelectedBox(false); } }; document.addEventListener('click', handleSelect); return () => document.removeEventListener('click', handleSelect); }, [selectBox]); return [clickSelectedBox, setClickSelectedBox]; };
기본 동작 막기
다음으로 selectBox의 Option이 나오는 것이 아닌 직접 만든 option들이 나와야하기 때문에
preventDefault
속성을 통해 기본 이벤트가 나오지 않도록 방지했습니다.index.tsx
const SelectBox = ({ options, selectedOption, setSelectedOption, width }: Props) => { const labelRef = useRef<HTMLLabelElement>(null); const [clickSelectedBox, setClickSelectedBox] = useSelect(labelRef); useEffect(() => { console.log(clickSelectedBox); }, [clickSelectedBox]); const handleOpenSelectBox = (e: MouseEvent) => { e.preventDefault(); setClickSelectedBox(true); }; const handleSelectBox = (e: any) => { setSelectedOption(e.target.value); setClickSelectedBox(false); }; return ( <Label width={width} ref={labelRef} onMouseDown={handleOpenSelectBox}> <Select value={selectedOption} onChange={handleSelectBox}> {options.map((option) => ( <option key={option} value={option}> {option} </option> ))} </Select> {clickSelectedBox && ( <SelectItemWrapper> {options.map((option) => ( <SelectItem key={option}>{option}</SelectItem> ))} </SelectItemWrapper> )} </Label> ); };
시멘틱한 마크업 신경쓰기
마지막으로 시멘틱한 마크업을 신경써주면 됩니다.
aria-label은 화면에 현재 요소를 설명할 텍스트가 없을 경우에 사용하는 설명용 텍스트를 담고 있습니다.
라벨은 사용자 인터페이스의 설명하는 역할을 해야합니다.
또한 selectBox가 group 임을 알려줌과 동시에 어떤 그룹인지 알려주면 됩니다.
import { Dispatch, KeyboardEvent, MouseEvent, SetStateAction, useRef } from 'react'; import { Select, Label, SelectItemWrapper, SelectItem } from './style'; import useSelect from './useSelect'; interface Props { options: string[]; selectedOption: string; setSelectedOption: Dispatch<SetStateAction<string>>; width: string | number; maxHeight: number; name: string; handleSelectOptionClick: any; [x: string]: any; } const SelectBox = ({ options, selectedOption, setSelectedOption, width, maxHeight, name, handleSelectOptionClick, ...props }: Props) => { const labelRef = useRef<HTMLLabelElement>(null); const [clickSelectedBox, setClickSelectedBox] = useSelect(labelRef); const handleOpenSelectBox = (e: MouseEvent) => { e.preventDefault(); setClickSelectedBox(true); }; const handleKeyDown = (e: KeyboardEvent) => { if (!e.code) return; if (e.code === 'Enter' || e.code === 'Space') { setClickSelectedBox(true); } }; const handleSelectBox = (e: any, option?: string) => { const optionValue = option ? option : e.target.value; setSelectedOption(optionValue); setClickSelectedBox(false); handleSelectOptionClick(optionValue); }; return ( <Label {...props} tabIndex={0} role="group" aria-label={name} width={width} ref={labelRef} onKeyDown={handleKeyDown} onMouseDown={handleOpenSelectBox} > <Select value={selectedOption} onChange={handleSelectBox}> {options.map((option) => ( <option key={option} value={option}> {option} </option> ))} </Select> {clickSelectedBox && ( <SelectItemWrapper maxHeight={maxHeight}> {options.map((option) => ( <SelectItem key={option} onClick={(e) => handleSelectBox(e, option)} isSelected={option === selectedOption} > {option} </SelectItem> ))} </SelectItemWrapper> )} </Label> ); }; export default SelectBox;
회고
base단위의 작은 컴포넌트를 만들때 어떠한 것들을 신경써야되는지 감이 잘 오지 않을떄가 많은것 같습니다.
저 또한 이 부분 때문에 컴포넌트를 만들고 정작 사용할땐 마음에 들지 않아 다시 수정하거나 새로 만드는 일이 비일비재했습니다.
이러한 경우에 내가 어떻게 이 컴포넌트를 사용하고 싶은지 생각을 먼저하고 그려본다면 내부의 base컴포넌트 구조가 더 쉽게 잡히는것 같다는 생각을 했습니다.
또한 기존의 select 마크업은 스타일적인 변형이 어렵기 때문에 custom컴포넌트를 만들기로 마음을 먹었는데, 시멘틱하게 만들고 싶었습니다.
많은 것들을 하나의 컴포넌트에 녹아내려다보니 어디서부터 만들어야할지 감이 잘 오지 않았습니다.
그래서 천천히 우선순위를 생각하고, 반대로 생각을 해보니 감이 조금은 오는것 같습니다.'React' 카테고리의 다른 글
Toast Editor toolbar 에러 (0) 2022.05.12 [React] React-router는 내부적으로 history api를 사용하는지 확인하기 (0) 2022.03.25 [React / 에러 핸들링] react-helmet, react-snap 안될때 해결 방법 (0) 2022.03.04 [React / 에러 핸들링] Modal컴포넌트 만들기 (0) 2022.02.08 [React / 에러 핸들링] useClickAway훅 만들기 (0) 2022.02.04