@surea11y/core 1.5.0 → 1.6.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 (145) hide show
  1. package/CHANGELOG.md +193 -149
  2. package/README.md +27 -6
  3. package/docs/ACT_RULE_MAPPING.md +243 -0
  4. package/docs/API_STABILITY.md +2 -2
  5. package/docs/BINDING_AUTHORS_GUIDE.md +3 -3
  6. package/docs/DESIGN_CHALLENGES.md +301 -0
  7. package/docs/ENGINE_OPTIONS.md +16 -4
  8. package/docs/I18N.md +4 -4
  9. package/docs/INTEGRATION.md +1 -1
  10. package/docs/LIMITATIONS.md +6 -4
  11. package/docs/REPORT.md +1 -1
  12. package/docs/RULE_AUTHORING.md +53 -25
  13. package/docs/RULE_CATALOG.md +1878 -169
  14. package/docs/RULE_TAXONOMY.md +2 -2
  15. package/docs/TROUBLESHOOTING.md +2 -2
  16. package/docs/WCAG_CONFORMANCE.md +25 -9
  17. package/package.json +3 -7
  18. package/src/baseline.js +3 -3
  19. package/src/checks/automatic/area-alt-present.js +2 -2
  20. package/src/checks/automatic/aria-allowed-attr.js +68 -10
  21. package/src/checks/automatic/aria-allowed-role.js +2 -2
  22. package/src/checks/automatic/aria-braille-equivalent.js +3 -3
  23. package/src/checks/automatic/aria-conditional-attr.js +5 -5
  24. package/src/checks/automatic/aria-deprecated-role.js +1 -1
  25. package/src/checks/automatic/aria-hidden-body.js +2 -2
  26. package/src/checks/automatic/aria-hidden-focus.js +5 -5
  27. package/src/checks/automatic/aria-prohibited-attr.js +18 -18
  28. package/src/checks/automatic/aria-prohibited-children.js +130 -37
  29. package/src/checks/automatic/aria-required-attr.js +60 -12
  30. package/src/checks/automatic/aria-required-children.js +21 -14
  31. package/src/checks/automatic/aria-required-parent.js +61 -9
  32. package/src/checks/automatic/aria-role-name-present.js +36 -22
  33. package/src/checks/automatic/aria-valid-attr-value.js +15 -12
  34. package/src/checks/automatic/aria-valid-attr.js +1 -1
  35. package/src/checks/automatic/autocomplete-valid.js +2 -2
  36. package/src/checks/automatic/binary-control-name-present.js +27 -5
  37. package/src/checks/automatic/button-name-present.js +92 -6
  38. package/src/checks/automatic/combobox-name-present.js +26 -6
  39. package/src/checks/automatic/contrast-computable.js +32 -0
  40. package/src/checks/automatic/contrast-enhanced.js +21 -1
  41. package/src/checks/automatic/contrast-minimum.js +21 -1
  42. package/src/checks/automatic/css-orientation-lock.js +96 -19
  43. package/src/checks/automatic/definition-list-children-valid.js +7 -8
  44. package/src/checks/automatic/deprecated-elements-not-used.js +1 -1
  45. package/src/checks/automatic/dialog-name-present.js +20 -2
  46. package/src/checks/automatic/duplicate-id-aria.js +5 -3
  47. package/src/checks/automatic/duplicate-id.js +198 -0
  48. package/src/checks/automatic/embed-text-alternative-present.js +2 -2
  49. package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
  50. package/src/checks/automatic/form-control-single-label.js +1 -1
  51. package/src/checks/automatic/iframe-focusable-content.js +63 -7
  52. package/src/checks/automatic/iframe-name-present.js +37 -3
  53. package/src/checks/automatic/iframe-title-unique.js +1 -1
  54. package/src/checks/automatic/img-alt-present.js +12 -4
  55. package/src/checks/automatic/label-in-name.js +172 -18
  56. package/src/checks/automatic/link-in-text-block.js +10 -10
  57. package/src/checks/automatic/link-name-present.js +22 -1
  58. package/src/checks/automatic/list-children-valid.js +6 -6
  59. package/src/checks/automatic/listbox-name-present.js +28 -8
  60. package/src/checks/automatic/listitem-parent-valid.js +4 -4
  61. package/src/checks/automatic/menuitem-name-present.js +20 -2
  62. package/src/checks/automatic/meta-refresh-no-exceptions.js +25 -14
  63. package/src/checks/automatic/meta-refresh-timing-absent.js +14 -7
  64. package/src/checks/automatic/meter-name-present.js +23 -4
  65. package/src/checks/automatic/nested-interactive-controls-absent.js +5 -5
  66. package/src/checks/automatic/option-name-present.js +23 -4
  67. package/src/checks/automatic/page-title-present.js +21 -3
  68. package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
  69. package/src/checks/automatic/progressbar-name-present.js +23 -4
  70. package/src/checks/automatic/role-img-alt-present.js +64 -16
  71. package/src/checks/automatic/searchbox-name-present.js +28 -8
  72. package/src/checks/automatic/server-side-image-map-absent.js +1 -1
  73. package/src/checks/automatic/slider-name-present.js +27 -6
  74. package/src/checks/automatic/spinbutton-name-present.js +28 -8
  75. package/src/checks/automatic/summary-name-present.js +18 -2
  76. package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
  77. package/src/checks/automatic/svg-text-alternative-present.js +13 -10
  78. package/src/checks/automatic/tab-name-present.js +21 -2
  79. package/src/checks/automatic/table-headers-attr-valid.js +43 -8
  80. package/src/checks/automatic/table-th-has-data-cells.js +61 -5
  81. package/src/checks/automatic/target-size-minimum.js +71 -53
  82. package/src/checks/automatic/td-has-header.js +5 -5
  83. package/src/checks/automatic/textbox-name-present.js +28 -8
  84. package/src/checks/automatic/tooltip-name-present.js +21 -2
  85. package/src/checks/automatic/treeitem-name-present.js +23 -4
  86. package/src/checks/automatic/valid-lang.js +92 -7
  87. package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
  88. package/src/checks/manual/accesskeys-manual.js +3 -3
  89. package/src/checks/manual/area-alt-decorative-manual.js +7 -0
  90. package/src/checks/manual/area-alt-quality-manual.js +6 -0
  91. package/src/checks/manual/aria-checked-state-mismatch-manual.js +5 -5
  92. package/src/checks/manual/aria-text-manual.js +4 -4
  93. package/src/checks/manual/bypass-blocks-present-manual.js +44 -26
  94. package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
  95. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
  96. package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
  97. package/src/checks/manual/empty-heading-manual.js +58 -11
  98. package/src/checks/manual/empty-table-header-manual.js +8 -8
  99. package/src/checks/manual/focus-order-semantics-manual.js +15 -15
  100. package/src/checks/manual/form-control-label-quality-manual.js +453 -0
  101. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -2
  102. package/src/checks/manual/heading-order-manual.js +3 -3
  103. package/src/checks/manual/heading-quality-manual.js +338 -0
  104. package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
  105. package/src/checks/manual/image-redundant-alt-manual.js +4 -4
  106. package/src/checks/manual/img-alt-decorative-manual.js +211 -52
  107. package/src/checks/manual/img-alt-quality-manual.js +7 -0
  108. package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
  109. package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
  110. package/src/checks/manual/label-title-only-manual.js +4 -4
  111. package/src/checks/manual/landmark-banner-is-top-level-manual.js +7 -7
  112. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +9 -9
  113. package/src/checks/manual/landmark-main-is-top-level-manual.js +6 -6
  114. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +6 -6
  115. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +6 -6
  116. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -2
  117. package/src/checks/manual/landmark-one-main-manual.js +6 -6
  118. package/src/checks/manual/landmark-unique-manual.js +9 -9
  119. package/src/checks/manual/link-name-quality-manual.js +161 -32
  120. package/src/checks/manual/media-transcript-present-manual.js +2 -3
  121. package/src/checks/manual/meta-viewport-large-manual.js +2 -2
  122. package/src/checks/manual/mouse-only-event-handlers-manual.js +9 -9
  123. package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
  124. package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
  125. package/src/checks/manual/p-as-heading-manual.js +4 -4
  126. package/src/checks/manual/page-has-heading-one-manual.js +6 -6
  127. package/src/checks/manual/page-title-patterns-manual.js +26 -3
  128. package/src/checks/manual/presentation-role-conflict-manual.js +55 -25
  129. package/src/checks/manual/region-manual.js +19 -19
  130. package/src/checks/manual/scope-attr-valid-manual.js +2 -2
  131. package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
  132. package/src/checks/manual/skip-link-manual.js +5 -5
  133. package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
  134. package/src/checks/manual/tabindex-manual.js +2 -2
  135. package/src/checks/manual/table-duplicate-name-manual.js +2 -2
  136. package/src/checks/manual/table-fake-caption-manual.js +2 -2
  137. package/src/checks/manual/video-caption-manual.js +3 -3
  138. package/src/checks/manual-review.js +17 -1
  139. package/src/core.js +8965 -1647
  140. package/src/report.js +2 -2
  141. package/surea11y.browser.js +3768 -611
  142. package/surea11y.i18n.de.js +1 -1
  143. package/surea11y.i18n.es.js +1 -1
  144. package/surea11y.i18n.fr.js +1 -1
  145. package/bin/surea11y-core.js +0 -20
@@ -6,7 +6,7 @@
6
6
  * @check empty-heading
7
7
  * @atomic true
8
8
  * @summary Heading elements must not be empty
9
- * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion)
10
10
  * @applicability
11
11
  * Applies to elements with a heading role: native <h1>-<h6>, or any
12
12
  * element with explicit role="heading" (unless overridden by another
@@ -18,20 +18,20 @@
18
18
  * heading is announced as "heading, level N" with nothing else, which
19
19
  * is confusing when navigating by heading.
20
20
  * @implementation-notes
21
- * - Not WCAG-normative — authored as an advisory, cantTell-capped
21
+ * - Not WCAG-normative, authored as an advisory, cantTell-capped
22
22
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
23
23
  * header comment for the shared rationale/precedent.
24
24
  * - This is also the reconciliation point for the ACT-rules
25
- * "heading-name-present" requirement (see ROADMAP.md "Tier 5
26
- * candidates"): already covered by this pre-existing rule under a
27
- * different name, not a separate gap. `title` is accepted as a naming
25
+ * "heading-name-present" requirement: already covered by this
26
+ * pre-existing rule under a different name, not a separate gap.
27
+ * `title` is accepted as a naming
28
28
  * fallback, and hidden/aria-hidden/display:none headings are excluded
29
29
  * (gated on `isAccTreeEligible`), so an empty heading no AT user could
30
30
  * ever reach is not flagged.
31
31
  * - Descendant name resolution uses the shared, accname-aligned
32
- * `helpers.getContentNameInfo` (see dom-helpers.js) — the same "name
32
+ * `helpers.getContentNameInfo` (see dom-helpers.js), the same "name
33
33
  * from content" implementation the 19 `-name-present` rules already
34
- * use — rather than a narrower hand-rolled walker, so an `<img alt="...">`
34
+ * use, rather than a narrower hand-rolled walker, so an `<img alt="...">`
35
35
  * descendant's alt text (e.g. a
36
36
  * `<h1><a><div><img alt="..."></div></a></h1>` logo header) is correctly
37
37
  * picked up as the heading's name instead of producing a false "empty
@@ -74,11 +74,58 @@ function runInPage(ctx) {
74
74
  return raw.split(/\s+/)[0].toLowerCase();
75
75
  }
76
76
 
77
+ // Same Global States and Properties set used elsewhere in this engine
78
+ // (aria-required-parent.js, aria-prohibited-children.js) for the same
79
+ // presentational-roles-conflict-resolution concept: a native h1-h6
80
+ // marked role="none"/"presentation" still reverts to its native heading
81
+ // role when it carries a global ARIA attribute (even one with an empty
82
+ // value, like aria-label="" -- the attribute's presence is what
83
+ // triggers conflict resolution, not its value).
84
+ const GLOBAL_ARIA_ATTRS = [
85
+ 'aria-atomic',
86
+ 'aria-braillelabel',
87
+ 'aria-brailleroledescription',
88
+ 'aria-busy',
89
+ 'aria-controls',
90
+ 'aria-current',
91
+ 'aria-describedby',
92
+ 'aria-description',
93
+ 'aria-details',
94
+ 'aria-disabled',
95
+ 'aria-dropeffect',
96
+ 'aria-errormessage',
97
+ 'aria-flowto',
98
+ 'aria-grabbed',
99
+ 'aria-haspopup',
100
+ 'aria-hidden',
101
+ 'aria-invalid',
102
+ 'aria-keyshortcuts',
103
+ 'aria-label',
104
+ 'aria-labelledby',
105
+ 'aria-live',
106
+ 'aria-owns',
107
+ 'aria-relevant',
108
+ 'aria-roledescription'
109
+ ];
110
+
111
+ function hasGlobalAriaAttr(el) {
112
+ for (const attr of GLOBAL_ARIA_ATTRS) {
113
+ if (el.getAttribute && el.getAttribute(attr) != null) return true;
114
+ }
115
+ return false;
116
+ }
117
+
77
118
  function isHeading(el) {
78
- const explicit = getExplicitRoleToken(el);
79
- if (explicit) return explicit === 'heading';
80
119
  const tag = el.tagName ? el.tagName.toLowerCase() : '';
81
- return /^h[1-6]$/.test(tag);
120
+ const isNativeHeadingTag = /^h[1-6]$/.test(tag);
121
+
122
+ const explicit = getExplicitRoleToken(el);
123
+ if (!explicit) return isNativeHeadingTag;
124
+ if (explicit === 'heading') return true;
125
+ if ((explicit === 'none' || explicit === 'presentation') && isNativeHeadingTag) {
126
+ return hasGlobalAriaAttr(el);
127
+ }
128
+ return false;
82
129
  }
83
130
 
84
131
  function getAccessibleNameText(el) {
@@ -100,7 +147,7 @@ function runInPage(ctx) {
100
147
  if (joined) return joined;
101
148
  }
102
149
  // Shared, accname-aligned "name from content" implementation (see
103
- // dom-helpers.js's getContentNameInfo header comment) — resolves an
150
+ // dom-helpers.js's getContentNameInfo header comment). Resolves an
104
151
  // <img> descendant's own alt text, an aria-label/aria-labelledby'd
105
152
  // descendant's own name, etc., and already gates every descendant on
106
153
  // full accessibility-tree eligibility (aria-hidden, display:none,
@@ -6,7 +6,7 @@
6
6
  * @check empty-table-header
7
7
  * @atomic true
8
8
  * @summary Table header cells must not be empty
9
- * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion)
10
10
  * @applicability
11
11
  * Applies to <th> elements that don't carry a conflicting explicit role,
12
12
  * plus any element (native <th> or not) with role="columnheader" or
@@ -19,19 +19,19 @@
19
19
  * @expectation
20
20
  * The header cell has visible text content. A <th> named only via
21
21
  * aria-label/aria-labelledby (no visible text) is ALSO flagged, not
22
- * treated as equivalent — aria-label support on <th> is genuinely
23
- * inconsistent in practice: NVDA+Firefox and iOS VoiceOver+Safari ignore
22
+ * treated as equivalent: aria-label support on <th> is inconsistent
23
+ * in practice. NVDA+Firefox and iOS VoiceOver+Safari ignore
24
24
  * it entirely (only visible text is announced), JAWS+Chrome/IE11 also only
25
25
  * announce visible text in the header cell itself. Visible text is the one
26
26
  * mechanism confirmed to work across every tested combination. See
27
27
  * https://html5accessibility.com/stuff/2024/05/22/not-so-short-note-on-aria-label-usage-big-table-edition/.
28
28
  * @implementation-notes
29
- * - Not WCAG-normative — authored as an advisory, cantTell-capped
29
+ * - Not WCAG-normative, authored as an advisory, cantTell-capped
30
30
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
31
31
  * header comment for the shared rationale/precedent.
32
32
  * - Two distinct reasonCodes: TABLE_HEADER_EMPTY (no accessible name at
33
33
  * all) vs. TABLE_HEADER_NAME_NOT_VISIBLE_TEXT (has aria-label/
34
- * aria-labelledby but no visible text) — the latter gets a different
34
+ * aria-labelledby but no visible text), the latter gets a different
35
35
  * summary/hint explaining the AT-support gap, since the header isn't
36
36
  * literally nameless, just unreliably named in practice.
37
37
  */
@@ -41,7 +41,7 @@ const id = 'empty-table-header';
41
41
  const meta = {
42
42
  title: 'Table header cells must not be empty',
43
43
  description:
44
- 'Checks that table header cells (<th>, or any element with role="columnheader"/"rowheader") have visible text content — a header named only via aria-label/aria-labelledby is also flagged, since real screen-reader/browser support for that is inconsistent.',
44
+ 'Checks that table header cells (<th>, or any element with role="columnheader"/"rowheader") have visible text content. A header named only via aria-label/aria-labelledby is also flagged, since real screen-reader/browser support for that is inconsistent.',
45
45
  i18n: {
46
46
  titleKey: 'emptyTableHeader_title',
47
47
  descriptionKey: 'emptyTableHeader_description'
@@ -146,8 +146,8 @@ function runInPage(ctx) {
146
146
  occurrences.push(
147
147
  helpers.reportOccurrence(el, {
148
148
  summary:
149
- 'This table header cell has no visible text — its only accessible name comes from aria-label/aria-labelledby, which real screen-reader/browser combinations (e.g. NVDA+Firefox, iOS VoiceOver+Safari) are known to ignore on <th> elements.',
150
- hint: 'Add visible text content to this header cell (in addition to, or instead of, aria-label/aria-labelledby) — visible text is the only naming mechanism confirmed to work across tested screen readers.',
149
+ 'This table header cell has no visible text. Its only accessible name comes from aria-label/aria-labelledby, which real screen-reader/browser combinations (e.g. NVDA+Firefox, iOS VoiceOver+Safari) are known to ignore on <th> elements.',
150
+ hint: 'Add visible text content to this header cell (in addition to, or instead of, aria-label/aria-labelledby); visible text is the only naming mechanism confirmed to work across tested screen readers.',
151
151
  i18n: {
152
152
  summaryKey: 'emptyTableHeader_summary_cantTell_ariaOnly',
153
153
  hintKey: 'emptyTableHeader_hint_cantTell_ariaOnly',
@@ -6,32 +6,32 @@
6
6
  * @check focus-order-semantics
7
7
  * @atomic true
8
8
  * @summary Elements added to the tab order should have interactive semantics
9
- * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion)
10
10
  * @applicability
11
11
  * Elements with an explicit `tabindex` of `0` or greater (in the tab
12
12
  * order) AND an explicit `role` attribute that is one of a curated set
13
13
  * of clearly non-interactive, structural/document roles.
14
14
  * @expectation
15
- * An element deliberately placed in the tab order should communicate
16
- * why it's focusable — a role like `heading`, `list`, `region`, or
15
+ * An element placed in the tab order on purpose should communicate
16
+ * why it's focusable: a role like `heading`, `list`, `region`, or
17
17
  * `presentation` gives assistive technology no interactive semantic to
18
18
  * announce, which is confusing for keyboard users who land on it and
19
19
  * get no indication of what activating it (if anything) would do.
20
20
  * @implementation-notes
21
- * - Not WCAG-normative — authored as an advisory, cantTell-capped
21
+ * - Not WCAG-normative, authored as an advisory, cantTell-capped
22
22
  * `type: 'manual'` rule.
23
- * - The non-interactive role list is deliberately curated and
24
- * conservative (structural/document roles only) — legitimate custom
25
- * widget patterns using `tabindex` with a genuinely interactive role
26
- * (`option`, `tab`, `menuitem`, etc.) are never flagged. Elements with
27
- * `tabindex` and NO role at all are also not flagged: native semantics
28
- * or an intentionally generic custom-interactive pattern cannot be
23
+ * - The non-interactive role list is curated and conservative
24
+ * (structural/document roles only). Legitimate custom widget patterns
25
+ * using `tabindex` with an actually interactive role (`option`, `tab`,
26
+ * `menuitem`, etc.) are never flagged. Elements with `tabindex` and NO
27
+ * role at all are also not flagged: native semantics or an
28
+ * intentionally generic custom-interactive pattern cannot be
29
29
  * distinguished from markup alone with the same confidence.
30
- * - `region` is deliberately NOT in the non-interactive role list: a
31
- * tabbable `role="region"` is a real, WCAG 2.1.1/2.1.3-grounded pattern
32
- * this engine's own `scrollable-region-focusable` check exists to
33
- * RECOMMEND (a scrollable landmark with no other focusable content
34
- * needs `tabindex="0"` to be keyboard-reachable at all) — flagging it
30
+ * - `region` is intentionally excluded from the non-interactive role
31
+ * list: a tabbable `role="region"` is a real, WCAG 2.1.1/2.1.3-grounded
32
+ * pattern this engine's own `scrollable-region-focusable` check exists
33
+ * to RECOMMEND (a scrollable landmark with no other focusable content
34
+ * needs `tabindex="0"` to be keyboard-reachable at all), so flagging it
35
35
  * here would be internally inconsistent with that sibling check. A
36
36
  * `role="region"` is also commonly made tabbable on its own merits
37
37
  * (e.g. a cookie-consent banner or notification/toast region a keyboard
@@ -0,0 +1,453 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
3
+ 'use strict';
4
+
5
+ /**
6
+ * @check form-control-label-quality
7
+ * @atomic true
8
+ * @summary A form field's visible label text should describe that field, not repeat another one
9
+ * @standard WCAG 2.2
10
+ * @sc 2.4.6
11
+ * @applicability
12
+ * Visible form fields: native `input` (excluding hidden and the
13
+ * button-like types), `select`, `textarea`, or an element with one of
14
+ * the ARIA widget roles ACT cc0f0a lists (checkbox, combobox, listbox,
15
+ * menuitemcheckbox, menuitemradio, radio, searchbox, slider,
16
+ * spinbutton, switch, textbox) that carry a visible programmatic
17
+ * label: a `<label>` association, or the elements `aria-labelledby`
18
+ * points at. A field named only by `aria-label`/`title` has no visible
19
+ * label to judge and is out of scope here (its labelling mechanism is
20
+ * `form-control-programmatic-label-quality`'s concern, its presence
21
+ * `form-control-programmatic-label-present`'s).
22
+ * @expectation
23
+ * The visible label text (a) is not a placeholder left in the markup
24
+ * ("label", "field", "enter text", ...), (b) is not shared with another
25
+ * field that no visible context tells apart (the same "Name" twice,
26
+ * with nothing visible on screen saying which is shipping and which is
27
+ * billing), and (c) is the whole of the field's programmatic label, not
28
+ * the visible fragment of a label whose descriptive part is hidden.
29
+ * @implementation-notes
30
+ * - Authored as `type: 'manual'` (cantTell-capped, never fail). Whether a
31
+ * label describes its field is a reading judgment: ACT cc0f0a fails
32
+ * `<label>Menu<input type="text" name="fname"></label>` on the meaning
33
+ * of the word alone, which no markup-level check can reach. What is
34
+ * deterministic is a placeholder string, a label repeated with no
35
+ * visible differentiator, and a label split between visible and hidden
36
+ * parts: the three shapes this rule reports.
37
+ * - Only PROGRAMMATIC labels count, per ACT: a `<label>` or the targets
38
+ * of `aria-labelledby`. `aria-label` is invisible text, so it can carry
39
+ * no visual context and is not what a sighted user reads.
40
+ * - Visibility is ACT's, not the accessibility tree's: a label positioned
41
+ * off screen or clipped is programmatically fine and visually absent,
42
+ * which is exactly what makes ACT's failed example 4 (`<h2>` at
43
+ * `top: -9999px`) a failure rather than a pass. Hence
44
+ * `isDomVisibleEligible` for rendering plus the shared visibility
45
+ * hints for the off-screen/clipped/transparent patterns.
46
+ * - The context a duplicate label is judged against is the visible
47
+ * context nearest the field: its `<fieldset>`'s visible `<legend>`, or
48
+ * failing that the nearest visible heading before it. A row of a table
49
+ * or a list item contributes its own text too, which is what keeps a
50
+ * repeated "Quantity" field in a product table (differentiated by the
51
+ * product name in the same row) from being reported.
52
+ * - Two fields conflict only when their label text AND their context are
53
+ * both identical. Same label under two different visible headings is
54
+ * ACT's passed example 5 and is not reported.
55
+ * - A label split across visible and hidden parts is reported on its own:
56
+ * `aria-labelledby="submit search"` where "Search" is `display: none`
57
+ * and only the "Go" button renders leaves a sighted user reading a
58
+ * different label than a screen reader announces. ACT's failed example
59
+ * 5 is exactly that. The hidden part may well be a deliberate
60
+ * AT-only addition, which is why this is a review signal rather than a
61
+ * defect.
62
+ */
63
+
64
+ const id = 'form-control-label-quality';
65
+
66
+ const meta = {
67
+ title: 'Form field labels should be descriptive and distinguishable',
68
+ description:
69
+ 'Flags a visible form-field label that is a placeholder ("Label", "Field"), or that repeats another field\'s label with no visible context (heading, legend, or row) telling the two apart.',
70
+ i18n: {
71
+ titleKey: 'formControlLabelQuality_title',
72
+ descriptionKey: 'formControlLabelQuality_description'
73
+ },
74
+ helpUrl: null,
75
+ tags: ['wcag2aa', 'wcag246', 'forms', 'labels', 'quality', 'atomic', 'manual'],
76
+ wcagSc: ['2.4.6'],
77
+ normativeMappings: [
78
+ {
79
+ standard: 'WCAG',
80
+ version: '2.2',
81
+ requirement: '2.4.6',
82
+ title: 'Headings and Labels',
83
+ conformanceLevel: 'AA'
84
+ }
85
+ ],
86
+ defaultSeverity: 'minor',
87
+ category: 'operable',
88
+ type: 'manual',
89
+ defaultConfidence: 'medium',
90
+ coverage: { facetsBySc: { '2.4.6': ['form-control-label-descriptive-evidence'] } }
91
+ };
92
+
93
+ function runInPage(ctx) {
94
+ const { document, helpers, rule } = ctx;
95
+
96
+ // Declared inside runInPage; see scripts/build-core.js header
97
+ // ("runInPage MUST be self-contained").
98
+ const PLACEHOLDER_LABEL_TEXT = new Set([
99
+ 'label',
100
+ 'field',
101
+ 'form field',
102
+ 'input',
103
+ 'input field',
104
+ 'text',
105
+ 'text field',
106
+ 'enter text',
107
+ 'type here',
108
+ 'value',
109
+ 'placeholder',
110
+ 'untitled',
111
+ 'tbd',
112
+ 'todo',
113
+ 'to do',
114
+ 'n/a',
115
+ 'test',
116
+ 'example',
117
+ 'default'
118
+ ]);
119
+
120
+ const FIELD_SELECTOR = [
121
+ 'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"])',
122
+ 'select',
123
+ 'textarea',
124
+ '[role="checkbox"]',
125
+ '[role="combobox"]',
126
+ '[role="listbox"]',
127
+ '[role="menuitemcheckbox"]',
128
+ '[role="menuitemradio"]',
129
+ '[role="radio"]',
130
+ '[role="searchbox"]',
131
+ '[role="slider"]',
132
+ '[role="spinbutton"]',
133
+ '[role="switch"]',
134
+ '[role="textbox"]'
135
+ ].join(', ');
136
+
137
+ const ROW_SELECTOR = 'tr, [role="row"], li, [role="listitem"]';
138
+ const HEADING_SELECTOR = 'h1, h2, h3, h4, h5, h6, [role="heading"]';
139
+
140
+ function normalizeWs(s) {
141
+ return String(s || '')
142
+ .replace(/\s+/g, ' ')
143
+ .trim();
144
+ }
145
+
146
+ function normalize(s) {
147
+ return normalizeWs(s)
148
+ .toLowerCase()
149
+ .replace(/[.,;:!?*]+$/g, '')
150
+ .trim();
151
+ }
152
+
153
+ const isDomVisibleEligible =
154
+ helpers && typeof helpers.isDomVisibleEligible === 'function'
155
+ ? helpers.isDomVisibleEligible
156
+ : null;
157
+ const getVisibilityHintsInfo =
158
+ helpers && typeof helpers.getVisibilityHintsInfo === 'function'
159
+ ? helpers.getVisibilityHintsInfo
160
+ : null;
161
+
162
+ const HIDING_HINTS = ['offscreen', 'clipped', 'opacityZero', 'zeroSize'];
163
+
164
+ // ACT's "visible": rendered, and not hidden by one of the visually-hidden
165
+ // CSS patterns. A label the user cannot read provides no visual context,
166
+ // however well it is wired up programmatically.
167
+ function isVisible(el) {
168
+ if (!el) return false;
169
+ if (isDomVisibleEligible) {
170
+ try {
171
+ const vis = isDomVisibleEligible(el, ctx, {
172
+ visibilityMode: 'styleOnly',
173
+ disableGeometry: true
174
+ });
175
+ if (vis && vis.eligible === false) return false;
176
+ } catch {
177
+ // treat as rendered
178
+ }
179
+ }
180
+ if (getVisibilityHintsInfo) {
181
+ try {
182
+ const info = getVisibilityHintsInfo(el, ctx);
183
+ const hints = (info && info.hints) || [];
184
+ for (const hint of HIDING_HINTS) {
185
+ if (hints.indexOf(hint) !== -1) return false;
186
+ }
187
+ } catch {
188
+ // treat as visible
189
+ }
190
+ }
191
+ return true;
192
+ }
193
+
194
+ function resolveIdRefs(el, attr) {
195
+ const raw = normalizeWs(el.getAttribute && el.getAttribute(attr));
196
+ if (!raw) return [];
197
+ const out = [];
198
+ for (const refId of raw.split(/\s+/).filter(Boolean)) {
199
+ try {
200
+ const ref = document.getElementById(refId);
201
+ if (ref) out.push(ref);
202
+ } catch {
203
+ // ignore an unusable reference
204
+ }
205
+ }
206
+ return out;
207
+ }
208
+
209
+ function getNativeLabels(el) {
210
+ const labels = [];
211
+ try {
212
+ if (el.labels && el.labels.length) {
213
+ for (const label of el.labels) labels.push(label);
214
+ return labels;
215
+ }
216
+ } catch {
217
+ // fall through to the manual lookup
218
+ }
219
+ const idVal = normalizeWs(el.getAttribute && el.getAttribute('id'));
220
+ if (idVal) {
221
+ try {
222
+ for (const label of document.querySelectorAll('label[for]')) {
223
+ if (normalizeWs(label.getAttribute('for')) === idVal) labels.push(label);
224
+ }
225
+ } catch {
226
+ // ignore
227
+ }
228
+ }
229
+ try {
230
+ const wrapping = el.closest ? el.closest('label') : null;
231
+ if (wrapping && labels.indexOf(wrapping) === -1) labels.push(wrapping);
232
+ } catch {
233
+ // ignore
234
+ }
235
+ return labels;
236
+ }
237
+
238
+ // The programmatic labels of a field, per ACT: aria-labelledby targets when
239
+ // present, otherwise the <label> elements associated with it. aria-label is
240
+ // left out on purpose; see the header comment.
241
+ function getVisibleLabelText(el) {
242
+ const referenced = resolveIdRefs(el, 'aria-labelledby');
243
+ const labels = referenced.length ? referenced : getNativeLabels(el);
244
+ const parts = [];
245
+ let hiddenParts = 0;
246
+ for (const label of labels) {
247
+ const text = normalizeWs(label.textContent);
248
+ if (!text) continue;
249
+ if (isVisible(label)) parts.push(text);
250
+ else hiddenParts += 1;
251
+ }
252
+ return { text: normalizeWs(parts.join(' ')), hiddenParts };
253
+ }
254
+
255
+ const headings = (() => {
256
+ try {
257
+ return Array.prototype.slice.call(document.querySelectorAll(HEADING_SELECTOR));
258
+ } catch {
259
+ return [];
260
+ }
261
+ })();
262
+
263
+ function precedes(a, b) {
264
+ try {
265
+ // DOCUMENT_POSITION_PRECEDING (2) on b relative to a.
266
+ return !!(b.compareDocumentPosition(a) & 2);
267
+ } catch {
268
+ return false;
269
+ }
270
+ }
271
+
272
+ function nearestVisibleHeadingText(el) {
273
+ for (let i = headings.length - 1; i >= 0; i--) {
274
+ const heading = headings[i];
275
+ if (!precedes(heading, el)) continue;
276
+ if (!isVisible(heading)) continue;
277
+ const text = normalizeWs(heading.textContent);
278
+ if (text) return text;
279
+ }
280
+ return '';
281
+ }
282
+
283
+ function fieldsetLegendText(el) {
284
+ let fieldset;
285
+ try {
286
+ fieldset = el.closest ? el.closest('fieldset') : null;
287
+ } catch {
288
+ fieldset = null;
289
+ }
290
+ while (fieldset) {
291
+ let legend;
292
+ try {
293
+ legend = fieldset.querySelector('legend');
294
+ } catch {
295
+ legend = null;
296
+ }
297
+ if (legend && isVisible(legend)) {
298
+ const text = normalizeWs(legend.textContent);
299
+ if (text) return text;
300
+ }
301
+ try {
302
+ fieldset = fieldset.parentElement ? fieldset.parentElement.closest('fieldset') : null;
303
+ } catch {
304
+ fieldset = null;
305
+ }
306
+ }
307
+ return '';
308
+ }
309
+
310
+ // A table row or list item carries its own context (the product name a
311
+ // repeated "Quantity" field belongs to), so it takes part in the key.
312
+ function rowContextText(el, labelText) {
313
+ let row;
314
+ try {
315
+ row = el.closest ? el.closest(ROW_SELECTOR) : null;
316
+ } catch {
317
+ row = null;
318
+ }
319
+ if (!row || !isVisible(row)) return '';
320
+ const text = normalizeWs(row.textContent);
321
+ if (!text) return '';
322
+ return normalizeWs(text.split(labelText).join(' '));
323
+ }
324
+
325
+ function contextKey(el, labelText) {
326
+ const group = fieldsetLegendText(el) || nearestVisibleHeadingText(el);
327
+ return `${normalize(group)}##${normalize(rowContextText(el, labelText))}`;
328
+ }
329
+
330
+ const nodes = helpers.queryAllSmart
331
+ ? helpers.queryAllSmart(FIELD_SELECTOR)
332
+ : helpers.queryAll(FIELD_SELECTOR);
333
+
334
+ const fields = [];
335
+ for (const el of nodes) {
336
+ if (!el || el.nodeType !== 1) continue;
337
+ if (!isVisible(el)) continue;
338
+
339
+ const label = getVisibleLabelText(el);
340
+ const labelText = label.text;
341
+ if (!labelText) continue; // no visible label to judge, a different rule's concern
342
+
343
+ fields.push({
344
+ el,
345
+ labelText,
346
+ normalized: normalize(labelText),
347
+ hiddenParts: label.hiddenParts
348
+ });
349
+ }
350
+
351
+ if (!fields.length) {
352
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
353
+ }
354
+
355
+ // Group by label text plus the visible context that would tell two
356
+ // same-named fields apart.
357
+ const byKey = new Map();
358
+ for (const field of fields) {
359
+ field.key = `${field.normalized}||${contextKey(field.el, field.labelText)}`;
360
+ const bucket = byKey.get(field.key);
361
+ if (bucket) bucket.push(field);
362
+ else byKey.set(field.key, [field]);
363
+ }
364
+
365
+ const occurrences = [];
366
+
367
+ for (const field of fields) {
368
+ const isPlaceholder = PLACEHOLDER_LABEL_TEXT.has(field.normalized);
369
+ const shared = byKey.get(field.key) || [];
370
+ const isDuplicate = shared.length > 1;
371
+ const isPartiallyHidden = field.hiddenParts > 0;
372
+
373
+ if (!isPlaceholder && !isDuplicate && !isPartiallyHidden) continue;
374
+
375
+ const reasonCode = isPlaceholder
376
+ ? 'PLACEHOLDER_LABEL_TEXT'
377
+ : isDuplicate
378
+ ? 'DUPLICATE_LABEL_TEXT'
379
+ : 'PARTIALLY_HIDDEN_LABEL';
380
+
381
+ const eligInfo = helpers.getEligibilityInfo
382
+ ? (() => {
383
+ try {
384
+ return helpers.getEligibilityInfo(field.el, ctx, { targetSet: 'acc' });
385
+ } catch {
386
+ return null;
387
+ }
388
+ })()
389
+ : null;
390
+
391
+ const summaryByReason = {
392
+ PLACEHOLDER_LABEL_TEXT: `This field's visible label ("${field.labelText}") is a placeholder rather than a description of what the field is for.`,
393
+ DUPLICATE_LABEL_TEXT: `This field's visible label ("${field.labelText}") is shared with ${shared.length - 1} other field(s), with no visible heading, legend or row text telling them apart.`,
394
+ PARTIALLY_HIDDEN_LABEL: `This field's label is split: "${field.labelText}" is what renders, while ${field.hiddenParts} other part(s) of the label are hidden from sight.`
395
+ };
396
+ const hintByReason = {
397
+ PLACEHOLDER_LABEL_TEXT:
398
+ 'Replace the label with one naming the information the field collects.',
399
+ DUPLICATE_LABEL_TEXT:
400
+ 'Give each field a label of its own, or put the distinguishing context on screen: a visible heading or a fieldset legend above each group.',
401
+ PARTIALLY_HIDDEN_LABEL:
402
+ 'Confirm the visible part alone identifies the field, or make the rest of the label visible.'
403
+ };
404
+ const SUMMARY_KEY_BY_REASON = {
405
+ PLACEHOLDER_LABEL_TEXT: 'formControlLabelQuality_summary_cantTell_placeholder',
406
+ DUPLICATE_LABEL_TEXT: 'formControlLabelQuality_summary_cantTell_duplicate',
407
+ PARTIALLY_HIDDEN_LABEL: 'formControlLabelQuality_summary_cantTell_partiallyHidden'
408
+ };
409
+ const HINT_KEY_BY_REASON = {
410
+ PLACEHOLDER_LABEL_TEXT: 'formControlLabelQuality_hint_cantTell_placeholder',
411
+ DUPLICATE_LABEL_TEXT: 'formControlLabelQuality_hint_cantTell_duplicate',
412
+ PARTIALLY_HIDDEN_LABEL: 'formControlLabelQuality_hint_cantTell_partiallyHidden'
413
+ };
414
+
415
+ occurrences.push(
416
+ helpers.reportOccurrence(field.el, {
417
+ summary: summaryByReason[reasonCode],
418
+ hint: hintByReason[reasonCode],
419
+ i18n: {
420
+ summaryKey: SUMMARY_KEY_BY_REASON[reasonCode],
421
+ hintKey: HINT_KEY_BY_REASON[reasonCode],
422
+ params: {
423
+ label: field.labelText,
424
+ count: String(shared.length - 1),
425
+ hiddenCount: String(field.hiddenParts)
426
+ }
427
+ },
428
+ data: {
429
+ details: {
430
+ reasonCode,
431
+ label: field.labelText,
432
+ sharedWith: isDuplicate ? shared.length - 1 : 0,
433
+ hiddenLabelParts: field.hiddenParts
434
+ },
435
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
436
+ }
437
+ })
438
+ );
439
+ }
440
+
441
+ if (occurrences.length) {
442
+ return {
443
+ ruleId: rule.ruleId,
444
+ outcome: 'cantTell',
445
+ severity: rule.defaultSeverity || 'minor',
446
+ occurrences
447
+ };
448
+ }
449
+
450
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
451
+ }
452
+
453
+ module.exports = { id, meta, runInPage };
@@ -107,7 +107,7 @@ function runInPage(ctx) {
107
107
 
108
108
  // getLabelMethod is provided by the shared dom-helpers bundle that
109
109
  // dom-runner.js always constructs for every rule execution (built-in or
110
- // custom) — see createDomHelpers's own getLabelMethod, which implements
110
+ // custom); see createDomHelpers's own getLabelMethod, which implements
111
111
  // this exact <label>/aria-labelledby/aria-label/title/placeholder
112
112
  // priority order. No local reimplementation is needed as a fallback.
113
113
  function getLabelMethodSafe(el) {
@@ -232,7 +232,7 @@ function runInPage(ctx) {
232
232
  };
233
233
  }
234
234
 
235
- // Manual rules may only emit cantTell/notApplicable (never pass/fail) —
235
+ // Manual rules may only emit cantTell/notApplicable (never pass/fail):
236
236
  // no applicable control relied on a weak (title/placeholder) primary
237
237
  // label, so there is nothing to flag for review.
238
238
  return {