pour-engine 1.40.0 → 1.42.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/config/project.config.js +1 -1
- package/engine/AUDIT.html +24 -690
- package/engine/COVERAGE.md +17 -15
- package/engine/README.md +10 -5
- package/engine/index.js +34 -8
- package/engine/lib/accessible-name.js +69 -63
- package/engine/lib/contrast.js +111 -10
- package/engine/lib/dom.js +78 -18
- package/engine/lib/roles.js +5 -2
- package/engine/rules/best-practice/fieldset-legend.js +11 -5
- package/engine/rules/best-practice/region.js +1 -1
- package/engine/rules/index.js +29 -0
- package/engine/rules/wcag/1.1.1-area-alt.js +6 -1
- package/engine/rules/wcag/1.1.1-image-alt.js +2 -1
- package/engine/rules/wcag/1.1.1-svg-img-alt.js +14 -17
- package/engine/rules/wcag/1.2.3-video-audio-description.js +42 -0
- package/engine/rules/wcag/1.3.1-aria-required-children.js +20 -8
- package/engine/rules/wcag/1.3.1-aria-required-parent.js +7 -10
- package/engine/rules/wcag/1.3.1-form-label.js +5 -29
- package/engine/rules/wcag/1.3.1-list-structure.js +4 -3
- package/engine/rules/wcag/1.3.1-table-headers.js +23 -2
- package/engine/rules/wcag/1.3.4-orientation.js +117 -48
- package/engine/rules/wcag/1.3.5-autocomplete-valid.js +21 -8
- package/engine/rules/wcag/1.3.6-region-purpose.js +35 -0
- package/engine/rules/wcag/1.4.1-link-in-text-block.js +19 -3
- package/engine/rules/wcag/1.4.10-reflow.js +3 -3
- package/engine/rules/wcag/1.4.11-non-text-contrast.js +9 -1
- package/engine/rules/wcag/1.4.12-text-spacing.js +15 -2
- package/engine/rules/wcag/1.4.3-color-contrast.js +79 -7
- package/engine/rules/wcag/1.4.3-control-contrast.js +35 -6
- package/engine/rules/wcag/1.4.4-meta-viewport.js +7 -2
- package/engine/rules/wcag/1.4.8-text-justified.js +49 -0
- package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +5 -5
- package/engine/rules/wcag/2.2.1-meta-refresh.js +10 -4
- package/engine/rules/wcag/2.2.2-pause-stop-hide.js +5 -5
- package/engine/rules/wcag/2.2.4-meta-refresh-no-exceptions.js +50 -0
- package/engine/rules/wcag/2.4.1-bypass-blocks.js +2 -2
- package/engine/rules/wcag/2.4.10-section-heading.js +86 -0
- package/engine/rules/wcag/2.4.11-focus-not-obscured.js +134 -78
- package/engine/rules/wcag/2.4.12-focus-not-obscured-enhanced.js +17 -0
- package/engine/rules/wcag/2.4.2-document-title.js +7 -8
- package/engine/rules/wcag/2.4.4-link-text-generic.js +6 -5
- package/engine/rules/wcag/2.4.6-heading-label-placeholder.js +69 -0
- package/engine/rules/wcag/2.4.9-link-text-generic-only.js +8 -23
- package/engine/rules/wcag/2.5.1-pointer-gesture-alternative.js +98 -0
- package/engine/rules/wcag/2.5.8-target-size.js +161 -45
- package/engine/rules/wcag/3.3.4-financial-form-confirmation.js +32 -0
- package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +10 -13
- package/engine/rules/wcag/3.3.9-captcha-alternative.js +35 -0
- package/engine/rules/wcag/4.1.2-aria-attr-valid.js +4 -0
- package/engine/rules/wcag/4.1.2-aria-field-name.js +6 -5
- package/engine/rules/wcag/4.1.2-aria-valid-refs.js +8 -3
- package/engine/rules/wcag/4.1.2-button-name.js +1 -1
- package/engine/rules/wcag/4.1.2-composite-widget-name.js +1 -1
- package/engine/rules/wcag/4.1.2-invoker-target.js +2 -1
- package/engine/rules/wcag/4.1.2-label-for-valid.js +2 -2
- package/engine/rules/wcag/4.1.2-valid-role.js +6 -1
- package/engine/wcag22.js +19 -19
- package/package.json +1 -1
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import {
|
|
6
6
|
parseColor, contrastRatio, composite, effectiveBackground, isLargeText,
|
|
7
7
|
backgroundImageSource, cumulativeOpacity, opacityAnimating, restingOpacity, showRatio,
|
|
8
|
+
hasPaintEffects, opacityGroupPaint, splitBackgroundLayers, sampledGradientRange,
|
|
8
9
|
} from '../../lib/contrast.js';
|
|
9
10
|
|
|
10
11
|
export default {
|
|
@@ -56,10 +57,26 @@ export default {
|
|
|
56
57
|
// far less contrast than its declared colours suggest.
|
|
57
58
|
const opacity = opacityAnimating(element) ? restingOpacity(element) : cumulativeOpacity(element);
|
|
58
59
|
if (opacity < 0.05) return { status: 'pass' };
|
|
60
|
+
if (hasPaintEffects(element)) {
|
|
61
|
+
return { status: 'incomplete', message: 'A filter or blend mode changes the colours presented inside this field. Check the resulting text contrast by eye.' };
|
|
62
|
+
}
|
|
59
63
|
// The control's own background wins; else whatever shows through it.
|
|
60
64
|
const own = parseColor(style.backgroundColor);
|
|
61
65
|
let background;
|
|
62
|
-
if (
|
|
66
|
+
if (style.backgroundImage !== 'none') {
|
|
67
|
+
const layers = splitBackgroundLayers(style.backgroundImage);
|
|
68
|
+
const range = layers.length === 1 && layers[0].includes('gradient(')
|
|
69
|
+
? sampledGradientRange(layers[0]) : null;
|
|
70
|
+
// A constant opaque gradient has exactly one painted background.
|
|
71
|
+
// Other image paint needs spatial sampling, not its fallback colour.
|
|
72
|
+
const fullBox = /^(auto|auto auto)$/.test(style.backgroundSize)
|
|
73
|
+
&& style.backgroundPosition === '0% 0%'
|
|
74
|
+
&& ['repeat', 'no-repeat'].includes(style.backgroundRepeat);
|
|
75
|
+
if (!range || range.min !== range.max || opacity < 1 || !fullBox) {
|
|
76
|
+
return { status: 'incomplete', message: 'An image or gradient paints this field background. Check its text against the pixels behind it.' };
|
|
77
|
+
}
|
|
78
|
+
background = range.minColor;
|
|
79
|
+
} else if (own && own.a >= 1) {
|
|
63
80
|
background = own;
|
|
64
81
|
} else {
|
|
65
82
|
// An image or gradient painted under a see-through control is a
|
|
@@ -73,18 +90,27 @@ export default {
|
|
|
73
90
|
};
|
|
74
91
|
}
|
|
75
92
|
const behind = effectiveBackground(element);
|
|
76
|
-
background = behind
|
|
93
|
+
background = behind; // effectiveBackground already composites the field fill
|
|
77
94
|
}
|
|
78
95
|
if (!background) {
|
|
79
96
|
return { status: 'incomplete', message: 'The control’s background could not be determined — check its text contrast by eye.' };
|
|
80
97
|
}
|
|
81
98
|
const required = isLargeText(style) ? 3 : 4.5;
|
|
82
99
|
|
|
100
|
+
let unresolvedGroup = false;
|
|
83
101
|
const judge = (color, what, ownOpacity = 1) => {
|
|
84
102
|
const parsed = parseColor(color);
|
|
85
103
|
if (!parsed || parsed.a === 0) return null;
|
|
86
104
|
// Same treatment the main rule gives faded text: what reaches the eye
|
|
87
105
|
// is the declared colour thinned by the opacity it is painted at.
|
|
106
|
+
if (opacity < 1) {
|
|
107
|
+
const group = opacityGroupPaint(element, { ...parsed, a: parsed.a * ownOpacity });
|
|
108
|
+
if (group?.unresolved) { unresolvedGroup = true; return null; }
|
|
109
|
+
if (group) {
|
|
110
|
+
const ratio = contrastRatio(group.foreground, group.background);
|
|
111
|
+
return ratio >= required ? null : { what, ratio };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
88
114
|
const painted = opacity * ownOpacity;
|
|
89
115
|
const faded = painted < 1 ? { ...parsed, a: parsed.a * painted } : parsed;
|
|
90
116
|
const fg = faded.a < 1 ? composite(faded, background) : faded;
|
|
@@ -94,16 +120,18 @@ export default {
|
|
|
94
120
|
};
|
|
95
121
|
|
|
96
122
|
const failures = [];
|
|
97
|
-
const valueVerdict = judge(style.color, 'value text');
|
|
123
|
+
const valueVerdict = judge(style.webkitTextFillColor || style.color, 'value text');
|
|
98
124
|
if (valueVerdict) failures.push(valueVerdict);
|
|
99
125
|
// Placeholder text is real text users must read; browsers expose its
|
|
100
|
-
// computed colour via the ::placeholder pseudo-element.
|
|
101
|
-
|
|
126
|
+
// computed colour via the ::placeholder pseudo-element. Selectors 4
|
|
127
|
+
// defines :placeholder-shown for controls actually showing that text;
|
|
128
|
+
// a populated field's unpainted hint must not create a violation.
|
|
129
|
+
if (element.getAttribute('placeholder')?.trim() && element.matches(':placeholder-shown')) {
|
|
102
130
|
let placeholderColor = null;
|
|
103
131
|
let placeholderOpacity = 1;
|
|
104
132
|
try {
|
|
105
133
|
const placeholderStyle = getComputedStyle(element, '::placeholder');
|
|
106
|
-
placeholderColor = placeholderStyle.color;
|
|
134
|
+
placeholderColor = placeholderStyle.webkitTextFillColor || placeholderStyle.color;
|
|
107
135
|
// ::placeholder { opacity: .3 } is a widespread idiom (framework
|
|
108
136
|
// resets, Firefox's own UA default of 0.54), and the criterion is on
|
|
109
137
|
// the visual presentation of the text, which is the faded colour.
|
|
@@ -117,6 +145,7 @@ export default {
|
|
|
117
145
|
if (verdict) failures.push(verdict);
|
|
118
146
|
}
|
|
119
147
|
}
|
|
148
|
+
if (unresolvedGroup) return { status: 'incomplete', message: 'The field and its text fade together over an unresolved background. Check the presented text contrast by eye.' };
|
|
120
149
|
if (!failures.length) return { status: 'pass' };
|
|
121
150
|
const worst = failures.sort((a, b) => a.ratio - b.ratio)[0];
|
|
122
151
|
return {
|
|
@@ -20,10 +20,15 @@ export default {
|
|
|
20
20
|
const maxScale = content.match(/maximum-scale\s*=\s*([\d.]+)/i);
|
|
21
21
|
const cappedZoom = maxScale && parseFloat(maxScale[1]) < 2;
|
|
22
22
|
if (!disablesZoom && !cappedZoom) return { status: 'pass' };
|
|
23
|
+
// Asserted the way ACT rule b4f0c3 asserts it, under its assumption that
|
|
24
|
+
// the page provides no other way to enlarge the text (technique G178).
|
|
25
|
+
// The message names that assumption so a reviewer who knows the page
|
|
26
|
+
// has its own text-size control can dismiss the finding.
|
|
27
|
+
// https://www.w3.org/WAI/standards-guidelines/act/rules/b4f0c3/
|
|
23
28
|
return {
|
|
24
29
|
status: 'fail',
|
|
25
|
-
message: 'This viewport meta tag stops
|
|
26
|
-
fix: 'Remove user-scalable=no and any maximum-scale below 2 from the viewport meta tag.',
|
|
30
|
+
message: 'This viewport meta tag stops users zooming the page, so text cannot be enlarged with the browser. 1.4.4 is met only if the page provides its own control that enlarges all text to 200% (technique G178); this finding assumes it does not, since the tag cannot show it.',
|
|
31
|
+
fix: 'Remove user-scalable=no and any maximum-scale below 2 from the viewport meta tag. If the page has its own text-size control that reaches 200%, record that and dismiss this finding.',
|
|
27
32
|
};
|
|
28
33
|
},
|
|
29
34
|
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// WCAG SC 1.4.8 Visual Presentation (Level AAA) — the justified-text slice
|
|
2
|
+
//
|
|
3
|
+
// 1.4.8 lists five things a reader must be able to achieve for blocks of
|
|
4
|
+
// text; the third is "text is not justified (aligned to both the left and
|
|
5
|
+
// the right margins)". Justified text is the one of the five that the page
|
|
6
|
+
// itself imposes and that shows in a computed style; F88 ("using text that
|
|
7
|
+
// is justified") is its failure technique. The criterion is met when a
|
|
8
|
+
// mechanism lets the reader undo it, and the Understanding says the
|
|
9
|
+
// browser may provide that mechanism, so a justified block is asked about,
|
|
10
|
+
// not failed: the DOM cannot show whether a style switcher or a reader
|
|
11
|
+
// mode is on offer. The other four requirements (colour selection, 80
|
|
12
|
+
// character measure, line spacing, 200% resize) are met by browser
|
|
13
|
+
// mechanisms on ordinary pages and are not judged here.
|
|
14
|
+
//
|
|
15
|
+
// Only blocks with real running text count: the block's own text and the
|
|
16
|
+
// text of its inline children, at least 120 characters, so a justified
|
|
17
|
+
// wrapper is reported once at the paragraph that carries the words, not at
|
|
18
|
+
// every ancestor that inherits the alignment.
|
|
19
|
+
const BLOCK = /^(?:block|list-item|table-cell|flow-root)$/;
|
|
20
|
+
|
|
21
|
+
function inlineText(element) {
|
|
22
|
+
let text = '';
|
|
23
|
+
for (const node of element.childNodes) {
|
|
24
|
+
if (node.nodeType === 3) text += node.textContent;
|
|
25
|
+
else if (node.nodeType === 1 && getComputedStyle(node).display.startsWith('inline')) text += node.textContent;
|
|
26
|
+
}
|
|
27
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default {
|
|
31
|
+
id: 'text-justified',
|
|
32
|
+
name: 'Justified text',
|
|
33
|
+
impact: 'moderate',
|
|
34
|
+
tags: ['wcag2aaa', 'wcag148'],
|
|
35
|
+
help: 'Blocks of text should not be justified to both margins (AAA)',
|
|
36
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/visual-presentation.html',
|
|
37
|
+
selector: 'p, li, dd, dt, td, th, blockquote, figcaption, summary, address, div, section, article, main, aside, header, footer',
|
|
38
|
+
evaluate(element) {
|
|
39
|
+
const style = getComputedStyle(element);
|
|
40
|
+
if (style.textAlign !== 'justify' && style.textAlign !== 'justify-all') return { status: 'pass' };
|
|
41
|
+
if (!BLOCK.test(style.display)) return { status: 'pass' };
|
|
42
|
+
if (inlineText(element).length < 120) return { status: 'pass' };
|
|
43
|
+
return {
|
|
44
|
+
status: 'incomplete',
|
|
45
|
+
message: 'This block of text is justified to both margins. The uneven spaces between words form "rivers of white" down the page that some readers with dyslexia or low vision cannot read across (failure F88). 1.4.8 allows justified text only where a mechanism lets the reader switch it off; check that one exists, in the page or the browser.',
|
|
46
|
+
fix: 'Set text-align: start (or left) on running text, or offer a control that turns justification off.',
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
};
|
|
@@ -92,13 +92,13 @@ export default {
|
|
|
92
92
|
// An explicit negative tabindex is the author OPTING OUT of the tab
|
|
93
93
|
// order, and the browser honours it: measured in Chrome 151, a scroller
|
|
94
94
|
// with tabindex="-1" is skipped by Tab even though the same scroller
|
|
95
|
-
// without the attribute would be auto-focused.
|
|
96
|
-
//
|
|
97
|
-
//
|
|
95
|
+
// without the attribute would be auto-focused. That does not prove
|
|
96
|
+
// keyboard inoperability: external controls may scroll the region or
|
|
97
|
+
// move focus into it programmatically.
|
|
98
98
|
if (element.hasAttribute('tabindex')) {
|
|
99
99
|
return {
|
|
100
|
-
status: '
|
|
101
|
-
message: 'This region scrolls,
|
|
100
|
+
status: 'incomplete',
|
|
101
|
+
message: 'This region scrolls, has no detected tabbable content and is removed from sequential focus by a negative tabindex. Check whether keyboard-operable controls elsewhere scroll it or move focus into it; without such a mechanism, the overflowed content may be unreachable.',
|
|
102
102
|
fix: 'Change tabindex="-1" to tabindex="0" (plus role="region" and an aria-label describing it), or make something inside it tabbable.',
|
|
103
103
|
};
|
|
104
104
|
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* the old test demanded the literal "url=", so content="0;https://…" was
|
|
11
11
|
* asserted as a reload loop while the browser redirected from it.)
|
|
12
12
|
*/
|
|
13
|
-
function refreshDestination(content) {
|
|
13
|
+
export function refreshDestination(content) {
|
|
14
14
|
let rest = content.replace(/^\s*[\d.]*\s*(?:[;,]\s*)?/, '');
|
|
15
15
|
// "url" is consumed letter by letter, then optional whitespace and "=".
|
|
16
16
|
rest = rest.replace(/^url\s*(?:=\s*)?/i, '');
|
|
@@ -33,7 +33,7 @@ function refreshDestination(content) {
|
|
|
33
33
|
* rule asserted a time limit on pages the browser never moves. Verified in
|
|
34
34
|
* Chromium: both of those pages stay put while content="1; url=…" navigates.
|
|
35
35
|
*/
|
|
36
|
-
function refreshDelay(content) {
|
|
36
|
+
export function refreshDelay(content) {
|
|
37
37
|
const isAsciiSpace = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\f' || c === '\r';
|
|
38
38
|
const isDigit = (c) => c >= '0' && c <= '9';
|
|
39
39
|
let i = 0;
|
|
@@ -80,10 +80,16 @@ export default {
|
|
|
80
80
|
// 2.2.1 exempts a time limit "longer than 20 hours" — 20 hours exactly
|
|
81
81
|
// is still inside the criterion.
|
|
82
82
|
if (delay > 72000) return { status: 'pass' };
|
|
83
|
+
// Asserted the way ACT rule bc659a asserts it: a meta refresh gives the
|
|
84
|
+
// user no way to turn the limit off, adjust it or extend it, so the only
|
|
85
|
+
// escapes are the real-time and essential exceptions, which the tag
|
|
86
|
+
// cannot show either way. The message names that assumption so a
|
|
87
|
+
// reviewer who knows the page offers a control can dismiss the finding.
|
|
88
|
+
// https://www.w3.org/WAI/standards-guidelines/act/rules/bc659a/
|
|
83
89
|
return {
|
|
84
90
|
status: 'fail',
|
|
85
|
-
message: `The page refreshes
|
|
86
|
-
fix: 'Remove the timed refresh; let users act in their own time.',
|
|
91
|
+
message: `The page refreshes or redirects itself after ${delay}s. Slow readers lose their place, or the whole page, and a meta refresh offers no way to turn that off, adjust it or extend it. 2.2.1 excuses a time limit only where it is part of a real-time event or essential; this finding assumes neither applies, since the tag cannot show it.`,
|
|
92
|
+
fix: 'Remove the timed refresh; let users act in their own time. If the page offers a control that turns the refresh off, or the refresh is essential, record that and dismiss this finding.',
|
|
87
93
|
};
|
|
88
94
|
},
|
|
89
95
|
};
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Two shapes are checkable, and they need different verdicts.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
9
|
+
// Legacy markup is not proof of motion: blink has no native animation in
|
|
10
|
+
// modern browsers, and a marquee may be stopped or controlled by script.
|
|
11
11
|
//
|
|
12
12
|
// A CSS animation set to repeat for ever is the modern ticker. Clauses 1
|
|
13
13
|
// and 2 are settled by the CSS itself — it starts with the page and never
|
|
@@ -117,15 +117,15 @@ export default {
|
|
|
117
117
|
if (!elements.length) return [];
|
|
118
118
|
const moving = endlesslyMoving(elements[0].ownerDocument);
|
|
119
119
|
return elements.map((element) => {
|
|
120
|
-
if (element.tagName === 'MARQUEE'
|
|
120
|
+
if (element.tagName === 'MARQUEE') {
|
|
121
121
|
// scrollamount="0" is the documented way to ship a <marquee> that
|
|
122
122
|
// doesn't actually move.
|
|
123
123
|
if (element.tagName === 'MARQUEE' && element.getAttribute('scrollamount') === '0') {
|
|
124
124
|
return { status: 'pass' };
|
|
125
125
|
}
|
|
126
126
|
return {
|
|
127
|
-
status: '
|
|
128
|
-
message:
|
|
127
|
+
status: 'incomplete',
|
|
128
|
+
message: 'This marquee can scroll content. Check whether it actually starts automatically, continues for more than five seconds alongside other content, and has a working pause, stop or hide mechanism. The element alone does not establish those conditions.',
|
|
129
129
|
fix: 'Replace it with static content, or a CSS animation with a pause control that honours prefers-reduced-motion.',
|
|
130
130
|
};
|
|
131
131
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// WCAG SC 2.2.4 Interruptions (Level AAA) and SC 3.2.5 Change on Request
|
|
2
|
+
// (Level AAA) — the timed meta refresh, with none of 2.2.1's escapes.
|
|
3
|
+
//
|
|
4
|
+
// Both criteria list the same two failure techniques: F40 (a meta redirect
|
|
5
|
+
// with a time limit) and F41 (a meta refresh that reloads the page). 2.2.1
|
|
6
|
+
// excuses a limit longer than 20 hours; neither AAA criterion does. An
|
|
7
|
+
// interruption the reader cannot postpone or suppress breaks 2.2.4, and a
|
|
8
|
+
// change of context the reader did not ask for breaks 3.2.5, however long
|
|
9
|
+
// the timer. The instant redirect (zero seconds with a destination) stays
|
|
10
|
+
// allowed: G110 and H76 are sufficient techniques of 3.2.5 itself.
|
|
11
|
+
//
|
|
12
|
+
// One rule, two tags, on the twin-tag precedent (2.1.1/2.1.3): the same
|
|
13
|
+
// fact fails both. It runs beside 2.2.1's rule at AAA scope, so a refresh
|
|
14
|
+
// inside 20 hours is reported by both; each names its own criterion.
|
|
15
|
+
// 2.2.4 exempts emergencies, which a meta tag cannot show, so the message
|
|
16
|
+
// names that assumption the way the 2.2.1 rule names its own.
|
|
17
|
+
import { refreshDelay, refreshDestination } from './2.2.1-meta-refresh.js';
|
|
18
|
+
|
|
19
|
+
export default {
|
|
20
|
+
id: 'meta-refresh-no-exceptions',
|
|
21
|
+
name: 'Timed refresh (AAA)',
|
|
22
|
+
impact: 'serious',
|
|
23
|
+
tags: ['wcag2aaa', 'wcag224', 'wcag325'],
|
|
24
|
+
help: 'The page must not refresh or redirect itself on any timer (AAA)',
|
|
25
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/interruptions.html',
|
|
26
|
+
selector: 'meta[http-equiv="refresh" i]',
|
|
27
|
+
visibleOnly: false,
|
|
28
|
+
evaluate(element) {
|
|
29
|
+
const content = element.getAttribute('content') ?? '';
|
|
30
|
+
const delay = refreshDelay(content);
|
|
31
|
+
// The browser discards the directive: nothing is scheduled.
|
|
32
|
+
if (delay === null) return { status: 'pass' };
|
|
33
|
+
const hasDestination = refreshDestination(content) !== '';
|
|
34
|
+
// Zero seconds with a destination is the instant client-side redirect
|
|
35
|
+
// that G110 and H76 permit.
|
|
36
|
+
if (delay === 0 && hasDestination) return { status: 'pass' };
|
|
37
|
+
if (!hasDestination) {
|
|
38
|
+
return {
|
|
39
|
+
status: 'fail',
|
|
40
|
+
message: `The page reloads itself${delay > 0 ? ` every ${delay}s` : ' immediately, over and over'} (failure F41). The reader cannot postpone or suppress the reload, which 2.2.4 requires, and it changes the page without being asked, which 3.2.5 forbids. Neither AAA criterion has 2.2.1's 20-hour allowance.`,
|
|
41
|
+
fix: 'Remove the timed refresh and update the content in place, or let the reader request an update with a control.',
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
status: 'fail',
|
|
46
|
+
message: `The page redirects itself after ${delay}s (failure F40). The reader cannot postpone or suppress the move, which 2.2.4 requires, and the change of context is not requested, which 3.2.5 forbids; the AAA criteria allow no 20-hour exemption. 2.2.4 excuses only an emergency, which the tag cannot show, so this finding assumes there is none.`,
|
|
47
|
+
fix: 'Redirect instantly (content="0; url=…") or on the server, or give the reader a link and let them choose when to move.',
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
};
|
|
@@ -80,8 +80,8 @@ export default {
|
|
|
80
80
|
const linkCount = roots.reduce((n, root) => n + (root.querySelectorAll?.('a[href], button').length ?? 0), 0);
|
|
81
81
|
if (linkCount < 4) return { status: 'pass' };
|
|
82
82
|
return {
|
|
83
|
-
status: '
|
|
84
|
-
message: `This page has ${linkCount} links and buttons but no landmark, heading, skip link
|
|
83
|
+
status: 'incomplete',
|
|
84
|
+
message: `This page has ${linkCount} links and buttons but no detected landmark, heading, skip link or titled frame. Check whether it contains blocks repeated on other pages and, if so, whether a mechanism lets users bypass them. A single page cannot establish repetition.`,
|
|
85
85
|
fix: 'Add a skip link like <a href="#content">Skip to content</a>, wrap primary content in <main>, or structure the page with headings.',
|
|
86
86
|
};
|
|
87
87
|
},
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// WCAG SC 2.4.10 Section Headings (Level AAA)
|
|
2
|
+
// "Section headings are used to organize the content." The Understanding
|
|
3
|
+
// defines a section as a self-contained portion of written content, says
|
|
4
|
+
// the criterion covers sections within writing rather than UI components,
|
|
5
|
+
// and gives H69 (a heading element at the beginning of each section) as
|
|
6
|
+
// the technique. Where a page's sections begin is a judgement about the
|
|
7
|
+
// writing, except in one case the author has made for us: an HTML
|
|
8
|
+
// <section> element is a declared section. A declared section that holds
|
|
9
|
+
// running text and starts with no heading is the honest candidate; whether
|
|
10
|
+
// it is really a section of writing or a layout wrapper stays the
|
|
11
|
+
// reader's call, so this asks and never asserts.
|
|
12
|
+
//
|
|
13
|
+
// A section has its heading when one of its own sits anywhere inside it
|
|
14
|
+
// (a nested section's or article's heading is that element's), when it
|
|
15
|
+
// BEGINS with one however the markup nests it (inside a header, a div or a
|
|
16
|
+
// nested article), or when the element immediately before it is that
|
|
17
|
+
// heading or ends with it: the blog pattern of an article's title followed by a
|
|
18
|
+
// section holding the post body (measured on the corpus: 292 such bodies
|
|
19
|
+
// on one feed page, none of them a missing heading). A section named by
|
|
20
|
+
// aria-labelledby or aria-label has a title by another route and is left
|
|
21
|
+
// alone. Sections with little text, or none, are the UI-component case the
|
|
22
|
+
// criterion excludes.
|
|
23
|
+
const HEADING = 'h1, h2, h3, h4, h5, h6, [role="heading"]';
|
|
24
|
+
const SUBSTANTIVE = 'img, svg, video, audio, canvas, iframe, input, select, textarea, button';
|
|
25
|
+
const SKIP = 'script, style, template, noscript';
|
|
26
|
+
|
|
27
|
+
/** The first (or, reversed, the last) substantive thing inside an element,
|
|
28
|
+
* in document order: a heading, or text or media that is not one. */
|
|
29
|
+
function edgeIsHeading(element, fromEnd) {
|
|
30
|
+
const children = (node) => (fromEnd ? [...node.childNodes].reverse() : [...node.childNodes]);
|
|
31
|
+
const stack = children(element).reverse();
|
|
32
|
+
while (stack.length) {
|
|
33
|
+
const node = stack.pop();
|
|
34
|
+
if (node.nodeType === 3) { if (node.textContent.trim()) return false; continue; }
|
|
35
|
+
if (node.nodeType !== 1) continue;
|
|
36
|
+
if (node.matches(HEADING)) return true;
|
|
37
|
+
if (node.matches(SKIP)) continue;
|
|
38
|
+
if (node.matches(SUBSTANTIVE)) return false;
|
|
39
|
+
for (const child of children(node).reverse()) stack.push(child);
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default {
|
|
45
|
+
id: 'section-heading',
|
|
46
|
+
name: 'Section headings',
|
|
47
|
+
impact: 'moderate',
|
|
48
|
+
tags: ['wcag2aaa', 'wcag2410'],
|
|
49
|
+
help: 'Sections of written content should begin with a heading (AAA)',
|
|
50
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/section-headings.html',
|
|
51
|
+
selector: 'section',
|
|
52
|
+
evaluate(element) {
|
|
53
|
+
if (element.hasAttribute('aria-label') || element.hasAttribute('aria-labelledby')) return { status: 'pass' };
|
|
54
|
+
const role = element.getAttribute('role');
|
|
55
|
+
if (role && role !== 'region') return { status: 'pass' };
|
|
56
|
+
// Any heading of the section's own (not a nested section's or article's)
|
|
57
|
+
// organises it, wherever it sits; a section that BEGINS with a heading,
|
|
58
|
+
// however nested, is headed too.
|
|
59
|
+
const ownHeading = [...element.querySelectorAll(HEADING)]
|
|
60
|
+
.some((heading) => heading.closest('section, article') === element);
|
|
61
|
+
if (ownHeading || edgeIsHeading(element, false)) return { status: 'pass' };
|
|
62
|
+
const before = element.previousElementSibling;
|
|
63
|
+
if (before && (before.matches(HEADING) || (before.matches('header, hgroup') && before.querySelector(HEADING)) || edgeIsHeading(before, true))) {
|
|
64
|
+
return { status: 'pass' };
|
|
65
|
+
}
|
|
66
|
+
// A feed of untitled notes: the section is the body of an article with
|
|
67
|
+
// no heading of its own, in a run of at least three such items. Whether
|
|
68
|
+
// the notes of a feed need headings is one question about the feed, for
|
|
69
|
+
// the reviewer to ask once; asking it per item put 292 findings on one
|
|
70
|
+
// corpus page and helped nobody.
|
|
71
|
+
const item = element.closest('article');
|
|
72
|
+
if (item && item.parentElement && !item.querySelector(HEADING)) {
|
|
73
|
+
const items = (node) => [...node.children].filter((child) => child.matches('article') || child.querySelector(':scope > article')).length;
|
|
74
|
+
const container = item.parentElement;
|
|
75
|
+
if (items(container) >= 3 || (container.parentElement && items(container.parentElement) >= 3)) return { status: 'pass' };
|
|
76
|
+
}
|
|
77
|
+
const text = (element.textContent ?? '').replace(/\s+/g, ' ').trim();
|
|
78
|
+
const paragraphs = element.querySelectorAll('p').length;
|
|
79
|
+
if (text.length < 200 && paragraphs < 2) return { status: 'pass' };
|
|
80
|
+
return {
|
|
81
|
+
status: 'incomplete',
|
|
82
|
+
message: 'This section holds written content but begins with no heading of its own, so readers scanning by headings cannot find or skip it. 2.4.10 asks for a heading at the start of each section of writing; check whether this is a section of content that needs one, or a purely structural wrapper.',
|
|
83
|
+
fix: 'Start the section with a heading that names its topic, at the level below its parent heading, or name it with aria-labelledby pointing at its visible title.',
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
};
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// An opaque fixed/sticky overlay covering a focusable element is the
|
|
3
3
|
// criterion's core case (bottom cookie bars over footer links). Partial
|
|
4
4
|
// overlap passes by definition ("not entirely hidden"), so only full
|
|
5
|
-
// containment counts.
|
|
5
|
+
// containment counts. The AAA sibling built from the same factory (2.4.12,
|
|
6
|
+
// "no part of the component is hidden") counts any overlap at all.
|
|
6
7
|
//
|
|
7
8
|
// This REVIEWS rather than fails, deliberately. What a snapshot can see is
|
|
8
9
|
// "element X is under the panel right now", and that is a fact about the
|
|
@@ -31,85 +32,140 @@ function edgeReserved(doc, edge, needed) {
|
|
|
31
32
|
return px >= needed - 1;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Build the minimum (full containment) or enhanced (any overlap) rule.
|
|
37
|
+
* `partial: false` is 2.4.11 exactly as it has always run: one hit-test at
|
|
38
|
+
* the element's centre, and a blocker only counts when its box contains the
|
|
39
|
+
* whole element. `partial: true` is 2.4.12: the centre plus the four corners
|
|
40
|
+
* and four edge midpoints are hit-tested, and a blocker counts when it paints
|
|
41
|
+
* above the element at any of them and overlaps its box at all.
|
|
42
|
+
*/
|
|
43
|
+
export function createFocusObscuredRule({ id, name, tags, help, helpUrl, partial }) {
|
|
44
|
+
return {
|
|
45
|
+
id,
|
|
46
|
+
name,
|
|
47
|
+
impact: 'serious',
|
|
48
|
+
tags,
|
|
49
|
+
help,
|
|
50
|
+
helpUrl,
|
|
51
|
+
selector: FOCUSABLE,
|
|
52
|
+
visibility: 'visual',
|
|
53
|
+
evaluateAll(elements) {
|
|
54
|
+
const doc = elements[0]?.ownerDocument ?? document;
|
|
55
|
+
const win = doc.defaultView;
|
|
56
|
+
// While a modal (cookie-consent dialog etc.) is open, the page behind
|
|
57
|
+
// it is INTENTIONALLY covered and focus is supposed to be trapped in
|
|
58
|
+
// the modal — a snapshot in that state proves nothing about the
|
|
59
|
+
// criterion. The backdrop is often a separate sibling of the dialog,
|
|
60
|
+
// so the modal state is checked document-wide, not per overlay.
|
|
61
|
+
const modal = [...doc.querySelectorAll('dialog[open], [role="dialog"], [role="alertdialog"], [aria-modal="true"]')]
|
|
62
|
+
.some((el) => {
|
|
63
|
+
const rect = el.getBoundingClientRect();
|
|
64
|
+
return rect.width > 0 && rect.height > 0 && getComputedStyle(el).visibility !== 'hidden';
|
|
65
|
+
});
|
|
66
|
+
if (modal) return elements.map(() => ({ status: 'pass' }));
|
|
67
|
+
|
|
68
|
+
// Hit-test per IN-VIEWPORT focusable instead of style-scanning the
|
|
69
|
+
// whole document for overlays: elementsFromPoint at the target's
|
|
70
|
+
// centre surfaces whatever paints above it, and only the handful of
|
|
71
|
+
// on-screen targets pay any cost — a 200k-node spec page costs the
|
|
72
|
+
// same as a landing page. Per-overlay verdicts are memoized.
|
|
73
|
+
const overlayVerdict = new Map();
|
|
74
|
+
const isObscuringOverlay = (layer) => {
|
|
75
|
+
if (overlayVerdict.has(layer)) return overlayVerdict.get(layer);
|
|
76
|
+
let verdict = false;
|
|
77
|
+
const style = getComputedStyle(layer);
|
|
78
|
+
if (style.position === 'fixed' || style.position === 'sticky') {
|
|
79
|
+
const bg = style.backgroundColor.match(/rgba?\(([^)]+)\)/)?.[1]?.split(',');
|
|
80
|
+
const alpha = bg?.[3] === undefined ? 1 : parseFloat(bg[3]);
|
|
81
|
+
const rect = layer.getBoundingClientRect();
|
|
82
|
+
verdict = alpha >= 0.9 && rect.width >= 40 && rect.height >= 24;
|
|
83
|
+
}
|
|
84
|
+
overlayVerdict.set(layer, verdict);
|
|
85
|
+
return verdict;
|
|
86
|
+
};
|
|
87
|
+
const contained = (rect, overlay) =>
|
|
88
|
+
rect.left >= overlay.left && rect.right <= overlay.right
|
|
89
|
+
&& rect.top >= overlay.top && rect.bottom <= overlay.bottom;
|
|
90
|
+
const overlaps = (rect, overlay) =>
|
|
91
|
+
Math.min(rect.right, overlay.right) - Math.max(rect.left, overlay.left) > 0
|
|
92
|
+
&& Math.min(rect.bottom, overlay.bottom) - Math.max(rect.top, overlay.top) > 0;
|
|
93
|
+
const clampX = (x) => Math.min(Math.max(x, 0), win.innerWidth - 1);
|
|
94
|
+
const clampY = (y) => Math.min(Math.max(y, 0), win.innerHeight - 1);
|
|
95
|
+
// The layer painting above the element at one point, if it is an
|
|
96
|
+
// obscuring overlay whose box relates to the element's as the mode
|
|
97
|
+
// demands. elementsFromPoint lists the whole stack, covered elements
|
|
98
|
+
// included, so an element ABSENT from it does not paint at that point
|
|
99
|
+
// at all (a rounded corner, a clip-path): nothing is hidden there.
|
|
100
|
+
// Measured on the corpus: consent-dialog buttons with rounded corners
|
|
101
|
+
// over their own opaque backdrop were flagged from exactly that.
|
|
102
|
+
const blockerAt = (element, rect, x, y) => {
|
|
103
|
+
const stack = doc.elementsFromPoint(clampX(x), clampY(y));
|
|
104
|
+
const index = stack.indexOf(element);
|
|
105
|
+
if (index <= 0) return null;
|
|
106
|
+
const above = stack.slice(0, index);
|
|
107
|
+
return above.find((layer) =>
|
|
108
|
+
!layer.contains(element) && !element.contains(layer)
|
|
109
|
+
&& isObscuringOverlay(layer)
|
|
110
|
+
&& (partial ? overlaps : contained)(rect, layer.getBoundingClientRect())) ?? null;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
return elements.map((element) => {
|
|
114
|
+
// `:disabled` catches controls disabled by an ancestor fieldset, which
|
|
115
|
+
// the `disabled` property does not reflect — those never take focus.
|
|
116
|
+
if (element.matches(':disabled')) return { status: 'pass' };
|
|
117
|
+
// Inert content cannot be focused, so nothing can obscure its focus.
|
|
118
|
+
if (isInert(element)) return { status: 'pass' };
|
|
119
|
+
const rect = element.getBoundingClientRect();
|
|
120
|
+
if (!rect.width || !rect.height) return { status: 'pass' };
|
|
121
|
+
// Only in-viewport geometry is trustworthy (or cheap).
|
|
122
|
+
if (rect.bottom < 0 || rect.right < 0 || rect.top > win.innerHeight || rect.left > win.innerWidth) {
|
|
123
|
+
return { status: 'pass' };
|
|
124
|
+
}
|
|
125
|
+
const cx = rect.left + rect.width / 2;
|
|
126
|
+
const cy = rect.top + rect.height / 2;
|
|
127
|
+
let blocker = blockerAt(element, rect, cx, cy);
|
|
128
|
+
if (!blocker && partial) {
|
|
129
|
+
// A strip of cookie bar over the bottom of a link leaves its
|
|
130
|
+
// centre clear; the corners and edge midpoints see it.
|
|
131
|
+
const inset = 1;
|
|
132
|
+
const xs = [rect.left + inset, cx, rect.right - inset];
|
|
133
|
+
const ys = [rect.top + inset, cy, rect.bottom - inset];
|
|
134
|
+
for (const x of xs) {
|
|
135
|
+
for (const y of ys) {
|
|
136
|
+
if (x === cx && y === cy) continue;
|
|
137
|
+
blocker = blockerAt(element, rect, x, y);
|
|
138
|
+
if (blocker) break;
|
|
139
|
+
}
|
|
140
|
+
if (blocker) break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (!blocker) return { status: 'pass' };
|
|
144
|
+
// Which viewport edge is the panel pinned to? That is the edge the
|
|
145
|
+
// browser's scroll-into-view has to clear.
|
|
146
|
+
const panel = blocker.getBoundingClientRect();
|
|
147
|
+
const edge = panel.top <= 1 && panel.bottom < win.innerHeight ? 'top'
|
|
148
|
+
: panel.bottom >= win.innerHeight - 1 ? 'bottom' : null;
|
|
149
|
+
if (edge && edgeReserved(doc, edge, panel.height)) return { status: 'pass' };
|
|
150
|
+
return partial ? {
|
|
151
|
+
status: 'incomplete',
|
|
152
|
+
message: 'Part of this element is currently underneath an opaque fixed panel. 2.4.12 (AAA) allows no part of a focused component to be hidden by author content, and whether that happens depends on where the page sits when focus reaches it — the browser does not scroll an element that is already in the viewport, merely overlapped, so focus can land half-hidden. Tab through the page and check the whole element, indicator included, stays clear of the panel.',
|
|
153
|
+
fix: `Reserve room for the panel with scroll-padding-${edge ?? 'bottom'} on the scrolling container, or move focus clear of it when the panel is up.`,
|
|
154
|
+
} : {
|
|
155
|
+
status: 'incomplete',
|
|
156
|
+
message: 'This element is currently underneath an opaque fixed panel. Whether that breaks 2.4.11 depends on where the page sits when focus reaches it — the browser does not scroll an element that is already in the viewport, merely covered, so focus can land invisibly. Tab through the page and check the focus indicator is never entirely hidden.',
|
|
157
|
+
fix: `Reserve room for the panel with scroll-padding-${edge ?? 'bottom'} on the scrolling container, or move focus clear of it when the panel is up.`,
|
|
158
|
+
};
|
|
159
|
+
});
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export default createFocusObscuredRule({
|
|
35
165
|
id: 'focus-not-obscured',
|
|
36
166
|
name: 'Unobscured focus',
|
|
37
|
-
impact: 'serious',
|
|
38
167
|
tags: ['wcag22aa', 'wcag2411'],
|
|
39
168
|
help: 'Focused elements must not be fully hidden behind overlays',
|
|
40
169
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-minimum.html',
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
evaluateAll(elements) {
|
|
44
|
-
const doc = elements[0]?.ownerDocument ?? document;
|
|
45
|
-
const win = doc.defaultView;
|
|
46
|
-
// While a modal (cookie-consent dialog etc.) is open, the page behind
|
|
47
|
-
// it is INTENTIONALLY covered and focus is supposed to be trapped in
|
|
48
|
-
// the modal — a snapshot in that state proves nothing about 2.4.11.
|
|
49
|
-
// The backdrop is often a separate sibling of the dialog, so the modal
|
|
50
|
-
// state is checked document-wide, not per overlay.
|
|
51
|
-
const modal = [...doc.querySelectorAll('dialog[open], [role="dialog"], [role="alertdialog"], [aria-modal="true"]')]
|
|
52
|
-
.some((el) => {
|
|
53
|
-
const rect = el.getBoundingClientRect();
|
|
54
|
-
return rect.width > 0 && rect.height > 0 && getComputedStyle(el).visibility !== 'hidden';
|
|
55
|
-
});
|
|
56
|
-
if (modal) return elements.map(() => ({ status: 'pass' }));
|
|
57
|
-
|
|
58
|
-
// Hit-test per IN-VIEWPORT focusable instead of style-scanning the
|
|
59
|
-
// whole document for overlays: elementsFromPoint at the target's
|
|
60
|
-
// centre surfaces whatever paints above it, and only the handful of
|
|
61
|
-
// on-screen targets pay any cost — a 200k-node spec page costs the
|
|
62
|
-
// same as a landing page. Per-overlay verdicts are memoized.
|
|
63
|
-
const overlayVerdict = new Map();
|
|
64
|
-
const isObscuringOverlay = (layer) => {
|
|
65
|
-
if (overlayVerdict.has(layer)) return overlayVerdict.get(layer);
|
|
66
|
-
let verdict = false;
|
|
67
|
-
const style = getComputedStyle(layer);
|
|
68
|
-
if (style.position === 'fixed' || style.position === 'sticky') {
|
|
69
|
-
const bg = style.backgroundColor.match(/rgba?\(([^)]+)\)/)?.[1]?.split(',');
|
|
70
|
-
const alpha = bg?.[3] === undefined ? 1 : parseFloat(bg[3]);
|
|
71
|
-
const rect = layer.getBoundingClientRect();
|
|
72
|
-
verdict = alpha >= 0.9 && rect.width >= 40 && rect.height >= 24;
|
|
73
|
-
}
|
|
74
|
-
overlayVerdict.set(layer, verdict);
|
|
75
|
-
return verdict;
|
|
76
|
-
};
|
|
77
|
-
const covered = (rect, overlay) =>
|
|
78
|
-
rect.left >= overlay.left && rect.right <= overlay.right
|
|
79
|
-
&& rect.top >= overlay.top && rect.bottom <= overlay.bottom;
|
|
80
|
-
|
|
81
|
-
return elements.map((element) => {
|
|
82
|
-
// `:disabled` catches controls disabled by an ancestor fieldset, which
|
|
83
|
-
// the `disabled` property does not reflect — those never take focus.
|
|
84
|
-
if (element.matches(':disabled')) return { status: 'pass' };
|
|
85
|
-
// Inert content cannot be focused, so nothing can obscure its focus.
|
|
86
|
-
if (isInert(element)) return { status: 'pass' };
|
|
87
|
-
const rect = element.getBoundingClientRect();
|
|
88
|
-
if (!rect.width || !rect.height) return { status: 'pass' };
|
|
89
|
-
// Only in-viewport geometry is trustworthy (or cheap).
|
|
90
|
-
if (rect.bottom < 0 || rect.right < 0 || rect.top > win.innerHeight || rect.left > win.innerWidth) {
|
|
91
|
-
return { status: 'pass' };
|
|
92
|
-
}
|
|
93
|
-
const x = Math.min(Math.max(rect.left + rect.width / 2, 0), win.innerWidth - 1);
|
|
94
|
-
const y = Math.min(Math.max(rect.top + rect.height / 2, 0), win.innerHeight - 1);
|
|
95
|
-
const stack = doc.elementsFromPoint(x, y);
|
|
96
|
-
const index = stack.indexOf(element);
|
|
97
|
-
if (index <= 0) return { status: 'pass' }; // topmost, or not hit-testable
|
|
98
|
-
const blocker = stack.slice(0, index).find((layer) =>
|
|
99
|
-
!layer.contains(element) && !element.contains(layer)
|
|
100
|
-
&& isObscuringOverlay(layer) && covered(rect, layer.getBoundingClientRect()));
|
|
101
|
-
if (!blocker) return { status: 'pass' };
|
|
102
|
-
// Which viewport edge is the panel pinned to? That is the edge the
|
|
103
|
-
// browser's scroll-into-view has to clear.
|
|
104
|
-
const panel = blocker.getBoundingClientRect();
|
|
105
|
-
const edge = panel.top <= 1 && panel.bottom < win.innerHeight ? 'top'
|
|
106
|
-
: panel.bottom >= win.innerHeight - 1 ? 'bottom' : null;
|
|
107
|
-
if (edge && edgeReserved(doc, edge, panel.height)) return { status: 'pass' };
|
|
108
|
-
return {
|
|
109
|
-
status: 'incomplete',
|
|
110
|
-
message: 'This element is currently underneath an opaque fixed panel. Whether that breaks 2.4.11 depends on where the page sits when focus reaches it — the browser does not scroll an element that is already in the viewport, merely covered, so focus can land invisibly. Tab through the page and check the focus indicator is never entirely hidden.',
|
|
111
|
-
fix: `Reserve room for the panel with scroll-padding-${edge ?? 'bottom'} on the scrolling container, or move focus clear of it when the panel is up.`,
|
|
112
|
-
};
|
|
113
|
-
});
|
|
114
|
-
},
|
|
115
|
-
};
|
|
170
|
+
partial: false,
|
|
171
|
+
});
|