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:
+175
-26
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user