@surea11y/core 1.4.1 → 1.5.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 (104) hide show
  1. package/CHANGELOG.md +47 -7
  2. package/README.md +19 -3
  3. package/docs/API_STABILITY.md +2 -2
  4. package/docs/ENGINE_OPTIONS.md +14 -10
  5. package/docs/I18N.md +176 -20
  6. package/docs/INTEGRATION.md +28 -6
  7. package/docs/LIMITATIONS.md +1 -1
  8. package/docs/OUTPUT_SCHEMA.md +13 -3
  9. package/docs/REPORT.md +2 -0
  10. package/docs/RULE_AUTHORING.md +53 -0
  11. package/docs/TROUBLESHOOTING.md +2 -2
  12. package/package.json +6 -1
  13. package/src/checks/automatic/aria-allowed-attr.js +27 -30
  14. package/src/checks/automatic/aria-allowed-role.js +14 -16
  15. package/src/checks/automatic/aria-braille-equivalent.js +17 -19
  16. package/src/checks/automatic/aria-conditional-attr.js +17 -19
  17. package/src/checks/automatic/aria-deprecated-role.js +62 -49
  18. package/src/checks/automatic/aria-hidden-body.js +2 -9
  19. package/src/checks/automatic/aria-hidden-focus.js +99 -18
  20. package/src/checks/automatic/aria-prohibited-attr.js +54 -55
  21. package/src/checks/automatic/aria-prohibited-children.js +26 -26
  22. package/src/checks/automatic/aria-required-attr.js +14 -17
  23. package/src/checks/automatic/aria-required-children.js +17 -20
  24. package/src/checks/automatic/aria-required-parent.js +17 -20
  25. package/src/checks/automatic/aria-roles-valid.js +37 -23
  26. package/src/checks/automatic/aria-valid-attr-value.js +18 -21
  27. package/src/checks/automatic/aria-valid-attr.js +14 -17
  28. package/src/checks/automatic/autocomplete-valid.js +15 -17
  29. package/src/checks/automatic/avoid-inline-spacing.js +14 -16
  30. package/src/checks/automatic/binary-control-name-present.js +19 -21
  31. package/src/checks/automatic/button-name-present.js +23 -28
  32. package/src/checks/automatic/combobox-name-present.js +15 -17
  33. package/src/checks/automatic/css-orientation-lock.js +22 -22
  34. package/src/checks/automatic/definition-list-children-valid.js +18 -21
  35. package/src/checks/automatic/deprecated-elements-not-used.js +14 -16
  36. package/src/checks/automatic/dialog-name-present.js +16 -18
  37. package/src/checks/automatic/dlitem-parent-valid.js +15 -17
  38. package/src/checks/automatic/duplicate-id-aria.js +45 -37
  39. package/src/checks/automatic/form-control-single-label.js +38 -40
  40. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
  41. package/src/checks/automatic/iframe-focusable-content.js +29 -31
  42. package/src/checks/automatic/iframe-name-present.js +15 -17
  43. package/src/checks/automatic/iframe-title-unique.js +18 -23
  44. package/src/checks/automatic/label-in-name.js +31 -36
  45. package/src/checks/automatic/link-in-text-block.js +19 -21
  46. package/src/checks/automatic/link-name-present.js +25 -30
  47. package/src/checks/automatic/list-children-valid.js +15 -17
  48. package/src/checks/automatic/listbox-name-present.js +15 -17
  49. package/src/checks/automatic/listitem-parent-valid.js +14 -17
  50. package/src/checks/automatic/menuitem-name-present.js +16 -18
  51. package/src/checks/automatic/meta-refresh-no-exceptions.js +15 -18
  52. package/src/checks/automatic/meta-refresh-timing-absent.js +14 -17
  53. package/src/checks/automatic/meta-viewport-zoom-enabled.js +14 -17
  54. package/src/checks/automatic/meter-name-present.js +16 -18
  55. package/src/checks/automatic/nested-interactive-controls-absent.js +17 -19
  56. package/src/checks/automatic/option-name-present.js +16 -18
  57. package/src/checks/automatic/progressbar-name-present.js +19 -21
  58. package/src/checks/automatic/searchbox-name-present.js +19 -17
  59. package/src/checks/automatic/server-side-image-map-absent.js +15 -18
  60. package/src/checks/automatic/slider-name-present.js +15 -17
  61. package/src/checks/automatic/spinbutton-name-present.js +19 -17
  62. package/src/checks/automatic/summary-name-present.js +16 -18
  63. package/src/checks/automatic/tab-name-present.js +16 -18
  64. package/src/checks/automatic/table-headers-attr-valid.js +15 -17
  65. package/src/checks/automatic/table-th-has-data-cells.js +15 -19
  66. package/src/checks/automatic/target-size-minimum.js +104 -81
  67. package/src/checks/automatic/td-has-header.js +15 -20
  68. package/src/checks/automatic/textbox-name-present.js +15 -17
  69. package/src/checks/automatic/tooltip-name-present.js +16 -18
  70. package/src/checks/automatic/treeitem-name-present.js +16 -18
  71. package/src/checks/automatic/valid-lang.js +15 -17
  72. package/src/checks/manual/accesskeys-manual.js +18 -19
  73. package/src/checks/manual/aria-checked-state-mismatch-manual.js +19 -22
  74. package/src/checks/manual/bypass-blocks-present-manual.js +4 -12
  75. package/src/checks/manual/empty-heading-manual.js +15 -17
  76. package/src/checks/manual/empty-table-header-manual.js +27 -30
  77. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -9
  78. package/src/checks/manual/heading-order-manual.js +17 -22
  79. package/src/checks/manual/image-redundant-alt-manual.js +14 -17
  80. package/src/checks/manual/label-title-only-manual.js +15 -17
  81. package/src/checks/manual/landmark-banner-is-top-level-manual.js +14 -17
  82. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +14 -17
  83. package/src/checks/manual/landmark-main-is-top-level-manual.js +14 -17
  84. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +2 -7
  85. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +2 -7
  86. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -7
  87. package/src/checks/manual/landmark-one-main-manual.js +2 -9
  88. package/src/checks/manual/landmark-unique-manual.js +22 -27
  89. package/src/checks/manual/link-name-quality-manual.js +15 -17
  90. package/src/checks/manual/meta-viewport-large-manual.js +14 -17
  91. package/src/checks/manual/mouse-only-event-handlers-manual.js +17 -19
  92. package/src/checks/manual/page-has-heading-one-manual.js +2 -9
  93. package/src/checks/manual/presentation-role-conflict-manual.js +19 -21
  94. package/src/checks/manual/region-manual.js +13 -6
  95. package/src/checks/manual/scope-attr-valid-manual.js +14 -17
  96. package/src/checks/manual/skip-link-manual.js +39 -47
  97. package/src/checks/manual/tabindex-manual.js +14 -17
  98. package/src/checks/manual/table-duplicate-name-manual.js +14 -17
  99. package/src/core.js +4203 -3604
  100. package/src/report.js +14 -0
  101. package/surea11y.browser.js +1960 -3671
  102. package/surea11y.i18n.de.js +22 -0
  103. package/surea11y.i18n.es.js +22 -0
  104. package/surea11y.i18n.fr.js +22 -0
@@ -146,6 +146,54 @@ function runInPage(ctx) {
146
146
  return false;
147
147
  }
148
148
 
149
+ // An open modal is expected to trap focus, so a focusable background behind
150
+ // it may be unreachable; that can't be proven statically, so the finding is
151
+ // downgraded to cantTell instead of fail. A modal that is display:none is
152
+ // not open, so it does not count.
153
+ function isRenderedForModal(node) {
154
+ if (!node) return false;
155
+ if (!isDomVisibleEligible) return true;
156
+ try {
157
+ const vis = isDomVisibleEligible(node, ctx, {
158
+ visibilityMode: 'styleOnly',
159
+ disableGeometry: true
160
+ });
161
+ if (vis && vis.eligible === false) return false;
162
+ } catch {
163
+ // treat as rendered
164
+ }
165
+ return true;
166
+ }
167
+
168
+ // Native <dialog>, aria-modal="true", or a dialog/alertdialog role, so
169
+ // libraries that leave aria-modal off (e.g. Angular Material defaults to
170
+ // aria-modal="false") still count as an open modal.
171
+ function collectOpenModalCandidates() {
172
+ const nodes = qAll('dialog[open],[aria-modal="true"],[role="dialog"],[role="alertdialog"]');
173
+ const out = [];
174
+ for (let i = 0; i < nodes.length; i++) {
175
+ const n = nodes[i];
176
+ if (!n || !n.getAttribute) continue;
177
+ if (!isRenderedForModal(n)) continue;
178
+ out.push(n);
179
+ }
180
+ return out;
181
+ }
182
+
183
+ // Only a modal in a separate subtree is the "hidden background behind an
184
+ // open dialog" case. A modal inside the aria-hidden subtree (or an
185
+ // aria-hidden root inside the modal) is a genuine defect, so keep it a fail.
186
+ function hasSeparateOpenModal(rootEl, candidates) {
187
+ for (let i = 0; i < candidates.length; i++) {
188
+ const m = candidates[i];
189
+ if (!m || m === rootEl) continue;
190
+ if (isWithinComposedSubtree(m, rootEl)) continue;
191
+ if (isWithinComposedSubtree(rootEl, m)) continue;
192
+ return true;
193
+ }
194
+ return false;
195
+ }
196
+
149
197
  function getDeepActiveElement() {
150
198
  let cur = document && document.activeElement ? document.activeElement : null;
151
199
  let guard = 0;
@@ -713,6 +761,8 @@ function runInPage(ctx) {
713
761
  entry.rootIsFocusable = isActuallyFocusable(el);
714
762
  }
715
763
 
764
+ const modalCandidates = collectOpenModalCandidates();
765
+
716
766
  // 3) Report one occurrence per aria-hidden root that contains focusable content.
717
767
  const failOccurrences = [];
718
768
  const uncertainOccurrences = [];
@@ -765,24 +815,56 @@ function runInPage(ctx) {
765
815
  : `aria-hidden ${tagName} is focusable (${totalFocusable} focusable element(s)).`
766
816
  : `aria-hidden ${tagName} contains ${totalFocusable} focusable element(s).`;
767
817
 
768
- const runtimeProbe = probeImmediateFocusRedirect(entry);
769
- const downgradedToCantTell = !!(runtimeProbe && runtimeProbe.redirected);
770
-
771
- const cantTellSummary = `aria-hidden ${tagName} received focus but focus moved immediately to another element. Verify sentinel/focus-trap behavior.`;
818
+ // A modal takes precedence over the redirect probe; skip the probe when a
819
+ // modal already explains the background.
820
+ const modalOpenOutside = modalCandidates.length
821
+ ? hasSeparateOpenModal(el, modalCandidates)
822
+ : false;
823
+
824
+ const runtimeProbe = modalOpenOutside ? null : probeImmediateFocusRedirect(entry);
825
+ const downgradedByRedirect = !!(runtimeProbe && runtimeProbe.redirected);
826
+ const downgradedByModal = modalOpenOutside;
827
+ const downgradedToCantTell = downgradedByModal || downgradedByRedirect;
828
+
829
+ const cantTellRedirectSummary = `aria-hidden ${tagName} received focus but focus moved immediately to another element. Verify sentinel/focus-trap behavior.`;
830
+ const cantTellModalSummary = `aria-hidden ${tagName} contains ${totalFocusable} focusable element(s) while a modal dialog is open. If the modal keeps keyboard focus trapped they may be unreachable; verify focus cannot land on them.`;
831
+
832
+ let occSummary;
833
+ let occHint;
834
+ let occSummaryKey;
835
+ let occHintKey;
836
+ let occReasonCode;
837
+
838
+ if (downgradedByModal) {
839
+ occSummary = cantTellModalSummary;
840
+ occHint =
841
+ 'A modal dialog appears to be open. Prefer making the background inert (or a native <dialog> opened with showModal()) so it leaves the tab order, then verify keyboard focus stays within the dialog.';
842
+ occSummaryKey = 'ariaHidden_focus_summary_cantTell_modal';
843
+ occHintKey = 'ariaHidden_focus_hint_cantTell_modal';
844
+ occReasonCode = 'ariaHiddenFocusable_modalOpen_needsReview';
845
+ } else if (downgradedByRedirect) {
846
+ occSummary = cantTellRedirectSummary;
847
+ occHint =
848
+ 'Verify this is an intentional focus sentinel/focus-trap handoff and that keyboard users never remain on hidden focus targets.';
849
+ occSummaryKey = 'ariaHidden_focus_summary_cantTell_redirect';
850
+ occHintKey = 'ariaHidden_focus_hint_cantTell_redirect';
851
+ occReasonCode = 'ariaHiddenFocusable_runtimeRedirect_needsReview';
852
+ } else {
853
+ occSummary = summaryText;
854
+ occHint =
855
+ 'Remove focusability from descendants or remove aria-hidden; ensure focus and accessibility trees stay aligned.';
856
+ occSummaryKey = summaryKey;
857
+ occHintKey = 'ariaHidden_focus_hint_fail';
858
+ occReasonCode = reasonCode;
859
+ }
772
860
 
773
861
  const baseOccurrence = {
774
- summary: downgradedToCantTell ? cantTellSummary : summaryText,
775
- hint: downgradedToCantTell
776
- ? 'Verify this is an intentional focus sentinel/focus-trap handoff and that keyboard users never remain on hidden focus targets.'
777
- : 'Remove focusability from descendants or remove aria-hidden; ensure focus and accessibility trees stay aligned.',
862
+ summary: occSummary,
863
+ hint: occHint,
778
864
  occurrenceOutcome: downgradedToCantTell ? 'cantTell' : 'fail',
779
865
  i18n: {
780
- summaryKey: downgradedToCantTell
781
- ? 'ariaHidden_focus_summary_cantTell_redirect'
782
- : summaryKey,
783
- hintKey: downgradedToCantTell
784
- ? 'ariaHidden_focus_hint_cantTell_redirect'
785
- : 'ariaHidden_focus_hint_fail',
866
+ summaryKey: occSummaryKey,
867
+ hintKey: occHintKey,
786
868
  params: {
787
869
  element: tagName,
788
870
  focusableCount: String(totalFocusable),
@@ -795,15 +877,14 @@ function runInPage(ctx) {
795
877
  },
796
878
  data: {
797
879
  details: {
798
- reasonCode: downgradedToCantTell
799
- ? 'ariaHiddenFocusable_runtimeRedirect_needsReview'
800
- : reasonCode,
880
+ reasonCode: occReasonCode,
801
881
  metrics: {
802
882
  focusableTotal: totalFocusable,
803
883
  focusableDescendants: descendantFocusable,
804
884
  rootIsFocusable: selfFocusable,
805
885
  offendersCaptured: entry.offenders.length,
806
- visibilityHints: hintsArr.slice(0)
886
+ visibilityHints: hintsArr.slice(0),
887
+ modalOpen: !!downgradedByModal
807
888
  },
808
889
  runtimeProbe: runtimeProbe || null,
809
890
  offenders: entry.offenders.slice(0)
@@ -167,25 +167,22 @@ function runInPage(ctx) {
167
167
 
168
168
  if (!present.length) continue;
169
169
 
170
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
171
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
172
-
173
170
  for (const attr of present) {
174
- failOccurrences.push({
175
- selector: stableSelector,
176
- html,
177
- occurrenceOutcome: 'fail',
178
- summary: 'This attribute is prohibited on this element’s role.',
179
- hint: 'Remove this attribute; this role must not carry an accessible name.',
180
- i18n: {
181
- summaryKey: 'ariaProhibitedAttr_summary_fail',
182
- hintKey: 'ariaProhibitedAttr_hint_fail',
183
- params: { attr, role }
184
- },
185
- data: {
186
- details: { reasonCode: 'ARIA_ATTR_PROHIBITED', attr, role }
187
- }
188
- });
171
+ failOccurrences.push(
172
+ helpers.reportOccurrence(el, {
173
+ occurrenceOutcome: 'fail',
174
+ summary: 'This attribute is prohibited on this element’s role.',
175
+ hint: 'Remove this attribute; this role must not carry an accessible name.',
176
+ i18n: {
177
+ summaryKey: 'ariaProhibitedAttr_summary_fail',
178
+ hintKey: 'ariaProhibitedAttr_hint_fail',
179
+ params: { attr, role }
180
+ },
181
+ data: {
182
+ details: { reasonCode: 'ARIA_ATTR_PROHIBITED', attr, role }
183
+ }
184
+ })
185
+ );
189
186
  }
190
187
  }
191
188
 
@@ -361,47 +358,49 @@ function runInPage(ctx) {
361
358
  String(nameInfo.value || '').trim() !== ''
362
359
  );
363
360
 
364
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
365
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
366
-
367
361
  for (const attr of present) {
368
362
  if (hasContentFallback) {
369
- cantTellOccurrences.push({
370
- selector: stableSelector,
371
- html,
372
- occurrenceOutcome: 'cantTell',
373
- summary: `This ${tag} has no role, so ${attr} may not be exposed as its accessible name by assistive technology — but the element's own content already provides one.`,
374
- hint: 'Verify whether the existing text content already serves as this element’s label; if so the naming attribute is redundant, otherwise give the element a role that supports naming (e.g. role="img").',
375
- i18n: {
376
- summaryKey: 'ariaProhibitedAttr_summary_cantTell_roleless',
377
- hintKey: 'ariaProhibitedAttr_hint_cantTell_roleless',
378
- params: { attr, element: tag }
379
- },
380
- data: {
381
- details: {
382
- reasonCode: 'ARIA_ATTR_PROHIBITED_ROLELESS_NEEDS_REVIEW',
383
- attr,
384
- role: null,
385
- element: tag
363
+ cantTellOccurrences.push(
364
+ helpers.reportOccurrence(el, {
365
+ occurrenceOutcome: 'cantTell',
366
+ summary: `This ${tag} has no role, so ${attr} may not be exposed as its accessible name by assistive technology — but the element's own content already provides one.`,
367
+ hint: 'Verify whether the existing text content already serves as this element’s label; if so the naming attribute is redundant, otherwise give the element a role that supports naming (e.g. role="img").',
368
+ i18n: {
369
+ summaryKey: 'ariaProhibitedAttr_summary_cantTell_roleless',
370
+ hintKey: 'ariaProhibitedAttr_hint_cantTell_roleless',
371
+ params: { attr, element: tag }
372
+ },
373
+ data: {
374
+ details: {
375
+ reasonCode: 'ARIA_ATTR_PROHIBITED_ROLELESS_NEEDS_REVIEW',
376
+ attr,
377
+ role: null,
378
+ element: tag
379
+ }
386
380
  }
387
- }
388
- });
381
+ })
382
+ );
389
383
  } else {
390
- failOccurrences.push({
391
- selector: stableSelector,
392
- html,
393
- occurrenceOutcome: 'fail',
394
- summary: `This ${tag} has no role and no other accessible-name source, so ${attr} is not reliably exposed to assistive technology.`,
395
- hint: 'Give this element a role that supports an accessible name (e.g. role="img"/"button"), or remove this attribute if it serves no purpose without one.',
396
- i18n: {
397
- summaryKey: 'ariaProhibitedAttr_summary_fail_roleless',
398
- hintKey: 'ariaProhibitedAttr_hint_fail_roleless',
399
- params: { attr, element: tag }
400
- },
401
- data: {
402
- details: { reasonCode: 'ARIA_ATTR_PROHIBITED_ROLELESS', attr, role: null, element: tag }
403
- }
404
- });
384
+ failOccurrences.push(
385
+ helpers.reportOccurrence(el, {
386
+ occurrenceOutcome: 'fail',
387
+ summary: `This ${tag} has no role and no other accessible-name source, so ${attr} is not reliably exposed to assistive technology.`,
388
+ hint: 'Give this element a role that supports an accessible name (e.g. role="img"/"button"), or remove this attribute if it serves no purpose without one.',
389
+ i18n: {
390
+ summaryKey: 'ariaProhibitedAttr_summary_fail_roleless',
391
+ hintKey: 'ariaProhibitedAttr_hint_fail_roleless',
392
+ params: { attr, element: tag }
393
+ },
394
+ data: {
395
+ details: {
396
+ reasonCode: 'ARIA_ATTR_PROHIBITED_ROLELESS',
397
+ attr,
398
+ role: null,
399
+ element: tag
400
+ }
401
+ }
402
+ })
403
+ );
405
404
  }
406
405
  }
407
406
  }
@@ -256,10 +256,6 @@ function runInPage(ctx) {
256
256
  for (const entry of owned) {
257
257
  if (entry.role && requiredSet.has(entry.role)) continue;
258
258
 
259
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(entry.el) : 'html';
260
- const html = helpers.getOuterHtmlSnippet
261
- ? helpers.getOuterHtmlSnippet(entry.el)
262
- : entry.el.outerHTML || '';
263
259
  const containerSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
264
260
 
265
261
  const isRoleless = !entry.role;
@@ -286,29 +282,33 @@ function runInPage(ctx) {
286
282
  hintKey = 'ariaProhibitedChildren_hint_fail';
287
283
  }
288
284
 
289
- occurrences.push({
290
- selector: stableSelector,
291
- html,
292
- summary,
293
- hint,
294
- i18n: {
295
- summaryKey,
296
- hintKey,
297
- params: isRoleless
298
- ? { attr: entry.attr, containerRole: role }
299
- : { childRole: entry.role, containerRole: role, allowedRoles: requiredOwned.join(', ') }
300
- },
301
- data: {
302
- details: {
303
- reasonCode: isRoleless ? 'ARIA_PROHIBITED_CHILD_ROLELESS' : 'ARIA_PROHIBITED_CHILD',
304
- childRole: entry.role,
305
- attr: entry.attr,
306
- containerRole: role,
307
- containerSelector,
308
- allowedOwnedRoles: requiredOwned
285
+ occurrences.push(
286
+ helpers.reportOccurrence(entry.el, {
287
+ summary,
288
+ hint,
289
+ i18n: {
290
+ summaryKey,
291
+ hintKey,
292
+ params: isRoleless
293
+ ? { attr: entry.attr, containerRole: role }
294
+ : {
295
+ childRole: entry.role,
296
+ containerRole: role,
297
+ allowedRoles: requiredOwned.join(', ')
298
+ }
299
+ },
300
+ data: {
301
+ details: {
302
+ reasonCode: isRoleless ? 'ARIA_PROHIBITED_CHILD_ROLELESS' : 'ARIA_PROHIBITED_CHILD',
303
+ childRole: entry.role,
304
+ attr: entry.attr,
305
+ containerRole: role,
306
+ containerSelector,
307
+ allowedOwnedRoles: requiredOwned
308
+ }
309
309
  }
310
- }
311
- });
310
+ })
311
+ );
312
312
  }
313
313
  }
314
314
 
@@ -129,24 +129,21 @@ function runInPage(ctx) {
129
129
 
130
130
  if (!missing.length) continue;
131
131
 
132
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
133
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
134
-
135
132
  for (const attr of missing) {
136
- occurrences.push({
137
- selector: stableSelector,
138
- html,
139
- summary: 'This attribute is required for this element’s role, but is missing.',
140
- hint: 'Add this attribute with a valid value for this role.',
141
- i18n: {
142
- summaryKey: 'ariaRequiredAttr_summary_fail',
143
- hintKey: 'ariaRequiredAttr_hint_fail',
144
- params: { attr, role }
145
- },
146
- data: {
147
- details: { reasonCode: 'ARIA_ATTR_REQUIRED_MISSING', attr, role }
148
- }
149
- });
133
+ occurrences.push(
134
+ helpers.reportOccurrence(el, {
135
+ summary: 'This attribute is required for this element’s role, but is missing.',
136
+ hint: 'Add this attribute with a valid value for this role.',
137
+ i18n: {
138
+ summaryKey: 'ariaRequiredAttr_summary_fail',
139
+ hintKey: 'ariaRequiredAttr_hint_fail',
140
+ params: { attr, role }
141
+ },
142
+ data: {
143
+ details: { reasonCode: 'ARIA_ATTR_REQUIRED_MISSING', attr, role }
144
+ }
145
+ })
146
+ );
150
147
  }
151
148
  }
152
149
 
@@ -253,27 +253,24 @@ function runInPage(ctx) {
253
253
 
254
254
  if (found) continue;
255
255
 
256
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
257
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
258
-
259
- occurrences.push({
260
- selector: stableSelector,
261
- html,
262
- summary: 'This container role has no owned child with a required role.',
263
- hint: 'Add a descendant (or aria-owns-referenced element) with one of the required owned roles.',
264
- i18n: {
265
- summaryKey: 'ariaRequiredChildren_summary_fail',
266
- hintKey: 'ariaRequiredChildren_hint_fail',
267
- params: { role, requiredRoles: requiredOwned.join(', ') }
268
- },
269
- data: {
270
- details: {
271
- reasonCode: 'ARIA_REQUIRED_CHILD_MISSING',
272
- role,
273
- requiredOwnedRoles: requiredOwned
256
+ occurrences.push(
257
+ helpers.reportOccurrence(el, {
258
+ summary: 'This container role has no owned child with a required role.',
259
+ hint: 'Add a descendant (or aria-owns-referenced element) with one of the required owned roles.',
260
+ i18n: {
261
+ summaryKey: 'ariaRequiredChildren_summary_fail',
262
+ hintKey: 'ariaRequiredChildren_hint_fail',
263
+ params: { role, requiredRoles: requiredOwned.join(', ') }
264
+ },
265
+ data: {
266
+ details: {
267
+ reasonCode: 'ARIA_REQUIRED_CHILD_MISSING',
268
+ role,
269
+ requiredOwnedRoles: requiredOwned
270
+ }
274
271
  }
275
- }
276
- });
272
+ })
273
+ );
277
274
  }
278
275
 
279
276
  if (applicableCount === 0) {
@@ -210,27 +210,24 @@ function runInPage(ctx) {
210
210
 
211
211
  if (hasContext) continue;
212
212
 
213
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
214
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
215
-
216
- occurrences.push({
217
- selector: stableSelector,
218
- html,
219
- summary: 'This role requires a specific ancestor/owner context role, which was not found.',
220
- hint: 'Place this element inside (or aria-owns-reference it from) an element with an acceptable context role.',
221
- i18n: {
222
- summaryKey: 'ariaRequiredParent_summary_fail',
223
- hintKey: 'ariaRequiredParent_hint_fail',
224
- params: { role, requiredRoles: requiredContext.join(', ') }
225
- },
226
- data: {
227
- details: {
228
- reasonCode: 'ARIA_REQUIRED_PARENT_MISSING',
229
- role,
230
- requiredContextRoles: requiredContext
213
+ occurrences.push(
214
+ helpers.reportOccurrence(el, {
215
+ summary: 'This role requires a specific ancestor/owner context role, which was not found.',
216
+ hint: 'Place this element inside (or aria-owns-reference it from) an element with an acceptable context role.',
217
+ i18n: {
218
+ summaryKey: 'ariaRequiredParent_summary_fail',
219
+ hintKey: 'ariaRequiredParent_hint_fail',
220
+ params: { role, requiredRoles: requiredContext.join(', ') }
221
+ },
222
+ data: {
223
+ details: {
224
+ reasonCode: 'ARIA_REQUIRED_PARENT_MISSING',
225
+ role,
226
+ requiredContextRoles: requiredContext
227
+ }
231
228
  }
232
- }
233
- });
229
+ })
230
+ );
234
231
  }
235
232
 
236
233
  if (applicableCount === 0) {
@@ -68,14 +68,28 @@ function runInPage(ctx) {
68
68
  let applicableCount = 0;
69
69
 
70
70
  // Programmatically hidden per the ACT glossary: display:none, visibility not
71
- // visible, or aria-hidden on the element or an ancestor.
71
+ // visible, or aria-hidden on the element or an ancestor. inert is treated
72
+ // the same although the glossary predates it -- an inert subtree is out of
73
+ // the accessibility tree entirely, so a role on it reaches no one.
72
74
  function isHidden(el) {
73
75
  try {
74
76
  if (typeof helpers.isDomVisibleEligible === 'function') {
75
77
  if (!helpers.isDomVisibleEligible(el, ctx)) return true;
76
78
  }
77
- for (let n = el; n && n.getAttribute; n = n.parentElement) {
79
+ // Walk the composed tree, not parentElement: that stops at a shadow
80
+ // root, so a host carrying aria-hidden or inert would never be seen
81
+ // from inside its own shadow content.
82
+ const up =
83
+ typeof helpers.composedParent === 'function'
84
+ ? helpers.composedParent
85
+ : (n) => n.parentElement;
86
+
87
+ // A shadow root has no getAttribute, so skip past it rather than
88
+ // stopping: the host one step further up is the node that matters.
89
+ for (let n = el; n; n = up(n)) {
90
+ if (!n.getAttribute) continue;
78
91
  if (String(n.getAttribute('aria-hidden') || '').toLowerCase() === 'true') return true;
92
+ if (n.hasAttribute && n.hasAttribute('inert')) return true;
79
93
  }
80
94
  } catch {
81
95
  return false;
@@ -106,27 +120,27 @@ function runInPage(ctx) {
106
120
  const role = tokens[0];
107
121
  const isKnown = tokens.some((t) => ariaHelpers.isKnownRole(t));
108
122
  const reasonCode = !isKnown ? 'ARIA_ROLE_INVALID' : 'ARIA_ROLE_ABSTRACT';
109
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
110
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
111
-
112
- occurrences.push({
113
- selector: stableSelector,
114
- html,
115
- summary: !isKnown
116
- ? 'The role attribute value is not a recognized ARIA role.'
117
- : 'The role attribute value is an abstract ARIA role, which must not be used directly.',
118
- hint: !isKnown
119
- ? 'Use a valid ARIA role token, or remove the role attribute if none applies.'
120
- : 'Replace this abstract role with a concrete role appropriate for the widget/structure.',
121
- i18n: {
122
- summaryKey: !isKnown ? 'ariaRolesValid_summary_invalid' : 'ariaRolesValid_summary_abstract',
123
- hintKey: !isKnown ? 'ariaRolesValid_hint_invalid' : 'ariaRolesValid_hint_abstract',
124
- params: { role }
125
- },
126
- data: {
127
- details: { reasonCode, role }
128
- }
129
- });
123
+
124
+ occurrences.push(
125
+ helpers.reportOccurrence(el, {
126
+ summary: !isKnown
127
+ ? 'The role attribute value is not a recognized ARIA role.'
128
+ : 'The role attribute value is an abstract ARIA role, which must not be used directly.',
129
+ hint: !isKnown
130
+ ? 'Use a valid ARIA role token, or remove the role attribute if none applies.'
131
+ : 'Replace this abstract role with a concrete role appropriate for the widget/structure.',
132
+ i18n: {
133
+ summaryKey: !isKnown
134
+ ? 'ariaRolesValid_summary_invalid'
135
+ : 'ariaRolesValid_summary_abstract',
136
+ hintKey: !isKnown ? 'ariaRolesValid_hint_invalid' : 'ariaRolesValid_hint_abstract',
137
+ params: { role }
138
+ },
139
+ data: {
140
+ details: { reasonCode, role }
141
+ }
142
+ })
143
+ );
130
144
  }
131
145
 
132
146
  if (applicableCount === 0) {
@@ -105,29 +105,26 @@ function runInPage(ctx) {
105
105
 
106
106
  if (!invalid || !invalid.length) continue;
107
107
 
108
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
109
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
110
-
111
108
  for (const item of invalid) {
112
- occurrences.push({
113
- selector: stableSelector,
114
- html,
115
- summary: 'This element has an ARIA attribute with an invalid value.',
116
- hint: 'Use a value that matches the attribute’s expected type (see the WAI-ARIA specification for this attribute).',
117
- i18n: {
118
- summaryKey: 'ariaValidAttrValue_summary_fail',
119
- hintKey: 'ariaValidAttrValue_hint_fail',
120
- params: { attr: item.name, value: item.value }
121
- },
122
- data: {
123
- details: {
124
- reasonCode: 'ARIA_ATTR_VALUE_INVALID',
125
- attr: item.name,
126
- value: item.value,
127
- valueReason: item.reason
109
+ occurrences.push(
110
+ helpers.reportOccurrence(el, {
111
+ summary: 'This element has an ARIA attribute with an invalid value.',
112
+ hint: 'Use a value that matches the attribute’s expected type (see the WAI-ARIA specification for this attribute).',
113
+ i18n: {
114
+ summaryKey: 'ariaValidAttrValue_summary_fail',
115
+ hintKey: 'ariaValidAttrValue_hint_fail',
116
+ params: { attr: item.name, value: item.value }
117
+ },
118
+ data: {
119
+ details: {
120
+ reasonCode: 'ARIA_ATTR_VALUE_INVALID',
121
+ attr: item.name,
122
+ value: item.value,
123
+ valueReason: item.reason
124
+ }
128
125
  }
129
- }
130
- });
126
+ })
127
+ );
131
128
  }
132
129
  }
133
130