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 도 삭제해주었다.
역할이 같으니 구현한 모습도 크게 다르지 않다.
같은 역할을 하는 컴포넌트이더라도 구현 방식에 차이가 있을 수 있다는 것을 알게 되었다.
상황에 따라 어디까지 내부에서 관리하고, 어디부터 외부에서 제어해야할까 고민하는 단계가 매우 중요하겠다는 생각이 든다.
'개발개발 > Date-project' 카테고리의 다른 글
[vanilla-extract] sprinkles 적용하는 방법 (0) | 2025.04.21 |
---|---|
조건부 렌더링 - 비정상적인 버튼 동작 발생 원인 / 휴리스틱 알고리즘 / key prop의 중요성 (0) | 2025.04.21 |
submit 버튼이 아닌데 submit 되는 버그 (0) | 2025.04.18 |
배럴 파일 자동 생성 스크립트 (0) | 2025.04.09 |
모노레포 vite build 결과물이 이상해요? (0) | 2025.04.08 |