@surea11y/core 1.4.1 → 1.6.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 +212 -128
- package/README.md +46 -9
- package/docs/ACT_RULE_MAPPING.md +243 -0
- package/docs/API_STABILITY.md +4 -4
- package/docs/BINDING_AUTHORS_GUIDE.md +3 -3
- package/docs/DESIGN_CHALLENGES.md +301 -0
- package/docs/ENGINE_OPTIONS.md +30 -14
- package/docs/I18N.md +176 -20
- package/docs/INTEGRATION.md +29 -7
- package/docs/LIMITATIONS.md +6 -4
- package/docs/OUTPUT_SCHEMA.md +13 -3
- package/docs/REPORT.md +3 -1
- package/docs/RULE_AUTHORING.md +104 -23
- package/docs/RULE_CATALOG.md +1878 -169
- package/docs/RULE_TAXONOMY.md +2 -2
- package/docs/TROUBLESHOOTING.md +4 -4
- package/docs/WCAG_CONFORMANCE.md +25 -9
- package/package.json +8 -7
- package/src/baseline.js +3 -3
- package/src/checks/automatic/area-alt-present.js +2 -2
- package/src/checks/automatic/aria-allowed-attr.js +95 -40
- package/src/checks/automatic/aria-allowed-role.js +16 -18
- package/src/checks/automatic/aria-braille-equivalent.js +19 -21
- package/src/checks/automatic/aria-conditional-attr.js +22 -24
- package/src/checks/automatic/aria-deprecated-role.js +63 -50
- package/src/checks/automatic/aria-hidden-body.js +4 -11
- package/src/checks/automatic/aria-hidden-focus.js +104 -23
- package/src/checks/automatic/aria-prohibited-attr.js +71 -72
- package/src/checks/automatic/aria-prohibited-children.js +154 -61
- package/src/checks/automatic/aria-required-attr.js +74 -29
- package/src/checks/automatic/aria-required-children.js +38 -34
- package/src/checks/automatic/aria-required-parent.js +78 -29
- package/src/checks/automatic/aria-role-name-present.js +36 -22
- package/src/checks/automatic/aria-roles-valid.js +37 -23
- package/src/checks/automatic/aria-valid-attr-value.js +33 -33
- package/src/checks/automatic/aria-valid-attr.js +15 -18
- package/src/checks/automatic/autocomplete-valid.js +17 -19
- package/src/checks/automatic/avoid-inline-spacing.js +14 -16
- package/src/checks/automatic/binary-control-name-present.js +46 -26
- package/src/checks/automatic/button-name-present.js +115 -34
- package/src/checks/automatic/combobox-name-present.js +40 -22
- package/src/checks/automatic/contrast-computable.js +32 -0
- package/src/checks/automatic/contrast-enhanced.js +21 -1
- package/src/checks/automatic/contrast-minimum.js +21 -1
- package/src/checks/automatic/css-orientation-lock.js +118 -41
- package/src/checks/automatic/definition-list-children-valid.js +25 -29
- package/src/checks/automatic/deprecated-elements-not-used.js +15 -17
- package/src/checks/automatic/dialog-name-present.js +36 -20
- package/src/checks/automatic/dlitem-parent-valid.js +15 -17
- package/src/checks/automatic/duplicate-id-aria.js +50 -40
- package/src/checks/automatic/duplicate-id.js +198 -0
- package/src/checks/automatic/embed-text-alternative-present.js +2 -2
- package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
- package/src/checks/automatic/form-control-single-label.js +39 -41
- package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
- package/src/checks/automatic/iframe-focusable-content.js +92 -38
- package/src/checks/automatic/iframe-name-present.js +53 -21
- package/src/checks/automatic/iframe-title-unique.js +19 -24
- package/src/checks/automatic/img-alt-present.js +12 -4
- package/src/checks/automatic/label-in-name.js +198 -49
- package/src/checks/automatic/link-in-text-block.js +29 -31
- package/src/checks/automatic/link-name-present.js +47 -31
- package/src/checks/automatic/list-children-valid.js +21 -23
- package/src/checks/automatic/listbox-name-present.js +42 -24
- package/src/checks/automatic/listitem-parent-valid.js +18 -21
- package/src/checks/automatic/menuitem-name-present.js +36 -20
- package/src/checks/automatic/meta-refresh-no-exceptions.js +39 -31
- package/src/checks/automatic/meta-refresh-timing-absent.js +29 -25
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +14 -17
- package/src/checks/automatic/meter-name-present.js +38 -21
- package/src/checks/automatic/nested-interactive-controls-absent.js +22 -24
- package/src/checks/automatic/option-name-present.js +39 -22
- package/src/checks/automatic/page-title-present.js +21 -3
- package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
- package/src/checks/automatic/progressbar-name-present.js +41 -24
- package/src/checks/automatic/role-img-alt-present.js +64 -16
- package/src/checks/automatic/searchbox-name-present.js +46 -24
- package/src/checks/automatic/server-side-image-map-absent.js +16 -19
- package/src/checks/automatic/slider-name-present.js +42 -23
- package/src/checks/automatic/spinbutton-name-present.js +46 -24
- package/src/checks/automatic/summary-name-present.js +34 -20
- package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
- package/src/checks/automatic/svg-text-alternative-present.js +13 -10
- package/src/checks/automatic/tab-name-present.js +37 -20
- package/src/checks/automatic/table-headers-attr-valid.js +57 -24
- package/src/checks/automatic/table-th-has-data-cells.js +76 -24
- package/src/checks/automatic/target-size-minimum.js +172 -131
- package/src/checks/automatic/td-has-header.js +20 -25
- package/src/checks/automatic/textbox-name-present.js +42 -24
- package/src/checks/automatic/tooltip-name-present.js +37 -20
- package/src/checks/automatic/treeitem-name-present.js +39 -22
- package/src/checks/automatic/valid-lang.js +107 -24
- package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
- package/src/checks/manual/accesskeys-manual.js +21 -22
- package/src/checks/manual/area-alt-decorative-manual.js +7 -0
- package/src/checks/manual/area-alt-quality-manual.js +6 -0
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +23 -26
- package/src/checks/manual/aria-text-manual.js +4 -4
- package/src/checks/manual/bypass-blocks-present-manual.js +48 -38
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
- package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
- package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
- package/src/checks/manual/empty-heading-manual.js +73 -28
- package/src/checks/manual/empty-table-header-manual.js +33 -36
- package/src/checks/manual/focus-order-semantics-manual.js +15 -15
- package/src/checks/manual/form-control-label-quality-manual.js +453 -0
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +4 -11
- package/src/checks/manual/heading-order-manual.js +20 -25
- package/src/checks/manual/heading-quality-manual.js +338 -0
- package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
- package/src/checks/manual/image-redundant-alt-manual.js +18 -21
- package/src/checks/manual/img-alt-decorative-manual.js +211 -52
- package/src/checks/manual/img-alt-quality-manual.js +7 -0
- package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
- package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
- package/src/checks/manual/label-title-only-manual.js +19 -21
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +21 -24
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +23 -26
- package/src/checks/manual/landmark-main-is-top-level-manual.js +20 -23
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +8 -13
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +8 -13
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +4 -9
- package/src/checks/manual/landmark-one-main-manual.js +8 -15
- package/src/checks/manual/landmark-unique-manual.js +31 -36
- package/src/checks/manual/link-name-quality-manual.js +162 -35
- package/src/checks/manual/media-transcript-present-manual.js +2 -3
- package/src/checks/manual/meta-viewport-large-manual.js +16 -19
- package/src/checks/manual/mouse-only-event-handlers-manual.js +26 -28
- package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
- package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
- package/src/checks/manual/p-as-heading-manual.js +4 -4
- package/src/checks/manual/page-has-heading-one-manual.js +8 -15
- package/src/checks/manual/page-title-patterns-manual.js +26 -3
- package/src/checks/manual/presentation-role-conflict-manual.js +74 -46
- package/src/checks/manual/region-manual.js +32 -25
- package/src/checks/manual/scope-attr-valid-manual.js +16 -19
- package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
- package/src/checks/manual/skip-link-manual.js +44 -52
- package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
- package/src/checks/manual/tabindex-manual.js +16 -19
- package/src/checks/manual/table-duplicate-name-manual.js +16 -19
- package/src/checks/manual/table-fake-caption-manual.js +2 -2
- package/src/checks/manual/video-caption-manual.js +3 -3
- package/src/checks/manual-review.js +17 -1
- package/src/core.js +13086 -5169
- package/src/report.js +16 -2
- package/surea11y.browser.js +5665 -4219
- package/surea11y.i18n.de.js +22 -0
- package/surea11y.i18n.es.js +22 -0
- package/surea11y.i18n.fr.js +22 -0
- package/bin/surea11y-core.js +0 -20
|
@@ -9,31 +9,36 @@
|
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
10
|
* @sc 2.4.1
|
|
11
11
|
* @applicability
|
|
12
|
-
* Always applicable to any HTML document with a <body> element
|
|
12
|
+
* Always applicable to any HTML document with a <body> element:
|
|
13
13
|
* "bypass blocks" is a whole-page concern, matching
|
|
14
14
|
* aria-hidden-body / page-title-present's pattern of
|
|
15
15
|
* evaluating the document directly rather than a scoped root.
|
|
16
16
|
* @expectation
|
|
17
17
|
* At least one of the following recognized WCAG 2.4.1 techniques is
|
|
18
18
|
* present:
|
|
19
|
-
* (a) a main landmark (<main> or [role="main"])
|
|
19
|
+
* (a) a main landmark (<main> or [role="main"]), technique ARIA11: a
|
|
20
20
|
* screen reader user can jump straight to it, bypassing everything
|
|
21
21
|
* before it (nav, header, repeated blocks) in one step;
|
|
22
|
-
* (b) a working same-page anchor link
|
|
22
|
+
* (b) a working same-page anchor link, technique G1/G123: an
|
|
23
23
|
* <a href="#id"> (or legacy <a name="id">) whose target resolves to
|
|
24
24
|
* a real element in the link's own tree (light DOM or the same shadow
|
|
25
|
-
* root).
|
|
26
|
-
*
|
|
27
|
-
* (c) at least one heading (<h1>-<h6> or [role="heading"])
|
|
28
|
-
*
|
|
29
|
-
*
|
|
25
|
+
* root). Not required to be positioned before a <nav> or be
|
|
26
|
+
* keyboard-focus-order-first (see implementation notes);
|
|
27
|
+
* (c) at least one heading (<h1>-<h6> or [role="heading"]) that is both
|
|
28
|
+
* included in the accessibility tree AND visible (not off-screen,
|
|
29
|
+
* clipped, opacity:0, or zero-size-overflow-hidden), technique H69:
|
|
30
|
+
* heading navigation is itself a standards-recognized bypass
|
|
31
|
+
* mechanism (e.g. a screen reader's "jump by heading" command), but
|
|
32
|
+
* ACT 047fe0's own Expectation requires visibility too, since a
|
|
33
|
+
* screen-reader-only heading leaves sighted keyboard users with no
|
|
34
|
+
* equivalent way to locate the start of non-repeated content.
|
|
30
35
|
* @implementation-notes
|
|
31
36
|
* - Outcome model: this rule is `type: 'manual'` (cantTell-capped, never
|
|
32
37
|
* `fail`). When a recognized mechanism is found the page has nothing to
|
|
33
38
|
* review here → `notApplicable` (matching page-has-heading-one-manual /
|
|
34
39
|
* skip-link-manual's "nothing to flag" convention). When none is found we
|
|
35
|
-
* return `cantTell
|
|
36
|
-
* verify"
|
|
40
|
+
* return `cantTell`: "we could not detect a bypass mechanism, please
|
|
41
|
+
* verify," rather than a hard `fail`. The absence of a *detectable*
|
|
37
42
|
* mechanism is NOT high-confidence evidence that 2.4.1 is violated, for
|
|
38
43
|
* several reasons the engine cannot resolve from a single static snapshot:
|
|
39
44
|
* • Applicability itself is undecidable in-page. 2.4.1 governs blocks of
|
|
@@ -45,33 +50,28 @@
|
|
|
45
50
|
* rest of the page is routinely made `inert` or `aria-hidden="true"`,
|
|
46
51
|
* so the page's real <main>/headings are (correctly) filtered out by
|
|
47
52
|
* isAccTreeEligible for the duration of that state and only the dialog
|
|
48
|
-
* is exposed
|
|
53
|
+
* is exposed, so a snapshot taken then would see "no mechanism" though
|
|
49
54
|
* the page has one once the dialog closes. The same applies to content
|
|
50
55
|
* that is display:none until revealed by script (tabs, accordions, an
|
|
51
56
|
* unmounted SPA view).
|
|
52
57
|
* Both cases would produce false positives under a hard `fail`, which this
|
|
53
58
|
* engine reserves for high-confidence violations; `cantTell` routes them to
|
|
54
|
-
* human review instead.
|
|
55
|
-
*
|
|
56
|
-
* failing it), and why no ACT rule hard-fails 2.4.1 by presence alone.
|
|
59
|
+
* human review instead. No ACT rule hard-fails 2.4.1 by presence alone,
|
|
60
|
+
* for the same reason.
|
|
57
61
|
* - This rule intentionally checks presence, not position, for the
|
|
58
|
-
* same-page-anchor condition (b): a full bypass algorithm is heuristic
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* (a page whose only anchor link isn't a real skip mechanism, e.g. a
|
|
65
|
-
* "back to top" link) — never raise a spurious one.
|
|
62
|
+
* same-page-anchor condition (b): a full bypass algorithm is heuristic,
|
|
63
|
+
* and getting DOM-order / keyboard-focus-order positioning exactly right
|
|
64
|
+
* without introducing false positives is materially harder than checking
|
|
65
|
+
* presence alone. Being lenient about condition (b) can only make us
|
|
66
|
+
* *miss* a review prompt (a page whose only anchor link isn't a real
|
|
67
|
+
* skip mechanism, e.g. a "back to top" link), never raising a spurious one.
|
|
66
68
|
* - Shadow DOM: all three conditions use `helpers.queryAllSmart`, which is
|
|
67
69
|
* shadow-DOM-aware (when the run enables includeShadowDom) and applies the
|
|
68
70
|
* engine's hidden-content policy. The same-page-anchor target is resolved
|
|
69
|
-
* in the link's own root (`getRootNode()
|
|
71
|
+
* in the link's own root (`getRootNode()`: the document, or the shadow
|
|
70
72
|
* root the link lives in) before falling back to the document, so a skip
|
|
71
73
|
* link encapsulated in a web component is credited the same as one in the
|
|
72
|
-
* light DOM.
|
|
73
|
-
* `document.querySelectorAll`/`getElementById`, which never pierced shadow
|
|
74
|
-
* roots — a genuine gap now closed.)
|
|
74
|
+
* light DOM.
|
|
75
75
|
*/
|
|
76
76
|
|
|
77
77
|
const id = 'bypass-blocks-present';
|
|
@@ -228,9 +228,26 @@ function runInPage(ctx) {
|
|
|
228
228
|
return false;
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
// ACT 047fe0's own Expectation requires the heading to be visible, not
|
|
232
|
+
// only included in the accessibility tree: a screen-reader-only heading
|
|
233
|
+
// still leaves sighted keyboard users with no way to locate the start of
|
|
234
|
+
// non-repeated content. "Visible" per ACT's own glossary: making it fully
|
|
235
|
+
// transparent would change rendered pixels, which every CSS-only hiding
|
|
236
|
+
// technique (off-screen positioning, clip/clip-path, opacity:0, a
|
|
237
|
+
// zero-size overflow:hidden box) fails.
|
|
238
|
+
function isCssHidden(el) {
|
|
239
|
+
if (!helpers || typeof helpers.getVisibilityHintsInfo !== 'function') return false;
|
|
240
|
+
try {
|
|
241
|
+
const info = helpers.getVisibilityHintsInfo(el, ctx, {});
|
|
242
|
+
return !!(info && Array.isArray(info.hints) && info.hints.length > 0);
|
|
243
|
+
} catch {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
231
248
|
function hasHeading() {
|
|
232
249
|
for (const el of queryAll('h1, h2, h3, h4, h5, h6, [role="heading"]')) {
|
|
233
|
-
if (el && isExposedToAt(el)) return true;
|
|
250
|
+
if (el && isExposedToAt(el) && !isCssHidden(el)) return true;
|
|
234
251
|
}
|
|
235
252
|
return false;
|
|
236
253
|
}
|
|
@@ -244,18 +261,11 @@ function runInPage(ctx) {
|
|
|
244
261
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
245
262
|
}
|
|
246
263
|
|
|
247
|
-
const stableSelector = helpers.buildSelector ? helpers.buildSelector(body) : 'body';
|
|
248
|
-
const html = helpers.getOuterHtmlSnippet
|
|
249
|
-
? helpers.getOuterHtmlSnippet(body)
|
|
250
|
-
: (body.outerHTML || '').slice(0, 200);
|
|
251
|
-
|
|
252
264
|
const occurrences = [
|
|
253
|
-
{
|
|
254
|
-
selector: stableSelector,
|
|
255
|
-
html,
|
|
265
|
+
helpers.reportOccurrence(body, {
|
|
256
266
|
summary:
|
|
257
|
-
'No recognized way to bypass repeated blocks of content was detected on this page
|
|
258
|
-
hint: 'Confirm the page offers a bypass mechanism: a main landmark (<main> or role="main"), a working "skip to content" link, or heading elements that assistive technology can use to jump past repeated content. (A mechanism may be temporarily hidden
|
|
267
|
+
'No recognized way to bypass repeated blocks of content was detected on this page. Verify a bypass mechanism exists.',
|
|
268
|
+
hint: 'Confirm the page offers a bypass mechanism: a main landmark (<main> or role="main"), a working "skip to content" link, or heading elements that assistive technology can use to jump past repeated content. (A mechanism may be temporarily hidden, e.g. while a modal dialog makes the page inert, or provided on a per-site basis; this needs human confirmation.)',
|
|
259
269
|
i18n: {
|
|
260
270
|
summaryKey: 'bypassBlocksPresent_summary_cantTell',
|
|
261
271
|
hintKey: 'bypassBlocksPresent_hint_cantTell',
|
|
@@ -265,7 +275,7 @@ function runInPage(ctx) {
|
|
|
265
275
|
details: { reasonCode: 'BYPASS_MECHANISM_ABSENT' },
|
|
266
276
|
visibilityFilter: { targetSet: 'acc', accEligible: null, reasons: [] }
|
|
267
277
|
}
|
|
268
|
-
}
|
|
278
|
+
})
|
|
269
279
|
];
|
|
270
280
|
|
|
271
281
|
return {
|
|
@@ -9,6 +9,14 @@
|
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
10
|
* @sc 1.1.1
|
|
11
11
|
* @type manual
|
|
12
|
+
* @applicability
|
|
13
|
+
* Applies to <canvas> elements that already carry a text alternative:
|
|
14
|
+
* fallback content inside the element, an ARIA name, or a title. A
|
|
15
|
+
* <canvas> with none of those has no alternative whose quality could be
|
|
16
|
+
* judged; that's canvas-text-alternative-present's failure. The element
|
|
17
|
+
* must be included in the accessibility tree, and
|
|
18
|
+
* role="presentation"/"none" takes it out of scope unless it is focusable,
|
|
19
|
+
* which restores its role.
|
|
12
20
|
* @expectation
|
|
13
21
|
* Human review is required to confirm that the provided text alternative is accurate and appropriate.
|
|
14
22
|
*/
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @check css-focus-indicator-suppressed
|
|
7
|
+
* @atomic true
|
|
8
|
+
* @summary CSS must not remove the focus indicator without drawing a replacement
|
|
9
|
+
* @standard WCAG 2.2
|
|
10
|
+
* @sc 2.4.7
|
|
11
|
+
* @applicability
|
|
12
|
+
* Elements in sequential focus navigation (tabbable and rendered) on a
|
|
13
|
+
* page whose accessible stylesheets contain at least one `:focus` or
|
|
14
|
+
* `:focus-visible` rule. With no focus rule anywhere, every element
|
|
15
|
+
* keeps the user agent's own indicator and there is nothing to check.
|
|
16
|
+
* @expectation
|
|
17
|
+
* No element is matched by a `:focus`/`:focus-visible` rule that removes
|
|
18
|
+
* the outline (`outline: none`, `outline: 0`, `outline-color:
|
|
19
|
+
* transparent`, ...) unless some other focus rule matching it draws a
|
|
20
|
+
* replacement: a border, box-shadow, background, color change, a
|
|
21
|
+
* positive outline of its own, or a `::before`/`::after` decoration.
|
|
22
|
+
* @implementation-notes
|
|
23
|
+
* - Authored as `type: 'manual'` (cantTell-capped, never fail). CSS is
|
|
24
|
+
* only one of the ways a page can indicate focus: ACT oj04fd's own
|
|
25
|
+
* passed examples suppress the outline in CSS and then paint an
|
|
26
|
+
* indicator from an `onfocus` handler, on a sibling element. Static
|
|
27
|
+
* markup cannot see that, so a suppressed outline is a strong review
|
|
28
|
+
* signal rather than a proven failure.
|
|
29
|
+
* - Suppression is only read off the rule's SUBJECT: in `.a:focus .b`,
|
|
30
|
+
* the declarations apply to `.b` while `.a` has focus, so it says
|
|
31
|
+
* nothing about `.b`'s own focus indicator. A replacement, by contrast,
|
|
32
|
+
* is accepted from any rule whose focused compound matches the element:
|
|
33
|
+
* that's exactly the "focus me, paint something elsewhere" pattern
|
|
34
|
+
* (`#link:focus + .indicator { background: navy }`), which does give
|
|
35
|
+
* the user a visible change.
|
|
36
|
+
* - Replacement properties are a curated list of the ones that change
|
|
37
|
+
* pixels (border, box-shadow, background, color, text-decoration,
|
|
38
|
+
* filter, opacity, transform, and `content` for a pseudo-element
|
|
39
|
+
* part), matching this engine's other curated-list checks. A rule
|
|
40
|
+
* setting only `outline-offset` alongside `outline: none` is not a
|
|
41
|
+
* replacement: it offsets an outline that is no longer drawn.
|
|
42
|
+
* - Cross-origin stylesheets throw on `.cssRules` access and are skipped,
|
|
43
|
+
* same limitation as `css-orientation-lock`. A page whose only focus
|
|
44
|
+
* styles live in one of those is reported as having no focus rules at
|
|
45
|
+
* all, so it is not flagged.
|
|
46
|
+
* - Selector matching goes through `el.matches()` on the focus pseudo
|
|
47
|
+
* stripped out of the selector. A selector the engine cannot parse
|
|
48
|
+
* (vendor pseudo-elements, `:host`, unsupported `:is()` forms) throws
|
|
49
|
+
* there and is skipped rather than guessed at.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
const id = 'css-focus-indicator-suppressed';
|
|
53
|
+
|
|
54
|
+
const meta = {
|
|
55
|
+
title: 'Focus indicator must not be removed without a replacement',
|
|
56
|
+
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 other focus rule matching them.',
|
|
58
|
+
i18n: {
|
|
59
|
+
titleKey: 'cssFocusIndicatorSuppressed_title',
|
|
60
|
+
descriptionKey: 'cssFocusIndicatorSuppressed_description'
|
|
61
|
+
},
|
|
62
|
+
helpUrl: null,
|
|
63
|
+
tags: ['wcag2aa', 'wcag247', 'navigation', 'focus', 'css', 'atomic', 'manual'],
|
|
64
|
+
wcagSc: ['2.4.7'],
|
|
65
|
+
normativeMappings: [
|
|
66
|
+
{
|
|
67
|
+
standard: 'WCAG',
|
|
68
|
+
version: '2.2',
|
|
69
|
+
requirement: '2.4.7',
|
|
70
|
+
title: 'Focus Visible',
|
|
71
|
+
conformanceLevel: 'AA'
|
|
72
|
+
}
|
|
73
|
+
],
|
|
74
|
+
defaultSeverity: 'serious',
|
|
75
|
+
category: 'operable',
|
|
76
|
+
type: 'manual',
|
|
77
|
+
defaultConfidence: 'medium',
|
|
78
|
+
coverage: { facetsBySc: { '2.4.7': ['focus-indicator-not-suppressed'] } }
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function runInPage(ctx) {
|
|
82
|
+
const { document, helpers, rule } = ctx;
|
|
83
|
+
|
|
84
|
+
// Declared inside runInPage; see scripts/build-core.js header
|
|
85
|
+
// ("runInPage MUST be self-contained").
|
|
86
|
+
const CSS_STYLE_RULE = 1;
|
|
87
|
+
|
|
88
|
+
// Properties whose presence in a focus rule changes what the user sees.
|
|
89
|
+
// `outline` is handled separately, since the same property is both the
|
|
90
|
+
// suppression and the most common replacement.
|
|
91
|
+
const REPLACEMENT_PROPS = [
|
|
92
|
+
'background',
|
|
93
|
+
'background-color',
|
|
94
|
+
'background-image',
|
|
95
|
+
'border',
|
|
96
|
+
'border-color',
|
|
97
|
+
'border-style',
|
|
98
|
+
'border-width',
|
|
99
|
+
'border-top',
|
|
100
|
+
'border-right',
|
|
101
|
+
'border-bottom',
|
|
102
|
+
'border-left',
|
|
103
|
+
'border-radius',
|
|
104
|
+
'box-shadow',
|
|
105
|
+
'color',
|
|
106
|
+
'content',
|
|
107
|
+
'filter',
|
|
108
|
+
'font-weight',
|
|
109
|
+
'opacity',
|
|
110
|
+
'text-decoration',
|
|
111
|
+
'text-decoration-line',
|
|
112
|
+
'text-decoration-color',
|
|
113
|
+
'text-shadow',
|
|
114
|
+
'transform'
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
const MAX_DEPTH = 10;
|
|
118
|
+
|
|
119
|
+
function trim(v) {
|
|
120
|
+
return (v == null ? '' : String(v)).trim();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function lower(v) {
|
|
124
|
+
return trim(v).toLowerCase();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function getProp(style, name) {
|
|
128
|
+
if (!style) return '';
|
|
129
|
+
try {
|
|
130
|
+
if (typeof style.getPropertyValue === 'function') return lower(style.getPropertyValue(name));
|
|
131
|
+
} catch {
|
|
132
|
+
return '';
|
|
133
|
+
}
|
|
134
|
+
return '';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isZeroLength(v) {
|
|
138
|
+
return /^0(\.0+)?(px|em|rem|pt|pc|in|cm|mm|ex|ch|vw|vh|vmin|vmax|%)?$/.test(v);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// outline: none | 0 | transparent, in shorthand or longhand form.
|
|
142
|
+
function suppressesOutline(style) {
|
|
143
|
+
const outlineStyle = getProp(style, 'outline-style');
|
|
144
|
+
if (outlineStyle === 'none' || outlineStyle === 'hidden') return true;
|
|
145
|
+
|
|
146
|
+
const outlineWidth = getProp(style, 'outline-width');
|
|
147
|
+
if (outlineWidth && isZeroLength(outlineWidth)) return true;
|
|
148
|
+
|
|
149
|
+
if (getProp(style, 'outline-color') === 'transparent') return true;
|
|
150
|
+
|
|
151
|
+
const outline = getProp(style, 'outline');
|
|
152
|
+
if (outline) {
|
|
153
|
+
const tokens = outline.split(/\s+/).filter(Boolean);
|
|
154
|
+
for (const token of tokens) {
|
|
155
|
+
if (token === 'none' || token === 'hidden' || token === 'transparent') return true;
|
|
156
|
+
if (isZeroLength(token)) return true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// A positive outline counts as a replacement: `*:focus { outline: none }`
|
|
163
|
+
// followed by `a:focus { outline: 2px solid }` leaves links indicated.
|
|
164
|
+
function drawsOutline(style) {
|
|
165
|
+
if (suppressesOutline(style)) return false;
|
|
166
|
+
return !!(
|
|
167
|
+
getProp(style, 'outline') ||
|
|
168
|
+
getProp(style, 'outline-style') ||
|
|
169
|
+
getProp(style, 'outline-width') ||
|
|
170
|
+
getProp(style, 'outline-color')
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function providesReplacement(style) {
|
|
175
|
+
if (drawsOutline(style)) return true;
|
|
176
|
+
for (const prop of REPLACEMENT_PROPS) {
|
|
177
|
+
if (getProp(style, prop)) return true;
|
|
178
|
+
}
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Splits a selector list on top-level commas only, so a comma inside
|
|
183
|
+
// :not(...)/:is(...) does not break a selector in half.
|
|
184
|
+
function splitSelectorList(selectorText) {
|
|
185
|
+
const parts = [];
|
|
186
|
+
let depth = 0;
|
|
187
|
+
let current = '';
|
|
188
|
+
for (const ch of String(selectorText || '')) {
|
|
189
|
+
if (ch === '(') depth += 1;
|
|
190
|
+
if (ch === ')') depth = Math.max(0, depth - 1);
|
|
191
|
+
if (ch === ',' && depth === 0) {
|
|
192
|
+
parts.push(current);
|
|
193
|
+
current = '';
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
current += ch;
|
|
197
|
+
}
|
|
198
|
+
if (trim(current)) parts.push(current);
|
|
199
|
+
return parts.map(trim).filter(Boolean);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// :focus and :focus-visible, but never :focus-within: that one fires on
|
|
203
|
+
// an ancestor of the focused element and says nothing about whether the
|
|
204
|
+
// element itself is indicated.
|
|
205
|
+
const FOCUS_PSEUDO = /:focus(-visible)?(?![-\w])/g;
|
|
206
|
+
|
|
207
|
+
function hasFocusPseudo(part) {
|
|
208
|
+
FOCUS_PSEUDO.lastIndex = 0;
|
|
209
|
+
return FOCUS_PSEUDO.test(part);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Splits a complex selector into its compounds, keeping the combinators
|
|
213
|
+
// out: "a:focus + .indicator" -> ["a:focus", ".indicator"]. Descendant
|
|
214
|
+
// combinators inside :not(...)/:is(...) are left alone.
|
|
215
|
+
function splitCompounds(part) {
|
|
216
|
+
const compounds = [];
|
|
217
|
+
let depth = 0;
|
|
218
|
+
let current = '';
|
|
219
|
+
for (const ch of part) {
|
|
220
|
+
if (ch === '(') depth += 1;
|
|
221
|
+
if (ch === ')') depth = Math.max(0, depth - 1);
|
|
222
|
+
if (depth === 0 && (ch === ' ' || ch === '>' || ch === '+' || ch === '~')) {
|
|
223
|
+
if (trim(current)) compounds.push(trim(current));
|
|
224
|
+
current = '';
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
current += ch;
|
|
228
|
+
}
|
|
229
|
+
if (trim(current)) compounds.push(trim(current));
|
|
230
|
+
return compounds;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function stripFocusPseudo(compound) {
|
|
234
|
+
const stripped = trim(String(compound).replace(FOCUS_PSEUDO, ''));
|
|
235
|
+
return stripped || '*';
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// A pseudo-element part styles generated content rather than the element,
|
|
239
|
+
// so it can draw a replacement but can never be the thing suppressing the
|
|
240
|
+
// element's own outline.
|
|
241
|
+
function hasPseudoElement(part) {
|
|
242
|
+
return /::[a-z-]+/.test(part) || /:(before|after)\b/.test(part);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function matchesSafe(el, selector) {
|
|
246
|
+
if (!el || typeof el.matches !== 'function' || !selector) return false;
|
|
247
|
+
try {
|
|
248
|
+
return el.matches(selector);
|
|
249
|
+
} catch {
|
|
250
|
+
return false; // selector this engine cannot parse, skip rather than guess
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function closestSafe(el, selector) {
|
|
255
|
+
if (!el || typeof el.closest !== 'function' || !selector) return false;
|
|
256
|
+
try {
|
|
257
|
+
return !!el.closest(selector);
|
|
258
|
+
} catch {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const suppressors = []; // { selector, base }
|
|
264
|
+
const providers = []; // { base, subject }
|
|
265
|
+
|
|
266
|
+
function collectFromStyleRule(cssRule) {
|
|
267
|
+
const style = cssRule.style;
|
|
268
|
+
if (!style) return;
|
|
269
|
+
|
|
270
|
+
const suppresses = suppressesOutline(style);
|
|
271
|
+
const provides = providesReplacement(style);
|
|
272
|
+
if (!suppresses && !provides) return;
|
|
273
|
+
|
|
274
|
+
for (const part of splitSelectorList(cssRule.selectorText)) {
|
|
275
|
+
if (!hasFocusPseudo(part)) continue;
|
|
276
|
+
|
|
277
|
+
const compounds = splitCompounds(part);
|
|
278
|
+
let focusIndex = -1;
|
|
279
|
+
for (let i = 0; i < compounds.length; i++) {
|
|
280
|
+
if (hasFocusPseudo(compounds[i])) {
|
|
281
|
+
focusIndex = i;
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (focusIndex === -1) continue;
|
|
286
|
+
|
|
287
|
+
const isSubject = focusIndex === compounds.length - 1;
|
|
288
|
+
const focusedBase =
|
|
289
|
+
stripFocusPseudo(compounds[focusIndex]).replace(/::?[a-z-]+$/i, '') || '*';
|
|
290
|
+
|
|
291
|
+
if (suppresses && isSubject && !hasPseudoElement(part)) {
|
|
292
|
+
suppressors.push({ selector: trim(part), base: stripFocusPseudo(part) });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// A replacement is credited to the element that takes focus, wherever
|
|
296
|
+
// the rule paints it: on the element itself, its pseudo-element, a
|
|
297
|
+
// sibling, or a descendant.
|
|
298
|
+
if (provides) providers.push({ base: focusedBase, subject: isSubject });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function walkRules(rules, depth) {
|
|
303
|
+
if (!rules || depth > MAX_DEPTH) return;
|
|
304
|
+
for (const cssRule of rules) {
|
|
305
|
+
if (!cssRule) continue;
|
|
306
|
+
if (cssRule.type === CSS_STYLE_RULE && cssRule.selectorText) {
|
|
307
|
+
collectFromStyleRule(cssRule);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
// @media, @supports, @layer, ...: recurse into grouping rules.
|
|
311
|
+
let nested;
|
|
312
|
+
try {
|
|
313
|
+
nested = cssRule.cssRules || null;
|
|
314
|
+
} catch {
|
|
315
|
+
nested = null;
|
|
316
|
+
}
|
|
317
|
+
if (nested) walkRules(nested, depth + 1);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
let sheetCount = 0;
|
|
322
|
+
try {
|
|
323
|
+
const sheets = document.styleSheets || [];
|
|
324
|
+
for (const sheet of sheets) {
|
|
325
|
+
let rules = null;
|
|
326
|
+
try {
|
|
327
|
+
rules = sheet && sheet.cssRules ? sheet.cssRules : null;
|
|
328
|
+
} catch {
|
|
329
|
+
continue; // cross-origin stylesheet, not inspectable
|
|
330
|
+
}
|
|
331
|
+
if (!rules) continue;
|
|
332
|
+
sheetCount += 1;
|
|
333
|
+
walkRules(rules, 0);
|
|
334
|
+
}
|
|
335
|
+
} catch {
|
|
336
|
+
// no-throw: treat as no accessible stylesheets
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (sheetCount === 0 || !suppressors.length) {
|
|
340
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const getFocusableInfo =
|
|
344
|
+
helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
345
|
+
const isDomVisibleEligible =
|
|
346
|
+
helpers && typeof helpers.isDomVisibleEligible === 'function'
|
|
347
|
+
? helpers.isDomVisibleEligible
|
|
348
|
+
: null;
|
|
349
|
+
|
|
350
|
+
function isTabbable(el) {
|
|
351
|
+
if (!getFocusableInfo) return false;
|
|
352
|
+
try {
|
|
353
|
+
const info = getFocusableInfo(el, ctx);
|
|
354
|
+
return !!(info && info.tabbable);
|
|
355
|
+
} catch {
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function isRendered(el) {
|
|
361
|
+
if (!isDomVisibleEligible) return true;
|
|
362
|
+
try {
|
|
363
|
+
const vis = isDomVisibleEligible(el, ctx, {
|
|
364
|
+
visibilityMode: 'styleOnly',
|
|
365
|
+
disableGeometry: true
|
|
366
|
+
});
|
|
367
|
+
return !(vis && vis.eligible === false);
|
|
368
|
+
} catch {
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const CANDIDATE_SELECTOR =
|
|
374
|
+
'a[href],area[href],button,input,select,textarea,summary,[tabindex],[contenteditable]';
|
|
375
|
+
const candidates = helpers.queryAllSmart
|
|
376
|
+
? helpers.queryAllSmart(CANDIDATE_SELECTOR)
|
|
377
|
+
: helpers.queryAll(CANDIDATE_SELECTOR);
|
|
378
|
+
|
|
379
|
+
const occurrences = [];
|
|
380
|
+
let applicableCount = 0;
|
|
381
|
+
|
|
382
|
+
for (const el of candidates) {
|
|
383
|
+
if (!el || el.nodeType !== 1) continue;
|
|
384
|
+
if (!isTabbable(el)) continue;
|
|
385
|
+
if (!isRendered(el)) continue;
|
|
386
|
+
|
|
387
|
+
applicableCount += 1;
|
|
388
|
+
|
|
389
|
+
const suppressing = suppressors.filter((s) => matchesSafe(el, s.base));
|
|
390
|
+
if (!suppressing.length) continue;
|
|
391
|
+
|
|
392
|
+
const indicated = providers.some((p) =>
|
|
393
|
+
p.subject ? matchesSafe(el, p.base) : matchesSafe(el, p.base) || closestSafe(el, p.base)
|
|
394
|
+
);
|
|
395
|
+
if (indicated) continue;
|
|
396
|
+
|
|
397
|
+
const selectors = [...new Set(suppressing.map((s) => s.selector))];
|
|
398
|
+
const eligInfo = helpers.getEligibilityInfo
|
|
399
|
+
? (() => {
|
|
400
|
+
try {
|
|
401
|
+
return helpers.getEligibilityInfo(el, ctx, { targetSet: 'dom' });
|
|
402
|
+
} catch {
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
})()
|
|
406
|
+
: null;
|
|
407
|
+
|
|
408
|
+
occurrences.push(
|
|
409
|
+
helpers.reportOccurrence(el, {
|
|
410
|
+
summary: `This element takes a tab stop, and "${selectors.join(', ')}" removes its focus outline with no replacement indicator in any other focus rule matching it.`,
|
|
411
|
+
hint: 'Draw a replacement indicator in the same rule (a visible outline, border, box-shadow, or background change), or drop the outline reset. If the indicator is applied from script instead, confirm it appears for keyboard users.',
|
|
412
|
+
i18n: {
|
|
413
|
+
summaryKey: 'cssFocusIndicatorSuppressed_summary_cantTell',
|
|
414
|
+
hintKey: 'cssFocusIndicatorSuppressed_hint_cantTell',
|
|
415
|
+
params: { selectors: selectors.join(', ') }
|
|
416
|
+
},
|
|
417
|
+
data: {
|
|
418
|
+
details: {
|
|
419
|
+
reasonCode: 'FOCUS_INDICATOR_SUPPRESSED',
|
|
420
|
+
suppressingSelectors: selectors
|
|
421
|
+
},
|
|
422
|
+
visibilityFilter: eligInfo || { targetSet: 'dom', accEligible: null, reasons: [] }
|
|
423
|
+
}
|
|
424
|
+
})
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (applicableCount === 0) {
|
|
429
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (occurrences.length) {
|
|
433
|
+
return {
|
|
434
|
+
ruleId: rule.ruleId,
|
|
435
|
+
outcome: 'cantTell',
|
|
436
|
+
severity: rule.defaultSeverity || 'serious',
|
|
437
|
+
occurrences
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -9,6 +9,14 @@
|
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
10
|
* @sc 1.1.1
|
|
11
11
|
* @type manual
|
|
12
|
+
* @applicability
|
|
13
|
+
* Applies to <embed> elements that already carry a text alternative: a
|
|
14
|
+
* non-empty aria-label, an aria-labelledby that resolves to non-empty
|
|
15
|
+
* text, or a non-empty title. An aria-labelledby pointing at a missing id
|
|
16
|
+
* resolves to nothing and so is not a text alternative to review; that
|
|
17
|
+
* element is embed-text-alternative-present's failure. The element must be
|
|
18
|
+
* included in the accessibility tree, and role="presentation"/"none" takes
|
|
19
|
+
* it out of scope unless it is focusable, which restores its role.
|
|
12
20
|
* @expectation
|
|
13
21
|
* Human review is required to confirm that the provided text alternative is accurate and appropriate.
|
|
14
22
|
*/
|