@surea11y/core 1.6.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +140 -0
- package/README.md +179 -90
- package/docs/ACT_RULE_MAPPING.md +10 -8
- package/docs/API_STABILITY.md +67 -6
- package/docs/BINDING_AUTHORS_GUIDE.md +104 -2
- package/docs/CI_INTEGRATIONS.md +43 -0
- package/docs/DESIGN_CHALLENGES.md +162 -2
- package/docs/EARL.md +100 -0
- package/docs/ENGINE_OPTIONS.md +109 -5
- package/docs/I18N.md +62 -20
- package/docs/INTEGRATION.md +4 -2
- package/docs/JUNIT.md +73 -0
- package/docs/LIMITATIONS.md +4 -1
- package/docs/OUTPUT_SCHEMA.md +62 -11
- package/docs/POLICY.md +1 -1
- package/docs/REPORT.md +7 -2
- package/docs/RULE_AUTHORING.md +83 -17
- package/docs/RULE_CATALOG.md +212 -139
- package/docs/RULE_EXAMPLES.md +2189 -0
- package/docs/RULE_HELPERS.md +390 -0
- package/docs/RULE_TAXONOMY.md +27 -6
- package/docs/SARIF.md +23 -3
- package/docs/WCAG_CONFORMANCE.md +64 -3
- package/package.json +41 -12
- package/profiles/index.js +14 -0
- package/src/checks/automatic/area-alt-present.js +87 -31
- package/src/checks/automatic/aria-allowed-attr.js +6 -0
- package/src/checks/automatic/aria-allowed-role.js +32 -23
- package/src/checks/automatic/aria-braille-equivalent.js +43 -17
- package/src/checks/automatic/aria-conditional-attr.js +17 -10
- package/src/checks/automatic/aria-deprecated-role.js +12 -0
- package/src/checks/automatic/aria-hidden-body.js +1 -1
- package/src/checks/automatic/aria-hidden-focus.js +74 -18
- package/src/checks/automatic/aria-prohibited-attr.js +22 -4
- package/src/checks/automatic/aria-prohibited-children.js +6 -6
- package/src/checks/automatic/aria-required-attr.js +88 -12
- package/src/checks/automatic/aria-required-children.js +33 -16
- package/src/checks/automatic/aria-required-parent.js +32 -6
- package/src/checks/automatic/aria-role-name-present.js +20 -3
- package/src/checks/automatic/aria-roles-valid.js +52 -21
- package/src/checks/automatic/aria-valid-attr-value.js +89 -24
- package/src/checks/automatic/aria-valid-attr.js +14 -9
- package/src/checks/automatic/autocomplete-valid.js +152 -26
- package/src/checks/automatic/avoid-inline-spacing.js +207 -15
- package/src/checks/automatic/button-name-present.js +2 -1
- package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
- package/src/checks/automatic/combobox-name-present.js +34 -51
- package/src/checks/automatic/contrast-computable.js +45 -4
- package/src/checks/automatic/contrast-enhanced.js +16 -4
- package/src/checks/automatic/contrast-minimum.js +57 -11
- package/src/checks/automatic/css-orientation-lock.js +171 -12
- package/src/checks/automatic/definition-list-children-valid.js +67 -23
- package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
- package/src/checks/automatic/dialog-name-present.js +28 -9
- package/src/checks/automatic/duplicate-id-aria.js +5 -0
- package/src/checks/automatic/duplicate-id.js +19 -10
- package/src/checks/automatic/form-control-single-label.js +9 -0
- package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
- package/src/checks/automatic/iframe-focusable-content.js +12 -4
- package/src/checks/automatic/iframe-title-unique.js +36 -81
- package/src/checks/automatic/input-image-alt-present.js +32 -20
- package/src/checks/automatic/label-in-name.js +78 -69
- package/src/checks/automatic/language-page-present.js +12 -6
- package/src/checks/automatic/link-in-text-block.js +512 -44
- package/src/checks/automatic/link-name-present.js +13 -5
- package/src/checks/automatic/list-children-valid.js +18 -1
- package/src/checks/automatic/listbox-name-present.js +19 -49
- package/src/checks/automatic/listitem-parent-valid.js +4 -3
- package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
- package/src/checks/automatic/page-title-present.js +16 -4
- package/src/checks/automatic/progressbar-name-present.js +11 -1
- package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +9 -5
- package/src/checks/automatic/searchbox-name-present.js +32 -49
- package/src/checks/automatic/server-side-image-map-absent.js +48 -28
- package/src/checks/automatic/slider-name-present.js +38 -52
- package/src/checks/automatic/spinbutton-name-present.js +32 -49
- package/src/checks/automatic/target-size-minimum.js +84 -16
- package/src/checks/automatic/td-has-header.js +60 -23
- package/src/checks/automatic/text-spacing-content-loss.js +548 -0
- package/src/checks/automatic/textbox-name-present.js +32 -49
- package/src/checks/automatic/valid-lang.js +15 -10
- package/src/checks/manual/area-alt-quality-manual.js +113 -31
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
- package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
- package/src/checks/manual/css-hidden-focus.js +215 -7
- package/src/checks/manual/form-control-label-quality-manual.js +243 -29
- package/src/checks/manual/heading-order-manual.js +9 -1
- package/src/checks/manual/heading-quality-manual.js +143 -9
- package/src/checks/manual/img-alt-decorative-manual.js +6 -3
- package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
- package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
- package/src/checks/manual/link-name-quality-manual.js +130 -4
- package/src/checks/manual/media-transcript-present-manual.js +65 -8
- package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
- package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
- package/src/checks/manual/p-as-heading-manual.js +89 -44
- package/src/checks/manual/page-title-patterns-manual.js +77 -8
- package/src/checks/manual/password-paste-enabled-manual.js +255 -0
- package/src/checks/manual/skip-link-manual.js +42 -14
- package/src/checks/manual/table-fake-caption-manual.js +32 -1
- package/src/checks/manual/video-caption-manual.js +47 -24
- package/src/checks/manual-review.js +0 -4
- package/src/core.js +18061 -46194
- package/src/coverage/en301549-map.js +187 -0
- package/src/coverage/standards.js +279 -0
- package/src/coverage/wcag-facets.js +1119 -0
- package/src/coverage/wcag-version-map.js +101 -0
- package/src/earl.js +144 -0
- package/src/en301549.js +33 -0
- package/src/junit.js +321 -0
- package/src/profile-kit.js +163 -0
- package/src/report.js +343 -74
- package/src/sarif.js +56 -5
- package/src/wcag.js +105 -0
- package/surea11y.browser.js +11 -41039
- package/surea11y.i18n.de.js +2 -21
- package/surea11y.i18n.es.js +2 -21
- package/surea11y.i18n.fr.js +2 -21
- package/surea11y.i18n.ja.js +3 -0
- package/src/checks/manual/area-alt-decorative-manual.js +0 -255
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @check text-spacing-content-loss
|
|
7
|
+
* @atomic true
|
|
8
|
+
* @summary Text must stay readable when the user increases text spacing
|
|
9
|
+
* @standard WCAG 2.2
|
|
10
|
+
* @sc 1.4.12
|
|
11
|
+
* @applicability
|
|
12
|
+
* Applies to a page with visible text. The loss of content needs a layout
|
|
13
|
+
* (a browser): without one, only the style sheets are read.
|
|
14
|
+
* @expectation
|
|
15
|
+
* WCAG 1.4.12: with line height at 1.5 times the font
|
|
16
|
+
* size, spacing after paragraphs at 2 times, letter spacing at 0.12 times
|
|
17
|
+
* and word spacing at 0.16 times, no content or functionality is lost.
|
|
18
|
+
* - In a browser, the spacing is applied as a style sheet that wins over
|
|
19
|
+
* the page's own (inline `!important` aside, which avoid-inline-spacing
|
|
20
|
+
* reports), and each line of text is measured before and after against
|
|
21
|
+
* the ancestors that clip it (`overflow: hidden` or `clip`). A line that
|
|
22
|
+
* was inside and ends at least half outside (half its height, or half
|
|
23
|
+
* an em across) fails: the container cuts that text off
|
|
24
|
+
* (TEXT_CLIPPED). A line pushed out by less is asked about
|
|
25
|
+
* (TEXT_CLIPPED_PARTLY), and so is text that comes to overlap other
|
|
26
|
+
* text it did not overlap before (TEXT_OVERLAPS).
|
|
27
|
+
* - In any environment, a style sheet rule that sets line-height,
|
|
28
|
+
* letter-spacing or word-spacing below those values with `!important`
|
|
29
|
+
* is asked about (STYLESHEET_IMPORTANT): a tool that adds its own style
|
|
30
|
+
* sheet to the page cannot override it, though a user style sheet can.
|
|
31
|
+
* Without a layout and with no such rule, the rule is notApplicable, with
|
|
32
|
+
* `data.reason: 'noLayout'`.
|
|
33
|
+
* @implementation-notes
|
|
34
|
+
* - The spacing sheet is one cascade layer declared before every other
|
|
35
|
+
* style: layered `!important` declarations beat unlayered ones and later
|
|
36
|
+
* layers, as a user style sheet would. It is removed afterwards, and the
|
|
37
|
+
* scroll position put back.
|
|
38
|
+
* - Text that was already outside its clipping box before (a carousel
|
|
39
|
+
* slide, text hidden off screen) is not counted. A scrolling ancestor
|
|
40
|
+
* (`overflow: auto` or `scroll`) keeps its content reachable and does not
|
|
41
|
+
* clip.
|
|
42
|
+
* - Lines are read with Range.getClientRects() on each text node, up to
|
|
43
|
+
* 3,000 text nodes per page.
|
|
44
|
+
* - The thresholds are the ones avoid-inline-spacing uses: line-height 1.5,
|
|
45
|
+
* letter-spacing 0.12, word-spacing 0.16, as multiples of the font size.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
const id = 'text-spacing-content-loss';
|
|
49
|
+
|
|
50
|
+
const meta = {
|
|
51
|
+
title: 'Text stays readable when the user increases text spacing',
|
|
52
|
+
description:
|
|
53
|
+
'Applies the WCAG 1.4.12 text spacing in the browser and checks that no text is cut off or made to overlap, and asks about style sheet rules that force spacing with !important.',
|
|
54
|
+
i18n: {
|
|
55
|
+
titleKey: 'textSpacingContentLoss_title',
|
|
56
|
+
descriptionKey: 'textSpacingContentLoss_description'
|
|
57
|
+
},
|
|
58
|
+
helpUrl: null,
|
|
59
|
+
tags: ['wcag21aa', 'wcag1412', 'structure', 'atomic', 'automatic'],
|
|
60
|
+
wcagSc: ['1.4.12'],
|
|
61
|
+
normativeMappings: [
|
|
62
|
+
{
|
|
63
|
+
standard: 'WCAG',
|
|
64
|
+
version: '2.2',
|
|
65
|
+
requirement: '1.4.12',
|
|
66
|
+
title: 'Text Spacing',
|
|
67
|
+
conformanceLevel: 'AA'
|
|
68
|
+
}
|
|
69
|
+
],
|
|
70
|
+
defaultSeverity: 'serious',
|
|
71
|
+
category: 'perceivable',
|
|
72
|
+
type: 'automatic',
|
|
73
|
+
defaultConfidence: 'medium',
|
|
74
|
+
coverage: { facetsBySc: { '1.4.12': ['text-spacing-content-loss'] } }
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function runInPage(ctx) {
|
|
78
|
+
const { document, helpers, rule } = ctx;
|
|
79
|
+
const view = document.defaultView || null;
|
|
80
|
+
|
|
81
|
+
const MIN_RATIO = { 'line-height': 1.5, 'letter-spacing': 0.12, 'word-spacing': 0.16 };
|
|
82
|
+
const SPACING_PROPS = Object.keys(MIN_RATIO);
|
|
83
|
+
const MAX_TEXT_NODES = 3000;
|
|
84
|
+
const CSS_STYLE_RULE = 1;
|
|
85
|
+
const LAYER = 'surea11y-text-spacing';
|
|
86
|
+
|
|
87
|
+
function styleOf(el, pseudo) {
|
|
88
|
+
try {
|
|
89
|
+
return view.getComputedStyle(el, pseudo || null);
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function px(v) {
|
|
95
|
+
const n = parseFloat(v);
|
|
96
|
+
return Number.isFinite(n) ? n : null;
|
|
97
|
+
}
|
|
98
|
+
// A spacing value as a multiple of the font size, or null when it cannot
|
|
99
|
+
// be read. A computed value is in px; a declared one, which is all a DOM
|
|
100
|
+
// emulator returns, may be in em, rem or %, or unitless for line-height.
|
|
101
|
+
function ratioOf(prop, value, fontSize) {
|
|
102
|
+
const v = String(value == null ? '' : value)
|
|
103
|
+
.trim()
|
|
104
|
+
.toLowerCase();
|
|
105
|
+
if (v === 'normal') return prop === 'line-height' ? 1.2 : 0;
|
|
106
|
+
const m = /^(-?\d*\.?\d+)(px|em|rem|%)?$/.exec(v);
|
|
107
|
+
if (!m) return null;
|
|
108
|
+
const n = parseFloat(m[1]);
|
|
109
|
+
const unit = m[2] || '';
|
|
110
|
+
if (unit === 'px') return n / fontSize;
|
|
111
|
+
if (unit === 'em') return n;
|
|
112
|
+
if (unit === 'rem') {
|
|
113
|
+
const root = px(
|
|
114
|
+
styleOf(document.documentElement) && styleOf(document.documentElement).fontSize
|
|
115
|
+
);
|
|
116
|
+
return (n * (root || 16)) / fontSize;
|
|
117
|
+
}
|
|
118
|
+
if (unit === '%') return prop === 'line-height' ? n / 100 : null;
|
|
119
|
+
return prop === 'line-height' ? n : null;
|
|
120
|
+
}
|
|
121
|
+
function textOf(el) {
|
|
122
|
+
return String(el.textContent || '')
|
|
123
|
+
.replace(/\s+/g, ' ')
|
|
124
|
+
.trim()
|
|
125
|
+
.slice(0, 60);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---- Style sheet rules that force spacing with !important ----
|
|
129
|
+
|
|
130
|
+
const importantFindings = [];
|
|
131
|
+
(function readSheets() {
|
|
132
|
+
const seen = new Set();
|
|
133
|
+
function check(cssRule) {
|
|
134
|
+
const style = cssRule.style;
|
|
135
|
+
for (const prop of SPACING_PROPS) {
|
|
136
|
+
let value;
|
|
137
|
+
let priority;
|
|
138
|
+
try {
|
|
139
|
+
value = String(style.getPropertyValue(prop) || '').trim();
|
|
140
|
+
priority = String(style.getPropertyPriority(prop) || '');
|
|
141
|
+
} catch {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (!value || priority !== 'important') continue;
|
|
145
|
+
if (/^(inherit|initial|unset|revert|revert-layer)$/i.test(value)) continue;
|
|
146
|
+
let targets;
|
|
147
|
+
try {
|
|
148
|
+
targets = helpers.queryAllSmart
|
|
149
|
+
? helpers.queryAllSmart(cssRule.selectorText)
|
|
150
|
+
: helpers.queryAll(cssRule.selectorText);
|
|
151
|
+
} catch {
|
|
152
|
+
targets = [];
|
|
153
|
+
}
|
|
154
|
+
for (const el of targets) {
|
|
155
|
+
if (!el || seen.has(el) || !textOf(el)) continue;
|
|
156
|
+
const cs = styleOf(el);
|
|
157
|
+
const fontSize = px(cs && cs.fontSize) || 16;
|
|
158
|
+
const ratio = ratioOf(prop, cs && cs.getPropertyValue(prop), fontSize);
|
|
159
|
+
// A value that already meets the metric leaves nothing to override.
|
|
160
|
+
if (ratio == null ? false : ratio >= MIN_RATIO[prop]) continue;
|
|
161
|
+
seen.add(el);
|
|
162
|
+
importantFindings.push({ el, prop, value, selector: String(cssRule.selectorText) });
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function walk(rules, depth) {
|
|
168
|
+
if (!rules || depth > 8) return;
|
|
169
|
+
for (const r of rules) {
|
|
170
|
+
if (!r) continue;
|
|
171
|
+
if (r.type === CSS_STYLE_RULE && r.selectorText && r.style) check(r);
|
|
172
|
+
let nested = null;
|
|
173
|
+
try {
|
|
174
|
+
nested = r.cssRules || null;
|
|
175
|
+
} catch {}
|
|
176
|
+
if (nested) walk(nested, depth + 1);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
for (const sheet of document.styleSheets || []) {
|
|
181
|
+
let rules = null;
|
|
182
|
+
try {
|
|
183
|
+
rules = sheet.cssRules;
|
|
184
|
+
} catch {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
walk(rules, 0);
|
|
188
|
+
}
|
|
189
|
+
} catch {}
|
|
190
|
+
})();
|
|
191
|
+
|
|
192
|
+
// ---- Lines of text, before and after the spacing ----
|
|
193
|
+
|
|
194
|
+
function hasLayout() {
|
|
195
|
+
const probe = document.documentElement || null;
|
|
196
|
+
if (!view || !probe || typeof probe.getClientRects !== 'function') return false;
|
|
197
|
+
if (typeof document.createRange !== 'function') return false;
|
|
198
|
+
try {
|
|
199
|
+
const rects = probe.getClientRects();
|
|
200
|
+
return !!(rects && rects.length > 0);
|
|
201
|
+
} catch {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const clipped = [];
|
|
207
|
+
const partly = [];
|
|
208
|
+
const overlaps = [];
|
|
209
|
+
let textCount = 0;
|
|
210
|
+
|
|
211
|
+
if (hasLayout() && document.body) {
|
|
212
|
+
const SKIP = new Set(['script', 'style', 'noscript', 'template', 'textarea', 'select']);
|
|
213
|
+
const nodes = [];
|
|
214
|
+
const walker = document.createTreeWalker(document.body, 4);
|
|
215
|
+
for (let n = walker.nextNode(); n && nodes.length < MAX_TEXT_NODES; n = walker.nextNode()) {
|
|
216
|
+
if (!/\S/.test(n.nodeValue || '')) continue;
|
|
217
|
+
const parent = n.parentElement;
|
|
218
|
+
if (!parent || SKIP.has(String(parent.localName))) continue;
|
|
219
|
+
nodes.push(n);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const clipCache = new Map();
|
|
223
|
+
// Ancestors that clip on an axis: [{ el, x, y }]. Past an ancestor that
|
|
224
|
+
// scrolls on an axis, outer ancestors no longer clip the text on that
|
|
225
|
+
// axis: what goes past them can be scrolled to.
|
|
226
|
+
function clippersOf(el) {
|
|
227
|
+
if (clipCache.has(el)) return clipCache.get(el);
|
|
228
|
+
const out = [];
|
|
229
|
+
let scrollX = false;
|
|
230
|
+
let scrollY = false;
|
|
231
|
+
for (
|
|
232
|
+
let a = el;
|
|
233
|
+
a && a.nodeType === 1 && a !== document.documentElement;
|
|
234
|
+
a = a.parentElement
|
|
235
|
+
) {
|
|
236
|
+
const cs = styleOf(a);
|
|
237
|
+
if (!cs) continue;
|
|
238
|
+
const x = !scrollX && (cs.overflowX === 'hidden' || cs.overflowX === 'clip');
|
|
239
|
+
const y = !scrollY && (cs.overflowY === 'hidden' || cs.overflowY === 'clip');
|
|
240
|
+
if (x || y) out.push({ el: a, x, y });
|
|
241
|
+
if (cs.overflowX === 'auto' || cs.overflowX === 'scroll') scrollX = true;
|
|
242
|
+
if (cs.overflowY === 'auto' || cs.overflowY === 'scroll') scrollY = true;
|
|
243
|
+
if (scrollX && scrollY) break;
|
|
244
|
+
}
|
|
245
|
+
clipCache.set(el, out);
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function shown(el) {
|
|
250
|
+
try {
|
|
251
|
+
return typeof el.checkVisibility === 'function'
|
|
252
|
+
? el.checkVisibility({ opacityProperty: true, visibilityProperty: true })
|
|
253
|
+
: true;
|
|
254
|
+
} catch {
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const sx = () => view.scrollX || 0;
|
|
260
|
+
const sy = () => view.scrollY || 0;
|
|
261
|
+
function linesOf(node) {
|
|
262
|
+
const range = document.createRange();
|
|
263
|
+
try {
|
|
264
|
+
range.selectNodeContents(node);
|
|
265
|
+
const out = [];
|
|
266
|
+
const ox = sx();
|
|
267
|
+
const oy = sy();
|
|
268
|
+
for (const r of range.getClientRects()) {
|
|
269
|
+
if (r.width < 1 || r.height < 1) continue;
|
|
270
|
+
out.push({
|
|
271
|
+
left: r.left + ox,
|
|
272
|
+
top: r.top + oy,
|
|
273
|
+
right: r.right + ox,
|
|
274
|
+
bottom: r.bottom + oy
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
return out;
|
|
278
|
+
} catch {
|
|
279
|
+
return [];
|
|
280
|
+
} finally {
|
|
281
|
+
try {
|
|
282
|
+
range.detach();
|
|
283
|
+
} catch {}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
function boxOf(el) {
|
|
287
|
+
const r = el.getBoundingClientRect();
|
|
288
|
+
const cs = styleOf(el);
|
|
289
|
+
const bl = px(cs && cs.borderLeftWidth) || 0;
|
|
290
|
+
const bt = px(cs && cs.borderTopWidth) || 0;
|
|
291
|
+
const ox = sx();
|
|
292
|
+
const oy = sy();
|
|
293
|
+
return {
|
|
294
|
+
left: r.left + ox + bl,
|
|
295
|
+
top: r.top + oy + bt,
|
|
296
|
+
right: r.left + ox + bl + el.clientWidth,
|
|
297
|
+
bottom: r.top + oy + bt + el.clientHeight
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function measure() {
|
|
302
|
+
const lines = new Map();
|
|
303
|
+
const boxes = new Map();
|
|
304
|
+
for (const n of nodes) {
|
|
305
|
+
lines.set(n, linesOf(n));
|
|
306
|
+
for (const c of clippersOf(n.parentElement)) {
|
|
307
|
+
if (!boxes.has(c.el)) boxes.set(c.el, boxOf(c.el));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return { lines, boxes };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const scroll = [sx(), sy()];
|
|
314
|
+
const before = measure();
|
|
315
|
+
const fontSizes = new Map();
|
|
316
|
+
for (const n of nodes) {
|
|
317
|
+
const cs = styleOf(n.parentElement);
|
|
318
|
+
fontSizes.set(n, px(cs && cs.fontSize) || 16);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Text a person could see before the spacing: rendered, and wholly
|
|
322
|
+
// inside every ancestor that clips it. Visually hidden text (a 1px box
|
|
323
|
+
// with overflow hidden), text scrolled out of a carousel or hidden by
|
|
324
|
+
// opacity or visibility is left out.
|
|
325
|
+
const visibleBefore = new Set();
|
|
326
|
+
for (const n of nodes) {
|
|
327
|
+
const lines = before.lines.get(n) || [];
|
|
328
|
+
if (!lines.length || !shown(n.parentElement)) continue;
|
|
329
|
+
const inside = clippersOf(n.parentElement).every((c) => {
|
|
330
|
+
const b0 = before.boxes.get(c.el);
|
|
331
|
+
return (
|
|
332
|
+
!!b0 &&
|
|
333
|
+
lines.every((l) => {
|
|
334
|
+
const o = outside(l, b0, c);
|
|
335
|
+
return o.dx <= 1 && o.dy <= 1;
|
|
336
|
+
})
|
|
337
|
+
);
|
|
338
|
+
});
|
|
339
|
+
if (inside) visibleBefore.add(n);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const sheet = document.createElement('style');
|
|
343
|
+
sheet.setAttribute('data-surea11y', LAYER);
|
|
344
|
+
sheet.textContent =
|
|
345
|
+
`@layer ${LAYER} {` +
|
|
346
|
+
'* { line-height: 1.5 !important; letter-spacing: 0.12em !important; word-spacing: 0.16em !important; }' +
|
|
347
|
+
'p { margin-bottom: 2em !important; }' +
|
|
348
|
+
'}';
|
|
349
|
+
let after;
|
|
350
|
+
try {
|
|
351
|
+
const head = document.head || document.documentElement;
|
|
352
|
+
head.insertBefore(sheet, head.firstChild);
|
|
353
|
+
after = measure();
|
|
354
|
+
} finally {
|
|
355
|
+
if (sheet.parentNode) sheet.parentNode.removeChild(sheet);
|
|
356
|
+
try {
|
|
357
|
+
view.scrollTo(scroll[0], scroll[1]);
|
|
358
|
+
} catch {}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// How far a line sits outside a box, along the axes the box clips.
|
|
362
|
+
function outside(line, box, c) {
|
|
363
|
+
const dy = c.y ? Math.max(0, box.top - line.top, line.bottom - box.bottom) : 0;
|
|
364
|
+
const dx = c.x ? Math.max(0, box.left - line.left, line.right - box.right) : 0;
|
|
365
|
+
return { dx, dy };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const reportedClip = new Set();
|
|
369
|
+
for (const n of nodes) {
|
|
370
|
+
const linesAfter = (after && after.lines.get(n)) || [];
|
|
371
|
+
if (!linesAfter.length || !visibleBefore.has(n)) continue;
|
|
372
|
+
textCount += 1;
|
|
373
|
+
const fontSize = fontSizes.get(n);
|
|
374
|
+
for (const c of clippersOf(n.parentElement)) {
|
|
375
|
+
if (reportedClip.has(c.el)) continue;
|
|
376
|
+
const b1 = after.boxes.get(c.el);
|
|
377
|
+
if (!b1) continue;
|
|
378
|
+
let worst = null;
|
|
379
|
+
for (const l of linesAfter) {
|
|
380
|
+
const o = outside(l, b1, c);
|
|
381
|
+
const height = l.bottom - l.top;
|
|
382
|
+
const lost = o.dy >= height / 2 || o.dx >= fontSize / 2;
|
|
383
|
+
const some = o.dy > 2 || o.dx > 2;
|
|
384
|
+
if (lost) {
|
|
385
|
+
worst = 'lost';
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
if (some) worst = 'some';
|
|
389
|
+
}
|
|
390
|
+
if (worst) {
|
|
391
|
+
reportedClip.add(c.el);
|
|
392
|
+
(worst === 'lost' ? clipped : partly).push({ el: c.el, text: textOf(n.parentElement) });
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Text that comes to overlap text from another element.
|
|
399
|
+
if (after) {
|
|
400
|
+
const BAND = 40;
|
|
401
|
+
const buckets = new Map();
|
|
402
|
+
const entries = [];
|
|
403
|
+
for (const n of nodes) {
|
|
404
|
+
if (!visibleBefore.has(n)) continue;
|
|
405
|
+
for (const whole of after.lines.get(n) || []) {
|
|
406
|
+
// Only the part of the line its clipping ancestors still show is
|
|
407
|
+
// painted; what they cut off is the clipping check's.
|
|
408
|
+
const l = { ...whole };
|
|
409
|
+
for (const c of clippersOf(n.parentElement)) {
|
|
410
|
+
const b = after.boxes.get(c.el);
|
|
411
|
+
if (!b) continue;
|
|
412
|
+
if (c.x) {
|
|
413
|
+
l.left = Math.max(l.left, b.left);
|
|
414
|
+
l.right = Math.min(l.right, b.right);
|
|
415
|
+
}
|
|
416
|
+
if (c.y) {
|
|
417
|
+
l.top = Math.max(l.top, b.top);
|
|
418
|
+
l.bottom = Math.min(l.bottom, b.bottom);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (l.right - l.left < 1 || l.bottom - l.top < 1) continue;
|
|
422
|
+
const entry = { n, l };
|
|
423
|
+
entries.push(entry);
|
|
424
|
+
for (let b = Math.floor(l.top / BAND); b <= Math.floor(l.bottom / BAND); b++) {
|
|
425
|
+
if (!buckets.has(b)) buckets.set(b, []);
|
|
426
|
+
buckets.get(b).push(entry);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const intersects = (a, b) => {
|
|
431
|
+
const w = Math.min(a.right, b.right) - Math.max(a.left, b.left);
|
|
432
|
+
const h = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
|
|
433
|
+
if (w <= 0 || h <= 0) return false;
|
|
434
|
+
const smaller = Math.min(
|
|
435
|
+
(a.right - a.left) * (a.bottom - a.top),
|
|
436
|
+
(b.right - b.left) * (b.bottom - b.top)
|
|
437
|
+
);
|
|
438
|
+
return w * h > smaller * 0.25;
|
|
439
|
+
};
|
|
440
|
+
const overlappedBefore = (n1, n2) =>
|
|
441
|
+
(before.lines.get(n1) || []).some((a) =>
|
|
442
|
+
(before.lines.get(n2) || []).some((b) => intersects(a, b))
|
|
443
|
+
);
|
|
444
|
+
const reported = new Set();
|
|
445
|
+
for (const list of buckets.values()) {
|
|
446
|
+
for (let i = 0; i < list.length; i++) {
|
|
447
|
+
for (let j = i + 1; j < list.length; j++) {
|
|
448
|
+
const a = list[i];
|
|
449
|
+
const b = list[j];
|
|
450
|
+
const pa = a.n.parentElement;
|
|
451
|
+
const pb = b.n.parentElement;
|
|
452
|
+
if (pa === pb || pa.contains(pb) || pb.contains(pa)) continue;
|
|
453
|
+
if (reported.has(pa) || reported.has(pb)) continue;
|
|
454
|
+
if (!intersects(a.l, b.l) || overlappedBefore(a.n, b.n)) continue;
|
|
455
|
+
reported.add(pa);
|
|
456
|
+
overlaps.push({ el: pa, text: textOf(pa), other: textOf(pb) });
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ---- Findings ----
|
|
464
|
+
|
|
465
|
+
const MESSAGES = {
|
|
466
|
+
TEXT_CLIPPED: {
|
|
467
|
+
summary: (p) =>
|
|
468
|
+
`With the text spacing of WCAG 1.4.12 applied, this element cuts off the text "${p.text}".`,
|
|
469
|
+
hint: 'Let the container grow with its text: avoid fixed heights and widths with overflow: hidden on text, or let it scroll (WCAG 1.4.12).',
|
|
470
|
+
key: 'fail_clipped'
|
|
471
|
+
},
|
|
472
|
+
TEXT_CLIPPED_PARTLY: {
|
|
473
|
+
summary: (p) =>
|
|
474
|
+
`With the text spacing of WCAG 1.4.12 applied, the text "${p.text}" reaches past the edge of this element, which hides what goes past it.`,
|
|
475
|
+
hint: 'Check with the text spacing applied that this text can still be read in full (WCAG 1.4.12).',
|
|
476
|
+
key: 'cantTell_clippedPartly',
|
|
477
|
+
needed: 'Whether the text that reaches past the edge of the element can still be read.'
|
|
478
|
+
},
|
|
479
|
+
TEXT_OVERLAPS: {
|
|
480
|
+
summary: (p) =>
|
|
481
|
+
`With the text spacing of WCAG 1.4.12 applied, the text "${p.text}" comes to overlap the text "${p.other}".`,
|
|
482
|
+
hint: 'Check with the text spacing applied that both texts can still be read (WCAG 1.4.12).',
|
|
483
|
+
key: 'cantTell_overlaps',
|
|
484
|
+
needed: 'Whether the overlapping texts can still be read.'
|
|
485
|
+
},
|
|
486
|
+
STYLESHEET_IMPORTANT: {
|
|
487
|
+
summary: (p) =>
|
|
488
|
+
`A style sheet rule (${p.selector}) sets ${p.property}: ${p.value} with !important on this text, below the spacing WCAG 1.4.12 lets users apply.`,
|
|
489
|
+
hint: 'Remove !important from the spacing declaration so users can increase it, or check that a user style sheet still overrides it (WCAG 1.4.12).',
|
|
490
|
+
key: 'cantTell_stylesheetImportant',
|
|
491
|
+
needed: 'Whether users can still apply their own text spacing to this text.'
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
const fails = [];
|
|
496
|
+
const questions = [];
|
|
497
|
+
function report(reasonCode, el, params, uncertaintyCode) {
|
|
498
|
+
const msg = MESSAGES[reasonCode];
|
|
499
|
+
const occ = helpers.reportOccurrence(el, {
|
|
500
|
+
summary: msg.summary(params),
|
|
501
|
+
hint: msg.hint,
|
|
502
|
+
i18n: {
|
|
503
|
+
summaryKey: `textSpacingContentLoss_summary_${msg.key}`,
|
|
504
|
+
hintKey: `textSpacingContentLoss_hint_${msg.key}`,
|
|
505
|
+
params
|
|
506
|
+
},
|
|
507
|
+
...(msg.needed
|
|
508
|
+
? { uncertainty: { code: uncertaintyCode, needed: msg.needed, evidence: { reasonCode } } }
|
|
509
|
+
: {}),
|
|
510
|
+
data: { details: { reasonCode, ...params } }
|
|
511
|
+
});
|
|
512
|
+
(msg.needed ? questions : fails).push(occ);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
for (const f of clipped) report('TEXT_CLIPPED', f.el, { text: f.text });
|
|
516
|
+
for (const f of partly) {
|
|
517
|
+
report('TEXT_CLIPPED_PARTLY', f.el, { text: f.text }, 'judgement-required');
|
|
518
|
+
}
|
|
519
|
+
for (const f of overlaps) {
|
|
520
|
+
report('TEXT_OVERLAPS', f.el, { text: f.text, other: f.other }, 'judgement-required');
|
|
521
|
+
}
|
|
522
|
+
for (const f of importantFindings) {
|
|
523
|
+
report(
|
|
524
|
+
'STYLESHEET_IMPORTANT',
|
|
525
|
+
f.el,
|
|
526
|
+
{ selector: f.selector, property: f.prop, value: f.value },
|
|
527
|
+
'runtime-dependent'
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (fails.length || questions.length) {
|
|
532
|
+
return {
|
|
533
|
+
ruleId: rule.ruleId,
|
|
534
|
+
...helpers.resolveTieredOutcome(fails, questions, rule.defaultSeverity || 'serious')
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
if (textCount)
|
|
538
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
539
|
+
return {
|
|
540
|
+
ruleId: rule.ruleId,
|
|
541
|
+
outcome: 'notApplicable',
|
|
542
|
+
severity: 'minor',
|
|
543
|
+
occurrences: [],
|
|
544
|
+
...(hasLayout() ? {} : { data: { reason: 'noLayout' } })
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
* accepted: text sitting inside a custom textbox widget is not reliably
|
|
21
21
|
* exposed as its name. On a labelable element (<input role="textbox">) an
|
|
22
22
|
* associated <label> counts as well.
|
|
23
|
+
* On a text-like <input> or a <textarea>, the placeholder counts last
|
|
24
|
+
* (HTML-AAM's final name source): a placeholder-only label is a 3.3.2
|
|
25
|
+
* question, not a missing name.
|
|
23
26
|
*/
|
|
24
27
|
|
|
25
28
|
const id = 'textbox-name-present';
|
|
@@ -149,21 +152,6 @@ function runInPage(ctx) {
|
|
|
149
152
|
}
|
|
150
153
|
}
|
|
151
154
|
|
|
152
|
-
function buildLabelForMap(doc) {
|
|
153
|
-
const map = new Map(); // id -> label element (first)
|
|
154
|
-
try {
|
|
155
|
-
const labels = doc && doc.getElementsByTagName ? doc.getElementsByTagName('label') : [];
|
|
156
|
-
for (let i = 0; i < labels.length; i += 1) {
|
|
157
|
-
const lab = labels[i];
|
|
158
|
-
if (!lab || !lab.getAttribute) continue;
|
|
159
|
-
const f = normalizeWs(lab.getAttribute('for'));
|
|
160
|
-
if (!f) continue;
|
|
161
|
-
if (!map.has(f)) map.set(f, lab);
|
|
162
|
-
}
|
|
163
|
-
} catch {}
|
|
164
|
-
return map;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
155
|
const occurrences = [];
|
|
168
156
|
let applicableCount = 0;
|
|
169
157
|
|
|
@@ -172,42 +160,27 @@ function runInPage(ctx) {
|
|
|
172
160
|
? helpers.queryAllSmart(selector)
|
|
173
161
|
: helpers.queryAll(selector);
|
|
174
162
|
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
163
|
+
// Delegates to the shared, spec-guarded lookup (dom-helpers.js's
|
|
164
|
+
// getAssociatedLabelElements): a <label> -- wrapping or via `for` --
|
|
165
|
+
// only ever associates with a genuinely labelable element, so this
|
|
166
|
+
// correctly returns nothing for role-only widgets a <label> merely sits
|
|
167
|
+
// next to, and the real thing for a labelable element like
|
|
168
|
+
// <input role="textbox">.
|
|
179
169
|
function getNativeLabelText(el) {
|
|
170
|
+
if (!helpers || typeof helpers.getAssociatedLabelElements !== 'function') return '';
|
|
171
|
+
let labels;
|
|
180
172
|
try {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
} catch {}
|
|
193
|
-
try {
|
|
194
|
-
if (el.closest) {
|
|
195
|
-
const wrap = el.closest('label');
|
|
196
|
-
if (wrap) {
|
|
197
|
-
const t = getLabelText(wrap);
|
|
198
|
-
if (t) return t;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
} catch {}
|
|
202
|
-
try {
|
|
203
|
-
const idAttr = getAttr(el, 'id');
|
|
204
|
-
if (idAttr && labelForMap.has(idAttr)) {
|
|
205
|
-
const lab = labelForMap.get(idAttr);
|
|
206
|
-
const t = lab ? getLabelText(lab) : '';
|
|
207
|
-
if (t) return t;
|
|
208
|
-
}
|
|
209
|
-
} catch {}
|
|
210
|
-
return '';
|
|
173
|
+
labels = helpers.getAssociatedLabelElements(el) || [];
|
|
174
|
+
} catch {
|
|
175
|
+
labels = [];
|
|
176
|
+
}
|
|
177
|
+
const parts = [];
|
|
178
|
+
const max = Math.min(4, labels.length);
|
|
179
|
+
for (let i = 0; i < max; i += 1) {
|
|
180
|
+
const t = getLabelText(labels[i]);
|
|
181
|
+
if (t) parts.push(t);
|
|
182
|
+
}
|
|
183
|
+
return normalizeWs(parts.join(' '));
|
|
211
184
|
}
|
|
212
185
|
|
|
213
186
|
function hasName(el) {
|
|
@@ -224,6 +197,16 @@ function runInPage(ctx) {
|
|
|
224
197
|
const lab = getNativeLabelText(el);
|
|
225
198
|
if (lab) return { ok: true, method: 'label' };
|
|
226
199
|
|
|
200
|
+
// HTML-AAM's last name source for a text-like <input> or a <textarea>,
|
|
201
|
+
// after title: the placeholder. A placeholder-only label is a 3.3.2
|
|
202
|
+
// question, not a missing name.
|
|
203
|
+
if (helpers.getNativeHostNameInfo) {
|
|
204
|
+
try {
|
|
205
|
+
const host = helpers.getNativeHostNameInfo(el, ctx, { placeholder: true });
|
|
206
|
+
if (host && host.present) return { ok: true, method: host.mechanism };
|
|
207
|
+
} catch {}
|
|
208
|
+
}
|
|
209
|
+
|
|
227
210
|
// role="textbox" is name-from-author-only per WAI-ARIA: it must NOT
|
|
228
211
|
// fall back to subtree content.
|
|
229
212
|
return { ok: false, method: 'none' };
|