@surea11y/core 1.5.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 (157) hide show
  1. package/CHANGELOG.md +240 -149
  2. package/README.md +51 -44
  3. package/docs/ACT_RULE_MAPPING.md +245 -0
  4. package/docs/API_STABILITY.md +53 -5
  5. package/docs/BINDING_AUTHORS_GUIDE.md +106 -4
  6. package/docs/DESIGN_CHALLENGES.md +367 -0
  7. package/docs/EARL.md +100 -0
  8. package/docs/ENGINE_OPTIONS.md +42 -4
  9. package/docs/I18N.md +4 -4
  10. package/docs/INTEGRATION.md +4 -2
  11. package/docs/LIMITATIONS.md +9 -5
  12. package/docs/OUTPUT_SCHEMA.md +44 -6
  13. package/docs/POLICY.md +1 -1
  14. package/docs/REPORT.md +1 -1
  15. package/docs/RULE_AUTHORING.md +63 -36
  16. package/docs/RULE_CATALOG.md +1928 -169
  17. package/docs/RULE_HELPERS.md +333 -0
  18. package/docs/RULE_TAXONOMY.md +27 -6
  19. package/docs/SARIF.md +21 -2
  20. package/docs/TROUBLESHOOTING.md +2 -2
  21. package/docs/WCAG_CONFORMANCE.md +34 -10
  22. package/package.json +11 -9
  23. package/src/baseline.js +3 -3
  24. package/src/checks/automatic/area-alt-present.js +2 -2
  25. package/src/checks/automatic/aria-allowed-attr.js +74 -10
  26. package/src/checks/automatic/aria-allowed-role.js +34 -25
  27. package/src/checks/automatic/aria-braille-equivalent.js +21 -13
  28. package/src/checks/automatic/aria-conditional-attr.js +22 -15
  29. package/src/checks/automatic/aria-deprecated-role.js +13 -1
  30. package/src/checks/automatic/aria-hidden-body.js +3 -3
  31. package/src/checks/automatic/aria-hidden-focus.js +5 -5
  32. package/src/checks/automatic/aria-prohibited-attr.js +23 -18
  33. package/src/checks/automatic/aria-prohibited-children.js +136 -43
  34. package/src/checks/automatic/aria-required-attr.js +119 -24
  35. package/src/checks/automatic/aria-required-children.js +54 -30
  36. package/src/checks/automatic/aria-required-parent.js +93 -15
  37. package/src/checks/automatic/aria-role-name-present.js +37 -23
  38. package/src/checks/automatic/aria-roles-valid.js +52 -21
  39. package/src/checks/automatic/aria-valid-attr-value.js +89 -33
  40. package/src/checks/automatic/aria-valid-attr.js +15 -10
  41. package/src/checks/automatic/autocomplete-valid.js +2 -2
  42. package/src/checks/automatic/avoid-inline-spacing.js +133 -6
  43. package/src/checks/automatic/binary-control-name-present.js +27 -5
  44. package/src/checks/automatic/button-name-present.js +92 -6
  45. package/src/checks/automatic/combobox-name-present.js +26 -6
  46. package/src/checks/automatic/contrast-computable.js +42 -0
  47. package/src/checks/automatic/contrast-enhanced.js +33 -1
  48. package/src/checks/automatic/contrast-minimum.js +33 -1
  49. package/src/checks/automatic/css-orientation-lock.js +138 -24
  50. package/src/checks/automatic/definition-list-children-valid.js +7 -8
  51. package/src/checks/automatic/deprecated-elements-not-used.js +1 -1
  52. package/src/checks/automatic/dialog-name-present.js +20 -2
  53. package/src/checks/automatic/duplicate-id-aria.js +10 -3
  54. package/src/checks/automatic/duplicate-id.js +203 -0
  55. package/src/checks/automatic/embed-text-alternative-present.js +2 -2
  56. package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
  57. package/src/checks/automatic/form-control-single-label.js +10 -1
  58. package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
  59. package/src/checks/automatic/iframe-focusable-content.js +68 -7
  60. package/src/checks/automatic/iframe-name-present.js +37 -3
  61. package/src/checks/automatic/iframe-title-unique.js +1 -1
  62. package/src/checks/automatic/img-alt-present.js +12 -4
  63. package/src/checks/automatic/label-in-name.js +204 -68
  64. package/src/checks/automatic/link-in-text-block.js +285 -29
  65. package/src/checks/automatic/link-name-present.js +22 -1
  66. package/src/checks/automatic/list-children-valid.js +6 -6
  67. package/src/checks/automatic/listbox-name-present.js +28 -8
  68. package/src/checks/automatic/listitem-parent-valid.js +4 -4
  69. package/src/checks/automatic/menuitem-name-present.js +20 -2
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +25 -14
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +14 -7
  72. package/src/checks/automatic/meter-name-present.js +23 -4
  73. package/src/checks/automatic/nested-interactive-controls-absent.js +5 -5
  74. package/src/checks/automatic/option-name-present.js +23 -4
  75. package/src/checks/automatic/page-title-present.js +21 -3
  76. package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
  77. package/src/checks/automatic/progressbar-name-present.js +23 -4
  78. package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +64 -16
  79. package/src/checks/automatic/searchbox-name-present.js +28 -8
  80. package/src/checks/automatic/server-side-image-map-absent.js +1 -1
  81. package/src/checks/automatic/slider-name-present.js +27 -6
  82. package/src/checks/automatic/spinbutton-name-present.js +28 -8
  83. package/src/checks/automatic/summary-name-present.js +18 -2
  84. package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
  85. package/src/checks/automatic/svg-text-alternative-present.js +13 -10
  86. package/src/checks/automatic/tab-name-present.js +21 -2
  87. package/src/checks/automatic/table-headers-attr-valid.js +43 -8
  88. package/src/checks/automatic/table-th-has-data-cells.js +61 -5
  89. package/src/checks/automatic/target-size-minimum.js +155 -58
  90. package/src/checks/automatic/td-has-header.js +24 -23
  91. package/src/checks/automatic/textbox-name-present.js +28 -8
  92. package/src/checks/automatic/tooltip-name-present.js +21 -2
  93. package/src/checks/automatic/treeitem-name-present.js +23 -4
  94. package/src/checks/automatic/valid-lang.js +92 -7
  95. package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
  96. package/src/checks/manual/accesskeys-manual.js +3 -3
  97. package/src/checks/manual/area-alt-decorative-manual.js +7 -0
  98. package/src/checks/manual/area-alt-quality-manual.js +6 -0
  99. package/src/checks/manual/aria-checked-state-mismatch-manual.js +5 -5
  100. package/src/checks/manual/aria-text-manual.js +4 -4
  101. package/src/checks/manual/bypass-blocks-present-manual.js +44 -26
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
  103. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
  105. package/src/checks/manual/empty-heading-manual.js +58 -11
  106. package/src/checks/manual/empty-table-header-manual.js +8 -8
  107. package/src/checks/manual/focus-order-semantics-manual.js +15 -15
  108. package/src/checks/manual/form-control-label-quality-manual.js +563 -0
  109. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -2
  110. package/src/checks/manual/heading-order-manual.js +3 -3
  111. package/src/checks/manual/heading-quality-manual.js +338 -0
  112. package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
  113. package/src/checks/manual/image-redundant-alt-manual.js +4 -4
  114. package/src/checks/manual/img-alt-decorative-manual.js +211 -52
  115. package/src/checks/manual/img-alt-quality-manual.js +7 -0
  116. package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
  117. package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
  118. package/src/checks/manual/label-title-only-manual.js +4 -4
  119. package/src/checks/manual/landmark-banner-is-top-level-manual.js +7 -7
  120. package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
  121. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +9 -9
  122. package/src/checks/manual/landmark-main-is-top-level-manual.js +6 -6
  123. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +6 -6
  124. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +6 -6
  125. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -2
  126. package/src/checks/manual/landmark-one-main-manual.js +6 -6
  127. package/src/checks/manual/landmark-unique-manual.js +9 -9
  128. package/src/checks/manual/link-name-quality-manual.js +161 -32
  129. package/src/checks/manual/media-transcript-present-manual.js +2 -3
  130. package/src/checks/manual/meta-viewport-large-manual.js +2 -2
  131. package/src/checks/manual/mouse-only-event-handlers-manual.js +9 -9
  132. package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
  133. package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
  134. package/src/checks/manual/p-as-heading-manual.js +4 -4
  135. package/src/checks/manual/page-has-heading-one-manual.js +6 -6
  136. package/src/checks/manual/page-title-patterns-manual.js +26 -3
  137. package/src/checks/manual/password-paste-enabled-manual.js +255 -0
  138. package/src/checks/manual/presentation-role-conflict-manual.js +55 -25
  139. package/src/checks/manual/region-manual.js +19 -19
  140. package/src/checks/manual/scope-attr-valid-manual.js +2 -2
  141. package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
  142. package/src/checks/manual/skip-link-manual.js +5 -5
  143. package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
  144. package/src/checks/manual/tabindex-manual.js +2 -2
  145. package/src/checks/manual/table-duplicate-name-manual.js +2 -2
  146. package/src/checks/manual/table-fake-caption-manual.js +2 -2
  147. package/src/checks/manual/video-caption-manual.js +3 -3
  148. package/src/checks/manual-review.js +17 -1
  149. package/src/core.js +8880 -41883
  150. package/src/earl.js +144 -0
  151. package/src/report.js +2 -2
  152. package/src/sarif.js +22 -2
  153. package/surea11y.browser.js +10 -37882
  154. package/surea11y.i18n.de.js +2 -21
  155. package/surea11y.i18n.es.js +2 -21
  156. package/surea11y.i18n.fr.js +2 -21
  157. package/bin/surea11y-core.js +0 -20
@@ -0,0 +1,563 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
3
+ 'use strict';
4
+
5
+ /**
6
+ * @check form-control-label-quality
7
+ * @atomic true
8
+ * @summary A form field's visible label text should describe that field, not repeat another one
9
+ * @standard WCAG 2.2
10
+ * @sc 2.4.6
11
+ * @applicability
12
+ * Visible form fields: native `input` (excluding hidden and the
13
+ * button-like types), `select`, `textarea`, or an element with one of
14
+ * the ARIA widget roles ACT cc0f0a lists (checkbox, combobox, listbox,
15
+ * menuitemcheckbox, menuitemradio, radio, searchbox, slider,
16
+ * spinbutton, switch, textbox) that carry a visible programmatic
17
+ * label: a `<label>` association, or the elements `aria-labelledby`
18
+ * points at. A field named only by `aria-label`/`title` has no visible
19
+ * label to judge and is out of scope here (its labelling mechanism is
20
+ * `form-control-programmatic-label-quality`'s concern, its presence
21
+ * `form-control-programmatic-label-present`'s).
22
+ * @expectation
23
+ * The visible label text (a) is not a placeholder left in the markup
24
+ * ("label", "field", "enter text", ...), (b) is not shared with another
25
+ * field that no visible context tells apart (the same "Name" twice,
26
+ * with nothing visible on screen saying which is shipping and which is
27
+ * billing), and (c) is the whole of the field's programmatic label, not
28
+ * the visible fragment of a label whose descriptive part is hidden.
29
+ * @implementation-notes
30
+ * - Authored as `type: 'manual'` (cantTell-capped, never fail). Whether a
31
+ * label describes its field is a reading judgment: ACT cc0f0a fails
32
+ * `<label>Menu<input type="text" name="fname"></label>` on the meaning
33
+ * of the word alone, which no markup-level check can reach. What is
34
+ * deterministic is a placeholder string, a label repeated with no
35
+ * visible differentiator, and a label split between visible and hidden
36
+ * parts: the three shapes this rule reports.
37
+ * - Only PROGRAMMATIC labels count, per ACT: a `<label>` or the targets
38
+ * of `aria-labelledby`. `aria-label` is invisible text, so it can carry
39
+ * no visual context and is not what a sighted user reads.
40
+ * - Visibility is ACT's, not the accessibility tree's: a label positioned
41
+ * off screen or clipped is programmatically fine and visually absent,
42
+ * which is exactly what makes ACT's failed example 4 (`<h2>` at
43
+ * `top: -9999px`) a failure rather than a pass. Hence
44
+ * `isDomVisibleEligible` for rendering plus the shared visibility
45
+ * hints for the off-screen/clipped/transparent patterns.
46
+ * - The context a duplicate label is judged against is the visible
47
+ * context nearest the field: its `<fieldset>`'s visible `<legend>`, or
48
+ * failing that the nearest visible heading before it. A row of a table
49
+ * or a list item contributes its own text too, which is what keeps a
50
+ * repeated "Quantity" field in a product table (differentiated by the
51
+ * product name in the same row) from being reported.
52
+ * - Two fields conflict only when their label text AND their context are
53
+ * both identical. Same label under two different visible headings is
54
+ * ACT's passed example 5 and is not reported.
55
+ * - A label split across visible and hidden parts is reported on its own:
56
+ * `aria-labelledby="submit search"` where "Search" is `display: none`
57
+ * and only the "Go" button renders leaves a sighted user reading a
58
+ * different label than a screen reader announces. ACT's failed example
59
+ * 5 is exactly that. The hidden part may well be a deliberate
60
+ * AT-only addition, which is why this is a review signal rather than a
61
+ * defect.
62
+ */
63
+
64
+ const id = 'form-control-label-quality';
65
+
66
+ const meta = {
67
+ title: 'Form field labels should be descriptive and distinguishable',
68
+ description:
69
+ 'Flags a visible form-field label that is a placeholder ("Label", "Field"), or that repeats another field\'s label with no visible context (heading, legend, or row) telling the two apart.',
70
+ i18n: {
71
+ titleKey: 'formControlLabelQuality_title',
72
+ descriptionKey: 'formControlLabelQuality_description'
73
+ },
74
+ helpUrl: null,
75
+ tags: ['wcag2aa', 'wcag246', 'forms', 'labels', 'quality', 'atomic', 'manual'],
76
+ wcagSc: ['2.4.6'],
77
+ normativeMappings: [
78
+ {
79
+ standard: 'WCAG',
80
+ version: '2.2',
81
+ requirement: '2.4.6',
82
+ title: 'Headings and Labels',
83
+ conformanceLevel: 'AA'
84
+ }
85
+ ],
86
+ defaultSeverity: 'minor',
87
+ category: 'operable',
88
+ type: 'manual',
89
+ defaultConfidence: 'medium',
90
+ coverage: { facetsBySc: { '2.4.6': ['form-control-label-descriptive-evidence'] } }
91
+ };
92
+
93
+ function runInPage(ctx) {
94
+ const { document, helpers, rule } = ctx;
95
+
96
+ // Declared inside runInPage; see scripts/build-core.js header
97
+ // ("runInPage MUST be self-contained").
98
+ const PLACEHOLDER_LABEL_TEXT = new Set([
99
+ 'label',
100
+ 'field',
101
+ 'form field',
102
+ 'input',
103
+ 'input field',
104
+ 'text',
105
+ 'text field',
106
+ 'enter text',
107
+ 'type here',
108
+ 'value',
109
+ 'placeholder',
110
+ 'untitled',
111
+ 'tbd',
112
+ 'todo',
113
+ 'to do',
114
+ 'n/a',
115
+ 'test',
116
+ 'example',
117
+ 'default'
118
+ ]);
119
+
120
+ const FIELD_SELECTOR = [
121
+ 'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"])',
122
+ 'select',
123
+ 'textarea',
124
+ '[role="checkbox"]',
125
+ '[role="combobox"]',
126
+ '[role="listbox"]',
127
+ '[role="menuitemcheckbox"]',
128
+ '[role="menuitemradio"]',
129
+ '[role="radio"]',
130
+ '[role="searchbox"]',
131
+ '[role="slider"]',
132
+ '[role="spinbutton"]',
133
+ '[role="switch"]',
134
+ '[role="textbox"]'
135
+ ].join(', ');
136
+
137
+ const ROW_SELECTOR = 'tr, [role="row"], li, [role="listitem"]';
138
+ const HEADING_SELECTOR = 'h1, h2, h3, h4, h5, h6, [role="heading"]';
139
+
140
+ function normalizeWs(s) {
141
+ return String(s || '')
142
+ .replace(/\s+/g, ' ')
143
+ .trim();
144
+ }
145
+
146
+ function normalize(s) {
147
+ return normalizeWs(s)
148
+ .toLowerCase()
149
+ .replace(/[.,;:!?*]+$/g, '')
150
+ .trim();
151
+ }
152
+
153
+ const isDomVisibleEligible =
154
+ helpers && typeof helpers.isDomVisibleEligible === 'function'
155
+ ? helpers.isDomVisibleEligible
156
+ : null;
157
+ const getVisibilityHintsInfo =
158
+ helpers && typeof helpers.getVisibilityHintsInfo === 'function'
159
+ ? helpers.getVisibilityHintsInfo
160
+ : null;
161
+
162
+ const HIDING_HINTS = ['offscreen', 'clipped', 'opacityZero', 'zeroSize'];
163
+
164
+ // ACT's "visible": rendered, and not hidden by one of the visually-hidden
165
+ // CSS patterns. A label the user cannot read provides no visual context,
166
+ // however well it is wired up programmatically.
167
+ function isVisible(el) {
168
+ if (!el) return false;
169
+ if (isDomVisibleEligible) {
170
+ try {
171
+ const vis = isDomVisibleEligible(el, ctx, {
172
+ visibilityMode: 'styleOnly',
173
+ disableGeometry: true
174
+ });
175
+ if (vis && vis.eligible === false) return false;
176
+ } catch {
177
+ // treat as rendered
178
+ }
179
+ }
180
+ if (getVisibilityHintsInfo) {
181
+ try {
182
+ const info = getVisibilityHintsInfo(el, ctx);
183
+ const hints = (info && info.hints) || [];
184
+ for (const hint of HIDING_HINTS) {
185
+ if (hints.indexOf(hint) !== -1) return false;
186
+ }
187
+ } catch {
188
+ // treat as visible
189
+ }
190
+ }
191
+ return true;
192
+ }
193
+
194
+ function resolveIdRefs(el, attr) {
195
+ const raw = normalizeWs(el.getAttribute && el.getAttribute(attr));
196
+ if (!raw) return [];
197
+ const out = [];
198
+ for (const refId of raw.split(/\s+/).filter(Boolean)) {
199
+ try {
200
+ const ref = document.getElementById(refId);
201
+ if (ref) out.push(ref);
202
+ } catch {
203
+ // ignore an unusable reference
204
+ }
205
+ }
206
+ return out;
207
+ }
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.
245
+ function getNativeLabels(el) {
246
+ const labels = [];
247
+ const idVal = normalizeWs(el.getAttribute && el.getAttribute('id'));
248
+ if (idVal) {
249
+ const forLabels = labelsByForId.get(idVal);
250
+ if (forLabels) {
251
+ for (const label of forLabels) labels.push(label);
252
+ }
253
+ }
254
+ try {
255
+ const wrapping = el.closest ? el.closest('label') : null;
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
+ }
266
+ } catch {
267
+ // ignore
268
+ }
269
+ return labels;
270
+ }
271
+
272
+ // The programmatic labels of a field, per ACT: aria-labelledby targets when
273
+ // present, otherwise the <label> elements associated with it. aria-label is
274
+ // left out on purpose; see the header comment.
275
+ function getVisibleLabelText(el) {
276
+ const referenced = resolveIdRefs(el, 'aria-labelledby');
277
+ const labels = referenced.length ? referenced : getNativeLabels(el);
278
+ const parts = [];
279
+ let hiddenParts = 0;
280
+ for (const label of labels) {
281
+ const text = normalizeWs(label.textContent);
282
+ if (!text) continue;
283
+ if (isVisible(label)) parts.push(text);
284
+ else hiddenParts += 1;
285
+ }
286
+ return { text: normalizeWs(parts.join(' ')), hiddenParts };
287
+ }
288
+
289
+ const headings = (() => {
290
+ try {
291
+ return Array.prototype.slice.call(document.querySelectorAll(HEADING_SELECTOR));
292
+ } catch {
293
+ return [];
294
+ }
295
+ })();
296
+
297
+ function precedes(a, b) {
298
+ try {
299
+ // DOCUMENT_POSITION_PRECEDING (2) on b relative to a.
300
+ return !!(b.compareDocumentPosition(a) & 2);
301
+ } catch {
302
+ return false;
303
+ }
304
+ }
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();
315
+ function nearestVisibleHeadingText(el) {
316
+ return nearestVisibleHeadingByField.get(el) || '';
317
+ }
318
+
319
+ function fieldsetLegendText(el) {
320
+ let fieldset;
321
+ try {
322
+ fieldset = el.closest ? el.closest('fieldset') : null;
323
+ } catch {
324
+ fieldset = null;
325
+ }
326
+ while (fieldset) {
327
+ let legend;
328
+ try {
329
+ legend = fieldset.querySelector('legend');
330
+ } catch {
331
+ legend = null;
332
+ }
333
+ if (legend && isVisible(legend)) {
334
+ const text = normalizeWs(legend.textContent);
335
+ if (text) return text;
336
+ }
337
+ try {
338
+ fieldset = fieldset.parentElement ? fieldset.parentElement.closest('fieldset') : null;
339
+ } catch {
340
+ fieldset = null;
341
+ }
342
+ }
343
+ return '';
344
+ }
345
+
346
+ // A table row or list item carries its own context (the product name a
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();
351
+ function rowContextText(el, labelText) {
352
+ let row;
353
+ try {
354
+ row = el.closest ? el.closest(ROW_SELECTOR) : null;
355
+ } catch {
356
+ row = null;
357
+ }
358
+ if (!row || !isVisible(row)) return '';
359
+ let text = rowTextCache.get(row);
360
+ if (text === undefined) {
361
+ text = normalizeWs(row.textContent);
362
+ rowTextCache.set(row, text);
363
+ }
364
+ if (!text) return '';
365
+ return normalizeWs(text.split(labelText).join(' '));
366
+ }
367
+
368
+ function contextKey(el, labelText) {
369
+ const group = fieldsetLegendText(el) || nearestVisibleHeadingText(el);
370
+ return `${normalize(group)}##${normalize(rowContextText(el, labelText))}`;
371
+ }
372
+
373
+ const nodes = helpers.queryAllSmart
374
+ ? helpers.queryAllSmart(FIELD_SELECTOR)
375
+ : helpers.queryAll(FIELD_SELECTOR);
376
+
377
+ const fields = [];
378
+ for (const el of nodes) {
379
+ if (!el || el.nodeType !== 1) continue;
380
+ if (!isVisible(el)) continue;
381
+
382
+ const label = getVisibleLabelText(el);
383
+ const labelText = label.text;
384
+ if (!labelText) continue; // no visible label to judge, a different rule's concern
385
+
386
+ fields.push({
387
+ el,
388
+ labelText,
389
+ normalized: normalize(labelText),
390
+ hiddenParts: label.hiddenParts
391
+ });
392
+ }
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
+
461
+ if (!fields.length) {
462
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
463
+ }
464
+
465
+ // Group by label text plus the visible context that would tell two
466
+ // same-named fields apart.
467
+ const byKey = new Map();
468
+ for (const field of fields) {
469
+ field.key = `${field.normalized}||${contextKey(field.el, field.labelText)}`;
470
+ const bucket = byKey.get(field.key);
471
+ if (bucket) bucket.push(field);
472
+ else byKey.set(field.key, [field]);
473
+ }
474
+
475
+ const occurrences = [];
476
+
477
+ for (const field of fields) {
478
+ const isPlaceholder = PLACEHOLDER_LABEL_TEXT.has(field.normalized);
479
+ const shared = byKey.get(field.key) || [];
480
+ const isDuplicate = shared.length > 1;
481
+ const isPartiallyHidden = field.hiddenParts > 0;
482
+
483
+ if (!isPlaceholder && !isDuplicate && !isPartiallyHidden) continue;
484
+
485
+ const reasonCode = isPlaceholder
486
+ ? 'PLACEHOLDER_LABEL_TEXT'
487
+ : isDuplicate
488
+ ? 'DUPLICATE_LABEL_TEXT'
489
+ : 'PARTIALLY_HIDDEN_LABEL';
490
+
491
+ const eligInfo = helpers.getEligibilityInfo
492
+ ? (() => {
493
+ try {
494
+ return helpers.getEligibilityInfo(field.el, ctx, { targetSet: 'acc' });
495
+ } catch {
496
+ return null;
497
+ }
498
+ })()
499
+ : null;
500
+
501
+ const summaryByReason = {
502
+ PLACEHOLDER_LABEL_TEXT: `This field's visible label ("${field.labelText}") is a placeholder rather than a description of what the field is for.`,
503
+ DUPLICATE_LABEL_TEXT: `This field's visible label ("${field.labelText}") is shared with ${shared.length - 1} other field(s), with no visible heading, legend or row text telling them apart.`,
504
+ PARTIALLY_HIDDEN_LABEL: `This field's label is split: "${field.labelText}" is what renders, while ${field.hiddenParts} other part(s) of the label are hidden from sight.`
505
+ };
506
+ const hintByReason = {
507
+ PLACEHOLDER_LABEL_TEXT:
508
+ 'Replace the label with one naming the information the field collects.',
509
+ DUPLICATE_LABEL_TEXT:
510
+ 'Give each field a label of its own, or put the distinguishing context on screen: a visible heading or a fieldset legend above each group.',
511
+ PARTIALLY_HIDDEN_LABEL:
512
+ 'Confirm the visible part alone identifies the field, or make the rest of the label visible.'
513
+ };
514
+ const SUMMARY_KEY_BY_REASON = {
515
+ PLACEHOLDER_LABEL_TEXT: 'formControlLabelQuality_summary_cantTell_placeholder',
516
+ DUPLICATE_LABEL_TEXT: 'formControlLabelQuality_summary_cantTell_duplicate',
517
+ PARTIALLY_HIDDEN_LABEL: 'formControlLabelQuality_summary_cantTell_partiallyHidden'
518
+ };
519
+ const HINT_KEY_BY_REASON = {
520
+ PLACEHOLDER_LABEL_TEXT: 'formControlLabelQuality_hint_cantTell_placeholder',
521
+ DUPLICATE_LABEL_TEXT: 'formControlLabelQuality_hint_cantTell_duplicate',
522
+ PARTIALLY_HIDDEN_LABEL: 'formControlLabelQuality_hint_cantTell_partiallyHidden'
523
+ };
524
+
525
+ occurrences.push(
526
+ helpers.reportOccurrence(field.el, {
527
+ summary: summaryByReason[reasonCode],
528
+ hint: hintByReason[reasonCode],
529
+ i18n: {
530
+ summaryKey: SUMMARY_KEY_BY_REASON[reasonCode],
531
+ hintKey: HINT_KEY_BY_REASON[reasonCode],
532
+ params: {
533
+ label: field.labelText,
534
+ count: String(shared.length - 1),
535
+ hiddenCount: String(field.hiddenParts)
536
+ }
537
+ },
538
+ data: {
539
+ details: {
540
+ reasonCode,
541
+ label: field.labelText,
542
+ sharedWith: isDuplicate ? shared.length - 1 : 0,
543
+ hiddenLabelParts: field.hiddenParts
544
+ },
545
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
546
+ }
547
+ })
548
+ );
549
+ }
550
+
551
+ if (occurrences.length) {
552
+ return {
553
+ ruleId: rule.ruleId,
554
+ outcome: 'cantTell',
555
+ severity: rule.defaultSeverity || 'minor',
556
+ occurrences
557
+ };
558
+ }
559
+
560
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
561
+ }
562
+
563
+ module.exports = { id, meta, runInPage };
@@ -107,7 +107,7 @@ function runInPage(ctx) {
107
107
 
108
108
  // getLabelMethod is provided by the shared dom-helpers bundle that
109
109
  // dom-runner.js always constructs for every rule execution (built-in or
110
- // custom) — see createDomHelpers's own getLabelMethod, which implements
110
+ // custom); see createDomHelpers's own getLabelMethod, which implements
111
111
  // this exact <label>/aria-labelledby/aria-label/title/placeholder
112
112
  // priority order. No local reimplementation is needed as a fallback.
113
113
  function getLabelMethodSafe(el) {
@@ -232,7 +232,7 @@ function runInPage(ctx) {
232
232
  };
233
233
  }
234
234
 
235
- // Manual rules may only emit cantTell/notApplicable (never pass/fail) —
235
+ // Manual rules may only emit cantTell/notApplicable (never pass/fail):
236
236
  // no applicable control relied on a weak (title/placeholder) primary
237
237
  // label, so there is nothing to flag for review.
238
238
  return {
@@ -6,10 +6,10 @@
6
6
  * @check heading-order
7
7
  * @atomic true
8
8
  * @summary Heading levels must not skip a level going deeper
9
- * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion)
10
10
  * @applicability
11
11
  * Applies whenever the page contains two or more heading elements
12
- * (native <h1>-<h6>, or explicit role="heading" with aria-level —
12
+ * (native <h1>-<h6>, or explicit role="heading" with aria-level;
13
13
  * default level 2 per the ARIA spec when aria-level is absent/invalid).
14
14
  * @expectation
15
15
  * In document order, each heading's level is no more than one greater
@@ -19,7 +19,7 @@
19
19
  * when navigating by heading. Going back to a shallower level at any
20
20
  * point is always fine.
21
21
  * @implementation-notes
22
- * - Not WCAG-normative — authored as an advisory, cantTell-capped
22
+ * - Not WCAG-normative, authored as an advisory, cantTell-capped
23
23
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
24
24
  * header comment for the shared rationale/precedent.
25
25
  * - Tracks the highest level reached so far (not just the immediately