@surea11y/core 1.7.0 → 1.8.1
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 +103 -1
- package/README.md +157 -54
- package/docs/ACT_RULE_MAPPING.md +8 -7
- package/docs/API_STABILITY.md +18 -5
- package/docs/BINDING_AUTHORS_GUIDE.md +2 -2
- package/docs/CI_INTEGRATIONS.md +43 -0
- package/docs/DESIGN_CHALLENGES.md +97 -3
- package/docs/EARL.md +2 -2
- package/docs/ENGINE_OPTIONS.md +81 -3
- package/docs/I18N.md +62 -20
- package/docs/JUNIT.md +73 -0
- package/docs/LIMITATIONS.md +1 -0
- package/docs/OUTPUT_SCHEMA.md +19 -6
- package/docs/REPORT.md +7 -2
- package/docs/RULE_AUTHORING.md +73 -6
- package/docs/RULE_CATALOG.md +139 -116
- package/docs/RULE_EXAMPLES.md +2189 -0
- package/docs/RULE_HELPERS.md +62 -5
- package/docs/RULE_TAXONOMY.md +2 -2
- package/docs/SARIF.md +2 -1
- package/docs/WCAG_CONFORMANCE.md +56 -3
- package/package.json +34 -11
- package/profiles/index.js +14 -0
- package/src/checks/automatic/area-alt-present.js +87 -31
- package/src/checks/automatic/aria-braille-equivalent.js +25 -7
- package/src/checks/automatic/aria-hidden-focus.js +74 -18
- package/src/checks/automatic/aria-prohibited-attr.js +17 -4
- package/src/checks/automatic/aria-required-attr.js +29 -0
- package/src/checks/automatic/aria-role-name-present.js +19 -2
- package/src/checks/automatic/aria-valid-attr-value.js +28 -16
- package/src/checks/automatic/autocomplete-valid.js +26 -11
- package/src/checks/automatic/avoid-inline-spacing.js +105 -40
- 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 +35 -4
- package/src/checks/automatic/contrast-enhanced.js +4 -4
- package/src/checks/automatic/contrast-minimum.js +45 -11
- package/src/checks/automatic/css-orientation-lock.js +152 -30
- 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.js +6 -2
- package/src/checks/automatic/identical-iframes-same-purpose.js +4 -4
- package/src/checks/automatic/iframe-focusable-content.js +7 -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 +40 -13
- package/src/checks/automatic/language-page-present.js +12 -6
- package/src/checks/automatic/link-in-text-block.js +272 -60
- 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-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 +0 -11
- package/src/checks/automatic/td-has-header.js +41 -5
- 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 +109 -5
- 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/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/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 +14285 -2219
- 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/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 +34 -3
- package/src/wcag.js +105 -0
- package/surea11y.browser.js +5 -4
- package/surea11y.i18n.de.js +1 -1
- package/surea11y.i18n.es.js +1 -1
- package/surea11y.i18n.fr.js +1 -1
- package/surea11y.i18n.ja.js +3 -0
- package/src/checks/manual/area-alt-decorative-manual.js +0 -255
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
* Notes:
|
|
18
18
|
* - Focusability is computed via ctx.helpers.getFocusableInfo (native + tabindex + contenteditable).
|
|
19
19
|
* - Elements that are not rendered (e.g., display:none, visibility:hidden, [hidden]) are excluded.
|
|
20
|
+
* - Disabled controls are not focusable, including those disabled by an ancestor
|
|
21
|
+
* <fieldset disabled> (matched with :disabled).
|
|
22
|
+
* - <area href> has no box of its own, so it is judged by the <img usemap> that uses its
|
|
23
|
+
* <map>: it counts as focusable when such an image is rendered and not inert.
|
|
20
24
|
* - Elements hidden via CSS in ways that still allow keyboard focus (e.g., opacity:0, off-screen, clip)
|
|
21
25
|
* remain in-scope and will be flagged when focusable.
|
|
22
26
|
*/
|
|
@@ -525,9 +529,69 @@ function runInPage(ctx) {
|
|
|
525
529
|
// DOM-visibility gate to avoid false positives:
|
|
526
530
|
// Exclude structural/CSS hidden cases that prevent focus (display:none, visibility:hidden, hidden attr, etc.).
|
|
527
531
|
// IMPORTANT: Do NOT exclude opacity-based invisibility; opacity:0 remains in-scope.
|
|
532
|
+
// Style-only visibility gate. opacity:0 does not count as hidden: the
|
|
533
|
+
// element can still take focus.
|
|
534
|
+
function isRenderedForFocus(el) {
|
|
535
|
+
if (!isDomVisibleEligible) return true;
|
|
536
|
+
try {
|
|
537
|
+
const vis = isDomVisibleEligible(el, ctx, {
|
|
538
|
+
visibilityMode: 'styleOnly',
|
|
539
|
+
disableGeometry: true
|
|
540
|
+
});
|
|
541
|
+
if (vis && vis.eligible === false) {
|
|
542
|
+
const rs = Array.isArray(vis.reasons) ? vis.reasons : [];
|
|
543
|
+
const nonOpacity = rs.filter((r) => String(r) !== 'opacityZero');
|
|
544
|
+
if (nonOpacity.length) return false;
|
|
545
|
+
}
|
|
546
|
+
} catch {
|
|
547
|
+
// ignore
|
|
548
|
+
}
|
|
549
|
+
return true;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// <area href> generates no box of its own (browsers give it display:none),
|
|
553
|
+
// so the visibility gate cannot be applied to it. It takes focus when its
|
|
554
|
+
// <map> is used by an <img usemap> that is rendered and not inert; judge it
|
|
555
|
+
// by that image instead.
|
|
556
|
+
function isFocusableArea(el) {
|
|
557
|
+
if (!trim(el.getAttribute('href'))) return false;
|
|
558
|
+
let map;
|
|
559
|
+
try {
|
|
560
|
+
map = el.closest ? el.closest('map') : null;
|
|
561
|
+
} catch {
|
|
562
|
+
map = null;
|
|
563
|
+
}
|
|
564
|
+
if (!map) return false;
|
|
565
|
+
const name = trim(map.getAttribute('name') || map.getAttribute('id'));
|
|
566
|
+
if (!name) return false;
|
|
567
|
+
const scope = el.getRootNode ? el.getRootNode() : document;
|
|
568
|
+
if (!scope || typeof scope.querySelectorAll !== 'function') return false;
|
|
569
|
+
let imgs;
|
|
570
|
+
try {
|
|
571
|
+
imgs = Array.from(scope.querySelectorAll('img[usemap]'));
|
|
572
|
+
} catch {
|
|
573
|
+
imgs = [];
|
|
574
|
+
}
|
|
575
|
+
const want = name.toLowerCase();
|
|
576
|
+
for (const img of imgs) {
|
|
577
|
+
const usemap = lower(img.getAttribute('usemap')).replace(/^#/, '');
|
|
578
|
+
if (usemap !== want) continue;
|
|
579
|
+
if (hasInertAncestor(img)) continue;
|
|
580
|
+
if (isRenderedForFocus(img)) return true;
|
|
581
|
+
}
|
|
582
|
+
return false;
|
|
583
|
+
}
|
|
584
|
+
|
|
528
585
|
function isActuallyFocusable(el) {
|
|
529
586
|
if (!el || !el.getAttribute) return false;
|
|
530
587
|
|
|
588
|
+
// An explicit negative tabindex takes the area out of the tab order too.
|
|
589
|
+
if (lower(el.tagName || '') === 'area') {
|
|
590
|
+
const ti = trim(el.getAttribute('tabindex'));
|
|
591
|
+
if (ti !== '' && !Number.isNaN(Number(ti)) && Number(ti) < 0) return false;
|
|
592
|
+
return isFocusableArea(el);
|
|
593
|
+
}
|
|
594
|
+
|
|
531
595
|
// Hard blockers that should always win (even if fallback logic would say "focusable")
|
|
532
596
|
if (hasInertAncestor(el)) return false;
|
|
533
597
|
if (isDisabledFormControl(el)) return false;
|
|
@@ -564,7 +628,7 @@ function runInPage(ctx) {
|
|
|
564
628
|
const tag = lower(el.tagName || '');
|
|
565
629
|
let fallbackFocusable = false;
|
|
566
630
|
|
|
567
|
-
if (tag === 'a'
|
|
631
|
+
if (tag === 'a') {
|
|
568
632
|
const href = trim(el.getAttribute('href'));
|
|
569
633
|
fallbackFocusable = !!href;
|
|
570
634
|
} else if (tag === 'button' || tag === 'select' || tag === 'textarea' || tag === 'summary') {
|
|
@@ -601,23 +665,7 @@ function runInPage(ctx) {
|
|
|
601
665
|
|
|
602
666
|
// 2) exclude non-rendered / non-visible-by-style blockers
|
|
603
667
|
// IMPORTANT: Do NOT exclude opacity-based invisibility; opacity:0 remains in-scope.
|
|
604
|
-
|
|
605
|
-
try {
|
|
606
|
-
const vis = isDomVisibleEligible(el, ctx, {
|
|
607
|
-
visibilityMode: 'styleOnly',
|
|
608
|
-
disableGeometry: true
|
|
609
|
-
});
|
|
610
|
-
if (vis && vis.eligible === false) {
|
|
611
|
-
const rs = Array.isArray(vis.reasons) ? vis.reasons : [];
|
|
612
|
-
const nonOpacity = rs.filter((r) => String(r) !== 'opacityZero');
|
|
613
|
-
if (nonOpacity.length) return false;
|
|
614
|
-
}
|
|
615
|
-
} catch {
|
|
616
|
-
// ignore
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
return true;
|
|
668
|
+
return isRenderedForFocus(el);
|
|
621
669
|
}
|
|
622
670
|
|
|
623
671
|
function hasInertAncestor(el) {
|
|
@@ -635,6 +683,14 @@ function runInPage(ctx) {
|
|
|
635
683
|
}
|
|
636
684
|
|
|
637
685
|
function isDisabledFormControl(el) {
|
|
686
|
+
try {
|
|
687
|
+
// :disabled also covers a control disabled by an ancestor
|
|
688
|
+
// <fieldset disabled> (outside its first <legend>), which the
|
|
689
|
+
// `disabled` IDL attribute does not reflect.
|
|
690
|
+
if (typeof el.matches === 'function' && el.matches(':disabled')) return true;
|
|
691
|
+
} catch {
|
|
692
|
+
// ignore
|
|
693
|
+
}
|
|
638
694
|
try {
|
|
639
695
|
// Covers button/input/select/textarea/option/optgroup/fieldset etc.
|
|
640
696
|
if (typeof el.disabled === 'boolean' && el.disabled) return true;
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
* list for naming attributes (pure text-semantics / non-naming
|
|
15
15
|
* structural roles: caption, code, deletion, emphasis, generic,
|
|
16
16
|
* insertion, mark, none, paragraph, presentation, strong, subscript,
|
|
17
|
-
* suggestion, superscript, time),
|
|
17
|
+
* suggestion, superscript, time), plus a native <caption> with no valid
|
|
18
|
+
* explicit role, whose implicit role is caption, and (b) elements with no
|
|
19
|
+
* role at all:
|
|
18
20
|
* a curated set of native HTML tags verified to carry no implicit role
|
|
19
21
|
* (see ROLELESS_NATIVE_TAGS below), or any autonomous custom element (a
|
|
20
22
|
* hyphenated, author-defined tag per the Custom Elements spec; see
|
|
@@ -147,14 +149,25 @@ function runInPage(ctx) {
|
|
|
147
149
|
|
|
148
150
|
// --- Tier 1: explicit, valid role from the naming-prohibited set ---
|
|
149
151
|
|
|
152
|
+
// A native <caption> has the caption role without saying so, and is judged
|
|
153
|
+
// the same way as role="caption"; only one that carries a naming attribute
|
|
154
|
+
// needs visiting.
|
|
155
|
+
const tier1Selector = '[role], caption[aria-label], caption[aria-labelledby]';
|
|
150
156
|
const roleNodes = helpers.queryAllSmart
|
|
151
|
-
? helpers.queryAllSmart(
|
|
152
|
-
: helpers.queryAll(
|
|
157
|
+
? helpers.queryAllSmart(tier1Selector)
|
|
158
|
+
: helpers.queryAll(tier1Selector);
|
|
153
159
|
|
|
154
160
|
for (const el of roleNodes) {
|
|
155
161
|
if (!el || !el.getAttribute) continue;
|
|
156
162
|
|
|
157
|
-
const
|
|
163
|
+
const explicitRole = ariaHelpers.getExplicitRole(el);
|
|
164
|
+
let role = explicitRole;
|
|
165
|
+
if (
|
|
166
|
+
(!explicitRole || !ariaHelpers.isValidConcreteRole(explicitRole)) &&
|
|
167
|
+
String(el.localName || '').toLowerCase() === 'caption'
|
|
168
|
+
) {
|
|
169
|
+
role = 'caption';
|
|
170
|
+
}
|
|
158
171
|
if (!role || !ROLES_PROHIBITING_NAME.has(role)) continue;
|
|
159
172
|
|
|
160
173
|
applicableCount += 1;
|
|
@@ -18,6 +18,11 @@
|
|
|
18
18
|
* <input type="checkbox" role="checkbox">, which is exempt because the
|
|
19
19
|
* native control's own state exposure already covers it; no aria-checked
|
|
20
20
|
* is required. helpers.aria.getNativeRoleForElement resolves this).
|
|
21
|
+
* A native <input type="checkbox"> or <input type="radio"> with another
|
|
22
|
+
* checkable role (switch, menuitemcheckbox, menuitemradio, or checkbox on
|
|
23
|
+
* a radio and the reverse) is in scope, but its aria-checked counts as
|
|
24
|
+
* supplied: the browser exposes the input's own checked state (HTML-AAM),
|
|
25
|
+
* so it passes without the attribute.
|
|
21
26
|
* @expectation
|
|
22
27
|
* Every required state/property for that role is present and non-empty.
|
|
23
28
|
* Graded by whether ARIA supplies a stand-in for the missing attribute:
|
|
@@ -136,6 +141,23 @@ function runInPage(ctx) {
|
|
|
136
141
|
}
|
|
137
142
|
}
|
|
138
143
|
|
|
144
|
+
const CHECKABLE_ROLES = new Set([
|
|
145
|
+
'checkbox',
|
|
146
|
+
'switch',
|
|
147
|
+
'radio',
|
|
148
|
+
'menuitemcheckbox',
|
|
149
|
+
'menuitemradio'
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
function isNativeCheckable(el) {
|
|
153
|
+
if (String(el.localName || '').toLowerCase() !== 'input') return false;
|
|
154
|
+
if (el.namespaceURI && el.namespaceURI !== 'http://www.w3.org/1999/xhtml') return false;
|
|
155
|
+
const type = String(el.getAttribute('type') || '')
|
|
156
|
+
.trim()
|
|
157
|
+
.toLowerCase();
|
|
158
|
+
return type === 'checkbox' || type === 'radio';
|
|
159
|
+
}
|
|
160
|
+
|
|
139
161
|
function isMarkedBusy(el) {
|
|
140
162
|
const v = el.getAttribute('aria-busy');
|
|
141
163
|
return v != null && String(v).trim().toLowerCase() === 'true';
|
|
@@ -165,6 +187,12 @@ function runInPage(ctx) {
|
|
|
165
187
|
|
|
166
188
|
const required = ariaHelpers.getRequiredAttrsForRole(role).slice();
|
|
167
189
|
|
|
190
|
+
// A native checkbox or radio exposes its own checked state whatever
|
|
191
|
+
// checkable role it carries (HTML-AAM maps the checkedness; ARIA in HTML
|
|
192
|
+
// tells authors not to set aria-checked on it), so aria-checked is
|
|
193
|
+
// supplied on <input type="checkbox" role="switch"> and the like.
|
|
194
|
+
const nativeChecked = isNativeCheckable(el) && CHECKABLE_ROLES.has(role);
|
|
195
|
+
|
|
168
196
|
// combobox's aria-controls is required only once the popup is actually
|
|
169
197
|
// displayed (aria-expanded="true") -- see this file's header comment.
|
|
170
198
|
if (role === 'combobox' && String(el.getAttribute('aria-expanded') || '').trim() === 'true') {
|
|
@@ -187,6 +215,7 @@ function runInPage(ctx) {
|
|
|
187
215
|
const missing = [];
|
|
188
216
|
for (const attr of required) {
|
|
189
217
|
const v = el.getAttribute(attr);
|
|
218
|
+
if (attr === 'aria-checked' && nativeChecked) continue;
|
|
190
219
|
if (v == null || String(v).trim() === '') missing.push(attr);
|
|
191
220
|
}
|
|
192
221
|
|
|
@@ -31,7 +31,11 @@
|
|
|
31
31
|
* to non-empty text, or a non-empty title. Every role in the set is
|
|
32
32
|
* name-from-author-only, so descendant text is not accepted:
|
|
33
33
|
* a labelled child inside a composite widget would otherwise pass the
|
|
34
|
-
* container that has no name of its own.
|
|
34
|
+
* container that has no name of its own. The name the HTML host element
|
|
35
|
+
* gives itself counts too, since the browser still computes it under the
|
|
36
|
+
* role: the first child <legend> of a <fieldset>, the first child
|
|
37
|
+
* <caption> of a <table>, and an associated <label> on a labelable
|
|
38
|
+
* element such as <progress> or <meter>.
|
|
35
39
|
*/
|
|
36
40
|
|
|
37
41
|
const id = 'aria-role-name-present';
|
|
@@ -186,7 +190,20 @@ function runInPage(ctx) {
|
|
|
186
190
|
|
|
187
191
|
const title = ariaLabel || labelled ? '' : getAttr(el, 'title');
|
|
188
192
|
|
|
189
|
-
|
|
193
|
+
// The host's own HTML naming still applies under the role: the first
|
|
194
|
+
// <legend> of <fieldset role="radiogroup">, the <caption> of
|
|
195
|
+
// <table role="grid">, a <label> of <progress role="progressbar">.
|
|
196
|
+
let hostName = '';
|
|
197
|
+
if (!(ariaLabel || labelled || title) && helpers.getNativeHostNameInfo) {
|
|
198
|
+
try {
|
|
199
|
+
const host = helpers.getNativeHostNameInfo(el, ctx);
|
|
200
|
+
hostName = host && host.present ? host.value : '';
|
|
201
|
+
} catch {
|
|
202
|
+
hostName = '';
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const ok = !!(ariaLabel || labelled || title || hostName);
|
|
190
207
|
if (ok) continue;
|
|
191
208
|
|
|
192
209
|
const eligInfo = getEligibilityInfo
|
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
* @expectation
|
|
16
16
|
* Each attribute's value conforms to its WAI-ARIA-declared value type:
|
|
17
17
|
* boolean ("true"/"false"), tristate ("true"/"false"/"mixed"), a token
|
|
18
|
-
* from a fixed enumerated set, an integer
|
|
19
|
-
* reference (list) that resolves to an
|
|
18
|
+
* from a fixed enumerated set, an integer (within the range WAI-ARIA sets
|
|
19
|
+
* for it), a real number, or an ID reference (list) that resolves to an
|
|
20
|
+
* existing element in the document.
|
|
20
21
|
* Per ACT 6a7281's own applicability ("any state or property that is
|
|
21
22
|
* NOT empty"), an explicitly empty value, including a bare boolean-style
|
|
22
23
|
* attribute with no "=value" at all, e.g. `aria-checked` alone, is out
|
|
@@ -27,7 +28,7 @@
|
|
|
27
28
|
* - Not rule-gated on isAccTreeEligible: this remains a static-markup
|
|
28
29
|
* property, while engine-level hidden-subtree filtering still applies
|
|
29
30
|
* unless engineOptions.includeHiddenElements is true.
|
|
30
|
-
* - ID-reference resolution (see aria-helpers.js's idExists) only
|
|
31
|
+
* - ID-reference resolution (see aria-helpers.js's idExists) only considers
|
|
31
32
|
* idref-list attributes (aria-labelledby, aria-describedby,
|
|
32
33
|
* aria-controls, aria-owns, etc.) when NONE of the space-separated ids
|
|
33
34
|
* resolve, a partially-dangling list (some ids exist, some don't) is
|
|
@@ -38,16 +39,20 @@
|
|
|
38
39
|
* text names it as a non-required property whose target "may be created
|
|
39
40
|
* in response to an event that may or may not happen" (a validation
|
|
40
41
|
* error message rendered only once the error actually occurs).
|
|
41
|
-
* -
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* aria-
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* dangling aria-labelledby
|
|
50
|
-
*
|
|
42
|
+
* - An idref list that resolves to nothing is a `cantTell`, never a fail.
|
|
43
|
+
* The element falls back to its other name and description sources (a
|
|
44
|
+
* <button aria-describedby="nope">Save</button> is still named "Save"),
|
|
45
|
+
* so whether anything was lost depends on what the reference was meant
|
|
46
|
+
* to add, and the target may be created later. aria-controls goes one
|
|
47
|
+
* step further: a collapsed widget (aria-expanded="false" or
|
|
48
|
+
* aria-selected="false") passes outright, since the absence of the
|
|
49
|
+
* controlled element is exactly what that state means. A name that goes
|
|
50
|
+
* missing because of a dangling aria-labelledby is reported by the name
|
|
51
|
+
* rules.
|
|
52
|
+
* - Integers are also checked against the lower bounds WAI-ARIA 1.2 sets:
|
|
53
|
+
* aria-level, aria-posinset, aria-colindex, aria-rowindex and
|
|
54
|
+
* aria-colspan at least 1, aria-rowspan at least 0, aria-setsize at
|
|
55
|
+
* least 1 or exactly -1.
|
|
51
56
|
* - Two tiers in one run means helpers.resolveTieredOutcome decides the
|
|
52
57
|
* aggregate: a real fail elsewhere on the page still reports fail, and
|
|
53
58
|
* the aria-controls occurrences ride along rather than being dropped.
|
|
@@ -151,19 +156,26 @@ function runInPage(ctx) {
|
|
|
151
156
|
}
|
|
152
157
|
|
|
153
158
|
for (const item of review || []) {
|
|
159
|
+
const controls = item.name === 'aria-controls';
|
|
154
160
|
cantTellOccurrences.push(
|
|
155
161
|
helpers.reportOccurrence(el, {
|
|
156
162
|
summary:
|
|
157
163
|
'No element with this id exists right now, so the engine cannot tell whether this reference is wrong.',
|
|
158
|
-
hint:
|
|
164
|
+
hint: controls
|
|
165
|
+
? 'Confirm the controlled element is created when the widget opens; if it never exists, remove or correct the reference.'
|
|
166
|
+
: 'Check whether an element with this id is added later. If not, correct or remove the reference; until then the element uses its other name or description sources.',
|
|
159
167
|
i18n: {
|
|
160
168
|
summaryKey: 'ariaValidAttrValue_summary_cantTell_idref',
|
|
161
|
-
hintKey:
|
|
169
|
+
hintKey: controls
|
|
170
|
+
? 'ariaValidAttrValue_hint_cantTell_idref'
|
|
171
|
+
: 'ariaValidAttrValue_hint_cantTell_idrefList',
|
|
162
172
|
params: { attr: item.name, value: item.value }
|
|
163
173
|
},
|
|
164
174
|
uncertainty: {
|
|
165
175
|
code: 'runtime-dependent',
|
|
166
|
-
needed:
|
|
176
|
+
needed: controls
|
|
177
|
+
? 'Whether the widget creates the referenced element when it opens.'
|
|
178
|
+
: 'Whether the referenced element is added later, and whether its absence loses a name, description or relationship.',
|
|
167
179
|
evidence: {
|
|
168
180
|
attribute: item.name,
|
|
169
181
|
referencedId: item.value,
|
|
@@ -10,12 +10,15 @@
|
|
|
10
10
|
* @sc 1.3.5
|
|
11
11
|
* @applicability
|
|
12
12
|
* Applies to form controls (input, select, textarea) with a non-empty
|
|
13
|
-
* autocomplete attribute.
|
|
13
|
+
* autocomplete attribute. Disabled controls (the disabled attribute,
|
|
14
|
+
* including a control disabled by a disabled fieldset ancestor, or
|
|
15
|
+
* aria-disabled="true") and input types with a fixed value are exempt,
|
|
16
|
+
* as in ACT 73f2c2.
|
|
14
17
|
* @expectation
|
|
15
18
|
* The value is "on"/"off" alone, or a well-formed autofill detail
|
|
16
19
|
* token list: an optional "section-*" token, then an optional
|
|
17
20
|
* "shipping"/"billing" token, then an optional contact-modality token
|
|
18
|
-
* (home/work/mobile/fax/pager
|
|
21
|
+
* (home/work/mobile/fax/pager), then exactly one recognized
|
|
19
22
|
* field-name token (name, email, street-address, cc-number, tel, ...),
|
|
20
23
|
* optionally followed by "webauthn". A malformed value means the field
|
|
21
24
|
* is not reliably identified for assistive technology that relies on
|
|
@@ -23,12 +26,15 @@
|
|
|
23
26
|
* @implementation-notes
|
|
24
27
|
* - Implements the structural shape of the WHATWG autofill grammar
|
|
25
28
|
* (section/mode/contact-modality prefixes + one field-name token, in
|
|
26
|
-
* order) with the
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
29
|
+
* order) with the fixed field-name vocabulary from the HTML Standard,
|
|
30
|
+
* rather than validating every field-specific constraint (e.g. which
|
|
31
|
+
* contact-modality tokens are legal for which field names), matches
|
|
32
|
+
* this engine's established "scoped" precedent (see aria-helpers.js)
|
|
33
|
+
* for keeping high-confidence fail without reimplementing the entire
|
|
34
|
+
* spec.
|
|
35
|
+
* - A field name that does not suit its control (bday-day on type=tel)
|
|
36
|
+
* passes: browsers still derive the purpose from it, and ACT 73f2c2
|
|
37
|
+
* leaves contextual appropriateness out of scope.
|
|
32
38
|
*/
|
|
33
39
|
|
|
34
40
|
const id = 'autocomplete-valid';
|
|
@@ -113,18 +119,20 @@ function runInPage(ctx) {
|
|
|
113
119
|
'tel-national',
|
|
114
120
|
'tel-area-code',
|
|
115
121
|
'tel-local',
|
|
122
|
+
'tel-local-prefix',
|
|
123
|
+
'tel-local-suffix',
|
|
116
124
|
'tel-extension',
|
|
117
125
|
'email',
|
|
118
126
|
'impp',
|
|
119
127
|
'url',
|
|
120
128
|
'photo'
|
|
121
129
|
]);
|
|
122
|
-
const CONTACT_MODALITY = new Set(['home', 'work', 'mobile', 'fax', 'pager'
|
|
130
|
+
const CONTACT_MODALITY = new Set(['home', 'work', 'mobile', 'fax', 'pager']);
|
|
123
131
|
|
|
132
|
+
// True when the value is a well-formed autofill detail token list.
|
|
124
133
|
function isValidAutocomplete(raw) {
|
|
125
134
|
const tokens = raw.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
126
135
|
if (!tokens.length) return false;
|
|
127
|
-
if (tokens.length === 1 && (tokens[0] === 'on' || tokens[0] === 'off')) return true;
|
|
128
136
|
|
|
129
137
|
let i = 0;
|
|
130
138
|
if (tokens[i] && tokens[i].startsWith('section-') && tokens[i].length > 'section-'.length)
|
|
@@ -175,6 +183,13 @@ function runInPage(ctx) {
|
|
|
175
183
|
if (FIXED_VALUE_TYPES.has(type)) return true;
|
|
176
184
|
}
|
|
177
185
|
if (el.hasAttribute && el.hasAttribute('disabled')) return true;
|
|
186
|
+
// A control inside a disabled fieldset (outside its first legend) is
|
|
187
|
+
// disabled too.
|
|
188
|
+
try {
|
|
189
|
+
if (el.matches && el.matches(':disabled')) return true;
|
|
190
|
+
} catch {
|
|
191
|
+
/* selector unsupported */
|
|
192
|
+
}
|
|
178
193
|
if (String(el.getAttribute('aria-disabled') || '').toLowerCase() === 'true') return true;
|
|
179
194
|
return false;
|
|
180
195
|
}
|
|
@@ -197,7 +212,7 @@ function runInPage(ctx) {
|
|
|
197
212
|
occurrences.push(
|
|
198
213
|
helpers.reportOccurrence(el, {
|
|
199
214
|
summary: 'This autocomplete attribute value is not a valid autofill value.',
|
|
200
|
-
hint: 'Use "on"/"off", or a valid autofill token list (e.g. "shipping
|
|
215
|
+
hint: 'Use "on"/"off", or a valid autofill token list (e.g. "shipping postal-code", "cc-number").',
|
|
201
216
|
i18n: {
|
|
202
217
|
summaryKey: 'autocompleteValid_summary_fail',
|
|
203
218
|
hintKey: 'autocompleteValid_hint_fail',
|
|
@@ -25,12 +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 layout would settle and this cannot.
|
|
29
|
-
* establish that no wrap is possible -- text not allowed to wrap,
|
|
30
|
-
* fixed-width element inside a horizontally scrolling ancestor
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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.
|
|
34
39
|
*/
|
|
35
40
|
|
|
36
41
|
const id = 'avoid-inline-spacing';
|
|
@@ -89,6 +94,7 @@ function runInPage(ctx) {
|
|
|
89
94
|
let applicableCount = 0;
|
|
90
95
|
const undecided = [];
|
|
91
96
|
const noWrap = [];
|
|
97
|
+
const shortText = [];
|
|
92
98
|
|
|
93
99
|
// Within one declaration block, importance wins over order, so the last
|
|
94
100
|
// important declaration is the one that takes effect. Passed Example 5 of ACT
|
|
@@ -184,6 +190,32 @@ function runInPage(ctx) {
|
|
|
184
190
|
return false;
|
|
185
191
|
}
|
|
186
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
|
+
|
|
187
219
|
// ACT scopes these rules to text visible on screen, and text pushed far off
|
|
188
220
|
// canvas is the one hidden shape the shared eligibility check keeps eligible.
|
|
189
221
|
function isOffScreen(el) {
|
|
@@ -290,10 +322,15 @@ function runInPage(ctx) {
|
|
|
290
322
|
// A forced value on text that cannot wrap is outside these ACT rules'
|
|
291
323
|
// applicability, and whether it wraps is not decidable here, so it is
|
|
292
324
|
// reported for review rather than failed.
|
|
293
|
-
|
|
325
|
+
const text = blockText(el);
|
|
326
|
+
if (cannotSoftWrap(el) || hasNoBreakOpportunity(text)) {
|
|
294
327
|
noWrap.push({ el, props: flagged.slice() });
|
|
295
328
|
continue;
|
|
296
329
|
}
|
|
330
|
+
if (mayFitOnOneLine(el, text)) {
|
|
331
|
+
shortText.push({ el, props: flagged.slice() });
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
297
334
|
|
|
298
335
|
const tag = el.tagName.toLowerCase();
|
|
299
336
|
|
|
@@ -316,27 +353,48 @@ function runInPage(ctx) {
|
|
|
316
353
|
if (applicableCount === 0) {
|
|
317
354
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
318
355
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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 }) =>
|
|
334
391
|
helpers.reportOccurrence(el, {
|
|
335
|
-
|
|
336
|
-
|
|
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.',
|
|
337
395
|
i18n: {
|
|
338
|
-
summaryKey: '
|
|
339
|
-
hintKey: '
|
|
396
|
+
summaryKey: 'avoidInlineSpacing_summary_cantTell_shortText',
|
|
397
|
+
hintKey: 'avoidInlineSpacing_hint_cantTell_shortText',
|
|
340
398
|
params: {
|
|
341
399
|
element: (el.tagName || '').toLowerCase(),
|
|
342
400
|
properties: props.join(', ')
|
|
@@ -348,28 +406,23 @@ function runInPage(ctx) {
|
|
|
348
406
|
evidence: {
|
|
349
407
|
element: (el.tagName || '').toLowerCase(),
|
|
350
408
|
properties: props,
|
|
351
|
-
reasonCode: '
|
|
409
|
+
reasonCode: 'INLINE_SPACING_SHORT_TEXT'
|
|
352
410
|
}
|
|
353
411
|
},
|
|
354
412
|
data: {
|
|
355
413
|
details: {
|
|
356
|
-
reasonCode: '
|
|
414
|
+
reasonCode: 'INLINE_SPACING_SHORT_TEXT',
|
|
357
415
|
element: (el.tagName || '').toLowerCase(),
|
|
358
416
|
properties: props
|
|
359
417
|
}
|
|
360
418
|
}
|
|
361
419
|
})
|
|
362
420
|
)
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
return {
|
|
367
|
-
ruleId: rule.ruleId,
|
|
368
|
-
outcome: 'cantTell',
|
|
369
|
-
severity: rule.defaultSeverity || 'moderate',
|
|
370
|
-
confidence: 'low',
|
|
371
|
-
occurrences: undecided.map(({ el, props }) =>
|
|
421
|
+
)
|
|
422
|
+
.concat(
|
|
423
|
+
undecided.map(({ el, props }) =>
|
|
372
424
|
helpers.reportOccurrence(el, {
|
|
425
|
+
occurrenceOutcome: 'cantTell',
|
|
373
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.`,
|
|
374
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.',
|
|
375
428
|
i18n: {
|
|
@@ -398,9 +451,21 @@ function runInPage(ctx) {
|
|
|
398
451
|
}
|
|
399
452
|
})
|
|
400
453
|
)
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
+
};
|
|
404
469
|
}
|
|
405
470
|
|
|
406
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.
|