⚽️ 목표
W3C ARIA APG(Authoring Practices Guide)에 준수하는 접근성 있는 탭을 리액트로 만들자.
•
탭만드는건 쉬우나 "접근성 좋은 탭을 만드는 것은 절대 쉽지 않다.” 필요한 요구사항을 리액트로 구현한다.
해당 글에서 다루지 않는것
TL;DR
접근성에 대한 몇마디
•
기능을 구현하는 것과 접근성이 보장된 기능을 구현하는 것은 여러 면에서 엄청나게 큰 차이를 가지고 있다.
•
“접근성이 보장된 기능을 구현하는 것”에 구현 부분은 매우 쉬운 축에 속한다.(웹이라는 특성상, SPA가 아닌 환경에서 SPA를 제공하기 위한 특성만 제외하고 생각했을 때) 하지만 “접근성이 보장된” 즉, “접근성 보장을 위한 요구사항”과 그 필요성을 파악하는데 오히려 더 많은 시간과 노력이 필요하다.
◦
특정 기능 구현의 경우 비교적 쉽게 요구사항과 제한 사항에 대한 파악이 가능하다. 하지만 웹 접근성의 경우 기능구현에 비해 요구사항 및 제한사항 파악에 비교적 어렵다. 흔히들 웹접근성이라는 단어를 생각할 경우 시각장애인을 떠올리기 마련이지만 그 외에도 너무나도 많은 장애가 존재한다.(인지능력, 청각, 행동능력, 지능 등등)
•
일반적인 개발도 그렇듯이 처음부터 완벽한 프로덕트를 만들 순 없다. 일반적인 코드도 리펙토링 하듯이 기존에 구현된 UI를 하나씩 “접근성이 보장된” 기능으로 업데이트해가며 접근성 관련 공부를 하는 것 도 좋은 방법이다. 처음부터 모든 걸 완벽하게 알 수 없다는 건 당신도 알고 있지 않은가?
◦
W3C에서도 이와 같은 접근법을 추천한다. 한 번에 너무 많은 양의 정보를 이해하려다가 떨어져 나가기 십상이기 때문이다. 다들 “기능 구현의 여부”에 신경 쓰다 보니 이와 같은 접근법을 한다 생각한다. 대부분의 개발자들에게 “기능 구현”은 반드시 해내야 하는 Task임으로 어떻게든 해내는 게 목표지만 접근성은 있으면 좋은 것 + 필수가 아닌 선택이라고 인식되어 있기 때문에 “이 정도면 되겠지”와 같은 자세로 임하는 case가 절대다수 이기 때문이다.
이정도면 되겠지?
•
안 좋은 자세지만 물론 백번 이해한다. 시간에 쫓기고, “여기서 이거 더하면 야근인데”라는 생각도 들고, “이쯤 했으면 된 거 아니야?”라는 생각도 들고… 여기서 우리(개발자가) 다시 한번 생각해야 하는 접근법은 “접근성은 선택이 아닌 필수다”라는 자세다.
•
지금 당장 납기 또는 듀데이트가 너무 짧아 시간에 쫓겨 기능구현조차 시간이 모자랄 수 있다. 이럴 때는 분명하게 팀 내 todo 리스트나 기술부채에 정확한 만료일을 설정하여 꼭 추후에 접근성이 보장될 수 있도록 작업해야 한다.
1. 들어가기 전 - 탭 구성요소
탭의 구성 요소
탭의 구성요소 - w3c에서 발췌
•
탭의 구성요소는 크게 탭(활성화된 탭, 비활성화된 탭) 그리고 탭에 해당되는 컨텐츠가 들어있는 탭패널로 구분된다.
•
탭형태를 가진 UI의 접근성 개선 방법은 크게 두 가지로 나눠 구현할 수 있다. → 2. 두 가지 종류의 탭을 확인한다.
2. 요구사항
key 입력 관련
1.
Tab 키 → 키보드의 Tab 키를 이용해 탭들이 존재하는 탭 리스트에 활성화된 탭 요소로 focus가 가능해야 한다.
a.
탭 리스트가 포커스 된 후 다음 포커스 대상은 기본적으로 탭 패널이다.
2.
좌우 화살표 → 좌우 화살표로 탭의 포커스를 이동할 수 있어야 한다.
a.
좌측 끝에 도달할 경우 맨 마지막 요소를 focus, 우측 끝에 도달할 경우 맨 처음 요소를 focus 해야 한다.
3.
Space or Enter → manual activate 이여야 할 경우 해당 키들로 탭을 활성화시킬 수 있어야 한다.
4.
Home(optional) → 키보드의 Home키를 입력했 을 경우 첫 번째 탭이 focus 돼야 한다.
5.
End(optional) → 키보드의 End 키를 입력했을 경우 마지막 탭이 focus 돼야 한다.
2. 두가지 종류의 탭
•
기본적으로 W3C 권고사항으로는 탭이 포커스 됐을 때 해당 탭에 해당되는 탭 패널이 자동으로 활성화되는 걸 권고한다.(눈에 띄는 지연시간이 존재하지 않을 경우)
•
눈에 띄는 delay가 존재할 경우 추가입력으로 활성화하는 기능을 추가해야 한다.
focus 시 바로 활성화 되는 탭 패널
•
탭이 focus 되었을 때 해당되는 내용이 바로 나타나는 형태를 말한다.
•
이미 해당 탭에 “컨텐츠가 이미 로드되어 있어 바로 즉각적인 UI 반응을 보여줄 수 있을 때 사용”하는 접근법이다.
추가 입력으로 활성화 되는 탭 패널
•
탭이 focus 되고 enter나 space를 이용해 선택을 해야 해당 탭에 해당되는 내용이 나타나는 형태를 말한다.
•
새로운 탭이 네트워크 요청을 수행하거나 페이지가 새로고침 되는 경우 자동 activate는 사용성을 많이 떨어뜨린다. “새로고침 되거나 네트워크 요청을 수행하는 경우 Enter나 Space키를 이용해 해당 탭을 활성화시키는 기능을 추가”해야 한다.
이 글에서는 focus 시 바로 활성화되는 탭 패널에 대해 다룬다.
3. ARIA attributes 관련 단어정리
•
탭 리스트의 기본 HTML 구조를 만들기 전에 알아 둬야 할 aria attributes가 존재한다.
•
이 글은 ARIA attributes에 대한 설명 글이 아니므로 간단하게 설명하겠다.
accessible name
<button>버튼명</button>
<a>~로 가는 링크</a>
HTML
복사
•
마크업 작성자가 보조 기술 사용자에게 요소에 대한 레이블을 제공하기 위해 요소와 연관시키는 짧은 문자열(일반적으로 1~3 단어)이다.
•
W3C APG 명세상 accessible name과 accessible description에 대한 정의와 사용법을 정독하길 바란다.
•
당신이 대충 “이 정도면 되겠지”하고 정했던 accessible name이 하나둘씩 떠오르며 “와…그동안 접근성 안다고 했던 건 정말 부끄러웠던 거구나”하는 현타까지 한 번에 몰려올 것이다. 내가 그랬거든.
aria-controls
•
“해당 interactive element가 변경시키는 요소와 연결”시켜주는 역할은 한다.
•
e.g.) 투어러 버튼은 “tabpanel-1”이라는 id를 가진 div를 visible 상태로 변경한다. 투어러 버튼은 tabpanel-1이라는 aria-controls attribute 값을 가지고 있어야 변경하는 상태를 연결할 수 있다.
aria-labelledby
•
accessible name을 만들어 주는 역할을 하는 attributes 중 하나로 최고 우선순위로 accessible name을 지정한다.
aria-selected
•
여러 위젯의 “선택됨”에 대한 추가적인 표시를 하는 attribute이다.
4. 기본 HTML 구조
<h3>BMW Motorrad</h3> <!-- 계층구조에 맞는 헤딩 레벨로 변경 필수 -->
<div role="tablist">
<button id="tab-1" role="tab" aria-controls="tabpanel-1" aria-selected="true">투어러</button>
<button id="tab-2" role="tab" aria-controls="tabpanel-2" aria-selected="false">슈퍼 스포츠</button>
<button id="tab-3" role="tab" aria-controls="tabpanel-3" aria-selected="false">어드벤쳐</button>
</div>
<div id="tabpanel-1" role="tabpanel" aria-labelledby="tab-1">
<p>투어러 장르는 bmw motorrad의 긴 역사에서 빼놓을 수 없는 장르입니다.</p>
</div>
<div id="tabpanel-2" role="tabpanel" aria-labelledby="tab-2">
<p>bmw s1000rr은 새로운 역사를 써내려가는 BMW Motorrad의 슈퍼 스포츠 모터사이클 입니다.</p>
</div>
<div id="tabpanel-3" role="tabpanel" aria-labelledby="tab-3">
<p>BMW Motorrad를 대표하는 어드벤쳐 장르입니다. 타브랜드와 비교할수 없는 최고의 경험을 선사합니다.</p>
</div>
HTML
복사
•
탭을 활성화하는 버튼의 role은 tab이고 해당 tab의 컨텐츠를 가지고 있는 요소의 role은 tabpanel이다.
버튼이 ul - li가 아닌 이유
•
ul element가 없음에 대한 w3c의 ARIA practice 리포지터리에 존재하는 이슈다. 정독해 보면 도움이 된다.
•
이유는 크게 두가지로 나뉜다.
◦
tablist/tab role이 ul의 리스트성, 또는 리스트스러움(listness)를 제거하기 때문에 필요 없다.
◦
접근성 관점에서 봤을 때 div, ul의 차이점이 존재하지 않는다.
•
ul, ol은 생략되어 있지만, 이미 list라는 role을 이미 가지고 있다. 해당 role을 제거하고 tablist로 새로운 role을 줄 필요성이 존재하지 않기 때문이다.
•
div라는 의미 없는 태그(simentic 아닌 태그)에 tablist라는 role 또한 sementic으로 list를 의미하기 때문이다.
•
ul - li를 이용하여 생성 후 li를 버튼으로 만들 수 도 있지만 ARIA 첫 번째 규칙에 어긋나는 접근법이다. native HTML element(이 상황에서는 버튼)를 사용할 수 있을 때 왜 굳이 li를 버튼으로 만들어야 하는가?
탭에 버튼을 사용하는 이유
If you can use a native HTML element or attribute with the semantics and behavior you require already built in, instead of re-purposing an element and adding an ARIA role, state or property to make it accessible, then do so.
•
ARIA의 첫 번째 규칙이다. - native HTML element를 사용할 수 있을 때는 native HTML Element를 사용하는 게 코드상 더 간단하고 견고한(Robust) 브라우저간 지원이 되기 때문이다.
5. aria-labelledby와 aria-controls로 엮기
graph TD tab-1 --aria-controls--> tabpanel-1 tab-2 --aria-controls--> tabpanel-2 tab-3 --aria-controls--> tabpanel-3 tabpanel-1 --aria-labelledby--> tab-1 tabpanel-2 --aria-labelledby--> tab-2 tabpanel-3 --aria-labelledby--> tab-3
Mermaid
복사
tab의 aria-controls
해당 버튼에 영향을 받는 요소와 연결하는 역할
•
tab-1, 2, 3은 aira-controls를 이용하여 tabpanel-1, 2, 3과 연결시킨다고 표현하는 게 맞다. assistive technology에게 각 탭들이 해당 tabpanel과 연결되어 있는 기능이라고 선언해 주는 것 과 같은 역할을 한다.
tabPanel의 aria-labelledby
accessible name을 만들어 주는 역할
•
aria-labelledby는 명시적인 label이 존재하지 않을 경우 accessible name을 만들 때 사용한다. tabPanel에는 명식적인 label이 존재하지 않기 때문에 해당 tabpanel과 연결된 tab, 즉 버튼의 accessible name을 가져와 accessible name을 만들어준다.
•
aria-labelledby는 여러 id를 참조해 accessible name을 만들어 낼 수 있다. 이 방식은 추후에 확인해본다.
aria-labelledby 자기참조
<div
id="panelId"
tabIndex="0"
role="tabpanel"
aria-labelledby="tab-1 panelId"
aria-label="에 대한 설명"
hidden={!isSelected}
>
<p>{desc}</p>
</div>
HTML
복사
•
aria-labelledby는 모든 accessible name의 최우선 순위를 가진다. 어떤 accessible name을 가지고 있더라도 accessible technology는 무시하고 aria-labelledby로 만들어진 accessible name을 레이블로 인식한다.
•
aria-labelledby는 자기(요소)를 참조 할 수 있다. id를 생성 해준 후 label로 accessible name을 생성해준 후 aria-labelledby를 이용해 엮어주면 된다.
graph TD subgraph tabButton id1(id=tab1) an1(Accessible Name=투어러) end subgraph tabPannel id2(id=panelId) an2(Accessible Name=에 대한 설명) lb(labelledby=tab1 panelId) --> id1 --> id2 end an1 -.-> result(tabPannel의 Accessible Name : 투어러 + 에 대한 설명) an2 -.-> result
Mermaid
복사
•
결과적으로 해당의 accessible name은 button의 accessible name인 “투어러”와 tabpanel의 accessible name인 “에 대한 설명”이 합쳐져 assistive technology는 “투어러 에 대한 설명” 이라고 결정하여 화면을 낭독한다.
aria-labelledby로 엮은 tabpanel의 accessible name
6. JSX return (마크업 확인)
•
추가한 마크업에 Interactivity를 추가하기 전에 JSX return문을 확인하여 중간 진행사항을 확인한다.
// tab과 tabpanel에 필요한 정보들을 매칭한 배열 상수, 더 편한 관리를 위함.
const tabInfoArr = [
{
genre: "투어러",
desc: "투어러 장르는 bmw motorrad의 긴 역사에서 빼놓을 수 없는 장르입니다.",
},
{
genre: "슈퍼 스포츠",
desc: "bmw motorrad 슈퍼 스포츠의 자랑스러운 모델 bmw s1000rr은 새로운 역사를 써내려가는 슈퍼 스포츠 모터사이클 입니다.",
},
{
genre: "어드벤쳐",
desc: "BMW Motorrad를 대표하는 어드벤쳐 장르입니다. 타브랜드와 비교할수 없는 최고의 경험을 선사합니다.",
},
];
// ... 생략 ...
// interactivity를 위한 코드 추가 예정
const TabPage = () => {
const [activeMenu, setActiveMenu] = useState("투어러"); // 현재 활성화된 탭 관리용 state
const [focusIndex, setFocusIndex] = useState<number | null>(0); // 포커스한 index 관리용 state
const tabRefList: React.MutableRefObject<
Map<number, HTMLButtonElement>
> = useRef(new Map());
return (
<main className={style.container}>
<h2>BMW Motorrad</h2>
<div role="tablist">
{tabInfoArr.map(({ genre }, index) => {
const isSelected = genre === activeMenu;
return (
<button
key="tab"
type="button"
tabIndex={isSelected ? 0 : -1}
aria-selected={isSelected}
id={`tab-${index + 1}`}
role="tab"
aria-controls={`tabpanel-${index + 1}`}
ref={(domNode) => {
if (domNode) {
tabRefList.current.set(index, domNode);
return;
}
tabRefList.current.delete(index);
}}
onClick={() => {
setActiveMenu(genre);
}}
>
{genre}
</button>
);
})}
</div>
{tabInfoArr.map(({ genre, desc }, index) => {
const isSelected = genre === activeMenu;
const key = `tabpanel-${index + 1}`;
return (
<div
key={key}
id={key}
tabIndex={0}
role="tabpanel"
aria-labelledby={`tab-${index + 1} ${key}`}
aria-label="에 대한 설명"
hidden={!isSelected}
>
<p>{desc}</p>
</div>
);
})}
</main>
);
};
TypeScript
복사
Array 형태의 tab이름과 tabpanel 내용
•
관리의 용이성을 위해 tab과 매칭되는 tabpanel의 내용을 object array로 관리한다.
일반적이지 않은 ref의 형태
•
useRef는 훅 이기 때문에 컴포넌트 최상단에 존재해야 하며 JSX Return 문에서 선언할 수 없다. 그럼 Ref의 list가 필요할 때는 어떻게 해야할까? → 공식문서에 추천된 JS의 Map 자료형을 이용하여 리스트 형태로 Ref를 관리하는 방법을 사용했다.
•
querySelectorAll도 방법이 될 수 있겠지만 명령형(이라 쓰고 jQuery라고 읽어도 된다) 방식의 방법, react적이지 못한 방법, 그리고 error prone한 방법이라 skip한다. → 본인이 속한 조직, 환경에 따라 다를 수 있다고 생각한다. 모두 ref 배열에 넣기에 너무 많은 경우의 수가 존재할 경우 id도 querySelectorAll의 사용도 충분히 고려해볼 만 하다 생각한다.
6. Interactivity 추가 (with React)
Keyboard Event Listener
const [activeMenu, setActiveMenu] = useState<GenreType>("Tourer");
const [focusIndex, setFocusIndex] = useState<number | null>(null);
const tabRefList: React.MutableRefObject<
Map<number, HTMLButtonElement>
> = useRef(new Map());
useEffect(() => {
if (focusIndex === null) {
return undefined;
}
const keydownHandler = (e: KeyboardEvent) => {
if (e.key === "ArrowRight") {
const nextIndex =
focusIndex + 1 === tabInfoArr.length ? 0 : focusIndex + 1;
setFocusIndex(nextIndex);
setActiveMenu(tabInfoArr[nextIndex].genre);
tabRefList.current.get(nextIndex)?.focus();
return;
}
if (e.key === "ArrowLeft") {
const nextIndex =
focusIndex === 0 ? tabInfoArr.length - 1 : focusIndex - 1;
setFocusIndex(nextIndex);
setActiveMenu(tabInfoArr[nextIndex].genre);
tabRefList.current.get(nextIndex)?.focus();
}
};
window.addEventListener("keydown", keydownHandler);
return () => {
window.removeEventListener("keydown", keydownHandler);
};
}, [focusIndex]);
TypeScript
복사
•
focus, active의 경우 화면에 보여지는 값들이기 때문에 state를 이용해 관리한다.
◦
케이스에 따라서 클릭
•
useEffect를 이용하여 키보드 입력 실행 시 케이스에 대한 처리를 수행한다. 위에서 다뤘듯이 해당 포스트는 탭 변경 시 confirm 인터랙션 없이 바로 수행하기 때문에 Focus와 Active를 한번에 설정한다.
결론
"use client";
import { useEffect, useRef, useState } from "react";
import { tabInfoArr } from "./constant";
import * as style from "./sytyle.css";
type GenreType = (typeof tabInfoArr)[number]["genre"];
const TabPage = () => {
const [activeMenu, setActiveMenu] = useState<GenreType>("Tourer");
const [focusIndex, setFocusIndex] = useState<number | null>(null);
const tabRefList: React.MutableRefObject<
Map<number, HTMLButtonElement>
> = useRef(new Map());
useEffect(() => {
if (focusIndex === null) {
return undefined;
}
const keydownHandler = (e: KeyboardEvent) => {
if (e.key === "ArrowRight") {
const nextIndex =
focusIndex + 1 === tabInfoArr.length ? 0 : focusIndex + 1;
setFocusIndex(nextIndex);
setActiveMenu(tabInfoArr[nextIndex].genre);
tabRefList.current.get(nextIndex)?.focus();
return;
}
if (e.key === "ArrowLeft") {
const nextIndex =
focusIndex === 0 ? tabInfoArr.length - 1 : focusIndex - 1;
setFocusIndex(nextIndex);
setActiveMenu(tabInfoArr[nextIndex].genre);
tabRefList.current.get(nextIndex)?.focus();
}
};
window.addEventListener("keydown", keydownHandler);
return () => {
window.removeEventListener("keydown", keydownHandler);
};
}, [focusIndex]);
return (
<main className={style.container}>
<h2>BMW Motorrad</h2>
<div role="tablist">
{tabInfoArr.map(({ genre }, index) => {
const isSelected = genre === activeMenu;
return (
<button
key="tab"
type="button"
tabIndex={isSelected ? 0 : -1}
aria-selected={isSelected}
id={`tab-${index + 1}`}
role="tab"
aria-controls={`tabpanel-${index + 1}`}
onFocus={() => setFocusIndex(index)}
onBlur={() => setFocusIndex(null)}
ref={(domNode) => {
if (domNode) {
tabRefList.current.set(index, domNode);
return;
}
tabRefList.current.delete(index);
}}
onClick={() => {
setActiveMenu(genre);
}}
>
{genre}
</button>
);
})}
</div>
{tabInfoArr.map(({ genre, desc }, index) => {
const isSelected = genre === activeMenu;
const key = `tabpanel-${index + 1}`;
return (
<div
key={key}
id={key}
tabIndex={0}
role="tabpanel"
aria-labelledby={`tab-${index + 1} ${key}`}
aria-label="에 대한 설명"
hidden={!isSelected}
>
<p>{desc}</p>
</div>
);
})}
</main>
);
};
export default TabPage;
JavaScript
복사