@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
@@ -23,11 +23,10 @@ const meta = {
23
23
  };
24
24
 
25
25
  function runInPage(ctx) {
26
- const { document, root, helpers, rule } = ctx;
26
+ const { document, helpers, rule } = ctx;
27
27
  const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
28
28
  ? helpers.getEligibilityInfo
29
29
  : null;
30
- const safeRoot = root || document;
31
30
 
32
31
 
33
32
  function normalizeWs(s) {
@@ -91,7 +90,7 @@ function runInPage(ctx) {
91
90
  let applicableCount = 0;
92
91
 
93
92
  const selector = '[role="tooltip"]';
94
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
93
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
95
94
 
96
95
 
97
96
  function evaluate(el) {
@@ -23,11 +23,10 @@ const meta = {
23
23
  };
24
24
 
25
25
  function runInPage(ctx) {
26
- const { document, root, helpers, rule } = ctx;
26
+ const { document, helpers, rule } = ctx;
27
27
  const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
28
28
  ? helpers.getEligibilityInfo
29
29
  : null;
30
- const safeRoot = root || document;
31
30
 
32
31
 
33
32
  function normalizeWs(s) {
@@ -91,7 +90,7 @@ function runInPage(ctx) {
91
90
  let applicableCount = 0;
92
91
 
93
92
  const selector = "[role=\"treeitem\"]";
94
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
93
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
95
94
 
96
95
 
97
96
  function hasName(el) {
@@ -47,12 +47,11 @@ const meta = {
47
47
  };
48
48
 
49
49
  function runInPage(ctx) {
50
- const { document, root, helpers, rule } = ctx;
51
- const safeRoot = root || document;
50
+ const { document, helpers, rule } = ctx;
52
51
 
53
52
  const BCP47_RE = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/;
54
53
 
55
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[lang]', safeRoot) : helpers.queryAll('[lang]', safeRoot);
54
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[lang]') : helpers.queryAll('[lang]');
56
55
 
57
56
  const occurrences = [];
58
57
  let applicableCount = 0;
@@ -41,13 +41,7 @@ const meta = {
41
41
 
42
42
  function runInPage(ctx) {
43
43
  const { document, helpers, rule } = ctx;
44
-
45
- let nodes = [];
46
- try {
47
- nodes = document.querySelectorAll('[accesskey]');
48
- } catch {
49
- nodes = [];
50
- }
44
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[accesskey]') : helpers.queryAll('[accesskey]');
51
45
 
52
46
  const groups = new Map(); // normalized key -> elements[]
53
47
  for (const el of nodes) {
@@ -71,14 +71,13 @@ const meta = {
71
71
  };
72
72
 
73
73
  function runInPage(ctx) {
74
- const { document, root, helpers, rule } = ctx;
75
- const safeRoot = root || document;
74
+ const { document, helpers, rule } = ctx;
76
75
 
77
76
  function trim(v) { return (v == null ? '' : String(v)).trim(); }
78
77
 
79
78
  const nodes = helpers.queryAllSmart
80
- ? helpers.queryAllSmart('input[type="checkbox"][aria-checked], input[type="radio"][aria-checked]', safeRoot)
81
- : helpers.queryAll('input[type="checkbox"][aria-checked], input[type="radio"][aria-checked]', safeRoot);
79
+ ? helpers.queryAllSmart('input[type="checkbox"][aria-checked], input[type="radio"][aria-checked]')
80
+ : helpers.queryAll('input[type="checkbox"][aria-checked], input[type="radio"][aria-checked]');
82
81
 
83
82
  const occurrences = [];
84
83
  let applicableCount = 0;
@@ -46,13 +46,12 @@ const meta = {
46
46
  };
47
47
 
48
48
  function runInPage(ctx) {
49
- const { document, root, helpers, rule } = ctx;
50
- const safeRoot = root || document;
49
+ const { document, helpers, rule } = ctx;
51
50
 
52
51
  const FOCUSABLE_DESCENDANT_SELECTOR =
53
52
  'a[href], button, input, select, textarea, [tabindex], iframe, [contenteditable]:not([contenteditable="false"])';
54
53
 
55
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role="text"]', safeRoot) : helpers.queryAll('[role="text"]', safeRoot);
54
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role="text"]') : helpers.queryAll('[role="text"]');
56
55
 
57
56
  const occurrences = [];
58
57
  let applicableCount = 0;
@@ -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;
@@ -48,8 +48,7 @@ const meta = {
48
48
  };
49
49
 
50
50
  function runInPage(ctx) {
51
- const { document, root, helpers, rule } = ctx;
52
- const safeRoot = root || document;
51
+ const { document, helpers, rule } = ctx;
53
52
 
54
53
  const NON_INTERACTIVE_ROLES = new Set([
55
54
  'generic', 'group', 'text', 'presentation', 'none', 'img', 'heading',
@@ -59,7 +58,7 @@ function runInPage(ctx) {
59
58
  'definition', 'paragraph', 'caption', 'status', 'alert', 'log', 'tooltip'
60
59
  ]);
61
60
 
62
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[tabindex][role]', safeRoot) : helpers.queryAll('[tabindex][role]', safeRoot);
61
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[tabindex][role]') : helpers.queryAll('[tabindex][role]');
63
62
 
64
63
  const occurrences = [];
65
64
  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,14 +52,13 @@ const meta = {
52
52
  };
53
53
 
54
54
  function runInPage(ctx) {
55
- const { document, root, helpers, rule } = ctx;
56
- const safeRoot = root || document;
55
+ const { document, helpers, rule } = ctx;
57
56
 
58
57
  function normName(s) {
59
58
  return (s == null ? '' : String(s)).replace(/\s+/g, ' ').trim().toLowerCase();
60
59
  }
61
60
 
62
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('a[href]', safeRoot) : helpers.queryAll('a[href]', safeRoot);
61
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('a[href]') : helpers.queryAll('a[href]');
63
62
 
64
63
  const groups = new Map(); // normName -> [{ el, href }]
65
64
  let applicableCount = 0;
@@ -47,8 +47,7 @@ const meta = {
47
47
  };
48
48
 
49
49
  function runInPage(ctx) {
50
- const { document, root, helpers, rule } = ctx;
51
- const safeRoot = root || document;
50
+ const { document, helpers, rule } = ctx;
52
51
 
53
52
  function normalizeWs(s) {
54
53
  return String(s || '').replace(/\s+/g, ' ').trim();
@@ -67,7 +66,7 @@ function runInPage(ctx) {
67
66
  return normalizeWs(text);
68
67
  }
69
68
 
70
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('img[alt]', safeRoot) : helpers.queryAll('img[alt]', safeRoot);
69
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('img[alt]') : helpers.queryAll('img[alt]');
71
70
 
72
71
  const occurrences = [];
73
72
  let applicableCount = 0;
@@ -45,11 +45,10 @@ const meta = {
45
45
  };
46
46
 
47
47
  function runInPage(ctx) {
48
- const { document, root, helpers, rule } = ctx;
49
- const safeRoot = root || document;
48
+ const { document, helpers, rule } = ctx;
50
49
 
51
50
  const selector = 'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]),select,textarea';
52
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
51
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
53
52
 
54
53
  const labelsByFor = new Map();
55
54
  const allLabels = document.getElementsByTagName ? document.getElementsByTagName('label') : [];
@@ -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 '';
@@ -71,25 +71,37 @@ function runInPage(ctx) {
71
71
  return raw.split(/\s+/)[0].toLowerCase();
72
72
  }
73
73
 
74
- const SECTIONING_ANCESTORS = new Set(['article', 'aside', 'main', 'nav', 'section']);
75
-
76
- function isSuppressedBySectioningAncestor(el) {
77
- let p = el.parentElement;
78
- while (p) {
79
- const tag = p.tagName ? p.tagName.toLowerCase() : '';
80
- if (SECTIONING_ANCESTORS.has(tag)) return true;
81
- p = p.parentElement;
82
- }
83
- return false;
74
+ // Delegates to the shared helpers.hasLandmarkScopingAncestor for the
75
+ // question "does this element sit inside a sectioning-content/<main>
76
+ // ancestor that suppresses its conditional implicit role" — role-aware
77
+ // (an ancestor's bare TAG only counts when it carries no role attribute
78
+ // at all; an explicit role="dialog"-style override no longer suppresses)
79
+ // rather than a local tag-only copy. See that function's header comment
80
+ // in src/core/aria-helpers.js for the full algorithm and the real page
81
+ // (handsontable.com's docs-assistant side panel, an
82
+ // <aside role="dialog"> containing its own <header>) that surfaced this
83
+ // rule's own former tag-only copy as a false negative.
84
+ function hasSectioningAncestor(el, includeMain) {
85
+ return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
86
+ ? helpers.hasLandmarkScopingAncestor(el, { includeMain })
87
+ : false;
84
88
  }
85
89
 
86
90
  function getImplicitLandmarkRole(el) {
87
91
  const tag = el.tagName ? el.tagName.toLowerCase() : '';
88
- if (tag === 'header') return isSuppressedBySectioningAncestor(el) ? '' : 'banner';
89
- if (tag === 'footer') return isSuppressedBySectioningAncestor(el) ? '' : 'contentinfo';
92
+ if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
93
+ if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
90
94
  if (tag === 'main') return 'main';
91
95
  if (tag === 'nav') return 'navigation';
92
- if (tag === 'aside') return isSuppressedBySectioningAncestor(el) ? '' : 'complementary';
96
+ if (tag === 'aside') {
97
+ // A named <aside> is never suppressed, even when nested — matches
98
+ // landmark-unique's own verified-against-reference-engine precedent
99
+ // (that engine's real `aside` implicit-role function keeps
100
+ // "complementary" when the element has an accessible name, even
101
+ // inside sectioning content); propagated here for consistency.
102
+ if (!hasSectioningAncestor(el, false)) return 'complementary';
103
+ return getAccessibleLandmarkName(el) ? 'complementary' : '';
104
+ }
93
105
  if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
94
106
  if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
95
107
  return '';
@@ -61,6 +61,14 @@ const meta = {
61
61
  coverage: {}
62
62
  };
63
63
 
64
+ // This check is inherently whole-document (does the PAGE have this
65
+ // property?), not evaluable per-subtree -- notApplicable when contextSelector
66
+ // scoped this run narrower than the whole document, or when
67
+ // engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
68
+ function applicability(ctx) {
69
+ return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
70
+ }
71
+
64
72
  function runInPage(ctx) {
65
73
  const { document, helpers, rule } = ctx;
66
74
 
@@ -148,4 +156,4 @@ function runInPage(ctx) {
148
156
  };
149
157
  }
150
158
 
151
- module.exports = { id, meta, runInPage };
159
+ module.exports = { id, meta, runInPage, applicability };