@surea11y/core 1.2.0 → 1.4.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 +81 -7
- package/LICENSE +373 -21
- package/README.md +175 -35
- package/bin/surea11y-core.js +20 -0
- package/docs/API_STABILITY.md +27 -1
- package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
- package/docs/CI_INTEGRATIONS.md +103 -0
- package/docs/ENGINE_OPTIONS.md +2 -0
- package/docs/I18N.md +12 -9
- package/docs/INTEGRATION.md +19 -1
- package/docs/LIMITATIONS.md +1 -1
- package/docs/OUTPUT_SCHEMA.md +1 -1
- package/docs/REPORT.md +1 -1
- package/docs/RULE_CATALOG.md +1 -1
- package/docs/SARIF.md +59 -0
- package/package.json +63 -18
- package/src/baseline.js +0 -0
- package/src/checks/automatic/area-alt-present.js +63 -31
- package/src/checks/automatic/aria-allowed-attr.js +204 -80
- package/src/checks/automatic/aria-allowed-role.js +23 -7
- package/src/checks/automatic/aria-braille-equivalent.js +34 -10
- package/src/checks/automatic/aria-conditional-attr.js +32 -14
- package/src/checks/automatic/aria-deprecated-role.js +26 -11
- package/src/checks/automatic/aria-hidden-body.js +48 -23
- package/src/checks/automatic/aria-hidden-focus.js +420 -66
- package/src/checks/automatic/aria-prohibited-attr.js +327 -60
- package/src/checks/automatic/aria-prohibited-children.js +111 -103
- package/src/checks/automatic/aria-required-attr.js +29 -15
- package/src/checks/automatic/aria-required-children.js +44 -24
- package/src/checks/automatic/aria-required-parent.js +64 -35
- package/src/checks/automatic/aria-role-name-present.js +49 -21
- package/src/checks/automatic/aria-roles-valid.js +24 -12
- package/src/checks/automatic/aria-valid-attr-value.js +46 -22
- package/src/checks/automatic/aria-valid-attr.js +19 -5
- package/src/checks/automatic/autocomplete-valid.js +76 -16
- package/src/checks/automatic/avoid-inline-spacing.js +23 -8
- package/src/checks/automatic/binary-control-name-present.js +62 -50
- package/src/checks/automatic/button-name-present.js +54 -24
- package/src/checks/automatic/bypass-blocks-present.js +51 -32
- package/src/checks/automatic/canvas-text-alternative-present.js +59 -26
- package/src/checks/automatic/combobox-name-present.js +40 -45
- package/src/checks/automatic/contrast-computable.js +363 -341
- package/src/checks/automatic/contrast-enhanced.js +489 -466
- package/src/checks/automatic/contrast-minimum.js +488 -465
- package/src/checks/automatic/css-orientation-lock.js +51 -35
- package/src/checks/automatic/definition-list-children-valid.js +46 -25
- package/src/checks/automatic/deprecated-elements-not-used.js +25 -9
- package/src/checks/automatic/dialog-name-present.js +47 -85
- package/src/checks/automatic/dlitem-parent-valid.js +25 -8
- package/src/checks/automatic/duplicate-id-aria.js +28 -9
- package/src/checks/automatic/embed-text-alternative-present.js +88 -35
- package/src/checks/automatic/form-control-programmatic-label-present.js +81 -196
- package/src/checks/automatic/form-control-single-label.js +50 -14
- package/src/checks/automatic/html-xml-lang-mismatch.js +36 -18
- package/src/checks/automatic/iframe-focusable-content.js +265 -22
- package/src/checks/automatic/iframe-name-present.js +33 -9
- package/src/checks/automatic/iframe-title-unique.js +32 -9
- package/src/checks/automatic/img-alt-present.js +54 -52
- package/src/checks/automatic/input-image-alt-present.js +141 -112
- package/src/checks/automatic/label-in-name.js +65 -41
- package/src/checks/automatic/language-page-present.js +111 -109
- package/src/checks/automatic/link-in-text-block.js +61 -19
- package/src/checks/automatic/link-name-present.js +47 -14
- package/src/checks/automatic/list-children-valid.js +40 -33
- package/src/checks/automatic/listbox-name-present.js +41 -19
- package/src/checks/automatic/listitem-parent-valid.js +48 -13
- package/src/checks/automatic/menuitem-name-present.js +41 -61
- package/src/checks/automatic/meta-refresh-no-exceptions.js +32 -11
- package/src/checks/automatic/meta-refresh-timing-absent.js +22 -6
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +26 -7
- package/src/checks/automatic/meter-name-present.js +40 -36
- package/src/checks/automatic/nested-interactive-controls-absent.js +58 -15
- package/src/checks/automatic/object-text-alternative-present.js +93 -39
- package/src/checks/automatic/option-name-present.js +40 -21
- package/src/checks/automatic/page-title-present.js +19 -6
- package/src/checks/automatic/progressbar-name-present.js +49 -44
- package/src/checks/automatic/role-img-alt-present.js +211 -159
- package/src/checks/automatic/searchbox-name-present.js +41 -19
- package/src/checks/automatic/server-side-image-map-absent.js +27 -11
- package/src/checks/automatic/slider-name-present.js +42 -47
- package/src/checks/automatic/spinbutton-name-present.js +41 -19
- package/src/checks/automatic/summary-name-present.js +39 -17
- package/src/checks/automatic/svg-image-text-alternative-present.js +116 -47
- package/src/checks/automatic/svg-text-alternative-present.js +262 -230
- package/src/checks/automatic/tab-name-present.js +39 -60
- package/src/checks/automatic/table-headers-attr-valid.js +27 -10
- package/src/checks/automatic/table-th-has-data-cells.js +24 -8
- package/src/checks/automatic/target-size-minimum.js +123 -48
- package/src/checks/automatic/td-has-header.js +53 -12
- package/src/checks/automatic/textbox-name-present.js +41 -19
- package/src/checks/automatic/tooltip-name-present.js +39 -18
- package/src/checks/automatic/treeitem-name-present.js +40 -21
- package/src/checks/automatic/valid-lang.js +22 -6
- package/src/checks/automatic/video-poster-text-alternative-present.js +81 -36
- package/src/checks/manual/accesskeys-manual.js +17 -6
- package/src/checks/manual/area-alt-decorative-manual.js +194 -193
- package/src/checks/manual/area-alt-quality-manual.js +184 -141
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +48 -34
- package/src/checks/manual/aria-text-manual.js +20 -11
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +151 -114
- package/src/checks/manual/css-hidden-focus.js +375 -169
- package/src/checks/manual/embed-text-alternative-quality-manual.js +178 -162
- package/src/checks/manual/empty-heading-manual.js +41 -24
- package/src/checks/manual/empty-table-header-manual.js +69 -31
- package/src/checks/manual/focus-order-semantics-manual.js +60 -13
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +209 -246
- package/src/checks/manual/heading-order-manual.js +50 -8
- package/src/checks/manual/identical-links-same-purpose-manual.js +36 -12
- package/src/checks/manual/image-redundant-alt-manual.js +38 -8
- package/src/checks/manual/img-alt-decorative-manual.js +133 -96
- package/src/checks/manual/img-alt-quality-manual.js +178 -127
- package/src/checks/manual/input-image-alt-decorative-manual.js +127 -92
- package/src/checks/manual/input-image-alt-quality-manual.js +127 -92
- package/src/checks/manual/label-title-only-manual.js +44 -28
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -38
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +85 -32
- package/src/checks/manual/landmark-main-is-top-level-manual.js +69 -27
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +45 -33
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +43 -31
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +27 -21
- package/src/checks/manual/landmark-one-main-manual.js +38 -43
- package/src/checks/manual/landmark-unique-manual.js +78 -67
- package/src/checks/manual/link-name-quality-manual.js +45 -12
- package/src/checks/manual/media-transcript-present-manual.js +37 -22
- package/src/checks/manual/meta-viewport-large-manual.js +19 -6
- package/src/checks/manual/mouse-only-event-handlers-manual.js +40 -11
- package/src/checks/manual/no-autoplay-audio-manual.js +22 -6
- package/src/checks/manual/object-text-alternative-quality-manual.js +177 -154
- package/src/checks/manual/p-as-heading-manual.js +24 -7
- package/src/checks/manual/page-has-heading-one-manual.js +42 -32
- package/src/checks/manual/page-title-patterns-manual.js +80 -50
- package/src/checks/manual/presentation-role-conflict-manual.js +101 -47
- package/src/checks/manual/region-manual.js +244 -60
- package/src/checks/manual/scope-attr-valid-manual.js +13 -4
- package/src/checks/manual/scrollable-region-focusable-manual.js +39 -11
- package/src/checks/manual/skip-link-manual.js +42 -18
- package/src/checks/manual/svg-text-alternative-quality-manual.js +208 -165
- package/src/checks/manual/tabindex-manual.js +13 -4
- package/src/checks/manual/table-duplicate-name-manual.js +22 -11
- package/src/checks/manual/table-fake-caption-manual.js +48 -10
- package/src/checks/manual/video-caption-manual.js +17 -4
- package/src/checks/manual-review.js +58 -12
- package/src/core.js +41705 -29650
- package/src/index.js +2 -0
- package/src/report.js +109 -47
- package/src/sarif.js +190 -0
- package/surea11y.browser.js +37774 -0
- package/bin/core.js +0 -348
- package/docs/CLI.md +0 -75
- package/src/catalogs/composites.wcag.js +0 -490
- package/src/checks/rules-and-tags.full.csv +0 -19
- package/src/checks/rules-and-tags.full.json +0 -259
- package/src/core/aria-helpers.js +0 -970
- package/src/core/contrast-helpers.js +0 -1147
- package/src/core/dom-helpers.js +0 -4235
- package/src/core/dom-runner.js +0 -671
- package/src/core/frame-messaging.js +0 -210
- package/src/core/frame-scan.js +0 -178
- package/src/core/rollup-composites.js +0 -135
- package/src/core/rule-meta.js +0 -159
- package/src/coverage/wcag-facets.js +0 -1079
- package/src/coverage/wcag-version-map.js +0 -84
- package/src/i18n/en.js +0 -923
- package/src/i18n/fr.js +0 -844
- package/src/policy/contracts.js +0 -18
- package/src/policy/resolvePolicy.js +0 -55
- package/src/policy/schemas/engine-options.schema.json +0 -103
- package/src/policy/schemas/policy-contract.schema.json +0 -40
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -17,17 +19,21 @@
|
|
|
17
19
|
* are ambiguous: many screen readers only announce one of them, and it
|
|
18
20
|
* is not deterministic which.
|
|
19
21
|
* @implementation-notes
|
|
20
|
-
* -
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
22
|
+
* - Distinct, atomic decision from form-control-programmatic-label-present
|
|
23
|
+
* (that rule checks a label exists at all; this one checks there is at
|
|
24
|
+
* most one).
|
|
25
|
+
* - A label that isn't accessibility-tree-eligible (display:none,
|
|
26
|
+
* aria-hidden, etc.) is excluded before counting: it can't contribute to
|
|
27
|
+
* the control's accessible name, so it can't create the ambiguity this
|
|
28
|
+
* rule exists to catch.
|
|
24
29
|
*/
|
|
25
30
|
|
|
26
31
|
const id = 'form-control-single-label';
|
|
27
32
|
|
|
28
33
|
const meta = {
|
|
29
34
|
title: 'Form controls must not have multiple labels',
|
|
30
|
-
description:
|
|
35
|
+
description:
|
|
36
|
+
'Checks that a form control is associated with at most one <label> (by wrapping or by label[for]).',
|
|
31
37
|
i18n: {
|
|
32
38
|
titleKey: 'formControlSingleLabel_title',
|
|
33
39
|
descriptionKey: 'formControlSingleLabel_description'
|
|
@@ -36,7 +42,13 @@ const meta = {
|
|
|
36
42
|
tags: ['wcag2a', 'wcag332', 'forms', 'atomic', 'automatic'],
|
|
37
43
|
wcagSc: ['3.3.2'],
|
|
38
44
|
normativeMappings: [
|
|
39
|
-
{
|
|
45
|
+
{
|
|
46
|
+
standard: 'WCAG',
|
|
47
|
+
version: '2.2',
|
|
48
|
+
requirement: '3.3.2',
|
|
49
|
+
title: 'Labels or Instructions',
|
|
50
|
+
conformanceLevel: 'A'
|
|
51
|
+
}
|
|
40
52
|
],
|
|
41
53
|
defaultSeverity: 'moderate',
|
|
42
54
|
category: 'understandable',
|
|
@@ -48,8 +60,14 @@ const meta = {
|
|
|
48
60
|
function runInPage(ctx) {
|
|
49
61
|
const { document, helpers, rule } = ctx;
|
|
50
62
|
|
|
51
|
-
const
|
|
52
|
-
|
|
63
|
+
const isAccTreeEligible =
|
|
64
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
65
|
+
|
|
66
|
+
const selector =
|
|
67
|
+
'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]),select,textarea';
|
|
68
|
+
const nodes = helpers.queryAllSmart
|
|
69
|
+
? helpers.queryAllSmart(selector)
|
|
70
|
+
: helpers.queryAll(selector);
|
|
53
71
|
|
|
54
72
|
// Build a for-value -> label[] map once (avoids per-control dynamic
|
|
55
73
|
// attribute-selector construction / CSS.escape, which is not guaranteed
|
|
@@ -82,11 +100,20 @@ function runInPage(ctx) {
|
|
|
82
100
|
for (const lab of labelsByFor.get(controlId)) labels.add(lab);
|
|
83
101
|
}
|
|
84
102
|
|
|
85
|
-
|
|
103
|
+
const eligibleLabels = isAccTreeEligible
|
|
104
|
+
? new Set(
|
|
105
|
+
[...labels].filter((lab) => {
|
|
106
|
+
const elig = isAccTreeEligible(lab);
|
|
107
|
+
return !elig || elig.eligible !== false;
|
|
108
|
+
})
|
|
109
|
+
)
|
|
110
|
+
: labels;
|
|
111
|
+
|
|
112
|
+
if (eligibleLabels.size <= 1) continue;
|
|
86
113
|
|
|
87
114
|
const tag = el.tagName.toLowerCase();
|
|
88
115
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
89
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
116
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
90
117
|
|
|
91
118
|
occurrences.push({
|
|
92
119
|
selector: stableSelector,
|
|
@@ -96,10 +123,14 @@ function runInPage(ctx) {
|
|
|
96
123
|
i18n: {
|
|
97
124
|
summaryKey: 'formControlSingleLabel_summary_fail',
|
|
98
125
|
hintKey: 'formControlSingleLabel_hint_fail',
|
|
99
|
-
params: { element: tag, labelCount: String(
|
|
126
|
+
params: { element: tag, labelCount: String(eligibleLabels.size) }
|
|
100
127
|
},
|
|
101
128
|
data: {
|
|
102
|
-
details: {
|
|
129
|
+
details: {
|
|
130
|
+
reasonCode: 'FORM_FIELD_MULTIPLE_LABELS',
|
|
131
|
+
element: tag,
|
|
132
|
+
labelCount: eligibleLabels.size
|
|
133
|
+
}
|
|
103
134
|
}
|
|
104
135
|
});
|
|
105
136
|
}
|
|
@@ -108,9 +139,14 @@ function runInPage(ctx) {
|
|
|
108
139
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
109
140
|
}
|
|
110
141
|
if (occurrences.length) {
|
|
111
|
-
return {
|
|
142
|
+
return {
|
|
143
|
+
ruleId: rule.ruleId,
|
|
144
|
+
outcome: 'fail',
|
|
145
|
+
severity: rule.defaultSeverity || 'moderate',
|
|
146
|
+
occurrences
|
|
147
|
+
};
|
|
112
148
|
}
|
|
113
149
|
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
114
150
|
}
|
|
115
151
|
|
|
116
|
-
module.exports = { id, meta, runInPage };
|
|
152
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -27,7 +29,8 @@ const id = 'html-xml-lang-mismatch';
|
|
|
27
29
|
|
|
28
30
|
const meta = {
|
|
29
31
|
title: 'lang and xml:lang must not disagree',
|
|
30
|
-
description:
|
|
32
|
+
description:
|
|
33
|
+
"Checks that the <html> element's lang and xml:lang attributes declare the same primary language, when both are present.",
|
|
31
34
|
i18n: {
|
|
32
35
|
titleKey: 'htmlXmlLangMismatch_title',
|
|
33
36
|
descriptionKey: 'htmlXmlLangMismatch_description'
|
|
@@ -36,7 +39,13 @@ const meta = {
|
|
|
36
39
|
tags: ['wcag2a', 'wcag311', 'structure', 'language', 'atomic', 'automatic'],
|
|
37
40
|
wcagSc: ['3.1.1'],
|
|
38
41
|
normativeMappings: [
|
|
39
|
-
{
|
|
42
|
+
{
|
|
43
|
+
standard: 'WCAG',
|
|
44
|
+
version: '2.2',
|
|
45
|
+
requirement: '3.1.1',
|
|
46
|
+
title: 'Language of Page',
|
|
47
|
+
conformanceLevel: 'A'
|
|
48
|
+
}
|
|
40
49
|
],
|
|
41
50
|
defaultSeverity: 'serious',
|
|
42
51
|
category: 'understandable',
|
|
@@ -57,7 +66,7 @@ function runInPage(ctx) {
|
|
|
57
66
|
const { document, helpers, rule } = ctx;
|
|
58
67
|
|
|
59
68
|
const html = document && document.documentElement;
|
|
60
|
-
const tag =
|
|
69
|
+
const tag = html && html.tagName ? String(html.tagName).toLowerCase() : '';
|
|
61
70
|
if (!html || tag !== 'html') {
|
|
62
71
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
63
72
|
}
|
|
@@ -76,24 +85,33 @@ function runInPage(ctx) {
|
|
|
76
85
|
}
|
|
77
86
|
|
|
78
87
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(html) : 'html';
|
|
79
|
-
const htmlSnippet = helpers.getOuterHtmlSnippet
|
|
88
|
+
const htmlSnippet = helpers.getOuterHtmlSnippet
|
|
89
|
+
? helpers.getOuterHtmlSnippet(html)
|
|
90
|
+
: (html.outerHTML || '').slice(0, 200);
|
|
80
91
|
|
|
81
|
-
const occurrences = [
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
92
|
+
const occurrences = [
|
|
93
|
+
{
|
|
94
|
+
selector: stableSelector,
|
|
95
|
+
html: htmlSnippet,
|
|
96
|
+
summary: `The lang ("${lang}") and xml:lang ("${xmlLang}") attributes declare different languages.`,
|
|
97
|
+
hint: 'Make lang and xml:lang declare the same primary language, or remove the deprecated xml:lang attribute.',
|
|
98
|
+
i18n: {
|
|
99
|
+
summaryKey: 'htmlXmlLangMismatch_summary_fail',
|
|
100
|
+
hintKey: 'htmlXmlLangMismatch_hint_fail',
|
|
101
|
+
params: { lang, xmlLang }
|
|
102
|
+
},
|
|
103
|
+
data: {
|
|
104
|
+
details: { reasonCode: 'HTML_XML_LANG_MISMATCH', lang, xmlLang }
|
|
105
|
+
}
|
|
93
106
|
}
|
|
94
|
-
|
|
107
|
+
];
|
|
95
108
|
|
|
96
|
-
return {
|
|
109
|
+
return {
|
|
110
|
+
ruleId: rule.ruleId,
|
|
111
|
+
outcome: 'fail',
|
|
112
|
+
severity: rule.defaultSeverity || 'serious',
|
|
113
|
+
occurrences
|
|
114
|
+
};
|
|
97
115
|
}
|
|
98
116
|
|
|
99
117
|
module.exports = { id, meta, runInPage, applicability };
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -36,7 +38,8 @@ const id = 'iframe-focusable-content';
|
|
|
36
38
|
|
|
37
39
|
const meta = {
|
|
38
40
|
title: 'Frames with tabindex="-1" must not contain focusable content',
|
|
39
|
-
description:
|
|
41
|
+
description:
|
|
42
|
+
'Checks that same-origin <iframe>/<frame> elements with tabindex="-1" do not contain focusable content, since browsers do not propagate that restriction into the frame’s embedded document.',
|
|
40
43
|
i18n: {
|
|
41
44
|
titleKey: 'iframeFocusableContent_title',
|
|
42
45
|
descriptionKey: 'iframeFocusableContent_description'
|
|
@@ -45,7 +48,13 @@ const meta = {
|
|
|
45
48
|
tags: ['wcag2a', 'wcag211', 'structure', 'atomic', 'automatic', 'keyboard', 'iframe'],
|
|
46
49
|
wcagSc: ['2.1.1'],
|
|
47
50
|
normativeMappings: [
|
|
48
|
-
{
|
|
51
|
+
{
|
|
52
|
+
standard: 'WCAG',
|
|
53
|
+
version: '2.2',
|
|
54
|
+
requirement: '2.1.1',
|
|
55
|
+
title: 'Keyboard',
|
|
56
|
+
conformanceLevel: 'A'
|
|
57
|
+
}
|
|
49
58
|
],
|
|
50
59
|
defaultSeverity: 'moderate',
|
|
51
60
|
category: 'operable',
|
|
@@ -55,19 +64,151 @@ const meta = {
|
|
|
55
64
|
};
|
|
56
65
|
|
|
57
66
|
function runInPage(ctx) {
|
|
58
|
-
const {
|
|
67
|
+
const { helpers, rule, document } = ctx;
|
|
68
|
+
|
|
69
|
+
// Self-contained rendering check for the embedded document (a distinct
|
|
70
|
+
// realm — see this rule's own header comment on why the outer
|
|
71
|
+
// document's shared eligibility helpers can't be reused here).
|
|
72
|
+
// Deliberately checks only genuine non-rendering (display:none,
|
|
73
|
+
// visibility:hidden, the hidden attribute) via the ancestor chain, NOT
|
|
74
|
+
// aria-hidden: aria-hidden alone does not remove an element from a real
|
|
75
|
+
// browser's native tab order (the same anti-pattern this engine's own
|
|
76
|
+
// aria-hidden-focus rule exists to catch), so an aria-hidden-but-
|
|
77
|
+
// visually-rendered focusable element inside the frame is still
|
|
78
|
+
// genuinely reachable by keyboard and must stay flagged.
|
|
79
|
+
function isRenderedInDoc(doc, el) {
|
|
80
|
+
try {
|
|
81
|
+
const view = doc.defaultView;
|
|
82
|
+
if (!view || typeof view.getComputedStyle !== 'function') return true;
|
|
83
|
+
let node = el;
|
|
84
|
+
while (node && node.nodeType === 1) {
|
|
85
|
+
if (node.hasAttribute && node.hasAttribute('hidden')) return false;
|
|
86
|
+
const cs = view.getComputedStyle(node);
|
|
87
|
+
if (cs) {
|
|
88
|
+
if (cs.display === 'none') return false;
|
|
89
|
+
if (cs.visibility === 'hidden' || cs.visibility === 'collapse') return false;
|
|
90
|
+
}
|
|
91
|
+
node = node.parentElement;
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
} catch {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function getDeepActiveElement(docRef) {
|
|
100
|
+
let cur = docRef && docRef.activeElement ? docRef.activeElement : null;
|
|
101
|
+
let guard = 0;
|
|
102
|
+
while (cur && cur.shadowRoot && cur.shadowRoot.activeElement && guard++ < 20) {
|
|
103
|
+
cur = cur.shadowRoot.activeElement;
|
|
104
|
+
}
|
|
105
|
+
return cur;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function focusElementSafe(el) {
|
|
109
|
+
if (!el || typeof el.focus !== 'function') return false;
|
|
110
|
+
try {
|
|
111
|
+
el.focus({ preventScroll: true });
|
|
112
|
+
return true;
|
|
113
|
+
} catch {
|
|
114
|
+
try {
|
|
115
|
+
el.focus();
|
|
116
|
+
return true;
|
|
117
|
+
} catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function runFocusObservationWindow(win, fn) {
|
|
124
|
+
if (!win || typeof fn !== 'function') return;
|
|
125
|
+
const originalSetTimeout =
|
|
126
|
+
typeof win.setTimeout === 'function' ? win.setTimeout.bind(win) : null;
|
|
127
|
+
const originalRequestAnimationFrame =
|
|
128
|
+
typeof win.requestAnimationFrame === 'function' ? win.requestAnimationFrame.bind(win) : null;
|
|
129
|
+
const originalQueueMicrotask =
|
|
130
|
+
typeof win.queueMicrotask === 'function' ? win.queueMicrotask.bind(win) : null;
|
|
131
|
+
|
|
132
|
+
const queuedMicrotasks = [];
|
|
133
|
+
const queuedRaf = [];
|
|
134
|
+
const queuedTimers = [];
|
|
135
|
+
let fakeTimerId = 1;
|
|
136
|
+
|
|
137
|
+
const patchedSetTimeout = function (cb, delay) {
|
|
138
|
+
const d = Number.isFinite(Number(delay)) ? Number(delay) : 0;
|
|
139
|
+
if (typeof cb === 'function' && d <= 200) {
|
|
140
|
+
const args = [];
|
|
141
|
+
for (let i = 2; i < arguments.length; i++) args.push(arguments[i]);
|
|
142
|
+
queuedTimers.push({ delay: d, cb: () => cb.apply(win, args) });
|
|
143
|
+
return fakeTimerId++;
|
|
144
|
+
}
|
|
145
|
+
if (originalSetTimeout) return originalSetTimeout.apply(win, arguments);
|
|
146
|
+
return fakeTimerId++;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const patchedRaf = function (cb) {
|
|
150
|
+
if (typeof cb === 'function') {
|
|
151
|
+
queuedRaf.push(cb);
|
|
152
|
+
return fakeTimerId++;
|
|
153
|
+
}
|
|
154
|
+
if (originalRequestAnimationFrame) return originalRequestAnimationFrame.apply(win, arguments);
|
|
155
|
+
return fakeTimerId++;
|
|
156
|
+
};
|
|
59
157
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
158
|
+
const patchedQueueMicrotask = function (cb) {
|
|
159
|
+
if (typeof cb === 'function') queuedMicrotasks.push(cb);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
if (originalSetTimeout) win.setTimeout = patchedSetTimeout;
|
|
164
|
+
if (originalRequestAnimationFrame) win.requestAnimationFrame = patchedRaf;
|
|
165
|
+
if (originalQueueMicrotask) win.queueMicrotask = patchedQueueMicrotask;
|
|
166
|
+
fn();
|
|
167
|
+
|
|
168
|
+
let guard = 0;
|
|
169
|
+
while (
|
|
170
|
+
(queuedMicrotasks.length || queuedRaf.length || queuedTimers.length) &&
|
|
171
|
+
guard++ < 100
|
|
172
|
+
) {
|
|
173
|
+
while (queuedMicrotasks.length) {
|
|
174
|
+
const mt = queuedMicrotasks.shift();
|
|
175
|
+
try {
|
|
176
|
+
mt();
|
|
177
|
+
} catch {}
|
|
178
|
+
}
|
|
179
|
+
while (queuedRaf.length) {
|
|
180
|
+
const rf = queuedRaf.shift();
|
|
181
|
+
try {
|
|
182
|
+
rf(16);
|
|
183
|
+
} catch {}
|
|
184
|
+
}
|
|
185
|
+
if (queuedTimers.length) {
|
|
186
|
+
queuedTimers.sort((a, b) => a.delay - b.delay);
|
|
187
|
+
const tt = queuedTimers.shift();
|
|
188
|
+
try {
|
|
189
|
+
tt.cb();
|
|
190
|
+
} catch {}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} finally {
|
|
194
|
+
if (originalSetTimeout) win.setTimeout = originalSetTimeout;
|
|
195
|
+
if (originalRequestAnimationFrame) win.requestAnimationFrame = originalRequestAnimationFrame;
|
|
196
|
+
if (originalQueueMicrotask) win.queueMicrotask = originalQueueMicrotask;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function getFocusableCandidates(doc) {
|
|
201
|
+
if (!doc || !doc.querySelectorAll) return [];
|
|
202
|
+
let els;
|
|
63
203
|
try {
|
|
64
204
|
els = doc.querySelectorAll(
|
|
65
205
|
'a[href], area[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), ' +
|
|
66
|
-
|
|
206
|
+
'select:not([disabled]), textarea:not([disabled]), iframe, [contenteditable="true"], [tabindex]'
|
|
67
207
|
);
|
|
68
208
|
} catch {
|
|
69
|
-
return
|
|
209
|
+
return [];
|
|
70
210
|
}
|
|
211
|
+
const candidates = [];
|
|
71
212
|
for (const el of els) {
|
|
72
213
|
if (!el || !el.getAttribute) continue;
|
|
73
214
|
const raw = el.getAttribute('tabindex');
|
|
@@ -75,9 +216,77 @@ function runInPage(ctx) {
|
|
|
75
216
|
const n = Number(String(raw).trim());
|
|
76
217
|
if (!Number.isNaN(n) && n < 0) continue; // explicitly removed from tab order
|
|
77
218
|
}
|
|
78
|
-
|
|
219
|
+
if (!isRenderedInDoc(doc, el)) continue; // display:none/visibility:hidden/[hidden]: never reachable at all
|
|
220
|
+
candidates.push(el);
|
|
79
221
|
}
|
|
80
|
-
return
|
|
222
|
+
return candidates;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function probeImmediateFocusRedirect(frameEl, embeddedDoc, candidate) {
|
|
226
|
+
if (!frameEl || !embeddedDoc || !candidate) return null;
|
|
227
|
+
const embeddedWindow = embeddedDoc.defaultView;
|
|
228
|
+
if (!embeddedWindow) return null;
|
|
229
|
+
|
|
230
|
+
let focusedByEvent = false;
|
|
231
|
+
const onFocusCapture = () => {
|
|
232
|
+
focusedByEvent = true;
|
|
233
|
+
};
|
|
234
|
+
try {
|
|
235
|
+
candidate.addEventListener('focus', onFocusCapture, true);
|
|
236
|
+
} catch {}
|
|
237
|
+
|
|
238
|
+
const innerFocusTrace = [];
|
|
239
|
+
const outerFocusTrace = [];
|
|
240
|
+
const onInnerFocusIn = (ev) => {
|
|
241
|
+
if (ev && ev.target) innerFocusTrace.push(ev.target);
|
|
242
|
+
};
|
|
243
|
+
const onOuterFocusIn = (ev) => {
|
|
244
|
+
if (ev && ev.target) outerFocusTrace.push(ev.target);
|
|
245
|
+
};
|
|
246
|
+
try {
|
|
247
|
+
embeddedDoc.addEventListener('focusin', onInnerFocusIn, true);
|
|
248
|
+
document.addEventListener('focusin', onOuterFocusIn, true);
|
|
249
|
+
} catch {}
|
|
250
|
+
|
|
251
|
+
const beforeInner = getDeepActiveElement(embeddedDoc);
|
|
252
|
+
const beforeOuter = getDeepActiveElement(document);
|
|
253
|
+
let focused = false;
|
|
254
|
+
runFocusObservationWindow(embeddedWindow, () => {
|
|
255
|
+
focused = focusElementSafe(candidate);
|
|
256
|
+
});
|
|
257
|
+
try {
|
|
258
|
+
embeddedDoc.removeEventListener('focusin', onInnerFocusIn, true);
|
|
259
|
+
document.removeEventListener('focusin', onOuterFocusIn, true);
|
|
260
|
+
} catch {}
|
|
261
|
+
try {
|
|
262
|
+
candidate.removeEventListener('focus', onFocusCapture, true);
|
|
263
|
+
} catch {}
|
|
264
|
+
|
|
265
|
+
if (!focused) return null;
|
|
266
|
+
|
|
267
|
+
const afterInner = getDeepActiveElement(embeddedDoc);
|
|
268
|
+
const afterOuter = getDeepActiveElement(document);
|
|
269
|
+
const sawRedirectedInnerTrace = innerFocusTrace.some((n) => n && n !== candidate);
|
|
270
|
+
const sawRedirectedOuterTrace = outerFocusTrace.some(
|
|
271
|
+
(n) => n && n !== frameEl && n !== candidate
|
|
272
|
+
);
|
|
273
|
+
const redirectedWithinFrame =
|
|
274
|
+
!!(afterInner && afterInner !== candidate) || sawRedirectedInnerTrace;
|
|
275
|
+
const redirectedOutOfFrame =
|
|
276
|
+
!!(afterOuter && afterOuter !== frameEl) || sawRedirectedOuterTrace;
|
|
277
|
+
const sawCandidateFocus = focusedByEvent || innerFocusTrace.some((n) => n === candidate);
|
|
278
|
+
|
|
279
|
+
if (beforeInner && beforeInner !== afterInner) focusElementSafe(beforeInner);
|
|
280
|
+
if (beforeOuter && beforeOuter !== afterOuter) focusElementSafe(beforeOuter);
|
|
281
|
+
|
|
282
|
+
if (!sawCandidateFocus) return null;
|
|
283
|
+
if (!redirectedWithinFrame && !redirectedOutOfFrame) return null;
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
redirected: true,
|
|
287
|
+
redirectedWithinFrame,
|
|
288
|
+
redirectedOutOfFrame
|
|
289
|
+
};
|
|
81
290
|
}
|
|
82
291
|
|
|
83
292
|
function getNegativeTabIndex(el) {
|
|
@@ -87,16 +296,19 @@ function runInPage(ctx) {
|
|
|
87
296
|
return !Number.isNaN(n) && n < 0;
|
|
88
297
|
}
|
|
89
298
|
|
|
90
|
-
const nodes = helpers.queryAllSmart
|
|
299
|
+
const nodes = helpers.queryAllSmart
|
|
300
|
+
? helpers.queryAllSmart('iframe, frame')
|
|
301
|
+
: helpers.queryAll('iframe, frame');
|
|
91
302
|
|
|
92
|
-
const
|
|
303
|
+
const failOccurrences = [];
|
|
304
|
+
const cantTellOccurrences = [];
|
|
93
305
|
let applicableCount = 0;
|
|
94
306
|
|
|
95
307
|
for (const el of nodes) {
|
|
96
308
|
if (!el || !el.getAttribute) continue;
|
|
97
309
|
if (!getNegativeTabIndex(el)) continue;
|
|
98
310
|
|
|
99
|
-
let contentDoc
|
|
311
|
+
let contentDoc;
|
|
100
312
|
try {
|
|
101
313
|
contentDoc = el.contentDocument || null;
|
|
102
314
|
} catch {
|
|
@@ -106,16 +318,41 @@ function runInPage(ctx) {
|
|
|
106
318
|
|
|
107
319
|
applicableCount += 1;
|
|
108
320
|
|
|
109
|
-
|
|
321
|
+
const candidates = getFocusableCandidates(contentDoc);
|
|
322
|
+
if (!candidates.length) continue;
|
|
110
323
|
|
|
111
324
|
const tag = el.tagName.toLowerCase();
|
|
112
325
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
113
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
326
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
327
|
+
const shouldProbe = candidates.length === 1;
|
|
328
|
+
const runtimeProbe = shouldProbe
|
|
329
|
+
? probeImmediateFocusRedirect(el, contentDoc, candidates[0])
|
|
330
|
+
: null;
|
|
114
331
|
|
|
115
|
-
|
|
332
|
+
if (runtimeProbe && runtimeProbe.redirected) {
|
|
333
|
+
cantTellOccurrences.push({
|
|
334
|
+
selector: stableSelector,
|
|
335
|
+
html,
|
|
336
|
+
summary:
|
|
337
|
+
'This frame has tabindex="-1" and a focusable candidate, but focus moves immediately to another target. Verify keyboard reachability in a real browser.',
|
|
338
|
+
hint: 'If this is an intentional focus handoff, ensure keyboard users cannot remain on hidden/intermediate frame content.',
|
|
339
|
+
i18n: null,
|
|
340
|
+
data: {
|
|
341
|
+
details: {
|
|
342
|
+
reasonCode: 'IFRAME_TABINDEX_NEGATIVE_CONTENT_RUNTIME_REDIRECT',
|
|
343
|
+
element: tag,
|
|
344
|
+
runtimeProbe
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
failOccurrences.push({
|
|
116
352
|
selector: stableSelector,
|
|
117
353
|
html,
|
|
118
|
-
summary:
|
|
354
|
+
summary:
|
|
355
|
+
'This frame has tabindex="-1" but its content contains focusable elements, which remain reachable by keyboard.',
|
|
119
356
|
hint: 'Remove focusable content from the frame, or remove tabindex="-1" if the frame is meant to be reachable.',
|
|
120
357
|
i18n: {
|
|
121
358
|
summaryKey: 'iframeFocusableContent_summary_fail',
|
|
@@ -131,10 +368,16 @@ function runInPage(ctx) {
|
|
|
131
368
|
if (applicableCount === 0) {
|
|
132
369
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
133
370
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
371
|
+
// helpers.resolveTieredOutcome is unconditionally provided by dom-helpers.js
|
|
372
|
+
// (see its own header comment) -- no fallback needed, matching the same
|
|
373
|
+
// cleanup already applied to aria-hidden-focus.js/aria-prohibited-attr.js/
|
|
374
|
+
// target-size-minimum.js.
|
|
375
|
+
const resolved = helpers.resolveTieredOutcome(
|
|
376
|
+
failOccurrences,
|
|
377
|
+
cantTellOccurrences,
|
|
378
|
+
rule.defaultSeverity || 'moderate'
|
|
379
|
+
);
|
|
380
|
+
return { ruleId: rule.ruleId, ...resolved };
|
|
138
381
|
}
|
|
139
382
|
|
|
140
|
-
module.exports = { id, meta, runInPage };
|
|
383
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -25,7 +27,8 @@ const id = 'iframe-name-present';
|
|
|
25
27
|
|
|
26
28
|
const meta = {
|
|
27
29
|
title: 'Frames have an accessible name',
|
|
28
|
-
description:
|
|
30
|
+
description:
|
|
31
|
+
'Checks that <iframe>/<frame> elements expose a non-empty accessible name via aria-label, aria-labelledby, or the title attribute.',
|
|
29
32
|
i18n: {
|
|
30
33
|
titleKey: 'iframeNamePresent_title',
|
|
31
34
|
descriptionKey: 'iframeNamePresent_description'
|
|
@@ -34,7 +37,13 @@ const meta = {
|
|
|
34
37
|
tags: ['wcag2a', 'wcag412', 'structure', 'atomic', 'automatic', 'name', 'iframe'],
|
|
35
38
|
wcagSc: ['4.1.2'],
|
|
36
39
|
normativeMappings: [
|
|
37
|
-
{
|
|
40
|
+
{
|
|
41
|
+
standard: 'WCAG',
|
|
42
|
+
version: '2.2',
|
|
43
|
+
requirement: '4.1.2',
|
|
44
|
+
title: 'Name, Role, Value',
|
|
45
|
+
conformanceLevel: 'A'
|
|
46
|
+
}
|
|
38
47
|
],
|
|
39
48
|
defaultSeverity: 'serious',
|
|
40
49
|
category: 'robust',
|
|
@@ -44,9 +53,11 @@ const meta = {
|
|
|
44
53
|
};
|
|
45
54
|
|
|
46
55
|
function runInPage(ctx) {
|
|
47
|
-
const {
|
|
56
|
+
const { helpers, rule } = ctx;
|
|
48
57
|
|
|
49
|
-
const nodes = helpers.queryAllSmart
|
|
58
|
+
const nodes = helpers.queryAllSmart
|
|
59
|
+
? helpers.queryAllSmart('iframe, frame')
|
|
60
|
+
: helpers.queryAll('iframe, frame');
|
|
50
61
|
|
|
51
62
|
const occurrences = [];
|
|
52
63
|
let applicableCount = 0;
|
|
@@ -62,15 +73,23 @@ function runInPage(ctx) {
|
|
|
62
73
|
|
|
63
74
|
applicableCount += 1;
|
|
64
75
|
|
|
65
|
-
const nameInfo = helpers.getAccessibleNameInfo
|
|
76
|
+
const nameInfo = helpers.getAccessibleNameInfo
|
|
77
|
+
? helpers.getAccessibleNameInfo(el, ctx, { maxRefs: 8 })
|
|
78
|
+
: null;
|
|
66
79
|
if (nameInfo && nameInfo.present && nameInfo.value) continue;
|
|
67
80
|
|
|
68
81
|
const eligInfo = helpers.getEligibilityInfo
|
|
69
|
-
? (() => {
|
|
82
|
+
? (() => {
|
|
83
|
+
try {
|
|
84
|
+
return helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' });
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
})()
|
|
70
89
|
: null;
|
|
71
90
|
const tag = el.tagName.toLowerCase();
|
|
72
91
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
73
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
92
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
74
93
|
|
|
75
94
|
occurrences.push({
|
|
76
95
|
selector: stableSelector,
|
|
@@ -93,9 +112,14 @@ function runInPage(ctx) {
|
|
|
93
112
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
94
113
|
}
|
|
95
114
|
if (occurrences.length) {
|
|
96
|
-
return {
|
|
115
|
+
return {
|
|
116
|
+
ruleId: rule.ruleId,
|
|
117
|
+
outcome: 'fail',
|
|
118
|
+
severity: rule.defaultSeverity || 'serious',
|
|
119
|
+
occurrences
|
|
120
|
+
};
|
|
97
121
|
}
|
|
98
122
|
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
99
123
|
}
|
|
100
124
|
|
|
101
|
-
module.exports = { id, meta, runInPage };
|
|
125
|
+
module.exports = { id, meta, runInPage };
|