表单是网页里最容易出无障碍问题的地方之一。标签没关联、错误提示读不出来、必填标识缺失,这些问题对普通用户影响不大,却会让屏幕阅读器用户完全摸不着头脑。冰岛的网络无障碍指南IWAC(Icelandic Web Accessibility Guidelines的社区简称)对表单控件有一套明确的约束,比如每个输入控件必须有可访问名称、错误信息必须与控件程序化关联、分组控件必须使用组标签等。如果只是靠文档和代码评审来落实这些规则,效果往往不稳定。更可靠的做法是用TypeScript的类型系统把这些规则固化下来,让不符合规范的代码根本编译不过。

IWAC对表单控件的核心要求梳理
在设计类型之前,得先把规范本身拆解成可以被类型系统表达的规则。IWAC对表单的要求可以归纳成三类。第一类是可访问名称:每个可交互控件必须有一个可访问名称,来源可以是label元素、aria-label属性或者aria-labelledby引用。第二类是状态与描述:控件处于错误状态时,必须通过aria-invalid标记,并用aria-describedby把错误文本与控件关联起来,让辅助技术能够朗读出来。第三类是结构分组:像单选按钮组、复选框组这样的控件集合,必须放在fieldset与legend结构中,或者使用role="group"配合aria-labelledby。
这三类要求里,前两类可以直接映射为TS的类型约束,第三类更多是组件结构层面的,可以在封装组件props时通过必填字段来体现。把它们列清楚之后,下一步就是判断哪些要求适合用基础类型表达,哪些需要联合类型和泛型配合。这一步很关键,因为类型设计得太松,等于没约束;设计得太紧,又会给业务开发添堵。
设计基础类型:可访问名称的强制约束
先定义一个描述可访问名称的类型。可访问名称有三个合法来源,三者至少满足其一。直接用一个全可选的接口是拦不住遗漏的,所以这里用联合类型的思路,要求调用方必须显式传入三种命名方式之一:
// 可访问名称的联合类型:三选一,缺一不可
export type AccessibleName =
| { label: string; labelFor?: never; ariaLabel?: never }
| { label?: never; labelFor: string; ariaLabel?: never }
| { label?: never; labelFor?: never; ariaLabel: string };
// labelFor 指向页面中某个元素 id,用 aria-labelledby 关联
// ariaLabel 则直接作为 aria-label 属性值这个写法利用了never类型的互斥特性,保证调用方只能选择一种命名方式,同时至少提供一种。接下来在控件props里使用它:
export interface TextFieldProps extends AccessibleName {
id: string;
name: string;
value: string;
onChange: (value: string) => void;
required?: boolean;
describedBy?: string; // 指向提示文本或错误文本的 id
invalid?: boolean; // 对应 aria-invalid
}这样一来,如果某个开发者写<TextField id="email" ... />却忘了传label或ariaLabel,编译器会直接报错,提示缺少可访问名称。相比在运行时打warning,编译期报错的成本要低得多,也更容易在代码评审之前就把问题消灭掉。值得注意的是,describedBy与invalid虽然可以做成可选,但在封装组件实现时应当做联动处理:只要invalid为true,组件就自动渲染aria-invalid="true",并确保describedBy指向的错误容器存在。
错误提示与状态关联的类型设计
IWAC要求错误信息必须能被辅助技术获取,这意味着错误文本不能只是视觉上红字显示在输入框下面,而要建立程序化关联。可以在类型层面做更强的约束:当控件标记为错误状态时,错误描述id变成必填。这同样可以用联合类型实现:
type BaseProps<T> = {
id: string;
name: string;
value: T;
onChange: (value: T) => void;
} & AccessibleName;
export type FieldProps<T> =
| (BaseProps<T> & { invalid?: false; errorId?: undefined })
| (BaseProps<T> & { invalid: true; errorId: string; errorMessage: string });这个定义的含义很直白:如果传了invalid: true,那就必须同时给出errorId和errorMessage,否则类型不匹配。组件实现时,把errorMessage渲染到errorId指向的容器中,并设置aria-describedby={errorId}。这种模式把规范条款直接翻译成了类型签名,新人即使没读过IWAC文档,也能在IDE的报错提示里理解该做什么。
对于提示文本,还可以进一步封装一个FieldDescription类型,把hint的id自动拼接后传入describedBy数组。实践中常见的做法是组件内部统一生成id规则,比如用useId生成hint和error的id,再由组件自己组装aria-describedby的值,调用方只需要传文本内容,减少手工维护id的心智负担。
分组控件的类型封装与完整示例
单选组和复选框组的无障碍要求侧重结构。类型上可以设计一个GroupProps,强制要求legend文本:
export interface RadioGroupProps {
name: string;
legend: string; // 对应 fieldset 的 legend,必填
value: string;
onChange: (value: string) => void;
options: Array<{ value: string; label: string; disabled?: boolean }>;
invalid?: boolean;
errorMessage?: string;
}组件实现内部渲染fieldset与legend,错误状态下给组内每个radio设置aria-invalid并添加aria-describedby。业务方使用时,由于legend是必填项,漏写组标签会直接导致编译失败。下面是一个输入框组件的参考实现片段:
function TextField(props: FieldProps<string>) {
const hintId = `${props.id}-hint`;
const errorId = props.invalid ? props.errorId : undefined;
const describedBy = [errorId].filter(Boolean).join(" ");
return (
<div>
<label htmlFor={props.id}>{props.label}</label>
<input
id={props.id}
name={props.name}
value={props.value}
aria-invalid={props.invalid || undefined}
aria-describedby={describedBy || undefined}
onChange={(e) => props.onChange(e.target.value)}
/>
{props.invalid && (
<p id={props.errorId} role="alert">{props.errorMessage}</p>
)}
</div>
);
}这个实现里,label、aria-invalid、aria-describedby的生成逻辑全部收敛在组件内部,业务代码不需要手写任何aria属性,从源头上减少了出错空间。
封装方案的优缺点与落地建议
这套方案的好处很明显:规范约束前移到编译期,代码评审时不用反复检查aria属性有没有写全;类型定义本身就是文档,新成员看props签名就能知道无障碍要求;组件实现与规范绑死后,业务侧几乎无法绕过。缺点也值得正视:联合类型在类型报错信息上有时不够友好,TS给出的错误提示可能很长,需要在团队内做一次说明;类型约束覆盖不了纯视觉层面的问题,比如颜色对比度不足、焦点样式不可见,这些仍需借助自动化检测工具和人工测试。
落地时有几点建议。第一,类型封装和组件封装要配套做,只定义类型而不提供组件,约束力会大打折扣。第二,可以结合CI中的无障碍检测(比如axe-core扫描)形成双保险,类型管静态结构,扫描管渲染结果。第三,随着规范更新,类型定义要有人维护,建议把这套类型放到公共组件库里统一管理,而不是散落在各个业务仓库。这样一套下来,IWAC的表单要求基本都能稳定落地,代码的可访问性不再依赖个人自觉。
TypeScript表单无障碍IWAC修改时间:2026-09-13 23:09:05