ARIA 접근성 가이드

컴포넌트 패턴

콤보박스(자동완성)

입력란에 글자를 치면 아래에 추천 목록이 뜨고, 화살표로 골라 값을 채우는 패턴입니다. 포커스는 입력란에 둔 채 aria-activedescendant로 목록의 현재 항목을 알리고, 결과 건수는 라이브 리전으로 안내합니다.

언제 쓰나요

콤보박스(combobox)는 입력란 + 팝업 목록이 결합한 위젯입니다. 이 페이지는 가장 흔한 형태인 목록 자동완성(aria-autocomplete="list")을 다룹니다. 사용자가 글자를 입력하면 일치하는 후보가 목록으로 뜨고, 화살표로 골라 입력란을 채웁니다. 도시·주소 검색, 상품 검색어 추천, 받는 사람 선택처럼 후보가 많아 다 보여 주기 어려울 때 씁니다.

  • 후보가 적고 직접 입력할 필요가 없다면 네이티브 <select>가 가장 안정적입니다.
  • 간단한 추천만 필요하다면 <input list="…"> + <datalist>를 검토하세요. 스크립트 없이 추천 목록을 주지만, 모양을 바꿀 수 없고 브라우저·화면낭독기마다 동작 차이가 큽니다.
  • 목록에서 여러 개를 고르거나, 항목 안에 버튼 등 다른 컨트롤이 들어가야 한다면 콤보박스가 아니라 다른 구성(검색 + 리스트박스, 그리드 팝업 등)을 검토합니다.
  • 콤보박스는 가장 구현이 까다로운 패턴 중 하나입니다. 검증된 라이브러리나 디자인 시스템 컴포넌트가 있다면 그것을 먼저 쓰고, 아래 체크리스트로 점검하세요.

구조

콤보박스 구조
label#city-label  "도시"
input[role=combobox]                                ← DOM 포커스는 항상 여기
  aria-autocomplete="list"
  aria-expanded="true"
  aria-controls="city-list"         ─────┐
  aria-activedescendant="opt-1"     ──┐  │
ul#city-list[role=listbox]  ◀─────────┼──┘  aria-labelledby="city-label"
  li#opt-0[role=option]  부산          │
  li#opt-1[role=option]  부천  aria-selected="true"  ◀── 가상 포커스
p[role=status]  "추천 도시 2개가 있습니다."   ← 결과 건수 안내
구조
요소역할속성
입력란comboboxaria-autocomplete, aria-expanded, aria-controls, aria-activedescendant, <label>로 이름
추천 목록listboxaria-labelledby 또는 aria-label
추천 항목option고유한 id, 가상 포커스가 있으면 aria-selected="true"
결과 건수status (라이브 리전)aria-live="polite"가 암묵적으로 들어 있음

ARIA 1.2의 콤보박스

ARIA 1.1까지는 입력란을 감싼 컨테이너에 role="combobox"를 주고 aria-owns로 목록을 연결했습니다. ARIA 1.2부터는 입력란 자체가 combobox이고, 목록은 aria-controls로 연결합니다. 오래된 예제나 라이브러리의 구조를 그대로 따라 하지 않도록 주의하세요.

키보드 동작

입력란 (목록이 닫혀 있거나 가상 포커스가 입력란에 있을 때)

입력란 (목록이 닫혀 있거나 가상 포커스가 입력란에 있을 때)
키동작
↓목록이 닫혀 있으면 열고 첫 항목에 가상 포커스를 둡니다. 열려 있으면 다음 항목으로 이동합니다.
↑ (선택)목록이 닫혀 있으면 열고 마지막 항목에 가상 포커스를 둡니다.
Alt + ↓ (선택)가상 포커스를 옮기지 않고 목록만 엽니다.
Esc목록이 열려 있으면 닫습니다. 닫혀 있으면 입력란을 비울 수 있습니다(선택).
문자 입력입력란에 글자를 입력하고, 그에 맞게 목록을 다시 거릅니다.
Tab목록을 닫고 다음 요소로 이동합니다. 목록 항목은 Tab 순서에 들어가지 않습니다.

목록에 가상 포커스가 있을 때

목록에 가상 포커스가 있을 때
키동작
↓ / ↑다음·이전 항목으로 가상 포커스를 옮깁니다. 양 끝에서는 반대쪽 끝으로 순환합니다.
Enter가상 포커스가 있는 항목으로 입력란을 채우고 목록을 닫습니다.
Esc목록을 닫고 가상 포커스를 입력란으로 되돌립니다.
Alt + ↑ (선택)가상 포커스가 있는 항목을 고르고 목록을 닫습니다.
← / → / Home / End가상 포커스를 입력란으로 되돌리고, 입력란 안에서 커서를 움직입니다.
문자 입력가상 포커스를 입력란으로 되돌리고 글자를 입력합니다.

한글 입력(IME) 조합 중의 키 처리

한글은 자모를 조합하는 동안에도 keydown이 발생합니다. 조합 중에 Enter나 ↓를 처리하면 마지막 글자가 두 번 입력되거나 목록이 엉뚱하게 움직일 수 있습니다. 키 처리 첫 줄에서 event.isComposing(또는 keyCode === 229)이면 무시하도록 해 두세요. 아래 데모도 이렇게 처리합니다.

필요한 ARIA

combobox
입력란에 줍니다. 화면낭독기는 “콤보 상자” 또는 “편집 가능, 자동완성”처럼 안내해, 입력하면 추천이 뜬다는 사실을 미리 알립니다. 이름은 일반 입력란처럼 <label for>로 줍니다.
aria-autocomplete
자동완성 방식입니다. list는 “입력에 맞는 후보 목록을 보여 준다”, both는 “목록에 더해 입력란에 첫 후보를 미리 채워 넣는다(인라인 자동완성)”, none은 “입력과 상관없이 같은 목록”입니다. 실제 동작과 같은 값을 줍니다.
aria-expanded
목록이 보이면 true, 숨겨지면 false입니다. 콤보박스에서는 필수입니다.
aria-controls
목록(listbox)의 id를 가리킵니다. 목록이 보일 때는 반드시 있어야 합니다.
aria-activedescendant
DOM 포커스는 입력란에 그대로 두고, “지금 목록의 어느 항목을 보고 있는지”를 항목의 id로 알립니다. 그래서 사용자는 화살표로 항목을 훑다가 바로 글자를 이어서 입력할 수 있습니다. 가상 포커스가 입력란으로 돌아오면 속성을 제거합니다. 브라우저는 이 가상 포커스를 그려 주지 않으므로 CSS로 눈에 띄게 표시해야 합니다.
listbox · option · aria-selected
추천 목록과 각 항목입니다. 가상 포커스가 있는 항목에 aria-selected="true"를 줍니다(APG 예제와 같음). 목록에는 이름을 주어 “도시, 목록”처럼 안내되게 합니다.
status (aria-live)
“추천 도시 3개가 있습니다”처럼 결과 건수를 알리는 라이브 리전입니다. 일부 화면낭독기는 목록이 떴다는 사실을 따로 알리지 않으므로, 결과 건수 안내가 있으면 목록이 있다는 것을 알 수 있습니다. 글자를 칠 때마다 말하면 입력을 방해하므로 입력이 멈춘 뒤(약 0.5~1초) 한 번만 갱신합니다. 라이브 리전 요소는 처음부터 페이지에 있어야 합니다. (라이브 리전 참고)

직접 해 보기

“부”, “서”, “안”처럼 첫 글자를 입력한 뒤 ↓ ↑로 이동하고 Enter로 골라 보세요. 빈 입력란에서 ↓를 누르면 전체 목록이 열리고, Esc를 한 번 누르면 목록이 닫히며 한 번 더 누르면 입력란이 비워집니다.

잘못된 구현

평범한 입력란 아래에 div 목록을 띄웠습니다. 마우스로만 고를 수 있고, 화면낭독기 사용자는 목록이 떴는지, 몇 개인지, 무엇이 있는지 알 수 없습니다.

올바른 구현

화살표를 누를 때 입력란의 aria-activedescendant와 항목의 aria-selected가 함께 바뀌는 것을 실시간 코드에서 확인해 보세요. 아래 문장은 role="status"라 화면낭독기가 결과 건수를 읽어 줍니다.

전체 코드

HTML
<label for="city" id="city-label">도시</label>
<div class="combobox">
  <input type="text" id="city" role="combobox"
         aria-autocomplete="list" aria-expanded="false" aria-controls="city-list"
         autocomplete="off">
  <ul role="listbox" id="city-list" aria-labelledby="city-label" hidden></ul>
</div>
<p id="city-status" role="status"></p>

<script>
  initCombobox(
    document.getElementById('city'),
    document.getElementById('city-list'),
    document.getElementById('city-status'),
    ['서울', '부산', '대구', '인천', '광주', '대전', '울산', '세종']
  );
</script>
JavaScript
// 목록 자동완성 콤보박스 (aria-autocomplete="list")
// DOM 포커스는 항상 입력란에 두고, 목록의 "가상 포커스"는 aria-activedescendant로 알립니다.
function initCombobox(input, listbox, status, data) {
  var options = [];      // 현재 보이는 option 요소들
  var active = -1;       // 가상 포커스가 있는 option 번호 (-1 = 입력란)
  var announceTimer = null;

  function matches(q) {
    if (!q) return data.slice();
    return data.filter(function (d) { return d.indexOf(q) === 0; });   // 앞부분 일치
  }

  function render(list) {
    listbox.innerHTML = '';
    options = list.map(function (text, i) {
      var li = document.createElement('li');
      li.setAttribute('role', 'option');
      li.id = listbox.id + '-opt-' + i;
      li.textContent = text;
      listbox.appendChild(li);
      return li;
    });
    active = -1;
    input.removeAttribute('aria-activedescendant');
  }

  function isOpen() { return input.getAttribute('aria-expanded') === 'true'; }
  function open() {
    if (!options.length) return;
    listbox.hidden = false;
    input.setAttribute('aria-expanded', 'true');
  }
  function close() {
    listbox.hidden = true;
    input.setAttribute('aria-expanded', 'false');
    setActive(-1);
  }

  function setActive(i) {
    if (active > -1 && options[active]) options[active].removeAttribute('aria-selected');
    active = i;
    if (i > -1) {
      options[i].setAttribute('aria-selected', 'true');
      input.setAttribute('aria-activedescendant', options[i].id);
      options[i].scrollIntoView({ block: 'nearest' });
    } else {
      input.removeAttribute('aria-activedescendant');
    }
  }

  function choose(i) {
    input.value = options[i].textContent;
    close();
    input.focus();
  }

  // 결과 건수: 입력이 멈춘 뒤 한 번만 알립니다 (입력할 때마다 말하면 방해가 됩니다).
  function announce(count) {
    clearTimeout(announceTimer);
    announceTimer = setTimeout(function () {
      status.textContent = count
        ? '추천 도시 ' + count + '개가 있습니다. 위아래 화살표로 고르세요.'
        : '일치하는 도시가 없습니다.';
    }, 700);
  }

  function update(showAllWhenEmpty) {
    var q = input.value.trim();
    if (!q && !showAllWhenEmpty) { render([]); close(); status.textContent = ''; return; }
    var list = matches(q);
    render(list);
    if (list.length) open(); else close();
    announce(list.length);
  }

  input.addEventListener('input', function () { update(false); });

  input.addEventListener('keydown', function (e) {
    if (e.isComposing || e.keyCode === 229) return;   // 한글 조합 중인 키는 무시
    var n = options.length;
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        if (!isOpen()) { update(true); if (!e.altKey && options.length) setActive(0); }
        else if (!e.altKey) setActive((active + 1) % n);
        break;
      case 'ArrowUp':
        e.preventDefault();
        if (e.altKey) { if (active > -1) choose(active); else close(); }
        else if (!isOpen()) { update(true); if (options.length) setActive(options.length - 1); }
        else setActive(active <= 0 ? n - 1 : active - 1);
        break;
      case 'Enter':
        if (isOpen() && active > -1) { e.preventDefault(); choose(active); }
        break;
      case 'Escape':
        e.preventDefault();
        e.stopPropagation();
        if (isOpen()) close(); else { input.value = ''; render([]); status.textContent = ''; }
        break;
      case 'ArrowLeft': case 'ArrowRight': case 'Home': case 'End':
        setActive(-1);     // 가상 포커스를 입력란으로 되돌리고, 커서는 기본 동작대로 이동
        break;
      case 'Tab':
        close();
        break;
    }
  });

  // 마우스: mousedown 기본 동작을 막아 입력란의 포커스를 유지한 채 고릅니다.
  listbox.addEventListener('mousedown', function (e) { e.preventDefault(); });
  listbox.addEventListener('click', function (e) {
    var li = e.target.closest('[role="option"]');
    if (li) choose(options.indexOf(li));
  });
  input.addEventListener('blur', function () { close(); });
}
CSS — 가상 포커스 표시
/* 가상 포커스(aria-activedescendant)는 브라우저가 표시해 주지 않으므로 직접 그립니다 */
[role="option"][aria-selected="true"] {
  outline: 2px solid currentColor;
  outline-offset: -2px;
  background: Highlight;
  color: HighlightText;
}
[role="listbox"] { max-height: 14rem; overflow-y: auto; }
비권장비권장 — ARIA 1.1 방식 (컨테이너가 combobox)
<div role="combobox" aria-owns="list" aria-expanded="true">
  <input type="text">
</div>
<ul id="list" role="listbox">…</ul>
권장권장 — ARIA 1.2 방식 (입력란이 combobox)
<input type="text" role="combobox" aria-autocomplete="list"
       aria-expanded="true" aria-controls="list" aria-activedescendant="opt-1">
<ul id="list" role="listbox">…</ul>

화면낭독기에서는

화면낭독기 안내 예시 (표현은 화면낭독기·브라우저·버전에 따라 조금씩 다릅니다)
상황들리는 안내 (예)
입력란에 포커스도시, 콤보 상자, 축소됨, 편집 가능, 자동완성 (구성은 화면낭독기마다 다름)
“부” 입력 후 잠시 멈춤추천 도시 2개가 있습니다. 위아래 화살표로 고르세요.
↓로 첫 항목부산, 1/2
↓로 다음 항목부천, 2/2
Enter로 선택도시, 콤보 상자, 축소됨, 부천
일치하는 후보가 없을 때일치하는 도시가 없습니다.
잘못된 구현에서 입력(입력한 글자만 들리고 목록 안내 없음)

점검 체크리스트

  • 입력란에 role="combobox"와 보이는 레이블(<label>)이 있습니다.
  • aria-autocomplete 값이 실제 동작(list/both/none)과 같습니다.
  • 목록이 보일 때 aria-expanded="true"이고, aria-controls가 목록 id를 가리킵니다.
  • ↓ ↑로 항목을 이동하면 aria-activedescendant가 해당 항목 id로 바뀝니다.
  • 가상 포커스가 있는 항목에 aria-selected="true"와 눈에 띄는 시각 표시가 있습니다.
  • Enter로 고르고, Esc로 닫히며, 포커스는 계속 입력란에 있습니다.
  • 결과 건수(또는 결과 없음)가 라이브 리전으로, 입력이 멈춘 뒤 한 번만 안내됩니다.
  • 한글 조합 중의 키 입력(isComposing)은 무시합니다.
  • 마우스로 항목을 눌러도 입력란 포커스가 유지되고 값이 채워집니다.
  • 브라우저 자동완성이 겹치지 않도록 autocomplete="off"를 주었습니다(개인정보 입력란이면 알맞은 토큰 검토).
  • 목록이 입력란을 따라 스크롤되고, 가상 포커스 항목이 목록 영역 밖으로 가려지지 않습니다.

관련 WCAG 성공 기준

참고 자료