@surea11y/core 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +94 -1
- package/README.md +157 -54
- package/docs/ACT_RULE_MAPPING.md +2 -2
- 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 +1 -1
- 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 +152 -26
- 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 +14419 -2231
- 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,25 +10,40 @@
|
|
|
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
|
-
* optionally followed by "webauthn".
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
+
* optionally followed by "webauthn". The field name must also suit the
|
|
24
|
+
* control: the HTML Standard gives each field name a control group, and
|
|
25
|
+
* each group is allowed only on some input types (street-address only on
|
|
26
|
+
* textarea or select; email only on text, search or email inputs; and so
|
|
27
|
+
* on). A malformed or unsuitable value means the field is not reliably
|
|
28
|
+
* identified for assistive technology that relies on autocomplete to
|
|
29
|
+
* describe the expected input purpose.
|
|
23
30
|
* @implementation-notes
|
|
24
31
|
* - Implements the structural shape of the WHATWG autofill grammar
|
|
25
32
|
* (section/mode/contact-modality prefixes + one field-name token, in
|
|
26
|
-
* order) with the
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
33
|
+
* order) with the fixed field-name vocabulary from the HTML Standard,
|
|
34
|
+
* rather than validating every field-specific constraint (e.g. which
|
|
35
|
+
* contact-modality tokens are legal for which field names), matches
|
|
36
|
+
* this engine's established "scoped" precedent (see aria-helpers.js)
|
|
37
|
+
* for keeping high-confidence fail without reimplementing the entire
|
|
38
|
+
* spec.
|
|
39
|
+
* - Control groups: textarea, select and input type=hidden accept every
|
|
40
|
+
* group. input types text and search (and a missing or unknown type)
|
|
41
|
+
* accept every group except Multiline (street-address). password, email,
|
|
42
|
+
* url, tel, number, month and date inputs accept only their own group
|
|
43
|
+
* (email also accepts username). Other input types (time, week,
|
|
44
|
+
* datetime-local, range, color) are not checked for the group, as the
|
|
45
|
+
* W3C validator does not check them either. A mismatch is reported with
|
|
46
|
+
* reasonCode AUTOCOMPLETE_FIELD_CONTROL_MISMATCH.
|
|
32
47
|
*/
|
|
33
48
|
|
|
34
49
|
const id = 'autocomplete-valid';
|
|
@@ -113,18 +128,78 @@ function runInPage(ctx) {
|
|
|
113
128
|
'tel-national',
|
|
114
129
|
'tel-area-code',
|
|
115
130
|
'tel-local',
|
|
131
|
+
'tel-local-prefix',
|
|
132
|
+
'tel-local-suffix',
|
|
116
133
|
'tel-extension',
|
|
117
134
|
'email',
|
|
118
135
|
'impp',
|
|
119
136
|
'url',
|
|
120
137
|
'photo'
|
|
121
138
|
]);
|
|
122
|
-
const CONTACT_MODALITY = new Set(['home', 'work', 'mobile', 'fax', 'pager'
|
|
139
|
+
const CONTACT_MODALITY = new Set(['home', 'work', 'mobile', 'fax', 'pager']);
|
|
123
140
|
|
|
124
|
-
|
|
141
|
+
// Control group of each field name that is not in the Text group (HTML
|
|
142
|
+
// Standard, autofill field table).
|
|
143
|
+
const FIELD_GROUP = {
|
|
144
|
+
username: 'username',
|
|
145
|
+
'new-password': 'password',
|
|
146
|
+
'current-password': 'password',
|
|
147
|
+
'one-time-code': 'password',
|
|
148
|
+
'street-address': 'multiline',
|
|
149
|
+
'cc-exp': 'month',
|
|
150
|
+
'cc-exp-month': 'numeric',
|
|
151
|
+
'cc-exp-year': 'numeric',
|
|
152
|
+
'transaction-amount': 'numeric',
|
|
153
|
+
bday: 'date',
|
|
154
|
+
'bday-day': 'numeric',
|
|
155
|
+
'bday-month': 'numeric',
|
|
156
|
+
'bday-year': 'numeric',
|
|
157
|
+
url: 'url',
|
|
158
|
+
photo: 'url',
|
|
159
|
+
impp: 'url',
|
|
160
|
+
tel: 'tel',
|
|
161
|
+
email: 'email'
|
|
162
|
+
};
|
|
163
|
+
// Groups accepted by input types other than text and search. text and
|
|
164
|
+
// search accept every group except multiline.
|
|
165
|
+
const GROUPS_BY_INPUT_TYPE = {
|
|
166
|
+
password: ['password'],
|
|
167
|
+
email: ['email', 'username'],
|
|
168
|
+
url: ['url'],
|
|
169
|
+
tel: ['tel'],
|
|
170
|
+
number: ['numeric'],
|
|
171
|
+
month: ['month'],
|
|
172
|
+
date: ['date']
|
|
173
|
+
};
|
|
174
|
+
const KNOWN_INPUT_TYPES = new Set([
|
|
175
|
+
'hidden',
|
|
176
|
+
'text',
|
|
177
|
+
'search',
|
|
178
|
+
'tel',
|
|
179
|
+
'url',
|
|
180
|
+
'email',
|
|
181
|
+
'password',
|
|
182
|
+
'date',
|
|
183
|
+
'month',
|
|
184
|
+
'week',
|
|
185
|
+
'time',
|
|
186
|
+
'datetime-local',
|
|
187
|
+
'number',
|
|
188
|
+
'range',
|
|
189
|
+
'color',
|
|
190
|
+
'checkbox',
|
|
191
|
+
'radio',
|
|
192
|
+
'file',
|
|
193
|
+
'submit',
|
|
194
|
+
'image',
|
|
195
|
+
'reset',
|
|
196
|
+
'button'
|
|
197
|
+
]);
|
|
198
|
+
|
|
199
|
+
// Returns the field-name token of a well-formed value, or null.
|
|
200
|
+
function getFieldName(raw) {
|
|
125
201
|
const tokens = raw.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
126
|
-
if (!tokens.length) return
|
|
127
|
-
if (tokens.length === 1 && (tokens[0] === 'on' || tokens[0] === 'off')) return true;
|
|
202
|
+
if (!tokens.length) return null;
|
|
128
203
|
|
|
129
204
|
let i = 0;
|
|
130
205
|
if (tokens[i] && tokens[i].startsWith('section-') && tokens[i].length > 'section-'.length)
|
|
@@ -136,7 +211,7 @@ function runInPage(ctx) {
|
|
|
136
211
|
const next = tokens[i + 1];
|
|
137
212
|
const isContactField =
|
|
138
213
|
next === 'email' || next === 'impp' || next === 'tel' || (next || '').startsWith('tel-');
|
|
139
|
-
if (!isContactField) return
|
|
214
|
+
if (!isContactField) return null;
|
|
140
215
|
i += 1;
|
|
141
216
|
}
|
|
142
217
|
|
|
@@ -144,8 +219,24 @@ function runInPage(ctx) {
|
|
|
144
219
|
if (tokens[end - 1] === 'webauthn') end -= 1;
|
|
145
220
|
|
|
146
221
|
const remaining = tokens.slice(i, end);
|
|
147
|
-
if (remaining.length !== 1) return
|
|
148
|
-
return FIELD_NAMES.has(remaining[0]);
|
|
222
|
+
if (remaining.length !== 1) return null;
|
|
223
|
+
return FIELD_NAMES.has(remaining[0]) ? remaining[0] : null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// True when the field name's control group is allowed on this control.
|
|
227
|
+
function fieldSuitsControl(el, fieldName) {
|
|
228
|
+
const tag = String(el.tagName || '').toLowerCase();
|
|
229
|
+
if (tag !== 'input') return true;
|
|
230
|
+
let type = String(el.getAttribute('type') || 'text')
|
|
231
|
+
.trim()
|
|
232
|
+
.toLowerCase();
|
|
233
|
+
if (!KNOWN_INPUT_TYPES.has(type)) type = 'text';
|
|
234
|
+
if (type === 'hidden') return true;
|
|
235
|
+
const group = FIELD_GROUP[fieldName] || 'text';
|
|
236
|
+
if (type === 'text' || type === 'search') return group !== 'multiline';
|
|
237
|
+
const allowed = GROUPS_BY_INPUT_TYPE[type];
|
|
238
|
+
if (!allowed) return true;
|
|
239
|
+
return allowed.includes(group);
|
|
149
240
|
}
|
|
150
241
|
|
|
151
242
|
const nodes = helpers.queryAllSmart
|
|
@@ -175,6 +266,13 @@ function runInPage(ctx) {
|
|
|
175
266
|
if (FIXED_VALUE_TYPES.has(type)) return true;
|
|
176
267
|
}
|
|
177
268
|
if (el.hasAttribute && el.hasAttribute('disabled')) return true;
|
|
269
|
+
// A control inside a disabled fieldset (outside its first legend) is
|
|
270
|
+
// disabled too.
|
|
271
|
+
try {
|
|
272
|
+
if (el.matches && el.matches(':disabled')) return true;
|
|
273
|
+
} catch {
|
|
274
|
+
/* selector unsupported */
|
|
275
|
+
}
|
|
178
276
|
if (String(el.getAttribute('aria-disabled') || '').toLowerCase() === 'true') return true;
|
|
179
277
|
return false;
|
|
180
278
|
}
|
|
@@ -190,21 +288,49 @@ function runInPage(ctx) {
|
|
|
190
288
|
|
|
191
289
|
applicableCount += 1;
|
|
192
290
|
|
|
193
|
-
|
|
194
|
-
|
|
291
|
+
const fieldName = getFieldName(raw);
|
|
195
292
|
const tag = el.tagName.toLowerCase();
|
|
196
293
|
|
|
294
|
+
if (!fieldName) {
|
|
295
|
+
occurrences.push(
|
|
296
|
+
helpers.reportOccurrence(el, {
|
|
297
|
+
summary: 'This autocomplete attribute value is not a valid autofill value.',
|
|
298
|
+
hint: 'Use "on"/"off", or a valid autofill token list (e.g. "shipping postal-code", "cc-number").',
|
|
299
|
+
i18n: {
|
|
300
|
+
summaryKey: 'autocompleteValid_summary_fail',
|
|
301
|
+
hintKey: 'autocompleteValid_hint_fail',
|
|
302
|
+
params: { element: tag, value: raw }
|
|
303
|
+
},
|
|
304
|
+
data: {
|
|
305
|
+
details: { reasonCode: 'AUTOCOMPLETE_VALUE_INVALID', element: tag, value: raw }
|
|
306
|
+
}
|
|
307
|
+
})
|
|
308
|
+
);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (fieldSuitsControl(el, fieldName)) continue;
|
|
313
|
+
|
|
314
|
+
const inputType = String(el.getAttribute('type') || 'text')
|
|
315
|
+
.trim()
|
|
316
|
+
.toLowerCase();
|
|
197
317
|
occurrences.push(
|
|
198
318
|
helpers.reportOccurrence(el, {
|
|
199
|
-
summary:
|
|
200
|
-
hint: 'Use
|
|
319
|
+
summary: `The autofill field name "${fieldName}" is not allowed on an input of type "${inputType}".`,
|
|
320
|
+
hint: 'Use a field name that suits this type of control, or change the control (street-address needs a textarea; email needs a text, search or email input; bday-day needs a text, search or number input).',
|
|
201
321
|
i18n: {
|
|
202
|
-
summaryKey: '
|
|
203
|
-
hintKey: '
|
|
204
|
-
params: { element: tag, value: raw }
|
|
322
|
+
summaryKey: 'autocompleteValid_summary_mismatch',
|
|
323
|
+
hintKey: 'autocompleteValid_hint_mismatch',
|
|
324
|
+
params: { element: tag, value: raw, fieldName, inputType }
|
|
205
325
|
},
|
|
206
326
|
data: {
|
|
207
|
-
details: {
|
|
327
|
+
details: {
|
|
328
|
+
reasonCode: 'AUTOCOMPLETE_FIELD_CONTROL_MISMATCH',
|
|
329
|
+
element: tag,
|
|
330
|
+
value: raw,
|
|
331
|
+
fieldName,
|
|
332
|
+
inputType
|
|
333
|
+
}
|
|
208
334
|
}
|
|
209
335
|
})
|
|
210
336
|
);
|