원티드 프론트엔드 프리온보딩 코스(취업연계)에 지원했습니다.
구현 과제를 달성하며 해결하려 했던 문제들에 대해 이야기를 하고자합니다.해당 글은 Modal 컴포넌트에 대해서만 작성했습니다.
가이드라인에서는 위 컴포넌트 중 2가지 이상만 구현해도 좋다고했으나, 그 이상을 구현할 경우 가산점이 붙는다고하여 모두 제작하게 되었습니다.
상세코드는 깃허브 에서 열람하실 수 있습니다.
구현범위가 어디까지인가요?
구현 과제 가이드라인에서는 예시 GIF를 제공해줬지만 GIF만으로는 정확한 구현 범위를 알기가 힘들었습니다.
하여, 기존에 자주 사용하던 라이브러리를 참고하여 최대한 웹접근성을 지키는 방향으로 충실히 구현하고자 했습니다.
W3C - Modal Dialog Example 위 예시를 참고하여 모달을 구현하기 위한 사항들을 아래와 같이 정리했습니다.
아래에서 설명하는 방법들을 통해 해당 사항을 구현했습니다.
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
을 통해 열리고 닫히는데요. open
이 false
일 때 모달요소가 렌더링 되는것을 방지하기 위해 open
이 true
인 경우에만 Modal 컴포넌트
를 렌더링하도록 했습니다.
때문에 open
이 false
일 때는 contentRef.current
가 존재하지 않습니다.
contentRef
는 모달내 콘텐츠를 감싸는 요소인데요.
<div ref={contentRef}>{children}</div>
open
이 true
일 때 setLastFocus
를 통해 포커스가 가능한 모든 요소를 탐색하고 해당 요소 중 마지막 요소를 lastFocusableEl
에 저장해주었습니다.
그리고 prevActiveEl
에 모달이 열리기전 마지막으로 접근했던 요소를 저장해주고 closeBtn.current
로 포커스를 이동시켰습니다.
closeBtn.current
는 닫기 버튼인데요. 모달이 열린 후 바로 닫기 버튼으로 포커스를 이동시키는는 이유는 키보드를 사용하는 사용자가 오접근하게될시 닫기버튼으로 빠르게 이동시켜주는 것이 중요하기 때문입니다.
if (prevActiveEl) {
prevActiveEl.focus();
}
모달이 닫힐 때는 prevActiveEl
을 활용하여 처음 모달을 호출했던 버튼으로 포커스를 이동시켜주었습니다.
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]);
모달내에서 포커스가 빠져나가지 못하게하려면 포커스트랩의 배치도 중요합니다.
저는 focusTrapHead
와 focusTrapFoot
두개를 만들어 각각 다이얼로그 요소내의 최상단과 최하단에 위치시켰습니다.
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}
/>
focusTrapHead
와 focusTrapFoot
두 요소 모두 tabIndex
를 0
으로 두어 포커스가 가능합니다.
각 트랩으로 이동시 focusTrapHead
는 마지막요소 또는 닫기 버튼으로 이동시켜주고 focusTrapFoot
은 최상단트랩의 다음 요소인 닫기 버튼으로 이동시켜주어 포커스가 모달의 외부로 빠져나가지 않게 도와줍니다.
위와 같은 방법을 통해 모달 내의 포커스를 유지 할 수 있었으며, 키보드를 사용하는 유저들도 불편함 없이 모달 컴포넌트를 사용할수있게 되었습니다. 😃