@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
|
@@ -206,29 +206,63 @@ function runInPage(ctx) {
|
|
|
206
206
|
return out;
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
+
// Index of `<label for="...">` elements by their `for` value, built once
|
|
210
|
+
// (not the native `el.labels`, deliberately -- see getNativeLabels).
|
|
211
|
+
const labelsByForId = new Map();
|
|
212
|
+
try {
|
|
213
|
+
for (const label of document.querySelectorAll('label[for]')) {
|
|
214
|
+
const forVal = normalizeWs(label.getAttribute('for'));
|
|
215
|
+
if (!forVal) continue;
|
|
216
|
+
const bucket = labelsByForId.get(forVal);
|
|
217
|
+
if (bucket) bucket.push(label);
|
|
218
|
+
else labelsByForId.set(forVal, [label]);
|
|
219
|
+
}
|
|
220
|
+
} catch {
|
|
221
|
+
// labelsByForId stays empty; getNativeLabels still has the wrapping-label check
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Per HTML's label-control algorithm, a wrapping <label> with no `for`
|
|
225
|
+
// attribute is associated with its own FIRST labelable descendant only --
|
|
226
|
+
// these are exactly the tags that count (input excluding type=hidden,
|
|
227
|
+
// which FIELD_SELECTOR already excludes from `el` itself, but a wrapping
|
|
228
|
+
// label could still wrap a hidden input ahead of the real field).
|
|
229
|
+
const LABELABLE_SELECTOR =
|
|
230
|
+
'input:not([type="hidden"]), select, textarea, button, meter, output, progress';
|
|
231
|
+
|
|
232
|
+
// The native `el.labels` accessor is spec-correct but, in this engine's
|
|
233
|
+
// supported Node/jsdom runtime (see tests/node-runtime-parity.test.js),
|
|
234
|
+
// jsdom implements it as a live query that walks the WHOLE document on
|
|
235
|
+
// every access, and for every `<label for>` it passes, calls `.control`
|
|
236
|
+
// -- itself another whole-document walk to resolve that id (jsdom's
|
|
237
|
+
// form-controls.js getLabelsForLabelable / HTMLLabelElement-impl.js
|
|
238
|
+
// `get control`). Called once per field, that's the O(fields * document
|
|
239
|
+
// size) cost that used to dominate this rule under jsdom (a real browser
|
|
240
|
+
// maintains an internal id index, so this cost is jsdom-specific, but
|
|
241
|
+
// jsdom is a real, tested runtime for this engine, not just a benchmark
|
|
242
|
+
// artifact). A `for`-attribute index built once above, plus a bounded
|
|
243
|
+
// `closest('label')` walk, answers the same question in O(1) amortized
|
|
244
|
+
// per field instead.
|
|
209
245
|
function getNativeLabels(el) {
|
|
210
246
|
const labels = [];
|
|
211
|
-
try {
|
|
212
|
-
if (el.labels && el.labels.length) {
|
|
213
|
-
for (const label of el.labels) labels.push(label);
|
|
214
|
-
return labels;
|
|
215
|
-
}
|
|
216
|
-
} catch {
|
|
217
|
-
// fall through to the manual lookup
|
|
218
|
-
}
|
|
219
247
|
const idVal = normalizeWs(el.getAttribute && el.getAttribute('id'));
|
|
220
248
|
if (idVal) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
}
|
|
225
|
-
} catch {
|
|
226
|
-
// ignore
|
|
249
|
+
const forLabels = labelsByForId.get(idVal);
|
|
250
|
+
if (forLabels) {
|
|
251
|
+
for (const label of forLabels) labels.push(label);
|
|
227
252
|
}
|
|
228
253
|
}
|
|
229
254
|
try {
|
|
230
255
|
const wrapping = el.closest ? el.closest('label') : null;
|
|
231
|
-
|
|
256
|
+
const hasForAttr = !!(wrapping && wrapping.hasAttribute && wrapping.hasAttribute('for'));
|
|
257
|
+
if (wrapping && !hasForAttr && labels.indexOf(wrapping) === -1) {
|
|
258
|
+
let firstControl = null;
|
|
259
|
+
try {
|
|
260
|
+
firstControl = wrapping.querySelector ? wrapping.querySelector(LABELABLE_SELECTOR) : null;
|
|
261
|
+
} catch {
|
|
262
|
+
firstControl = null;
|
|
263
|
+
}
|
|
264
|
+
if (firstControl === el) labels.push(wrapping);
|
|
265
|
+
}
|
|
232
266
|
} catch {
|
|
233
267
|
// ignore
|
|
234
268
|
}
|
|
@@ -269,15 +303,17 @@ function runInPage(ctx) {
|
|
|
269
303
|
}
|
|
270
304
|
}
|
|
271
305
|
|
|
306
|
+
// Nearest preceding *visible* heading text, per field. Precomputed below
|
|
307
|
+
// (once `fields` is built) via a single document-order sweep rather than
|
|
308
|
+
// scanning the full `headings` array backward for every field: `headings`
|
|
309
|
+
// and `fields` are each already in document order (both come from
|
|
310
|
+
// querySelectorAll/queryAllSmart), so a two-pointer merge answers every
|
|
311
|
+
// field in one O(fields + headings) pass instead of the O(fields *
|
|
312
|
+
// headings) pairwise compareDocumentPosition/isVisible calls a per-field
|
|
313
|
+
// backward scan requires -- the dominant cost on heading/form-heavy pages.
|
|
314
|
+
const nearestVisibleHeadingByField = new Map();
|
|
272
315
|
function nearestVisibleHeadingText(el) {
|
|
273
|
-
|
|
274
|
-
const heading = headings[i];
|
|
275
|
-
if (!precedes(heading, el)) continue;
|
|
276
|
-
if (!isVisible(heading)) continue;
|
|
277
|
-
const text = normalizeWs(heading.textContent);
|
|
278
|
-
if (text) return text;
|
|
279
|
-
}
|
|
280
|
-
return '';
|
|
316
|
+
return nearestVisibleHeadingByField.get(el) || '';
|
|
281
317
|
}
|
|
282
318
|
|
|
283
319
|
function fieldsetLegendText(el) {
|
|
@@ -309,6 +345,9 @@ function runInPage(ctx) {
|
|
|
309
345
|
|
|
310
346
|
// A table row or list item carries its own context (the product name a
|
|
311
347
|
// repeated "Quantity" field belongs to), so it takes part in the key.
|
|
348
|
+
// `row.textContent` serializes the whole row subtree, so it's memoized per
|
|
349
|
+
// row element -- several fields (one per column) commonly share a row.
|
|
350
|
+
const rowTextCache = new Map();
|
|
312
351
|
function rowContextText(el, labelText) {
|
|
313
352
|
let row;
|
|
314
353
|
try {
|
|
@@ -317,7 +356,11 @@ function runInPage(ctx) {
|
|
|
317
356
|
row = null;
|
|
318
357
|
}
|
|
319
358
|
if (!row || !isVisible(row)) return '';
|
|
320
|
-
|
|
359
|
+
let text = rowTextCache.get(row);
|
|
360
|
+
if (text === undefined) {
|
|
361
|
+
text = normalizeWs(row.textContent);
|
|
362
|
+
rowTextCache.set(row, text);
|
|
363
|
+
}
|
|
321
364
|
if (!text) return '';
|
|
322
365
|
return normalizeWs(text.split(labelText).join(' '));
|
|
323
366
|
}
|
|
@@ -348,6 +391,73 @@ function runInPage(ctx) {
|
|
|
348
391
|
});
|
|
349
392
|
}
|
|
350
393
|
|
|
394
|
+
// Populate nearestVisibleHeadingByField (declared above nearestVisibleHeadingText)
|
|
395
|
+
// via a single two-pointer sweep instead of, per field, scanning the full
|
|
396
|
+
// `headings` array backward and calling compareDocumentPosition/isVisible
|
|
397
|
+
// against every one of them -- the O(fields * headings) cost that used to
|
|
398
|
+
// dominate this rule (and the whole engine) on heading/form-heavy pages.
|
|
399
|
+
//
|
|
400
|
+
// Two correctness precautions this needs, since a two-pointer merge only
|
|
401
|
+
// works over sequences that are BOTH already in real document order:
|
|
402
|
+
//
|
|
403
|
+
// 1. A field inside a shadow root has no document-order relationship to
|
|
404
|
+
// any heading at all: per the DOM spec, compareDocumentPosition
|
|
405
|
+
// between nodes in different trees returns an implementation-specific
|
|
406
|
+
// (not document-order-derived) PRECEDING/FOLLOWING bit. Merging it in
|
|
407
|
+
// would be meaningless and could even desync the sweep for later
|
|
408
|
+
// fields, so it's filtered out up front and always answered '' --
|
|
409
|
+
// matching the "no light-DOM heading can be this field's visible
|
|
410
|
+
// context" reading rather than trusting that arbitrary bit.
|
|
411
|
+
// 2. `fields` (from queryAllSmart) is NOT guaranteed to be globally
|
|
412
|
+
// document-order: it's built root by root (see resolveContextRoots),
|
|
413
|
+
// and a multi-region `engineOptions.contextSelector` array is resolved
|
|
414
|
+
// in the CALLER's array order, not sorted by document position. A
|
|
415
|
+
// fresh copy is sorted by real position before the sweep so the merge
|
|
416
|
+
// is correct regardless of contextSelector's region order (this is a
|
|
417
|
+
// cheap O(k log k) sort, not the O(n*m) cost being fixed).
|
|
418
|
+
{
|
|
419
|
+
const orderedFields = [];
|
|
420
|
+
for (const field of fields) {
|
|
421
|
+
let sameRoot;
|
|
422
|
+
try {
|
|
423
|
+
sameRoot =
|
|
424
|
+
typeof field.el.getRootNode !== 'function' || field.el.getRootNode() === document;
|
|
425
|
+
} catch {
|
|
426
|
+
sameRoot = true;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (!sameRoot) {
|
|
430
|
+
nearestVisibleHeadingByField.set(field.el, '');
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
orderedFields.push(field);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
orderedFields.sort((a, b) => {
|
|
437
|
+
try {
|
|
438
|
+
const bits = a.el.compareDocumentPosition(b.el);
|
|
439
|
+
if (bits & 4) return -1; // b follows a
|
|
440
|
+
if (bits & 2) return 1; // b precedes a
|
|
441
|
+
} catch {
|
|
442
|
+
/* ignore -- treat as equal/unordered */
|
|
443
|
+
}
|
|
444
|
+
return 0;
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
let hIdx = 0;
|
|
448
|
+
let current = '';
|
|
449
|
+
for (const field of orderedFields) {
|
|
450
|
+
while (hIdx < headings.length && precedes(headings[hIdx], field.el)) {
|
|
451
|
+
const heading = headings[hIdx];
|
|
452
|
+
hIdx += 1;
|
|
453
|
+
if (!isVisible(heading)) continue;
|
|
454
|
+
const text = normalizeWs(heading.textContent);
|
|
455
|
+
if (text) current = text;
|
|
456
|
+
}
|
|
457
|
+
nearestVisibleHeadingByField.set(field.el, current);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
351
461
|
if (!fields.length) {
|
|
352
462
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
353
463
|
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @check landmark-complementary-is-top-level
|
|
7
|
+
* @atomic true
|
|
8
|
+
* @summary The complementary landmark must not be nested inside another landmark
|
|
9
|
+
* @standard Best Practices (no formal WCAG Success Criterion)
|
|
10
|
+
* @applicability
|
|
11
|
+
* Applies whenever the page contains at least one element carrying the
|
|
12
|
+
* complementary role: explicit role="complementary", or an <aside> that
|
|
13
|
+
* keeps its implicit role (see implementation notes on when it does not).
|
|
14
|
+
* @expectation
|
|
15
|
+
* No complementary candidate has an ancestor that is itself a landmark
|
|
16
|
+
* region. Complementary content supports the main content of the page and
|
|
17
|
+
* sits beside it; nested inside another landmark it is a section of that
|
|
18
|
+
* landmark instead, which is not what landmark navigation announces.
|
|
19
|
+
* @implementation-notes
|
|
20
|
+
* - Not WCAG-normative, authored as an advisory, cantTell-capped
|
|
21
|
+
* `type: 'manual'` rule, matching its three siblings
|
|
22
|
+
* (`landmark-banner-is-top-level`, `landmark-contentinfo-is-top-level`,
|
|
23
|
+
* `landmark-main-is-top-level`). Landmark detection and the
|
|
24
|
+
* ancestor walk are identical to theirs; only the role being looked for
|
|
25
|
+
* differs.
|
|
26
|
+
* - An unnamed <aside> inside sectioning content has no complementary role
|
|
27
|
+
* per HTML-AAM, so it is not a candidate at all: reporting it would name a
|
|
28
|
+
* landmark that does not exist. A *named* one keeps the role wherever it
|
|
29
|
+
* sits, which is exactly the case worth review -- an <aside aria-label>
|
|
30
|
+
* inside <main> really is a complementary landmark nested in another
|
|
31
|
+
* landmark. `landmark-unique` and the sibling top-level rules already
|
|
32
|
+
* resolve <aside> this way, through the same shared helper.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const id = 'landmark-complementary-is-top-level';
|
|
36
|
+
|
|
37
|
+
const meta = {
|
|
38
|
+
title: 'Complementary landmark must be top-level',
|
|
39
|
+
description:
|
|
40
|
+
'Checks that the complementary landmark (role="complementary" or an <aside> that keeps its implicit role) is not nested inside another landmark region.',
|
|
41
|
+
i18n: {
|
|
42
|
+
titleKey: 'landmarkComplementaryIsTopLevel_title',
|
|
43
|
+
descriptionKey: 'landmarkComplementaryIsTopLevel_description'
|
|
44
|
+
},
|
|
45
|
+
helpUrl: null,
|
|
46
|
+
tags: ['best-practice', 'landmarks', 'structure', 'atomic', 'manual'],
|
|
47
|
+
wcagSc: [],
|
|
48
|
+
normativeMappings: [],
|
|
49
|
+
defaultSeverity: 'minor',
|
|
50
|
+
category: 'operable',
|
|
51
|
+
type: 'manual',
|
|
52
|
+
defaultConfidence: 'medium',
|
|
53
|
+
coverage: {}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function runInPage(ctx) {
|
|
57
|
+
const { document, root, helpers, rule } = ctx;
|
|
58
|
+
|
|
59
|
+
// Declared inside runInPage; see scripts/build-core.js header
|
|
60
|
+
// ("runInPage MUST be self-contained").
|
|
61
|
+
function normalizeWs(s) {
|
|
62
|
+
return String(s || '')
|
|
63
|
+
.replace(/\s+/g, ' ')
|
|
64
|
+
.trim();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
68
|
+
// target's own accessible name, not raw textContent -> title attribute fallback) rather than a
|
|
69
|
+
// local copy -- see that function's header comment in src/core/dom-helpers.js. Sharing it keeps
|
|
70
|
+
// the title-attribute fallback consistent across the landmark rules.
|
|
71
|
+
function getAccessibleLandmarkName(el) {
|
|
72
|
+
try {
|
|
73
|
+
if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
|
|
74
|
+
const info = helpers.getLandmarkNameInfo(el, ctx);
|
|
75
|
+
if (info && info.present && info.value) return normalizeWs(info.value);
|
|
76
|
+
}
|
|
77
|
+
} catch {}
|
|
78
|
+
return '';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getExplicitRoleToken(el) {
|
|
82
|
+
const raw = normalizeWs(el.getAttribute && el.getAttribute('role'));
|
|
83
|
+
if (!raw) return '';
|
|
84
|
+
return raw.split(/\s+/)[0].toLowerCase();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Delegates to the shared helpers.hasLandmarkScopingAncestor for the
|
|
88
|
+
// question "does this element sit inside a sectioning-content/<main>
|
|
89
|
+
// ancestor that suppresses its conditional implicit role": role-aware
|
|
90
|
+
// (an ancestor's bare TAG only counts when it carries no role attribute
|
|
91
|
+
// at all; an explicit role="dialog"-style override no longer suppresses)
|
|
92
|
+
// rather than a local tag-only copy. See that function's header comment
|
|
93
|
+
// in src/core/aria-helpers.js for the full algorithm.
|
|
94
|
+
function hasSectioningAncestor(el, includeMain) {
|
|
95
|
+
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
96
|
+
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
97
|
+
: false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function getImplicitLandmarkRole(el) {
|
|
101
|
+
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
102
|
+
if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
|
|
103
|
+
if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
|
|
104
|
+
if (tag === 'main') return 'main';
|
|
105
|
+
if (tag === 'nav') return 'navigation';
|
|
106
|
+
if (tag === 'aside') {
|
|
107
|
+
// A named <aside> is never suppressed, even when nested. It keeps
|
|
108
|
+
// "complementary" when it has an accessible name, even inside
|
|
109
|
+
// sectioning content. Matches landmark-unique's precedent.
|
|
110
|
+
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
111
|
+
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
112
|
+
}
|
|
113
|
+
if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
|
|
114
|
+
if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
|
|
115
|
+
return '';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const LANDMARK_ROLES = new Set([
|
|
119
|
+
'banner',
|
|
120
|
+
'contentinfo',
|
|
121
|
+
'main',
|
|
122
|
+
'navigation',
|
|
123
|
+
'complementary',
|
|
124
|
+
'region',
|
|
125
|
+
'form',
|
|
126
|
+
'search'
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
function getLandmarkRole(el) {
|
|
130
|
+
if (!el || !el.getAttribute) return '';
|
|
131
|
+
const explicit = getExplicitRoleToken(el);
|
|
132
|
+
if (explicit) return LANDMARK_ROLES.has(explicit) ? explicit : '';
|
|
133
|
+
return getImplicitLandmarkRole(el);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// A candidate must actually carry the complementary role. An <aside> that
|
|
137
|
+
// HTML-AAM strips the role from is not a complementary landmark at all, so
|
|
138
|
+
// flagging it would report a landmark that does not exist.
|
|
139
|
+
function isComplementaryCandidate(el) {
|
|
140
|
+
return getLandmarkRole(el) === 'complementary';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function hasLandmarkAncestor(el) {
|
|
144
|
+
const scopeRoots = Array.isArray(root) ? root : root ? [root] : [];
|
|
145
|
+
let p = el.parentElement;
|
|
146
|
+
while (p) {
|
|
147
|
+
if (getLandmarkRole(p)) return true;
|
|
148
|
+
// Don't climb past the scanned scope -- see aria-helpers.js's
|
|
149
|
+
// hasLandmarkScopingAncestor for the same fix and rationale.
|
|
150
|
+
if (scopeRoots.includes(p)) break;
|
|
151
|
+
p = p.parentElement;
|
|
152
|
+
}
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
|
|
157
|
+
// landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
|
|
158
|
+
// widget's own landmark is invisible to a light-DOM-only query.
|
|
159
|
+
let nodes;
|
|
160
|
+
try {
|
|
161
|
+
nodes =
|
|
162
|
+
helpers && typeof helpers.queryAllSmart === 'function'
|
|
163
|
+
? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
|
|
164
|
+
: document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
|
|
165
|
+
} catch {
|
|
166
|
+
nodes = [];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const complementaries = [];
|
|
170
|
+
const seen = new Set();
|
|
171
|
+
for (const el of nodes) {
|
|
172
|
+
if (!el || seen.has(el)) continue;
|
|
173
|
+
seen.add(el);
|
|
174
|
+
if (!isComplementaryCandidate(el)) continue;
|
|
175
|
+
|
|
176
|
+
// An aria-hidden candidate is removed from the accessibility tree
|
|
177
|
+
// entirely, so it is not part of the landmark structure assistive
|
|
178
|
+
// technology users navigate and there is no real landmark to call
|
|
179
|
+
// nested. queryAllSmart's default hidden-content policy only excludes
|
|
180
|
+
// "hard" CSS-based hiding (display:none, etc.), not the softer
|
|
181
|
+
// aria-hidden exclusion, so this needs its own check.
|
|
182
|
+
if (helpers && typeof helpers.isAccTreeEligible === 'function') {
|
|
183
|
+
const elig = (() => {
|
|
184
|
+
try {
|
|
185
|
+
return helpers.isAccTreeEligible(el, ctx);
|
|
186
|
+
} catch {
|
|
187
|
+
return { eligible: true, reasons: [] };
|
|
188
|
+
}
|
|
189
|
+
})();
|
|
190
|
+
if (elig && elig.eligible === false) continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
complementaries.push(el);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (complementaries.length === 0) {
|
|
197
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const occurrences = [];
|
|
201
|
+
for (const el of complementaries) {
|
|
202
|
+
if (!hasLandmarkAncestor(el)) continue;
|
|
203
|
+
|
|
204
|
+
occurrences.push(
|
|
205
|
+
helpers.reportOccurrence(el, {
|
|
206
|
+
summary: 'This complementary landmark is nested inside another landmark region.',
|
|
207
|
+
hint: 'Move the complementary landmark (<aside>/role="complementary") so it is not contained by another landmark; complementary content belongs beside the main content, not inside another region.',
|
|
208
|
+
i18n: {
|
|
209
|
+
summaryKey: 'landmarkComplementaryIsTopLevel_summary_cantTell',
|
|
210
|
+
hintKey: 'landmarkComplementaryIsTopLevel_hint_cantTell',
|
|
211
|
+
params: {}
|
|
212
|
+
},
|
|
213
|
+
data: {
|
|
214
|
+
details: { reasonCode: 'LANDMARK_COMPLEMENTARY_NOT_TOP_LEVEL' }
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (occurrences.length) {
|
|
221
|
+
return {
|
|
222
|
+
ruleId: rule.ruleId,
|
|
223
|
+
outcome: 'cantTell',
|
|
224
|
+
severity: rule.defaultSeverity || 'minor',
|
|
225
|
+
occurrences
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @check password-paste-enabled
|
|
7
|
+
* @atomic true
|
|
8
|
+
* @summary An authentication field must not block pasting into it
|
|
9
|
+
* @standard WCAG 2.2
|
|
10
|
+
* @sc 3.3.8
|
|
11
|
+
* @applicability
|
|
12
|
+
* Applies to any control whose autocomplete token is current-password,
|
|
13
|
+
* new-password or one-time-code, and to <input type="password"> unless its
|
|
14
|
+
* autocomplete names another purpose. A disabled or readonly field takes no
|
|
15
|
+
* input to block, and one outside the accessibility tree is not being asked
|
|
16
|
+
* for, so neither is in scope.
|
|
17
|
+
* @expectation
|
|
18
|
+
* A reviewer confirms the field can still be pasted into. Remembering a
|
|
19
|
+
* password is a cognitive function test, and 3.3.8 asks for a mechanism
|
|
20
|
+
* that helps the user through one; a password manager, or the clipboard
|
|
21
|
+
* for a one-time code, is that mechanism.
|
|
22
|
+
* @implementation-notes
|
|
23
|
+
* - Advisory and capped at cantTell. Whether a handler really stops the user
|
|
24
|
+
* depends on script the markup does not carry, so no reading of it is safe
|
|
25
|
+
* enough to fail on: a split one-time-code field cancels the default paste
|
|
26
|
+
* and then spreads the digits across its boxes. The two cases are reported
|
|
27
|
+
* apart -- a handler that only cancels, and one that goes on to do more --
|
|
28
|
+
* so a reviewer knows which to look at first. `return true` allows the
|
|
29
|
+
* paste and reports nothing.
|
|
30
|
+
* - type="password" masks rather than authenticates, and a card security code
|
|
31
|
+
* is masked the same way, so an autocomplete naming another purpose puts the
|
|
32
|
+
* field out of scope. A masked field with no autocomplete cannot be told
|
|
33
|
+
* apart, which is the one case left.
|
|
34
|
+
* - `autocomplete="off"` is not reported. Browsers override it for password
|
|
35
|
+
* fields so password managers keep working, and the mechanism survives.
|
|
36
|
+
* - A handler attached with addEventListener leaves nothing to read, the
|
|
37
|
+
* limit every static scan shares.
|
|
38
|
+
* - 3.3.8 also excuses the test where the process offers another
|
|
39
|
+
* authentication method. A sibling button proves nothing about whether it
|
|
40
|
+
* works or replaces this step, so it is not inferred; the facet is recorded
|
|
41
|
+
* as partial for that reason.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
const id = 'password-paste-enabled';
|
|
45
|
+
|
|
46
|
+
const meta = {
|
|
47
|
+
title: 'Authentication fields must not block pasting',
|
|
48
|
+
description:
|
|
49
|
+
'Checks that a password or one-time-code field carries no inline paste handler that cancels the paste, which would remove the password manager or clipboard that WCAG 3.3.8 relies on as the assisting mechanism.',
|
|
50
|
+
i18n: {
|
|
51
|
+
titleKey: 'passwordPasteEnabled_title',
|
|
52
|
+
descriptionKey: 'passwordPasteEnabled_description'
|
|
53
|
+
},
|
|
54
|
+
helpUrl: null,
|
|
55
|
+
tags: ['wcag22aa', 'wcag338', 'forms', 'authentication', 'atomic', 'manual', 'acc'],
|
|
56
|
+
wcagSc: ['3.3.8'],
|
|
57
|
+
normativeMappings: [
|
|
58
|
+
{
|
|
59
|
+
standard: 'WCAG',
|
|
60
|
+
version: '2.2',
|
|
61
|
+
requirement: '3.3.8',
|
|
62
|
+
title: 'Accessible Authentication (Minimum)',
|
|
63
|
+
conformanceLevel: 'AA'
|
|
64
|
+
}
|
|
65
|
+
],
|
|
66
|
+
defaultSeverity: 'serious',
|
|
67
|
+
category: 'understandable',
|
|
68
|
+
type: 'manual',
|
|
69
|
+
defaultConfidence: 'medium',
|
|
70
|
+
coverage: { facetsBySc: { '3.3.8': ['authentication-paste-not-blocked'] } }
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function runInPage(ctx) {
|
|
74
|
+
const { helpers, rule } = ctx;
|
|
75
|
+
|
|
76
|
+
// Declared inside runInPage; see scripts/build-core.js header
|
|
77
|
+
// ("runInPage MUST be self-contained").
|
|
78
|
+
const AUTH_AUTOCOMPLETE_TOKENS = ['current-password', 'new-password', 'one-time-code'];
|
|
79
|
+
|
|
80
|
+
function normalizeWs(s) {
|
|
81
|
+
return String(s || '')
|
|
82
|
+
.replace(/\s+/g, ' ')
|
|
83
|
+
.trim();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function autocompleteTokens(el) {
|
|
87
|
+
return normalizeWs(el.getAttribute && el.getAttribute('autocomplete'))
|
|
88
|
+
.toLowerCase()
|
|
89
|
+
.split(' ')
|
|
90
|
+
.filter(Boolean);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// A field an authentication step reads: a password input, or any control
|
|
94
|
+
// whose autocomplete token names an authentication secret. The autocomplete
|
|
95
|
+
// route matters for one-time codes, which are ordinary text inputs.
|
|
96
|
+
//
|
|
97
|
+
// type="password" is a masking control, not only an authentication one: a
|
|
98
|
+
// card security code is routinely masked the same way. An explicit
|
|
99
|
+
// autocomplete purpose that is not an authentication one says so, and takes
|
|
100
|
+
// the field back out of scope.
|
|
101
|
+
function isAuthField(el) {
|
|
102
|
+
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
103
|
+
if (tag !== 'input' && tag !== 'textarea') return false;
|
|
104
|
+
|
|
105
|
+
const tokens = autocompleteTokens(el);
|
|
106
|
+
if (AUTH_AUTOCOMPLETE_TOKENS.some((t) => tokens.includes(t))) return true;
|
|
107
|
+
|
|
108
|
+
const type = normalizeWs(el.getAttribute && el.getAttribute('type')).toLowerCase();
|
|
109
|
+
if (tag !== 'input' || type !== 'password') return false;
|
|
110
|
+
|
|
111
|
+
const declaresOtherPurpose = tokens.some(
|
|
112
|
+
(t) => t !== 'on' && t !== 'off' && !AUTH_AUTOCOMPLETE_TOKENS.includes(t)
|
|
113
|
+
);
|
|
114
|
+
return !declaresOtherPurpose;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// A field that takes no input at all cannot be pasted into either, so a
|
|
118
|
+
// paste handler on it blocks nothing.
|
|
119
|
+
function acceptsInput(el) {
|
|
120
|
+
if (el.hasAttribute && (el.hasAttribute('disabled') || el.hasAttribute('readonly'))) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Classifies an inline handler as 'cancelOnly', 'opaque' or 'none'.
|
|
127
|
+
//
|
|
128
|
+
// Cancelling is not the same as blocking. Replacing the default paste is
|
|
129
|
+
// how a split one-time-code field distributes the digits across its boxes,
|
|
130
|
+
// and how a password field strips stray whitespace from what was pasted --
|
|
131
|
+
// both call preventDefault and then insert the text themselves, which
|
|
132
|
+
// helps the user rather than stopping them. So a handler counts as
|
|
133
|
+
// blocking only when cancelling is the whole of what it does. Anything
|
|
134
|
+
// further -- a call, an assignment, a condition -- may well be putting the
|
|
135
|
+
// text back, and the markup does not say, so it reports cantTell.
|
|
136
|
+
function classifyHandler(source) {
|
|
137
|
+
const src = String(source || '').trim();
|
|
138
|
+
if (!src) return 'none';
|
|
139
|
+
|
|
140
|
+
const CANCEL = [
|
|
141
|
+
/\breturn\s+false\b/gi,
|
|
142
|
+
/\breturn\s*!1\b/gi,
|
|
143
|
+
/\bpreventDefault\s*\(\s*\)/gi,
|
|
144
|
+
/\breturnValue\s*=\s*(?:false|!1)\b/gi
|
|
145
|
+
];
|
|
146
|
+
// Says "yes, paste" outright, so it blocks nothing.
|
|
147
|
+
const ALLOW = [/\breturn\s+true\b/gi, /\breturn\s*!0\b/gi];
|
|
148
|
+
// Neither cancels nor re-inserts.
|
|
149
|
+
const NEUTRAL = [/\bstop(?:Immediate)?Propagation\s*\(\s*\)/gi, /\breturn\b/gi];
|
|
150
|
+
// What is left of an object reference once its method call is removed.
|
|
151
|
+
const REFS = /\b(?:window|document|this|event|evt|ev|e|arguments\[0\])\b/gi;
|
|
152
|
+
const ONLY_PUNCTUATION = /^[\s;.,()[\]{}]*$/;
|
|
153
|
+
|
|
154
|
+
let rest = src;
|
|
155
|
+
let sawCancel = false;
|
|
156
|
+
for (const re of CANCEL) {
|
|
157
|
+
const next = rest.replace(re, ' ');
|
|
158
|
+
if (next !== rest) sawCancel = true;
|
|
159
|
+
rest = next;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!sawCancel) {
|
|
163
|
+
for (const re of ALLOW) rest = rest.replace(re, ' ');
|
|
164
|
+
for (const re of NEUTRAL) rest = rest.replace(re, ' ');
|
|
165
|
+
rest = rest.replace(REFS, ' ');
|
|
166
|
+
return ONLY_PUNCTUATION.test(rest) ? 'none' : 'opaque';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const re of NEUTRAL) rest = rest.replace(re, ' ');
|
|
170
|
+
rest = rest.replace(REFS, ' ');
|
|
171
|
+
// Anything left beyond punctuation is the handler doing more than cancel.
|
|
172
|
+
return ONLY_PUNCTUATION.test(rest) ? 'cancelOnly' : 'opaque';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const nodes = helpers.queryAllSmart
|
|
176
|
+
? helpers.queryAllSmart('input, textarea')
|
|
177
|
+
: helpers.queryAll('input, textarea');
|
|
178
|
+
|
|
179
|
+
const cancelling = [];
|
|
180
|
+
const undetermined = [];
|
|
181
|
+
|
|
182
|
+
for (const el of nodes) {
|
|
183
|
+
if (!el || !el.getAttribute) continue;
|
|
184
|
+
if (!isAuthField(el)) continue;
|
|
185
|
+
|
|
186
|
+
if (!acceptsInput(el)) continue;
|
|
187
|
+
|
|
188
|
+
const eligResult = helpers.isAccTreeEligible ? helpers.isAccTreeEligible(el, ctx) : true;
|
|
189
|
+
const eligible =
|
|
190
|
+
typeof eligResult === 'boolean' ? eligResult : !!(eligResult && eligResult.eligible);
|
|
191
|
+
if (!eligible) continue;
|
|
192
|
+
|
|
193
|
+
const handler = el.getAttribute('onpaste');
|
|
194
|
+
if (handler === null) continue;
|
|
195
|
+
|
|
196
|
+
const verdict = classifyHandler(handler);
|
|
197
|
+
if (verdict === 'none') continue;
|
|
198
|
+
|
|
199
|
+
const eligInfo = helpers.getEligibilityInfo
|
|
200
|
+
? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' })
|
|
201
|
+
: { targetSet: 'acc', accEligible: null, reasons: [] };
|
|
202
|
+
|
|
203
|
+
if (verdict === 'cancelOnly') {
|
|
204
|
+
cancelling.push(
|
|
205
|
+
helpers.reportOccurrence(el, {
|
|
206
|
+
summary:
|
|
207
|
+
'This authentication field has a paste handler whose only effect is to cancel the paste.',
|
|
208
|
+
hint: 'Confirm by hand whether pasting still works. If it is blocked, remove the handler so a password manager, or the clipboard for a one-time code, can fill the field.',
|
|
209
|
+
i18n: {
|
|
210
|
+
summaryKey: 'passwordPasteEnabled_summary_fail',
|
|
211
|
+
hintKey: 'passwordPasteEnabled_hint_fail',
|
|
212
|
+
params: {}
|
|
213
|
+
},
|
|
214
|
+
data: {
|
|
215
|
+
visibilityFilter: eligInfo,
|
|
216
|
+
details: { reasonCode: 'PASTE_CANCELLED', handler: normalizeWs(handler) }
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// An inline handler that delegates: whether it cancels lives in code the
|
|
224
|
+
// markup does not carry.
|
|
225
|
+
undetermined.push(
|
|
226
|
+
helpers.reportOccurrence(el, {
|
|
227
|
+
summary:
|
|
228
|
+
'This authentication field has a paste handler, and whether it cancels pasting could not be determined.',
|
|
229
|
+
hint: 'Check by hand that pasting into the field still works, so a password manager or the clipboard can fill it.',
|
|
230
|
+
i18n: {
|
|
231
|
+
summaryKey: 'passwordPasteEnabled_summary_cantTell',
|
|
232
|
+
hintKey: 'passwordPasteEnabled_hint_cantTell',
|
|
233
|
+
params: {}
|
|
234
|
+
},
|
|
235
|
+
data: {
|
|
236
|
+
visibilityFilter: eligInfo,
|
|
237
|
+
details: { reasonCode: 'PASTE_HANDLER_OPAQUE', handler: normalizeWs(handler) }
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const occurrences = cancelling.concat(undetermined);
|
|
244
|
+
if (!occurrences.length) {
|
|
245
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
ruleId: rule.ruleId,
|
|
249
|
+
outcome: 'cantTell',
|
|
250
|
+
severity: rule.defaultSeverity || 'serious',
|
|
251
|
+
occurrences
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
module.exports = { id, meta, runInPage };
|