React

[React] 검색어 자동완성 키보드 이벤트 기능 구현

hid1 2023. 1. 5. 16:40

 

영화 검색 기능 구현

 

인풋에 텍스트를 작성하면 인풋 아래 자동 완성 목록이 나타나게 되는데
위와 같이 검색키보드 이벤트를 통하여 목록들 하나씩 선택되도록 구현해보려고 한다.

  • 키보드 아래 방향키를 누르면 아래의 자동완성 목록들이 하나씩 선택되고 인풋에 선택된 영화의 제목이 나타난다.
  • 자동 완성 목록 끝에서 아래 방향키를 누르면 다시 목록의 첫번째 아이템으로 이동한다.
  • 첫번째 아이템에서 방향키 위를 누르면 기존의 내가 검색한 텍스트가 다시 나타난다.
  • 선택된 검색 아이템을 기준으로 스크롤이 자동으로 된다.
  • 엔터를 누르면 인풋에 나타난 텍스트를 쿼리로 받아 검색 결과 페이지로 라우팅된다.

 

* 기능 구현 과정에 초점을 두어 코드를 간소화하여 작성하였다.


사용될 state 및 ref

const [isAutoSearch, setIsAutoSearch] = useState(false);
const [searchKeyword, setSearchKeyword] = useState("");
const [autoSearchKeyword, setAutoSearchKeyword] = useState("");
const [focusIndex, setFocusIndex] = useState(-1);
const focusRef = useRef(null);
const scrollRef = useRef(null);


isAutoSearch : 현재 자동완성목록을 탐색하고 있는 지에 대한 불린값
searchKeyword : 내가 직접 인풋에 타이핑한 검색값
autoSearchKeyword : 현재 선택된 자동완성 검색값
focusIndex : 현재 선택된 자동완성 목록의 인덱스

listRef : 자동완성목록 컨테이너 요소
focusRef : 현재 선택된 아이템 요소


 input

<input
	type="text"
	placeholder="영화를 검색해보세요"
	title="검색"
	name="검색"
	value={isAutoSearch ? autoSearchKeyword : searchKeyword}
	onChange={handleInputChange}
	onKeyUp={handleKeyUp}
/>


  value 값에 isAutoSearch를 기준으로 true이면 autoSearchKeyword, false이면 searchKeyword로 관리를 하였다.
  isAutoSearch는 유저가 인풋에 검색어를 작성 후 아래에 자동완성 목록이 있어 키보드 아래방향키를 누르게 되면 true가 된다. 그리고 첫번째 아이템에서 위방향키를 누르거나 추가로 검색어를 작성시 false로 바뀐다.


 change 이벤트

const handleInputChange = (e) => {
	if (isAutoSearch) {
		const enteredValue =
		e.nativeEvent.inputType === "deleteContentBackward"
		? ""
		: e.nativeEvent.data;
		focusIndex >= 0 && setSearchKeyword(autoSearchKeyword + enteredValue)
		setIsAutoSearch(false)
		setFocusIndex(-1)
		return
	}
	setSearchKeyword(e.target.value)
}

 

  onChange 이벤트 핸들러 함수에 이벤트 객체를 받아 searchKeyword에 담았다.
  if(isAutoSearch) 구문은 밑에 다시 언급할 예정


keyup 이벤트

const handleKeyUp = (e) => {
	if(KeyEvent[e.key]) KeyEvent[e.key]()
}


 KeyEvent 객체를 만들어 e.key와 동일한 이름의 메소드를 지어 동일하다면 그 메소드를 실행시키는 코드이다.

만약 KeyEvent[e.key]가 있다면 그 메소드를 실행시켜 주었다.

 

 const KeyEvent = {
    Enter: () => {
      goToSearchPage()
    },
    ArrowDown: () => {
      if (autoSearchList.length === 0) {
        return
      }
      if (listRef.current.childElementCount === focusIndex + 1) {
        setFocusIndex(() => 0)
        return
      }
      if (focusIndex === -1) {
        setIsAutoSearch(true)
      }
      setFocusIndex((index) => index + 1)
      setAutoSearchKeyword(autoSearchList.results[focusIndex + 1].title)
    },
    ArrowUp: () => {
      if (focusIndex === -1) {
        return
      }
      if (focusIndex === 0) {
        setAutoSearchKeyword("")
        setFocusIndex((index) => index - 1)
        setIsAutoSearch(false)
        return
      }

      setFocusIndex((index) => index - 1)
      setAutoSearchKeyword(autoSearchList.results[focusIndex - 1].title)
    },
    Escape: () => {
      setAutoSearchKeyword("")
      setFocusIndex(-1)
      setIsAutoSearch(false)
    },
  }



  KeyEvent 객체이다. 한 메소드 씩 살펴보겠다.

 const KeyEvent = {
    Enter: () => {
      goToSearchPage()
    }
  }

  const goToSearchPage = () => {
    if (isNull()) return
    navigate(
      `/search?query=${isAutoSearch ? autoSearchKeyword : searchKeyword}`
    )
  }


  엔터키를 누르면 goToSearchPage 함수가 실행된다.
  isAutoSearch state를 통해 분기처리 했다.
  (isNull은 인풋 공백을 체크하는 용도로 작성하였다)

 

  const KeyEvent = {
     ArrowDown: () => {
      if (autoSearchList.length === 0) {
        return
      }
      if (listRef.current.childElementCount === focusIndex + 1) {
        setFocusIndex(() => 0)
        return
      }
      if (focusIndex === -1) {
        setIsAutoSearch(true)
      }
      setFocusIndex((index) => index + 1)
      setAutoSearchKeyword(autoSearchList[focusIndex + 1].title)
    }
  }


  아래 방향키를 누르면 실행된다.
  아래 방향키를 누르면 focusIndex가 1씩 증가하고,
  AutoSearchKeyword에 선택된 아이템의 title을 가져와 인풋에 나타낸다.

 

  • 자동검색목록이 없으면 리턴
  • focusIndex가 자동완성목록 컨테이너의 아이템의 개수를 초과하면 focusIndex를 다시 0으로 만들어 첫번째 아이템을 선택하게 한다.
  • focusIndex가 -1이라면 유저가 자동검색목록을 이제 막 탐색하려고 하는 상태이기 때문에 isAutoSearch를 ture로 준다.

 

   const KeyEvent = {
     ArrowUp: () => {
      if (focusIndex === -1) {
        return
      }
      if (focusIndex === 0) {
        setAutoSearchKeyword("")
        setFocusIndex((index) => index - 1)
        setIsAutoSearch(false)
        return
      }

      setFocusIndex((index) => index - 1)
      setAutoSearchKeyword(autoSearchList.results[focusIndex - 1].title)
    }
  }



  위 방향키를 선택하면 실행된다.
  위 방향키를 누르면 focusIndex가 1씩 감소하고,
  AutoSearchKeyword에 선택된 아이템의 title을 가져와 인풋에 나타낸다.

  • focusIndex가 -1이면 탐색 상태가 아니기 때문에 리턴
  • focusIndex가 0이면 isAutoSearch가 false가 된다. (키워드 공백, 인덱스 -1)
  • 인풋의 value를 isAutoSearch로 분기처리 했기 때문에 내가 기존에 검색했던 searchKeyword가 다시 나타나게 된다.



const KeyEvent = {
     Escape: () => {
      setAutoSearchKeyword("")
      setFocusIndex(-1)
      setIsAutoSearch(false)
    }
}


esc키를 누르면 자동검색 목록 탐색 종료를 하게 된다.

const handleInputChange = (e) => {
    if (isAutoSearch) {
      const enteredValue =
      e.nativeEvent.inputType === "deleteContentBackward"
        ? ""
        : e.nativeEvent.data
      focusIndex >= 0 && setSearchKeyword(autoSearchKeyword + enteredValue)
      setIsAutoSearch(false)
      setFocusIndex(-1)
      return
    }

    setSearchKeyword(e.target.value)
}


  다시 돌아와 인풋 체인지 핸들러를 보면,
  if 구문은 자동검색 목록을 탐색 중일 때 내가 타이핑을 추가하게 될 경우의 코드이다.
  autoSearchKeyword에 내가 방금 타이핑한 값인 enteredValue가 더해져 searchKeyword에 담겨지고 탐색은 종료된다.
  하지만 타이핑말고 backspace를 치면 오류가 나게 된다.
  그래서 뒤로가기를 누른 걸 감지하는 e.nativeEvent.inputType === "deleteContentBackward"를 통해
  뒤로가기를 누르면 ""이 추가, 아니면 방금 내가 타이핑한 값을 추출하는 e.nativeEvent.data를 추가하였다.


useEffect(() => {
    if (isAutoSearch) {
      return
    }
    getSearchQuery({ query: searchKeyword })
}, [getSearchQuery, searchKeyword, isAutoSearch])


useEffect로 searchKeyword가 변경될 때마다 api 호출을 하여 자동검색 목록을 받아온다.
자동검색 목록 탐색을 중일 땐 가져오지 않게 리턴처리 하였다.

 


현재 선택된 아이템에 따른 스크롤

 

useEffect(() => {
    scrollRef.current?.scrollIntoView({ behavior: "smooth", block: "center" })
}, [focusIndex])

<SearchResultList ref={listRef}>
  {autoSearchList.map((movie, listIndex) => (
    <Link to={`/detail/${movie.id}`} key={movie.id}>
      <SearchResultItem
        ref={listIndex === focusIndex ? focusRef : undefined}
      >
        // 생략
      </SearchResultItem>
    </Link>
  ))}
</SearchResultList>


전에 따로 작성해둔 글을 참고하면 된다.

 

[JavaScript] 특정 요소에 따른 스크롤 자동 조정 : scrollIntoView()

element.scrollIntoView scrollIntoView()를 호출하면 호출된 요소가 사용자에게 보여지도록 상위 요소의 스크롤이 이동된다. element.scrollIntoView(); element.scrollIntoView(alignToTop); // Boolean 파라미터 element.scrollInto

ddd120.tistory.com

 

 

 

전체 코드

const Search = () => {
  const [isAutoSearch, setIsAutoSearch] = useState(false);
  const [searchKeyword, setSearchKeyword] = useState("");
  const [autoSearchKeyword, setAutoSearchKeyword] = useState("");
  const [focusIndex, setFocusIndex] = useState(-1);
  const listRef = useRef(null);
  const focusRef = useRef(null);

  const handleInputChange = (e) => {
    const enteredValue =
      e.nativeEvent.inputType === "deleteContentBackward"
        ? ""
        : e.nativeEvent.data;
    if (isAutoSearch) {
      focusIndex >= 0 && setSearchKeyword(autoSearchKeyword + enteredValue);
      setIsAutoSearch(false);
      setFocusIndex(-1);
      return;
    }

    setSearchKeyword(e.target.value);
  };

  const goToSearchPage = () => {
    if (isNull()) return;
    navigate(
      `/search?query=${isAutoSearch ? autoSearchKeyword : searchKeyword}`
    );
  };

  const KeyEvent = {
    Enter: () => {
      goToSearchPage();
    },
    ArrowDown: () => {
      if (autoSearchList.length === 0) {
        return;
      }
      if (listRef.current.childElementCount === focusIndex + 1) {
        setFocusIndex(() => 0);
        return;
      }
      if (focusIndex === -1) {
        setIsAutoSearch(true);
      }
      setFocusIndex((index) => index + 1);
      setAutoSearchKeyword(autoSearchList.results[focusIndex + 1].title);
    },
    ArrowUp: () => {
      if (focusIndex === -1) {
        return;
      }
      if (focusIndex === 0) {
        setAutoSearchKeyword("");
        setFocusIndex((index) => index - 1);
        setIsAutoSearch(false);
        return;
      }

      setFocusIndex((index) => index - 1);
      setAutoSearchKeyword(autoSearchList.results[focusIndex - 1].title);
    },
    Escape: () => {
      setAutoSearchKeyword("");
      setFocusIndex(-1);
      setIsAutoSearch(false);
    },
  };

  const handleKeyUp = (e) => {
      if(KeyEvent[e.key]) KeyEvent[e.key]();
  };

  useEffect(() => {
    if (isAutoSearch) {
      return;
    }
    getSearchQuery({ query: searchKeyword });
  }, [getSearchQuery, searchKeyword, isAutoSearch]);

  useEffect(() => {
    scrollRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
  }, [focusIndex]);

  return (
    <Base>
      <Input
        type="text"
        placeholder="영화를 검색해보세요"
        title="검색"
        name="검색"
        value={isAutoSearch ? autoSearchKeyword : searchKeyword}
        onChange={handleInputChange}
        onKeyUp={handleKeyUp}
      />
     <SearchResultList ref={listRef}>
      {autoSearchList.map((movie, listIndex) => (
        <Link to={`/detail/${movie.id}`} key={movie.id}>
          <SearchResultItem
            ref={listIndex === focusIndex ? focusRef : undefined}
          >
            // 생략
          </SearchResultItem>
        </Link>
      ))}
     </SearchResultList>
    </Base>
  );
};



 

반응형