iBetter Books
수정

도입 스토리

"검색창에 자동완성이 뜨는데, 스크린리더가 목록이 열렸는지 모르는 것 같아요." 김개발이 말했습니다.

스크린리더로 검색창에 텍스트를 입력하면 시각적으로 드롭다운이 나타났지만, 스크린리더는 아무 말도 하지 않았습니다. 키보드로 드롭다운을 탐색하려면 어떻게 해야 하는지도 알 수 없었습니다.

"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>에 부여합니다.

다음 챕터에서는 데이터 테이블의 접근성을 다룹니다. 복잡한 표를 스크린리더 사용자가 이해할 수 있게 만드는 방법을 알아봅니다.