기능 구현 - 검색어 자동완성 기능

치맨·2023년 9월 3일
0

기능구현

목록 보기
7/9
post-thumbnail

목차


미리보기

검색어 자동완성 기능 구현

  • React로 검색어 자동완성 기능을 구현해볼려고 합니다. 우선 구글은 자동완성 기능이 어떻게 동작하는지 알아보겠습니다.

  1. Input을 클릭한다.
  2. 클릭시 DropDown이 나타난다.
  3. 특정 키워드를 Input에 입력시 DropDown에 키워드가 나타난다.
  4. 키보드로 내리면 각 키워드들에게 Hover 된다. + 키보드 뿐만 아니라 마우스도 가능
  5. Enter 혹은 마우스로 클릭시 검색어로 검색이 된다.

1. Form과 Input 생성

  • Input에서 값을 입력받아, 검색할 수 있도록 하기 위해 Form과 Input을 만들어 줍니다.

  • Input에 검색할 내용을 State값으로 관리해줍니다.

2. DropDown Custom Hook 생성

  • 우선 input에 클릭시 DropDown이 나타나야 됩니다. 저는 custom Hook으로 빼서 적용해보겠습니다.
  1. input을 클릭하면 나타나고, 다른곳을 클릭하면 없애도록 해야하기 때문에 isFocus라는 state를 하나 만들어서 관리해줍니다. 저는 form에 data-set으로 id값을 담아주고, (e.target as HTMLElement).closest('form')?.dataset.id을 통해 클릭시 true, 다른곳 클릭시 false로 바꿔주도록 했습니다. ref를 써서 form에 접근해도 괜찮지 않을가 합니다.

// isFocus 상태 관리
const [isFocus, setIsFocus] = useState(false)

// 클릭시 DropDown을 나타내고(isFocus:true), 다른곳 클릭시 없애주도록(isFocus:false) 
const handleClickSearchBox = (e: MouseEvent) => {
    const isFocus = (e.target as HTMLElement).closest('form')?.dataset.id;
    isFocus ? setIsFocus(true) : setIsFocus(false);
  };

// form을 제외한 다른곳 클릭시 없애기 위해 window에 click Event를 걸어줍니다. 
useEffect(() => {
    window.addEventListener('click', handleClickSearchBox);
    return () => {
      window.removeEventListener('click', handleClickSearchBox);
    };
  }, []);  
  1. 특정 키워드를 Input에 입력시 DropDown에 키워드가 나타난다.
  • 실제로는 API를 통해 해야겠지만, 저는 임시로 특정 키워드만 담아둔 Arr를 filter를 통해 나타내봤습니다.

// 임시 data arr
const PREVIEW_LIST = [
  '굽네치킨',
  '지코바 치킨',
  '버거킹',
  'bhc',
  'bbq',
  'pizza',
  'pizza hut',
  'hamburger',
  '도미노피자',
  '반올림피자',
  '돈까스',
  '돈치킨',
  '치킨까스',
  '햄버거',
  '햄토스트',
];
 // 검색어 입력시 검색어와 연관된 Data를 담을 List, default값은 PREVIEW_LIST
 const [dropDownList, setDropDownList] = useState(PREVIEW_LIST);

// dropDownList들 중에 마우스나 키보드를 사용해서 index번째 값에 접근하기 위한 index
// -1로 시작한 이유는 data가 0번째부터 있기 때문에 -1부터 시작해서 내려가도록 합니다.
 const [dropDownItemIndex, setDropDownItemIndex] = useState(-1);

// 키워드가 없다면 빈배열[]로 만들어주고 종료합니다.
  const updatedDropDownList = () => {
    if (inputValue === '') {
      setDropDownList([]);
      return;
    }

    // 키워드가 있다면 filter와 includes를 통해 해당 키워드의 대.소문자 구별없이 filtering 해줍니다.
    const getRelatedKeywordArr = PREVIEW_LIST.filter(
      textItem => textItem.includes(inputValue.toLocaleLowerCase()) || textItem.includes(inputValue.toUpperCase())
    );

    // dropDownList 상태값을 filtering한 배열로 바꿔주고, -1로 초기화해줍니다. 초기화 하는 이유는 검색어가 바뀔때 이전 index를 기억하지 않도록 하기 위함입니다.
    setDropDownList(getRelatedKeywordArr);
    setDropDownItemIndex(-1);
  };

// useEffect의 dep[]를 통해 inputValue가 바뀔때마다 updatedDropDownList를 update 해줍니다. 
  useEffect(() => {
    updatedDropDownList();
  }, [inputValue]);
  1. 키보드로 위,아래, Enter(검색) 및 마우스로 클릭시 검색하도록 구현해줍니다.
const navigate = useNavigate();

// 마우스로 클릭시 navigate를 통해 이동
const handleClickDropDownList = (dropDownItem: string) => {
    navigate(`/search?keyword=${dropDownItem}`);
  };

// 키보드로 입력시 
const handleDropDownKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
  
  // 빈값이거나 isComposing이 true일 경우 종료해줍니다. isComposing관련해서 아래에서 다시 말씀드리겟습니다.
    if (inputValue.trim() === '' || event.nativeEvent.isComposing) return;

  // 아래방향 키보드 누를경우
    if (event.code === 'ArrowDown') {
      dropDownItemIndex === dropDownList.length - 1
        ? setDropDownItemIndex(-1)
        : setDropDownItemIndex(dropDownItemIndex + 1);
    }

  // 위방향 키보드 누를경우
    if (event.code === 'ArrowUp') {
      dropDownItemIndex === -1
        ? setDropDownItemIndex(dropDownList.length - 1)
        : setDropDownItemIndex(dropDownItemIndex - 1);
    }

    // Enter 키보드 누를경우
    if (event.code === 'Enter') {
      let keyword = dropDownList[dropDownItemIndex] ? dropDownList[dropDownItemIndex] : inputValue;
      navigate(`/search?keyword=${keyword}`);
    }
  };

useDropDown Hook 전체코드

import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';

const PREVIEW_LIST = [
  '굽네치킨',
  '지코바 치킨',
  '버거킹',
  'bhc',
  'bbq',
  'pizza',
  'pizza hut',
  'hamburger',
  '도미노피자',
  '반올림피자',
  '돈까스',
  '돈치킨',
  '치킨까스',
  '햄버거',
  '햄토스트',
];

const useDropdown = (inputValue: string) => {
  const navigate = useNavigate();
  const [dropDownList, setDropDownList] = useState(PREVIEW_LIST);
  const [dropDownItemIndex, setDropDownItemIndex] = useState(-1);
  const [isFocus, setIsFocus] = useState(false);

  const updatedDropDownList = () => {
    if (inputValue === '') {
      setDropDownList([]);
      return;
    }

    const getRelatedKeywordArr = PREVIEW_LIST.filter(
      textItem => textItem.includes(inputValue.toLocaleLowerCase()) || textItem.includes(inputValue.toUpperCase())
    );

    setDropDownList(getRelatedKeywordArr);
    setDropDownItemIndex(-1);
  };

  const handleDropDownKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
    if (inputValue.trim() === '' || event.nativeEvent.isComposing) return;

    if (event.code === 'ArrowDown') {
      dropDownItemIndex === dropDownList.length - 1
        ? setDropDownItemIndex(-1)
        : setDropDownItemIndex(dropDownItemIndex + 1);
    }

    if (event.code === 'ArrowUp') {
      dropDownItemIndex === -1
        ? setDropDownItemIndex(dropDownList.length - 1)
        : setDropDownItemIndex(dropDownItemIndex - 1);
    }

    if (event.code === 'Enter') {
      let keyword = dropDownList[dropDownItemIndex] ? dropDownList[dropDownItemIndex] : inputValue;
      navigate(`/search?keyword=${keyword}`);
    }
  };

  const handleClickSearchBox = (e: MouseEvent) => {
    const isFocus = (e.target as HTMLElement).closest('form')?.dataset.id;
    isFocus ? setIsFocus(true) : setIsFocus(false);
  };

  const handleClickDropDownList = (dropDownItem: string) => {
    navigate(`/search?keyword=${dropDownItem}`);
  };

  useEffect(() => {
    updatedDropDownList();
  }, [inputValue]);

  useEffect(() => {
    window.addEventListener('click', handleClickSearchBox);
    return () => {
      window.removeEventListener('click', handleClickSearchBox);
    };
  }, []);

  return {
    handleClickDropDownList,
    handleDropDownKeyDown,
    isFocus,
    dropDownList,
    setDropDownItemIndex,
    dropDownItemIndex,
  };
};

export default useDropdown;

3. Dropdown 컴포넌트 생성

  • customHook에서 만들어준 state값을 dropdown에서 받아서 적용해줍니다.
import { css } from '@emotion/react';
import theme from '../../../styles/theme';

const DropDownBox = css`
  margin: -10px auto;
  padding-top: 10px;
  background-color: white;
  height: 100px;
`;

const DropDownItem = css`
  padding: 0 16px;

  &.selected {
    cursor: pointer;
    background-color: ${theme.grey200};
  }
`;

interface Props {
  handleClickDropDownList: (dropDownItem: string) => void;
  dropDownList: string[];
  setDropDownItemIndex: React.Dispatch<React.SetStateAction<number>>;
  dropDownItemIndex: number;
}

const Dropdown = ({ handleClickDropDownList, dropDownList, setDropDownItemIndex, dropDownItemIndex }: Props) => {
  return (
    <div css={DropDownBox}>
    // dropDownList가 없으면 없다는 메세지를 나타내줍니다.
      {dropDownList.length === 0 && <div css={DropDownItem}>해당하는 단어가 없습니다</div>}
// dropDownList가 존재한다면 dropDownList를 map으로 돌면서 각각의 Item에 onClick handler, mouseOver handler를 달아줍니다. 
      {dropDownList.map((dropDownItem, dropDownIndex) => {
        return (
          <ul
            css={DropDownItem}
            key={`${dropDownItem}-${dropDownIndex}`}
            onClick={() => handleClickDropDownList(dropDownItem)}
            onMouseOver={() => setDropDownItemIndex(dropDownIndex)}
            className={dropDownItemIndex === dropDownIndex ? 'selected' : 'null'}
          >
            <li>{dropDownItem}<li>
          </ul>
        );
      })}
    </div>
  );
};

export default Dropdown;

SearchBar 컴포넌트 전체코드

import { useState } from 'react';

import { Input, Wrapper } from './SearchBar.style';
import useDropdown from '../../../hooks/useDropdown';
import Dropdown from '../../common/Dropdown/Dropdown';

const SearchBar = () => {
  const [inputValue, setInputValue] = useState('');

  const {
    handleDropDownKeyDown,
    isFocus,
    handleClickDropDownList,
    dropDownList,
    setDropDownItemIndex,
    dropDownItemIndex,
  } = useDropdown(inputValue);

  const changeInputValue = (event: React.ChangeEvent<HTMLInputElement>) => {
    setInputValue(event.target.value);
  };

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    if (inputValue.trim() === '') return;
  };

  return (
    <form css={Wrapper} data-id={'isInputFocus'} onSubmit={handleSubmit}>
      <input
        placeholder="테스트를 위해 피자를 입력해주세요"
        css={Input}
        autoComplete="off"
        type="text"
        name="keyword"
        value={inputValue}
        onChange={changeInputValue}
        onKeyDown={handleDropDownKeyDown}
      />
      {isFocus && (
        <Dropdown
          handleClickDropDownList={handleClickDropDownList}
          dropDownList={dropDownList}
          setDropDownItemIndex={setDropDownItemIndex}
          dropDownItemIndex={dropDownItemIndex}
        />
      )}
    </form>
  );
};

export default SearchBar;

마주친 문제

한글의 경우 문제발생

  • 한글의 경우 2번의 Event가 발생하는 문제

  • 영어의 경우 문제가 없으나, 한글의 경우 event가 2번 발생하는 문제가 생깁니다.
    즉 아래 방향키를 한번만 눌러도 영상에서의 첫번째 값인 도미노 피자가 아닌, 반올림피자에 커서가 가게됩니다.

  • 바로 영상 보고 가시죠

크로스브라우징 이슈

  • Chrome 브라우저에서는 문제가 발생하지만, Firefox에서는 또 문제가 발생하지 않습니다.

어떤문제였는가?

  • 한글의 경우 조합문자이기 때문에 현재의 단어가 완성된 단어인지, 조합중인 단어인지 판단하기 위해 isComposing이라는 속성을 통해 판단한다고 합니다.

  • 따라서 키보드를 누를땐 isComposing이 true, 키보드를 땔때 isComposing이 false가 되서 2번 이벤트가 발생합니다.

  • 아래의 사진에서 한글의 경우 _ 표시가 나타납니다. 이경우는 2번의 Event가 발생합니다.

어떻게 해결했는가?

  • 따라서 isComposing이 true인 경우 Return 시켜서 문제를 해결할 수 있었습니다.
    if (inputValue.trim() === '' || event.nativeEvent.isComposing) return;

  • 일반적으로 React 이벤트 핸들러에서 event 객체는 React가 제공하는 합성 이벤트(Synthetic Event)입니다. 합성 이벤트는 브라우저 간의 이벤트 호환성을 보장하고 이벤트 풀링(Event Pooling)을 통해 성능을 최적화하기 위해 사용됩니다

  • 따라서 키보드 event에 접근하기 위해 event.nativeEvent.isComposing로 접근해야 합니다. 리액트 공식문서 참고

profile
기본기가 탄탄한 개발자가 되자!

0개의 댓글