@surea11y/core 1.2.0 → 1.4.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 (168) hide show
  1. package/CHANGELOG.md +81 -7
  2. package/LICENSE +373 -21
  3. package/README.md +175 -35
  4. package/bin/surea11y-core.js +20 -0
  5. package/docs/API_STABILITY.md +27 -1
  6. package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
  7. package/docs/CI_INTEGRATIONS.md +103 -0
  8. package/docs/ENGINE_OPTIONS.md +2 -0
  9. package/docs/I18N.md +12 -9
  10. package/docs/INTEGRATION.md +19 -1
  11. package/docs/LIMITATIONS.md +1 -1
  12. package/docs/OUTPUT_SCHEMA.md +1 -1
  13. package/docs/REPORT.md +1 -1
  14. package/docs/RULE_CATALOG.md +1 -1
  15. package/docs/SARIF.md +59 -0
  16. package/package.json +63 -18
  17. package/src/baseline.js +0 -0
  18. package/src/checks/automatic/area-alt-present.js +63 -31
  19. package/src/checks/automatic/aria-allowed-attr.js +204 -80
  20. package/src/checks/automatic/aria-allowed-role.js +23 -7
  21. package/src/checks/automatic/aria-braille-equivalent.js +34 -10
  22. package/src/checks/automatic/aria-conditional-attr.js +32 -14
  23. package/src/checks/automatic/aria-deprecated-role.js +26 -11
  24. package/src/checks/automatic/aria-hidden-body.js +48 -23
  25. package/src/checks/automatic/aria-hidden-focus.js +420 -66
  26. package/src/checks/automatic/aria-prohibited-attr.js +327 -60
  27. package/src/checks/automatic/aria-prohibited-children.js +111 -103
  28. package/src/checks/automatic/aria-required-attr.js +29 -15
  29. package/src/checks/automatic/aria-required-children.js +44 -24
  30. package/src/checks/automatic/aria-required-parent.js +64 -35
  31. package/src/checks/automatic/aria-role-name-present.js +49 -21
  32. package/src/checks/automatic/aria-roles-valid.js +24 -12
  33. package/src/checks/automatic/aria-valid-attr-value.js +46 -22
  34. package/src/checks/automatic/aria-valid-attr.js +19 -5
  35. package/src/checks/automatic/autocomplete-valid.js +76 -16
  36. package/src/checks/automatic/avoid-inline-spacing.js +23 -8
  37. package/src/checks/automatic/binary-control-name-present.js +62 -50
  38. package/src/checks/automatic/button-name-present.js +54 -24
  39. package/src/checks/automatic/bypass-blocks-present.js +51 -32
  40. package/src/checks/automatic/canvas-text-alternative-present.js +59 -26
  41. package/src/checks/automatic/combobox-name-present.js +40 -45
  42. package/src/checks/automatic/contrast-computable.js +363 -341
  43. package/src/checks/automatic/contrast-enhanced.js +489 -466
  44. package/src/checks/automatic/contrast-minimum.js +488 -465
  45. package/src/checks/automatic/css-orientation-lock.js +51 -35
  46. package/src/checks/automatic/definition-list-children-valid.js +46 -25
  47. package/src/checks/automatic/deprecated-elements-not-used.js +25 -9
  48. package/src/checks/automatic/dialog-name-present.js +47 -85
  49. package/src/checks/automatic/dlitem-parent-valid.js +25 -8
  50. package/src/checks/automatic/duplicate-id-aria.js +28 -9
  51. package/src/checks/automatic/embed-text-alternative-present.js +88 -35
  52. package/src/checks/automatic/form-control-programmatic-label-present.js +81 -196
  53. package/src/checks/automatic/form-control-single-label.js +50 -14
  54. package/src/checks/automatic/html-xml-lang-mismatch.js +36 -18
  55. package/src/checks/automatic/iframe-focusable-content.js +265 -22
  56. package/src/checks/automatic/iframe-name-present.js +33 -9
  57. package/src/checks/automatic/iframe-title-unique.js +32 -9
  58. package/src/checks/automatic/img-alt-present.js +54 -52
  59. package/src/checks/automatic/input-image-alt-present.js +141 -112
  60. package/src/checks/automatic/label-in-name.js +65 -41
  61. package/src/checks/automatic/language-page-present.js +111 -109
  62. package/src/checks/automatic/link-in-text-block.js +61 -19
  63. package/src/checks/automatic/link-name-present.js +47 -14
  64. package/src/checks/automatic/list-children-valid.js +40 -33
  65. package/src/checks/automatic/listbox-name-present.js +41 -19
  66. package/src/checks/automatic/listitem-parent-valid.js +48 -13
  67. package/src/checks/automatic/menuitem-name-present.js +41 -61
  68. package/src/checks/automatic/meta-refresh-no-exceptions.js +32 -11
  69. package/src/checks/automatic/meta-refresh-timing-absent.js +22 -6
  70. package/src/checks/automatic/meta-viewport-zoom-enabled.js +26 -7
  71. package/src/checks/automatic/meter-name-present.js +40 -36
  72. package/src/checks/automatic/nested-interactive-controls-absent.js +58 -15
  73. package/src/checks/automatic/object-text-alternative-present.js +93 -39
  74. package/src/checks/automatic/option-name-present.js +40 -21
  75. package/src/checks/automatic/page-title-present.js +19 -6
  76. package/src/checks/automatic/progressbar-name-present.js +49 -44
  77. package/src/checks/automatic/role-img-alt-present.js +211 -159
  78. package/src/checks/automatic/searchbox-name-present.js +41 -19
  79. package/src/checks/automatic/server-side-image-map-absent.js +27 -11
  80. package/src/checks/automatic/slider-name-present.js +42 -47
  81. package/src/checks/automatic/spinbutton-name-present.js +41 -19
  82. package/src/checks/automatic/summary-name-present.js +39 -17
  83. package/src/checks/automatic/svg-image-text-alternative-present.js +116 -47
  84. package/src/checks/automatic/svg-text-alternative-present.js +262 -230
  85. package/src/checks/automatic/tab-name-present.js +39 -60
  86. package/src/checks/automatic/table-headers-attr-valid.js +27 -10
  87. package/src/checks/automatic/table-th-has-data-cells.js +24 -8
  88. package/src/checks/automatic/target-size-minimum.js +123 -48
  89. package/src/checks/automatic/td-has-header.js +53 -12
  90. package/src/checks/automatic/textbox-name-present.js +41 -19
  91. package/src/checks/automatic/tooltip-name-present.js +39 -18
  92. package/src/checks/automatic/treeitem-name-present.js +40 -21
  93. package/src/checks/automatic/valid-lang.js +22 -6
  94. package/src/checks/automatic/video-poster-text-alternative-present.js +81 -36
  95. package/src/checks/manual/accesskeys-manual.js +17 -6
  96. package/src/checks/manual/area-alt-decorative-manual.js +194 -193
  97. package/src/checks/manual/area-alt-quality-manual.js +184 -141
  98. package/src/checks/manual/aria-checked-state-mismatch-manual.js +48 -34
  99. package/src/checks/manual/aria-text-manual.js +20 -11
  100. package/src/checks/manual/canvas-text-alternative-quality-manual.js +151 -114
  101. package/src/checks/manual/css-hidden-focus.js +375 -169
  102. package/src/checks/manual/embed-text-alternative-quality-manual.js +178 -162
  103. package/src/checks/manual/empty-heading-manual.js +41 -24
  104. package/src/checks/manual/empty-table-header-manual.js +69 -31
  105. package/src/checks/manual/focus-order-semantics-manual.js +60 -13
  106. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +209 -246
  107. package/src/checks/manual/heading-order-manual.js +50 -8
  108. package/src/checks/manual/identical-links-same-purpose-manual.js +36 -12
  109. package/src/checks/manual/image-redundant-alt-manual.js +38 -8
  110. package/src/checks/manual/img-alt-decorative-manual.js +133 -96
  111. package/src/checks/manual/img-alt-quality-manual.js +178 -127
  112. package/src/checks/manual/input-image-alt-decorative-manual.js +127 -92
  113. package/src/checks/manual/input-image-alt-quality-manual.js +127 -92
  114. package/src/checks/manual/label-title-only-manual.js +44 -28
  115. package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -38
  116. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +85 -32
  117. package/src/checks/manual/landmark-main-is-top-level-manual.js +69 -27
  118. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +45 -33
  119. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +43 -31
  120. package/src/checks/manual/landmark-no-duplicate-main-manual.js +27 -21
  121. package/src/checks/manual/landmark-one-main-manual.js +38 -43
  122. package/src/checks/manual/landmark-unique-manual.js +78 -67
  123. package/src/checks/manual/link-name-quality-manual.js +45 -12
  124. package/src/checks/manual/media-transcript-present-manual.js +37 -22
  125. package/src/checks/manual/meta-viewport-large-manual.js +19 -6
  126. package/src/checks/manual/mouse-only-event-handlers-manual.js +40 -11
  127. package/src/checks/manual/no-autoplay-audio-manual.js +22 -6
  128. package/src/checks/manual/object-text-alternative-quality-manual.js +177 -154
  129. package/src/checks/manual/p-as-heading-manual.js +24 -7
  130. package/src/checks/manual/page-has-heading-one-manual.js +42 -32
  131. package/src/checks/manual/page-title-patterns-manual.js +80 -50
  132. package/src/checks/manual/presentation-role-conflict-manual.js +101 -47
  133. package/src/checks/manual/region-manual.js +244 -60
  134. package/src/checks/manual/scope-attr-valid-manual.js +13 -4
  135. package/src/checks/manual/scrollable-region-focusable-manual.js +39 -11
  136. package/src/checks/manual/skip-link-manual.js +42 -18
  137. package/src/checks/manual/svg-text-alternative-quality-manual.js +208 -165
  138. package/src/checks/manual/tabindex-manual.js +13 -4
  139. package/src/checks/manual/table-duplicate-name-manual.js +22 -11
  140. package/src/checks/manual/table-fake-caption-manual.js +48 -10
  141. package/src/checks/manual/video-caption-manual.js +17 -4
  142. package/src/checks/manual-review.js +58 -12
  143. package/src/core.js +41705 -29650
  144. package/src/index.js +2 -0
  145. package/src/report.js +109 -47
  146. package/src/sarif.js +190 -0
  147. package/surea11y.browser.js +37774 -0
  148. package/bin/core.js +0 -348
  149. package/docs/CLI.md +0 -75
  150. package/src/catalogs/composites.wcag.js +0 -490
  151. package/src/checks/rules-and-tags.full.csv +0 -19
  152. package/src/checks/rules-and-tags.full.json +0 -259
  153. package/src/core/aria-helpers.js +0 -970
  154. package/src/core/contrast-helpers.js +0 -1147
  155. package/src/core/dom-helpers.js +0 -4235
  156. package/src/core/dom-runner.js +0 -671
  157. package/src/core/frame-messaging.js +0 -210
  158. package/src/core/frame-scan.js +0 -178
  159. package/src/core/rollup-composites.js +0 -135
  160. package/src/core/rule-meta.js +0 -159
  161. package/src/coverage/wcag-facets.js +0 -1079
  162. package/src/coverage/wcag-version-map.js +0 -84
  163. package/src/i18n/en.js +0 -923
  164. package/src/i18n/fr.js +0 -844
  165. package/src/policy/contracts.js +0 -18
  166. package/src/policy/resolvePolicy.js +0 -55
  167. package/src/policy/schemas/engine-options.schema.json +0 -103
  168. package/src/policy/schemas/policy-contract.schema.json +0 -40
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -25,267 +27,228 @@
25
27
  const id = 'form-control-programmatic-label-quality';
26
28
 
27
29
  const meta = {
28
- title: 'Form controls should not rely on placeholder or title as the primary label',
29
- description:
30
- 'Flags form controls whose computed accessible name relies on placeholder or title as the primary labeling method. Prefer <label> or aria-labelledby.',
31
- i18n: {
32
- titleKey: 'formControl_programmaticLabelQuality_title',
33
- descriptionKey: 'formControl_programmaticLabelQuality_description'
34
- },
35
- helpUrl: null,
36
- tags: ['wcag2a', 'wcag412', 'forms', 'labels', 'quality', 'atomic', 'manual'],
37
- wcagSc: ['4.1.2'],
38
- normativeMappings: [
39
- { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
40
- ],
41
- defaultSeverity: 'moderate',
42
- category: 'robust',
43
- type: 'manual',
44
- defaultConfidence: 'medium',
45
- coverage: {
46
- facetsBySc: {
47
- '4.1.2': ['form-control-name-quality']
48
- }
30
+ title: 'Form controls should not rely on placeholder or title as the primary label',
31
+ description:
32
+ 'Flags form controls whose computed accessible name relies on placeholder or title as the primary labeling method. Prefer <label> or aria-labelledby.',
33
+ i18n: {
34
+ titleKey: 'formControl_programmaticLabelQuality_title',
35
+ descriptionKey: 'formControl_programmaticLabelQuality_description'
36
+ },
37
+ helpUrl: null,
38
+ tags: ['wcag2a', 'wcag412', 'forms', 'labels', 'quality', 'atomic', 'manual'],
39
+ wcagSc: ['4.1.2'],
40
+ normativeMappings: [
41
+ {
42
+ standard: 'WCAG',
43
+ version: '2.2',
44
+ requirement: '4.1.2',
45
+ title: 'Name, Role, Value',
46
+ conformanceLevel: 'A'
47
+ }
48
+ ],
49
+ defaultSeverity: 'moderate',
50
+ category: 'robust',
51
+ type: 'manual',
52
+ defaultConfidence: 'medium',
53
+ coverage: {
54
+ facetsBySc: {
55
+ '4.1.2': ['form-control-name-quality']
49
56
  }
57
+ }
50
58
  };
51
59
 
52
60
  function runInPage(ctx) {
53
- const { document, root, helpers, rule } = ctx;
54
- const safeRoot = root || document;
55
-
56
- const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
57
-
58
- const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
59
- const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
60
-
61
- const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
62
- const getAriaLabelInfo = helpers && typeof helpers.getAriaLabelInfo === 'function' ? helpers.getAriaLabelInfo : null;
63
- const getAriaLabelledByInfo = helpers && typeof helpers.getAriaLabelledByInfo === 'function' ? helpers.getAriaLabelledByInfo : null;
64
-
65
- const getAttributeInfo = helpers && typeof helpers.getAttributeInfo === 'function' ? helpers.getAttributeInfo : null;
66
-
67
- // Optional helper; we validate/normalize if present.
68
- const getLabelMethod = helpers && typeof helpers.getLabelMethod === 'function' ? helpers.getLabelMethod : null;
69
-
70
- const trim = (v) => (v == null ? '' : String(v)).trim();
71
-
72
- const metrics = {
73
- applicableCount: 0,
74
- flaggedCount: 0,
75
- byMethod: { label: 0, 'aria-labelledby': 0, 'aria-label': 0, title: 0, placeholder: 0, none: 0 }
76
- };
77
-
78
- function safeQueryAll(sel) {
79
- try {
80
- if (queryAllSmart) return Array.from(queryAllSmart(sel) || []);
81
- return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : [];
82
- } catch {
83
- return [];
84
- }
85
- }
86
-
87
- function isEligibleAcc(el) {
88
- if (!isAccTreeEligible) return true;
89
- try {
90
- const r = isAccTreeEligible(el, ctx);
91
- if (typeof r === 'boolean') return r;
92
- return !!(r && r.eligible);
93
- } catch {
94
- return true;
95
- }
96
- }
97
-
98
- function isFocusable(el) {
99
- if (!getFocusableInfo) return false;
100
- try {
101
- const fi = getFocusableInfo(el, ctx);
102
- return !!(fi && fi.focusable);
103
- } catch {
104
- return false;
105
- }
106
- }
107
-
108
- function getNonEmptyAttr(el, name) {
109
- if (!getAttributeInfo) return '';
110
- try {
111
- const info = getAttributeInfo(el, name);
112
- return info && info.present ? trim(info.value) : '';
113
- } catch {
114
- return '';
115
- }
61
+ const { document, root, helpers, rule } = ctx;
62
+ const safeRoot = root || document;
63
+
64
+ const queryAllSmart =
65
+ helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
66
+
67
+ const isAccTreeEligible =
68
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
69
+ const getEligibilityInfo =
70
+ helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
71
+
72
+ const getFocusableInfo =
73
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
74
+
75
+ const getLabelMethod =
76
+ helpers && typeof helpers.getLabelMethod === 'function' ? helpers.getLabelMethod : null;
77
+
78
+ const trim = (v) => (v == null ? '' : String(v)).trim();
79
+
80
+ const metrics = {
81
+ applicableCount: 0,
82
+ flaggedCount: 0,
83
+ byMethod: { label: 0, 'aria-labelledby': 0, 'aria-label': 0, title: 0, placeholder: 0, none: 0 }
84
+ };
85
+
86
+ function safeQueryAll(sel) {
87
+ try {
88
+ if (queryAllSmart) return Array.from(queryAllSmart(sel) || []);
89
+ return safeRoot && safeRoot.querySelectorAll
90
+ ? Array.from(safeRoot.querySelectorAll(sel))
91
+ : [];
92
+ } catch {
93
+ return [];
116
94
  }
117
-
118
- function hasLabelAssociation(el) {
119
- // 1) Native labels API
120
- try {
121
- if (el && 'labels' in el && el.labels && el.labels.length) return true;
122
- } catch {}
123
-
124
- // 2) Wrapped by <label>
125
- try {
126
- if (el && el.closest) {
127
- const wrap = el.closest('label');
128
- if (wrap) return true;
129
- }
130
- } catch {}
131
-
132
- // 3) <label for="id">
133
- try {
134
- const idAttr = el && el.getAttribute ? trim(el.getAttribute('id')) : '';
135
- if (!idAttr || !document || !document.querySelector) return false;
136
-
137
- const esc = idAttr.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
138
- const sel = `label[for="${esc}"]`;
139
- return !!document.querySelector(sel);
140
- } catch {
141
- return false;
142
- }
95
+ }
96
+
97
+ function isEligibleAcc(el) {
98
+ if (!isAccTreeEligible) return true;
99
+ try {
100
+ const r = isAccTreeEligible(el, ctx);
101
+ if (typeof r === 'boolean') return r;
102
+ return !!(r && r.eligible);
103
+ } catch {
104
+ return true;
143
105
  }
144
-
145
- function computeLabelMethodFallback(el) {
146
- // Deterministic priority order:
147
- // label > aria-labelledby > aria-label > title > placeholder
148
- if (hasLabelAssociation(el)) return { method: 'label', value: '' };
149
-
150
- if (getAriaLabelledByInfo) {
151
- try {
152
- const info = getAriaLabelledByInfo(el, ctx, { maxRefs: 8 });
153
- const v = info && info.present ? trim(info.value) : '';
154
- if (v) return { method: 'aria-labelledby', value: v };
155
- } catch {}
156
- }
157
-
158
- if (getAriaLabelInfo) {
159
- try {
160
- const info = getAriaLabelInfo(el, ctx);
161
- const v = info && info.present ? trim(info.value) : '';
162
- if (v) return { method: 'aria-label', value: v };
163
- } catch {}
164
- }
165
-
166
- const titleV = getNonEmptyAttr(el, 'title');
167
- if (titleV) return { method: 'title', value: titleV };
168
-
169
- const phV = getNonEmptyAttr(el, 'placeholder');
170
- if (phV) return { method: 'placeholder', value: phV };
171
-
106
+ }
107
+
108
+ // getLabelMethod is provided by the shared dom-helpers bundle that
109
+ // dom-runner.js always constructs for every rule execution (built-in or
110
+ // custom) — see createDomHelpers's own getLabelMethod, which implements
111
+ // this exact <label>/aria-labelledby/aria-label/title/placeholder
112
+ // priority order. No local reimplementation is needed as a fallback.
113
+ function getLabelMethodSafe(el) {
114
+ if (!getLabelMethod) return { method: 'none', value: '' };
115
+ try {
116
+ const r = getLabelMethod(el, ctx);
117
+ const m = r && typeof r.method === 'string' ? r.method : 'none';
118
+ const v = r && r.value != null ? trim(r.value) : '';
119
+ if (!Object.prototype.hasOwnProperty.call(metrics.byMethod, m))
172
120
  return { method: 'none', value: '' };
121
+ return { method: m, value: v };
122
+ } catch {
123
+ return { method: 'none', value: '' };
173
124
  }
125
+ }
174
126
 
175
- function getLabelMethodSafe(el) {
176
- if (getLabelMethod) {
177
- try {
178
- const r = getLabelMethod(el, ctx);
179
- const m = r && typeof r.method === 'string' ? r.method : 'none';
180
- const v = r && r.value != null ? trim(r.value) : '';
181
- if (!Object.prototype.hasOwnProperty.call(metrics.byMethod, m)) return { method: 'none', value: '' };
182
- return { method: m, value: v };
183
- } catch {
184
- // fall through
185
- }
186
- }
187
- return computeLabelMethodFallback(el);
188
- }
189
-
190
- // Native controls only (same as your current rule)
191
- const selector =
192
- 'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]),select,textarea';
127
+ // Native controls only (same as your current rule)
128
+ const selector =
129
+ 'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]),select,textarea';
193
130
 
194
- const nodes = safeQueryAll(selector);
131
+ const nodes = safeQueryAll(selector);
195
132
 
196
- if (!nodes.length) {
197
- return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [], data: { details: { metrics } } };
133
+ if (!nodes.length) {
134
+ return {
135
+ ruleId: rule.ruleId,
136
+ outcome: 'notApplicable',
137
+ severity: 'minor',
138
+ occurrences: [],
139
+ data: { details: { metrics } }
140
+ };
141
+ }
142
+
143
+ const occurrences = [];
144
+
145
+ for (const el of nodes) {
146
+ if (!el || !el.getAttribute) continue;
147
+
148
+ if (!isEligibleAcc(el)) continue;
149
+
150
+ const role = (() => {
151
+ try {
152
+ return trim(el.getAttribute('role')).toLowerCase();
153
+ } catch {
154
+ return '';
155
+ }
156
+ })();
157
+
158
+ let fi = null;
159
+ if (getFocusableInfo) {
160
+ try {
161
+ fi = getFocusableInfo(el, ctx);
162
+ } catch {
163
+ fi = null;
164
+ }
198
165
  }
199
-
200
- const occurrences = [];
201
-
202
- for (const el of nodes) {
203
- if (!el || !el.getAttribute) continue;
204
-
205
- if (!isEligibleAcc(el)) continue;
206
-
207
- const role = (() => {
208
- try {
209
- return trim(el.getAttribute('role')).toLowerCase();
210
- } catch {
211
- return '';
212
- }
213
- })();
214
-
215
- let fi = null;
216
- if (getFocusableInfo) {
217
- try { fi = getFocusableInfo(el, ctx); } catch { fi = null; }
218
- }
219
- const tabbable = !!(fi && fi.tabbable);
220
-
221
- if ((role === 'presentation' || role === 'none') && !tabbable) continue;
222
-
223
- metrics.applicableCount += 1;
224
-
225
- const label = getLabelMethodSafe(el);
226
- const method = label && typeof label.method === 'string' ? label.method : 'none';
227
- if (Object.prototype.hasOwnProperty.call(metrics.byMethod, method)) metrics.byMethod[method] += 1;
228
- else metrics.byMethod.none += 1;
229
-
230
- // Flag only when the *primary* (best) method is title/placeholder
231
- const isWeakPrimary = method === 'title' || method === 'placeholder';
232
- if (!isWeakPrimary) continue;
233
-
234
- metrics.flaggedCount += 1;
235
-
236
- const vf = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
237
-
238
- const reasonCode = method === 'title' ? 'label_from_title_primary' : 'label_from_placeholder_primary';
239
- const methodLabel =
240
- method === 'title' ? 'title'
241
- : method === 'placeholder' ? 'placeholder'
242
- : 'title or placeholder';
243
-
244
- const baseOccurrence = {
245
- summary: 'Form control’s primary label is derived from title or placeholder.',
246
- hint: 'Prefer a persistent <label> or aria-labelledby. Avoid relying on placeholder/title as the primary label.',
247
- i18n: {
248
- summaryKey: 'formControl_programmaticLabelQuality_summary_cantTell',
249
- hintKey: 'formControl_programmaticLabelQuality_hint_cantTell',
250
- params: { element: (el.tagName || '').toLowerCase(), method, methodLabel }
251
- },
252
- data: {
253
- visibilityFilter: vf || { targetSet: 'acc', accEligible: null, reasons: [] },
254
- details: {
255
- reasonCode,
256
- labelMethod: method,
257
- labelStrength: 'weak',
258
- recommendedMethods: ['label', 'aria-labelledby'],
259
- sourceText: (label && label.value ? String(label.value).slice(0, 120) : '')
260
- }
261
- }
262
- };
263
-
264
- if (helpers && typeof helpers.reportOccurrence === 'function') {
265
- occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
266
- } else {
267
- occurrences.push({ selector: '', html: '', ...baseOccurrence });
166
+ const tabbable = !!(fi && fi.tabbable);
167
+
168
+ if ((role === 'presentation' || role === 'none') && !tabbable) continue;
169
+
170
+ metrics.applicableCount += 1;
171
+
172
+ const label = getLabelMethodSafe(el);
173
+ const method = label && typeof label.method === 'string' ? label.method : 'none';
174
+ if (Object.prototype.hasOwnProperty.call(metrics.byMethod, method))
175
+ metrics.byMethod[method] += 1;
176
+ else metrics.byMethod.none += 1;
177
+
178
+ // Flag only when the *primary* (best) method is title/placeholder
179
+ const isWeakPrimary = method === 'title' || method === 'placeholder';
180
+ if (!isWeakPrimary) continue;
181
+
182
+ metrics.flaggedCount += 1;
183
+
184
+ const vf = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
185
+
186
+ const reasonCode =
187
+ method === 'title' ? 'label_from_title_primary' : 'label_from_placeholder_primary';
188
+ const methodLabel =
189
+ method === 'title'
190
+ ? 'title'
191
+ : method === 'placeholder'
192
+ ? 'placeholder'
193
+ : 'title or placeholder';
194
+
195
+ const baseOccurrence = {
196
+ summary: 'Form control’s primary label is derived from title or placeholder.',
197
+ hint: 'Prefer a persistent <label> or aria-labelledby. Avoid relying on placeholder/title as the primary label.',
198
+ i18n: {
199
+ summaryKey: 'formControl_programmaticLabelQuality_summary_cantTell',
200
+ hintKey: 'formControl_programmaticLabelQuality_hint_cantTell',
201
+ params: { element: (el.tagName || '').toLowerCase(), method, methodLabel }
202
+ },
203
+ data: {
204
+ visibilityFilter: vf || { targetSet: 'acc', accEligible: null, reasons: [] },
205
+ details: {
206
+ reasonCode,
207
+ labelMethod: method,
208
+ labelStrength: 'weak',
209
+ recommendedMethods: ['label', 'aria-labelledby'],
210
+ sourceText: label && label.value ? String(label.value).slice(0, 120) : ''
268
211
  }
269
- }
270
-
271
- if (metrics.applicableCount === 0) {
272
- return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [], data: { details: { metrics } } };
273
- }
212
+ }
213
+ };
274
214
 
275
- if (occurrences.length) {
276
- return {
277
- ruleId: rule.ruleId,
278
- outcome: 'cantTell',
279
- severity: rule.defaultSeverity || 'minor',
280
- occurrences,
281
- data: { details: { metrics } }
282
- };
215
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
216
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
217
+ } else {
218
+ occurrences.push({ selector: '', html: '', ...baseOccurrence });
283
219
  }
284
-
285
- // Manual rules may only emit cantTell/notApplicable (never pass/fail) —
286
- // no applicable control relied on a weak (title/placeholder) primary
287
- // label, so there is nothing to flag for review.
288
- return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [], data: { details: { metrics } } };
220
+ }
221
+
222
+ if (metrics.applicableCount === 0) {
223
+ return {
224
+ ruleId: rule.ruleId,
225
+ outcome: 'notApplicable',
226
+ severity: 'minor',
227
+ occurrences: [],
228
+ data: { details: { metrics } }
229
+ };
230
+ }
231
+
232
+ if (occurrences.length) {
233
+ return {
234
+ ruleId: rule.ruleId,
235
+ outcome: 'cantTell',
236
+ severity: rule.defaultSeverity || 'minor',
237
+ occurrences,
238
+ data: { details: { metrics } }
239
+ };
240
+ }
241
+
242
+ // Manual rules may only emit cantTell/notApplicable (never pass/fail) —
243
+ // no applicable control relied on a weak (title/placeholder) primary
244
+ // label, so there is nothing to flag for review.
245
+ return {
246
+ ruleId: rule.ruleId,
247
+ outcome: 'notApplicable',
248
+ severity: 'minor',
249
+ occurrences: [],
250
+ data: { details: { metrics } }
251
+ };
289
252
  }
290
253
 
291
254
  module.exports = { id, meta, runInPage };
@@ -1,10 +1,12 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check heading-order
5
7
  * @atomic true
6
8
  * @summary Heading levels must not skip a level going deeper
7
- * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
10
  * @applicability
9
11
  * Applies whenever the page contains two or more heading elements
10
12
  * (native <h1>-<h6>, or explicit role="heading" with aria-level —
@@ -47,10 +49,12 @@ const meta = {
47
49
  };
48
50
 
49
51
  function runInPage(ctx) {
50
- const { document, helpers, rule } = ctx;
52
+ const { helpers, rule } = ctx;
51
53
 
52
54
  function normalizeWs(s) {
53
- return String(s || '').replace(/\s+/g, ' ').trim();
55
+ return String(s || '')
56
+ .replace(/\s+/g, ' ')
57
+ .trim();
54
58
  }
55
59
 
56
60
  function getExplicitRoleToken(el) {
@@ -65,20 +69,47 @@ function runInPage(ctx) {
65
69
  if (explicit !== 'heading') return 0;
66
70
  const raw = normalizeWs(el.getAttribute && el.getAttribute('aria-level'));
67
71
  const n = parseInt(raw, 10);
68
- return (Number.isFinite(n) && n >= 1) ? n : 2;
72
+ return Number.isFinite(n) && n >= 1 ? n : 2;
69
73
  }
70
74
  const tag = el.tagName ? el.tagName.toLowerCase() : '';
71
75
  const m = /^h([1-6])$/.exec(tag);
72
76
  return m ? parseInt(m[1], 10) : 0;
73
77
  }
74
78
 
75
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('h1, h2, h3, h4, h5, h6, [role]') : helpers.queryAll('h1, h2, h3, h4, h5, h6, [role]');
79
+ const nodes = helpers.queryAllSmart
80
+ ? helpers.queryAllSmart('h1, h2, h3, h4, h5, h6, [role]')
81
+ : helpers.queryAll('h1, h2, h3, h4, h5, h6, [role]');
76
82
 
77
83
  const headings = [];
78
84
  const seen = new Set();
79
85
  for (const el of nodes) {
80
86
  if (!el || seen.has(el)) continue;
81
87
  seen.add(el);
88
+
89
+ // queryAllSmart's default hidden-content policy only excludes "hard"
90
+ // CSS-based hiding (display:none, visibility:hidden, etc.) -- it does
91
+ // NOT exclude aria-hidden, a softer/semantic removal from the
92
+ // accessibility tree that still leaves an element visually rendered.
93
+ // A heading order rule whose whole premise is "the document outline
94
+ // assistive technology users rely on" (see this rule's own header
95
+ // comment) must not let an aria-hidden heading participate in that
96
+ // outline at all: it's invisible to exactly the users this rule
97
+ // exists to protect. Without this check an aria-hidden heading is both
98
+ // flagged itself (though it isn't part of the AT-perceived outline)
99
+ // and can mask a real skip immediately after it, by advancing the
100
+ // "highest level reached so far" tracker on a level no AT user
101
+ // actually encountered.
102
+ if (helpers.isAccTreeEligible) {
103
+ const elig = (() => {
104
+ try {
105
+ return helpers.isAccTreeEligible(el, ctx);
106
+ } catch {
107
+ return { eligible: true, reasons: [] };
108
+ }
109
+ })();
110
+ if (elig && elig.eligible === false) continue;
111
+ }
112
+
82
113
  const level = getHeadingLevel(el);
83
114
  if (level > 0) headings.push({ el, level });
84
115
  }
@@ -95,7 +126,9 @@ function runInPage(ctx) {
95
126
 
96
127
  if (level > highestSoFar + 1) {
97
128
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
98
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
129
+ const html = helpers.getOuterHtmlSnippet
130
+ ? helpers.getOuterHtmlSnippet(el)
131
+ : el.outerHTML || '';
99
132
 
100
133
  occurrences.push({
101
134
  selector: stableSelector,
@@ -108,7 +141,11 @@ function runInPage(ctx) {
108
141
  params: { fromLevel: String(highestSoFar), toLevel: String(level) }
109
142
  },
110
143
  data: {
111
- details: { reasonCode: 'HEADING_ORDER_SKIPPED_LEVEL', fromLevel: highestSoFar, toLevel: level }
144
+ details: {
145
+ reasonCode: 'HEADING_ORDER_SKIPPED_LEVEL',
146
+ fromLevel: highestSoFar,
147
+ toLevel: level
148
+ }
112
149
  }
113
150
  });
114
151
  }
@@ -117,7 +154,12 @@ function runInPage(ctx) {
117
154
  }
118
155
 
119
156
  if (occurrences.length) {
120
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
157
+ return {
158
+ ruleId: rule.ruleId,
159
+ outcome: 'cantTell',
160
+ severity: rule.defaultSeverity || 'minor',
161
+ occurrences
162
+ };
121
163
  }
122
164
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
123
165
  }