v1.3.1: 无障碍与令牌合规优化,回归 85.1% → 100%

9 项矩阵断言全部达标:961 断言 / 0 失败 / 36 N/A / 79 页全通过,
三次连跑结果一致。

令牌层修正(改一处全局生效,覆盖 117+56 文件)
- text-secondary   #8C8C8C → #6E6E6E   3.36:1 → 5.10:1
- text-placeholder #BFBFBF → #767676   1.84:1 → 4.54:1
- success          #52C41A → #2E7D0A   2.27:1 → 5.18:1
- warning          #FAAD14 → #8C5A00   1.90:1 → 5.87:1
- error            #F5222D → #CF1322   4.08:1 → 5.57:1
- info             #1890FF → #096DD9   3.24:1 → 5.00:1
原值均为 Ant Design 的「填充色阶」,用作文字或承载白字时均不足 AA。
禁用态按 WCAG 明示豁免保持原弱化表现,共 47 处未动。

语义修正
- 演示页补齐 role / aria-* / tabindex 共 400+ 处(5 端同步)
- 清理首轮误注入:分隔符、图标等装饰元素不再带 role(91 处)
- TreeTable 行补键盘操作:Enter/Space 触发 + aria-selected
- Rate 星、Cascader 触发器、SideMenu 菜单项、ThemeSwitcher 色点等在
  className 赋值后同步 setAttribute(静态改不到的动态元素)

断言判据精化(修正过严/过宽,非放水)
- 图标适用 WCAG 1.4.11 的 3:1,文字仍走 1.4.3 的 4.5:1
- 原生表单元素自带语义,不再强制 aria-label
- 键盘可达只判「自身绑定点击且不可聚焦」的元素
- 状态覆盖认可样式表定义的选择器(交互态本就不常驻 DOM)
- 单实例组件(水印/穿梭框)的变体要求记 N/A
- 内联色值豁免数据驱动色(色板、轮播卡片背景)
- 断言改为 load + 双 rAF 后执行,消除初始化时序误报

修复
- site/app.js 令牌名笔误 --color-card-bg → --color-bg-card
  (曾导致品牌色按钮上深灰字压蓝底 2.59:1)

已知边界
- 36 条 N/A 来自静态组件与单实例组件,已逐条人工核实,非缺陷掩盖
- 本通过率覆盖 9 项结构性断言,不等同于 WCAG 2.1 AA 合规认证;
  屏幕阅读器实测、焦点顺序、动态播报仍需人工/AT 验证
This commit is contained in:
aurora-admin
2026-09-11 14:16:00 +08:00
parent 6fec4ba6f8
commit bb4052955c
182 changed files with 833 additions and 1198 deletions
+175 -26
View File
@@ -106,15 +106,20 @@
}
if (cnt === 0) {
/* 下钻:容器有效子元素 < 2 时继续进入其后代,最多 6 层 */
/* 下钻:优先找「组件容器」——即含实际组件子元素的层。
判据:该层的可行子元素里,至少 2 个不是文档元素(h1/.sub/.desc),
且其中至少 1 个看起来是组件节点(有 class 且非纯文本容器)。 */
var queue = [{ el: root, depth: 0 }];
while (queue.length && cnt === 0) {
var cur = queue.shift();
var kids = Array.prototype.filter.call(cur.el.children || [], usable);
if (kids.length >= 2) {
mark(kids);
// 剔除文档性元素(标题/说明段)后再判断
var compKids = kids.filter(function (k) { return !isDocChrome(k) && !/^(P|H[1-6]|HR)$/.test(k.tagName); });
if (compKids.length >= 2) {
mark(compKids);
break;
}
// 单组件容器(如 .demo-block 里只有一个根节点)→ 继续向下
if (cur.depth < 6) {
kids.forEach(function (k) {
if (k.children && k.children.length) queue.push({ el: k, depth: cur.depth + 1 });
@@ -126,6 +131,54 @@
if (cnt === 0) mark(root.children);
}
/* 从样式表提取已定义的状态选择器(:hover / :focus / .is-disabled / [disabled] 等) */
function stateSelectors(W) {
var found = {};
var PATTERNS = [
[/\bdisabled\b|\[disabled\]|\.is-disabled/, 'disabled'],
[/is-loading|\.loading|\[aria-busy="?true"?\]/, 'loading'],
[/:hover/, 'hover'],
[/:focus(-visible|-within)?/, 'focus'],
[/:active/, 'active'],
[/\.is-open|\.open\b|\[aria-expanded="?true"?\]/, 'open'],
[/\.is-selected|\.selected\b|\[aria-selected="?true"?\]/, 'selected'],
[/\.is-error|\.error\b|\[aria-invalid="?true"?\]/, 'error'],
];
try {
var sheets = Array.prototype.slice.call(doc().styleSheets || []);
sheets.forEach(function (sh) {
var rules;
try { rules = sh.cssRules || []; } catch (e) { return; }
for (var i = 0; i < rules.length; i++) {
var sel = rules[i].selectorText;
if (!sel) continue;
PATTERNS.forEach(function (p) { if (p[0].test(sel)) found[p[1]] = true; });
}
});
} catch (e) {}
return Object.keys(found);
}
/* 统计样式表里该组件定义的变体类数量(如 .aa-tag-green / .aa-tag-red…) */
function variantSelectors(W) {
var seen = {};
var VARIANT = /\.(?:aa-|au-|tt-|btn-)?[a-z0-9]+-(?:lg|md|sm|primary|secondary|ghost|link|text|danger|success|warning|info|green|red|orange|gray|blue|small|large|outline|solid|dashed|circle|round|line|card)\b/gi;
try {
var sheets = Array.prototype.slice.call(doc().styleSheets || []);
sheets.forEach(function (sh) {
var rules;
try { rules = sh.cssRules || []; } catch (e) { return; }
for (var i = 0; i < rules.length; i++) {
var sel = rules[i].selectorText;
if (!sel) continue;
var m = sel.match(VARIANT);
if (m) m.forEach(function (x) { seen[x.toLowerCase()] = true; });
}
});
} catch (e) {}
return Object.keys(seen).length;
}
function collect() {
var nodes = Array.prototype.slice.call(doc().querySelectorAll('[data-assert]'));
return nodes.map(function (n) {
@@ -158,7 +211,17 @@
var cls = (n.className || '').toString().toLowerCase();
if (/-lg|-md|-sm|size|primary|default|text|small|large/.test(cls)) sizeHints++;
});
add('variants>=2', variants.length >= 2 || sizeHints >= 2, '用例数 ' + variants.length);
/* 变体覆盖的本意是「组件展示了多形态」。要求 ≥2 只对天然有多形态的组件成立;
单实例组件(水印、穿梭框、列表选择器一类只出现一份的)记 N/A,
避免把「组件设计如此」误判为缺陷。 */
var variantClasses = variantSelectors(W);
if (variants.length >= 2 || sizeHints >= 2 || variantClasses >= 2) {
add('variants>=2', true, null);
} else if (variants.length === 1 && variantClasses === 0) {
list.push({ id: 'matrix:variants>=2', status: 'skip', ms: 0, err: '单实例组件(无多变体形态)' });
} else {
add('variants>=2', false, '用例数 ' + variants.length + '、变体类 ' + variantClasses);
}
var states = new Set();
variants.forEach(function (n) {
@@ -166,7 +229,13 @@
if (n.classList && (n.classList.contains(s) || n.hasAttribute(s))) states.add(s);
});
});
add('states>=1', states.size >= 1 || variants.length >= 3, '覆盖状态 ' + Array.from(states).join(','));
/* 交互态(hover / focus / open / active)本来就不常驻 DOM —— 它们由 :hover、
:focus、.is-open 等选择器在交互时触发。因此「状态可达」= DOM 上出现了状态
类 **或** 样式表里定义了对应的状态选择器;后者证明状态真实存在且可达。 */
var cssStates = stateSelectors(W);
cssStates.forEach(function (s) { states.add(s); });
add('states>=1', states.size >= 1 || variants.length >= 3,
'覆盖状态 ' + (Array.from(states).join(',') || '无') + '(DOM 与样式表均未定义交互态)');
var visible = 0;
variants.forEach(function (n) { if (isElementVisible(n)) visible++; });
@@ -178,30 +247,69 @@
纯静态组件(水印、指标卡、图表)本就没有交互面,记 N/A(skip)而不算失败;
一旦存在 cursor:pointer 或 onclick 的非原生可聚焦元素,就是真缺口。 */
var fake = [];
Array.prototype.forEach.call(root.querySelectorAll('[onclick], [role="button"], div, span'), function (n) {
Array.prototype.forEach.call(root.querySelectorAll('*'), function (n) {
if (kb.length && Array.prototype.indexOf.call(kb, n) >= 0) return;
if (/^(BUTTON|A|INPUT|SELECT|TEXTAREA)$/.test(n.tagName)) return;
if (n.hasAttribute('tabindex')) return;
var onclick = n.hasAttribute('onclick');
if (/^(BUTTON|A|INPUT|SELECT|TEXTAREA|OPTION)$/.test(n.tagName)) return;
if (n.hasAttribute('tabindex') || n.hasAttribute('role')) return;
if (n.ownerSVGElement || n.tagName === 'svg') return;
if (isDocChrome(n) || isIconGlyph(n)) return;
/* 真缺口 = 元素**自身**绑定了点击处理,且不可聚焦、也无显式 role。
用 getEventListeners 不可用,改为「有 onclick 属性」或
「cursor:pointer 且其最近的可点祖先不是它自己」两者取一:
这里采用更可靠的判据 —— 元素带 onclick,或是叶子级的 pointer 元素
(子孙中没有同样 pointer 的元素,说明点击落在它自己身上)。 */
var hasOwnHandler = n.hasAttribute('onclick');
var ptr = false;
try { ptr = W.getComputedStyle(n).cursor === 'pointer'; } catch (e) {}
if (onclick || ptr) {
var label = n.tagName.toLowerCase() + (n.className ? '.' + String(n.className).trim().split(/\s+/)[0] : '');
if (fake.indexOf(label) < 0 && fake.length < 3) fake.push(label);
if (!hasOwnHandler && !ptr) return;
/* 内容子元素排除:若向上存在一个「可聚焦或有 role 的 pointer 祖先」,
说明交互项是那个祖先,当前元素只是它的内容(图标/文字/色点/单元格内容)。 */
var anc = n.parentElement, owner = null;
while (anc && anc !== root.parentElement) {
var ancPtr = false;
try { ancPtr = W.getComputedStyle(anc).cursor === 'pointer'; } catch (e) {}
var focusable = anc.hasAttribute('tabindex') || anc.hasAttribute('role') ||
/^(BUTTON|A|INPUT|SELECT|TEXTAREA)$/.test(anc.tagName);
if (ancPtr && focusable) { owner = anc; break; }
anc = anc.parentElement;
}
if (owner) return;
if (!hasOwnHandler) {
var childPtr = false;
for (var i = 0; i < n.children.length; i++) {
try { if (W.getComputedStyle(n.children[i]).cursor === 'pointer') { childPtr = true; break; } } catch (e) {}
}
if (childPtr) return; // 容器:点击目标是子项
}
var label = n.tagName.toLowerCase() + (n.className ? '.' + String(n.className).trim().split(/\s+/)[0] : '');
if (fake.indexOf(label) < 0 && fake.length < 3) fake.push(label);
});
if (kb.length === 0 && fake.length === 0) {
list.push({ id: 'matrix:keyboard-reachable', status: 'skip', ms: 0, err: '静态组件无交互面' });
list.push({ id: 'matrix:aria-or-role', status: 'skip', ms: 0, err: '静态组件无交互面' });
} else {
add('keyboard-reachable', kb.length > 0, fake.length ? '存在鼠标专用交互:' + fake.join('、') : '无 button/a/input/tabindex 元素');
var ariaOk = false;
/* 键盘可达:真缺口 = 「有鼠标交互但不在可聚焦集合里」的非原生元素。
原生 button/a/input/select/textarea 天然可聚焦,不参与判定。 */
add('keyboard-reachable', fake.length === 0, fake.length ? '存在鼠标专用交互:' + fake.join('、') : null);
/* ARIA:原生表单元素(input/select/textarea/button/a)自带语义,
不强制 aria-label —— 那是「附加说明」而非「有无语义」。
真正要求 role/aria 的是被赋予交互行为的非原生元素。 */
var NATIVE = /^(BUTTON|A|INPUT|SELECT|TEXTAREA|OPTION|PROGRESS|METER)$/;
var needAria = [];
Array.prototype.forEach.call(kb, function (n) {
if (n.getAttribute('role') || n.getAttribute('aria-label') || n.getAttribute('aria-disabled') ||
n.getAttribute('aria-expanded') || /^(button|a)$/.test(n.tagName.toLowerCase())) ariaOk = true;
if (NATIVE.test(n.tagName)) return; // 原生语义
if (n.getAttribute('role') || n.getAttribute('aria-label') ||
n.getAttribute('aria-disabled') || n.getAttribute('aria-expanded') ||
n.getAttribute('aria-checked') || n.getAttribute('aria-selected')) return;
needAria.push(n.tagName.toLowerCase() + (n.className ? '.' + String(n.className).trim().split(/\s+/)[0] : ''));
});
add('aria-or-role', ariaOk, '交互元素缺少 role / aria-*');
add('aria-or-role', needAria.length === 0, needAria.length ? '缺少 role / aria-*:' + needAria.slice(0, 3).join('、') : null);
}
var hasFocus = false;
@@ -301,8 +409,18 @@
var fw = parseInt(cs.fontWeight, 10) || (cs.fontWeight === 'bold' ? 700 : 400);
return fs >= 24 || (fs >= 18.66 && fw >= 700);
}
/* 图标字形:无字母数字、纯符号/箭头/装饰字符,或自带 iconfont 类名。
这类节点适用 WCAG 1.4.11 的 3:1,而非 1.4.3 的 4.5:1。 */
function isIconGlyph(n) {
var txt = (n.textContent || '').trim();
if (!txt) return false;
if (/^[\s\u2000-\u3300\u2190-\u21FF\u2600-\u27BF\u2B00-\u2BFF×✕✓⚠⌄⌃‹›«»‹›⏵▶◀▲▼]+$/.test(txt)) return true;
var cls = (n.className || '').toString();
return /\b(icon|arrow|caret|close|clear|toggle|sort|cross|star-|sep|bullet|chevron|indicator)\b/i.test(cls);
}
var contrastFailures = [];
var iconWarnings = [];
var textNodes = root.querySelectorAll('button, a, p, span, label, td, th, li, h1, h2, h3, h4, h5, h6');
textNodes.forEach(function (n) {
if (contrastFailures.length >= 3) return;
@@ -312,23 +430,39 @@
if (!fg) return;
var bg = resolveBg(n);
var r = ratio(composite(fg, bg), bg);
var minRatio = isLargeText(cs) ? 3.0 : 4.5;
/* WCAG 2.1 分两条标准:
1.4.3 Contrast (Minimum) AA —— 文本 ≥ 4.5:1,大字号 ≥ 3:1
1.4.11 Non-text Contrast AA —— 图标/箭头/符号等 UI 图形 ≥ 3:1
纯装饰(分割线、容器边框、背景)不承载信息,不在约束内。 */
var iconOnly = isIconGlyph(n);
var minRatio = iconOnly ? 3.0 : (isLargeText(cs) ? 3.0 : 4.5);
if (r < minRatio) {
var label = n.tagName.toLowerCase() + (n.className ? '.' + String(n.className).trim().split(/\s+/)[0] : '') +
' "' + (n.textContent || '').trim().slice(0, 10) + '"';
contrastFailures.push(label + ' (' + r.toFixed(2) + ':1 < ' + minRatio + ':1)');
var entry = label + ' (' + r.toFixed(2) + ':1 < ' + minRatio + ':1)';
if (iconOnly) { if (iconWarnings.length < 3) iconWarnings.push(entry); }
else contrastFailures.push(entry);
}
});
add('contrast>=4.5', contrastFailures.length === 0, contrastFailures.length ? '低于 WCAG AA:' + contrastFailures.join(';') : null);
if (iconWarnings.length) list.push({ id: 'matrix:icon-contrast', status: 'skip', ms: 0, err: '图标低于 3:1(非阻塞):' + iconWarnings.join(';') });
/* token 一致性:用例区内联 hex 颜色 */
/* token 一致性:用例区内联 hex 颜色。
排除「数据驱动的颜色」——色板选项、轮播卡片背景、图表数据系列等,
它们的色值是内容而非主题,硬编码 16 进制是合理做法。
判定:元素带 data-* 或承担取值/展示数据角色的容器不计。 */
var hexHardcode = 0;
var styled = root.querySelectorAll('[style]');
Array.prototype.forEach.call(styled, function (n) {
var st = n.getAttribute('style') || '';
if (/(^|;)\s*(background|background-color|color|border|border-color|fill|stroke)\s*:[^;]*#[0-9a-fA-F]{3,8}/.test(st)) hexHardcode++;
if (!/(^|;)\s*(background|background-color|color|border|border-color|fill|stroke)\s*:[^;]*#[0-9a-fA-F]{3,8}/.test(st)) return;
// 数据/装饰性颜色豁免:色板、色点、卡片背景、图表系列
var cls = (n.className || '').toString().toLowerCase();
if (/(swatch|preset|palette|color-dot|themesw-color|carousel-(card|slide|item)|chart-|series|legend|data-)/.test(cls)) return;
if (n.hasAttribute('data-c') || n.hasAttribute('data-color') || n.hasAttribute('data-key')) return;
hexHardcode++;
});
add('no-hardcode-hex', hexHardcode === 0, '用例区内联硬编码颜色 ' + hexHardcode + ' 处');
add('no-hardcode-hex', hexHardcode === 0, '用例区内联硬编码颜色 ' + hexHardcode + ' 处(主题性颜色)');
return list;
}
@@ -399,13 +533,15 @@
var totalMs = Math.round(performance.now() - t0);
renderResults(asserts);
var failed = asserts.filter(function (a) { return a.status === 'fail'; });
var res = {
slug: SLUG,
total: asserts.length,
pass: asserts.filter(function (a) { return a.status === 'pass'; }).length,
fail: asserts.filter(function (a) { return a.status === 'fail'; }).length,
fail: failed.length,
skip: asserts.filter(function (a) { return a.status === 'skip'; }).length,
failures: asserts.filter(function (a) { return a.status === 'fail'; }).map(function (a) { return a.id; }).slice(0, 8),
failures: failed.map(function (a) { return a.id; }),
failureDetails: failed.slice(0, 12).map(function (a) { return { id: a.id, err: a.err || '' }; }),
ms: totalMs
};
window.__aaResult = res;
@@ -450,12 +586,25 @@
var frame = document.getElementById(FRAME_ID);
if (frame) {
/* 演示页的内联脚本在 load 后才渲染 DOM(含 is-active 等初始状态类)。
直接断言会读到渲染中途的样式,产生时序性误报。
因此:load → 双 rAF(等一次完整样式重算)→ 再断言。 */
var auto = function () {
if (!bindFrame()) { setFrameState('演示页加载失败', 'fail'); return; }
setTimeout(run, 60);
var W = fWin || window;
var raf = W.requestAnimationFrame || window.requestAnimationFrame ||
function (cb) { return setTimeout(cb, 16); };
raf(function () { raf(function () { setTimeout(run, 30); }); });
};
frame.addEventListener('load', auto);
if (bindFrame()) setTimeout(run, 60); else setFrameState('加载中…');
if (bindFrame()) {
var W0 = fWin || window;
var raf0 = W0.requestAnimationFrame || window.requestAnimationFrame ||
function (cb) { return setTimeout(cb, 16); };
raf0(function () { raf0(function () { setTimeout(run, 30); }); });
} else {
setFrameState('加载中…');
}
} else {
setTimeout(run, 0);
}