웹 접근성을 준수하는 코드 작성하기는 현재 총 5편의 시리즈로 구성되어 있습니다.
- 웹 접근성을 준수하는 코드 작성하기 #1 - 서론
- 웹 접근성을 준수하는 코드 작성하기 #2 - Tab UI Script (현재글)
- 웹 접근성을 준수하는 코드 작성하기 #번외편 - 대체 텍스트 적용기
- 웹 접근성을 준수하는 코드 작성하기 #3 - Accordion UI Script
- 웹 접근성을 준수하는 코드 작성하기 #4 - Popup Script
- 웹 접근성을 준수하는 코드 작성하기 #5 - Swiper.js 사용 시 웹 접근성 준수 하기
들어가며
오늘은 많이 사용하는 UI 스크립트 중 Tab UI 스크립트를 작성해보려고 한다.
마크업은 아주 간단하게 아래와 같이 작성 해 보았다.
탭 버튼을 리스트로 묶어 주었고, 바로 다음 탭 패널 div 를 배치하였다.
<div class="wrap-tab-container" data-role="tab">
<ul class="wrap-tab-list" role="tablist">
<li class="tab-list active">
<button
id="tab-01"
class="tab-button"
role="tab"
aria-selected="true"
aria-controls="tab-panel-01"
>
tablist 01
</button>
</li>
<li class="tab-list">
<button
id="tab-02"
class="tab-button"
role="tab"
aria-selected="false"
aria-controls="tab-panel-02"
tabindex="-1"
>
tablist 02
</button>
</li>
<li class="tab-list">
<button
id="tab-03"
class="tab-button"
role="tab"
aria-selected="false"
aria-controls="tab-panel-03"
tabindex="-1"
>
tablist 03
</button>
</li>
</ul>
<div class="wrap-tab-contents">
<div
id="tab-panel-01"
class="tab-contents"
role="tabpanel"
aria-labelledby="tab-01"
tabindex="0"
>
<p>tab contents 01</p>
</div>
<div
id="tab-panel-02"
class="tab-contents"
role="tabpanel"
aria-labelledby="tab-02"
tabindex="0"
hidden="true"
>
<p>tab contents 02</p>
</div>
<div
id="tab-panel-03"
class="tab-contents"
role="tabpanel"
aria-labelledby="tab-03"
tabindex="0"
hidden="true"
>
<p>tab contents 03</p>
</div>
</div>
</div>
Tab Button
- role=“tab” 으로 버튼이 tab button임을 명시해준다.
- aria-selected 값으로 해당 버튼이 선택 된 상태인지 아닌지 명시해준다.
- tab button 과 연결 될 tabpanel div id를 aria-controls=“tabpanel div id” 로 연결하여 두 컨텐츠가 연관관계에 있다는 것을 알려준다.
- 선택 되지 않은 버튼의 tabindex 값을 -1로 설정하여 키보드 탭으로 접근이 되지 않게 해준다. (tab button 의 키보드 접근은 키보드 화살표로 가능)
Tab Panel
- role=“tabpanel” 로 해당 div 가 연결된 tab button이 있는 tab panel이라는 것을 명시해준다.
- 현재 tab panel과 연결된 tab button의 id 를 aria-labelledby=“tab button id” 로 연결해준다.
- 활성화 되지 않은 tab panel은 스크린 리더기가 읽을 수 없게 hidden 속성을 넣어 숨겨준다.
기본적으로 위와 같은 규칙을 지키면서 코드를 작성해야지 스크린리더기가 제대로 해당 컨텐츠를 읽어주게 된다.
현재 작성된 tab list 마크업 규칙
- role=“tablist” 는 ul li 목록 형으로 li 안에 a 또는 button 형태로 되어있다. (li 의 className = ‘tab-list’ / a 또는 button -> role=“tab”)
- role=“tabpanel” 은 상위에 role=“tabpanel” 들을 감싸는 부모 div 로 wrap-tab-contents 가 있다.
- wrap-tab-contents 와 role=“tablist” 는 형제 요소로 상위 부모 div 로 data-role=“tab”이 있다.
- role=“tab” 의 id 와 role=“tabpanel”의 id는 서로 aria-controls, aria-labelledby 로 매칭 되어있다.
- role=“tab”의 활성화 된 상태는 aria-selected=“true”, 상위 li 의 className ‘active’가 있다.
- role=“tab”의 비활성화 된 상태는 aria-selected=“false” 와 tabindex=“-1” 가 있다.
- role=“tabpanel”은 기본 tabindex=“0” 을 가진다.
- role=“tabpanel”의 비활성화 상태는 hidden=“true” 가 있어야 한다. (활성화 시에는 아무 값 없음)
위 규칙은 아래 작성된 스크립트를 그대로 사용 시 지켜야 할 마크업 규칙이다.
const tabGroups = document.querySelectorAll('[data-role="tab"]');
if (tabGroups) {
let currentTarget, targetTabWrap, targetTabListWrap, targetPanelWrap;
// 이벤트 타겟 변수 설정
const init = (e) => {
currentTarget = e.target.tagName;
currentTarget === 'BUTTON' || 'A'
? (currentTarget = e.target)
: (currentTarget = e.target.closest('button') || e.target.closest('a'));
targetTabWrap = currentTarget.closest('[data-role="tab"]');
targetTabListWrap = targetTabWrap.querySelector('[role="tablist"]');
targetPanelWrap = targetTabWrap.querySelector('.wrap-tab-contents');
};
// 클릭 이벤트
const tabClickEvt = (e) => {
init(e);
if (currentTarget.ariaSelected === 'false') {
// 미선택된 탭 속성 false 상태로 만들기
tabRemoveEvt(targetTabListWrap, targetPanelWrap);
// 선택 된 탭 속성 true 상태로 만들기
tabAddEvt(currentTarget, targetTabWrap);
}
};
// 키보드 접근 이벤트
const tabKeyUpEvt = (e) => {
init(e);
const targetBtnWrap = currentTarget.parentElement;
if (e.key == 'ArrowRight') {
// 키보드 -> 화살표를 눌렀을 때
if (targetBtnWrap.nextElementSibling) {
targetBtnWrap.nextElementSibling.children[0].focus();
tabRemoveEvt(targetTabListWrap, targetPanelWrap);
tabAddEvt(targetBtnWrap.nextElementSibling.children[0], targetTabWrap);
} else homeKeyEvt(targetTabListWrap, targetTabWrap, targetPanelWrap);
} else if (e.key == 'ArrowLeft') {
// 키보드 <- 화살표를 눌렀을 때
if (targetBtnWrap.previousElementSibling) {
targetBtnWrap.previousElementSibling.children[0].focus();
tabRemoveEvt(targetTabListWrap, targetPanelWrap);
tabAddEvt(targetBtnWrap.previousElementSibling.children[0], targetTabWrap);
} else endKeyEvt(targetTabListWrap, targetTabWrap, targetPanelWrap);
}
// 키보드 End 키 눌렀을 때
else if (e.key == 'End') endKeyEvt(targetTabListWrap, targetTabWrap, targetPanelWrap);
// 키보드 Home 키 눌렀을 때
else if (e.key == 'Home')
homeKeyEvt(targetTabListWrap, targetTabWrap, targetPanelWrap);
};
// tab active event
const tabAddEvt = (currentTarget, targetPanelWrap) => {
// 선택 된 탭 속성 true 로 변경
currentTarget.setAttribute('aria-selected', 'true');
currentTarget.removeAttribute('tabindex');
currentTarget.parentElement.classList.add('active');
// 연결 된 tabpanel 숨김 해제
targetPanelWrap
.querySelector(`[aria-labelledby="${currentTarget.id}"]`)
.removeAttribute('hidden');
targetPanelWrap
.querySelector(`[aria-labelledby="${currentTarget.id}"]`)
.setAttribute('tabindex', '0');
};
// tab active remove event
const tabRemoveEvt = (tabListWrap, tabPanelWrap) => {
targetTabListWrap.querySelectorAll('li').forEach((tabBtnWrap) => {
// 기존에 선택 된 탭 속성 false 로 변경
if (tabBtnWrap.classList.contains('active')) {
tabBtnWrap.classList.remove('active');
tabBtnWrap.querySelector('[role="tab"]').setAttribute('aria-selected', 'false');
tabBtnWrap.querySelector('[role="tab"]').setAttribute('tabindex', '-1');
}
});
// 기존에 선택 된 tabpanel 숨김
for (let tabPanel of targetPanelWrap.children) {
tabPanel.setAttribute('hidden', 'false');
tabPanel.setAttribute('tabindex', '-1');
}
};
// 키보드 Home key Event (선택된 탭 리스트 중 첫 번째 리스트로 포커스 이동)
const homeKeyEvt = (targetTabListWrap, targetTabWrap, targetPanelWrap) => {
targetTabListWrap.children[0].children[0].focus();
tabRemoveEvt(targetTabListWrap, targetPanelWrap);
tabAddEvt(targetTabListWrap.children[0].children[0], targetTabWrap);
};
// 키보드 End key Event (선택된 탭 리스트 중 마지막 리스트로 포커스 이동)
const endKeyEvt = (targetTabListWrap, targetTabWrap, targetPanelWrap) => {
const targetTabLists = targetTabListWrap.querySelectorAll('li');
targetTabLists[targetTabLists.length - 1].children[0].focus();
tabRemoveEvt(targetTabListWrap, targetPanelWrap);
tabAddEvt(targetTabLists[targetTabLists.length - 1].children[0], targetTabWrap);
};
// 클릭/키보드 탭 이벤트 제거/할당
tabGroups.forEach((tabWrapper) => {
const tabBtns = tabWrapper.querySelectorAll('[role="tab"]');
tabBtns.forEach((tabBtn) => {
tabBtn.removeEventListener('click', tabClickEvt);
tabBtn.addEventListener('click', tabClickEvt);
tabBtn.removeEventListener('keyup', tabKeyUpEvt);
tabBtn.addEventListener('keyup', tabKeyUpEvt);
});
});
}
굉장히 복잡해 보이지만 차근차근 읽어보면 해당 주석으로도 충분히 설명이 가능한 코드 이다.
해당 스크립트에 적용된 사항은 아래와 같다.
- 탭 기능 구현
- 한 페이지에 여러 개의 탭이 있을 경우에도 오류 없이 동작.
- 탭 안에 탭이 있을 경우에도 오류 없이 동작.
- 키보드 접근 시 탭 버튼은 좌, 우 화살표로 탭 접근 가능.
- 탭 버튼에서 home, end 키 선택 시 해당 탭 리스트의 제일 첫 버튼과 마지막 버튼 포커스.
(위 스크립트는 좌우 탭형에서만 적용이 가능하며, es6 문법이 포함되어 있기 때문에 IE에서는 동작 하지 않는다. IE 동작을 위해선 closest 와 forEach, 화살표 함수, 백틱 에 관한 polyfill 적용이 필요하다.)
동작이 코드만으로 이해가 되지 않는다면 예제 사이트를 방문해서 키보드로 동작해보면 쉽게 이해할 수 있다.
동작 예시 : https://jsfiddle.net/seulbiLee/7f5qya4e/6
마치며
웹접근성 프로젝트가 아니라면 tab UI script 같은 경우는 아주 간단하게 작업 되어 코드 몇 줄이면 끝나는 경우가 많다. 하지만 tab ui script는 접근성 고려한 코드를 작성한다고 해도 많이 어렵지 않기 때문에, 웹접근성 관련 된 코드 공부가 하고 싶다면 tab UI 로 시작해보는 것도 좋은 선택지가 될 것 같다.