@surea11y/core 1.4.1 → 1.6.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 (151) hide show
  1. package/CHANGELOG.md +212 -128
  2. package/README.md +46 -9
  3. package/docs/ACT_RULE_MAPPING.md +243 -0
  4. package/docs/API_STABILITY.md +4 -4
  5. package/docs/BINDING_AUTHORS_GUIDE.md +3 -3
  6. package/docs/DESIGN_CHALLENGES.md +301 -0
  7. package/docs/ENGINE_OPTIONS.md +30 -14
  8. package/docs/I18N.md +176 -20
  9. package/docs/INTEGRATION.md +29 -7
  10. package/docs/LIMITATIONS.md +6 -4
  11. package/docs/OUTPUT_SCHEMA.md +13 -3
  12. package/docs/REPORT.md +3 -1
  13. package/docs/RULE_AUTHORING.md +104 -23
  14. package/docs/RULE_CATALOG.md +1878 -169
  15. package/docs/RULE_TAXONOMY.md +2 -2
  16. package/docs/TROUBLESHOOTING.md +4 -4
  17. package/docs/WCAG_CONFORMANCE.md +25 -9
  18. package/package.json +8 -7
  19. package/src/baseline.js +3 -3
  20. package/src/checks/automatic/area-alt-present.js +2 -2
  21. package/src/checks/automatic/aria-allowed-attr.js +95 -40
  22. package/src/checks/automatic/aria-allowed-role.js +16 -18
  23. package/src/checks/automatic/aria-braille-equivalent.js +19 -21
  24. package/src/checks/automatic/aria-conditional-attr.js +22 -24
  25. package/src/checks/automatic/aria-deprecated-role.js +63 -50
  26. package/src/checks/automatic/aria-hidden-body.js +4 -11
  27. package/src/checks/automatic/aria-hidden-focus.js +104 -23
  28. package/src/checks/automatic/aria-prohibited-attr.js +71 -72
  29. package/src/checks/automatic/aria-prohibited-children.js +154 -61
  30. package/src/checks/automatic/aria-required-attr.js +74 -29
  31. package/src/checks/automatic/aria-required-children.js +38 -34
  32. package/src/checks/automatic/aria-required-parent.js +78 -29
  33. package/src/checks/automatic/aria-role-name-present.js +36 -22
  34. package/src/checks/automatic/aria-roles-valid.js +37 -23
  35. package/src/checks/automatic/aria-valid-attr-value.js +33 -33
  36. package/src/checks/automatic/aria-valid-attr.js +15 -18
  37. package/src/checks/automatic/autocomplete-valid.js +17 -19
  38. package/src/checks/automatic/avoid-inline-spacing.js +14 -16
  39. package/src/checks/automatic/binary-control-name-present.js +46 -26
  40. package/src/checks/automatic/button-name-present.js +115 -34
  41. package/src/checks/automatic/combobox-name-present.js +40 -22
  42. package/src/checks/automatic/contrast-computable.js +32 -0
  43. package/src/checks/automatic/contrast-enhanced.js +21 -1
  44. package/src/checks/automatic/contrast-minimum.js +21 -1
  45. package/src/checks/automatic/css-orientation-lock.js +118 -41
  46. package/src/checks/automatic/definition-list-children-valid.js +25 -29
  47. package/src/checks/automatic/deprecated-elements-not-used.js +15 -17
  48. package/src/checks/automatic/dialog-name-present.js +36 -20
  49. package/src/checks/automatic/dlitem-parent-valid.js +15 -17
  50. package/src/checks/automatic/duplicate-id-aria.js +50 -40
  51. package/src/checks/automatic/duplicate-id.js +198 -0
  52. package/src/checks/automatic/embed-text-alternative-present.js +2 -2
  53. package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
  54. package/src/checks/automatic/form-control-single-label.js +39 -41
  55. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
  56. package/src/checks/automatic/iframe-focusable-content.js +92 -38
  57. package/src/checks/automatic/iframe-name-present.js +53 -21
  58. package/src/checks/automatic/iframe-title-unique.js +19 -24
  59. package/src/checks/automatic/img-alt-present.js +12 -4
  60. package/src/checks/automatic/label-in-name.js +198 -49
  61. package/src/checks/automatic/link-in-text-block.js +29 -31
  62. package/src/checks/automatic/link-name-present.js +47 -31
  63. package/src/checks/automatic/list-children-valid.js +21 -23
  64. package/src/checks/automatic/listbox-name-present.js +42 -24
  65. package/src/checks/automatic/listitem-parent-valid.js +18 -21
  66. package/src/checks/automatic/menuitem-name-present.js +36 -20
  67. package/src/checks/automatic/meta-refresh-no-exceptions.js +39 -31
  68. package/src/checks/automatic/meta-refresh-timing-absent.js +29 -25
  69. package/src/checks/automatic/meta-viewport-zoom-enabled.js +14 -17
  70. package/src/checks/automatic/meter-name-present.js +38 -21
  71. package/src/checks/automatic/nested-interactive-controls-absent.js +22 -24
  72. package/src/checks/automatic/option-name-present.js +39 -22
  73. package/src/checks/automatic/page-title-present.js +21 -3
  74. package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
  75. package/src/checks/automatic/progressbar-name-present.js +41 -24
  76. package/src/checks/automatic/role-img-alt-present.js +64 -16
  77. package/src/checks/automatic/searchbox-name-present.js +46 -24
  78. package/src/checks/automatic/server-side-image-map-absent.js +16 -19
  79. package/src/checks/automatic/slider-name-present.js +42 -23
  80. package/src/checks/automatic/spinbutton-name-present.js +46 -24
  81. package/src/checks/automatic/summary-name-present.js +34 -20
  82. package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
  83. package/src/checks/automatic/svg-text-alternative-present.js +13 -10
  84. package/src/checks/automatic/tab-name-present.js +37 -20
  85. package/src/checks/automatic/table-headers-attr-valid.js +57 -24
  86. package/src/checks/automatic/table-th-has-data-cells.js +76 -24
  87. package/src/checks/automatic/target-size-minimum.js +172 -131
  88. package/src/checks/automatic/td-has-header.js +20 -25
  89. package/src/checks/automatic/textbox-name-present.js +42 -24
  90. package/src/checks/automatic/tooltip-name-present.js +37 -20
  91. package/src/checks/automatic/treeitem-name-present.js +39 -22
  92. package/src/checks/automatic/valid-lang.js +107 -24
  93. package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
  94. package/src/checks/manual/accesskeys-manual.js +21 -22
  95. package/src/checks/manual/area-alt-decorative-manual.js +7 -0
  96. package/src/checks/manual/area-alt-quality-manual.js +6 -0
  97. package/src/checks/manual/aria-checked-state-mismatch-manual.js +23 -26
  98. package/src/checks/manual/aria-text-manual.js +4 -4
  99. package/src/checks/manual/bypass-blocks-present-manual.js +48 -38
  100. package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
  101. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
  102. package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
  103. package/src/checks/manual/empty-heading-manual.js +73 -28
  104. package/src/checks/manual/empty-table-header-manual.js +33 -36
  105. package/src/checks/manual/focus-order-semantics-manual.js +15 -15
  106. package/src/checks/manual/form-control-label-quality-manual.js +453 -0
  107. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +4 -11
  108. package/src/checks/manual/heading-order-manual.js +20 -25
  109. package/src/checks/manual/heading-quality-manual.js +338 -0
  110. package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
  111. package/src/checks/manual/image-redundant-alt-manual.js +18 -21
  112. package/src/checks/manual/img-alt-decorative-manual.js +211 -52
  113. package/src/checks/manual/img-alt-quality-manual.js +7 -0
  114. package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
  115. package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
  116. package/src/checks/manual/label-title-only-manual.js +19 -21
  117. package/src/checks/manual/landmark-banner-is-top-level-manual.js +21 -24
  118. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +23 -26
  119. package/src/checks/manual/landmark-main-is-top-level-manual.js +20 -23
  120. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +8 -13
  121. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +8 -13
  122. package/src/checks/manual/landmark-no-duplicate-main-manual.js +4 -9
  123. package/src/checks/manual/landmark-one-main-manual.js +8 -15
  124. package/src/checks/manual/landmark-unique-manual.js +31 -36
  125. package/src/checks/manual/link-name-quality-manual.js +162 -35
  126. package/src/checks/manual/media-transcript-present-manual.js +2 -3
  127. package/src/checks/manual/meta-viewport-large-manual.js +16 -19
  128. package/src/checks/manual/mouse-only-event-handlers-manual.js +26 -28
  129. package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
  130. package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
  131. package/src/checks/manual/p-as-heading-manual.js +4 -4
  132. package/src/checks/manual/page-has-heading-one-manual.js +8 -15
  133. package/src/checks/manual/page-title-patterns-manual.js +26 -3
  134. package/src/checks/manual/presentation-role-conflict-manual.js +74 -46
  135. package/src/checks/manual/region-manual.js +32 -25
  136. package/src/checks/manual/scope-attr-valid-manual.js +16 -19
  137. package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
  138. package/src/checks/manual/skip-link-manual.js +44 -52
  139. package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
  140. package/src/checks/manual/tabindex-manual.js +16 -19
  141. package/src/checks/manual/table-duplicate-name-manual.js +16 -19
  142. package/src/checks/manual/table-fake-caption-manual.js +2 -2
  143. package/src/checks/manual/video-caption-manual.js +3 -3
  144. package/src/checks/manual-review.js +17 -1
  145. package/src/core.js +13086 -5169
  146. package/src/report.js +16 -2
  147. package/surea11y.browser.js +5665 -4219
  148. package/surea11y.i18n.de.js +22 -0
  149. package/surea11y.i18n.es.js +22 -0
  150. package/surea11y.i18n.fr.js +22 -0
  151. package/bin/surea11y-core.js +0 -20
@@ -6,7 +6,7 @@
6
6
  * @check landmark-main-is-top-level
7
7
  * @atomic true
8
8
  * @summary The main landmark must not be nested inside another landmark
9
- * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion)
10
10
  * @applicability
11
11
  * Applies whenever the page contains at least one main landmark
12
12
  * (explicit role="main", or an implicit <main> element).
@@ -16,15 +16,15 @@
16
16
  * whole-page main content area and confuses landmark-based navigation
17
17
  * for assistive technology users.
18
18
  * @implementation-notes
19
- * - Not WCAG-normative — authored as an advisory, cantTell-capped
19
+ * - Not WCAG-normative, authored as an advisory, cantTell-capped
20
20
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
21
21
  * header comment for the shared rationale/precedent (this rule mirrors
22
22
  * its structure with main in place of banner/header).
23
23
  * - Unlike landmark-banner-is-top-level/landmark-contentinfo-is-top-level
24
24
  * (see that file's header comment), candidate selection here doesn't
25
25
  * need to be unconditional: `<main>`'s implicit role is unconditional
26
- * per HTML-AAM — unlike `<header>`/`<footer>`, nesting never suppresses
27
- * it — so `getImplicitLandmarkRole`'s `main` branch is never subject to
26
+ * per HTML-AAM. Unlike `<header>`/`<footer>`, nesting never suppresses
27
+ * it, so `getImplicitLandmarkRole`'s `main` branch is never subject to
28
28
  * the self-defeating candidate-selection problem those two rules guard
29
29
  * against.
30
30
  */
@@ -81,7 +81,7 @@ function runInPage(ctx) {
81
81
 
82
82
  // Delegates to the shared helpers.hasLandmarkScopingAncestor for the
83
83
  // question "does this element sit inside a sectioning-content/<main>
84
- // ancestor that suppresses its conditional implicit role" — role-aware
84
+ // ancestor that suppresses its conditional implicit role": role-aware
85
85
  // (an ancestor's bare TAG only counts when it carries no role attribute
86
86
  // at all; an explicit role="dialog"-style override no longer suppresses)
87
87
  // rather than a local tag-only copy. See that function's header comment
@@ -100,7 +100,7 @@ function runInPage(ctx) {
100
100
  if (tag === 'main') return 'main';
101
101
  if (tag === 'nav') return 'navigation';
102
102
  if (tag === 'aside') {
103
- // A named <aside> is never suppressed, even when nested — it keeps
103
+ // A named <aside> is never suppressed, even when nested. It keeps
104
104
  // "complementary" when it has an accessible name, even inside
105
105
  // sectioning content. Matches landmark-unique's precedent.
106
106
  if (!hasSectioningAncestor(el, false)) return 'complementary';
@@ -192,23 +192,20 @@ function runInPage(ctx) {
192
192
  for (const el of mains) {
193
193
  if (!hasLandmarkAncestor(el)) continue;
194
194
 
195
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
196
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
197
-
198
- occurrences.push({
199
- selector: stableSelector,
200
- html,
201
- summary: 'This main landmark is nested inside another landmark region.',
202
- hint: 'Move the main landmark (<main>/role="main") so it is not contained by another landmark; main should be a top-level region of the page.',
203
- i18n: {
204
- summaryKey: 'landmarkMainIsTopLevel_summary_cantTell',
205
- hintKey: 'landmarkMainIsTopLevel_hint_cantTell',
206
- params: {}
207
- },
208
- data: {
209
- details: { reasonCode: 'LANDMARK_MAIN_NOT_TOP_LEVEL' }
210
- }
211
- });
195
+ occurrences.push(
196
+ helpers.reportOccurrence(el, {
197
+ summary: 'This main landmark is nested inside another landmark region.',
198
+ hint: 'Move the main landmark (<main>/role="main") so it is not contained by another landmark; main should be a top-level region of the page.',
199
+ i18n: {
200
+ summaryKey: 'landmarkMainIsTopLevel_summary_cantTell',
201
+ hintKey: 'landmarkMainIsTopLevel_hint_cantTell',
202
+ params: {}
203
+ },
204
+ data: {
205
+ details: { reasonCode: 'LANDMARK_MAIN_NOT_TOP_LEVEL' }
206
+ }
207
+ })
208
+ );
212
209
  }
213
210
 
214
211
  if (occurrences.length) {
@@ -6,19 +6,19 @@
6
6
  * @check landmark-no-duplicate-banner
7
7
  * @atomic true
8
8
  * @summary A page must not have more than one banner landmark
9
- * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion)
10
10
  * @applicability
11
11
  * Applies whenever the page contains at least one banner landmark
12
- * (explicit role="banner", or an implicit, non-nested <header> — see
12
+ * (explicit role="banner", or an implicit, non-nested <header>; see
13
13
  * landmark-banner-is-top-level's implementation notes for the
14
14
  * shared landmark-detection model).
15
15
  * @expectation
16
16
  * At most one banner landmark exists on the page. Per WAI-ARIA
17
17
  * Authoring Practices, the banner landmark represents site-oriented
18
- * content that identifies the page as a whole — having more than one
18
+ * content that identifies the page as a whole, so having more than one
19
19
  * is ambiguous for assistive technology users navigating by landmark.
20
20
  * @implementation-notes
21
- * - Not WCAG-normative — authored as an advisory, cantTell-capped
21
+ * - Not WCAG-normative, authored as an advisory, cantTell-capped
22
22
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
23
23
  * header comment for the shared rationale/precedent.
24
24
  * - Flags every banner instance (not just the "extra" ones) when more
@@ -81,11 +81,11 @@ function runInPage(ctx) {
81
81
 
82
82
  // Delegates to the shared helpers.hasLandmarkScopingAncestor for the
83
83
  // question "does this element sit inside a sectioning-content/<main>
84
- // ancestor that suppresses its conditional implicit role" — role-aware
84
+ // ancestor that suppresses its conditional implicit role": role-aware
85
85
  // (an ancestor's bare TAG only counts when it carries no role attribute
86
86
  // at all; an explicit role="dialog"-style override no longer suppresses)
87
87
  // rather than a local tag-only copy. See that function's header comment
88
- // in src/core/aria-helpers.js for the full algorithm — e.g. an
88
+ // in src/core/aria-helpers.js for the full algorithm, e.g. an
89
89
  // <aside role="dialog"> containing its own <header>, where the <header>
90
90
  // keeps its banner role.
91
91
  function hasSectioningAncestor(el, includeMain) {
@@ -171,12 +171,7 @@ function runInPage(ctx) {
171
171
  }
172
172
 
173
173
  const occurrences = banners.map((el) => {
174
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
175
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
176
-
177
- return {
178
- selector: stableSelector,
179
- html,
174
+ return helpers.reportOccurrence(el, {
180
175
  summary: 'This page has more than one banner landmark.',
181
176
  hint: 'Keep only one banner landmark (header/role="banner") per page.',
182
177
  i18n: {
@@ -187,7 +182,7 @@ function runInPage(ctx) {
187
182
  data: {
188
183
  details: { reasonCode: 'LANDMARK_DUPLICATE_BANNER', count: banners.length }
189
184
  }
190
- };
185
+ });
191
186
  });
192
187
 
193
188
  return {
@@ -6,18 +6,18 @@
6
6
  * @check landmark-no-duplicate-contentinfo
7
7
  * @atomic true
8
8
  * @summary A page must not have more than one contentinfo landmark
9
- * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion)
10
10
  * @applicability
11
11
  * Applies whenever the page contains at least one contentinfo landmark
12
12
  * (explicit role="contentinfo", or an implicit, non-nested <footer>).
13
13
  * @expectation
14
- * At most one contentinfo landmark exists on the page — mirrors
14
+ * At most one contentinfo landmark exists on the page, mirroring
15
15
  * landmark-no-duplicate-banner's rationale for contentinfo.
16
16
  * @implementation-notes
17
- * - Not WCAG-normative — authored as an advisory, cantTell-capped
17
+ * - Not WCAG-normative, authored as an advisory, cantTell-capped
18
18
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
19
19
  * header comment for the shared rationale/precedent.
20
- * - Only landmarks actually exposed to assistive technology can collide —
20
+ * - Only landmarks actually exposed to assistive technology can collide,
21
21
  * same as the sibling banner/main rules, avoiding hidden-duplicate false
22
22
  * positives.
23
23
  */
@@ -74,7 +74,7 @@ function runInPage(ctx) {
74
74
 
75
75
  // Delegates to the shared helpers.hasLandmarkScopingAncestor for the
76
76
  // question "does this element sit inside a sectioning-content/<main>
77
- // ancestor that suppresses its conditional implicit role" — role-aware
77
+ // ancestor that suppresses its conditional implicit role": role-aware
78
78
  // (an ancestor's bare TAG only counts when it carries no role attribute
79
79
  // at all; an explicit role="dialog"-style override no longer suppresses)
80
80
  // rather than a local tag-only copy. See that function's header comment
@@ -93,7 +93,7 @@ function runInPage(ctx) {
93
93
  if (tag === 'main') return 'main';
94
94
  if (tag === 'nav') return 'navigation';
95
95
  if (tag === 'aside') {
96
- // A named <aside> is never suppressed, even when nested — it keeps
96
+ // A named <aside> is never suppressed, even when nested. It keeps
97
97
  // "complementary" when it has an accessible name, even inside
98
98
  // sectioning content. Matches landmark-unique's precedent.
99
99
  if (!hasSectioningAncestor(el, false)) return 'complementary';
@@ -163,12 +163,7 @@ function runInPage(ctx) {
163
163
  }
164
164
 
165
165
  const occurrences = contentinfos.map((el) => {
166
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
167
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
168
-
169
- return {
170
- selector: stableSelector,
171
- html,
166
+ return helpers.reportOccurrence(el, {
172
167
  summary: 'This page has more than one contentinfo landmark.',
173
168
  hint: 'Keep only one contentinfo landmark (footer/role="contentinfo") per page.',
174
169
  i18n: {
@@ -179,7 +174,7 @@ function runInPage(ctx) {
179
174
  data: {
180
175
  details: { reasonCode: 'LANDMARK_DUPLICATE_CONTENTINFO', count: contentinfos.length }
181
176
  }
182
- };
177
+ });
183
178
  });
184
179
 
185
180
  return {
@@ -6,7 +6,7 @@
6
6
  * @check landmark-no-duplicate-main
7
7
  * @atomic true
8
8
  * @summary A page must not have more than one main landmark
9
- * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion)
10
10
  * @applicability
11
11
  * Applies whenever the page contains at least one main landmark
12
12
  * (explicit role="main", or an implicit <main>).
@@ -15,7 +15,7 @@
15
15
  * decision from landmark-one-main (that rule flags zero
16
16
  * mains too; this one only flags more than one).
17
17
  * @implementation-notes
18
- * - Not WCAG-normative — authored as an advisory, cantTell-capped
18
+ * - Not WCAG-normative, authored as an advisory, cantTell-capped
19
19
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
20
20
  * header comment for the shared rationale/precedent.
21
21
  * - Only landmarks actually exposed to assistive technology can collide.
@@ -108,12 +108,7 @@ function runInPage(ctx) {
108
108
  }
109
109
 
110
110
  const occurrences = mains.map((el) => {
111
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
112
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
113
-
114
- return {
115
- selector: stableSelector,
116
- html,
111
+ return helpers.reportOccurrence(el, {
117
112
  summary: 'This page has more than one main landmark.',
118
113
  hint: 'Keep only one main landmark (<main>/role="main") per page.',
119
114
  i18n: {
@@ -124,7 +119,7 @@ function runInPage(ctx) {
124
119
  data: {
125
120
  details: { reasonCode: 'LANDMARK_DUPLICATE_MAIN', count: mains.length }
126
121
  }
127
- };
122
+ });
128
123
  });
129
124
 
130
125
  return {
@@ -6,24 +6,24 @@
6
6
  * @check landmark-one-main
7
7
  * @atomic true
8
8
  * @summary The page should have a main landmark
9
- * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion)
10
10
  * @applicability
11
- * Always applicable to any HTML document with a <body> element —
11
+ * Always applicable to any HTML document with a <body> element:
12
12
  * "does the page have a main landmark" is a whole-page concern,
13
13
  * matching bypass-blocks-present's pattern of evaluating the
14
14
  * document directly.
15
15
  * @expectation
16
16
  * At least one main landmark (role="main" or <main>), exposed to
17
- * assistive technology, exists on the page — a page with none gives
17
+ * assistive technology, exists on the page. A page with none gives
18
18
  * AT users no landmark to jump straight to for the primary content.
19
19
  * @implementation-notes
20
- * - Not WCAG-normative — authored as an advisory, cantTell-capped
20
+ * - Not WCAG-normative, authored as an advisory, cantTell-capped
21
21
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
22
22
  * header comment for the shared rationale/precedent.
23
23
  * - Presence-only: a plain descendant-exists test. It does NOT flag more
24
24
  * than one main. "More than one main" is a separate rule,
25
- * `landmark-no-duplicate-main`, already implemented — deliberately not
26
- * duplicated here, since a page can genuinely have two visible `<main>`
25
+ * `landmark-no-duplicate-main`, already implemented and not
26
+ * duplicated here, since a page can legitimately have two visible `<main>`
27
27
  * elements, which is out of scope for "does a main landmark exist," not
28
28
  * a violation this rule should report.
29
29
  * - Filters candidates through `isAccTreeEligible` (hidden/aria-hidden/
@@ -123,19 +123,12 @@ function runInPage(ctx) {
123
123
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
124
124
  }
125
125
 
126
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(body) : 'body';
127
- const html = helpers.getOuterHtmlSnippet
128
- ? helpers.getOuterHtmlSnippet(body)
129
- : (body.outerHTML || '').slice(0, 200);
130
-
131
126
  return {
132
127
  ruleId: rule.ruleId,
133
128
  outcome: 'cantTell',
134
129
  severity: rule.defaultSeverity || 'minor',
135
130
  occurrences: [
136
- {
137
- selector: stableSelector,
138
- html,
131
+ helpers.reportOccurrence(body, {
139
132
  summary: 'This page has no main landmark.',
140
133
  hint: 'Add a main landmark (<main> or role="main") around the page\'s primary content.',
141
134
  i18n: {
@@ -146,7 +139,7 @@ function runInPage(ctx) {
146
139
  data: {
147
140
  details: { reasonCode: 'LANDMARK_MAIN_MISSING' }
148
141
  }
149
- }
142
+ })
150
143
  ]
151
144
  };
152
145
  }
@@ -6,20 +6,20 @@
6
6
  * @check landmark-unique
7
7
  * @atomic true
8
8
  * @summary Landmarks sharing the same role must have unique accessible names
9
- * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion)
10
10
  * @applicability
11
11
  * Applies whenever two or more landmark regions on the page share the
12
12
  * same landmark role (banner, contentinfo, main, navigation,
13
- * complementary, region, form, or search — see implementation notes
13
+ * complementary, region, form, or search; see implementation notes
14
14
  * for the detection model).
15
15
  * @expectation
16
16
  * Among landmarks sharing a role, each has a distinct accessible name
17
- * (via aria-label/aria-labelledby — landmarks are not named from
17
+ * (via aria-label/aria-labelledby; landmarks are not named from
18
18
  * content). Two same-role landmarks with the same name (including two
19
19
  * both left unnamed) are indistinguishable to assistive technology
20
20
  * users navigating by landmark.
21
21
  * @implementation-notes
22
- * - Not WCAG-normative — authored as an advisory, cantTell-capped
22
+ * - Not WCAG-normative, authored as an advisory, cantTell-capped
23
23
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
24
24
  * header comment for the shared rationale/precedent and the landmark-
25
25
  * detection model (HTML-AAM implicit-role mapping + explicit role
@@ -80,18 +80,18 @@ function runInPage(ctx) {
80
80
 
81
81
  // Delegates to the shared helpers.hasLandmarkScopingAncestor (role-aware:
82
82
  // an ancestor's bare TAG only counts when it carries no role attribute at
83
- // all; an explicit role="dialog"-style override no longer suppresses —
83
+ // all; an explicit role="dialog"-style override no longer suppresses;
84
84
  // see that function's header comment in src/core/aria-helpers.js), using
85
85
  // two distinct ancestor scopes rather than one shared list: <header>/
86
86
  // <footer> use "sectioning content PLUS <main>" (includeMain: true) to
87
87
  // decide banner/contentinfo suppression, but <aside> uses PLAIN
88
- // sectioning content only — NOT main (includeMain: false) — to decide
88
+ // sectioning content only, not main (includeMain: false), to decide
89
89
  // complementary suppression. A single shared sectioning-ancestors set
90
90
  // that includes 'main' is correct for header/footer but wrong for aside:
91
91
  // e.g. two unnamed <aside> elements that are direct children of <main>
92
92
  // would have their implicit "complementary" role incorrectly suppressed,
93
93
  // hiding a real duplicate-landmark violation. The role-aware half matters
94
- // too: e.g. an <aside role="dialog"> containing its own <header> —
94
+ // too: take an <aside role="dialog"> containing its own <header>.
95
95
  // role="dialog" isn't one of the four scoping roles, so the nested
96
96
  // <header> keeps "banner" per spec, but a tag-only (non-role-aware)
97
97
  // check would unconditionally suppress it just because the ancestor TAG
@@ -110,7 +110,7 @@ function runInPage(ctx) {
110
110
  if (tag === 'nav') return 'navigation';
111
111
  if (tag === 'aside') {
112
112
  // An <aside> is suppressed by a sectioning-content ancestor ONLY
113
- // when it also has no accessible name — a named <aside> is never
113
+ // when it also has no accessible name. A named <aside> is never
114
114
  // suppressed, even when nested.
115
115
  if (!hasSectioningAncestor(el, false)) return 'complementary';
116
116
  return getAccessibleLandmarkName(el) ? 'complementary' : '';
@@ -137,7 +137,7 @@ function runInPage(ctx) {
137
137
  if (explicit) {
138
138
  if (!LANDMARK_ROLES.has(explicit)) return '';
139
139
  // <form>/<section> only count as landmarks when they have an
140
- // accessible name — a property of the ELEMENT, not of how the role
140
+ // accessible name, a property of the ELEMENT, not of how the role
141
141
  // got there. This applies whether the role is implicit (already
142
142
  // handled in getImplicitLandmarkRole below) or explicit. Per the W3C
143
143
  // ARIA-in-HTML spec ("a form is not exposed as a landmark region
@@ -219,34 +219,29 @@ function runInPage(ctx) {
219
219
  if (group.length <= 1) continue;
220
220
 
221
221
  for (const { el } of group) {
222
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
223
- const html = helpers.getOuterHtmlSnippet
224
- ? helpers.getOuterHtmlSnippet(el)
225
- : el.outerHTML || '';
226
-
227
- occurrences.push({
228
- selector: stableSelector,
229
- html,
230
- summary: normalizedName
231
- ? `This ${role} landmark shares its accessible name with another ${role} landmark.`
232
- : `This ${role} landmark has no accessible name, and more than one unnamed ${role} landmark exists on this page.`,
233
- hint: `Give each ${role} landmark a distinct name via aria-label or aria-labelledby.`,
234
- i18n: {
235
- summaryKey: normalizedName
236
- ? 'landmarkUnique_summary_cantTell_duplicateName'
237
- : 'landmarkUnique_summary_cantTell_bothUnnamed',
238
- hintKey: 'landmarkUnique_hint_cantTell',
239
- params: { role }
240
- },
241
- data: {
242
- details: {
243
- reasonCode: 'LANDMARK_NOT_UNIQUE',
244
- role,
245
- name: normalizedName,
246
- groupSize: group.length
222
+ occurrences.push(
223
+ helpers.reportOccurrence(el, {
224
+ summary: normalizedName
225
+ ? `This ${role} landmark shares its accessible name with another ${role} landmark.`
226
+ : `This ${role} landmark has no accessible name, and more than one unnamed ${role} landmark exists on this page.`,
227
+ hint: `Give each ${role} landmark a distinct name via aria-label or aria-labelledby.`,
228
+ i18n: {
229
+ summaryKey: normalizedName
230
+ ? 'landmarkUnique_summary_cantTell_duplicateName'
231
+ : 'landmarkUnique_summary_cantTell_bothUnnamed',
232
+ hintKey: 'landmarkUnique_hint_cantTell',
233
+ params: { role }
234
+ },
235
+ data: {
236
+ details: {
237
+ reasonCode: 'LANDMARK_NOT_UNIQUE',
238
+ role,
239
+ name: normalizedName,
240
+ groupSize: group.length
241
+ }
247
242
  }
248
- }
249
- });
243
+ })
244
+ );
250
245
  }
251
246
  }
252
247
  }