Angular Components 无障碍 UI Patterns 架构与编写规范全指南
【免费下载链接】componentsComponent infrastructure and Material Design components for Angular项目地址: https://gitcode.com/GitHub_Trending/co/components
导读
本文以 Angular Components(Material Design Components for Angular)仓库中src/aria/private/ui-pattern-rules.md这份内部作者规范为骨架,系统讲解其"ARIA 无障碍模式(Accessibility Pattern)→ Behavior → UI Pattern"三层架构模型,以及编写一个合规 UI Pattern 的全部硬性规范:License 头、导入规则、SignalLike 输入类型体系、四大核心方法(validate()/setDefaultState()/onKeydown()/onPointerdown()),并深度剖析KeyboardEventManager与PointerEventManager事件管理系统的修饰键匹配原理。读完本文,你将掌握如何基于该仓库的behaviors基础组件从零编写一个可通过单元测试、行为正确、事件处理无缺陷的无障碍 UI Pattern。
一、三层核心概念:Accessibility Pattern、Behavior 与 UI Pattern
仓库中src/aria/private目录将无障碍组件的实现抽象为三层概念(见 ui-pattern-rules.md):
- Accessibility Patterns(无障碍模式):业界公认的、可复用的无障碍 Web 内容解决方案,常见的模式包括树视图(Tree View)、开关(Switch)、列表框(Listbox)、网格(Grid)等。它们是 WAI-ARIA 实践中沉淀下来的标准交互形态。
- Behaviors(行为):封装"无障碍模式"中某类常见行为的 TypeScript 类,例如导航(navigation)、选择(selection)、焦点管理(focus)、类型查找(typeahead)。它们粒度更小、可独立测试。
- UI Patterns:由多个 Behavior 类组合而成的 TypeScript 类,将某个完整无障碍模式的全部功能装配起来。
这种"行为组件化"的设计意图非常明确:把键盘导航、选区管理、焦点策略等复杂逻辑从具体组件中剥离出来,让每个行为可独立演进、独立测试,再由 UI Pattern 组装出符合 ARIA 规范的整体。
从源码结构看,src/aria/private/behaviors/下真实存在的行为库包括:
list-focus/list-navigation/list-selection/list-typeahead:列表类交互的四大行为;list:将上述四者整合为统一的List行为类;grid:网格的行列数据、焦点、导航、选择行为;expansion、popup、label、tree:展开、弹层、标签与树相关行为;signal-like:整个架构的信号原语基础;event-manager:事件处理管理器(键盘 / 指针 / 点击)。
这些行为类与"UI Pattern"之间的组装关系,可在真实的ListboxPattern中看到实例:它通过new List(inputs)一次性组合出navigationBehavior、selectionBehavior、typeaheadBehavior、focusBehavior(见 list.ts),再叠加自己特有的id、readonly等输入,形成完整的 ARIA Listbox 模式。
二、编写规范之一:License 头与导入纪律
2.1 License 头(License Header)
规范要求所有.ts文件必须以@license注释开头。文档给出的标准模板如下:
/** * @license * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */该模板与仓库中真实源码(如 signal-like.ts、listbox.ts)完全一致。仓库还配套了 tslint 规则requireLicenseBannerRule.ts在 CI 中强制校验此约束。
2.2 导入纪律(Imports)
文档对导入路径有三条硬性约束:
- 导入语句置于文件顶部,且按字母顺序排列;
- 所有其他依赖必须来自
ui-patterns文件夹内部(对应仓库路径即src/aria/private内部),即不允许跨目录随意引用组件实现; - 信号原语(
signal、computed等)必须从signal-like.ts导入,而非直接来自@angular/core。
对照真实源码可见这一纪律的执行方式。例如 listbox.ts 的导入区:
import {_getEventTarget} from '@angular/cdk/platform'; import {OptionPattern} from './option'; import {KeyboardEventManager, Modifier, ClickEventManager} from '../behaviors/event-manager'; import {computed, signal, SignalLike} from '../behaviors/signal-like/signal-like'; import {List, ListInputs} from '../behaviors/list/list';这里仅允许@angular/cdk/platform这类基础设施的外部依赖,其余全部指向src/aria/private内部的行为库与信号库。
三、类型定义规范:全 Signal 化输入与 SignalLike 体系
3.1 每个 UI Pattern 都要有输入类型
规范要求:每个 UI Pattern 都为其构造参数定义一个类型(Type Definition),所有输入必须是信号(signal),并通过接口继承方式组合各 Behavior 的输入类型:
export interface ExampleInputs extends Behavior1Inputs, Behavior2Inputs { // Additional inputs. }真实的ListboxInputs即为此模式:
export type ListboxInputs<V> = ListInputs<OptionPattern<V>, V> & { /** A unique identifier for the listbox. */ id: SignalLike<string>; /** Whether the listbox is readonly. */ readonly: SignalLike<boolean>; };3.2 为什么不能直接用Signal/WritableSignal
关键约束:输入不得使用@angular/core的Signal或WritableSignal类型,必须使用ui-patterns/behaviors/signal-like导出的SignalLike与WritableSignalLike。
打开signal-like.ts可以看到这一抽象的精髓——SignalLike被定义为最简单的函数签名() => T:
export type SignalLike<T> = () => T; export interface WritableSignalLike<T> extends SignalLike<T> { set(value: T): void; update(updateFn: (value: T) => T): void; asReadonly(): SignalLike<T>; }这意味着任何"读取时返回当前值"的可调用对象都能满足SignalLike,包括:
- Angular 原生
signal()的读取函数; computed()的结果;- 普通 getter 函数,或经
convertGetterSetterToWritableSignalLike(getter, setter)包装的 getter/setter 对(见 signal-like.ts); - 其他任何响应式库的读取函数。
它保证了 UI Patterns 与具体信号实现解耦——既可以用仓库自带的signal/computed/linkedSignal(内部基于@angular/core/primitives/signals封装,见 signal-like.ts),也可以无缝接入外部宿主应用自己的信号体系。
四、类定义规范:Behavior 实例化与四大核心方法
4.1 Behavior 的实例化方式
规范明确:所有 Behavior 都定义为类变量,并在 UI Pattern 的构造函数中实例化。因为 Behavior 之间可能存在依赖关系(例如List需要把focusBehavior注入给ListSelection、ListTypeahead、ListNavigation),构造时机必须统一。
export class ExamplePattern { /** Controls behavior1 for the accessiblity pattern. */ behavior1: Behavior1; /** Controls behavior2 for the accessiblity pattern. */ behavior2: Behavior2; constructor(inputs: ExampleInputs) { this.behavior1 = new Behavior1(inputs); this.behavior2 = new Behavior2(inputs); } }4.2 每个 UI Pattern 必须实现的四个核心方法
文档给出的骨架类如下:
export class ExamplePattern { constructor(inputs: ExampleInputs) { /* ... */ } /** Checks that the internal state of the pattern is valid. */ validate(): string[] {} /** Sets the default initial state of the accessibility pattern. */ setDefaultState(): void {} /** Handles keydown events for the accessibility pattern. */ onKeydown(event: KeyboardEvent) {} /** Handles pointer events for the accessibility pattern. */ onPointerdown(event: PointerEvent) {} }四个方法的分工与实现要点:
validate(): string[]:校验模式内部状态是否合法,返回违规信息数组(空数组即合法)。真实范例见ListboxPattern.validate():单选的 listbox 不允许有多个选中值、option 的 value 不允许重复、option 的 id 不允许重复,三者各返回一条可读的违规描述。setDefaultState(): void:设置模式的默认初始状态,通常应在 UI Pattern 与各子项(如 OptionPattern)相互引用就绪后调用一次。真实范例同样在 listbox.ts:优先把 activeItem 设为第一个"可聚焦且已选中"的项,否则设为第一个可聚焦项;若开启 followFocus 模式还会顺带执行select()。仓库中还配了setDefaultStateEffect()(listbox.ts)确保只在"尚未被用户交互过"时重置状态。onKeydown(event: KeyboardEvent)与onPointerdown(event: PointerEvent):事件入口,负责把原生事件交给内部的事件管理器处理。真实范例见 listbox.ts:两者都会先判断!this.disabled(),并维护hasBeenInteracted信号,再调用this.keydown().handle(event)/this.clickManager().handle(event)。
五、事件处理规范:EventManager 事件管理系统
规范要求 UI Pattern 的事件处理器统一基于ui-patterns/behaviors/event-manager的 EventManager 系统编写,该系统暴露三个主要管理器类(见 event-manager/index.ts):
KeyboardEventManager:专用于键盘事件;PointerEventManager:专用于指针事件;ClickEventManager:专用于点击事件(过滤键盘/辅助技术产生的模拟 click)。
事件管理器应通过computed创建,从而让注册的处理器随依赖信号自动重建:
export class ExamplePattern { // Behaviors... /** The keydown event manager for the accessibility pattern. */ keydown = computed(() => { return new KeyboardEventManager() .on('ArrowUp', () => { /* Arrow up logic. */ }) .on('ArrowDown', () => { /* Arrow down logic. */ }); }); /** The pointerdown event manager for the listbox. */ pointerdown = computed(() => { return new PointerEventManager().on(() => {/* Pointerdown logic. */}); }); onKeydown(event: KeyboardEvent) { this.keydown().handle(event); } onPointerdown(event: PointerEvent) { this.pointerdown().handle(event); } }5.1 EventManager 基类与统一处理管线
在 event-manager.ts 中,EventManager抽象基类定义了统一调度逻辑:handle(event)遍历内部注册的配置,命中matcher的执行handler,并按preventDefault/stopPropagation选项自动补齐这两个"最容易被忘记的事件处理坑":
handle(event: T): void { for (const config of this.configs) { if (config.matcher(event)) { config.handler(event); if (config.preventDefault) event.preventDefault(); if (config.stopPropagation) event.stopPropagation(); } } }每种管理器都带有默认选项:KeyboardEventManager默认ignoreRepeat: true、preventDefault: true、stopPropagation: true(见 keyboard-event-manager.ts);PointerEventManager与ClickEventManager默认两者均为false(见 pointer-event-manager.ts、click-event-manager.ts)。单次调用可通过第三个参数options覆盖,例如 listbox 中导航键使用{ignoreRepeat: false}以支持按住方向键连续移动。
5.2 Modifier 修饰键枚举:位标志设计
修饰键在 event-manager.ts 中被定义为位标志枚举:
export enum Modifier { None = 0, Ctrl = 0b1, Shift = 0b10, Alt = 0b100, Meta = 0b1000, Any = 'Any', }配套函数getModifiers(event)把事件的ctrlKey/shiftKey/altKey/metaKey转换为对应的位标志组合(见 event-manager.ts),hasModifiers(event, modifiers)则判断事件修饰键与期望值是否精确相等,并支持Modifier.Any(任意修饰键)与数组语义(见 event-manager.ts)。
六、KeyboardEventManager 深度解析
6.1 key 的三种形态:字符串、信号与正则
KeyCode类型定义为string | SignalLike<string> | RegExp(见 keyboard-event-manager.ts)。这带来极大的编排灵活性——按键可以随信号动态变化,也可以用正则批量匹配。匹配逻辑_isMatch(keyboard-event-manager.ts)依次处理:事件 key 为空、修饰键不匹配、重复事件(event.repeat且未显式关闭ignoreRepeat)直接不命中;RegExp走key.test(event.key);字符串/信号则做不区分大小写的比对。
文档给出信号形式的示例:
/** The key used to navigate to the next item in the list. */ nextKey = computed(() => { .. }); /** The key used to navigate to the next item in the list. */ prevKey = computed(() => { ... }); keydown = computed(() => { return new KeyboardEventManager() .on(prevKey, () => { /* Navigate prev logic. */ }) .on(nextKey, () => { /* Navigate next logic. */ }); });真实案例:ListboxPattern的prevKey/nextKey会根据垂直/水平方向与 RTL 文本方向动态返回ArrowUp、ArrowDown、ArrowLeft、ArrowRight(见 listbox.ts),并注册typeaheadRegexp = /^.$/来捕获任意单字符键触发类型查找(listbox.ts)。这就是信号/正则 key 价值的直观体现。
6.2 修饰键的三种组合用法
文档给出了Modifier参数在KeyboardEventManager上的全部三种用法:
(1)单个修饰键 + 按键:
keydown = computed(() => { return new KeyboardEventManager() .on(Modifier.Shift, 'ArrowUp', () => { /* Arrow up while holding shift logic. */ }); });(2)修饰键数组——"任一命中"语义:
keydown = computed(() => { return new KeyboardEventManager() .on([Modifier.Ctrl, Modifier.Meta], 'ArrowUp', () => { /* Arrow up while holding ctrl OR meta logic. */ }); });(3)位或运算符——"组合按下"语义:
keydown = computed(() => { return new KeyboardEventManager() .on(Modifier.Ctrl | Modifier.Shift, 'ArrowUp', () => { /* Arrow up while holding ctrl + shift logic. */ }); });这三种用法的实现依据都在_normalizeInputs与hasModifiers中:管理器通过参数形态判断是否携带修饰键(数组或枚举属性),数组经过hasModifiers的some()实现"或"语义,位或后的单个值则参与精确相等比较实现"且"语义(keyboard-event-manager.ts、event-manager.ts)。
七、PointerEventManager 深度解析
PointerEventManager在修饰键能力上与键盘管理器完全对齐,同样支持单个修饰键、数组"或"、位或"且"三种写法:
// 单个修饰键 pointerdown = computed(() => { return new PointerEventManager() .on(Modifier.Shift, (e) => { /* Pointerdown while holding shift logic. */ }); }); // 数组:ctrl OR meta pointerdown = computed(() => { return new PointerEventManager() .on([Modifier.Ctrl, Modifier.Meta], () => { /* Pointerdown while holding ctrl OR meta logic. */ }); }); // 位或:ctrl + shift pointerdown = computed(() => { return new PointerEventManager() .on(Modifier.Ctrl | Modifier.Shift, () => { /* Pointerdown while holding ctrl + shift logic. */ }); });除修饰键外,PointerEventManager还引入MouseButton枚举来区分鼠标按键(见 pointer-event-manager.ts):
export enum MouseButton { Main = 0, Auxiliary = 1, Secondary = 2, }其on()提供三重重载:on(handler)(主键无修饰)、on(modifiers, handler)(主键 + 修饰)、on(button, modifiers, handler)(指定按键 + 修饰),匹配时要求event.button与目标按键相等且修饰键匹配(见 pointer-event-manager.ts)。与之配套的ClickEventManager还会额外过滤模拟点击——通过event.detail === 0或缺少pointerType判断键盘/读屏产生的 fake click(isFakeClick),避免与键盘激活逻辑重复执行(见 click-event-manager.ts)。
八、实战案例:从规范到 ListboxPattern
ListboxPattern(src/aria/private/listbox/listbox.ts)是把上述全部规范落地的完整范本,也是阅读该规范后最值得对照的"参考答案":
- 类型定义:
ListboxInputs<V>用交叉类型继承ListInputs并新增id、readonly; - Behavior 组合:构造器内
new List(inputs)装配ListFocus/ListSelection/ListTypeahead/ListNavigation四个行为,同时暴露listBehavior供各事件处理器调用; - computed 事件管理器:
keydown、clickManager均为computed,内部按readonly/multi/followFocus三种维度的组合条件注册几十组按键与点击处理器,覆盖单选、多选、范围选择、Ctrl/Meta 复选、Shift 范围、Home/End、类型查找等完整 ARIA Listbox 交互(listbox.ts); - 四大核心方法:
validate()返回违规数组、setDefaultState()/setDefaultStateEffect()初始化活动项、onKeydown()/onClick()作为事件入口(listbox.ts)。
与之配套的单元测试listbox.spec.ts对上述每条交互路径(键盘导航、范围选择、修饰键组合、类型查找等)逐一验证;behaviors目录下各行为类(list-navigation、list-selection、grid-navigation、grid-selection等)也都带有独立 spec 测试文件,例如 grid-data.spec.ts、list-typeahead.spec.ts。这些测试共同构成了"规范可执行、行为可验证"的质量闭环,是后续新增 UI Pattern 时的行为参照。
结语
ui-pattern-rules.md虽然篇幅不长,却浓缩了 Angular Components 团队在无障碍组件工程化上的全部核心约定:以 Behavior 为可复用单元、以 SignalLike 为解耦边界、以 EventManager 为事件安全网、以四个核心方法为统一契约。按照本文梳理的步骤——先搭 License 与导入纪律,再定义全信号输入类型,然后在构造函数中组装 Behavior、实现四个核心方法,最后用computed组合出事件管理器——你就能在src/aria/private下编写出与ListboxPattern同构、可测试、可维护的新 UI Pattern,例如combobox、menu、toolbar等目录下对应模式的实现(src/aria/private/)。深度参与或扩展该架构时,建议同时阅读各行为类的 spec 文件,以行为测试作为"可运行的行为规范"。
【免费下载链接】componentsComponent infrastructure and Material Design components for Angular项目地址: https://gitcode.com/GitHub_Trending/co/components
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考