⚽️ 목표
이용자들의 사용성과 접근성을 위해 모달에 Focus Trap을 적용하자
focus trap이 뭔지 모르겠으면?
TL;DR
→ JS 로직으로 포커스 트랩 적용
→ aria-hidden을 이용해 접근성 개선
→ 컴포넌트화 하여 재사용성 개선
1. Focus Trap이 대체 왜 필요한거니
필요성
•
모달창에 따라 다르지만, 모달창이 활성 되어있을 경우 활성화된 모달창의 UI 요소들에게만 접근 가능해야한다.
◦
위와 같이 모달창 뒤의 배경이 blur 처리 + 색 효과가 적용되어 시각적으로 접근 불가능한 형태일 경우를 말한다.
◦
실제로 접근 불가능한 데이터 → 클릭도 할 수 없고 이용자들이 지금 상황에서 필요하지 않은 데이터이기 때문이다.
WCAG 2.2
Web Content Accessibility Guidelines의 약자로 웹 콘텐츠 접근성 지침으로, 웹 사이트와 애플리케이션의 접근성을 개선하기 위한 국제 표준
•
WCAG 2.2 가이드라인 목차 2.4.3 Focus Order의 Examples 중 하나로 존재한다.
◦
Intent, Benefits, Examples 모두 읽어보자.
•
JS로직만을 이용한 Focus Trap은 접근성이 개선이 필요하다.
◦
모달이 열릴 경우 모달 뒤의 데이터들은 이용자가 접근 불가능한 정보들이다. 하지만 화면 낭독기 이용자들은 여전히 모달 뒤에 존재하는 접근불가한 컨텐츠들에 대한 정보를 제공받게 된다.
2. 코드 상황
sequenceDiagram participant pageA pageA ->> loginModal: 1. 페이지에서 button을 클릭해 loginModal open Note over loginModal: 2. page 위에 loginModal이 활성화됨 Note over loginModal: 2-1. focus가 loginModal에서만 수행되는게 아닌<br/> pageA에 존재하는 HTML 요소들에도 focus 가능
Mermaid
복사
문제 1. 보이지 않지만 포커스 가능한 UI 요소들
•
pageA에 loginModal이 활성화 되지만 focus trap이 적용되지않아 실제로 보이지 않는 화면의 UI 요소들에게 focus가 되고 있다.
•
이용자들은 modal에 가려 실제로 보이지 않는 화면에 존재하는 요소들에게 focus가 되어 포커스가 어디 존재하는지 알 수 없다. → 결과적으로 이용자들이 확인할수 없는 의미없는 UI 요소들에 대한 focus다.
•
위와같은 모달의 경우 focus는 모달 내부 UI 컨텐츠에서 loop 해야한다.
문제 2. 아주 떨어지는 접근성
•
화면 낭독기 버전, 종류에 따라 모달이 화면 전체를 덮을 경우 여전히 화면 낭독기는 모달이 동작되기 이전 페이지에 정보들을 읽고 지금 당장 필요하지 않은 정보들을 제공한다.
•
“없는 정보보단 많은 정보가 더 낫다” 라는 자세보다 “꼭 필요한 정보를 꼭 제공하자”라는 자세로 접근하는게 중요하다.. 화면낭독이 이용자들은 시각적으로 정보를 얻는 웹 브라우저 이용자들보다 필요한 정보를 얻는게 오래걸리고 많은 노력을 필요로 하기 때문이다.
•
현재 코드에서는 화면 낭독이 이용자들이 필요로 하지 않는 원본페이지의 정보를 그대로 제공할 여지가 존재한다. 모달이 떴음에도 불구하고 화면 낭독기가 모달 배경에 존재하는 원본 페이지를 읽을 가능성이 존재한다는 뜻이다.
•
이를 해결하기위해 나는 화면 낭독기에 “이거 읽지마 지금 안읽어도 돼”를 전달할 것이다.
모달의 형태
•
포커스 가능한 HTML 요소들은 총 3개가 존재한다.→ 닫기버튼, 이메일 입력버튼, 비밀번호 찾기버튼
•
모달창 주변은 blur 처리가된 회색 백그라운드이다. 뒤에는 해당 모달이 열리기 전까지 이용자들이 이용하던 페이지이다.
3. loginModal 컴포넌트 생성
기본 코드
export const LoginModal: FunctionComponent = () => {
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleTab = (keyBoardEvent: KeyboardEvent) => {
// 탭 핸들링을 위한 함수
}
document.addEventListener("keydown", handleTab);
return () => {
document.removeEventListener("keydown", handleTab);
};
}, [modalRef]);
return (
<div css={cssObj.wrapper} ref={modalRef} tabIndex={-1}>
{/* ...생략 */}
TypeScript
복사
•
제일 상단 태그에 useRef를 이용해 참조 → 해당 태그 자식 요소들중 포커스 가능한 요소들을 확인하기위해!
•
tabindex = {-1} → 프로그래밍 적으로 (element.focus와 같이) 포커스는 가능하지만 tab으로 정상 접근은 불가능한 상태를 말한다, 모달 등장 시 모달 뒤 pageA에 존재하던 focus를 옮겨오기 위해 필요하다.
•
최상단 부모 css요소(cssObj.wrapper)에 outline = none; 을 추가하여 focus 시 이펙트를 제거한다. 브라우저에 따라 다르지만 첫 포커스 당시 esc를 누를 경우 제일 상단 모달에 outline이 표시된다.
•
모달 컴포넌트가 mount 됐을때 event listener를 추가하기위해 useEffect를 추가했다.
◦
unmount 시 event listener 제거는… 말안해도 알지? 했지? 말해 했다고 말해!
4. handleTab 함수 만들기
useEffect(() => {
const handleTab = (keyBoardEvent: KeyboardEvent) => {
if (modalRef.current === null) return;
const focusableElements = modalRef.current.querySelectorAll(
"button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])"
) as NodeListOf<HTMLElement>;
const firstFocusableElement = focusableElements[0];
const lastFocusableElement = focusableElements[focusableElements.length - 1];
if (keyBoardEvent.key === "Tab" && keyBoardEvent.shiftKey && document.activeElement === firstFocusableElement) {
keyBoardEvent.preventDefault();
lastFocusableElement.focus();
return;
}
if (keyBoardEvent.key === "Tab" && !keyBoardEvent.shiftKey && document.activeElement === lastFocusableElement) { keyBoardEvent.preventDefault();
firstFocusableElement.focus();
}
};
modalRef?.current?.focus();
document.addEventListener("keydown", handleTab);
return () => {
document.removeEventListener("keydown", handleTab);
};
}, [modalRef]);
};
TypeScript
복사
포커스 가능한 요소 미리 변수화
•
focusableElements → modalRef, 모달 컴포넌트의 최상단 요소의 자식 들 중 포커스 가능한 HTML 요소들을 querySelectorAll로 참조
◦
모달에서 값을 참조할 경우 포커스 가능한 3개의 HTMLElement 들을 가져온다. → 닫기 버튼, input, submit 버튼
Element vs HTML Element
•
firstFocusableElement, lastFocusableElement → 각 포커스 가능한 첫 HTML 요소, 마지막 전 HTML 요소, 마지막 HTML 요소
◦
위 모달에 매핑할 경우
firstFocusableElement | 닫기버튼 | 첫번째 요소 |
lastFocusableElement | 비밀번호 찾기 버튼 | 맨 마지막 요소 |
5. 이용자 tab 입력 시 강제로 처리해야 할 2가지 케이스
•
크게 두가지로 나뉘어진다. 해당 케이스를 제외한 나머지 케이스들은 “브라우저가 원래 하던대로 하도록 내비둔다.”
◦
당연히 하던대로 하도록 내비두기 위해선 브라우저가 기본적으로 어떻게 작동하는지 알아야겠지?
현재 포커스된 요소가 firstFocusableElement, tab + shift로 입력했을 때
graph TD a[모달 뒤에 존재하는 페이지에 포커스 가능한 요소들] -.일반적인 접근 시.- C{firstFocusableElement} -.- beforeLastFocusableElement -.- lastFocusableElement C ==tab+shift입력 시 맨 마지막 요소가 focus 되어야함 ======> lastFocusableElement
Mermaid
복사
•
브라우저는 기본 동작으로 tab + shift는 focus 가능한 이전요소를 focus하며 처음에 있는 요소에서 입력될경우 경우 포커스 가능한 맨마지막 요소를 focus한다.
•
해당 케이스는 포커스된 요소가 첫번째 요소이며 tab + shift 입력 시 이전 요소로 보내야할 경우 → 여기서 이전 요소는 마지막 포커스 가능한 요소다.
•
자바스크립트 로직을 이용하여 firstFocusableElement focused 상태이며 tab+shift 를 입력하는 경우 마지막 요소로 focus하는 로직 추가한다.
if (keyBoardEvent.key === "Tab" && keyBoardEvent.shiftKey && document.activeElement === firstFocusableElement) {
keyBoardEvent.preventDefault();
lastFocusableElement.focus();
return;
}
TypeScript
복사
현재 포커스된 요소가 lastFocusableElement, tab로 입력했을 때
graph TD firstFocusableElement -.-> beforeLastFocusableElement -.-> C{lastFocusableElement} -.일반적인 접근 시.-> a[모달 뒤에 존재하는 페이지에 포커스 가능한 요소들] C{lastFocusableElement} ==tab 입력 시 제일 첫 요소가 focus 되어야함 ======> firstFocusableElement
Mermaid
복사
•
브라우저는 기본 동작으로 tab 입력 시 focus 가능한 다음 요소를 focus하며 맨 마지막 요소일 경우 포커스 가능한 첫 요소를 focus한다.
•
해당 케이스는 포커스된 요소가 마지막 요소이며 tab 입력 시 첫 요소로 보내야할 경우 → 여기서 다음 요소는 첫번째 포커스 가능한 요소다.
•
자바스크립트 로직을 이용하여 lastFocusableElement focused 상태이며 tab을 입력하는 경우 모달의 첫번째 요소로 focus하는 로직을 추가한다.
if (keyBoardEvent.key === "Tab" && !keyBoardEvent.shiftKey && document.activeElement === lastFocusableElement) {
keyBoardEvent.preventDefault();
firstFocusableElement.focus();
}
};
TypeScript
복사
5. aria-hidden을 이용한 접근 불가능한 요소 관리
•
화면 낭독기 이용자들의 접근성 향상을 위해 모달 열림 시 필요하지 않은 정보들(모달 뒤에 존재하는 페이지)에 대한 처리가 필요하다. → 필요하지 않은 과한 정보의 전달은 오히려 접근성에 나쁜 영향을 끼친다.
페이지 HTML 구조
<html lang="ko">
<head>...</head>
<body>
<div id="modalPortal"></div>
<div id="toastPortal"></div>
<div id="__next" data-reactroot></div>
</body>
</html>
HTML
복사
•
현재 모달은 createPortal을 이용해 modalPortal 에 모달을 직접 주입한다.
div id = “__next “
•
실제 리액트의 랜더링 결과가 출력되는 div로 커스텀 가능한 이름이다.
•
본인은 모달이 실행될 경우 실제 페이지 결과물들이 존재하는 “__next” 아이디를 가진 div에 aria-hidden 속성을 부여할 예정이다.
aria-hidden
•
접근성 API에 노출 여부를 설정하는 속성값 → 화면 낭독기와 같은 보조도구에게 노출 여부를 지정하는 값
•
부모에게 지정하면 자식들은 자동으로 aria-hidden 상태가 된다. → 우리는 __next 아이디를 가진 div에만 적용하면 된다..!!
코드
useEffect(() => {
const handleTab = (keyBoardEvent: KeyboardEvent) => {
// ...생략
const element = document.getElementById("__next") as HTMLElement;
element.ariaHidden = "true";
return () => {
element.ariaHidden = "false";
};
}, [modalRef]);
TypeScript
복사
•
useEffect 내부에 첫 로드 시 __next div의 레퍼런스를 가져와 aria-hidden 옵션 true로 지정한다.
•
해당 모달이 닫힐 때 aria-hidden false로 지정하여 화면 낭독기와 같은 접근성 보조기구가 접근 가능하도록 설정한다.
6. 완성된 코드 - 컴포넌트 화
import { RefObject, useEffect } from "react";
export const useFocusTrap = (modalRef: RefObject<HTMLElement>) => {
useEffect(() => {
const handleTab = (keyBoardEvent: KeyboardEvent) => {
if (modalRef.current === null) return;
const focusableElements: NodeListOf<HTMLElement> = modalRef.current.querySelectorAll(
"button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])"
);
const firstFocusableElement = focusableElements[0];
const lastFocusableElement = focusableElements[focusableElements.length - 1];
if (keyBoardEvent.key === "Tab" && keyBoardEvent.shiftKey && document.activeElement === firstFocusableElement) {
keyBoardEvent.preventDefault();
lastFocusableElement.focus();
return;
}
if (keyBoardEvent.key === "Tab" && !keyBoardEvent.shiftKey && document.activeElement === lastFocusableElement) {
keyBoardEvent.preventDefault();
firstFocusableElement.focus();
}
};
modalRef.current?.focus();
const element = document.getElementById("__next") as HTMLElement;
element.ariaHidden = "true";
document.addEventListener("keydown", handleTab);
return () => {
document.removeEventListener("keydown", handleTab);
element.ariaHidden = "false";
};
}, [modalRef]);
};
TypeScript
복사
•
지금까지 작성한 코드를 별도의 hook으로 나눠 다른 모달 컴포넌트에서 재활용 할 수 있도록 훅으로 만들어 간단하게 재사용 중 이다.