Dialog, Modal에서 키입력을 가두는 방법 (키보드트랩, 포커스트랩)

박진현·2023년 11월 1일
2

dialog창이 떠 있을때, dialog창 뒤에 있는 컴포넌트들의 키입력이 먹혀서 의도치않은 UX가 되어버리는 경우가 많이 발생했다.
이를 막기 위해 아래처럼 훅을 만들었다.
이왕 키입력을 막는 김에, 탭키로 탭인덱스를 이동하지 못하도록 수정하였다.

11/22 추가수정

  • disabled인 키들은 포커스되지 않으므로 셀렉터에서 모두 제외하였다
  • 렌더로직에 함수가 있으면 렌더링마다 재선언되는 낭비가 발생하기 때문에 컴포넌트 외부로 로직을 뺐다.
  • js Doc 추가하고 가독성 좋게 리팩토링하였다
import { MutableRefObject, useEffect, useRef } from 'react';

import Const from 'constant/Const';

/**
 * @사용법   const elRef = useKeyboardTrap(); 선언 후, wrapper 엘리먼트에 ref={elRef} 추가
 * @returns {MutableRefObject<T | null>} elRef
 */
function useKeyboardTrap<T extends HTMLElement = HTMLElement>() {
  // 키보드 트랩, 포커스 트랩
  const elRef = useRef<T | null>(null);

  useEffect(() => {
    const handleFocus = (evt: KeyboardEvent) => _handleFocus(evt, elRef);

    elRef.current?.addEventListener(
      Const.EVENT_TYPE.KEYDOWN,
      handleFocus as EventListener
    );
    document.addEventListener(Const.EVENT_TYPE.KEYUP, preventEvt);
    document.addEventListener(Const.EVENT_TYPE.KEYDOWN, preventEvt);

    return () => {
      elRef.current?.removeEventListener(
        Const.EVENT_TYPE.KEYDOWN,
        handleFocus as EventListener
      );
      document.removeEventListener(Const.EVENT_TYPE.KEYUP, preventEvt);
      document.removeEventListener(Const.EVENT_TYPE.KEYDOWN, preventEvt);
    };
  }, []);

  return elRef;
}

export default useKeyboardTrap;

// funcs
/**
 * Handles focus for keyboard navigation within focusable elements.
 * @param {KeyboardEvent} evt - The keyboard event object.
 * @param {MutableRefObject<HTMLElement | null>} elRef - Reference to the HTMLElement.
 */
function _handleFocus(
  evt: KeyboardEvent,
  elRef: MutableRefObject<HTMLElement | null>
) {
  const TAB_KEY = 'Tab';
  const focusableElementSelector = elRef.current?.querySelectorAll<HTMLElement>(
    'a[href]:not([disabled]), area[href]:not([disabled]), button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), iframe, summary, details, video[controls], audio[controls], [contenteditable=""], [contenteditable="true"], [tabindex]:not([tabindex^="-"])'
  );

  if (!focusableElementSelector || !elRef.current) return;

  const firstFocusableEl = focusableElementSelector[0];
  const lastFocusableEl =
    focusableElementSelector[focusableElementSelector.length - 1];
  const isTabPressed = evt.key === TAB_KEY;

  // Tab key
  if (isTabPressed) {
    if (evt.shiftKey) {
      // Shift + Tab
      if (document.activeElement !== firstFocusableEl) return;
      lastFocusableEl?.focus();
      evt.preventDefault();
    } else {
      // Tab
      if (document.activeElement !== lastFocusableEl) return;
      firstFocusableEl.focus();
      evt.preventDefault();
    }
  }
}
function preventEvt(evt: Event) {
  // 다른 컴포넌트에서 입력된 이벤트를 막기 위해 사용
  evt.stopPropagation();
  evt.stopImmediatePropagation();
}

이렇게 포커스 트랩과, 키보드 트랩을 동시에할 수 있는 훅을 사용하면된다.

사용하고 싶은 곳에서 const elRef = useKeyboardTrap()
을 선언한 후, Wrapper 컴포넌트에 ref={elRef} 를 주면 된다.

여기서 중요한 점은,
elRef에 이벤트를 달아줄 때, keyup을 하면 절대 안된다는 것이다.
down했을 때 이벤트를 읽고, 현재 포커스되어 있는 상태에서 다음 위치를 정하는 로직이기 때문이다.

profile
👨🏻‍💻 호기심이 많고 에러를 좋아하는 프론트엔드 개발자 박진현 입니다.

0개의 댓글