@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
|
@@ -25,8 +25,17 @@
|
|
|
25
25
|
* - Within one declaration block, importance outranks order, so the effective
|
|
26
26
|
* declaration is the last `!important` one for that property.
|
|
27
27
|
* - ACT 78fd32/24afc2/9e45ec additionally require the text to contain a soft
|
|
28
|
-
* wrap break, which
|
|
29
|
-
*
|
|
28
|
+
* wrap break, which layout would settle and this cannot. Three shapes
|
|
29
|
+
* establish that no wrap is possible -- text not allowed to wrap, a
|
|
30
|
+
* fixed-width element inside a horizontally scrolling ancestor, and text
|
|
31
|
+
* with no break opportunity (one word, no CJK characters) -- and those are
|
|
32
|
+
* reported for review rather than failed.
|
|
33
|
+
* - Text short enough to fit on one line may never wrap either, so it is also
|
|
34
|
+
* reviewed rather than failed. "Short" is an estimate made without layout:
|
|
35
|
+
* the text's length at half an em per character (a full em for CJK and
|
|
36
|
+
* other wide characters) fits within 320 CSS pixels, the narrowest width
|
|
37
|
+
* WCAG 1.4.10 asks content to reflow to. Longer text is treated as wrapping
|
|
38
|
+
* and fails.
|
|
30
39
|
*/
|
|
31
40
|
|
|
32
41
|
const id = 'avoid-inline-spacing';
|
|
@@ -83,6 +92,9 @@ function runInPage(ctx) {
|
|
|
83
92
|
|
|
84
93
|
const occurrences = [];
|
|
85
94
|
let applicableCount = 0;
|
|
95
|
+
const undecided = [];
|
|
96
|
+
const noWrap = [];
|
|
97
|
+
const shortText = [];
|
|
86
98
|
|
|
87
99
|
// Within one declaration block, importance wins over order, so the last
|
|
88
100
|
// important declaration is the one that takes effect. Passed Example 5 of ACT
|
|
@@ -148,6 +160,62 @@ function runInPage(ctx) {
|
|
|
148
160
|
return null;
|
|
149
161
|
}
|
|
150
162
|
|
|
163
|
+
// ACT 78fd32/24afc2/9e45ec apply only to text that contains a soft wrap
|
|
164
|
+
// break. Layout settles that; short of it, two shapes establish that no wrap
|
|
165
|
+
// can happen -- text not allowed to wrap, and a fixed-width element inside a
|
|
166
|
+
// horizontally scrolling ancestor, which narrowing the viewport cannot reach.
|
|
167
|
+
function cannotSoftWrap(el) {
|
|
168
|
+
const cs = computedStyleOf(el);
|
|
169
|
+
const whiteSpace = cs ? String(cs.whiteSpace || '').toLowerCase() : '';
|
|
170
|
+
if (whiteSpace === 'nowrap' || whiteSpace === 'pre') return true;
|
|
171
|
+
|
|
172
|
+
if (!/(^|;)\s*width\s*:/i.test(String(el.getAttribute('style') || ''))) return false;
|
|
173
|
+
|
|
174
|
+
const chain =
|
|
175
|
+
helpers && typeof helpers.ancestorsIncludingSelf === 'function'
|
|
176
|
+
? helpers.ancestorsIncludingSelf(el)
|
|
177
|
+
: null;
|
|
178
|
+
const ancestors = chain || [];
|
|
179
|
+
if (!chain) {
|
|
180
|
+
for (let a = el.parentElement; a; a = a.parentElement) ancestors.push(a);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (const ancestor of ancestors) {
|
|
184
|
+
if (ancestor === el) continue;
|
|
185
|
+
const acs = computedStyleOf(ancestor);
|
|
186
|
+
if (!acs) continue;
|
|
187
|
+
const overflowX = String(acs.overflowX || acs.overflow || '').toLowerCase();
|
|
188
|
+
if (overflowX === 'scroll' || overflowX === 'auto') return true;
|
|
189
|
+
}
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Text with no break opportunity cannot take a soft wrap break: a single
|
|
194
|
+
// word of a script that separates words with spaces. CJK text can break
|
|
195
|
+
// between most characters, so it never counts as unbreakable here.
|
|
196
|
+
const WIDE_CHAR =
|
|
197
|
+
/[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]/;
|
|
198
|
+
function blockText(el) {
|
|
199
|
+
return String(el.textContent || '')
|
|
200
|
+
.replace(/\s+/g, ' ')
|
|
201
|
+
.trim();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function hasNoBreakOpportunity(text) {
|
|
205
|
+
return !!text && !/\s/.test(text) && !WIDE_CHAR.test(text);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Whether the text may fit on one line at the narrowest width WCAG 1.4.10
|
|
209
|
+
// asks content to reflow to (320 CSS pixels), estimated without layout at
|
|
210
|
+
// half an em per character and a full em per wide character.
|
|
211
|
+
const REFLOW_WIDTH_PX = 320;
|
|
212
|
+
function mayFitOnOneLine(el, text) {
|
|
213
|
+
const fontSize = fontSizeOf(computedStyleOf(el)) || 16;
|
|
214
|
+
let ems = 0;
|
|
215
|
+
for (const ch of text) ems += WIDE_CHAR.test(ch) ? 1 : 0.5;
|
|
216
|
+
return ems * fontSize <= REFLOW_WIDTH_PX;
|
|
217
|
+
}
|
|
218
|
+
|
|
151
219
|
// ACT scopes these rules to text visible on screen, and text pushed far off
|
|
152
220
|
// canvas is the one hidden shape the shared eligibility check keeps eligible.
|
|
153
221
|
function isOffScreen(el) {
|
|
@@ -228,6 +296,7 @@ function runInPage(ctx) {
|
|
|
228
296
|
if (!hasVisibleTextChild(el) || !isRendered(el) || isOffScreen(el)) continue;
|
|
229
297
|
|
|
230
298
|
const flagged = [];
|
|
299
|
+
const unresolved = [];
|
|
231
300
|
let inScope = false;
|
|
232
301
|
for (const prop of SPACING_PROPS) {
|
|
233
302
|
const decl = effectiveDeclaration(raw, prop);
|
|
@@ -235,14 +304,33 @@ function runInPage(ctx) {
|
|
|
235
304
|
if (INHERITED_KEYWORDS.indexOf(decl.value.toLowerCase()) !== -1) continue;
|
|
236
305
|
inScope = true;
|
|
237
306
|
const ratio = spacingRatio(el, prop, decl.value);
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
if (ratio === null)
|
|
307
|
+
// Not flagged, since `fail` needs a measured value -- but recorded, so
|
|
308
|
+
// the element does not fall through to `pass` unmeasured.
|
|
309
|
+
if (ratio === null) {
|
|
310
|
+
unresolved.push(prop);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
241
313
|
if (ratio < MIN_RATIO[prop]) flagged.push(prop);
|
|
242
314
|
}
|
|
243
315
|
|
|
244
316
|
if (inScope) applicableCount += 1;
|
|
245
|
-
if (!flagged.length)
|
|
317
|
+
if (!flagged.length) {
|
|
318
|
+
if (unresolved.length) undecided.push({ el, props: unresolved.slice() });
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// A forced value on text that cannot wrap is outside these ACT rules'
|
|
323
|
+
// applicability, and whether it wraps is not decidable here, so it is
|
|
324
|
+
// reported for review rather than failed.
|
|
325
|
+
const text = blockText(el);
|
|
326
|
+
if (cannotSoftWrap(el) || hasNoBreakOpportunity(text)) {
|
|
327
|
+
noWrap.push({ el, props: flagged.slice() });
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (mayFitOnOneLine(el, text)) {
|
|
331
|
+
shortText.push({ el, props: flagged.slice() });
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
246
334
|
|
|
247
335
|
const tag = el.tagName.toLowerCase();
|
|
248
336
|
|
|
@@ -265,15 +353,119 @@ function runInPage(ctx) {
|
|
|
265
353
|
if (applicableCount === 0) {
|
|
266
354
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
267
355
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
356
|
+
|
|
357
|
+
const cantTellOccurrences = noWrap
|
|
358
|
+
.map(({ el, props }) =>
|
|
359
|
+
helpers.reportOccurrence(el, {
|
|
360
|
+
occurrenceOutcome: 'cantTell',
|
|
361
|
+
summary: `This element's inline style forces ${props.join(', ')} with !important, but its text does not appear able to wrap, so the text-spacing criterion may not apply to it.`,
|
|
362
|
+
hint: 'Confirm whether this text ever wraps. If it cannot, the criterion does not apply; if it can, remove !important or set a value that already meets the metric.',
|
|
363
|
+
i18n: {
|
|
364
|
+
summaryKey: 'avoidInlineSpacing_summary_cantTell_noSoftWrap',
|
|
365
|
+
hintKey: 'avoidInlineSpacing_hint_cantTell_noSoftWrap',
|
|
366
|
+
params: {
|
|
367
|
+
element: (el.tagName || '').toLowerCase(),
|
|
368
|
+
properties: props.join(', ')
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
uncertainty: {
|
|
372
|
+
code: 'not-computable',
|
|
373
|
+
needed: 'Whether this text ever contains a soft wrap break, which needs layout.',
|
|
374
|
+
evidence: {
|
|
375
|
+
element: (el.tagName || '').toLowerCase(),
|
|
376
|
+
properties: props,
|
|
377
|
+
reasonCode: 'INLINE_SPACING_NO_SOFT_WRAP'
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
data: {
|
|
381
|
+
details: {
|
|
382
|
+
reasonCode: 'INLINE_SPACING_NO_SOFT_WRAP',
|
|
383
|
+
element: (el.tagName || '').toLowerCase(),
|
|
384
|
+
properties: props
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
})
|
|
388
|
+
)
|
|
389
|
+
.concat(
|
|
390
|
+
shortText.map(({ el, props }) =>
|
|
391
|
+
helpers.reportOccurrence(el, {
|
|
392
|
+
occurrenceOutcome: 'cantTell',
|
|
393
|
+
summary: `This element's inline style forces ${props.join(', ')} with !important, but its text is short enough to fit on one line, so it may never wrap and the text-spacing criterion may not apply to it.`,
|
|
394
|
+
hint: 'Confirm whether this text wraps at narrow widths. If it always fits on one line, the criterion does not apply; if it can wrap, remove !important or set a value that already meets the metric.',
|
|
395
|
+
i18n: {
|
|
396
|
+
summaryKey: 'avoidInlineSpacing_summary_cantTell_shortText',
|
|
397
|
+
hintKey: 'avoidInlineSpacing_hint_cantTell_shortText',
|
|
398
|
+
params: {
|
|
399
|
+
element: (el.tagName || '').toLowerCase(),
|
|
400
|
+
properties: props.join(', ')
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
uncertainty: {
|
|
404
|
+
code: 'not-computable',
|
|
405
|
+
needed: 'Whether this text ever contains a soft wrap break, which needs layout.',
|
|
406
|
+
evidence: {
|
|
407
|
+
element: (el.tagName || '').toLowerCase(),
|
|
408
|
+
properties: props,
|
|
409
|
+
reasonCode: 'INLINE_SPACING_SHORT_TEXT'
|
|
410
|
+
}
|
|
411
|
+
},
|
|
412
|
+
data: {
|
|
413
|
+
details: {
|
|
414
|
+
reasonCode: 'INLINE_SPACING_SHORT_TEXT',
|
|
415
|
+
element: (el.tagName || '').toLowerCase(),
|
|
416
|
+
properties: props
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
})
|
|
420
|
+
)
|
|
421
|
+
)
|
|
422
|
+
.concat(
|
|
423
|
+
undecided.map(({ el, props }) =>
|
|
424
|
+
helpers.reportOccurrence(el, {
|
|
425
|
+
occurrenceOutcome: 'cantTell',
|
|
426
|
+
summary: `This element's inline style sets ${props.join(', ')} with !important, but the value could not be resolved, so whether it meets the WCAG text-spacing metric could not be determined.`,
|
|
427
|
+
hint: 'Check this value by hand against the metric (line-height 1.5, letter-spacing 0.12em, word-spacing 0.16em), or express it in a unit the engine can resolve against the element’s computed font size.',
|
|
428
|
+
i18n: {
|
|
429
|
+
summaryKey: 'avoidInlineSpacing_summary_cantTell',
|
|
430
|
+
hintKey: 'avoidInlineSpacing_hint_cantTell',
|
|
431
|
+
params: {
|
|
432
|
+
element: (el.tagName || '').toLowerCase(),
|
|
433
|
+
properties: props.join(', ')
|
|
434
|
+
}
|
|
435
|
+
},
|
|
436
|
+
uncertainty: {
|
|
437
|
+
code: 'not-computable',
|
|
438
|
+
needed: 'A resolved value for the spacing declarations marked !important.',
|
|
439
|
+
evidence: {
|
|
440
|
+
element: (el.tagName || '').toLowerCase(),
|
|
441
|
+
properties: props,
|
|
442
|
+
reasonCode: 'INLINE_SPACING_NOT_RESOLVABLE'
|
|
443
|
+
}
|
|
444
|
+
},
|
|
445
|
+
data: {
|
|
446
|
+
details: {
|
|
447
|
+
reasonCode: 'INLINE_SPACING_NOT_RESOLVABLE',
|
|
448
|
+
element: (el.tagName || '').toLowerCase(),
|
|
449
|
+
properties: props
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
})
|
|
453
|
+
)
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
// See helpers.resolveTieredOutcome (src/core/dom-helpers.js): a fail-tier
|
|
457
|
+
// finding never silently discards the cantTell-tier findings from the same
|
|
458
|
+
// run, so an undecided element survives a failure elsewhere on the page.
|
|
459
|
+
const resolved = helpers.resolveTieredOutcome(
|
|
460
|
+
occurrences,
|
|
461
|
+
cantTellOccurrences,
|
|
462
|
+
rule.defaultSeverity || 'moderate'
|
|
463
|
+
);
|
|
464
|
+
return {
|
|
465
|
+
ruleId: rule.ruleId,
|
|
466
|
+
...resolved,
|
|
467
|
+
...(resolved.outcome === 'cantTell' ? { confidence: 'low' } : null)
|
|
468
|
+
};
|
|
277
469
|
}
|
|
278
470
|
|
|
279
471
|
module.exports = { id, meta, runInPage };
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
* "Submit"/"Reset" default, which is why those two are never nameless.
|
|
23
23
|
* Failing both, a button whose role is name-from-content falls back to its
|
|
24
24
|
* subtree text, counting each descendant's own name (an <img alt>,
|
|
25
|
-
* aria-label or title) rather than only text
|
|
25
|
+
* aria-label, an <svg>'s <title> child, or title) rather than only text
|
|
26
|
+
* nodes.
|
|
26
27
|
*/
|
|
27
28
|
|
|
28
29
|
// NOTE: Repo ruleId contract requires ENGINE_TAG prefix in the rule id.
|
|
@@ -12,7 +12,16 @@
|
|
|
12
12
|
* Applies to <canvas> elements included in the accessibility tree.
|
|
13
13
|
* Hidden elements are excluded whether or not they are focusable.
|
|
14
14
|
* @expectation
|
|
15
|
-
* Each applicable <canvas> provides a text alternative via fallback content
|
|
15
|
+
* Each applicable <canvas> provides a text alternative via fallback content
|
|
16
|
+
* or an accessible name, with two exceptions:
|
|
17
|
+
* - role="img" (first role token) makes the canvas's children
|
|
18
|
+
* presentational and its name comes from the author only, so fallback
|
|
19
|
+
* content does not count: aria-labelledby, aria-label or title must name
|
|
20
|
+
* it.
|
|
21
|
+
* - role="none"/"presentation" marks the canvas decorative, and it passes.
|
|
22
|
+
* The role is ignored (presentational role conflict) when the canvas is
|
|
23
|
+
* focusable or carries aria-label/aria-labelledby, and the canvas is then
|
|
24
|
+
* judged like any other.
|
|
16
25
|
*/
|
|
17
26
|
|
|
18
27
|
const id = 'canvas-text-alternative-present';
|
|
@@ -85,6 +94,64 @@ function runInPage(ctx) {
|
|
|
85
94
|
? helpers.getTextAlternativeInfo
|
|
86
95
|
: null;
|
|
87
96
|
|
|
97
|
+
const getAriaNameInfo =
|
|
98
|
+
helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
|
|
99
|
+
|
|
100
|
+
const getFocusableInfo =
|
|
101
|
+
helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
102
|
+
|
|
103
|
+
function attrText(el, name) {
|
|
104
|
+
try {
|
|
105
|
+
const v = el.getAttribute(name);
|
|
106
|
+
return v == null ? '' : String(v).trim();
|
|
107
|
+
} catch {
|
|
108
|
+
return '';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function firstRoleToken(el) {
|
|
113
|
+
const raw = attrText(el, 'role').toLowerCase();
|
|
114
|
+
return raw ? raw.split(/\s+/)[0] : '';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ARIA's presentational role conflict: a focusable element, or one with a
|
|
118
|
+
// global naming attribute, keeps its native role.
|
|
119
|
+
function isPresentationHonoured(el) {
|
|
120
|
+
if (attrText(el, 'aria-label') || attrText(el, 'aria-labelledby')) return false;
|
|
121
|
+
if (getFocusableInfo) {
|
|
122
|
+
try {
|
|
123
|
+
const fi = getFocusableInfo(el, ctx);
|
|
124
|
+
if (fi && fi.focusable) return false;
|
|
125
|
+
} catch {}
|
|
126
|
+
} else if (attrText(el, 'tabindex') !== '') {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// role="img": the name comes from the author (aria-labelledby, aria-label,
|
|
133
|
+
// then title), never from the children.
|
|
134
|
+
function getRoleImgNameInfo(el) {
|
|
135
|
+
let aria = null;
|
|
136
|
+
if (getAriaNameInfo) {
|
|
137
|
+
try {
|
|
138
|
+
aria = getAriaNameInfo(el, ctx);
|
|
139
|
+
} catch {
|
|
140
|
+
aria = null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (aria && aria.present && aria.value) {
|
|
144
|
+
return { present: true, value: aria.value, mechanism: aria.mechanism };
|
|
145
|
+
}
|
|
146
|
+
if (!getAriaNameInfo) {
|
|
147
|
+
const label = attrText(el, 'aria-label');
|
|
148
|
+
if (label) return { present: true, value: label, mechanism: 'aria-label' };
|
|
149
|
+
}
|
|
150
|
+
const title = attrText(el, 'title');
|
|
151
|
+
if (title) return { present: true, value: title, mechanism: 'title' };
|
|
152
|
+
return { present: false, value: '', mechanism: 'none' };
|
|
153
|
+
}
|
|
154
|
+
|
|
88
155
|
const canvases = (() => {
|
|
89
156
|
try {
|
|
90
157
|
return Array.from((queryAllSmart ? queryAllSmart('canvas') : queryAll('canvas')) || []);
|
|
@@ -118,16 +185,25 @@ function runInPage(ctx) {
|
|
|
118
185
|
|
|
119
186
|
applicableCount += 1;
|
|
120
187
|
|
|
188
|
+
const role = firstRoleToken(el);
|
|
189
|
+
|
|
190
|
+
// A decorative canvas needs no text alternative.
|
|
191
|
+
if ((role === 'none' || role === 'presentation') && isPresentationHonoured(el)) continue;
|
|
192
|
+
|
|
193
|
+
const isRoleImg = role === 'img';
|
|
194
|
+
|
|
121
195
|
// Expectation: must provide a text alternative.
|
|
122
|
-
const ti =
|
|
123
|
-
? (
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
196
|
+
const ti = isRoleImg
|
|
197
|
+
? getRoleImgNameInfo(el)
|
|
198
|
+
: getTextAlternativeInfo
|
|
199
|
+
? (() => {
|
|
200
|
+
try {
|
|
201
|
+
return getTextAlternativeInfo(el, ctx);
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
})()
|
|
206
|
+
: null;
|
|
131
207
|
|
|
132
208
|
const hasTextAlt = !!(ti && ti.present);
|
|
133
209
|
|
|
@@ -135,14 +211,29 @@ function runInPage(ctx) {
|
|
|
135
211
|
|
|
136
212
|
const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
|
|
137
213
|
|
|
214
|
+
const messages = isRoleImg
|
|
215
|
+
? {
|
|
216
|
+
summary:
|
|
217
|
+
'This <canvas role="img"> has no accessible name; with role="img" its fallback content does not count.',
|
|
218
|
+
hint: 'Name it with aria-label or aria-labelledby, or remove role="img" so that its fallback content can serve as the text alternative.',
|
|
219
|
+
summaryKey: 'canvas_textAltPresent_summary_fail_roleImg',
|
|
220
|
+
hintKey: 'canvas_textAltPresent_hint_fail_roleImg'
|
|
221
|
+
}
|
|
222
|
+
: {
|
|
223
|
+
summary: 'Missing text alternative for <canvas>.',
|
|
224
|
+
hint: 'Provide fallback text inside <canvas> or an accessible name (e.g., aria-label/aria-labelledby).',
|
|
225
|
+
summaryKey: 'canvas_textAltPresent_summary_fail',
|
|
226
|
+
hintKey: 'canvas_textAltPresent_hint_fail'
|
|
227
|
+
};
|
|
228
|
+
|
|
138
229
|
const baseOccurrence = {
|
|
139
230
|
selector: '',
|
|
140
231
|
html: '',
|
|
141
|
-
summary:
|
|
142
|
-
hint:
|
|
232
|
+
summary: messages.summary,
|
|
233
|
+
hint: messages.hint,
|
|
143
234
|
i18n: {
|
|
144
|
-
summaryKey:
|
|
145
|
-
hintKey:
|
|
235
|
+
summaryKey: messages.summaryKey,
|
|
236
|
+
hintKey: messages.hintKey,
|
|
146
237
|
params: { element: 'canvas' }
|
|
147
238
|
},
|
|
148
239
|
data: {
|
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
* accepted: text sitting inside a custom combobox widget is not reliably
|
|
21
21
|
* exposed as its name. On a labelable element (<input role="combobox">) 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 = 'combobox-name-present';
|
|
@@ -70,21 +73,6 @@ function runInPage(ctx) {
|
|
|
70
73
|
}
|
|
71
74
|
}
|
|
72
75
|
|
|
73
|
-
function buildLabelForMap(doc) {
|
|
74
|
-
const map = new Map(); // id -> label element (first)
|
|
75
|
-
try {
|
|
76
|
-
const labels = doc && doc.getElementsByTagName ? doc.getElementsByTagName('label') : [];
|
|
77
|
-
for (let i = 0; i < labels.length; i += 1) {
|
|
78
|
-
const lab = labels[i];
|
|
79
|
-
if (!lab || !lab.getAttribute) continue;
|
|
80
|
-
const f = normalizeWs(lab.getAttribute('for'));
|
|
81
|
-
if (!f) continue;
|
|
82
|
-
if (!map.has(f)) map.set(f, lab);
|
|
83
|
-
}
|
|
84
|
-
} catch {}
|
|
85
|
-
return map;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
76
|
function getConservativeSubtreeText(document, container) {
|
|
89
77
|
// "Name from content", recurses into descendants and uses each one's
|
|
90
78
|
// own accessible name (img alt, aria-label/aria-labelledby, title) when
|
|
@@ -172,45 +160,30 @@ 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="combobox">.
|
|
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
|
-
function evaluate(el
|
|
186
|
+
function evaluate(el) {
|
|
214
187
|
const ariaLabel = getAttr(el, 'aria-label');
|
|
215
188
|
if (ariaLabel) return { ok: true, method: 'aria-label' };
|
|
216
189
|
|
|
@@ -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="combobox" is name-from-author-only per WAI-ARIA: it must NOT
|
|
228
211
|
// fall back to subtree content. Visible text near/inside a custom
|
|
229
212
|
// combobox widget is not reliably exposed as its accessible name.
|
|
@@ -239,7 +222,7 @@ function runInPage(ctx) {
|
|
|
239
222
|
|
|
240
223
|
applicableCount += 1;
|
|
241
224
|
|
|
242
|
-
const res = evaluate(el
|
|
225
|
+
const res = evaluate(el);
|
|
243
226
|
if (res.ok) continue;
|
|
244
227
|
|
|
245
228
|
const eligInfo = getEligibilityInfo
|
|
@@ -115,6 +115,25 @@ function runInPage(ctx) {
|
|
|
115
115
|
|
|
116
116
|
const seenFailEls = new Set();
|
|
117
117
|
|
|
118
|
+
// Defined inside runInPage, which is serialized into the page and cannot
|
|
119
|
+
// reach module scope. English fallbacks for the dictionary keys below.
|
|
120
|
+
const CANT_TELL_HINT_KEYS = {
|
|
121
|
+
background: 'contrastComputable_hint_cantTell_background',
|
|
122
|
+
effect: 'contrastComputable_hint_cantTell_effect',
|
|
123
|
+
rootNotOpaque: 'contrastComputable_hint_cantTell_rootNotOpaque',
|
|
124
|
+
generic: 'contrast_hint_cantTell_manual'
|
|
125
|
+
};
|
|
126
|
+
const CANT_TELL_HINTS = {
|
|
127
|
+
background:
|
|
128
|
+
'Measure the contrast by hand on the rendered page, where the background behind the text gives the least contrast. Normal text needs at least 4.5:1 and large text 3:1 (7:1 and 4.5:1 for AAA).',
|
|
129
|
+
effect:
|
|
130
|
+
'Measure the contrast by hand on the rendered page with the effect applied, since the declared CSS colors are not what ends up on screen. Normal text needs at least 4.5:1 and large text 3:1 (7:1 and 4.5:1 for AAA).',
|
|
131
|
+
rootNotOpaque:
|
|
132
|
+
'No element behind this text, up to the root, has a fully opaque background, so the color it sits on depends on where the page is displayed. Set an opaque background color on html or body, or measure the contrast against the background the page is actually shown on.',
|
|
133
|
+
generic:
|
|
134
|
+
"Measure this text's contrast by hand on the rendered page. Normal text needs at least 4.5:1 and large text 3:1 (7:1 and 4.5:1 for AAA)."
|
|
135
|
+
};
|
|
136
|
+
|
|
118
137
|
function pushCantTellOccurrence(el, reasonCode, extraDetails) {
|
|
119
138
|
try {
|
|
120
139
|
if (!el || seenFailEls.has(el)) return;
|
|
@@ -151,6 +170,18 @@ function runInPage(ctx) {
|
|
|
151
170
|
summaryKey = 'contrastComputable_cantTell_rootNotOpaque';
|
|
152
171
|
else if (rc === 'TEXT_SHADOW') summaryKey = 'contrastComputable_cantTell_textShadow';
|
|
153
172
|
|
|
173
|
+
// Every cantTell leaves a person to measure the contrast, so each one
|
|
174
|
+
// says how, grouped by what blocked the calculation.
|
|
175
|
+
let hintKind = 'generic';
|
|
176
|
+
if (rc === 'BACKGROUND_IMAGE_OR_GRADIENT') hintKind = 'background';
|
|
177
|
+
else if (
|
|
178
|
+
rc === 'MIX_BLEND_MODE' ||
|
|
179
|
+
rc === 'BACKGROUND_FILTER_OR_BACKDROP_FILTER' ||
|
|
180
|
+
rc === 'TEXT_SHADOW'
|
|
181
|
+
)
|
|
182
|
+
hintKind = 'effect';
|
|
183
|
+
else if (rc === 'BACKGROUND_NOT_OPAQUE_AT_ROOT') hintKind = 'rootNotOpaque';
|
|
184
|
+
|
|
154
185
|
const details = Object.assign(
|
|
155
186
|
{ reasonCode: rc },
|
|
156
187
|
extraDetails && typeof extraDetails === 'object' ? extraDetails : {}
|
|
@@ -160,10 +191,10 @@ function runInPage(ctx) {
|
|
|
160
191
|
selector: '',
|
|
161
192
|
html: '',
|
|
162
193
|
summary: '',
|
|
163
|
-
hint:
|
|
194
|
+
hint: CANT_TELL_HINTS[hintKind],
|
|
164
195
|
i18n: {
|
|
165
196
|
summaryKey,
|
|
166
|
-
hintKey:
|
|
197
|
+
hintKey: CANT_TELL_HINT_KEYS[hintKind],
|
|
167
198
|
params: Object.assign(
|
|
168
199
|
{ reasonCode: rc },
|
|
169
200
|
details && typeof details === 'object'
|
|
@@ -178,6 +209,16 @@ function runInPage(ctx) {
|
|
|
178
209
|
: {}
|
|
179
210
|
)
|
|
180
211
|
},
|
|
212
|
+
uncertainty: {
|
|
213
|
+
code: 'not-computable',
|
|
214
|
+
needed: 'A contrast ratio for this text, which this page composition blocks.',
|
|
215
|
+
evidence: {
|
|
216
|
+
reasonCode: rc,
|
|
217
|
+
blockerProperty: details.blockerProperty || null,
|
|
218
|
+
blockerValue: details.blockerValue || null,
|
|
219
|
+
backgroundAlpha: details.backgroundAlpha === undefined ? null : details.backgroundAlpha
|
|
220
|
+
}
|
|
221
|
+
},
|
|
181
222
|
data: { details }
|
|
182
223
|
};
|
|
183
224
|
|
|
@@ -364,11 +405,11 @@ function runInPage(ctx) {
|
|
|
364
405
|
{
|
|
365
406
|
selector: '',
|
|
366
407
|
summary: '',
|
|
367
|
-
hint:
|
|
408
|
+
hint: CANT_TELL_HINTS.generic,
|
|
368
409
|
html: '',
|
|
369
410
|
i18n: {
|
|
370
411
|
summaryKey: 'contrastComputable_cantTell_engineFailure',
|
|
371
|
-
hintKey:
|
|
412
|
+
hintKey: CANT_TELL_HINT_KEYS.generic,
|
|
372
413
|
params: { reasonCode: 'ENGINE_EXCEPTION' }
|
|
373
414
|
},
|
|
374
415
|
data: { details: { reasonCode: 'ENGINE_EXCEPTION' } }
|