들어가며
이번에는 검색어 자동 완성 기능을 구현해 보겠습니다.
기능
기능은 크게 세 가지로 나눌 수 있습니다.
1. 검색어를 입력하면 해당 글자를 포함한 상품이 있는 경우 리스트로 표시해 줍니다.
2. Tab키, 위 방향키, 또는 아래 방향키를 눌러 리스트 내에서 움직일 수 있습니다.
현재 선택한 아이템을 표시해 주며,검색창에 이름이 입력됩니다.
3. 엔터키를 누르거나, 리스트 아이템을 클릭하면 해당 검색 결과로 이동합니다.
동작 과정
전체적인 동작 과정은 다음과 같은 모습입니다.
구현 예시 코드
아래는 구현 예시 코드입니다.
import { useRouter } from 'next/router';
import {
ChangeEvent,
KeyboardEvent,
MouseEvent,
useEffect,
useRef,
useState
} from 'react';
import { useForm } from 'react-hook-form';
import useSWR from 'swr';
import { useRecoilValue } from 'recoil';
import { pageTypeAtom } from '@/atoms';
import SearchButton from './search-button';
import { ProductsResponse } from '@/types/product';
interface ISearchForm {
query: string;
}
export default function SearchForm() {
const router = useRouter();
const { q } = router.query;
const { register, setValue, handleSubmit } = useForm<ISearchForm>();
const [searchWord, setSearchWord] = useState('');
const pageType = useRecoilValue(pageTypeAtom);
const searchUrl = `/${pageType}/search`;
const [isListVisible, setIsListVisible] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const itemRefs = useRef<(HTMLElement | null)[]>([]);
const { data } = useSWR<ProductsResponse>(
searchWord ? `/api/products/search?q=${searchWord}` : null
);
const isOpenSearchList = isListVisible && data && data?.products?.length > 0;
const onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
setIsListVisible(true);
setSearchWord(e.currentTarget.value);
};
const onInputBlur = () => {
setIsListVisible(false);
};
const onInputClick = (e: MouseEvent<HTMLInputElement>) => {
setIsListVisible(true);
const target = e.currentTarget;
const query = target.value || '';
setSearchWord(query);
};
const onInputFocus = () => {
setSelectedIndex(-1);
};
const onInputKeyDown = (e: KeyboardEvent<HTMLElement>) => {
if (!data?.products) return;
if (e.key === 'Tab' || e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prevIndex) =>
prevIndex >= data.products.length - 1 ? 0 : prevIndex + 1
);
} else if (e.key === 'ArrowUp') {
setSelectedIndex((prevIndex) => Math.max(-1, prevIndex - 1));
}
};
const onButtonMouseDown = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
};
const onButtonMouseUp = (e: MouseEvent<HTMLButtonElement>) => {
const target = e.currentTarget;
const query = target.textContent || '';
navigateToSearch(query);
};
const navigateToSearch = (query: string) => {
router.push(`${searchUrl}?q=${query}`);
};
const onValid = ({ query }: ISearchForm) => {
navigateToSearch(query);
};
useEffect(() => {
const ref = itemRefs.current[selectedIndex];
if (!ref) return;
if (ref.textContent) setValue('query', ref.textContent);
}, [selectedIndex]);
useEffect(() => {
if (!q) return;
setValue('query', q?.toString());
}, [q, setValue]);
return (
<form
onSubmit={handleSubmit(onValid)}
>
<SearchButton />
<div>
<input
type="text"
required
{...register('query', {
required: true,
minLength: 2,
onChange: onInputChange,
onBlur: onInputBlur
})}
placeholder="검색어를 입력해주세요."
onClick={onInputClick}
onFocus={onInputFocus}
onKeyDown={onInputKeyDown}
/>
{isOpenSearchList && (
<div role="list">
{data.products.map((product, index) => (
<button
key={product.id}
ref={(ref) => {
itemRefs.current[index] = ref;
}}
type="button"
onMouseDown={onButtonMouseDown}
onMouseUp={onButtonMouseUp}
>
{product.name}
</button>
))}
</div>
)}
</div>
</form>
);
}
코드 설명
이제 각 코드를 쪼개서 간단히 살펴보겠습니다.
const [searchWord, setSearchWord] = useState('');
const { data } = useSWR<ProductsResponse>(
searchWord ? `/api/products/search?q=${searchWord}` : null
);
검색어는 searchWord로 관리됩니다. api에 searchWord를 보내면, 이름에 searchWord를 포함한 아이템 리스트를 반환받습니다.
const [isListVisible, setIsListVisible] = useState(false);
const isOpenSearchList = isListVisible && data && data?.products?.length > 0;
리스트 표시 여부는 isOpenSearchList로 관리합니다. isListVisible이 true이고, 아이템 리스트가 존재하는 경우 보입니다.
<input
type="text"
required
{...register('query', {
required: true,
minLength: 2,
onChange: onInputChange,
onBlur: onInputBlur
})}
placeholder="검색어를 입력해주세요."
onClick={onInputClick}
onFocus={onInputFocus}
onKeyDown={onInputKeyDown}
/>
검색창입니다. react hook form의 register를 사용했습니다. 주의할 점은 이벤트 핸들러를 등록하는 부분인데요.
onChange과 onBlur의 경우 register 안에 이벤트 핸들러를 등록해주어야 합니다.
더 자세한 내용을 아래 글을 참고해 주세요.
2023.12.10 - [Side Project/Troubleshooting] - react hook form에서 이벤트 사용하기
const [selectedIndex, setSelectedIndex] = useState(-1);
const itemRefs = useRef<(HTMLElement | null)[]>([]);
어떤 아이템이 선택됐는지는 selectedIndex와 itemsRef로 관리합니다.
selectedIndex는 -1부터 시작합니다.
<div role="list">
{data.products.map((product, index) => (
<button
key={product.id}
ref={(ref) => {
itemRefs.current[index] = ref;
}}
type="button"
onMouseDown={onButtonMouseDown}
onMouseUp={onButtonMouseUp}
>
{product.name}
</button>
))}
</div>
아이템 리스트입니다. 아이템의 인덱스는 0부터 시작합니다.
각 아이템에 ref를 설정해 관리합니다.
const onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
setIsListVisible(true);
setSearchWord(e.currentTarget.value);
};
검색어가 입력되면 onChange 이벤트가 발생해 검색어를 searchWord에 저장합니다.
리스트가 존재한다면 이때 표시됩니다.
const onInputBlur = () => {
setIsListVisible(false);
};
검색창 외부 요소를 누르면 onBlur 이벤트가 발생해 리스트가 사라집니다.
const onInputClick = (e: MouseEvent<HTMLInputElement>) => {
setIsListVisible(true);
const target = e.currentTarget;
const query = target.value || '';
setSearchWord(query);
};
검색창을 클릭하면 searchWord를 다시 설정합니다.
리스트가 있는 경우 표시됩니다.
const onInputFocus = () => {
setSelectedIndex(-1);
};
검색창이 포커스 되면 onFocus 이벤트가 발생해 selectedIndex를 -1로 초기화합니다.
const onInputKeyDown = (e: KeyboardEvent<HTMLElement>) => {
if (!data?.products) return;
if (e.key === 'Tab' || e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prevIndex) =>
prevIndex >= data.products.length - 1 ? 0 : prevIndex + 1
);
} else if (e.key === 'ArrowUp') {
setSelectedIndex((prevIndex) => Math.max(-1, prevIndex - 1));
}
};
검색창 내에서 키보드를 누르면 onKeyDown 이벤트가 발생합니다.
Tab키 또는 아래 방향키를 누르면 인덱스가 1 증가합니다. 마지막 인덱스인 경우, 다시 0으로 돌아갑니다.
위 방향키를 누르면 인덱스가 1 감소합니다. 인덱스가 -1인 경우, 더 이상 감소하지 않습니다.
e.preventDefault();를 사용해 외부 요소가 포커스 되는 걸 방지합니다.
useEffect(() => {
const ref = itemRefs.current[selectedIndex];
if (!ref) return;
if (ref.textContent) setValue('query', ref.textContent);
}, [selectedIndex]);
현재 선택된 아이템을 불러와 이름을 검색창에 입력합니다. 이때 입력값은 유저의 입력을 통해 바뀐 것이 아니므로 onChange 이벤트는 발생하지 않습니다.
const navigateToSearch = (query: string) => {
router.push(`${searchUrl}?q=${query}`);
};
const onValid = ({ query }: ISearchForm) => {
navigateToSearch(query);
};
<form
onSubmit={handleSubmit(onValid)}
>
... 생략 ...
</form>
엔터를 누르면 폼 제출이 발생해 해당 검색 결과로 이동합니다.
const onButtonMouseDown = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
};
const onButtonMouseUp = (e: MouseEvent<HTMLButtonElement>) => {
const target = e.currentTarget;
const query = target.textContent || '';
navigateToSearch(query);
};
리스트 아이템을 클릭하면 폼 제출이 발생하지 않고 해당 검색 결과로 이동합니다.
이때 onBlur 이벤트가 발생해 동작을 방해하는 것을 막기 위해 onButtonMouseDown 핸들러에서 e.preventDefault();를 사용합니다.
더 자세한 내용은 아래 글을 참고해 주세요.
2023.12.10 - [Side Project/Troubleshooting] - onBlur 이벤트와 마우스 이벤트
마치며
이렇게 검색어 자동 완성 기능을 간단하게 구현해 보았는데요.
이 기능을 넣을까 말까 꽤 고민하다가 아무래도 유저 입장에서 미리 정보를 알 수 있으면 편할 거 같아 넣게 되었습니다.
처음 생각할 때는 그리 어렵지 않아 보였는데 생각보다 이것저것 신경 쓸 게 있어 은근 애를 먹긴 했지만...
그래도 넣고 싶었던 기능이라 구현하고 나니 기분은 좋네요 XD
지적이나 다른 의견은 언제나 환영합니다 :D
감사합니다.
'Side Project > Feature' 카테고리의 다른 글
Nodemailer를 사용해 이메일 인증 구현하기 (0) | 2023.11.28 |
---|