모두를 위한 React 컴포넌트 1부 – 햄버거 메뉴와 모달 접근성(Chapter 2)
안녕하세요 엔비전스입니다.
이번 시간에는 지난 아티클에 이어 햄버거 메뉴와 모달 접근성 만들기, 두 번째 챕터로 찾아뵙게 되었습니다.
지난 시간에는 비모달형(non-modal) 구조로 햄버거 전체 메뉴를 만들어보고, 모달형으로 바꾸는 작업을 위해 미리 React의 Reference를 사용해서 메뉴 내부에 초점을 보내주는 작업까지 해 보았습니다.
이번에는 마치 대화상자나 dimmed 처리된 레이어와 같이 메뉴가 펼쳐진 동안에는 메뉴 밖으로 초점이 빠져나가지 못하게 만드는 포커스 트랩과 스크린 리더 사용자가 메뉴 외 요소를 가상 커서로 탐색하지 못하게끔 하는 aria-hidden을 다뤄 메뉴를 모달처럼 만들어 볼 것입니다.
우선, 첫 번째로 할 작업으로는 메뉴를 리액트 앱의 최상위 div 컨테이너와 분리하는 작업을 할 겁니다.
지난번에 만든 프로젝트의 src 폴더에 Layers.js라는 새 파일을 만듭니다. 그리고 다음과 같이 작성합니다.
Layers.js
import ReactDOM from 'react-dom' // React-dom을 불러옵니다. CRA로 만든 프로젝트에 기본적으로 깔려있습니다.
const MenuLayer = ({children}) =>{ /*
MenuLayer Portal을 만들고 파라미터 필드에 객체 형태로 props.children을 불러옵니다.
Portal은 root과 분리된 또다른 최상위 div에 컴포넌트를 렌더링할 때 사용하는 기능입니다.*/
const layer = document.getElementById('menuLayer');/*레이어로 사용할 div의 아이디를 정해줍니다.
저는 단순하게 menuLayer로 하겠습니다. */
return ReactDOM.createPortal(children,layer)/*
아까 파라미터로 불러온 props.children을 첫 번째 인자로
위에서 const 상수로 넣어둔 menuLayer div를 두 번째 인자로 넣어주고, return을 해줍시다.
*/
}
export default {menuLayer} // 모듈을 사용할 수 있도록 내보냅니다.
자, Portal을 잘 모르신다면 여기서 이제 의문이 드실 수 있는 부분이 있을 겁니다. 우리는 아직 id가 menuLayer인 div를 어디에도 만들지 않았다는 점입니다. 위에서 ReactDOM 컴포넌트를 작성했으니, 이제 추가할 겁니다.
body 태그 아래에 있는 root과 형제인 새로운 최상위 div는 고전적인 html 편집을 통해 가능합니다. 프로젝트 폴더 내에 있는 public 하위 폴더에 index.html에다가요.
./public/index.html
...
<body>
<div id="root"></div>
<div id="menuLayer"></div>
<!--
....
--->
자, 이제 MenuLayer를 사용할 준비가 끝났습니다. 이제 메뉴를 menuLayer 컨테이너로 옮겨볼게요. 기존에 있던 메뉴 컴포넌트를 <MenuLayer>...</MenuLayer>
로 감싸면 됩니다.
menu.js(1)
...
<Layers.MenuLayer>
<MenuBox></MenuBox>{/*4. 다음에 작성할 MenuBox 컴포넌트를 미리 적어놓습니다.*/}
</Layers.MenuLayer>
</MenuControllerContext.Provider>
자 이제 root과 메뉴는 완전히 분리되었습니다. 이제 본격적으로 포커스 트랩을 포함한 모달화 코딩을 시작하겠습니다.
이를 시작하기에 앞서, 이 아티클의 경우는 slide in/out 효과를 CSS의 transition으로 구현하는 것을 기준으로 합니다. 해당 효과가 필요 없다면 CSS 파트를 취향껏 수정하시면 됩니다.
우선, transition으로 슬라이드 효과를 내야 하기 때문에 다음 부분을 추가해 줍니다.
App.css
#menu-wrapper{...position:absolute; transition:visibility 0.3s, left 0.3s;}
.isOpen.false{left:-1000px; visibility:hidden;}
.isOpen.true{left:0;}
그리고 visibility:hidden으로 숨겼기 때문에 저는 한 가지 작업을 더 했습니다. MenuBox 컴포넌트 안에 사용했던 useEffect 콜백에 있는 ref 요소에 초점을 줄 때 딜레이를 줬습니다. NVDA에서 visibility:hidden과 같이 요소가 숨겨졌을 때, 바르게 인식하지 못하는 문제 때문에요.
menu.js(1) MenuBox 컴포넌트의 useEffect(1)
if(isOpen){
setTimeout(()=>MenuEntrance.current.focus(),100)
}else if(isOpen === false){
setTimeout(()=>MenuExit.current.focus(),100);
}
동영상 1: 슬라이드 메뉴가 잘 구현된 모습
위 CSS를 통해 메뉴가 접혔을 때는 visibility:hidden으로 인해 메뉴에 초점이 가지 않도록 간단히 처리했으며, 슬라이드 또한 부드럽게 잘 동작합니다.
자, 그런데 우린 분명 모달형 메뉴를 만들기로 했었어요. 하지만 이 메뉴에는 치명적인 문제가 있습니다. 아래 동영상에서 제가 장난삼아 만들어본 버튼 친구가 그 문제를 설명해 줄 겁니다.
동영상 2: 모달의 초점 이슈
메뉴를 열고, Shift + TAB 키로 메뉴를 탐색해보면, 메뉴 밖으로 초점이 이동되고, 메롱~ 하면서 혀를 내밀고 있는 버튼이 Tab 친구에게 잡혔네요.
우리가 만들기로 한 것은 모달 메뉴이기 때문에 저 혀를 날름 내밀고 있는 버튼 친구가 잡혀서는 안 돼요. 그러니 Tab 친구가 불쌍하지만, 메뉴라는 우리 안에 가둬보도록 합시다.
menu.js(2) - MenuBox 컴포넌트의 useEffect(2)
useEffect(()=>{
const root = document.getElementById('root');//1. 있다가 사용합니다 미리 선언해요.
const menu = document.getElementById('menuLayer');//2. 초점을 가진 요소르 검색하기 위해 변수에 메뉴 영역을 담습니다.
const focusable = [...menu.querySelectorAll(
'a, button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])'
)].filter(el => !el.hasAttribute('disabled'));/*3. 메뉴 내에서 초점을 받을 수 있는 요소를 배열로 담고,
disabled 처리된 요소를 필터로 걸러서 저장합니다.*/
const [firstEl,lastEl] = [focusable[0],focusable[focusable.length-1]];
/*3-1 초점을 못 나가게 막을 요소를 각각 저장합니다. */
if(isOpen){
/*3-2. 포커스를 가두는 이벤트들을 작성합시다. */
firstEl.addEventListener('keydown',function(e){
if(e.key === "Tab" && e.shiftKey ){
e.preventDefault();
lastEl.focus();
}
})/* 3-2-1. FirstEl에 초점이 있는 상태로 Tab 키와 Shift를 같이 누르면 기본 초점 이동을 막고,
lastEl 친구에게로 초점을 보내게끔 합니다. */
lastEl.addEventListener('keydown',function(e){
if(e.key === "Tab" && !e.shiftKey ){
e.preventDefault();
firstEl.focus();
}
})/* 3-2-2. 마찬가지로 TAB 키만 눌렀을 때 기본 초점 이동을 막고,
firstEl 친구에게로 초점을 보냅니다. */
...
자, 이제 메뉴를 다시 열고, Tab키와 Shift + Tab 키로 탈옥(?)을 시도해볼까요?
동영상 3: 초점 탈출 시도(1)
보고 듣는 것처럼 firstEl과 lastEl이라는 든든한 두 문지기가 Tab을 빠져나가지 못하게 잘 막아주는 것을 볼 수 있었습니다. 그러나 우린 아직 한 가지 문제를 해결하지 못했습니다.
바로 스크린 리더의 가상 커서를 막는 문제입니다. 가상 커서와 초점은 별개의 문제로, 가상 커서를 사용하는 스크린 리더 사용자는 강제로 초점을 메뉴 밖에 있는 버튼 친구에게 보낼 수 있습니다. 이것도 동영상으로 함께 보아요.
동영상 4: 초점의 탈출 시도(2) with 스크린 리더
NVDA에서는 가상 커서(브라우즈 모드)가 켜진 상태로 알파벳 B 키, 또는 Shift + 알파벳 B 키를 누르면 버튼 단위로 웹을 탐색할 수 있습니다. 위 동영상은 가상 커서의 이 버튼 단위 탐색을 사용해서 밖으로 빠져나간 거지요.
만약에 링크가 있었다면, k (전체 링크 탐색) 키나 u 키(방문 안 한 링크 탐색), v 키(방문한 링크 탐색) 등으로 빠져나갈 수 있었을 거예요. 자 그럼, 스크린 리더가 초점이 우리 밖으로 빠져나가는 것을 돕지 못하게 막아보도록 하겠습니다.
menu.js(3) - MenuBox 컴포넌트의 useEffect(3)
if(isOpen){
...
//4. 아까 root이라는 이름으로 지정해둔 root 컨테이너를 활용하여, 스크린 리더의 가상 커서를 막아줍시다.
setTimeout(()=>MenuEntrance.current.focus(),100)
//visibility:hidden 속성때문에 너무 빨리 초점이 이동되면, 가상 커서가 인식하지 못하는 경우가 있으므로 딜레이를 주어야 합니다.
root.setAttribute('aria-hidden',true);//4-1. isOpen이 true면 aria-hidden이 활성화되어 가상 커서의 탐색 키를 막습니다.
}else if(isOpen === false){
root.setAttribute('aria-hidden',false);//4-2. isOpen이 false면 마찬가지로 aria-hidden을 해제하여 가상 커서 탐색 키를 사용하도록 합니다.
setTimeout(()=>MenuExit.current.focus(),100);
//마찬가지로 aria-hidden 속성때문에 너무 빨리 초점이 이동되면, 가상 커서가 인식하지 못하는 경우가 있으므로 딜레이를 주어야 합니다.
}
},[isOpen,MenuExit,MenuEntrance])
자, 이제 스크린 리더로 다시 한 번 탈출을 시도해볼까요?
동영상 5: 초점의 탈출 시도(3) with 스크린 리더
이제 스크린 리더의 가상 커서도 Tab의 탈출을 돕지 못합니다. 아주 견고한 우리가 완성됐네요. 이처럼 모달 대화상자나 모달형 햄버거 메뉴는 Tab 키와 가상 커서, 그리고 모바일 초점이 화면 밖으로 나가지 못하게끔 초점 관리를 해주어야 합니다. 이렇게 외부로 초점이 가지 못하도록 하는 기법을 초점 함정(포커스 트랩:focus-trap)이라고 부릅니다.
자, 모달형 햄버거 메뉴가 완성되었습니다! 제 실력이 부족한 탓에 이해하기 어려우셨을 거라 생각되지만, 모달형 요소에 어떠한 기법이 필요한가에 대해서는 충분히 소개가 되었을 거라고 생각합니다. 모두를 위한 React 컴포넌트 1부는 여기까지이며, 다음에는 더 발전된 모습, 더 유익한 정보로 찾아뵙도록 노력하겠습니다. 읽어주셔서 감사합니다.