@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
@@ -19,7 +19,7 @@
19
19
  * ancestor/owner's aria-owns) whose effective role is one of the
20
20
  * acceptable context roles for this element's role.
21
21
  * @implementation-notes
22
- * - Deliberately scoped to REQUIRED_CONTEXT_ROLE in src/core/aria-helpers.js
22
+ * - Scoped to REQUIRED_CONTEXT_ROLE in src/core/aria-helpers.js
23
23
  * (see that file's header for the conservative-scope rationale); roles
24
24
  * with an explicitly empty entry (e.g. tabpanel) are left unconstrained.
25
25
  * - Context-role matching uses ariaHelpers.getContainmentRole, which
@@ -34,8 +34,8 @@
34
34
  * independent path via a reverse lookup over the search root.
35
35
  * - Gated on isAccTreeEligible for the element itself. The ancestor-role
36
36
  * walk itself doesn't care about visibility (a hidden ancestor's role is
37
- * still found by plain DOM/composed-tree containment, so a genuinely
38
- * correctly-nested-but-hidden widget was never at risk here) — the
37
+ * still found by plain DOM/composed-tree containment, so a
38
+ * correctly-nested-but-hidden widget was never at risk here), the
39
39
  * remaining false-positive shape is an element whose required ancestor
40
40
  * context doesn't exist YET because it (and its wrapping context) are
41
41
  * assembled together at reveal time (e.g. a portal-rendered item staged
@@ -96,13 +96,57 @@ function runInPage(ctx) {
96
96
  }
97
97
 
98
98
  // Roles that may host a nested listitem/treeitem group without breaking
99
- // the required-context chain — the group role is transparent for exactly
99
+ // the required-context chain, the group role is transparent for exactly
100
100
  // these two roles.
101
101
  const GROUP_TRANSPARENT_FOR_ROLES = new Set(['listitem', 'treeitem']);
102
102
 
103
- // A real ancestor role — not "no role at all" and not the two roles that
103
+ // The WAI-ARIA "Global States and Properties" set (same list as
104
+ // aria-prohibited-children.js's GLOBAL_ARIA_ATTRS, duplicated, not
105
+ // imported, since runInPage must be self-contained per
106
+ // scripts/build-core.js). A roleless ancestor carrying any of these is
107
+ // still "included in the accessibility tree" and is therefore a real
108
+ // (generic) parent, not a transparent one: ACT's ff89c9 test corpus
109
+ // covers exactly this with role="listitem" whose actual DOM parent is a
110
+ // roleless <div aria-live="polite">, itself inside a role="list", the
111
+ // required-context chain is broken by that included-but-roleless div,
112
+ // even though a role="list" ancestor does exist further up.
113
+ const GLOBAL_ARIA_ATTRS = [
114
+ 'aria-atomic',
115
+ 'aria-braillelabel',
116
+ 'aria-brailleroledescription',
117
+ 'aria-busy',
118
+ 'aria-controls',
119
+ 'aria-current',
120
+ 'aria-describedby',
121
+ 'aria-description',
122
+ 'aria-details',
123
+ 'aria-disabled',
124
+ 'aria-dropeffect',
125
+ 'aria-errormessage',
126
+ 'aria-flowto',
127
+ 'aria-grabbed',
128
+ 'aria-haspopup',
129
+ 'aria-hidden',
130
+ 'aria-invalid',
131
+ 'aria-keyshortcuts',
132
+ 'aria-label',
133
+ 'aria-labelledby',
134
+ 'aria-live',
135
+ 'aria-owns',
136
+ 'aria-relevant',
137
+ 'aria-roledescription'
138
+ ];
139
+
140
+ function hasGlobalAriaAttr(el) {
141
+ for (const attr of GLOBAL_ARIA_ATTRS) {
142
+ if (el.getAttribute && el.getAttribute(attr) != null) return true;
143
+ }
144
+ return false;
145
+ }
146
+
147
+ // A real ancestor role, not "no role at all" and not the two roles that
104
148
  // strip an element from the accessibility tree's parent/child chain
105
- // entirely (presentation/none) — stops the search. This is stricter than
149
+ // entirely (presentation/none), stops the search. This is stricter than
106
150
  // "any ancestor with the right role anywhere up the tree": the
107
151
  // required-context relationship is about the accessibility tree's actual
108
152
  // PARENT, so an intervening ancestor with its OWN distinct real role
@@ -110,19 +154,27 @@ function runInPage(ctx) {
110
154
  // a further-up ancestor has the correct role. E.g. a <button role="tab">
111
155
  // inside a plain <li> (native listitem) inside <ul role="tablist"> fails:
112
156
  // the tablist is never the tab's accessible-tree parent, the listitem is.
157
+ //
158
+ // A roleless ancestor is normally transparent (it isn't a node in the
159
+ // accessibility tree at all), UNLESS it carries a global ARIA attribute
160
+ // -- that alone includes it in the tree as a real, roleless (generic)
161
+ // parent, which still blocks the search the same way a distinct real
162
+ // role would.
113
163
  function getRealContextRole(el) {
114
164
  const role = ariaHelpers.getContainmentRole(el);
115
- if (!role || role === 'presentation' || role === 'none') return '';
165
+ if (!role || role === 'presentation' || role === 'none') {
166
+ return hasGlobalAriaAttr(el) ? 'generic' : '';
167
+ }
116
168
  return role;
117
169
  }
118
170
 
119
- // Flat-tree ancestor walk (ctx.helpers.composedParent — assignedSlot wins
171
+ // Flat-tree ancestor walk (ctx.helpers.composedParent, assignedSlot wins
120
172
  // over parentNode, then shadow host). A slotted light-DOM element's real
121
173
  // rendered ancestor is whatever the shadow tree wraps its <slot> in (e.g.
122
174
  // a role="list" container), not its own light-DOM parentElement.
123
175
  // composedParent can return a non-Element node (a ShadowRoot, nodeType
124
176
  // 11) when climbing out of a shadow tree that has no further light-DOM
125
- // parent — skip those and keep climbing rather than treating them as a
177
+ // parent, skip those and keep climbing rather than treating them as a
126
178
  // (roleless) context.
127
179
  const getComposedParent =
128
180
  helpers && typeof helpers.composedParent === 'function'
@@ -210,27 +262,24 @@ function runInPage(ctx) {
210
262
 
211
263
  if (hasContext) continue;
212
264
 
213
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
214
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
215
-
216
- occurrences.push({
217
- selector: stableSelector,
218
- html,
219
- summary: 'This role requires a specific ancestor/owner context role, which was not found.',
220
- hint: 'Place this element inside (or aria-owns-reference it from) an element with an acceptable context role.',
221
- i18n: {
222
- summaryKey: 'ariaRequiredParent_summary_fail',
223
- hintKey: 'ariaRequiredParent_hint_fail',
224
- params: { role, requiredRoles: requiredContext.join(', ') }
225
- },
226
- data: {
227
- details: {
228
- reasonCode: 'ARIA_REQUIRED_PARENT_MISSING',
229
- role,
230
- requiredContextRoles: requiredContext
265
+ occurrences.push(
266
+ helpers.reportOccurrence(el, {
267
+ summary: 'This role requires a specific ancestor/owner context role, which was not found.',
268
+ hint: 'Place this element inside (or aria-owns-reference it from) an element with an acceptable context role.',
269
+ i18n: {
270
+ summaryKey: 'ariaRequiredParent_summary_fail',
271
+ hintKey: 'ariaRequiredParent_hint_fail',
272
+ params: { role, requiredRoles: requiredContext.join(', ') }
273
+ },
274
+ data: {
275
+ details: {
276
+ reasonCode: 'ARIA_REQUIRED_PARENT_MISSING',
277
+ role,
278
+ requiredContextRoles: requiredContext
279
+ }
231
280
  }
232
- }
233
- });
281
+ })
282
+ );
234
283
  }
235
284
 
236
285
  if (applicableCount === 0) {
@@ -3,7 +3,8 @@
3
3
  'use strict';
4
4
 
5
5
  /**
6
- * Generic name-presence rule for selected ARIA composite/widget roles that should expose an accessible name.
6
+ * Generic name-presence rule for the ARIA roles WAI-ARIA *requires* an
7
+ * accessible name for.
7
8
  *
8
9
  * Notes:
9
10
  * - This rule intentionally focuses on author-provided naming mechanisms:
@@ -11,14 +12,34 @@
11
12
  * - It does NOT treat descendant text content as a valid name source for these roles
12
13
  * (to avoid false passes from labelled children inside composite widgets).
13
14
  * - Eligibility is based on helpers.isAccTreeEligible(node, ctx) per engine checks.
15
+ * - The role set is generated from aria-query by
16
+ * scripts/generate-aria-tables.js; see that script for why each role is in
17
+ * or out, and for the name-required roles no rule covers yet.
18
+ *
19
+ * @applicability
20
+ * Applies to elements whose role attribute is exactly one of grid, meter,
21
+ * progressbar, radiogroup or tree, and that are included in the
22
+ * accessibility tree. Membership is decided by WAI-ARIA's own "Accessible
23
+ * Name Required: True" characteristic, not by whether a role merely permits
24
+ * a name: tablist, toolbar, menu, menubar and scrollbar are name-from-author
25
+ * roles the spec does not require a name for, and are out of
26
+ * scope. meter and progressbar are also covered by meter-name-present and
27
+ * progressbar-name-present, which map to SC 1.1.1; this rule is what gives
28
+ * those two roles their 4.1.2 coverage.
29
+ * @expectation
30
+ * The element has a non-empty aria-label, an aria-labelledby that resolves
31
+ * to non-empty text, or a non-empty title. Every role in the set is
32
+ * name-from-author-only, so descendant text is not accepted:
33
+ * a labelled child inside a composite widget would otherwise pass the
34
+ * container that has no name of its own.
14
35
  */
15
36
 
16
37
  const id = 'aria-role-name-present';
17
38
 
18
39
  const meta = {
19
- title: 'ARIA widget/container roles have an accessible name',
40
+ title: 'ARIA roles that require an accessible name have one',
20
41
  description:
21
- 'Checks that selected ARIA widget/container roles expose a non-empty accessible name.',
42
+ 'Checks that the ARIA roles WAI-ARIA requires an accessible name for expose a non-empty one.',
22
43
  i18n: {
23
44
  titleKey: 'ariaRoleNamePresent_title',
24
45
  descriptionKey: 'ariaRoleNamePresent_description'
@@ -93,7 +114,7 @@ function runInPage(ctx) {
93
114
  // button-name-present et al.), which correctly includes hidden/aria-hidden
94
115
  // referenced nodes per the Accessible Name and Description Computation
95
116
  // spec (a hidden node directly referenced by aria-labelledby still
96
- // supplies its text — this is a standard visually-hidden-label pattern).
117
+ // supplies its text, this is a standard visually-hidden-label pattern).
97
118
  const resolveLabelledby = (el, maxRefs) => {
98
119
  if (!getAriaLabelledByInfo) return '';
99
120
  try {
@@ -115,23 +136,16 @@ function runInPage(ctx) {
115
136
  }
116
137
  };
117
138
 
118
- // Frozen allowlist of roles to check in this generic rule.
119
- // (Keep this small, explicit, and standards-based for determinism.)
120
- const roleSet = new Set([
121
- 'scrollbar',
122
- 'toolbar',
123
- 'tablist',
124
- 'radiogroup',
125
- 'tree',
126
- 'grid',
127
- 'menu',
128
- 'menubar',
129
- 'meter',
130
- 'progressbar'
131
- ]);
132
-
133
- const selector =
134
- '[role="scrollbar"],[role="toolbar"],[role="tablist"],[role="radiogroup"],[role="tree"],[role="grid"],[role="menu"],[role="menubar"],[role="meter"],[role="progressbar"]';
139
+ // Roles WAI-ARIA marks "Accessible Name Required: True" and names from the
140
+ // author only. Generated from aria-query by scripts/generate-aria-tables.js:
141
+ // a role that merely *allows* an author name (tablist, toolbar, menu,
142
+ // menubar, scrollbar) is not a 4.1.2 failure when unnamed and is not listed.
143
+ // <generated:aria-name-required-roles>
144
+ const NAME_REQUIRED_ROLES = new Set(['grid', 'meter', 'progressbar', 'radiogroup', 'tree']);
145
+ // </generated:aria-name-required-roles>
146
+
147
+ // Derived from the set above so the two cannot drift apart.
148
+ const selector = [...NAME_REQUIRED_ROLES].map((r) => `[role="${r}"]`).join(',');
135
149
 
136
150
  const nodes = (() => {
137
151
  try {
@@ -161,7 +175,7 @@ function runInPage(ctx) {
161
175
  return '';
162
176
  }
163
177
  })();
164
- if (!roleSet.has(role)) continue;
178
+ if (!NAME_REQUIRED_ROLES.has(role)) continue;
165
179
 
166
180
  if (!isEligible(el)) continue;
167
181
 
@@ -68,14 +68,28 @@ function runInPage(ctx) {
68
68
  let applicableCount = 0;
69
69
 
70
70
  // Programmatically hidden per the ACT glossary: display:none, visibility not
71
- // visible, or aria-hidden on the element or an ancestor.
71
+ // visible, or aria-hidden on the element or an ancestor. inert is treated
72
+ // the same although the glossary predates it -- an inert subtree is out of
73
+ // the accessibility tree entirely, so a role on it reaches no one.
72
74
  function isHidden(el) {
73
75
  try {
74
76
  if (typeof helpers.isDomVisibleEligible === 'function') {
75
77
  if (!helpers.isDomVisibleEligible(el, ctx)) return true;
76
78
  }
77
- for (let n = el; n && n.getAttribute; n = n.parentElement) {
79
+ // Walk the composed tree, not parentElement: that stops at a shadow
80
+ // root, so a host carrying aria-hidden or inert would never be seen
81
+ // from inside its own shadow content.
82
+ const up =
83
+ typeof helpers.composedParent === 'function'
84
+ ? helpers.composedParent
85
+ : (n) => n.parentElement;
86
+
87
+ // A shadow root has no getAttribute, so skip past it rather than
88
+ // stopping: the host one step further up is the node that matters.
89
+ for (let n = el; n; n = up(n)) {
90
+ if (!n.getAttribute) continue;
78
91
  if (String(n.getAttribute('aria-hidden') || '').toLowerCase() === 'true') return true;
92
+ if (n.hasAttribute && n.hasAttribute('inert')) return true;
79
93
  }
80
94
  } catch {
81
95
  return false;
@@ -106,27 +120,27 @@ function runInPage(ctx) {
106
120
  const role = tokens[0];
107
121
  const isKnown = tokens.some((t) => ariaHelpers.isKnownRole(t));
108
122
  const reasonCode = !isKnown ? 'ARIA_ROLE_INVALID' : 'ARIA_ROLE_ABSTRACT';
109
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
110
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
111
-
112
- occurrences.push({
113
- selector: stableSelector,
114
- html,
115
- summary: !isKnown
116
- ? 'The role attribute value is not a recognized ARIA role.'
117
- : 'The role attribute value is an abstract ARIA role, which must not be used directly.',
118
- hint: !isKnown
119
- ? 'Use a valid ARIA role token, or remove the role attribute if none applies.'
120
- : 'Replace this abstract role with a concrete role appropriate for the widget/structure.',
121
- i18n: {
122
- summaryKey: !isKnown ? 'ariaRolesValid_summary_invalid' : 'ariaRolesValid_summary_abstract',
123
- hintKey: !isKnown ? 'ariaRolesValid_hint_invalid' : 'ariaRolesValid_hint_abstract',
124
- params: { role }
125
- },
126
- data: {
127
- details: { reasonCode, role }
128
- }
129
- });
123
+
124
+ occurrences.push(
125
+ helpers.reportOccurrence(el, {
126
+ summary: !isKnown
127
+ ? 'The role attribute value is not a recognized ARIA role.'
128
+ : 'The role attribute value is an abstract ARIA role, which must not be used directly.',
129
+ hint: !isKnown
130
+ ? 'Use a valid ARIA role token, or remove the role attribute if none applies.'
131
+ : 'Replace this abstract role with a concrete role appropriate for the widget/structure.',
132
+ i18n: {
133
+ summaryKey: !isKnown
134
+ ? 'ariaRolesValid_summary_invalid'
135
+ : 'ariaRolesValid_summary_abstract',
136
+ hintKey: !isKnown ? 'ariaRolesValid_hint_invalid' : 'ariaRolesValid_hint_abstract',
137
+ params: { role }
138
+ },
139
+ data: {
140
+ details: { reasonCode, role }
141
+ }
142
+ })
143
+ );
130
144
  }
131
145
 
132
146
  if (applicableCount === 0) {
@@ -15,9 +15,14 @@
15
15
  * @expectation
16
16
  * Each attribute's value conforms to its WAI-ARIA-declared value type:
17
17
  * boolean ("true"/"false"), tristate ("true"/"false"/"mixed"), a token
18
- * from a fixed enumerated set, an integer, a real number, or an empty
19
- * value or ID reference (list) that resolves to an existing element in
20
- * the document.
18
+ * from a fixed enumerated set, an integer, a real number, or an ID
19
+ * reference (list) that resolves to an existing element in the document.
20
+ * Per ACT 6a7281's own applicability ("any state or property that is
21
+ * NOT empty"), an explicitly empty value, including a bare boolean-style
22
+ * attribute with no "=value" at all, e.g. `aria-checked` alone, is out
23
+ * of scope for every value type, not a violation: a common, deliberate
24
+ * pattern in templated markup (e.g. React conditionally rendering
25
+ * `aria-describedby={hasError ? errorId : ''}`).
21
26
  * @implementation-notes
22
27
  * - Not rule-gated on isAccTreeEligible: this remains a static-markup
23
28
  * property, while engine-level hidden-subtree filtering still applies
@@ -25,16 +30,14 @@
25
30
  * - ID-reference resolution (see aria-helpers.js's idExists) only flags
26
31
  * idref-list attributes (aria-labelledby, aria-describedby,
27
32
  * aria-controls, aria-owns, etc.) when NONE of the space-separated ids
28
- * resolve — a partially-dangling list (some ids exist, some don't) is
33
+ * resolve, a partially-dangling list (some ids exist, some don't) is
29
34
  * left unflagged (only invalidate when every token fails to resolve).
30
- * Single-idref attributes (aria-activedescendant, aria-errormessage) are
31
- * flagged whenever their one id doesn't resolve.
32
- * - An explicitly-EMPTY idref/idref-list value (e.g.
33
- * `aria-describedby=""`) is valid, not a violation — `allowEmpty` holds
34
- * for every idref/idref-list ARIA attribute. A common, deliberate pattern
35
- * in templated markup (e.g. React conditionally rendering
36
- * `aria-describedby={hasError ? errorId : ''}`); flagging it would be a
37
- * false positive.
35
+ * Of the two single-idref attributes, aria-activedescendant is flagged
36
+ * whenever its one id doesn't resolve; aria-errormessage's existence is
37
+ * never checked (format only), ACT 6a7281's own Background
38
+ * text names it as a non-required property whose target "may be created
39
+ * in response to an event that may or may not happen" (a validation
40
+ * error message rendered only once the error actually occurs).
38
41
  */
39
42
 
40
43
  const id = 'aria-valid-attr-value';
@@ -105,29 +108,26 @@ function runInPage(ctx) {
105
108
 
106
109
  if (!invalid || !invalid.length) continue;
107
110
 
108
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
109
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
110
-
111
111
  for (const item of invalid) {
112
- occurrences.push({
113
- selector: stableSelector,
114
- html,
115
- summary: 'This element has an ARIA attribute with an invalid value.',
116
- hint: 'Use a value that matches the attribute’s expected type (see the WAI-ARIA specification for this attribute).',
117
- i18n: {
118
- summaryKey: 'ariaValidAttrValue_summary_fail',
119
- hintKey: 'ariaValidAttrValue_hint_fail',
120
- params: { attr: item.name, value: item.value }
121
- },
122
- data: {
123
- details: {
124
- reasonCode: 'ARIA_ATTR_VALUE_INVALID',
125
- attr: item.name,
126
- value: item.value,
127
- valueReason: item.reason
112
+ occurrences.push(
113
+ helpers.reportOccurrence(el, {
114
+ summary: 'This element has an ARIA attribute with an invalid value.',
115
+ hint: 'Use a value that matches the attribute’s expected type (see the WAI-ARIA specification for this attribute).',
116
+ i18n: {
117
+ summaryKey: 'ariaValidAttrValue_summary_fail',
118
+ hintKey: 'ariaValidAttrValue_hint_fail',
119
+ params: { attr: item.name, value: item.value }
120
+ },
121
+ data: {
122
+ details: {
123
+ reasonCode: 'ARIA_ATTR_VALUE_INVALID',
124
+ attr: item.name,
125
+ value: item.value,
126
+ valueReason: item.reason
127
+ }
128
128
  }
129
- }
130
- });
129
+ })
130
+ );
131
131
  }
132
132
  }
133
133
 
@@ -18,7 +18,7 @@
18
18
  * deterministic defect).
19
19
  * @implementation-notes
20
20
  * - Distinct from aria-valid-attr-value (which validates the VALUE
21
- * of a recognized attribute) — this rule only validates the attribute
21
+ * of a recognized attribute), this rule only validates the attribute
22
22
  * NAME.
23
23
  * - Not rule-gated on isAccTreeEligible: this remains a static-markup
24
24
  * property, while engine-level hidden-subtree filtering still applies
@@ -86,24 +86,21 @@ function runInPage(ctx) {
86
86
 
87
87
  if (!invalidNames || !invalidNames.length) continue;
88
88
 
89
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
90
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
91
-
92
89
  for (const name of invalidNames) {
93
- occurrences.push({
94
- selector: stableSelector,
95
- html,
96
- summary: 'This element has an attribute that is not a recognized ARIA attribute.',
97
- hint: 'Correct the attribute name (check for typos), or remove it if not needed.',
98
- i18n: {
99
- summaryKey: 'ariaValidAttr_summary_fail',
100
- hintKey: 'ariaValidAttr_hint_fail',
101
- params: { attr: name }
102
- },
103
- data: {
104
- details: { reasonCode: 'ARIA_ATTR_INVALID', attr: name }
105
- }
106
- });
90
+ occurrences.push(
91
+ helpers.reportOccurrence(el, {
92
+ summary: 'This element has an attribute that is not a recognized ARIA attribute.',
93
+ hint: 'Correct the attribute name (check for typos), or remove it if not needed.',
94
+ i18n: {
95
+ summaryKey: 'ariaValidAttr_summary_fail',
96
+ hintKey: 'ariaValidAttr_hint_fail',
97
+ params: { attr: name }
98
+ },
99
+ data: {
100
+ details: { reasonCode: 'ARIA_ATTR_INVALID', attr: name }
101
+ }
102
+ })
103
+ );
107
104
  }
108
105
  }
109
106
 
@@ -26,7 +26,7 @@
26
26
  * order) with the full fixed field-name vocabulary from the HTML
27
27
  * Standard, rather than validating every field-specific constraint
28
28
  * (e.g. which contact-modality tokens are legal for which field
29
- * names) — matches this engine's established "deliberately scoped"
29
+ * names), matches this engine's established "scoped"
30
30
  * precedent (see aria-helpers.js) for keeping high-confidence fail
31
31
  * without reimplementing the entire spec.
32
32
  */
@@ -63,7 +63,7 @@ const meta = {
63
63
  function runInPage(ctx) {
64
64
  const { helpers, rule } = ctx;
65
65
 
66
- // Declared inside runInPage — see scripts/build-core.js header
66
+ // Declared inside runInPage, see scripts/build-core.js header
67
67
  // ("runInPage MUST be self-contained").
68
68
  const FIELD_NAMES = new Set([
69
69
  'name',
@@ -193,23 +193,21 @@ function runInPage(ctx) {
193
193
  if (isValidAutocomplete(raw)) continue;
194
194
 
195
195
  const tag = el.tagName.toLowerCase();
196
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
197
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
198
-
199
- occurrences.push({
200
- selector: stableSelector,
201
- html,
202
- summary: 'This autocomplete attribute value is not a valid autofill value.',
203
- hint: 'Use "on"/"off", or a valid autofill token list (e.g. "shipping street-address", "cc-number").',
204
- i18n: {
205
- summaryKey: 'autocompleteValid_summary_fail',
206
- hintKey: 'autocompleteValid_hint_fail',
207
- params: { element: tag, value: raw }
208
- },
209
- data: {
210
- details: { reasonCode: 'AUTOCOMPLETE_VALUE_INVALID', element: tag, value: raw }
211
- }
212
- });
196
+
197
+ occurrences.push(
198
+ helpers.reportOccurrence(el, {
199
+ summary: 'This autocomplete attribute value is not a valid autofill value.',
200
+ hint: 'Use "on"/"off", or a valid autofill token list (e.g. "shipping street-address", "cc-number").',
201
+ i18n: {
202
+ summaryKey: 'autocompleteValid_summary_fail',
203
+ hintKey: 'autocompleteValid_hint_fail',
204
+ params: { element: tag, value: raw }
205
+ },
206
+ data: {
207
+ details: { reasonCode: 'AUTOCOMPLETE_VALUE_INVALID', element: tag, value: raw }
208
+ }
209
+ })
210
+ );
213
211
  }
214
212
 
215
213
  if (applicableCount === 0) {
@@ -245,23 +245,21 @@ function runInPage(ctx) {
245
245
  if (!flagged.length) continue;
246
246
 
247
247
  const tag = el.tagName.toLowerCase();
248
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
249
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
250
248
 
251
- occurrences.push({
252
- selector: stableSelector,
253
- html,
254
- summary: `This element's inline style forces ${flagged.join(', ')} with !important below the WCAG text-spacing metric, so the user cannot raise it.`,
255
- hint: 'Remove !important from line-height/letter-spacing/word-spacing in inline styles, or set a value that already meets the metric (line-height 1.5, letter-spacing 0.12em, word-spacing 0.16em).',
256
- i18n: {
257
- summaryKey: 'avoidInlineSpacing_summary_fail',
258
- hintKey: 'avoidInlineSpacing_hint_fail',
259
- params: { element: tag, properties: flagged.join(', ') }
260
- },
261
- data: {
262
- details: { reasonCode: 'INLINE_SPACING_IMPORTANT', element: tag, properties: flagged }
263
- }
264
- });
249
+ occurrences.push(
250
+ helpers.reportOccurrence(el, {
251
+ summary: `This element's inline style forces ${flagged.join(', ')} with !important below the WCAG text-spacing metric, so the user cannot raise it.`,
252
+ hint: 'Remove !important from line-height/letter-spacing/word-spacing in inline styles, or set a value that already meets the metric (line-height 1.5, letter-spacing 0.12em, word-spacing 0.16em).',
253
+ i18n: {
254
+ summaryKey: 'avoidInlineSpacing_summary_fail',
255
+ hintKey: 'avoidInlineSpacing_hint_fail',
256
+ params: { element: tag, properties: flagged.join(', ') }
257
+ },
258
+ data: {
259
+ details: { reasonCode: 'INLINE_SPACING_IMPORTANT', element: tag, properties: flagged }
260
+ }
261
+ })
262
+ );
265
263
  }
266
264
 
267
265
  if (applicableCount === 0) {