끄적이는 개발노트

Next.js 15 - SearchableDropdown 본문

JavaScript/Next.js

Next.js 15 - SearchableDropdown

크런키스틱 2025. 5. 28. 20:57
728x90

프로젝트를 진행하던 중, 검색 가능한 input을 만들 일이 생겼다. 기존의 input은 타이핑을 통해서만 내용을 채우지만 원하는 input의 형식은 '타이핑을 통한 input 채우기 + input 값 검색하여 리스트 제공 및 선택' 이었다.

이 기능을 위해 라이브러리를 찾아보고 쓸까 고민도 했지만 리스트 데이터에 존재하는 값만 받을지 안받을지에 대한 고민이 남아있어서 직접 만들기로 했다.

 

1. 테스트용 데이터 생성

아래는 설명을 위해 만든 테스트용 데이터로 실제 데이터는 api를 통해 가져온 데이터가 되야 한다.

 

const TestData = [
  { _id: "1", name: "김치찌개" },
  { _id: "2", name: "된장찌개" },
  { _id: "3", name: "불고기" },
  { _id: "4", name: "김치" },
  { _id: "5", name: "비빔밥" },
  { _id: "6", name: "잡채" },
  { _id: "7", name: "삼겹살" },
  { _id: "8", name: "갈비찜" },
  { _id: "9", name: "청국장" },
  { _id: "10", name: "떡볶이" },
];

 

 

2. 인덱스 타입 설정

해당 데이터는 나중에 keyName을 통해 key와 그에 따른 value를 가져와야하기 때문에 key에 대한 타입을 동적으로 처리할 필요가 있다. 이를 위해서 index type을 미리 지정해둔다.

// src/types/indexableType.ts

export interface StringIndexable {
  [key: string]: string;
}

 

 

3. Custom Hook 생성

// src/hooks/useSearchable.tsx

import { KeyboardEvent, MouseEvent, useEffect, useRef, useState } from "react";
import type { StringIndexable } from "@/types/indexableType";

const useSearchableDropdown = <T extends StringIndexable>(
  list: T[],	// 데이터 (generic type을 통해 데이터 타입 유동적으로)
  keyName: string,	// 데이터 key
  value: string,	// 최종적으로 input에 넣어질 state
  onChange: (...event: any[]) => void	// 최종적으로 input 값에 변경을 하는 state action
) => {
  const [query, setQuery] = useState<string>("");	// 입력을 통한 input state
  const [filterList, setFilterList] = useState<T[]>([]);	// 입력된 값으로 매칭된 데이터 리스트
  const [isOpen, setIsOpen] = useState<boolean>(false);		// 데이터 리스트의 열렸는지에 대한 여부
  const [selectIndex, setSelectIndex] = useState<number>(0);	// 데이터 리스트의 index (키보드 이벤트를 위해)

  const inputRef = useRef<HTMLInputElement>(null);	// input ref
  const itemRef = useRef<HTMLUListElement>(null);	// 키보드 이벤트를 위해 list item ref

  // input 클릭되면 toggleDropdown 이벤트 실행
  useEffect(() => {
    document.addEventListener("click", toggleDropdown);
    return () => document.removeEventListener("click", toggleDropdown);
  }, []);
  
  // ref를 통해 마우스 이벤트를 감지하여 데이터 리스트 open
  // 리스트 item index 매번 0으로 세팅
  const toggleDropdown = (
    e: MouseEvent<HTMLElement, globalThis.MouseEvent> | globalThis.MouseEvent
  ) => {
    if (e && e.target === inputRef.current) {
      setIsOpen(true);
      setSelectIndex(0);
    } else {
      setIsOpen(false);
      setSelectIndex(0);
    }
  };

  // input query(입력값)을 통해 리스트 필터링, 필터링한 값 filterList state에 삽입
  useEffect(() => {
    const filter = list.filter(
      (item) => item[keyName].toLowerCase().indexOf(query.toLowerCase()) > -1
    );
    setFilterList(filter);
  }, [query]);

  // 리스트에서 아이템 선택 시 이벤트
  // 입력값 초기화(query)
  // onChange를 통해 최종 value state 업데이트
  // 리스트 닫기
  const selectItem = (item: any) => {
    setQuery("");
    onChange(item[keyName]);
    setIsOpen(false);
  };

  // input에 보여질 value 설정
  // 입력값(query)이 있다면 입력값을,
  // 최종값(value)가 있다면 최종값을 보이게 설정
  const displayValue = () => {
    if (query) return query;
    if (value) return value;

    return "";
  };

  // 키보드 이벤트 설정
  const onKeyPress = (e: KeyboardEvent<HTMLDivElement>) => {
  	// 필터링된 리스트 길이
    const length = filterList.length;
    // 리스트 열림 + 리스트 존재 + 아래 화살표 이벤트
    if (isOpen && length > 0 && e.code === "ArrowDown") {
      setSelectIndex((selectIndex + 1) % length);
    }
    // 리스트 열림 + 리스트 존재 + 위 화살표 이벤트
    if (isOpen && length > 0 && e.code === "ArrowUp") {
      if (selectIndex === 0) setSelectIndex(length - 1);
      else setSelectIndex(selectIndex - 1);
    }
	// 엔터키 이벤트
    if (e.code === "Enter") {
      // 값이 있다면 selectItem을 통해 value 채우기
      if (isOpen && length > 0) {
        const item = filterList[selectIndex];
        if (item) selectItem(item);
      } else {
        setIsOpen(false);
      }
    }
  };

  // 키보드 스크롤 이벤트로 이동에 따른 부드러운 스크롤 처리
  const setKeyboardScroll = () => {
    const result = itemRef.current?.querySelector(".bg-textBlur");
    result?.scrollIntoView({
      behavior: "smooth",
      block: "end",
    });
  };
  
  // // 리스트 값만 사용하고 싶을 경우를 위한 value 값 검증
  // useEffect(() => {
  //   if (!isOpen && !value) {
  //     setQuery("");
  //   }
  // }, [isOpen, value]);

  return {
    inputRef,
    itemRef,
    query,
    setQuery,
    filterList,
    setFilterList,
    isOpen,
    setIsOpen,
    selectIndex,
    setSelectIndex,
    toggleDropdown,
    onKeyPress,
    setKeyboardScroll,
    selectItem,
    displayValue,
  };
};

export default useSearchableDropdown;

 

동작 방식을 간단히 설명하자면 다음과 같다. 각 코드에 대한 설명은 주석을 확인하면 된다.

  1. input을 클릭하거나 값을 입력하면 리스트 나타남
  2. input에 값을 입력하면 query state가 변경되며, 그에 따라 리스트를 필터링
  3. 리스트에 아이템을 선택하면 keyname에 해당하는 값을 value로 설정 + query는 다시 초기화 + 리스트 닫힘
  4. 키보드 이벤트를 추가하여 위와 마찬가지인 기능구현 (+ 부드러운 스크롤 처리 => component에서 설정한 className 필요)
  5. 주석처리된 코드는 input의 직접 입력은 막고 리스트에 해당하는 데이터만 사용하고 싶을 경우를 위한 검증으로, selectItem을 통해 value가 채워진 경우가 아니라면 무조건 빈 string으로 다시 돌아감

 

 

4. Component 생성

재사용이 가능한 컴포넌트이기 때문에 따로 생성했다.

아래와 같이 사용하면 되는데, 받아오는 props는 본인 입맛에 따라 추가 및 변경하면 된다.

다만, state와 state 변경 이벤트를 해당 컴포넌트나 hook에서 선언하지 않고 props로 받은 이유는 본인이 생각했을 때, 해당 state는 부모 컴포넌트 쪽에서 선언되고 사용될 것이라고 생각이 들어 props 형태로 구현했다.

// src/components/searchableDropdown.tsx

import useSearchableDropdown from "@/hooks/useSearchable";
import type { StringIndexable } from "@/types/indexableType";
import React from "react";
import ChevronUpIcon from "../../public/assets/fonts/svg/chevron-up";
import ChevronDownIcon from "../../public/assets/fonts/svg/chevron-down";

// 리스트는 어떤 속성이 들어올지 모르므로 Generic Type으로 interface 생성
interface ISearchableDropdown<T> {
  list: T[];
  keyName: string;
  placeholder: string;
  value: string;
  onChange: (...event: any[]) => void;
}

// 생성한 generic 타입을 미리 만들어둔 index type과 같이 사용
const SearchableDropdown = <T extends StringIndexable>({
  list,
  keyName,
  placeholder,
  value,
  onChange,
}: ISearchableDropdown<T>) => {
  // 미리 만들어둔 searchable hook 가져오기
  // 받아오는 props 외에도 별도의 custom 설정을 원한다면 추가 가능
  const {
    inputRef,
    itemRef,
    setQuery,
    filterList,
    isOpen,
    setIsOpen,
    selectIndex,
    setSelectIndex,
    toggleDropdown,
    onKeyPress,
    setKeyboardScroll,
    selectItem,
    displayValue,
  } = useSearchableDropdown(list, keyName, value, onChange);

  return (
    <div className="relative cursor-default">
      <input
        id={keyName}
        className="w-full rounded-md bg-white p-2 text-sm text-gray-800 box-border ring-1 ring-gray-800 focus:ring-green-600 shadow-xs outline-none hover:bg-gray-200"
        name={keyName}
        ref={inputRef}
        type="text"
        value={displayValue()}
        placeholder={placeholder}
        // input이 변경될 때는 query 변경 + value는 초기화
        onChange={(e) => {
          setQuery(e.target.value);
          onChange("");
          setIsOpen(true);
          setSelectIndex(0);
        }}
        onClick={toggleDropdown}
        // keyboard event 설정
        onKeyDown={onKeyPress}
      />
      // 리스트 open과 필터링된 리스트의 존재여부에 따라 화살표 변경
      {isOpen && filterList.length > 0 ? (
        <ChevronUpIcon className="absolute top-1.5 right-0 w-6 h-6 text-gray-800 pointer-events-none" />
      ) : (
        <ChevronDownIcon className="absolute top-1.5 right-0 w-6 h-6 text-gray-800 pointer-events-none" />
      )}

	  // 리스트 open + 필터링된 리스트가 있을 경우 리스트 표현
      {isOpen && filterList.length > 0 && (
      	// 키보드 이벤트를 위해 ref 연결
        <ul
          ref={itemRef}
          className="absolute start-0 z-10 w-full max-h-64 overflow-y-auto box-border rounded-md bg-white text-sm ring-1 shadow-lg ring-gray-200 "
        >
          {filterList.map((item, index) => {
            let background = "";
            let hover = "";
			// value와 아이템 값이 지정됐는지에 따라 hover와 background 색상 설정
            if (value === item[keyName]) {
              hover = "hover:bg-green-300";
              background = "bg-green-200";
              if (index === selectIndex) {
                background = "bg-green-300";
              }
            } else {
              hover = "hover:bg-gray-100";
              background = "bg-white";
              if (index === selectIndex) {
                background = "bg-gray-100";		<----- 여기에 설정한 className을 통해 hook의 스크롤 이벤트 인식
              }
            }
            // 임의로 이루어지는 scroll event기 때문에 실행되는 시간을 조정
            setTimeout(() => {
              setKeyboardScroll();
            }, 100);

            return (
              <li
                key={item._id}
                className={`p-2 box-border cursor-pointer truncate ${background} ${hover}`}
                onClick={() => {
                  selectItem(item);
                  onChange(item[keyName]);
                }}
              >
                {item[keyName]}
              </li>
            );
          })}
        </ul>
      )}
    </div>
  );
};

export default SearchableDropdown;

 

 

 

5. page

// app/searchable-dropdown/page.tsx

"use client";

import SearchableDropdown from "@/components/searchableDropdown";
import { useState } from "react";

const TestData = [
  { _id: "1", name: "김치찌개" },
  { _id: "2", name: "된장찌개" },
  { _id: "3", name: "불고기" },
  { _id: "4", name: "김치" },
  { _id: "5", name: "비빔밥" },
  { _id: "6", name: "잡채" },
  { _id: "7", name: "삼겹살" },
  { _id: "8", name: "갈비찜" },
  { _id: "9", name: "청국장" },
  { _id: "10", name: "떡볶이" },
];

const SearchableDropdownPage = () => {
  const [name, setName] = useState<string>("");

  return (
    <div className="w-full h-full flex flex-col justify-center items-center gap-4">
      <SearchableDropdown
        list={TestData}
        keyName="name"
        placeholder="음식을 입력해 주세요."
        value={name}
        onChange={setName}
      />
    </div>
  );
};

export default SearchableDropdownPage;

 

 

6. 실행

아래와 같이 잘 실행되는 것을 확인할 수 있다.

728x90

'JavaScript > Next.js' 카테고리의 다른 글

Next.js 15 - svgr  (0) 2025.06.05
Next.js 15 - Tiptap Editor  (0) 2025.06.05
Next.js 15 - Zustand  (0) 2025.05.21
Next.js 15 - Toast Message (with. Portal & Context API)  (0) 2025.05.14
Next.js 15 - Calendar 만들기 (with. dayjs)  (0) 2025.05.08