无障碍 Web 组件

影子边界会改变语义和 ARIA 的运作方式。主要有两种故障:影子树内缺少语义,以及 ARIA 引用因无法跨越影子根而静默中断。

从语义标记开始:在模板中使用真实的 <button>、<nav>、<ul>,这样大多数语义都会自动生效,并为缺口保留 ElementInternals/ARIA。

通过 ElementInternals ARIA mixin 实现默认语义

通过 ElementInternals 在 JS 中设置自定义元素的角色和 ARIA 状态,而不是将 ARIA 属性写入宿主。这样可以保留组件拥有的默认语义,同时仍允许使用者使用 Light DOM 中的属性替换这些语义。

class ToggleSwitch extends HTMLElement {
  #internals;
  constructor() {
    super();
    this.#internals = this.attachInternals();
    this.#internals.role = 'switch';          // default role, no host attribute needed
    this.#internals.ariaChecked = 'false';    // exposed to the accessibility tree
    this.addEventListener('click', () => this.toggle());
    this.addEventListener('keydown', (e) => {
      if (e.key === ' ') { e.preventDefault(); this.toggle(); }
    });
  }
  connectedCallback() {
    // Put the host in the tab order on connect (setting tabIndex in the constructor throws on createElement).
    if (!this.hasAttribute('tabindex')) this.tabIndex = 0;
  }
  toggle() {
    const on = this.#internals.ariaChecked !== 'true';
    this.#internals.ariaChecked = String(on);
  }
}
customElements.define('toggle-switch', ToggleSwitch);

这样可以避免使用 ARIA 属性污染 DOM,并防止使用者意外破坏这些属性,同时无障碍树仍能看到正确的角色和状态。

焦点管理和 delegatesFocus

  • 当宿主元素本身是互动控件,并通过 ElementInternals 承载角色和状态时(如上文中的 ToggleSwitch),请将宿主置于 connectedCallback 内的 Tab 键顺序中(if (!this.hasAttribute('tabindex')) this.tabIndex = 0 - 绝不能在构造函数中,否则会在 document.createElement 上抛出异常),以便焦点落在宿主上,并让辅助技术播报其角色和状态。请勿在静态、非互动组件(例如标注或卡片)的主机上设置 tabindex。
  • 当组件封装拥有语义的内部可聚焦控件时(或在具有 static formAssociated = true 的表单关联自定义元素上),使用 delegatesFocus: true 附加 shadow root。聚焦宿主(并点击表单关联元素的关联 <label>)会将焦点转发到第一个可聚焦的子元素,并且 :focus 与宿主匹配。请勿将 delegatesFocus: true 与宿主上的角色/状态结合使用,否则焦点将落在内部元素上,并跳过宿主的状态。
  • 对于漫游焦点或将焦点移至新显示的区域,请为目标指定 tabindex="-1" 并调用 .focus();明确指定可通过编程方式聚焦的目标,而不是依赖于 Tab 键顺序。

跨 shadow root 的 ARIA 关系

  • 强制性:由 aria-labelledby、aria-describedby、aria-controls 等引用的 id 仅在引用元素自身的树中解析。Light DOM 中基于 id 的属性无法指向 shadow 树内的 id,反之亦然;此类引用会被静默忽略。
  • 如需跨越边界,请使用反映的 ARIA 元素属性(例如 ariaLabelledByElements、ariaDescribedByElements 等),这些属性采用直接 JS 元素引用,而不是 ID。它们会向外解析:shadow tree 中的元素可以指向 Light DOM 或任何祖先树(包括插槽内容)中的目标,因此您可以使用 Light DOM 内容标记 shadow 内部控件,而 id 引用无法表达这一点。
  • 它们不会向内解析:元素无法引用后代 shadow tree 中的内容(例如,宿主无法由其自己的 shadow 内容标记)。对于该方向,新兴的参考目标机制是标准方向,但尚未广泛推出。
  • 如果无法通过这两种方式表达关系,请将其保留在一个树中(在同一树中呈现标签),或使用 aria-label 文本而不是 id 引用。

后备策略

如果 ElementInternals ARIA mixin 不可用,请改为在宿主上将角色和 ARIA 状态设置为属性 (this.setAttribute('role', 'switch'))。这种方法在任何地方都有效,但代价是将属性放入 DOM 中,而使用者可能会覆盖这些属性。

如果无法使用反映的 ARIA 元素属性,请对同树关系使用基于 id 的 aria-* 属性;跨边界标签没有基于 id 的等效属性,因此请回退到 aria-label 文本。