[리액트 컴포넌트] Custom Dropdown

리오·2023년 6월 5일
0

Front End

목록 보기
9/10

개발하다보면 <Select>태그만으로 원하는 드롭다운을 구현하기 어렵습니다.
Select버튼을 눌렀을 때, 기존 디자인과 동떨어진 드롭다운이 펼쳐지면 사용자는 웹 서비스에서 어색함을 느끼게 됩니다.

이번 프로젝트를 진행하면서 React에서 사용할 수 있는 Select 컴포넌트를 직접 만들었습니다.
컴포넌트를 만들면서 고려했던 부분들을 공유합니다.

선택된 값을 상태로 저장

가장 기본적으로 드롭다운 내에 여러 옵션들 중에서 하나를 클릭했을 때, 선택된 값을 상태로 관리해줍니다.

  const [selectedValue, setSelectedValue] = useState(
    defaultValue || options[0].value,
  );

옵션 label-value

이 Custum Select는 여러 개의 옵션들을 드롭다운으로 보여줍니다.
이 때, 옵션들이 가진 value와, 이 옵션들이 드롭다운에 표시되었을 때 보여지는 텍스트를 다르게 만들고 싶었습니다. 따라서 Option이라는 interface를 만들고, 보여질 label과 실제 저장될 value를 분리하였습니다.

// Option 인터페이스 정의
export interface Option {
  label: string;
  value: string | number;
}

드롭다운 컴포넌트 생성

//(1)
const [isOpen, setIsOpen] = useState(false);

//(2)
const dropdown = isOpen && (
  <div
    style={{
      position: 'absolute',
      zIndex: zIndex,
      top: `${menuPosition.top}px`,
      left: `${menuPosition.left}px`,
      width: `${menuPosition.width}px`,
      maxHeight: `${maxVisibleOptions * 40}px`,
      overflowY: overflowY,
    }}
  >
  	{options.map((option) => (
    <div
      key={option.value}
      className="dropdown-option"
      onClick={() => handleOptionClick(option)}
      >
        {option.label}
      </div>
    ))}
  </div>
  );

(1) 먼저 드롭다운이 열려서 화면에 렌더링될지 여부를 상태로 저장합니다.
(2) isOpen이 true 인 경우에만 드롭다운이 렌더링되도록 합니다.

외부 영역을 클릭했을 때 드롭다운 접히게 만들기

드롭다운 영역의 외부를 마우스로 클릭했을 때, 자동으로 드롭다운이 닫히게 만들고 싶었습니다.
따라서 useEffect훅으로 mousedown event listener를 등록하였습니다.

// 외부 클릭을 감지하여 드롭다운을 닫는 이펙트
  useEffect(() => {
    const handleOutsideClick = (event: any) => {
      //(1)
      const dropdownOptionNodes = Array.from(document.querySelectorAll('.dropdown-option'));
      if (dropdownOptionNodes.includes(event.target)) {
        return;
      }
      //(2)
      if (
        containerRef.current &&
        !containerRef.current.contains(event.target)
      ) {
        setIsOpen(false);
      }
    };
	//(3)
    document.addEventListener('mousedown', handleOutsideClick);
    return () => {
      document.removeEventListener('mousedown', handleOutsideClick);
    };
  }, []);

(1) 먼저 click event가 발생한 지점이 드롭다운의 외부에서 발생한 event가 맞는지 확인해야합니다. 그래서 미리 옵션들의 className에 'dropdown-option'을 주고 querySelectorAll 을 통해서 옵션들에 해당하는 노드들을 배열로 가져와 줍니다. 그리고 이 배열 요소 중에 click event가 발생한 타겟이 존재하는지 판단합니다. 만약 이 배열 안에 이벤트 타겟이 존재한다면, 드롭다운 영역 안에서 발생한 click event이므로 무시해줍니다.

(2) 만약 CustomSelect 컴포넌트 내부가 클릭되었다면, 펼쳐진 드롭다운을 다시 답아줍니다.

(3) 드롭다운 외부가 클릭되는 이벤트 핸들러가 완성되었으니, document 객체에 mousedown 이벤트리스터 함수로 추가해줍니다. Custom Select가 언마운트 되었을 때에는 제거합니다.

드롭다운이 펼쳐지는 위치 계산하기

 // 드롭다운 메뉴의 위치를 관리하는 state
const [menuPosition, setMenuPosition] = useState({
  top: 0,
  left: 0,
  width: 0,
});
// 드롭다운의 위치를 계산하는 이펙트
useEffect(() => {
  if (containerRef.current) {
    const rect = containerRef.current.getBoundingClientRect();
    setMenuPosition({
      top: rect.bottom + window.scrollY,
      left: rect.left + window.scrollX,
      width: rect.width,
    });
   }
  }, [isOpen]);

드롭다운은 position 속성이 absolute이기 때문에, 드롭다운이 보여지는 위치를 설정해주어야 했습니다.
(1) 드롭다운 메뉴의 위치와 크기를 관리하는 상태를 만들어서, y축 상대위치, x축 상대위치, 드롭다운의 넓이를 저장해줍니다.
(2) 그리고 CustomSelect의 현재 위치와 크기를 getBoundingClientRect 함수로 가져와서 드롭다운 영역이 보일 위치에 반영해줍니다.
MDN(getBoundingClientRect)

전체 코드

import React, { useState, useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';

// Option 인터페이스 정의
export interface Option {
  label: string;
  value: string | number;
}

// Props 타입 정의
type CustomSelectProps = {
  options: Option[];
  onChange: (value: string | number) => void;
  defaultValue?: string | number;
  className?: string;
  maxVisibleOptions?: number;
  zIndex?: number;
};

const CustomSelect: React.FC<CustomSelectProps> = ({
  options,
  onChange,
  defaultValue,
  className,
  maxVisibleOptions = 5,
  zIndex = 20,
}) => {
  // 토글 상태와 선택된 값을 관리하는 state
  const [isOpen, setIsOpen] = useState(false);
  const [selectedValue, setSelectedValue] = useState(
    defaultValue || options[0].value,
  );

  // 드롭다운 메뉴의 위치를 관리하는 state
  const [menuPosition, setMenuPosition] = useState({
    top: 0,
    left: 0,
    width: 0,
  });

  // 컨테이너 요소의 참조
  const containerRef = useRef<HTMLDivElement | null>(null);

  // 드롭다운 토글 함수
  const toggleDropdown = () => {
    setIsOpen((prev) => !prev);
  };

  // 옵션 클릭 핸들러
  const handleOptionClick = (option: Option) => {
    setSelectedValue(option.value);
    onChange(option.value);
    setIsOpen(false);
  };

  // 외부 클릭을 감지하여 드롭다운을 닫는 이펙트
  useEffect(() => {
    const handleOutsideClick = (event: any) => {
      if (
        Array.from(document.querySelectorAll('.dropdown-option')).includes(
          event.target,
        )
      ) {
        return;
      }
      if (
        containerRef.current &&
        !containerRef.current.contains(event.target)
      ) {
        setIsOpen(false);
      }
    };

    document.addEventListener('mousedown', handleOutsideClick);
    return () => {
      document.removeEventListener('mousedown', handleOutsideClick);
    };
  }, []);

  // 드롭다운의 위치를 계산하는 이펙트
  useEffect(() => {
    if (containerRef.current) {
      const rect = containerRef.current.getBoundingClientRect();
      setMenuPosition({
        top: rect.bottom + window.scrollY,
        left: rect.left + window.scrollX,
        width: rect.width,
      });
    }
  }, [isOpen]);

  // 기본 값 설정 이펙트
  useEffect(() => {
    setSelectedValue(defaultValue || options[0].value);
  }, [defaultValue, options]);

  // 드롭다운 메뉴 렌더링
  const overflowY = options.length <= maxVisibleOptions ? 'hidden' : 'scroll';
  const dropdown = isOpen && (
    <div
      style={{
        position: 'absolute',
        zIndex: zIndex,
        top: `${menuPosition.top}px`,
        left: `${menuPosition.left}px`,
        width: `${menuPosition.width}px`,
        maxHeight: `${maxVisibleOptions * 40}px`,
        overflowY: overflowY,
      }}
      className="w-full overflow-hidden border rounded-lg shadow"
    >
      {options.map((option) => (
        <div
          key={option.value}
          className="px-3 py-2 truncate bg-white cursor-pointer dropdown-option hover:bg-gray-200"
          onClick={() => handleOptionClick(option)}
        >
          {option.label}
        </div>
      ))}
    </div>
  );

  const selectClass = clsx(
    'flex items-center justify-between px-3 py-2 w-full',
    'border rounded cursor-pointer border-subLine',
    className,
  );
  return (
    <div className="relative w-full select-none" ref={containerRef}>
      <div className={`${selectClass}`} onClick={toggleDropdown}>
        <span className="truncate">
          {options.find((option) => option.value === selectedValue)?.label}
        </span>
        {/* 아이콘 렌더링 */}
        <svg
          xmlns="http://www.w3.org/2000/svg"
          width="8"
          height="7"
          viewBox="0 0 8 7"
          fill="none"
          className="flex-shrink-0"
        >
          <path
            d="M4.49041 6.72C4.27245 7.09333 3.72755 7.09333 3.50959 6.72L0.0767116 0.839999C-0.141249 0.466666 0.131202 -3.76869e-08 0.567123 0L7.43288 5.93569e-07C7.8688 6.31256e-07 8.14125 0.466667 7.92329 0.840001L4.49041 6.72Z"
            fill="#282828"
          />
        </svg>
      </div>
      {/* 드롭다운 메뉴를 Portal을 사용하여 렌더링 */}
      {ReactDOM.createPortal(dropdown, document.body)}
    </div>
  );
};

export default CustomSelect;

이 개선점이나 부족한 점들을 댓글에 남겨주시면 더 좋은 글을 쓸 수 있는 원동력이 됩니다!

profile
오늘도 승승장구를 위해 연습 중

0개의 댓글