@surea11y/core 1.4.0 → 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.
- package/CHANGELOG.md +88 -7
- package/README.md +19 -3
- package/bin/surea11y-core.js +0 -0
- package/docs/API_STABILITY.md +2 -2
- package/docs/ARIA_DEPRECATION.md +95 -0
- package/docs/ENGINE_OPTIONS.md +14 -10
- package/docs/I18N.md +176 -20
- package/docs/INTEGRATION.md +28 -6
- package/docs/LIMITATIONS.md +1 -1
- package/docs/OUTPUT_SCHEMA.md +13 -3
- package/docs/REPORT.md +2 -0
- package/docs/RULE_AUTHORING.md +53 -0
- package/docs/RULE_CATALOG.md +6 -6
- package/docs/TROUBLESHOOTING.md +2 -2
- package/package.json +8 -1
- package/src/checks/automatic/aria-allowed-attr.js +661 -99
- package/src/checks/automatic/aria-allowed-role.js +14 -16
- package/src/checks/automatic/aria-braille-equivalent.js +17 -19
- package/src/checks/automatic/aria-conditional-attr.js +17 -19
- package/src/checks/automatic/aria-deprecated-role.js +122 -41
- package/src/checks/automatic/aria-hidden-body.js +2 -9
- package/src/checks/automatic/aria-hidden-focus.js +99 -18
- package/src/checks/automatic/aria-prohibited-attr.js +54 -55
- package/src/checks/automatic/aria-prohibited-children.js +26 -26
- package/src/checks/automatic/aria-required-attr.js +14 -17
- package/src/checks/automatic/aria-required-children.js +17 -20
- package/src/checks/automatic/aria-required-parent.js +17 -20
- package/src/checks/automatic/aria-roles-valid.js +66 -27
- package/src/checks/automatic/aria-valid-attr-value.js +18 -21
- package/src/checks/automatic/aria-valid-attr.js +14 -17
- package/src/checks/automatic/autocomplete-valid.js +52 -18
- package/src/checks/automatic/avoid-inline-spacing.js +192 -34
- package/src/checks/automatic/binary-control-name-present.js +30 -24
- package/src/checks/automatic/button-name-present.js +73 -45
- package/src/checks/automatic/canvas-text-alternative-present.js +15 -8
- package/src/checks/automatic/combobox-name-present.js +23 -18
- package/src/checks/automatic/css-orientation-lock.js +22 -22
- package/src/checks/automatic/definition-list-children-valid.js +18 -21
- package/src/checks/automatic/deprecated-elements-not-used.js +14 -16
- package/src/checks/automatic/dialog-name-present.js +24 -19
- package/src/checks/automatic/dlitem-parent-valid.js +15 -17
- package/src/checks/automatic/duplicate-id-aria.js +45 -37
- package/src/checks/automatic/form-control-programmatic-label-present.js +48 -4
- package/src/checks/automatic/form-control-single-label.js +109 -43
- package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
- package/src/checks/automatic/iframe-focusable-content.js +29 -31
- package/src/checks/automatic/iframe-name-present.js +15 -17
- package/src/checks/automatic/iframe-title-unique.js +18 -23
- package/src/checks/automatic/img-alt-present.js +16 -9
- package/src/checks/automatic/input-image-alt-present.js +99 -48
- package/src/checks/automatic/label-in-name.js +102 -33
- package/src/checks/automatic/language-page-present.js +5 -1
- package/src/checks/automatic/link-in-text-block.js +19 -21
- package/src/checks/automatic/link-name-present.js +75 -47
- package/src/checks/automatic/list-children-valid.js +15 -17
- package/src/checks/automatic/listbox-name-present.js +23 -18
- package/src/checks/automatic/listitem-parent-valid.js +14 -17
- package/src/checks/automatic/menuitem-name-present.js +24 -19
- package/src/checks/automatic/meta-refresh-no-exceptions.js +44 -18
- package/src/checks/automatic/meta-refresh-timing-absent.js +44 -21
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +51 -32
- package/src/checks/automatic/meter-name-present.js +24 -19
- package/src/checks/automatic/nested-interactive-controls-absent.js +179 -51
- package/src/checks/automatic/object-text-alternative-present.js +14 -7
- package/src/checks/automatic/option-name-present.js +24 -19
- package/src/checks/automatic/progressbar-name-present.js +27 -22
- package/src/checks/automatic/searchbox-name-present.js +27 -18
- package/src/checks/automatic/server-side-image-map-absent.js +15 -18
- package/src/checks/automatic/slider-name-present.js +26 -19
- package/src/checks/automatic/spinbutton-name-present.js +27 -18
- package/src/checks/automatic/summary-name-present.js +24 -19
- package/src/checks/automatic/tab-name-present.js +24 -19
- package/src/checks/automatic/table-headers-attr-valid.js +15 -17
- package/src/checks/automatic/table-th-has-data-cells.js +82 -25
- package/src/checks/automatic/target-size-minimum.js +104 -81
- package/src/checks/automatic/td-has-header.js +15 -20
- package/src/checks/automatic/textbox-name-present.js +23 -18
- package/src/checks/automatic/tooltip-name-present.js +24 -19
- package/src/checks/automatic/treeitem-name-present.js +24 -19
- package/src/checks/automatic/valid-lang.js +31 -20
- package/src/checks/manual/accesskeys-manual.js +18 -19
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +19 -22
- package/src/checks/{automatic/bypass-blocks-present.js → manual/bypass-blocks-present-manual.js} +98 -44
- package/src/checks/manual/empty-heading-manual.js +15 -17
- package/src/checks/manual/empty-table-header-manual.js +27 -30
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -9
- package/src/checks/manual/heading-order-manual.js +17 -22
- package/src/checks/manual/image-redundant-alt-manual.js +14 -17
- package/src/checks/manual/input-image-alt-decorative-manual.js +24 -0
- package/src/checks/manual/label-title-only-manual.js +15 -17
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +25 -39
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +21 -27
- package/src/checks/manual/landmark-main-is-top-level-manual.js +14 -17
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +2 -7
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +2 -7
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -7
- package/src/checks/manual/landmark-one-main-manual.js +2 -9
- package/src/checks/manual/landmark-unique-manual.js +22 -27
- package/src/checks/manual/link-name-quality-manual.js +15 -17
- package/src/checks/manual/meta-viewport-large-manual.js +14 -17
- package/src/checks/manual/mouse-only-event-handlers-manual.js +17 -19
- package/src/checks/manual/page-has-heading-one-manual.js +2 -9
- package/src/checks/manual/presentation-role-conflict-manual.js +19 -21
- package/src/checks/manual/region-manual.js +13 -6
- package/src/checks/manual/scope-attr-valid-manual.js +14 -17
- package/src/checks/manual/skip-link-manual.js +39 -47
- package/src/checks/manual/tabindex-manual.js +14 -17
- package/src/checks/manual/table-duplicate-name-manual.js +14 -17
- package/src/core.js +8818 -4330
- package/src/report.js +14 -0
- package/src/sarif.js +2 -2
- package/surea11y.browser.js +4023 -3911
- package/surea11y.i18n.de.js +22 -0
- package/surea11y.i18n.es.js +22 -0
- package/surea11y.i18n.fr.js +22 -0
|
@@ -82,24 +82,22 @@ function runInPage(ctx) {
|
|
|
82
82
|
|
|
83
83
|
if (info.allowed) continue;
|
|
84
84
|
|
|
85
|
-
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
86
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
87
85
|
const tag = (el.tagName || '').toLowerCase();
|
|
88
86
|
|
|
89
|
-
occurrences.push(
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
|
|
87
|
+
occurrences.push(
|
|
88
|
+
helpers.reportOccurrence(el, {
|
|
89
|
+
summary: 'This role is not permitted on this element.',
|
|
90
|
+
hint: 'Use a role permitted for this element, or change the host element.',
|
|
91
|
+
i18n: {
|
|
92
|
+
summaryKey: 'ariaAllowedRole_summary_fail',
|
|
93
|
+
hintKey: 'ariaAllowedRole_hint_fail',
|
|
94
|
+
params: { role, element: tag }
|
|
95
|
+
},
|
|
96
|
+
data: {
|
|
97
|
+
details: { reasonCode: 'ARIA_ROLE_NOT_ALLOWED_FOR_ELEMENT', role, element: tag }
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
);
|
|
103
101
|
}
|
|
104
102
|
|
|
105
103
|
if (applicableCount === 0) {
|
|
@@ -119,29 +119,27 @@ function runInPage(ctx) {
|
|
|
119
119
|
|
|
120
120
|
if (!missing.length) continue;
|
|
121
121
|
|
|
122
|
-
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
123
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
124
122
|
const tag = (el.tagName || '').toLowerCase();
|
|
125
123
|
|
|
126
124
|
for (const m of missing) {
|
|
127
|
-
occurrences.push(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
125
|
+
occurrences.push(
|
|
126
|
+
helpers.reportOccurrence(el, {
|
|
127
|
+
summary: `This element has ${m.attr} but no ${m.requires}, its non-braille equivalent.`,
|
|
128
|
+
hint: `${m.attr} is a Braille-specific supplement, not a replacement — also provide ${m.requires}.`,
|
|
129
|
+
i18n: {
|
|
130
|
+
summaryKey: 'ariaBrailleEquivalent_summary_fail',
|
|
131
|
+
hintKey: 'ariaBrailleEquivalent_hint_fail',
|
|
132
|
+
params: { element: tag, attr: m.attr, requires: m.requires }
|
|
133
|
+
},
|
|
134
|
+
data: {
|
|
135
|
+
details: {
|
|
136
|
+
reasonCode: 'BRAILLE_ATTR_WITHOUT_EQUIVALENT',
|
|
137
|
+
attr: m.attr,
|
|
138
|
+
requires: m.requires
|
|
139
|
+
}
|
|
142
140
|
}
|
|
143
|
-
}
|
|
144
|
-
|
|
141
|
+
})
|
|
142
|
+
);
|
|
145
143
|
}
|
|
146
144
|
}
|
|
147
145
|
|
|
@@ -86,28 +86,26 @@ function runInPage(ctx) {
|
|
|
86
86
|
const invalidValue = trim(el.getAttribute('aria-invalid')).toLowerCase();
|
|
87
87
|
if (TRUTHY_INVALID_VALUES.has(invalidValue)) continue;
|
|
88
88
|
|
|
89
|
-
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
90
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
91
89
|
const tag = (el.tagName || '').toLowerCase();
|
|
92
90
|
|
|
93
|
-
occurrences.push(
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
'
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
91
|
+
occurrences.push(
|
|
92
|
+
helpers.reportOccurrence(el, {
|
|
93
|
+
summary:
|
|
94
|
+
'This element has aria-errormessage but aria-invalid is missing or "false", so the error message is not exposed.',
|
|
95
|
+
hint: 'Set aria-invalid to "true" (or "grammar"/"spelling") whenever aria-errormessage should be exposed to assistive technology.',
|
|
96
|
+
i18n: {
|
|
97
|
+
summaryKey: 'ariaConditionalAttr_summary_fail',
|
|
98
|
+
hintKey: 'ariaConditionalAttr_hint_fail',
|
|
99
|
+
params: { element: tag, ariaInvalid: invalidValue || '(absent)' }
|
|
100
|
+
},
|
|
101
|
+
data: {
|
|
102
|
+
details: {
|
|
103
|
+
reasonCode: 'ARIA_ERRORMESSAGE_WITHOUT_TRUTHY_INVALID',
|
|
104
|
+
ariaInvalid: invalidValue
|
|
105
|
+
}
|
|
108
106
|
}
|
|
109
|
-
}
|
|
110
|
-
|
|
107
|
+
})
|
|
108
|
+
);
|
|
111
109
|
}
|
|
112
110
|
|
|
113
111
|
if (applicableCount === 0) {
|
|
@@ -5,30 +5,36 @@
|
|
|
5
5
|
/**
|
|
6
6
|
* @check aria-deprecated-role
|
|
7
7
|
* @atomic true
|
|
8
|
-
* @summary An explicit role attribute
|
|
8
|
+
* @summary An explicit role attribute should not use a deprecated or author-discouraged ARIA role
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
10
|
* @sc 4.1.2
|
|
11
11
|
* @applicability
|
|
12
12
|
* Applies to any element whose role attribute's first (used) token is a
|
|
13
|
-
* valid, non-abstract ARIA role that authors
|
|
13
|
+
* valid, non-abstract ARIA role that authors should not explicitly
|
|
14
14
|
* declare — either because WAI-ARIA has deprecated it (e.g. "directory",
|
|
15
|
-
* superseded by role="list") or because it
|
|
16
|
-
* internal use
|
|
17
|
-
*
|
|
18
|
-
* MDN's "It should not be used by web authors" guidance).
|
|
15
|
+
* superseded by role="list") or because it is reserved for user-agent-
|
|
16
|
+
* internal use (role="generic", which ARIA 1.2 §5.4 says authors SHOULD
|
|
17
|
+
* NOT use in content).
|
|
19
18
|
* @expectation
|
|
20
|
-
* The role in use is neither deprecated nor
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
19
|
+
* The role in use is neither deprecated nor reserved. Graded by the
|
|
20
|
+
* strength of the rule ARIA states:
|
|
21
|
+
* - CANTTELL at SHOULD NOT, which leaves the usage conforming, so the
|
|
22
|
+
* author decides whether it matters: a deprecated role ("directory") or
|
|
23
|
+
* one reserved for user agents ("generic").
|
|
24
|
+
* - FAIL at MUST NOT. No ARIA 1.2 or 1.3 role carries an author MUST NOT
|
|
25
|
+
* outside the abstract roles, so this outcome is reserved for a later
|
|
26
|
+
* revision promoting a role to that strength.
|
|
27
|
+
* Distinct, atomic decision from aria-roles-valid (existence/
|
|
28
|
+
* abstractness): a role can be valid and non-abstract while still being
|
|
29
|
+
* discouraged in explicit author use.
|
|
24
30
|
*/
|
|
25
31
|
|
|
26
32
|
const id = 'aria-deprecated-role';
|
|
27
33
|
|
|
28
34
|
const meta = {
|
|
29
|
-
title: 'role attribute
|
|
35
|
+
title: 'role attribute should not use a deprecated or author-discouraged ARIA role',
|
|
30
36
|
description:
|
|
31
|
-
'Checks that an explicit role="" attribute does not use a role deprecated by the WAI-ARIA specification, or one reserved for user-agent-internal use
|
|
37
|
+
'Checks that an explicit role="" attribute does not use a role deprecated by the WAI-ARIA specification, or one reserved for user-agent-internal use (e.g. role="generic").',
|
|
32
38
|
i18n: {
|
|
33
39
|
titleKey: 'ariaDeprecatedRole_title',
|
|
34
40
|
descriptionKey: 'ariaDeprecatedRole_description'
|
|
@@ -64,12 +70,43 @@ function runInPage(ctx) {
|
|
|
64
70
|
? helpers.queryAllSmart('[role]')
|
|
65
71
|
: helpers.queryAll('[role]');
|
|
66
72
|
|
|
67
|
-
const
|
|
73
|
+
const failOccurrences = [];
|
|
74
|
+
const cantTellOccurrences = [];
|
|
68
75
|
let applicableCount = 0;
|
|
69
76
|
|
|
77
|
+
// A role on an element hidden from assistive technology has no effect, so
|
|
78
|
+
// ACT 674b10 does not apply to it.
|
|
79
|
+
function isHidden(el) {
|
|
80
|
+
try {
|
|
81
|
+
if (typeof helpers.isDomVisibleEligible === 'function') {
|
|
82
|
+
if (!helpers.isDomVisibleEligible(el, ctx)) return true;
|
|
83
|
+
}
|
|
84
|
+
// Walk the composed tree, not parentElement: that stops at a shadow
|
|
85
|
+
// root, so a host carrying aria-hidden or inert would never be seen
|
|
86
|
+
// from inside its own shadow content.
|
|
87
|
+
const up =
|
|
88
|
+
typeof helpers.composedParent === 'function'
|
|
89
|
+
? helpers.composedParent
|
|
90
|
+
: (n) => n.parentElement;
|
|
91
|
+
|
|
92
|
+
// A shadow root has no getAttribute, so skip past it rather than
|
|
93
|
+
// stopping: the host one step further up is the node that matters.
|
|
94
|
+
for (let n = el; n; n = up(n)) {
|
|
95
|
+
if (!n.getAttribute) continue;
|
|
96
|
+
if (String(n.getAttribute('aria-hidden') || '').toLowerCase() === 'true') return true;
|
|
97
|
+
if (n.hasAttribute && n.hasAttribute('inert')) return true;
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
70
105
|
for (const el of nodes) {
|
|
71
106
|
if (!el || !el.getAttribute) continue;
|
|
72
107
|
|
|
108
|
+
if (isHidden(el)) continue;
|
|
109
|
+
|
|
73
110
|
const role = ariaHelpers.getExplicitRole(el);
|
|
74
111
|
if (!role) continue;
|
|
75
112
|
|
|
@@ -79,42 +116,86 @@ function runInPage(ctx) {
|
|
|
79
116
|
|
|
80
117
|
applicableCount += 1;
|
|
81
118
|
|
|
82
|
-
|
|
119
|
+
const deprecated = ariaHelpers.isDeprecatedRole(role);
|
|
120
|
+
const discouraged =
|
|
121
|
+
typeof ariaHelpers.isAuthorDiscouragedRole === 'function' &&
|
|
122
|
+
ariaHelpers.isAuthorDiscouragedRole(role);
|
|
123
|
+
const prohibited =
|
|
124
|
+
typeof ariaHelpers.isAuthorProhibitedRole === 'function' &&
|
|
125
|
+
ariaHelpers.isAuthorProhibitedRole(role);
|
|
126
|
+
if (!deprecated && !discouraged && !prohibited) continue;
|
|
83
127
|
|
|
84
|
-
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
85
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
86
128
|
const guidance = ariaHelpers.getDeprecatedRoleGuidance
|
|
87
129
|
? ariaHelpers.getDeprecatedRoleGuidance(role)
|
|
88
|
-
:
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
130
|
+
: {
|
|
131
|
+
key: 'ariaDeprecatedRole_guidance_default',
|
|
132
|
+
text: 'Replace the deprecated role with its recommended replacement.'
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
if (prohibited) {
|
|
136
|
+
// Author MUST NOT: the usage is non-conforming, not merely discouraged.
|
|
137
|
+
failOccurrences.push(
|
|
138
|
+
helpers.reportOccurrence(el, {
|
|
139
|
+
summary: `This element uses role="${role}", which authors must not explicitly declare.`,
|
|
140
|
+
hint: guidance.text,
|
|
141
|
+
occurrenceOutcome: 'fail',
|
|
142
|
+
i18n: {
|
|
143
|
+
summaryKey: 'ariaDeprecatedRole_summary_fail',
|
|
144
|
+
hintKey: guidance.key,
|
|
145
|
+
params: { role }
|
|
146
|
+
},
|
|
147
|
+
data: {
|
|
148
|
+
details: { reasonCode: 'ARIA_ROLE_AUTHOR_PROHIBITED', role, guidance: guidance.text }
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
);
|
|
152
|
+
} else if (discouraged) {
|
|
153
|
+
// Reserved for user-agent-internal use, at SHOULD NOT strength.
|
|
154
|
+
cantTellOccurrences.push(
|
|
155
|
+
helpers.reportOccurrence(el, {
|
|
156
|
+
summary: `This element uses role="${role}", which is reserved for user agents (still valid, but discouraged).`,
|
|
157
|
+
hint: guidance.text,
|
|
158
|
+
occurrenceOutcome: 'cantTell',
|
|
159
|
+
i18n: {
|
|
160
|
+
summaryKey: 'ariaDeprecatedRole_summary_cantTell_discouraged',
|
|
161
|
+
hintKey: guidance.key,
|
|
162
|
+
params: { role }
|
|
163
|
+
},
|
|
164
|
+
data: {
|
|
165
|
+
details: { reasonCode: 'ARIA_ROLE_AUTHOR_DISCOURAGED', role, guidance: guidance.text }
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
);
|
|
169
|
+
} else {
|
|
170
|
+
// Deprecated but still valid: surfaced for the author to decide.
|
|
171
|
+
cantTellOccurrences.push(
|
|
172
|
+
helpers.reportOccurrence(el, {
|
|
173
|
+
summary: `This element uses role="${role}", which is deprecated in WAI-ARIA.`,
|
|
174
|
+
hint: guidance.text,
|
|
175
|
+
occurrenceOutcome: 'cantTell',
|
|
176
|
+
i18n: {
|
|
177
|
+
summaryKey: 'ariaDeprecatedRole_summary_cantTell',
|
|
178
|
+
hintKey: guidance.key,
|
|
179
|
+
params: { role }
|
|
180
|
+
},
|
|
181
|
+
data: {
|
|
182
|
+
details: { reasonCode: 'ARIA_ROLE_DEPRECATED', role, guidance: guidance.text }
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
);
|
|
186
|
+
}
|
|
104
187
|
}
|
|
105
188
|
|
|
106
189
|
if (applicableCount === 0) {
|
|
107
190
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
108
191
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
192
|
+
|
|
193
|
+
const resolved = helpers.resolveTieredOutcome(
|
|
194
|
+
failOccurrences,
|
|
195
|
+
cantTellOccurrences,
|
|
196
|
+
rule.defaultSeverity || 'moderate'
|
|
197
|
+
);
|
|
198
|
+
return { ruleId: rule.ruleId, ...resolved };
|
|
118
199
|
}
|
|
119
200
|
|
|
120
201
|
module.exports = { id, meta, runInPage };
|
|
@@ -86,15 +86,8 @@ function runInPage(ctx) {
|
|
|
86
86
|
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
const stableSelector = helpers.buildSelector ? helpers.buildSelector(body) : 'body';
|
|
90
|
-
const html = helpers.getOuterHtmlSnippet
|
|
91
|
-
? helpers.getOuterHtmlSnippet(body)
|
|
92
|
-
: (body.outerHTML || '').slice(0, 200);
|
|
93
|
-
|
|
94
89
|
const occurrences = [
|
|
95
|
-
{
|
|
96
|
-
selector: stableSelector,
|
|
97
|
-
html,
|
|
90
|
+
helpers.reportOccurrence(body, {
|
|
98
91
|
summary:
|
|
99
92
|
'The document body has aria-hidden="true", which hides the entire page from assistive technologies.',
|
|
100
93
|
hint: 'Remove aria-hidden from <body>. Hide specific elements instead, if that was the intent.',
|
|
@@ -106,7 +99,7 @@ function runInPage(ctx) {
|
|
|
106
99
|
data: {
|
|
107
100
|
details: { reasonCode: 'ARIA_HIDDEN_BODY' }
|
|
108
101
|
}
|
|
109
|
-
}
|
|
102
|
+
})
|
|
110
103
|
];
|
|
111
104
|
|
|
112
105
|
return {
|
|
@@ -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
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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:
|
|
775
|
-
hint:
|
|
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:
|
|
781
|
-
|
|
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:
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
}
|