ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [React] dropdown(selectBox) 시멘틱하게 만들기
    React 2022. 1. 24. 22:59

    시행착오

    select컴포넌트를 만들어야겠다고 마음먹은 후에 어떻게 만들어야 될지 많은 삽질을 했습니다.

    하지만 가장 중요하게 생각했던 부분은 보이스오버가 잘 되는지, 시맨틱한 마크업인가? 를 생각하면서 개발하는 것이었습니다.

    하지만 어디서부터 시작해야할지 감도 잡히지 않았고, 계속 만들고 갈아엎기의 연속이되었습니다.

    왜 계속 갈아엎게 되나 생각을 하다보니 bottom-up 방식으로 만들다 보니 정작 내가 사용을 할 때 불편한 부분들이 계속 발견되었기 때문입니다.



    top-down으로 생각해보기

    계속 selectBox먼저 만들다 보니 이런 문제가 생겼다고 생각이 들어 어떻게 내가 이 컴포넌트를 사용하고 싶은지부터 생각해보고, 그려보기로 했습니다.
    어떤 Option이 선택되었는지 상위 컴포넌트에서 알아야 하기 때문에 위와 같이 selectedOptionsetSelectedOption을 넘겨주었습니다.

    <사용하는 곳>

    <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컴포넌트를 만들기로 마음을 먹었는데, 시멘틱하게 만들고 싶었습니다.
    많은 것들을 하나의 컴포넌트에 녹아내려다보니 어디서부터 만들어야할지 감이 잘 오지 않았습니다.
    그래서 천천히 우선순위를 생각하고, 반대로 생각을 해보니 감이 조금은 오는것 같습니다.

    댓글

Designed by Tistory.