@surea11y/core 1.4.1 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +212 -128
- package/README.md +46 -9
- package/docs/ACT_RULE_MAPPING.md +243 -0
- package/docs/API_STABILITY.md +4 -4
- package/docs/BINDING_AUTHORS_GUIDE.md +3 -3
- package/docs/DESIGN_CHALLENGES.md +301 -0
- package/docs/ENGINE_OPTIONS.md +30 -14
- package/docs/I18N.md +176 -20
- package/docs/INTEGRATION.md +29 -7
- package/docs/LIMITATIONS.md +6 -4
- package/docs/OUTPUT_SCHEMA.md +13 -3
- package/docs/REPORT.md +3 -1
- package/docs/RULE_AUTHORING.md +104 -23
- package/docs/RULE_CATALOG.md +1878 -169
- package/docs/RULE_TAXONOMY.md +2 -2
- package/docs/TROUBLESHOOTING.md +4 -4
- package/docs/WCAG_CONFORMANCE.md +25 -9
- package/package.json +8 -7
- package/src/baseline.js +3 -3
- package/src/checks/automatic/area-alt-present.js +2 -2
- package/src/checks/automatic/aria-allowed-attr.js +95 -40
- package/src/checks/automatic/aria-allowed-role.js +16 -18
- package/src/checks/automatic/aria-braille-equivalent.js +19 -21
- package/src/checks/automatic/aria-conditional-attr.js +22 -24
- package/src/checks/automatic/aria-deprecated-role.js +63 -50
- package/src/checks/automatic/aria-hidden-body.js +4 -11
- package/src/checks/automatic/aria-hidden-focus.js +104 -23
- package/src/checks/automatic/aria-prohibited-attr.js +71 -72
- package/src/checks/automatic/aria-prohibited-children.js +154 -61
- package/src/checks/automatic/aria-required-attr.js +74 -29
- package/src/checks/automatic/aria-required-children.js +38 -34
- package/src/checks/automatic/aria-required-parent.js +78 -29
- package/src/checks/automatic/aria-role-name-present.js +36 -22
- package/src/checks/automatic/aria-roles-valid.js +37 -23
- package/src/checks/automatic/aria-valid-attr-value.js +33 -33
- package/src/checks/automatic/aria-valid-attr.js +15 -18
- package/src/checks/automatic/autocomplete-valid.js +17 -19
- package/src/checks/automatic/avoid-inline-spacing.js +14 -16
- package/src/checks/automatic/binary-control-name-present.js +46 -26
- package/src/checks/automatic/button-name-present.js +115 -34
- package/src/checks/automatic/combobox-name-present.js +40 -22
- package/src/checks/automatic/contrast-computable.js +32 -0
- package/src/checks/automatic/contrast-enhanced.js +21 -1
- package/src/checks/automatic/contrast-minimum.js +21 -1
- package/src/checks/automatic/css-orientation-lock.js +118 -41
- package/src/checks/automatic/definition-list-children-valid.js +25 -29
- package/src/checks/automatic/deprecated-elements-not-used.js +15 -17
- package/src/checks/automatic/dialog-name-present.js +36 -20
- package/src/checks/automatic/dlitem-parent-valid.js +15 -17
- package/src/checks/automatic/duplicate-id-aria.js +50 -40
- package/src/checks/automatic/duplicate-id.js +198 -0
- package/src/checks/automatic/embed-text-alternative-present.js +2 -2
- package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
- package/src/checks/automatic/form-control-single-label.js +39 -41
- package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
- package/src/checks/automatic/iframe-focusable-content.js +92 -38
- package/src/checks/automatic/iframe-name-present.js +53 -21
- package/src/checks/automatic/iframe-title-unique.js +19 -24
- package/src/checks/automatic/img-alt-present.js +12 -4
- package/src/checks/automatic/label-in-name.js +198 -49
- package/src/checks/automatic/link-in-text-block.js +29 -31
- package/src/checks/automatic/link-name-present.js +47 -31
- package/src/checks/automatic/list-children-valid.js +21 -23
- package/src/checks/automatic/listbox-name-present.js +42 -24
- package/src/checks/automatic/listitem-parent-valid.js +18 -21
- package/src/checks/automatic/menuitem-name-present.js +36 -20
- package/src/checks/automatic/meta-refresh-no-exceptions.js +39 -31
- package/src/checks/automatic/meta-refresh-timing-absent.js +29 -25
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +14 -17
- package/src/checks/automatic/meter-name-present.js +38 -21
- package/src/checks/automatic/nested-interactive-controls-absent.js +22 -24
- package/src/checks/automatic/option-name-present.js +39 -22
- package/src/checks/automatic/page-title-present.js +21 -3
- package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
- package/src/checks/automatic/progressbar-name-present.js +41 -24
- package/src/checks/automatic/role-img-alt-present.js +64 -16
- package/src/checks/automatic/searchbox-name-present.js +46 -24
- package/src/checks/automatic/server-side-image-map-absent.js +16 -19
- package/src/checks/automatic/slider-name-present.js +42 -23
- package/src/checks/automatic/spinbutton-name-present.js +46 -24
- package/src/checks/automatic/summary-name-present.js +34 -20
- package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
- package/src/checks/automatic/svg-text-alternative-present.js +13 -10
- package/src/checks/automatic/tab-name-present.js +37 -20
- package/src/checks/automatic/table-headers-attr-valid.js +57 -24
- package/src/checks/automatic/table-th-has-data-cells.js +76 -24
- package/src/checks/automatic/target-size-minimum.js +172 -131
- package/src/checks/automatic/td-has-header.js +20 -25
- package/src/checks/automatic/textbox-name-present.js +42 -24
- package/src/checks/automatic/tooltip-name-present.js +37 -20
- package/src/checks/automatic/treeitem-name-present.js +39 -22
- package/src/checks/automatic/valid-lang.js +107 -24
- package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
- package/src/checks/manual/accesskeys-manual.js +21 -22
- package/src/checks/manual/area-alt-decorative-manual.js +7 -0
- package/src/checks/manual/area-alt-quality-manual.js +6 -0
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +23 -26
- package/src/checks/manual/aria-text-manual.js +4 -4
- package/src/checks/manual/bypass-blocks-present-manual.js +48 -38
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
- package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
- package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
- package/src/checks/manual/empty-heading-manual.js +73 -28
- package/src/checks/manual/empty-table-header-manual.js +33 -36
- package/src/checks/manual/focus-order-semantics-manual.js +15 -15
- package/src/checks/manual/form-control-label-quality-manual.js +453 -0
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +4 -11
- package/src/checks/manual/heading-order-manual.js +20 -25
- package/src/checks/manual/heading-quality-manual.js +338 -0
- package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
- package/src/checks/manual/image-redundant-alt-manual.js +18 -21
- package/src/checks/manual/img-alt-decorative-manual.js +211 -52
- package/src/checks/manual/img-alt-quality-manual.js +7 -0
- package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
- package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
- package/src/checks/manual/label-title-only-manual.js +19 -21
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +21 -24
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +23 -26
- package/src/checks/manual/landmark-main-is-top-level-manual.js +20 -23
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +8 -13
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +8 -13
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +4 -9
- package/src/checks/manual/landmark-one-main-manual.js +8 -15
- package/src/checks/manual/landmark-unique-manual.js +31 -36
- package/src/checks/manual/link-name-quality-manual.js +162 -35
- package/src/checks/manual/media-transcript-present-manual.js +2 -3
- package/src/checks/manual/meta-viewport-large-manual.js +16 -19
- package/src/checks/manual/mouse-only-event-handlers-manual.js +26 -28
- package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
- package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
- package/src/checks/manual/p-as-heading-manual.js +4 -4
- package/src/checks/manual/page-has-heading-one-manual.js +8 -15
- package/src/checks/manual/page-title-patterns-manual.js +26 -3
- package/src/checks/manual/presentation-role-conflict-manual.js +74 -46
- package/src/checks/manual/region-manual.js +32 -25
- package/src/checks/manual/scope-attr-valid-manual.js +16 -19
- package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
- package/src/checks/manual/skip-link-manual.js +44 -52
- package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
- package/src/checks/manual/tabindex-manual.js +16 -19
- package/src/checks/manual/table-duplicate-name-manual.js +16 -19
- package/src/checks/manual/table-fake-caption-manual.js +2 -2
- package/src/checks/manual/video-caption-manual.js +3 -3
- package/src/checks/manual-review.js +17 -1
- package/src/core.js +13086 -5169
- package/src/report.js +16 -2
- package/surea11y.browser.js +5665 -4219
- package/surea11y.i18n.de.js +22 -0
- package/surea11y.i18n.es.js +22 -0
- package/surea11y.i18n.fr.js +22 -0
- package/bin/surea11y-core.js +0 -20
|
@@ -8,6 +8,24 @@
|
|
|
8
8
|
* @summary Pointer-operable targets should be at least 24×24 CSS px (or meet an exception)
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
10
|
* @sc 2.5.8
|
|
11
|
+
* @applicability
|
|
12
|
+
* Applies to <button>, <summary>, <a href>, <area href>, <input>,
|
|
13
|
+
* <select>, <textarea> and elements with role="button"/"link" that are
|
|
14
|
+
* pointer-reachable: rendered, not suppressed by pointer-events:none, and
|
|
15
|
+
* with a measurable box of non-zero size. Accessibility-tree exclusion isn't
|
|
16
|
+
* a filter here: an aria-hidden control is still a target a pointer can hit.
|
|
17
|
+
* <area> is matched but never actually evaluated, for the reason given below.
|
|
18
|
+
* @expectation
|
|
19
|
+
* Each target is at least 24 by 24 CSS pixels, or meets one of the SC
|
|
20
|
+
* 2.5.8 exceptions this rule can establish from geometry: spacing (a
|
|
21
|
+
* 24px-diameter circle centred on the target reaches no unrelated target),
|
|
22
|
+
* the inline exception for a link inside a run of text, or user-agent
|
|
23
|
+
* sizing (an unstyled native checkbox or radio, detected by appearance not
|
|
24
|
+
* having been reset to none). An undersized target too close to a
|
|
25
|
+
* neighbour fails. Where an exception may apply but geometry cannot
|
|
26
|
+
* confirm it (two inline links in one run of text, or a target inside an
|
|
27
|
+
* SVG, canvas or image map that may be essential), the result is cantTell
|
|
28
|
+
* rather than a guess.
|
|
11
29
|
*
|
|
12
30
|
* Notes (engine intent):
|
|
13
31
|
* - This rule is DOM-based and measures pointer hit regions available to sighted pointer users.
|
|
@@ -19,38 +37,43 @@
|
|
|
19
37
|
* - Spacing: a 24px-diameter circle centered on an undersized target must not
|
|
20
38
|
* intersect another (unrelated) target's box or another undersized
|
|
21
39
|
* target's own circle. Two passes: a fast center-distance check (exact for
|
|
22
|
-
* undersized-vs-undersized
|
|
40
|
+
* undersized-vs-undersized, a reasonable proxy otherwise) and a 16-point
|
|
23
41
|
* perimeter sample via elementFromPoint as a more precise fallback for
|
|
24
42
|
* cases the distance check under-detects (e.g. a small target adjacent to
|
|
25
43
|
* a large, elongated neighbor). Ancestor/descendant relationships between
|
|
26
|
-
* the target and the "other" element are never treated as a conflict
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* - Inline: a link inside a
|
|
44
|
+
* the target and the "other" element are never treated as a conflict (see
|
|
45
|
+
* isRelated): a nested-interactive shape, a small control inside its own
|
|
46
|
+
* wrapping link/button, is one visual region, not two independent targets.
|
|
47
|
+
* That pattern is nested-interactive-controls-absent's concern, not a
|
|
48
|
+
* spacing one.
|
|
49
|
+
* - Inline: a link inside a text-block container passes outright
|
|
50
|
+
* (isInlineTextExceptionTarget). An inline link whose only spacing conflict
|
|
51
|
+
* is another inline link in the same run is reported as cantTell
|
|
52
|
+
* (isInlineLinkTarget), since the inline exception may cover it but
|
|
53
|
+
* geometry can't confirm that.
|
|
32
54
|
* - User Agent Control: an unstyled native checkbox/radio, detected via
|
|
33
|
-
* `appearance` not being reset to `none` (see isUserAgentSizedControl)
|
|
34
|
-
*
|
|
55
|
+
* `appearance` not being reset to `none` (see isUserAgentSizedControl).
|
|
56
|
+
* Scoped narrowly to checkbox/radio specifically, not every form control,
|
|
35
57
|
* since those are the only types with unambiguous native rendering.
|
|
36
58
|
* - Essential/Equivalent: only a narrow, high-confidence subset is asserted
|
|
37
|
-
* (SVG/canvas/map-embedded controls
|
|
59
|
+
* (SVG/canvas/map-embedded controls, see isPlausiblyEssentialOrEquivalent);
|
|
38
60
|
* anything else defers to cantTell rather than guessing "essential" from a
|
|
39
61
|
* layout container.
|
|
40
62
|
*
|
|
41
|
-
* Known,
|
|
63
|
+
* Known gap, left unimplemented on purpose: `<area>` (image-map hotspot)
|
|
42
64
|
* elements are not evaluated at all. `area[href]` is in CANDIDATE_SELECTOR
|
|
43
|
-
* for forward-compatibility, but it's currently a no-op
|
|
65
|
+
* for forward-compatibility, but it's currently a no-op. `<area>` has no
|
|
44
66
|
* CSS box of its own (`display: none` by the HTML spec's default UA
|
|
45
|
-
* stylesheet
|
|
46
|
-
* always reports zero geometry and
|
|
47
|
-
* `display:none` check rejects it before any
|
|
48
|
-
* real `<area>` hit-region is computed by the
|
|
49
|
-
* `coords` attributes against the associated
|
|
50
|
-
* an entirely different measurement path than
|
|
51
|
-
* Implementing that properly (parsing `coords`,
|
|
52
|
-
* `<img>` via its `usemap`, accounting for the image's
|
|
53
|
-
* size) is a separate, larger feature, not attempted in
|
|
67
|
+
* stylesheet, confirmed against the spec rather than a jsdom quirk), so
|
|
68
|
+
* `getBoundingClientRect()` always reports zero geometry and
|
|
69
|
+
* `isPointerReachable`'s existing `display:none` check rejects it before any
|
|
70
|
+
* size/exception logic runs. A real `<area>` hit-region is computed by the
|
|
71
|
+
* browser from its `shape`/`coords` attributes against the associated
|
|
72
|
+
* `<img>`'s *rendered* size, an entirely different measurement path than
|
|
73
|
+
* every other candidate here. Implementing that properly (parsing `coords`,
|
|
74
|
+
* resolving the owning `<img>` via its `usemap`, accounting for the image's
|
|
75
|
+
* CSS-scaled render size) is a separate, larger feature, not attempted in
|
|
76
|
+
* this pass.
|
|
54
77
|
*
|
|
55
78
|
* This is an automatic, deterministic approximation intended to be:
|
|
56
79
|
* - strict on clear failures,
|
|
@@ -60,7 +83,8 @@
|
|
|
60
83
|
const id = 'target-size-minimum';
|
|
61
84
|
|
|
62
85
|
const meta = {
|
|
63
|
-
title:
|
|
86
|
+
title:
|
|
87
|
+
'Pointer targets must be at least 24x24px large, or leave sufficient distance to other targets',
|
|
64
88
|
description:
|
|
65
89
|
'Checks that pointer-operable targets have an effective hit region of at least 24 by 24 CSS pixels, or meet an allowed exception (e.g. sufficient spacing).',
|
|
66
90
|
i18n: {
|
|
@@ -147,23 +171,10 @@ function runInPage(ctx) {
|
|
|
147
171
|
return 'html';
|
|
148
172
|
}
|
|
149
173
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
} catch {}
|
|
155
|
-
try {
|
|
156
|
-
return el && el.outerHTML ? String(el.outerHTML) : '';
|
|
157
|
-
} catch {}
|
|
158
|
-
return '';
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function isInlineTextExceptionTarget(el) {
|
|
162
|
-
// SC 2.5.8 exception: target is in a sentence/block of text (inline)
|
|
163
|
-
// Deterministic heuristic:
|
|
164
|
-
// - must be a link-like target
|
|
165
|
-
// - must be rendered as inline/inline-*
|
|
166
|
-
// - must be inside a typical text container
|
|
174
|
+
// A link-like target (a[href] or role="link") rendered inline/inline-* and
|
|
175
|
+
// carrying visible text. This is the shape the SC 2.5.8 inline exception is
|
|
176
|
+
// about, without the surrounding-container requirement.
|
|
177
|
+
function isInlineLinkTarget(el) {
|
|
167
178
|
try {
|
|
168
179
|
if (!el || el.nodeType !== 1) return false;
|
|
169
180
|
|
|
@@ -180,27 +191,31 @@ function runInPage(ctx) {
|
|
|
180
191
|
|
|
181
192
|
const cs = getStyle(el);
|
|
182
193
|
const display = cs && cs.display ? String(cs.display) : '';
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
// Inline exception is for inline text runs; many design systems use inline-block for links,
|
|
186
|
-
// so treat inline-block variants as eligible for this exception.
|
|
194
|
+
// Many design systems use inline-block for links, so treat the
|
|
195
|
+
// inline-block variants as inline here too.
|
|
187
196
|
const isInline =
|
|
188
197
|
display === 'inline' ||
|
|
189
198
|
display === 'inline-block' ||
|
|
190
199
|
display === 'inline-flex' ||
|
|
191
200
|
display === 'inline-grid' ||
|
|
192
201
|
display === 'inline-table';
|
|
193
|
-
|
|
194
202
|
if (!isInline) return false;
|
|
195
203
|
|
|
196
|
-
|
|
197
|
-
|
|
204
|
+
return (el.textContent || '').trim().length > 0;
|
|
205
|
+
} catch {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function isInlineTextExceptionTarget(el) {
|
|
211
|
+
// SC 2.5.8 exception: an inline link that sits inside a run of text.
|
|
212
|
+
try {
|
|
213
|
+
if (!isInlineLinkTarget(el)) return false;
|
|
198
214
|
|
|
199
|
-
// Require that it sits in a "text block" context
|
|
200
215
|
const textContainer = closest(el, 'p, li, dd, dt, blockquote, figcaption, caption, td, th');
|
|
201
216
|
if (textContainer) return true;
|
|
202
217
|
|
|
203
|
-
//
|
|
218
|
+
// Links are often wrapped in an inline element inside the text block.
|
|
204
219
|
const inlineContainer = closest(el, 'span, em, strong, small, label');
|
|
205
220
|
if (inlineContainer) {
|
|
206
221
|
const outerTextBlock = closest(
|
|
@@ -359,11 +374,11 @@ function runInPage(ctx) {
|
|
|
359
374
|
// element, or either one is an ancestor of the other. A nested-interactive
|
|
360
375
|
// pattern (e.g. a small <button> inside a wrapping <a href>, or vice
|
|
361
376
|
// versa) is a single visual/interactive region, not two independently
|
|
362
|
-
// placed targets
|
|
377
|
+
// placed targets. The spacing exception's "does the circle intersect
|
|
363
378
|
// ANOTHER target" language is about separate targets, not an element and
|
|
364
379
|
// its own container. (Nested interactive controls are their own,
|
|
365
|
-
// separately-flagged anti-pattern
|
|
366
|
-
//
|
|
380
|
+
// separately-flagged anti-pattern, nested-interactive-controls-absent,
|
|
381
|
+
// not a target-size spacing concern.)
|
|
367
382
|
function isRelated(a, b) {
|
|
368
383
|
try {
|
|
369
384
|
if (!a || !b) return false;
|
|
@@ -420,7 +435,7 @@ function runInPage(ctx) {
|
|
|
420
435
|
function hasSpacingConflict(target) {
|
|
421
436
|
// 0) Pure geometry: deterministic center-distance check against ANY
|
|
422
437
|
// nearby target, not just other undersized ones. Per WCAG 2.5.8, the
|
|
423
|
-
// spacing exception depends on proximity to any adjacent target
|
|
438
|
+
// spacing exception depends on proximity to any adjacent target, so an
|
|
424
439
|
// undersized target sitting flush against an adequately-sized one still
|
|
425
440
|
// fails the exception, which an undersized-only comparison would miss.
|
|
426
441
|
for (const other of items) {
|
|
@@ -444,8 +459,8 @@ function runInPage(ctx) {
|
|
|
444
459
|
// as a confident conflict. Perimeter sampling is an approximation
|
|
445
460
|
// (rounded corners, border-radius, and sub-pixel geometry can shift a
|
|
446
461
|
// sample point in or out of a neighboring element), so a result that
|
|
447
|
-
// merely reaches HIT_THRESHOLD is not asserted as a deterministic
|
|
448
|
-
//
|
|
462
|
+
// merely reaches HIT_THRESHOLD is not asserted as a deterministic fail.
|
|
463
|
+
// See the ambiguous band below.
|
|
449
464
|
const CONFIDENT_THRESHOLD = 5;
|
|
450
465
|
let hitCount = 0;
|
|
451
466
|
let firstConflictEl = null;
|
|
@@ -491,20 +506,20 @@ function runInPage(ctx) {
|
|
|
491
506
|
|
|
492
507
|
// WCAG 2.5.8 "User Agent Control" exception: the target's size requirement
|
|
493
508
|
// does not apply at all when its size is determined by the user agent and
|
|
494
|
-
// not modified by the author
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
// input[type=checkbox]/[type=radio] specifically
|
|
498
|
-
// types with a universally-recognized, unambiguous native rendering
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
//
|
|
509
|
+
// not modified by the author. The canonical example is an unstyled native
|
|
510
|
+
// checkbox/radio (browsers render these well under 24px by default, and
|
|
511
|
+
// that's not the author's choice). Scoped narrowly to
|
|
512
|
+
// input[type=checkbox]/[type=radio] specifically, the only form-control
|
|
513
|
+
// types with a universally-recognized, unambiguous native rendering, and
|
|
514
|
+
// not extended to select/range/color/file, whose "default" sizing varies
|
|
515
|
+
// enough across browsers/OSes that a wrong exemption there risks masking a
|
|
516
|
+
// real author-introduced undersized target.
|
|
502
517
|
//
|
|
503
518
|
// Detection signal: `appearance` (or the legacy `-webkit-appearance`)
|
|
504
519
|
// computed as `none` is the near-universal first step of custom
|
|
505
|
-
// checkbox/radio styling across every CSS framework/design system
|
|
506
|
-
//
|
|
507
|
-
//
|
|
520
|
+
// checkbox/radio styling across every CSS framework/design system. If the
|
|
521
|
+
// author hasn't reset it, the browser is still rendering its own default
|
|
522
|
+
// control chrome, so the size is UA-determined rather than authored.
|
|
508
523
|
function isUserAgentSizedControl(el) {
|
|
509
524
|
try {
|
|
510
525
|
if (!el || el.nodeType !== 1) return false;
|
|
@@ -550,8 +565,8 @@ function runInPage(ctx) {
|
|
|
550
565
|
// Image map targets are often constrained by the underlying image.
|
|
551
566
|
// Currently unreachable in practice: <area> never becomes a
|
|
552
567
|
// measurable candidate at all (see the file header's "Known,
|
|
553
|
-
//
|
|
554
|
-
//
|
|
568
|
+
// unimplemented gap" note). Kept for forward compatibility if that
|
|
569
|
+
// gap is closed later.
|
|
555
570
|
if (tag === 'area') return true;
|
|
556
571
|
|
|
557
572
|
// Graphics / spatial interaction regions are commonly essential by design.
|
|
@@ -575,7 +590,7 @@ function runInPage(ctx) {
|
|
|
575
590
|
|
|
576
591
|
// User Agent Control exception: size isn't the author's choice, so the
|
|
577
592
|
// size requirement (and therefore any spacing conflict stemming from
|
|
578
|
-
// it) doesn't apply at all
|
|
593
|
+
// it) doesn't apply at all. Skip straight to pass, no need to even
|
|
579
594
|
// evaluate spacing.
|
|
580
595
|
if (isUserAgentSizedControl(it.el)) {
|
|
581
596
|
continue;
|
|
@@ -584,91 +599,117 @@ function runInPage(ctx) {
|
|
|
584
599
|
const info = hasSpacingConflict(it);
|
|
585
600
|
|
|
586
601
|
if (!info.conflict && info.confident === false) {
|
|
587
|
-
// Ambiguous perimeter-sampling result near the decision threshold
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
//
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
conflictHitCount: info.hitCount,
|
|
610
|
-
conflictWith: info.conflictEl ? buildSelector(info.conflictEl) : null
|
|
602
|
+
// Ambiguous perimeter-sampling result near the decision threshold: report
|
|
603
|
+
// it as its own cantTell-tier occurrence for this target so it isn't lost
|
|
604
|
+
// once any other target on the page has a confident fail (see
|
|
605
|
+
// helpers.resolveTieredOutcome's header comment).
|
|
606
|
+
cantTellOccurrences.push(
|
|
607
|
+
helpers.reportOccurrence(it.el, {
|
|
608
|
+
occurrenceOutcome: 'cantTell',
|
|
609
|
+
summary:
|
|
610
|
+
'Target may be too small and too close to another target, but the overlap is near the detection threshold and could not be confidently measured.',
|
|
611
|
+
hint: 'Manually verify the effective spacing between this target and its neighbor; increase target size or spacing if the overlap is real.',
|
|
612
|
+
i18n: {
|
|
613
|
+
summaryKey: 'targetSizeMinimum_summary_cantTell_ambiguousSpacing',
|
|
614
|
+
hintKey: 'targetSizeMinimum_hint_cantTell_ambiguousSpacing',
|
|
615
|
+
params: {}
|
|
616
|
+
},
|
|
617
|
+
data: {
|
|
618
|
+
details: {
|
|
619
|
+
measured: { width: it.rect.width, height: it.rect.height },
|
|
620
|
+
reasonCode: 'undersized-ambiguous-spacing',
|
|
621
|
+
conflictHitCount: info.hitCount,
|
|
622
|
+
conflictWith: info.conflictEl ? buildSelector(info.conflictEl) : null
|
|
623
|
+
}
|
|
611
624
|
}
|
|
612
|
-
}
|
|
613
|
-
|
|
625
|
+
})
|
|
626
|
+
);
|
|
614
627
|
continue;
|
|
615
628
|
}
|
|
616
629
|
|
|
617
630
|
if (info.conflict) {
|
|
618
631
|
if (isPlausiblyEssentialOrEquivalent(it.el)) {
|
|
619
632
|
// Confident spacing conflict, but the target may be exempt as part
|
|
620
|
-
// of an essential graphic/image-map region
|
|
621
|
-
//
|
|
622
|
-
cantTellOccurrences.push(
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
'
|
|
628
|
-
|
|
633
|
+
// of an essential graphic/image-map region, so report it as
|
|
634
|
+
// cantTell rather than dropping it.
|
|
635
|
+
cantTellOccurrences.push(
|
|
636
|
+
helpers.reportOccurrence(it.el, {
|
|
637
|
+
occurrenceOutcome: 'cantTell',
|
|
638
|
+
summary:
|
|
639
|
+
'Target is too small and too close to another target, but may be exempt as part of an essential graphic or image-map region.',
|
|
640
|
+
hint: 'Verify whether this target’s size is essential to its function (e.g. part of an SVG/canvas/image map); if not, increase target size or spacing.',
|
|
641
|
+
i18n: {
|
|
642
|
+
summaryKey: 'targetSizeMinimum_summary_cantTell_plausiblyEssential',
|
|
643
|
+
hintKey: 'targetSizeMinimum_hint_cantTell_plausiblyEssential',
|
|
644
|
+
params: {}
|
|
645
|
+
},
|
|
646
|
+
data: {
|
|
647
|
+
details: {
|
|
648
|
+
measured: { width: it.rect.width, height: it.rect.height },
|
|
649
|
+
reasonCode: 'undersized-plausibly-essential',
|
|
650
|
+
conflictHitCount: info.hitCount,
|
|
651
|
+
conflictWith: info.conflictEl ? buildSelector(info.conflictEl) : null
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
})
|
|
655
|
+
);
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// This target and its conflicting neighbor are both inline links in the
|
|
660
|
+
// same text run, where the SC 2.5.8 inline exception may apply. That
|
|
661
|
+
// can't be decided from geometry alone, so defer to manual review.
|
|
662
|
+
if (isInlineLinkTarget(it.el) && info.conflictEl && isInlineLinkTarget(info.conflictEl)) {
|
|
663
|
+
cantTellOccurrences.push(
|
|
664
|
+
helpers.reportOccurrence(it.el, {
|
|
665
|
+
occurrenceOutcome: 'cantTell',
|
|
666
|
+
summary:
|
|
667
|
+
'Target is smaller than 24×24 CSS px and close to another inline link in the same run of text, where the inline exception may apply.',
|
|
668
|
+
hint: 'Confirm whether these links form a run of inline text (which is exempt); otherwise increase the target size to at least 24×24 CSS px or add spacing.',
|
|
669
|
+
i18n: {
|
|
670
|
+
summaryKey: 'targetSizeMinimum_summary_cantTell_inlineLinkRun',
|
|
671
|
+
hintKey: 'targetSizeMinimum_hint_cantTell_inlineLinkRun',
|
|
672
|
+
params: {}
|
|
673
|
+
},
|
|
674
|
+
data: {
|
|
675
|
+
details: {
|
|
676
|
+
measured: { width: it.rect.width, height: it.rect.height },
|
|
677
|
+
reasonCode: 'undersized-inline-link-run',
|
|
678
|
+
conflictHitCount: info.hitCount,
|
|
679
|
+
conflictWith: info.conflictEl ? buildSelector(info.conflictEl) : null
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
})
|
|
683
|
+
);
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
failOccurrences.push(
|
|
688
|
+
helpers.reportOccurrence(it.el, {
|
|
689
|
+
occurrenceOutcome: 'fail',
|
|
690
|
+
summary: 'Target is too small and too close to another target.',
|
|
691
|
+
hint: 'Increase target size to at least 24 by 24 CSS pixels, or add sufficient spacing.',
|
|
629
692
|
i18n: {
|
|
630
|
-
summaryKey: '
|
|
631
|
-
hintKey: '
|
|
693
|
+
summaryKey: 'targetSizeMinimum_summary_fail',
|
|
694
|
+
hintKey: 'targetSizeMinimum_hint_fail',
|
|
632
695
|
params: {}
|
|
633
696
|
},
|
|
634
697
|
data: {
|
|
635
698
|
details: {
|
|
636
699
|
measured: { width: it.rect.width, height: it.rect.height },
|
|
637
|
-
reasonCode: 'undersized-
|
|
700
|
+
reasonCode: 'undersized-and-too-close',
|
|
638
701
|
conflictHitCount: info.hitCount,
|
|
639
702
|
conflictWith: info.conflictEl ? buildSelector(info.conflictEl) : null
|
|
640
703
|
}
|
|
641
704
|
}
|
|
642
|
-
})
|
|
643
|
-
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
failOccurrences.push({
|
|
647
|
-
selector: buildSelector(it.el),
|
|
648
|
-
html: htmlSnippet(it.el),
|
|
649
|
-
occurrenceOutcome: 'fail',
|
|
650
|
-
summary: 'Target is too small and too close to another target.',
|
|
651
|
-
hint: 'Increase target size to at least 24 by 24 CSS pixels, or add sufficient spacing.',
|
|
652
|
-
i18n: {
|
|
653
|
-
summaryKey: 'targetSizeMinimum_summary_fail',
|
|
654
|
-
hintKey: 'targetSizeMinimum_hint_fail',
|
|
655
|
-
params: {}
|
|
656
|
-
},
|
|
657
|
-
data: {
|
|
658
|
-
details: {
|
|
659
|
-
measured: { width: it.rect.width, height: it.rect.height },
|
|
660
|
-
reasonCode: 'undersized-and-too-close',
|
|
661
|
-
conflictHitCount: info.hitCount,
|
|
662
|
-
conflictWith: info.conflictEl ? buildSelector(info.conflictEl) : null
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
});
|
|
705
|
+
})
|
|
706
|
+
);
|
|
666
707
|
}
|
|
667
708
|
}
|
|
668
709
|
|
|
669
710
|
// See helpers.resolveTieredOutcome's own header comment (src/core/dom-helpers.js):
|
|
670
711
|
// a fail-tier finding never silently discards cantTell-tier findings from
|
|
671
|
-
// the same run
|
|
712
|
+
// the same run. Both are returned together when the outcome is 'fail'.
|
|
672
713
|
const resolved = helpers.resolveTieredOutcome(
|
|
673
714
|
failOccurrences,
|
|
674
715
|
cantTellOccurrences,
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* @applicability
|
|
12
12
|
* `<table>` elements with at least 4 rows and at least 4 columns
|
|
13
13
|
* (a "large" table, where implicit row/column header association is
|
|
14
|
-
*
|
|
14
|
+
* useful; small tables are usually self-evident), and with
|
|
15
15
|
* NO `colspan`/`rowspan` anywhere in the table.
|
|
16
16
|
* @expectation
|
|
17
17
|
* Every `<td>` has an associated header, via one of:
|
|
@@ -22,14 +22,14 @@
|
|
|
22
22
|
* earlier row, OR
|
|
23
23
|
* - an implicit row header: some `<th>` earlier in the same row.
|
|
24
24
|
* @implementation-notes
|
|
25
|
-
* - Closes the gap `table-th-has-data-cells`
|
|
25
|
+
* - Closes the gap `table-th-has-data-cells` deferred (see
|
|
26
26
|
* that rule's own implementation notes): this is the fuller positional
|
|
27
|
-
* header-association algorithm, but still intentionally scoped
|
|
27
|
+
* header-association algorithm, but still intentionally scoped,
|
|
28
28
|
* tables with any `colspan`/`rowspan` are skipped entirely (marked
|
|
29
29
|
* `notApplicable`) rather than risk a wrong column-index computation
|
|
30
30
|
* producing a false `fail`.
|
|
31
31
|
* - The `headers`-attribute branch does not itself validate that the
|
|
32
|
-
* referenced ids exist or point at `<th>` elements
|
|
32
|
+
* referenced ids exist or point at `<th>` elements, that's already
|
|
33
33
|
* `table-headers-attr-valid`'s job.
|
|
34
34
|
*/
|
|
35
35
|
|
|
@@ -38,7 +38,7 @@ const id = 'td-has-header';
|
|
|
38
38
|
const meta = {
|
|
39
39
|
title: 'Data cells in large tables must have an associated header',
|
|
40
40
|
description:
|
|
41
|
-
'Checks that every <td> in a large, simple (no colspan/rowspan) table has an associated header
|
|
41
|
+
'Checks that every <td> in a large, simple (no colspan/rowspan) table has an associated header, via a headers attribute, an implicit column <th> above it, or an implicit row <th> to its left.',
|
|
42
42
|
i18n: {
|
|
43
43
|
titleKey: 'tdHasHeader_title',
|
|
44
44
|
descriptionKey: 'tdHasHeader_description'
|
|
@@ -150,26 +150,21 @@ function runInPage(ctx) {
|
|
|
150
150
|
if (hasColumnHeaderAbove(r, c)) continue;
|
|
151
151
|
if (hasRowHeaderBefore(r, c)) continue;
|
|
152
152
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
},
|
|
169
|
-
data: {
|
|
170
|
-
details: { reasonCode: 'TD_NO_ASSOCIATED_HEADER', row: r, column: c }
|
|
171
|
-
}
|
|
172
|
-
});
|
|
153
|
+
occurrences.push(
|
|
154
|
+
helpers.reportOccurrence(cell, {
|
|
155
|
+
summary:
|
|
156
|
+
'This data cell has no associated header (no headers attribute, no column <th> above it, no row <th> to its left).',
|
|
157
|
+
hint: 'Add a headers attribute referencing the relevant <th> id(s), or restructure the table so this cell has an implicit row/column header.',
|
|
158
|
+
i18n: {
|
|
159
|
+
summaryKey: 'tdHasHeader_summary_fail',
|
|
160
|
+
hintKey: 'tdHasHeader_hint_fail',
|
|
161
|
+
params: { row: String(r), column: String(c) }
|
|
162
|
+
},
|
|
163
|
+
data: {
|
|
164
|
+
details: { reasonCode: 'TD_NO_ASSOCIATED_HEADER', row: r, column: c }
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
);
|
|
173
168
|
}
|
|
174
169
|
}
|
|
175
170
|
}
|
|
@@ -2,11 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
'use strict';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* @check textbox-name-present
|
|
7
|
+
* @atomic true
|
|
8
|
+
* @summary Elements with role="textbox" must have an accessible name
|
|
9
|
+
* @standard WCAG 2.2
|
|
10
|
+
* @sc 4.1.2
|
|
11
|
+
* @applicability
|
|
12
|
+
* Applies to elements carrying role="textbox" (the attribute must name
|
|
13
|
+
* that role alone, not a fallback list) that are included in the
|
|
14
|
+
* accessibility tree. An element with the matching implicit role but no
|
|
15
|
+
* role attribute is out of scope.
|
|
16
|
+
* @expectation
|
|
17
|
+
* The element has a non-empty accessible name from aria-label, from an
|
|
18
|
+
* aria-labelledby that resolves to non-empty text, or from title.
|
|
19
|
+
* role="textbox" is name-from-author-only, so subtree text is never
|
|
20
|
+
* accepted: text sitting inside a custom textbox widget is not reliably
|
|
21
|
+
* exposed as its name. On a labelable element (<input role="textbox">) an
|
|
22
|
+
* associated <label> counts as well.
|
|
23
|
+
*/
|
|
24
|
+
|
|
5
25
|
const id = 'textbox-name-present';
|
|
6
26
|
|
|
7
27
|
const meta = {
|
|
8
|
-
title: '
|
|
9
|
-
description: 'Checks that elements expose a non-empty accessible name.',
|
|
28
|
+
title: 'Textboxes have an accessible name',
|
|
29
|
+
description: 'Checks that elements with role="textbox" expose a non-empty accessible name.',
|
|
10
30
|
i18n: {
|
|
11
31
|
titleKey: 'textboxNamePresent_title',
|
|
12
32
|
descriptionKey: 'textboxNamePresent_description'
|
|
@@ -51,7 +71,7 @@ function runInPage(ctx) {
|
|
|
51
71
|
}
|
|
52
72
|
|
|
53
73
|
function getConservativeSubtreeText(document, container) {
|
|
54
|
-
// "Name from content"
|
|
74
|
+
// "Name from content", recurses into descendants and uses each one's
|
|
55
75
|
// own accessible name (img alt, aria-label/aria-labelledby, title) when
|
|
56
76
|
// it has one, not just literal text nodes. See getContentNameInfo's
|
|
57
77
|
// header comment in src/core/dom-helpers.js for the full rationale
|
|
@@ -68,7 +88,7 @@ function runInPage(ctx) {
|
|
|
68
88
|
|
|
69
89
|
// A <label> contributes a name via its own aria-label/aria-labelledby
|
|
70
90
|
// (checked first, same ARIA-over-content precedence any element's
|
|
71
|
-
// accessible name gives
|
|
91
|
+
// accessible name gives, e.g. <label aria-label="Search"><svg
|
|
72
92
|
// aria-hidden="true">...</svg></label> names its control "Search" even
|
|
73
93
|
// though the label's only child content is aria-hidden) or, failing
|
|
74
94
|
// that, its rendered content (getConservativeSubtreeText).
|
|
@@ -83,10 +103,10 @@ function runInPage(ctx) {
|
|
|
83
103
|
if (content) return content;
|
|
84
104
|
// Final fallback per the general accname text-alternative algorithm,
|
|
85
105
|
// which applies to any element being asked for its name regardless of
|
|
86
|
-
// why (own aria-label, an aria-labelledby reference, or
|
|
106
|
+
// why (own aria-label, an aria-labelledby reference, or, here, native
|
|
87
107
|
// <label for> association): title, when nothing else yields a name.
|
|
88
108
|
// Purely additive (only fills in a name where there was none before),
|
|
89
|
-
// so it carries no false-positive risk
|
|
109
|
+
// so it carries no false-positive risk, see dialog-name-present.js's
|
|
90
110
|
// identical <iframe>-title-fallback fix for the concrete real-world
|
|
91
111
|
// trigger this same accname step covers elsewhere.
|
|
92
112
|
return getAttr(lab, 'title');
|
|
@@ -96,7 +116,7 @@ function runInPage(ctx) {
|
|
|
96
116
|
const raw = getAttr(el, 'aria-labelledby');
|
|
97
117
|
if (!raw) return '';
|
|
98
118
|
// Delegates to the shared getTextFromIdRefs helper instead of computing
|
|
99
|
-
// name-from-content of the referenced element
|
|
119
|
+
// name-from-content of the referenced element, see dialog-name-
|
|
100
120
|
// present.js's identical fix for the full rationale (an <iframe>
|
|
101
121
|
// aria-labelledby target's only name source is its title attribute,
|
|
102
122
|
// which name-from-content alone can never see).
|
|
@@ -227,24 +247,22 @@ function runInPage(ctx) {
|
|
|
227
247
|
}
|
|
228
248
|
})()
|
|
229
249
|
: null;
|
|
230
|
-
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
231
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
232
250
|
|
|
233
|
-
occurrences.push(
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
}
|
|
247
|
-
|
|
251
|
+
occurrences.push(
|
|
252
|
+
helpers.reportOccurrence(el, {
|
|
253
|
+
summary: 'This element has no accessible name.',
|
|
254
|
+
hint: "Provide aria-label, aria-labelledby, or a title attribute. Visible text content is not exposed as this textbox's accessible name.",
|
|
255
|
+
i18n: {
|
|
256
|
+
summaryKey: 'textboxNamePresent_summary_fail',
|
|
257
|
+
hintKey: 'textboxNamePresent_hint_fail',
|
|
258
|
+
params: { controlType: 'textbox' }
|
|
259
|
+
},
|
|
260
|
+
data: {
|
|
261
|
+
details: { reasonCode: 'name_missing', controlType: 'textbox', methodTried: res.method },
|
|
262
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
|
|
263
|
+
}
|
|
264
|
+
})
|
|
265
|
+
);
|
|
248
266
|
}
|
|
249
267
|
|
|
250
268
|
if (applicableCount === 0) {
|