๋ฐ˜์‘ํ˜•

ํ‚ค๋ณด๋“œ๋กœ ์กฐ์ž‘ ๊ฐ€๋Šฅํ•œ ์ž๋™์™„์„ฑ ๊ฒ€์ƒ‰์ฐฝ ๊ตฌํ˜„ ํ™”๋ฉด GIF (์˜ต์…˜ ๋ชฉ๋ก์€ ํ‚ค์›Œ๋“œ์— ๋”ฐ๋ผ API ํ˜ธ์ถœ)

๊ฒ€์ƒ‰์ฐฝ ์ž๋™ ์™„์„ฑ ๊ธฐ๋Šฅ์€ <input>, <datalist> ํƒœ๊ทธ๋ฅผ ์ด์šฉํ•ด ๋น ๋ฅด๊ฒŒ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ๋‹ค(์ฐธ๊ณ  ํฌ์ŠคํŒ…). ํ‚ค๋ณด๋“œ ๋ฐฉํ–ฅํ‚ค๋กœ ์˜ต์…˜์„ ์„ ํƒํ•  ์ˆ˜๋„ ์žˆ๋‹ค. ํ•˜์ง€๋งŒ ์ด ๋ฐฉ๋ฒ•์€ ๋ฆฌ์ŠคํŠธ ์š”์†Œ์— ํด๋ฆญ ์ด๋ฒคํŠธ๋ฅผ ํ• ๋‹นํ•  ์ˆ˜ ์—†๋Š” ๋‹จ์ ์ด ์žˆ๋‹ค(๊ฒ€์ƒ‰์„ ์ •๋ง ๋งŽ์ด ํ•ด๋ดค์ง€๋งŒ ๊ฒฐ๋ก ์€ ๋ถˆ๊ฐ€).

 

๊ฒ€์ƒ‰์ฐฝ์— ํ‚ค์›Œ๋“œ๋ฅผ ์ž…๋ ฅํ•œ ํ›„ ๋ชฉ๋ก์— ์žˆ๋Š” ๊ฐ ์˜ต์…˜(<li>)์„ ํด๋ฆญํ•  ๋•Œ๋งˆ๋‹ค ํŠน์ • ์•ก์…˜์„ ์ทจํ•ด์•ผ ํ•œ๋‹ค๋ฉด ์œ„ ๋ฐฉ๋ฒ•์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†๋‹ค. ๋Œ€์•ˆ์€ React Select ์ฒ˜๋Ÿผ ์ž˜ ๋งŒ๋“  ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์‚ฌ์šฉํ•˜๊ฑฐ๋‚˜, ๋ช‡ ๋…„๊ฐ„ ์œ ์ง€ ๋ณด์ˆ˜ํ•œ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ณด๋‹จ ์™„์„ฑ๋„๋Š” ์กฐ๊ธˆ ๋–จ์–ด์งˆ ์ˆ˜ ์žˆ์ง€๋งŒ ์ง์ ‘ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ๋‹ค. ์•„๋ž˜๋Š” ์ง์ ‘ ๊ตฌํ˜„ํ•œ ๋‚ด์šฉ์„ ๊ธฐ๋กํ•œ ๋‚ด์šฉ.

 

๊ตฌํ˜„ ๋ชฉํ‘œ


  • ๋งˆ์šฐ์Šค๋กœ ๋ฆฌ์ŠคํŠธ ์ด๋™ / ์„ ํƒ
  • Enter ํ‚ค๋กœ ์„ ํƒ
  • ๋ฐฉํ–ฅํ‚ค๋กœ ๋ฆฌ์ŠคํŠธ ์ด๋™ (Keyboard navigate)
  • ์Šคํฌ๋กค์ด ์ƒ๊ธด ๋ชฉ๋ก์—์„œ ๋ฐฉํ–ฅํ‚ค๋กœ ์ด๋™ํ–ˆ์„ ๋•Œ ํฌ์ปค์Šค ์ „ํ™˜/์Šคํฌ๋กค ์ด๋™ (Scrollable)
  • ๋ฐฉํ–ฅํ‚ค โ‡„ ๋งˆ์šฐ์Šค๋กœ ์ „ํ™˜ํ•ด๋„ ํ•ญ์ƒ ๋งˆ์ง€๋ง‰์— ์œ„์น˜ํ–ˆ๋˜ ์˜ต์…˜ ์ธ๋ฑ์Šค์—์„œ ์ด๋™ ์‹œ์ž‘

 

๊ธฐ๋ณธ ๊ตฌ์กฐ


๋”๋ณด๊ธฐ

๊ธฐ๋ณธ ๊ตฌ์กฐ๋งŒ ๊น”๋”ํ•˜๊ฒŒ ํ‘œํ˜„ํ•˜๊ธฐ ์œ„ํ•ด onFocus ๋“ฑ ์†์„ฑ ๊ฐ’ ๋‚ด์šฉ๊ณผ ์ธํ„ฐํŽ˜์ด์Šค ๋“ฑ์€ ์ƒ๋žตํ–ˆ๋‹ค. 

// AutoComplete.tsx
export default function AutoComplete({
  options, // ๊ฒ€์ƒ‰์ฐฝ์— ์ž…๋ ฅํ•œ ํ‚ค์›Œ๋“œ์— ๋Œ€ํ•œ ์˜ต์…˜ ๋ชฉ๋ก : Array<DefaultEntry>
  selected, // ์„ ํƒํ•œ ์˜ต์…˜ ๋ชฉ๋ก : Array<DefaultEntry>
  term, // ๊ฒ€์ƒ‰์ฐฝ ํ‚ค์›Œ๋“œ : string
  loading, // ์ž…๋ ฅํ•œ ํ‚ค์›Œ๋“œ์— ๋Œ€ํ•œ ์˜ต์…˜ ๋ฆฌ์ŠคํŠธ ์กฐํšŒ(API) ์ƒํƒœ : boolean
  setTerm, // ๊ฒ€์ƒ‰์ฐฝ ํ‚ค์›Œ๋“œ ์ƒํƒœ ๋ณ€๊ฒฝ ํ•จ์ˆ˜ : React.Dispatch<React.SetStateAction<string>>;
  selectHandler, // ๋งˆ์šฐ์Šค ํด๋ฆญ&์—”ํ„ฐ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ์˜ต์…˜์„ ์„ ํƒํ–ˆ์„ ๋•Œ ํ•ธ๋“ค๋Ÿฌ : VoidHandler<DefaultEntry>;
  maxSelect = 10, // ์„ ํƒํ•  ์ˆ˜ ์žˆ๋Š” ์ตœ๋Œ€ ์˜ต์…˜ ์ˆ˜ : number
  placeholder = KR_INPUT_KEYWORD, // Input Placeholder : string
}: AutoCompleteProps) {
  const [openList, setOpenList] = useState(false);
  const [cursor, setCursor] = useState(0); // options index
  const [hoveredEl, setHoveredEl] = useState<false | DefaultEntry>(false);
  const ulRef = useRef<HTMLUListElement>(null);

  // ์ธ์ž๋กœ ๋„˜๊ธด ํ‚ค ์ฝ”๋“œ์˜ ์ž…๋ ฅ์„ ๊ฐ์ง€ํ•˜๋Š” ์ปค์Šคํ…€ ํ›…
  const arrowUpPressed = useKeyPress("ArrowUp");
  const arrowDownPressed = useKeyPress("ArrowDown");
  const enterPressed = useKeyPress("Enter");
  const escPressed = useKeyPress("Escape");

  const preventFocusMove = (event: React.MouseEvent) => {
    /* focus๊ฐ€ ๊ฒ€์ƒ‰์ฐฝ์— ์œ ์ง€๋˜๋„๋ก ์ œ์–ด */
  };
  const focusHandler = (event: React.FocusEvent) => {
    /* ๊ฒ€์ƒ‰์ฐฝ onFocus&onBlur ํ•ธ๋“ค๋Ÿฌ */
  };
  const cursorHandler = useCallback(() => {
    /* ํ‚ค๋ณด๋“œ&๋งˆ์šฐ์Šค ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ */
  }, []);

  useEffect(() => {
    /* cursorHandler ํ˜ธ์ถœ */
  }, [cursorHandler, openList]);
  useEffect(() => {
    /* ์˜ต์…˜ ๋ฆฌ์ŠคํŠธ ํ‘œ์‹œ ์—ฌ๋ถ€ ์ œ์–ด */
  }, [term, options]);

  return (
    <div role="presentation" onMouseMove={() => {}} onKeyDown={() => {}}>
      {/* ์ƒ๋‹จ ๊ฒ€์ƒ‰์ฐฝ */}
      <div onFocus={() => {}} onBlur={() => {}} className="...">
        <input
          type="text"
          className="..."
          placeholder={() => {}}
          onChange={() => {}}
          autoComplete="off"
          autoCorrect="off"
          spellCheck={false}
        />
        {loading && <Spinner />}{" "}
        {/* ์ž…๋ ฅํ•œ ๊ฒ€์ƒ‰์ฐฝ์„ ์กฐํšŒํ•  ๋•Œ ๋กœ๋”ฉ ์Šคํ”ผ๋„ˆ ํ‘œ์‹œ */}
        <span className="sm:h-3/6 w-px bg-gray-200 sm:ml-2.5" />{" "}
        {/* ๋กœ๋”ฉ ์Šคํ”ผ๋„ˆ์™€ โ–ผ ๋ฒ„ํŠผ ์‚ฌ์ด์˜ ๊ตฌ๋ถ„์ž */}
        <button type="button" className="...">
          โ–ผ
        </button>{" "}
        {/* ๋ชฉ๋ก ํ‘œ์‹œ ๋ฒ„ํŠผ. ๊ฒ€์ƒ‰์ฐฝ ํฌ์ปค์Šค์‹œ ๋ชฉ๋ก์„ ํ‘œ์‹œํ•˜๋ฏ€๋กœ ํฐ ์˜๋ฏธ๋Š” ์—†์Œ */}
      </div>
      {/* ํ•˜๋‹จ ์˜ต์…˜ ๋ชฉ๋ก */}
      <ul role="tablist" ref={() => {}} onMouseDown={() => {}} className="...">
        {options.map((option, i) => (
          <li
            role="presentation"
            className="..."
            key="..."
            onMouseEnter={() => {}}
            onMouseLeave={() => {}}
            onClick={() => {}}
          >
            {option.name}
          </li>
        ))}
      </ul>
    </div>
  );
}

 

์—ฌ๋Ÿฌ ๊ณณ์—์„œ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก ์•„๋ž˜ ์ƒํƒœ๋Š” ๋ถ€๋ชจ ์ปดํฌ๋„ŒํŠธ์—์„œ ์ „๋‹ฌ๋ฐ›๋„๋ก ์ž‘์„ฑํ–ˆ๋‹ค. AutoComplete ์ปดํฌ๋„ŒํŠธ์—์„  ํ‚ค์›Œ๋“œ๊ฐ€ ๋ณ€๊ฒฝ๋  ๋•Œ๋งˆ๋‹ค setTerm ํ•จ์ˆ˜๋ฅผ ํ˜ธ์ถœํ•˜๊ณ , ํ•ด๋‹น ํ‚ค์›Œ๋“œ(term)์— ๋Œ€ํ•œ ์˜ต์…˜ ๋ชฉ๋ก(options)์„ ์ „๋‹ฌ๋ฐ›์•„(ํ‚ค์›Œ๋“œ์— ๋Œ€ํ•ด API ํ˜ธ์ถœ) ๋ฆฌ์ŠคํŠธ๋ฅผ ํ‘œ์‹œํ•œ๋‹ค. ๋ฆฌ์ŠคํŠธ์˜ ๊ฐ ์˜ต์…˜์„ ํด๋ฆญํ•˜๋ฉด selectHandler ํ•ธ๋“ค๋Ÿฌ๋ฅผ ํ˜ธ์ถœํ•ด ์„ ํƒํ•œ ์˜ต์…˜ ๋ชฉ๋ก์„ ์—…๋ฐ์ดํŠธํ•œ๋‹ค.

 

  1. ๊ฒ€์ƒ‰์–ด(ํ‚ค์›Œ๋“œ) ์ƒํƒœ : term
  2. ๊ฒ€์ƒ‰์–ด ์ƒํƒœ ๋ณ€๊ฒฝ ํ•จ์ˆ˜ : setTerm
  3. ์ž…๋ ฅํ•œ ๊ฒ€์ƒ‰์–ด์— ๋Œ€ํ•œ ์˜ต์…˜ ๋ชฉ๋ก(๋ฐฐ์—ด) : options ex) [{ id: number, name: string }, {...}]
  4. ์„ ํƒํ•œ ์˜ต์…˜ ๋ชฉ๋ก : selected
  5. ์ตœ๋Œ€ ์„ ํƒํ•  ์ˆ˜ ์žˆ๋Š” ์˜ต์…˜ ์ˆ˜ : maxSelect
  6. ์˜ต์…˜ ์„ ํƒ ํ•ธ๋“ค๋Ÿฌ(์˜ต์…˜์„ ๋งˆ์šฐ์Šค๋กœ Clickํ•˜๊ฑฐ๋‚˜ Enter ๋ฒ„ํŠผ์„ ๋ˆŒ๋ €์„ ๋•Œ์˜ ์•ก์…˜) : selectHandler
  7. ๊ฒ€์ƒ‰์–ด์— ๋Œ€ํ•œ ์˜ต์…˜ ๋ชฉ๋ก ์กฐํšŒ ์ƒํƒœ : loading

 

๋‚ด๋ถ€ ์ƒํƒœ


๐Ÿ’ก ํ‚ค๋ณด๋“œ / ๋งˆ์šฐ์Šค ์กฐ์ž‘์œผ๋กœ ์ธํ•ด ํ˜„์žฌ ํฌ์ปค์Šค๋œ ์š”์†Œ์˜ ์ธ๋ฑ์Šค๋ฅผ cursor ์ƒํƒœ๋กœ ๊ด€๋ฆฌํ•˜๋Š” ๊ฒƒ์ด ํ•ต์‹ฌ

 

const [openList, setOpenList] = useState(false);
const [cursor, setCursor] = useState(0); // index
const [hoveredEl, setHoveredEl] = useState<false | DefaultEntry>(false);
const [mouseMove, setMouseMove] = useState(true);

 

  1. openList : ์˜ต์…˜ ๋ชฉ๋ก ํ‘œ์‹œ ์—ฌ๋ถ€. ๊ฒ€์ƒ‰ ํ‚ค์›Œ๋“œ๊ฐ€ ๋ณ€๊ฒฝ๋  ๋•Œ๋งˆ๋‹ค openList ์ƒํƒœ๋ฅผ ์—…๋ฐ์ดํŠธํ•œ๋‹ค. โžŠ๊ฒ€์ƒ‰์–ด๊ฐ€ ๋นˆ ๋ฌธ์ž์—ด์ด๊ฑฐ๋‚˜ โž‹๊ฒ€์ƒ‰์–ด์— ๋Œ€ํ•œ ์˜ต์…˜ ๋ชฉ๋ก์ด ์—†๋‹ค๋ฉด false๋กœ ์„ค์ •.
  2. cursor : ํ˜„์žฌ ํฌ์ปค์Šค๊ฐ€ ์–ด๋Š ์˜ต์…˜ ์š”์†Œ(<li>)์— ์œ„์น˜ํ–ˆ๋Š”์ง€ ๋‚˜ํƒ€๋‚ด๋Š” ์ธ๋ฑ์Šค. โžŠํ‚ค๋ณด๋“œ ์œ„ / ์•„๋ž˜ ๋ฐฉํ–ฅํ‚ค๋กœ ์ด๋™ํ•˜๊ฑฐ๋‚˜ โž‹๋งˆ์šฐ์Šค๋กœ ํ˜ธ๋ฒ„ํ•  ๋•Œ๋งˆ๋‹ค ์ƒํƒœ ์—…๋ฐ์ดํŠธ
  3. hoveredEl : ๋งˆ์šฐ์Šค๋กœ ํ˜ธ๋ฒ„ํ•˜๊ณ  ์žˆ๋Š” ์š”์†Œ. ๋งˆ์šฐ์Šค๋กœ ์˜ต์…˜์„ Navigate ํ•  ๋•Œ๋งˆ๋‹ค ์ปค์„œ์— ์žˆ๋Š” ์š”์†Œ ์ •๋ณด๊ฐ€ hoveredEl ์ƒํƒœ์— ๋‹ด๊ธด๋‹ค. options ๋ฐฐ์—ด์˜ ๊ฐ ์š”์†Œ๊ฐ€ hoveredEl ์ƒํƒœ์— ๋‹ด๊ธฐ๋Š” ์…ˆ์ด๋ฏ€๋กœ indexOf ๋ฉ”์„œ๋“œ๋กœ ์ธ๋ฑ์Šค ๊ฒ€์ƒ‰ ๊ฐ€๋Šฅ. ex) options.indexOf(hoveredEl)
  4. mouseMove : ํ˜„์žฌ ๋งˆ์šฐ์Šค๋ฅผ ์›€์ง์ด๊ณ  ์žˆ๋Š”์ง€ ์—ฌ๋ถ€. ํ‚ค๋ณด๋“œ๋ฅผ ๋ˆŒ๋ €์„ ๋• false, ๋งˆ์šฐ์Šค๋ฅผ ์›€์ง์˜€์„ ๋• true๋กœ ๋ณ€๊ฒฝ. ์œ„ / ์•„๋ž˜ ๋ฐฉํ–ฅํ‚ค๋กœ ์˜ต์…˜ ๋ชฉ๋ก์„ ์ด๋™ํ•  ๋•Œ ๋งˆ์šฐ์Šค ํ˜ธ๋ฒ„์— ์˜ํ•ด cursor ์ƒํƒœ๊ฐ€ ๋ณ€๊ฒฝ๋˜๋Š” ๊ฒƒ์„ ๋ฐฉ์ง€ํ•˜๊ธฐ ์œ„ํ•ด, false ์ผ ๋•Œ๋งŒ hoveredEl(๋งˆ์šฐ์Šค๋กœ ํ˜ธ๋ฒ„ํ•˜๊ณ  ์žˆ๋Š” ์š”์†Œ) ์ƒํƒœ๋ฅผ ์„ค์ •ํ•œ๋‹ค.

 

ํ‚ค๋ณด๋“œ ์ด๋ฒคํŠธ ๊ฐ์ง€ / ์ปค์Šคํ…€ ํ›…


๋”๋ณด๊ธฐ
import { useEffect, useState } from "react";

// reference1: https://stackoverflow.com/a/57721430/3730665
// reference2: https://blog.whereisthemouse.com/create-a-list-component-with-keyboard-navigation-in-react
export default function useKeyPress(targetKey: KeyboardEvent["key"]) {
  const [keyPressed, setKeyPressed] = useState(false);

  useEffect(() => {
    const downHandler = (e: KeyboardEvent) => {
      // ํ•œ๊ธ€์€ ์กฐํ•ฉ๋ฌธ์ž๊ธฐ ๋•Œ๋ฌธ์— ๋ฌธ์ž ์ž…๋ ฅ ์‹œ ๊ธ€์ž๊ฐ€ ์กฐํ•ฉ์ค‘์ธ์ง€ ํŒ๋‹จํ•œ๋‹ค
      // ํ•œ๊ธ€ ์ž…๋ ฅ ์‹œ ์ž…๋ ฅ์ค‘์ธ ๋ฌธ์ž ํ•˜๋‹จ์— ๋ฐ‘์ค„์ด ๋‚˜์˜ค๋Š” ์ด์œ ๋„ ์กฐํ•ฉ๋ฌธ์ž์—ฌ์„œ ๊ทธ๋ ‡๋‹ค
      // ๋•Œ๋ฌธ์— ๊ธ€์ž ์ž…๋ ฅ์„ ๋งˆ์น˜๊ณ  ๋ฐฉํ–ฅํ‚ค๋‚˜ ์—”ํ„ฐํ‚ค ๋“ฑ์„ ๋ˆ„๋ฅด๋ฉด ์กฐํ•ฉ ์—ฌ๋ถ€๋ฅผ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•ด
      // ํ‚ค๋ณด๋“œ ์ด๋ฒคํŠธ๊ฐ€ 1๋ฒˆ ๋” ๋ฐœ์ƒํ•œ๋‹ค. ์กฐํ•ฉ ์—ฌ๋ถ€๋Š” e.isComposing ์—์„œ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค
      // ์˜์–ด ์ž…๋ ฅ ์‹œ e.isComposing ์€ ํ•ญ์ƒ false, ํ•œ๊ธ€์€ ์กฐํ•ฉ์ค‘์ผ ๋•Œ true
      // ํ‚ค๋ณด๋“œ ์ด๋ฒคํŠธ ์ค‘๋ณต์„ ๋ง‰๊ธฐ ์œ„ํ•ด e.isComposing === false ์กฐ๊ฑด์„ ์ถ”๊ฐ€ํ•œ๋‹ค
      if (e.key === targetKey && !e.isComposing) {
        // e.keyCode๋Š” deprecated ๋์œผ๋ฏ€๋กœ e.key๋กœ ์ ‘๊ทผ
        setKeyPressed(true);
      }
    };

    const upHandler = (e: KeyboardEvent) => {
      if (e.key === targetKey && !e.isComposing) {
        setKeyPressed(false);
      }
    };

    window.addEventListener("keydown", downHandler); // ํ‚ค๋ฅผ ๋ˆŒ๋ €์„ ๋•Œ
    window.addEventListener("keyup", upHandler); // ํ‚ค๋ฅผ ๋•” ๋•Œ

    return () => {
      window.removeEventListener("keydown", downHandler);
      window.removeEventListener("keyup", upHandler);
    };
  }, [targetKey]);

  return keyPressed;
}

 

๐Ÿ’ก ํ•œ๊ธ€์„ ํฌํ•จํ•œ CJK๋Š” ์กฐํ•ฉ๋ฌธ์ž๊ธฐ ๋•Œ๋ฌธ์— ๋ฌธ์ž ์ž…๋ ฅ์‹œ ๊ธ€์ž๊ฐ€ ์กฐํ•ฉ์ค‘์ธ์ง€ ํŒ๋‹จํ•œ๋‹ค. ์ž…๋ ฅ ์‹œ CJK ๋ฌธ์ž ํ•˜๋‹จ์— ๋ฐ‘์ค„์ด ๋‚˜์˜ค๋Š” ์ด์œ ๋„ ์กฐํ•ฉ๋ฌธ์ž์—ฌ์„œ ๊ทธ๋ ‡๋‹ค. ๋‘๋ฒˆ์งธ ๋ฌธ์ž ์ž…๋ ฅ(์•„, ใ…‡ใ…‡ ๋“ฑ) ์‹œ์ ๋ถ€ํ„ฐ ๊ธ€์ž๊ฐ€ ์กฐํ•ฉ์ค‘์ธ๊ฑธ๋กœ ํŒ๋‹จํ•˜๋ฉฐ ์ด๋•Œ event.isComposing ๊ฐ’์€ true๋กœ ๋ณ€ํ•œ๋‹ค.

 

์ŠคํŽ˜์ด์Šค๋‚˜ ๋ฐฉํ–ฅํ‚ค๋ฅผ ๋ˆŒ๋Ÿฌ ๋ฌธ์ž ์กฐํ•ฉ์„ ์™„๋ฃŒํ•˜๋ฉด ๋ฐ‘์ค„์ด ์‚ฌ๋ผ์ง€๊ณ  isComposing ๊ฐ’์€ ๋‹ค์‹œ false๊ฐ€ ๋˜๋Š”๋ฐ, ์ด๋•Œ ํ‚ค๋ณด๋“œ ์ด๋ฒคํŠธ๊ฐ€ ํ•œ ๋ฒˆ ๋” ๋ฐœ์ƒํ•œ๋‹ค. ์ด๋กœ์ธํ•œ ํ‚ค๋ณด๋“œ ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ ์ค‘๋ณต ์‹คํ–‰์„ ๋ง‰๊ณ  ์‹ถ์œผ๋ฉด event.isComposing === false ์กฐ๊ฑด์„ ์ถ”๊ฐ€ํ•˜๋ฉด ๋œ๋‹ค.

// ์ธ์ž๋กœ ๋„˜๊ธด ํ‚ค ์ฝ”๋“œ์˜ ์ž…๋ ฅ์„ ๊ฐ์ง€ํ•˜๋Š” ์ปค์Šคํ…€ ํ›…
const arrowUpPressed = useKeyPress('ArrowUp'); 
const arrowDownPressed = useKeyPress('ArrowDown');
const enterPressed = useKeyPress('Enter');
const escPressed = useKeyPress('Escape');

 

๋ฐฉํ–ฅํ‚ค(์˜ต์…˜ ์ด๋™), ์—”ํ„ฐ(์˜ต์…˜ ์„ ํƒ), ESC(์˜ต์…˜ ๋ชฉ๋ก ๋‹ซํž˜)์— ๋Œ€ํ•œ ํ‚ค ์ž…๋ ฅ์„ ๊ฐ์ง€ํ•ด์•ผ ๋˜๋ฏ€๋กœ useKeyPress ์ปค์Šคํ…€ ํ›…์„ ๋งŒ๋“ค์–ด์„œ ์žฌ์‚ฌ์šฉํ•˜๋ฉด ์ข‹๋‹ค.

 

ArrowUp, ArrowDown ๊ฐ™์€ ํ‚ค ์ฝ”๋“œ(์ฐธ๊ณ ) ๋ฌธ์ž์—ด์„ ์ปค์Šคํ…€ ํ›…์˜ ์ธ์ž๋กœ ๋„˜๊ฒจ์„œ ์‚ฌ์šฉํ•œ๋‹ค. ํ‚ค๋ณด๋“œ๋ฅผ ๋ˆŒ๋Ÿฌ ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด event.key ๊ฐ’๊ณผ ์ธ์ž๋กœ ๋ฐ›์€ ํ‚ค ์ฝ”๋“œ์™€ ๋น„๊ตํ•œ ํ›„ ๊ฐ™๋‹ค๋ฉด ์ž…๋ ฅ ์ƒํƒœ๋ฅผ true๋กœ ๋ณ€๊ฒฝํ•œ๋‹ค.

 

๊ฒ€์ƒ‰์ฐฝ ํฌ์ปค์Šค ์‹œ ๋ถ€๋ชจ ์š”์†Œ ์ƒ‰์ƒ ๋ณ€๊ฒฝ


<input> ์š”์†Œ๊ฐ€ ํฌ์ปค์Šค๋˜๋ฉด ๋ถ€๋ชจ ์š”์†Œ์ธ <div>์˜ ํ…Œ๋‘๋ฆฌ ์ƒ‰์ƒ์ด ๋ณ€ํ•˜๊ณ  ์žˆ๋‹ค

:focus-within ์†์„ฑ์€ ํฌ์ปค์Šค๋ฅผ ๋ฐ›์€ ์š”์†Œ๋ฅผ ํฌํ•จํ•˜๋Š” ๋ถ€๋ชจ ์š”์†Œ๋ฅผ ๊ฐ€๋ฆฌํ‚จ๋‹ค(๋” ์ž์„ธํ•œ ๋‚ด์šฉ์€ ๋งํฌ ์ฐธ๊ณ ). ์ด ์†์„ฑ์„ ์ด์šฉํ•ด ์ž์‹ ์š”์†Œ์ธ <input>์— ํฌ์ปค์Šค๋์„ ๋•Œ ๋ถ€๋ชจ ์š”์†Œ์ธ <div>์˜ ํ…Œ๋‘๋ฆฌ ์ƒ‰์ƒ์„ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ๋‹ค.

 

๐Ÿ’ก border ์†์„ฑ์€ ํ…Œ๋‘๋ฆฌ ๋„ˆ๋น„ ๋งŒํผ ๋ฐ•์Šค ํฌ๊ธฐ๋„ ๋Š˜์–ด๋‚˜์ง€๋งŒ, outline์€ ๋ฐ•์Šค ํฌ๊ธฐ์— ์˜ํ–ฅ์„ ์ฃผ์ง€ ์•Š๊ณ  ํ…Œ๋‘๋ฆฌ๋งŒ ์ƒ๊ธด๋‹ค. ์ „์ฒด ๋ ˆ์ด์•„์›ƒ์— ์˜ํ–ฅ์„ ์ฃผ๊ณ  ์‹ถ์ง€ ์•Š์„ ๋•Œ outline์„ ์‚ฌ์šฉํ•œ๋‹ค.

<div
  className="border-2 w-full flex items-center focus-within:border-orange-400 transition ease-in-out sm:w-9/12 h-10 mb-2"
  // ...
>
  <input
    // input ํƒœ๊ทธ๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ํฌ์ปค์Šค ๋์„ ๋•Œ outline์ด ์ƒ๊ธฐ๋ฏ€๋กœ ์ด๋ฅผ ๊ฐ์ถ˜๋‹ค(outline-none)
    className="grow h-full w-10/12 sm:w-full sm:text-sm outline-none p-2"
    onChange={({ target }) => setTerm(target.value)}
    // ...
  />
</div>;

 

๊ฒ€์ƒ‰์ฐฝ์— ํฌ์ปค์Šคํ–ˆ์„ ๋•Œ ๋ชฉ๋ก ํ‘œ์‹œ


๊ฒ€์ƒ‰์ฐฝ์„ ํด๋ฆญํ•ด์„œ ํฌ์ปค์Šค ๋์„ ๋•Œ(onFocus ์ด๋ฒคํŠธ ๋ฐœ์ƒ) โžŠํ˜„์žฌ ๊ฒ€์ƒ‰ ํ‚ค์›Œ๋“œ(term)๊ฐ€ ๋นˆ ๋ฌธ์ž์—ด์ด ์•„๋‹ˆ๊ณ  โž‹ํ‘œ์‹œํ•  ์˜ต์…˜(options)์ด 1๊ฐœ ์ด์ƒ์ด๋ผ๋ฉด ๊ฒ€์ƒ‰์ฐฝ ํ•˜๋‹จ์— ์˜ต์…˜ ๋ชฉ๋ก์„ ํ‘œ์‹œํ•˜๋„๋ก ์ž‘์„ฑํ•œ๋‹ค.

const focusHandler = (event: React.FocusEvent) => {
  const isFocus = event.type === "focus";
  setOpenList(isFocus && !!term.trim() && !!options.length); // openList ์ƒํƒœ ๋ณ€๊ฒฝ
};

return (
  <div role="presentation" onMouseMove={/*...*/} onKeyDown={/*...*/}>
    <div onFocus={focusHandler} onBlur={focusHandler} className="...">
      <input
        onChange={({ target }) => setTerm(target.value)}
        // ...
      />
      {/* ... */}
    </div>
    <ul
      role="tablist"
      className={classnames({ hidden: !openList }, "border-2 w-full ...")}
      // ...
    >
      {/* ... */}
    </ul>
  </div>
);

 

๊ฒ€์ƒ‰ ํ‚ค์›Œ๋“œ(term)์™€ ํ‘œ์‹œํ•  ์˜ต์…˜์ด(options) ๋ณ€๊ฒฝ๋์„ ๋•Œ๋„ ์˜ต์…˜ ๋ชฉ๋ก์„ ๋ณด์ด๊ฑฐ๋‚˜ ๊ฐ์ถ˜๋‹ค. ๊ฒ€์ƒ‰์–ด๊ฐ€ ๋นˆ ๋ฌธ์ž์—ด์ด๋ผ๋ฉด ์˜ต์…˜ ๋ชฉ๋ก ์–ด๋””์—๋„ ํฌ์ปค์Šค ๋˜์ง€ ์•Š์€ ์ƒํƒœ์ด๋ฏ€๋กœ cursor ์ธ๋ฑ์Šค ์ƒํƒœ๋ฅผ 0์œผ๋กœ ์ดˆ๊ธฐํ™”ํ•œ๋‹ค.

useEffect(() => {
  setOpenList(!!term.trim() && !!options.length);
  if (term.trim() === "") setCursor(0);
}, [term, options]);

 

ํ‚ค๋ณด๋“œ / ๋งˆ์šฐ์Šค ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ โญ๏ธ


โžŠ๊ฐ์ง€ํ•˜๊ณ  ์žˆ๋Š” ํ‚ค ์ฝ”๋“œ์˜ ์ž…๋ ฅ ์ƒํƒœ๊ฐ€ ๋ณ€๊ฒฝ๋˜๊ฑฐ๋‚˜, โž‹๊ฐ ์˜ต์…˜ ์š”์†Œ์˜ onMouseEnter, onMouseLeave ์ด๋ฒคํŠธ์— ์˜ํ•ด hoveredEl ์ƒํƒœ๊ฐ€ ๋ณ€๊ฒฝ๋  ๋•Œ ์ด๋ฅผ ์ฒ˜๋ฆฌํ•  ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์ •์˜ํ•œ๋‹ค.

const cursorHandler = useCallback(() => {
  if (arrowDownPressed && cursor < options.length - 1) {
    setCursor((prev) => prev + 1); // cursor ์ƒํƒœ๊ฐ€ options ๊ธธ์ด๋ณด๋‹ค ์ž‘์„ ๋•Œ๋งŒ cursor + 1
  } else if (arrowUpPressed && cursor > 0) {
    setCursor((prev) => prev - 1); // cursor ์ƒํƒœ๊ฐ€ 0๋ณด๋‹ค ํด ๋•Œ๋งŒ cursor - 1
  } else if (enterPressed && selected.length < maxSelect) {
    selectHandler(options[cursor]);
  } else if (escPressed) {
    setOpenList(false); // ESC ํ‚ค๋ฅผ ๋ˆ„๋ฅด๋ฉด ์˜ต์…˜ ๋ชฉ๋ก ๊ฐ์ถ”๊ธฐ
  } else if (hoveredEl) {
    setCursor(options.indexOf(hoveredEl)); // ๋งˆ์šฐ์Šค๊ฐ€ ์œ„์น˜ํ•œ ์˜ต์…˜ ์š”์†Œ์˜ ์ธ๋ฑ์Šค๋กœ cursor ์ƒํƒœ ์„ค์ •
    setHoveredEl(false); // ๐Ÿ”๏ธ ์œ„&์•„๋ž˜ ๋ฐฉํ–ฅํ‚ค๋ฅผ ๋ˆŒ๋Ÿฌ๋„ ํ•ญ์ƒ ๋งˆ์šฐ์Šค๊ฐ€ ์œ„์น˜ํ•œ ์š”์†Œ๋กœ ์ด๋™ํ•˜๋Š” ๋ฌธ์ œ ๋ฐฉ์ง€
  }
  // ...
}, [arrowUpPressed, arrowDownPressed, enterPressed, escPressed, hoveredEl]);

useEffect(() => {
  if (openList) cursorHandler(); // ์˜ต์…˜ ๋ชฉ๋ก์ด ํ‘œ์‹œ๋œ ์ƒํƒœ์—์„œ๋งŒ cursorHandler ํ˜ธ์ถœ
}, [cursorHandler, openList]);

 

์œ„ ์กฐ๊ฑด์˜ ๊ฐ€์žฅ ๋งˆ์ง€๋ง‰ ๋ถ€๋ถ„ setHoveredEl(false)์„ ๋ช…์‹œํ•˜์ง€ ์•Š์œผ๋ฉด ์œ„ / ์•„๋ž˜ ๋ฐฉํ–ฅํ‚ค๋ฅผ ์•„๋ฌด๋ฆฌ ๋ˆŒ๋Ÿฌ๋„ ํ•ญ์ƒ ๋งˆ์šฐ์Šค๊ฐ€ ์œ„์น˜ํ•œ ์š”์†Œ๋กœ ํฌ์ปค์Šค๊ฐ€ ์ด๋™ํ•˜๋Š” ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. ๊ณผ์ •์„ ์š”์•ฝํ•ด๋ณด๋ฉด...

 

ํ‚ค๋ณด๋“œ ๋ฐฉํ–ฅํ‚ค๋กœ ์ด๋™ํ•ด๋„ ํ•ญ์ƒ ์ปค์„œ๊ฐ€ ์œ„์น˜ํ•œ ์š”์†Œ ์œ„๋กœ ์ด๋™ํ•˜๋Š” ํ™”๋ฉด GIF

  • ๋งˆ์šฐ์Šค๊ฐ€ ์˜ต์…˜(<li>) ๋ชฉ๋ก ์œ„์— ์žˆ๋Š” ์ƒํƒœ์—์„œ, ๋ฐฉํ–ฅํ‚ค๋ฅผ ๋ˆŒ๋Ÿฌ ๋‹ค๋ฅธ ์š”์†Œ๋กœ ์ด๋™ํ•  ๋•Œ๋งˆ๋‹ค
  • ์ด๋™ํ•œ ์š”์†Œ ์œ„์—์„œ onMouseEnter ์ด๋ฒคํŠธ๊ฐ€ ๋‹ค์‹œ ํ˜ธ์ถœ๋˜๊ณ , hoveredEl ์ƒํƒœ์— ํ•ด๋‹น ์š”์†Œ ์ •๋ณด๊ฐ€ ๋‹ด๊น€
  • ์ฆ‰, ์œ„ / ์•„๋ž˜ ๋ฐฉํ–ฅํ‚ค๋ฅผ ์•„๋ฌด๋ฆฌ ๋ˆŒ๋Ÿฌ๋„ ํ•ญ์ƒ ๋งˆ์šฐ์Šค๊ฐ€ ์œ„์น˜ํ•œ ์š”์†Œ๋กœ ํฌ์ปค์Šค๊ฐ€ ์ด๋™ํ•˜๋Š” ๋ฌธ์ œ ๋ฐœ์ƒ
  • ์ด๋ฅผ ๋ฐฉ์ง€ํ•˜๊ธฐ ์œ„ํ•ด onMouseEnter ์ด๋ฒคํŠธ๋กœ cursor๊ฐ€ ๋ณ€๊ฒฝ๋  ๋•Œ๋งˆ๋‹ค hoveredEl ์ƒํƒœ๋ฅผ false๋กœ ๋ณ€๊ฒฝ

 

๊ฐ ์˜ต์…˜ ์š”์†Œ์— ํด๋ฆญ ํ•ธ๋“ค๋Ÿฌ ํ• ๋‹น / ํ˜ธ๋ฒ„ ํšจ๊ณผ ์ถ”๊ฐ€


๋งˆ์šฐ์Šค๋กœ ์š”์†Œ๋ฅผ ์ด๋™ํ•  ๋•Œ๋งˆ๋‹ค cursor ์ƒํƒœ๊ฐ€ ํ•ด๋‹น ์š”์†Œ์˜ ์ธ๋ฑ์Šค๋กœ ๋ฐ”๋€ ํ›„ ์ฃผํ™ฉ ๋ฐฐ๊ฒฝ์œผ๋กœ ๋ณ€๊ฒฝํ•œ๋‹ค

๐Ÿ’ก onMouseOver(๋ฐ˜๋Œ€๋Š” onMouseOut)๋Š” ์ด๋ฒคํŠธ๋ฅผ ๊ฑธ์ง€ ์•Š์€ ์ž์‹ ์š”์†Œ์— ๋งˆ์šฐ์Šค๊ฐ€ ์ง„์ž…ํ•ด๋„ ์ด๋ฒคํŠธ ์ „ํŒŒ๋กœ ์ธํ•ด ํŠธ๋ฆฌ๊ฑฐ๋œ๋‹ค. ์ž์‹ ์š”์†Œ โ‡„ ๋ถ€๋ชจ ์š”์†Œ ์‚ฌ์ด๋ฅผ ์˜ค๊ฐˆ ๋•Œ๋„ ํŠธ๋ฆฌ๊ฑฐ ๋œ๋‹ค. onMouseEnter(๋ฐ˜๋Œ€๋Š” onMouseLeave)๋Š” ์ด๋ฒคํŠธ๊ฐ€ ๊ฑธ๋ฆฐ ์š”์†Œ ์œ„์— ๋งˆ์šฐ์Šค๊ฐ€ ์ง„์ž…ํ•ด์•ผ๋งŒ ํŠธ๋ฆฌ๊ฑฐ ๋œ๋‹ค. — ์ฐธ๊ณ 

 

๋งˆ์šฐ์Šค๊ฐ€ ์˜ต์…˜ ์š”์†Œ ์•ˆ์ชฝ์— ๋“ค์–ด์™”์„ ๋•Œ(onMouseEnter) hoveredEl ์ƒํƒœ์— ํ•ด๋‹น ์š”์†Œ๋ฅผ ์ €์žฅํ•˜๊ณ , ๋งˆ์šฐ์Šค๊ฐ€ ํ•ด๋‹น ์š”์†Œ๋ฅผ ๋ฒ—์–ด๋‚ฌ์„ ๋•Œ(onMouseLeave) hoveredEl ์ƒํƒœ๋ฅผ false๋กœ ์„ค์ •ํ•œ๋‹ค.

 

์˜ต์…˜ ์š”์†Œ๋ฅผ ํด๋ฆญํ–ˆ์„ ๋• ์ด๋ฏธ ์„ ํƒํ•œ ์˜ต์…˜ (selected.length) ๊ฐœ์ˆ˜๊ฐ€ ์ตœ๋Œ€ ์„ ํƒ ๊ฐœ์ˆ˜(maxSelect) ๋ณด๋‹ค ์ž‘์„ ๋•Œ๋งŒ selectHandler๋ฅผ ์‹คํ–‰ํ•ด์„œ ์„ ํƒํ•œ ์˜ต์…˜ ๋ชฉ๋ก์— ์ถ”๊ฐ€ํ•œ๋‹ค.

<div role="presentation" onMouseMove={/*...*/} onKeyDown={/*...*/}>
  {/* ๊ฒ€์ƒ‰์ฐฝ ์˜์—ญ ์ƒ๋žต */}
  <ul
    role="tablist"
    className={classnames({ hidden: !openList }, "border-2 w-full ...")}
    // ...
  >
    {options.map((option, i) => (
      <li
        role="presentation"
        className={classnames("z-10 w-full p-2", {
          "text-gray-300": !!selected.find((s) => s.id === option.id), // ์ด๋ฏธ ์„ ํƒํ•œ ์˜ต์…˜์€ ํšŒ์ƒ‰์œผ๋กœ ํ‘œ์‹œ
          "bg-orange-400 text-white": cursor === i, // ๋ฐฐ๊ฒฝ์ƒ‰ ๋ณ€๊ฒฝ
        })}
        key={option.name}
        onMouseEnter={() => setHoveredEl(option)} // ๋งˆ์šฐ์Šค๊ฐ€ ์š”์†Œ ์•ˆ์— ๋“ค์–ด์™”์„ ๋•Œ
        onMouseLeave={() => setHoveredEl(false)} // ๋งˆ์šฐ์Šค๊ฐ€ ์š”์†Œ๋ฅผ ๋ฒ—์–ด๋‚ฌ์„ ๋•Œ
        onClick={() => selected.length < maxSelect && selectHandler(option)} // ์„ ํƒ ๋ชฉ๋ก์— ์ถ”๊ฐ€
      >
        {option.name}
      </li>
    ))}
  </ul>
</div>;

 

  1. ๋งˆ์šฐ์Šค ์ปค์„œ๊ฐ€ ์š”์†Œ ์•ˆ์ชฝ์œผ๋กœ ์ง„์ž…
    • onMouseEnter ์ด๋ฒคํŠธ ํŠธ๋ฆฌ๊ฑฐ
    • hoveredEl ์ƒํƒœ์— ํ•ด๋‹น ์š”์†Œ ์ €์žฅ
  2. hoveredEl ์ƒํƒœ๊ฐ€ ๋ณ€๊ฒฝ๋์œผ๋ฏ€๋กœ cursorHandler ํ•ธ๋“ค๋Ÿฌ ์‹คํ–‰
    • cursor ์ƒํƒœ๋ฅผ ํ•ด๋‹น ์š”์†Œ ์ธ๋ฑ์Šค๋กœ ๋ณ€๊ฒฝ setCursor(options.indexOf(hoveredEl));
    • hoveredEl ์ƒํƒœ๋ฅผ ๋‹ค์‹œ false๋กœ ๋ณ€๊ฒฝ
  3. options ๋ Œ๋” ๋ฆฌ์ŠคํŠธ์—์„œ cursor === i ์กฐ๊ฑด์ด ์ผ์น˜ํ•˜๋ฏ€๋กœ ๋ฐฐ๊ฒฝ์ƒ‰ ๋ณ€๊ฒฝ

 

Input ์ฐฝ์˜ Blur ์ด๋ฒคํŠธ ๋ฐฉ์ง€ โญ๏ธ


๊ฒ€์ƒ‰์ฐฝ์„ ํด๋ฆญํ•œ ์ˆœ๊ฐ„ focus๋Š” <input> ์š”์†Œ๋กœ ์ด๋™ํ•œ๋‹ค. ์ž…๋ ฅํ•œ ํ‚ค์›Œ๋“œ์— ๋Œ€ํ•œ ์˜ต์…˜์ด 1๊ฐœ ์ด์ƒ์ด๋ผ๋ฉด ํ•˜๋‹จ์— ์˜ต์…˜ ๋ชฉ๋ก์ด ๋œฌ๋‹ค. ๋ชฉ๋ก์— ์žˆ๋Š” ์˜ต์…˜(<li>)์„ ํด๋ฆญํ•˜๋ฉด ๊ฒ€์ƒ‰์ฐฝ ๋ถ€๋ชจ ์š”์†Œ์˜ onBlur ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.

 

“์–‘ํŒŒ”๋ฅผ ํด๋ฆญํ–ˆ์ง€๋งŒ ํด๋ฆญ ์‹œ์ ์— ๊ฒ€์ƒ‰์ฐฝ์ด ํฌ์ปค์Šค๋ฅผ ์žƒ๊ณ  ์˜ต์…˜ ๋ชฉ๋ก์ด ์‚ฌ๋ผ์ ธ์„œ ํด๋ฆญ ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ•˜์ง€ ์•Š๊ณ  ์žˆ๋‹ค

๐Ÿ’ก ํ˜„์žฌ ํฌ์ปค์Šค๋œ ์š”์†Œ๋Š” document.activeElement ๋ฉ”์„œ๋“œ๋กœ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค.

 

onBlur ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด focusHandler์— ์˜ํ•ด openList ์ƒํƒœ๊ฐ€ false๋กœ ๋ณ€๊ฒฝ๋˜๊ณ  ์˜ต์…˜ ๋ชฉ๋ก์ด ๋‹ซํžˆ๋Š” ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. ์˜ต์…˜ ์š”์†Œ(<li>)์˜ onClick ์ด๋ฒคํŠธ๋ณด๋‹ค onBlur ์ด๋ฒคํŠธ๊ฐ€ ๋จผ์ € ๋ฐœ์ƒํ•ด์„œ ์˜ต์…˜ ๋ชฉ๋ก์„ ๋‹ซ๊ธฐ ๋•Œ๋ฌธ์— ํด๋ฆญ ์ด๋ฒคํŠธ๋Š” ๋ฐœ์ƒํ•˜์ง€ ์•Š๋Š”๋‹ค(์˜ต์…˜ ๋ชฉ๋ก์ด ๋‹ซํžŒ ํ›„ focus๋Š” ๊ธฐ๋ณธ๊ฐ’์ธ <body> ์š”์†Œ๋กœ ์ด๋™ํ•œ๋‹ค).

 

์ด ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด์„  ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ์ˆœ์„œ๋ถ€ํ„ฐ ์•Œ๊ณ  ์žˆ์–ด์•ผ ๋œ๋‹ค. ์ฐธ๊ณ ๋กœ mousedown ์ด๋ฒคํŠธ๋Š” ๋งˆ์šฐ์Šค๋ฅผ ๋ˆ„๋ฅด๊ณ  ์žˆ์„ ๋•Œ ๋ฐœ์ƒํ•˜๋ฉฐ, click ์ด๋ฒคํŠธ๋Š” ๋งˆ์šฐ์Šค๋ฅผ ๋ˆ„๋ฅธ ํ›„ ๋• ์„ ๋•Œ ๋ฐœ์ƒํ•œ๋‹ค.

๋งˆ์šฐ์Šค ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ์ˆœ์„œ : mousedown → blur → mouseup → click

 

onBlur ์ด๋ฒคํŠธ๋ณด๋‹ค onMouseDown ์ด๋ฒคํŠธ๋ฅผ ๋จผ์ € ์ฒ˜๋ฆฌํ•˜๋ฏ€๋กœ onMouseDown ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ–ˆ์„ ๋•Œ ํ•ธ๋“ค๋Ÿฌ ์•ˆ์—์„œ onBlur ์ด๋ฒคํŠธ๋ฅผ ๋ฌดํšจํ™”ํ•˜๋Š” event.preventDefault() ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜๋ฉด ๋œ๋‹ค. ์ด๋ฒคํŠธ ์œ„์ž„ ํŒจํ„ด์„ ํ™œ์šฉํ•ด onMouseDown ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ๋Š” ๊ฐ ์˜ต์…˜ ์š”์†Œ(<li>)์˜ ๋ถ€๋ชจ์ธ <ul>์— ํ• ๋‹นํ•œ๋‹ค.

const focusHandler = (event: React.FocusEvent) => {
  const isFocus = event.type === "focus";
  setOpenList(isFocus && !!term.trim() && !!options.length); // openList ์ƒํƒœ ๋ณ€๊ฒฝ
};

const preventFocusMove = (event: React.MouseEvent) => {
  event.preventDefault(); // blur ์ด๋ฒคํŠธ๋ฅผ ๋ฌดํšจํ™”ํ•ด์„œ <input>์— focus ์œ ์ง€
};

return (
  <div role="presentation" onMouseMove={/*...*/} onKeyDown={/*...*/}>
    <div onFocus={focusHandler} onBlur={focusHandler} className="...">
      {/* <input> ๋“ฑ ์ƒ๋žต */}
    </div>
    <ul
      role="tablist"
      onMouseDown={preventFocusMove}
      ref={ulRef}
      className="..."
    >
      {/* <li> ๋ฆฌ์ŠคํŠธ ์ƒ๋žต */}
    </ul>
  </div>
);

 

ํ‚ค๋ณด๋“œ ์ด๋™์‹œ ์Šคํฌ๋กค๋„ ๊ฐ™์ด ์›€์ง์ด๊ธฐ โญ๏ธ


ํ‚ค๋ณด๋“œ ๋ฐฉํ–ฅํ‚ค๋ฅผ ์ด์šฉํ•ด “ํŒŒ์Šฌ๋ฆฌ” ์•„๋ž˜๋กœ ์ด๋™ํ•ด๋„ ์Šคํฌ๋กค๋˜์ง€ ์•Š๊ณ  ์žˆ๋‹ค

์˜ต์…˜ ๊ฐœ์ˆ˜๊ฐ€ ๋งŽ์•„ ๋ฆฌ์ŠคํŠธ(<ul>) ์ปจํ…Œ์ด๋„ˆ์˜ ์ตœ๋Œ€ ๋†’์ด๋ฅผ ์ดˆ๊ณผํ•˜๋ฉด ์Šคํฌ๋กค์ด ์ƒ๊ธด๋‹ค. ํ•˜์ง€๋งŒ ํ‚ค๋ณด๋“œ ๋ฐฉํ–ฅํ‚ค๋ฅผ ๋ˆŒ๋Ÿฌ ๊ฐ€๋ ค์ง„ ์˜ต์…˜ ์š”์†Œ๋กœ ์ด๋™ํ•ด๋„ ์Šคํฌ๋กค๋˜์ง€ ์•Š๋Š”๋‹ค. ์ด๋ฅผ ํ•ด๊ฒฐํ•˜๋ ค๋ฉด ์ปจํ…Œ์ด๋„ˆ์˜ scrollTop(์Šคํฌ๋กคํ•ด์„œ ๊ฐ€๋ ค์ง„ ์ฝ˜ํ…์ธ  ์˜์—ญ์˜ ๋†’์ด)์†์„ฑ์„ ์กฐ์ •ํ•˜๋Š” ์ž‘์—…์ด ํ•„์š”ํ•˜๋‹ค. — ๊ธฐํ•˜ ํ”„๋กœํผํ‹ฐ ์ฐธ๊ณ  ๋งํฌ

 

์•„๋ž˜๋กœ ์Šคํฌ๋กค

๐Ÿ’ก elem.clientHeight๋Š” ํ•ด๋‹น ์š”์†Œ์˜ ๋†’์ด๋ฅผ ๋‚˜ํƒ€๋‚ด๋ฉฐ ์œ„/์•„๋ž˜ padding๊ฐ’์„ ํฌํ•จํ•œ๋‹ค.

 

<ul> ๋†’์ด๋Š” 252px, 1๊ฐœ ์˜ต์…˜(<li>) ์š”์†Œ ๋†’์ด๋Š” 36px๋ผ๊ณ  ๊ฐ€์ •ํ•˜๊ณ , ํ‚ค๋ณด๋“œ ์•„๋ž˜(down) ๋ฐฉํ–ฅํ‚ค๋ฅผ 7๋ฒˆ ๋ˆ„๋ฅด๋ฉด 8๋ฒˆ์งธ ์š”์†Œ์— ์œ„์น˜ํ•œ๋‹ค(์ด๋•Œ cursor ์ธ๋ฑ์Šค๋Š” 7). <ul>์—์„œ ํ‘œ์‹œํ•  ์ˆ˜ ์žˆ๋Š” ์š”์†Œ๋Š” 7๋ฒˆ์งธ ์š”์†Œ ๊นŒ์ง€๋ฏ€๋กœ(36px * 7 = 252px) 8๋ฒˆ์งธ ์š”์†Œ๋Š” ๋ณด์ด์ง€ ์•Š๋Š”๋‹ค. ๋”ฐ๋ผ์„œ <ul> ์š”์†Œ์˜ ์Šคํฌ๋กค์„ 36px ๋งŒํผ ์ด๋™ํ•ด์„œ(scrollTop += 36px) 1๋ฒˆ์งธ ์š”์†Œ๋Š” ๊ฐ€๋ฆฌ๊ณ , 8๋ฒˆ์งธ ์š”์†Œ๋ฅผ ๋ณด์ด๋„๋ก ํ•˜๋ฉด ๋œ๋‹ค. ์ฝ”๋“œ๋กœ ํ‘œํ˜„ํ•ด๋ณด๋ฉด...

const childrenHeight = $parentEl.children[0]?.clientHeight; // 1๊ฐœ ์ž์‹(li) ์š”์†Œ ๋†’์ด(padding ํฌํ•จ)
const parentOffset = childrenHeight * cursorIdx; // ํ˜„์žฌ ํฌ์ปค์Šค๋œ ์š”์†Œ(li) "์ด์ „"๊นŒ์ง€์˜ ์ด ๋†’์ด
const viewport = scrollTop + $parentEl.clientHeight; // ul ๋†’์ด(padding ํฌํ•จ) + ์Šคํฌ๋กค ๋†’์ด

// cursor๋Š” 0๋ถ€ํ„ฐ ์‹œ์ž‘ํ•˜๋Š” index๋ฏ€๋กœ li ๋†’์ด๋ฅผ ํ•œ๋ฒˆ ๋” ๋”ํ•œ๋‹ค
if (viewport < parentOffset + childrenHeight) {
  $parentEl.scrollTop += childrenHeight; // ์•„๋ž˜๋กœ 1์นธ ์Šคํฌ๋กค
}

 

์œ„๋กœ ์Šคํฌ๋กค

๋ฐ˜๋Œ€๋กœ ์œ„(up) ๋ฐฉํ–ฅํ‚ค๋ฅผ ๋ˆ„๋ฅธ ํ›„ ํฌ์ปค์Šค๋œ ์š”์†Œ(<li>) ์ด์ „๊นŒ์ง€์˜ ์ด ๋†’์ด๊ฐ€ scrollTop ๋ณด๋‹ค ์ž‘๋‹ค๋ฉด ์Šคํฌ๋กค์— ๊ฐ€๋ ค์ ธ ๋ณด์ด์ง€ ์•Š๋Š”๋‹ค. ์ฆ‰, ํฌ์ปค์Šค๋œ ์ž์‹ ์š”์†Œ ์ด์ „๊นŒ์ง€์˜ ์ด ๋†’์ด๋Š” ํ•ญ์ƒ scrollTop๊ณผ ๊ฐ™๊ฑฐ๋‚˜ ์ปค์•ผ๋งŒ ์ปจํ…Œ์ด๋„ˆ viewport์•ˆ์— ๋“ค์–ด์˜จ๋‹ค. ์œ„(up) ๋ฐฉํ–ฅํ‚ค๋ฅผ ๋ˆ„๋ฅธ ์ƒํ™ฉ์„ ์žฌํ˜„ํ•ด๋ณด๋ฉด...

 

ํฌ์ปค์Šค๋Š” 3๋ฒˆ์งธ ์š”์†Œ์— ์œ„์น˜ํ•˜๊ณ , 2๊ฐœ ์š”์†Œ๊ฐ€ ์Šคํฌ๋กค์— ์˜ํ•ด ๊ฐ€๋ ค์ง„ ์ƒํƒœ

  • ์œ„ ์ด๋ฏธ์ง€๋ฅผ ๊ธฐ์ค€์œผ๋กœ 2๊ฐœ ์š”์†Œ(์˜ต์…˜)๊ฐ€ ์Šคํฌ๋กค์— ์˜ํ•ด ๊ฐ€๋ ค์ ธ์„œ scrollTop์€ 72px๋‹ค. 3๋ฒˆ์งธ ์š”์†Œ์— ํฌ์ปค์Šค๋œ ์ƒํƒœ์ด๋ฏ€๋กœ, 3๋ฒˆ์งธ ์š”์†Œ ์ด์ „๊นŒ์ง€์˜ ์ด ๋†’์ด๋Š” 36px * 2 = 72px ๊ฐ€ ๋œ๋‹ค.
  • ์œ„(up) ๋ฐฉํ–ฅํ‚ค๋ฅผ ๋ˆŒ๋Ÿฌ 2๋ฒˆ์งธ ์š”์†Œ์— ํฌ์ปค์Šคํ•˜๋ฉด 2๋ฒˆ์งธ ์š”์†Œ ์ด์ „๊นŒ์ง€์˜ ์ด ๋†’์ด๋Š” 36px * 1 = 36px๊ฐ€ ๋œ๋‹ค. ์Šคํฌ๋กค์— ์˜ํ•ด 2๋ฒˆ์งธ ์š”์†Œ๊ฐ€ ๊ฐ€๋ ค์ง„ ์ƒํƒœ๋ฏ€๋กœ scrollTop์„ ํฌ์ปค์Šค ์ด์ „๊นŒ์ง€์˜ ์ด ๋†’์ด์ธ 36px ๋กœ ๋ณ€๊ฒฝํ•ด์„œ ๊ฐ€๋ ค์ง„ ์š”์†Œ๋ฅผ ํ‘œ์‹œํ•œ๋‹ค.
if (parentOffset < scrollTop) {
  $parentEl.scrollTop = parentOffset; // ์œ„๋กœ 1์นธ ์Šคํฌ๋กค
}

 

์œ ํ‹ธ ํ•จ์ˆ˜๋กœ ๋ถ„๋ฆฌ

๋ฐฉํ–ฅํ‚ค ์œ„ / ์•„๋ž˜ ํ‚ค๋ฅผ ๋ˆ„๋ฅผ ๋•Œ๋งˆ๋‹ค ์ปจํ…Œ์ด๋„ˆ(<ul>)์˜ scrollTop ์†์„ฑ์„ ์กฐ์ ˆํ•ด์•ผ ํ•˜๋ฏ€๋กœ cursorHandler ํ•จ์ˆ˜ ๋‚ด์—์„œ ์ฒ˜๋ฆฌํ•œ๋‹ค. ์—ฌ๋Ÿฌ ๊ณณ์—์„œ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก ์ปจํ…Œ์ด๋„ˆ ์š”์†Œ์˜ ref ๊ฐ์ฒด์™€ cursor ์ธ๋ฑ์Šค๋ฅผ ์ธ์ž๋กœ ๋ฐ›๋Š” ์œ ํ‹ธ ํ•จ์ˆ˜๋กœ ๋ถ„๋ฆฌํ•œ๋‹ค.

 

๐Ÿ’ก DOM ์กฐ์ž‘์„ ์œ„ํ•œ ์šฉ๋„๋Š” RefObject, ๋ณ€์ˆ˜๋ฅผ ๊ด€๋ฆฌํ•  ๋• MutableRefObject ๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค(์ฐธ๊ณ )

 

import { RefObject } from "react";

// lib/utils.ts
export const scroller = <T extends HTMLElement>(
  ref: RefObject<T>,
  cursorIdx: number,
) => {
  if (ref.current?.children) {
    const { current: $parentEl } = ref;
    const { scrollTop } = $parentEl; // ์Šคํฌ๋กคํ•œ ๋†’์ด

    const childrenHeight = $parentEl.children[0].clientHeight; // 1๊ฐœ ์ž์‹(li) ์š”์†Œ ๋†’์ด(padding ํฌํ•จ)
    const parentOffset = childrenHeight * cursorIdx; // ํ˜„์žฌ ํฌ์ปค์Šค๋œ ์š”์†Œ(li) "์ด์ „" ๊นŒ์ง€์˜ ์ด ๋†’์ด
    const viewport = scrollTop + $parentEl.clientHeight; // ์ปจํ…Œ์ด๋„ˆ ๋†’์ด(padding ํฌํ•จ) + ์Šคํฌ๋กค ๋†’์ด

    // cursor ๋Š” 0๋ถ€ํ„ฐ ์‹œ์ž‘ํ•˜๋Š” index ์ด๋ฏ€๋กœ childrenHeight ๋ฅผ ํ•œ๋ฒˆ ๋” ๋”ํ•œ๋‹ค
    if (viewport < parentOffset + childrenHeight) {
      $parentEl.scrollTop += childrenHeight; // ์•„๋ž˜๋กœ 1์นธ ์Šคํฌ๋กค
    }

    if (parentOffset < scrollTop) {
      $parentEl.scrollTop = parentOffset; // ์œ„๋กœ 1์นธ ์Šคํฌ๋กค
    }
  }
};
// AutoComplete.tsx
const ulRef = useRef<HTMLUListElement>(null);

const cursorHandler = useCallback(() => {
  // ...์ƒ๋žต
  if (!hoveredEl) scroller<HTMLUListElement>(ulRef, cursor); // ํ‚ค๋ณด๋“œ ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ–ˆ์„ ๋•Œ๋งŒ ์Šคํฌ๋กค ์กฐ์ •
}, [arrowUpPressed, arrowDownPressed, enterPressed, escPressed, hoveredEl]);

return (
  <div role="presentation" onMouseMove={/*...*/} onKeyDown={/*...*/}>
    {/* ๊ฒ€์ƒ‰์ฐฝ ์˜์—ญ ์ƒ๋žต */}
    <ul role="tablist" ref={ulRef} onMouseDown={/*...*/} className="...">
      {/* <li> ๋ฆฌ์ŠคํŠธ ์ƒ๋žต */}
    </ul>
  </div>
);

 

์Šคํฌ๋กค์‹œ ์ปค์„œ ์•„๋ž˜ ์š”์†Œ๋กœ ํฌ์ปค์Šค๋˜๋Š” ๋ฌธ์ œ ํ•ด๊ฒฐ


ํ‚ค๋ณด๋“œ ์•„๋ž˜ ๋ฐฉํ–ฅํ‚ค๋กœ ์Šคํฌ๋กคํ•  ๋•Œ๋งˆ๋‹ค ๋งˆ์šฐ์Šค ์ปค์„œ ์•„๋ž˜ ์žˆ๋Š” ์š”์†Œ๋กœ ํฌ์ปค์Šค ๋˜๊ณ  ์žˆ๋‹ค.

๋งˆ์šฐ์Šค ์ปค์„œ๊ฐ€ ์˜ต์…˜ ์˜์—ญ์— ์œ„์น˜ํ•œ ์ƒํƒœ์—์„œ ํ‚ค๋ณด๋“œ ์•„๋ž˜(down) ๋ฐฉํ–ฅํ‚ค๋กœ ์Šคํฌ๋กคํ•  ๋•Œ๋งˆ๋‹ค ์ปค์„œ ์•„๋ž˜์— ์š”์†Œ๊ฐ€ ํฌ์ปค์Šค ๋˜๋Š” ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. ์œ„ GIF ์ด๋ฏธ์ง€๋ฅผ ๊ธฐ์ค€์œผ๋กœ ํŒŒ์Šฌ๋ฆฌ์—์„œ ์•„๋ž˜๋กœ ์Šคํฌ๋กคํ•˜๋ฉด ๋กœ์ฆˆ๋งˆ๋ฆฌ๊ฐ€ ํฌ์ปค์Šค ๋ผ์•ผ ์ •์ƒ์ด์ง€๋งŒ, ๋งˆ์šฐ์Šค ์ปค์„œ ์•„๋ž˜์— ์œ„์น˜ํ•œ ๋Šํƒ€๋ฆฌ๋ฒ„์„ฏ์— ํฌ์ปค์Šค ๋˜๊ณ  ์žˆ๋‹ค.

 

โžŠ๋ฐฉํ–ฅํ‚ค๋กœ ์Šคํฌ๋กคํ•  ๋•Œ๋งˆ๋‹ค โž‹์ปค์„œ ์•„๋ž˜ <li> ์š”์†Œ์˜ onMouseEnter ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ•ด์„œ โžŒhoveredEl ์ƒํƒœ๋ฅผ ๋ณ€๊ฒฝํ•˜๊ณ , โžhoveredEl ์ƒํƒœ ๋ณ€๊ฒฝ์„ ๊ฐ์ง€ํ•œ cursorHandler ํ•ธ๋“ค๋Ÿฌ์—์„œ cursor ์ƒํƒœ(์ธ๋ฑ์Šค)๋ฅผ ๋ณ€๊ฒฝํ•ด์„œ ๋ฐœ์ƒํ•˜๋Š” ๋ฌธ์ œ๋‹ค.

<li
  onMouseEnter={() => setHoveredEl(option)} // ๋งˆ์šฐ์Šค๊ฐ€ ์š”์†Œ ์•ˆ์— ๋“ค์–ด์™”์„ ๋•Œ
  onMouseLeave={() => setHoveredEl(false)} // ๋งˆ์šฐ์Šค๊ฐ€ ์š”์†Œ๋ฅผ ๋ฒ—์–ด๋‚ฌ์„ ๋•Œ
  onClick={() => selected.length < maxSelect && selectHandler(option)} // ์„ ํƒ ๋ชฉ๋ก์— ์ถ”๊ฐ€
  // ...
>
  {option.name}
</li>;

 

์œ„ ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด์„  ํ‚ค๋ณด๋“œ ์œ„ / ์•„๋ž˜ ๋ฐฉํ–ฅํ‚ค๋กœ ์กฐ์ž‘์ค‘์ผ ๋• <li> ์š”์†Œ์˜ onMouseEnter ํ•ธ๋“ค๋Ÿฌ๊ฐ€ ์‹คํ–‰๋˜์ง€ ์•Š๋„๋ก ํ•ด์•ผ ๋œ๋‹ค. ๋งˆ์šฐ์Šค๊ฐ€ ์›€์ง์ด๊ณ  ์žˆ๋Š”์ง€ ์—ฌ๋ถ€๋ฅผ ๋‚˜ํƒ€๋‚ด๋Š” mouseMove ์ƒํƒœ๋ฅผ ์ถ”๊ฐ€ํ•˜๊ณ , ํ‚ค๋ณด๋“œ ์ž…๋ ฅ ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด false๋กœ, ๋งˆ์šฐ์Šค๋ฅผ ์›€์ง์ด๋ฉด true๋กœ ๋ฐ”๊พธ๋„๋ก ์ž‘์„ฑํ•œ๋‹ค.

const [mouseMove, setMouseMove] = useState(true);
// ...

return (
  <div
    role="presentation"
    onMouseMove={() => !mouseMove && setMouseMove(true)}
    onKeyDown={() => mouseMove && setMouseMove(false)}
  >
    {/* ๊ฒ€์ƒ‰์ฐฝ ์˜์—ญ ์ƒ๋žต */}
    <ul
      role="tablist"
      className={classnames({ hidden: !openList }, "border-2 w-full ...")}
      // ...
    >
      {options.map((option, i) => (
        <li
          onMouseEnter={() => mouseMove && setHoveredEl(option)}
          onMouseLeave={() => setHoveredEl(false)}
          onClick={() => selected.length < maxSelect && selectHandler(option)}
          // ...
        >
          {option.name}
        </li>
      ))}
    </ul>
  </div>
);

 

์ „์ฒด ์ฝ”๋“œ


๋”๋ณด๊ธฐ

Allergy.tsx - ๊ฒ€์ƒ‰์–ด์— ๋Œ€ํ•œ ์˜ต์…˜ ๋ฆฌ์ŠคํŠธ API ํ˜ธ์ถœ(๋ถ€๋ชจ ์ปดํฌ๋„ŒํŠธ) โ–ผ

import React, { useEffect, useState } from "react";
import {
  KR_NO_ALLERGY_SELECTED,
  KR_SELECT_ALLERGY,
  KR_SELECT_ALLERGY_GUIDE,
} from "lib/constants";
import useDebounce from "hooks/useDebounce";
import { useDispatch, useSelector } from "react-redux";
import {
  addAllergy,
  getAllergyList,
  removeAllergy,
} from "modules/order.allergySlice";
import Tag from "components/Tag";
import { AppDispatch, RootState } from "modules";
import AutoComplete from "components/AutoComplete";
import Vegetable from "./Vegetable";

interface AllergyProps {
  selectable?: boolean;
}

export default function Allergy({ selectable = true }: AllergyProps) {
  const [term, setTerm] = useState("");
  const [debouncedTerm] = useDebounce({ term, delay: 300 }); // ์ปค์Šคํ…€ ํ›…

  const dispatch = useDispatch<AppDispatch>();

  const { queryData, allergies, loading } = useSelector(
    (state: RootState) => state.allergy,
  );

  useEffect(() => {
    if (debouncedTerm.trim() === "") return undefined;
    // ์š”์ฒญ ์ง„ํ–‰์ค‘์ผ ๋•Œ ์ƒˆ๋กœ์šด ์š”์ฒญ ๋ฐ›์œผ๋ฉด ์ด์ „ ์š”์ฒญ ์ทจ์†Œ
    // 1) Abort ์„ค์ •: https://redux-toolkit.js.org/api/createAsyncThunk#canceling-while-running
    // 2) Cancel Token ์„ค์ • : https://redux-toolkit.js.org/api/createAsyncThunk#listening-for-abort-events
    const promise = dispatch(getAllergyList(debouncedTerm));
    return () => promise.abort();
  }, [debouncedTerm, dispatch]);

  return (
    <div>
      <Vegetable />
      <h1 className="text-3xl font-bold mb-4 mt-8">{KR_SELECT_ALLERGY}</h1>

      <div className="flex mb-3 gap-2">
        {allergies.map((allergy) => (
          <Tag
            key={allergy.name}
            tag={allergy}
            selectable={selectable}
            handler={(option) => dispatch(removeAllergy(option))}
          />
        ))}

        {allergies.length === 0 && (
          <span className="text-gray-400">{KR_NO_ALLERGY_SELECTED}</span>
        )}
      </div>

      {selectable && (
        <AutoComplete
          options={queryData}
          selected={allergies}
          term={term}
          loading={loading}
          setTerm={setTerm}
          selectHandler={(option) => dispatch(addAllergy(option))}
          maxSelect={3}
          placeholder={KR_SELECT_ALLERGY_GUIDE}
        />
      )}
    </div>
  );
}
๋”๋ณด๊ธฐ

AutoComplete.tsx - ์ž๋™์™„์„ฑ ๊ฒ€์ƒ‰์ฐฝ (์ž์‹ ์ปดํฌ๋„ŒํŠธ) โ–ผ

import classnames from "classnames";
import React, { useCallback, useEffect, useRef, useState } from "react";
import useKeyPress from "hooks/useKeyPress";
import { DROPDOWN_SIGN, KR_INPUT_KEYWORD } from "lib/constants";
import { scroller } from "lib/utils";
import Spinner from "./Spinner";

interface AutoCompleteProps {
  options: Array<DefaultEntry>;
  selected: Array<DefaultEntry>;
  term: string;
  loading: boolean;
  setTerm: React.Dispatch<React.SetStateAction<string>>;
  selectHandler: VoidHandler<DefaultEntry>;
  maxSelect?: number;
  placeholder?: string;
}

export default function AutoComplete({
  options,
  selected,
  term,
  loading,
  setTerm,
  selectHandler,
  maxSelect = 10,
  placeholder = KR_INPUT_KEYWORD,
}: AutoCompleteProps) {
  const [openList, setOpenList] = useState(false);
  const [cursor, setCursor] = useState(0); // ํ˜„์žฌ ํฌ์ปค์Šค๊ฐ€ ์–ด๋Š ์˜ต์…˜ ์š”์†Œ์— ์œ„์น˜ํ–ˆ๋Š”์ง€๋ฅผ ๋‚˜ํƒ€๋‚ด๋Š” ์ธ๋ฑ์Šค
  const [hoveredEl, setHoveredEl] = useState<false | DefaultEntry>(false);
  const [mouseMove, setMouseMove] = useState(true);
  const ulRef = useRef<HTMLUListElement>(null);

  const arrowUpPressed = useKeyPress("ArrowUp");
  const arrowDownPressed = useKeyPress("ArrowDown");
  const enterPressed = useKeyPress("Enter");
  const escPressed = useKeyPress("Escape");

  const preventFocusMove = (event: React.MouseEvent) => {
    // input ์š”์†Œํด ํด๋ฆญํ•˜๋ฉด focus ๋Š” input ์š”์†Œ๋กœ ์ด๋™ํ•œ๋‹ค -> setOpenList(true)
    // openList ์ƒํƒœ๊ฐ€ true ์ด๋ฏ€๋กœ input ์š”์†Œ ํ•˜๋‹จ์— ์˜ต์…˜ ๋ชฉ๋ก(<ul>...)์ด ๋œฌ๋‹ค
    // ์—ฌ๊ธฐ์„œ ์˜ต์…˜ ๋ชฉ๋ก์„ ํด๋ฆญํ•˜๋ฉด (input)blur ์ด๋ฒคํŠธ ๋ฐœ์ƒ ํ›„ focus ๊ฐ€ ๋ฆฌ์ŠคํŠธ ๋ชฉ๋ก์œผ๋กœ ์ด๋™ํ•œ๋‹ค
    // blur ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ–ˆ์œผ๋ฏ€๋กœ setOpenList(false) ์ƒํƒœ๋กœ ๋ณ€๊ฒฝ๋˜๊ณ  ๋ฆฌ์ŠคํŠธ๊ฐ€ ๋‹ซํžŒ๋‹ค
    // input ์š”์†Œ๊ฐ€ ํฌ์ปค์Šค๋œ ์ƒํƒœ์—์„œ ์˜ต์…˜ ๋ชฉ๋ก์„ ํด๋ฆญํ•ด๋„ ๋‹ซํžˆ์ง€ ์•Š๊ฒŒ ํ•˜๋ ค๋ฉด
    // input ์š”์†Œ์˜ blur ์ด๋ฒคํŠธ๋ฅผ ๋ฌดํšจํ™”(preventDefault)ํ•˜๋ฉด ๋œ๋‹ค.
    // ๊ทธ๋Ÿผ focus ๊ฐ€ ๋ฆฌ์ŠคํŠธ ๋ชฉ๋ก์œผ๋กœ ์ด๋™ํ•˜์ง€ ์•Š๋Š”๋‹ค
    // ๋งˆ์šฐ์Šค ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ์ˆœ์„œ : mousedown -> blur -> mouseup -> click
    // blur ์ด๋ฒคํŠธ๋ณด๋‹ค mousedown ์ฒ˜๋ฆฌ์ˆœ์„œ๊ฐ€ ๋” ๋†’๊ธฐ ๋•Œ๋ฌธ์— mousedown ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ•œ ์ˆœ๊ฐ„
    // ํ•ธ๋“ค๋Ÿฌ์—์„œ event.preventDefault() ๋ฉ”์„œ๋“œ๋ฅผ ์‹คํ–‰ํ•˜๋ฉด blur ์ด๋ฒคํŠธ ์‹คํ–‰์„ ๋ฐฉ์ง€ํ•  ์ˆ˜ ์žˆ๋‹ค
    // ์ฐธ๊ณ ๊ธ€: https://bit.ly/3xwJqiG
    event.preventDefault();
  };

  const focusHandler = (event: React.FocusEvent) => {
    const isFocus = event.type === "focus";
    setOpenList(isFocus && !!term.trim() && !!options.length);
  };

  // reference : https://stackoverflow.com/questions/42036865/react-how-to-navigate-through-list-by-arrow-keys
  const cursorHandler = useCallback(() => {
    if (arrowDownPressed && cursor < options.length - 1) {
      setCursor((prev) => prev + 1);
    } else if (arrowUpPressed && cursor > 0) {
      setCursor((prev) => prev - 1);
    } else if (enterPressed && selected.length < maxSelect) {
      selectHandler(options[cursor]);
    } else if (escPressed) {
      setOpenList(false);
    } else if (hoveredEl) {
      setCursor(options.indexOf(hoveredEl));
      setHoveredEl(false);
      // ๋งˆ์šฐ์Šค๊ฐ€ ์˜ต์…˜(li) ๋ฆฌ์ŠคํŠธ ์œ„์— ์žˆ๋Š” ์ƒํƒœ์—์„œ ๋ฐฉํ–ฅํ‚ค๋ฅผ ๋ˆŒ๋Ÿฌ ๋‹ค๋ฅธ ์š”์†Œ๋กœ ์ด๋™ํ•  ๋•Œ๋งˆ๋‹ค
      // ์ด๋™ํ•œ ์š”์†Œ ์œ„์—์„œ mouseEnter ์ด๋ฒคํŠธ๊ฐ€ ๋‹ค์‹œ ํ˜ธ์ถœ๋˜๊ณ  hoveredEl ์ƒํƒœ๋ฅผ true ๋กœ ๋ณ€๊ฒฝํ•จ.
      // ๋•Œ๋ฌธ์— ์œ„์•„๋ž˜ ๋ฐฉํ–ฅํ‚ค๋ฅผ ์•„๋ฌด๋ฆฌ ๋ˆŒ๋Ÿฌ๋„ ํ•ญ์ƒ ๋งˆ์šฐ์Šค๊ฐ€ ์œ„์น˜ํ•œ ์š”์†Œ๋กœ ์ปค์„œ๊ฐ€ ๋Œ์•„๊ฐ€๋Š” ๋ฌธ์ œ ๋ฐœ์ƒํ•จ
      // ์ด๋ฅผ ๋ฐฉ์ง€ํ•˜๊ธฐ ์œ„ํ•ด mouseEnter ์ด๋ฒคํŠธ๋ฅผ ํ†ตํ•ด ์ปค์„œ๋ฅผ ๋ณ€๊ฒฝํ•  ๋•Œ๋งˆ๋‹ค hoveredEl ์ƒํƒœ false ๋กœ ๋ณ€๊ฒฝ
    }

    if (!hoveredEl) scroller<HTMLUListElement>(ulRef, cursor); // ํ‚ค๋ณด๋“œ ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ–ˆ์„ ๋•Œ๋งŒ ์Šคํฌ๋กค ์กฐ์ •
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [arrowUpPressed, arrowDownPressed, enterPressed, escPressed, hoveredEl]);

  useEffect(() => {
    if (openList) cursorHandler();
  }, [cursorHandler, openList]);

  useEffect(() => {
    setOpenList(!!term.trim() && !!options.length);
    if (term.trim() === "") setCursor(0);
  }, [term, options]);

  return (
    <div
      role="presentation"
      onMouseMove={() => setMouseMove(true)}
      onKeyDown={() => mouseMove && setMouseMove(false)}
    >
      <div
        onFocus={focusHandler} // focus ๋ฅผ ๋ฐ›์„ ๋•Œ
        onBlur={focusHandler} // focus ๋ฅผ ์žƒ์„ ๋•Œ
        className="border-2 w-full sm:w-9/12 h-10 mb-2 flex items-center focus-within:border-orange-400 transition ease-in-out"
      >
        <input
          className="grow h-full w-10/12 sm:w-full sm:text-sm outline-none p-2"
          placeholder={placeholder}
          type="text"
          autoComplete="off"
          autoCorrect="off"
          spellCheck={false}
          onChange={({ target }) => setTerm(target.value)}
        />
        {loading && <Spinner />}
        <span className="sm:h-3/6 w-px bg-gray-200 sm:ml-2.5" />
        <button
          type="button"
          className={classnames("w-8 grid place-content-center text-gray-300", {
            "text-orange-400": openList,
          })}
        >
          {DROPDOWN_SIGN}
        </button>
      </div>
      <ul
        role="tablist"
        ref={ulRef}
        onMouseDown={preventFocusMove} // blur ์ด๋ฒคํŠธ๋ฅผ ๋ฌดํšจํ™”ํ•ด์„œ <input>์— focus ์œ ์ง€
        className={classnames(
          "border-2 w-full sm:w-9/12 h-fit max-h-64 overflow-y-auto text-sm cursor-pointer",
          { hidden: !openList },
        )}
      >
        {options.map((option, i) => (
          <li
            role="presentation"
            className={classnames("z-10 w-full p-2", {
              "text-gray-300": !!selected.find((s) => s.id === option.id),
              "bg-orange-400 text-white": cursor === i,
            })}
            key={option.name}
            onMouseEnter={() => mouseMove && setHoveredEl(option)}
            onMouseLeave={() => setHoveredEl(false)}
            onClick={() => selected.length < maxSelect && selectHandler(option)}
          >
            {option.name}
          </li>
        ))}
      </ul>
    </div>
  );
}
๋”๋ณด๊ธฐ

Spinner.tsx - ๋กœ๋”ฉ ์Šคํ”ผ๋„ˆ โ–ผ

import React from "react";

interface SpinnerProps {
  classNameProps?: string;
  color?: string;
}

export default function Spinner({
  classNameProps,
  color = "#FB923C",
}: SpinnerProps) {
  return (
    <div className="grid place-content-center">
      <svg
        role="status"
        className={`w-5 h-5 text-gray-200 animate-spin ${classNameProps}`}
        viewBox="0 0 100 101"
        fill={color}
        xmlns="http://www.w3.org/2000/svg"
      >
        <path
          d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
          fill="currentColor"
        />
        <path
          d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
          fill="currentFill"
        />
      </svg>
    </div>
  );
}
๋”๋ณด๊ธฐ

useDebounce.tsx - ๊ฒ€์ƒ‰์–ด ๋””๋ฐ”์šด์Šค โ–ผ

import { useEffect, useState } from "react";
import { validateKoreanChar } from "lib/utils";

interface UseDebounceProps {
  term: string;
  delay: number;
}

// reference: https://usehooks.com/useDebounce/
export default function useDebounce({ term, delay }: UseDebounceProps) {
  const [debouncedTerm, setDebouncedTerm] = useState(term);

  useEffect(() => {
    let timer: NodeJS.Timer;
    if (validateKoreanChar(term, 1)) {
      // ์ž์Œ ํ˜น์€ ๋ชจ์Œ๋งŒ ์ž…๋ ฅํ–ˆ๋Š”์ง€ ๊ฒ€์‚ฌ
      timer = setTimeout(() => {
        setDebouncedTerm(term);
      }, delay);
    }
    return () => clearTimeout(timer);
  }, [term, delay]);

  return [debouncedTerm] as const;
}
๋”๋ณด๊ธฐ

utils.tsx - ํ•œ๊ธ€ ์ž…๋ ฅ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ๋ฐ Scroller โ–ผ

import { RefObject } from "react";
// ...

// ์ž์Œ ํ˜น์€ ๋ชจ์Œ๋งŒ ์ž…๋ ฅํ–ˆ๋Š”์ง€ ๊ฒ€์‚ฌ
export const validateKoreanChar = (str: string, length: number) => {
  const re = /([^๊ฐ€-ํžฃ\x20])/i;
  return !re.test(str) && str.length >= length;
};

export const scroller = <T extends HTMLElement>(
  ref: RefObject<T>,
  cursorIdx: number,
) => {
  if (ref.current?.children) {
    // reference: http://jsfiddle.net/squeral/4jf0n2ff/
    const { current: $parentEl } = ref;
    const { scrollTop } = $parentEl; // ์Šคํฌ๋กคํ•œ ๋†’์ด

    const childrenHeight = $parentEl.children[0].clientHeight; // 1๊ฐœ ์ž์‹(li) ์š”์†Œ ๋†’์ด(padding ํฌํ•จ)
    const parentOffset = childrenHeight * cursorIdx; // ํ˜„์žฌ ํฌ์ปค์Šค๋œ ์š”์†Œ(li) "์ด์ „"๊นŒ์ง€์˜ ์ด ๋†’์ด
    const viewport = scrollTop + $parentEl.clientHeight; // ์ปจํ…Œ์ด๋„ˆ ๋†’์ด(padding ํฌํ•จ) + ์Šคํฌ๋กค ๋†’์ด

    // cursor ๋Š” 0๋ถ€ํ„ฐ ์‹œ์ž‘ํ•˜๋Š” index ์ด๋ฏ€๋กœ childrenHeight ๋ฅผ ํ•œ๋ฒˆ ๋” ๋”ํ•œ๋‹ค
    if (viewport < parentOffset + childrenHeight) {
      $parentEl.scrollTop += childrenHeight; // ์•„๋ž˜๋กœ 1์นธ ์Šคํฌ๋กค
    }

    if (parentOffset < scrollTop) {
      $parentEl.scrollTop = parentOffset; // ์œ„๋กœ 1์นธ ์Šคํฌ๋กค
    }
  }
};

 

๋ ˆํผ๋Ÿฐ์Šค


 


๊ธ€ ์ˆ˜์ •์‚ฌํ•ญ์€ ๋…ธ์…˜ ํŽ˜์ด์ง€์— ๊ฐ€์žฅ ๋น ๋ฅด๊ฒŒ ๋ฐ˜์˜๋ฉ๋‹ˆ๋‹ค. ๋งํฌ๋ฅผ ์ฐธ๊ณ ํ•ด ์ฃผ์„ธ์š”
๋ฐ˜์‘ํ˜•