@surea11y/core 1.5.0 → 1.7.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 +240 -149
- package/README.md +51 -44
- package/docs/ACT_RULE_MAPPING.md +245 -0
- package/docs/API_STABILITY.md +53 -5
- package/docs/BINDING_AUTHORS_GUIDE.md +106 -4
- package/docs/DESIGN_CHALLENGES.md +367 -0
- package/docs/EARL.md +100 -0
- package/docs/ENGINE_OPTIONS.md +42 -4
- package/docs/I18N.md +4 -4
- package/docs/INTEGRATION.md +4 -2
- package/docs/LIMITATIONS.md +9 -5
- package/docs/OUTPUT_SCHEMA.md +44 -6
- package/docs/POLICY.md +1 -1
- package/docs/REPORT.md +1 -1
- package/docs/RULE_AUTHORING.md +63 -36
- package/docs/RULE_CATALOG.md +1928 -169
- package/docs/RULE_HELPERS.md +333 -0
- package/docs/RULE_TAXONOMY.md +27 -6
- package/docs/SARIF.md +21 -2
- package/docs/TROUBLESHOOTING.md +2 -2
- package/docs/WCAG_CONFORMANCE.md +34 -10
- package/package.json +11 -9
- package/src/baseline.js +3 -3
- package/src/checks/automatic/area-alt-present.js +2 -2
- package/src/checks/automatic/aria-allowed-attr.js +74 -10
- package/src/checks/automatic/aria-allowed-role.js +34 -25
- package/src/checks/automatic/aria-braille-equivalent.js +21 -13
- package/src/checks/automatic/aria-conditional-attr.js +22 -15
- package/src/checks/automatic/aria-deprecated-role.js +13 -1
- package/src/checks/automatic/aria-hidden-body.js +3 -3
- package/src/checks/automatic/aria-hidden-focus.js +5 -5
- package/src/checks/automatic/aria-prohibited-attr.js +23 -18
- package/src/checks/automatic/aria-prohibited-children.js +136 -43
- package/src/checks/automatic/aria-required-attr.js +119 -24
- package/src/checks/automatic/aria-required-children.js +54 -30
- package/src/checks/automatic/aria-required-parent.js +93 -15
- package/src/checks/automatic/aria-role-name-present.js +37 -23
- package/src/checks/automatic/aria-roles-valid.js +52 -21
- package/src/checks/automatic/aria-valid-attr-value.js +89 -33
- package/src/checks/automatic/aria-valid-attr.js +15 -10
- package/src/checks/automatic/autocomplete-valid.js +2 -2
- package/src/checks/automatic/avoid-inline-spacing.js +133 -6
- package/src/checks/automatic/binary-control-name-present.js +27 -5
- package/src/checks/automatic/button-name-present.js +92 -6
- package/src/checks/automatic/combobox-name-present.js +26 -6
- package/src/checks/automatic/contrast-computable.js +42 -0
- package/src/checks/automatic/contrast-enhanced.js +33 -1
- package/src/checks/automatic/contrast-minimum.js +33 -1
- package/src/checks/automatic/css-orientation-lock.js +138 -24
- package/src/checks/automatic/definition-list-children-valid.js +7 -8
- package/src/checks/automatic/deprecated-elements-not-used.js +1 -1
- package/src/checks/automatic/dialog-name-present.js +20 -2
- package/src/checks/automatic/duplicate-id-aria.js +10 -3
- package/src/checks/automatic/duplicate-id.js +203 -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 +10 -1
- package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
- package/src/checks/automatic/iframe-focusable-content.js +68 -7
- package/src/checks/automatic/iframe-name-present.js +37 -3
- package/src/checks/automatic/iframe-title-unique.js +1 -1
- package/src/checks/automatic/img-alt-present.js +12 -4
- package/src/checks/automatic/label-in-name.js +204 -68
- package/src/checks/automatic/link-in-text-block.js +285 -29
- package/src/checks/automatic/link-name-present.js +22 -1
- package/src/checks/automatic/list-children-valid.js +6 -6
- package/src/checks/automatic/listbox-name-present.js +28 -8
- package/src/checks/automatic/listitem-parent-valid.js +4 -4
- package/src/checks/automatic/menuitem-name-present.js +20 -2
- package/src/checks/automatic/meta-refresh-no-exceptions.js +25 -14
- package/src/checks/automatic/meta-refresh-timing-absent.js +14 -7
- package/src/checks/automatic/meter-name-present.js +23 -4
- package/src/checks/automatic/nested-interactive-controls-absent.js +5 -5
- package/src/checks/automatic/option-name-present.js +23 -4
- 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 +23 -4
- package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +64 -16
- package/src/checks/automatic/searchbox-name-present.js +28 -8
- package/src/checks/automatic/server-side-image-map-absent.js +1 -1
- package/src/checks/automatic/slider-name-present.js +27 -6
- package/src/checks/automatic/spinbutton-name-present.js +28 -8
- package/src/checks/automatic/summary-name-present.js +18 -2
- 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 +21 -2
- package/src/checks/automatic/table-headers-attr-valid.js +43 -8
- package/src/checks/automatic/table-th-has-data-cells.js +61 -5
- package/src/checks/automatic/target-size-minimum.js +155 -58
- package/src/checks/automatic/td-has-header.js +24 -23
- package/src/checks/automatic/textbox-name-present.js +28 -8
- package/src/checks/automatic/tooltip-name-present.js +21 -2
- package/src/checks/automatic/treeitem-name-present.js +23 -4
- package/src/checks/automatic/valid-lang.js +92 -7
- package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
- package/src/checks/manual/accesskeys-manual.js +3 -3
- 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 +5 -5
- package/src/checks/manual/aria-text-manual.js +4 -4
- package/src/checks/manual/bypass-blocks-present-manual.js +44 -26
- 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 +58 -11
- package/src/checks/manual/empty-table-header-manual.js +8 -8
- package/src/checks/manual/focus-order-semantics-manual.js +15 -15
- package/src/checks/manual/form-control-label-quality-manual.js +563 -0
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -2
- package/src/checks/manual/heading-order-manual.js +3 -3
- 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 +4 -4
- 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 +4 -4
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +7 -7
- package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +9 -9
- package/src/checks/manual/landmark-main-is-top-level-manual.js +6 -6
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +6 -6
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +6 -6
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -2
- package/src/checks/manual/landmark-one-main-manual.js +6 -6
- package/src/checks/manual/landmark-unique-manual.js +9 -9
- package/src/checks/manual/link-name-quality-manual.js +161 -32
- package/src/checks/manual/media-transcript-present-manual.js +2 -3
- package/src/checks/manual/meta-viewport-large-manual.js +2 -2
- package/src/checks/manual/mouse-only-event-handlers-manual.js +9 -9
- 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 +6 -6
- package/src/checks/manual/page-title-patterns-manual.js +26 -3
- package/src/checks/manual/password-paste-enabled-manual.js +255 -0
- package/src/checks/manual/presentation-role-conflict-manual.js +55 -25
- package/src/checks/manual/region-manual.js +19 -19
- package/src/checks/manual/scope-attr-valid-manual.js +2 -2
- package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
- package/src/checks/manual/skip-link-manual.js +5 -5
- package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
- package/src/checks/manual/tabindex-manual.js +2 -2
- package/src/checks/manual/table-duplicate-name-manual.js +2 -2
- 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 +8880 -41883
- package/src/earl.js +144 -0
- package/src/report.js +2 -2
- package/src/sarif.js +22 -2
- package/surea11y.browser.js +10 -37882
- package/surea11y.i18n.de.js +2 -21
- package/surea11y.i18n.es.js +2 -21
- package/surea11y.i18n.fr.js +2 -21
- package/bin/surea11y-core.js +0 -20
|
@@ -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
|
*/
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* @check empty-heading
|
|
7
7
|
* @atomic true
|
|
8
8
|
* @summary Heading elements must not be empty
|
|
9
|
-
* @standard Best Practices (no formal WCAG Success Criterion
|
|
9
|
+
* @standard Best Practices (no formal WCAG Success Criterion)
|
|
10
10
|
* @applicability
|
|
11
11
|
* Applies to elements with a heading role: native <h1>-<h6>, or any
|
|
12
12
|
* element with explicit role="heading" (unless overridden by another
|
|
@@ -18,20 +18,20 @@
|
|
|
18
18
|
* heading is announced as "heading, level N" with nothing else, which
|
|
19
19
|
* is confusing when navigating by heading.
|
|
20
20
|
* @implementation-notes
|
|
21
|
-
* - Not WCAG-normative
|
|
21
|
+
* - Not WCAG-normative, authored as an advisory, cantTell-capped
|
|
22
22
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
23
23
|
* header comment for the shared rationale/precedent.
|
|
24
24
|
* - This is also the reconciliation point for the ACT-rules
|
|
25
|
-
* "heading-name-present" requirement
|
|
26
|
-
*
|
|
27
|
-
*
|
|
25
|
+
* "heading-name-present" requirement: already covered by this
|
|
26
|
+
* pre-existing rule under a different name, not a separate gap.
|
|
27
|
+
* `title` is accepted as a naming
|
|
28
28
|
* fallback, and hidden/aria-hidden/display:none headings are excluded
|
|
29
29
|
* (gated on `isAccTreeEligible`), so an empty heading no AT user could
|
|
30
30
|
* ever reach is not flagged.
|
|
31
31
|
* - Descendant name resolution uses the shared, accname-aligned
|
|
32
|
-
* `helpers.getContentNameInfo` (see dom-helpers.js)
|
|
32
|
+
* `helpers.getContentNameInfo` (see dom-helpers.js), the same "name
|
|
33
33
|
* from content" implementation the 19 `-name-present` rules already
|
|
34
|
-
* use
|
|
34
|
+
* use, rather than a narrower hand-rolled walker, so an `<img alt="...">`
|
|
35
35
|
* descendant's alt text (e.g. a
|
|
36
36
|
* `<h1><a><div><img alt="..."></div></a></h1>` logo header) is correctly
|
|
37
37
|
* picked up as the heading's name instead of producing a false "empty
|
|
@@ -74,11 +74,58 @@ function runInPage(ctx) {
|
|
|
74
74
|
return raw.split(/\s+/)[0].toLowerCase();
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
// Same Global States and Properties set used elsewhere in this engine
|
|
78
|
+
// (aria-required-parent.js, aria-prohibited-children.js) for the same
|
|
79
|
+
// presentational-roles-conflict-resolution concept: a native h1-h6
|
|
80
|
+
// marked role="none"/"presentation" still reverts to its native heading
|
|
81
|
+
// role when it carries a global ARIA attribute (even one with an empty
|
|
82
|
+
// value, like aria-label="" -- the attribute's presence is what
|
|
83
|
+
// triggers conflict resolution, not its value).
|
|
84
|
+
const GLOBAL_ARIA_ATTRS = [
|
|
85
|
+
'aria-atomic',
|
|
86
|
+
'aria-braillelabel',
|
|
87
|
+
'aria-brailleroledescription',
|
|
88
|
+
'aria-busy',
|
|
89
|
+
'aria-controls',
|
|
90
|
+
'aria-current',
|
|
91
|
+
'aria-describedby',
|
|
92
|
+
'aria-description',
|
|
93
|
+
'aria-details',
|
|
94
|
+
'aria-disabled',
|
|
95
|
+
'aria-dropeffect',
|
|
96
|
+
'aria-errormessage',
|
|
97
|
+
'aria-flowto',
|
|
98
|
+
'aria-grabbed',
|
|
99
|
+
'aria-haspopup',
|
|
100
|
+
'aria-hidden',
|
|
101
|
+
'aria-invalid',
|
|
102
|
+
'aria-keyshortcuts',
|
|
103
|
+
'aria-label',
|
|
104
|
+
'aria-labelledby',
|
|
105
|
+
'aria-live',
|
|
106
|
+
'aria-owns',
|
|
107
|
+
'aria-relevant',
|
|
108
|
+
'aria-roledescription'
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
function hasGlobalAriaAttr(el) {
|
|
112
|
+
for (const attr of GLOBAL_ARIA_ATTRS) {
|
|
113
|
+
if (el.getAttribute && el.getAttribute(attr) != null) return true;
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
77
118
|
function isHeading(el) {
|
|
78
|
-
const explicit = getExplicitRoleToken(el);
|
|
79
|
-
if (explicit) return explicit === 'heading';
|
|
80
119
|
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
81
|
-
|
|
120
|
+
const isNativeHeadingTag = /^h[1-6]$/.test(tag);
|
|
121
|
+
|
|
122
|
+
const explicit = getExplicitRoleToken(el);
|
|
123
|
+
if (!explicit) return isNativeHeadingTag;
|
|
124
|
+
if (explicit === 'heading') return true;
|
|
125
|
+
if ((explicit === 'none' || explicit === 'presentation') && isNativeHeadingTag) {
|
|
126
|
+
return hasGlobalAriaAttr(el);
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
82
129
|
}
|
|
83
130
|
|
|
84
131
|
function getAccessibleNameText(el) {
|
|
@@ -100,7 +147,7 @@ function runInPage(ctx) {
|
|
|
100
147
|
if (joined) return joined;
|
|
101
148
|
}
|
|
102
149
|
// Shared, accname-aligned "name from content" implementation (see
|
|
103
|
-
// dom-helpers.js's getContentNameInfo header comment)
|
|
150
|
+
// dom-helpers.js's getContentNameInfo header comment). Resolves an
|
|
104
151
|
// <img> descendant's own alt text, an aria-label/aria-labelledby'd
|
|
105
152
|
// descendant's own name, etc., and already gates every descendant on
|
|
106
153
|
// full accessibility-tree eligibility (aria-hidden, display:none,
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* @check empty-table-header
|
|
7
7
|
* @atomic true
|
|
8
8
|
* @summary Table header cells must not be empty
|
|
9
|
-
* @standard Best Practices (no formal WCAG Success Criterion
|
|
9
|
+
* @standard Best Practices (no formal WCAG Success Criterion)
|
|
10
10
|
* @applicability
|
|
11
11
|
* Applies to <th> elements that don't carry a conflicting explicit role,
|
|
12
12
|
* plus any element (native <th> or not) with role="columnheader" or
|
|
@@ -19,19 +19,19 @@
|
|
|
19
19
|
* @expectation
|
|
20
20
|
* The header cell has visible text content. A <th> named only via
|
|
21
21
|
* aria-label/aria-labelledby (no visible text) is ALSO flagged, not
|
|
22
|
-
* treated as equivalent
|
|
23
|
-
*
|
|
22
|
+
* treated as equivalent: aria-label support on <th> is inconsistent
|
|
23
|
+
* in practice. NVDA+Firefox and iOS VoiceOver+Safari ignore
|
|
24
24
|
* it entirely (only visible text is announced), JAWS+Chrome/IE11 also only
|
|
25
25
|
* announce visible text in the header cell itself. Visible text is the one
|
|
26
26
|
* mechanism confirmed to work across every tested combination. See
|
|
27
27
|
* https://html5accessibility.com/stuff/2024/05/22/not-so-short-note-on-aria-label-usage-big-table-edition/.
|
|
28
28
|
* @implementation-notes
|
|
29
|
-
* - Not WCAG-normative
|
|
29
|
+
* - Not WCAG-normative, authored as an advisory, cantTell-capped
|
|
30
30
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
31
31
|
* header comment for the shared rationale/precedent.
|
|
32
32
|
* - Two distinct reasonCodes: TABLE_HEADER_EMPTY (no accessible name at
|
|
33
33
|
* all) vs. TABLE_HEADER_NAME_NOT_VISIBLE_TEXT (has aria-label/
|
|
34
|
-
* aria-labelledby but no visible text)
|
|
34
|
+
* aria-labelledby but no visible text), the latter gets a different
|
|
35
35
|
* summary/hint explaining the AT-support gap, since the header isn't
|
|
36
36
|
* literally nameless, just unreliably named in practice.
|
|
37
37
|
*/
|
|
@@ -41,7 +41,7 @@ const id = 'empty-table-header';
|
|
|
41
41
|
const meta = {
|
|
42
42
|
title: 'Table header cells must not be empty',
|
|
43
43
|
description:
|
|
44
|
-
'Checks that table header cells (<th>, or any element with role="columnheader"/"rowheader") have visible text content
|
|
44
|
+
'Checks that table header cells (<th>, or any element with role="columnheader"/"rowheader") have visible text content. A header named only via aria-label/aria-labelledby is also flagged, since real screen-reader/browser support for that is inconsistent.',
|
|
45
45
|
i18n: {
|
|
46
46
|
titleKey: 'emptyTableHeader_title',
|
|
47
47
|
descriptionKey: 'emptyTableHeader_description'
|
|
@@ -146,8 +146,8 @@ function runInPage(ctx) {
|
|
|
146
146
|
occurrences.push(
|
|
147
147
|
helpers.reportOccurrence(el, {
|
|
148
148
|
summary:
|
|
149
|
-
'This table header cell has no visible text
|
|
150
|
-
hint: 'Add visible text content to this header cell (in addition to, or instead of, aria-label/aria-labelledby)
|
|
149
|
+
'This table header cell has no visible text. Its only accessible name comes from aria-label/aria-labelledby, which real screen-reader/browser combinations (e.g. NVDA+Firefox, iOS VoiceOver+Safari) are known to ignore on <th> elements.',
|
|
150
|
+
hint: 'Add visible text content to this header cell (in addition to, or instead of, aria-label/aria-labelledby); visible text is the only naming mechanism confirmed to work across tested screen readers.',
|
|
151
151
|
i18n: {
|
|
152
152
|
summaryKey: 'emptyTableHeader_summary_cantTell_ariaOnly',
|
|
153
153
|
hintKey: 'emptyTableHeader_hint_cantTell_ariaOnly',
|
|
@@ -6,32 +6,32 @@
|
|
|
6
6
|
* @check focus-order-semantics
|
|
7
7
|
* @atomic true
|
|
8
8
|
* @summary Elements added to the tab order should have interactive semantics
|
|
9
|
-
* @standard Best Practices (no formal WCAG Success Criterion
|
|
9
|
+
* @standard Best Practices (no formal WCAG Success Criterion)
|
|
10
10
|
* @applicability
|
|
11
11
|
* Elements with an explicit `tabindex` of `0` or greater (in the tab
|
|
12
12
|
* order) AND an explicit `role` attribute that is one of a curated set
|
|
13
13
|
* of clearly non-interactive, structural/document roles.
|
|
14
14
|
* @expectation
|
|
15
|
-
* An element
|
|
16
|
-
* why it's focusable
|
|
15
|
+
* An element placed in the tab order on purpose should communicate
|
|
16
|
+
* why it's focusable: a role like `heading`, `list`, `region`, or
|
|
17
17
|
* `presentation` gives assistive technology no interactive semantic to
|
|
18
18
|
* announce, which is confusing for keyboard users who land on it and
|
|
19
19
|
* get no indication of what activating it (if anything) would do.
|
|
20
20
|
* @implementation-notes
|
|
21
|
-
* - Not WCAG-normative
|
|
21
|
+
* - Not WCAG-normative, authored as an advisory, cantTell-capped
|
|
22
22
|
* `type: 'manual'` rule.
|
|
23
|
-
* - The non-interactive role list is
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
23
|
+
* - The non-interactive role list is curated and conservative
|
|
24
|
+
* (structural/document roles only). Legitimate custom widget patterns
|
|
25
|
+
* using `tabindex` with an actually interactive role (`option`, `tab`,
|
|
26
|
+
* `menuitem`, etc.) are never flagged. Elements with `tabindex` and NO
|
|
27
|
+
* role at all are also not flagged: native semantics or an
|
|
28
|
+
* intentionally generic custom-interactive pattern cannot be
|
|
29
29
|
* distinguished from markup alone with the same confidence.
|
|
30
|
-
* - `region` is
|
|
31
|
-
* tabbable `role="region"` is a real, WCAG 2.1.1/2.1.3-grounded
|
|
32
|
-
* this engine's own `scrollable-region-focusable` check exists
|
|
33
|
-
* RECOMMEND (a scrollable landmark with no other focusable content
|
|
34
|
-
* needs `tabindex="0"` to be keyboard-reachable at all)
|
|
30
|
+
* - `region` is intentionally excluded from the non-interactive role
|
|
31
|
+
* list: a tabbable `role="region"` is a real, WCAG 2.1.1/2.1.3-grounded
|
|
32
|
+
* pattern this engine's own `scrollable-region-focusable` check exists
|
|
33
|
+
* to RECOMMEND (a scrollable landmark with no other focusable content
|
|
34
|
+
* needs `tabindex="0"` to be keyboard-reachable at all), so flagging it
|
|
35
35
|
* here would be internally inconsistent with that sibling check. A
|
|
36
36
|
* `role="region"` is also commonly made tabbable on its own merits
|
|
37
37
|
* (e.g. a cookie-consent banner or notification/toast region a keyboard
|