blog logo
iaman

ํฌ์ปค์Šค ํŠธ๋žฉ (Focus Trap) ๐Ÿ™Š


๊ตฌ๊ธ€๋ง ํ•ด๋ณด๋ฉด ์•„๋ž˜์™€ ๊ฐ™์€ ์„ค๋ช…์„ ๋ณด์—ฌ์ค€๋‹ค.

๋ชจ๋‹ฌ ์ปดํฌ๋„ŒํŠธ๊ฐ€ ์—ด๋ฆฐ ์ƒํƒœ์—์„œ ํ‚ค๋ณด๋“œ ํฌ์ปค์Šค๊ฐ€ ๋ชจ๋‹ฌ ์™ธ๋ถ€(๋ชจ๋‹ฌ ์ปดํฌ๋„ŒํŠธ๊ฐ€ ์•„๋‹Œ ๋ชจ๋‹ฌ ๋’ค์˜ ์š”์†Œ๋“ค)๋กœ ๋น ์ ธ๋‚˜๊ฐ€์ง€ ๋ชปํ•˜๋„๋ก ๊ฐ€๋‘๋Š” ๊ฒƒ์„ ํฌ์ปค์Šค ํŠธ๋žฉ์ด๋ผ๊ณ  ๋ถ€๋ฆ…๋‹ˆ๋‹ค.

๋ธŒ๋ผ์šฐ์ €์—์„œ Tab ํ‚ค๋ฅผ ๊ณ„์† ๋ˆ„๋ฅด๋ฉด ๋ฐ”๊นฅ์ชฝ ํŽ˜์ด์ง€ input ์˜์—ญ ~ ( ๊ฐœ๋ฐœ์ž ๋„๊ตฌ๋ฅผ ์—ฐ ์ƒํƒœ์—์„œ )๊ฐœ๋ฐœ์ž ๋„๊ตฌ ์˜์—ญ๊นŒ์ง€ Tab ํ‚ค์˜ ์˜ํ–ฅ์ด ๊ฐ€๊ฒŒ๋œ๋‹ค.

์ด๋Š” ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์„ ์•ˆ์ข‹๊ฒŒ ํ•  ์ˆ˜ ์žˆ๋‹ค.

๊ทธ๋Ÿฌ๋ฉด

1. ๋ชจ๋‹ฌ์ฐฝ์ด ๋„์›Œ์ง€๋ฉด ๋ชจ๋‹ฌ ๋‚ด์—์„œ๋งŒ Tab ์œ ํšจ (๋งˆ์ง€๋ง‰ keyin์š”์†Œ์— ๋‹ค๋‹ฌ์œผ๋ฉด ์ฒซ ์š”์†Œ๋กœ ํƒญ&ํฌ์ปค์Šค)

2. Tab + Shift๋ฅผ ๋ˆ„๋ฅด๊ฒŒ ๋˜๋ฉด ์ด์ „ Tab์˜ ์š”์†Œ๋กœ ์›€์ง์ด๊ธฐ (์ฒซ keyin์š”์†Œ์— ๋‹ค๋‹ฌ์œผ๋ฉด ๋งˆ์ง€๋ง‰ ์š”์†Œ๋กœ ํƒญ&ํฌ์ปค์Šค)

#๊ตฌํ˜„ ํ•ด๋ณด์ž.

const FocusTrapModal = ({ isOpen, onClose }) => {
  const focusTrapArea = useRef(null);
  const focusPossibleEles = useRef([]);
  const currFocusIdx = useRef(0);

  const handleTab = () => {
    const currHtml = focusPossibleEles.current[currFocusIdx.current + 1];
    if (currHtml !== undefined) {
      currHtml.focus();
      currFocusIdx.current++;
      return;
    }
    focusPossibleEles.current[0].focus();
    currFocusIdx.current = 0;
  };

  const wrapHandleTab = (e) => {
    if (!e.shiftKey && e.key === "Tab") {
      e.preventDefault();
      handleTab();
    }
  };

  const handleShiftTab = () => {
    const currenthtml = focusPossibleEles.current[currFocusIdx.current - 1];
    if (currenthtml !== undefined) {
      currenthtml.focus();
      currFocusIdx.current--;
      return;
    }
    focusPossibleEles.current.at(-1).focus();
    currFocusIdx.current = focusPossibleEles.current.length - 1;
  };

  const wrapHandleShiftTab = (e) => {
    if (e.shiftKey && e.key === "Tab") {
      e.preventDefault();
      handleShiftTab();
    }
  };

  const preventKeyDown = (e) => {
    // ํ•œ๊ธ€ ์ œ์–ด
    if (e.isComposing || e.key === "Backspace") {
      return;
    }

    wrapHandleTab(e);
    wrapHandleShiftTab(e);
  };

  const handleBeforeUnload = (event) => {
    const message = "๋ณ€๊ฒฝ์‚ฌํ•ญ์ด ์ €์žฅ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ์ •๋ง ๋– ๋‚˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?";
    event.returnValue = message;
    return message;
  };

  useEffect(
    function functionAnjunghwan() {
      if (isOpen) {
        focusPossibleEles.current = Array.from(
          focusTrapArea.current.children
        ).filter((val) => val.tabIndex >= 0 && val.disabled !== true);

        focusPossibleEles.current[0].focus();
        focusTrapArea.current.addEventListener("keydown", preventKeyDown);

        window.addEventListener("beforeunload", handleBeforeUnload);
      }
      return () => {
        if (focusTrapArea.current) {
          focusTrapArea.current.removeEventListener("keydown", preventKeyDown);
        }
        window.removeEventListener("beforeunload", handleBeforeUnload);
      };
    },
    [isOpen]
  );

  const handleOnClick = (e) => {
    e.preventDefault();
    currFocusIdx.current = e.target.tabIndex;
    focusPossibleEles.current[e.target.tabIndex].focus();
  };

  return (
    <div className="modal-overlay">
      <div className="modal" ref={focusTrapArea}>
        <button className="close-btn" onClick={onClose} tabIndex={-1}>
          X
        </button>
        <h2>๋ชจ๋‹ฌ ์ฐฝ</h2>

        <input
          type="text"
          placeholder="์—ฌ๊ธฐ์— ์ž…๋ ฅ"
          onClick={handleOnClick}
          tabIndex={0}
        />
        <textarea
          placeholder="์—ฌ๊ธฐ์— ํ…์ŠคํŠธ ์ž…๋ ฅ"
          onClick={handleOnClick}
          tabIndex={1}
        />
        <input
          type="text"
          placeholder="์—ฌ๊ธฐ์— ์ž…๋ ฅ"
          onClick={handleOnClick}
          tabIndex={2}
        />
        <input
          type="text"
          placeholder="์—ฌ๊ธฐ์— ์ž…๋ ฅ"
          onClick={handleOnClick}
          tabIndex={3}
        />

        <button onClick={onClose} tabIndex={-1}>
          ์ €์žฅ
        </button>
        <button onClick={onClose} tabIndex={-1}>
          ์ทจ์†Œ
        </button>
        <button disabled onClick={onClose} tabIndex={-1}>
          ์ž„์‹œ์ €์žฅ
        </button>
      </div>
    </div>
  );
};
export default FocusTrapModal;

๊ทธ๋Ÿฐ๋ฐ keyin ์š”์†Œ์— ์˜์–ด๋ฅผ keyinํ•˜๊ณ  Tabํ•˜๋ฉด ์ž˜๋˜๋Š”๋ฐ

ํ•œ๊ธ€์„ keyinํ•˜๊ณ  Tabํ•˜๋ฉด ๋งˆ์ง€๋ง‰ ๊ธ€์ž๊ฐ€ ๋ณต์‚ฌ๋˜์–ด Tab์ดํ›„์— ๋‹ค์Œ keyin์š”์†Œ์— ๋ณต์ œ๋˜๋Š” ์ƒํ™ฉ์ด ์ƒ๊ฒผ๋‹ค.

์ด๋Ÿฌํ•œ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ–ˆ์„๋•Œ๋Š”

if(e.isComposing){
   return;
}

์œผ๋กœ ๋ง‰์•„์ฃผ๋ฉด ๋œ๋‹ค.

๊ตฌ๊ธ€๋ง ํ•ด๋ณด๋ฉด

ํ•œ๊ธ€์€ ์˜์–ด์™€ ๋‹ฌ๋ฆฌ ์ž์Œ,๋ชจ์Œ์œผ๋กœ ์ด๋ฃจ์–ด์ ธ ์žˆ๊ธฐ ๋•Œ๋ฌธ์ด๋ผ๊ณ  ํ•œ๋‹ค.

์ž์Œ,๋ชจ์Œ์„ ์ž…๋ ฅํ•  ๋•Œ IME(Input Method Editor)๊ฐ€ ์ž…๋ ฅ์ค‘(๋ฌธ์ž ์กฐํ•ฉ ์ค‘)์ž„์„ ๋‚˜ํƒ€๋‚ด๋Š” `isComposing`์ƒํƒœ๊ฐ€ ๋œ๋‹ค๊ณ  ํ•œ๋‹ค.