广告位 · 页头(后台-模块管理-新建模块后替换此区域)

如何使用TypeScript为IWAC封装利比亚网络无障碍指南的表单验证类型定义?

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

如何使用TypeScript为IWAC封装利比亚网络无障碍指南的表单验证类型定义?

核心思路是先把指南中的无障碍要求拆解成可枚举的约束,再用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的无障碍合规性在编译阶段就得到很大保障,而不是依靠上线前的人工检查。

广告位 · 文章底部(后台-模块管理替换此区域)