@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.
Files changed (59) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +24 -38
  3. package/docs/ACT_RULE_MAPPING.md +8 -6
  4. package/docs/API_STABILITY.md +51 -3
  5. package/docs/BINDING_AUTHORS_GUIDE.md +104 -2
  6. package/docs/DESIGN_CHALLENGES.md +66 -0
  7. package/docs/EARL.md +100 -0
  8. package/docs/ENGINE_OPTIONS.md +28 -2
  9. package/docs/INTEGRATION.md +4 -2
  10. package/docs/LIMITATIONS.md +3 -1
  11. package/docs/OUTPUT_SCHEMA.md +44 -6
  12. package/docs/POLICY.md +1 -1
  13. package/docs/RULE_AUTHORING.md +11 -12
  14. package/docs/RULE_CATALOG.md +76 -26
  15. package/docs/RULE_HELPERS.md +333 -0
  16. package/docs/RULE_TAXONOMY.md +25 -4
  17. package/docs/SARIF.md +21 -2
  18. package/docs/WCAG_CONFORMANCE.md +9 -1
  19. package/package.json +9 -3
  20. package/src/checks/automatic/aria-allowed-attr.js +6 -0
  21. package/src/checks/automatic/aria-allowed-role.js +32 -23
  22. package/src/checks/automatic/aria-braille-equivalent.js +18 -10
  23. package/src/checks/automatic/aria-conditional-attr.js +17 -10
  24. package/src/checks/automatic/aria-deprecated-role.js +12 -0
  25. package/src/checks/automatic/aria-hidden-body.js +1 -1
  26. package/src/checks/automatic/aria-prohibited-attr.js +5 -0
  27. package/src/checks/automatic/aria-prohibited-children.js +6 -6
  28. package/src/checks/automatic/aria-required-attr.js +59 -12
  29. package/src/checks/automatic/aria-required-children.js +33 -16
  30. package/src/checks/automatic/aria-required-parent.js +32 -6
  31. package/src/checks/automatic/aria-role-name-present.js +1 -1
  32. package/src/checks/automatic/aria-roles-valid.js +52 -21
  33. package/src/checks/automatic/aria-valid-attr-value.js +74 -21
  34. package/src/checks/automatic/aria-valid-attr.js +14 -9
  35. package/src/checks/automatic/avoid-inline-spacing.js +133 -6
  36. package/src/checks/automatic/contrast-computable.js +10 -0
  37. package/src/checks/automatic/contrast-enhanced.js +12 -0
  38. package/src/checks/automatic/contrast-minimum.js +12 -0
  39. package/src/checks/automatic/css-orientation-lock.js +42 -5
  40. package/src/checks/automatic/duplicate-id-aria.js +5 -0
  41. package/src/checks/automatic/duplicate-id.js +13 -8
  42. package/src/checks/automatic/form-control-single-label.js +9 -0
  43. package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
  44. package/src/checks/automatic/iframe-focusable-content.js +5 -0
  45. package/src/checks/automatic/label-in-name.js +38 -56
  46. package/src/checks/automatic/link-in-text-block.js +279 -23
  47. package/src/checks/automatic/target-size-minimum.js +84 -5
  48. package/src/checks/automatic/td-has-header.js +19 -18
  49. package/src/checks/manual/form-control-label-quality-manual.js +134 -24
  50. package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
  51. package/src/checks/manual/password-paste-enabled-manual.js +255 -0
  52. package/src/core.js +3863 -44184
  53. package/src/earl.js +144 -0
  54. package/src/sarif.js +22 -2
  55. package/surea11y.browser.js +10 -41039
  56. package/surea11y.i18n.de.js +2 -21
  57. package/surea11y.i18n.es.js +2 -21
  58. package/surea11y.i18n.fr.js +2 -21
  59. /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
- try {
222
- for (const label of document.querySelectorAll('label[for]')) {
223
- if (normalizeWs(label.getAttribute('for')) === idVal) labels.push(label);
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
- if (wrapping && labels.indexOf(wrapping) === -1) labels.push(wrapping);
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
- for (let i = headings.length - 1; i >= 0; i--) {
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
- const text = normalizeWs(row.textContent);
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 };