功能说明
发布记录表包含版本、发布日期与下载量三列,每列表头内放一个按钮。点击按钮按对应列排序,再点同一列切换升降序。表头通过 aria-sort 表示当前排序状态,非活动列设为 none;可视箭头仅作装饰并加 aria-hidden。排序完成后,role=“status” 区域播报“按某列升序/降序排列”,表格本身在无 JavaScript 时仍按初始顺序可读。
关键代码
每个 th 使用 scope=“col”,caption 描述表格内容。按钮而不是可点击的 th 承担交互,天然支持键盘与焦点样式。排序值优先读取 td 的 data-value,日期用 ISO 字符串、数字用纯数字、版本用文本数字排序,避免展示格式影响比较。localeCompare 配合 numeric 让 1.4.0 排在 1.10.0 之前。交换行使用 tbody.append(…rows),不会重建节点或丢失事件。
使用注意
aria-sort 只应出现在当前排序列,其他列用 none;不要给所有列同时设置 ascending。视觉箭头必须与 aria-sort 同步,否则读屏器与视觉用户得到矛盾信息。示例在前端排序当前三行,真实分页表格应由服务端排序并返回同一顺序。排序应保持稳定,现代浏览器的 Array.sort 已稳定,但旧环境需确认。排序列的按钮名称要包含方向提示或依赖 aria-sort。
验证方式
用 Tab 移动到版本按钮并按回车,行顺序应变化且箭头更新;再次按回车切换为降序。检查 DOM 中只有当前 th 的 aria-sort 为 ascending 或 descending。点击发布日期与下载量,确认按日期和数值而非字符串排序,状态区域文本同步更新。关闭 JavaScript 后表格仍显示完整数据,只是不能排序。
代码示例
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sortable release history</title>
<style>
body {
margin: 0;
padding: 2rem;
font-family: system-ui, sans-serif;
}
table {
border-collapse: collapse;
width: 100%;
max-width: 44rem;
}
caption {
text-align: left;
font-weight: 700;
padding-bottom: 0.75rem;
}
th,
td {
padding: 0.6rem 0.75rem;
border-bottom: 1px solid gray;
text-align: left;
}
th button {
font: inherit;
font-weight: 700;
border: 0;
background: none;
color: inherit;
padding: 0;
cursor: pointer;
}
th button:focus-visible {
outline: 3px solid #4f46e5;
outline-offset: 3px;
}
.indicator {
margin-left: 0.35rem;
}
</style>
</head>
<body>
<main>
<h1>Release history</h1>
<table id="releases">
<caption>Atlas API releases, sortable by version, date, or downloads</caption>
<thead>
<tr>
<th scope="col" aria-sort="ascending">
<button type="button" data-type="text" data-label="version">
Version <span class="indicator" aria-hidden="true">▲</span>
</button>
</th>
<th scope="col" aria-sort="none">
<button type="button" data-type="date" data-label="release date">
Released <span class="indicator" aria-hidden="true">↕</span>
</button>
</th>
<th scope="col" aria-sort="none">
<button type="button" data-type="number" data-label="downloads">
Downloads <span class="indicator" aria-hidden="true">↕</span>
</button>
</th>
</tr>
</thead>
<tbody>
<tr>
<td data-value="1.4.0">v1.4.0</td>
<td data-value="2026-03-04">4 Mar 2026</td>
<td data-value="980">980</td>
</tr>
<tr>
<td data-value="1.9.2">v1.9.2</td>
<td data-value="2026-08-01">1 Aug 2026</td>
<td data-value="301">301</td>
</tr>
<tr>
<td data-value="1.10.0">v1.10.0</td>
<td data-value="2026-09-12">12 Sep 2026</td>
<td data-value="142">142</td>
</tr>
</tbody>
</table>
<p id="sort-status" role="status" aria-live="polite"></p>
</main>
<script>
const table = document.querySelector('#releases');
const tbody = table.tBodies[0];
const status = document.querySelector('#sort-status');
const headers = [...table.querySelectorAll('th')];
let activeHeader = headers[0];
let direction = 'ascending';
function cellValue(row, index, type) {
const cell = row.cells[index];
const raw = cell.dataset.value ?? cell.textContent.trim();
if (type === 'number') return Number(raw);
return raw;
}
function updateIndicators() {
for (const header of headers) {
const indicator = header.querySelector('.indicator');
if (!indicator) continue;
if (header === activeHeader) {
indicator.textContent = direction === 'ascending' ? '▲' : '▼';
} else {
indicator.textContent = '↕';
}
}
}
for (const header of headers) {
const button = header.querySelector('button');
if (!button) continue;
button.addEventListener('click', () => {
const columnIndex = header.cellIndex;
const type = button.dataset.type;
direction = header === activeHeader && direction === 'ascending'
? 'descending'
: 'ascending';
for (const candidate of headers) {
candidate.setAttribute('aria-sort', candidate === header ? direction : 'none');
}
activeHeader = header;
updateIndicators();
const rows = [...tbody.rows];
rows.sort((left, right) => {
const a = cellValue(left, columnIndex, type);
const b = cellValue(right, columnIndex, type);
let result;
if (type === 'number') {
result = a - b;
} else if (type === 'date') {
result = String(a).localeCompare(String(b));
} else {
result = String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
}
return direction === 'ascending' ? result : -result;
});
tbody.append(...rows);
status.textContent = 'Sorted by ' + button.dataset.label + ' ' + direction + '.';
});
}
</script>
</body>
</html>