@surea11y/core 1.7.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/CHANGELOG.md +103 -1
  2. package/README.md +157 -54
  3. package/docs/ACT_RULE_MAPPING.md +8 -7
  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 +2 -2
  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 +26 -11
  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 +14285 -2219
  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,12 +10,15 @@
10
10
  * @sc 1.3.5
11
11
  * @applicability
12
12
  * Applies to form controls (input, select, textarea) with a non-empty
13
- * autocomplete attribute.
13
+ * autocomplete attribute. Disabled controls (the disabled attribute,
14
+ * including a control disabled by a disabled fieldset ancestor, or
15
+ * aria-disabled="true") and input types with a fixed value are exempt,
16
+ * as in ACT 73f2c2.
14
17
  * @expectation
15
18
  * The value is "on"/"off" alone, or a well-formed autofill detail
16
19
  * token list: an optional "section-*" token, then an optional
17
20
  * "shipping"/"billing" token, then an optional contact-modality token
18
- * (home/work/mobile/fax/pager/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
23
  * optionally followed by "webauthn". A malformed value means the field
21
24
  * is not reliably identified for assistive technology that relies on
@@ -23,12 +26,15 @@
23
26
  * @implementation-notes
24
27
  * - Implements the structural shape of the WHATWG autofill grammar
25
28
  * (section/mode/contact-modality prefixes + one field-name token, in
26
- * order) with the 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.
29
+ * order) with the fixed field-name vocabulary from the HTML Standard,
30
+ * rather than validating every field-specific constraint (e.g. which
31
+ * contact-modality tokens are legal for which field names), matches
32
+ * this engine's established "scoped" precedent (see aria-helpers.js)
33
+ * for keeping high-confidence fail without reimplementing the entire
34
+ * spec.
35
+ * - A field name that does not suit its control (bday-day on type=tel)
36
+ * passes: browsers still derive the purpose from it, and ACT 73f2c2
37
+ * leaves contextual appropriateness out of scope.
32
38
  */
33
39
 
34
40
  const id = 'autocomplete-valid';
@@ -113,18 +119,20 @@ function runInPage(ctx) {
113
119
  'tel-national',
114
120
  'tel-area-code',
115
121
  'tel-local',
122
+ 'tel-local-prefix',
123
+ 'tel-local-suffix',
116
124
  'tel-extension',
117
125
  'email',
118
126
  'impp',
119
127
  'url',
120
128
  'photo'
121
129
  ]);
122
- const CONTACT_MODALITY = new Set(['home', 'work', 'mobile', 'fax', 'pager', 'impp']);
130
+ const CONTACT_MODALITY = new Set(['home', 'work', 'mobile', 'fax', 'pager']);
123
131
 
132
+ // True when the value is a well-formed autofill detail token list.
124
133
  function isValidAutocomplete(raw) {
125
134
  const tokens = raw.trim().toLowerCase().split(/\s+/).filter(Boolean);
126
135
  if (!tokens.length) return false;
127
- if (tokens.length === 1 && (tokens[0] === 'on' || tokens[0] === 'off')) return true;
128
136
 
129
137
  let i = 0;
130
138
  if (tokens[i] && tokens[i].startsWith('section-') && tokens[i].length > 'section-'.length)
@@ -175,6 +183,13 @@ function runInPage(ctx) {
175
183
  if (FIXED_VALUE_TYPES.has(type)) return true;
176
184
  }
177
185
  if (el.hasAttribute && el.hasAttribute('disabled')) return true;
186
+ // A control inside a disabled fieldset (outside its first legend) is
187
+ // disabled too.
188
+ try {
189
+ if (el.matches && el.matches(':disabled')) return true;
190
+ } catch {
191
+ /* selector unsupported */
192
+ }
178
193
  if (String(el.getAttribute('aria-disabled') || '').toLowerCase() === 'true') return true;
179
194
  return false;
180
195
  }
@@ -197,7 +212,7 @@ function runInPage(ctx) {
197
212
  occurrences.push(
198
213
  helpers.reportOccurrence(el, {
199
214
  summary: 'This autocomplete attribute value is not a valid autofill value.',
200
- hint: 'Use "on"/"off", or a valid autofill token list (e.g. "shipping street-address", "cc-number").',
215
+ hint: 'Use "on"/"off", or a valid autofill token list (e.g. "shipping postal-code", "cc-number").',
201
216
  i18n: {
202
217
  summaryKey: 'autocompleteValid_summary_fail',
203
218
  hintKey: 'autocompleteValid_hint_fail',
@@ -25,12 +25,17 @@
25
25
  * - Within one declaration block, importance outranks order, so the effective
26
26
  * declaration is the last `!important` one for that property.
27
27
  * - ACT 78fd32/24afc2/9e45ec additionally require the text to contain a soft
28
- * wrap break, which layout would settle and this cannot. Two shapes do
29
- * establish that no wrap is possible -- text not allowed to wrap, and a
30
- * fixed-width element inside a horizontally scrolling ancestor -- and those
31
- * are reported for review rather than failed. Anything else is treated as
32
- * wrapping, so a forced value on text that never wraps for some other reason
33
- * is still reported.
28
+ * wrap break, which layout would settle and this cannot. Three shapes
29
+ * establish that no wrap is possible -- text not allowed to wrap, a
30
+ * fixed-width element inside a horizontally scrolling ancestor, and text
31
+ * with no break opportunity (one word, no CJK characters) -- and those are
32
+ * reported for review rather than failed.
33
+ * - Text short enough to fit on one line may never wrap either, so it is also
34
+ * reviewed rather than failed. "Short" is an estimate made without layout:
35
+ * the text's length at half an em per character (a full em for CJK and
36
+ * other wide characters) fits within 320 CSS pixels, the narrowest width
37
+ * WCAG 1.4.10 asks content to reflow to. Longer text is treated as wrapping
38
+ * and fails.
34
39
  */
35
40
 
36
41
  const id = 'avoid-inline-spacing';
@@ -89,6 +94,7 @@ function runInPage(ctx) {
89
94
  let applicableCount = 0;
90
95
  const undecided = [];
91
96
  const noWrap = [];
97
+ const shortText = [];
92
98
 
93
99
  // Within one declaration block, importance wins over order, so the last
94
100
  // important declaration is the one that takes effect. Passed Example 5 of ACT
@@ -184,6 +190,32 @@ function runInPage(ctx) {
184
190
  return false;
185
191
  }
186
192
 
193
+ // Text with no break opportunity cannot take a soft wrap break: a single
194
+ // word of a script that separates words with spaces. CJK text can break
195
+ // between most characters, so it never counts as unbreakable here.
196
+ const WIDE_CHAR =
197
+ /[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]/;
198
+ function blockText(el) {
199
+ return String(el.textContent || '')
200
+ .replace(/\s+/g, ' ')
201
+ .trim();
202
+ }
203
+
204
+ function hasNoBreakOpportunity(text) {
205
+ return !!text && !/\s/.test(text) && !WIDE_CHAR.test(text);
206
+ }
207
+
208
+ // Whether the text may fit on one line at the narrowest width WCAG 1.4.10
209
+ // asks content to reflow to (320 CSS pixels), estimated without layout at
210
+ // half an em per character and a full em per wide character.
211
+ const REFLOW_WIDTH_PX = 320;
212
+ function mayFitOnOneLine(el, text) {
213
+ const fontSize = fontSizeOf(computedStyleOf(el)) || 16;
214
+ let ems = 0;
215
+ for (const ch of text) ems += WIDE_CHAR.test(ch) ? 1 : 0.5;
216
+ return ems * fontSize <= REFLOW_WIDTH_PX;
217
+ }
218
+
187
219
  // ACT scopes these rules to text visible on screen, and text pushed far off
188
220
  // canvas is the one hidden shape the shared eligibility check keeps eligible.
189
221
  function isOffScreen(el) {
@@ -290,10 +322,15 @@ function runInPage(ctx) {
290
322
  // A forced value on text that cannot wrap is outside these ACT rules'
291
323
  // applicability, and whether it wraps is not decidable here, so it is
292
324
  // reported for review rather than failed.
293
- if (cannotSoftWrap(el)) {
325
+ const text = blockText(el);
326
+ if (cannotSoftWrap(el) || hasNoBreakOpportunity(text)) {
294
327
  noWrap.push({ el, props: flagged.slice() });
295
328
  continue;
296
329
  }
330
+ if (mayFitOnOneLine(el, text)) {
331
+ shortText.push({ el, props: flagged.slice() });
332
+ continue;
333
+ }
297
334
 
298
335
  const tag = el.tagName.toLowerCase();
299
336
 
@@ -316,27 +353,48 @@ function runInPage(ctx) {
316
353
  if (applicableCount === 0) {
317
354
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
318
355
  }
319
- if (occurrences.length) {
320
- return {
321
- ruleId: rule.ruleId,
322
- outcome: 'fail',
323
- severity: rule.defaultSeverity || 'moderate',
324
- occurrences
325
- };
326
- }
327
- if (noWrap.length) {
328
- return {
329
- ruleId: rule.ruleId,
330
- outcome: 'cantTell',
331
- severity: rule.defaultSeverity || 'moderate',
332
- confidence: 'low',
333
- occurrences: noWrap.map(({ el, props }) =>
356
+
357
+ const cantTellOccurrences = noWrap
358
+ .map(({ el, props }) =>
359
+ helpers.reportOccurrence(el, {
360
+ occurrenceOutcome: 'cantTell',
361
+ summary: `This element's inline style forces ${props.join(', ')} with !important, but its text does not appear able to wrap, so the text-spacing criterion may not apply to it.`,
362
+ hint: 'Confirm whether this text ever wraps. If it cannot, the criterion does not apply; if it can, remove !important or set a value that already meets the metric.',
363
+ i18n: {
364
+ summaryKey: 'avoidInlineSpacing_summary_cantTell_noSoftWrap',
365
+ hintKey: 'avoidInlineSpacing_hint_cantTell_noSoftWrap',
366
+ params: {
367
+ element: (el.tagName || '').toLowerCase(),
368
+ properties: props.join(', ')
369
+ }
370
+ },
371
+ uncertainty: {
372
+ code: 'not-computable',
373
+ needed: 'Whether this text ever contains a soft wrap break, which needs layout.',
374
+ evidence: {
375
+ element: (el.tagName || '').toLowerCase(),
376
+ properties: props,
377
+ reasonCode: 'INLINE_SPACING_NO_SOFT_WRAP'
378
+ }
379
+ },
380
+ data: {
381
+ details: {
382
+ reasonCode: 'INLINE_SPACING_NO_SOFT_WRAP',
383
+ element: (el.tagName || '').toLowerCase(),
384
+ properties: props
385
+ }
386
+ }
387
+ })
388
+ )
389
+ .concat(
390
+ shortText.map(({ el, props }) =>
334
391
  helpers.reportOccurrence(el, {
335
- summary: `This element's inline style forces ${props.join(', ')} with !important, but its text does not appear able to wrap, so the text-spacing criterion may not apply to it.`,
336
- hint: 'Confirm whether this text ever wraps. If it cannot, the criterion does not apply; if it can, remove !important or set a value that already meets the metric.',
392
+ occurrenceOutcome: 'cantTell',
393
+ summary: `This element's inline style forces ${props.join(', ')} with !important, but its text is short enough to fit on one line, so it may never wrap and the text-spacing criterion may not apply to it.`,
394
+ hint: 'Confirm whether this text wraps at narrow widths. If it always fits on one line, the criterion does not apply; if it can wrap, remove !important or set a value that already meets the metric.',
337
395
  i18n: {
338
- summaryKey: 'avoidInlineSpacing_summary_cantTell_noSoftWrap',
339
- hintKey: 'avoidInlineSpacing_hint_cantTell_noSoftWrap',
396
+ summaryKey: 'avoidInlineSpacing_summary_cantTell_shortText',
397
+ hintKey: 'avoidInlineSpacing_hint_cantTell_shortText',
340
398
  params: {
341
399
  element: (el.tagName || '').toLowerCase(),
342
400
  properties: props.join(', ')
@@ -348,28 +406,23 @@ function runInPage(ctx) {
348
406
  evidence: {
349
407
  element: (el.tagName || '').toLowerCase(),
350
408
  properties: props,
351
- reasonCode: 'INLINE_SPACING_NO_SOFT_WRAP'
409
+ reasonCode: 'INLINE_SPACING_SHORT_TEXT'
352
410
  }
353
411
  },
354
412
  data: {
355
413
  details: {
356
- reasonCode: 'INLINE_SPACING_NO_SOFT_WRAP',
414
+ reasonCode: 'INLINE_SPACING_SHORT_TEXT',
357
415
  element: (el.tagName || '').toLowerCase(),
358
416
  properties: props
359
417
  }
360
418
  }
361
419
  })
362
420
  )
363
- };
364
- }
365
- if (undecided.length) {
366
- return {
367
- ruleId: rule.ruleId,
368
- outcome: 'cantTell',
369
- severity: rule.defaultSeverity || 'moderate',
370
- confidence: 'low',
371
- occurrences: undecided.map(({ el, props }) =>
421
+ )
422
+ .concat(
423
+ undecided.map(({ el, props }) =>
372
424
  helpers.reportOccurrence(el, {
425
+ occurrenceOutcome: 'cantTell',
373
426
  summary: `This element's inline style sets ${props.join(', ')} with !important, but the value could not be resolved, so whether it meets the WCAG text-spacing metric could not be determined.`,
374
427
  hint: 'Check this value by hand against the metric (line-height 1.5, letter-spacing 0.12em, word-spacing 0.16em), or express it in a unit the engine can resolve against the element’s computed font size.',
375
428
  i18n: {
@@ -398,9 +451,21 @@ function runInPage(ctx) {
398
451
  }
399
452
  })
400
453
  )
401
- };
402
- }
403
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
454
+ );
455
+
456
+ // See helpers.resolveTieredOutcome (src/core/dom-helpers.js): a fail-tier
457
+ // finding never silently discards the cantTell-tier findings from the same
458
+ // run, so an undecided element survives a failure elsewhere on the page.
459
+ const resolved = helpers.resolveTieredOutcome(
460
+ occurrences,
461
+ cantTellOccurrences,
462
+ rule.defaultSeverity || 'moderate'
463
+ );
464
+ return {
465
+ ruleId: rule.ruleId,
466
+ ...resolved,
467
+ ...(resolved.outcome === 'cantTell' ? { confidence: 'low' } : null)
468
+ };
404
469
  }
405
470
 
406
471
  module.exports = { id, meta, runInPage };
@@ -22,7 +22,8 @@
22
22
  * "Submit"/"Reset" default, which is why those two are never nameless.
23
23
  * Failing both, a button whose role is name-from-content falls back to its
24
24
  * subtree text, counting each descendant's own name (an <img alt>,
25
- * aria-label or title) rather than only text nodes.
25
+ * aria-label, an <svg>'s <title> child, or title) rather than only text
26
+ * nodes.
26
27
  */
27
28
 
28
29
  // NOTE: Repo ruleId contract requires ENGINE_TAG prefix in the rule id.