@surea11y/core 1.4.0 → 1.4.1

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 (49) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/bin/surea11y-core.js +0 -0
  3. package/docs/ARIA_DEPRECATION.md +95 -0
  4. package/docs/RULE_CATALOG.md +6 -6
  5. package/package.json +3 -1
  6. package/src/checks/automatic/aria-allowed-attr.js +648 -83
  7. package/src/checks/automatic/aria-deprecated-role.js +105 -37
  8. package/src/checks/automatic/aria-roles-valid.js +31 -6
  9. package/src/checks/automatic/autocomplete-valid.js +37 -1
  10. package/src/checks/automatic/avoid-inline-spacing.js +179 -19
  11. package/src/checks/automatic/binary-control-name-present.js +11 -3
  12. package/src/checks/automatic/button-name-present.js +50 -17
  13. package/src/checks/automatic/canvas-text-alternative-present.js +15 -8
  14. package/src/checks/automatic/combobox-name-present.js +8 -1
  15. package/src/checks/automatic/dialog-name-present.js +8 -1
  16. package/src/checks/automatic/form-control-programmatic-label-present.js +48 -4
  17. package/src/checks/automatic/form-control-single-label.js +107 -39
  18. package/src/checks/automatic/img-alt-present.js +16 -9
  19. package/src/checks/automatic/input-image-alt-present.js +99 -48
  20. package/src/checks/automatic/label-in-name.js +82 -8
  21. package/src/checks/automatic/language-page-present.js +5 -1
  22. package/src/checks/automatic/link-name-present.js +50 -17
  23. package/src/checks/automatic/listbox-name-present.js +8 -1
  24. package/src/checks/automatic/menuitem-name-present.js +8 -1
  25. package/src/checks/automatic/meta-refresh-no-exceptions.js +29 -0
  26. package/src/checks/automatic/meta-refresh-timing-absent.js +30 -4
  27. package/src/checks/automatic/meta-viewport-zoom-enabled.js +37 -15
  28. package/src/checks/automatic/meter-name-present.js +8 -1
  29. package/src/checks/automatic/nested-interactive-controls-absent.js +161 -31
  30. package/src/checks/automatic/object-text-alternative-present.js +14 -7
  31. package/src/checks/automatic/option-name-present.js +8 -1
  32. package/src/checks/automatic/progressbar-name-present.js +8 -1
  33. package/src/checks/automatic/searchbox-name-present.js +8 -1
  34. package/src/checks/automatic/slider-name-present.js +11 -2
  35. package/src/checks/automatic/spinbutton-name-present.js +8 -1
  36. package/src/checks/automatic/summary-name-present.js +8 -1
  37. package/src/checks/automatic/tab-name-present.js +8 -1
  38. package/src/checks/automatic/table-th-has-data-cells.js +67 -6
  39. package/src/checks/automatic/textbox-name-present.js +8 -1
  40. package/src/checks/automatic/tooltip-name-present.js +8 -1
  41. package/src/checks/automatic/treeitem-name-present.js +8 -1
  42. package/src/checks/automatic/valid-lang.js +16 -3
  43. package/src/checks/{automatic/bypass-blocks-present.js → manual/bypass-blocks-present-manual.js} +97 -35
  44. package/src/checks/manual/input-image-alt-decorative-manual.js +24 -0
  45. package/src/checks/manual/landmark-banner-is-top-level-manual.js +11 -22
  46. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +7 -10
  47. package/src/core.js +4853 -964
  48. package/src/sarif.js +2 -2
  49. package/surea11y.browser.js +2256 -433
@@ -13,22 +13,48 @@
13
13
  * a[href], button, input (not hidden), select, textarea; or an explicit
14
14
  * ARIA widget role: button, link, checkbox, radio, switch, tab, textbox,
15
15
  * combobox, listbox, menuitem, menuitemcheckbox, menuitemradio, option,
16
- * slider, spinbutton, searchbox, treeitem).
16
+ * slider, spinbutton, searchbox, treeitem). The container is applicable
17
+ * regardless of whether it is itself focusable — focusability is only
18
+ * used to decide whether a *descendant* nests an interactive control.
17
19
  * @expectation
18
- * The element does not contain, as a descendant, another element from
19
- * that same interactive-control set (e.g. a <button> wrapping a
20
- * <select>, or a link containing a checkbox). Nested interactive
21
- * controls are not reliably announced or operable via assistive
22
- * technology — activating the outer control and the inner one become
23
- * ambiguous, and some AT only exposes one of the two.
20
+ * The element does not contain, as a descendant, another *operable*
21
+ * interactive control (e.g. a <button> wrapping a <select>, or a link
22
+ * containing a checkbox). Nested interactive controls are not reliably
23
+ * announced or operable via assistive technology — activating the outer
24
+ * control and the inner one become ambiguous, and some AT only exposes
25
+ * one of the two.
24
26
  * @implementation-notes
25
- * - Reports on the outer (containing) control, not the nested descendant —
26
- * this engine considers the container the fixable unit ("move the nested
27
- * control outside this element").
28
- * - A container can be reported once even with multiple nested
29
- * descendants (listed together); a deeply nested chain (A > B > C, all
30
- * interactive) reports both A and B as separate occurrences, since each
31
- * genuinely contains a nested interactive control.
27
+ * - A descendant counts as a nested interactive control only when it is both
28
+ * (a) a native interactive element or an explicit ARIA widget role and
29
+ * (b) operable: exposed to the accessibility tree and platform-focusable
30
+ * (natively focusable, or focusable via tabindex), per
31
+ * helpers.getFocusableInfo, which accounts for contenteditable, :disabled,
32
+ * inert, and invalid/negative tabindex.
33
+ * - A widget-role child that its container drives rather than exposes as its
34
+ * own focus target creates no operability ambiguity and does not count:
35
+ * a role="option" inside a listbox/combobox, a role="tab" inside a tablist,
36
+ * a role="treeitem" inside a tree, a role="menuitem" inside a menu, or a
37
+ * role="radio" inside a radiogroup. Composites manage such children with one
38
+ * of two focus models, and both are exempt: aria-activedescendant (the child
39
+ * has no tabindex and is not focusable) and roving tabindex (the active
40
+ * child carries tabindex="0" and the rest tabindex="-1"). Under roving
41
+ * tabindex the owned child IS platform-focusable, so focusability alone
42
+ * cannot be the discriminator; membership in the container's owned-child set
43
+ * is what exempts it. A control that is itself focusable while nested in a
44
+ * control it does NOT belong to still counts: a <button> inside an <a href>,
45
+ * a focusable role="button" inside a link, an editable field inside a
46
+ * control, or an orphan role="option" with no owning listbox/combobox.
47
+ * - A disabled native control (<button disabled>, <input disabled>) and an
48
+ * inert descendant are not focusable, so they do not nest an interactive
49
+ * control; a display:none or aria-hidden descendant is not exposed to AT
50
+ * and is likewise out of scope. aria-disabled leaves an element focusable,
51
+ * so it still counts.
52
+ * - Occurrences report the outer (containing) control, the fixable unit
53
+ * ("move the nested control outside this element"), not the descendant.
54
+ * - Only the shallowest operable descendants are attributed to a container.
55
+ * In a chain A > B > C where all three are operable, A is reported for B
56
+ * and B for C; A is not reported for C, whose nesting belongs to its
57
+ * nearest operable ancestor B.
32
58
  */
33
59
 
34
60
  const id = 'nested-interactive-controls-absent';
@@ -92,6 +118,8 @@ function runInPage(ctx) {
92
118
 
93
119
  const isAccTreeEligible =
94
120
  helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
121
+ const getFocusableInfo =
122
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
95
123
 
96
124
  function isEligible(node) {
97
125
  if (!isAccTreeEligible) return true;
@@ -103,6 +131,122 @@ function runInPage(ctx) {
103
131
  }
104
132
  }
105
133
 
134
+ function matchesInteractive(node) {
135
+ return !!(
136
+ node &&
137
+ node.nodeType === 1 &&
138
+ typeof node.matches === 'function' &&
139
+ node.matches(INTERACTIVE_SELECTOR)
140
+ );
141
+ }
142
+
143
+ // A composite widget owns children of a fixed role and drives their focus
144
+ // itself (via roving tabindex or aria-activedescendant). The roving-tabindex
145
+ // model gives the active child tabindex="0" and the rest tabindex="-1", so
146
+ // the owned children ARE platform-focusable even though the container, not
147
+ // the child, is the operable unit. Such a child is part of the composite,
148
+ // not a control nested inside another control, so it must not be reported
149
+ // regardless of its tabindex. The map is keyed by the child role and lists
150
+ // the container roles that legitimately own it.
151
+ const COMPOSITE_CHILD_CONTAINERS = {
152
+ option: ['listbox', 'combobox'],
153
+ tab: ['tablist'],
154
+ treeitem: ['tree'],
155
+ menuitem: ['menu', 'menubar'],
156
+ menuitemcheckbox: ['menu', 'menubar'],
157
+ menuitemradio: ['menu', 'menubar'],
158
+ radio: ['radiogroup']
159
+ };
160
+
161
+ function getExplicitRole(node) {
162
+ if (!node || node.nodeType !== 1 || typeof node.getAttribute !== 'function') return '';
163
+ const raw = node.getAttribute('role');
164
+ if (!raw) return '';
165
+ // role accepts a space-separated fallback list; the first token wins.
166
+ const first = raw.trim().split(/\s+/)[0];
167
+ return first ? first.toLowerCase() : '';
168
+ }
169
+
170
+ function parentElementOf(node) {
171
+ if (!node) return null;
172
+ if (node.parentElement) return node.parentElement;
173
+ const p = node.parentNode;
174
+ return p && p.nodeType === 1 ? p : null;
175
+ }
176
+
177
+ // True when `node` is an owned child of a composite widget: its role is a
178
+ // composite-child role and a matching container role is present on one of
179
+ // its ancestors. The roving tab stop (tabindex="0") such a container places
180
+ // on its active child does not make that child an independently nested
181
+ // control.
182
+ function isManagedCompositeChild(node) {
183
+ const role = getExplicitRole(node);
184
+ const containers = COMPOSITE_CHILD_CONTAINERS[role];
185
+ if (!containers) return false;
186
+ let p = parentElementOf(node);
187
+ while (p && p.nodeType === 1) {
188
+ if (containers.indexOf(getExplicitRole(p)) !== -1) return true;
189
+ p = parentElementOf(p);
190
+ }
191
+ return false;
192
+ }
193
+
194
+ // Operable = a native-interactive / ARIA-widget element that is exposed to
195
+ // the accessibility tree and platform-focusable. A widget-role element with
196
+ // no independent focus (e.g. a role="option" with no tabindex, driven by
197
+ // its container via roving tabindex or aria-activedescendant) is not
198
+ // operable and does not nest an interactive control. When no focus model is
199
+ // available, role membership alone qualifies. (Composite-owned children are
200
+ // handled earlier as attribution boundaries in collectNestedOperable, so
201
+ // they never reach here.)
202
+ function isOperableInteractive(node) {
203
+ if (!matchesInteractive(node)) return false;
204
+ if (!isEligible(node)) return false;
205
+ if (!getFocusableInfo) return true;
206
+ try {
207
+ const info = getFocusableInfo(node, ctx);
208
+ return !!(info && info.focusable);
209
+ } catch {
210
+ return true;
211
+ }
212
+ }
213
+
214
+ // Shallowest operable interactive descendants of `root`. Traversal stops at
215
+ // each counted node instead of descending into it, so a control nested
216
+ // inside another operable control is attributed to its nearest operable
217
+ // ancestor rather than to every enclosing container. Eligibility is applied
218
+ // per node during the walk, so hidden or aria-hidden subtrees drop out.
219
+ function collectNestedOperable(root) {
220
+ const out = [];
221
+ const top = root && root.children;
222
+ if (!top || !top.length) return out;
223
+ const stack = [];
224
+ for (let i = top.length - 1; i >= 0; i--) stack.push(top[i]);
225
+ while (stack.length) {
226
+ const node = stack.pop();
227
+ if (node && node.nodeType === 1) {
228
+ // A composite-owned child (option in a listbox/combobox, tab in a
229
+ // tablist, ...) is not a nested interactive control: its container
230
+ // owns it and drives its focus (roving tabindex or
231
+ // aria-activedescendant). Treat it as an attribution boundary — do not
232
+ // count it, and do not descend past it. Any control genuinely nested
233
+ // inside it is attributed to the child itself (examined as its own
234
+ // container in the main loop), keeping the report at the nearest
235
+ // interactive ancestor rather than bubbling up to the composite.
236
+ if (isManagedCompositeChild(node)) continue;
237
+ if (isOperableInteractive(node)) {
238
+ out.push(node);
239
+ continue; // do not descend into a counted control
240
+ }
241
+ }
242
+ const kids = node && node.children;
243
+ if (kids && kids.length) {
244
+ for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]);
245
+ }
246
+ }
247
+ return out;
248
+ }
249
+
106
250
  const nodes = helpers.queryAllSmart
107
251
  ? helpers.queryAllSmart(INTERACTIVE_SELECTOR)
108
252
  : helpers.queryAll(INTERACTIVE_SELECTOR);
@@ -111,30 +255,16 @@ function runInPage(ctx) {
111
255
  let applicableCount = 0;
112
256
 
113
257
  for (const el of nodes) {
114
- if (!el || !el.querySelectorAll) continue;
258
+ if (!el || el.nodeType !== 1) continue;
115
259
  if (!isEligible(el)) continue;
116
260
 
117
261
  applicableCount += 1;
118
262
 
119
- // The nested search uses the raw native querySelectorAll (not
120
- // helpers.queryAllSmart), so it isn't subject to that helper's
121
- // default hidden-content policy at all -- not even hard CSS-based
122
- // hiding, let alone aria-hidden. A nested descendant that is never
123
- // actually rendered or exposed to AT (display:none, aria-hidden,
124
- // etc.) creates no real ambiguity for ANY user, since it isn't there
125
- // to be confused with the outer control.
126
- let nested;
127
- try {
128
- nested = Array.from(el.querySelectorAll(INTERACTIVE_SELECTOR)).filter(isEligible);
129
- } catch {
130
- nested = [];
131
- }
263
+ const nested = collectNestedOperable(el);
132
264
  if (!nested.length) continue;
133
265
 
134
266
  const nestedTags = nested.map((n) => (n && n.tagName ? n.tagName.toLowerCase() : 'unknown'));
135
- const dedupedNestedTags = [
136
- ...new Set(nested.map((n) => (n && n.tagName ? n.tagName.toLowerCase() : 'unknown')))
137
- ];
267
+ const dedupedNestedTags = [...new Set(nestedTags)];
138
268
 
139
269
  const tag = el.tagName.toLowerCase();
140
270
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
@@ -9,9 +9,8 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 1.1.1
11
11
  * @applicability
12
- * Applies to <object> elements that are exposed to assistive technologies.
13
- * Objects otherwise hidden from the accessibility tree remain applicable
14
- * if they are tabbable or referenced by IDREF relationships (per engine eligibility checks).
12
+ * Applies to <object> elements included in the accessibility tree.
13
+ * Hidden objects are excluded whether or not they are focusable.
15
14
  * role="presentation"/role="none" are excluded only when not focusable.
16
15
  * @expectation
17
16
  * Each applicable <object> provides a text alternative via:
@@ -71,8 +70,15 @@ function runInPage(ctx) {
71
70
  const getEligibilityInfo =
72
71
  helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
73
72
 
74
- const isAccTreeEligible =
75
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
73
+ // ACT 8fc3b6 applies only to objects included in the accessibility tree, so a
74
+ // focusable object inside aria-hidden is out of scope; aria-hidden-focus
75
+ // reports that markup instead.
76
+ const isEligibleHelper =
77
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
78
+ ? helpers.isIncludedInAccessibilityTree
79
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
80
+ ? helpers.isAccTreeEligible
81
+ : null;
76
82
 
77
83
  const getFocusableInfo =
78
84
  helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
@@ -148,14 +154,15 @@ function runInPage(ctx) {
148
154
  for (const el of objects) {
149
155
  if (!el || !el.getAttribute) continue;
150
156
 
151
- if (isAccTreeEligible) {
157
+ if (isEligibleHelper) {
152
158
  const elig = (() => {
153
159
  try {
154
- return isAccTreeEligible(el, ctx);
160
+ return isEligibleHelper(el, ctx);
155
161
  } catch {
156
162
  return { eligible: true, reasons: [] };
157
163
  }
158
164
  })();
165
+ if (elig === false) continue;
159
166
  if (elig && elig.eligible === false) continue;
160
167
  }
161
168
 
@@ -83,9 +83,16 @@ function runInPage(ctx) {
83
83
  return '';
84
84
  }
85
85
 
86
+ // Naming rules apply only to elements included in the accessibility tree
87
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
88
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
86
89
  function isEligibleAcc(helpers, el, ctx) {
87
90
  const fn =
88
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
91
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
92
+ ? helpers.isIncludedInAccessibilityTree
93
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
94
+ ? helpers.isAccTreeEligible
95
+ : null;
89
96
  if (!fn) return true;
90
97
  try {
91
98
  const r = fn(el, ctx);
@@ -67,9 +67,16 @@ function runInPage(ctx) {
67
67
  return '';
68
68
  }
69
69
 
70
+ // Naming rules apply only to elements included in the accessibility tree
71
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
72
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
70
73
  function isEligibleAcc(helpers, el, ctx) {
71
74
  const fn =
72
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
75
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
76
+ ? helpers.isIncludedInAccessibilityTree
77
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
78
+ ? helpers.isAccTreeEligible
79
+ : null;
73
80
  if (!fn) return true;
74
81
  try {
75
82
  const r = fn(el, ctx);
@@ -109,9 +109,16 @@ function runInPage(ctx) {
109
109
  return '';
110
110
  }
111
111
 
112
+ // Naming rules apply only to elements included in the accessibility tree
113
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
114
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
112
115
  function isEligibleAcc(helpers, el, ctx) {
113
116
  const fn =
114
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
117
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
118
+ ? helpers.isIncludedInAccessibilityTree
119
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
120
+ ? helpers.isAccTreeEligible
121
+ : null;
115
122
  if (!fn) return true;
116
123
  try {
117
124
  const r = fn(el, ctx);
@@ -125,9 +125,16 @@ function runInPage(ctx) {
125
125
  return '';
126
126
  }
127
127
 
128
+ // Naming rules apply only to elements included in the accessibility tree
129
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
130
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
128
131
  function isEligibleAcc(helpers, el, ctx) {
129
132
  const fn =
130
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
133
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
134
+ ? helpers.isIncludedInAccessibilityTree
135
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
136
+ ? helpers.isAccTreeEligible
137
+ : null;
131
138
  if (!fn) return true;
132
139
  try {
133
140
  const r = fn(el, ctx);
@@ -141,7 +148,9 @@ function runInPage(ctx) {
141
148
  const occurrences = [];
142
149
  let applicableCount = 0;
143
150
 
144
- const selector = 'input[type="range"], [role="slider"]';
151
+ // Native input[type=range] belongs to
152
+ // form-control-programmatic-label-present.
153
+ const selector = '[role="slider"]';
145
154
  const nodes = helpers.queryAllSmart
146
155
  ? helpers.queryAllSmart(selector)
147
156
  : helpers.queryAll(selector);
@@ -109,9 +109,16 @@ function runInPage(ctx) {
109
109
  return '';
110
110
  }
111
111
 
112
+ // Naming rules apply only to elements included in the accessibility tree
113
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
114
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
112
115
  function isEligibleAcc(helpers, el, ctx) {
113
116
  const fn =
114
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
117
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
118
+ ? helpers.isIncludedInAccessibilityTree
119
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
120
+ ? helpers.isAccTreeEligible
121
+ : null;
115
122
  if (!fn) return true;
116
123
  try {
117
124
  const r = fn(el, ctx);
@@ -83,9 +83,16 @@ function runInPage(ctx) {
83
83
  return '';
84
84
  }
85
85
 
86
+ // Naming rules apply only to elements included in the accessibility tree
87
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
88
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
86
89
  function isEligibleAcc(helpers, el, ctx) {
87
90
  const fn =
88
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
91
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
92
+ ? helpers.isIncludedInAccessibilityTree
93
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
94
+ ? helpers.isAccTreeEligible
95
+ : null;
89
96
  if (!fn) return true;
90
97
  try {
91
98
  const r = fn(el, ctx);
@@ -83,9 +83,16 @@ function runInPage(ctx) {
83
83
  return '';
84
84
  }
85
85
 
86
+ // Naming rules apply only to elements included in the accessibility tree
87
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
88
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
86
89
  function isEligibleAcc(helpers, el, ctx) {
87
90
  const fn =
88
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
91
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
92
+ ? helpers.isIncludedInAccessibilityTree
93
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
94
+ ? helpers.isAccTreeEligible
95
+ : null;
89
96
  if (!fn) return true;
90
97
  try {
91
98
  const r = fn(el, ctx);
@@ -9,7 +9,10 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 1.3.1
11
11
  * @applicability
12
- * Applies to <table> elements that contain at least one <th>.
12
+ * Applies to <table> elements that keep their table semantics and are included
13
+ * in the accessibility tree, and that contain at least one <th> which is
14
+ * visible, included in the accessibility tree, and not overridden by an
15
+ * explicit role other than rowheader/columnheader.
13
16
  * @expectation
14
17
  * The table also contains at least one <td> somewhere in it.
15
18
  * @implementation-notes
@@ -30,9 +33,10 @@
30
33
  * if some particular <th> in it doesn't actually describe any cell
31
34
  * (false negative, not a false positive — acceptable under this
32
35
  * engine's philosophy).
33
- * - Not rule-gated on isAccTreeEligible: this remains a static-markup
34
- * property, while engine-level hidden-subtree filtering still applies
35
- * unless engineOptions.includeHiddenElements is true.
36
+ * - Applicability IS gated, because the zero-<td> case above turns any
37
+ * over-broad scope straight into a false fail: a layout table marked
38
+ * role="presentation" with a stray <th> and no <td> was being failed,
39
+ * as were tables whose only header was hidden or carried role="cell".
36
40
  */
37
41
 
38
42
  const id = 'table-th-has-data-cells';
@@ -72,16 +76,73 @@ function runInPage(ctx) {
72
76
  const occurrences = [];
73
77
  let applicableCount = 0;
74
78
 
79
+ function explicitRole(el) {
80
+ try {
81
+ return String((el && el.getAttribute && el.getAttribute('role')) || '')
82
+ .trim()
83
+ .toLowerCase()
84
+ .split(/\s+/)[0];
85
+ } catch {
86
+ return '';
87
+ }
88
+ }
89
+
90
+ function isIncludedInTree(el) {
91
+ const fn =
92
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
93
+ ? helpers.isIncludedInAccessibilityTree
94
+ : null;
95
+ if (!fn) return true;
96
+ try {
97
+ const r = fn(el, ctx);
98
+ return typeof r === 'boolean' ? r : !!(r && r.eligible);
99
+ } catch {
100
+ return true;
101
+ }
102
+ }
103
+
104
+ // role="presentation"/"none" removes the table role, and with it every
105
+ // header cell the role would have implied. A table is not focusable, so
106
+ // presentational role conflict resolution does not restore it.
107
+ function hasTableSemantics(table) {
108
+ const role = explicitRole(table);
109
+ if (role === 'presentation' || role === 'none') return false;
110
+ if (role && role !== 'table' && role !== 'grid' && role !== 'treegrid') return false;
111
+ return isIncludedInTree(table);
112
+ }
113
+
114
+ // A <th> only carries a rowheader/columnheader role while nothing overrides
115
+ // it, and a hidden header describes nothing to a screen reader either way.
116
+ function isHeaderCellInScope(th) {
117
+ if (!th) return false;
118
+ const role = explicitRole(th);
119
+ if (role && role !== 'rowheader' && role !== 'columnheader') return false;
120
+ if (helpers.isDomVisibleEligible) {
121
+ try {
122
+ if (!helpers.isDomVisibleEligible(th, ctx, { targetSet: 'dom' }).eligible) return false;
123
+ } catch {
124
+ // fall through to the tree check
125
+ }
126
+ }
127
+ return isIncludedInTree(th);
128
+ }
129
+
75
130
  for (const table of tables) {
76
131
  if (!table || !table.querySelectorAll) continue;
77
132
 
133
+ // A table stripped of its semantics has no header cells to describe
134
+ // anything, so nothing in it is in scope.
135
+ if (!hasTableSemantics(table)) continue;
136
+
78
137
  let ths;
79
138
  try {
80
139
  ths = table.querySelectorAll('th');
81
140
  } catch {
82
141
  ths = [];
83
142
  }
84
- if (!ths.length) continue;
143
+
144
+ const headers = Array.from(ths).filter(isHeaderCellInScope);
145
+ if (!headers.length) continue;
85
146
 
86
147
  applicableCount += 1;
87
148
 
@@ -93,7 +154,7 @@ function runInPage(ctx) {
93
154
  }
94
155
  if (hasDataCell) continue;
95
156
 
96
- for (const th of ths) {
157
+ for (const th of headers) {
97
158
  if (!th) continue;
98
159
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(th) : 'html';
99
160
  const html = helpers.getOuterHtmlSnippet
@@ -109,9 +109,16 @@ function runInPage(ctx) {
109
109
  return '';
110
110
  }
111
111
 
112
+ // Naming rules apply only to elements included in the accessibility tree
113
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
114
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
112
115
  function isEligibleAcc(helpers, el, ctx) {
113
116
  const fn =
114
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
117
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
118
+ ? helpers.isIncludedInAccessibilityTree
119
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
120
+ ? helpers.isAccTreeEligible
121
+ : null;
115
122
  if (!fn) return true;
116
123
  try {
117
124
  const r = fn(el, ctx);
@@ -83,9 +83,16 @@ function runInPage(ctx) {
83
83
  return '';
84
84
  }
85
85
 
86
+ // Naming rules apply only to elements included in the accessibility tree
87
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
88
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
86
89
  function isEligibleAcc(helpers, el, ctx) {
87
90
  const fn =
88
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
91
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
92
+ ? helpers.isIncludedInAccessibilityTree
93
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
94
+ ? helpers.isAccTreeEligible
95
+ : null;
89
96
  if (!fn) return true;
90
97
  try {
91
98
  const r = fn(el, ctx);
@@ -83,9 +83,16 @@ function runInPage(ctx) {
83
83
  return '';
84
84
  }
85
85
 
86
+ // Naming rules apply only to elements included in the accessibility tree
87
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
88
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
86
89
  function isEligibleAcc(helpers, el, ctx) {
87
90
  const fn =
88
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
91
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
92
+ ? helpers.isIncludedInAccessibilityTree
93
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
94
+ ? helpers.isAccTreeEligible
95
+ : null;
89
96
  if (!fn) return true;
90
97
  try {
91
98
  const r = fn(el, ctx);
@@ -58,7 +58,13 @@ const meta = {
58
58
  function runInPage(ctx) {
59
59
  const { helpers, rule } = ctx;
60
60
 
61
+ // Shape alone accepts unregistered tags such as "eng" and "em-US", so the
62
+ // primary subtag is checked against the IANA registry via the shared helper.
61
63
  const BCP47_RE = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/;
64
+ const isValidTag =
65
+ typeof helpers.isValidLanguageTag === 'function'
66
+ ? helpers.isValidLanguageTag
67
+ : (v) => BCP47_RE.test(String(v || ''));
62
68
 
63
69
  const nodes = helpers.queryAllSmart
64
70
  ? helpers.queryAllSmart('[lang]')
@@ -71,12 +77,19 @@ function runInPage(ctx) {
71
77
  if (!el || !el.getAttribute) continue;
72
78
  if (el.tagName && el.tagName.toLowerCase() === 'html') continue;
73
79
 
74
- const raw = String(el.getAttribute('lang') || '').trim();
75
- if (!raw) continue;
80
+ const rawAttr = el.getAttribute('lang');
81
+ if (rawAttr === null || rawAttr === '') continue; // ACT de46e4: empty is out of scope
82
+
83
+ // The rule applies only where text actually inherits the language, so an
84
+ // element with no text, or whose text reaches no one, has nothing to
85
+ // declare a language for.
86
+ if (!String(el.textContent || '').trim()) continue;
76
87
 
77
88
  applicableCount += 1;
78
89
 
79
- if (BCP47_RE.test(raw)) continue;
90
+ // A whitespace-only value is in scope and has no primary language tag.
91
+ const raw = String(rawAttr).trim();
92
+ if (isValidTag(raw)) continue;
80
93
 
81
94
  const tag = el.tagName.toLowerCase();
82
95
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';