Next.js

๊ฒ€์ƒ‰ ๋ชฉ๋ก ๊ตฌํ˜„ํ•˜๊ธฐ (with ์œ„, ์•„๋ž˜ ๋ฐฉํ–ฅํ‚ค ์„ ํƒ)

presentKey 2024. 3. 9. 03:48

๐ŸŽ‰ ๊ตฌํ˜„ ๊ฒฐ๊ณผ

 

๐Ÿšฉ ๊ตฌํ˜„ ๋ชฉํ‘œ

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์„ ์„ ํƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
text ํ•˜๋‚˜์˜ ์ƒํƒœ๋งŒ ๊ฐ€์ง€๊ณ  ์žˆ์„ ๋•Œ

๐Ÿƒ‍โ™‚๏ธ ๊ตฌํ˜„ 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;
}