@surea11y/core 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/README.md +24 -38
- package/docs/ACT_RULE_MAPPING.md +8 -6
- package/docs/API_STABILITY.md +51 -3
- package/docs/BINDING_AUTHORS_GUIDE.md +104 -2
- package/docs/DESIGN_CHALLENGES.md +66 -0
- package/docs/EARL.md +100 -0
- package/docs/ENGINE_OPTIONS.md +28 -2
- package/docs/INTEGRATION.md +4 -2
- package/docs/LIMITATIONS.md +3 -1
- package/docs/OUTPUT_SCHEMA.md +44 -6
- package/docs/POLICY.md +1 -1
- package/docs/RULE_AUTHORING.md +11 -12
- package/docs/RULE_CATALOG.md +76 -26
- package/docs/RULE_HELPERS.md +333 -0
- package/docs/RULE_TAXONOMY.md +25 -4
- package/docs/SARIF.md +21 -2
- package/docs/WCAG_CONFORMANCE.md +9 -1
- package/package.json +9 -3
- package/src/checks/automatic/aria-allowed-attr.js +6 -0
- package/src/checks/automatic/aria-allowed-role.js +32 -23
- package/src/checks/automatic/aria-braille-equivalent.js +18 -10
- package/src/checks/automatic/aria-conditional-attr.js +17 -10
- package/src/checks/automatic/aria-deprecated-role.js +12 -0
- package/src/checks/automatic/aria-hidden-body.js +1 -1
- package/src/checks/automatic/aria-prohibited-attr.js +5 -0
- package/src/checks/automatic/aria-prohibited-children.js +6 -6
- package/src/checks/automatic/aria-required-attr.js +59 -12
- package/src/checks/automatic/aria-required-children.js +33 -16
- package/src/checks/automatic/aria-required-parent.js +32 -6
- package/src/checks/automatic/aria-role-name-present.js +1 -1
- package/src/checks/automatic/aria-roles-valid.js +52 -21
- package/src/checks/automatic/aria-valid-attr-value.js +74 -21
- package/src/checks/automatic/aria-valid-attr.js +14 -9
- package/src/checks/automatic/avoid-inline-spacing.js +133 -6
- package/src/checks/automatic/contrast-computable.js +10 -0
- package/src/checks/automatic/contrast-enhanced.js +12 -0
- package/src/checks/automatic/contrast-minimum.js +12 -0
- package/src/checks/automatic/css-orientation-lock.js +42 -5
- package/src/checks/automatic/duplicate-id-aria.js +5 -0
- package/src/checks/automatic/duplicate-id.js +13 -8
- package/src/checks/automatic/form-control-single-label.js +9 -0
- package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
- package/src/checks/automatic/iframe-focusable-content.js +5 -0
- package/src/checks/automatic/label-in-name.js +38 -56
- package/src/checks/automatic/link-in-text-block.js +279 -23
- package/src/checks/automatic/target-size-minimum.js +84 -5
- package/src/checks/automatic/td-has-header.js +19 -18
- package/src/checks/manual/form-control-label-quality-manual.js +134 -24
- package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
- package/src/checks/manual/password-paste-enabled-manual.js +255 -0
- package/src/core.js +3863 -44184
- package/src/earl.js +144 -0
- package/src/sarif.js +22 -2
- package/surea11y.browser.js +10 -41039
- package/surea11y.i18n.de.js +2 -21
- package/surea11y.i18n.es.js +2 -21
- package/surea11y.i18n.fr.js +2 -21
- /package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +0 -0
|
@@ -31,13 +31,13 @@
|
|
|
31
31
|
* of the actual adjacent text node(s), a deliberate scope-down, since
|
|
32
32
|
* plain text nodes inherit their rendering from the parent in the
|
|
33
33
|
* overwhelming majority of real markup.
|
|
34
|
-
* -
|
|
35
|
-
*
|
|
36
|
-
* `contrast-
|
|
37
|
-
*
|
|
38
|
-
* reserved for deterministic
|
|
39
|
-
*
|
|
40
|
-
*
|
|
34
|
+
* - A candidate that cannot be evaluated reports cantTell, not pass:
|
|
35
|
+
* contrast not confidently computable (the blockers `contrast-minimum`/
|
|
36
|
+
* `contrast-computable` use), or `text-decoration` unreadable in both
|
|
37
|
+
* the computed style and the CSSOM (see `decorationInfo`). `fail` stays
|
|
38
|
+
* reserved for deterministic violations; this is the computability gate
|
|
39
|
+
* RULE_TAXONOMY.md §1.1 allows automatic rules, as in `contrast-minimum`
|
|
40
|
+
* and `target-size-minimum`.
|
|
41
41
|
* - Reuses the shared `helpers.contrast` subsystem (same
|
|
42
42
|
* computeEffectiveForeground/Background, getComputabilityBlocker,
|
|
43
43
|
* contrastRatio helpers as `contrast-minimum`), rather than re-deriving
|
|
@@ -103,14 +103,176 @@ function runInPage(ctx) {
|
|
|
103
103
|
return null;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
106
|
+
// Whether the element is underlined, and whether the computed style can be
|
|
107
|
+
// trusted to say so. A conforming CSSOM serialises the `text-decoration`
|
|
108
|
+
// shorthand with the line value first, so it and `text-decoration-line`
|
|
109
|
+
// always agree on whether `underline` is present. jsdom does not cascade
|
|
110
|
+
// the property at all: the shorthand reads back as the UA's "underline"
|
|
111
|
+
// for every <a> whatever the author CSS says, and the longhand as "none"
|
|
112
|
+
// unless the author used the longhand. Either one taken alone is wrong in
|
|
113
|
+
// one direction, so disagreement is the signal to stop trusting both.
|
|
114
|
+
function decorationInfo(cs) {
|
|
115
|
+
if (!cs) return { underlined: false, trustworthy: false };
|
|
116
|
+
|
|
117
|
+
const lineRaw = String(cs.textDecorationLine || '')
|
|
118
|
+
.trim()
|
|
119
|
+
.toLowerCase();
|
|
120
|
+
const shortRaw = String(cs.textDecoration || '')
|
|
121
|
+
.trim()
|
|
122
|
+
.toLowerCase();
|
|
123
|
+
|
|
124
|
+
const lineTokens = lineRaw.split(/\s+/).filter(Boolean);
|
|
125
|
+
const shortTokens = shortRaw.split(/\s+/).filter(Boolean);
|
|
126
|
+
|
|
127
|
+
// Only one of the two exposed: nothing to cross-check against, take it.
|
|
128
|
+
if (!lineTokens.length) {
|
|
129
|
+
return { underlined: shortTokens.includes('underline'), trustworthy: shortTokens.length > 0 };
|
|
130
|
+
}
|
|
131
|
+
if (!shortTokens.length) {
|
|
132
|
+
return { underlined: lineTokens.includes('underline'), trustworthy: true };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const byLine = lineTokens.includes('underline');
|
|
136
|
+
const byShort = shortTokens.includes('underline');
|
|
137
|
+
return { underlined: byLine, trustworthy: byLine === byShort };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Resolves `text-decoration` from the author stylesheets when the computed
|
|
141
|
+
// style is untrustworthy, reading the CSSOM as `css-orientation-lock` and
|
|
142
|
+
// `css-focus-indicator-suppressed` do. Without it the rule could not decide
|
|
143
|
+
// anything under a DOM emulator, which is how the CLI scans static HTML.
|
|
144
|
+
//
|
|
145
|
+
// A narrow cascade is enough: `text-decoration-line` is not inherited, so
|
|
146
|
+
// only declarations matching the element itself and its inline style apply,
|
|
147
|
+
// ordered by specificity. With no author declaration the UA default stands,
|
|
148
|
+
// and for a link that is an underline. Anything that would make the answer a
|
|
149
|
+
// guess yields `resolved: false` and the caller reports cantTell.
|
|
150
|
+
const CSS_STYLE_RULE = 1;
|
|
151
|
+
const MAX_NESTED_DEPTH = 8;
|
|
152
|
+
|
|
153
|
+
function splitSelectorList(selectorText) {
|
|
154
|
+
const parts = [];
|
|
155
|
+
let depth = 0;
|
|
156
|
+
let current = '';
|
|
157
|
+
for (const ch of String(selectorText || '')) {
|
|
158
|
+
if (ch === '(') depth += 1;
|
|
159
|
+
if (ch === ')') depth = Math.max(0, depth - 1);
|
|
160
|
+
if (ch === ',' && depth === 0) {
|
|
161
|
+
parts.push(current);
|
|
162
|
+
current = '';
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
current += ch;
|
|
166
|
+
}
|
|
167
|
+
parts.push(current);
|
|
168
|
+
return parts.map((p) => p.trim()).filter(Boolean);
|
|
110
169
|
}
|
|
111
170
|
|
|
112
|
-
|
|
113
|
-
|
|
171
|
+
// Approximate CSS specificity as a single sortable integer. Exactness is
|
|
172
|
+
// not required: this only orders declarations of one property against each
|
|
173
|
+
// other, and near-ties are broken by document order as the cascade does.
|
|
174
|
+
function specificityOf(selector) {
|
|
175
|
+
const s = String(selector || '');
|
|
176
|
+
const ids = (s.match(/#[\w-]+/g) || []).length;
|
|
177
|
+
const classesEtc = (s.match(/\.[\w-]+|\[[^\]]*\]|:(?!:)[\w-]+/g) || []).length;
|
|
178
|
+
const types = (s.match(/(^|[\s>+~])[a-z][\w-]*/gi) || []).length;
|
|
179
|
+
return ids * 10000 + classesEtc * 100 + types;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// A declaration wins if it is the last one, in (specificity, order), whose
|
|
183
|
+
// selector matches. `!important` outranks everything non-important.
|
|
184
|
+
function underlineFromDeclaration(style) {
|
|
185
|
+
if (!style || typeof style.getPropertyValue !== 'function') return null;
|
|
186
|
+
for (const prop of ['text-decoration-line', 'text-decoration']) {
|
|
187
|
+
const raw = String(style.getPropertyValue(prop) || '')
|
|
188
|
+
.trim()
|
|
189
|
+
.toLowerCase();
|
|
190
|
+
if (!raw) continue;
|
|
191
|
+
const important = String(style.getPropertyPriority(prop) || '') === 'important';
|
|
192
|
+
return { underlined: /\bunderline\b/.test(raw), important };
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function resolveUnderlineFromCssom(el) {
|
|
198
|
+
const doc = el && el.ownerDocument ? el.ownerDocument : null;
|
|
199
|
+
if (!doc || typeof el.matches !== 'function') return { underlined: false, resolved: false };
|
|
200
|
+
|
|
201
|
+
let best = null; // { rank, order, underlined }
|
|
202
|
+
let order = 0;
|
|
203
|
+
let unreadableSheet = false;
|
|
204
|
+
let unparsableSelector = false;
|
|
205
|
+
|
|
206
|
+
function consider(cssRule) {
|
|
207
|
+
const decl = underlineFromDeclaration(cssRule.style);
|
|
208
|
+
if (!decl) return;
|
|
209
|
+
for (const part of splitSelectorList(cssRule.selectorText)) {
|
|
210
|
+
// A pseudo-element rule paints a box other than the link's own text.
|
|
211
|
+
if (/::[a-z-]+/i.test(part)) continue;
|
|
212
|
+
// A state the static DOM is not in (:hover/:focus/...) does not
|
|
213
|
+
// describe the link's resting appearance, which is what this rule is
|
|
214
|
+
// about.
|
|
215
|
+
if (/:(hover|focus|focus-visible|focus-within|active|target|visited)\b/i.test(part)) {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
let matched;
|
|
219
|
+
try {
|
|
220
|
+
matched = el.matches(part);
|
|
221
|
+
} catch {
|
|
222
|
+
unparsableSelector = true;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (!matched) continue;
|
|
226
|
+
order += 1;
|
|
227
|
+
const rank = (decl.important ? 1e9 : 0) + specificityOf(part);
|
|
228
|
+
if (!best || rank >= best.rank) best = { rank, order, underlined: decl.underlined };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function walk(rules, depth) {
|
|
233
|
+
if (!rules || depth > MAX_NESTED_DEPTH) return;
|
|
234
|
+
for (const cssRule of rules) {
|
|
235
|
+
if (!cssRule) continue;
|
|
236
|
+
if (cssRule.type === CSS_STYLE_RULE && cssRule.selectorText) {
|
|
237
|
+
consider(cssRule);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
let nested;
|
|
241
|
+
try {
|
|
242
|
+
nested = cssRule.cssRules || null;
|
|
243
|
+
} catch {
|
|
244
|
+
nested = null;
|
|
245
|
+
}
|
|
246
|
+
if (nested) walk(nested, depth + 1);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
for (const sheet of doc.styleSheets || []) {
|
|
252
|
+
let rules = null;
|
|
253
|
+
try {
|
|
254
|
+
rules = sheet && sheet.cssRules ? sheet.cssRules : null;
|
|
255
|
+
} catch {
|
|
256
|
+
unreadableSheet = true; // cross-origin, not inspectable
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (rules) walk(rules, 0);
|
|
260
|
+
}
|
|
261
|
+
} catch {
|
|
262
|
+
return { underlined: false, resolved: false };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// The inline style attribute outranks every stylesheet declaration.
|
|
266
|
+
const inline = underlineFromDeclaration(el.style);
|
|
267
|
+
if (inline) return { underlined: inline.underlined, resolved: true };
|
|
268
|
+
|
|
269
|
+
if (best) return { underlined: best.underlined, resolved: true };
|
|
270
|
+
|
|
271
|
+
// No author declaration reached this element. If a sheet or selector was
|
|
272
|
+
// unreadable, one of them might have, so the answer is unknown; otherwise
|
|
273
|
+
// the UA default stands, and for a link that means underlined.
|
|
274
|
+
if (unreadableSheet || unparsableSelector) return { underlined: false, resolved: false };
|
|
275
|
+
return { underlined: true, resolved: true };
|
|
114
276
|
}
|
|
115
277
|
|
|
116
278
|
function hasSurroundingText(el, parent) {
|
|
@@ -141,7 +303,15 @@ function runInPage(ctx) {
|
|
|
141
303
|
: helpers.queryAll(selector);
|
|
142
304
|
|
|
143
305
|
const occurrences = [];
|
|
306
|
+
const undecided = [];
|
|
144
307
|
let applicableCount = 0;
|
|
308
|
+
let decidedCount = 0;
|
|
309
|
+
|
|
310
|
+
// Applicable, but not evaluable. Held separately so the outcome below can
|
|
311
|
+
// tell "checked and sound" apart from "never decided".
|
|
312
|
+
function markUndecided(el, reasonCode) {
|
|
313
|
+
undecided.push({ el, reasonCode });
|
|
314
|
+
}
|
|
145
315
|
|
|
146
316
|
for (const el of nodes) {
|
|
147
317
|
if (!el || !el.getAttribute) continue;
|
|
@@ -159,27 +329,40 @@ function runInPage(ctx) {
|
|
|
159
329
|
const linkCs = safeComputedStyle(el);
|
|
160
330
|
const parentCs = safeComputedStyle(parent);
|
|
161
331
|
|
|
162
|
-
|
|
163
|
-
|
|
332
|
+
// Cues that do not depend on `text-decoration` come first, so a link
|
|
333
|
+
// carrying one is decided even where decoration is unreadable.
|
|
164
334
|
const linkWeight = c && linkCs ? c.normalizeFontWeight(linkCs.fontWeight) : 400;
|
|
165
335
|
const parentWeight = c && parentCs ? c.normalizeFontWeight(parentCs.fontWeight) : 400;
|
|
166
|
-
if (linkWeight !== parentWeight)
|
|
336
|
+
if (linkWeight !== parentWeight) {
|
|
337
|
+
decidedCount += 1;
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
167
340
|
|
|
168
341
|
const linkStyle = (linkCs && linkCs.fontStyle) || 'normal';
|
|
169
342
|
const parentStyle = (parentCs && parentCs.fontStyle) || 'normal';
|
|
170
|
-
if (linkStyle !== parentStyle)
|
|
343
|
+
if (linkStyle !== parentStyle) {
|
|
344
|
+
decidedCount += 1;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
171
347
|
|
|
172
|
-
if (!c)
|
|
348
|
+
if (!c) {
|
|
349
|
+
markUndecided(el, 'CONTRAST_HELPERS_UNAVAILABLE');
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
173
352
|
|
|
174
353
|
let flagged = false;
|
|
354
|
+
let computed = false;
|
|
175
355
|
let ratio = null;
|
|
176
356
|
let fgLinkHex = '';
|
|
177
357
|
let fgParentHex = '';
|
|
358
|
+
let undecidedReason = 'COLOR_NOT_COMPUTABLE';
|
|
178
359
|
|
|
179
360
|
try {
|
|
180
361
|
const blocker = c.getComputabilityBlocker(el);
|
|
181
362
|
if (blocker && blocker.ok === false) {
|
|
182
|
-
// Not confidently computable
|
|
363
|
+
// Not confidently computable: recorded below rather than skipped, so
|
|
364
|
+
// it cannot be mistaken for a clean result.
|
|
365
|
+
if (blocker.reasonCode) undecidedReason = String(blocker.reasonCode);
|
|
183
366
|
} else {
|
|
184
367
|
const bg = c.computeEffectiveBackground(el, {
|
|
185
368
|
contrast: { mode, rootCanvasFallback },
|
|
@@ -202,15 +385,49 @@ function runInPage(ctx) {
|
|
|
202
385
|
fgLinkHex = c.rgbToHex ? c.rgbToHex(fgLinkOpaque) : '';
|
|
203
386
|
fgParentHex = c.rgbToHex ? c.rgbToHex(fgParentOpaque) : '';
|
|
204
387
|
|
|
388
|
+
computed = true;
|
|
205
389
|
if (!(ratio >= 3)) flagged = true;
|
|
206
390
|
}
|
|
207
|
-
// else: not confidently computable,
|
|
391
|
+
// else: not confidently computable, recorded below.
|
|
208
392
|
}
|
|
209
393
|
} catch {
|
|
210
|
-
//
|
|
394
|
+
// No-throw: treat as not computable and record it.
|
|
395
|
+
undecidedReason = 'ENGINE_EXCEPTION';
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (!computed) {
|
|
399
|
+
markUndecided(el, undecidedReason);
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Contrast alone is an accepted alternative to an underline (G183), so a
|
|
404
|
+
// link clearing 3:1 is distinguishable regardless of decoration.
|
|
405
|
+
if (!flagged) {
|
|
406
|
+
decidedCount += 1;
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Below 3:1, an underline is the last remaining cue -- and only now does
|
|
411
|
+
// it matter whether this environment can actually report one.
|
|
412
|
+
const decoration = decorationInfo(linkCs);
|
|
413
|
+
let underlined;
|
|
414
|
+
if (decoration.trustworthy) {
|
|
415
|
+
underlined = decoration.underlined;
|
|
416
|
+
} else {
|
|
417
|
+
const fromCssom = resolveUnderlineFromCssom(el);
|
|
418
|
+
if (!fromCssom.resolved) {
|
|
419
|
+
markUndecided(el, 'TEXT_DECORATION_NOT_RESOLVABLE');
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
underlined = fromCssom.underlined;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (underlined) {
|
|
426
|
+
decidedCount += 1;
|
|
427
|
+
continue;
|
|
211
428
|
}
|
|
212
429
|
|
|
213
|
-
|
|
430
|
+
decidedCount += 1;
|
|
214
431
|
|
|
215
432
|
const eligInfo = helpers.getEligibilityInfo
|
|
216
433
|
? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' })
|
|
@@ -243,6 +460,9 @@ function runInPage(ctx) {
|
|
|
243
460
|
if (applicableCount === 0) {
|
|
244
461
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
245
462
|
}
|
|
463
|
+
|
|
464
|
+
// A proven violation outranks an undecided candidate, which outranks a clean
|
|
465
|
+
// one. The middle step keeps an unevaluable link out of `pass`.
|
|
246
466
|
if (occurrences.length) {
|
|
247
467
|
return {
|
|
248
468
|
ruleId: rule.ruleId,
|
|
@@ -251,7 +471,43 @@ function runInPage(ctx) {
|
|
|
251
471
|
occurrences
|
|
252
472
|
};
|
|
253
473
|
}
|
|
254
|
-
|
|
474
|
+
|
|
475
|
+
if (undecided.length) {
|
|
476
|
+
return {
|
|
477
|
+
ruleId: rule.ruleId,
|
|
478
|
+
outcome: 'cantTell',
|
|
479
|
+
severity: rule.defaultSeverity || 'serious',
|
|
480
|
+
confidence: 'low',
|
|
481
|
+
occurrences: undecided.map(({ el, reasonCode }) =>
|
|
482
|
+
helpers.reportOccurrence(el, {
|
|
483
|
+
summary:
|
|
484
|
+
'Whether this link is distinguishable from the surrounding text by non-color means could not be determined.',
|
|
485
|
+
hint: 'Confirm by eye that the link carries an underline, a font-weight or font-style difference, or at least 3:1 contrast against the surrounding text. Running the engine in a real browser rather than a DOM emulator resolves most cases automatically.',
|
|
486
|
+
i18n: {
|
|
487
|
+
summaryKey: 'linkInTextBlock_summary_cantTell',
|
|
488
|
+
hintKey: 'linkInTextBlock_hint_cantTell'
|
|
489
|
+
},
|
|
490
|
+
uncertainty: {
|
|
491
|
+
code: 'not-computable',
|
|
492
|
+
needed:
|
|
493
|
+
'Whether the link carries an underline, weight or style difference, or 3:1 contrast against its surrounding text.',
|
|
494
|
+
evidence: { reasonCode }
|
|
495
|
+
},
|
|
496
|
+
data: {
|
|
497
|
+
visibilityFilter: helpers.getEligibilityInfo
|
|
498
|
+
? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' })
|
|
499
|
+
: { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
500
|
+
details: { reasonCode }
|
|
501
|
+
}
|
|
502
|
+
})
|
|
503
|
+
)
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (decidedCount > 0) {
|
|
508
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
509
|
+
}
|
|
510
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
255
511
|
}
|
|
256
512
|
|
|
257
513
|
module.exports = { id, meta, runInPage };
|
|
@@ -293,14 +293,29 @@ function runInPage(ctx) {
|
|
|
293
293
|
}
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
+
// Per-run memoization: getComputedStyle is a real, non-trivial cost in an
|
|
297
|
+
// actual browser (unlike jsdom, which no-ops it), and hasSpacingConflict
|
|
298
|
+
// below calls getStyle on the SAME candidate element repeatedly -- once
|
|
299
|
+
// per undersized target it's compared against, via
|
|
300
|
+
// isInlineTextExceptionTarget/isInlineLinkTarget -- so with U undersized
|
|
301
|
+
// targets and N total candidates this was an uncached O(U * N)
|
|
302
|
+
// getComputedStyle call count. The DOM is read-only for the rest of this
|
|
303
|
+
// rule's run (no writes between reads), so caching per element here is
|
|
304
|
+
// safe -- style cannot change mid-run.
|
|
305
|
+
const __styleCache = new WeakMap();
|
|
296
306
|
function getStyle(el) {
|
|
307
|
+
if (__styleCache.has(el)) return __styleCache.get(el);
|
|
308
|
+
let cs;
|
|
297
309
|
try {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
310
|
+
cs =
|
|
311
|
+
document && document.defaultView && document.defaultView.getComputedStyle
|
|
312
|
+
? document.defaultView.getComputedStyle(el)
|
|
313
|
+
: null;
|
|
301
314
|
} catch {
|
|
302
|
-
|
|
315
|
+
cs = null;
|
|
303
316
|
}
|
|
317
|
+
__styleCache.set(el, cs);
|
|
318
|
+
return cs;
|
|
304
319
|
}
|
|
305
320
|
|
|
306
321
|
function isPointerReachable(el) {
|
|
@@ -431,6 +446,46 @@ function runInPage(ctx) {
|
|
|
431
446
|
|
|
432
447
|
const undersized = items.filter((it) => it.rect.width < MIN || it.rect.height < MIN);
|
|
433
448
|
|
|
449
|
+
// Spatial grid over ALL items, cell size = MIN (24px), so hasSpacingConflict's
|
|
450
|
+
// proximity check below doesn't have to compare every undersized target
|
|
451
|
+
// against every other item -- an O(items^2) cost that dominates real-browser
|
|
452
|
+
// (not jsdom -- see this rule's own perf note further down) runtime on a
|
|
453
|
+
// page with many small targets. A point can only be within MIN of another
|
|
454
|
+
// point that shares its grid cell or one of the 8 adjacent cells: cell size
|
|
455
|
+
// equals the search radius, so two points in cells 2+ apart on either axis
|
|
456
|
+
// are, on that axis alone, already >= MIN apart. Restricting the candidate
|
|
457
|
+
// set to that 3x3 neighborhood is therefore never a false negative -- the
|
|
458
|
+
// exact same dist() < MIN check still runs on every candidate it returns,
|
|
459
|
+
// just skipping candidates that are geometrically guaranteed too far away.
|
|
460
|
+
// This changes performance only, never which targets conflict.
|
|
461
|
+
const grid = new Map(); // "cx,cy" -> item[]
|
|
462
|
+
function cellKeyFor(cx, cy) {
|
|
463
|
+
return cx + ',' + cy;
|
|
464
|
+
}
|
|
465
|
+
for (const it of items) {
|
|
466
|
+
const cx = Math.floor(it.center.cx / MIN);
|
|
467
|
+
const cy = Math.floor(it.center.cy / MIN);
|
|
468
|
+
const key = cellKeyFor(cx, cy);
|
|
469
|
+
let bucket = grid.get(key);
|
|
470
|
+
if (!bucket) {
|
|
471
|
+
bucket = [];
|
|
472
|
+
grid.set(key, bucket);
|
|
473
|
+
}
|
|
474
|
+
bucket.push(it);
|
|
475
|
+
}
|
|
476
|
+
function nearbyItems(center) {
|
|
477
|
+
const cx = Math.floor(center.cx / MIN);
|
|
478
|
+
const cy = Math.floor(center.cy / MIN);
|
|
479
|
+
const out = [];
|
|
480
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
481
|
+
for (let dy = -1; dy <= 1; dy++) {
|
|
482
|
+
const bucket = grid.get(cellKeyFor(cx + dx, cy + dy));
|
|
483
|
+
if (bucket) for (const it of bucket) out.push(it);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return out;
|
|
487
|
+
}
|
|
488
|
+
|
|
434
489
|
// --- spacing/occlusion evaluation ---
|
|
435
490
|
function hasSpacingConflict(target) {
|
|
436
491
|
// 0) Pure geometry: deterministic center-distance check against ANY
|
|
@@ -438,7 +493,7 @@ function runInPage(ctx) {
|
|
|
438
493
|
// spacing exception depends on proximity to any adjacent target, so an
|
|
439
494
|
// undersized target sitting flush against an adequately-sized one still
|
|
440
495
|
// fails the exception, which an undersized-only comparison would miss.
|
|
441
|
-
for (const other of
|
|
496
|
+
for (const other of nearbyItems(target.center)) {
|
|
442
497
|
if (!other || !other.el || isRelated(target.el, other.el)) continue;
|
|
443
498
|
|
|
444
499
|
// Ignore inline-text exception targets when evaluating spacing conflicts.
|
|
@@ -614,6 +669,14 @@ function runInPage(ctx) {
|
|
|
614
669
|
hintKey: 'targetSizeMinimum_hint_cantTell_ambiguousSpacing',
|
|
615
670
|
params: {}
|
|
616
671
|
},
|
|
672
|
+
uncertainty: {
|
|
673
|
+
code: 'not-computable',
|
|
674
|
+
needed: 'A reliable measurement of the spacing between this target and its neighbour.',
|
|
675
|
+
evidence: {
|
|
676
|
+
measured: { width: it.rect.width, height: it.rect.height },
|
|
677
|
+
conflictHitCount: info.hitCount
|
|
678
|
+
}
|
|
679
|
+
},
|
|
617
680
|
data: {
|
|
618
681
|
details: {
|
|
619
682
|
measured: { width: it.rect.width, height: it.rect.height },
|
|
@@ -643,6 +706,14 @@ function runInPage(ctx) {
|
|
|
643
706
|
hintKey: 'targetSizeMinimum_hint_cantTell_plausiblyEssential',
|
|
644
707
|
params: {}
|
|
645
708
|
},
|
|
709
|
+
uncertainty: {
|
|
710
|
+
code: 'judgement-required',
|
|
711
|
+
needed: 'Whether this target’s size is essential, which WCAG exempts.',
|
|
712
|
+
evidence: {
|
|
713
|
+
measured: { width: it.rect.width, height: it.rect.height },
|
|
714
|
+
conflictHitCount: info.hitCount
|
|
715
|
+
}
|
|
716
|
+
},
|
|
646
717
|
data: {
|
|
647
718
|
details: {
|
|
648
719
|
measured: { width: it.rect.width, height: it.rect.height },
|
|
@@ -671,6 +742,14 @@ function runInPage(ctx) {
|
|
|
671
742
|
hintKey: 'targetSizeMinimum_hint_cantTell_inlineLinkRun',
|
|
672
743
|
params: {}
|
|
673
744
|
},
|
|
745
|
+
uncertainty: {
|
|
746
|
+
code: 'judgement-required',
|
|
747
|
+
needed: 'Whether this target is a link in a sentence, which WCAG exempts.',
|
|
748
|
+
evidence: {
|
|
749
|
+
measured: { width: it.rect.width, height: it.rect.height },
|
|
750
|
+
conflictHitCount: info.hitCount
|
|
751
|
+
}
|
|
752
|
+
},
|
|
674
753
|
data: {
|
|
675
754
|
details: {
|
|
676
755
|
measured: { width: it.rect.width, height: it.rect.height },
|
|
@@ -118,27 +118,28 @@ function runInPage(ctx) {
|
|
|
118
118
|
return !!(cell && cell.tagName && cell.tagName.toLowerCase() === 'th' && isEligible(cell));
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
function hasRowHeaderBefore(r, c) {
|
|
130
|
-
const cells = rowCells[r] || [];
|
|
131
|
-
for (let ci = 0; ci < c; ci++) {
|
|
132
|
-
if (isHeaderCell(cells[ci])) return true;
|
|
133
|
-
}
|
|
134
|
-
return false;
|
|
135
|
-
}
|
|
121
|
+
// "Was there a <th> above this cell's column" and "was there a <th>
|
|
122
|
+
// earlier in this cell's row" are both prefix questions over a scan
|
|
123
|
+
// already in progress (rows top to bottom, cells left to right within a
|
|
124
|
+
// row), so each is tracked incrementally instead of rescanning the
|
|
125
|
+
// rows/columns already passed for every cell: colHasHeaderAbove[c]
|
|
126
|
+
// carries forward across rows, rowHasHeaderBefore resets at the start
|
|
127
|
+
// of each row. One pass over every cell, not one rescan per cell.
|
|
128
|
+
const colHasHeaderAbove = new Array(maxCols).fill(false);
|
|
136
129
|
|
|
137
130
|
for (let r = 0; r < rowCells.length; r++) {
|
|
138
131
|
const cells = rowCells[r];
|
|
132
|
+
let rowHasHeaderBefore = false;
|
|
133
|
+
|
|
139
134
|
for (let c = 0; c < cells.length; c++) {
|
|
140
135
|
const cell = cells[c];
|
|
141
|
-
if (!cell
|
|
136
|
+
if (!cell) continue;
|
|
137
|
+
|
|
138
|
+
if (isHeaderCell(cell)) {
|
|
139
|
+
colHasHeaderAbove[c] = true;
|
|
140
|
+
rowHasHeaderBefore = true;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
142
143
|
|
|
143
144
|
// An aria-hidden data cell isn't exposed to AT either, so it has
|
|
144
145
|
// no need for an accessible header association.
|
|
@@ -147,8 +148,8 @@ function runInPage(ctx) {
|
|
|
147
148
|
const headersAttr = trim(cell.getAttribute('headers'));
|
|
148
149
|
if (headersAttr) continue;
|
|
149
150
|
|
|
150
|
-
if (
|
|
151
|
-
if (
|
|
151
|
+
if (colHasHeaderAbove[c]) continue;
|
|
152
|
+
if (rowHasHeaderBefore) continue;
|
|
152
153
|
|
|
153
154
|
occurrences.push(
|
|
154
155
|
helpers.reportOccurrence(cell, {
|