컴포넌트 패턴
콤보박스(자동완성)
입력란에 글자를 치면 아래에 추천 목록이 뜨고, 화살표로 골라 값을 채우는 패턴입니다. 포커스는 입력란에 둔 채 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개가 있습니다." ← 결과 건수 안내| 요소 | 역할 | 속성 |
|---|---|---|
| 입력란 | combobox | aria-autocomplete, aria-expanded, aria-controls, aria-activedescendant, <label>로 이름 |
| 추천 목록 | listbox | aria-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"라 화면낭독기가 결과 건수를 읽어 줍니다.
전체 코드
<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>// 목록 자동완성 콤보박스 (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(); });
}/* 가상 포커스(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; }<div role="combobox" aria-owns="list" aria-expanded="true">
<input type="text">
</div>
<ul id="list" role="listbox">…</ul><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 성공 기준
- 1.3.1 정보와 관계 (A) — 입력란·목록·항목의 관계가 역할과 속성으로 표현되어야 합니다.
- 2.1.1 키보드 (A) — 후보를 키보드만으로 탐색하고 고를 수 있어야 합니다.
- 2.4.7 포커스 표시 (AA) — aria-activedescendant로 가리킨 항목이 눈에 보여야 합니다.
- 3.2.2 입력 시 (A) — 항목을 훑는 것만으로 폼이 제출되거나 페이지가 바뀌면 안 됩니다.
- 4.1.2 이름, 역할, 값 (A) — 콤보박스의 이름, 펼침 상태, 현재 항목이 전달되어야 합니다.
- 4.1.3 상태 메시지 (AA) — 결과 건수 같은 상태 변화를 포커스 이동 없이 알려야 합니다.