@surea11y/core 1.1.1 → 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 (113) hide show
  1. package/CHANGELOG.md +29 -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-allowed-attr.js +2 -3
  15. package/src/checks/automatic/aria-allowed-role.js +2 -3
  16. package/src/checks/automatic/aria-braille-equivalent.js +3 -4
  17. package/src/checks/automatic/aria-conditional-attr.js +3 -4
  18. package/src/checks/automatic/aria-deprecated-role.js +2 -3
  19. package/src/checks/automatic/aria-hidden-body.js +9 -1
  20. package/src/checks/automatic/aria-prohibited-attr.js +2 -3
  21. package/src/checks/automatic/aria-prohibited-children.js +73 -17
  22. package/src/checks/automatic/aria-required-attr.js +2 -3
  23. package/src/checks/automatic/aria-required-children.js +2 -3
  24. package/src/checks/automatic/aria-required-parent.js +3 -4
  25. package/src/checks/automatic/aria-roles-valid.js +2 -3
  26. package/src/checks/automatic/aria-valid-attr-value.js +2 -3
  27. package/src/checks/automatic/aria-valid-attr.js +2 -3
  28. package/src/checks/automatic/autocomplete-valid.js +2 -3
  29. package/src/checks/automatic/avoid-inline-spacing.js +2 -3
  30. package/src/checks/automatic/binary-control-name-present.js +2 -3
  31. package/src/checks/automatic/button-name-present.js +14 -4
  32. package/src/checks/automatic/bypass-blocks-present.js +44 -8
  33. package/src/checks/automatic/combobox-name-present.js +2 -3
  34. package/src/checks/automatic/css-orientation-lock.js +9 -1
  35. package/src/checks/automatic/definition-list-children-valid.js +2 -3
  36. package/src/checks/automatic/deprecated-elements-not-used.js +2 -3
  37. package/src/checks/automatic/dialog-name-present.js +2 -3
  38. package/src/checks/automatic/dlitem-parent-valid.js +2 -3
  39. package/src/checks/automatic/form-control-single-label.js +2 -3
  40. package/src/checks/automatic/html-xml-lang-mismatch.js +9 -1
  41. package/src/checks/automatic/iframe-focusable-content.js +2 -3
  42. package/src/checks/automatic/iframe-name-present.js +2 -3
  43. package/src/checks/automatic/iframe-title-unique.js +2 -3
  44. package/src/checks/automatic/label-in-name.js +7 -8
  45. package/src/checks/automatic/language-page-present.js +9 -1
  46. package/src/checks/automatic/link-in-text-block.js +2 -3
  47. package/src/checks/automatic/link-name-present.js +16 -4
  48. package/src/checks/automatic/list-children-valid.js +2 -3
  49. package/src/checks/automatic/listbox-name-present.js +2 -3
  50. package/src/checks/automatic/listitem-parent-valid.js +2 -3
  51. package/src/checks/automatic/menuitem-name-present.js +2 -3
  52. package/src/checks/automatic/meta-refresh-no-exceptions.js +9 -1
  53. package/src/checks/automatic/meta-refresh-timing-absent.js +9 -1
  54. package/src/checks/automatic/meta-viewport-zoom-enabled.js +9 -1
  55. package/src/checks/automatic/meter-name-present.js +2 -3
  56. package/src/checks/automatic/nested-interactive-controls-absent.js +2 -3
  57. package/src/checks/automatic/option-name-present.js +2 -3
  58. package/src/checks/automatic/page-title-present.js +9 -1
  59. package/src/checks/automatic/progressbar-name-present.js +2 -3
  60. package/src/checks/automatic/searchbox-name-present.js +2 -3
  61. package/src/checks/automatic/server-side-image-map-absent.js +2 -3
  62. package/src/checks/automatic/slider-name-present.js +2 -3
  63. package/src/checks/automatic/spinbutton-name-present.js +2 -3
  64. package/src/checks/automatic/summary-name-present.js +2 -3
  65. package/src/checks/automatic/tab-name-present.js +2 -3
  66. package/src/checks/automatic/table-headers-attr-valid.js +2 -3
  67. package/src/checks/automatic/table-th-has-data-cells.js +2 -3
  68. package/src/checks/automatic/td-has-header.js +2 -3
  69. package/src/checks/automatic/textbox-name-present.js +2 -3
  70. package/src/checks/automatic/tooltip-name-present.js +2 -3
  71. package/src/checks/automatic/treeitem-name-present.js +2 -3
  72. package/src/checks/automatic/valid-lang.js +2 -3
  73. package/src/checks/manual/accesskeys-manual.js +1 -7
  74. package/src/checks/manual/aria-checked-state-mismatch-manual.js +3 -4
  75. package/src/checks/manual/aria-text-manual.js +2 -3
  76. package/src/checks/manual/empty-heading-manual.js +1 -6
  77. package/src/checks/manual/empty-table-header-manual.js +5 -9
  78. package/src/checks/manual/focus-order-semantics-manual.js +2 -3
  79. package/src/checks/manual/heading-order-manual.js +1 -6
  80. package/src/checks/manual/identical-links-same-purpose-manual.js +2 -3
  81. package/src/checks/manual/image-redundant-alt-manual.js +2 -3
  82. package/src/checks/manual/label-title-only-manual.js +2 -3
  83. package/src/checks/manual/landmark-banner-is-top-level-manual.js +30 -14
  84. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +30 -14
  85. package/src/checks/manual/landmark-main-is-top-level-manual.js +30 -14
  86. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +25 -13
  87. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +25 -13
  88. package/src/checks/manual/landmark-one-main-manual.js +9 -1
  89. package/src/checks/manual/landmark-unique-manual.js +32 -30
  90. package/src/checks/manual/link-name-quality-manual.js +2 -3
  91. package/src/checks/manual/meta-viewport-large-manual.js +9 -1
  92. package/src/checks/manual/mouse-only-event-handlers-manual.js +2 -3
  93. package/src/checks/manual/no-autoplay-audio-manual.js +2 -3
  94. package/src/checks/manual/p-as-heading-manual.js +2 -3
  95. package/src/checks/manual/page-has-heading-one-manual.js +40 -3
  96. package/src/checks/manual/page-title-patterns-manual.js +9 -1
  97. package/src/checks/manual/presentation-role-conflict-manual.js +3 -4
  98. package/src/checks/manual/region-manual.js +34 -14
  99. package/src/checks/manual/scope-attr-valid-manual.js +2 -7
  100. package/src/checks/manual/scrollable-region-focusable-manual.js +2 -3
  101. package/src/checks/manual/skip-link-manual.js +91 -9
  102. package/src/checks/manual/tabindex-manual.js +2 -7
  103. package/src/checks/manual/table-duplicate-name-manual.js +2 -3
  104. package/src/checks/manual/table-fake-caption-manual.js +2 -3
  105. package/src/checks/manual/video-caption-manual.js +2 -3
  106. package/src/core/aria-helpers.js +82 -18
  107. package/src/core/dom-helpers.js +88 -3
  108. package/src/core/dom-runner.js +23 -0
  109. package/src/core/rule-meta.js +19 -0
  110. package/src/core.js +2632 -885
  111. package/src/i18n/en.js +6 -2
  112. package/src/i18n/fr.js +6 -2
  113. package/src/report.js +482 -0
@@ -75,46 +75,48 @@ function runInPage(ctx) {
75
75
  return raw.split(/\s+/)[0].toLowerCase();
76
76
  }
77
77
 
78
- // Two distinct ancestor sets, verified 2026-07-20 against a widely-used
79
- // reference engine's own implicit-role functions directly rather than
80
- // assumed from one shared list: <header>/<footer> use "sectioning content
81
- // PLUS <main>" (that engine's getSectioningContentPlusMainSelector) to decide
82
- // banner/contentinfo suppression, but <aside> uses PLAIN sectioning
83
- // content only (article/aside/nav/section — NOT main) to decide
84
- // complementary suppression. The old single SECTIONING_ANCESTORS set
85
- // (which included 'main') was correct for header/footer but wrong for
86
- // aside — found via a real page: Know Your Meme's two unnamed
87
- // <aside class="extra-large-only"> elements are direct children of
88
- // <main>, which incorrectly suppressed their implicit "complementary"
89
- // role entirely, hiding a real duplicate-landmark violation that
90
- // reference engine correctly flags.
91
- const SECTIONING_ANCESTORS_PLUS_MAIN = new Set(['article', 'aside', 'main', 'nav', 'section']);
92
- const SECTIONING_ANCESTORS = new Set(['article', 'aside', 'nav', 'section']);
93
-
94
- function hasSectioningAncestorFrom(el, set) {
95
- let p = el.parentElement;
96
- while (p) {
97
- const tag = p.tagName ? p.tagName.toLowerCase() : '';
98
- if (set.has(tag)) return true;
99
- p = p.parentElement;
100
- }
101
- return false;
78
+ // Delegates to the shared helpers.hasLandmarkScopingAncestor (role-aware:
79
+ // an ancestor's bare TAG only counts when it carries no role attribute at
80
+ // all; an explicit role="dialog"-style override no longer suppresses —
81
+ // see that function's header comment in src/core/aria-helpers.js) rather
82
+ // than the two local tag-only Sets this file used to carry. Two distinct
83
+ // ancestor scopes, verified 2026-07-20 against a widely-used reference
84
+ // engine's own implicit-role functions directly rather than assumed from
85
+ // one shared list: <header>/<footer> use "sectioning content PLUS <main>"
86
+ // (includeMain: true) to decide banner/contentinfo suppression, but
87
+ // <aside> uses PLAIN sectioning content only — NOT main (includeMain:
88
+ // false) — to decide complementary suppression. The old single
89
+ // SECTIONING_ANCESTORS set (which included 'main') was correct for
90
+ // header/footer but wrong for aside — found via a real page: Know Your
91
+ // Meme's two unnamed <aside class="extra-large-only"> elements are direct
92
+ // children of <main>, which incorrectly suppressed their implicit
93
+ // "complementary" role entirely, hiding a real duplicate-landmark
94
+ // violation that reference engine correctly flags. The tag-only
95
+ // (non-role-aware) half of this bug was separately found and fixed
96
+ // 2026-07-30 via the cross-engine comparisons project, on
97
+ // handsontable.com's docs-assistant side panel: an <aside role="dialog">
98
+ // containing its own <header> — role="dialog" isn't one of the four
99
+ // scoping roles, so the nested <header> keeps "banner" per spec, but a
100
+ // tag-only check unconditionally suppressed it just because the ancestor
101
+ // TAG was <aside>.
102
+ function hasSectioningAncestor(el, includeMain) {
103
+ return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
104
+ ? helpers.hasLandmarkScopingAncestor(el, { includeMain })
105
+ : false;
102
106
  }
103
107
 
104
108
  function getImplicitLandmarkRole(el) {
105
109
  const tag = el.tagName ? el.tagName.toLowerCase() : '';
106
- if (tag === 'header') return hasSectioningAncestorFrom(el, SECTIONING_ANCESTORS_PLUS_MAIN) ? '' : 'banner';
107
- if (tag === 'footer') return hasSectioningAncestorFrom(el, SECTIONING_ANCESTORS_PLUS_MAIN) ? '' : 'contentinfo';
110
+ if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
111
+ if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
108
112
  if (tag === 'main') return 'main';
109
113
  if (tag === 'nav') return 'navigation';
110
114
  if (tag === 'aside') {
111
115
  // Per a widely-used reference engine's own `aside` implicit-role function: suppressed by a
112
116
  // sectioning-content ancestor ONLY when the <aside> also has no
113
117
  // accessible name — a named <aside> is never suppressed, even when
114
- // nested. Not yet evidenced by a real page in this corpus, but
115
- // implemented to match the verified source exactly rather than
116
- // leaving a known partial fix in place.
117
- if (!hasSectioningAncestorFrom(el, SECTIONING_ANCESTORS)) return 'complementary';
118
+ // nested.
119
+ if (!hasSectioningAncestor(el, false)) return 'complementary';
118
120
  return getAccessibleLandmarkName(el) ? 'complementary' : '';
119
121
  }
120
122
  if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
@@ -60,8 +60,7 @@ const meta = {
60
60
  };
61
61
 
62
62
  function runInPage(ctx) {
63
- const { document, root, helpers, rule } = ctx;
64
- const safeRoot = root || document;
63
+ const { document, helpers, rule } = ctx;
65
64
 
66
65
  const GENERIC_LINK_TEXT = new Set([
67
66
  'click here', 'here', 'click', 'more', 'more info', 'more information',
@@ -80,7 +79,7 @@ function runInPage(ctx) {
80
79
  }
81
80
 
82
81
  const selector = 'a[href]';
83
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
82
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
84
83
 
85
84
  const occurrences = [];
86
85
  let applicableCount = 0;
@@ -43,6 +43,14 @@ const meta = {
43
43
  coverage: {}
44
44
  };
45
45
 
46
+ // This check is inherently whole-document (does the PAGE have this
47
+ // property?), not evaluable per-subtree -- notApplicable when contextSelector
48
+ // scoped this run narrower than the whole document, or when
49
+ // engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
50
+ function applicability(ctx) {
51
+ return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
52
+ }
53
+
46
54
  function runInPage(ctx) {
47
55
  const { document, helpers, rule } = ctx;
48
56
 
@@ -116,4 +124,4 @@ function runInPage(ctx) {
116
124
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
117
125
  }
118
126
 
119
- module.exports = { id, meta, runInPage };
127
+ module.exports = { id, meta, runInPage, applicability };
@@ -67,8 +67,7 @@ const meta = {
67
67
  };
68
68
 
69
69
  function runInPage(ctx) {
70
- const { document, root, helpers, rule } = ctx;
71
- const safeRoot = root || document;
70
+ const { document, helpers, rule } = ctx;
72
71
 
73
72
  const MOUSE_ONLY_ATTRS = [
74
73
  'onmouseover', 'onmouseout', 'onmousedown', 'onmouseup',
@@ -79,7 +78,7 @@ function runInPage(ctx) {
79
78
  function trim(v) { return (v == null ? '' : String(v)).trim(); }
80
79
 
81
80
  const selector = MOUSE_ONLY_ATTRS.map((a) => `[${a}]`).join(', ');
82
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
81
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
83
82
 
84
83
  const occurrences = [];
85
84
  let applicableCount = 0;
@@ -58,10 +58,9 @@ const meta = {
58
58
  };
59
59
 
60
60
  function runInPage(ctx) {
61
- const { document, root, helpers, rule } = ctx;
62
- const safeRoot = root || document;
61
+ const { document, helpers, rule } = ctx;
63
62
 
64
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('audio[autoplay], video[autoplay]', safeRoot) : helpers.queryAll('audio[autoplay], video[autoplay]', safeRoot);
63
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('audio[autoplay], video[autoplay]') : helpers.queryAll('audio[autoplay], video[autoplay]');
65
64
 
66
65
  const occurrences = [];
67
66
  let applicableCount = 0;
@@ -53,8 +53,7 @@ const meta = {
53
53
  };
54
54
 
55
55
  function runInPage(ctx) {
56
- const { document, root, helpers, rule } = ctx;
57
- const safeRoot = root || document;
56
+ const { document, helpers, rule } = ctx;
58
57
 
59
58
  const MAX_HEADING_LIKE_CHARS = 120;
60
59
  const MIN_FONT_SIZE_PX = 18;
@@ -106,7 +105,7 @@ function runInPage(ctx) {
106
105
  return Number.isFinite(px) ? px : 0;
107
106
  }
108
107
 
109
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('p', safeRoot) : helpers.queryAll('p', safeRoot);
108
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('p') : helpers.queryAll('p');
110
109
 
111
110
  const occurrences = [];
112
111
  let applicableCount = 0;
@@ -19,6 +19,18 @@
19
19
  * - Not WCAG-normative — authored as an advisory, cantTell-capped
20
20
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
21
21
  * header comment for the shared rationale/precedent.
22
+ * - Filters candidates through `isAccTreeEligible` (hidden/aria-hidden/
23
+ * display:none/inert elements don't count as "the page has a heading
24
+ * one"), matching `landmark-one-main`'s own precedent. Found via a real
25
+ * page (CDC's flu page, 2026-07-30): its only `<h1>` sits inside a
26
+ * `display:none` ancestor — genuinely unreachable by sighted and screen
27
+ * reader users alike — and a raw `document.querySelectorAll` credited it
28
+ * anyway, reporting `notApplicable` where axe-core correctly fails. This
29
+ * does NOT regress purely-visually-clipped-but-AT-exposed headings (e.g.
30
+ * eBay's homepage `<h1>` hidden via clip-path/off-screen positioning,
31
+ * `visibility:visible`, no `aria-hidden`) — `isAccTreeEligible` only
32
+ * excludes elements actually removed from the accessibility tree, not
33
+ * ones merely clipped from the visual viewport.
22
34
  */
23
35
 
24
36
  const id = 'page-has-heading-one';
@@ -41,6 +53,14 @@ const meta = {
41
53
  coverage: {}
42
54
  };
43
55
 
56
+ // This check is inherently whole-document (does the PAGE have this
57
+ // property?), not evaluable per-subtree -- notApplicable when contextSelector
58
+ // scoped this run narrower than the whole document, or when
59
+ // engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
60
+ function applicability(ctx) {
61
+ return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
62
+ }
63
+
44
64
  function runInPage(ctx) {
45
65
  const { document, helpers, rule } = ctx;
46
66
 
@@ -70,14 +90,31 @@ function runInPage(ctx) {
70
90
  return tag === 'h1';
71
91
  }
72
92
 
93
+ const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
94
+
95
+ function isExposedToAt(el) {
96
+ if (!isAccTreeEligible) return true;
97
+ try {
98
+ const r = isAccTreeEligible(el, ctx);
99
+ if (typeof r === 'boolean') return r;
100
+ return !!(r && r.eligible);
101
+ } catch {
102
+ return true;
103
+ }
104
+ }
105
+
106
+ // queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
107
+ // landmark-one-main-manual.js's identical precedent.
73
108
  let nodes = [];
74
109
  try {
75
- nodes = document.querySelectorAll('h1, [role]');
110
+ nodes = helpers && typeof helpers.queryAllSmart === 'function'
111
+ ? helpers.queryAllSmart('h1, [role]')
112
+ : document.querySelectorAll('h1, [role]');
76
113
  } catch {
77
114
  nodes = [];
78
115
  }
79
116
 
80
- const hasH1 = Array.from(nodes).some((el) => el && isLevelOneHeading(el));
117
+ const hasH1 = Array.from(nodes).some((el) => el && isLevelOneHeading(el) && isExposedToAt(el));
81
118
 
82
119
  if (hasH1) {
83
120
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
@@ -107,4 +144,4 @@ function runInPage(ctx) {
107
144
  };
108
145
  }
109
146
 
110
- module.exports = { id, meta, runInPage };
147
+ module.exports = { id, meta, runInPage, applicability };
@@ -24,6 +24,14 @@ const meta = {
24
24
  coverage: { facetsBySc: { '2.4.2': ['page-title-patterns'] } }
25
25
  };
26
26
 
27
+ // This check is inherently whole-document (does the PAGE have this
28
+ // property?), not evaluable per-subtree -- notApplicable when contextSelector
29
+ // scoped this run narrower than the whole document, or when
30
+ // engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
31
+ function applicability(ctx) {
32
+ return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
33
+ }
34
+
27
35
  function runInPage(ctx) {
28
36
  const { document, helpers, rule } = ctx;
29
37
  const probes = ctx && ctx.inputs && ctx.inputs.probes && typeof ctx.inputs.probes === 'object'
@@ -259,4 +267,4 @@ function runInPage(ctx) {
259
267
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
260
268
  }
261
269
 
262
- module.exports = { id, meta, runInPage };
270
+ module.exports = { id, meta, runInPage, applicability };
@@ -73,8 +73,7 @@ const meta = {
73
73
  };
74
74
 
75
75
  function runInPage(ctx) {
76
- const { document, root, helpers, rule } = ctx;
77
- const safeRoot = root || document;
76
+ const { document, helpers, rule } = ctx;
78
77
 
79
78
  // The full set of ARIA attributes marked `global: true` per the WAI-ARIA
80
79
  // spec (confirmed against a widely-used reference engine's own
@@ -91,8 +90,8 @@ function runInPage(ctx) {
91
90
  const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
92
91
 
93
92
  const nodes = helpers.queryAllSmart
94
- ? helpers.queryAllSmart('[role="presentation"], [role="none"], img[alt=""]', safeRoot)
95
- : helpers.queryAll('[role="presentation"], [role="none"], img[alt=""]', safeRoot);
93
+ ? helpers.queryAllSmart('[role="presentation"], [role="none"], img[alt=""]')
94
+ : helpers.queryAll('[role="presentation"], [role="none"], img[alt=""]');
96
95
 
97
96
  const occurrences = [];
98
97
  let applicableCount = 0;
@@ -46,6 +46,14 @@ const meta = {
46
46
  coverage: {}
47
47
  };
48
48
 
49
+ // This check is inherently whole-document (does the PAGE have this
50
+ // property?), not evaluable per-subtree -- notApplicable when contextSelector
51
+ // scoped this run narrower than the whole document, or when
52
+ // engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
53
+ function applicability(ctx) {
54
+ return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
55
+ }
56
+
49
57
  function runInPage(ctx) {
50
58
  const { document, helpers, rule } = ctx;
51
59
 
@@ -79,25 +87,37 @@ function runInPage(ctx) {
79
87
  return raw.split(/\s+/)[0].toLowerCase();
80
88
  }
81
89
 
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;
90
+ // Delegates to the shared helpers.hasLandmarkScopingAncestor for the
91
+ // question "does this element sit inside a sectioning-content/<main>
92
+ // ancestor that suppresses its conditional implicit role" — role-aware
93
+ // (an ancestor's bare TAG only counts when it carries no role attribute
94
+ // at all; an explicit role="dialog"-style override no longer suppresses)
95
+ // rather than a local tag-only copy. See that function's header comment
96
+ // in src/core/aria-helpers.js for the full algorithm and the real page
97
+ // (handsontable.com's docs-assistant side panel, an
98
+ // <aside role="dialog"> containing its own <header>) that surfaced this
99
+ // rule's own former tag-only copy as a false negative.
100
+ function hasSectioningAncestor(el, includeMain) {
101
+ return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
102
+ ? helpers.hasLandmarkScopingAncestor(el, { includeMain })
103
+ : false;
92
104
  }
93
105
 
94
106
  function getImplicitLandmarkRole(el) {
95
107
  const tag = el.tagName ? el.tagName.toLowerCase() : '';
96
- if (tag === 'header') return isSuppressedBySectioningAncestor(el) ? '' : 'banner';
97
- if (tag === 'footer') return isSuppressedBySectioningAncestor(el) ? '' : 'contentinfo';
108
+ if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
109
+ if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
98
110
  if (tag === 'main') return 'main';
99
111
  if (tag === 'nav') return 'navigation';
100
- if (tag === 'aside') return isSuppressedBySectioningAncestor(el) ? '' : 'complementary';
112
+ if (tag === 'aside') {
113
+ // A named <aside> is never suppressed, even when nested — matches
114
+ // landmark-unique's own verified-against-reference-engine precedent
115
+ // (that engine's real `aside` implicit-role function keeps
116
+ // "complementary" when the element has an accessible name, even
117
+ // inside sectioning content); propagated here for consistency.
118
+ if (!hasSectioningAncestor(el, false)) return 'complementary';
119
+ return getAccessibleLandmarkName(el) ? 'complementary' : '';
120
+ }
101
121
  if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
102
122
  if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
103
123
  return '';
@@ -180,4 +200,4 @@ function runInPage(ctx) {
180
200
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
181
201
  }
182
202
 
183
- module.exports = { id, meta, runInPage };
203
+ module.exports = { id, meta, runInPage, applicability };
@@ -39,16 +39,11 @@ const meta = {
39
39
  };
40
40
 
41
41
  function runInPage(ctx) {
42
- const { document, helpers, rule } = ctx;
42
+ const { helpers, rule } = ctx;
43
43
 
44
44
  const VALID_SCOPES = new Set(['row', 'col', 'rowgroup', 'colgroup']);
45
45
 
46
- let nodes = [];
47
- try {
48
- nodes = document.querySelectorAll('[scope]');
49
- } catch {
50
- nodes = [];
51
- }
46
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[scope]') : helpers.queryAll('[scope]');
52
47
 
53
48
  const occurrences = [];
54
49
  let applicableCount = 0;
@@ -64,8 +64,7 @@ const meta = {
64
64
  };
65
65
 
66
66
  function runInPage(ctx) {
67
- const { document, root, helpers, rule } = ctx;
68
- const safeRoot = root || document;
67
+ const { document, helpers, rule } = ctx;
69
68
 
70
69
  function safeComputedStyle(el) {
71
70
  try {
@@ -110,7 +109,7 @@ function runInPage(ctx) {
110
109
  }
111
110
 
112
111
  const CANDIDATE_SELECTOR = 'div, section, article, aside, main, nav, pre, table, blockquote, ul, ol, textarea';
113
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(CANDIDATE_SELECTOR, safeRoot) : helpers.queryAll(CANDIDATE_SELECTOR, safeRoot);
112
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(CANDIDATE_SELECTOR) : helpers.queryAll(CANDIDATE_SELECTOR);
114
113
 
115
114
  const occurrences = [];
116
115
  let applicableCount = 0;
@@ -3,7 +3,7 @@
3
3
  /**
4
4
  * @check skip-link
5
5
  * @atomic true
6
- * @summary A "skip" link must resolve to a real, focusable target
6
+ * @summary A "skip" link must resolve to a real, usable target
7
7
  * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
8
  * @applicability
9
9
  * Applies to <a href="#fragment"> elements whose accessible name
@@ -18,8 +18,11 @@
18
18
  * implementation-notes) — this only widens the known-convention list.
19
19
  * @expectation
20
20
  * The link's fragment resolves to a real element in the document
21
- * (via a matching id, or a legacy <a name="...">). A skip link whose
22
- * target does not exist silently does nothing when activated.
21
+ * (via a matching id, or a legacy <a name="...">), and that target is
22
+ * currently usable (not hidden from the accessibility tree; and, when
23
+ * browser geometry is available, not zero-area/no-rects). A skip link
24
+ * whose target is missing or effectively unusable does not provide a
25
+ * reliable bypass destination.
23
26
  * @implementation-notes
24
27
  * - Not WCAG-normative — authored as an advisory, cantTell-capped
25
28
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
@@ -33,8 +36,8 @@
33
36
  const id = 'skip-link';
34
37
 
35
38
  const meta = {
36
- title: 'Skip link must have a resolvable target',
37
- description: 'Checks that a "skip to ..." link\'s href fragment resolves to a real element in the document.',
39
+ title: 'Skip link must have a resolvable, usable target',
40
+ description: 'Checks that a "skip to ..." link\'s href fragment resolves to a real, currently usable element in the document.',
38
41
  i18n: {
39
42
  titleKey: 'skipLink_title',
40
43
  descriptionKey: 'skipLink_description'
@@ -51,8 +54,7 @@ const meta = {
51
54
  };
52
55
 
53
56
  function runInPage(ctx) {
54
- const { document, root, helpers, rule } = ctx;
55
- const safeRoot = root || document;
57
+ const { document, helpers, rule } = ctx;
56
58
 
57
59
  function normalizeWs(s) {
58
60
  return String(s || '').replace(/\s+/g, ' ').trim();
@@ -79,7 +81,31 @@ function runInPage(ctx) {
79
81
  return normalizeWs(el.textContent);
80
82
  }
81
83
 
82
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('a[href]', safeRoot) : helpers.queryAll('a[href]', safeRoot);
84
+ function hasReliableGeometrySupport() {
85
+ const probe = document.documentElement || document.body || null;
86
+ if (!probe || !probe.getClientRects || !probe.getBoundingClientRect) return false;
87
+ try {
88
+ const rects = probe.getClientRects();
89
+ const rectCount = rects ? rects.length : 0;
90
+ const r = probe.getBoundingClientRect();
91
+ const w = r && Number.isFinite(r.width) ? r.width : 0;
92
+ const h = r && Number.isFinite(r.height) ? r.height : 0;
93
+ return rectCount > 0 && (w > 0 || h > 0);
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+
99
+ function toEligibility(info) {
100
+ return {
101
+ eligible: !!(info && info.eligible),
102
+ reasons: info && Array.isArray(info.reasons) ? info.reasons.slice(0) : []
103
+ };
104
+ }
105
+
106
+ const geometrySupported = hasReliableGeometrySupport();
107
+
108
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('a[href]') : helpers.queryAll('a[href]');
83
109
 
84
110
  const occurrences = [];
85
111
  let applicableCount = 0;
@@ -117,7 +143,63 @@ function runInPage(ctx) {
117
143
  }
118
144
  }
119
145
 
120
- if (target) continue;
146
+ if (target) {
147
+ const accEligibility = toEligibility(
148
+ helpers.getEligibilityInfo
149
+ ? helpers.getEligibilityInfo(target, ctx, { targetSet: 'acc' })
150
+ : (helpers.isAccTreeEligible ? helpers.isAccTreeEligible(target, ctx) : { eligible: true, reasons: [] })
151
+ );
152
+
153
+ let geometryEligibility = null;
154
+ let geometryReasonCode = null;
155
+ if (geometrySupported && helpers.isDomVisibleEligible) {
156
+ geometryEligibility = toEligibility(
157
+ helpers.isDomVisibleEligible(target, ctx, { visibilityMode: 'styleAndGeometry', ignoreOpacity: true })
158
+ );
159
+ if (!geometryEligibility.eligible && geometryEligibility.reasons.includes('noClientRects')) {
160
+ geometryReasonCode = 'NO_CLIENT_RECTS';
161
+ } else if (!geometryEligibility.eligible && geometryEligibility.reasons.includes('zeroArea')) {
162
+ geometryReasonCode = 'ZERO_AREA_TARGET';
163
+ }
164
+ }
165
+
166
+ const unusableByAcc = !accEligibility.eligible;
167
+ const unusableByGeometry = !!geometryReasonCode;
168
+ if (!unusableByAcc && !unusableByGeometry) continue;
169
+
170
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
171
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
172
+
173
+ occurrences.push({
174
+ selector: stableSelector,
175
+ html,
176
+ summary: 'This skip link points to a target that exists but is not currently usable.',
177
+ hint: 'Point this skip link to a target that is exposed and usable as a navigation destination.',
178
+ i18n: {
179
+ summaryKey: 'skipLink_summary_unusableTarget_cantTell',
180
+ hintKey: 'skipLink_hint_unusableTarget_cantTell',
181
+ params: { href }
182
+ },
183
+ data: {
184
+ details: {
185
+ reasonCode: 'SKIP_LINK_TARGET_UNUSABLE',
186
+ href,
187
+ unusableReasonCode: unusableByAcc ? 'ACC_TREE_INELIGIBLE' : geometryReasonCode,
188
+ targetSelector: helpers.buildSelector ? helpers.buildSelector(target) : null,
189
+ geometryCheckEnabled: geometrySupported
190
+ },
191
+ visibilityFilter: {
192
+ targetSet: 'acc',
193
+ accEligible: accEligibility.eligible,
194
+ reasons: accEligibility.reasons
195
+ },
196
+ targetGeometry: geometryEligibility
197
+ ? { eligible: geometryEligibility.eligible, reasons: geometryEligibility.reasons }
198
+ : { eligible: null, reasons: [] }
199
+ }
200
+ });
201
+ continue;
202
+ }
121
203
 
122
204
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
123
205
  const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
@@ -40,14 +40,9 @@ const meta = {
40
40
  };
41
41
 
42
42
  function runInPage(ctx) {
43
- const { document, helpers, rule } = ctx;
43
+ const { helpers, rule } = ctx;
44
44
 
45
- let nodes = [];
46
- try {
47
- nodes = document.querySelectorAll('[tabindex]');
48
- } catch {
49
- nodes = [];
50
- }
45
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[tabindex]') : helpers.queryAll('[tabindex]');
51
46
 
52
47
  const occurrences = [];
53
48
  let applicableCount = 0;
@@ -43,14 +43,13 @@ const meta = {
43
43
  };
44
44
 
45
45
  function runInPage(ctx) {
46
- const { document, root, helpers, rule } = ctx;
47
- const safeRoot = root || document;
46
+ const { document, helpers, rule } = ctx;
48
47
 
49
48
  function normalizeWs(s) {
50
49
  return String(s || '').replace(/\s+/g, ' ').trim();
51
50
  }
52
51
 
53
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('table[summary]', safeRoot) : helpers.queryAll('table[summary]', safeRoot);
52
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('table[summary]') : helpers.queryAll('table[summary]');
54
53
 
55
54
  const occurrences = [];
56
55
  let applicableCount = 0;
@@ -51,12 +51,11 @@ const meta = {
51
51
  };
52
52
 
53
53
  function runInPage(ctx) {
54
- const { document, root, helpers, rule } = ctx;
55
- const safeRoot = root || document;
54
+ const { document, helpers, rule } = ctx;
56
55
 
57
56
  function trim(v) { return (v == null ? '' : String(v)).trim(); }
58
57
 
59
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('table', safeRoot) : helpers.queryAll('table', safeRoot);
58
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('table') : helpers.queryAll('table');
60
59
 
61
60
  const occurrences = [];
62
61
  let applicableCount = 0;
@@ -51,10 +51,9 @@ const meta = {
51
51
  };
52
52
 
53
53
  function runInPage(ctx) {
54
- const { document, root, helpers, rule } = ctx;
55
- const safeRoot = root || document;
54
+ const { document, helpers, rule } = ctx;
56
55
 
57
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('video', safeRoot) : helpers.queryAll('video', safeRoot);
56
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('video') : helpers.queryAll('video');
58
57
 
59
58
  const occurrences = [];
60
59
  let applicableCount = 0;