@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
  /**
@@ -11,20 +13,27 @@
11
13
  * Human review is required to confirm that the provided text alternative is accurate and appropriate.
12
14
  */
13
15
 
14
- const id = "input-image-alt-quality";
16
+ const id = 'input-image-alt-quality';
15
17
 
16
18
  const meta = {
17
- title: "<input type=\"image\"> alt text must be appropriate (manual review)",
18
- description: "Flags <input type=\"image\"> elements with non-empty alt text for human review of appropriateness.",
19
+ title: '<input type="image"> alt text must be appropriate (manual review)',
20
+ description:
21
+ 'Flags <input type="image"> elements with non-empty alt text for human review of appropriateness.',
19
22
  i18n: {
20
- titleKey: "inputImage_altQuality_title",
21
- descriptionKey: "inputImage_altQuality_description"
23
+ titleKey: 'inputImage_altQuality_title',
24
+ descriptionKey: 'inputImage_altQuality_description'
22
25
  },
23
26
  helpUrl: null,
24
- tags: ["wcag2a", "wcag111", "forms", "images", "manual", "atomic"],
27
+ tags: ['wcag2a', 'wcag111', 'forms', 'images', 'manual', 'atomic'],
25
28
  wcagSc: ['1.1.1'],
26
29
  normativeMappings: [
27
- { standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A' }
30
+ {
31
+ standard: 'WCAG',
32
+ version: '2.2',
33
+ requirement: '1.1.1',
34
+ title: 'Non-text Content',
35
+ conformanceLevel: 'A'
36
+ }
28
37
  ],
29
38
  defaultSeverity: 'minor',
30
39
  category: 'perceivable',
@@ -38,107 +47,133 @@ const meta = {
38
47
  };
39
48
 
40
49
  function runInPage(ctx) {
41
-
42
- const { document, root, helpers, rule } = ctx;
43
- const safeRoot = root || document;
44
-
45
- const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
46
- const queryAll = helpers && typeof helpers.queryAll === 'function'
47
- ? helpers.queryAll
48
- : (sel) => {
49
- try { return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : []; }
50
- catch { return []; }
50
+ const { document, root, helpers, rule } = ctx;
51
+ const safeRoot = root || document;
52
+
53
+ const queryAllSmart =
54
+ helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
55
+ const queryAll =
56
+ helpers && typeof helpers.queryAll === 'function'
57
+ ? helpers.queryAll
58
+ : (sel) => {
59
+ try {
60
+ return safeRoot && safeRoot.querySelectorAll
61
+ ? Array.from(safeRoot.querySelectorAll(sel))
62
+ : [];
63
+ } catch {
64
+ return [];
65
+ }
51
66
  };
52
67
 
53
- const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
54
- ? helpers.getEligibilityInfo
55
- : null;
56
-
57
- const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
58
- ? helpers.isAccTreeEligible
59
- : null;
60
-
61
- const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function'
62
- ? helpers.getFocusableInfo
63
- : null;
64
-
65
- function isRolePresentationExcluded(el) {
66
- const role = (() => {
67
- try { return String(el.getAttribute('role') || '').trim().toLowerCase(); }
68
- catch { return ''; }
69
- })();
70
- if (role !== 'presentation' && role !== 'none') return false;
71
-
72
- // Exclude only when NOT focusable (mirrors img-alt-present policy)
73
- let focusable = false;
74
- if (getFocusableInfo) {
75
- const fi = (() => { try { return getFocusableInfo(el, ctx); } catch { return null; } })();
76
- focusable = !!(fi && fi.focusable);
77
- } else {
78
- const tabindex = el.getAttribute('tabindex');
79
- focusable = tabindex != null && String(tabindex).trim() !== '' && !Number.isNaN(Number(String(tabindex).trim()));
80
- }
81
- return !focusable;
82
- }
83
-
84
-
85
- const els = (() => {
86
- try { return Array.from((queryAllSmart ? queryAllSmart('input[type="image"]') : queryAll('input[type="image"]')) || []); }
87
- catch { return queryAll('input[type="image"]'); }
88
- })();
89
-
90
- if (!els.length) {
91
- return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
92
- }
68
+ const getEligibilityInfo =
69
+ helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
93
70
 
94
- const occurrences = [];
95
- let applicableCount = 0;
71
+ const isAccTreeEligible =
72
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
96
73
 
97
- for (const el of els) {
98
- if (!el || !el.getAttribute) continue;
74
+ const getFocusableInfo =
75
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
99
76
 
100
- if (isAccTreeEligible) {
101
- const elig = (() => {
102
- try { return isAccTreeEligible(el, ctx); } catch { return { eligible: true, reasons: [] }; }
103
- })();
104
- if (elig && elig.eligible === false) continue;
77
+ function isRolePresentationExcluded(el) {
78
+ const role = (() => {
79
+ try {
80
+ return String(el.getAttribute('role') || '')
81
+ .trim()
82
+ .toLowerCase();
83
+ } catch {
84
+ return '';
85
+ }
86
+ })();
87
+ if (role !== 'presentation' && role !== 'none') return false;
88
+
89
+ // Exclude only when NOT focusable (mirrors img-alt-present policy)
90
+ let focusable;
91
+ if (getFocusableInfo) {
92
+ const fi = (() => {
93
+ try {
94
+ return getFocusableInfo(el, ctx);
95
+ } catch {
96
+ return null;
105
97
  }
98
+ })();
99
+ focusable = !!(fi && fi.focusable);
100
+ } else {
101
+ const tabindex = el.getAttribute('tabindex');
102
+ focusable =
103
+ tabindex != null &&
104
+ String(tabindex).trim() !== '' &&
105
+ !Number.isNaN(Number(String(tabindex).trim()));
106
+ }
107
+ return !focusable;
108
+ }
106
109
 
107
- if (isRolePresentationExcluded(el)) continue;
108
-
109
- // Rule-specific applicability (only elements that already have a text alternative mechanism)
110
- if (!((el.getAttribute('alt') != null && String(el.getAttribute('alt')).trim() !== ''))) continue;
110
+ const els = (() => {
111
+ try {
112
+ return Array.from(
113
+ (queryAllSmart ? queryAllSmart('input[type="image"]') : queryAll('input[type="image"]')) ||
114
+ []
115
+ );
116
+ } catch {
117
+ return queryAll('input[type="image"]');
118
+ }
119
+ })();
111
120
 
112
- applicableCount += 1;
121
+ if (!els.length) {
122
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
123
+ }
113
124
 
114
- const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
125
+ const occurrences = [];
126
+ let applicableCount = 0;
115
127
 
116
- const baseOccurrence = {
117
- summary: 'Review alt text on <input type="image"> for accuracy and appropriateness.',
118
- hint: 'Ensure the alt text describes the control’s action (e.g., “Search”, “Submit order”) in context.',
119
- i18n: {
120
- summaryKey: 'inputImage_altQuality_summary_cantTell',
121
- hintKey: 'inputImage_altQuality_hint_cantTell',
122
- params: { element: 'input[type=image]' }
123
- },
124
- data: {
125
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
126
- details: { alt: String(el.getAttribute('alt') || '') } // optional but useful
127
- }
128
- };
128
+ for (const el of els) {
129
+ if (!el || !el.getAttribute) continue;
129
130
 
130
- if (helpers && typeof helpers.reportOccurrence === 'function') {
131
- occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
132
- } else {
133
- occurrences.push({ selector: '', html: '', ...baseOccurrence });
131
+ if (isAccTreeEligible) {
132
+ const elig = (() => {
133
+ try {
134
+ return isAccTreeEligible(el, ctx);
135
+ } catch {
136
+ return { eligible: true, reasons: [] };
134
137
  }
138
+ })();
139
+ if (elig && elig.eligible === false) continue;
135
140
  }
136
141
 
137
- if (applicableCount === 0) {
138
- return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
142
+ if (isRolePresentationExcluded(el)) continue;
143
+
144
+ // Rule-specific applicability (only elements that already have a text alternative mechanism)
145
+ if (!(el.getAttribute('alt') != null && String(el.getAttribute('alt')).trim() !== '')) continue;
146
+
147
+ applicableCount += 1;
148
+
149
+ const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
150
+
151
+ const baseOccurrence = {
152
+ summary: 'Review alt text on <input type="image"> for accuracy and appropriateness.',
153
+ hint: 'Ensure the alt text describes the control’s action (e.g., “Search”, “Submit order”) in context.',
154
+ i18n: {
155
+ summaryKey: 'inputImage_altQuality_summary_cantTell',
156
+ hintKey: 'inputImage_altQuality_hint_cantTell',
157
+ params: { element: 'input[type=image]' }
158
+ },
159
+ data: {
160
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
161
+ details: { alt: String(el.getAttribute('alt') || '') } // optional but useful
162
+ }
163
+ };
164
+
165
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
166
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
167
+ } else {
168
+ occurrences.push({ selector: '', html: '', ...baseOccurrence });
139
169
  }
170
+ }
171
+
172
+ if (applicableCount === 0) {
173
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
174
+ }
140
175
 
141
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: 'minor', occurrences };
176
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: 'minor', occurrences };
142
177
  }
143
178
 
144
179
  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 label-title-only
5
7
  * @atomic true
6
8
  * @summary Form controls should not rely on the title attribute as their only label
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 to labelable form controls (input, excluding
10
12
  * hidden/submit/reset/button/image; select; textarea) that have a
@@ -28,7 +30,8 @@ const id = 'label-title-only';
28
30
 
29
31
  const meta = {
30
32
  title: 'Form controls should not use title as their only label',
31
- description: 'Checks that a form control with a title attribute also has a real label (label element, aria-label, or aria-labelledby).',
33
+ description:
34
+ 'Checks that a form control with a title attribute also has a real label (label element, aria-label, or aria-labelledby).',
32
35
  i18n: {
33
36
  titleKey: 'labelTitleOnly_title',
34
37
  descriptionKey: 'labelTitleOnly_description'
@@ -45,20 +48,13 @@ const meta = {
45
48
  };
46
49
 
47
50
  function runInPage(ctx) {
48
- const { document, helpers, rule } = ctx;
49
-
50
- const selector = 'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]),select,textarea';
51
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
52
-
53
- const labelsByFor = new Map();
54
- const allLabels = document.getElementsByTagName ? document.getElementsByTagName('label') : [];
55
- for (const lab of allLabels) {
56
- if (!lab || !lab.getAttribute) continue;
57
- const forValue = String(lab.getAttribute('for') || '').trim();
58
- if (!forValue) continue;
59
- if (!labelsByFor.has(forValue)) labelsByFor.set(forValue, []);
60
- labelsByFor.get(forValue).push(lab);
61
- }
51
+ const { helpers, rule } = ctx;
52
+
53
+ const selector =
54
+ 'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]),select,textarea';
55
+ const nodes = helpers.queryAllSmart
56
+ ? helpers.queryAllSmart(selector)
57
+ : helpers.queryAll(selector);
62
58
 
63
59
  const occurrences = [];
64
60
  let applicableCount = 0;
@@ -66,25 +62,40 @@ function runInPage(ctx) {
66
62
  for (const el of nodes) {
67
63
  if (!el || !el.getAttribute) continue;
68
64
 
65
+ if (helpers.isAccTreeEligible) {
66
+ const elig = (() => {
67
+ try {
68
+ return helpers.isAccTreeEligible(el, ctx);
69
+ } catch {
70
+ return { eligible: true, reasons: [] };
71
+ }
72
+ })();
73
+ if (elig && elig.eligible === false) continue;
74
+ }
75
+
69
76
  const title = String(el.getAttribute('title') || '').trim();
70
77
  if (!title) continue;
71
78
 
72
79
  applicableCount += 1;
73
80
 
74
- const ariaLabel = String(el.getAttribute('aria-label') || '').trim();
75
- if (ariaLabel) continue;
76
- const ariaLabelledby = String(el.getAttribute('aria-labelledby') || '').trim();
77
- if (ariaLabelledby) continue;
78
-
79
- const wrappingLabel = el.closest ? el.closest('label') : null;
80
- if (wrappingLabel) continue;
81
-
82
- const controlId = String(el.getAttribute('id') || '').trim();
83
- if (controlId && labelsByFor.has(controlId)) continue;
81
+ // Delegates to the shared helpers.getAccessibleNameInfo (aria ->
82
+ // native <label> -> title, the same precedence every other
83
+ // name-dependent rule in this engine uses) rather than a local,
84
+ // hand-rolled "does a <label for>/wrapping <label> exist" check. A
85
+ // structural-association-only check (for="..."/wrapping) never verifies
86
+ // the label actually contributes a name -- an empty <label for="x">
87
+ // </label> or empty wrapping <label> would exempt the control even
88
+ // though title is functionally its only real label (see
89
+ // dom-helpers.js's hasLabelAssociation/labelContributesAccessibleName).
90
+ // If the resolved mechanism isn't 'title', some higher-priority
91
+ // mechanism (aria-label/aria-labelledby/a real contributing label)
92
+ // already won and this control isn't title-only.
93
+ const nameInfo = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(el, ctx) : null;
94
+ if (!nameInfo || nameInfo.mechanism !== 'title') continue;
84
95
 
85
96
  const tag = el.tagName.toLowerCase();
86
97
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
87
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
98
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
88
99
 
89
100
  occurrences.push({
90
101
  selector: stableSelector,
@@ -106,7 +117,12 @@ function runInPage(ctx) {
106
117
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
107
118
  }
108
119
  if (occurrences.length) {
109
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
120
+ return {
121
+ ruleId: rule.ruleId,
122
+ outcome: 'cantTell',
123
+ severity: rule.defaultSeverity || 'minor',
124
+ occurrences
125
+ };
110
126
  }
111
127
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
112
128
  }
@@ -1,24 +1,25 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check landmark-banner-is-top-level
5
7
  * @atomic true
6
8
  * @summary The banner landmark must not be nested inside another landmark
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
- * Applies whenever the page contains at least one banner landmark
10
- * (explicit role="banner", or an implicit <header> that is not itself
11
- * nested inside <article>/<aside>/<main>/<nav>/<section> — see
12
- * implementation notes).
11
+ * Applies whenever the page contains at least one banner candidate:
12
+ * explicit role="banner", OR a <header> with NO role attribute at all,
13
+ * regardless of nesting (see implementation notes on why candidate
14
+ * selection is deliberately unconditional).
13
15
  * @expectation
14
- * No banner landmark has an ancestor that is itself any landmark
16
+ * No banner candidate has an ancestor that is itself any landmark
15
17
  * region. A banner nested inside another landmark is not a top-level,
16
18
  * whole-page banner and confuses landmark-based navigation for
17
19
  * assistive technology users.
18
20
  * @implementation-notes
19
- * - Not WCAG-normative (a widely-used reference engine classifies this as "Best Practices," no SC
20
- * tag) — authored as an advisory, cantTell-capped `type: 'manual'`
21
- * rule per ROADMAP.md Tier 1b and the design doc's policy model
21
+ * - Not WCAG-normative — authored as an advisory, cantTell-capped
22
+ * `type: 'manual'` rule per ROADMAP.md Tier 1b and the design doc's policy model
22
23
  * ("Advisory / best-practice rules may exist, but must not produce
23
24
  * `fail`"). Matches the existing `page-title-patterns-manual.js`
24
25
  * precedent: deterministic DOM analysis, no human required, but
@@ -26,16 +27,28 @@
26
27
  * - Landmark detection here models WAI-ARIA APG landmark roles and the
27
28
  * HTML-AAM implicit-role mapping (header→banner, footer→contentinfo,
28
29
  * main→main, nav→navigation, aside→complementary, section/form→
29
- * region/form only when accessibly named), not a byte-for-byte port
30
- * of a widely-used reference engine's internal algorithm — verify against upstream if exact
31
- * parity is ever required.
30
+ * region/form only when accessibly named).
31
+ * - Candidate selection (`isBannerCandidate` below) is deliberately
32
+ * unconditional — a `<header>`/`role="banner"` counts as a candidate
33
+ * regardless of nesting — rather than reusing the same HTML-AAM
34
+ * sectioning-ancestor suppression (`getImplicitLandmarkRole`'s
35
+ * `hasSectioningAncestor` gate) that the violation check itself relies
36
+ * on. Gating candidate selection on that suppression would be
37
+ * self-defeating: the moment a `<header>` is nested inside another
38
+ * landmark, that same nesting would make it stop counting as a banner
39
+ * candidate in the first place, so the rule could never flag the one
40
+ * case it exists to catch. The ancestor walk (`hasLandmarkAncestor`) is intentionally
41
+ * asymmetric: it still uses the full suppression-aware
42
+ * `getLandmarkRole` for each ancestor, since an ancestor genuinely
43
+ * needs its own real role to count as blocking.
32
44
  */
33
45
 
34
46
  const id = 'landmark-banner-is-top-level';
35
47
 
36
48
  const meta = {
37
49
  title: 'Banner landmark must be top-level',
38
- description: 'Checks that the banner landmark (role="banner" or a non-nested <header>) is not nested inside another landmark region.',
50
+ description:
51
+ 'Checks that the banner landmark (role="banner" or a non-nested <header>) is not nested inside another landmark region.',
39
52
  i18n: {
40
53
  titleKey: 'landmarkBannerIsTopLevel_title',
41
54
  descriptionKey: 'landmarkBannerIsTopLevel_description'
@@ -57,14 +70,15 @@ function runInPage(ctx) {
57
70
  // Declared inside runInPage — see scripts/build-core.js header
58
71
  // ("runInPage MUST be self-contained").
59
72
  function normalizeWs(s) {
60
- return String(s || '').replace(/\s+/g, ' ').trim();
73
+ return String(s || '')
74
+ .replace(/\s+/g, ' ')
75
+ .trim();
61
76
  }
62
77
 
63
78
  // Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
64
79
  // target's own accessible name, not raw textContent -> title attribute fallback) rather than a
65
- // local copy -- see that function's header comment in src/core/dom-helpers.js for the real bug
66
- // (missing title fallback) this replaced across all 7 landmark rule files that had their own
67
- // copy of this logic.
80
+ // local copy -- see that function's header comment in src/core/dom-helpers.js. Sharing it keeps
81
+ // the title-attribute fallback consistent across the landmark rules.
68
82
  function getAccessibleLandmarkName(el) {
69
83
  try {
70
84
  if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
@@ -87,10 +101,8 @@ function runInPage(ctx) {
87
101
  // (an ancestor's bare TAG only counts when it carries no role attribute
88
102
  // at all; an explicit role="dialog"-style override no longer suppresses)
89
103
  // rather than a local tag-only copy. See that function's header comment
90
- // in src/core/aria-helpers.js for the full algorithm and the real page
91
- // (handsontable.com's docs-assistant side panel, an
92
- // <aside role="dialog"> containing its own <header>) that surfaced this
93
- // rule's own former tag-only copy as a false negative.
104
+ // in src/core/aria-helpers.js for the full algorithm. Example: an
105
+ // <aside role="dialog"> containing its own <header>.
94
106
  function hasSectioningAncestor(el, includeMain) {
95
107
  return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
96
108
  ? helpers.hasLandmarkScopingAncestor(el, { includeMain })
@@ -104,11 +116,9 @@ function runInPage(ctx) {
104
116
  if (tag === 'main') return 'main';
105
117
  if (tag === 'nav') return 'navigation';
106
118
  if (tag === 'aside') {
107
- // A named <aside> is never suppressed, even when nested — matches
108
- // landmark-unique's own verified-against-reference-engine precedent
109
- // (that engine's real `aside` implicit-role function keeps
110
- // "complementary" when the element has an accessible name, even
111
- // inside sectioning content); propagated here for consistency.
119
+ // A named <aside> is never suppressed, even when nested — it keeps
120
+ // "complementary" when it has an accessible name, even inside
121
+ // sectioning content. Matches landmark-unique's precedent.
112
122
  if (!hasSectioningAncestor(el, false)) return 'complementary';
113
123
  return getAccessibleLandmarkName(el) ? 'complementary' : '';
114
124
  }
@@ -117,7 +127,16 @@ function runInPage(ctx) {
117
127
  return '';
118
128
  }
119
129
 
120
- const LANDMARK_ROLES = new Set(['banner', 'contentinfo', 'main', 'navigation', 'complementary', 'region', 'form', 'search']);
130
+ const LANDMARK_ROLES = new Set([
131
+ 'banner',
132
+ 'contentinfo',
133
+ 'main',
134
+ 'navigation',
135
+ 'complementary',
136
+ 'region',
137
+ 'form',
138
+ 'search'
139
+ ]);
121
140
 
122
141
  function getLandmarkRole(el) {
123
142
  if (!el || !el.getAttribute) return '';
@@ -126,8 +145,20 @@ function runInPage(ctx) {
126
145
  return getImplicitLandmarkRole(el);
127
146
  }
128
147
 
148
+ // Candidate selection is deliberately NOT the same as getLandmarkRole()
149
+ // === 'banner' — see the fix note above. A <header> is a
150
+ // candidate purely by tag + absence of any role attribute, independent
151
+ // of whether sectioning-ancestor nesting would currently suppress its
152
+ // implicit role; an explicit role="banner" is always a candidate too.
153
+ function isBannerCandidate(el) {
154
+ if (!el || !el.getAttribute) return false;
155
+ const explicit = getExplicitRoleToken(el);
156
+ if (explicit) return explicit === 'banner';
157
+ return !!(el.tagName && el.tagName.toLowerCase() === 'header');
158
+ }
159
+
129
160
  function hasLandmarkAncestor(el) {
130
- const scopeRoots = Array.isArray(root) ? root : (root ? [root] : []);
161
+ const scopeRoots = Array.isArray(root) ? root : root ? [root] : [];
131
162
  let p = el.parentElement;
132
163
  while (p) {
133
164
  if (getLandmarkRole(p)) return true;
@@ -140,14 +171,14 @@ function runInPage(ctx) {
140
171
  }
141
172
 
142
173
  // queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
143
- // landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
144
- // that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
145
- // invisible to a light-DOM-only query.
146
- let nodes = [];
174
+ // landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
175
+ // widget's own landmark is invisible to a light-DOM-only query.
176
+ let nodes;
147
177
  try {
148
- nodes = helpers && typeof helpers.queryAllSmart === 'function'
149
- ? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
150
- : document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
178
+ nodes =
179
+ helpers && typeof helpers.queryAllSmart === 'function'
180
+ ? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
181
+ : document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
151
182
  } catch {
152
183
  nodes = [];
153
184
  }
@@ -157,7 +188,28 @@ function runInPage(ctx) {
157
188
  for (const el of nodes) {
158
189
  if (!el || seen.has(el)) continue;
159
190
  seen.add(el);
160
- if (getLandmarkRole(el) === 'banner') banners.push(el);
191
+ if (!isBannerCandidate(el)) continue;
192
+
193
+ // An aria-hidden banner candidate is removed from the accessibility
194
+ // tree entirely -- it isn't part of the landmark structure assistive
195
+ // technology users navigate at all, so it shouldn't be flagged as
196
+ // "nested inside another landmark" (there's no real landmark there to
197
+ // begin with, from AT's perspective). queryAllSmart's default hidden-
198
+ // content policy only excludes "hard" CSS-based hiding (display:none,
199
+ // etc.), not the softer aria-hidden exclusion, so this needs its own
200
+ // check.
201
+ if (helpers && typeof helpers.isAccTreeEligible === 'function') {
202
+ const elig = (() => {
203
+ try {
204
+ return helpers.isAccTreeEligible(el, ctx);
205
+ } catch {
206
+ return { eligible: true, reasons: [] };
207
+ }
208
+ })();
209
+ if (elig && elig.eligible === false) continue;
210
+ }
211
+
212
+ banners.push(el);
161
213
  }
162
214
 
163
215
  if (banners.length === 0) {
@@ -169,7 +221,7 @@ function runInPage(ctx) {
169
221
  if (!hasLandmarkAncestor(el)) continue;
170
222
 
171
223
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
172
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
224
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
173
225
 
174
226
  occurrences.push({
175
227
  selector: stableSelector,
@@ -188,7 +240,12 @@ function runInPage(ctx) {
188
240
  }
189
241
 
190
242
  if (occurrences.length) {
191
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
243
+ return {
244
+ ruleId: rule.ruleId,
245
+ outcome: 'cantTell',
246
+ severity: rule.defaultSeverity || 'minor',
247
+ occurrences
248
+ };
192
249
  }
193
250
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
194
251
  }