← 中文资源

简体中文版本

使用 aria-sort 的无障碍可排序表格

用表头中的真实按钮排序数据,始终保持单一 aria-sort,并通过状态区域播报排序字段与方向。

阅读英文原文

功能说明

发布记录表包含版本、发布日期与下载量三列,每列表头内放一个按钮。点击按钮按对应列排序,再点同一列切换升降序。表头通过 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>