본문 바로가기

송송 DEV

송송은 뚠뚠 오늘도 뚠뚠 열심히 ~ 코딩 하네 뚠뚠

리액트 커스텀 셀렉트 박스 만들기

 

개요

리액트에서 기본 셀렉트 태그가 아닌 자유롭게 스타일링할 수 있는 커스텀 셀렉트 박스를 만들어보자!

브라우저에서 기본 제공하는 셀렉트 박스에 대해 알아보고 원하는 스타일을 입힐 수 있도록 커스텀 셀렉트 박스를 만들어보기로 한다.

 

미리 보기


PC
모바일

 


셀렉트 박스 알아보기

 

셀렉트박스는 다양한 옵션 중에서 사용자가 하나를 선택할 수 있는 UI로 드롭다운 메뉴, 콤보박스 등 다양한 이름으로 불린다. 기본적으로 HTML의 <select> 태그와 <option> 태그를 이용하여 만들 수 있다. 

 

기본 셀렉트 태그

먼저 기본 셀렉트 태그를 살펴보자.

샘플 이미지의 소스는 mdn의 예제 소스이다. 링크

  • PC

크롬
엣지
파이어폭스

 

  • 모바일
크롬
카카오톡 브라우저
삼성 인터넷

 

브라우저 자체의 스타일이 있으며 CSS로 제어할 수 있는 부분이 적기 때문에 스타일 커스텀하기가 매우 까다롭다. 반면, 모바일에서는 브라우저 자체적으로 적합한 인터페이스를 제공하는 점이 장점이다.

 

커스텀 셀렉트 박스

이에 반해 select태그가 아닌 다른 태그를 활용해 같은 효과를 내는 방법도 있다. 이미 다른 사람들이 만들어 둔 훌륭한 패키지가 많이 있다. 가능하다면 이를 이용하는 것이 가장 편리하다.

다만, 기본 셀렉트박스처럼 모바일을 위한 인터페이스가 따로 없고 PC와 동일하게 유지 해야 하는 것이 단점이다. (여기까지 구현한 것은 아직 보지 못했다.)

 

mdn 예제의 커스텀 셀렉트박스


mui의 셀렉트 박스

 

이번에는 모바일에서는 브라우저 자체 인터페이스를 사용하고 싶어 패키지를 설치해 쓴다 한들 커스텀이 불가피했다. 그래서 직접 구현하기로 했다.

 

구현 사항 정리

  • 리액트로 제작
  • 기본 select와 option 태그는 살리고 <ul>, <li> 마크업 추가 작성
  • 화면이 클 때는 따로 작성한 <ul>, <li> 이용
  • 디바이스가 모바일이고, 화면이 작을 때는 기본 selectoption태그 사용
  • 키보드 입력 대응

맨 처음에는 단순하게 option 태그를 display:none으로 가리고 그 위에 내가 따로 작성한 마크업을 보이게 하려 작업하려 했는데…

 

option을 display:none;으로 설정한 상태

 

위의 이미지처럼 셀렉트박스는 브라우저 기본 스타일을 많이 따라가기 때문에 css로 완벽하게 제어할 수 없다. 따라서 e.preventDefault()로 기본 기능을 막고 기존 옵션 선택 역할을 하는 함수를 따로 만들어 구현했다.

 

구현 과정

1. 컴포넌트 구조

큰 구조를 먼저 보자면 아래와 같다.

기본 select와 option 태그에 따로 커스텀할 ul과 li태그를 추가하고 select 태그 대신 ul까지 감싸는 .wrapper에 핸들러를 달아준다.

const CustomSelect = () => {
  // state
  const [isExpand, setIsExpand] = useState(false);
  const [selected, setSelected] = useState("key01");

    // custom hook
    const { type: deviceType } = useDevice();
  const { w: deviceWidth } = useResize();

  const handleKeydown = (e) => {
    // 키보드 제어
        // ...중략
  };

  const handleMouseDown = (e) => {
        // 마우스 클릭 제어
        // ...중략
  };

  return (
    <>
      <div
        className="wrapper"
        onBlur={() => {
          // onBlur일 때 하단 드롭다운 메뉴를 닫는다
          // select 태그가 아니라 ul리스트도 함께 감싼 wrapper에
          // onBlur를 넣어줘야 ul태그의 버튼 이벤트를 onClick에 넣을 수 있다
          setIsExpand(() => false);
        }}
        onKeyDown={(e) => {
          if (deviceWidth > moWidth || deviceType !== "mobile")
            handleKeydown(e);
        }}
        onMouseDown={(e) => {
          if (deviceWidth > moWidth || deviceType !== "mobile") handleMouseDown(e);
        }}
      >
        <div>
          <span className={`arrow ${isExpand ? "is-expanded" : ""}`}></span>
          <select
            name="select"
            value={selected}
            onChange={(e) => {
              // option을 선택하면 selected 값을 변경
              setSelected(e.target.value);
            }}
          >
            {optionData.length > 0 &&
              optionData.map(({ optionKey, optionName }) => {
                // optionData를 이용해 옵션 렌더링
                return (
                  <option key={optionKey} value={optionKey}>
                    {optionName}
                  </option>
                );
              })}
          </select>
        </div>
        {isExpand && (
          <ul>
            {optionData.length > 0 &&
              optionData.map(({ optionKey, optionName }) => {
                // optionData를 이용해 ul 리스트 렌더링
                return (
                  <li key={optionKey}>
                    <button
                      buttonid={optionKey}
                      type="button"
                      onClick={() => {
                        // select option을 선택하면  onchange를 이용해 state 값을 변경한다
                        // selected state를 바로 변경함
                        setSelected(optionKey);
                        setIsExpand(false);
                      }}
                      className={selected === optionKey ? "selected" : ""}
                    >
                      {optionName}
                    </button>
                  </li>
                );
              })}
          </ul>
        )}
      </div>
    </>
  );
};

 

2. 마우스 동작 추가

const handleMouseDown = (e) => {
  // select태그를 누르는 순간 option 리스트가 노출되므로
  // 마우스를 뗄 때 실행되는 onClick이 아닌
  // onMouseDown일 때 기본 동작을 막아야 함
  e.preventDefault();

  // select 리스트가 열려 있는 상태에서 다시 누른 상황이라면
  // focus되어 있는지 체크하고
  // focus되어 있는 상태라면 blur 처리
  if (e.target.matches(":focus")) {
    setIsExpand((prev) => !prev);
  } else {
    e.target.focus();
    setIsExpand(() => true);
  }

  return false;
};

 

3. 키보드 동작 추가

사실 처음에 키보드로 제어하는 부분은 생각하지 못하고 있다가 나중에 추가했다.

셀렉트 태그가 포커싱 됐을 때 화살표 위, 아래 키를 이용해 항목을 선택할 수 있고 엔터 키를 이용해 커스텀으로 구현한 옵션 리스트를 열고 닫을 수 있다. 입력한 키에 따라 분기처리를 해야했기 때문에 다소 복잡하다.

 

const handleKeydown = (e) => {
  // 키보드 제어
  // KeyCode
  // 38 : 화살표 위 | 40 : 화살표 아래 | 13:엔터
  if (e.KeyCode === 38 || e.KeyCode === 40 || e.keyCode === 13) {
    e.preventDefault(); // 기본동작을 막아 options 비노출
  }

  if (e.keyCode === 38 || e.keyCode === 40) {
    // 위, 아래 키 눌렀을 때 선택한 데이터 변경
    setIsExpand(() => true);
    setSelected((prev) => {
      const newIdx = () => {
                // 이전, 다음 아이템의 인덱스 번호 가져와 값 변경
        const oldIdx = optionData.findIndex(
          (option) => option.optionKey === prev
        );
        if (e.keyCode === 38) {
          return oldIdx === 0 ? oldIdx : oldIdx - 1;
        }
        if (e.keyCode === 40) {
          return oldIdx === optionData.length - 1 ? oldIdx : oldIdx + 1;
        }
      };

      return optionData[newIdx()].optionKey;
    });
  }
  if (e.keyCode === 13) {
    // 엔터 키 눌렀을 때 ul리스트 토글
    setIsExpand((prev) => !prev);
  }
};

 

4. 커스텀 셀렉트 이용 조건 추가

커스텀 훅을 이용하여 브라우저 가로 너비와 모바일 디바이스인지 아닌지를 확인한다.

모바일이 아니라면 무조건 커스텀 리스트를 보이도록 하고 모바일이더라도 화면 사이즈가 큰 경우에도 커스텀 박스를 노출하도록 한다.

(커스텀 훅 내부 소스에 대한 설명은 이번 글에서는 생략한다)

 

  • 커스텀 훅 소스
// custom hook 추가
const useDevice = () => {
  // 모바일 디바이스인지 아닌지 체크
  // uaParser 패키지 이용
  const uaParser = new UAParser(window.navigator.userAgent);
  return useMemo(() => {
    try {
      return uaParser.getDevice();
    } catch (err) {
      return null;
    }
  }, []);
};

const useResize = () => {
  // 브라우저 가로 너비 감지
  const [state, setState] = useState({
    w: window?.innerWidth,
    h: window?.innerHeight
  });

  const debounce = (func, delay) => {
    let timeoutId = null;
    return (...args) => {
      if (timeoutId) {
        clearTimeout(timeoutId);
      }
      timeoutId = setTimeout(() => func(...args), delay);
    };
  };

  const onResize = (e) => {
    setState(() => {
      return {
        w: e.target.innerWidth,
        h: e.target.innerHeight
      };
    });
  };

  useEffect(() => {
    window.addEventListener("resize", debounce(onResize, 100));
    return () => {
      window.removeEventListener("resize", onResize);
    };
  }, []);

  return state;
};

 

  • 추가한 훅을 컴포넌트에서 사용
// 추가한 훅을 컴포넌트에서 사용
const CustomSelect = () => {
    // 컴포넌트에서 커스텀 훅 추가한 후 
    const { type: deviceType } = useDevice();
  const { w: deviceWidth } = useResize();

    // ...중략
    return (
    <>
      <div
        className="wrapper"
        onBlur={() => {
          setIsExpand(() => false);
        }}
        onKeyDown={(e) => {
                    // 키보드를 눌렀을 때 디바이스가 모바일이 아니거나
                    // useWidth 훅에서 가져온 화면 너비값이 설정한 것보다 작으면
                    // 2, 3단계에서 만든 핸들러를 이용해 커스텀 셀렉트 박스 노출
          if (deviceType !== "mobile" || deviceWidth > moWidth)
            handleKeydown(e);
        }}
        onMouseDown={(e) => {
                    // 마우스를 클릭했을 때
          if (deviceType !== "mobile" || deviceWidth > moWidth) handleMouseDown(e);
        }}
      >

    {/*...중략*/}
}

 

5. CSS 스타일링

select 태그의 기본 화살표를 가리기 위해 아래와 같이 추가해준다.

이외에는 특별한 것은 없고 자유롭게 스타일링 하면 된다.

select {
  -moz-appearance: none;
  -webkit-appearance: none;
  -o-appearance: none;
  -ms-appearance: none;
  appearance: none;
}

 

최종 결과물

See the Pen react-select-box by ylem76 (@ylem76) on CodePen.

 

결론

IE는 퇴출 되었다고 하지만 인풋 동작 및 스타일은 브라우저 별로 생각보다 상이했다.

동일한 사용자 경험을 위해 어디까지 직접 구현하고 어디까지 기본 태그를 이용하는 게 좋을까 생각해볼 필요가 있는 것 같다.