컴포넌트 패턴
정렬 가능한 표
<table>, <caption>, <th scope>로 데이터 표의 구조를 전달하고, 열 머리글 안의 정렬 버튼과 aria-sort로 현재 정렬 상태를 알리는 방법입니다.
언제 쓰나요
행과 열로 이루어진 데이터를 보여 줄 때 표를 씁니다. 사용자가 값을 비교하려고 특정 열을 기준으로 다시 늘어놓을 수 있게 하려면 정렬 기능을 더합니다.
네이티브 표를 먼저 쓰세요
<table>, <caption>, <thead>, <th>, <td>는 각각 table, caption, rowgroup, columnheader·rowheader, cell 역할을 이미 가지고 있습니다. 화면낭독기는 이 구조를 이용해 셀을 옮길 때마다 해당 행·열 머리글을 함께 읽어 줍니다. div에 ARIA 표 역할을 일일이 붙이는 방식은 CSS 그리드 등으로 어쩔 수 없을 때만 씁니다.
다음 경우에는 다른 방법이 알맞습니다.
- 화면 배치(레이아웃)를 위해서는 표를 쓰지 않습니다. 레이아웃은 CSS로 만듭니다. 이미 레이아웃 표가 있다면
role="presentation"으로 표 의미를 없앱니다. - 셀 사이를 화살표 키로 이동하며 편집·선택하는 스프레드시트형 위젯이라면
grid패턴이 필요합니다. 정렬만 하는 표는 grid가 아니라 일반 표입니다.
구조
<caption>: 표의 제목입니다. 표의 접근 가능한 이름이 되고, 화면낭독기가 표에 들어갈 때 읽습니다. 정렬할 수 있다는 안내도 여기에 넣을 수 있습니다.<th scope="col">: 열 머리글(columnheader). 정렬 가능한 열은 머리글 안에<button>을 넣습니다.<th scope="row">: 행 머리글(rowheader). 각 행을 대표하는 값(예: 이름)을 행 머리글로 두면, 다른 열로 이동할 때 어떤 행인지 함께 들립니다.aria-sort: 현재 정렬 기준인 열 머리글(th) 하나에만ascending(오름차순) 또는descending(내림차순)을 붙입니다. 버튼이 아니라th에 붙입니다.- 정렬 방향을 나타내는 화살표 아이콘은 장식이므로
aria-hidden="true"로 숨깁니다. 방향 정보는aria-sort가 전달합니다.
<table>
<caption>도서 목록 <span class="sr-only">(열 머리글 버튼으로 정렬할 수 있습니다)</span></caption>
<thead>
<tr>
<th scope="col" aria-sort="ascending">
<button type="button">제목 <span aria-hidden="true">▲</span></button>
</th>
<th scope="col">
<button type="button">출간 연도 <span aria-hidden="true">◆</span></button>
</th>
</tr>
</thead>
<tbody>
<tr><th scope="row">가나다 이야기</th><td>2021</td></tr>
</tbody>
</table>정렬 버튼 이름에 상태를 넣지 마세요
버튼 이름은 열 이름(예: “제목”)으로 둡니다. “제목, 오름차순 정렬됨”처럼 상태를 이름에 섞으면 aria-sort와 중복되어 두 번 읽히거나, 버튼을 누를 때 일어날 일(다음 방향)과 현재 상태가 헷갈립니다.
키보드 동작
정렬 가능한 표는 표 자체에 특별한 키 조작이 없습니다. 정렬 버튼은 일반 버튼처럼 동작합니다. 셀 사이 이동은 화면낭독기의 표 탐색 기능(예: NVDA·JAWS의 Ctrl + Alt + 화살표)이 담당합니다.
| 키 | 동작 |
|---|---|
| Tab / Shift + Tab | 정렬 버튼 사이를 이동합니다. 표 셀은 Tab 순서에 들어가지 않습니다. |
| Enter / Space | 포커스된 열을 기준으로 정렬합니다. 같은 열을 다시 누르면 방향이 바뀝니다. |
셀에 tabindex를 주지 마세요
데이터 셀에 tabindex="0"을 주면 키보드 사용자가 셀마다 Tab을 눌러야 해서 오히려 불편합니다. 화면낭독기 사용자는 표 탐색 기능으로 셀을 읽습니다. 셀 단위 포커스 이동이 꼭 필요하다면 grid 패턴을 씁니다.
필요한 ARIA
| 역할·속성 | 붙이는 곳 | 설명 |
|---|---|---|
table 등 표 역할 | - | 네이티브 표 요소가 이미 가지고 있으므로 따로 쓰지 않습니다. |
aria-sort | 정렬 기준 열의 th | ascending / descending. 한 번에 한 머리글에만 둡니다. 정렬 기준이 아닌 열은 속성을 빼는 것이 좋습니다. other는 오름·내림이 아닌 정렬 방식일 때 씁니다. |
button | 머리글 안 정렬 컨트롤 | 네이티브 <button>을 씁니다. 이름은 열 이름입니다. |
aria-live 또는 status | 표 밖의 상태 메시지 영역 | 선택 사항. 정렬 후 “제목 기준 오름차순으로 정렬했습니다”를 알립니다. 일부 화면낭독기는 aria-sort 변경을 바로 읽지 않으므로 보완 수단으로 씁니다. |
aria-describedby | table | 선택 사항. 표 아래의 주석이나 정렬 안내 문장을 연결합니다. |
직접 해 보기
위는 div로 흉내 낸 잘못된 구현이고, 아래는 네이티브 표에 정렬 버튼을 넣은 올바른 구현입니다. 아래 표에서 Tab으로 머리글 버튼에 이동해 Enter를 눌러 보세요. 실시간 코드에서 aria-sort가 옮겨 가는 모습을 볼 수 있습니다.
잘못된 구현: div로 만든 표
머리글을 마우스로 클릭하면 정렬되지만, 키보드로는 닿을 수 없고 표 구조와 정렬 상태가 전달되지 않습니다.
올바른 구현
| 가을 정원 | 2023 | 14,500 |
|---|---|---|
| 나무의 시간 | 2021 | 18,000 |
| 파도의 기록 | 2019 | 16,000 |
실시간 코드
전체 코드
<table>
<tr>
<td onclick="sortBy(0)"><b>제목 ▲</b></td>
<td onclick="sortBy(1)"><b>출간 연도</b></td>
</tr>
<tr><td>가을 정원</td><td>2023</td></tr>
</table><table>
<caption>도서 목록</caption>
<thead>
<tr>
<th scope="col" aria-sort="ascending">
<button type="button">제목<span aria-hidden="true">▲</span></button>
</th>
<th scope="col">
<button type="button">출간 연도<span aria-hidden="true"></span></button>
</th>
</tr>
</thead>
<tbody>
<tr><th scope="row">가을 정원</th><td>2023</td></tr>
</tbody>
</table><table id="books">
<caption>도서 목록 <span class="sr-only">(열 머리글 버튼으로 정렬할 수 있습니다)</span></caption>
<thead>
<tr>
<th scope="col" aria-sort="ascending">
<button type="button" data-type="text">제목<span class="icon" aria-hidden="true"></span></button>
</th>
<th scope="col">
<button type="button" data-type="number">출간 연도<span class="icon" aria-hidden="true"></span></button>
</th>
<th scope="col">
<button type="button" data-type="number">가격(원)<span class="icon" aria-hidden="true"></span></button>
</th>
</tr>
</thead>
<tbody>
<tr><th scope="row">가을 정원</th><td>2023</td><td data-value="14500">14,500</td></tr>
<tr><th scope="row">나무의 시간</th><td>2021</td><td data-value="18000">18,000</td></tr>
<tr><th scope="row">파도의 기록</th><td>2019</td><td data-value="16000">16,000</td></tr>
</tbody>
</table>
<p class="sr-only" role="status" id="sort-status"></p>var table = document.getElementById('books');
var status = document.getElementById('sort-status');
var tbody = table.tBodies[0];
var headers = table.querySelectorAll('thead th');
var LABEL = { ascending: '오름차순', descending: '내림차순' };
function value(row, i, type) {
var cell = row.children[i];
var v = cell.getAttribute('data-value') || cell.textContent.trim();
return type === 'number' ? Number(v) : v;
}
function sortBy(i) {
var th = headers[i];
var btn = th.querySelector('button');
var type = btn.dataset.type;
var dir = th.getAttribute('aria-sort') === 'ascending' ? 'descending' : 'ascending';
var rows = Array.from(tbody.rows).sort(function (a, b) {
var x = value(a, i, type), y = value(b, i, type);
var r = type === 'number' ? x - y : String(x).localeCompare(String(y), 'ko');
return dir === 'ascending' ? r : -r;
});
rows.forEach(function (row) { tbody.appendChild(row); });
// aria-sort는 정렬 기준 열 하나에만
headers.forEach(function (h) { h.removeAttribute('aria-sort'); });
th.setAttribute('aria-sort', dir);
// aria-sort 변경을 바로 읽지 않는 환경을 위한 보완 안내
status.textContent = btn.textContent.trim() + ' 기준 ' + LABEL[dir] + '으로 정렬했습니다.';
}
headers.forEach(function (th, i) {
var btn = th.querySelector('button');
if (btn) btn.addEventListener('click', function () { sortBy(i); });
});th[aria-sort="ascending"] .icon::before { content: "▲"; }
th[aria-sort="descending"] .icon::before { content: "▼"; }
th:not([aria-sort]) .icon::before { content: "↕"; opacity: .45; }정렬 표시를 aria-sort 속성 선택자로 그리면 화면 표시와 보조기술에 전달되는 상태가 어긋날 일이 없습니다.
화면낭독기에서는
| 상황 | 들리는 안내 (예) |
|---|---|
| 표로 이동 | 도서 목록 (열 머리글 버튼으로 정렬할 수 있습니다), 표, 4행 3열 |
| 정렬 기준 열의 버튼에 포커스 | 제목, 열 머리글, 오름차순 정렬됨, 버튼 |
| “출간 연도” 버튼을 눌러 정렬 | 출간 연도 기준 오름차순으로 정렬했습니다.(상태 메시지) |
| 표 탐색으로 “가격” 열의 셀로 이동 | 가격(원), 나무의 시간, 18,000(열·행 머리글과 함께 읽힘) |
| 잘못된 구현(div)에 도달 | 제목 출간 연도 가격(원) 파도의 기록 2019 16000 …(표 구조 없이 텍스트만 이어서 읽힘) |
점검 체크리스트
- 데이터 표를
<table>로 만들었고, 레이아웃에는 표를 쓰지 않았습니다. <caption>으로 표 제목을 주었습니다.- 열 머리글은
<th scope="col">, 행 머리글은<th scope="row">입니다. - 정렬 컨트롤은
th안의<button>이며, 이름은 열 이름입니다. aria-sort는 현재 정렬 기준인th하나에만 있고, 정렬할 때마다 옮겨집니다.- 정렬 방향 아이콘은
aria-hidden="true"이고, 색이 아닌 모양으로도 방향을 구분할 수 있습니다. - 정렬 후 포커스가 누른 버튼에 그대로 남습니다.
- (선택) 정렬 결과를 상태 메시지로 알립니다.
- 좁은 화면에서도 표가 가로 스크롤 영역 안에서 읽힙니다(WCAG 1.4.10).
관련 WCAG 성공 기준
- 1.3.1 정보와 관계 (A): 머리글과 데이터 셀의 관계를 마크업으로 전달합니다.
- 1.3.2 의미 있는 순서 (A)
- 1.4.10 리플로우 (AA)
- 2.1.1 키보드 (A): 키보드로 정렬할 수 있습니다.
- 4.1.2 이름, 역할, 값 (A): 정렬 상태가 보조기술에 전달됩니다.
- 4.1.3 상태 메시지 (AA): 정렬 결과 안내를 포커스 이동 없이 전달합니다.