원티드 프론트엔드 프리온보딩 코스(취업연계)에 지원했습니다.
구현 과제를 달성하며 해결하려 했던 문제들에 대해 이야기를 하고자합니다.

해당 글은 Modal 컴포넌트에 대해서만 작성했습니다.

구현 과제

  • Toggle
  • Modal (해당 글의 주제)
  • Tab
  • Tag
  • AutoComplete
  • ClickToEdit

가이드라인에서는 위 컴포넌트 중 2가지 이상만 구현해도 좋다고했으나, 그 이상을 구현할 경우 가산점이 붙는다고하여 모두 제작하게 되었습니다.

상세코드는 깃허브 에서 열람하실 수 있습니다.


구현범위가 어디까지인가요?

구현 과제 가이드라인에서는 예시 GIF를 제공해줬지만 GIF만으로는 정확한 구현 범위를 알기가 힘들었습니다.
하여, 기존에 자주 사용하던 라이브러리를 참고하여 최대한 웹접근성을 지키는 방향으로 충실히 구현하고자 했습니다.

W3C - Modal Dialog Example 위 예시를 참고하여 모달을 구현하기 위한 사항들을 아래와 같이 정리했습니다.

  • 대화 상자가 화면의 100%를 채워야한다.
  • 일부 모바일 장치에서 발생하는 배경 움직임을 숨겨야한다.
  • 포커스는 대화 상자내에서만 이동해야한다.
  • 대화 상자를 숨기면 기존에 대화 상자를 호출했던 버튼으로 포커스가 이동해야한다.
  • Escape 키를 통해 대화 상자를 닫아야한다.

위 목록 중 특히 집중했던 사항은 대화 상자내에서만 이동하는 포커스였는데요.

아래에서 설명하는 방법들을 통해 해당 사항을 구현했습니다.

1. 포커스 가능한 요소들 정하기

const [prevActiveEl, setPrevActiveEl] = useState();
const closeBtn = useRef();
const [lastFocusableEl, setLastFocusableEl] = useState();
const contentRef = useRef();

const setLastFocus = useCallback(() => {
  const focusableEls = [
    ...contentRef.current.querySelectorAll(
      'a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])'
    ),
  ].filter(
    (el) => !el.hasAttribute("disabled") && !el.getAttribute("aria-hidden")
  );
  const lastEl = focusableEls[focusableEls.length - 1];
  if (lastEl) {
    setLastFocusableEl(lastEl);
  }
}, [contentRef]);

useEffect(() => {
  if (open && closeBtn.current) {
    setPrevActiveEl(document.activeElement);
    closeBtn.current.focus();
  }
  if (open && contentRef.current) {
    setLastFocus();
  }
}, [open, contentRef]);

제가 만든 모달은 useState를 사용하는 변수 open을 통해 열리고 닫히는데요. openfalse일 때 모달요소가 렌더링 되는것을 방지하기 위해 opentrue인 경우에만 Modal 컴포넌트를 렌더링하도록 했습니다.

때문에 openfalse일 때는 contentRef.current가 존재하지 않습니다.


contentRef는 모달내 콘텐츠를 감싸는 요소인데요.

<div ref={contentRef}>{children}</div>

opentrue일 때 setLastFocus를 통해 포커스가 가능한 모든 요소를 탐색하고 해당 요소 중 마지막 요소를 lastFocusableEl에 저장해주었습니다.

그리고 prevActiveEl에 모달이 열리기전 마지막으로 접근했던 요소를 저장해주고 closeBtn.current로 포커스를 이동시켰습니다.


closeBtn.current는 닫기 버튼인데요. 모달이 열린 후 바로 닫기 버튼으로 포커스를 이동시키는는 이유는 키보드를 사용하는 사용자가 오접근하게될시 닫기버튼으로 빠르게 이동시켜주는 것이 중요하기 때문입니다.


if (prevActiveEl) {
  prevActiveEl.focus();
}

모달이 닫힐 때는 prevActiveEl을 활용하여 처음 모달을 호출했던 버튼으로 포커스를 이동시켜주었습니다.

2. 포커스트랩 배치하기

const focusTrapHead = useRef();
const focusTrapFoot = useRef();

const focusLastEl = useCallback(
  (event) => {
    if (event.target === focusTrapHead.current) {
      if (lastFocusableEl) {
        lastFocusableEl.focus();
      } else {
        closeBtn.current.focus();
      }
    }
  },
  [focusTrapHead, lastFocusableEl]
);

const focusFirstEl = useCallback(
  (event) => {
    if (event.target === focusTrapFoot.current) {
      closeBtn.current.focus();
    }
  },
  [focusTrapFoot]
);

useEffect(() => {
  const focusHead = focusTrapHead.current;
  const focusFoot = focusTrapFoot.current;
  if (focusHead) {
    focusHead.addEventListener("focusin", focusLastEl);
  }
  if (focusFoot) {
    focusFoot.addEventListener("focusin", focusFirstEl);
  }
  return () => {
    if (focusFoot) {
      focusFoot.removeEventListener("focusin", focusLastEl);
    }
    if (focusHead) {
      focusHead.removeEventListener("focusin", focusFirstEl);
    }
  };
}, [focusTrapHead, focusTrapFoot, open, focusFirstEl, focusLastEl]);

모달내에서 포커스가 빠져나가지 못하게하려면 포커스트랩의 배치도 중요합니다.

저는 focusTrapHeadfocusTrapFoot 두개를 만들어 각각 다이얼로그 요소내의 최상단과 최하단에 위치시켰습니다.

Tab키를 사용해 포커스 이동시 순차적으로 이동하기 때문에 최상단과 최하단에 배치하는것이 좋다고 생각했습니다.

<div
  ref={focusTrapHead}
  tabIndex={0}
  className="fixed bg-transparent -top-10"
  style={focusTrapStyle}
  />
<button
  ref={closeBtn}
  type="button"
  className="rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
  onClick={() => setOpen(false)}
  >

  // ...생략

  <div ref={contentRef}>{children}</div>
  <div
    ref={focusTrapFoot}
    tabIndex={0}
    className="fixed bg-transparent -top-10"
    style={focusTrapStyle}
    />

focusTrapHeadfocusTrapFoot 두 요소 모두 tabIndex0으로 두어 포커스가 가능합니다.

각 트랩으로 이동시 focusTrapHead는 마지막요소 또는 닫기 버튼으로 이동시켜주고 focusTrapFoot은 최상단트랩의 다음 요소인 닫기 버튼으로 이동시켜주어 포커스가 모달의 외부로 빠져나가지 않게 도와줍니다.


위와 같은 방법을 통해 모달 내의 포커스를 유지 할 수 있었으며, 키보드를 사용하는 유저들도 불편함 없이 모달 컴포넌트를 사용할수있게 되었습니다. 😃

profile
기록보다 기력을

0개의 댓글