@surea11y/core 1.1.2 → 1.3.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 (160) hide show
  1. package/CHANGELOG.md +48 -4
  2. package/LICENSE +373 -21
  3. package/README.md +70 -1
  4. package/bin/core.js +240 -11
  5. package/docs/API_STABILITY.md +61 -0
  6. package/docs/BASELINE.md +66 -0
  7. package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
  8. package/docs/CI_INTEGRATIONS.md +103 -0
  9. package/docs/CLI.md +80 -1
  10. package/docs/ENGINE_OPTIONS.md +4 -0
  11. package/docs/INTEGRATION.md +19 -1
  12. package/docs/OUTPUT_SCHEMA.md +2 -2
  13. package/docs/REPORT.md +33 -0
  14. package/docs/RULE_AUTHORING.md +31 -0
  15. package/docs/RULE_CATALOG.md +1 -1
  16. package/docs/SARIF.md +59 -0
  17. package/package.json +15 -4
  18. package/src/baseline.js +0 -0
  19. package/src/catalogs/composites.wcag.js +414 -450
  20. package/src/checks/automatic/area-alt-present.js +59 -25
  21. package/src/checks/automatic/aria-allowed-attr.js +193 -33
  22. package/src/checks/automatic/aria-allowed-role.js +21 -7
  23. package/src/checks/automatic/aria-braille-equivalent.js +32 -10
  24. package/src/checks/automatic/aria-conditional-attr.js +24 -7
  25. package/src/checks/automatic/aria-deprecated-role.js +22 -8
  26. package/src/checks/automatic/aria-hidden-body.js +50 -19
  27. package/src/checks/automatic/aria-hidden-focus.js +408 -53
  28. package/src/checks/automatic/aria-prohibited-attr.js +296 -22
  29. package/src/checks/automatic/aria-prohibited-children.js +125 -29
  30. package/src/checks/automatic/aria-required-attr.js +23 -8
  31. package/src/checks/automatic/aria-required-children.js +37 -14
  32. package/src/checks/automatic/aria-required-parent.js +48 -14
  33. package/src/checks/automatic/aria-role-name-present.js +47 -21
  34. package/src/checks/automatic/aria-roles-valid.js +22 -12
  35. package/src/checks/automatic/aria-valid-attr-value.js +28 -7
  36. package/src/checks/automatic/aria-valid-attr.js +17 -5
  37. package/src/checks/automatic/autocomplete-valid.js +74 -16
  38. package/src/checks/automatic/avoid-inline-spacing.js +20 -6
  39. package/src/checks/automatic/binary-control-name-present.js +60 -50
  40. package/src/checks/automatic/button-name-present.js +48 -18
  41. package/src/checks/automatic/bypass-blocks-present.js +50 -25
  42. package/src/checks/automatic/canvas-text-alternative-present.js +57 -26
  43. package/src/checks/automatic/combobox-name-present.js +38 -45
  44. package/src/checks/automatic/contrast-computable.js +361 -341
  45. package/src/checks/automatic/contrast-enhanced.js +487 -466
  46. package/src/checks/automatic/contrast-minimum.js +486 -465
  47. package/src/checks/automatic/css-orientation-lock.js +39 -9
  48. package/src/checks/automatic/definition-list-children-valid.js +40 -19
  49. package/src/checks/automatic/deprecated-elements-not-used.js +21 -7
  50. package/src/checks/automatic/dialog-name-present.js +37 -75
  51. package/src/checks/automatic/dlitem-parent-valid.js +23 -8
  52. package/src/checks/automatic/duplicate-id-aria.js +24 -6
  53. package/src/checks/automatic/embed-text-alternative-present.js +86 -35
  54. package/src/checks/automatic/form-control-programmatic-label-present.js +79 -196
  55. package/src/checks/automatic/form-control-single-label.js +47 -10
  56. package/src/checks/automatic/html-xml-lang-mismatch.js +43 -19
  57. package/src/checks/automatic/iframe-focusable-content.js +26 -11
  58. package/src/checks/automatic/iframe-name-present.js +31 -9
  59. package/src/checks/automatic/iframe-title-unique.js +29 -8
  60. package/src/checks/automatic/img-alt-present.js +47 -43
  61. package/src/checks/automatic/input-image-alt-present.js +143 -112
  62. package/src/checks/automatic/label-in-name.js +50 -22
  63. package/src/checks/automatic/language-page-present.js +117 -109
  64. package/src/checks/automatic/link-in-text-block.js +59 -19
  65. package/src/checks/automatic/link-name-present.js +45 -14
  66. package/src/checks/automatic/list-children-valid.js +26 -9
  67. package/src/checks/automatic/listbox-name-present.js +39 -19
  68. package/src/checks/automatic/listitem-parent-valid.js +18 -6
  69. package/src/checks/automatic/menuitem-name-present.js +39 -61
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +37 -8
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +28 -6
  72. package/src/checks/automatic/meta-viewport-zoom-enabled.js +32 -7
  73. package/src/checks/automatic/meter-name-present.js +36 -33
  74. package/src/checks/automatic/nested-interactive-controls-absent.js +31 -10
  75. package/src/checks/automatic/object-text-alternative-present.js +91 -39
  76. package/src/checks/automatic/option-name-present.js +38 -21
  77. package/src/checks/automatic/page-title-present.js +26 -7
  78. package/src/checks/automatic/progressbar-name-present.js +41 -34
  79. package/src/checks/automatic/role-img-alt-present.js +209 -157
  80. package/src/checks/automatic/searchbox-name-present.js +39 -19
  81. package/src/checks/automatic/server-side-image-map-absent.js +23 -8
  82. package/src/checks/automatic/slider-name-present.js +40 -47
  83. package/src/checks/automatic/spinbutton-name-present.js +39 -19
  84. package/src/checks/automatic/summary-name-present.js +37 -17
  85. package/src/checks/automatic/svg-image-text-alternative-present.js +114 -47
  86. package/src/checks/automatic/svg-text-alternative-present.js +246 -226
  87. package/src/checks/automatic/tab-name-present.js +37 -60
  88. package/src/checks/automatic/table-headers-attr-valid.js +24 -8
  89. package/src/checks/automatic/table-th-has-data-cells.js +22 -8
  90. package/src/checks/automatic/target-size-minimum.js +118 -48
  91. package/src/checks/automatic/td-has-header.js +29 -11
  92. package/src/checks/automatic/textbox-name-present.js +39 -19
  93. package/src/checks/automatic/tooltip-name-present.js +37 -18
  94. package/src/checks/automatic/treeitem-name-present.js +38 -21
  95. package/src/checks/automatic/valid-lang.js +20 -6
  96. package/src/checks/automatic/video-poster-text-alternative-present.js +79 -36
  97. package/src/checks/manual/accesskeys-manual.js +14 -5
  98. package/src/checks/manual/area-alt-decorative-manual.js +192 -193
  99. package/src/checks/manual/area-alt-quality-manual.js +182 -141
  100. package/src/checks/manual/aria-checked-state-mismatch-manual.js +34 -11
  101. package/src/checks/manual/aria-text-manual.js +14 -6
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +149 -114
  103. package/src/checks/manual/css-hidden-focus.js +196 -165
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +171 -160
  105. package/src/checks/manual/empty-heading-manual.js +24 -12
  106. package/src/checks/manual/empty-table-header-manual.js +21 -14
  107. package/src/checks/manual/focus-order-semantics-manual.js +45 -10
  108. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +207 -246
  109. package/src/checks/manual/heading-order-manual.js +22 -12
  110. package/src/checks/manual/identical-links-same-purpose-manual.js +34 -12
  111. package/src/checks/manual/image-redundant-alt-manual.js +17 -7
  112. package/src/checks/manual/img-alt-decorative-manual.js +131 -96
  113. package/src/checks/manual/img-alt-quality-manual.js +176 -127
  114. package/src/checks/manual/input-image-alt-decorative-manual.js +125 -92
  115. package/src/checks/manual/input-image-alt-quality-manual.js +125 -92
  116. package/src/checks/manual/label-title-only-manual.js +14 -5
  117. package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -29
  118. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +82 -29
  119. package/src/checks/manual/landmark-main-is-top-level-manual.js +64 -23
  120. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +54 -23
  121. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +54 -23
  122. package/src/checks/manual/landmark-no-duplicate-main-manual.js +17 -8
  123. package/src/checks/manual/landmark-one-main-manual.js +35 -21
  124. package/src/checks/manual/landmark-unique-manual.js +73 -45
  125. package/src/checks/manual/link-name-quality-manual.js +43 -12
  126. package/src/checks/manual/media-transcript-present-manual.js +35 -22
  127. package/src/checks/manual/meta-viewport-large-manual.js +25 -6
  128. package/src/checks/manual/mouse-only-event-handlers-manual.js +38 -11
  129. package/src/checks/manual/no-autoplay-audio-manual.js +20 -6
  130. package/src/checks/manual/object-text-alternative-quality-manual.js +175 -154
  131. package/src/checks/manual/p-as-heading-manual.js +22 -7
  132. package/src/checks/manual/page-has-heading-one-manual.js +40 -23
  133. package/src/checks/manual/page-title-patterns-manual.js +87 -51
  134. package/src/checks/manual/presentation-role-conflict-manual.js +59 -19
  135. package/src/checks/manual/region-manual.js +275 -62
  136. package/src/checks/manual/scope-attr-valid-manual.js +11 -9
  137. package/src/checks/manual/scrollable-region-focusable-manual.js +37 -11
  138. package/src/checks/manual/skip-link-manual.js +35 -12
  139. package/src/checks/manual/svg-text-alternative-quality-manual.js +206 -165
  140. package/src/checks/manual/tabindex-manual.js +11 -9
  141. package/src/checks/manual/table-duplicate-name-manual.js +17 -7
  142. package/src/checks/manual/table-fake-caption-manual.js +24 -7
  143. package/src/checks/manual/video-caption-manual.js +15 -4
  144. package/src/checks/manual-review.js +56 -12
  145. package/src/core/aria-helpers.js +1128 -823
  146. package/src/core/contrast-helpers.js +1217 -1062
  147. package/src/core/dom-helpers.js +4176 -3886
  148. package/src/core/dom-runner.js +720 -596
  149. package/src/core/frame-messaging.js +189 -138
  150. package/src/core/frame-scan.js +94 -82
  151. package/src/core/rollup-composites.js +94 -102
  152. package/src/core/rule-meta.js +71 -35
  153. package/src/core.js +37939 -29121
  154. package/src/i18n/en.js +1194 -887
  155. package/src/i18n/fr.js +1136 -793
  156. package/src/policy/contracts.js +13 -13
  157. package/src/policy/resolvePolicy.js +48 -44
  158. package/src/report.js +502 -0
  159. package/src/sarif.js +175 -0
  160. package/surea11y.browser.js +36042 -0
@@ -25,267 +25,228 @@
25
25
  const id = 'form-control-programmatic-label-quality';
26
26
 
27
27
  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
- }
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
+ {
40
+ standard: 'WCAG',
41
+ version: '2.2',
42
+ requirement: '4.1.2',
43
+ title: 'Name, Role, Value',
44
+ conformanceLevel: 'A'
45
+ }
46
+ ],
47
+ defaultSeverity: 'moderate',
48
+ category: 'robust',
49
+ type: 'manual',
50
+ defaultConfidence: 'medium',
51
+ coverage: {
52
+ facetsBySc: {
53
+ '4.1.2': ['form-control-name-quality']
49
54
  }
55
+ }
50
56
  };
51
57
 
52
58
  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
- }
59
+ const { document, root, helpers, rule } = ctx;
60
+ const safeRoot = root || document;
61
+
62
+ const queryAllSmart =
63
+ helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
64
+
65
+ const isAccTreeEligible =
66
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
67
+ const getEligibilityInfo =
68
+ helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
69
+
70
+ const getFocusableInfo =
71
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
72
+
73
+ const getLabelMethod =
74
+ helpers && typeof helpers.getLabelMethod === 'function' ? helpers.getLabelMethod : null;
75
+
76
+ const trim = (v) => (v == null ? '' : String(v)).trim();
77
+
78
+ const metrics = {
79
+ applicableCount: 0,
80
+ flaggedCount: 0,
81
+ byMethod: { label: 0, 'aria-labelledby': 0, 'aria-label': 0, title: 0, placeholder: 0, none: 0 }
82
+ };
83
+
84
+ function safeQueryAll(sel) {
85
+ try {
86
+ if (queryAllSmart) return Array.from(queryAllSmart(sel) || []);
87
+ return safeRoot && safeRoot.querySelectorAll
88
+ ? Array.from(safeRoot.querySelectorAll(sel))
89
+ : [];
90
+ } catch {
91
+ return [];
85
92
  }
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
- }
116
- }
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
- }
93
+ }
94
+
95
+ function isEligibleAcc(el) {
96
+ if (!isAccTreeEligible) return true;
97
+ try {
98
+ const r = isAccTreeEligible(el, ctx);
99
+ if (typeof r === 'boolean') return r;
100
+ return !!(r && r.eligible);
101
+ } catch {
102
+ return true;
143
103
  }
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
-
104
+ }
105
+
106
+ // getLabelMethod is provided by the shared dom-helpers bundle that
107
+ // dom-runner.js always constructs for every rule execution (built-in or
108
+ // custom) — see createDomHelpers's own getLabelMethod, which implements
109
+ // this exact <label>/aria-labelledby/aria-label/title/placeholder
110
+ // priority order. No local reimplementation is needed as a fallback.
111
+ function getLabelMethodSafe(el) {
112
+ if (!getLabelMethod) return { method: 'none', value: '' };
113
+ try {
114
+ const r = getLabelMethod(el, ctx);
115
+ const m = r && typeof r.method === 'string' ? r.method : 'none';
116
+ const v = r && r.value != null ? trim(r.value) : '';
117
+ if (!Object.prototype.hasOwnProperty.call(metrics.byMethod, m))
172
118
  return { method: 'none', value: '' };
119
+ return { method: m, value: v };
120
+ } catch {
121
+ return { method: 'none', value: '' };
173
122
  }
123
+ }
174
124
 
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';
125
+ // Native controls only (same as your current rule)
126
+ const selector =
127
+ 'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]),select,textarea';
193
128
 
194
- const nodes = safeQueryAll(selector);
129
+ const nodes = safeQueryAll(selector);
195
130
 
196
- if (!nodes.length) {
197
- return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [], data: { details: { metrics } } };
131
+ if (!nodes.length) {
132
+ return {
133
+ ruleId: rule.ruleId,
134
+ outcome: 'notApplicable',
135
+ severity: 'minor',
136
+ occurrences: [],
137
+ data: { details: { metrics } }
138
+ };
139
+ }
140
+
141
+ const occurrences = [];
142
+
143
+ for (const el of nodes) {
144
+ if (!el || !el.getAttribute) continue;
145
+
146
+ if (!isEligibleAcc(el)) continue;
147
+
148
+ const role = (() => {
149
+ try {
150
+ return trim(el.getAttribute('role')).toLowerCase();
151
+ } catch {
152
+ return '';
153
+ }
154
+ })();
155
+
156
+ let fi = null;
157
+ if (getFocusableInfo) {
158
+ try {
159
+ fi = getFocusableInfo(el, ctx);
160
+ } catch {
161
+ fi = null;
162
+ }
198
163
  }
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; }
164
+ const tabbable = !!(fi && fi.tabbable);
165
+
166
+ if ((role === 'presentation' || role === 'none') && !tabbable) continue;
167
+
168
+ metrics.applicableCount += 1;
169
+
170
+ const label = getLabelMethodSafe(el);
171
+ const method = label && typeof label.method === 'string' ? label.method : 'none';
172
+ if (Object.prototype.hasOwnProperty.call(metrics.byMethod, method))
173
+ metrics.byMethod[method] += 1;
174
+ else metrics.byMethod.none += 1;
175
+
176
+ // Flag only when the *primary* (best) method is title/placeholder
177
+ const isWeakPrimary = method === 'title' || method === 'placeholder';
178
+ if (!isWeakPrimary) continue;
179
+
180
+ metrics.flaggedCount += 1;
181
+
182
+ const vf = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
183
+
184
+ const reasonCode =
185
+ method === 'title' ? 'label_from_title_primary' : 'label_from_placeholder_primary';
186
+ const methodLabel =
187
+ method === 'title'
188
+ ? 'title'
189
+ : method === 'placeholder'
190
+ ? 'placeholder'
191
+ : 'title or placeholder';
192
+
193
+ const baseOccurrence = {
194
+ summary: 'Form control’s primary label is derived from title or placeholder.',
195
+ hint: 'Prefer a persistent <label> or aria-labelledby. Avoid relying on placeholder/title as the primary label.',
196
+ i18n: {
197
+ summaryKey: 'formControl_programmaticLabelQuality_summary_cantTell',
198
+ hintKey: 'formControl_programmaticLabelQuality_hint_cantTell',
199
+ params: { element: (el.tagName || '').toLowerCase(), method, methodLabel }
200
+ },
201
+ data: {
202
+ visibilityFilter: vf || { targetSet: 'acc', accEligible: null, reasons: [] },
203
+ details: {
204
+ reasonCode,
205
+ labelMethod: method,
206
+ labelStrength: 'weak',
207
+ recommendedMethods: ['label', 'aria-labelledby'],
208
+ sourceText: label && label.value ? String(label.value).slice(0, 120) : ''
218
209
  }
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 });
268
- }
269
- }
270
-
271
- if (metrics.applicableCount === 0) {
272
- return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [], data: { details: { metrics } } };
273
- }
210
+ }
211
+ };
274
212
 
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
- };
213
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
214
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
215
+ } else {
216
+ occurrences.push({ selector: '', html: '', ...baseOccurrence });
283
217
  }
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 } } };
218
+ }
219
+
220
+ if (metrics.applicableCount === 0) {
221
+ return {
222
+ ruleId: rule.ruleId,
223
+ outcome: 'notApplicable',
224
+ severity: 'minor',
225
+ occurrences: [],
226
+ data: { details: { metrics } }
227
+ };
228
+ }
229
+
230
+ if (occurrences.length) {
231
+ return {
232
+ ruleId: rule.ruleId,
233
+ outcome: 'cantTell',
234
+ severity: rule.defaultSeverity || 'minor',
235
+ occurrences,
236
+ data: { details: { metrics } }
237
+ };
238
+ }
239
+
240
+ // Manual rules may only emit cantTell/notApplicable (never pass/fail) —
241
+ // no applicable control relied on a weak (title/placeholder) primary
242
+ // label, so there is nothing to flag for review.
243
+ return {
244
+ ruleId: rule.ruleId,
245
+ outcome: 'notApplicable',
246
+ severity: 'minor',
247
+ occurrences: [],
248
+ data: { details: { metrics } }
249
+ };
289
250
  }
290
251
 
291
252
  module.exports = { id, meta, runInPage };
@@ -47,10 +47,12 @@ const meta = {
47
47
  };
48
48
 
49
49
  function runInPage(ctx) {
50
- const { document, helpers, rule } = ctx;
50
+ const { helpers, rule } = ctx;
51
51
 
52
52
  function normalizeWs(s) {
53
- return String(s || '').replace(/\s+/g, ' ').trim();
53
+ return String(s || '')
54
+ .replace(/\s+/g, ' ')
55
+ .trim();
54
56
  }
55
57
 
56
58
  function getExplicitRoleToken(el) {
@@ -65,19 +67,16 @@ function runInPage(ctx) {
65
67
  if (explicit !== 'heading') return 0;
66
68
  const raw = normalizeWs(el.getAttribute && el.getAttribute('aria-level'));
67
69
  const n = parseInt(raw, 10);
68
- return (Number.isFinite(n) && n >= 1) ? n : 2;
70
+ return Number.isFinite(n) && n >= 1 ? n : 2;
69
71
  }
70
72
  const tag = el.tagName ? el.tagName.toLowerCase() : '';
71
73
  const m = /^h([1-6])$/.exec(tag);
72
74
  return m ? parseInt(m[1], 10) : 0;
73
75
  }
74
76
 
75
- let nodes = [];
76
- try {
77
- nodes = document.querySelectorAll('h1, h2, h3, h4, h5, h6, [role]');
78
- } catch {
79
- nodes = [];
80
- }
77
+ const nodes = helpers.queryAllSmart
78
+ ? helpers.queryAllSmart('h1, h2, h3, h4, h5, h6, [role]')
79
+ : helpers.queryAll('h1, h2, h3, h4, h5, h6, [role]');
81
80
 
82
81
  const headings = [];
83
82
  const seen = new Set();
@@ -100,7 +99,9 @@ function runInPage(ctx) {
100
99
 
101
100
  if (level > highestSoFar + 1) {
102
101
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
103
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
102
+ const html = helpers.getOuterHtmlSnippet
103
+ ? helpers.getOuterHtmlSnippet(el)
104
+ : el.outerHTML || '';
104
105
 
105
106
  occurrences.push({
106
107
  selector: stableSelector,
@@ -113,7 +114,11 @@ function runInPage(ctx) {
113
114
  params: { fromLevel: String(highestSoFar), toLevel: String(level) }
114
115
  },
115
116
  data: {
116
- details: { reasonCode: 'HEADING_ORDER_SKIPPED_LEVEL', fromLevel: highestSoFar, toLevel: level }
117
+ details: {
118
+ reasonCode: 'HEADING_ORDER_SKIPPED_LEVEL',
119
+ fromLevel: highestSoFar,
120
+ toLevel: level
121
+ }
117
122
  }
118
123
  });
119
124
  }
@@ -122,7 +127,12 @@ function runInPage(ctx) {
122
127
  }
123
128
 
124
129
  if (occurrences.length) {
125
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
130
+ return {
131
+ ruleId: rule.ruleId,
132
+ outcome: 'cantTell',
133
+ severity: rule.defaultSeverity || 'minor',
134
+ occurrences
135
+ };
126
136
  }
127
137
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
128
138
  }
@@ -42,7 +42,13 @@ const meta = {
42
42
  tags: ['wcag2aaa', 'wcag249', 'navigation', 'atomic', 'manual'],
43
43
  wcagSc: ['2.4.9'],
44
44
  normativeMappings: [
45
- { standard: 'WCAG', version: '2.2', requirement: '2.4.9', title: 'Link Purpose (Link Only)', conformanceLevel: 'AAA' }
45
+ {
46
+ standard: 'WCAG',
47
+ version: '2.2',
48
+ requirement: '2.4.9',
49
+ title: 'Link Purpose (Link Only)',
50
+ conformanceLevel: 'AAA'
51
+ }
46
52
  ],
47
53
  defaultSeverity: 'minor',
48
54
  category: 'operable',
@@ -52,13 +58,15 @@ const meta = {
52
58
  };
53
59
 
54
60
  function runInPage(ctx) {
55
- const { document, helpers, rule } = ctx;
61
+ const { helpers, rule } = ctx;
56
62
 
57
63
  function normName(s) {
58
64
  return (s == null ? '' : String(s)).replace(/\s+/g, ' ').trim().toLowerCase();
59
65
  }
60
66
 
61
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('a[href]') : helpers.queryAll('a[href]');
67
+ const nodes = helpers.queryAllSmart
68
+ ? helpers.queryAllSmart('a[href]')
69
+ : helpers.queryAll('a[href]');
62
70
 
63
71
  const groups = new Map(); // normName -> [{ el, href }]
64
72
  let applicableCount = 0;
@@ -67,18 +75,24 @@ function runInPage(ctx) {
67
75
  if (!el || !el.getAttribute) continue;
68
76
 
69
77
  const eligResult = helpers.isAccTreeEligible ? helpers.isAccTreeEligible(el, ctx) : true;
70
- const eligible = typeof eligResult === 'boolean' ? eligResult : !!(eligResult && eligResult.eligible);
78
+ const eligible =
79
+ typeof eligResult === 'boolean' ? eligResult : !!(eligResult && eligResult.eligible);
71
80
  if (!eligible) continue;
72
81
 
73
82
  const nameInfo = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(el, ctx) : null;
74
- const rawName = (nameInfo && typeof nameInfo.value === 'string' && nameInfo.value.trim())
75
- ? nameInfo.value
76
- : (el.textContent || '');
83
+ const rawName =
84
+ nameInfo && typeof nameInfo.value === 'string' && nameInfo.value.trim()
85
+ ? nameInfo.value
86
+ : el.textContent || '';
77
87
  const name = normName(rawName);
78
88
  if (!name) continue;
79
89
 
80
- let href = '';
81
- try { href = String(el.href || ''); } catch { href = ''; }
90
+ let href;
91
+ try {
92
+ href = String(el.href || '');
93
+ } catch {
94
+ href = '';
95
+ }
82
96
  if (!href) continue;
83
97
 
84
98
  applicableCount += 1;
@@ -95,12 +109,15 @@ function runInPage(ctx) {
95
109
 
96
110
  for (const { el, href } of entries) {
97
111
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
98
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
112
+ const html = helpers.getOuterHtmlSnippet
113
+ ? helpers.getOuterHtmlSnippet(el)
114
+ : el.outerHTML || '';
99
115
 
100
116
  const baseOccurrence = {
101
117
  selector: stableSelector,
102
118
  html,
103
- summary: 'This link shares an accessible name with other links on the page that lead to a different destination.',
119
+ summary:
120
+ 'This link shares an accessible name with other links on the page that lead to a different destination.',
104
121
  hint: 'Ensure links with the same text serve the same purpose, or make the link text distinct enough to describe each destination.',
105
122
  i18n: {
106
123
  summaryKey: 'identicalLinksSamePurpose_summary_cantTell',
@@ -130,7 +147,12 @@ function runInPage(ctx) {
130
147
  }
131
148
 
132
149
  if (occurrences.length) {
133
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
150
+ return {
151
+ ruleId: rule.ruleId,
152
+ outcome: 'cantTell',
153
+ severity: rule.defaultSeverity || 'minor',
154
+ occurrences
155
+ };
134
156
  }
135
157
 
136
158
  // Manual rules may only emit cantTell/notApplicable (never pass/fail):