[React] 검색어 자동완성 키보드 이벤트 기능 구현
영화 검색 기능 구현
인풋에 텍스트를 작성하면 인풋 아래 자동 완성 목록이 나타나게 되는데
위와 같이 검색키보드 이벤트를 통하여 목록들 하나씩 선택되도록 구현해보려고 한다.
- 키보드 아래 방향키를 누르면 아래의 자동완성 목록들이 하나씩 선택되고 인풋에 선택된 영화의 제목이 나타난다.
- 자동 완성 목록 끝에서 아래 방향키를 누르면 다시 목록의 첫번째 아이템으로 이동한다.
- 첫번째 아이템에서 방향키 위를 누르면 기존의 내가 검색한 텍스트가 다시 나타난다.
- 선택된 검색 아이템을 기준으로 스크롤이 자동으로 된다.
- 엔터를 누르면 인풋에 나타난 텍스트를 쿼리로 받아 검색 결과 페이지로 라우팅된다.
* 기능 구현 과정에 초점을 두어 코드를 간소화하여 작성하였다.
사용될 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>
전에 따로 작성해둔 글을 참고하면 된다.
전체 코드
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>
);
};