@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.
- package/CHANGELOG.md +140 -0
- package/README.md +179 -90
- package/docs/ACT_RULE_MAPPING.md +10 -8
- package/docs/API_STABILITY.md +67 -6
- package/docs/BINDING_AUTHORS_GUIDE.md +104 -2
- package/docs/CI_INTEGRATIONS.md +43 -0
- package/docs/DESIGN_CHALLENGES.md +162 -2
- package/docs/EARL.md +100 -0
- package/docs/ENGINE_OPTIONS.md +109 -5
- package/docs/I18N.md +62 -20
- package/docs/INTEGRATION.md +4 -2
- package/docs/JUNIT.md +73 -0
- package/docs/LIMITATIONS.md +4 -1
- package/docs/OUTPUT_SCHEMA.md +62 -11
- package/docs/POLICY.md +1 -1
- package/docs/REPORT.md +7 -2
- package/docs/RULE_AUTHORING.md +83 -17
- package/docs/RULE_CATALOG.md +212 -139
- package/docs/RULE_EXAMPLES.md +2189 -0
- package/docs/RULE_HELPERS.md +390 -0
- package/docs/RULE_TAXONOMY.md +27 -6
- package/docs/SARIF.md +23 -3
- package/docs/WCAG_CONFORMANCE.md +64 -3
- package/package.json +41 -12
- package/profiles/index.js +14 -0
- package/src/checks/automatic/area-alt-present.js +87 -31
- package/src/checks/automatic/aria-allowed-attr.js +6 -0
- package/src/checks/automatic/aria-allowed-role.js +32 -23
- package/src/checks/automatic/aria-braille-equivalent.js +43 -17
- package/src/checks/automatic/aria-conditional-attr.js +17 -10
- package/src/checks/automatic/aria-deprecated-role.js +12 -0
- package/src/checks/automatic/aria-hidden-body.js +1 -1
- package/src/checks/automatic/aria-hidden-focus.js +74 -18
- package/src/checks/automatic/aria-prohibited-attr.js +22 -4
- package/src/checks/automatic/aria-prohibited-children.js +6 -6
- package/src/checks/automatic/aria-required-attr.js +88 -12
- package/src/checks/automatic/aria-required-children.js +33 -16
- package/src/checks/automatic/aria-required-parent.js +32 -6
- package/src/checks/automatic/aria-role-name-present.js +20 -3
- package/src/checks/automatic/aria-roles-valid.js +52 -21
- package/src/checks/automatic/aria-valid-attr-value.js +89 -24
- package/src/checks/automatic/aria-valid-attr.js +14 -9
- package/src/checks/automatic/autocomplete-valid.js +152 -26
- package/src/checks/automatic/avoid-inline-spacing.js +207 -15
- package/src/checks/automatic/button-name-present.js +2 -1
- package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
- package/src/checks/automatic/combobox-name-present.js +34 -51
- package/src/checks/automatic/contrast-computable.js +45 -4
- package/src/checks/automatic/contrast-enhanced.js +16 -4
- package/src/checks/automatic/contrast-minimum.js +57 -11
- package/src/checks/automatic/css-orientation-lock.js +171 -12
- package/src/checks/automatic/definition-list-children-valid.js +67 -23
- package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
- package/src/checks/automatic/dialog-name-present.js +28 -9
- package/src/checks/automatic/duplicate-id-aria.js +5 -0
- package/src/checks/automatic/duplicate-id.js +19 -10
- package/src/checks/automatic/form-control-single-label.js +9 -0
- package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
- package/src/checks/automatic/iframe-focusable-content.js +12 -4
- package/src/checks/automatic/iframe-title-unique.js +36 -81
- package/src/checks/automatic/input-image-alt-present.js +32 -20
- package/src/checks/automatic/label-in-name.js +78 -69
- package/src/checks/automatic/language-page-present.js +12 -6
- package/src/checks/automatic/link-in-text-block.js +512 -44
- package/src/checks/automatic/link-name-present.js +13 -5
- package/src/checks/automatic/list-children-valid.js +18 -1
- package/src/checks/automatic/listbox-name-present.js +19 -49
- package/src/checks/automatic/listitem-parent-valid.js +4 -3
- package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
- package/src/checks/automatic/page-title-present.js +16 -4
- package/src/checks/automatic/progressbar-name-present.js +11 -1
- package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +9 -5
- package/src/checks/automatic/searchbox-name-present.js +32 -49
- package/src/checks/automatic/server-side-image-map-absent.js +48 -28
- package/src/checks/automatic/slider-name-present.js +38 -52
- package/src/checks/automatic/spinbutton-name-present.js +32 -49
- package/src/checks/automatic/target-size-minimum.js +84 -16
- package/src/checks/automatic/td-has-header.js +60 -23
- package/src/checks/automatic/text-spacing-content-loss.js +548 -0
- package/src/checks/automatic/textbox-name-present.js +32 -49
- package/src/checks/automatic/valid-lang.js +15 -10
- package/src/checks/manual/area-alt-quality-manual.js +113 -31
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
- package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
- package/src/checks/manual/css-hidden-focus.js +215 -7
- package/src/checks/manual/form-control-label-quality-manual.js +243 -29
- package/src/checks/manual/heading-order-manual.js +9 -1
- package/src/checks/manual/heading-quality-manual.js +143 -9
- package/src/checks/manual/img-alt-decorative-manual.js +6 -3
- package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
- package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
- package/src/checks/manual/link-name-quality-manual.js +130 -4
- package/src/checks/manual/media-transcript-present-manual.js +65 -8
- package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
- package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
- package/src/checks/manual/p-as-heading-manual.js +89 -44
- package/src/checks/manual/page-title-patterns-manual.js +77 -8
- package/src/checks/manual/password-paste-enabled-manual.js +255 -0
- package/src/checks/manual/skip-link-manual.js +42 -14
- package/src/checks/manual/table-fake-caption-manual.js +32 -1
- package/src/checks/manual/video-caption-manual.js +47 -24
- package/src/checks/manual-review.js +0 -4
- package/src/core.js +18061 -46194
- package/src/coverage/en301549-map.js +187 -0
- package/src/coverage/standards.js +279 -0
- package/src/coverage/wcag-facets.js +1119 -0
- package/src/coverage/wcag-version-map.js +101 -0
- package/src/earl.js +144 -0
- package/src/en301549.js +33 -0
- package/src/junit.js +321 -0
- package/src/profile-kit.js +163 -0
- package/src/report.js +343 -74
- package/src/sarif.js +56 -5
- package/src/wcag.js +105 -0
- package/surea11y.browser.js +11 -41039
- package/surea11y.i18n.de.js +2 -21
- package/surea11y.i18n.es.js +2 -21
- package/surea11y.i18n.fr.js +2 -21
- package/surea11y.i18n.ja.js +3 -0
- 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'
|
|
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
|
-
|
|
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),
|
|
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(
|
|
152
|
-
: helpers.queryAll(
|
|
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
|
|
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
|
|
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', '
|
|
130
|
-
wcagSc: ['
|
|
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: '
|
|
136
|
-
title: '
|
|
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: { '
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
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', '
|
|
79
|
-
wcagSc: ['
|
|
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: '
|
|
85
|
-
title: '
|
|
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: { '
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
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', '
|
|
60
|
-
wcagSc: ['
|
|
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: '
|
|
66
|
-
title: '
|
|
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: { '
|
|
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
|
-
|
|
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
|