首页
/ Angular @angular/aria Combobox 指令深度解析:触发器与弹出层的协调机制(自动补全、下拉选择与日期选择器)

Angular @angular/aria Combobox 指令深度解析:触发器与弹出层的协调机制(自动补全、下拉选择与日期选择器)

2026-09-06 13:50:42作者:田桥桑Industrious

本文基于 Angular 仓库中的 Combobox 模式指南 adev/src/content/guide/aria/combobox.md 展开,系统讲解 @angular/aria/combobox 提供的三个原语指令(ComboboxComboboxPopupComboboxWidget)如何协调"触发器元素 + 弹出层"这一经典 ARIA 交互模式。读完本文,你可以直接复用仓库中的自动补全、只读下拉(Select 基础)、日期选择网格与 Dialog 弹出层四类完整示例,并掌握配套的 ComboboxHarness 测试方案。

Combobox 是什么:一个输入-弹出协调原语

文档将 Combobox 定义为:一条协调触发器元素(文本输入框、按钮或 div 等)与弹出层(popup)的指令,它是 autocomplete(自动补全)、select(单选下拉)与 multiselect(多选下拉)三类模式的底层原语。对应 W3C ARIA Authoring Practices 中的 Combobox 交互模式。

从仓库中的 API 元数据 adev/src/content/aria/aria-combobox.json 可以看到,该模块(@angular/aria/combobox)对外暴露四个符号:

符号 类型 作用
Combobox 指令,选择器 [ngCombobox] 直接应用在触发器元素上,管理焦点与展开状态,若触发器可编辑则协调补全建议,并把导航键转发到活动弹出层
ComboboxPopup 结构型指令 标记作为弹出层内容的 ng-template,条件渲染
ComboboxWidget 指令,选择器 [ngComboboxWidget] 标记弹出层内的"部件"元素,负责把 ID 与 active descendant 信息回传给 combobox
COMBOBOX_POPUP InjectionToken<ComboboxPopup> 暴露 combobox 弹出层的注入 token

Combobox 指令基于信号实现(元数据中 expandedvalue 均为 ModelSignal,其余为 InputSignal),并继承 DeferredContentAware(延迟内容感知基类),保证弹出层在需要时才渲染。

核心 API 一览(来自 API 元数据)

Combobox 触发器指令的输入项:

输入 类型 说明
disabled InputSignalWithTransform<boolean, unknown> 是否禁用
readonly InputSignalWithTransform<boolean, unknown> 是否只读(不可编辑但仍可触发弹出层)
softDisabled InputSignalWithTransform<boolean, unknown> 软禁用:元素保持可聚焦
alwaysExpanded InputSignalWithTransform<boolean, unknown> 弹出层是否始终保持展开
tabindex InputSignal<undefined> 触发器 tabindex
expanded ModelSignal<boolean> 展开状态,双向绑定,对应输出 expandedChange
value ModelSignal<string> 输入框值,双向绑定,对应输出 valueChange
inlineSuggestion InputSignal<string | undefined> 显示在输入框中的内联建议

ComboboxWidget 暴露 element(部件元素引用)、popupId(弹出层 ID 信号)与 activeDescendant 输入(活动后代元素 ID),并通过 onFocusin / onFocusout 两个事件处理方法处理部件的焦点进出。ComboboxPopup 则提供 combobox(所属 combobox)、controlTarget(控制目标元素)、popupIdactiveDescendantpopupType 输入——popupType 的取值是 'listbox' | 'tree' | 'grid' | 'dialog',即一个 combobox 可以协调四种类型的弹出内容,这是它与 Listbox、Tree、Grid、Dialog 等其他 ARIA 指令集成的关键。

说明:本仓库中的 API 元数据文件 标注其源码仓库为 angular/components(即组件库独立仓库),元数据记录了各符号的源码相对位置(如 /src/aria/combobox/combobox.ts)。本文的 API 描述以该元数据为准。

何时直接用 Combobox,何时用上层模式

指南明确给出使用边界:

直接组合箱(Combobox)原语的场景

  • 构建自定义自动补全模式:实现专门的过滤或建议逻辑
  • 创建自定义选择组件:开发有特殊需求(unique requirements)的下拉
  • 协调输入与弹出层:把文本输入与 listbox、tree 或 dialog 内容配对
  • 在用户空间实现自定义过滤:由你自己过滤并编排匹配选项

应使用上层文档化模式的场景

  • 需要标准过滤自动补全:使用 Autocomplete 模式
  • 需要单选下拉:使用 Select 模式
  • 需要多选下拉:使用 Multiselect 模式

指南特别注明:Autocomplete、Select、Multiselect 三个文档化模式都是把本指令与 Listbox 指南中的 listbox 指令组合而成的具体用例。因此可以把它理解为分层设计:ngCombobox 管"触发器 ↔ 弹出层"的生命周期与 ARIA 关系,而过滤、选中、提交等语义交给上层模式实现。

实战示例一:自动补全(手动组合)

这是文档 Overview 一节的主示例,位于 adev/src/content/examples/aria/autocomplete/src/manual/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)"
    />
    <!-- ... 清除按钮 ... -->
  </div>

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

  <!-- CDK 连接式弹出层,由 popupExpanded 信号驱动开关 -->
  <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"
          selectionMode="explicit"
          [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">
              {{ country }}
            </div>
          }
        </div>
      </div>
    </ng-template>
  </ng-template>
</div>

app.ts(信号驱动的状态管理,节选自 app.ts

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';

@Component({...})
export class App {
  readonly listbox = viewChild(Listbox);
  readonly combobox = viewChild(Combobox);

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

  // 过滤在用户空间完成:用 computed 信号响应式过滤选项列表
  countries = computed(() =>
    ALL_COUNTRIES.filter((country) =>
      country.toLowerCase().startsWith(this.query().toLowerCase())),
  );

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

  onCommit() {
    const selected = this.selectedOption();
    if (selected.length > 0) {
      this.query.set(selected[0]);
    }
    this.popupExpanded.set(false);
    this.combobox()?.element.focus();
  }
}

这个示例值得注意的三个设计点:

  1. 过滤完全发生在用户空间query 信号变化 → countries computed 重新过滤 → 模板 @for 重渲染。指南原话:"Filtering is managed in user space by updating a signal that reactively filters the options list. Users navigate with arrow keys and select with Enter or click." 这种设计把过滤策略(前缀匹配、模糊匹配、异步搜索等)完全交给开发者。
  2. 三层指令各司其职ngCombobox 挂在 <input> 上管理触发与展开;ngComboboxPopup 标记弹出层模板;ngComboboxWidgetngListbox 同时挂在选项容器上——前者向 combobox 回传 activeDescendant,后者提供 listbox 的键盘选择能力。
  3. afterRenderEffect 中的滚动同步:展开状态为 true 时把活动项滚入视口,展示的是信号 + 渲染后副作用的标准写法。

每个示例都提供 Basic / Material / Retro 三种主题变体(见 autocomplete 示例目录 下的 basicmanualhighlightsignal-forms 等子目录),方便对照不同视觉体系的落地方式。

实战示例二:只读触发器——Select / Multiselect 的地基

指南的 "Readonly mode" 一节指出:不需要文本输入的"下拉触发"可以通过两种手段实现——

  • <button> 作为宿主触发器;
  • 或对输入触发器施加原生 HTML readonly 属性。

弹出层在点击或方向键时打开。这正是 Select(单选下拉)与 Multiselect(紧凑显示的多选)两个模式的实现基础,它们的完整实现(含触发器与浮层定位)可见 select 示例目录(文档中引用了其中的 icons 变体)。Combobox 指令元数据里 readonlysoftDisabled 两个输入(前者禁止编辑但保留触发,后者保持可聚焦)对应的就是这类不可编辑触发器的需求。

实战示例三:与二维网格协调的日期选择器

Combobox 并不局限于 listbox 内容——popupType 支持 'grid''dialog'。文档给出的日期选择器示例位于 adev/src/content/examples/aria/combobox/src/datepicker/basic/app/:用户在日历网格表格中用方向键导航日期,用点击、Enter 或 Spacebar 确认选择。其模板 app.html 的关键结构:

<input
  #combobox="ngCombobox"
  ngCombobox
  placeholder="Pick a date..."
  [(value)]="selection"
  (input)="onInputInput(comboboxInput.value)"
  [(expanded)]="popupExpanded"
  aria-describedby="date-format-hint"
  (keydown)="onInputKeydown($event)"
  (click)="popupExpanded.set(true)"
/>

<ng-template
  [cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: false}"
  [cdkConnectedOverlayOpen]="popupExpanded()"
  (overlayOutsideClick)="popupExpanded.set(false)"
>
  <ng-template ngComboboxPopup [combobox]="combobox" popupType="dialog">
    <div class="example-popover">
      <!-- 用 [cdkTrapFocusAutoCapture]="false" 保护键盘输入不被焦点捕获抢走 -->
      <div
        ngComboboxWidget
        class="example-datepicker-popup"
        cdkTrapFocus
        [cdkTrapFocusAutoCapture]="false"
        (keydown)="handleWidgetKeydown($event)"
      >
        <div aria-live="polite" class="cdk-visually-hidden">
          {{ activeMonthAnnouncement() }}
        </div>
        <!-- 头部:上/下月导航按钮 + 月份标题 -->
        ...
        <!-- 方向键边界检查绑定到网格表格 -->
        <table
          #gridTable
          tabindex="-1"
          ngGrid
          #grid="ngGrid"
          class="example-datepicker-grid"
          colWrap="continuous"
          rowWrap="nowrap"
          [enableSelection]="true"
          selectionMode="explicit"
          (keydown)="onGridKeydown($event)"
        >
          <!-- thead:星期表头;tbody:@for (week of weeks()) 逐行渲染
               ngGridRow / ngGridCell / ngGridCellWidget -->
        </table>
      </div>
    </div>
  </ng-template>
</ng-template>

可以看到组合关系:外层 ngCombobox(日期文本输入)→ ngComboboxPopuppopupType="dialog")→ ngComboboxWidget(内含 cdkTrapFocus 焦点陷阱)→ ngGrid 网格表格负责二维方向键导航。模板注释还点出了两个实战细节:用 [cdkTrapFocusAutoCapture]="false" 避免焦点被强制捕获而干扰输入,以及通过 aria-live 区域播报当前活动月份。

实战示例四:Dialog 弹出层(嵌套 Combobox + 焦点陷阱)

当浮层需要模态行为或遮罩交互时,文档建议使用 dialog 弹出层,位于 adev/src/content/examples/aria/combobox/src/dialog/app/。其 app.html 展示了两个 combobox 嵌套的完整结构:

  • 外层触发器是一个带原生 readonly 的输入框容器(tabindex="-1"),点击后展开;
  • 弹出层内容标记 ngComboboxWidget,内部再放一个内层可编辑 combobox[alwaysExpanded]="true",作为搜索框),它有自己的 ngComboboxPopup 包裹选项 listbox;
  • 弹出层配置 [cdkConnectedOverlayDisableClose]="true",关闭逻辑交给内部处理;
  • 搜索框监听 keydown.escape 走自定义的 onSearchEscape 逻辑。

这个示例直观展示了 Combobox 的"可嵌套性":外层负责打开/关闭 dialog 弹层,内层负责搜索过滤与选择提交(onCommit),alwaysExpanded 输入则省去了内层弹出层的展开状态管理。

键盘导航与屏幕阅读器支持

指南 "Features" 一节列出的能力,在源码元数据与示例中可以逐一对上:

  • 触发器-弹出层协调expanded 模型信号 + cdkConnectedOverlayOpen 驱动开关;
  • 灵活协调:通过 ComboboxPopup.popupType 支持 listbox、tree、grid、dialog 四类内容;
  • 键盘导航:方向键、Enter、Escape 处理——Combobox 的文档注释明确其"forwards navigation keys down into the active popup"(把导航键转发到活动弹出层),即触发器按键时焦点保持在输入框上,活动项通过 aria-activedescendant 机制表达(示例中 listbox 使用 focusMode="activedescendant");
  • 屏幕阅读器支持:内置 ARIA 属性,包括 role="combobox"aria-expanded(文档原文),ComboboxWidgetactiveDescendant 输入负责同步 aria-activedescendant
  • 弹出层管理:基于用户交互自动显隐(点击、方向键触发,overlayOutsideClick 关闭等);
  • 信号响应式:全部状态(展开、值、选中项)以信号管理,如自动补全示例中的 popupExpandedqueryselectedOption 三个信号。

各示例还普遍使用视觉隐藏的 aria-live="polite" 区域(cdk-visually-hidden)播报"未找到结果"或月份切换等状态,这是屏幕阅读器体验的关键一环。

测试:ComboboxHarness

Angular Aria 提供 ComboboxHarness 用于测试 combobox 组件(来自 @angular/aria/combobox/testing 子模块入口)。文档给出的完整测试示例:

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 {MyComboboxComponent} from './my-combobox'; // Your component

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

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

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

  it('should allow opening and closing the popup', async () => {
    const combobox = await loader.getHarness(ComboboxHarness);

    // Verify initial state
    expect(await combobox.isOpen()).toBe(false);

    // Open the popup
    await combobox.open();
    expect(await combobox.isOpen()).toBe(true);

    // Close the popup
    await combobox.close();
    expect(await combobox.isOpen()).toBe(false);
  });
});

要点:通过 TestbedHarnessEnvironment.loader(fixture) 获取 HarnessLoadergetHarness(ComboboxHarness) 定位页面中的 combobox,然后用 isOpen() / open() / close() 等语义化 API 断言展开行为,无需关心底层 ARIA 属性的具体写法。

相关模式与指令速查

Combobox 是以下文档化模式的底层原语(指南 "Related patterns and directives" 一节):

  • Autocomplete:过滤与建议模式,协调输入打字与选项列表(示例见 autocomplete 示例目录);
  • Select:单选下拉模式,直接应用于不可编辑的按钮触发器(示例见 select 示例目录);
  • Multiselect:多选模式,应用于可多选 Listbox 的不可编辑触发器。

Combobox 最常见的组合对象:

  • Listbox:最典型的弹出层内容;
  • Tree:层级化的弹出层内容;
  • 以及本例中出现的 Grid(日期选择器)与 Dialog(模态弹层)。

小结

@angular/aria/combobox 的价值在于把 W3C Combobox 模式中最容易出错的部分——触发器与弹出层的 ARIA 关系(role="combobox"aria-expanded、active descendant)、焦点与展开状态管理、导航键转发——封装为三个各司其职的指令:[ngCombobox](触发器)、ngComboboxPopup(弹出层模板)、[ngComboboxWidget](弹出层部件)。在此基础上,用 CDK 的 cdkConnectedOverlay 负责定位与开关,用信号负责状态,就可以以很薄的胶水代码搭出自动补全、只读下拉、日期网格与 Dialog 弹层这四类可访问组件;ComboboxHarness 则让展开/收起行为可以稳定地写进测试。所有示例代码都可以在 adev/src/content/examples/aria/ 下按 Basic / Material / Retro 三种主题对照阅读。

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