@surea11y/core 1.0.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 +49 -0
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/bin/core.js +244 -0
- package/docs/BINDING_AUTHORS_GUIDE.md +41 -0
- package/docs/CLI.md +49 -0
- package/docs/ENGINE_OPTIONS.md +155 -0
- package/docs/I18N.md +47 -0
- package/docs/INTEGRATION.md +156 -0
- package/docs/LIMITATIONS.md +31 -0
- package/docs/OUTPUT_SCHEMA.md +237 -0
- package/docs/POLICY.md +71 -0
- package/docs/RULE_AUTHORING.md +375 -0
- package/docs/RULE_CATALOG.md +180 -0
- package/docs/RULE_TAXONOMY.md +145 -0
- package/docs/TROUBLESHOOTING.md +48 -0
- package/docs/WCAG_CONFORMANCE.md +49 -0
- package/package.json +60 -0
- package/src/catalogs/composites.wcag.js +490 -0
- package/src/checks/automatic/area-alt-present.js +225 -0
- package/src/checks/automatic/aria-allowed-attr.js +206 -0
- package/src/checks/automatic/aria-allowed-role.js +102 -0
- package/src/checks/automatic/aria-braille-equivalent.js +139 -0
- package/src/checks/automatic/aria-conditional-attr.js +110 -0
- package/src/checks/automatic/aria-deprecated-role.js +106 -0
- package/src/checks/automatic/aria-hidden-body.js +87 -0
- package/src/checks/automatic/aria-hidden-focus.js +480 -0
- package/src/checks/automatic/aria-prohibited-attr.js +156 -0
- package/src/checks/automatic/aria-prohibited-children.js +265 -0
- package/src/checks/automatic/aria-required-attr.js +154 -0
- package/src/checks/automatic/aria-required-children.js +274 -0
- package/src/checks/automatic/aria-required-parent.js +222 -0
- package/src/checks/automatic/aria-role-name-present.js +201 -0
- package/src/checks/automatic/aria-roles-valid.js +110 -0
- package/src/checks/automatic/aria-valid-attr-value.js +123 -0
- package/src/checks/automatic/aria-valid-attr.js +109 -0
- package/src/checks/automatic/autocomplete-valid.js +134 -0
- package/src/checks/automatic/avoid-inline-spacing.js +107 -0
- package/src/checks/automatic/binary-control-name-present.js +294 -0
- package/src/checks/automatic/button-name-present.js +146 -0
- package/src/checks/automatic/bypass-blocks-present.js +162 -0
- package/src/checks/automatic/canvas-text-alternative-present.js +140 -0
- package/src/checks/automatic/combobox-name-present.js +267 -0
- package/src/checks/automatic/contrast-computable.js +378 -0
- package/src/checks/automatic/contrast-enhanced.js +517 -0
- package/src/checks/automatic/contrast-minimum.js +512 -0
- package/src/checks/automatic/css-orientation-lock.js +206 -0
- package/src/checks/automatic/definition-list-children-valid.js +148 -0
- package/src/checks/automatic/deprecated-elements-not-used.js +91 -0
- package/src/checks/automatic/dialog-name-present.js +209 -0
- package/src/checks/automatic/dlitem-parent-valid.js +100 -0
- package/src/checks/automatic/duplicate-id-aria.js +126 -0
- package/src/checks/automatic/embed-text-alternative-present.js +190 -0
- package/src/checks/automatic/form-control-programmatic-label-present.js +409 -0
- package/src/checks/automatic/form-control-single-label.js +117 -0
- package/src/checks/automatic/html-xml-lang-mismatch.js +91 -0
- package/src/checks/automatic/iframe-focusable-content.js +141 -0
- package/src/checks/automatic/iframe-name-present.js +102 -0
- package/src/checks/automatic/iframe-title-unique.js +107 -0
- package/src/checks/automatic/img-alt-present.js +223 -0
- package/src/checks/automatic/input-image-alt-present.js +155 -0
- package/src/checks/automatic/label-in-name.js +326 -0
- package/src/checks/automatic/language-page-present.js +159 -0
- package/src/checks/automatic/link-in-text-block.js +218 -0
- package/src/checks/automatic/link-name-present.js +114 -0
- package/src/checks/automatic/list-children-valid.js +152 -0
- package/src/checks/automatic/listbox-name-present.js +236 -0
- package/src/checks/automatic/listitem-parent-valid.js +118 -0
- package/src/checks/automatic/menuitem-name-present.js +201 -0
- package/src/checks/automatic/meta-refresh-no-exceptions.js +105 -0
- package/src/checks/automatic/meta-refresh-timing-absent.js +107 -0
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +118 -0
- package/src/checks/automatic/meter-name-present.js +160 -0
- package/src/checks/automatic/nested-interactive-controls-absent.js +135 -0
- package/src/checks/automatic/object-text-alternative-present.js +193 -0
- package/src/checks/automatic/option-name-present.js +157 -0
- package/src/checks/automatic/page-title-present.js +86 -0
- package/src/checks/automatic/progressbar-name-present.js +165 -0
- package/src/checks/automatic/role-img-alt-present.js +206 -0
- package/src/checks/automatic/searchbox-name-present.js +236 -0
- package/src/checks/automatic/server-side-image-map-absent.js +88 -0
- package/src/checks/automatic/slider-name-present.js +276 -0
- package/src/checks/automatic/spinbutton-name-present.js +236 -0
- package/src/checks/automatic/summary-name-present.js +153 -0
- package/src/checks/automatic/svg-image-text-alternative-present.js +220 -0
- package/src/checks/automatic/svg-text-alternative-present.js +298 -0
- package/src/checks/automatic/tab-name-present.js +200 -0
- package/src/checks/automatic/table-headers-attr-valid.js +122 -0
- package/src/checks/automatic/table-th-has-data-cells.js +117 -0
- package/src/checks/automatic/target-size-minimum.js +605 -0
- package/src/checks/automatic/td-has-header.js +151 -0
- package/src/checks/automatic/textbox-name-present.js +236 -0
- package/src/checks/automatic/tooltip-name-present.js +158 -0
- package/src/checks/automatic/treeitem-name-present.js +157 -0
- package/src/checks/automatic/valid-lang.js +100 -0
- package/src/checks/automatic/video-poster-text-alternative-present.js +193 -0
- package/src/checks/manual/accesskeys-manual.js +93 -0
- package/src/checks/manual/area-alt-decorative-manual.js +247 -0
- package/src/checks/manual/area-alt-quality-manual.js +204 -0
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +141 -0
- package/src/checks/manual/aria-text-manual.js +109 -0
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +170 -0
- package/src/checks/manual/css-hidden-focus.js +259 -0
- package/src/checks/manual/embed-text-alternative-quality-manual.js +204 -0
- package/src/checks/manual/empty-heading-manual.js +182 -0
- package/src/checks/manual/empty-table-header-manual.js +163 -0
- package/src/checks/manual/focus-order-semantics-manual.js +117 -0
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +291 -0
- package/src/checks/manual/heading-order-manual.js +130 -0
- package/src/checks/manual/identical-links-same-purpose-manual.js +142 -0
- package/src/checks/manual/image-redundant-alt-manual.js +118 -0
- package/src/checks/manual/img-alt-decorative-manual.js +148 -0
- package/src/checks/manual/img-alt-quality-manual.js +182 -0
- package/src/checks/manual/input-image-alt-decorative-manual.js +144 -0
- package/src/checks/manual/input-image-alt-quality-manual.js +144 -0
- package/src/checks/manual/label-title-only-manual.js +115 -0
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +180 -0
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +169 -0
- package/src/checks/manual/landmark-main-is-top-level-manual.js +167 -0
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +177 -0
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +169 -0
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +132 -0
- package/src/checks/manual/landmark-one-main-manual.js +151 -0
- package/src/checks/manual/landmark-unique-manual.js +252 -0
- package/src/checks/manual/link-name-quality-manual.js +143 -0
- package/src/checks/manual/media-transcript-present-manual.js +373 -0
- package/src/checks/manual/meta-viewport-large-manual.js +119 -0
- package/src/checks/manual/mouse-only-event-handlers-manual.js +134 -0
- package/src/checks/manual/no-autoplay-audio-manual.js +116 -0
- package/src/checks/manual/object-text-alternative-quality-manual.js +194 -0
- package/src/checks/manual/p-as-heading-manual.js +163 -0
- package/src/checks/manual/page-has-heading-one-manual.js +110 -0
- package/src/checks/manual/page-title-patterns-manual.js +262 -0
- package/src/checks/manual/presentation-role-conflict-manual.js +159 -0
- package/src/checks/manual/region-manual.js +183 -0
- package/src/checks/manual/scope-attr-valid-manual.js +93 -0
- package/src/checks/manual/scrollable-region-focusable-manual.js +168 -0
- package/src/checks/manual/skip-link-manual.js +150 -0
- package/src/checks/manual/svg-text-alternative-quality-manual.js +209 -0
- package/src/checks/manual/tabindex-manual.js +94 -0
- package/src/checks/manual/table-duplicate-name-manual.js +99 -0
- package/src/checks/manual/table-fake-caption-manual.js +122 -0
- package/src/checks/manual/video-caption-manual.js +118 -0
- package/src/checks/manual-review.js +95 -0
- package/src/checks/rules-and-tags.full.csv +19 -0
- package/src/checks/rules-and-tags.full.json +259 -0
- package/src/core/aria-helpers.js +906 -0
- package/src/core/contrast-helpers.js +1147 -0
- package/src/core/dom-helpers.js +4085 -0
- package/src/core/dom-runner.js +627 -0
- package/src/core/frame-messaging.js +210 -0
- package/src/core/frame-scan.js +178 -0
- package/src/core/rollup-composites.js +135 -0
- package/src/core/rule-meta.js +140 -0
- package/src/core.js +79055 -0
- package/src/coverage/wcag-facets.js +1079 -0
- package/src/coverage/wcag-version-map.js +84 -0
- package/src/i18n/en.js +919 -0
- package/src/i18n/fr.js +527 -0
- package/src/index.js +4 -0
- package/src/policy/contracts.js +18 -0
- package/src/policy/resolvePolicy.js +55 -0
- package/src/policy/schemas/engine-options.schema.json +103 -0
- package/src/policy/schemas/policy-contract.schema.json +40 -0
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check target-size-minimum
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary Pointer-operable targets should be at least 24×24 CSS px (or meet an exception)
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 2.5.8
|
|
9
|
+
*
|
|
10
|
+
* Notes (engine intent):
|
|
11
|
+
* - This rule is DOM-based and measures pointer hit regions available to sighted pointer users.
|
|
12
|
+
* - Elements can be "pointer-operable" even if excluded from the accessibility tree (e.g. aria-hidden="true").
|
|
13
|
+
* - Excludes targets that are not pointer-reachable due to rendering suppression (display:none, etc.),
|
|
14
|
+
* or pointer suppression (pointer-events:none), or zero geometry (e.g. scale(0) -> zero rects).
|
|
15
|
+
*
|
|
16
|
+
* WCAG 2.5.8 exceptions implemented, and how:
|
|
17
|
+
* - Spacing: a 24px-diameter circle centered on an undersized target must not
|
|
18
|
+
* intersect another (unrelated) target's box or another undersized
|
|
19
|
+
* target's own circle. Two passes: a fast center-distance check (exact for
|
|
20
|
+
* undersized-vs-undersized; a reasonable proxy otherwise) and a 16-point
|
|
21
|
+
* perimeter sample via elementFromPoint as a more precise fallback for
|
|
22
|
+
* cases the distance check under-detects (e.g. a small target adjacent to
|
|
23
|
+
* a large, elongated neighbor). Ancestor/descendant relationships between
|
|
24
|
+
* the target and the "other" element are never treated as a conflict —
|
|
25
|
+
* see isRelated — since a nested-interactive shape (a small control inside
|
|
26
|
+
* its own wrapping link/button) is one visual region, not two independent
|
|
27
|
+
* targets; that pattern is nested-interactive-controls-absent's
|
|
28
|
+
* concern, not a spacing one.
|
|
29
|
+
* - Inline: a link inside a run of text (isInlineTextExceptionTarget).
|
|
30
|
+
* - User Agent Control: an unstyled native checkbox/radio, detected via
|
|
31
|
+
* `appearance` not being reset to `none` (see isUserAgentSizedControl) —
|
|
32
|
+
* scoped narrowly to checkbox/radio specifically, not every form control,
|
|
33
|
+
* since those are the only types with unambiguous native rendering.
|
|
34
|
+
* - Essential/Equivalent: only a narrow, high-confidence subset is asserted
|
|
35
|
+
* (SVG/canvas/map-embedded controls) — see isPlausiblyEssentialOrEquivalent;
|
|
36
|
+
* anything else defers to cantTell rather than guessing "essential" from a
|
|
37
|
+
* layout container.
|
|
38
|
+
*
|
|
39
|
+
* Known, deliberately unimplemented gap: `<area>` (image-map hotspot)
|
|
40
|
+
* elements are not evaluated at all. `area[href]` is in CANDIDATE_SELECTOR
|
|
41
|
+
* for forward-compatibility, but it's currently a no-op: `<area>` has no
|
|
42
|
+
* CSS box of its own (`display: none` by the HTML spec's default UA
|
|
43
|
+
* stylesheet — verified, not a jsdom quirk), so `getBoundingClientRect()`
|
|
44
|
+
* always reports zero geometry and `isPointerReachable`'s existing
|
|
45
|
+
* `display:none` check rejects it before any size/exception logic runs. A
|
|
46
|
+
* real `<area>` hit-region is computed by the browser from its `shape`/
|
|
47
|
+
* `coords` attributes against the associated `<img>`'s *rendered* size —
|
|
48
|
+
* an entirely different measurement path than every other candidate here.
|
|
49
|
+
* Implementing that properly (parsing `coords`, resolving the owning
|
|
50
|
+
* `<img>` via its `usemap`, accounting for the image's CSS-scaled render
|
|
51
|
+
* size) is a separate, larger feature, not attempted in this pass.
|
|
52
|
+
*
|
|
53
|
+
* This is an automatic, deterministic approximation intended to be:
|
|
54
|
+
* - strict on clear failures,
|
|
55
|
+
* - conservative when exceptions cannot be determined reliably.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
const id = 'target-size-minimum';
|
|
59
|
+
|
|
60
|
+
const meta = {
|
|
61
|
+
title: 'Pointer targets meet minimum size (AA)',
|
|
62
|
+
description:
|
|
63
|
+
'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).',
|
|
64
|
+
i18n: {
|
|
65
|
+
titleKey: 'targetSizeMinimum_title',
|
|
66
|
+
descriptionKey: 'targetSizeMinimum_description'
|
|
67
|
+
},
|
|
68
|
+
helpUrl: null,
|
|
69
|
+
tags: ['wcag22aa', 'wcag258', 'navigation', 'operable', 'pointer', 'target-size', 'atomic', 'automatic', 'dom'],
|
|
70
|
+
wcagSc: ['2.5.8'],
|
|
71
|
+
normativeMappings: [
|
|
72
|
+
{
|
|
73
|
+
standard: 'WCAG',
|
|
74
|
+
version: '2.2',
|
|
75
|
+
requirement: '2.5.8',
|
|
76
|
+
title: 'Target Size (Minimum)',
|
|
77
|
+
conformanceLevel: 'AA'
|
|
78
|
+
}
|
|
79
|
+
],
|
|
80
|
+
defaultSeverity: 'serious',
|
|
81
|
+
category: 'operable',
|
|
82
|
+
type: 'automatic',
|
|
83
|
+
defaultConfidence: 'medium',
|
|
84
|
+
coverage: {
|
|
85
|
+
facetsBySc: {
|
|
86
|
+
'2.5.8': ['target-size-minimum-pointer']
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// ---- constants (deterministic, CSS pixels) ----
|
|
92
|
+
const MIN_SIZE = 24;
|
|
93
|
+
const MIN_RADIUS = 12; // spacing exception circle radius (24px diameter)
|
|
94
|
+
|
|
95
|
+
// Grid sampling configuration (kept small for perf; only used for undersized targets)
|
|
96
|
+
const GRID_POINTS = 25; // 5x5
|
|
97
|
+
const GRID_N = 5;
|
|
98
|
+
|
|
99
|
+
// Circle sampling configuration (perimeter)
|
|
100
|
+
const CIRCLE_SAMPLES = 16; // deterministic, fixed
|
|
101
|
+
|
|
102
|
+
function runInPage(ctx) {
|
|
103
|
+
'use strict';
|
|
104
|
+
|
|
105
|
+
const { document, helpers, rule } = ctx;
|
|
106
|
+
|
|
107
|
+
const RULE_ID = (rule && rule.ruleId) || 'target-size-minimum';
|
|
108
|
+
const MIN = 24;
|
|
109
|
+
const RADIUS = MIN / 2;
|
|
110
|
+
|
|
111
|
+
// --- tiny safe helpers (never throw) ---
|
|
112
|
+
// Goes through helpers.queryAllSmart/queryAll (multi-root and shadow-DOM
|
|
113
|
+
// aware, cached) rather than a raw root/safeRoot DOM query -- ctx.root is
|
|
114
|
+
// an array (multi-region contextSelector support), not a single element
|
|
115
|
+
// with its own .querySelectorAll to call directly.
|
|
116
|
+
function qsa(sel) {
|
|
117
|
+
try {
|
|
118
|
+
if (helpers && typeof helpers.queryAllSmart === 'function') return Array.from(helpers.queryAllSmart(sel) || []);
|
|
119
|
+
if (helpers && typeof helpers.queryAll === 'function') return Array.from(helpers.queryAll(sel) || []);
|
|
120
|
+
return [];
|
|
121
|
+
} catch {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function buildSelector(el) {
|
|
127
|
+
try {
|
|
128
|
+
if (helpers && typeof helpers.buildSelector === 'function') return helpers.buildSelector(el);
|
|
129
|
+
} catch {}
|
|
130
|
+
try {
|
|
131
|
+
if (el && el.id) return `#${el.id}`;
|
|
132
|
+
} catch {}
|
|
133
|
+
return 'html';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function htmlSnippet(el) {
|
|
137
|
+
try {
|
|
138
|
+
if (helpers && typeof helpers.getOuterHtmlSnippet === 'function') return helpers.getOuterHtmlSnippet(el);
|
|
139
|
+
} catch {}
|
|
140
|
+
try {
|
|
141
|
+
return (el && el.outerHTML) ? String(el.outerHTML) : '';
|
|
142
|
+
} catch {}
|
|
143
|
+
return '';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isInlineTextExceptionTarget(el) {
|
|
147
|
+
// SC 2.5.8 exception: target is in a sentence/block of text (inline)
|
|
148
|
+
// Deterministic heuristic:
|
|
149
|
+
// - must be a link-like target
|
|
150
|
+
// - must be rendered as inline/inline-*
|
|
151
|
+
// - must be inside a typical text container
|
|
152
|
+
try {
|
|
153
|
+
if (!el || el.nodeType !== 1) return false;
|
|
154
|
+
|
|
155
|
+
const tag = (el.tagName || '').toLowerCase();
|
|
156
|
+
const role = (el.getAttribute && String(el.getAttribute('role') || '').trim().toLowerCase()) || '';
|
|
157
|
+
const isLinkLike = (tag === 'a' && el.getAttribute && el.getAttribute('href')) || role === 'link';
|
|
158
|
+
if (!isLinkLike) return false;
|
|
159
|
+
|
|
160
|
+
const cs = getStyle(el);
|
|
161
|
+
const display = cs && cs.display ? String(cs.display) : '';
|
|
162
|
+
if (!display) return false;
|
|
163
|
+
|
|
164
|
+
// Inline exception is for inline text runs; many design systems use inline-block for links,
|
|
165
|
+
// so treat inline-block variants as eligible for this exception.
|
|
166
|
+
const isInline =
|
|
167
|
+
display === 'inline' ||
|
|
168
|
+
display === 'inline-block' ||
|
|
169
|
+
display === 'inline-flex' ||
|
|
170
|
+
display === 'inline-grid' ||
|
|
171
|
+
display === 'inline-table';
|
|
172
|
+
|
|
173
|
+
if (!isInline) return false;
|
|
174
|
+
|
|
175
|
+
const t = (el.textContent || '').trim();
|
|
176
|
+
if (!t) return false;
|
|
177
|
+
|
|
178
|
+
// Require that it sits in a "text block" context
|
|
179
|
+
const textContainer = closest(el, 'p, li, dd, dt, blockquote, figcaption, caption, td, th');
|
|
180
|
+
if (textContainer) return true;
|
|
181
|
+
|
|
182
|
+
// Fallback: sometimes links are inside spans within a paragraph-like region
|
|
183
|
+
const inlineContainer = closest(el, 'span, em, strong, small, label');
|
|
184
|
+
if (inlineContainer) {
|
|
185
|
+
const outerTextBlock = closest(inlineContainer, 'p, li, dd, dt, blockquote, figcaption, caption, td, th');
|
|
186
|
+
if (outerTextBlock) return true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return false;
|
|
190
|
+
} catch {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function getRects(el) {
|
|
196
|
+
try {
|
|
197
|
+
if (!el || typeof el.getClientRects !== 'function') return [];
|
|
198
|
+
const r = el.getClientRects();
|
|
199
|
+
return r ? Array.from(r) : [];
|
|
200
|
+
} catch {
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function getBcr(el) {
|
|
206
|
+
try {
|
|
207
|
+
if (!el || typeof el.getBoundingClientRect !== 'function') return null;
|
|
208
|
+
return el.getBoundingClientRect();
|
|
209
|
+
} catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function hasHiddenAttr(el) {
|
|
215
|
+
try {
|
|
216
|
+
return !!(el && el.hasAttribute && el.hasAttribute('hidden'));
|
|
217
|
+
} catch {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function closest(el, sel) {
|
|
223
|
+
try {
|
|
224
|
+
return el && typeof el.closest === 'function' ? el.closest(sel) : null;
|
|
225
|
+
} catch {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function inClosedDetails(el) {
|
|
231
|
+
const det = closest(el, 'details');
|
|
232
|
+
if (!det) return false;
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
if (det.hasAttribute('open')) return false;
|
|
236
|
+
|
|
237
|
+
const tag = (el && el.tagName) ? String(el.tagName).toLowerCase() : '';
|
|
238
|
+
// summary remains operable even when <details> is closed
|
|
239
|
+
if (tag === 'summary') return false;
|
|
240
|
+
|
|
241
|
+
// everything else inside closed details is suppressed
|
|
242
|
+
return true;
|
|
243
|
+
} catch {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function inInertSubtree(el) {
|
|
249
|
+
// Any ancestor with [inert] suppresses, including self.
|
|
250
|
+
try {
|
|
251
|
+
return !!closest(el, '[inert]');
|
|
252
|
+
} catch {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function getStyle(el) {
|
|
258
|
+
try {
|
|
259
|
+
return (document && document.defaultView && document.defaultView.getComputedStyle)
|
|
260
|
+
? document.defaultView.getComputedStyle(el)
|
|
261
|
+
: null;
|
|
262
|
+
} catch {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function isPointerReachable(el) {
|
|
268
|
+
// Match test expectations:
|
|
269
|
+
// - exclude display:none
|
|
270
|
+
// - exclude [hidden]
|
|
271
|
+
// - exclude content-visibility:hidden
|
|
272
|
+
// - exclude visibility hidden/collapse
|
|
273
|
+
// - exclude pointer-events:none
|
|
274
|
+
// - exclude inert subtree
|
|
275
|
+
// - exclude elements inside closed details (except summary)
|
|
276
|
+
// - exclude elements with no client rects
|
|
277
|
+
// - DO NOT exclude aria-hidden or opacity:0
|
|
278
|
+
if (!el || el.nodeType !== 1) return false;
|
|
279
|
+
|
|
280
|
+
if (hasHiddenAttr(el)) return false;
|
|
281
|
+
if (inInertSubtree(el)) return false;
|
|
282
|
+
if (inClosedDetails(el)) return false;
|
|
283
|
+
|
|
284
|
+
// Not operable => exclude
|
|
285
|
+
try {
|
|
286
|
+
if (typeof el.matches === 'function' && el.matches(':disabled')) return false;
|
|
287
|
+
} catch {}
|
|
288
|
+
|
|
289
|
+
// aria-disabled elements are typically treated as not operable
|
|
290
|
+
try {
|
|
291
|
+
const ad = el.getAttribute && String(el.getAttribute('aria-disabled') || '').trim().toLowerCase();
|
|
292
|
+
if (ad === 'true') return false;
|
|
293
|
+
} catch {}
|
|
294
|
+
|
|
295
|
+
const rects = getRects(el);
|
|
296
|
+
if (!rects || rects.length === 0) return false;
|
|
297
|
+
|
|
298
|
+
const cs = getStyle(el);
|
|
299
|
+
const display = cs && cs.display ? String(cs.display) : 'block';
|
|
300
|
+
const visibility = cs && cs.visibility ? String(cs.visibility) : 'visible';
|
|
301
|
+
const contentVisibility = cs && cs.contentVisibility ? String(cs.contentVisibility) : 'visible';
|
|
302
|
+
const pointerEvents = cs && cs.pointerEvents ? String(cs.pointerEvents) : 'auto';
|
|
303
|
+
|
|
304
|
+
if (display === 'none') return false;
|
|
305
|
+
if (visibility === 'hidden' || visibility === 'collapse') return false;
|
|
306
|
+
if (contentVisibility === 'hidden') return false;
|
|
307
|
+
if (pointerEvents === 'none') return false;
|
|
308
|
+
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function centerOfRect(r) {
|
|
313
|
+
return { cx: r.left + r.width / 2, cy: r.top + r.height / 2 };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function dist(a, b) {
|
|
317
|
+
const dx = a.cx - b.cx;
|
|
318
|
+
const dy = a.cy - b.cy;
|
|
319
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function elementFromPoint(x, y) {
|
|
323
|
+
try {
|
|
324
|
+
if (document && typeof document.elementFromPoint === 'function') return document.elementFromPoint(x, y);
|
|
325
|
+
} catch {}
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Bidirectional containment check: true when `a` and `b` are the same
|
|
330
|
+
// element, or either one is an ancestor of the other. A nested-interactive
|
|
331
|
+
// pattern (e.g. a small <button> inside a wrapping <a href>, or vice
|
|
332
|
+
// versa) is a single visual/interactive region, not two independently
|
|
333
|
+
// placed targets — the spacing exception's "does the circle intersect
|
|
334
|
+
// ANOTHER target" language is about separate targets, not an element and
|
|
335
|
+
// its own container. (Nested interactive controls are their own,
|
|
336
|
+
// separately-flagged anti-pattern — nested-interactive-controls-
|
|
337
|
+
// absent — not a target-size spacing concern.)
|
|
338
|
+
function isRelated(a, b) {
|
|
339
|
+
try {
|
|
340
|
+
if (!a || !b) return false;
|
|
341
|
+
if (a === b) return true;
|
|
342
|
+
if (typeof a.contains === 'function' && a.contains(b)) return true;
|
|
343
|
+
if (typeof b.contains === 'function' && b.contains(a)) return true;
|
|
344
|
+
return false;
|
|
345
|
+
} catch {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const CANDIDATE_SELECTOR = 'button, summary, a[href], area[href], input, select, textarea, [role="button"], [role="link"]';
|
|
351
|
+
|
|
352
|
+
// --- candidate collection ---
|
|
353
|
+
const candidates = qsa(CANDIDATE_SELECTOR);
|
|
354
|
+
|
|
355
|
+
const applicable = [];
|
|
356
|
+
for (const el of candidates) {
|
|
357
|
+
if (isPointerReachable(el)) applicable.push(el);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (applicable.length === 0) {
|
|
361
|
+
return { ruleId: RULE_ID, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Precompute geometry for applicable elements.
|
|
365
|
+
const items = [];
|
|
366
|
+
for (const el of applicable) {
|
|
367
|
+
const r = getBcr(el);
|
|
368
|
+
if (!r) continue;
|
|
369
|
+
// Guard against nonsense
|
|
370
|
+
const w = Number(r.width);
|
|
371
|
+
const h = Number(r.height);
|
|
372
|
+
if (!Number.isFinite(w) || !Number.isFinite(h)) continue;
|
|
373
|
+
if (w <= 0 || h <= 0) continue;
|
|
374
|
+
|
|
375
|
+
items.push({
|
|
376
|
+
el,
|
|
377
|
+
rect: { left: r.left, top: r.top, width: w, height: h, right: r.left + w, bottom: r.top + h },
|
|
378
|
+
center: centerOfRect({ left: r.left, top: r.top, width: w, height: h })
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (items.length === 0) {
|
|
383
|
+
// had “applicable” but no measurable geometry
|
|
384
|
+
return { ruleId: RULE_ID, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const undersized = items.filter((it) => it.rect.width < MIN || it.rect.height < MIN);
|
|
388
|
+
|
|
389
|
+
// --- spacing/occlusion evaluation ---
|
|
390
|
+
function hasSpacingConflict(target) {
|
|
391
|
+
// 0) Pure geometry: deterministic center-distance check against ANY
|
|
392
|
+
// nearby target, not just other undersized ones. Per WCAG 2.5.8, the
|
|
393
|
+
// spacing exception depends on proximity to any adjacent target — an
|
|
394
|
+
// undersized target sitting flush against an adequately-sized one still
|
|
395
|
+
// fails the exception, which an undersized-only comparison would miss.
|
|
396
|
+
for (const other of items) {
|
|
397
|
+
if (!other || !other.el || isRelated(target.el, other.el)) continue;
|
|
398
|
+
|
|
399
|
+
// Ignore inline-text exception targets when evaluating spacing conflicts.
|
|
400
|
+
// (Inline links in text are exempt and should not invalidate spacing.)
|
|
401
|
+
if (isInlineTextExceptionTarget(other.el)) continue;
|
|
402
|
+
|
|
403
|
+
if (dist(target.center, other.center) < MIN) {
|
|
404
|
+
return { conflict: true, confident: true, hitCount: 0, conflictEl: other.el };
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// 1) Perimeter sampling: reduce false positives from incidental overlaps/stacking.
|
|
409
|
+
// Sample every point (no early exit) so the confidence banding below can
|
|
410
|
+
// be based on the full hit count rather than stopping the instant a
|
|
411
|
+
// threshold is crossed.
|
|
412
|
+
const HIT_THRESHOLD = 3;
|
|
413
|
+
// Comfortably above HIT_THRESHOLD: only a hit count this high is treated
|
|
414
|
+
// as a confident conflict. Perimeter sampling is an approximation
|
|
415
|
+
// (rounded corners, border-radius, and sub-pixel geometry can shift a
|
|
416
|
+
// sample point in or out of a neighboring element), so a result that
|
|
417
|
+
// merely reaches HIT_THRESHOLD is not asserted as a deterministic
|
|
418
|
+
// fail — see the ambiguous band below.
|
|
419
|
+
const CONFIDENT_THRESHOLD = 5;
|
|
420
|
+
let hitCount = 0;
|
|
421
|
+
let firstConflictEl = null;
|
|
422
|
+
|
|
423
|
+
const STEPS = 16;
|
|
424
|
+
for (let i = 0; i < STEPS; i++) {
|
|
425
|
+
const ang = (Math.PI * 2 * i) / STEPS;
|
|
426
|
+
const x = target.center.cx + RADIUS * Math.cos(ang);
|
|
427
|
+
const y = target.center.cy + RADIUS * Math.sin(ang);
|
|
428
|
+
|
|
429
|
+
const hit = elementFromPoint(x, y);
|
|
430
|
+
if (!hit) continue;
|
|
431
|
+
|
|
432
|
+
let hitCandidate = null;
|
|
433
|
+
try {
|
|
434
|
+
hitCandidate = hit.closest ? hit.closest(CANDIDATE_SELECTOR) : null;
|
|
435
|
+
} catch {}
|
|
436
|
+
|
|
437
|
+
if (!hitCandidate) continue;
|
|
438
|
+
if (isRelated(hitCandidate, target.el)) continue;
|
|
439
|
+
if (!isPointerReachable(hitCandidate)) continue;
|
|
440
|
+
|
|
441
|
+
// If the thing we hit is an inline-text exception target, don't treat it as a spacing conflict.
|
|
442
|
+
if (isInlineTextExceptionTarget(hitCandidate)) continue;
|
|
443
|
+
|
|
444
|
+
hitCount++;
|
|
445
|
+
if (!firstConflictEl) firstConflictEl = hitCandidate;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (hitCount >= CONFIDENT_THRESHOLD) {
|
|
449
|
+
return { conflict: true, confident: true, hitCount, conflictEl: firstConflictEl };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Ambiguous band: close enough to HIT_THRESHOLD that sampling noise
|
|
453
|
+
// could have tipped the result either way. Defer to manual review
|
|
454
|
+
// instead of committing to pass or fail.
|
|
455
|
+
if (hitCount >= HIT_THRESHOLD - 1) {
|
|
456
|
+
return { conflict: false, confident: false, hitCount, conflictEl: firstConflictEl };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return { conflict: false, confident: true, hitCount, conflictEl: null };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// WCAG 2.5.8 "User Agent Control" exception: the target's size requirement
|
|
463
|
+
// does not apply at all when its size is determined by the user agent and
|
|
464
|
+
// not modified by the author — the canonical example being an unstyled
|
|
465
|
+
// native checkbox/radio (browsers render these well under 24px by
|
|
466
|
+
// default, and that's not the author's choice). Scoped narrowly to
|
|
467
|
+
// input[type=checkbox]/[type=radio] specifically (the only form-control
|
|
468
|
+
// types with a universally-recognized, unambiguous native rendering) —
|
|
469
|
+
// deliberately not extended to select/range/color/file, whose "default"
|
|
470
|
+
// sizing varies enough across browsers/OSes that a wrong exemption there
|
|
471
|
+
// risks masking a real author-introduced undersized target.
|
|
472
|
+
//
|
|
473
|
+
// Detection signal: `appearance` (or the legacy `-webkit-appearance`)
|
|
474
|
+
// computed as `none` is the near-universal first step of custom
|
|
475
|
+
// checkbox/radio styling across every CSS framework/design system —
|
|
476
|
+
// if the author hasn't reset it, the browser is still rendering its own
|
|
477
|
+
// default control chrome, so the size is genuinely UA-determined.
|
|
478
|
+
function isUserAgentSizedControl(el) {
|
|
479
|
+
try {
|
|
480
|
+
if (!el || el.nodeType !== 1) return false;
|
|
481
|
+
const tag = (el.tagName || '').toLowerCase();
|
|
482
|
+
if (tag !== 'input') return false;
|
|
483
|
+
|
|
484
|
+
const type = (el.getAttribute && String(el.getAttribute('type') || '').trim().toLowerCase()) || '';
|
|
485
|
+
if (type !== 'checkbox' && type !== 'radio') return false;
|
|
486
|
+
|
|
487
|
+
const cs = getStyle(el);
|
|
488
|
+
let appearance = '';
|
|
489
|
+
try {
|
|
490
|
+
if (cs && typeof cs.getPropertyValue === 'function') {
|
|
491
|
+
appearance = cs.getPropertyValue('appearance') || cs.getPropertyValue('-webkit-appearance') || '';
|
|
492
|
+
} else if (cs) {
|
|
493
|
+
appearance = cs.appearance || cs.webkitAppearance || '';
|
|
494
|
+
}
|
|
495
|
+
} catch {}
|
|
496
|
+
appearance = String(appearance).trim().toLowerCase();
|
|
497
|
+
|
|
498
|
+
// Author has reset the native chrome => size is now author-controlled;
|
|
499
|
+
// the exception no longer applies, evaluate normally.
|
|
500
|
+
if (appearance === 'none') return false;
|
|
501
|
+
|
|
502
|
+
return true;
|
|
503
|
+
} catch {
|
|
504
|
+
return false;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function isPlausiblyEssentialOrEquivalent(el) {
|
|
509
|
+
try {
|
|
510
|
+
if (!el || el.nodeType !== 1) return false;
|
|
511
|
+
|
|
512
|
+
const tag = (el.tagName || '').toLowerCase();
|
|
513
|
+
|
|
514
|
+
// Image map targets are often constrained by the underlying image.
|
|
515
|
+
// Currently unreachable in practice: <area> never becomes a
|
|
516
|
+
// measurable candidate at all (see the file header's "Known,
|
|
517
|
+
// deliberately unimplemented gap" note) — kept for forward
|
|
518
|
+
// compatibility if that gap is closed later.
|
|
519
|
+
if (tag === 'area') return true;
|
|
520
|
+
|
|
521
|
+
// Graphics / spatial interaction regions are commonly essential by design.
|
|
522
|
+
if (closest(el, 'svg, canvas, map')) return true;
|
|
523
|
+
|
|
524
|
+
// Otherwise: do NOT guess "essential/equivalent" from layout containers.
|
|
525
|
+
return false;
|
|
526
|
+
} catch {
|
|
527
|
+
return false; // conservative: don't mask failures as cantTell
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const occurrences = [];
|
|
532
|
+
let hasUncertainConflicts = false;
|
|
533
|
+
|
|
534
|
+
for (const it of undersized) {
|
|
535
|
+
// Inline-text exception: do not fail purely on size/spacing for inline links in text.
|
|
536
|
+
if (isInlineTextExceptionTarget(it.el)) {
|
|
537
|
+
continue; // pass by exception (no occurrence)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// User Agent Control exception: size isn't the author's choice, so the
|
|
541
|
+
// size requirement (and therefore any spacing conflict stemming from
|
|
542
|
+
// it) doesn't apply at all — skip straight to pass, no need to even
|
|
543
|
+
// evaluate spacing.
|
|
544
|
+
if (isUserAgentSizedControl(it.el)) {
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const info = hasSpacingConflict(it);
|
|
549
|
+
|
|
550
|
+
if (!info.conflict && info.confident === false) {
|
|
551
|
+
// Ambiguous perimeter-sampling result near the decision threshold.
|
|
552
|
+
hasUncertainConflicts = true;
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (info.conflict) {
|
|
557
|
+
if (isPlausiblyEssentialOrEquivalent(it.el)) {
|
|
558
|
+
hasUncertainConflicts = true;
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
occurrences.push({
|
|
563
|
+
selector: buildSelector(it.el),
|
|
564
|
+
html: htmlSnippet(it.el),
|
|
565
|
+
summary: 'Target is too small and too close to another target.',
|
|
566
|
+
hint: 'Increase target size to at least 24 by 24 CSS pixels, or add sufficient spacing.',
|
|
567
|
+
i18n: {
|
|
568
|
+
summaryKey: 'targetSizeMinimum_summary_fail',
|
|
569
|
+
hintKey: 'targetSizeMinimum_hint_fail',
|
|
570
|
+
params: {}
|
|
571
|
+
},
|
|
572
|
+
data: {
|
|
573
|
+
details: {
|
|
574
|
+
measured: { width: it.rect.width, height: it.rect.height },
|
|
575
|
+
reasonCode: 'undersized-and-too-close',
|
|
576
|
+
conflictHitCount: info.hitCount,
|
|
577
|
+
conflictWith: info.conflictEl ? buildSelector(info.conflictEl) : null
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (occurrences.length > 0) {
|
|
585
|
+
return {
|
|
586
|
+
ruleId: RULE_ID,
|
|
587
|
+
outcome: 'fail',
|
|
588
|
+
severity: (rule && rule.defaultSeverity) || 'minor',
|
|
589
|
+
occurrences
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (hasUncertainConflicts) {
|
|
594
|
+
return {
|
|
595
|
+
ruleId: RULE_ID,
|
|
596
|
+
outcome: 'cantTell',
|
|
597
|
+
severity: 'minor',
|
|
598
|
+
occurrences: []
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
return { ruleId: RULE_ID, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
module.exports = { id, meta, runInPage };
|