@surea11y/core 1.7.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 +94 -1
- package/README.md +157 -54
- package/docs/ACT_RULE_MAPPING.md +2 -2
- package/docs/API_STABILITY.md +18 -5
- package/docs/BINDING_AUTHORS_GUIDE.md +2 -2
- package/docs/CI_INTEGRATIONS.md +43 -0
- package/docs/DESIGN_CHALLENGES.md +97 -3
- package/docs/EARL.md +1 -1
- package/docs/ENGINE_OPTIONS.md +81 -3
- package/docs/I18N.md +62 -20
- package/docs/JUNIT.md +73 -0
- package/docs/LIMITATIONS.md +1 -0
- package/docs/OUTPUT_SCHEMA.md +19 -6
- package/docs/REPORT.md +7 -2
- package/docs/RULE_AUTHORING.md +73 -6
- package/docs/RULE_CATALOG.md +139 -116
- package/docs/RULE_EXAMPLES.md +2189 -0
- package/docs/RULE_HELPERS.md +62 -5
- package/docs/RULE_TAXONOMY.md +2 -2
- package/docs/SARIF.md +2 -1
- package/docs/WCAG_CONFORMANCE.md +56 -3
- package/package.json +34 -11
- package/profiles/index.js +14 -0
- package/src/checks/automatic/area-alt-present.js +87 -31
- package/src/checks/automatic/aria-braille-equivalent.js +25 -7
- package/src/checks/automatic/aria-hidden-focus.js +74 -18
- package/src/checks/automatic/aria-prohibited-attr.js +17 -4
- package/src/checks/automatic/aria-required-attr.js +29 -0
- package/src/checks/automatic/aria-role-name-present.js +19 -2
- package/src/checks/automatic/aria-valid-attr-value.js +28 -16
- package/src/checks/automatic/autocomplete-valid.js +152 -26
- package/src/checks/automatic/avoid-inline-spacing.js +105 -40
- 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 +35 -4
- package/src/checks/automatic/contrast-enhanced.js +4 -4
- package/src/checks/automatic/contrast-minimum.js +45 -11
- package/src/checks/automatic/css-orientation-lock.js +152 -30
- 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.js +6 -2
- package/src/checks/automatic/identical-iframes-same-purpose.js +4 -4
- package/src/checks/automatic/iframe-focusable-content.js +7 -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 +40 -13
- package/src/checks/automatic/language-page-present.js +12 -6
- package/src/checks/automatic/link-in-text-block.js +272 -60
- 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-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 +0 -11
- package/src/checks/automatic/td-has-header.js +41 -5
- 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 +109 -5
- 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/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/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 +14419 -2231
- 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/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 +34 -3
- package/src/wcag.js +105 -0
- package/surea11y.browser.js +5 -4
- package/surea11y.i18n.de.js +1 -1
- package/surea11y.i18n.es.js +1 -1
- package/surea11y.i18n.fr.js +1 -1
- package/surea11y.i18n.ja.js +3 -0
- package/src/checks/manual/area-alt-decorative-manual.js +0 -255
|
@@ -24,18 +24,22 @@
|
|
|
24
24
|
* offscreen positioning do NOT exempt text, per ACT's own failed
|
|
25
25
|
* examples for both, only actual non-rendering does.
|
|
26
26
|
* @expectation
|
|
27
|
-
* The
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
27
|
+
* The primary language subtag of the lang value (the part before the
|
|
28
|
+
* first hyphen) is a registered language subtag, as ACT de46e4 requires.
|
|
29
|
+
* WCAG 3.1.2 (Language of Parts)
|
|
30
|
+
* requires that when a passage's language differs from the page's
|
|
31
|
+
* default, it is identified programmatically. A tag whose primary subtag
|
|
32
|
+
* is unknown fails to identify a real language at all; a malformed later
|
|
33
|
+
* subtag (lang="en-US_x") still identifies English and passes here, since
|
|
34
|
+
* it is a markup validity error rather than a missing language.
|
|
31
35
|
* @implementation-notes
|
|
32
36
|
* - Distinct, atomic decision from html-lang-attr-present (that
|
|
33
37
|
* rule covers the root <html> element only, for SC 3.1.1); this rule
|
|
34
38
|
* covers every other element, for SC 3.1.2.
|
|
35
|
-
* - Same
|
|
36
|
-
* subtag
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
+
* - Same primary-subtag check as html-lang-attr-present: the shared
|
|
40
|
+
* helper checks the subtag's shape and that the IANA Language Subtag
|
|
41
|
+
* Registry lists it, so unregistered tags such as "xx-ZZ", "eng" or
|
|
42
|
+
* "qaa" fail. Later subtags (region, script, variants) are not checked.
|
|
39
43
|
*/
|
|
40
44
|
|
|
41
45
|
const id = 'valid-lang';
|
|
@@ -72,7 +76,8 @@ function runInPage(ctx) {
|
|
|
72
76
|
|
|
73
77
|
// Shape alone accepts unregistered tags such as "eng" and "em-US", so the
|
|
74
78
|
// primary subtag is checked against the IANA registry via the shared helper.
|
|
75
|
-
|
|
79
|
+
// Only the primary subtag is judged (see @expectation).
|
|
80
|
+
const BCP47_RE = /^[a-zA-Z]{2,3}$/;
|
|
76
81
|
const isValidTag =
|
|
77
82
|
typeof helpers.isValidLanguageTag === 'function'
|
|
78
83
|
? helpers.isValidLanguageTag
|
|
@@ -174,7 +179,7 @@ function runInPage(ctx) {
|
|
|
174
179
|
|
|
175
180
|
// A whitespace-only value is in scope and has no primary language tag.
|
|
176
181
|
const raw = String(rawAttr).trim();
|
|
177
|
-
if (isValidTag(raw)) continue;
|
|
182
|
+
if (isValidTag(raw.split('-')[0])) continue;
|
|
178
183
|
|
|
179
184
|
const tag = el.tagName.toLowerCase();
|
|
180
185
|
|
|
@@ -10,20 +10,33 @@
|
|
|
10
10
|
* @sc 1.1.1
|
|
11
11
|
* @type manual
|
|
12
12
|
* @applicability
|
|
13
|
-
* Applies to <area> elements
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* Applies to <area> elements that get a non-empty text alternative from any
|
|
14
|
+
* source: aria-labelledby (resolving to text), aria-label, alt or title.
|
|
15
|
+
* The <area> must carry a non-empty href (otherwise it is not a hyperlink
|
|
16
|
+
* at all per the HTML spec) and belong to a <map> that an <img usemap>
|
|
17
|
+
* actually references; an <area> in an unused map is out of scope. The
|
|
18
|
+
* referencing <img> must actually be rendered (hidden/display:none/
|
|
19
|
+
* visibility exclude it; aria-hidden does not, since <area> is not a DOM
|
|
20
|
+
* descendant of <img>), and the <area> itself must be eligible: hidden,
|
|
21
|
+
* display:none and inert on the <area> or its <map> do not exclude it,
|
|
22
|
+
* since neither generates a box and a real browser's image-map
|
|
23
|
+
* hit-testing ignores all three there (verified against Chromium and
|
|
24
|
+
* Firefox); only those mechanisms on a genuine ancestor of the whole
|
|
25
|
+
* <img>+<map> pairing do. role="presentation"/"none" takes an element out
|
|
26
|
+
* unless it is focusable.
|
|
18
27
|
* @expectation
|
|
19
|
-
* Human review is required to confirm that the provided text alternative is
|
|
28
|
+
* Human review is required to confirm that the provided text alternative is
|
|
29
|
+
* accurate and appropriate. Each occurrence lists every source present
|
|
30
|
+
* (data.details.sources), so the reviewer checks each one: a title or
|
|
31
|
+
* aria-label that is not the name still reaches some users.
|
|
20
32
|
*/
|
|
21
33
|
|
|
22
34
|
const id = 'area-alt-quality';
|
|
23
35
|
|
|
24
36
|
const meta = {
|
|
25
|
-
title: '<area>
|
|
26
|
-
description:
|
|
37
|
+
title: '<area> text alternative must be appropriate (manual review)',
|
|
38
|
+
description:
|
|
39
|
+
'Flags <area> elements with a non-empty text alternative (alt, aria-label, aria-labelledby or title) for human review of appropriateness.',
|
|
27
40
|
i18n: {
|
|
28
41
|
titleKey: 'area_altQuality_title',
|
|
29
42
|
descriptionKey: 'area_altQuality_description'
|
|
@@ -76,6 +89,11 @@ function runInPage(ctx) {
|
|
|
76
89
|
const isAccTreeEligible =
|
|
77
90
|
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
78
91
|
|
|
92
|
+
const isDomVisibleEligible =
|
|
93
|
+
helpers && typeof helpers.isDomVisibleEligible === 'function'
|
|
94
|
+
? helpers.isDomVisibleEligible
|
|
95
|
+
: null;
|
|
96
|
+
|
|
79
97
|
const __accEligCache = new WeakMap();
|
|
80
98
|
function accEligibleCached(node) {
|
|
81
99
|
if (!isAccTreeEligible) return { eligible: true, reasons: [] };
|
|
@@ -93,6 +111,23 @@ function runInPage(ctx) {
|
|
|
93
111
|
return r;
|
|
94
112
|
}
|
|
95
113
|
|
|
114
|
+
const __domVisCache = new WeakMap();
|
|
115
|
+
function domVisibleCached(node) {
|
|
116
|
+
if (!isDomVisibleEligible) return { eligible: true, reasons: [] };
|
|
117
|
+
if (!node || typeof node !== 'object') return { eligible: true, reasons: [] };
|
|
118
|
+
const c = __domVisCache.get(node);
|
|
119
|
+
if (c) return c;
|
|
120
|
+
let r;
|
|
121
|
+
try {
|
|
122
|
+
r = isDomVisibleEligible(node, ctx, { visibilityMode: 'styleOnly', disableGeometry: true });
|
|
123
|
+
} catch {
|
|
124
|
+
r = { eligible: true, reasons: [] };
|
|
125
|
+
}
|
|
126
|
+
r = r && typeof r === 'object' ? r : { eligible: !!r, reasons: [] };
|
|
127
|
+
__domVisCache.set(node, r);
|
|
128
|
+
return r;
|
|
129
|
+
}
|
|
130
|
+
|
|
96
131
|
// --- image-map semantics (rule-local; match automatic <area> applicability) ---
|
|
97
132
|
function normUsemap(val) {
|
|
98
133
|
try {
|
|
@@ -149,6 +184,53 @@ function runInPage(ctx) {
|
|
|
149
184
|
return !focusable;
|
|
150
185
|
}
|
|
151
186
|
|
|
187
|
+
const getAriaNameInfo =
|
|
188
|
+
helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
|
|
189
|
+
|
|
190
|
+
// Every non-empty text-alternative source on the element, in accessible-name
|
|
191
|
+
// order: aria-labelledby (when it resolves to text), aria-label, alt, title.
|
|
192
|
+
// aria-labelledby wins over aria-label in the name, but a present aria-label
|
|
193
|
+
// is still listed, so each attribute present is asked about.
|
|
194
|
+
function collectTextAlternativeSources(el) {
|
|
195
|
+
const attr = (name) => {
|
|
196
|
+
try {
|
|
197
|
+
const v = el.getAttribute(name);
|
|
198
|
+
return v == null ? '' : String(v).trim();
|
|
199
|
+
} catch {
|
|
200
|
+
return '';
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
const sources = [];
|
|
204
|
+
let name = '';
|
|
205
|
+
let aria = null;
|
|
206
|
+
if (getAriaNameInfo) {
|
|
207
|
+
try {
|
|
208
|
+
aria = getAriaNameInfo(el, ctx);
|
|
209
|
+
} catch {
|
|
210
|
+
aria = null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (aria && aria.present && aria.value) {
|
|
214
|
+
name = String(aria.value).trim();
|
|
215
|
+
sources.push(aria.mechanism);
|
|
216
|
+
if (aria.mechanism === 'aria-labelledby' && attr('aria-label')) sources.push('aria-label');
|
|
217
|
+
} else if (!getAriaNameInfo && attr('aria-label')) {
|
|
218
|
+
name = attr('aria-label');
|
|
219
|
+
sources.push('aria-label');
|
|
220
|
+
}
|
|
221
|
+
const altText = attr('alt');
|
|
222
|
+
if (altText) {
|
|
223
|
+
sources.push('alt');
|
|
224
|
+
if (!name) name = altText;
|
|
225
|
+
}
|
|
226
|
+
const titleText = attr('title');
|
|
227
|
+
if (titleText) {
|
|
228
|
+
sources.push('title');
|
|
229
|
+
if (!name) name = titleText;
|
|
230
|
+
}
|
|
231
|
+
return { sources, name, alt: altText };
|
|
232
|
+
}
|
|
233
|
+
|
|
152
234
|
const els = (() => {
|
|
153
235
|
try {
|
|
154
236
|
return Array.from((queryAllSmart ? queryAllSmart('area') : queryAll('area')) || []);
|
|
@@ -188,10 +270,18 @@ function runInPage(ctx) {
|
|
|
188
270
|
}
|
|
189
271
|
if (!img) continue;
|
|
190
272
|
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
273
|
+
// Without href an <area> is not a hyperlink at all per the HTML spec,
|
|
274
|
+
// so there is nothing here for this rule to review.
|
|
275
|
+
const hrefRaw = el.getAttribute('href');
|
|
276
|
+
if (!hrefRaw || !hrefRaw.trim()) continue;
|
|
277
|
+
|
|
278
|
+
// The referencing <img> must actually be rendered. <area> is not a DOM
|
|
279
|
+
// descendant of <img>, so aria-hidden on the img has nothing to
|
|
280
|
+
// propagate along; hidden/display:none/visibility on the img still
|
|
281
|
+
// excludes it, since that removes the box the hotspot depends on.
|
|
282
|
+
if (isDomVisibleEligible) {
|
|
283
|
+
const imgVis = domVisibleCached(img);
|
|
284
|
+
if (imgVis && imgVis.eligible === false) continue;
|
|
195
285
|
}
|
|
196
286
|
|
|
197
287
|
if (isAccTreeEligible) {
|
|
@@ -201,38 +291,30 @@ function runInPage(ctx) {
|
|
|
201
291
|
|
|
202
292
|
if (isRolePresentationExcluded(el)) continue;
|
|
203
293
|
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
} catch {
|
|
209
|
-
alt = null;
|
|
210
|
-
}
|
|
211
|
-
if (alt === null) continue;
|
|
212
|
-
if (String(alt).trim() === '') continue; // only non-empty alt is applicable here
|
|
294
|
+
// Applies when any text-alternative source gives the area a non-empty
|
|
295
|
+
// name; each present source is listed so the reviewer checks all of them.
|
|
296
|
+
const alt = collectTextAlternativeSources(el);
|
|
297
|
+
if (!alt.sources.length) continue;
|
|
213
298
|
|
|
214
299
|
applicableCount += 1;
|
|
215
300
|
|
|
216
301
|
const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
|
|
302
|
+
const sourcesText = alt.sources.join(', ');
|
|
217
303
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
altVal = String(el.getAttribute('alt') || '');
|
|
221
|
-
} catch {
|
|
222
|
-
altVal = '';
|
|
223
|
-
}
|
|
304
|
+
const details = { name: alt.name, sources: alt.sources.slice() };
|
|
305
|
+
if (alt.alt) details.alt = alt.alt;
|
|
224
306
|
|
|
225
307
|
const baseOccurrence = {
|
|
226
|
-
summary:
|
|
227
|
-
hint: 'Ensure
|
|
308
|
+
summary: `Review the text alternative of this <area> (${sourcesText}) for accuracy and appropriateness.`,
|
|
309
|
+
hint: 'Ensure each listed text alternative identifies the destination/action of the image map area in context.',
|
|
228
310
|
i18n: {
|
|
229
311
|
summaryKey: 'area_altQuality_summary_cantTell',
|
|
230
312
|
hintKey: 'area_altQuality_hint_cantTell',
|
|
231
|
-
params: { element: (el.tagName || '').toLowerCase() }
|
|
313
|
+
params: { element: (el.tagName || '').toLowerCase(), sources: sourcesText }
|
|
232
314
|
},
|
|
233
315
|
data: {
|
|
234
316
|
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
235
|
-
details
|
|
317
|
+
details
|
|
236
318
|
}
|
|
237
319
|
};
|
|
238
320
|
|
|
@@ -165,8 +165,6 @@ function runInPage(ctx) {
|
|
|
165
165
|
? helpers.getTextAlternativeInfo
|
|
166
166
|
: null;
|
|
167
167
|
|
|
168
|
-
const trim = (v) => (v == null ? '' : String(v)).trim();
|
|
169
|
-
|
|
170
168
|
// Rule-specific applicability (only elements that already have a text alternative mechanism)
|
|
171
169
|
let textAltInfo = null;
|
|
172
170
|
if (getTextAlternativeInfo) {
|
|
@@ -10,15 +10,19 @@
|
|
|
10
10
|
* @sc 2.4.7
|
|
11
11
|
* @applicability
|
|
12
12
|
* Elements in sequential focus navigation (tabbable and rendered) on a
|
|
13
|
-
* page whose accessible stylesheets contain at least one
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* page whose accessible stylesheets contain at least one rule that
|
|
14
|
+
* removes the outline. With no such rule anywhere, every element keeps
|
|
15
|
+
* the user agent's own indicator and there is nothing to check.
|
|
16
16
|
* @expectation
|
|
17
|
-
* No element is matched by a
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
17
|
+
* No element is matched by a rule that removes the outline (`outline:
|
|
18
|
+
* none`, `outline: 0`, `outline-color: transparent`, ...) unless some
|
|
19
|
+
* focus rule matching it draws a replacement: a border, box-shadow,
|
|
20
|
+
* background, color change, a positive outline of its own, or a
|
|
21
|
+
* `::before`/`::after` decoration. The removing rule is either a
|
|
22
|
+
* `:focus`/`:focus-visible` rule, or a rule with no state at all
|
|
23
|
+
* (`a { outline: none }`, `* { outline: 0 }`): an author declaration
|
|
24
|
+
* outranks the user agent's focus outline whatever its specificity, so
|
|
25
|
+
* it removes the indicator in the focused state too (WCAG F78).
|
|
22
26
|
* @implementation-notes
|
|
23
27
|
* - Authored as `type: 'manual'` (cantTell-capped, never fail). CSS is
|
|
24
28
|
* only one of the ways a page can indicate focus: ACT oj04fd's own
|
|
@@ -54,7 +58,7 @@ const id = 'css-focus-indicator-suppressed';
|
|
|
54
58
|
const meta = {
|
|
55
59
|
title: 'Focus indicator must not be removed without a replacement',
|
|
56
60
|
description:
|
|
57
|
-
'Flags elements in the tab order whose focus outline is removed by a :focus/:focus-visible rule with no replacement indicator (border, box-shadow, background, ...) in any
|
|
61
|
+
'Flags elements in the tab order whose focus outline is removed, by a :focus/:focus-visible rule or by a rule with no state such as a { outline: none }, with no replacement indicator (border, box-shadow, background, ...) in any focus rule matching them.',
|
|
58
62
|
i18n: {
|
|
59
63
|
titleKey: 'cssFocusIndicatorSuppressed_title',
|
|
60
64
|
descriptionKey: 'cssFocusIndicatorSuppressed_description'
|
|
@@ -272,7 +276,16 @@ function runInPage(ctx) {
|
|
|
272
276
|
if (!suppresses && !provides) return;
|
|
273
277
|
|
|
274
278
|
for (const part of splitSelectorList(cssRule.selectorText)) {
|
|
275
|
-
if (!hasFocusPseudo(part))
|
|
279
|
+
if (!hasFocusPseudo(part)) {
|
|
280
|
+
// A rule with no focus state still applies while the element has
|
|
281
|
+
// focus, and an author declaration beats the user agent's focus
|
|
282
|
+
// outline. Other states (:hover, :active) never match the static
|
|
283
|
+
// element, so el.matches() leaves them out below.
|
|
284
|
+
if (suppresses && !hasPseudoElement(part)) {
|
|
285
|
+
suppressors.push({ selector: trim(part), base: trim(part) });
|
|
286
|
+
}
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
276
289
|
|
|
277
290
|
const compounds = splitCompounds(part);
|
|
278
291
|
let focusIndex = -1;
|
|
@@ -13,11 +13,25 @@
|
|
|
13
13
|
* via CSS techniques that can leave them in the tab order.
|
|
14
14
|
* @expectation
|
|
15
15
|
* No element should be tabbable while visually hidden (e.g., opacity:0, clipped, off-screen).
|
|
16
|
+
* An element that CSS brings back into view when it takes focus is not
|
|
17
|
+
* hidden while focused, and is not flagged: the usual skip-link pattern
|
|
18
|
+
* (`.skip { position: absolute; left: -9999px } .skip:focus { left: 0 }`),
|
|
19
|
+
* or a hiding rule that stops applying on focus
|
|
20
|
+
* (`.visually-hidden-focusable:not(:focus) { clip: rect(0 0 0 0) }`).
|
|
16
21
|
*
|
|
17
22
|
* Notes:
|
|
18
23
|
* - This rule intentionally targets CSS techniques that *can* keep an element focusable.
|
|
19
24
|
* - Elements removed from rendering (display:none, visibility:hidden, [hidden]) are excluded.
|
|
20
25
|
* - The rule uses deterministic heuristics (computed style parsing) and does not rely on layout geometry.
|
|
26
|
+
* - The focused style is worked out from the stylesheets, not by focusing the
|
|
27
|
+
* element: a DOM emulator does not restyle `:focus`. Rules whose subject
|
|
28
|
+
* carries `:focus`, `:focus-visible` or `:focus-within` (or an ancestor
|
|
29
|
+
* carries `:focus-within`) are laid over the computed style in document
|
|
30
|
+
* order; a rule written `:not(:focus)` / `:not(:focus-within)` /
|
|
31
|
+
* `:not(:focus-visible)` has its declarations reset to their initial values.
|
|
32
|
+
* Inline style outranks a stylesheet rule unless the rule is `!important`.
|
|
33
|
+
* The overlay ignores specificity among the focus rules, and cross-origin
|
|
34
|
+
* stylesheets cannot be read, so their focus rules are not seen.
|
|
21
35
|
*/
|
|
22
36
|
|
|
23
37
|
const id = 'css-hidden-focus';
|
|
@@ -106,9 +120,14 @@ function runInPage(ctx) {
|
|
|
106
120
|
|
|
107
121
|
// Returns deterministic "visually hidden but can remain focusable" hints.
|
|
108
122
|
function getVisibilityHints(el) {
|
|
123
|
+
if (!el) return [];
|
|
124
|
+
return hintsFromStyle(getComputedStyleSafe(el));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The same hints, read off any object carrying the computed-style fields
|
|
128
|
+
// used below (a CSSStyleDeclaration, or the focused-state overlay).
|
|
129
|
+
function hintsFromStyle(cs) {
|
|
109
130
|
const out = [];
|
|
110
|
-
if (!el) return out;
|
|
111
|
-
const cs = getComputedStyleSafe(el);
|
|
112
131
|
|
|
113
132
|
// opacity:0
|
|
114
133
|
try {
|
|
@@ -187,6 +206,177 @@ function runInPage(ctx) {
|
|
|
187
206
|
return uniq;
|
|
188
207
|
}
|
|
189
208
|
|
|
209
|
+
// ---- Focused state, worked out from the stylesheets ----
|
|
210
|
+
const CSS_STYLE_RULE = 1;
|
|
211
|
+
const MAX_RULE_DEPTH = 10;
|
|
212
|
+
const FOCUS_STATE = /:focus(?:-visible|-within)?(?![-\w])/g;
|
|
213
|
+
const OWN_FOCUS_STATE = /:focus(?:-visible)?(?![-\w])/;
|
|
214
|
+
const NOT_FOCUS_STATE = /:not\(\s*:focus(?:-visible|-within)?\s*\)/g;
|
|
215
|
+
// Longhand name -> computed-style field read by hintsFromStyle.
|
|
216
|
+
const HINT_PROPS = {
|
|
217
|
+
opacity: 'opacity',
|
|
218
|
+
clip: 'clip',
|
|
219
|
+
'clip-path': 'clipPath',
|
|
220
|
+
width: 'width',
|
|
221
|
+
height: 'height',
|
|
222
|
+
overflow: 'overflow',
|
|
223
|
+
position: 'position',
|
|
224
|
+
left: 'left',
|
|
225
|
+
top: 'top',
|
|
226
|
+
'text-indent': 'textIndent'
|
|
227
|
+
};
|
|
228
|
+
const INITIAL_VALUES = {
|
|
229
|
+
opacity: '1',
|
|
230
|
+
clip: 'auto',
|
|
231
|
+
'clip-path': 'none',
|
|
232
|
+
width: 'auto',
|
|
233
|
+
height: 'auto',
|
|
234
|
+
overflow: 'visible',
|
|
235
|
+
position: 'static',
|
|
236
|
+
left: 'auto',
|
|
237
|
+
top: 'auto',
|
|
238
|
+
'text-indent': '0px'
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
function splitTopLevel(text, separators) {
|
|
242
|
+
const parts = [];
|
|
243
|
+
let depth = 0;
|
|
244
|
+
let current = '';
|
|
245
|
+
for (const ch of String(text || '')) {
|
|
246
|
+
if (ch === '(') depth += 1;
|
|
247
|
+
if (ch === ')') depth = Math.max(0, depth - 1);
|
|
248
|
+
if (depth === 0 && separators.indexOf(ch) !== -1) {
|
|
249
|
+
parts.push(current);
|
|
250
|
+
current = '';
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
current += ch;
|
|
254
|
+
}
|
|
255
|
+
parts.push(current);
|
|
256
|
+
return parts.map(trim).filter(Boolean);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function hasPseudoElement(part) {
|
|
260
|
+
return /::[a-z-]+/i.test(part) || /:(before|after)\b/i.test(part);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Selector for the element while it has focus, or null when the rule
|
|
264
|
+
// cannot apply to it then (`.a:focus .b`: .a cannot have focus while .b
|
|
265
|
+
// does).
|
|
266
|
+
function focusedBase(part) {
|
|
267
|
+
const compounds = splitTopLevel(part, [' ', '>', '+', '~']);
|
|
268
|
+
for (let i = 0; i < compounds.length - 1; i++) {
|
|
269
|
+
if (OWN_FOCUS_STATE.test(compounds[i])) return null;
|
|
270
|
+
}
|
|
271
|
+
// `:focus` standing alone in a compound becomes `*`, then every focus
|
|
272
|
+
// state is dropped: while focused, the element matches them all.
|
|
273
|
+
return trim(part.replace(/(^|[\s>+~(])(?=:focus)/g, '$1*').replace(FOCUS_STATE, '')) || '*';
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
let focusRules = null;
|
|
277
|
+
function getFocusRules() {
|
|
278
|
+
if (focusRules) return focusRules;
|
|
279
|
+
focusRules = [];
|
|
280
|
+
function consider(cssRule) {
|
|
281
|
+
const style = cssRule.style;
|
|
282
|
+
if (!style) return;
|
|
283
|
+
const props = Object.keys(HINT_PROPS).filter((p) => trim(style.getPropertyValue(p)));
|
|
284
|
+
if (!props.length) return;
|
|
285
|
+
for (const part of splitTopLevel(cssRule.selectorText, [','])) {
|
|
286
|
+
if (hasPseudoElement(part)) continue;
|
|
287
|
+
NOT_FOCUS_STATE.lastIndex = 0;
|
|
288
|
+
if (NOT_FOCUS_STATE.test(part)) {
|
|
289
|
+
const base = trim(part.replace(NOT_FOCUS_STATE, '')) || '*';
|
|
290
|
+
FOCUS_STATE.lastIndex = 0;
|
|
291
|
+
if (!FOCUS_STATE.test(base)) focusRules.push({ kind: 'notFocus', base, style, props });
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
FOCUS_STATE.lastIndex = 0;
|
|
295
|
+
if (!FOCUS_STATE.test(part)) continue;
|
|
296
|
+
const base = focusedBase(part);
|
|
297
|
+
if (base) focusRules.push({ kind: 'focus', base, style, props });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function walk(rules, depth) {
|
|
301
|
+
if (!rules || depth > MAX_RULE_DEPTH) return;
|
|
302
|
+
for (const cssRule of rules) {
|
|
303
|
+
if (!cssRule) continue;
|
|
304
|
+
if (cssRule.type === CSS_STYLE_RULE && cssRule.selectorText) {
|
|
305
|
+
consider(cssRule);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
let nested;
|
|
309
|
+
try {
|
|
310
|
+
nested = cssRule.cssRules || null;
|
|
311
|
+
} catch {
|
|
312
|
+
nested = null;
|
|
313
|
+
}
|
|
314
|
+
if (nested) walk(nested, depth + 1);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
for (const sheet of document.styleSheets || []) {
|
|
319
|
+
let rules = null;
|
|
320
|
+
try {
|
|
321
|
+
rules = sheet && sheet.cssRules ? sheet.cssRules : null;
|
|
322
|
+
} catch {
|
|
323
|
+
continue; // cross-origin, not inspectable
|
|
324
|
+
}
|
|
325
|
+
if (rules) walk(rules, 0);
|
|
326
|
+
}
|
|
327
|
+
} catch {
|
|
328
|
+
// no readable stylesheets
|
|
329
|
+
}
|
|
330
|
+
return focusRules;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function matchesSafe(el, selector) {
|
|
334
|
+
try {
|
|
335
|
+
return typeof el.matches === 'function' && el.matches(selector);
|
|
336
|
+
} catch {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// The visibility hints the element would have while focused, or null when
|
|
342
|
+
// no focus-dependent rule reaches it.
|
|
343
|
+
function focusedVisibilityHints(el) {
|
|
344
|
+
const rules = getFocusRules().filter((r) => matchesSafe(el, r.base));
|
|
345
|
+
if (!rules.length) return null;
|
|
346
|
+
const inline = el.style || null;
|
|
347
|
+
const inlineHas = (p) => {
|
|
348
|
+
try {
|
|
349
|
+
return !!(inline && trim(inline.getPropertyValue(p)));
|
|
350
|
+
} catch {
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
const overlay = {};
|
|
355
|
+
for (const r of rules) {
|
|
356
|
+
if (r.kind !== 'notFocus') continue;
|
|
357
|
+
for (const p of r.props) if (!inlineHas(p)) overlay[p] = INITIAL_VALUES[p];
|
|
358
|
+
}
|
|
359
|
+
for (const r of rules) {
|
|
360
|
+
if (r.kind !== 'focus') continue;
|
|
361
|
+
for (const p of r.props) {
|
|
362
|
+
const important = String(r.style.getPropertyPriority(p) || '') === 'important';
|
|
363
|
+
if (inlineHas(p) && !important) continue;
|
|
364
|
+
overlay[p] = trim(r.style.getPropertyValue(p));
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
const cs = getComputedStyleSafe(el);
|
|
368
|
+
const focused = {};
|
|
369
|
+
for (const p of Object.keys(HINT_PROPS)) {
|
|
370
|
+
const field = HINT_PROPS[p];
|
|
371
|
+
focused[field] = Object.prototype.hasOwnProperty.call(overlay, p)
|
|
372
|
+
? overlay[p]
|
|
373
|
+
: cs
|
|
374
|
+
? cs[field]
|
|
375
|
+
: '';
|
|
376
|
+
}
|
|
377
|
+
return hintsFromStyle(focused);
|
|
378
|
+
}
|
|
379
|
+
|
|
190
380
|
function getFocusableInfoSafe(el) {
|
|
191
381
|
if (!getFocusableInfo) return null;
|
|
192
382
|
try {
|
|
@@ -400,6 +590,10 @@ function runInPage(ctx) {
|
|
|
400
590
|
const hints = getVisibilityHints(el);
|
|
401
591
|
if (!hints.length) continue; // <-- applicability gate
|
|
402
592
|
|
|
593
|
+
// Brought back into view when it takes focus: visible while focused.
|
|
594
|
+
const whenFocused = focusedVisibilityHints(el);
|
|
595
|
+
if (whenFocused && !whenFocused.length) continue;
|
|
596
|
+
|
|
403
597
|
const tagName = (() => {
|
|
404
598
|
try {
|
|
405
599
|
return lower(el.tagName || '');
|
|
@@ -419,17 +613,31 @@ function runInPage(ctx) {
|
|
|
419
613
|
|
|
420
614
|
const baseOccurrence = {
|
|
421
615
|
summary: downgradedToRedirectReview
|
|
422
|
-
? `Focusable ${tagName} appears visually hidden but focus moved immediately to another element. Verify sentinel/focus-trap behavior.`
|
|
423
|
-
: `Focusable ${tagName}
|
|
616
|
+
? `Focusable ${tagName} appears visually hidden, but focus moved immediately to another element. Verify sentinel/focus-trap behavior.`
|
|
617
|
+
: `Focusable ${tagName} is visually hidden (${hintsArr.join(',')}).`,
|
|
424
618
|
hint: downgradedToRedirectReview
|
|
425
619
|
? 'Verify this is an intentional focus sentinel/focus-trap handoff and that keyboard users never remain on visually hidden focus targets.'
|
|
426
|
-
: '
|
|
620
|
+
: 'Make the element visible when it can receive keyboard focus, or remove it from the tab order until it is visible.',
|
|
427
621
|
i18n: downgradedToRedirectReview
|
|
428
|
-
?
|
|
622
|
+
? {
|
|
623
|
+
summaryKey: 'cssHidden_focus_summary_cantTell_redirect',
|
|
624
|
+
hintKey: 'cssHidden_focus_hint_cantTell_redirect',
|
|
625
|
+
params: { element: tagName }
|
|
626
|
+
}
|
|
429
627
|
: {
|
|
430
628
|
summaryKey: 'cssHidden_focus_summary_cantTell',
|
|
431
629
|
hintKey: 'cssHidden_focus_hint_cantTell',
|
|
432
|
-
|
|
630
|
+
// One flag per technique, so each locale words them as sentences
|
|
631
|
+
// instead of showing the internal codes. visibilityHints stays
|
|
632
|
+
// for callers that read params directly.
|
|
633
|
+
params: {
|
|
634
|
+
element: tagName,
|
|
635
|
+
visibilityHints: hintsArr.join(','),
|
|
636
|
+
opacityZero: hintsArr.includes('opacityZero'),
|
|
637
|
+
offscreen: hintsArr.includes('offscreen'),
|
|
638
|
+
clipped: hintsArr.includes('clipped'),
|
|
639
|
+
zeroSizeOverflowHidden: hintsArr.includes('zeroSizeOverflowHidden')
|
|
640
|
+
}
|
|
433
641
|
},
|
|
434
642
|
data: {
|
|
435
643
|
details: {
|