@surea11y/core 1.6.0 → 1.8.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 (120) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/README.md +179 -90
  3. package/docs/ACT_RULE_MAPPING.md +10 -8
  4. package/docs/API_STABILITY.md +67 -6
  5. package/docs/BINDING_AUTHORS_GUIDE.md +104 -2
  6. package/docs/CI_INTEGRATIONS.md +43 -0
  7. package/docs/DESIGN_CHALLENGES.md +162 -2
  8. package/docs/EARL.md +100 -0
  9. package/docs/ENGINE_OPTIONS.md +109 -5
  10. package/docs/I18N.md +62 -20
  11. package/docs/INTEGRATION.md +4 -2
  12. package/docs/JUNIT.md +73 -0
  13. package/docs/LIMITATIONS.md +4 -1
  14. package/docs/OUTPUT_SCHEMA.md +62 -11
  15. package/docs/POLICY.md +1 -1
  16. package/docs/REPORT.md +7 -2
  17. package/docs/RULE_AUTHORING.md +83 -17
  18. package/docs/RULE_CATALOG.md +212 -139
  19. package/docs/RULE_EXAMPLES.md +2189 -0
  20. package/docs/RULE_HELPERS.md +390 -0
  21. package/docs/RULE_TAXONOMY.md +27 -6
  22. package/docs/SARIF.md +23 -3
  23. package/docs/WCAG_CONFORMANCE.md +64 -3
  24. package/package.json +41 -12
  25. package/profiles/index.js +14 -0
  26. package/src/checks/automatic/area-alt-present.js +87 -31
  27. package/src/checks/automatic/aria-allowed-attr.js +6 -0
  28. package/src/checks/automatic/aria-allowed-role.js +32 -23
  29. package/src/checks/automatic/aria-braille-equivalent.js +43 -17
  30. package/src/checks/automatic/aria-conditional-attr.js +17 -10
  31. package/src/checks/automatic/aria-deprecated-role.js +12 -0
  32. package/src/checks/automatic/aria-hidden-body.js +1 -1
  33. package/src/checks/automatic/aria-hidden-focus.js +74 -18
  34. package/src/checks/automatic/aria-prohibited-attr.js +22 -4
  35. package/src/checks/automatic/aria-prohibited-children.js +6 -6
  36. package/src/checks/automatic/aria-required-attr.js +88 -12
  37. package/src/checks/automatic/aria-required-children.js +33 -16
  38. package/src/checks/automatic/aria-required-parent.js +32 -6
  39. package/src/checks/automatic/aria-role-name-present.js +20 -3
  40. package/src/checks/automatic/aria-roles-valid.js +52 -21
  41. package/src/checks/automatic/aria-valid-attr-value.js +89 -24
  42. package/src/checks/automatic/aria-valid-attr.js +14 -9
  43. package/src/checks/automatic/autocomplete-valid.js +152 -26
  44. package/src/checks/automatic/avoid-inline-spacing.js +207 -15
  45. package/src/checks/automatic/button-name-present.js +2 -1
  46. package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
  47. package/src/checks/automatic/combobox-name-present.js +34 -51
  48. package/src/checks/automatic/contrast-computable.js +45 -4
  49. package/src/checks/automatic/contrast-enhanced.js +16 -4
  50. package/src/checks/automatic/contrast-minimum.js +57 -11
  51. package/src/checks/automatic/css-orientation-lock.js +171 -12
  52. package/src/checks/automatic/definition-list-children-valid.js +67 -23
  53. package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
  54. package/src/checks/automatic/dialog-name-present.js +28 -9
  55. package/src/checks/automatic/duplicate-id-aria.js +5 -0
  56. package/src/checks/automatic/duplicate-id.js +19 -10
  57. package/src/checks/automatic/form-control-single-label.js +9 -0
  58. package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
  59. package/src/checks/automatic/iframe-focusable-content.js +12 -4
  60. package/src/checks/automatic/iframe-title-unique.js +36 -81
  61. package/src/checks/automatic/input-image-alt-present.js +32 -20
  62. package/src/checks/automatic/label-in-name.js +78 -69
  63. package/src/checks/automatic/language-page-present.js +12 -6
  64. package/src/checks/automatic/link-in-text-block.js +512 -44
  65. package/src/checks/automatic/link-name-present.js +13 -5
  66. package/src/checks/automatic/list-children-valid.js +18 -1
  67. package/src/checks/automatic/listbox-name-present.js +19 -49
  68. package/src/checks/automatic/listitem-parent-valid.js +4 -3
  69. package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
  70. package/src/checks/automatic/page-title-present.js +16 -4
  71. package/src/checks/automatic/progressbar-name-present.js +11 -1
  72. package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +9 -5
  73. package/src/checks/automatic/searchbox-name-present.js +32 -49
  74. package/src/checks/automatic/server-side-image-map-absent.js +48 -28
  75. package/src/checks/automatic/slider-name-present.js +38 -52
  76. package/src/checks/automatic/spinbutton-name-present.js +32 -49
  77. package/src/checks/automatic/target-size-minimum.js +84 -16
  78. package/src/checks/automatic/td-has-header.js +60 -23
  79. package/src/checks/automatic/text-spacing-content-loss.js +548 -0
  80. package/src/checks/automatic/textbox-name-present.js +32 -49
  81. package/src/checks/automatic/valid-lang.js +15 -10
  82. package/src/checks/manual/area-alt-quality-manual.js +113 -31
  83. package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
  84. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
  85. package/src/checks/manual/css-hidden-focus.js +215 -7
  86. package/src/checks/manual/form-control-label-quality-manual.js +243 -29
  87. package/src/checks/manual/heading-order-manual.js +9 -1
  88. package/src/checks/manual/heading-quality-manual.js +143 -9
  89. package/src/checks/manual/img-alt-decorative-manual.js +6 -3
  90. package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
  91. package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
  92. package/src/checks/manual/link-name-quality-manual.js +130 -4
  93. package/src/checks/manual/media-transcript-present-manual.js +65 -8
  94. package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
  95. package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
  96. package/src/checks/manual/p-as-heading-manual.js +89 -44
  97. package/src/checks/manual/page-title-patterns-manual.js +77 -8
  98. package/src/checks/manual/password-paste-enabled-manual.js +255 -0
  99. package/src/checks/manual/skip-link-manual.js +42 -14
  100. package/src/checks/manual/table-fake-caption-manual.js +32 -1
  101. package/src/checks/manual/video-caption-manual.js +47 -24
  102. package/src/checks/manual-review.js +0 -4
  103. package/src/core.js +18061 -46194
  104. package/src/coverage/en301549-map.js +187 -0
  105. package/src/coverage/standards.js +279 -0
  106. package/src/coverage/wcag-facets.js +1119 -0
  107. package/src/coverage/wcag-version-map.js +101 -0
  108. package/src/earl.js +144 -0
  109. package/src/en301549.js +33 -0
  110. package/src/junit.js +321 -0
  111. package/src/profile-kit.js +163 -0
  112. package/src/report.js +343 -74
  113. package/src/sarif.js +56 -5
  114. package/src/wcag.js +105 -0
  115. package/surea11y.browser.js +11 -41039
  116. package/surea11y.i18n.de.js +2 -21
  117. package/surea11y.i18n.es.js +2 -21
  118. package/surea11y.i18n.fr.js +2 -21
  119. package/surea11y.i18n.ja.js +3 -0
  120. package/src/checks/manual/area-alt-decorative-manual.js +0 -255
@@ -17,6 +17,10 @@
17
17
  * Notes:
18
18
  * - Focusability is computed via ctx.helpers.getFocusableInfo (native + tabindex + contenteditable).
19
19
  * - Elements that are not rendered (e.g., display:none, visibility:hidden, [hidden]) are excluded.
20
+ * - Disabled controls are not focusable, including those disabled by an ancestor
21
+ * <fieldset disabled> (matched with :disabled).
22
+ * - <area href> has no box of its own, so it is judged by the <img usemap> that uses its
23
+ * <map>: it counts as focusable when such an image is rendered and not inert.
20
24
  * - Elements hidden via CSS in ways that still allow keyboard focus (e.g., opacity:0, off-screen, clip)
21
25
  * remain in-scope and will be flagged when focusable.
22
26
  */
@@ -525,9 +529,69 @@ function runInPage(ctx) {
525
529
  // DOM-visibility gate to avoid false positives:
526
530
  // Exclude structural/CSS hidden cases that prevent focus (display:none, visibility:hidden, hidden attr, etc.).
527
531
  // IMPORTANT: Do NOT exclude opacity-based invisibility; opacity:0 remains in-scope.
532
+ // Style-only visibility gate. opacity:0 does not count as hidden: the
533
+ // element can still take focus.
534
+ function isRenderedForFocus(el) {
535
+ if (!isDomVisibleEligible) return true;
536
+ try {
537
+ const vis = isDomVisibleEligible(el, ctx, {
538
+ visibilityMode: 'styleOnly',
539
+ disableGeometry: true
540
+ });
541
+ if (vis && vis.eligible === false) {
542
+ const rs = Array.isArray(vis.reasons) ? vis.reasons : [];
543
+ const nonOpacity = rs.filter((r) => String(r) !== 'opacityZero');
544
+ if (nonOpacity.length) return false;
545
+ }
546
+ } catch {
547
+ // ignore
548
+ }
549
+ return true;
550
+ }
551
+
552
+ // <area href> generates no box of its own (browsers give it display:none),
553
+ // so the visibility gate cannot be applied to it. It takes focus when its
554
+ // <map> is used by an <img usemap> that is rendered and not inert; judge it
555
+ // by that image instead.
556
+ function isFocusableArea(el) {
557
+ if (!trim(el.getAttribute('href'))) return false;
558
+ let map;
559
+ try {
560
+ map = el.closest ? el.closest('map') : null;
561
+ } catch {
562
+ map = null;
563
+ }
564
+ if (!map) return false;
565
+ const name = trim(map.getAttribute('name') || map.getAttribute('id'));
566
+ if (!name) return false;
567
+ const scope = el.getRootNode ? el.getRootNode() : document;
568
+ if (!scope || typeof scope.querySelectorAll !== 'function') return false;
569
+ let imgs;
570
+ try {
571
+ imgs = Array.from(scope.querySelectorAll('img[usemap]'));
572
+ } catch {
573
+ imgs = [];
574
+ }
575
+ const want = name.toLowerCase();
576
+ for (const img of imgs) {
577
+ const usemap = lower(img.getAttribute('usemap')).replace(/^#/, '');
578
+ if (usemap !== want) continue;
579
+ if (hasInertAncestor(img)) continue;
580
+ if (isRenderedForFocus(img)) return true;
581
+ }
582
+ return false;
583
+ }
584
+
528
585
  function isActuallyFocusable(el) {
529
586
  if (!el || !el.getAttribute) return false;
530
587
 
588
+ // An explicit negative tabindex takes the area out of the tab order too.
589
+ if (lower(el.tagName || '') === 'area') {
590
+ const ti = trim(el.getAttribute('tabindex'));
591
+ if (ti !== '' && !Number.isNaN(Number(ti)) && Number(ti) < 0) return false;
592
+ return isFocusableArea(el);
593
+ }
594
+
531
595
  // Hard blockers that should always win (even if fallback logic would say "focusable")
532
596
  if (hasInertAncestor(el)) return false;
533
597
  if (isDisabledFormControl(el)) return false;
@@ -564,7 +628,7 @@ function runInPage(ctx) {
564
628
  const tag = lower(el.tagName || '');
565
629
  let fallbackFocusable = false;
566
630
 
567
- if (tag === 'a' || tag === 'area') {
631
+ if (tag === 'a') {
568
632
  const href = trim(el.getAttribute('href'));
569
633
  fallbackFocusable = !!href;
570
634
  } else if (tag === 'button' || tag === 'select' || tag === 'textarea' || tag === 'summary') {
@@ -601,23 +665,7 @@ function runInPage(ctx) {
601
665
 
602
666
  // 2) exclude non-rendered / non-visible-by-style blockers
603
667
  // IMPORTANT: Do NOT exclude opacity-based invisibility; opacity:0 remains in-scope.
604
- if (isDomVisibleEligible) {
605
- try {
606
- const vis = isDomVisibleEligible(el, ctx, {
607
- visibilityMode: 'styleOnly',
608
- disableGeometry: true
609
- });
610
- if (vis && vis.eligible === false) {
611
- const rs = Array.isArray(vis.reasons) ? vis.reasons : [];
612
- const nonOpacity = rs.filter((r) => String(r) !== 'opacityZero');
613
- if (nonOpacity.length) return false;
614
- }
615
- } catch {
616
- // ignore
617
- }
618
- }
619
-
620
- return true;
668
+ return isRenderedForFocus(el);
621
669
  }
622
670
 
623
671
  function hasInertAncestor(el) {
@@ -635,6 +683,14 @@ function runInPage(ctx) {
635
683
  }
636
684
 
637
685
  function isDisabledFormControl(el) {
686
+ try {
687
+ // :disabled also covers a control disabled by an ancestor
688
+ // <fieldset disabled> (outside its first <legend>), which the
689
+ // `disabled` IDL attribute does not reflect.
690
+ if (typeof el.matches === 'function' && el.matches(':disabled')) return true;
691
+ } catch {
692
+ // ignore
693
+ }
638
694
  try {
639
695
  // Covers button/input/select/textarea/option/optgroup/fieldset etc.
640
696
  if (typeof el.disabled === 'boolean' && el.disabled) return true;
@@ -14,7 +14,9 @@
14
14
  * list for naming attributes (pure text-semantics / non-naming
15
15
  * structural roles: caption, code, deletion, emphasis, generic,
16
16
  * insertion, mark, none, paragraph, presentation, strong, subscript,
17
- * suggestion, superscript, time), and (b) elements with no role at all:
17
+ * suggestion, superscript, time), plus a native <caption> with no valid
18
+ * explicit role, whose implicit role is caption, and (b) elements with no
19
+ * role at all:
18
20
  * a curated set of native HTML tags verified to carry no implicit role
19
21
  * (see ROLELESS_NATIVE_TAGS below), or any autonomous custom element (a
20
22
  * hyphenated, author-defined tag per the Custom Elements spec; see
@@ -147,14 +149,25 @@ function runInPage(ctx) {
147
149
 
148
150
  // --- Tier 1: explicit, valid role from the naming-prohibited set ---
149
151
 
152
+ // A native <caption> has the caption role without saying so, and is judged
153
+ // the same way as role="caption"; only one that carries a naming attribute
154
+ // needs visiting.
155
+ const tier1Selector = '[role], caption[aria-label], caption[aria-labelledby]';
150
156
  const roleNodes = helpers.queryAllSmart
151
- ? helpers.queryAllSmart('[role]')
152
- : helpers.queryAll('[role]');
157
+ ? helpers.queryAllSmart(tier1Selector)
158
+ : helpers.queryAll(tier1Selector);
153
159
 
154
160
  for (const el of roleNodes) {
155
161
  if (!el || !el.getAttribute) continue;
156
162
 
157
- const role = ariaHelpers.getExplicitRole(el);
163
+ const explicitRole = ariaHelpers.getExplicitRole(el);
164
+ let role = explicitRole;
165
+ if (
166
+ (!explicitRole || !ariaHelpers.isValidConcreteRole(explicitRole)) &&
167
+ String(el.localName || '').toLowerCase() === 'caption'
168
+ ) {
169
+ role = 'caption';
170
+ }
158
171
  if (!role || !ROLES_PROHIBITING_NAME.has(role)) continue;
159
172
 
160
173
  applicableCount += 1;
@@ -370,6 +383,11 @@ function runInPage(ctx) {
370
383
  hintKey: 'ariaProhibitedAttr_hint_cantTell_roleless',
371
384
  params: { attr, element: tag }
372
385
  },
386
+ uncertainty: {
387
+ code: 'judgement-required',
388
+ needed: 'Whether the element’s own content already serves as its label.',
389
+ evidence: { attribute: attr, element: tag, role: null, hasOwnContent: true }
390
+ },
373
391
  data: {
374
392
  details: {
375
393
  reasonCode: 'ARIA_ATTR_PROHIBITED_ROLELESS_NEEDS_REVIEW',
@@ -7,7 +7,7 @@
7
7
  * @atomic true
8
8
  * @summary Container roles must not own an accessible-tree child with a disallowed role
9
9
  * @standard WCAG 2.2
10
- * @sc 4.1.2
10
+ * @sc 1.3.1
11
11
  * @applicability
12
12
  * Applies to elements with an explicit, valid role that is one of the
13
13
  * container roles with a documented "required owned elements" entry
@@ -126,14 +126,14 @@ const meta = {
126
126
  descriptionKey: 'ariaProhibitedChildren_description'
127
127
  },
128
128
  helpUrl: null,
129
- tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
130
- wcagSc: ['4.1.2'],
129
+ tags: ['wcag2a', 'wcag131', 'aria', 'structure', 'atomic', 'automatic'],
130
+ wcagSc: ['1.3.1'],
131
131
  normativeMappings: [
132
132
  {
133
133
  standard: 'WCAG',
134
134
  version: '2.2',
135
- requirement: '4.1.2',
136
- title: 'Name, Role, Value',
135
+ requirement: '1.3.1',
136
+ title: 'Info and Relationships',
137
137
  conformanceLevel: 'A'
138
138
  }
139
139
  ],
@@ -141,7 +141,7 @@ const meta = {
141
141
  category: 'robust',
142
142
  type: 'automatic',
143
143
  defaultConfidence: 'medium',
144
- coverage: { facetsBySc: { '4.1.2': ['aria-role-owned-children-allowed'] } }
144
+ coverage: { facetsBySc: { '1.3.1': ['aria-role-owned-children-allowed'] } }
145
145
  };
146
146
 
147
147
  function runInPage(ctx) {
@@ -18,9 +18,28 @@
18
18
  * <input type="checkbox" role="checkbox">, which is exempt because the
19
19
  * native control's own state exposure already covers it; no aria-checked
20
20
  * is required. helpers.aria.getNativeRoleForElement resolves this).
21
+ * A native <input type="checkbox"> or <input type="radio"> with another
22
+ * checkable role (switch, menuitemcheckbox, menuitemradio, or checkbox on
23
+ * a radio and the reverse) is in scope, but its aria-checked counts as
24
+ * supplied: the browser exposes the input's own checked state (HTML-AAM),
25
+ * so it passes without the attribute.
21
26
  * @expectation
22
- * Every required aria-* attribute for that role is present (and non-empty).
27
+ * Every required state/property for that role is present and non-empty.
28
+ * Graded by whether ARIA supplies a stand-in for the missing attribute:
29
+ * - FAIL where it does not, so the state is simply not exposed
30
+ * (aria-checked on checkbox/radio/switch/menuitemcheckbox/menuitemradio,
31
+ * aria-valuenow on slider/scrollbar/meter and on a focusable separator).
32
+ * - CANTTELL where ARIA defines an implicit value the role falls back to
33
+ * (aria-expanded on combobox, aria-level on heading), so the role still
34
+ * exposes a value and only the author knows whether it is the right one.
23
35
  * @implementation-notes
36
+ * - The implicit-value table is generated from aria-query's requiredProps by
37
+ * scripts/generate-aria-tables.js (REQUIRED_PROP_IMPLICIT_VALUES in
38
+ * src/core/aria-helpers.js), so the two tiers cannot drift apart from the
39
+ * spec by hand. ACT 4e8ab6 maps this rule's requirement to WAI-ARIA rather
40
+ * than to WCAG, and names 1.3.1/4.1.2 as "less strict" precisely because
41
+ * they "allow for fallback default values"; the cantTell tier is that
42
+ * carve-out, not a softening of the fail tier.
24
43
  * - Scoped to REQUIRED_PROPS_BY_ROLE in src/core/aria-helpers.js,
25
44
  * which only lists a required property when the spec is unambiguous and
26
45
  * context-independent, see that file's header for the rationale.
@@ -122,6 +141,23 @@ function runInPage(ctx) {
122
141
  }
123
142
  }
124
143
 
144
+ const CHECKABLE_ROLES = new Set([
145
+ 'checkbox',
146
+ 'switch',
147
+ 'radio',
148
+ 'menuitemcheckbox',
149
+ 'menuitemradio'
150
+ ]);
151
+
152
+ function isNativeCheckable(el) {
153
+ if (String(el.localName || '').toLowerCase() !== 'input') return false;
154
+ if (el.namespaceURI && el.namespaceURI !== 'http://www.w3.org/1999/xhtml') return false;
155
+ const type = String(el.getAttribute('type') || '')
156
+ .trim()
157
+ .toLowerCase();
158
+ return type === 'checkbox' || type === 'radio';
159
+ }
160
+
125
161
  function isMarkedBusy(el) {
126
162
  const v = el.getAttribute('aria-busy');
127
163
  return v != null && String(v).trim().toLowerCase() === 'true';
@@ -131,7 +167,8 @@ function runInPage(ctx) {
131
167
  ? helpers.queryAllSmart('[role]')
132
168
  : helpers.queryAll('[role]');
133
169
 
134
- const occurrences = [];
170
+ const failOccurrences = [];
171
+ const cantTellOccurrences = [];
135
172
  let applicableCount = 0;
136
173
 
137
174
  for (const el of nodes) {
@@ -150,6 +187,12 @@ function runInPage(ctx) {
150
187
 
151
188
  const required = ariaHelpers.getRequiredAttrsForRole(role).slice();
152
189
 
190
+ // A native checkbox or radio exposes its own checked state whatever
191
+ // checkable role it carries (HTML-AAM maps the checkedness; ARIA in HTML
192
+ // tells authors not to set aria-checked on it), so aria-checked is
193
+ // supplied on <input type="checkbox" role="switch"> and the like.
194
+ const nativeChecked = isNativeCheckable(el) && CHECKABLE_ROLES.has(role);
195
+
153
196
  // combobox's aria-controls is required only once the popup is actually
154
197
  // displayed (aria-expanded="true") -- see this file's header comment.
155
198
  if (role === 'combobox' && String(el.getAttribute('aria-expanded') || '').trim() === 'true') {
@@ -172,14 +215,50 @@ function runInPage(ctx) {
172
215
  const missing = [];
173
216
  for (const attr of required) {
174
217
  const v = el.getAttribute(attr);
218
+ if (attr === 'aria-checked' && nativeChecked) continue;
175
219
  if (v == null || String(v).trim() === '') missing.push(attr);
176
220
  }
177
221
 
178
222
  if (!missing.length) continue;
179
223
 
180
224
  for (const attr of missing) {
181
- occurrences.push(
225
+ const implicit =
226
+ typeof ariaHelpers.getRequiredAttrImplicitValue === 'function'
227
+ ? ariaHelpers.getRequiredAttrImplicitValue(role, attr)
228
+ : null;
229
+
230
+ if (implicit) {
231
+ cantTellOccurrences.push(
232
+ helpers.reportOccurrence(el, {
233
+ occurrenceOutcome: 'cantTell',
234
+ summary: `This attribute is required for this element’s role and is missing, but ARIA falls back to "${implicit}".`,
235
+ hint: 'Set the attribute explicitly if the implicit value is not the state you mean.',
236
+ i18n: {
237
+ summaryKey: 'ariaRequiredAttr_summary_cantTell',
238
+ hintKey: 'ariaRequiredAttr_hint_cantTell',
239
+ params: { attr, role, implicit }
240
+ },
241
+ uncertainty: {
242
+ code: 'spec-only',
243
+ needed: 'Whether the implicit fallback is the state the author meant.',
244
+ evidence: { attribute: attr, role, implicitValue: implicit }
245
+ },
246
+ data: {
247
+ details: {
248
+ reasonCode: 'ARIA_ATTR_REQUIRED_MISSING_IMPLICIT',
249
+ attr,
250
+ role,
251
+ implicitValue: implicit
252
+ }
253
+ }
254
+ })
255
+ );
256
+ continue;
257
+ }
258
+
259
+ failOccurrences.push(
182
260
  helpers.reportOccurrence(el, {
261
+ occurrenceOutcome: 'fail',
183
262
  summary: 'This attribute is required for this element’s role, but is missing.',
184
263
  hint: 'Add this attribute with a valid value for this role.',
185
264
  i18n: {
@@ -198,15 +277,12 @@ function runInPage(ctx) {
198
277
  if (applicableCount === 0) {
199
278
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
200
279
  }
201
- if (occurrences.length) {
202
- return {
203
- ruleId: rule.ruleId,
204
- outcome: 'fail',
205
- severity: rule.defaultSeverity || 'serious',
206
- occurrences
207
- };
208
- }
209
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
280
+ const resolved = helpers.resolveTieredOutcome(
281
+ failOccurrences,
282
+ cantTellOccurrences,
283
+ rule.defaultSeverity || 'serious'
284
+ );
285
+ return { ruleId: rule.ruleId, ...resolved };
210
286
  }
211
287
 
212
288
  module.exports = { id, meta, runInPage };
@@ -7,7 +7,7 @@
7
7
  * @atomic true
8
8
  * @summary Container roles that require specific owned elements must contain at least one
9
9
  * @standard WCAG 2.2
10
- * @sc 4.1.2
10
+ * @sc 1.3.1
11
11
  * @applicability
12
12
  * Applies to elements with an explicit, valid, non-abstract role that is
13
13
  * also one of the container roles with a documented "required owned
@@ -15,7 +15,17 @@
15
15
  * table, grid, treegrid, tablist, tree, row).
16
16
  * @expectation
17
17
  * At least one descendant, or one aria-owns-referenced element, has one
18
- * of the acceptable owned roles for that container role.
18
+ * of the acceptable owned roles for that container role. Reported at
19
+ * CANTTELL, never FAIL: this rule asks only whether the required content
20
+ * is PRESENT, and a container that owns nothing conveys nothing false --
21
+ * an empty role="list" is announced as a list with no items, which is what
22
+ * it is. Whether the content a container does own is VALID is
23
+ * aria-prohibited-children's decision, and that rule still fails, so a
24
+ * genuinely misdescribed structure (a role="button" among list items, a
25
+ * tablist of plain buttons) is caught with the same strength as before.
26
+ * The native-HTML equivalents already work this way: nothing in this
27
+ * ruleset fails an empty <ul>, and list-children-valid judges only the
28
+ * children that exist.
19
29
  * @implementation-notes
20
30
  * - Scoped to REQUIRED_OWNED_ROLES in src/core/aria-helpers.js
21
31
  * (see that file's header for the conservative-scope rationale).
@@ -75,14 +85,14 @@ const meta = {
75
85
  descriptionKey: 'ariaRequiredChildren_description'
76
86
  },
77
87
  helpUrl: null,
78
- tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
79
- wcagSc: ['4.1.2'],
88
+ tags: ['wcag2a', 'wcag131', 'aria', 'structure', 'atomic', 'automatic'],
89
+ wcagSc: ['1.3.1'],
80
90
  normativeMappings: [
81
91
  {
82
92
  standard: 'WCAG',
83
93
  version: '2.2',
84
- requirement: '4.1.2',
85
- title: 'Name, Role, Value',
94
+ requirement: '1.3.1',
95
+ title: 'Info and Relationships',
86
96
  conformanceLevel: 'A'
87
97
  }
88
98
  ],
@@ -90,7 +100,7 @@ const meta = {
90
100
  category: 'robust',
91
101
  type: 'automatic',
92
102
  defaultConfidence: 'medium',
93
- coverage: { facetsBySc: { '4.1.2': ['aria-role-required-owned-children'] } }
103
+ coverage: { facetsBySc: { '1.3.1': ['aria-role-required-owned-children'] } }
94
104
  };
95
105
 
96
106
  function runInPage(ctx) {
@@ -269,6 +279,16 @@ function runInPage(ctx) {
269
279
  hintKey: 'ariaRequiredChildren_hint_fail',
270
280
  params: { role, requiredRoles: requiredOwned.join(', ') }
271
281
  },
282
+ uncertainty: {
283
+ code: 'spec-only',
284
+ needed:
285
+ 'Whether this container is legitimately empty, or holds items that never got their role.',
286
+ evidence: {
287
+ role,
288
+ requiredOwnedRoles: requiredOwned,
289
+ childElementCount: el.children ? el.children.length : null
290
+ }
291
+ },
272
292
  data: {
273
293
  details: {
274
294
  reasonCode: 'ARIA_REQUIRED_CHILD_MISSING',
@@ -283,15 +303,12 @@ function runInPage(ctx) {
283
303
  if (applicableCount === 0) {
284
304
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
285
305
  }
286
- if (occurrences.length) {
287
- return {
288
- ruleId: rule.ruleId,
289
- outcome: 'fail',
290
- severity: rule.defaultSeverity || 'moderate',
291
- occurrences
292
- };
293
- }
294
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
306
+ const resolved = helpers.resolveTieredOutcome(
307
+ [],
308
+ occurrences,
309
+ rule.defaultSeverity || 'moderate'
310
+ );
311
+ return { ruleId: rule.ruleId, ...resolved };
295
312
  }
296
313
 
297
314
  module.exports = { id, meta, runInPage };
@@ -7,7 +7,7 @@
7
7
  * @atomic true
8
8
  * @summary Roles that require a specific ancestor/owner context role must have one
9
9
  * @standard WCAG 2.2
10
- * @sc 4.1.2
10
+ * @sc 1.3.1
11
11
  * @applicability
12
12
  * Applies to elements with an explicit, valid, non-abstract role that is
13
13
  * also one of the roles with a documented, non-empty "required context
@@ -43,6 +43,16 @@
43
43
  * aria-required-children/aria-prohibited-children, applied for
44
44
  * consistency; an element that isn't currently exposed to the
45
45
  * accessibility tree is skipped (notApplicable), not failed.
46
+ * - Also honors WAI-ARIA's escape hatch for a widget script has not
47
+ * finished assembling: "When a widget is missing required owned elements due to
48
+ * script execution or loading, authors MUST mark a containing element
49
+ * with aria-busy equal to true." aria-required-children reads that from
50
+ * the container it is checking; read from the item's side it is an
51
+ * ancestor, so the walk looks up rather than at the element itself, and
52
+ * only the exact string "true" counts. It also outranks the
53
+ * roleless-generic-parent rule below, since aria-busy is itself a global
54
+ * ARIA attribute and would otherwise block the context search and fail
55
+ * the very markup the spec says to mark.
46
56
  */
47
57
 
48
58
  const id = 'aria-required-parent';
@@ -56,14 +66,14 @@ const meta = {
56
66
  descriptionKey: 'ariaRequiredParent_description'
57
67
  },
58
68
  helpUrl: null,
59
- tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
60
- wcagSc: ['4.1.2'],
69
+ tags: ['wcag2a', 'wcag131', 'aria', 'structure', 'atomic', 'automatic'],
70
+ wcagSc: ['1.3.1'],
61
71
  normativeMappings: [
62
72
  {
63
73
  standard: 'WCAG',
64
74
  version: '2.2',
65
- requirement: '4.1.2',
66
- title: 'Name, Role, Value',
75
+ requirement: '1.3.1',
76
+ title: 'Info and Relationships',
67
77
  conformanceLevel: 'A'
68
78
  }
69
79
  ],
@@ -71,7 +81,7 @@ const meta = {
71
81
  category: 'robust',
72
82
  type: 'automatic',
73
83
  defaultConfidence: 'medium',
74
- coverage: { facetsBySc: { '4.1.2': ['aria-role-required-context-parent'] } }
84
+ coverage: { facetsBySc: { '1.3.1': ['aria-role-required-context-parent'] } }
75
85
  };
76
86
 
77
87
  function runInPage(ctx) {
@@ -183,6 +193,21 @@ function runInPage(ctx) {
183
193
  return n && n.parentElement ? n.parentElement : null;
184
194
  };
185
195
 
196
+ // The escape hatch marks the container being assembled, not the item inside
197
+ // it, so this walks up instead of reading the element's own attribute.
198
+ function hasBusyAncestor(el) {
199
+ let cur = getComposedParent(el);
200
+ let guard = 0;
201
+ while (cur && guard++ < 200) {
202
+ if (cur.nodeType === 1 && cur.getAttribute) {
203
+ const v = cur.getAttribute('aria-busy');
204
+ if (v != null && String(v).trim().toLowerCase() === 'true') return true;
205
+ }
206
+ cur = getComposedParent(cur);
207
+ }
208
+ return false;
209
+ }
210
+
186
211
  function hasAcceptableAncestorContext(el, acceptableRoles, ownRole) {
187
212
  const allowsGroup = acceptableRoles.has('group');
188
213
  let cur = getComposedParent(el);
@@ -252,6 +277,7 @@ function runInPage(ctx) {
252
277
  if (!requiredContext || !requiredContext.length) continue; // no entry, or explicitly unconstrained
253
278
 
254
279
  if (!isEligibleAcc(el)) continue; // not currently exposed to the accessibility tree
280
+ if (hasBusyAncestor(el)) continue; // author has signaled transient incompleteness per WAI-ARIA
255
281
 
256
282
  applicableCount += 1;
257
283
 
@@ -31,7 +31,11 @@
31
31
  * to non-empty text, or a non-empty title. Every role in the set is
32
32
  * name-from-author-only, so descendant text is not accepted:
33
33
  * a labelled child inside a composite widget would otherwise pass the
34
- * container that has no name of its own.
34
+ * container that has no name of its own. The name the HTML host element
35
+ * gives itself counts too, since the browser still computes it under the
36
+ * role: the first child <legend> of a <fieldset>, the first child
37
+ * <caption> of a <table>, and an associated <label> on a labelable
38
+ * element such as <progress> or <meter>.
35
39
  */
36
40
 
37
41
  const id = 'aria-role-name-present';
@@ -45,7 +49,7 @@ const meta = {
45
49
  descriptionKey: 'ariaRoleNamePresent_description'
46
50
  },
47
51
  helpUrl: null,
48
- tags: ['wcag2a', 'wcag412', 'navigation', 'atomic', 'automatic', 'name'],
52
+ tags: ['wcag2a', 'wcag412', 'aria', 'navigation', 'atomic', 'automatic', 'name'],
49
53
  wcagSc: ['4.1.2'],
50
54
  normativeMappings: [
51
55
  {
@@ -186,7 +190,20 @@ function runInPage(ctx) {
186
190
 
187
191
  const title = ariaLabel || labelled ? '' : getAttr(el, 'title');
188
192
 
189
- const ok = !!(ariaLabel || labelled || title);
193
+ // The host's own HTML naming still applies under the role: the first
194
+ // <legend> of <fieldset role="radiogroup">, the <caption> of
195
+ // <table role="grid">, a <label> of <progress role="progressbar">.
196
+ let hostName = '';
197
+ if (!(ariaLabel || labelled || title) && helpers.getNativeHostNameInfo) {
198
+ try {
199
+ const host = helpers.getNativeHostNameInfo(el, ctx);
200
+ hostName = host && host.present ? host.value : '';
201
+ } catch {
202
+ hostName = '';
203
+ }
204
+ }
205
+
206
+ const ok = !!(ariaLabel || labelled || title || hostName);
190
207
  if (ok) continue;
191
208
 
192
209
  const eligInfo = getEligibilityInfo