a11y 第15周笔记-无障碍基础 + WCAG 对比度检测 + union-type 状态建模

路线图阶段:第三阶段(Design Engineer 工具箱)第15周 —— 基础无障碍(accessibility / a11y) 任务:给配色生成器补齐 a11y 基础卫生,加一个”对比度检测”亮点功能,顺带把复制状态重构成 union-type 状态机 核心收获:a11y 一半是设计素养一半是工程;“能用着正常” ≠ “对所有人可用”;编译器不拦你 ≠ 行为正确

⚠️ 守住的 scope:对比度检测只做 fg-on-bg 一对,不做全配色矩阵;错误处理只到”告知用户”,不加重试/toast/execCommand。


0. a11y 是什么、为什么是设计背景的人的主场

accessibility(无障碍),缩写 a11y(a + 11个字母 + y,同 i18n=internationalization)。 = 让所有人都能用你的界面,包括:视障(screen reader 屏幕阅读器)、无法用鼠标(键盘导航)、色弱色盲(靠对比度)、临时不便(强光下看屏幕/单手)。

为什么是你的主场:a11y 的判断部分全是设计素养——对比度够不够、键盘友好吗、信息只靠颜色传达会不会有人看不到。这些你做 4 年 UI 早有直觉,只是没系统化、没和代码对应。纯前端常常不懂这些判断,而你天生懂 —— 这是 design engineer 的差异化。

POUR 四支柱(a11y 框架)

支柱英文一句话项目里对应
可感知Perceivable信息能被感知到颜色对比度、图片 alt、控件有名字
可操作Operable所有功能键盘能操作色卡能 Tab+Enter
可理解Understandable行为可预测、有清晰标签input 有 label
健壮Robust能被辅助技术正确解析用语义化 HTML

1. ⭐ 两把核心尺子:对比度 vs 只靠颜色(别混)

尺子1:contrast(对比度)= 明暗差,和色盲无关

尺子2:don’t rely on color alone(不要只靠颜色传达信息)

这俩正好对应项目两个特点:做配色工具 → 天然涉及对比度(尺子1);色卡/角标用颜色区分角色 → 注意别只靠颜色(尺子2)。


2. a11y 基础卫生:给组件补齐

(a) 表单控件要有”可访问的名字”(accessible name)

<span>Hex value</span>            {/* 你看得见,但机器不知道它和 input 有关 */}
<input ... />                     {/* screen reader: "编辑框,空" —— 不知道干嘛的 */}

⚠️ 视觉邻近 ≠ 程序关联(programmatic association)。 眼睛看到”这俩在一起”,机器需要你显式声明

三种做法:

做法用法适用
<label htmlFor="id"> + <input id="id">靠 id 挂钩有可见标签时首选(点 label 还能聚焦 input)
aria-label="..."只有 screen reader 能听到的名字视觉上不想要可见标签(图标按钮/取色器)
aria-labelledby="id"指向已有元素当标签复用现成文字(本周没用)

(b) ⭐ prop 传给自定义组件,组件内部必须”接住并用上”才生效

本周踩的坑:<ColorInput id="hex-input" /> 传了 id,但组件内部只解构了 value/onChange,没接 id → id 凭空消失,不报错也不生效 → <label htmlFor> 指向不存在的 id → 挂钩失败。

// ColorInput 内部必须显式接住并放到真实 input 上
export function ColorInput({ value, onChange, id }: ColorInputProps) {
  return <input id={id} value={value} onChange={onChange} ... />;
}

给自定义组件传 prop,组件内部必须解构+用上,prop 才有效。 封装组件天天用这条。 "aria-label" 带连字符不能当变量名,解构要重命名:{ "aria-label": ariaLabel }

(c) ⭐ 可交互元素用语义标签:<div onClick><button>

键盘测试(拔鼠标只用 Tab)暴露:色卡是 <div onClick>Tab 跳不到、Enter 不响应、screen reader 不报”可点”

<div> 是无语义容器,天生不可交互。<button> 免费给三件事:可 Tab 聚焦 + 响应 Enter/空格 + screen reader 报”按钮”。

<button onClick={() => onCopy(role, hslToHex(color))} ...>  {/* div→button,三件福利自动来 */}

3. ⭐⭐ a11y 失败是”静默”的 —— 验证习惯

上面这些坑的共同点:不报错、页面看着正常、用鼠标的正常视力用户完全无感。只有 screen reader/键盘/语音控制用户来用时才暴露,而那个人通常不反馈、只默默走掉。

所以 a11y 不能靠”我用着没问题”验证。要靠:

  • 主动看真实 DOM(DevTools 确认 id/aria-label 到底在不在)—— 眼见为实,别凭感觉
  • 键盘测试(拔鼠标,Tab+Enter 走完整个流程)
  • 工具扫描(axe / Lighthouse)

不假设”我用着正常就没问题” —— 这个意识本身就是 design engineer 的面试加分项。


4. ⭐ 亮点功能:WCAG 对比度检测

配色工具 + 对比度 = 天作之合。做一个”算 fg-on-bg 对比度、显示达不达标 WCAG AA”的功能。

WCAG(Web Content Accessibility Guidelines,网页无障碍指南)对比度标准

对比度范围 1:1(看不见) 到 21:1(纯黑配纯白)
AA(常用合格线):正文 ≥ 4.5:1;大字 ≥ 3:1
AAA(更严):正文 ≥ 7:1

对比度是可计算的数值 → “这配色无障碍吗”不是玄学,能用代码算出明确答案。

两个公式(照抄理解,魔法数字是 WCAG 规定死的)

// 公式1:relative luminance(相对亮度),输入 RGB
export function relativeLuminance({ r, g, b }: RGB): number {
  const channel = (value: number) => {        // per-channel: normalize + gamma correction
    const v = value / 255;
    return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
  };
  return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);  // 绿权重最大(人眼最敏感)
}

// 公式2:contrast ratio,输入两个颜色的 RGB
export function contrastRatio(rgb1: RGB, rgb2: RGB): number {
  const lum1 = relativeLuminance(rgb1);
  const lum2 = relativeLuminance(rgb2);
  const lighter = Math.max(lum1, lum2);       // Math.max/min → 不可能把亮暗弄反
  const darker = Math.min(lum1, lum2);
  return (lighter + 0.05) / (darker + 0.05);
}

⭐ 障碍:公式要 RGB,但 palette 是 HSL → 用现成函数组合

没有 hslToRgb,但有 hslToHex + hexToRgb → 串起来:

const ratio = contrastRatio(
  hexToRgb(hslToHex(palette.foreground)),   // HSL → hex → RGB
  hexToRgb(hslToHex(palette.background)),
);

week6 “小纯函数可组合(compose)“的又一次回报:不新写转换,复用两个现成函数拼出新能力。

验证习惯:用已知答案测函数

黑白对比度必是 21(WCAG 最大值):contrastRatio({r:0,g:0,b:0},{r:255,g:255,b:255}) → ≈21 → 公式对。

用”已知正确答案”验证函数,比空想对不对可靠。测完删 console.log。

徽章:文字承载信息,颜色只作加强(呼应尺子2)

const passesAA = ratio >= 4.5;
<span>Text contrast (fg on bg): {ratio.toFixed(2)}:1</span>   {/* toFixed(2): 13.757104… → 13.76 */}
<span>{passesAA ? "AA Pass" : "AA Fail"}</span>               {/* 文字是真相,不只靠颜色 */}

设计观察:generatePalette 把 bg 写死 l:95、fg 写死 l:15(亮度差固定 80)→ 默认必过 AA。真正会 fail 的是别的对(如 on-primary/primary)。所以 fg-on-bg 只是 v1,更有意义的检测是按钮那对(以后做)。


5. ⭐⭐ 额外收获:union-type 状态机建模(复制的三态)

需求:复制失败时给用户反馈(不只 console.log —— 那只帮开发者,用户什么都看不到)。 先做产品决定:失败时色卡显示”复制失败”(把”记录什么”重构成”用户该看到什么” = 错误处理/用户反馈)。

问题:旧 state 表达不了”失败”

copied: string | null 只能答”哪张卡成功了”,没法表示失败。硬 setCopied(role) 会让失败显示成”已复制”(说反)。

解法:一个 state 装”角色 + 状态”(one source of truth)

type CopyState = { role: string; status: "success" | "failed" } | null;  // union type 状态枚举
const [copyState, setCopyState] = useState<CopyState>(null);

Swatch 三态渲染:先算 label 变量(逻辑与呈现分离)

let label = hslToHex(color);            // idle default
if (status === "success") label = "已复制!";
if (status === "failed") label = "复制失败";
// JSX 里只有 {label} —— 比嵌套三元好读太多

匹配当前复杂度的写法:三态只是文字不同、结构相同 → “先算 label 变量”正解。 不用查找对象(状态少,过度设计)、不用早返回(结构没差异)。别为”显得高级”过度设计(over-engineering)。

父组件按卡算 status

status={copyState?.role === role ? copyState.status : null}   // ?. optional chaining: copyState 可能 null

用类型精确建模 UI 状态,是 design engineer 和”只会拼组件的人”的分水岭之一。


第15周验收


反复提醒过、需继续巩固的点