首页
/ Angular ARIA Autocomplete 实战指南:基于 @angular/aria 构建可访问的自动补全输入框

Angular ARIA Autocomplete 实战指南:基于 @angular/aria 构建可访问的自动补全输入框

2026-09-06 13:44:30作者:邬祺芯Juliet

本篇指南基于 Angular 仓库中的 ARIA Autocomplete 官方文档(adev/src/content/guide/aria/autocomplete.md)及其配套示例代码,讲解如何用 @angular/ariaComboboxListbox 指令组合,构建一个完全可访问(无障碍)的自动补全输入框。读完后,你将掌握三种选择模式(自动选择、手动选择、高亮模式)的模板与信号实现、与 Signal Forms 的 FormValueControl 集成方式,以及使用 ComboboxHarnessListboxHarness 编写测试用例的完整方法。

概述

自动补全(Autocomplete)是一种可访问的输入模式:用户在输入时,控件会对选项进行过滤并实时给出建议,帮助用户从较长的列表中快速定位并选中目标值。在 Angular 中,它由 @angular/aria 包提供的组合框(combobox)与列表框(listbox)指令共同实现。文档将其定位为一个"完全可访问的 combobox 实现",并强调其对键盘导航、屏幕阅读器、信号响应式、原生 HTML Popover API 以及双向(RTL)文本的支持。

需要注意的是,@angular/aria 在本仓库中是一个外部依赖,在根 package.json 中固定为 22.2.0-next.3,其指令的具体实现源码并不位于本仓库的 packages/ 目录内。因此本仓库能直接作为证据引用的是文档本身、配套的示例工程文件,以及它们所呈现的指令用法与调用约定。

使用场景与取舍

文档给出的选型建议非常明确,可以直接作为业务判断依据:

适合使用 Autocomplete 的场景:

  • 选项列表较长(超过 20 项):通过键入缩小候选集,比滚动下拉列表更快。
  • 用户清楚自己要找什么:可以键入期望值的一部分(如州名、产品名、用户名)。
  • 选项遵循可预测的模式:用户能猜出部分匹配项(如国家代码、邮箱域名、分类)。
  • 速度敏感:表单需要在较少导航操作内快速完成选择。

应避免使用 Autocomplete 的场景:

  • 列表少于 10 项——此时普通下拉框或单选按钮组提供更好的可见性。
  • 用户需要"浏览"选项——如果"发现"比"检索"更重要,应一开始就展示全部选项。
  • 选项对用户不陌生——用户无法键入一个他不知道存在的值。

核心能力

文档列出了 Angular autocomplete 提供的能力清单,结合示例代码可以逐条印证:

  • 键盘导航(Keyboard Navigation):方向键浏览选项,Enter 选中,Escape 关闭。
  • 屏幕阅读器支持(Screen Reader Support):内建 ARIA 属性供辅助技术使用。
  • 动态高亮行为(Dynamic Highlight Behavior):内建对行内选择建议(inline selection suggestions)的支持。
  • 基于信号的响应式(Signal-Based Reactivity):使用 Angular signals 做响应式状态管理。
  • Popover API 集成:借助原生 HTML Popover API 获得最佳定位。
  • 双向文本支持:自动处理从右到左(RTL)的语言。

在示例中,"基于信号的响应式"体现为大量 signalcomputedviewChildafterRenderEffecteffect 的组合(见后文源码分析),而"Popover API 集成"则体现在 cdkConnectedOverlay 上的 usePopover: 'inline' 配置。

基础实现:Auto-select(自动选择)模式

自动选择模式是默认示例。用户在键入部分文本时,输入框的值会自动更新为第一个过滤后匹配的选项,从而减少击键次数并即时反馈搜索方向正确。

下面是一个基于 @angular/aria 的典型组件(节选自 basic/app/app.ts)。它用 computed 完成前缀过滤,用 afterRenderEffect 保证弹层打开时活动项滚动进视野:

import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {OverlayModule} from '@angular/cdk/overlay';
import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core';
import {FormsModule} from '@angular/forms';

@Component({
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule],
})
export class App {
  readonly listbox = viewChild(Listbox);
  readonly combobox = viewChild(Combobox);

  popupExpanded = signal(false);
  query = signal('');
  selectedOption = signal<string[]>([]);

  // 前缀过滤:输入变化时自动收窄候选集
  countries = computed(() =>
    ALL_COUNTRIES.filter((country) => country.toLowerCase().startsWith(this.query().toLowerCase())),
  );

  constructor() {
    // 弹层展开时,把当前活动项滚动到可见区域
    afterRenderEffect(() => {
      if (this.combobox()?.expanded() === true) {
        this.listbox()?.scrollActiveItemIntoView();
      }
    });
  }

  onBlur() {
    this.commitSelection();
  }

  onCommit() {
    this.commitSelection();
    this.popupExpanded.set(false);
    this.combobox()?.element.focus();
  }

  private commitSelection() {
    const selected = this.selectedOption();
    if (selected.length > 0) {
      this.query.set(selected[0]);
    } else {
      this.query.set('');
      this.selectedOption.set([]);
    }
  }
}

对应的模板(basic/app/app.html)展示了几个关键装配点:

<div class="autocomplete-container">
  <div #origin class="autocomplete-input-container">
    <input
      #combobox="ngCombobox"
      ngCombobox
      class="autocomplete-input"
      placeholder="Select a country"
      [(value)]="query"
      [(expanded)]="popupExpanded"
      (click)="popupExpanded.set(true)"
      (focusout)="onBlur()"
    />
  </div>

  <!-- 无结果时向屏幕阅读器播报(视觉隐藏) -->
  <div aria-live="polite" class="cdk-visually-hidden">
    {{ countries().length === 0 ? 'No results found for ' + query() : '' }}
  </div>

  <!-- 弹层:基于 CDK Connected Overlay + 内联 Popover -->
  <ng-template
    [cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
    [cdkConnectedOverlayOpen]="popupExpanded()"
  >
    <ng-template ngComboboxPopup [combobox]="combobox">
      <div class="popup">
        @if (countries().length === 0) {
          <div class="no-results">No results found</div>
        }
        <div
          #listbox="ngListbox"
          ngListbox
          ngComboboxWidget
          class="listbox"
          focusMode="activedescendant"
          [tabindex]="-1"
          [activeDescendant]="listbox.activeDescendant()"
          [(value)]="selectedOption"
          (click)="onCommit()"
          (keydown.enter)="onCommit()"
        >
          @for (country of countries(); track country) {
            <div class="option" ngOption [value]="country" [label]="country">
              <span class="option-label">{{ country }}</span>
            </div>
          }
        </div>
      </div>
    </ng-template>
  </ng-template>
</div>

从源码结构看,这套实现有若干值得注意的约定:

  • 指令角色分工ngCombobox<input> 变成组合框输入;ngComboboxPopup 把外层 ng-template 标记为组合框的弹出层;ngComboboxWidgetngListbox 声明为组合框的"控件(widget)";ngListboxngOption 则构成列表框与选项。这正是文档 API 参考中 Combobox / ComboboxPopup / ComboboxWidget / Listbox / Option 五个符号在模板中的落点。
  • focusMode="activedescendant"[activeDescendant]:采用 ARIA 的 "active descendant" 焦点管理策略——焦点始终留在列表框上,通过 activeDescendant 属性告知辅助技术当前高亮项,从而实现"方向键移动而不真正移动 DOM 焦点"的无障碍体验。
  • cdk-visually-hidden + aria-live="polite":在无结果时向屏幕阅读器播报,同时保持视觉隐藏,兼顾可访问性与视觉布局。
  • usePopover: 'inline':呼应文档所述"借助原生 HTML Popover API",由 CDK Overlay 以内联 Popover 方式挂载弹层。

Manual(手动选择)模式

手动选择模式保持用户键入的文本不变,即在浏览建议列表时不自动改动输入框的值,只有当用户显式用 Enter 或点击确认时才更新输入。这避免了"自动回填"带来的困惑。

与基础模式的差异很小,核心落在 manual/app/app.html 的列表框上:它额外声明了 selectionMode="explicit",明确采用"显式选择"语义——

<div
  #listbox="ngListbox"
  ngListbox
  ngComboboxWidget
  class="listbox"
  focusMode="activedescendant"
  selectionMode="explicit"
  [tabindex]="-1"
  [activeDescendant]="listbox.activeDescendant()"
  [(value)]="selectedOption"
  (click)="onCommit()"
  (keydown.enter)="onCommit()"
>

同时,manual/app/app.tsonCommit() 简化为:仅当 selectedOption 非空时才写回 query,随后关闭弹层并把焦点交还给输入框。与基础模式相比,它去掉了 focusout 时的强制提交(onBlur),进一步把"何时更新值"的控制权交给用户。

Highlight(高亮)模式

高亮模式允许用户用方向键在选项中导航,但在显式选择(Enter 或点击)之前不改动输入框的值——即"边浏览边高亮,确认后回填"。

该模式在 highlight/app/app.ts 中引入了一个 navigated 信号与一个 effect,用于追踪"用户是否已经开始用方向键导航",并在弹层关闭时复位:

navigated = signal(false);

constructor() {
  afterRenderEffect(() => {
    if (this.combobox()?.expanded() === true) {
      this.listbox()?.scrollActiveItemIntoView();
    }
  });

  effect(() => {
    if (!this.popupExpanded()) {
      this.navigated.set(false);
    }
  });
}

模板(highlight/app/app.html)的关键在于通过 [inlineSuggestion] 实现行内建议高亮,并在方向键事件上打点导航状态:

<input
  #combobox="ngCombobox"
  ngCombobox
  class="autocomplete-input"
  placeholder="Select a country"
  [(value)]="query"
  [(expanded)]="popupExpanded"
  (click)="popupExpanded.set(true)"
  (keydown.arrowdown)="navigated.set(true)"
  (keydown.arrowup)="navigated.set(true)"
  [inlineSuggestion]="query() || navigated() ? selectedOption()[0] : undefined"
/>

这里 [inlineSuggestion] 的取值逻辑是:仅当"用户已有输入"或"已开始导航"时,才把当前高亮项 selectedOption()[0] 作为行内建议展示,否则为 undefined(不展示)。这与文档中"动态高亮行为——内建对行内选择建议的支持"的描述直接对应。

与 Signal Forms 集成

Angular 的 autocomplete 可与基于信号的 Signal Forms API 无缝集成。思路是:把复杂输入封装成实现 FormValueControl 的可复用自定义控件组件,再通过 [formField] 绑定到父表单,并配合 schema 校验规则。

文档给出的国家选择器示例(FormValueControl<string>)在 signal-forms/app/country-selector.ts 中实现了表单控件契约:

import {FormValueControl, ValidationError} from '@angular/forms/signals';

@Component({
  selector: 'country-selector',
  imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule],
})
export class CountrySelector implements FormValueControl<string> {
  readonly combobox = viewChild(Combobox);

  // FormValueControl 契约
  readonly value = model.required<string>();
  readonly touched = input<boolean>(false);
  readonly invalid = input<boolean>(false);
  readonly errors = input<readonly ValidationError.WithOptionalFieldTree[]>([]);
  readonly touch = output<void>();

  popupExpanded = signal(false);
  selectedOption = signal<string[]>([]);

  filteredCountries = computed(() => {
    const query = this.value()?.toLowerCase() ?? '';
    return ALL_COUNTRIES.filter((c) => c.toLowerCase().startsWith(query));
  });

  onCommit() {
    const selected = this.selectedOption();
    if (selected.length > 0) {
      this.value.set(selected[0]);
    }
    // 通知父表单域:该控件已被交互
    this.touch.emit();
    this.popupExpanded.set(false);
    this.combobox()?.element.focus();
  }

  clear() {
    this.value.set('');
    this.selectedOption.set([]);
    this.popupExpanded.set(false);
  }
}

模板(signal-forms/app/country-selector.html)在输入框上把校验状态映射为样式与错误提示,并把值通过 model 双向写回:

<input
  #combobox="ngCombobox"
  ngCombobox
  class="autocomplete-input"
  [class.invalid]="touched() && invalid()"
  [value]="value()"
  (input)="value.set($any($event.target).value)"
  [(expanded)]="popupExpanded"
/>
<!-- ...listbox 结构同上... -->

@if (touched() && invalid() && errors().length > 0) {
  <div class="error-message">
    {{ errors()[0].message }}
  </div>
}

父组件(signal-forms/app/app.ts)用 form 定义 schema,声明必填与"值必须属于已知国家集合"的校验:

model = signal({country: ''});
countryForm = form(this.model, (p) => {
  required(p.country, {message: 'Country selection is required'});

  validate(p.country, (ctx) => {
    const value = ctx.value();
    if (value && !ALL_COUNTRIES.includes(value)) {
      return {kind: 'invalidCountry', message: 'Please select a valid country.'};
    }
    return null;
  });
});

关于 FormValueControl 契约的更完整约定,可参考仓库中 Signal Forms 的自定义控件文档 custom-controls.md

测试

文档建议用 @angular/aria/combobox/testingComboboxHarness@angular/aria/listbox/testingListboxHarness 组合来测试自动补全组件,并给出了完整示例:

import {ComponentFixture, TestBed} from '@angular/core/testing';
import {HarnessLoader} from '@angular/cdk/testing';
import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed';
import {ComboboxHarness} from '@angular/aria/combobox/testing';
import {ListboxHarness} from '@angular/aria/listbox/testing';
import {MyAutocompleteComponent} from './my-autocomplete'; // 你的组件

describe('MyAutocompleteComponent', () => {
  let fixture: ComponentFixture<MyAutocompleteComponent>;
  let loader: HarnessLoader;

  beforeEach(async () => {
    TestBed.configureTestingModule({
      imports: [MyAutocompleteComponent],
    });

    fixture = TestBed.createComponent(MyAutocompleteComponent);
    await fixture.whenStable();
    loader = TestbedHarnessEnvironment.loader(fixture);
  });

  it('should filter options based on input', async () => {
    const combobox = await loader.getHarness(ComboboxHarness);

    // 输入触发过滤
    await combobox.setValue('ap');
    expect(await combobox.isOpen()).toBe(true);

    // 从弹层获取 listbox harness
    const listbox = await combobox.getPopupWidget(ListboxHarness);
    const options = await listbox.getOptions();

    // 验证过滤结果
    expect(options.length).toBe(2);
    expect(await options[0].getText()).toBe('Apple');

    // 选中第一项
    await options[0].click();

    // 验证输入值已更新且弹层已关闭
    expect(await combobox.isOpen()).toBe(false);
    expect(await combobox.getValue()).toBe('Apple');
  });
});

从这一测试可以读出 harness 的调用链:loader.getHarness(ComboboxHarness) 获取组合框 → getPopupWidget(ListboxHarness) 拿到弹出层里的列表框 → getOptions() 列出选项 → 对选项执行 click()、读取 getText()。这组方法正好覆盖了"输入过滤、弹层开合、选项选择与回填"三条核心断言路径,是把示例代码转成可回归测试的直接模板。

API 参考

文档指引读者查阅以下符号的详细 API(这些符号由 @angular/aria 外部包提供,本仓库通过根 package.json 锁定其版本):

  • Combobox — 组合框输入指令(ngCombobox)。
  • ComboboxPopup — 组合框弹出层指令(ngComboboxPopup)。
  • ComboboxWidget — 将列表框声明为组合框控件(ngComboboxWidget)。
  • Listbox — 列表框指令(ngListbox)。
  • Option — 列表选项指令(ngOption)。

这五个符号在前文模板中一一对应,构成"输入 → 弹层 → 列表框 → 选项"的完整可访问结构。

小结

本指南以 Angular 仓库的 ARIA Autocomplete 文档为骨架,结合其配套示例源码,完整呈现了:

  1. 选型判断:何时用、何时不用 autocomplete;
  2. 三种选择模式(Auto-select / Manual / Highlight)在模板与信号层的差异,尤其是 selectionMode[inlineSuggestion]navigated 信号的作用;
  3. 与 Signal Forms 的 FormValueControl 集成,把 autocomplete 封装为可校验的表单控件;
  4. 基于 ComboboxHarness / ListboxHarness 的测试写法
  5. 五个核心 API 符号在模板中的落点。

所有代码与约定均可在 adev/src/content/examples/aria/autocomplete/ 下按 basicmanualhighlightsignal-forms 四个子目录逐一对应查看,便于在自己的工程中复制改造。

登录后查看全文
热门项目推荐
相关项目推荐