웹 접근성을 준수하는 코드 작성하기 #2

Tab UI Script


웹 접근성을 준수하는 코드 작성하기는 현재 총 5편의 시리즈로 구성되어 있습니다.

  1. 웹 접근성을 준수하는 코드 작성하기 #1 - 서론
  2. 웹 접근성을 준수하는 코드 작성하기 #2 - Tab UI Script (현재글)
  3. 웹 접근성을 준수하는 코드 작성하기 #번외편 - 대체 텍스트 적용기
  4. 웹 접근성을 준수하는 코드 작성하기 #3 - Accordion UI Script
  5. 웹 접근성을 준수하는 코드 작성하기 #4 - Popup Script
  6. 웹 접근성을 준수하는 코드 작성하기 #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

  1. role=“tab” 으로 버튼이 tab button임을 명시해준다.
  2. aria-selected 값으로 해당 버튼이 선택 된 상태인지 아닌지 명시해준다.
  3. tab button 과 연결 될 tabpanel div id를 aria-controls=“tabpanel div id” 로 연결하여 두 컨텐츠가 연관관계에 있다는 것을 알려준다.
  4. 선택 되지 않은 버튼의 tabindex 값을 -1로 설정하여 키보드 탭으로 접근이 되지 않게 해준다. (tab button 의 키보드 접근은 키보드 화살표로 가능)

Tab Panel

  1. role=“tabpanel” 로 해당 div 가 연결된 tab button이 있는 tab panel이라는 것을 명시해준다.
  2. 현재 tab panel과 연결된 tab button의 id 를 aria-labelledby=“tab button id” 로 연결해준다.
  3. 활성화 되지 않은 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);
    });
  });
}

굉장히 복잡해 보이지만 차근차근 읽어보면 해당 주석으로도 충분히 설명이 가능한 코드 이다.
해당 스크립트에 적용된 사항은 아래와 같다.

  1. 탭 기능 구현
  2. 한 페이지에 여러 개의 탭이 있을 경우에도 오류 없이 동작.
  3. 탭 안에 탭이 있을 경우에도 오류 없이 동작.
  4. 키보드 접근 시 탭 버튼은 좌, 우 화살표로 탭 접근 가능.
  5. 탭 버튼에서 home, end 키 선택 시 해당 탭 리스트의 제일 첫 버튼과 마지막 버튼 포커스.

(위 스크립트는 좌우 탭형에서만 적용이 가능하며, es6 문법이 포함되어 있기 때문에 IE에서는 동작 하지 않는다. IE 동작을 위해선 closest 와 forEach, 화살표 함수, 백틱 에 관한 polyfill 적용이 필요하다.)

동작이 코드만으로 이해가 되지 않는다면 예제 사이트를 방문해서 키보드로 동작해보면 쉽게 이해할 수 있다.
동작 예시 : https://jsfiddle.net/seulbiLee/7f5qya4e/6

마치며

웹접근성 프로젝트가 아니라면 tab UI script 같은 경우는 아주 간단하게 작업 되어 코드 몇 줄이면 끝나는 경우가 많다. 하지만 tab ui script는 접근성 고려한 코드를 작성한다고 해도 많이 어렵지 않기 때문에, 웹접근성 관련 된 코드 공부가 하고 싶다면 tab UI 로 시작해보는 것도 좋은 선택지가 될 것 같다.



참고문서