home

리액트와 함께 접근성이 제공된 탭 UI(Accessible tab with React)

글 분류
main
키워드
a11y
react
생성일
2024/03/07 06:14
최근 수정일
2025/02/20 06:40
작성중

⚽️ 목표

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가 아닌 이유

1118
issues
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
복사

참조