@surea11y/core 1.0.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 (164) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +145 -0
  4. package/bin/core.js +244 -0
  5. package/docs/BINDING_AUTHORS_GUIDE.md +41 -0
  6. package/docs/CLI.md +49 -0
  7. package/docs/ENGINE_OPTIONS.md +155 -0
  8. package/docs/I18N.md +47 -0
  9. package/docs/INTEGRATION.md +156 -0
  10. package/docs/LIMITATIONS.md +31 -0
  11. package/docs/OUTPUT_SCHEMA.md +237 -0
  12. package/docs/POLICY.md +71 -0
  13. package/docs/RULE_AUTHORING.md +375 -0
  14. package/docs/RULE_CATALOG.md +180 -0
  15. package/docs/RULE_TAXONOMY.md +145 -0
  16. package/docs/TROUBLESHOOTING.md +48 -0
  17. package/docs/WCAG_CONFORMANCE.md +49 -0
  18. package/package.json +60 -0
  19. package/src/catalogs/composites.wcag.js +490 -0
  20. package/src/checks/automatic/area-alt-present.js +225 -0
  21. package/src/checks/automatic/aria-allowed-attr.js +206 -0
  22. package/src/checks/automatic/aria-allowed-role.js +102 -0
  23. package/src/checks/automatic/aria-braille-equivalent.js +139 -0
  24. package/src/checks/automatic/aria-conditional-attr.js +110 -0
  25. package/src/checks/automatic/aria-deprecated-role.js +106 -0
  26. package/src/checks/automatic/aria-hidden-body.js +87 -0
  27. package/src/checks/automatic/aria-hidden-focus.js +480 -0
  28. package/src/checks/automatic/aria-prohibited-attr.js +156 -0
  29. package/src/checks/automatic/aria-prohibited-children.js +265 -0
  30. package/src/checks/automatic/aria-required-attr.js +154 -0
  31. package/src/checks/automatic/aria-required-children.js +274 -0
  32. package/src/checks/automatic/aria-required-parent.js +222 -0
  33. package/src/checks/automatic/aria-role-name-present.js +201 -0
  34. package/src/checks/automatic/aria-roles-valid.js +110 -0
  35. package/src/checks/automatic/aria-valid-attr-value.js +123 -0
  36. package/src/checks/automatic/aria-valid-attr.js +109 -0
  37. package/src/checks/automatic/autocomplete-valid.js +134 -0
  38. package/src/checks/automatic/avoid-inline-spacing.js +107 -0
  39. package/src/checks/automatic/binary-control-name-present.js +294 -0
  40. package/src/checks/automatic/button-name-present.js +146 -0
  41. package/src/checks/automatic/bypass-blocks-present.js +162 -0
  42. package/src/checks/automatic/canvas-text-alternative-present.js +140 -0
  43. package/src/checks/automatic/combobox-name-present.js +267 -0
  44. package/src/checks/automatic/contrast-computable.js +378 -0
  45. package/src/checks/automatic/contrast-enhanced.js +517 -0
  46. package/src/checks/automatic/contrast-minimum.js +512 -0
  47. package/src/checks/automatic/css-orientation-lock.js +206 -0
  48. package/src/checks/automatic/definition-list-children-valid.js +148 -0
  49. package/src/checks/automatic/deprecated-elements-not-used.js +91 -0
  50. package/src/checks/automatic/dialog-name-present.js +209 -0
  51. package/src/checks/automatic/dlitem-parent-valid.js +100 -0
  52. package/src/checks/automatic/duplicate-id-aria.js +126 -0
  53. package/src/checks/automatic/embed-text-alternative-present.js +190 -0
  54. package/src/checks/automatic/form-control-programmatic-label-present.js +409 -0
  55. package/src/checks/automatic/form-control-single-label.js +117 -0
  56. package/src/checks/automatic/html-xml-lang-mismatch.js +91 -0
  57. package/src/checks/automatic/iframe-focusable-content.js +141 -0
  58. package/src/checks/automatic/iframe-name-present.js +102 -0
  59. package/src/checks/automatic/iframe-title-unique.js +107 -0
  60. package/src/checks/automatic/img-alt-present.js +223 -0
  61. package/src/checks/automatic/input-image-alt-present.js +155 -0
  62. package/src/checks/automatic/label-in-name.js +326 -0
  63. package/src/checks/automatic/language-page-present.js +159 -0
  64. package/src/checks/automatic/link-in-text-block.js +218 -0
  65. package/src/checks/automatic/link-name-present.js +114 -0
  66. package/src/checks/automatic/list-children-valid.js +152 -0
  67. package/src/checks/automatic/listbox-name-present.js +236 -0
  68. package/src/checks/automatic/listitem-parent-valid.js +118 -0
  69. package/src/checks/automatic/menuitem-name-present.js +201 -0
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +105 -0
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +107 -0
  72. package/src/checks/automatic/meta-viewport-zoom-enabled.js +118 -0
  73. package/src/checks/automatic/meter-name-present.js +160 -0
  74. package/src/checks/automatic/nested-interactive-controls-absent.js +135 -0
  75. package/src/checks/automatic/object-text-alternative-present.js +193 -0
  76. package/src/checks/automatic/option-name-present.js +157 -0
  77. package/src/checks/automatic/page-title-present.js +86 -0
  78. package/src/checks/automatic/progressbar-name-present.js +165 -0
  79. package/src/checks/automatic/role-img-alt-present.js +206 -0
  80. package/src/checks/automatic/searchbox-name-present.js +236 -0
  81. package/src/checks/automatic/server-side-image-map-absent.js +88 -0
  82. package/src/checks/automatic/slider-name-present.js +276 -0
  83. package/src/checks/automatic/spinbutton-name-present.js +236 -0
  84. package/src/checks/automatic/summary-name-present.js +153 -0
  85. package/src/checks/automatic/svg-image-text-alternative-present.js +220 -0
  86. package/src/checks/automatic/svg-text-alternative-present.js +298 -0
  87. package/src/checks/automatic/tab-name-present.js +200 -0
  88. package/src/checks/automatic/table-headers-attr-valid.js +122 -0
  89. package/src/checks/automatic/table-th-has-data-cells.js +117 -0
  90. package/src/checks/automatic/target-size-minimum.js +605 -0
  91. package/src/checks/automatic/td-has-header.js +151 -0
  92. package/src/checks/automatic/textbox-name-present.js +236 -0
  93. package/src/checks/automatic/tooltip-name-present.js +158 -0
  94. package/src/checks/automatic/treeitem-name-present.js +157 -0
  95. package/src/checks/automatic/valid-lang.js +100 -0
  96. package/src/checks/automatic/video-poster-text-alternative-present.js +193 -0
  97. package/src/checks/manual/accesskeys-manual.js +93 -0
  98. package/src/checks/manual/area-alt-decorative-manual.js +247 -0
  99. package/src/checks/manual/area-alt-quality-manual.js +204 -0
  100. package/src/checks/manual/aria-checked-state-mismatch-manual.js +141 -0
  101. package/src/checks/manual/aria-text-manual.js +109 -0
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +170 -0
  103. package/src/checks/manual/css-hidden-focus.js +259 -0
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +204 -0
  105. package/src/checks/manual/empty-heading-manual.js +182 -0
  106. package/src/checks/manual/empty-table-header-manual.js +163 -0
  107. package/src/checks/manual/focus-order-semantics-manual.js +117 -0
  108. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +291 -0
  109. package/src/checks/manual/heading-order-manual.js +130 -0
  110. package/src/checks/manual/identical-links-same-purpose-manual.js +142 -0
  111. package/src/checks/manual/image-redundant-alt-manual.js +118 -0
  112. package/src/checks/manual/img-alt-decorative-manual.js +148 -0
  113. package/src/checks/manual/img-alt-quality-manual.js +182 -0
  114. package/src/checks/manual/input-image-alt-decorative-manual.js +144 -0
  115. package/src/checks/manual/input-image-alt-quality-manual.js +144 -0
  116. package/src/checks/manual/label-title-only-manual.js +115 -0
  117. package/src/checks/manual/landmark-banner-is-top-level-manual.js +180 -0
  118. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +169 -0
  119. package/src/checks/manual/landmark-main-is-top-level-manual.js +167 -0
  120. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +177 -0
  121. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +169 -0
  122. package/src/checks/manual/landmark-no-duplicate-main-manual.js +132 -0
  123. package/src/checks/manual/landmark-one-main-manual.js +151 -0
  124. package/src/checks/manual/landmark-unique-manual.js +252 -0
  125. package/src/checks/manual/link-name-quality-manual.js +143 -0
  126. package/src/checks/manual/media-transcript-present-manual.js +373 -0
  127. package/src/checks/manual/meta-viewport-large-manual.js +119 -0
  128. package/src/checks/manual/mouse-only-event-handlers-manual.js +134 -0
  129. package/src/checks/manual/no-autoplay-audio-manual.js +116 -0
  130. package/src/checks/manual/object-text-alternative-quality-manual.js +194 -0
  131. package/src/checks/manual/p-as-heading-manual.js +163 -0
  132. package/src/checks/manual/page-has-heading-one-manual.js +110 -0
  133. package/src/checks/manual/page-title-patterns-manual.js +262 -0
  134. package/src/checks/manual/presentation-role-conflict-manual.js +159 -0
  135. package/src/checks/manual/region-manual.js +183 -0
  136. package/src/checks/manual/scope-attr-valid-manual.js +93 -0
  137. package/src/checks/manual/scrollable-region-focusable-manual.js +168 -0
  138. package/src/checks/manual/skip-link-manual.js +150 -0
  139. package/src/checks/manual/svg-text-alternative-quality-manual.js +209 -0
  140. package/src/checks/manual/tabindex-manual.js +94 -0
  141. package/src/checks/manual/table-duplicate-name-manual.js +99 -0
  142. package/src/checks/manual/table-fake-caption-manual.js +122 -0
  143. package/src/checks/manual/video-caption-manual.js +118 -0
  144. package/src/checks/manual-review.js +95 -0
  145. package/src/checks/rules-and-tags.full.csv +19 -0
  146. package/src/checks/rules-and-tags.full.json +259 -0
  147. package/src/core/aria-helpers.js +906 -0
  148. package/src/core/contrast-helpers.js +1147 -0
  149. package/src/core/dom-helpers.js +4085 -0
  150. package/src/core/dom-runner.js +627 -0
  151. package/src/core/frame-messaging.js +210 -0
  152. package/src/core/frame-scan.js +178 -0
  153. package/src/core/rollup-composites.js +135 -0
  154. package/src/core/rule-meta.js +140 -0
  155. package/src/core.js +79055 -0
  156. package/src/coverage/wcag-facets.js +1079 -0
  157. package/src/coverage/wcag-version-map.js +84 -0
  158. package/src/i18n/en.js +919 -0
  159. package/src/i18n/fr.js +527 -0
  160. package/src/index.js +4 -0
  161. package/src/policy/contracts.js +18 -0
  162. package/src/policy/resolvePolicy.js +55 -0
  163. package/src/policy/schemas/engine-options.schema.json +103 -0
  164. package/src/policy/schemas/policy-contract.schema.json +40 -0
@@ -0,0 +1,162 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check bypass-blocks-present
5
+ * @atomic true
6
+ * @summary The page must provide at least one mechanism to bypass repeated blocks of content
7
+ * @standard WCAG 2.2
8
+ * @sc 2.4.1
9
+ * @applicability
10
+ * Always applicable to any HTML document with a <body> element —
11
+ * "bypass blocks" is a whole-page concern, matching
12
+ * aria-hidden-body / page-title-present's pattern of
13
+ * evaluating the document directly rather than a scoped root.
14
+ * @expectation
15
+ * At least one of the following recognized WCAG 2.4.1 techniques is
16
+ * present:
17
+ * (a) a main landmark (<main> or [role="main"]) — technique ARIA11: a
18
+ * screen reader user can jump straight to it, bypassing everything
19
+ * before it (nav, header, repeated blocks) in one step;
20
+ * (b) a working same-page anchor link — technique G1/G123: an
21
+ * <a href="#id"> (or legacy <a name="id">) whose target resolves to
22
+ * a real element anywhere in the document. Deliberately NOT
23
+ * required to be positioned before a <nav> or be keyboard-focus-
24
+ * order-first — see implementation notes;
25
+ * (c) at least one heading (<h1>-<h6> or [role="heading"]) — technique
26
+ * H69: heading navigation is itself a standards-recognized bypass
27
+ * mechanism (e.g. a screen reader's "jump by heading" command).
28
+ * @implementation-notes
29
+ * - This rule intentionally checks presence, not position, for the
30
+ * same-page-anchor condition (b): a widely-used reference engine's real bypass algorithm is
31
+ * heuristic (see ROADMAP.md's Tier 1a note on why this rule was
32
+ * deferred from the rest of that batch), and getting DOM-order /
33
+ * keyboard-focus-order positioning exactly right without introducing
34
+ * false positives is materially harder than the rest of Tier 1a. Being
35
+ * lenient about condition (b) can only produce a false NEGATIVE (missing
36
+ * a page whose only anchor link isn't a real skip mechanism, e.g. a
37
+ * "back to top" link) — never a false positive — which matches this
38
+ * engine's non-negotiable "fail is reserved for high-confidence
39
+ * violations" policy. A future revision can tighten (b) once a
40
+ * positional heuristic has been validated against real pages without
41
+ * regressions.
42
+ * - `fail` therefore means: no main landmark, no resolvable same-page
43
+ * anchor link anywhere, and no heading anywhere on the page. That is a
44
+ * strong, low-ambiguity signal that the page truly has zero recognized
45
+ * bypass mechanism.
46
+ */
47
+
48
+ const id = 'bypass-blocks-present';
49
+
50
+ const meta = {
51
+ title: 'Page must provide a way to bypass repeated blocks',
52
+ description: 'Checks that the page has at least one recognized WCAG 2.4.1 bypass-blocks mechanism: a main landmark, a working same-page anchor link, or a heading.',
53
+ i18n: {
54
+ titleKey: 'bypassBlocksPresent_title',
55
+ descriptionKey: 'bypassBlocksPresent_description'
56
+ },
57
+ helpUrl: null,
58
+ tags: ['wcag2a', 'wcag241', 'navigation', 'atomic', 'automatic'],
59
+ wcagSc: ['2.4.1'],
60
+ normativeMappings: [
61
+ { standard: 'WCAG', version: '2.2', requirement: '2.4.1', title: 'Bypass Blocks', conformanceLevel: 'A' }
62
+ ],
63
+ defaultSeverity: 'serious',
64
+ category: 'operable',
65
+ type: 'automatic',
66
+ defaultConfidence: 'medium',
67
+ coverage: { facetsBySc: { '2.4.1': ['bypass-blocks-present'] } }
68
+ };
69
+
70
+ function runInPage(ctx) {
71
+ const { document, helpers, rule } = ctx;
72
+
73
+ const body = document && document.body ? document.body : null;
74
+ if (!body) {
75
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
76
+ }
77
+
78
+ function hasMainLandmark() {
79
+ try {
80
+ return !!document.querySelector('main, [role="main"]');
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ function hasWorkingAnchorLink() {
87
+ let links = [];
88
+ try {
89
+ links = document.querySelectorAll('a[href]');
90
+ } catch {
91
+ links = [];
92
+ }
93
+ for (const a of links) {
94
+ if (!a || !a.getAttribute) continue;
95
+ const href = String(a.getAttribute('href') || '').trim();
96
+ if (href.length < 2 || href.charAt(0) !== '#') continue;
97
+ let fragment = href.slice(1);
98
+ try {
99
+ fragment = decodeURIComponent(fragment);
100
+ } catch {
101
+ // keep raw fragment if it isn't a valid percent-encoded string
102
+ }
103
+ fragment = fragment.trim();
104
+ if (!fragment) continue;
105
+
106
+ let target = null;
107
+ try {
108
+ target = document.getElementById(fragment);
109
+ } catch {
110
+ target = null;
111
+ }
112
+ if (!target) {
113
+ try {
114
+ target = document.querySelector('a[name="' + fragment.replace(/"/g, '\\"') + '"]');
115
+ } catch {
116
+ target = null;
117
+ }
118
+ }
119
+ if (target) return true;
120
+ }
121
+ return false;
122
+ }
123
+
124
+ function hasHeading() {
125
+ try {
126
+ return !!document.querySelector('h1, h2, h3, h4, h5, h6, [role="heading"]');
127
+ } catch {
128
+ return false;
129
+ }
130
+ }
131
+
132
+ const mainLandmark = hasMainLandmark();
133
+ const anchorLink = mainLandmark ? false : hasWorkingAnchorLink();
134
+ const heading = (mainLandmark || anchorLink) ? false : hasHeading();
135
+
136
+ if (mainLandmark || anchorLink || heading) {
137
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
138
+ }
139
+
140
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(body) : 'body';
141
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(body) : (body.outerHTML || '').slice(0, 200);
142
+
143
+ const occurrences = [{
144
+ selector: stableSelector,
145
+ html,
146
+ summary: 'This page has no recognized way to bypass repeated blocks of content.',
147
+ hint: 'Add a main landmark (<main> or role="main"), a working "skip to content" link, or heading elements that assistive technology can use to jump past repeated content.',
148
+ i18n: {
149
+ summaryKey: 'bypassBlocksPresent_summary_fail',
150
+ hintKey: 'bypassBlocksPresent_hint_fail',
151
+ params: {}
152
+ },
153
+ data: {
154
+ details: { reasonCode: 'BYPASS_MECHANISM_ABSENT' },
155
+ visibilityFilter: { targetSet: 'acc', accEligible: null, reasons: [] }
156
+ }
157
+ }];
158
+
159
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
160
+ }
161
+
162
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,140 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check canvas-text-alternative-present
5
+ * @atomic true
6
+ * @summary Accessible <canvas> elements must provide a text alternative
7
+ * @standard WCAG 2.2
8
+ * @sc 1.1.1
9
+ * @applicability
10
+ * Applies to <canvas> elements that are exposed to assistive technologies.
11
+ * Elements otherwise hidden from the accessibility tree remain applicable
12
+ * if they are focusable (tabbable) or referenced by IDREF relationships (per engine eligibility checks).
13
+ * @expectation
14
+ * Each applicable <canvas> provides a text alternative via fallback content or an accessible name.
15
+ */
16
+
17
+ const id = 'canvas-text-alternative-present';
18
+
19
+ const meta = {
20
+ title: '<canvas> must provide a text alternative',
21
+ description: 'Checks that <canvas> elements provide a text alternative via fallback content or an accessible name.',
22
+ i18n: {
23
+ titleKey: 'canvas_textAltPresent_title',
24
+ descriptionKey: 'canvas_textAltPresent_description'
25
+ },
26
+ helpUrl: null,
27
+ tags: ['wcag2a', 'wcag111', 'nontext', 'canvas', 'atomic', 'automatic'],
28
+ wcagSc: ['1.1.1'],
29
+ normativeMappings: [
30
+ { standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A' }
31
+ ],
32
+ defaultSeverity: 'serious',
33
+ category: 'perceivable',
34
+ type: 'automatic',
35
+ defaultConfidence: 'high',
36
+ coverage: {
37
+ facetsBySc: {
38
+ '1.1.1': ['canvas-text-alternative-present']
39
+ }
40
+ }
41
+ };
42
+
43
+ function runInPage(ctx) {
44
+ const { document, root, helpers, rule } = ctx;
45
+ const safeRoot = root || document;
46
+
47
+ const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
48
+ const queryAll = helpers && typeof helpers.queryAll === 'function'
49
+ ? helpers.queryAll
50
+ : (sel) => {
51
+ try { return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : []; }
52
+ catch { return []; }
53
+ };
54
+
55
+ const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
56
+ ? helpers.getEligibilityInfo
57
+ : null;
58
+
59
+ const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
60
+ ? helpers.isAccTreeEligible
61
+ : null;
62
+
63
+ const getTextAlternativeInfo = helpers && typeof helpers.getTextAlternativeInfo === 'function'
64
+ ? helpers.getTextAlternativeInfo
65
+ : null;
66
+
67
+ const canvases = (() => {
68
+ try { return Array.from((queryAllSmart ? queryAllSmart('canvas') : queryAll('canvas')) || []); }
69
+ catch { return queryAll('canvas'); }
70
+ })();
71
+
72
+ if (!canvases.length) {
73
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
74
+ }
75
+
76
+ const occurrences = [];
77
+ let applicableCount = 0;
78
+
79
+ for (const el of canvases) {
80
+ if (!el) continue;
81
+
82
+ // Applicability: eligible in the accessibility tree (with focusable/IDREF exceptions handled by helper).
83
+ if (isAccTreeEligible) {
84
+ const elig = (() => {
85
+ try { return isAccTreeEligible(el, ctx); } catch { return { eligible: true, reasons: [] }; }
86
+ })();
87
+ if (elig && elig.eligible === false) continue;
88
+ }
89
+
90
+ applicableCount += 1;
91
+
92
+ // Expectation: must provide a text alternative.
93
+ const ti = getTextAlternativeInfo
94
+ ? (() => { try { return getTextAlternativeInfo(el, ctx); } catch { return null; } })()
95
+ : null;
96
+
97
+ const hasTextAlt = !!(ti && ti.present);
98
+
99
+ if (hasTextAlt) continue;
100
+
101
+ const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
102
+
103
+ const baseOccurrence = {
104
+ selector: '',
105
+ html: '',
106
+ summary: 'Missing text alternative for <canvas>.',
107
+ hint: 'Provide fallback text inside <canvas> or an accessible name (e.g., aria-label/aria-labelledby).',
108
+ i18n: {
109
+ summaryKey: 'canvas_textAltPresent_summary_fail',
110
+ hintKey: 'canvas_textAltPresent_hint_fail',
111
+ params: { element: 'canvas' }
112
+ },
113
+ data: {
114
+ // Always log eligibility/filter info (engine contract for targetSet=acc checks)
115
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
116
+ // Debuggable, deterministic helper facts (non-verdict)
117
+ textAlternative: ti || null
118
+ }
119
+ };
120
+
121
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
122
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
123
+ } else {
124
+ // Never compute selector/snippet in the rule.
125
+ occurrences.push({ ...baseOccurrence });
126
+ }
127
+ }
128
+
129
+ if (applicableCount === 0) {
130
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
131
+ }
132
+
133
+ if (!occurrences.length) {
134
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
135
+ }
136
+
137
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
138
+ }
139
+
140
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,267 @@
1
+ 'use strict';
2
+
3
+ const id = 'combobox-name-present';
4
+
5
+ const meta = {
6
+ title: 'Comboboxes have an accessible name',
7
+ description: 'Checks that elements with role="combobox" expose a non-empty accessible name.',
8
+ i18n: {
9
+ titleKey: 'comboboxNamePresent_title',
10
+ descriptionKey: 'comboboxNamePresent_description'
11
+ },
12
+ helpUrl: null,
13
+ tags: ['wcag2a','wcag412','forms','atomic','automatic','name','combobox'],
14
+ wcagSc: ['4.1.2'],
15
+ normativeMappings: [
16
+ { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
17
+ ],
18
+ defaultSeverity: 'serious',
19
+ category: 'robust',
20
+ type: 'automatic',
21
+ defaultConfidence: 'high',
22
+ coverage: { facetsBySc: { '4.1.2': ['combobox-name-present'] } }
23
+ };
24
+
25
+ function runInPage(ctx) {
26
+ const { document, root, helpers, rule } = ctx;
27
+ const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
28
+ ? helpers.getEligibilityInfo
29
+ : null;
30
+ const safeRoot = root || document;
31
+
32
+
33
+ function normalizeWs(s) {
34
+ return String(s || '').replace(/\s+/g, ' ').trim();
35
+ }
36
+
37
+ function getAttr(el, name) {
38
+ try {
39
+ if (!el || !el.getAttribute) return '';
40
+ return normalizeWs(el.getAttribute(name));
41
+ } catch { return ''; }
42
+ }
43
+
44
+ function hasAttr(el, name) {
45
+ try { return !!(el && el.hasAttribute && el.hasAttribute(name)); } catch { return false; }
46
+ }
47
+
48
+ function isExplicitProgrammatic(el) {
49
+ // Programmatic name mechanisms we treat as authoritative (presence-only):
50
+ // - aria-label (non-empty)
51
+ // - aria-labelledby (non-empty)
52
+ // - title (non-empty) [weak but allowed for presence]
53
+ const al = getAttr(el, 'aria-label');
54
+ if (al) return true;
55
+ const alb = getAttr(el, 'aria-labelledby');
56
+ if (alb) return true;
57
+ const t = getAttr(el, 'title');
58
+ if (t) return true;
59
+ return false;
60
+ }
61
+
62
+ function buildLabelForMap(doc) {
63
+ const map = new Map(); // id -> label element (first)
64
+ try {
65
+ const labels = doc && doc.getElementsByTagName ? doc.getElementsByTagName('label') : [];
66
+ for (let i = 0; i < labels.length; i += 1) {
67
+ const lab = labels[i];
68
+ if (!lab || !lab.getAttribute) continue;
69
+ const f = normalizeWs(lab.getAttribute('for'));
70
+ if (!f) continue;
71
+ if (!map.has(f)) map.set(f, lab);
72
+ }
73
+ } catch {}
74
+ return map;
75
+ }
76
+
77
+ function getConservativeSubtreeText(document, container) {
78
+ // "Name from content" — recurses into descendants and uses each one's
79
+ // own accessible name (img alt, aria-label/aria-labelledby, title) when
80
+ // it has one, not just literal text nodes. See getContentNameInfo's
81
+ // header comment in src/core/dom-helpers.js for the full rationale
82
+ // (this replaced a text-node-only TreeWalker that missed the common
83
+ // "<a><img alt='...'></a>" logo-link / "<button><img alt='...'></button>"
84
+ // icon-button pattern).
85
+ if (helpers.getContentNameInfo) {
86
+ const info = helpers.getContentNameInfo(container, ctx);
87
+ return info && info.present ? info.value : '';
88
+ }
89
+ const t = (container && container.textContent) ? String(container.textContent) : '';
90
+ return t.replace(/\s+/g, ' ').trim();
91
+ }
92
+
93
+ // A <label> contributes a name via its own aria-label/aria-labelledby
94
+ // (checked first, same ARIA-over-content precedence any element's
95
+ // accessible name gives — e.g. <label aria-label="Search"><svg
96
+ // aria-hidden="true">...</svg></label> names its control "Search" even
97
+ // though the label's only child content is aria-hidden) or, failing
98
+ // that, its rendered content (getConservativeSubtreeText).
99
+ function getLabelText(lab) {
100
+ if (helpers.getAriaNameInfo) {
101
+ try {
102
+ const aria = helpers.getAriaNameInfo(lab, ctx);
103
+ if (aria && aria.present && aria.value) return normalizeWs(aria.value);
104
+ } catch {}
105
+ }
106
+ const content = getConservativeSubtreeText(document, lab);
107
+ if (content) return content;
108
+ // Final fallback per the general accname text-alternative algorithm,
109
+ // which applies to any element being asked for its name regardless of
110
+ // why (own aria-label, an aria-labelledby reference, or — here — native
111
+ // <label for> association): title, when nothing else yields a name.
112
+ // Purely additive (only fills in a name where there was none before),
113
+ // so it carries no false-positive risk — see dialog-name-present.js's
114
+ // identical <iframe>-title-fallback fix for the concrete real-world
115
+ // trigger this same accname step covers elsewhere.
116
+ return getAttr(lab, 'title');
117
+ }
118
+
119
+ function resolveAriaLabelledbyText(document, el, maxRefs) {
120
+ const raw = getAttr(el, 'aria-labelledby');
121
+ if (!raw) return '';
122
+ // Delegates to the shared getTextFromIdRefs helper instead of computing
123
+ // name-from-content of the referenced element — see dialog-name-
124
+ // present.js's identical fix for the full rationale (an <iframe>
125
+ // aria-labelledby target's only name source is its title attribute,
126
+ // which name-from-content alone can never see).
127
+ if (helpers.getTextFromIdRefs) {
128
+ try {
129
+ const r = helpers.getTextFromIdRefs(raw, ctx, { maxRefs: maxRefs || 8 });
130
+ return normalizeWs(r && r.text);
131
+ } catch {}
132
+ }
133
+ return '';
134
+ }
135
+
136
+ function getInputValueName(el) {
137
+ try {
138
+ const type = getAttr(el, 'type').toLowerCase();
139
+ if (type !== 'button' && type !== 'submit' && type !== 'reset') return '';
140
+ const v = getAttr(el, 'value');
141
+ return v;
142
+ } catch { return ''; }
143
+ }
144
+
145
+ function isEligibleAcc(helpers, el, ctx) {
146
+ const fn = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
147
+ if (!fn) return true;
148
+ try {
149
+ const r = fn(el, ctx);
150
+ if (typeof r === 'boolean') return r;
151
+ return !!(r && r.eligible);
152
+ } catch {
153
+ return true;
154
+ }
155
+ }
156
+
157
+
158
+ const occurrences = [];
159
+ let applicableCount = 0;
160
+
161
+ const selector = '[role="combobox"]';
162
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
163
+
164
+ // Precompute label[for] map for combobox elements that are labelable
165
+ // native form controls (e.g. <input role="combobox">).
166
+ const labelForMap = buildLabelForMap(document);
167
+
168
+ function getNativeLabelText(el) {
169
+ try {
170
+ if ('labels' in el && el.labels && el.labels.length) {
171
+ const parts = [];
172
+ const max = Math.min(4, el.labels.length);
173
+ for (let i = 0; i < max; i += 1) {
174
+ const lab = el.labels[i];
175
+ const t = lab ? getLabelText(lab) : '';
176
+ if (t) parts.push(t);
177
+ }
178
+ const joined = normalizeWs(parts.join(' '));
179
+ if (joined) return joined;
180
+ }
181
+ } catch {}
182
+ try {
183
+ if (el.closest) {
184
+ const wrap = el.closest('label');
185
+ if (wrap) {
186
+ const t = getLabelText(wrap);
187
+ if (t) return t;
188
+ }
189
+ }
190
+ } catch {}
191
+ try {
192
+ const idAttr = getAttr(el, 'id');
193
+ if (idAttr && labelForMap.has(idAttr)) {
194
+ const lab = labelForMap.get(idAttr);
195
+ const t = lab ? getLabelText(lab) : '';
196
+ if (t) return t;
197
+ }
198
+ } catch {}
199
+ return '';
200
+ }
201
+
202
+ function evaluate(el, controlType) {
203
+ const ariaLabel = getAttr(el, 'aria-label');
204
+ if (ariaLabel) return { ok: true, method: 'aria-label' };
205
+
206
+ const ariaLabelledby = resolveAriaLabelledbyText(document, el, 8);
207
+ if (ariaLabelledby) return { ok: true, method: 'aria-labelledby' };
208
+
209
+ const title = getAttr(el, 'title');
210
+ if (title) return { ok: true, method: 'title' };
211
+
212
+ // Native <label> association (e.g. <input role="combobox">).
213
+ const lab = getNativeLabelText(el);
214
+ if (lab) return { ok: true, method: 'label' };
215
+
216
+ // role="combobox" is name-from-author-only per WAI-ARIA: it must NOT
217
+ // fall back to subtree content. Visible text near/inside a custom
218
+ // combobox widget is not reliably exposed as its accessible name.
219
+ return { ok: false, method: 'none' };
220
+ }
221
+
222
+ for (const el of nodes) {
223
+ if (!el) continue;
224
+ if (!isEligibleAcc(helpers, el, ctx)) continue;
225
+
226
+ const role = getAttr(el, 'role').toLowerCase();
227
+ if (role !== 'combobox') continue;
228
+
229
+ applicableCount += 1;
230
+
231
+ const res = evaluate(el, 'combobox');
232
+ if (res.ok) continue;
233
+
234
+ const eligInfo = getEligibilityInfo
235
+ ? (() => { try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })()
236
+ : null;
237
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
238
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
239
+
240
+ occurrences.push({
241
+ selector: stableSelector,
242
+ html,
243
+ summary: 'This combobox has no accessible name.',
244
+ hint: 'Provide aria-label or aria-labelledby (preferred), or ensure the element has visible text that is not hidden from assistive technologies.',
245
+ i18n: {
246
+ summaryKey: 'comboboxNamePresent_summary_fail',
247
+ hintKey: 'comboboxNamePresent_hint_fail',
248
+ params: {}
249
+ },
250
+ data: {
251
+ details: { reasonCode: 'name_missing', controlType: 'combobox', methodTried: res.method },
252
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
253
+ }
254
+ });
255
+ }
256
+
257
+
258
+ if (applicableCount === 0) {
259
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
260
+ }
261
+ if (occurrences.length) {
262
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
263
+ }
264
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
265
+ }
266
+
267
+ module.exports = { id, meta, runInPage };