본문 바로가기
개발개발/Date-project

커스텀 Select, 드롭다운 구현하기

by yelimu 2025. 4. 24.

html 태그 중 select, option을 구현하면 아래와 같이 간단한 드롭다운을 구현할 수 있다

<label for="pet-select">Choose a pet:</label>

<select name="pets" id="pet-select">
  <option value="">--Please choose an option--</option>
  <option value="dog">Dog</option>
  <option value="cat">Cat</option>
  <option value="hamster">Hamster</option>
  <option value="parrot">Parrot</option>
  <option value="spider">Spider</option>
  <option value="goldfish">Goldfish</option>
</select>

 

 

코드 출처는 MDN

 

이 경우 UI 커스텀이 안되기때문에 input으로 구현하는 경우가 많이 있는 것 같다.

이전 workroot 프로젝트와 이번 프로젝트에서도 커스텀 select 를 구현했다.

작동 방식은 비슷한데, 구현 방식과 react hook form 에 등록하는 방법이 각기 register, controller로 다르길래 한번 자세히 살펴봐야겠다는 생각이 들었다.

 

개발 환경은 둘다 동일하게 Next.js app router + TypeScript 이다.


[WorkRoot]

"use client";
import React, { forwardRef, useEffect, useState, useRef, useCallback } from "react";
import { IoMdArrowDropdown } from "react-icons/io";
import { cn } from "@/lib/tailwindUtil";
import DropdownList from "./dropdownComponent/DropdownList";
import { useFormContext } from "react-hook-form";

interface InputDropdownProps {
  options: string[];
  className?: string;
  errormessage?: string;
  name: string;
  value?: string;
}

// 직접 입력이 가능한 드롭다운 컴포넌트
const InputDropdown = forwardRef<HTMLInputElement, InputDropdownProps>(
  ({ options, className = "", errormessage, name }, ref) => {
    // 드롭다운 상태 관리
    const [isOpen, setIsOpen] = useState<boolean>(false);
    const [selectedValue, setSelectedValue] = useState<string>("");
    const [isCustomInput, setIsCustomInput] = useState<boolean>(false);
    const { setValue, watch } = useFormContext();
    const dropdownRef = useRef<HTMLDivElement>(null);

    // 외부 클릭 감지하여 드롭다운 닫기
    useEffect(() => {
      const handleClickOutside = (event: MouseEvent) => {
        if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
          setIsOpen(false);
        }
      };

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

    // 드롭다운 토글 핸들러
    const toggleDropdown = (e: React.MouseEvent) => {
      e.preventDefault();
      setIsOpen((prev) => !prev);
    };

    // 옵션 선택 핸들러
    const handleSelect = useCallback(
      (option: string | null) => {
        if (!option) {
          setIsOpen(false);
          return;
        }

        if (option === "직접 입력") {
          setIsCustomInput(true);
          setSelectedValue("");
          setValue(name, "", { shouldDirty: true });
          setIsOpen(false);
          return;
        }

        setSelectedValue(option);
        setIsCustomInput(false);
        setValue(name, option, { shouldDirty: true });
        setIsOpen(false);
      },
      [name, setValue]
    );

    // 입력값 변경 핸들러
    const handleInputChange = useCallback(
      (e: React.ChangeEvent<HTMLInputElement>) => {
        if (isCustomInput) {
          const value = e.target.value;
          setSelectedValue(value);
          setValue(name, value, { shouldDirty: true });
        }
      },
      [isCustomInput, name, setValue]
    );

    // 입력 필드 클릭 핸들러
    const handleInputClick = useCallback(
      (e: React.MouseEvent) => {
        e.stopPropagation();
        if (!isCustomInput) {
          setIsOpen(true);
        }
      },
      [isCustomInput]
    );

    // 폼 값 변경 감지하여 입력값 동기화
    useEffect(() => {
      const value = watch(name);
      if (value !== undefined) {
        setSelectedValue(value);
      }
    }, [name, watch]);

    const textStyle = "text-base";

    const errorStyle = errormessage ? "!border-state-error" : "";
    const errorTextStyle =
      "absolute -bottom-[26px] text-[13px] text-sm font-medium leading-[22px] text-state-error lg:text-base lg:leading-[26px]";

    return (
      <div
        ref={dropdownRef}
        className={cn("relative inline-block text-left", "w-80 lg:w-[640px]", textStyle, className, errorStyle)}
      >
        <div
          onClick={isCustomInput ? undefined : toggleDropdown}
          className={cn(
            "rounded-md border border-transparent bg-background-200 p-2",
            !isCustomInput && "cursor-pointer hover:border-grayscale-200 hover:bg-background-300",
            isOpen && "ring-1 ring-grayscale-300"
          )}
        >
          <input
            type="text"
            ref={ref}
            value={selectedValue}
            onChange={handleInputChange}
            onClick={handleInputClick}
            readOnly={!isCustomInput}
            className={cn(
              "flex w-full items-center justify-between px-4 py-2 font-medium focus:outline-none",
              isCustomInput ? "cursor-text bg-transparent" : "cursor-pointer bg-transparent",
              "text-grayscale-700"
            )}
            placeholder={isCustomInput ? "직접 입력하세요" : "선택"}
          />
          <button
            type="button"
            className="absolute right-3 top-3.5 text-3xl"
            onClick={(e) => {
              e.stopPropagation();
              if (isCustomInput) {
                setIsCustomInput(false);
                setSelectedValue("");
                setValue(name, "", { shouldDirty: true });
              }
            }}
          >
            <IoMdArrowDropdown className={cn("transition-transform duration-200", isOpen && "rotate-180")} />
          </button>
        </div>
        {isOpen && <DropdownList list={options} onSelect={handleSelect} itemStyle={textStyle} />}
        {errormessage && <p className={cn(errorTextStyle, "right-0 pr-2")}>{errormessage}</p>}
      </div>
    );
  }
);
InputDropdown.displayName = "InputDropdown";

export default InputDropdown;

 

상당히 길긴 하지만 ㅎ; 

주요 props 은 name, value 이다.

내부에서 드롭다운 열림 상태, 선택된 값 상태를 관리한다. (직접 입력 옵션을 선택 시 사용자 입력 값을 별도 상태로 관리) 

컴포넌트 내부에 input이 포함되어있다.

 

  • 리액트훅폼의 watch, setValue 가 컴포넌트 내부에 들어와있다.
    => 외부에서 상태관리를 제어하지 않으므로 외부 코드는 간단해진다. 하지만 컴포넌트의 재사용이 불가하다. (다른 상태 라이브러리 등) 
  • 컴포넌트 내부에서 로컬 상태와 폼 상태를 중복으로 관리하고 있다.
  • input 에 value로 전달하는 값은 로컬 상태이다. -> 이를 화면에 보여주고, 폼 상태는 별도로 전송 
    => 불필요한 로컬 상태 제거 가능

 

페이지에서는 이런 식으로 호출해서 사용했다.

return (
    <Label>모집인원</Label>
    <InputDropdown
    {...register("numberOfPositions", { required: "모집 인원을 선택해주세요", valueAsNumber: true, min: 1 })}
    options={["1", "2", "3", "4", "5", "직접 입력"]}
    errormessage={errors.numberOfPositions?.message as string}
    />
)

 

그러면 이런 모습으로 구현이 된다.

 


[Endear]

import { useEffect, useRef } from "react";
import Arrow from "../../../assets/downArrow.svg";
import TextInput from "../textfield/textInput/TextInput";
import OptionList from "./OptionList";
import { icon, selectWrapper } from "./style.css";
import type { SelectProps } from "./type";

const Select = ({
  errorMessage,
  disabled = false,
  placeholder = "placeholder",
  optionList,
  value,
  onChangeValue,
  isOpen,
  onClickClose,
  onClickInput,
  width,
  ...props
}: SelectProps) => {
  const ref = useRef<HTMLDivElement | null>(null);

  const handleClickOption = (value: string | number) => {
    onClickClose?.();
    onChangeValue?.(value);
  };

  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      if (ref.current && !ref.current.contains(event.target as HTMLElement)) {
        onClickClose?.();
      }
    };

    document.addEventListener("click", handleClickOutside);

    return () => {
      document.removeEventListener("click", handleClickOutside);
    };
  }, [isOpen]);

  return (
    <div ref={ref} className={selectWrapper} style={{ width: width }}>
      <TextInput
        readOnly
        disabled={disabled}
        errorMessage={errorMessage}
        value={value}
        placeholder={placeholder}
        onClick={onClickInput}
        suffix={<Arrow className={icon({ isOpen, disabled })} />}
        width={width}
        {...props}
      />
      {isOpen && !disabled && (
        <OptionList
          list={optionList}
          onClick={handleClickOption}
          selected={value as string}
        />
      )}
    </div>
  );
};

export default Select;

코드가 훨씬 간결하다.

 

  • 내부에서 관리하는 로컬 상태는 없고, 
  • 옵션을 선택하면 상위에서 prop으로 전달받은 함수를 실행한다. : 드롭다운 닫힘, 선택한 값 변경
  • 상위에서 prop으로 전달받은 value를 input value로 전달한다.
    => 패키지 배포를 염두에 두고 개발했기때문에 독립적이고, 컴포넌트 재사용성이 높다. 완전한 제어 컴포넌트

 

페이지에서는 아래와 같이 Controller로 등록했다.

    <Controller
    name="birthYear"
    control={control}
    rules={{ required: signupError.birthYear.error }}
    render={({ field }) => (
      <Select
        width="88px"
        placeholder="년도"
        optionList={yearList}
        value={field.value ?? ""}
        onChangeValue={field.onChange}
        isOpen={isOpenBirthOption.birthYear}
        onClickClose={() => handleCloseBirthOptions("birthYear")}
        onMouseDown={() => handleClickBirthInput(field.name)}
        errorMessage={errors.birthYear?.message}
      />
    )}
    />

부모 컴포넌트가 좀더 복잡해졌다. 

또한 isOpen, onClickClose 등의 함수도 전달해줘야하기때문에, 이게 없다면 컴포넌트가 동작하지 않는다.

 

++ ('25/4/30)

Select 내부에서 isOpen 상태를 관리해주면 매번 부모에서 전달할 필요 없음

왜 부모에서 전달해줘야한다고 생각했지?;; 

암튼 불필요한 onClickClose, onMouseDown 도 삭제해주었다. 

역할이 같으니 구현한 모습도 크게 다르지 않다.


 

같은 역할을 하는 컴포넌트이더라도 구현 방식에 차이가 있을 수 있다는 것을 알게 되었다.

상황에 따라 어디까지 내부에서 관리하고, 어디부터 외부에서 제어해야할까 고민하는 단계가 매우 중요하겠다는 생각이 든다.