用 aria-activedescendant 实现可访问的自动完成下拉框

举报
yd_229231784 发表于 2026/11/01 01:29:26 2026/11/01
【摘要】 本文用 aria-activedescendant 让焦点留在输入框,方向键更新高亮项,Enter 选中并同步 aria-expanded,Esc 关闭并清空高亮,给出最小可运行示例与边界处理。

问题场景

自动完成下拉框常见的实现是:用户输入后,把焦点移到第一个候选项,再用上下键在选项之间移动。这个方案对键盘操作看似可用,但对屏幕阅读器和开关控制用户不友好:焦点离开输入框后,用户可能失去当前输入上下文,读屏软件也会把正在输入的文本框切换成列表项,导致体验跳跃。WAI-ARIA 的 combobox 模式推荐另一种方式:焦点始终留在输入框,用 aria-activedescendant 指向列表中的活动项。方向键只更新活动项,不移动 DOM 焦点。

本文给出一个不依赖框架的最小实现:输入框保持焦点,列表用 listbox 语义,方向键更新高亮项,Enter 选中并关闭,Esc 关闭并清空高亮,同时维护 aria-expanded 与 aria-activedescendant。示例数据使用占位城市名,便于替换。

ARIA 约定

实现前先明确角色和属性:

  • 输入框:role=combobox、aria-autocomplete=list、aria-controls 指向列表、aria-expanded 表示列表是否展开、aria-activedescendant 指向当前活动选项的 id;aria-haspopup=listbox 可声明弹出内容类型。
  • 列表:role=listbox,每个选项 role=option,并具有唯一 id。选项不要设置 tabindex=0,否则可能被 Tab 键聚焦,违背焦点留在输入框的目标。
  • 选中与活动项是两件事:aria-activedescendant 表示当前活动项,视觉高亮与 aria-selected 可用来辅助表达。本文的单选自动完成中,把当前高亮项设为 aria-selected=true,其余为 false。如果需要多选,应改用支持多选的模式。
  • aria-expanded 必须在打开和关闭时同步更新为字符串 true 或 false,不要只改视觉样式。

最小可运行示例

将下面代码保存为 autocomplete.html,直接用浏览器打开。无构建步骤和外部依赖。

<!doctype html>
<html lang='zh-CN'>
<head>
  <meta charset='utf-8'>
  <title>自动完成示例</title>
  <style>
    body { font-family: sans-serif; padding: 24px; }
    .combo { position: relative; width: 260px; }
    #city-input { width: 100%; box-sizing: border-box; padding: 8px; }
    #city-listbox {
      position: absolute;
      left: 0;
      right: 0;
      margin: 4px 0 0;
      padding: 4px 0;
      list-style: none;
      border: 1px solid #ccc;
      background: #fff;
      max-height: 180px;
      overflow: auto;
    }
    #city-listbox [role=option] { padding: 8px 12px; cursor: pointer; }
    #city-listbox [aria-selected=true] { background: #e8f0fe; }
  </style>
</head>
<body>
  <label for='city-input'>选择城市</label>
  <div class='combo'>
    <input id='city-input' type='text' role='combobox' aria-autocomplete='list' aria-expanded='false' aria-controls='city-listbox' aria-haspopup='listbox' autocomplete='off'>
    <ul id='city-listbox' role='listbox' aria-label='城市建议' hidden></ul>
  </div>

  <script>
    (() => {
      const input = document.getElementById('city-input');
      const listbox = document.getElementById('city-listbox');
      const data = ['北京', '上海', '广州', '深圳', '杭州', '成都', '武汉', '西安'];
      let filtered = [];
      let activeIndex = -1;
      let composing = false;

      function isOpen() {
        return input.getAttribute('aria-expanded') === 'true';
      }

      function open() {
        input.setAttribute('aria-expanded', 'true');
        listbox.hidden = false;
      }

      function close() {
        input.setAttribute('aria-expanded', 'false');
        listbox.hidden = true;
        clearActive();
      }

      function clearActive() {
        activeIndex = -1;
        input.removeAttribute('aria-activedescendant');
        listbox.querySelectorAll('[aria-selected=true]').forEach((el) => {
          el.setAttribute('aria-selected', 'false');
        });
      }

      function setActive(index) {
        if (!filtered.length) {
          clearActive();
          return;
        }
        activeIndex = Math.max(0, Math.min(index, filtered.length - 1));
        const options = listbox.querySelectorAll('[role=option]');
        options.forEach((el, i) => {
          el.setAttribute('aria-selected', i === activeIndex ? 'true' : 'false');
        });
        const el = options[activeIndex];
        input.setAttribute('aria-activedescendant', el.id);
        if (!isOpen()) open();
        el.scrollIntoView({ block: 'nearest' });
      }

      function render(query) {
        const q = query.trim().toLowerCase();
        filtered = q ? data.filter((item) => item.toLowerCase().includes(q)) : data.slice();
        listbox.textContent = '';
        clearActive();
        if (!filtered.length) {
          close();
          return;
        }
        filtered.forEach((item, index) => {
          const li = document.createElement('li');
          li.id = 'city-option-' + index;
          li.setAttribute('role', 'option');
          li.setAttribute('aria-selected', 'false');
          li.dataset.value = item;
          li.textContent = item;
          li.addEventListener('mousedown', (event) => event.preventDefault());
          li.addEventListener('click', () => select(index));
          listbox.appendChild(li);
        });
        open();
      }

      function select(index) {
        if (index < 0 || index >= filtered.length) return;
        input.value = filtered[index];
        close();
        input.focus();
      }

      input.addEventListener('compositionstart', () => {
        composing = true;
      });

      input.addEventListener('compositionend', () => {
        composing = false;
        render(input.value);
      });

      input.addEventListener('input', () => {
        if (composing) return;
        render(input.value);
      });

      input.addEventListener('keydown', (event) => {
        if (event.isComposing) return;
        const key = event.key;

        if (key === 'ArrowDown') {
          event.preventDefault();
          if (!isOpen()) render(input.value);
          if (filtered.length) {
            setActive(activeIndex < 0 ? 0 : Math.min(activeIndex + 1, filtered.length - 1));
          }
          return;
        }

        if (key === 'ArrowUp') {
          event.preventDefault();
          if (!isOpen()) render(input.value);
          if (filtered.length) {
            setActive(activeIndex < 0 ? filtered.length - 1 : Math.max(activeIndex - 1, 0));
          }
          return;
        }

        if (key === 'Enter') {
          if (isOpen() && activeIndex >= 0) {
            event.preventDefault();
            select(activeIndex);
          } else if (isOpen()) {
            event.preventDefault();
            close();
          }
          return;
        }

        if (key === 'Escape') {
          if (isOpen()) {
            event.preventDefault();
            close();
          }
          return;
        }

        if (key === 'Tab') close();
      });

      document.addEventListener('click', (event) => {
        if (!input.contains(event.target) && !listbox.contains(event.target)) close();
      });
    })();
  </script>
</body>
</html>

代码中的关键点:

  • render 负责按输入过滤并重建选项。每次重建后清空活动项,避免 aria-activedescendant 指向已删除的 id。
  • setActive 只更新 id、aria-selected 和滚动位置,不调用 focus()。
  • 选项的 mousedown 事件调用 preventDefault,防止鼠标点击时焦点从输入框转移到列表项;点击后再执行选中。
  • 输入法组合期间用 compositionstart / compositionend 标记,避免中文输入过程中因 input 事件频繁过滤或 Enter 误选。
  • Enter 仅在列表展开且存在活动项时拦截默认行为并选中;列表关闭时不拦截,避免影响表单默认提交。Esc 仅在展开时拦截,关闭并清空高亮,输入框保持焦点。
  • 方向键在列表关闭时会先渲染并打开列表,再设置第一项或最后一项;到达边界时使用 clamp,不循环。

运行方法

  1. 新建文本文件,粘贴上述 HTML,保存为 autocomplete.html。
  2. 用浏览器打开,点击输入框,输入“北”或“杭”等字符。
  3. 按 ArrowDown / ArrowUp 观察高亮项变化,焦点应始终在输入框。
  4. 按 Enter 选中当前高亮项,列表关闭,aria-expanded 变为 false。
  5. 按 Esc 关闭列表并清空高亮;清空输入后重新输入可再次打开。

可在开发者工具中选中输入框,观察 aria-expanded 和 aria-activedescendant 的变化,确认它们与视觉状态一致。

边界与限制

  • 屏幕阅读器对 aria-activedescendant 的支持和播报细节存在差异,发布前应使用目标读屏软件实际测试;ARIA 属性不能替代可理解的交互反馈。
  • aria-activedescendant 的值必须是当前 DOM 中存在的元素 id。若使用虚拟滚动,活动项必须已经渲染;否则应先渲染活动项,或改用其他可访问方案。
  • 列表重建会删除旧选项,必须同步清空活动项 id,避免悬空引用。异步搜索时还要处理过期响应,保证高亮项属于当前结果集。
  • 移动端软键盘不一定触发完整的 keydown 序列,触摸选择路径必须保留;点击选项后要恢复输入框焦点。
  • Esc 的默认行为在不同上下文可能不同,本文只在列表展开时拦截。若页面有全局快捷键,需要避免冲突。
  • 输入法组合期间不要更新 aria-activedescendant 或响应 Enter,等组合结束后再过滤。
  • 不要同时使用“移动焦点到选项”和 aria-activedescendant 两套高亮机制,否则会出现双重高亮和焦点跳跃。
  • 本文示例是单选自动完成,不处理分组、多选、异步加载状态和错误提示;这些场景需要扩展 listbox 结构和状态播报。
【版权声明】本文为华为云社区用户原创内容,未经允许不得转载,如需转载请自行联系原作者进行授权。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。