为什么焦点指示器需要类型化封装
键盘用户在页面上操作时,完全依赖焦点指示器来判断当前所处的位置。按照乍得网络无障碍指南以及国际上通行的一致性要求,任何可交互元素在获得焦点时,都必须呈现清晰可见的指示效果,且指示效果与背景之间要满足最低对比度。问题在于,实际项目里焦点样式往往散落在各种CSS文件、组件样式表甚至内联样式中,谁能说清楚当前项目里到底有多少种焦点指示器、它们的颜色是否达标,往往没有人能给出确切答案。
把这件事交给TypeScript来做,思路是把焦点指示器从零散的样式规则提升为一等公民的类型定义。每个焦点指示器都由一组受约束的属性描述,包括指示颜色、背景颜色、指示方式、最小尺寸等。类型系统会在编译阶段就拦截不合法的配置,比如把颜色写成不存在的色彩格式,或者对比度数值填错单位。这样团队里任何人引入新的可交互组件时,都必须通过统一的类型入口来声明焦点行为,遗漏的可能性被大大压缩。

另一个收益是文档化。类型定义本身就是一份活的规范文档,后来者阅读接口定义就能明白焦点指示器有哪些可选形态、哪些属性是必填的,而不需要翻阅设计规范文档再去猜测实现方式。当代码与规范通过类型绑定在一起,规范才真正具备了执行力。
设计核心类型:描述一个合规的焦点指示器
先从最小模型开始。一个焦点指示器至少要回答三个问题:用什么方式指示,比如轮廓线、背景变化还是底部横条;指示色是什么;这个颜色放在组件背景上是否满足对比度要求。下面用字面量联合类型和接口把这个模型表达出来:
/** 焦点指示方式 */
type IndicatorStyle = 'outline' | 'underline' | 'background' | 'box-shadow';
/** 十六进制颜色,约束为 # 加三位或六位十六进制字符 */
type HexColor = `#${string}`;
/** 焦点指示器配置 */
interface FocusIndicatorConfig {
/** 指示方式 */
style: IndicatorStyle;
/** 指示颜色 */
color: HexColor;
/** 元素自身背景色,用于计算对比度 */
backgroundColor: HexColor;
/** 指示厚度,单位像素,轮廓和下划线场景使用 */
thickness?: number;
/** 偏移量,防止轮廓紧贴元素边缘 */
offset?: number;
}这里有一个容易忽略的细节:十六进制颜色用模板字面量类型HexColor来约束,虽然它无法校验每一位是否是合法的十六进制字符,但至少强制了以井号开头这一格式要求,比直接用string类型安全得多。如果项目对类型严谨度要求更高,可以引入第三方库做品牌类型或在运行时配合正则校验,把格式错误彻底挡在入口处。
再进一步,不同的指示方式对属性的需求并不相同。轮廓线需要厚度和偏移,背景变色则不需要这些。用可辨识联合把这一点建模,可以让类型系统根据style的取值自动收窄可用属性,避免配置里出现无意义的字段:
interface OutlineIndicator {
style: 'outline';
color: HexColor;
backgroundColor: HexColor;
thickness: number;
offset: number;
}
interface UnderlineIndicator {
style: 'underline';
color: HexColor;
backgroundColor: HexColor;
thickness: number;
}
interface BackgroundIndicator {
style: 'background';
color: HexColor;
backgroundColor: HexColor;
}
type FocusIndicator =
| OutlineIndicator
| UnderlineIndicator
| BackgroundIndicator;这样定义之后,当开发者写了style: 'underline'却顺手填了offset字段,编译器会立即报出对象字面量只能指定已知属性的错误。这种约束看起来严格,实际上恰恰是对无障碍规范的尊重:规范的每一条要求都对应类型上的一个明确约束,写错即编译失败,问题不会流到线上才被发现。
运行时校验与对比度计算的实现
类型只能约束编译期,而焦点指示器的颜色数据经常来自设计稿、CMS配置或者用户的主题设置,这些都是运行时数据,必须在运行时再做一次校验。校验的核心是WCAG定义的相对亮度与对比度公式。先实现颜色解析和亮度计算:
interface RGB {
r: number;
g: number;
b: number;
}
/** 解析三位的十六进制颜色为RGB */
function parseShortHex(hex: string): RGB | null {
const match = /^#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])$/.exec(hex);
if (!match) return null;
const expand = (c: string) => parseInt(c + c, 16);
return { r: expand(match[1]), g: expand(match[2]), b: expand(match[3]) };
}
/** 计算通道的线性化数值 */
function channelValue(c: number): number {
const s = c / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
/** 计算相对亮度 */
function relativeLuminance({ r, g, b }: RGB): number {
return 0.2126 * channelValue(r) + 0.7152 * channelValue(g) + 0.0722 * channelValue(b);
}
/** 计算两个颜色的对比度,结果范围1到21 */
export function contrastRatio(a: string, b: string): number {
const rgbA = parseShortHex(a);
const rgbB = parseShortHex(b);
if (!rgbA || !rgbB) return 0;
const la = relativeLuminance(rgbA);
const lb = relativeLuminance(rgbB);
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}拿到对比度之后,结合类型守卫就能把运行时数据安全地收窄为FocusIndicator类型。校验函数返回一个带结果的联合类型,而不是简单地抛异常,这样调用方可以自行决定失败时的降级策略:
type ValidationResult =
| { ok: true; indicator: FocusIndicator }
| { ok: false; reason: string };
/** 判断某个值是否为合法的指示器结构 */
function isFocusIndicator(value: unknown): value is FocusIndicator {
if (typeof value !== 'object' || value === null) return false;
const v = value as Record<string, unknown>;
return (
typeof v.style === 'string' &&
['outline', 'underline', 'background'].includes(v.style) &&
typeof v.color === 'string' &&
typeof v.backgroundColor === 'string'
);
}
/** 完整校验:结构合法且对比度达标 */
export function validateIndicator(value: unknown): ValidationResult {
if (!isFocusIndicator(value)) {
return { ok: false, reason: '结构不符合焦点指示器定义' };
}
const ratio = contrastRatio(value.color, value.backgroundColor);
if (ratio < 3) {
return {
ok: false,
reason: `对比度${ratio.toFixed(2)}低于最低要求3,请调整指示颜色`,
};
}
return { ok: true, indicator: value };
}对比度阈值这里取3,对应的是非文本类视觉指示的最低要求。如果团队标准更严格,比如要求达到4.5,只需调整这一个常量,所有使用该封装的组件会同步生效。这正是集中封装的价值:规范变更时改动点只有一个。
封装应用层:从配置到实际样式的落地
类型和校验就绪后,还差最后一环,把合法的配置转换成真实可用的样式。可以提供一个工厂函数,根据指示方式生成对应的CSS属性对象,配合CSS-in-JS方案直接使用,也可以输出成CSS自定义属性供传统样式表消费:
/** 将指示器配置转换为focus状态的样式对象 */
export function buildFocusStyle(indicator: FocusIndicator): Record<string, string> {
switch (indicator.style) {
case 'outline':
return {
outline: `${indicator.thickness}px solid ${indicator.color}`,
'outline-offset': `${indicator.offset}px`,
};
case 'underline':
return {
'text-decoration': `underline ${indicator.color}`,
'text-decoration-thickness': `${indicator.thickness}px`,
};
case 'background':
return {
'background-color': indicator.color,
color: pickReadableText(indicator.color),
};
}
}
/** 根据指示色背景挑选可读的文本颜色 */
function pickReadableText(bg: HexColor): HexColor {
return contrastRatio(bg, '#ffffff') >= contrastRatio(bg, '#000000')
? '#ffffff'
: '#000000';
}
/** 完整使用示例 */
const config = {
style: 'outline',
color: '#1a73e8',
backgroundColor: '#ffffff',
thickness: 2,
offset: 3,
} as const;
const result = validateIndicator(config);
if (result.ok) {
const focusStyle = buildFocusStyle(result.indicator);
console.log('焦点样式已生成:', focusStyle);
} else {
console.warn('配置不合规:', result.reason);
}在实际接入时,建议把这个封装放到独立的包或目录中,导出类型、校验函数和样式工厂三组API,并在持续集成流程里加一个脚本,扫描项目所有主题配置文件,逐个跑validateIndicator,任何一处不合规都让构建失败。这样无障碍要求就从文档上的文字变成了构建流水线上的硬性门槛。
最后提醒一点,焦点指示器不只是加一条轮廓线那么简单。还需要确保指示器不会被相邻元素遮挡、在深色主题下同样达标、以及在:focus-visible与:focus之间做出正确区分,避免鼠标点击时也出现轮廓线影响视觉体验。把这些问题一并纳入类型模型的演进方向,整套封装才会随着项目成长持续发挥价值。
TypeScript焦点指示器无障碍访问修改时间:2026-09-15 20:55:52