@surea11y/core 1.4.0 → 1.5.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 (115) hide show
  1. package/CHANGELOG.md +88 -7
  2. package/README.md +19 -3
  3. package/bin/surea11y-core.js +0 -0
  4. package/docs/API_STABILITY.md +2 -2
  5. package/docs/ARIA_DEPRECATION.md +95 -0
  6. package/docs/ENGINE_OPTIONS.md +14 -10
  7. package/docs/I18N.md +176 -20
  8. package/docs/INTEGRATION.md +28 -6
  9. package/docs/LIMITATIONS.md +1 -1
  10. package/docs/OUTPUT_SCHEMA.md +13 -3
  11. package/docs/REPORT.md +2 -0
  12. package/docs/RULE_AUTHORING.md +53 -0
  13. package/docs/RULE_CATALOG.md +6 -6
  14. package/docs/TROUBLESHOOTING.md +2 -2
  15. package/package.json +8 -1
  16. package/src/checks/automatic/aria-allowed-attr.js +661 -99
  17. package/src/checks/automatic/aria-allowed-role.js +14 -16
  18. package/src/checks/automatic/aria-braille-equivalent.js +17 -19
  19. package/src/checks/automatic/aria-conditional-attr.js +17 -19
  20. package/src/checks/automatic/aria-deprecated-role.js +122 -41
  21. package/src/checks/automatic/aria-hidden-body.js +2 -9
  22. package/src/checks/automatic/aria-hidden-focus.js +99 -18
  23. package/src/checks/automatic/aria-prohibited-attr.js +54 -55
  24. package/src/checks/automatic/aria-prohibited-children.js +26 -26
  25. package/src/checks/automatic/aria-required-attr.js +14 -17
  26. package/src/checks/automatic/aria-required-children.js +17 -20
  27. package/src/checks/automatic/aria-required-parent.js +17 -20
  28. package/src/checks/automatic/aria-roles-valid.js +66 -27
  29. package/src/checks/automatic/aria-valid-attr-value.js +18 -21
  30. package/src/checks/automatic/aria-valid-attr.js +14 -17
  31. package/src/checks/automatic/autocomplete-valid.js +52 -18
  32. package/src/checks/automatic/avoid-inline-spacing.js +192 -34
  33. package/src/checks/automatic/binary-control-name-present.js +30 -24
  34. package/src/checks/automatic/button-name-present.js +73 -45
  35. package/src/checks/automatic/canvas-text-alternative-present.js +15 -8
  36. package/src/checks/automatic/combobox-name-present.js +23 -18
  37. package/src/checks/automatic/css-orientation-lock.js +22 -22
  38. package/src/checks/automatic/definition-list-children-valid.js +18 -21
  39. package/src/checks/automatic/deprecated-elements-not-used.js +14 -16
  40. package/src/checks/automatic/dialog-name-present.js +24 -19
  41. package/src/checks/automatic/dlitem-parent-valid.js +15 -17
  42. package/src/checks/automatic/duplicate-id-aria.js +45 -37
  43. package/src/checks/automatic/form-control-programmatic-label-present.js +48 -4
  44. package/src/checks/automatic/form-control-single-label.js +109 -43
  45. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
  46. package/src/checks/automatic/iframe-focusable-content.js +29 -31
  47. package/src/checks/automatic/iframe-name-present.js +15 -17
  48. package/src/checks/automatic/iframe-title-unique.js +18 -23
  49. package/src/checks/automatic/img-alt-present.js +16 -9
  50. package/src/checks/automatic/input-image-alt-present.js +99 -48
  51. package/src/checks/automatic/label-in-name.js +102 -33
  52. package/src/checks/automatic/language-page-present.js +5 -1
  53. package/src/checks/automatic/link-in-text-block.js +19 -21
  54. package/src/checks/automatic/link-name-present.js +75 -47
  55. package/src/checks/automatic/list-children-valid.js +15 -17
  56. package/src/checks/automatic/listbox-name-present.js +23 -18
  57. package/src/checks/automatic/listitem-parent-valid.js +14 -17
  58. package/src/checks/automatic/menuitem-name-present.js +24 -19
  59. package/src/checks/automatic/meta-refresh-no-exceptions.js +44 -18
  60. package/src/checks/automatic/meta-refresh-timing-absent.js +44 -21
  61. package/src/checks/automatic/meta-viewport-zoom-enabled.js +51 -32
  62. package/src/checks/automatic/meter-name-present.js +24 -19
  63. package/src/checks/automatic/nested-interactive-controls-absent.js +179 -51
  64. package/src/checks/automatic/object-text-alternative-present.js +14 -7
  65. package/src/checks/automatic/option-name-present.js +24 -19
  66. package/src/checks/automatic/progressbar-name-present.js +27 -22
  67. package/src/checks/automatic/searchbox-name-present.js +27 -18
  68. package/src/checks/automatic/server-side-image-map-absent.js +15 -18
  69. package/src/checks/automatic/slider-name-present.js +26 -19
  70. package/src/checks/automatic/spinbutton-name-present.js +27 -18
  71. package/src/checks/automatic/summary-name-present.js +24 -19
  72. package/src/checks/automatic/tab-name-present.js +24 -19
  73. package/src/checks/automatic/table-headers-attr-valid.js +15 -17
  74. package/src/checks/automatic/table-th-has-data-cells.js +82 -25
  75. package/src/checks/automatic/target-size-minimum.js +104 -81
  76. package/src/checks/automatic/td-has-header.js +15 -20
  77. package/src/checks/automatic/textbox-name-present.js +23 -18
  78. package/src/checks/automatic/tooltip-name-present.js +24 -19
  79. package/src/checks/automatic/treeitem-name-present.js +24 -19
  80. package/src/checks/automatic/valid-lang.js +31 -20
  81. package/src/checks/manual/accesskeys-manual.js +18 -19
  82. package/src/checks/manual/aria-checked-state-mismatch-manual.js +19 -22
  83. package/src/checks/{automatic/bypass-blocks-present.js → manual/bypass-blocks-present-manual.js} +98 -44
  84. package/src/checks/manual/empty-heading-manual.js +15 -17
  85. package/src/checks/manual/empty-table-header-manual.js +27 -30
  86. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -9
  87. package/src/checks/manual/heading-order-manual.js +17 -22
  88. package/src/checks/manual/image-redundant-alt-manual.js +14 -17
  89. package/src/checks/manual/input-image-alt-decorative-manual.js +24 -0
  90. package/src/checks/manual/label-title-only-manual.js +15 -17
  91. package/src/checks/manual/landmark-banner-is-top-level-manual.js +25 -39
  92. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +21 -27
  93. package/src/checks/manual/landmark-main-is-top-level-manual.js +14 -17
  94. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +2 -7
  95. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +2 -7
  96. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -7
  97. package/src/checks/manual/landmark-one-main-manual.js +2 -9
  98. package/src/checks/manual/landmark-unique-manual.js +22 -27
  99. package/src/checks/manual/link-name-quality-manual.js +15 -17
  100. package/src/checks/manual/meta-viewport-large-manual.js +14 -17
  101. package/src/checks/manual/mouse-only-event-handlers-manual.js +17 -19
  102. package/src/checks/manual/page-has-heading-one-manual.js +2 -9
  103. package/src/checks/manual/presentation-role-conflict-manual.js +19 -21
  104. package/src/checks/manual/region-manual.js +13 -6
  105. package/src/checks/manual/scope-attr-valid-manual.js +14 -17
  106. package/src/checks/manual/skip-link-manual.js +39 -47
  107. package/src/checks/manual/tabindex-manual.js +14 -17
  108. package/src/checks/manual/table-duplicate-name-manual.js +14 -17
  109. package/src/core.js +8818 -4330
  110. package/src/report.js +14 -0
  111. package/src/sarif.js +2 -2
  112. package/surea11y.browser.js +4023 -3911
  113. package/surea11y.i18n.de.js +22 -0
  114. package/surea11y.i18n.es.js +22 -0
  115. package/surea11y.i18n.fr.js +22 -0
@@ -256,10 +256,6 @@ function runInPage(ctx) {
256
256
  for (const entry of owned) {
257
257
  if (entry.role && requiredSet.has(entry.role)) continue;
258
258
 
259
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(entry.el) : 'html';
260
- const html = helpers.getOuterHtmlSnippet
261
- ? helpers.getOuterHtmlSnippet(entry.el)
262
- : entry.el.outerHTML || '';
263
259
  const containerSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
264
260
 
265
261
  const isRoleless = !entry.role;
@@ -286,29 +282,33 @@ function runInPage(ctx) {
286
282
  hintKey = 'ariaProhibitedChildren_hint_fail';
287
283
  }
288
284
 
289
- occurrences.push({
290
- selector: stableSelector,
291
- html,
292
- summary,
293
- hint,
294
- i18n: {
295
- summaryKey,
296
- hintKey,
297
- params: isRoleless
298
- ? { attr: entry.attr, containerRole: role }
299
- : { childRole: entry.role, containerRole: role, allowedRoles: requiredOwned.join(', ') }
300
- },
301
- data: {
302
- details: {
303
- reasonCode: isRoleless ? 'ARIA_PROHIBITED_CHILD_ROLELESS' : 'ARIA_PROHIBITED_CHILD',
304
- childRole: entry.role,
305
- attr: entry.attr,
306
- containerRole: role,
307
- containerSelector,
308
- allowedOwnedRoles: requiredOwned
285
+ occurrences.push(
286
+ helpers.reportOccurrence(entry.el, {
287
+ summary,
288
+ hint,
289
+ i18n: {
290
+ summaryKey,
291
+ hintKey,
292
+ params: isRoleless
293
+ ? { attr: entry.attr, containerRole: role }
294
+ : {
295
+ childRole: entry.role,
296
+ containerRole: role,
297
+ allowedRoles: requiredOwned.join(', ')
298
+ }
299
+ },
300
+ data: {
301
+ details: {
302
+ reasonCode: isRoleless ? 'ARIA_PROHIBITED_CHILD_ROLELESS' : 'ARIA_PROHIBITED_CHILD',
303
+ childRole: entry.role,
304
+ attr: entry.attr,
305
+ containerRole: role,
306
+ containerSelector,
307
+ allowedOwnedRoles: requiredOwned
308
+ }
309
309
  }
310
- }
311
- });
310
+ })
311
+ );
312
312
  }
313
313
  }
314
314
 
@@ -129,24 +129,21 @@ function runInPage(ctx) {
129
129
 
130
130
  if (!missing.length) continue;
131
131
 
132
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
133
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
134
-
135
132
  for (const attr of missing) {
136
- occurrences.push({
137
- selector: stableSelector,
138
- html,
139
- summary: 'This attribute is required for this element’s role, but is missing.',
140
- hint: 'Add this attribute with a valid value for this role.',
141
- i18n: {
142
- summaryKey: 'ariaRequiredAttr_summary_fail',
143
- hintKey: 'ariaRequiredAttr_hint_fail',
144
- params: { attr, role }
145
- },
146
- data: {
147
- details: { reasonCode: 'ARIA_ATTR_REQUIRED_MISSING', attr, role }
148
- }
149
- });
133
+ occurrences.push(
134
+ helpers.reportOccurrence(el, {
135
+ summary: 'This attribute is required for this element’s role, but is missing.',
136
+ hint: 'Add this attribute with a valid value for this role.',
137
+ i18n: {
138
+ summaryKey: 'ariaRequiredAttr_summary_fail',
139
+ hintKey: 'ariaRequiredAttr_hint_fail',
140
+ params: { attr, role }
141
+ },
142
+ data: {
143
+ details: { reasonCode: 'ARIA_ATTR_REQUIRED_MISSING', attr, role }
144
+ }
145
+ })
146
+ );
150
147
  }
151
148
  }
152
149
 
@@ -253,27 +253,24 @@ function runInPage(ctx) {
253
253
 
254
254
  if (found) continue;
255
255
 
256
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
257
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
258
-
259
- occurrences.push({
260
- selector: stableSelector,
261
- html,
262
- summary: 'This container role has no owned child with a required role.',
263
- hint: 'Add a descendant (or aria-owns-referenced element) with one of the required owned roles.',
264
- i18n: {
265
- summaryKey: 'ariaRequiredChildren_summary_fail',
266
- hintKey: 'ariaRequiredChildren_hint_fail',
267
- params: { role, requiredRoles: requiredOwned.join(', ') }
268
- },
269
- data: {
270
- details: {
271
- reasonCode: 'ARIA_REQUIRED_CHILD_MISSING',
272
- role,
273
- requiredOwnedRoles: requiredOwned
256
+ occurrences.push(
257
+ helpers.reportOccurrence(el, {
258
+ summary: 'This container role has no owned child with a required role.',
259
+ hint: 'Add a descendant (or aria-owns-referenced element) with one of the required owned roles.',
260
+ i18n: {
261
+ summaryKey: 'ariaRequiredChildren_summary_fail',
262
+ hintKey: 'ariaRequiredChildren_hint_fail',
263
+ params: { role, requiredRoles: requiredOwned.join(', ') }
264
+ },
265
+ data: {
266
+ details: {
267
+ reasonCode: 'ARIA_REQUIRED_CHILD_MISSING',
268
+ role,
269
+ requiredOwnedRoles: requiredOwned
270
+ }
274
271
  }
275
- }
276
- });
272
+ })
273
+ );
277
274
  }
278
275
 
279
276
  if (applicableCount === 0) {
@@ -210,27 +210,24 @@ function runInPage(ctx) {
210
210
 
211
211
  if (hasContext) continue;
212
212
 
213
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
214
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
215
-
216
- occurrences.push({
217
- selector: stableSelector,
218
- html,
219
- summary: 'This role requires a specific ancestor/owner context role, which was not found.',
220
- hint: 'Place this element inside (or aria-owns-reference it from) an element with an acceptable context role.',
221
- i18n: {
222
- summaryKey: 'ariaRequiredParent_summary_fail',
223
- hintKey: 'ariaRequiredParent_hint_fail',
224
- params: { role, requiredRoles: requiredContext.join(', ') }
225
- },
226
- data: {
227
- details: {
228
- reasonCode: 'ARIA_REQUIRED_PARENT_MISSING',
229
- role,
230
- requiredContextRoles: requiredContext
213
+ occurrences.push(
214
+ helpers.reportOccurrence(el, {
215
+ summary: 'This role requires a specific ancestor/owner context role, which was not found.',
216
+ hint: 'Place this element inside (or aria-owns-reference it from) an element with an acceptable context role.',
217
+ i18n: {
218
+ summaryKey: 'ariaRequiredParent_summary_fail',
219
+ hintKey: 'ariaRequiredParent_hint_fail',
220
+ params: { role, requiredRoles: requiredContext.join(', ') }
221
+ },
222
+ data: {
223
+ details: {
224
+ reasonCode: 'ARIA_REQUIRED_PARENT_MISSING',
225
+ role,
226
+ requiredContextRoles: requiredContext
227
+ }
231
228
  }
232
- }
233
- });
229
+ })
230
+ );
234
231
  }
235
232
 
236
233
  if (applicableCount === 0) {
@@ -67,41 +67,80 @@ function runInPage(ctx) {
67
67
  const occurrences = [];
68
68
  let applicableCount = 0;
69
69
 
70
+ // Programmatically hidden per the ACT glossary: display:none, visibility not
71
+ // visible, or aria-hidden on the element or an ancestor. inert is treated
72
+ // the same although the glossary predates it -- an inert subtree is out of
73
+ // the accessibility tree entirely, so a role on it reaches no one.
74
+ function isHidden(el) {
75
+ try {
76
+ if (typeof helpers.isDomVisibleEligible === 'function') {
77
+ if (!helpers.isDomVisibleEligible(el, ctx)) return true;
78
+ }
79
+ // Walk the composed tree, not parentElement: that stops at a shadow
80
+ // root, so a host carrying aria-hidden or inert would never be seen
81
+ // from inside its own shadow content.
82
+ const up =
83
+ typeof helpers.composedParent === 'function'
84
+ ? helpers.composedParent
85
+ : (n) => n.parentElement;
86
+
87
+ // A shadow root has no getAttribute, so skip past it rather than
88
+ // stopping: the host one step further up is the node that matters.
89
+ for (let n = el; n; n = up(n)) {
90
+ if (!n.getAttribute) continue;
91
+ if (String(n.getAttribute('aria-hidden') || '').toLowerCase() === 'true') return true;
92
+ if (n.hasAttribute && n.hasAttribute('inert')) return true;
93
+ }
94
+ } catch {
95
+ return false;
96
+ }
97
+ return false;
98
+ }
99
+
70
100
  for (const el of nodes) {
71
101
  if (!el || !el.getAttribute) continue;
72
102
 
73
- const role = ariaHelpers.getExplicitRole(el);
74
- if (!role) continue; // role="" or whitespace-only: not this rule's concern
103
+ // ACT 674b10 is not applicable to a programmatically hidden element.
104
+ if (isHidden(el)) continue;
75
105
 
76
- applicableCount += 1;
106
+ // role takes a fallback list and the first token the browser recognises
107
+ // wins, so role="searchfield searchbox" resolves to searchbox. The rule
108
+ // fails only when no token names a concrete role.
109
+ const tokens =
110
+ typeof ariaHelpers.getAllRoleTokens === 'function'
111
+ ? ariaHelpers.getAllRoleTokens(el)
112
+ : [ariaHelpers.getExplicitRole(el)].filter(Boolean);
113
+ if (!tokens.length) continue; // role="" or whitespace-only: not this rule's concern
77
114
 
78
- const isAbstract = ariaHelpers.isAbstractRole(role);
79
- const isKnown = ariaHelpers.isKnownRole(role);
115
+ applicableCount += 1;
80
116
 
81
- if (isKnown && !isAbstract) continue;
117
+ const usable = tokens.find((t) => ariaHelpers.isKnownRole(t) && !ariaHelpers.isAbstractRole(t));
118
+ if (usable) continue;
82
119
 
120
+ const role = tokens[0];
121
+ const isKnown = tokens.some((t) => ariaHelpers.isKnownRole(t));
83
122
  const reasonCode = !isKnown ? 'ARIA_ROLE_INVALID' : 'ARIA_ROLE_ABSTRACT';
84
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
85
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
86
-
87
- occurrences.push({
88
- selector: stableSelector,
89
- html,
90
- summary: !isKnown
91
- ? 'The role attribute value is not a recognized ARIA role.'
92
- : 'The role attribute value is an abstract ARIA role, which must not be used directly.',
93
- hint: !isKnown
94
- ? 'Use a valid ARIA role token, or remove the role attribute if none applies.'
95
- : 'Replace this abstract role with a concrete role appropriate for the widget/structure.',
96
- i18n: {
97
- summaryKey: !isKnown ? 'ariaRolesValid_summary_invalid' : 'ariaRolesValid_summary_abstract',
98
- hintKey: !isKnown ? 'ariaRolesValid_hint_invalid' : 'ariaRolesValid_hint_abstract',
99
- params: { role }
100
- },
101
- data: {
102
- details: { reasonCode, role }
103
- }
104
- });
123
+
124
+ occurrences.push(
125
+ helpers.reportOccurrence(el, {
126
+ summary: !isKnown
127
+ ? 'The role attribute value is not a recognized ARIA role.'
128
+ : 'The role attribute value is an abstract ARIA role, which must not be used directly.',
129
+ hint: !isKnown
130
+ ? 'Use a valid ARIA role token, or remove the role attribute if none applies.'
131
+ : 'Replace this abstract role with a concrete role appropriate for the widget/structure.',
132
+ i18n: {
133
+ summaryKey: !isKnown
134
+ ? 'ariaRolesValid_summary_invalid'
135
+ : 'ariaRolesValid_summary_abstract',
136
+ hintKey: !isKnown ? 'ariaRolesValid_hint_invalid' : 'ariaRolesValid_hint_abstract',
137
+ params: { role }
138
+ },
139
+ data: {
140
+ details: { reasonCode, role }
141
+ }
142
+ })
143
+ );
105
144
  }
106
145
 
107
146
  if (applicableCount === 0) {
@@ -105,29 +105,26 @@ function runInPage(ctx) {
105
105
 
106
106
  if (!invalid || !invalid.length) continue;
107
107
 
108
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
109
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
110
-
111
108
  for (const item of invalid) {
112
- occurrences.push({
113
- selector: stableSelector,
114
- html,
115
- summary: 'This element has an ARIA attribute with an invalid value.',
116
- hint: 'Use a value that matches the attribute’s expected type (see the WAI-ARIA specification for this attribute).',
117
- i18n: {
118
- summaryKey: 'ariaValidAttrValue_summary_fail',
119
- hintKey: 'ariaValidAttrValue_hint_fail',
120
- params: { attr: item.name, value: item.value }
121
- },
122
- data: {
123
- details: {
124
- reasonCode: 'ARIA_ATTR_VALUE_INVALID',
125
- attr: item.name,
126
- value: item.value,
127
- valueReason: item.reason
109
+ occurrences.push(
110
+ helpers.reportOccurrence(el, {
111
+ summary: 'This element has an ARIA attribute with an invalid value.',
112
+ hint: 'Use a value that matches the attribute’s expected type (see the WAI-ARIA specification for this attribute).',
113
+ i18n: {
114
+ summaryKey: 'ariaValidAttrValue_summary_fail',
115
+ hintKey: 'ariaValidAttrValue_hint_fail',
116
+ params: { attr: item.name, value: item.value }
117
+ },
118
+ data: {
119
+ details: {
120
+ reasonCode: 'ARIA_ATTR_VALUE_INVALID',
121
+ attr: item.name,
122
+ value: item.value,
123
+ valueReason: item.reason
124
+ }
128
125
  }
129
- }
130
- });
126
+ })
127
+ );
131
128
  }
132
129
  }
133
130
 
@@ -86,24 +86,21 @@ function runInPage(ctx) {
86
86
 
87
87
  if (!invalidNames || !invalidNames.length) continue;
88
88
 
89
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
90
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
91
-
92
89
  for (const name of invalidNames) {
93
- occurrences.push({
94
- selector: stableSelector,
95
- html,
96
- summary: 'This element has an attribute that is not a recognized ARIA attribute.',
97
- hint: 'Correct the attribute name (check for typos), or remove it if not needed.',
98
- i18n: {
99
- summaryKey: 'ariaValidAttr_summary_fail',
100
- hintKey: 'ariaValidAttr_hint_fail',
101
- params: { attr: name }
102
- },
103
- data: {
104
- details: { reasonCode: 'ARIA_ATTR_INVALID', attr: name }
105
- }
106
- });
90
+ occurrences.push(
91
+ helpers.reportOccurrence(el, {
92
+ summary: 'This element has an attribute that is not a recognized ARIA attribute.',
93
+ hint: 'Correct the attribute name (check for typos), or remove it if not needed.',
94
+ i18n: {
95
+ summaryKey: 'ariaValidAttr_summary_fail',
96
+ hintKey: 'ariaValidAttr_hint_fail',
97
+ params: { attr: name }
98
+ },
99
+ data: {
100
+ details: { reasonCode: 'ARIA_ATTR_INVALID', attr: name }
101
+ }
102
+ })
103
+ );
107
104
  }
108
105
  }
109
106
 
@@ -130,7 +130,15 @@ function runInPage(ctx) {
130
130
  if (tokens[i] && tokens[i].startsWith('section-') && tokens[i].length > 'section-'.length)
131
131
  i += 1;
132
132
  if (tokens[i] === 'shipping' || tokens[i] === 'billing') i += 1;
133
- if (CONTACT_MODALITY.has(tokens[i])) i += 1;
133
+ // A contact modality token is only allowed when the field that follows is
134
+ // a contact field, so "work photo" is invalid while "work email" is not.
135
+ if (CONTACT_MODALITY.has(tokens[i])) {
136
+ const next = tokens[i + 1];
137
+ const isContactField =
138
+ next === 'email' || next === 'impp' || next === 'tel' || (next || '').startsWith('tel-');
139
+ if (!isContactField) return false;
140
+ i += 1;
141
+ }
134
142
 
135
143
  let end = tokens.length;
136
144
  if (tokens[end - 1] === 'webauthn') end -= 1;
@@ -147,33 +155,59 @@ function runInPage(ctx) {
147
155
  const occurrences = [];
148
156
  let applicableCount = 0;
149
157
 
158
+ // ACT 73f2c2 exempts controls where the attribute cannot describe an input
159
+ // purpose: the on/off toggle, disabled controls, input types with a fixed
160
+ // value, and controls that take no input.
161
+ const FIXED_VALUE_TYPES = new Set([
162
+ 'button',
163
+ 'checkbox',
164
+ 'file',
165
+ 'image',
166
+ 'radio',
167
+ 'reset',
168
+ 'submit'
169
+ ]);
170
+
171
+ function isExempt(el) {
172
+ const tag = String(el.tagName || '').toLowerCase();
173
+ if (tag === 'input') {
174
+ const type = String(el.getAttribute('type') || 'text').toLowerCase();
175
+ if (FIXED_VALUE_TYPES.has(type)) return true;
176
+ }
177
+ if (el.hasAttribute && el.hasAttribute('disabled')) return true;
178
+ if (String(el.getAttribute('aria-disabled') || '').toLowerCase() === 'true') return true;
179
+ return false;
180
+ }
181
+
150
182
  for (const el of nodes) {
151
183
  if (!el || !el.getAttribute) continue;
152
184
  const raw = String(el.getAttribute('autocomplete') || '').trim();
153
185
  if (!raw) continue;
154
186
 
187
+ const tokens = raw.toLowerCase().split(/\s+/).filter(Boolean);
188
+ if (tokens.length === 1 && (tokens[0] === 'on' || tokens[0] === 'off')) continue;
189
+ if (isExempt(el)) continue;
190
+
155
191
  applicableCount += 1;
156
192
 
157
193
  if (isValidAutocomplete(raw)) continue;
158
194
 
159
195
  const tag = el.tagName.toLowerCase();
160
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
161
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
162
-
163
- occurrences.push({
164
- selector: stableSelector,
165
- html,
166
- summary: 'This autocomplete attribute value is not a valid autofill value.',
167
- hint: 'Use "on"/"off", or a valid autofill token list (e.g. "shipping street-address", "cc-number").',
168
- i18n: {
169
- summaryKey: 'autocompleteValid_summary_fail',
170
- hintKey: 'autocompleteValid_hint_fail',
171
- params: { element: tag, value: raw }
172
- },
173
- data: {
174
- details: { reasonCode: 'AUTOCOMPLETE_VALUE_INVALID', element: tag, value: raw }
175
- }
176
- });
196
+
197
+ occurrences.push(
198
+ 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").',
201
+ i18n: {
202
+ summaryKey: 'autocompleteValid_summary_fail',
203
+ hintKey: 'autocompleteValid_hint_fail',
204
+ params: { element: tag, value: raw }
205
+ },
206
+ data: {
207
+ details: { reasonCode: 'AUTOCOMPLETE_VALUE_INVALID', element: tag, value: raw }
208
+ }
209
+ })
210
+ );
177
211
  }
178
212
 
179
213
  if (applicableCount === 0) {