@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.
Files changed (103) hide show
  1. package/CHANGELOG.md +94 -1
  2. package/README.md +157 -54
  3. package/docs/ACT_RULE_MAPPING.md +2 -2
  4. package/docs/API_STABILITY.md +18 -5
  5. package/docs/BINDING_AUTHORS_GUIDE.md +2 -2
  6. package/docs/CI_INTEGRATIONS.md +43 -0
  7. package/docs/DESIGN_CHALLENGES.md +97 -3
  8. package/docs/EARL.md +1 -1
  9. package/docs/ENGINE_OPTIONS.md +81 -3
  10. package/docs/I18N.md +62 -20
  11. package/docs/JUNIT.md +73 -0
  12. package/docs/LIMITATIONS.md +1 -0
  13. package/docs/OUTPUT_SCHEMA.md +19 -6
  14. package/docs/REPORT.md +7 -2
  15. package/docs/RULE_AUTHORING.md +73 -6
  16. package/docs/RULE_CATALOG.md +139 -116
  17. package/docs/RULE_EXAMPLES.md +2189 -0
  18. package/docs/RULE_HELPERS.md +62 -5
  19. package/docs/RULE_TAXONOMY.md +2 -2
  20. package/docs/SARIF.md +2 -1
  21. package/docs/WCAG_CONFORMANCE.md +56 -3
  22. package/package.json +34 -11
  23. package/profiles/index.js +14 -0
  24. package/src/checks/automatic/area-alt-present.js +87 -31
  25. package/src/checks/automatic/aria-braille-equivalent.js +25 -7
  26. package/src/checks/automatic/aria-hidden-focus.js +74 -18
  27. package/src/checks/automatic/aria-prohibited-attr.js +17 -4
  28. package/src/checks/automatic/aria-required-attr.js +29 -0
  29. package/src/checks/automatic/aria-role-name-present.js +19 -2
  30. package/src/checks/automatic/aria-valid-attr-value.js +28 -16
  31. package/src/checks/automatic/autocomplete-valid.js +152 -26
  32. package/src/checks/automatic/avoid-inline-spacing.js +105 -40
  33. package/src/checks/automatic/button-name-present.js +2 -1
  34. package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
  35. package/src/checks/automatic/combobox-name-present.js +34 -51
  36. package/src/checks/automatic/contrast-computable.js +35 -4
  37. package/src/checks/automatic/contrast-enhanced.js +4 -4
  38. package/src/checks/automatic/contrast-minimum.js +45 -11
  39. package/src/checks/automatic/css-orientation-lock.js +152 -30
  40. package/src/checks/automatic/definition-list-children-valid.js +67 -23
  41. package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
  42. package/src/checks/automatic/dialog-name-present.js +28 -9
  43. package/src/checks/automatic/duplicate-id.js +6 -2
  44. package/src/checks/automatic/identical-iframes-same-purpose.js +4 -4
  45. package/src/checks/automatic/iframe-focusable-content.js +7 -4
  46. package/src/checks/automatic/iframe-title-unique.js +36 -81
  47. package/src/checks/automatic/input-image-alt-present.js +32 -20
  48. package/src/checks/automatic/label-in-name.js +40 -13
  49. package/src/checks/automatic/language-page-present.js +12 -6
  50. package/src/checks/automatic/link-in-text-block.js +272 -60
  51. package/src/checks/automatic/link-name-present.js +13 -5
  52. package/src/checks/automatic/list-children-valid.js +18 -1
  53. package/src/checks/automatic/listbox-name-present.js +19 -49
  54. package/src/checks/automatic/listitem-parent-valid.js +4 -3
  55. package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
  56. package/src/checks/automatic/page-title-present.js +16 -4
  57. package/src/checks/automatic/progressbar-name-present.js +11 -1
  58. package/src/checks/automatic/role-img-text-alternative-present.js +9 -5
  59. package/src/checks/automatic/searchbox-name-present.js +32 -49
  60. package/src/checks/automatic/server-side-image-map-absent.js +48 -28
  61. package/src/checks/automatic/slider-name-present.js +38 -52
  62. package/src/checks/automatic/spinbutton-name-present.js +32 -49
  63. package/src/checks/automatic/target-size-minimum.js +0 -11
  64. package/src/checks/automatic/td-has-header.js +41 -5
  65. package/src/checks/automatic/text-spacing-content-loss.js +548 -0
  66. package/src/checks/automatic/textbox-name-present.js +32 -49
  67. package/src/checks/automatic/valid-lang.js +15 -10
  68. package/src/checks/manual/area-alt-quality-manual.js +113 -31
  69. package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
  70. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
  71. package/src/checks/manual/css-hidden-focus.js +215 -7
  72. package/src/checks/manual/form-control-label-quality-manual.js +109 -5
  73. package/src/checks/manual/heading-order-manual.js +9 -1
  74. package/src/checks/manual/heading-quality-manual.js +143 -9
  75. package/src/checks/manual/img-alt-decorative-manual.js +6 -3
  76. package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
  77. package/src/checks/manual/link-name-quality-manual.js +130 -4
  78. package/src/checks/manual/media-transcript-present-manual.js +65 -8
  79. package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
  80. package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
  81. package/src/checks/manual/p-as-heading-manual.js +89 -44
  82. package/src/checks/manual/page-title-patterns-manual.js +77 -8
  83. package/src/checks/manual/skip-link-manual.js +42 -14
  84. package/src/checks/manual/table-fake-caption-manual.js +32 -1
  85. package/src/checks/manual/video-caption-manual.js +47 -24
  86. package/src/checks/manual-review.js +0 -4
  87. package/src/core.js +14419 -2231
  88. package/src/coverage/en301549-map.js +187 -0
  89. package/src/coverage/standards.js +279 -0
  90. package/src/coverage/wcag-facets.js +1119 -0
  91. package/src/coverage/wcag-version-map.js +101 -0
  92. package/src/en301549.js +33 -0
  93. package/src/junit.js +321 -0
  94. package/src/profile-kit.js +163 -0
  95. package/src/report.js +343 -74
  96. package/src/sarif.js +34 -3
  97. package/src/wcag.js +105 -0
  98. package/surea11y.browser.js +5 -4
  99. package/surea11y.i18n.de.js +1 -1
  100. package/surea11y.i18n.es.js +1 -1
  101. package/surea11y.i18n.fr.js +1 -1
  102. package/surea11y.i18n.ja.js +3 -0
  103. 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' || tag === 'area') {
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
- if (isDomVisibleEligible) {
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), and (b) elements with no role at all:
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('[role]')
152
- : helpers.queryAll('[role]');
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 role = ariaHelpers.getExplicitRole(el);
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
- const ok = !!(ariaLabel || labelled || title);
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, a real number, or an ID
19
- * reference (list) that resolves to an existing element in the document.
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 flags
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
- * - aria-controls is never a fail on a target that doesn't resolve, and
42
- * this is the one place the rule reports two tiers. The controlled
43
- * element is routinely built when the widget opens, so a static scan
44
- * that cannot find it has not found a defect; it has found markup it
45
- * cannot decide. A collapsed widget (aria-expanded="false" or
46
- * aria-selected="false") passes outright, since the absence is exactly
47
- * what that state means; anything else is a `cantTell` for human
48
- * review. Every other idref/idref-list attribute keeps its fail: a
49
- * dangling aria-labelledby or aria-owns names content that was supposed
50
- * to be there already.
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: 'Confirm the controlled element is created when the widget opens; if it never exists, remove or correct the reference.',
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: 'ariaValidAttrValue_hint_cantTell_idref',
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: 'Whether the widget creates the referenced element when it opens.',
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/impp), then exactly one recognized
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". A malformed value means the field
21
- * is not reliably identified for assistive technology that relies on
22
- * autocomplete to describe the expected input purpose.
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 full fixed field-name vocabulary from the HTML
27
- * Standard, rather than validating every field-specific constraint
28
- * (e.g. which contact-modality tokens are legal for which field
29
- * names), matches this engine's established "scoped"
30
- * precedent (see aria-helpers.js) for keeping high-confidence fail
31
- * without reimplementing the entire spec.
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', 'impp']);
139
+ const CONTACT_MODALITY = new Set(['home', 'work', 'mobile', 'fax', 'pager']);
123
140
 
124
- function isValidAutocomplete(raw) {
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 false;
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 false;
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 false;
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
- if (isValidAutocomplete(raw)) continue;
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: 'This autocomplete attribute value is not a valid autofill value.',
200
- hint: 'Use "on"/"off", or a valid autofill token list (e.g. "shipping street-address", "cc-number").',
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: 'autocompleteValid_summary_fail',
203
- hintKey: 'autocompleteValid_hint_fail',
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: { reasonCode: 'AUTOCOMPLETE_VALUE_INVALID', element: tag, value: raw }
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
  );