@surea11y/core 1.6.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 (120) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/README.md +179 -90
  3. package/docs/ACT_RULE_MAPPING.md +10 -8
  4. package/docs/API_STABILITY.md +67 -6
  5. package/docs/BINDING_AUTHORS_GUIDE.md +104 -2
  6. package/docs/CI_INTEGRATIONS.md +43 -0
  7. package/docs/DESIGN_CHALLENGES.md +162 -2
  8. package/docs/EARL.md +100 -0
  9. package/docs/ENGINE_OPTIONS.md +109 -5
  10. package/docs/I18N.md +62 -20
  11. package/docs/INTEGRATION.md +4 -2
  12. package/docs/JUNIT.md +73 -0
  13. package/docs/LIMITATIONS.md +4 -1
  14. package/docs/OUTPUT_SCHEMA.md +62 -11
  15. package/docs/POLICY.md +1 -1
  16. package/docs/REPORT.md +7 -2
  17. package/docs/RULE_AUTHORING.md +83 -17
  18. package/docs/RULE_CATALOG.md +212 -139
  19. package/docs/RULE_EXAMPLES.md +2189 -0
  20. package/docs/RULE_HELPERS.md +390 -0
  21. package/docs/RULE_TAXONOMY.md +27 -6
  22. package/docs/SARIF.md +23 -3
  23. package/docs/WCAG_CONFORMANCE.md +64 -3
  24. package/package.json +41 -12
  25. package/profiles/index.js +14 -0
  26. package/src/checks/automatic/area-alt-present.js +87 -31
  27. package/src/checks/automatic/aria-allowed-attr.js +6 -0
  28. package/src/checks/automatic/aria-allowed-role.js +32 -23
  29. package/src/checks/automatic/aria-braille-equivalent.js +43 -17
  30. package/src/checks/automatic/aria-conditional-attr.js +17 -10
  31. package/src/checks/automatic/aria-deprecated-role.js +12 -0
  32. package/src/checks/automatic/aria-hidden-body.js +1 -1
  33. package/src/checks/automatic/aria-hidden-focus.js +74 -18
  34. package/src/checks/automatic/aria-prohibited-attr.js +22 -4
  35. package/src/checks/automatic/aria-prohibited-children.js +6 -6
  36. package/src/checks/automatic/aria-required-attr.js +88 -12
  37. package/src/checks/automatic/aria-required-children.js +33 -16
  38. package/src/checks/automatic/aria-required-parent.js +32 -6
  39. package/src/checks/automatic/aria-role-name-present.js +20 -3
  40. package/src/checks/automatic/aria-roles-valid.js +52 -21
  41. package/src/checks/automatic/aria-valid-attr-value.js +89 -24
  42. package/src/checks/automatic/aria-valid-attr.js +14 -9
  43. package/src/checks/automatic/autocomplete-valid.js +152 -26
  44. package/src/checks/automatic/avoid-inline-spacing.js +207 -15
  45. package/src/checks/automatic/button-name-present.js +2 -1
  46. package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
  47. package/src/checks/automatic/combobox-name-present.js +34 -51
  48. package/src/checks/automatic/contrast-computable.js +45 -4
  49. package/src/checks/automatic/contrast-enhanced.js +16 -4
  50. package/src/checks/automatic/contrast-minimum.js +57 -11
  51. package/src/checks/automatic/css-orientation-lock.js +171 -12
  52. package/src/checks/automatic/definition-list-children-valid.js +67 -23
  53. package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
  54. package/src/checks/automatic/dialog-name-present.js +28 -9
  55. package/src/checks/automatic/duplicate-id-aria.js +5 -0
  56. package/src/checks/automatic/duplicate-id.js +19 -10
  57. package/src/checks/automatic/form-control-single-label.js +9 -0
  58. package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
  59. package/src/checks/automatic/iframe-focusable-content.js +12 -4
  60. package/src/checks/automatic/iframe-title-unique.js +36 -81
  61. package/src/checks/automatic/input-image-alt-present.js +32 -20
  62. package/src/checks/automatic/label-in-name.js +78 -69
  63. package/src/checks/automatic/language-page-present.js +12 -6
  64. package/src/checks/automatic/link-in-text-block.js +512 -44
  65. package/src/checks/automatic/link-name-present.js +13 -5
  66. package/src/checks/automatic/list-children-valid.js +18 -1
  67. package/src/checks/automatic/listbox-name-present.js +19 -49
  68. package/src/checks/automatic/listitem-parent-valid.js +4 -3
  69. package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
  70. package/src/checks/automatic/page-title-present.js +16 -4
  71. package/src/checks/automatic/progressbar-name-present.js +11 -1
  72. package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +9 -5
  73. package/src/checks/automatic/searchbox-name-present.js +32 -49
  74. package/src/checks/automatic/server-side-image-map-absent.js +48 -28
  75. package/src/checks/automatic/slider-name-present.js +38 -52
  76. package/src/checks/automatic/spinbutton-name-present.js +32 -49
  77. package/src/checks/automatic/target-size-minimum.js +84 -16
  78. package/src/checks/automatic/td-has-header.js +60 -23
  79. package/src/checks/automatic/text-spacing-content-loss.js +548 -0
  80. package/src/checks/automatic/textbox-name-present.js +32 -49
  81. package/src/checks/automatic/valid-lang.js +15 -10
  82. package/src/checks/manual/area-alt-quality-manual.js +113 -31
  83. package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
  84. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
  85. package/src/checks/manual/css-hidden-focus.js +215 -7
  86. package/src/checks/manual/form-control-label-quality-manual.js +243 -29
  87. package/src/checks/manual/heading-order-manual.js +9 -1
  88. package/src/checks/manual/heading-quality-manual.js +143 -9
  89. package/src/checks/manual/img-alt-decorative-manual.js +6 -3
  90. package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
  91. package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
  92. package/src/checks/manual/link-name-quality-manual.js +130 -4
  93. package/src/checks/manual/media-transcript-present-manual.js +65 -8
  94. package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
  95. package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
  96. package/src/checks/manual/p-as-heading-manual.js +89 -44
  97. package/src/checks/manual/page-title-patterns-manual.js +77 -8
  98. package/src/checks/manual/password-paste-enabled-manual.js +255 -0
  99. package/src/checks/manual/skip-link-manual.js +42 -14
  100. package/src/checks/manual/table-fake-caption-manual.js +32 -1
  101. package/src/checks/manual/video-caption-manual.js +47 -24
  102. package/src/checks/manual-review.js +0 -4
  103. package/src/core.js +18061 -46194
  104. package/src/coverage/en301549-map.js +187 -0
  105. package/src/coverage/standards.js +279 -0
  106. package/src/coverage/wcag-facets.js +1119 -0
  107. package/src/coverage/wcag-version-map.js +101 -0
  108. package/src/earl.js +144 -0
  109. package/src/en301549.js +33 -0
  110. package/src/junit.js +321 -0
  111. package/src/profile-kit.js +163 -0
  112. package/src/report.js +343 -74
  113. package/src/sarif.js +56 -5
  114. package/src/wcag.js +105 -0
  115. package/surea11y.browser.js +11 -41039
  116. package/surea11y.i18n.de.js +2 -21
  117. package/surea11y.i18n.es.js +2 -21
  118. package/surea11y.i18n.fr.js +2 -21
  119. package/surea11y.i18n.ja.js +3 -0
  120. package/src/checks/manual/area-alt-decorative-manual.js +0 -255
@@ -11,16 +11,15 @@
11
11
  * @applicability
12
12
  * Applies to any element with a non-empty role="" attribute in the composed DOM.
13
13
  * @expectation
14
- * The role attribute's first token (the role actually used by assistive technology;
15
- * later space-separated tokens are author-supplied fallbacks and are not evaluated
16
- * here) must be a real WAI-ARIA role name, and must not be an abstract role
17
- * (abstract roles exist only for the specification's own role taxonomy and must
18
- * never be used directly in markup).
19
- * @implementation-notes
20
- * - Unlike this engine's accessible-name rules, ARIA validity is a static markup
21
- * property independent of current visibility/eligibility, so this rule does not
22
- * gate on isAccTreeEligible: an invalid role is a defect whether or not the
23
- * element happens to be hidden right now.
14
+ * At least one role token names a concrete, non-abstract ARIA role.
15
+ * Graded by what the element falls back to when none does:
16
+ * - FAIL on a roleless host (div, span, custom element), which is left
17
+ * exposed as generic, so the role the author meant reaches no one.
18
+ * - CANTTELL where the element has a native role (a <button>, <nav>,
19
+ * <a href>), which the accessibility tree keeps using. ACT 674b10 lists
20
+ * 4.1.2 as a secondary requirement only, "satisfied through the implicit
21
+ * role," so the bad token is worth reporting but is not itself the
22
+ * criterion failing.
24
23
  */
25
24
 
26
25
  const id = 'aria-roles-valid';
@@ -64,7 +63,8 @@ function runInPage(ctx) {
64
63
  ? helpers.queryAllSmart('[role]')
65
64
  : helpers.queryAll('[role]');
66
65
 
67
- const occurrences = [];
66
+ const failOccurrences = [];
67
+ const cantTellOccurrences = [];
68
68
  let applicableCount = 0;
69
69
 
70
70
  // Programmatically hidden per the ACT glossary: display:none, visibility not
@@ -121,8 +121,42 @@ function runInPage(ctx) {
121
121
  const isKnown = tokens.some((t) => ariaHelpers.isKnownRole(t));
122
122
  const reasonCode = !isKnown ? 'ARIA_ROLE_INVALID' : 'ARIA_ROLE_ABSTRACT';
123
123
 
124
- occurrences.push(
124
+ // An unusable role token leaves the element on its native role, when it
125
+ // has one: ACT 674b10 lists 4.1.2 as only a secondary requirement for
126
+ // exactly that reason. A roleless host (div, span, custom element) has
127
+ // nothing to fall back to and is exposed as generic instead.
128
+ const nativeRole =
129
+ typeof ariaHelpers.getNativeRoleForElement === 'function'
130
+ ? ariaHelpers.getNativeRoleForElement(el) || ''
131
+ : '';
132
+
133
+ if (nativeRole) {
134
+ cantTellOccurrences.push(
135
+ helpers.reportOccurrence(el, {
136
+ occurrenceOutcome: 'cantTell',
137
+ summary: `The role attribute value is not usable, so this element is still exposed as its native role="${nativeRole}".`,
138
+ hint: 'Fix or remove the role token; assistive technology is using the native role in the meantime.',
139
+ i18n: {
140
+ summaryKey: 'ariaRolesValid_summary_cantTell',
141
+ hintKey: 'ariaRolesValid_hint_cantTell',
142
+ params: { role, nativeRole }
143
+ },
144
+ uncertainty: {
145
+ code: 'spec-only',
146
+ needed: 'Whether the native role this element falls back to is the one intended.',
147
+ evidence: { role, nativeRole, fallbackExposed: true }
148
+ },
149
+ data: {
150
+ details: { reasonCode, role, nativeRole }
151
+ }
152
+ })
153
+ );
154
+ continue;
155
+ }
156
+
157
+ failOccurrences.push(
125
158
  helpers.reportOccurrence(el, {
159
+ occurrenceOutcome: 'fail',
126
160
  summary: !isKnown
127
161
  ? 'The role attribute value is not a recognized ARIA role.'
128
162
  : 'The role attribute value is an abstract ARIA role, which must not be used directly.',
@@ -146,15 +180,12 @@ function runInPage(ctx) {
146
180
  if (applicableCount === 0) {
147
181
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
148
182
  }
149
- if (occurrences.length) {
150
- return {
151
- ruleId: rule.ruleId,
152
- outcome: 'fail',
153
- severity: rule.defaultSeverity || 'serious',
154
- occurrences
155
- };
156
- }
157
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
183
+ const resolved = helpers.resolveTieredOutcome(
184
+ failOccurrences,
185
+ cantTellOccurrences,
186
+ rule.defaultSeverity || 'serious'
187
+ );
188
+ return { ruleId: rule.ruleId, ...resolved };
158
189
  }
159
190
 
160
191
  module.exports = { id, meta, runInPage };
@@ -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,6 +39,23 @@
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).
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.
56
+ * - Two tiers in one run means helpers.resolveTieredOutcome decides the
57
+ * aggregate: a real fail elsewhere on the page still reports fail, and
58
+ * the aria-controls occurrences ride along rather than being dropped.
41
59
  */
42
60
 
43
61
  const id = 'aria-valid-attr-value';
@@ -79,13 +97,15 @@ function runInPage(ctx) {
79
97
 
80
98
  const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('*') : helpers.queryAll('*');
81
99
 
82
- const occurrences = [];
100
+ const failOccurrences = [];
101
+ const cantTellOccurrences = [];
83
102
  let applicableCount = 0;
84
103
 
85
104
  for (const el of nodes) {
86
105
  if (!el || !el.attributes || !el.getAttribute) continue;
87
106
 
88
107
  let invalid = null;
108
+ let review = null;
89
109
  const attrs = el.attributes;
90
110
  for (let i = 0; i < attrs.length; i++) {
91
111
  const name = String(attrs[i].name || '').toLowerCase();
@@ -95,21 +115,26 @@ function runInPage(ctx) {
95
115
  applicableCount += 1;
96
116
 
97
117
  const rawValue = el.getAttribute(name);
98
- const result = ariaHelpers.validateAttrValue(name, rawValue);
99
- if (!result.valid) {
118
+ const result = ariaHelpers.validateAttrValue(name, rawValue, el);
119
+ if (result.valid) continue;
120
+
121
+ const item = {
122
+ name,
123
+ value: rawValue == null ? '' : String(rawValue),
124
+ reason: result.reason
125
+ };
126
+
127
+ if (result.review) {
128
+ if (!review) review = [];
129
+ review.push(item);
130
+ } else {
100
131
  if (!invalid) invalid = [];
101
- invalid.push({
102
- name,
103
- value: rawValue == null ? '' : String(rawValue),
104
- reason: result.reason
105
- });
132
+ invalid.push(item);
106
133
  }
107
134
  }
108
135
 
109
- if (!invalid || !invalid.length) continue;
110
-
111
- for (const item of invalid) {
112
- occurrences.push(
136
+ for (const item of invalid || []) {
137
+ failOccurrences.push(
113
138
  helpers.reportOccurrence(el, {
114
139
  summary: 'This element has an ARIA attribute with an invalid value.',
115
140
  hint: 'Use a value that matches the attribute’s expected type (see the WAI-ARIA specification for this attribute).',
@@ -129,20 +154,60 @@ function runInPage(ctx) {
129
154
  })
130
155
  );
131
156
  }
157
+
158
+ for (const item of review || []) {
159
+ const controls = item.name === 'aria-controls';
160
+ cantTellOccurrences.push(
161
+ helpers.reportOccurrence(el, {
162
+ summary:
163
+ 'No element with this id exists right now, so the engine cannot tell whether this reference is wrong.',
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.',
167
+ i18n: {
168
+ summaryKey: 'ariaValidAttrValue_summary_cantTell_idref',
169
+ hintKey: controls
170
+ ? 'ariaValidAttrValue_hint_cantTell_idref'
171
+ : 'ariaValidAttrValue_hint_cantTell_idrefList',
172
+ params: { attr: item.name, value: item.value }
173
+ },
174
+ uncertainty: {
175
+ code: 'runtime-dependent',
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.',
179
+ evidence: {
180
+ attribute: item.name,
181
+ referencedId: item.value,
182
+ resolvedAtScanTime: false
183
+ }
184
+ },
185
+ data: {
186
+ details: {
187
+ reasonCode: 'ARIA_ATTR_VALUE_TARGET_ABSENT',
188
+ attr: item.name,
189
+ value: item.value,
190
+ valueReason: item.reason
191
+ }
192
+ }
193
+ })
194
+ );
195
+ }
132
196
  }
133
197
 
134
198
  if (applicableCount === 0) {
135
199
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
136
200
  }
137
- if (occurrences.length) {
138
- return {
139
- ruleId: rule.ruleId,
140
- outcome: 'fail',
141
- severity: rule.defaultSeverity || 'serious',
142
- occurrences
143
- };
144
- }
145
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
201
+
202
+ // See helpers.resolveTieredOutcome's own header comment
203
+ // (src/core/dom-helpers.js): a fail-tier finding never silently discards
204
+ // cantTell-tier findings from the same run.
205
+ const resolved = helpers.resolveTieredOutcome(
206
+ failOccurrences,
207
+ cantTellOccurrences,
208
+ rule.defaultSeverity || 'serious'
209
+ );
210
+ return { ruleId: rule.ruleId, ...resolved };
146
211
  }
147
212
 
148
213
  module.exports = { id, meta, runInPage };
@@ -16,6 +16,12 @@
16
16
  * specification (catches typos / made-up attribute names, which are
17
17
  * silently ignored by assistive technology and therefore a real,
18
18
  * deterministic defect).
19
+ * Reported at CANTTELL rather than FAIL: an aria-* attribute the spec
20
+ * does not define is inert, so nothing about the element's exposed name,
21
+ * role or value changes because it is there. Where the author meant a real
22
+ * attribute and the element ends up without a name, that absence is the
23
+ * naming rules' decision, not this one's. ACT 5f99a7 maps 1.3.1/4.1.2 as
24
+ * secondary requirements, "less strict" than the rule itself.
19
25
  * @implementation-notes
20
26
  * - Distinct from aria-valid-attr-value (which validates the VALUE
21
27
  * of a recognized attribute), this rule only validates the attribute
@@ -96,6 +102,12 @@ function runInPage(ctx) {
96
102
  hintKey: 'ariaValidAttr_hint_fail',
97
103
  params: { attr: name }
98
104
  },
105
+ uncertainty: {
106
+ code: 'spec-only',
107
+ needed:
108
+ 'Whether the misspelling cost the element a name or state it has no other source for.',
109
+ evidence: { attribute: name, definedInAria: false, exposedEffect: 'none' }
110
+ },
99
111
  data: {
100
112
  details: { reasonCode: 'ARIA_ATTR_INVALID', attr: name }
101
113
  }
@@ -107,15 +119,8 @@ function runInPage(ctx) {
107
119
  if (applicableCount === 0) {
108
120
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
109
121
  }
110
- if (occurrences.length) {
111
- return {
112
- ruleId: rule.ruleId,
113
- outcome: 'fail',
114
- severity: rule.defaultSeverity || 'serious',
115
- occurrences
116
- };
117
- }
118
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
122
+ const resolved = helpers.resolveTieredOutcome([], occurrences, rule.defaultSeverity || 'serious');
123
+ return { ruleId: rule.ruleId, ...resolved };
119
124
  }
120
125
 
121
126
  module.exports = { id, meta, runInPage };
@@ -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
  );