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(对比度)= 明暗差,和色盲无关
- 对比度低 = 文字/背景明暗(luminance 亮度)差不够 → 影响所有看不清的人(低视力、老年人、强光下看手机的正常人)。
- ⚠️ 常见误区:对比度 ≠ 色盲问题。红绿色盲看高对比黑白文字毫无障碍。
- WCAG 对比度公式算的是 relative luminance(相对亮度),完全不管色相(hue)。
尺子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" | 指向已有元素当标签 | 复用现成文字(本周没用) |
htmlFor不是for:for是 JS 保留字(循环),JSX 改用htmlFor(同className不叫class)。- ARIA(Accessible Rich Internet Applications):给 HTML 补 a11y 信息的属性。铁律:能用原生 HTML(
<label>)就别用 ARIA,ARIA 是补丁不是首选。 aria-label是念给用户听的一句话 → 要自然语言:"Pick primary color"✓,不是"color-picker"❌(听起来像代码,没信息)。
(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,三件福利自动来 */}
- 对比”用 div 打补丁”要手写
tabIndex={0}+role="button"+onKeyDown三样才能追平一个 button → 黄金法则:能用原生语义元素就别用 div 补 ARIA。 - 呼应 week11:标签语义管”是什么”(看行为:能点击执行操作=按钮),className 管”长什么样”。button 照样能用 Tailwind 弄成色卡样子。
- focus ring(聚焦框):键盘用户 Tab 到时要有可见高亮。button 默认带。⚠️ 千万别
outline:none删掉它(键盘用户就瞎了);嫌丑就换好看的focus-visible:ring-2,不是删。
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> {/* 文字是真相,不只靠颜色 */}
toFixed(2)截断,别把 13.757104679513263 丢进 UI。- ⚠️ 别用 ✅/❌ emoji 当唯一信号:恰好是红绿(最常见色盲难区分),且 screen reader 会念”white heavy check mark”。文字是真相,颜色/图标只是加强。
设计观察: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);
- 成功:
setCopyState({ role, status: "success" }) - 失败:
setCopyState({ role, status: "failed" }) - ⚠️
setTimeout(() => setCopyState(null), 1000)放 try/catch 外面 —— 成功失败都要重置。 留在 try 里 → 失败态”复制失败”永不消失。这个 TS 不报错(逻辑非类型问题),只有真失败才暴露。
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周验收
- 懂 a11y 是什么、POUR 四支柱
- ⭐ 分清两把尺子:对比度(明暗,和色盲无关) vs 别只靠颜色(色盲)
- input 加 label/aria-label,懂”视觉邻近≠程序关联”、aria-label 要人话
- ⭐ prop 传给组件必须内部接住才生效(id 凭空消失的坑)
- ⭐ 可交互元素用
<button>不用<div onClick>(键盘可操作 + focus ring) - ⭐⭐ 懂 a11y 失败是静默的,养成”看真实 DOM + 键盘测试”验证习惯
- ⭐ 写 relativeLuminance + contrastRatio(WCAG 公式),HSL→hex→RGB 组合
- 做 AA Pass/Fail 徽章,文字承载信息不只靠颜色
- ⭐⭐ union-type 状态机建模复制三态(setTimeout 位置坑)
反复提醒过、需继续巩固的点
- 编译器不拦你 ≠ 行为正确:setTimeout 位置、漏 return(week14)都是 TS 不报、运行才暴露。
- “我用着正常” ≠ “对所有人可用”:a11y 靠主动验证(DOM/键盘),不靠自我体验。
- 选匹配复杂度的写法,别过度设计(over-engineering)。
- 作品集卫生:清 debug 用的 console.log、
//////分隔注释、没用的 import。