@surea11y/core 1.1.2 → 1.2.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 (47) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +3 -0
  3. package/bin/core.js +107 -3
  4. package/docs/API_STABILITY.md +61 -0
  5. package/docs/BASELINE.md +66 -0
  6. package/docs/CLI.md +26 -0
  7. package/docs/ENGINE_OPTIONS.md +2 -0
  8. package/docs/INTEGRATION.md +1 -1
  9. package/docs/OUTPUT_SCHEMA.md +1 -1
  10. package/docs/REPORT.md +33 -0
  11. package/docs/RULE_AUTHORING.md +31 -0
  12. package/package.json +1 -1
  13. package/src/baseline.js +0 -0
  14. package/src/checks/automatic/aria-hidden-body.js +9 -1
  15. package/src/checks/automatic/aria-prohibited-children.js +71 -14
  16. package/src/checks/automatic/bypass-blocks-present.js +9 -1
  17. package/src/checks/automatic/css-orientation-lock.js +9 -1
  18. package/src/checks/automatic/html-xml-lang-mismatch.js +9 -1
  19. package/src/checks/automatic/language-page-present.js +9 -1
  20. package/src/checks/automatic/meta-refresh-no-exceptions.js +9 -1
  21. package/src/checks/automatic/meta-refresh-timing-absent.js +9 -1
  22. package/src/checks/automatic/meta-viewport-zoom-enabled.js +9 -1
  23. package/src/checks/automatic/page-title-present.js +9 -1
  24. package/src/checks/manual/empty-heading-manual.js +1 -6
  25. package/src/checks/manual/empty-table-header-manual.js +5 -9
  26. package/src/checks/manual/heading-order-manual.js +1 -6
  27. package/src/checks/manual/landmark-banner-is-top-level-manual.js +30 -14
  28. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +30 -14
  29. package/src/checks/manual/landmark-main-is-top-level-manual.js +30 -14
  30. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +25 -13
  31. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +25 -13
  32. package/src/checks/manual/landmark-one-main-manual.js +9 -1
  33. package/src/checks/manual/landmark-unique-manual.js +32 -30
  34. package/src/checks/manual/meta-viewport-large-manual.js +9 -1
  35. package/src/checks/manual/page-has-heading-one-manual.js +9 -1
  36. package/src/checks/manual/page-title-patterns-manual.js +9 -1
  37. package/src/checks/manual/region-manual.js +34 -14
  38. package/src/checks/manual/scope-attr-valid-manual.js +2 -7
  39. package/src/checks/manual/tabindex-manual.js +2 -7
  40. package/src/core/aria-helpers.js +82 -18
  41. package/src/core/dom-helpers.js +32 -0
  42. package/src/core/dom-runner.js +8 -0
  43. package/src/core/rule-meta.js +19 -0
  44. package/src/core.js +1781 -404
  45. package/src/i18n/en.js +2 -0
  46. package/src/i18n/fr.js +2 -0
  47. package/src/report.js +482 -0
@@ -52,12 +52,42 @@
52
52
  * focusability) are static, declarative markup facts with no live-DOM/
53
53
  * hydration risk, unlike e.g. `aria-checked-state-mismatch`'s DOM-
54
54
  * property comparison.
55
+ * - Fixed 2026-07-30: a roleless-but-focusable descendant's message and
56
+ * `data.details.attr` used to always claim "carries tabindex" even when
57
+ * the element had no tabindex attribute at all and was only focusable
58
+ * natively (e.g. an <a href> link). `helpers.getFocusableInfo`'s
59
+ * `mechanism` field ('tabindex' | 'native' | ...) is now used to tell
60
+ * the two apart, with a distinct `nativeFocusable` attr/message for the
61
+ * native case. Found via a real Angular app: a routerLink <a> inside a
62
+ * role="list" was reported as "carries tabindex" though the rendered
63
+ * markup had no such attribute.
55
64
  * - Recursion stops at the first non-transparent role boundary, same as
56
65
  * that reference engine: a nested container with its own real role (e.g. a
57
66
  * <div role="listbox"> inside a menubar) is evaluated as ITS OWN
58
67
  * owned-role entry against the outer container (and, separately, gets
59
68
  * its own applicability pass as a container in the same rule run) —
60
69
  * its descendants are never misattributed to the outer container.
70
+ * - Fixed 2026-07-31: child-role resolution used `ariaHelpers.getExplicitRole`
71
+ * (explicit role="" attribute only), unlike aria-required-children's
72
+ * descendant matching which uses `ariaHelpers.getContainmentRole` (explicit
73
+ * role, falling back to the native-tag map — li/tr/td/th/tbody/ul/ol/
74
+ * table/select/input[type=radio] — see that helper's own header comment).
75
+ * A bare `<li>` with no role="" attribute — the common CSS-reset
76
+ * workaround `<ul role="list"><li>...</li></ul>` that getContainmentRole
77
+ * exists specifically to handle — was therefore read as roleless here,
78
+ * making it structurally transparent: the walk recursed straight through
79
+ * the listitem boundary into its subtree and could report a focusable
80
+ * descendant several levels down as a disallowed owned child of the list,
81
+ * instead of stopping at the (implicit) listitem the way
82
+ * aria-required-children already does. Switched to getContainmentRole so
83
+ * both rules resolve an owned child's role identically. This is a general
84
+ * fix, not list/listitem-specific: it applies to every container role in
85
+ * REQUIRED_OWNED_ROLES whose native-tag counterpart the child map covers
86
+ * (e.g. a bare `<tr>`/`<td>` under a role="table"/"grid"/"row" container
87
+ * with no explicit role="" was subject to the same flattening bug). Found
88
+ * via a real Angular Material-style component library: an `<a routerlink>`
89
+ * several DOM levels inside a bare `<li>` under `<ul role="list">` was
90
+ * reported as an unallowed owned child of the list.
61
91
  * - Gated on isAccTreeEligible for the container itself, matching the fix
62
92
  * applied to aria-required-children (see that rule's header): the
63
93
  * original "not gated" note here just cited that rule's reasoning
@@ -151,6 +181,11 @@ function runInPage(ctx) {
151
181
  // entry with role: null, which can never satisfy a required-role set)
152
182
  // when it carries a global aria-* attribute or is focusable — matches
153
183
  // a widely-used reference engine's own getOwnedRoles exactly (see header comment).
184
+ // kidRole comes from getContainmentRole, not getExplicitRole (see header
185
+ // comment's 2026-07-31 fix): "roleless" here means neither an explicit
186
+ // role="" NOR one of the native containment tags (li, tr, td, ...), so a
187
+ // bare <li>/<tr>/... is a real listitem/row boundary, not a transparent
188
+ // wrapper the walk should pass through.
154
189
  function collectOwnedRoles(el, requiredSet, out, depth) {
155
190
  if (depth > MAX_DEPTH) return;
156
191
  const kids = el.children ? Array.prototype.slice.call(el.children) : [];
@@ -158,21 +193,27 @@ function runInPage(ctx) {
158
193
  if (!kid || kid.nodeType !== 1) continue;
159
194
  if (!isEligibleAcc(kid)) continue;
160
195
 
161
- const kidRole = ariaHelpers.getExplicitRole(kid);
196
+ const kidRole = ariaHelpers.getContainmentRole(kid);
162
197
  const isPresentational = kidRole === 'presentation' || kidRole === 'none';
163
198
  const isTransparentGroup = (kidRole === 'group' || kidRole === 'rowgroup') && requiredSet.has(kidRole);
164
199
 
165
200
  if (!kidRole && !isPresentational) {
166
201
  const globalAttr = getGlobalAriaAttr(kid);
167
- let focusable = false;
202
+ let mechanism = 'none';
168
203
  try {
169
204
  const fi = helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo(kid, ctx) : null;
170
- focusable = !!(fi && fi.focusable);
205
+ mechanism = (fi && fi.focusable && fi.mechanism) || 'none';
171
206
  } catch {
172
- focusable = false;
207
+ mechanism = 'none';
173
208
  }
174
- if (globalAttr || focusable) {
175
- out.push({ el: kid, role: null, attr: globalAttr || 'tabindex' });
209
+ if (globalAttr || mechanism !== 'none') {
210
+ // `mechanism` distinguishes an actual tabindex="" attribute from
211
+ // native focusability (e.g. <a href>, <button>, <input>) — these
212
+ // are different facts and must not be reported as the same
213
+ // "carries tabindex" claim (a native anchor with no tabindex
214
+ // attribute at all is not "carrying tabindex").
215
+ const attr = globalAttr || (mechanism === 'tabindex' ? 'tabindex' : 'nativeFocusable');
216
+ out.push({ el: kid, role: null, attr });
176
217
  continue; // real accessible-tree node: stop here, do not recurse further
177
218
  }
178
219
  }
@@ -219,12 +260,28 @@ function runInPage(ctx) {
219
260
  const containerSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
220
261
 
221
262
  const isRoleless = !entry.role;
222
- const summary = isRoleless
223
- ? `This element has no explicit role but carries ${entry.attr}, making it a real accessible-tree node that is not an allowed owned child of the enclosing role="${role}" container.`
224
- : `This element has role="${entry.role}", which is not an allowed owned child of the enclosing role="${role}" container.`;
225
- const hint = isRoleless
226
- ? `Remove ${entry.attr} (or the role="${role}" container ownership), or give this element role="presentation"/"none" if it isn't meant to be its own accessible-tree node.`
227
- : `Remove or change this role so it matches one of the container's allowed owned roles (${requiredOwned.join(', ')}), or move this element outside the ${role} container.`;
263
+ const isNativeFocusable = entry.attr === 'nativeFocusable';
264
+
265
+ let summary;
266
+ let hint;
267
+ let summaryKey;
268
+ let hintKey;
269
+ if (isNativeFocusable) {
270
+ summary = `This element has no explicit role but is natively focusable, making it a real accessible-tree node that is not an allowed owned child of the enclosing role="${role}" container.`;
271
+ hint = `Give this element role="presentation"/"none", remove its native focusability (e.g. drop the href/tabindex-granting attribute), or move it outside the ${role} container.`;
272
+ summaryKey = 'ariaProhibitedChildren_summary_fail_native_focusable';
273
+ hintKey = 'ariaProhibitedChildren_hint_fail_native_focusable';
274
+ } else if (isRoleless) {
275
+ summary = `This element has no explicit role but carries ${entry.attr}, making it a real accessible-tree node that is not an allowed owned child of the enclosing role="${role}" container.`;
276
+ hint = `Remove ${entry.attr} (or the role="${role}" container ownership), or give this element role="presentation"/"none" if it isn't meant to be its own accessible-tree node.`;
277
+ summaryKey = 'ariaProhibitedChildren_summary_fail_roleless';
278
+ hintKey = 'ariaProhibitedChildren_hint_fail_roleless';
279
+ } else {
280
+ summary = `This element has role="${entry.role}", which is not an allowed owned child of the enclosing role="${role}" container.`;
281
+ hint = `Remove or change this role so it matches one of the container's allowed owned roles (${requiredOwned.join(', ')}), or move this element outside the ${role} container.`;
282
+ summaryKey = 'ariaProhibitedChildren_summary_fail';
283
+ hintKey = 'ariaProhibitedChildren_hint_fail';
284
+ }
228
285
 
229
286
  occurrences.push({
230
287
  selector: stableSelector,
@@ -232,8 +289,8 @@ function runInPage(ctx) {
232
289
  summary,
233
290
  hint,
234
291
  i18n: {
235
- summaryKey: isRoleless ? 'ariaProhibitedChildren_summary_fail_roleless' : 'ariaProhibitedChildren_summary_fail',
236
- hintKey: isRoleless ? 'ariaProhibitedChildren_hint_fail_roleless' : 'ariaProhibitedChildren_hint_fail',
292
+ summaryKey,
293
+ hintKey,
237
294
  params: isRoleless
238
295
  ? { attr: entry.attr, containerRole: role }
239
296
  : { childRole: entry.role, containerRole: role, allowedRoles: requiredOwned.join(', ') }
@@ -67,6 +67,14 @@ const meta = {
67
67
  coverage: { facetsBySc: { '2.4.1': ['bypass-blocks-present'] } }
68
68
  };
69
69
 
70
+ // This check is inherently whole-document (does the PAGE have this
71
+ // property?), not evaluable per-subtree -- notApplicable when contextSelector
72
+ // scoped this run narrower than the whole document, or when
73
+ // engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
74
+ function applicability(ctx) {
75
+ return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
76
+ }
77
+
70
78
  function runInPage(ctx) {
71
79
  const { document, helpers, rule } = ctx;
72
80
 
@@ -187,4 +195,4 @@ function runInPage(ctx) {
187
195
  return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
188
196
  }
189
197
 
190
- module.exports = { id, meta, runInPage };
198
+ module.exports = { id, meta, runInPage, applicability };
@@ -73,6 +73,14 @@ const meta = {
73
73
  coverage: { facetsBySc: { '1.3.4': ['css-orientation-lock'] } }
74
74
  };
75
75
 
76
+ // This check is inherently whole-document (does the PAGE have this
77
+ // property?), not evaluable per-subtree -- notApplicable when contextSelector
78
+ // scoped this run narrower than the whole document, or when
79
+ // engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
80
+ function applicability(ctx) {
81
+ return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
82
+ }
83
+
76
84
  function runInPage(ctx) {
77
85
  const { document, helpers, rule } = ctx;
78
86
 
@@ -203,4 +211,4 @@ function runInPage(ctx) {
203
211
  return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
204
212
  }
205
213
 
206
- module.exports = { id, meta, runInPage };
214
+ module.exports = { id, meta, runInPage, applicability };
@@ -45,6 +45,14 @@ const meta = {
45
45
  coverage: { facetsBySc: { '3.1.1': ['html-xml-lang-mismatch'] } }
46
46
  };
47
47
 
48
+ // This check is inherently whole-document (does the PAGE have this
49
+ // property?), not evaluable per-subtree -- notApplicable when contextSelector
50
+ // scoped this run narrower than the whole document, or when
51
+ // engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
52
+ function applicability(ctx) {
53
+ return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
54
+ }
55
+
48
56
  function runInPage(ctx) {
49
57
  const { document, helpers, rule } = ctx;
50
58
 
@@ -88,4 +96,4 @@ function runInPage(ctx) {
88
96
  return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
89
97
  }
90
98
 
91
- module.exports = { id, meta, runInPage };
99
+ module.exports = { id, meta, runInPage, applicability };
@@ -63,6 +63,14 @@ const meta = {
63
63
  }
64
64
  };
65
65
 
66
+ // This check is inherently whole-document (does the PAGE have this
67
+ // property?), not evaluable per-subtree -- notApplicable when contextSelector
68
+ // scoped this run narrower than the whole document, or when
69
+ // engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
70
+ function applicability(ctx) {
71
+ return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
72
+ }
73
+
66
74
  function runInPage(ctx) {
67
75
  const { document, rule, helpers } = ctx;
68
76
  const html = document && document.documentElement;
@@ -156,4 +164,4 @@ function runInPage(ctx) {
156
164
  };
157
165
  }
158
166
 
159
- module.exports = { id, meta, runInPage };
167
+ module.exports = { id, meta, runInPage, applicability };
@@ -58,6 +58,14 @@ const meta = {
58
58
  }
59
59
  };
60
60
 
61
+ // This check is inherently whole-document (does the PAGE have this
62
+ // property?), not evaluable per-subtree -- notApplicable when contextSelector
63
+ // scoped this run narrower than the whole document, or when
64
+ // engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
65
+ function applicability(ctx) {
66
+ return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
67
+ }
68
+
61
69
  function runInPage(ctx) {
62
70
  const { document, helpers, rule } = ctx;
63
71
 
@@ -102,4 +110,4 @@ function runInPage(ctx) {
102
110
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
103
111
  }
104
112
 
105
- module.exports = { id, meta, runInPage };
113
+ module.exports = { id, meta, runInPage, applicability };
@@ -49,6 +49,14 @@ const meta = {
49
49
  coverage: { facetsBySc: { '2.2.1': ['meta-refresh-timing-absent'] } }
50
50
  };
51
51
 
52
+ // This check is inherently whole-document (does the PAGE have this
53
+ // property?), not evaluable per-subtree -- notApplicable when contextSelector
54
+ // scoped this run narrower than the whole document, or when
55
+ // engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
56
+ function applicability(ctx) {
57
+ return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
58
+ }
59
+
52
60
  function runInPage(ctx) {
53
61
  const { document, helpers, rule } = ctx;
54
62
 
@@ -104,4 +112,4 @@ function runInPage(ctx) {
104
112
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
105
113
  }
106
114
 
107
- module.exports = { id, meta, runInPage };
115
+ module.exports = { id, meta, runInPage, applicability };
@@ -42,6 +42,14 @@ const meta = {
42
42
  coverage: { facetsBySc: { '1.4.4': ['meta-viewport-zoom-enabled'] } }
43
43
  };
44
44
 
45
+ // This check is inherently whole-document (does the PAGE have this
46
+ // property?), not evaluable per-subtree -- notApplicable when contextSelector
47
+ // scoped this run narrower than the whole document, or when
48
+ // engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
49
+ function applicability(ctx) {
50
+ return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
51
+ }
52
+
45
53
  function runInPage(ctx) {
46
54
  const { document, helpers, rule } = ctx;
47
55
 
@@ -115,4 +123,4 @@ function runInPage(ctx) {
115
123
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
116
124
  }
117
125
 
118
- module.exports = { id, meta, runInPage };
126
+ module.exports = { id, meta, runInPage, applicability };
@@ -22,6 +22,14 @@ const meta = {
22
22
  coverage: { facetsBySc: { '2.4.2': ['page-title-present'] } }
23
23
  };
24
24
 
25
+ // This check is inherently whole-document (does the PAGE have this
26
+ // property?), not evaluable per-subtree -- notApplicable when contextSelector
27
+ // scoped this run narrower than the whole document, or when
28
+ // engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
29
+ function applicability(ctx) {
30
+ return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
31
+ }
32
+
25
33
  function runInPage(ctx) {
26
34
  const { document, helpers, rule } = ctx;
27
35
 
@@ -83,4 +91,4 @@ function runInPage(ctx) {
83
91
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
84
92
  }
85
93
 
86
- module.exports = { id, meta, runInPage };
94
+ module.exports = { id, meta, runInPage, applicability };
@@ -112,12 +112,7 @@ function runInPage(ctx) {
112
112
  return normalizeWs(el.getAttribute && el.getAttribute('title'));
113
113
  }
114
114
 
115
- let nodes = [];
116
- try {
117
- nodes = document.querySelectorAll('h1, h2, h3, h4, h5, h6, [role]');
118
- } catch {
119
- nodes = [];
120
- }
115
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('h1, h2, h3, h4, h5, h6, [role]') : helpers.queryAll('h1, h2, h3, h4, h5, h6, [role]');
121
116
 
122
117
  const occurrences = [];
123
118
  let applicableCount = 0;
@@ -94,15 +94,11 @@ function runInPage(ctx) {
94
94
  return '';
95
95
  }
96
96
 
97
- let nodes = [];
98
- try {
99
- // Matches a widely-used reference engine's own empty-table-header selector exactly: a <th> with
100
- // no conflicting explicit role, plus any element carrying an explicit
101
- // columnheader/rowheader role (native or not).
102
- nodes = document.querySelectorAll('th:not([role]), [role="columnheader"], [role="rowheader"]');
103
- } catch {
104
- nodes = [];
105
- }
97
+ // Matches a widely-used reference engine's own empty-table-header selector exactly: a <th> with
98
+ // no conflicting explicit role, plus any element carrying an explicit
99
+ // columnheader/rowheader role (native or not).
100
+ const selector = 'th:not([role]), [role="columnheader"], [role="rowheader"]';
101
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
106
102
 
107
103
  const occurrences = [];
108
104
  let applicableCount = 0;
@@ -72,12 +72,7 @@ function runInPage(ctx) {
72
72
  return m ? parseInt(m[1], 10) : 0;
73
73
  }
74
74
 
75
- let nodes = [];
76
- try {
77
- nodes = document.querySelectorAll('h1, h2, h3, h4, h5, h6, [role]');
78
- } catch {
79
- nodes = [];
80
- }
75
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('h1, h2, h3, h4, h5, h6, [role]') : helpers.queryAll('h1, h2, h3, h4, h5, h6, [role]');
81
76
 
82
77
  const headings = [];
83
78
  const seen = new Set();
@@ -52,7 +52,7 @@ const meta = {
52
52
  };
53
53
 
54
54
  function runInPage(ctx) {
55
- const { document, helpers, rule } = ctx;
55
+ const { document, root, helpers, rule } = ctx;
56
56
 
57
57
  // Declared inside runInPage — see scripts/build-core.js header
58
58
  // ("runInPage MUST be self-contained").
@@ -81,25 +81,37 @@ function runInPage(ctx) {
81
81
  return raw.split(/\s+/)[0].toLowerCase();
82
82
  }
83
83
 
84
- const SECTIONING_ANCESTORS = new Set(['article', 'aside', 'main', 'nav', 'section']);
85
-
86
- function isSuppressedBySectioningAncestor(el) {
87
- let p = el.parentElement;
88
- while (p) {
89
- const tag = p.tagName ? p.tagName.toLowerCase() : '';
90
- if (SECTIONING_ANCESTORS.has(tag)) return true;
91
- p = p.parentElement;
92
- }
93
- return false;
84
+ // Delegates to the shared helpers.hasLandmarkScopingAncestor for the
85
+ // question "does this element sit inside a sectioning-content/<main>
86
+ // ancestor that suppresses its conditional implicit role" — role-aware
87
+ // (an ancestor's bare TAG only counts when it carries no role attribute
88
+ // at all; an explicit role="dialog"-style override no longer suppresses)
89
+ // rather than a local tag-only copy. See that function's header comment
90
+ // in src/core/aria-helpers.js for the full algorithm and the real page
91
+ // (handsontable.com's docs-assistant side panel, an
92
+ // <aside role="dialog"> containing its own <header>) that surfaced this
93
+ // rule's own former tag-only copy as a false negative.
94
+ function hasSectioningAncestor(el, includeMain) {
95
+ return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
96
+ ? helpers.hasLandmarkScopingAncestor(el, { includeMain })
97
+ : false;
94
98
  }
95
99
 
96
100
  function getImplicitLandmarkRole(el) {
97
101
  const tag = el.tagName ? el.tagName.toLowerCase() : '';
98
- if (tag === 'header') return isSuppressedBySectioningAncestor(el) ? '' : 'banner';
99
- if (tag === 'footer') return isSuppressedBySectioningAncestor(el) ? '' : 'contentinfo';
102
+ if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
103
+ if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
100
104
  if (tag === 'main') return 'main';
101
105
  if (tag === 'nav') return 'navigation';
102
- if (tag === 'aside') return isSuppressedBySectioningAncestor(el) ? '' : 'complementary';
106
+ if (tag === 'aside') {
107
+ // A named <aside> is never suppressed, even when nested — matches
108
+ // landmark-unique's own verified-against-reference-engine precedent
109
+ // (that engine's real `aside` implicit-role function keeps
110
+ // "complementary" when the element has an accessible name, even
111
+ // inside sectioning content); propagated here for consistency.
112
+ if (!hasSectioningAncestor(el, false)) return 'complementary';
113
+ return getAccessibleLandmarkName(el) ? 'complementary' : '';
114
+ }
103
115
  if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
104
116
  if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
105
117
  return '';
@@ -115,9 +127,13 @@ function runInPage(ctx) {
115
127
  }
116
128
 
117
129
  function hasLandmarkAncestor(el) {
130
+ const scopeRoots = Array.isArray(root) ? root : (root ? [root] : []);
118
131
  let p = el.parentElement;
119
132
  while (p) {
120
133
  if (getLandmarkRole(p)) return true;
134
+ // Don't climb past the scanned scope -- see aria-helpers.js's
135
+ // hasLandmarkScopingAncestor for the same fix and rationale.
136
+ if (scopeRoots.includes(p)) break;
121
137
  p = p.parentElement;
122
138
  }
123
139
  return false;
@@ -43,7 +43,7 @@ const meta = {
43
43
  };
44
44
 
45
45
  function runInPage(ctx) {
46
- const { document, helpers, rule } = ctx;
46
+ const { document, root, helpers, rule } = ctx;
47
47
 
48
48
  function normalizeWs(s) {
49
49
  return String(s || '').replace(/\s+/g, ' ').trim();
@@ -70,25 +70,37 @@ function runInPage(ctx) {
70
70
  return raw.split(/\s+/)[0].toLowerCase();
71
71
  }
72
72
 
73
- const SECTIONING_ANCESTORS = new Set(['article', 'aside', 'main', 'nav', 'section']);
74
-
75
- function isSuppressedBySectioningAncestor(el) {
76
- let p = el.parentElement;
77
- while (p) {
78
- const tag = p.tagName ? p.tagName.toLowerCase() : '';
79
- if (SECTIONING_ANCESTORS.has(tag)) return true;
80
- p = p.parentElement;
81
- }
82
- return false;
73
+ // Delegates to the shared helpers.hasLandmarkScopingAncestor for the
74
+ // question "does this element sit inside a sectioning-content/<main>
75
+ // ancestor that suppresses its conditional implicit role" — role-aware
76
+ // (an ancestor's bare TAG only counts when it carries no role attribute
77
+ // at all; an explicit role="dialog"-style override no longer suppresses)
78
+ // rather than a local tag-only copy. See that function's header comment
79
+ // in src/core/aria-helpers.js for the full algorithm and the real page
80
+ // (handsontable.com's docs-assistant side panel, an
81
+ // <aside role="dialog"> containing its own <header>) that surfaced this
82
+ // rule's own former tag-only copy as a false negative.
83
+ function hasSectioningAncestor(el, includeMain) {
84
+ return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
85
+ ? helpers.hasLandmarkScopingAncestor(el, { includeMain })
86
+ : false;
83
87
  }
84
88
 
85
89
  function getImplicitLandmarkRole(el) {
86
90
  const tag = el.tagName ? el.tagName.toLowerCase() : '';
87
- if (tag === 'header') return isSuppressedBySectioningAncestor(el) ? '' : 'banner';
88
- if (tag === 'footer') return isSuppressedBySectioningAncestor(el) ? '' : 'contentinfo';
91
+ if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
92
+ if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
89
93
  if (tag === 'main') return 'main';
90
94
  if (tag === 'nav') return 'navigation';
91
- if (tag === 'aside') return isSuppressedBySectioningAncestor(el) ? '' : 'complementary';
95
+ if (tag === 'aside') {
96
+ // A named <aside> is never suppressed, even when nested — matches
97
+ // landmark-unique's own verified-against-reference-engine precedent
98
+ // (that engine's real `aside` implicit-role function keeps
99
+ // "complementary" when the element has an accessible name, even
100
+ // inside sectioning content); propagated here for consistency.
101
+ if (!hasSectioningAncestor(el, false)) return 'complementary';
102
+ return getAccessibleLandmarkName(el) ? 'complementary' : '';
103
+ }
92
104
  if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
93
105
  if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
94
106
  return '';
@@ -104,9 +116,13 @@ function runInPage(ctx) {
104
116
  }
105
117
 
106
118
  function hasLandmarkAncestor(el) {
119
+ const scopeRoots = Array.isArray(root) ? root : (root ? [root] : []);
107
120
  let p = el.parentElement;
108
121
  while (p) {
109
122
  if (getLandmarkRole(p)) return true;
123
+ // Don't climb past the scanned scope -- see aria-helpers.js's
124
+ // hasLandmarkScopingAncestor for the same fix and rationale.
125
+ if (scopeRoots.includes(p)) break;
110
126
  p = p.parentElement;
111
127
  }
112
128
  return false;
@@ -41,7 +41,7 @@ const meta = {
41
41
  };
42
42
 
43
43
  function runInPage(ctx) {
44
- const { document, helpers, rule } = ctx;
44
+ const { document, root, helpers, rule } = ctx;
45
45
 
46
46
  function normalizeWs(s) {
47
47
  return String(s || '').replace(/\s+/g, ' ').trim();
@@ -68,25 +68,37 @@ function runInPage(ctx) {
68
68
  return raw.split(/\s+/)[0].toLowerCase();
69
69
  }
70
70
 
71
- const SECTIONING_ANCESTORS = new Set(['article', 'aside', 'main', 'nav', 'section']);
72
-
73
- function isSuppressedBySectioningAncestor(el) {
74
- let p = el.parentElement;
75
- while (p) {
76
- const tag = p.tagName ? p.tagName.toLowerCase() : '';
77
- if (SECTIONING_ANCESTORS.has(tag)) return true;
78
- p = p.parentElement;
79
- }
80
- return false;
71
+ // Delegates to the shared helpers.hasLandmarkScopingAncestor for the
72
+ // question "does this element sit inside a sectioning-content/<main>
73
+ // ancestor that suppresses its conditional implicit role" — role-aware
74
+ // (an ancestor's bare TAG only counts when it carries no role attribute
75
+ // at all; an explicit role="dialog"-style override no longer suppresses)
76
+ // rather than a local tag-only copy. See that function's header comment
77
+ // in src/core/aria-helpers.js for the full algorithm and the real page
78
+ // (handsontable.com's docs-assistant side panel, an
79
+ // <aside role="dialog"> containing its own <header>) that surfaced this
80
+ // rule's own former tag-only copy as a false negative.
81
+ function hasSectioningAncestor(el, includeMain) {
82
+ return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
83
+ ? helpers.hasLandmarkScopingAncestor(el, { includeMain })
84
+ : false;
81
85
  }
82
86
 
83
87
  function getImplicitLandmarkRole(el) {
84
88
  const tag = el.tagName ? el.tagName.toLowerCase() : '';
85
- if (tag === 'header') return isSuppressedBySectioningAncestor(el) ? '' : 'banner';
86
- if (tag === 'footer') return isSuppressedBySectioningAncestor(el) ? '' : 'contentinfo';
89
+ if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
90
+ if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
87
91
  if (tag === 'main') return 'main';
88
92
  if (tag === 'nav') return 'navigation';
89
- if (tag === 'aside') return isSuppressedBySectioningAncestor(el) ? '' : 'complementary';
93
+ if (tag === 'aside') {
94
+ // A named <aside> is never suppressed, even when nested — matches
95
+ // landmark-unique's own verified-against-reference-engine precedent
96
+ // (that engine's real `aside` implicit-role function keeps
97
+ // "complementary" when the element has an accessible name, even
98
+ // inside sectioning content); propagated here for consistency.
99
+ if (!hasSectioningAncestor(el, false)) return 'complementary';
100
+ return getAccessibleLandmarkName(el) ? 'complementary' : '';
101
+ }
90
102
  if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
91
103
  if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
92
104
  return '';
@@ -102,9 +114,13 @@ function runInPage(ctx) {
102
114
  }
103
115
 
104
116
  function hasLandmarkAncestor(el) {
117
+ const scopeRoots = Array.isArray(root) ? root : (root ? [root] : []);
105
118
  let p = el.parentElement;
106
119
  while (p) {
107
120
  if (getLandmarkRole(p)) return true;
121
+ // Don't climb past the scanned scope -- see aria-helpers.js's
122
+ // hasLandmarkScopingAncestor for the same fix and rationale.
123
+ if (scopeRoots.includes(p)) break;
108
124
  p = p.parentElement;
109
125
  }
110
126
  return false;
@@ -79,25 +79,37 @@ function runInPage(ctx) {
79
79
  return raw.split(/\s+/)[0].toLowerCase();
80
80
  }
81
81
 
82
- const SECTIONING_ANCESTORS = new Set(['article', 'aside', 'main', 'nav', 'section']);
83
-
84
- function isSuppressedBySectioningAncestor(el) {
85
- let p = el.parentElement;
86
- while (p) {
87
- const tag = p.tagName ? p.tagName.toLowerCase() : '';
88
- if (SECTIONING_ANCESTORS.has(tag)) return true;
89
- p = p.parentElement;
90
- }
91
- return false;
82
+ // Delegates to the shared helpers.hasLandmarkScopingAncestor for the
83
+ // question "does this element sit inside a sectioning-content/<main>
84
+ // ancestor that suppresses its conditional implicit role" — role-aware
85
+ // (an ancestor's bare TAG only counts when it carries no role attribute
86
+ // at all; an explicit role="dialog"-style override no longer suppresses)
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 and the real page
89
+ // (handsontable.com's docs-assistant side panel, an
90
+ // <aside role="dialog"> containing its own <header>) that surfaced this
91
+ // rule's own former tag-only copy as a false negative.
92
+ function hasSectioningAncestor(el, includeMain) {
93
+ return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
94
+ ? helpers.hasLandmarkScopingAncestor(el, { includeMain })
95
+ : false;
92
96
  }
93
97
 
94
98
  function getImplicitLandmarkRole(el) {
95
99
  const tag = el.tagName ? el.tagName.toLowerCase() : '';
96
- if (tag === 'header') return isSuppressedBySectioningAncestor(el) ? '' : 'banner';
97
- if (tag === 'footer') return isSuppressedBySectioningAncestor(el) ? '' : 'contentinfo';
100
+ if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
101
+ if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
98
102
  if (tag === 'main') return 'main';
99
103
  if (tag === 'nav') return 'navigation';
100
- if (tag === 'aside') return isSuppressedBySectioningAncestor(el) ? '' : 'complementary';
104
+ if (tag === 'aside') {
105
+ // A named <aside> is never suppressed, even when nested — matches
106
+ // landmark-unique's own verified-against-reference-engine precedent
107
+ // (that engine's real `aside` implicit-role function keeps
108
+ // "complementary" when the element has an accessible name, even
109
+ // inside sectioning content); propagated here for consistency.
110
+ if (!hasSectioningAncestor(el, false)) return 'complementary';
111
+ return getAccessibleLandmarkName(el) ? 'complementary' : '';
112
+ }
101
113
  if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
102
114
  if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
103
115
  return '';