利比亚网络无障碍指南(Libyan Web Accessibility Guidelines,以下简称LWAG)对表单控件有一系列严格的要求:每个输入框必须有对应的label标签、错误提示不能仅依赖颜色、键盘导航顺序必须符合逻辑等。IWAC作为一个面向利比亚公共服务场景的前端项目,需要把这些规则落实到具体的表单实现中。如果直接用JavaScript或宽松的TypeScript类型编写,很容易在某个角落漏掉label关联,或者传入错误的input类型,导致无障碍审查不过关。本文介绍一种用TypeScript类型系统封装LWAG表单验证规则的方法,让类型错误在编译期就暴露出来,而不是等到屏幕阅读器测试时才被发现。

核心思路是先把指南中的无障碍要求拆解成可枚举的约束,再用TypeScript的联合类型、字面量类型和映射类型建模。这样,当开发者试图创建一个不合规的表单字段时,IDE会立刻标红,同时类型定义本身也充当了活文档。接下来从三个层面展开:指南中的表单规则如何映射为类型、具体类型定义怎么写、以及如何在IWAC中落地并配合运行时校验。
利比亚网络无障碍指南中的表单要求如何映射为类型
LWAG对表单的要求可以归纳为几个关键点:所有表单控件必须有程序化关联的label,label文本必须非空;文本输入类控件需声明正确的type属性,例如数字输入不能用text代替;单选按钮和复选框必须成组,且每组必须有一个明确的分组标题;错误提示信息必须与对应控件通过aria-describedby或aria-live关联,并且不能只靠颜色区分。这些规则在传统开发中通常靠代码评审和人工测试来保障,但用TypeScript可以提前约束。
映射时,我们把每一条规则转换成类型层面的“不可能状态”。例如,一个输入字段如果没有关联的labelId,就不应该出现在表单配置对象中。这可以通过条件类型或联合类型实现:定义一个基础字段类型,再定义一个完整字段类型,后者强制要求labelId存在。如果传入了缺失labelId的对象,TypeScript会报错。类似地,对于type属性,使用字符串字面量联合类型'text' | 'email' | 'number' | ...,并禁止使用'text'来表示数字输入——这可以通过更细粒度的判别联合类型完成。
另一个常见问题是分组组件的label与单个控件的label混淆。LWAG要求分组标题使用fieldset和legend组合,而不是给每个radio都写一个label。在类型定义中,可以把分组组件和单控件组件区分开,分组组件必须包含legendText,单控件组件必须包含关联的具体labelId。这样开发者在构造radio组时如果只给了每个选项的label而遗漏了分组标题,类型检查就会失败。这种把无障碍语义编码进类型系统的方式,比事后写ESLint规则更直接。
用TypeScript定义表单验证类型的具体方案
下面给出一个可复用的类型模型。首先定义基础的输入类型联合,并根据LWAG限制去掉不合理的组合。例如,数字输入必须使用inputmode="numeric"或type="number",但不能用type="text"加上pattern来模拟,因为那会破坏移动端键盘体验和辅助技术识别。
type InputType =
| 'text'
| 'email'
| 'number'
| 'tel'
| 'url'
| 'search'
| 'password';
type FieldBase = {
name: string;
required: boolean;
labelId: string;
ariaDescribedBy?: string;
};
type TextField = FieldBase & {
kind: 'text';
inputType: Extract<InputType, 'text' | 'email' | 'tel' | 'url' | 'search' | 'password'>;
minLength?: number;
maxLength?: number;
};
type NumberField = FieldBase & {
kind: 'number';
inputType: 'number';
min?: number;
max?: number;
step?: number;
};
type ChoiceOption = {
value: string;
label: string;
};
type RadioGroup = {
kind: 'radio-group';
name: string;
legendText: string;
options: ChoiceOption[];
required: boolean;
ariaDescribedBy?: string;
};
type CheckboxGroup = {
kind: 'checkbox-group';
name: string;
legendText: string;
options: ChoiceOption[];
minSelected?: number;
ariaDescribedBy?: string;
};
type FormField = TextField | NumberField | RadioGroup | CheckboxGroup;
这个模型里,kind字段用于判别具体类型,labelId在FieldBase中必填,而分组组件用legendText代替。这样任何试图创建radio选项却缺少分组标题的情况都会在编译期失败。同时,NumberField的inputType被锁定为'number',不能填入'text',从而避免违反LWAG对数字输入的规定。
接下来可以继续利用映射类型生成验证结果类型。比如定义一个ValidationResult,根据字段类型不同返回不同的错误对象。还可以用条件类型来约束ariaDescribedBy引用的元素必须存在于同一表单中——这需要把表单建模成整个对象而非单个字段,但出于篇幅这里先关注字段级别。重点在于,类型定义不仅描述数据结构,还描述了无障碍约束,这让代码评审时看到类型就能理解规范要求。
在IWAC项目中封装并配合运行时校验
类型定义只能保证编译期正确,但用户输入和外部数据可能在运行时绕过类型检查。因此IWAC中还需要一套运行时校验函数,与类型定义配合使用。比较理想的做法是定义类型守卫(type guard),让运行时检查后收窄类型。
function isTextField(field: FormField): field is TextField {
return field.kind === 'text';
}
function validateField(field: FormField, value: string | string[]): string[] {
const errors: string[] = [];
if (field.required) {
if (Array.isArray(value) && value.length === 0) {
errors.push('此字段为必填项');
}
if (typeof value === 'string' && value.trim() === '') {
errors.push('此字段为必填项');
}
}
if (isTextField(field)) {
if (typeof value !== 'string') return errors;
if (field.inputType === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
errors.push('请输入有效的电子邮件地址');
}
if (field.minLength && value.length < field.minLength) {
errors.push(`最少需要 ${field.minLength} 个字符`);
}
}
if (field.kind === 'number' && typeof value === 'string' && value !== '') {
const num = Number(value);
if (Number.isNaN(num)) {
errors.push('请输入有效的数字');
}
if (field.min !== undefined && num < field.min) {
errors.push(`数值不能小于 ${field.min}`);
}
}
if (field.kind === 'radio-group' && typeof value === 'string') {
const validOptions = field.options.map(opt => opt.value);
if (!validOptions.includes(value)) {
errors.push('请选择一个有效的选项');
}
}
return errors;
}
这段代码中出现了反斜杠用于邮箱正则表达式/^[^\s@]+@[^\s@]+\.[^\s@]+$/,注意其中的\.是为了匹配点号,在TypeScript字符串中反斜杠无需转义,但在正则字面量中它本身就是转义字符,这与Windows路径中的反斜杠不同,两者都需要保留原样。运行时校验函数与类型定义共同构成双保险:类型定义约束开发者写出的字段配置,校验函数约束最终用户输入的合法性。
在IWAC项目里,可以把上述类型定义放在独立的lwag-form-types.ts文件中,然后通过一个工厂函数创建表单配置。例如createFormField返回一个已经过类型检查的对象,并附带校验方法。这样业务代码不需要直接操作原始类型,降低误用概率。同时,团队新增字段时必须先更新类型定义,这间接强制所有人重新阅读无障碍指南中的相关条款。
常见误区与类型设计的进一步优化
一个常见误区是把无障碍验证全部交给类型系统,忽略CSS和DOM层面的要求。比如LWAG还要求焦点样式可见、错误提示颜色不能是唯一的区分方式。类型定义无法控制CSS,但可以约束错误提示的HTML结构必须包含role="alert"或aria-live属性。在类型模型中,可以增加一个errorLiveRegionId字段,并强制要求每个表单提供一个对应的<div>容器。讨论标签名时需要转义,例如在正文中提及应该使用<label>标签进行关联,而使用<input>时必须有id属性。
另一个优化方向是利用模板字面量类型(template literal types)强制规定labelId和ariaDescribedBy的命名规范。例如type LabelId = `label-${string}`,这样如果传入labelId: 'myLabel'会报错,必须写成label-email形式。虽然这看起来严苛,但在大型项目中能大幅降低id冲突和关联遗漏的问题。LWAG也建议使用稳定的命名约定,类型系统可以让约定变成硬约束。
最后,类型定义还应当覆盖提交按钮的辅助文本。LWAG要求按钮文本清晰描述动作,比如不能用只有一个图标的按钮而没有aria-label。可以把提交按钮建模为SubmitAction类型,包含text: string和可选的ariaLabel?: string,若text为空则ariaLabel必须存在。通过条件类型type SubmitButton = { text: string; ariaLabel?: string } | { text: ''; ariaLabel: string }即可实现。这些细节累积起来,会让IWAC的无障碍合规性在编译阶段就得到很大保障,而不是依靠上线前的人工检查。

