Angular ARIA Autocomplete 实战指南:基于 @angular/aria 构建可访问的自动补全输入框
本篇指南基于 Angular 仓库中的 ARIA Autocomplete 官方文档(adev/src/content/guide/aria/autocomplete.md)及其配套示例代码,讲解如何用 @angular/aria 的 Combobox 与 Listbox 指令组合,构建一个完全可访问(无障碍)的自动补全输入框。读完后,你将掌握三种选择模式(自动选择、手动选择、高亮模式)的模板与信号实现、与 Signal Forms 的 FormValueControl 集成方式,以及使用 ComboboxHarness 与 ListboxHarness 编写测试用例的完整方法。
概述
自动补全(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)的语言。
在示例中,"基于信号的响应式"体现为大量 signal、computed、viewChild、afterRenderEffect 与 effect 的组合(见后文源码分析),而"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标记为组合框的弹出层;ngComboboxWidget把ngListbox声明为组合框的"控件(widget)";ngListbox与ngOption则构成列表框与选项。这正是文档 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.ts 的 onCommit() 简化为:仅当 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/testing 的 ComboboxHarness 与 @angular/aria/listbox/testing 的 ListboxHarness 组合来测试自动补全组件,并给出了完整示例:
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 文档为骨架,结合其配套示例源码,完整呈现了:
- 选型判断:何时用、何时不用 autocomplete;
- 三种选择模式(Auto-select / Manual / Highlight)在模板与信号层的差异,尤其是
selectionMode、[inlineSuggestion]与navigated信号的作用; - 与 Signal Forms 的
FormValueControl集成,把 autocomplete 封装为可校验的表单控件; - 基于
ComboboxHarness/ListboxHarness的测试写法; - 五个核心 API 符号在模板中的落点。
所有代码与约定均可在 adev/src/content/examples/aria/autocomplete/ 下按 basic、manual、highlight、signal-forms 四个子目录逐一对应查看,便于在自己的工程中复制改造。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00