@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
|
@@ -5,34 +5,36 @@
|
|
|
5
5
|
/**
|
|
6
6
|
* @check deprecated-elements-not-used
|
|
7
7
|
* @atomic true
|
|
8
|
-
* @summary
|
|
8
|
+
* @summary Scrolling <marquee> content needs a way to pause, stop, or hide it
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
10
|
* @sc 2.2.2
|
|
11
11
|
* @applicability
|
|
12
|
-
* Applies to any
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* or hide it.
|
|
12
|
+
* Applies to any scan scope; whether it contains a <marquee> element is
|
|
13
|
+
* always an answerable question. <marquee> is an obsolete, non-standard
|
|
14
|
+
* HTML element that browsers still render as auto-scrolling text, with no
|
|
15
|
+
* built-in user mechanism to pause, stop, or hide it.
|
|
16
16
|
* @expectation
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* Each <marquee> is reported as cantTell: the scrolling itself is certain,
|
|
18
|
+
* but a page can offer its own pause or stop control (a button calling the
|
|
19
|
+
* element's stop() method, for example), and then failure F16 does not
|
|
20
|
+
* apply. Whether such a control exists is for a person to check. A scope
|
|
21
|
+
* with no <marquee> passes; there is no separate not-applicable case.
|
|
21
22
|
* @implementation-notes
|
|
22
|
-
* -
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* Engine-level hidden-subtree
|
|
27
|
-
* engineOptions.includeHiddenElements is
|
|
23
|
+
* - <blink> is not reported. No current browser makes it blink: it renders
|
|
24
|
+
* as an unknown inline element, so it holds no blinking content for 2.2.2
|
|
25
|
+
* to govern.
|
|
26
|
+
* - Not rule-gated on isAccTreeEligible: presence in markup is what the rule
|
|
27
|
+
* asks about, independent of visibility. Engine-level hidden-subtree
|
|
28
|
+
* filtering still applies unless engineOptions.includeHiddenElements is
|
|
29
|
+
* true.
|
|
28
30
|
*/
|
|
29
31
|
|
|
30
32
|
const id = 'deprecated-elements-not-used';
|
|
31
33
|
|
|
32
34
|
const meta = {
|
|
33
|
-
title: '
|
|
35
|
+
title: 'Scrolling <marquee> content must be possible to pause, stop, or hide',
|
|
34
36
|
description:
|
|
35
|
-
'
|
|
37
|
+
'Asks, for each obsolete <marquee> element, whether the page offers a way to pause, stop, or hide its auto-scrolling content, since the element itself has none.',
|
|
36
38
|
i18n: {
|
|
37
39
|
titleKey: 'deprecatedElements_title',
|
|
38
40
|
descriptionKey: 'deprecatedElements_description'
|
|
@@ -60,47 +62,50 @@ function runInPage(ctx) {
|
|
|
60
62
|
const { helpers, rule } = ctx;
|
|
61
63
|
|
|
62
64
|
const nodes = helpers.queryAllSmart
|
|
63
|
-
? helpers.queryAllSmart('
|
|
64
|
-
: helpers.queryAll('
|
|
65
|
+
? helpers.queryAllSmart('marquee')
|
|
66
|
+
: helpers.queryAll('marquee');
|
|
65
67
|
|
|
66
68
|
const occurrences = [];
|
|
67
|
-
let applicableCount = 0;
|
|
68
69
|
|
|
69
70
|
for (const el of nodes) {
|
|
70
71
|
if (!el || !el.tagName) continue;
|
|
71
72
|
|
|
72
|
-
applicableCount += 1;
|
|
73
|
-
|
|
74
73
|
const tag = el.tagName.toLowerCase();
|
|
75
74
|
|
|
76
75
|
occurrences.push(
|
|
77
76
|
helpers.reportOccurrence(el, {
|
|
78
|
-
summary:
|
|
79
|
-
|
|
77
|
+
summary:
|
|
78
|
+
'This <marquee> scrolls its content, and the element itself gives the user no way to pause, stop, or hide it.',
|
|
79
|
+
hint: 'Check that the page offers a control that pauses, stops, or hides this content. Better: replace it with static content, or with an animation that has a pause/stop control.',
|
|
80
|
+
occurrenceOutcome: 'cantTell',
|
|
80
81
|
i18n: {
|
|
81
|
-
summaryKey: '
|
|
82
|
-
hintKey: '
|
|
82
|
+
summaryKey: 'deprecatedElements_summary_cantTell',
|
|
83
|
+
hintKey: 'deprecatedElements_hint_cantTell',
|
|
83
84
|
params: { element: tag }
|
|
84
85
|
},
|
|
86
|
+
uncertainty: {
|
|
87
|
+
code: 'runtime-dependent',
|
|
88
|
+
needed:
|
|
89
|
+
'Whether the page provides a control that pauses, stops, or hides the scrolling content.',
|
|
90
|
+
evidence: { element: tag }
|
|
91
|
+
},
|
|
85
92
|
data: {
|
|
86
|
-
details: { reasonCode: '
|
|
93
|
+
details: { reasonCode: 'MARQUEE_PAUSE_MECHANISM_UNKNOWN', element: tag }
|
|
87
94
|
}
|
|
88
95
|
})
|
|
89
96
|
);
|
|
90
97
|
}
|
|
91
98
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (occurrences.length) {
|
|
96
|
-
return {
|
|
97
|
-
ruleId: rule.ruleId,
|
|
98
|
-
outcome: 'fail',
|
|
99
|
-
severity: rule.defaultSeverity || 'serious',
|
|
100
|
-
occurrences
|
|
101
|
-
};
|
|
99
|
+
// No <marquee> in scope is itself the passing case (see @expectation above).
|
|
100
|
+
if (!occurrences.length) {
|
|
101
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
102
102
|
}
|
|
103
|
-
return {
|
|
103
|
+
return {
|
|
104
|
+
ruleId: rule.ruleId,
|
|
105
|
+
outcome: 'cantTell',
|
|
106
|
+
severity: rule.defaultSeverity || 'serious',
|
|
107
|
+
occurrences
|
|
108
|
+
};
|
|
104
109
|
}
|
|
105
110
|
|
|
106
111
|
module.exports = { id, meta, runInPage };
|
|
@@ -5,14 +5,16 @@
|
|
|
5
5
|
/**
|
|
6
6
|
* @check dialog-name-present
|
|
7
7
|
* @atomic true
|
|
8
|
-
* @summary
|
|
8
|
+
* @summary Dialogs (role="dialog"/"alertdialog" or a native <dialog>) must have an accessible name
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
10
|
* @sc 4.1.2
|
|
11
11
|
* @applicability
|
|
12
|
-
* Applies to elements
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* Applies to elements included in the accessibility tree whose role is
|
|
13
|
+
* dialog or alertdialog: an element whose role attribute resolves to one
|
|
14
|
+
* of those roles (the first token that names a concrete ARIA role wins,
|
|
15
|
+
* so role="alertdialog dialog" is an alertdialog), and a native <dialog>
|
|
16
|
+
* whose role attribute is absent or names no concrete role (its implicit
|
|
17
|
+
* role is dialog). A closed <dialog> is hidden and so not in scope.
|
|
16
18
|
* @expectation
|
|
17
19
|
* The element has a non-empty accessible name from aria-label, from an
|
|
18
20
|
* aria-labelledby that resolves to non-empty text, or from title. Both
|
|
@@ -25,7 +27,7 @@ const id = 'dialog-name-present';
|
|
|
25
27
|
const meta = {
|
|
26
28
|
title: 'Dialogs have an accessible name',
|
|
27
29
|
description:
|
|
28
|
-
'Checks that elements with role="dialog" or role="alertdialog" expose a non-empty accessible name.',
|
|
30
|
+
'Checks that dialogs (elements with role="dialog" or role="alertdialog", and native <dialog> elements) expose a non-empty accessible name.',
|
|
29
31
|
i18n: {
|
|
30
32
|
titleKey: 'dialogNamePresent_title',
|
|
31
33
|
descriptionKey: 'dialogNamePresent_description'
|
|
@@ -115,7 +117,8 @@ function runInPage(ctx) {
|
|
|
115
117
|
const occurrences = [];
|
|
116
118
|
let applicableCount = 0;
|
|
117
119
|
|
|
118
|
-
|
|
120
|
+
// Filtered below by the resolved role.
|
|
121
|
+
const selector = 'dialog,[role]';
|
|
119
122
|
const nodes = helpers.queryAllSmart
|
|
120
123
|
? helpers.queryAllSmart(selector)
|
|
121
124
|
: helpers.queryAll(selector);
|
|
@@ -137,12 +140,28 @@ function runInPage(ctx) {
|
|
|
137
140
|
return { ok: false, method: 'none' };
|
|
138
141
|
}
|
|
139
142
|
|
|
143
|
+
// The role the browser uses: the first token of the role attribute that
|
|
144
|
+
// names a concrete ARIA role, else the element's implicit role (dialog for
|
|
145
|
+
// a native <dialog>; the others are not in scope here).
|
|
146
|
+
const aria = helpers && helpers.aria;
|
|
147
|
+
function resolveRole(el) {
|
|
148
|
+
const tokens = getAttr(el, 'role').toLowerCase().split(' ').filter(Boolean);
|
|
149
|
+
for (const t of tokens) {
|
|
150
|
+
const concrete =
|
|
151
|
+
aria && typeof aria.isValidConcreteRole === 'function'
|
|
152
|
+
? aria.isValidConcreteRole(t)
|
|
153
|
+
: t === 'dialog' || t === 'alertdialog';
|
|
154
|
+
if (concrete) return t;
|
|
155
|
+
}
|
|
156
|
+
return String(el.tagName || '').toLowerCase() === 'dialog' ? 'dialog' : '';
|
|
157
|
+
}
|
|
158
|
+
|
|
140
159
|
for (const el of nodes) {
|
|
141
160
|
if (!el) continue;
|
|
142
|
-
if (!isEligibleAcc(helpers, el, ctx)) continue;
|
|
143
161
|
|
|
144
|
-
const role =
|
|
162
|
+
const role = resolveRole(el);
|
|
145
163
|
if (role !== 'dialog' && role !== 'alertdialog') continue;
|
|
164
|
+
if (!isEligibleAcc(helpers, el, ctx)) continue;
|
|
146
165
|
|
|
147
166
|
applicableCount += 1;
|
|
148
167
|
|
|
@@ -128,6 +128,11 @@ function runInPage(ctx) {
|
|
|
128
128
|
hintKey: 'duplicateIdAria_hint_cantTell',
|
|
129
129
|
params: { id: refId, duplicateCount: String(els.length) }
|
|
130
130
|
},
|
|
131
|
+
uncertainty: {
|
|
132
|
+
code: 'judgement-required',
|
|
133
|
+
needed: 'Whether the first element carrying this id is the intended target.',
|
|
134
|
+
evidence: { id: refId, duplicateCount: els.length, resolvesTo: 'first' }
|
|
135
|
+
},
|
|
131
136
|
data: {
|
|
132
137
|
details: {
|
|
133
138
|
reasonCode: 'DUPLICATE_ID_ARIA_REFERENCED',
|
|
@@ -14,19 +14,25 @@
|
|
|
14
14
|
* element renders or not, which is why ACT 3ea0c8 evaluates hidden
|
|
15
15
|
* elements too.
|
|
16
16
|
* @expectation
|
|
17
|
-
* No other element in the same tree carries the same id value
|
|
17
|
+
* No other element in the same tree carries the same id value, compared
|
|
18
|
+
* exactly as written (id="a " and id="a" are different ids). Ids are
|
|
18
19
|
* scoped per document tree and per shadow tree, so the same id inside
|
|
19
20
|
* two different shadow roots is not a duplicate.
|
|
20
21
|
* @implementation-notes
|
|
21
22
|
* - WCAG-VERSION SCOPED. SC 4.1.1 Parsing was removed in WCAG 2.2, so this
|
|
22
|
-
* rule is tagged `wcag2a` (its 2.0/2.1 origin) plus `wcag22-removed`.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* `
|
|
29
|
-
*
|
|
23
|
+
* rule is tagged `wcag2a` (its 2.0/2.1 origin) plus `wcag22-removed`.
|
|
24
|
+
* The engine acts on that tag itself: under a 2.2 target, which is the
|
|
25
|
+
* default, this rule still runs and still reports every duplicate it
|
|
26
|
+
* finds, but its fail is coerced to `cantTell` with a `wcagVersionScope`
|
|
27
|
+
* field saying why (see scopeOutcomeToWcagVersion in
|
|
28
|
+
* `src/core/dom-runner.js`). A consumer targeting 2.0 or 2.1
|
|
29
|
+
* (`engineOptions.wcagVersion`, or a tag set that implies it) gets the
|
|
30
|
+
* real 4.1.1 failure; one that would rather not see the rule at all
|
|
31
|
+
* under 2.2 still excludes it with `excludeTags: ['wcag22-removed']`.
|
|
32
|
+
* The alternative, dropping the SC mapping entirely, would have made a
|
|
33
|
+
* genuine 2.0/2.1 failure invisible to anyone conformance-testing
|
|
34
|
+
* against those versions. See `docs/ENGINE_OPTIONS.md` for the option
|
|
35
|
+
* and the tag, and `docs/DESIGN_CHALLENGES.md` for the decision history.
|
|
30
36
|
* - The defect outlives its Success Criterion: a duplicate id breaks
|
|
31
37
|
* `<label for>` association, fragment navigation, `getElementById`, and
|
|
32
38
|
* every ID-reference attribute, none of which stopped mattering when
|
|
@@ -121,7 +127,10 @@ function runInPage(ctx) {
|
|
|
121
127
|
|
|
122
128
|
for (const el of all) {
|
|
123
129
|
if (!el || el.nodeType !== 1 || !el.getAttribute) continue;
|
|
124
|
-
|
|
130
|
+
// Compared as written: "a " and "a" are different ids in the DOM, and
|
|
131
|
+
// getElementById does not trim. Whitespace inside an id is a separate
|
|
132
|
+
// validity error, not a duplicate.
|
|
133
|
+
const value = String(el.getAttribute('id') || '');
|
|
125
134
|
if (!value) continue;
|
|
126
135
|
|
|
127
136
|
applicableCount += 1;
|
|
@@ -173,6 +173,15 @@ function runInPage(ctx) {
|
|
|
173
173
|
hintKey: 'formControlSingleLabel_hint_cantTell',
|
|
174
174
|
params: { element: tag, labelCount: String(eligibleLabels.size) }
|
|
175
175
|
},
|
|
176
|
+
uncertainty: {
|
|
177
|
+
code: 'spec-only',
|
|
178
|
+
needed: 'Whether the empty label is filled in at runtime or is simply redundant.',
|
|
179
|
+
evidence: {
|
|
180
|
+
element: tag,
|
|
181
|
+
labelCount: eligibleLabels.size,
|
|
182
|
+
contributingLabelCount: contributing.length
|
|
183
|
+
}
|
|
184
|
+
},
|
|
176
185
|
data: {
|
|
177
186
|
details: {
|
|
178
187
|
reasonCode: 'FORM_FIELD_EXTRA_EMPTY_LABEL',
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @check identical-iframes-same-purpose
|
|
7
|
+
* @atomic true
|
|
8
|
+
* @summary Frames sharing an accessible name must embed the same resource
|
|
9
|
+
* @standard WCAG 2.2
|
|
10
|
+
* @sc 4.1.2
|
|
11
|
+
* @applicability
|
|
12
|
+
* Applies to each set of two or more <iframe>/<frame> elements that are
|
|
13
|
+
* included in the accessibility tree and share the same non-empty
|
|
14
|
+
* accessible name, compared with whitespace collapsed. A frame named
|
|
15
|
+
* only by a mechanism that names nothing, or hidden from the
|
|
16
|
+
* accessibility tree, is not part of a set; a set needs two surviving
|
|
17
|
+
* members to exist at all.
|
|
18
|
+
* @expectation
|
|
19
|
+
* Every frame in a set resolves to the same resource. A shared name
|
|
20
|
+
* describes one resource, so two frames answering to it must embed the
|
|
21
|
+
* same one.
|
|
22
|
+
* @implementation-notes
|
|
23
|
+
* - Frames are grouped by the computed accessible name, not the title
|
|
24
|
+
* attribute, so a title that aria-label or aria-labelledby overrides does
|
|
25
|
+
* not count. It replaces iframe-title-unique, deprecated in 1.8.0, which
|
|
26
|
+
* grouped them by the title attribute.
|
|
27
|
+
* - src values are compared as resolved absolute URLs with the fragment
|
|
28
|
+
* removed and a trailing slash normalised away, so a directory written
|
|
29
|
+
* both with and without one is a single resource.
|
|
30
|
+
* - Frames that resolve to different URLs are reported cantTell, never
|
|
31
|
+
* fail. Different resources can still be equivalent — differently worded
|
|
32
|
+
* copies of one page, or two adverts serving the same purpose — and
|
|
33
|
+
* nothing in the markup settles it. Comparing the embedded documents
|
|
34
|
+
* would not settle it either, since content differing is exactly what
|
|
35
|
+
* those equivalent cases look like.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const id = 'identical-iframes-same-purpose';
|
|
39
|
+
|
|
40
|
+
const meta = {
|
|
41
|
+
title: 'Frames with the same name embed the same resource',
|
|
42
|
+
description:
|
|
43
|
+
'Checks that <iframe>/<frame> elements sharing an accessible name embed the same resource, since one name can only describe one resource.',
|
|
44
|
+
i18n: {
|
|
45
|
+
titleKey: 'identicalIframesSamePurpose_title',
|
|
46
|
+
descriptionKey: 'identicalIframesSamePurpose_description'
|
|
47
|
+
},
|
|
48
|
+
helpUrl: null,
|
|
49
|
+
tags: ['wcag2a', 'wcag412', 'structure', 'atomic', 'automatic', 'name', 'iframe'],
|
|
50
|
+
wcagSc: ['4.1.2'],
|
|
51
|
+
normativeMappings: [
|
|
52
|
+
{
|
|
53
|
+
standard: 'WCAG',
|
|
54
|
+
version: '2.2',
|
|
55
|
+
requirement: '4.1.2',
|
|
56
|
+
title: 'Name, Role, Value',
|
|
57
|
+
conformanceLevel: 'A'
|
|
58
|
+
}
|
|
59
|
+
],
|
|
60
|
+
defaultSeverity: 'moderate',
|
|
61
|
+
category: 'robust',
|
|
62
|
+
type: 'automatic',
|
|
63
|
+
defaultConfidence: 'medium',
|
|
64
|
+
coverage: { facetsBySc: { '4.1.2': ['identical-iframes-same-purpose'] } }
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function runInPage(ctx) {
|
|
68
|
+
const { helpers, rule } = ctx;
|
|
69
|
+
|
|
70
|
+
const nodes = helpers.queryAllSmart
|
|
71
|
+
? helpers.queryAllSmart('iframe, frame')
|
|
72
|
+
: helpers.queryAll('iframe, frame');
|
|
73
|
+
|
|
74
|
+
function normalizedName(el) {
|
|
75
|
+
if (!helpers.getAccessibleNameInfo) return '';
|
|
76
|
+
let info;
|
|
77
|
+
try {
|
|
78
|
+
info = helpers.getAccessibleNameInfo(el, ctx, { maxRefs: 8 });
|
|
79
|
+
} catch {
|
|
80
|
+
return '';
|
|
81
|
+
}
|
|
82
|
+
if (!info || !info.present || !info.value) return '';
|
|
83
|
+
return String(info.value).replace(/\s+/g, ' ').trim();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// A light-DOM child of a shadow host with no slot to land in is absent from
|
|
87
|
+
// the flat tree and so renders nowhere, which the shared eligibility helper
|
|
88
|
+
// does not model.
|
|
89
|
+
function isUnslotted(el) {
|
|
90
|
+
try {
|
|
91
|
+
let cur = el;
|
|
92
|
+
let guard = 0;
|
|
93
|
+
while (cur && cur.nodeType === 1 && guard++ < 100) {
|
|
94
|
+
const parent = cur.parentNode;
|
|
95
|
+
if (!parent || parent.nodeType !== 1) return false;
|
|
96
|
+
if (parent.shadowRoot && cur.assignedSlot == null) return true;
|
|
97
|
+
cur = parent;
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function inAccessibilityTree(el) {
|
|
106
|
+
if (isUnslotted(el)) return false;
|
|
107
|
+
if (!helpers.isIncludedInAccessibilityTree) return true;
|
|
108
|
+
try {
|
|
109
|
+
return !!helpers.isIncludedInAccessibilityTree(el);
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// A directory written with and without its trailing slash is one resource,
|
|
116
|
+
// and a fragment selects within a resource rather than naming another.
|
|
117
|
+
function resourceKey(el) {
|
|
118
|
+
let raw;
|
|
119
|
+
try {
|
|
120
|
+
raw = el.getAttribute('src');
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
if (raw == null || !String(raw).trim()) return null;
|
|
125
|
+
|
|
126
|
+
const doc = (ctx && ctx.document) || (el.ownerDocument ? el.ownerDocument : null);
|
|
127
|
+
const base = doc && doc.baseURI ? doc.baseURI : undefined;
|
|
128
|
+
try {
|
|
129
|
+
const u = new URL(String(raw).trim(), base);
|
|
130
|
+
let pathname = u.pathname;
|
|
131
|
+
if (pathname.length > 1 && pathname.charAt(pathname.length - 1) === '/') {
|
|
132
|
+
pathname = pathname.slice(0, -1);
|
|
133
|
+
}
|
|
134
|
+
return u.protocol + '//' + u.host + pathname + u.search;
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const groups = new Map();
|
|
141
|
+
|
|
142
|
+
for (const el of nodes) {
|
|
143
|
+
if (!el || !el.tagName) continue;
|
|
144
|
+
if (!inAccessibilityTree(el)) continue;
|
|
145
|
+
|
|
146
|
+
const name = normalizedName(el);
|
|
147
|
+
if (!name) continue;
|
|
148
|
+
|
|
149
|
+
const list = groups.get(name);
|
|
150
|
+
if (list) list.push(el);
|
|
151
|
+
else groups.set(name, [el]);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const sets = [];
|
|
155
|
+
for (const [name, els] of groups) {
|
|
156
|
+
if (els.length >= 2) sets.push([name, els]);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!sets.length) {
|
|
160
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const occurrences = [];
|
|
164
|
+
|
|
165
|
+
for (const [name, els] of sets) {
|
|
166
|
+
const keys = els.map(resourceKey);
|
|
167
|
+
const resolved = keys.filter((k) => k != null);
|
|
168
|
+
const allResolved = resolved.length === keys.length;
|
|
169
|
+
const allSame = allResolved && resolved.every((k) => k === resolved[0]);
|
|
170
|
+
if (allSame) continue;
|
|
171
|
+
|
|
172
|
+
for (let i = 0; i < els.length; i++) {
|
|
173
|
+
const el = els[i];
|
|
174
|
+
const tag = el.tagName.toLowerCase();
|
|
175
|
+
occurrences.push(
|
|
176
|
+
helpers.reportOccurrence(el, {
|
|
177
|
+
summary:
|
|
178
|
+
'This frame shares its accessible name with another frame that embeds a different resource.',
|
|
179
|
+
hint: 'Give each frame a name describing the resource it embeds, or point them at the same resource.',
|
|
180
|
+
i18n: {
|
|
181
|
+
summaryKey: 'identicalIframesSamePurpose_summary_cantTell',
|
|
182
|
+
hintKey: 'identicalIframesSamePurpose_hint_cantTell',
|
|
183
|
+
params: { element: tag, name }
|
|
184
|
+
},
|
|
185
|
+
uncertainty:
|
|
186
|
+
keys[i] == null
|
|
187
|
+
? {
|
|
188
|
+
code: 'not-computable',
|
|
189
|
+
needed: 'A resolvable src for this frame.',
|
|
190
|
+
evidence: { element: tag, name, setSize: els.length }
|
|
191
|
+
}
|
|
192
|
+
: {
|
|
193
|
+
code: 'equivalence-unknown',
|
|
194
|
+
needed: 'Whether the two resources serve the same purpose despite differing.',
|
|
195
|
+
evidence: {
|
|
196
|
+
element: tag,
|
|
197
|
+
name,
|
|
198
|
+
resource: keys[i],
|
|
199
|
+
otherResources: resolved.filter((k) => k !== keys[i]),
|
|
200
|
+
setSize: els.length
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
data: {
|
|
204
|
+
details: {
|
|
205
|
+
reasonCode:
|
|
206
|
+
keys[i] == null ? 'IFRAME_RESOURCE_UNRESOLVED' : 'IFRAME_RESOURCE_DIFFERS',
|
|
207
|
+
element: tag,
|
|
208
|
+
name,
|
|
209
|
+
resource: keys[i],
|
|
210
|
+
setSize: els.length
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (occurrences.length) {
|
|
219
|
+
return {
|
|
220
|
+
ruleId: rule.ruleId,
|
|
221
|
+
outcome: 'cantTell',
|
|
222
|
+
severity: rule.defaultSeverity || 'moderate',
|
|
223
|
+
occurrences
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -386,10 +386,18 @@ function runInPage(ctx) {
|
|
|
386
386
|
if (runtimeProbe && runtimeProbe.redirected) {
|
|
387
387
|
cantTellOccurrences.push(
|
|
388
388
|
helpers.reportOccurrence(el, {
|
|
389
|
-
summary:
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
389
|
+
summary: `This <${tag}> has tabindex="-1" and focusable content, but focus moves immediately to another target. Verify keyboard reachability in a real browser.`,
|
|
390
|
+
hint: 'If this is an intentional focus handoff, make sure keyboard users cannot remain on hidden or intermediate frame content.',
|
|
391
|
+
i18n: {
|
|
392
|
+
summaryKey: 'iframeFocusableContent_summary_cantTell_redirect',
|
|
393
|
+
hintKey: 'iframeFocusableContent_hint_cantTell_redirect',
|
|
394
|
+
params: { element: tag }
|
|
395
|
+
},
|
|
396
|
+
uncertainty: {
|
|
397
|
+
code: 'runtime-dependent',
|
|
398
|
+
needed: 'Whether a keyboard user can reach this frame’s content in a real browser.',
|
|
399
|
+
evidence: { element: tag, focusRedirected: true }
|
|
400
|
+
},
|
|
393
401
|
data: {
|
|
394
402
|
details: {
|
|
395
403
|
reasonCode: 'IFRAME_TABINDEX_NEGATIVE_CONTENT_RUNTIME_REDIRECT',
|