도입 스토리
"검색창에 자동완성이 뜨는데, 스크린리더가 목록이 열렸는지 모르는 것 같아요." 김개발이 말했습니다.
스크린리더로 검색창에 텍스트를 입력하면 시각적으로 드롭다운이 나타났지만, 스크린리더는 아무 말도 하지 않았습니다. 키보드로 드롭다운을 탐색하려면 어떻게 해야 하는지도 알 수 없었습니다.
"Combobox 패턴이에요." 박멘토가 설명했습니다. "입력란 + 목록의 조합. APG에서 가장 복잡한 패턴 중 하나예요. aria-expanded, aria-autocomplete, aria-activedescendant — 세 가지 속성이 핵심이에요."
핵심 개념 설명
Combobox 패턴
자동완성 입력란은 Combobox 패턴으로 구현합니다.
<!-- Combobox 구조 --><div class="combobox-wrapper"> <label for="search-input" id="search-label">검색</label> <!-- 입력란 --> <input type="text" id="search-input" role="combobox" aria-expanded="false" aria-autocomplete="list" aria-controls="search-listbox" aria-activedescendant="" autocomplete="off" > <!-- 검색 결과 목록 --> <ul id="search-listbox" role="listbox" aria-label="검색 결과" hidden > <!-- 결과 항목: role="option" --> <li id="option-1" role="option" aria-selected="false">결과 1</li> <li id="option-2" role="option" aria-selected="false">결과 2</li> </ul></div>
Combobox JavaScript 구현
class Combobox { constructor(input, listbox) { this.input = input; this.listbox = listbox; this.options = []; this.activeIndex = -1; this.input.addEventListener('input', () => this.onInput()); this.input.addEventListener('keydown', (e) => this.onKeydown(e)); this.input.addEventListener('blur', () => this.close()); } async onInput() { const query = this.input.value; if (query.length < 2) { this.close(); return; } // 검색 결과 가져오기 const results = await fetchSuggestions(query); this.render(results); } render(results) { this.listbox.innerHTML = ''; if (results.length === 0) { this.close(); return; } this.options = results.map((result, i) => { const li = document.createElement('li'); li.id = `option-${i}`; li.role = 'option'; li.setAttribute('aria-selected', 'false'); li.textContent = result.text; li.addEventListener('mousedown', (e) => { e.preventDefault(); // blur 방지 this.select(i); }); this.listbox.appendChild(li); return li; }); this.open(); } open() { this.listbox.removeAttribute('hidden'); this.input.setAttribute('aria-expanded', 'true'); this.activeIndex = -1; } close() { this.listbox.setAttribute('hidden', ''); this.input.setAttribute('aria-expanded', 'false'); this.input.removeAttribute('aria-activedescendant'); this.activeIndex = -1; } navigate(direction) { const newIndex = this.activeIndex + direction; if (newIndex < 0 || newIndex >= this.options.length) return; // 이전 항목 비활성화 if (this.activeIndex >= 0) { this.options[this.activeIndex].setAttribute('aria-selected', 'false'); } // 새 항목 활성화 this.activeIndex = newIndex; const activeOption = this.options[newIndex]; activeOption.setAttribute('aria-selected', 'true'); // aria-activedescendant로 스크린리더에 알림 this.input.setAttribute('aria-activedescendant', activeOption.id); } select(index) { this.input.value = this.options[index].textContent; this.close(); this.input.focus(); } onKeydown(e) { if (!this.listbox.hidden) { switch (e.key) { case 'ArrowDown': e.preventDefault(); this.navigate(1); break; case 'ArrowUp': e.preventDefault(); this.navigate(-1); break; case 'Enter': if (this.activeIndex >= 0) { e.preventDefault(); this.select(this.activeIndex); } break; case 'Escape': this.close(); break; } } }}
드롭다운 메뉴 (Disclosure 패턴)
간단한 드롭다운은 Disclosure 패턴으로 구현합니다.
<!-- 드롭다운 버튼 --><div class="dropdown"> <button type="button" id="user-menu-btn" aria-expanded="false" aria-controls="user-menu" aria-haspopup="true" > 김개발 <span aria-hidden="true">▼</span> </button> <!-- 드롭다운 메뉴 --> <ul id="user-menu" role="menu" aria-labelledby="user-menu-btn" hidden > <li role="none"> <a href="/profile" role="menuitem">프로필</a> </li> <li role="none"> <a href="/settings" role="menuitem">설정</a> </li> <li role="none"> <button type="button" role="menuitem" onclick="logout()"> 로그아웃 </button> </li> </ul></div>
const btn = document.getElementById('user-menu-btn');const menu = document.getElementById('user-menu');const menuItems = menu.querySelectorAll('[role="menuitem"]');btn.addEventListener('click', () => { const isExpanded = btn.getAttribute('aria-expanded') === 'true'; btn.setAttribute('aria-expanded', !isExpanded); menu.hidden = isExpanded; if (!isExpanded) { // 첫 메뉴 항목으로 포커스 menuItems[0].focus(); }});// 메뉴 항목 키보드 이동menu.addEventListener('keydown', (e) => { const items = [...menuItems]; const currentIndex = items.indexOf(document.activeElement); switch (e.key) { case 'ArrowDown': e.preventDefault(); items[(currentIndex + 1) % items.length].focus(); break; case 'ArrowUp': e.preventDefault(); items[(currentIndex - 1 + items.length) % items.length].focus(); break; case 'Escape': btn.setAttribute('aria-expanded', 'false'); menu.hidden = true; btn.focus(); break; }});// 외부 클릭 시 닫기document.addEventListener('click', (e) => { if (!btn.contains(e.target) && !menu.contains(e.target)) { btn.setAttribute('aria-expanded', 'false'); menu.hidden = true; }});
단계별 실습
따라하기: Combobox 키보드 동작 확인
자동완성 입력란을 테스트합니다.
- 텍스트 입력 시 드롭다운 목록 열림.
- ArrowDown으로 목록 항목 순차 선택.
- ArrowUp으로 역순 이동.
- Enter로 선택 완료 후 입력란 포커스 유지.
- Escape로 목록 닫기 (입력 내용 유지).
- 스크린리더로 각 항목이 선택될 때 읽히는지 확인.
변형하기: 검색창 상태 안내 추가
검색 결과 수를 스크린리더에 알립니다.
function render(results) { // 기존 렌더링... // 결과 수 안내 const status = document.getElementById('search-status'); status.textContent = results.length > 0 ? `${results.length}개의 검색 제안이 있습니다.` : '검색 결과가 없습니다.';}
<div id="search-status" role="status" aria-live="polite" class="sr-only"></div>
정리와 확인
핵심 내용 요약
- Combobox:
role="combobox"+aria-expanded+aria-controls+aria-activedescendant aria-activedescendant: 포커스를 입력란에 유지하면서 선택 항목을 스크린리더에 알림- 드롭다운 메뉴:
aria-haspopup+aria-expanded+role="menu"+role="menuitem" role="none":<li>의 불필요한 역할 제거 (menu > menuitem 직접 연결을 위해)
확인 문제
문제 1. Combobox에서 aria-activedescendant를 사용하는 이유는?
포커스를 입력란에 유지하면서 목록에서 선택된 항목을 스크린리더에 알릴 수 있습니다.
실제 DOM 포커스를 목록 항목으로 이동하면 타이핑이 끊기기 때문입니다.
문제 2. <li role="none"> 안에 <a role="menuitem">을 넣는 이유는?
<ul role="menu">의 직접 자식은 role="menuitem"이어야 합니다.
<li>는 메뉴 컨텍스트에서 불필요한 역할이므로 role="none"으로 제거하고,
실제 메뉴 항목 역할을 <a>나 <button>에 부여합니다.
다음 챕터에서는 데이터 테이블의 접근성을 다룹니다. 복잡한 표를 스크린리더 사용자가 이해할 수 있게 만드는 방법을 알아봅니다.