ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [React] 검색어 자동완성 키보드 이벤트 기능 구현
    React 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>
      );
    };



     

    반응형

    댓글

Designed by Tistory.