iBetter Books
수정

도입 스토리

"탭 컴포넌트를 만들었어요." 김개발이 화면을 보여주었습니다.

Tab 키로 첫 번째 탭에 포커스가 갔습니다. Enter를 눌렀더니 탭이 활성화됐습니다. 다시 Tab을 눌렀더니 두 번째 탭으로 이동했습니다.

"좋아 보이는데요?" 박멘토가 말했습니다.

"감사합니다." 김개발이 웃었습니다.

"APG 탭 패턴을 봐요." 박멘토가 화면을 열었습니다. "탭 안에서의 이동은 방향키로 해야 해요. Tab 키는 탭 목록 전체를 하나의 그룹으로 보고 건너뛰어야 하고요. 이게 roving tabindex 패턴이에요."

핵심 개념 설명

탭 컴포넌트 — APG Tabs 패턴

<!-- 탭 구조 --><div class="tabs">  <!-- 탭 목록: role="tablist" -->  <div role="tablist" aria-label="상품 정보">    <!-- 첫 번째 탭 (기본 선택) -->    <button      id="tab-1"      role="tab"      aria-selected="true"      aria-controls="panel-1"      tabindex="0"    >      기본 정보    </button>    <!-- 비선택 탭: tabindex="-1" -->    <button      id="tab-2"      role="tab"      aria-selected="false"      aria-controls="panel-2"      tabindex="-1"    >      리뷰    </button>    <button      id="tab-3"      role="tab"      aria-selected="false"      aria-controls="panel-3"      tabindex="-1"    >      Q&A    </button>  </div>  <!-- 탭 패널 -->  <div    id="panel-1"    role="tabpanel"    aria-labelledby="tab-1"    tabindex="0"  >    <h3>기본 정보</h3>    <p>상품의 기본 정보입니다.</p>  </div>  <div    id="panel-2"    role="tabpanel"    aria-labelledby="tab-2"    tabindex="0"    hidden  >    <h3>리뷰</h3>    <p>사용자 리뷰입니다.</p>  </div>  <div    id="panel-3"    role="tabpanel"    aria-labelledby="tab-3"    tabindex="0"    hidden  >    <h3>Q&A</h3>    <p>자주 묻는 질문입니다.</p>  </div></div>

Roving Tabindex 패턴

탭 그룹 내에서 선택된 탭만 tabindex="0", 나머지는 tabindex="-1". 방향키로 탭 간 이동, Tab 키로 탭 패널로 이동합니다.

class Tabs {  constructor(container) {    this.container = container;    this.tabs = [...container.querySelectorAll('[role="tab"]')];    this.panels = [...container.querySelectorAll('[role="tabpanel"]')];    this.tabs.forEach(tab => {      tab.addEventListener('click', () => this.select(tab));      tab.addEventListener('keydown', (e) => this.handleKeydown(e));    });  }  select(selectedTab) {    this.tabs.forEach(tab => {      const isSelected = tab === selectedTab;      tab.setAttribute('aria-selected', isSelected);      tab.setAttribute('tabindex', isSelected ? '0' : '-1');    });    this.panels.forEach(panel => {      const isActive = panel.id === selectedTab.getAttribute('aria-controls');      panel.hidden = !isActive;    });    selectedTab.focus();  }  handleKeydown(e) {    const currentIndex = this.tabs.indexOf(e.currentTarget);    let nextIndex;    switch (e.key) {      case 'ArrowRight':        nextIndex = (currentIndex + 1) % this.tabs.length;        e.preventDefault();        break;      case 'ArrowLeft':        nextIndex = (currentIndex - 1 + this.tabs.length) % this.tabs.length;        e.preventDefault();        break;      case 'Home':        nextIndex = 0;        e.preventDefault();        break;      case 'End':        nextIndex = this.tabs.length - 1;        e.preventDefault();        break;      default:        return;    }    this.select(this.tabs[nextIndex]);  }}// 초기화document.querySelectorAll('.tabs').forEach(tabs => new Tabs(tabs));

아코디언 컴포넌트

아코디언은 여러 섹션을 접고 펼칠 수 있는 컴포넌트입니다.

<div class="accordion">  <!-- 아코디언 항목 1 -->  <h3>    <button      type="button"      aria-expanded="false"      aria-controls="section-1"      id="accordion-btn-1"    >      배송 정보    </button>  </h3>  <div    id="section-1"    role="region"    aria-labelledby="accordion-btn-1"    hidden  >    <p>배송은 주문 후 2-3일 소요됩니다.</p>  </div>  <!-- 아코디언 항목 2 -->  <h3>    <button      type="button"      aria-expanded="false"      aria-controls="section-2"      id="accordion-btn-2"    >      교환/반품    </button>  </h3>  <div    id="section-2"    role="region"    aria-labelledby="accordion-btn-2"    hidden  >    <p>구매 후 7일 이내 교환/반품이 가능합니다.</p>  </div></div>
document.querySelectorAll('.accordion button').forEach(btn => {  btn.addEventListener('click', () => {    const isExpanded = btn.getAttribute('aria-expanded') === 'true';    const panel = document.getElementById(btn.getAttribute('aria-controls'));    btn.setAttribute('aria-expanded', !isExpanded);    panel.hidden = isExpanded;  });});

탭과 달리 아코디언은 방향키 이동이 APG에서 필수가 아닙니다. 각 버튼이 독립적이어서 Tab으로 이동하는 것이 자연스럽습니다.

단계별 실습

따라하기: 탭 키보드 동작 테스트

접근성 있는 탭 컴포넌트의 키보드 동작을 확인합니다.

  • Tab 키로 탭 목록의 첫 번째 탭에 포커스.
  • 오른쪽 방향키로 다음 탭으로 이동 (선택).
  • 왼쪽 방향키로 이전 탭으로 이동.
  • Home/End로 첫/마지막 탭으로 이동.
  • Tab으로 탭 패널 안으로 이동.
  • Shift+Tab으로 탭 패널에서 탭 목록으로 복귀.

변형하기: 수직 탭 방향키 변경

세로형 탭에서는 좌우 방향키 대신 상하 방향키를 사용합니다.

// 수직 탭: aria-orientation 설정tablist.setAttribute('aria-orientation', 'vertical');// 수직 탭의 키보드 핸들러case 'ArrowDown':  nextIndex = (currentIndex + 1) % this.tabs.length;  e.preventDefault();  break;case 'ArrowUp':  nextIndex = (currentIndex - 1 + this.tabs.length) % this.tabs.length;  e.preventDefault();  break;

정리와 확인

핵심 내용 요약

  • 탭 구조: tablist > tab + tabpanel
  • Roving tabindex: 선택 탭 tabindex="0", 나머지 tabindex="-1"
  • 탭 방향키: 좌우(수평) 또는 상하(수직) 방향키로 탭 간 이동
  • aria-selected: 현재 선택된 탭 표시
  • 아코디언: aria-expanded + hidden 패널, Tab으로 버튼 간 이동

확인 문제

문제 1. 탭 컴포넌트에서 비선택 탭의 tabindex 값은?

tabindex="-1"
Tab 키로 그룹 전체를 건너뛰되, 방향키로 탭 간 이동을 지원합니다.

문제 2. aria-controls가 하는 역할은?

탭 버튼이 어떤 패널을 제어하는지 연결합니다.
aria-controls="panel-1"이면 id="panel-1" 요소를 가리킵니다.

다음 챕터에서는 자동완성 입력(Combobox)과 드롭다운 메뉴를 다룹니다. ARIA 패턴 중 가장 복잡한 컴포넌트들의 구현을 알아봅니다.