๐ ๊ตฌํ ๊ฒฐ๊ณผ
๐ฉ ๊ตฌํ ๋ชฉํ
1. ์ฌ์ฉ์์ ๊ฒ์ ํค์๋์ ์ผ์นํ๋ ๊ฒ์ ๋ชฉ๋ก ๋ณด์ฌ์ฃผ๊ธฐ
2. ์, ์๋ ๋ฐฉํฅํค๋ก ๊ฒ์ ๋ชฉ๋ก ์ด๋ํ๊ธฐ
๐ ์ปดํฌ๋ํธ์ ์ ์ฒด ๊ตฌ์กฐ
๋๋ณด๊ธฐ
export default function SearchFormWithList({
monsters,
responsive,
visuallyHidden = false,
}: Props) {
const {
text,
searchRef,
listRef,
filterdMonsters,
selected,
cursor,
listOpen,
handleTextChange,
handleTextClear,
handleOpenList,
handleCloseList,
handleLinkClick,
} = useSearch(monsters);
const pathname = usePathname();
return (
<div
className={`${responsive === 'sm-hidden' ? 'sm-hidden' : undefined} ${
visuallyHidden && pathname === '/' && 'visually-hidden'
} ${styles.search}`}
ref={searchRef}
>
<SearchForm
text={text}
selected={selected}
onChange={handleTextChange}
onTextClear={handleTextClear}
onClick={handleOpenList}
onCloseList={handleCloseList}
/>
{listOpen && (
<SearchList
ref={listRef}
monsters={filterdMonsters}
cursor={cursor}
onLinkClick={handleLinkClick}
onCloseList={handleCloseList}
/>
)}
</div>
);
}
- Props
- monsters: ์๋ฒ์์ ๋ฐ์์จ ์ ์ฒด ๊ฒ์ ๋ชฉ๋ก
- responsive, visuallyHidden: css ์คํ์ผ ๊ด๋ จ ์์ฑ
- useSearch() hook
- ๊ฒ์ ๊ด๋ จ ๋ก์ง์ ๋ด๊ณ ์๋ ํ
- ref
- searchRef: ์ต์์ element(div)์ ์ฐ๊ฒฐ (keydown ์ด๋ฒคํธ๋ฅผ ๋ฑ๋กํ๊ธฐ ์ํจ)
- listRef: <SearchList /> ์ปดํฌ๋ํธ์ <ol> ํ๊ทธ์ ์ฐ๊ฒฐ (key ์ด๋ฒคํธ ๋ฐ์ ์, list๊ฐ ์คํฌ๋กค๋๊ธฐ ์ํจ)
๐ SearchForm, SearchList ์ปดํฌ๋ํธ
๋๋ณด๊ธฐ
<SearchFrom />
export default function SearchForm({
text,
selected,
onChange,
onTextClear,
onClick,
onCloseList,
onCloseSearchBar,
}: Props) {
const router = useRouter();
const searchParams = useSearchParams();
const handleSumbit = (e: React.FormEvent) => {
e.preventDefault();
if (text.trim().length === 0 || !selected) {
toast.dismiss();
toast.warn('๋ชฌ์คํฐ๋ฅผ ์ ํํด์ฃผ์ธ์');
return;
}
onCloseList && onCloseList();
onCloseSearchBar && onCloseSearchBar();
router.push(`/category/${selected.path}?search=${selected.name}`, {
scroll: text !== searchParams.get('search'),
});
};
return (
<form className={styles.form} onSubmit={handleSumbit}>
<input
className={styles.search}
type='text'
placeholder='๋ชฌ์คํฐ ๊ฒ์'
spellCheck={false}
value={text}
onChange={onChange}
onClick={onClick}
/>
{text.length > 0 && (
<button
className={styles['reset-button']}
type='button'
onClick={onTextClear}
>
<CloseIcon size='small' />
</button>
)}
<button className={styles['submit-button']} type='submit'>
<SearchIcon color='white' />
</button>
</form>
);
}
<SearchList />
const SearchList = forwardRef(
(
{
monsters,
cursor,
display,
onLinkClick,
onCloseList,
onCloseSearchBar,
}: Props,
ref: ForwardedRef<HTMLOListElement>
) => {
const searchParams = useSearchParams();
const handleClick = (name: string) => {
onLinkClick(name);
onCloseList && onCloseList();
onCloseSearchBar && onCloseSearchBar();
};
return (
<div
className={`${styles['list-container']} ${
display === 'mobileSearchBar' && styles['mobile-search-bar']
}`}
>
<ol className={`${styles.list}`} ref={ref}>
{monsters.length === 0 && (
<p className={styles['not-match']}>์ผ์นํ๋ ๋ชฌ์คํฐ๊ฐ ์์ต๋๋ค.</p>
)}
{monsters.map(({ name, path }, index) => (
<li
key={name}
className={`${styles.item} ${cursor === index && styles.select}`}
>
<Link
className={`${styles.link}`}
href={{
pathname: `/category/${path}`,
query: { search: name },
}}
prefetch={false}
scroll={name !== searchParams.get('search')}
onClick={() => handleClick(name)}
>
{name}
</Link>
</li>
))}
</ol>
</div>
);
}
);
๐ useSearch()
๋๋ณด๊ธฐ
// ๊ฐ ๋ณ๊ฒฝ ์, SearchList ์ปดํฌ๋ํธ์ .list ํด๋์ค max-height ๋ณ๊ฒฝํด์ฃผ์ธ์.
const SEARCH_LIST_SHOW_MAX_ITEM_COUNT = 7;
export default function useSearch(monsters: SearchMonster[]) {
const [text, setText] = useState(''); //์ฌ์ฉ์๊ฐ ์
๋ ฅํ search form text ์ํ
const [keyword, setKeyword] = useState(''); // search list ๋ฐฉํฅํค ์ด๋ฒคํธ ๋ฐ์ ์ , ์ฌ์ฉ์๊ฐ ์
๋ ฅํ keyword ์ํ
const [selected, setSelected] = useState<SearchMonster | null>(null); // search list์์ ์ ํ๋ item
const [cursor, setCursor] = useState<number | null>(null); // search list์์ ํ์ฌ ์ ํ๋ item์ cursor
const [listOpen, setListOpen] = useState(false);
const searchRef = useRef<HTMLDivElement>(null); // search ์ต์์ element
const listRef = useRef<HTMLOListElement>(null); // search list element
const searchParams = useSearchParams();
/** search list ๋ฐฉํฅํค ์ด๋ฒคํธ ๋ฐ์ ์ , ์ฌ์ฉ์๊ฐ ์
๋ ฅํ keyword์ ๋ฐ๋ผ ๋ชฉ๋ก ํํฐ */
const filterdMonsters = useMemo(
() =>
monsters.filter(({ name }) =>
name
.replace(/ /g, '')
.toUpperCase()
.includes(keyword.replace(/ /g, '').toUpperCase())
),
[keyword, monsters]
);
const handleTextClear = useCallback(() => {
setText('');
setKeyword('');
setCursor(null);
}, []);
const handleOpenList = useCallback(() => setListOpen(true), []);
const handleCloseList = useCallback(() => setListOpen(false), []);
const handleLinkClick = useCallback((name: string) => {
setText(name);
setCursor(0);
setKeyword(name);
}, []);
const handleTextChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setListOpen(true);
setText(e.target.value);
setKeyword(e.target.value);
setCursor(null);
setSelected(null);
},
[]
);
/** ๋ฐฉํฅํค์ ๋ฐ๋ฅธ ๊ฒ์ ๋ชฉ๋ก ์คํฌ๋กค ํจ์ */
const scrollSearchList = useCallback(
(cursor: number, direction: keyDirection) => {
const listElement = listRef.current;
const itemHeight = listElement?.children[0].clientHeight || 32;
// ์๋ ๋ฐฉํฅํค
if (direction === ARROW_DOWN) {
cursor >= SEARCH_LIST_SHOW_MAX_ITEM_COUNT
? listElement?.scrollBy({ top: itemHeight })
: listElement?.scrollTo({ top: 0 });
return;
}
// ์ ๋ฐฉํฅํค
if (direction === ARROW_UP) {
cursor <= filterdMonsters.length - 1 - SEARCH_LIST_SHOW_MAX_ITEM_COUNT
? listElement?.scrollBy({ top: -itemHeight })
: listElement?.scrollTo({
top: listElement.childElementCount * itemHeight,
});
return;
}
},
[filterdMonsters.length]
);
/** ๊ฒ์ ๋ชฉ๋ก ์, ์๋ ๋ฐฉํฅํค ์ด๋ฒคํธ */
useEffect(() => {
if (!listOpen) return;
// ์ผ์นํ๋ ๋ชฌ์คํฐ๊ฐ ์์ผ๋ฉด ์ด๋ฒคํธ๊ฐ ๋ฐ์ํ์ง ์์
if (filterdMonsters.length === 0) {
setSelected(null);
return;
}
const searchElement = searchRef.current;
const KeyHandler = (e: KeyboardEvent) => {
// ์๋ ๋ฐฉํฅํค
if (e.key === ARROW_DOWN) {
let nextCursor;
if (cursor === null) nextCursor = 0;
else nextCursor = cursor < filterdMonsters.length - 1 ? cursor + 1 : 0;
setCursor(nextCursor);
setText(filterdMonsters[nextCursor].name);
setSelected({
name: filterdMonsters[nextCursor].name,
path: filterdMonsters[nextCursor].path,
});
scrollSearchList(nextCursor, ARROW_DOWN);
return;
}
// ์ ๋ฐฉํฅํค
if (e.key === ARROW_UP) {
let nextCursor;
if (cursor === null) nextCursor = filterdMonsters.length - 1;
else nextCursor = cursor > 0 ? cursor - 1 : filterdMonsters.length - 1;
setCursor(nextCursor);
setText(filterdMonsters[nextCursor].name);
setSelected({
name: filterdMonsters[nextCursor].name,
path: filterdMonsters[nextCursor].path,
});
scrollSearchList(nextCursor, ARROW_UP);
return;
}
};
searchElement?.addEventListener('keydown', KeyHandler);
return () => {
searchElement?.removeEventListener('keydown', KeyHandler);
};
}, [listOpen, cursor, filterdMonsters, scrollSearchList]);
/** ๊ฒ์ ๋ชฉ๋ก ๋ฐ๊นฅ ์์ญ ํด๋ฆญ ์, ๋ชฉ๋ก ๋ซ๊ธฐ */
useEffect(() => {
if (!listOpen) return;
const closeSearchList = (e: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
setCursor(null);
setListOpen(false);
}
};
document.addEventListener('click', closeSearchList);
return () => document.removeEventListener('click', closeSearchList);
}, [listOpen]);
/** URL's query string 'search'๊ฐ์ผ๋ก text, keyword, selected ์ํ ์ค์ */
useEffect(() => {
const searchParam = searchParams.get('search');
if (searchParam) {
const selectedMonster =
monsters.find((monster) => monster.name === searchParam) || null;
setText(searchParam);
setKeyword(searchParam);
setSelected(selectedMonster);
}
}, [searchParams]);
return {
text,
searchRef,
listRef,
filterdMonsters,
selected,
cursor,
listOpen,
handleTextChange,
handleTextClear,
handleOpenList,
handleCloseList,
handleLinkClick,
};
}
๐โ๏ธ ๊ตฌํ 1: ์ฌ์ฉ์์ ๊ฒ์ ํค์๋์ ์ผ์นํ๋ ๊ฒ์ ๋ชฉ๋ก ๋ณด์ฌ์ฃผ๊ธฐ
const [text, setText] = useState(''); //์ฌ์ฉ์๊ฐ ์
๋ ฅํ search form text ์ํ
const [keyword, setKeyword] = useState(''); // search list ๋ฐฉํฅํค ์ด๋ฒคํธ ๋ฐ์ ์ , ์ฌ์ฉ์๊ฐ ์
๋ ฅํ keyword ์ํ
/** search list ๋ฐฉํฅํค ์ด๋ฒคํธ ๋ฐ์ ์ , ์ฌ์ฉ์๊ฐ ์
๋ ฅํ keyword์ ๋ฐ๋ผ ๋ชฉ๋ก ํํฐ */
const filterdMonsters = useMemo(
() =>
monsters.filter(({ name }) =>
name
.replace(/ /g, '')
.toUpperCase()
.includes(keyword.replace(/ /g, '').toUpperCase())
),
[keyword, monsters]
);
const handleTextChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setListOpen(true);
setText(e.target.value);
setKeyword(e.target.value);
setCursor(null);
setSelected(null);
},
[]
);
- filterdMonsters
- keyword ๊ฐ์ด ์ ์ฒด ๊ฒ์ ๋ชฉ๋ก(monsters)์ ํฌํจ๋์ด ์๋๊ฒ๋ง filter
- filter๋ ๋ฐฐ์ด์ <SearchList />์ ์ ๋ฌ๋์ด ๊ฒ์ ๋ชฉ๋ก์ ๊ตฌ์ฑ
- text์ keyword ๋ ๊ฐ์ ์ํ๋ก ๋๋ ์ด์
- ์, ์๋ ๋ฐฉํฅํค ์ด๋ฒคํธ ๋ฐ์ ์, ํด๋น ๋ชฌ์คํฐ ์ด๋ฆ์ผ๋ก input text๊ฐ ๋ณ๊ฒฝ๋ฉ๋๋ค. ์ด๋ text ์ํ ๊ฐ์ผ๋ก ๊ฒ์ ๋ชฉ๋ก์ ํํฐํ๋ค๋ฉด input text์ ์๋ ๋ชฌ์คํฐ๋ง ๋ชฉ๋ก์ ๋ํ๋๊ฒ ๋ฉ๋๋ค.
- ๊ทธ๋์, ์ฌ์ฉ์๊ฐ ์ ๋ ฅํ ๊ฐ์ ๊ธฐ์ตํ๊ณ ์๋ ์ํ(keyword)๋ก ๊ฒ์ ๋ชฉ๋ก์ filterํด์ผ, ๋ค๋ฅธ ๋ชฉ๋ก item์ ์ ํํ ์ ์์ต๋๋ค.
๐โ๏ธ ๊ตฌํ 2: ์, ์๋ ๋ฐฉํฅํค๋ก ๊ฒ์ ๋ชฉ๋ก ์ด๋ํ๊ธฐ
1. ์, ์๋ ๋ฐฉํฅํค keydown ์ด๋ฒคํธ ๋ฑ๋ก
cursor๋ ์, ์๋ํค ์ด๋ฒคํธ๋ก ์ ํ๋ ๋ชฉ๋ก์ index๋ฅผ ๊ฐ๋ฅดํค๊ณ ์์ต๋๋ค.
keydown ์ด๋ฒคํธ๊ฐ ๋ฐ์ํ๋ฉด, ๋ค์ ์ปค์์ ์์น(nextCursor)๋ก cursor, text, selected ์ํ๋ฅผ ์ ๋ฐ์ดํธํ๊ณ , scrollSearchList(๊ฒ์ ๋ชฉ๋ก ์คํฌ๋กค) ํจ์ ์คํ
/** ๊ฒ์ ๋ชฉ๋ก ์, ์๋ ๋ฐฉํฅํค ์ด๋ฒคํธ */
useEffect(() => {
if (!listOpen) return;
// ์ผ์นํ๋ ๋ชฌ์คํฐ๊ฐ ์์ผ๋ฉด ์ด๋ฒคํธ๊ฐ ๋ฐ์ํ์ง ์์
if (filterdMonsters.length === 0) {
setSelected(null);
return;
}
const searchElement = searchRef.current;
const KeyHandler = (e: KeyboardEvent) => {
// ์๋ ๋ฐฉํฅํค
if (e.key === ARROW_DOWN) {
let nextCursor; // ๋ค์ ์ปค์์ ์์น
if (cursor === null) nextCursor = 0;
else nextCursor = cursor < filterdMonsters.length - 1 ? cursor + 1 : 0;
setCursor(nextCursor);
setText(filterdMonsters[nextCursor].name);
setSelected({
name: filterdMonsters[nextCursor].name,
path: filterdMonsters[nextCursor].path,
});
scrollSearchList(nextCursor, ARROW_DOWN);
return;
}
// ์ ๋ฐฉํฅํค
if (e.key === ARROW_UP) {
let nextCursor; // ๋ค์ ์ปค์์ ์์น
if (cursor === null) nextCursor = filterdMonsters.length - 1;
else nextCursor = cursor > 0 ? cursor - 1 : filterdMonsters.length - 1;
setCursor(nextCursor);
setText(filterdMonsters[nextCursor].name);
setSelected({
name: filterdMonsters[nextCursor].name,
path: filterdMonsters[nextCursor].path,
});
scrollSearchList(nextCursor, ARROW_UP);
return;
}
};
searchElement?.addEventListener('keydown', KeyHandler);
return () => {
searchElement?.removeEventListener('keydown', KeyHandler);
};
}, [listOpen, cursor, filterdMonsters, scrollSearchList]);
2. ๊ฒ์ ๋ชฉ๋ก ์คํฌ๋กค(scrollSearchList)
const scrollSearchList = useCallback(
(cursor: number, direction: keyDirection) => {
const listElement = listRef.current; // <ol> ๊ฒ์ ๋ชฉ๋ก ํ๊ทธ
const itemHeight = listElement?.children[0].clientHeight || 32; // item์ ๋์ด
// ์๋ ๋ฐฉํฅํค
if (direction === ARROW_DOWN) {
cursor >= SEARCH_LIST_SHOW_MAX_ITEM_COUNT
? listElement?.scrollBy({ top: itemHeight }) // item์ ๋์ด ๋งํผ ์คํฌ๋กค์ด ๋ด๋ ค๊ฐ
: listElement?.scrollTo({ top: 0 }); // ์ ์ผ ์๋ก ์คํฌ๋กค
return;
}
// ์ ๋ฐฉํฅํค
if (direction === ARROW_UP) {
cursor <= filterdMonsters.length - 1 - SEARCH_LIST_SHOW_MAX_ITEM_COUNT
? listElement?.scrollBy({ top: -itemHeight }) // item์ ๋์ด ๋งํผ ์คํฌ๋กค์ด ์ฌ๋ผ๊ฐ
: listElement?.scrollTo({ // ์ ์ผ ์๋๋ก ์คํฌ๋กค
top: listElement.childElementCount * itemHeight,
});
return;
}
},
[filterdMonsters.length]
);
- ์๋ ๋ฐฉํฅํค
- cursor >= SEARCH_LIST_SHOW_MAX_ITEM_COUNT
- ๋ชฉ๋ก 1~7๋ฒ๊น์ง๋ ์คํฌ๋กค์ด ์ต์๋จ์ ์๊ณ , 8๋ฒ๋ถํฐ๋ item์ ๋์ด ๋งํผ ์คํฌ๋กค์ด ๋ด๋ ค๊ฐ๋๋ค.
- ์ ๋ฐฉํฅํค
- cursor <= filterdMonsters.length - 1 - SEARCH_LIST_SHOW_MAX_ITEM_COUNT
- ๋ชฉ๋ก ๋งจ ๋ง์ง๋ง์์ 7๋ฒ๊น์ง๋ ์คํฌ๋กค์ด ์ตํ๋จ์ ์๊ณ , ๋ง์ง๋ง์์ 8๋ฒ๋ถํฐ๋ item์ ๋์ด ๋งํผ ์คํฌ๋กค์ด ์ฌ๋ผ๊ฐ๋๋ค.
- SEARCH_LIST_SHOW_MAX_COUNT = 7
- ๋ชฉ๋ก item์ด ์ต๋ 7๊ฐ๊น์ง ํ๋ฉด์ ๋ณด์
- css-in-js, inline-style, listRef๋ก max-height style์ ์ง์ ํ๋ฉด cssํ์ผ์์ 7์ ์์ ํ์ง ์์๋ ๋ฉ๋๋ค.
.list {
max-height: calc(var(--item-height) * 7);
overflow-y: auto;
overscroll-behavior: contain;
}