@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,193 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check object-text-alternative-present
5
+ * @atomic true
6
+ * @summary Accessible <object> elements must provide a text alternative
7
+ * @standard WCAG 2.2
8
+ * @sc 1.1.1
9
+ * @applicability
10
+ * Applies to <object> elements that are exposed to assistive technologies.
11
+ * Objects otherwise hidden from the accessibility tree remain applicable
12
+ * if they are tabbable or referenced by IDREF relationships (per engine eligibility checks).
13
+ * role="presentation"/role="none" are excluded only when not focusable.
14
+ * @expectation
15
+ * Each applicable <object> provides a text alternative via:
16
+ * - fallback content (non-empty text content inside <object>), OR
17
+ * - an accessible name (aria-labelledby/aria-label), OR
18
+ * - a title attribute (best-effort fallback).
19
+ */
20
+
21
+ const id = 'object-text-alternative-present';
22
+
23
+ const meta = {
24
+ title: '<object> must provide a text alternative',
25
+ description: 'Checks that <object> elements provide a text alternative via fallback content or an accessible name.',
26
+ i18n: {
27
+ titleKey: 'object_textAltPresent_title',
28
+ descriptionKey: 'object_textAltPresent_description'
29
+ },
30
+ helpUrl: null,
31
+ tags: ['wcag2a', 'wcag111', 'nontext', 'object', 'atomic', 'automatic'],
32
+ wcagSc: ['1.1.1'],
33
+ normativeMappings: [
34
+ { standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A' }
35
+ ],
36
+ defaultSeverity: 'serious',
37
+ category: 'perceivable',
38
+ type: 'automatic',
39
+ defaultConfidence: 'high',
40
+ coverage: { facetsBySc: { '1.1.1': ['object-text-alternative-present'] } }
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 getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function'
64
+ ? helpers.getFocusableInfo
65
+ : null;
66
+
67
+ const getAriaNameInfo = helpers && typeof helpers.getAriaNameInfo === 'function'
68
+ ? helpers.getAriaNameInfo
69
+ : null;
70
+
71
+ const objects = (() => {
72
+ try { return Array.from((queryAllSmart ? queryAllSmart('object') : queryAll('object')) || []); }
73
+ catch { return queryAll('object'); }
74
+ })();
75
+
76
+ if (!objects.length) {
77
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
78
+ }
79
+
80
+ const occurrences = [];
81
+ let applicableCount = 0;
82
+
83
+ const trim = (v) => (v == null ? '' : String(v)).trim();
84
+
85
+ function computeNameInfo(el) {
86
+ // For <object>, treat ARIA name as primary. Do not accept HTML <label> associations.
87
+ const flags = [];
88
+ let aria = null;
89
+
90
+ if (getAriaNameInfo) {
91
+ aria = (() => { try { return getAriaNameInfo(el, ctx); } catch { return null; } })();
92
+ }
93
+
94
+ if (aria && aria.present && trim(aria.value)) {
95
+ return { present: true, value: trim(aria.value), mechanism: aria.mechanism || 'aria', flags: (aria.flags || []).slice(0) };
96
+ }
97
+
98
+ const title = trim(el.getAttribute && el.getAttribute('title'));
99
+ if (title) {
100
+ flags.push('title-used');
101
+ return { present: true, value: title, mechanism: 'title', flags };
102
+ }
103
+
104
+ if (aria && aria.flags && aria.flags.length) {
105
+ for (const f of aria.flags) flags.push(f);
106
+ }
107
+
108
+ return { present: false, value: '', mechanism: 'none', flags };
109
+ }
110
+
111
+ function computeFallbackText(el) {
112
+ try {
113
+ // Deterministic + bounded: textContent can be large
114
+ const raw = trim(el.textContent || '');
115
+ const t = raw.length > 1000 ? raw.slice(0, 1000) : raw;
116
+ return { present: !!t, value: t, mechanism: 'fallback', flags: t ? [] : ['empty'] };
117
+ } catch {
118
+ return { present: false, value: '', mechanism: 'fallback', flags: ['error'] };
119
+ }
120
+ }
121
+
122
+ for (const el of objects) {
123
+ if (!el || !el.getAttribute) continue;
124
+
125
+ if (isAccTreeEligible) {
126
+ const elig = (() => { try { return isAccTreeEligible(el, ctx); } catch { return { eligible: true, reasons: [] }; } })();
127
+ if (elig && elig.eligible === false) continue;
128
+ }
129
+
130
+ // role presentation/none exclusion only when not focusable
131
+ const role = (() => { try { return String(el.getAttribute('role') || '').trim().toLowerCase(); } catch { return ''; } })();
132
+ if (role === 'presentation' || role === 'none') {
133
+ let focusable = false;
134
+ if (getFocusableInfo) {
135
+ const fi = (() => { try { return getFocusableInfo(el, ctx); } catch { return null; } })();
136
+ focusable = !!(fi && fi.focusable);
137
+ } else {
138
+ const tabindex = el.getAttribute('tabindex');
139
+ focusable = tabindex != null && String(tabindex).trim() !== '' && !Number.isNaN(Number(String(tabindex).trim()));
140
+ }
141
+ if (!focusable) continue;
142
+ }
143
+
144
+ applicableCount += 1;
145
+
146
+ const name = computeNameInfo(el);
147
+
148
+ // Only compute fallback text if there is no name.
149
+ // (textContent can be expensive; avoid when not needed)
150
+ const fb = name.present ? { present: false, value: '', mechanism: 'fallback', flags: ['skipped-name-present'] }
151
+ : computeFallbackText(el);
152
+
153
+ const hasTextAlt = !!(name.present || fb.present);
154
+ if (hasTextAlt) continue;
155
+
156
+
157
+ const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
158
+
159
+ const baseOccurrence = {
160
+ summary: 'Missing text alternative for <object>.',
161
+ hint: 'Provide meaningful fallback content inside <object>, or add an accessible name (aria-label/aria-labelledby).',
162
+ i18n: {
163
+ summaryKey: 'object_textAltPresent_summary_fail',
164
+ hintKey: 'object_textAltPresent_hint_fail',
165
+ params: { element: 'object' }
166
+ },
167
+ data: {
168
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
169
+ fallback: fb,
170
+ name
171
+ }
172
+ };
173
+
174
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
175
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
176
+ } else {
177
+ // Never compute selector/snippet in the rule.
178
+ occurrences.push({ selector: '', html: '', ...baseOccurrence });
179
+ }
180
+ }
181
+
182
+ if (applicableCount === 0) {
183
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
184
+ }
185
+
186
+ if (!occurrences.length) {
187
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
188
+ }
189
+
190
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
191
+ }
192
+
193
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,157 @@
1
+ 'use strict';
2
+
3
+ const id = 'option-name-present';
4
+
5
+ const meta = {
6
+ title: 'Accessible name is present',
7
+ description: 'Checks that elements expose a non-empty accessible name.',
8
+ i18n: {
9
+ titleKey: 'optionNamePresent_title',
10
+ descriptionKey: 'optionNamePresent_description'
11
+ },
12
+ helpUrl: null,
13
+ tags: ["wcag2a", "wcag412", "forms", "atomic", "automatic", "name", "option"],
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': ['option-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 getConservativeSubtreeText(document, container) {
45
+ // "Name from content" — recurses into descendants and uses each one's
46
+ // own accessible name (img alt, aria-label/aria-labelledby, title) when
47
+ // it has one, not just literal text nodes. See getContentNameInfo's
48
+ // header comment in src/core/dom-helpers.js for the full rationale
49
+ // (this replaced a text-node-only TreeWalker that missed the common
50
+ // "<a><img alt='...'></a>" logo-link / "<button><img alt='...'></button>"
51
+ // icon-button pattern).
52
+ if (helpers.getContentNameInfo) {
53
+ const info = helpers.getContentNameInfo(container, ctx);
54
+ return info && info.present ? info.value : '';
55
+ }
56
+ const t = (container && container.textContent) ? String(container.textContent) : '';
57
+ return t.replace(/\s+/g, ' ').trim();
58
+ }
59
+
60
+ function resolveAriaLabelledbyText(document, el, maxRefs) {
61
+ const raw = getAttr(el, 'aria-labelledby');
62
+ if (!raw) return '';
63
+ // Delegates to the shared getTextFromIdRefs helper instead of computing
64
+ // name-from-content of the referenced element — see dialog-name-
65
+ // present.js's identical fix for the full rationale (an <iframe>
66
+ // aria-labelledby target's only name source is its title attribute,
67
+ // which name-from-content alone can never see).
68
+ if (helpers.getTextFromIdRefs) {
69
+ try {
70
+ const r = helpers.getTextFromIdRefs(raw, ctx, { maxRefs: maxRefs || 8 });
71
+ return normalizeWs(r && r.text);
72
+ } catch {}
73
+ }
74
+ return '';
75
+ }
76
+
77
+ function isEligibleAcc(helpers, el, ctx) {
78
+ const fn = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
79
+ if (!fn) return true;
80
+ try {
81
+ const r = fn(el, ctx);
82
+ if (typeof r === 'boolean') return r;
83
+ return !!(r && r.eligible);
84
+ } catch {
85
+ return true;
86
+ }
87
+ }
88
+
89
+
90
+ const occurrences = [];
91
+ let applicableCount = 0;
92
+
93
+ const selector = "[role=\"option\"]";
94
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
95
+
96
+
97
+ function hasName(el) {
98
+ const ariaLabel = getAttr(el, 'aria-label');
99
+ if (ariaLabel) return { ok: true, method: 'aria-label' };
100
+
101
+ const labelled = resolveAriaLabelledbyText(document, el, 8);
102
+ if (labelled) return { ok: true, method: 'aria-labelledby' };
103
+
104
+ const title = getAttr(el, 'title');
105
+ if (title) return { ok: true, method: 'title' };
106
+
107
+
108
+ const t = getConservativeSubtreeText(document, el);
109
+ if (t) return { ok: true, method: 'content' };
110
+
111
+
112
+ return { ok: false, method: 'none' };
113
+ }
114
+
115
+ for (const el of nodes) {
116
+ if (!el) continue;
117
+ if (!isEligibleAcc(helpers, el, ctx)) continue;
118
+
119
+ applicableCount += 1;
120
+
121
+ const res = hasName(el);
122
+ if (res.ok) continue;
123
+
124
+ const eligInfo = getEligibilityInfo
125
+ ? (() => { try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })()
126
+ : null;
127
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
128
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
129
+
130
+ occurrences.push({
131
+ selector: stableSelector,
132
+ html,
133
+ summary: 'This element has no accessible name.',
134
+ hint: 'Provide aria-label or aria-labelledby (preferred), or provide visible text that is not hidden from assistive technologies.',
135
+ i18n: {
136
+ summaryKey: 'optionNamePresent_summary_fail',
137
+ hintKey: 'optionNamePresent_hint_fail',
138
+ params: { controlType: 'option' }
139
+ },
140
+ data: {
141
+ details: { reasonCode: 'name_missing', controlType: 'option', methodTried: res.method },
142
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
143
+ }
144
+ });
145
+ }
146
+
147
+
148
+ if (applicableCount === 0) {
149
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
150
+ }
151
+ if (occurrences.length) {
152
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
153
+ }
154
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
155
+ }
156
+
157
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ const id = 'page-title-present';
4
+
5
+ const meta = {
6
+ title: 'Page title is present and non-empty',
7
+ description: 'Checks that the document has a non-empty <title> element (WCAG 2.2 SC 2.4.2).',
8
+ i18n: {
9
+ titleKey: 'pageTitlePresent_title',
10
+ descriptionKey: 'pageTitlePresent_description'
11
+ },
12
+ helpUrl: null,
13
+ tags: ['wcag2a', 'wcag242', 'titles', 'atomic', 'navigation', 'automatic'],
14
+ wcagSc: ['2.4.2'],
15
+ normativeMappings: [
16
+ { standard: 'WCAG', version: '2.2', requirement: '2.4.2', title: 'Page Titled', conformanceLevel: 'A' }
17
+ ],
18
+ defaultSeverity: 'serious',
19
+ category: 'operable',
20
+ type: 'automatic',
21
+ defaultConfidence: 'high',
22
+ coverage: { facetsBySc: { '2.4.2': ['page-title-present'] } }
23
+ };
24
+
25
+ function runInPage(ctx) {
26
+ const { document, helpers, rule } = ctx;
27
+
28
+ const occurrences = [];
29
+ let applicableCount = 1;
30
+
31
+ // Not restricted to `head > title`: per HTML parsing, a <title> encountered
32
+ // after <head> has closed is not re-parented into <head>, yet the browser
33
+ // (and document.title, used below) still recognizes it as the document's
34
+ // title — matching that here avoids a false "missing title" report for a
35
+ // <title> that legitimately ended up outside <head>.
36
+ const titleEl = document.querySelector('title');
37
+ const titleText = (document.title || '').replace(/\s+/g, ' ').trim();
38
+
39
+ const missingTitleEl = !titleEl;
40
+ const emptyTitle = !missingTitleEl && titleText.length === 0;
41
+
42
+ if (missingTitleEl || emptyTitle) {
43
+ const reasonCode = missingTitleEl ? 'missingTitleElement' : 'emptyTitleText';
44
+
45
+ const occBase = {
46
+ selector: 'head > title',
47
+ html: '',
48
+ summary: missingTitleEl
49
+ ? 'The page is missing a <title> element.'
50
+ : 'The page has an empty <title>.',
51
+ hint: 'Provide a descriptive, non-empty <title> that identifies the page topic or purpose.',
52
+ i18n: {
53
+ summaryKey: missingTitleEl
54
+ ? 'pageTitlePresent_summary_fail_missing'
55
+ : 'pageTitlePresent_summary_fail_empty',
56
+ hintKey: 'pageTitlePresent_hint_fail',
57
+ params: {}
58
+ },
59
+ data: {
60
+ details: {
61
+ reasonCode,
62
+ titleText
63
+ },
64
+ visibilityFilter: { targetSet: 'acc', accEligible: null, reasons: [] }
65
+ }
66
+ };
67
+
68
+ if (!missingTitleEl && titleEl && helpers && typeof helpers.reportOccurrence === 'function') {
69
+ // empty title: we have a node, so engine can attach html
70
+ occurrences.push(helpers.reportOccurrence(titleEl, occBase));
71
+ } else {
72
+ // missing title element: no node exists, keep deterministic html string
73
+ occurrences.push({ ...occBase, html: '<title>(missing)</title>' });
74
+ }
75
+ }
76
+
77
+ if (applicableCount === 0) {
78
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
79
+ }
80
+ if (occurrences.length) {
81
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
82
+ }
83
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
84
+ }
85
+
86
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,165 @@
1
+ 'use strict';
2
+
3
+ const id = 'progressbar-name-present';
4
+
5
+ const meta = {
6
+ title: 'Progress bars have an accessible name',
7
+ description: 'Checks that elements with role="progressbar" expose a non-empty accessible name.',
8
+ i18n: {
9
+ titleKey: 'progressbarNamePresent_title',
10
+ descriptionKey: 'progressbarNamePresent_description'
11
+ },
12
+ helpUrl: null,
13
+ tags: ['wcag2a', 'wcag111', 'nontext', 'atomic', 'automatic', 'name'],
14
+ wcagSc: ['1.1.1'],
15
+ normativeMappings: [
16
+ { standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A' }
17
+ ],
18
+ defaultSeverity: 'serious',
19
+ category: 'perceivable',
20
+ type: 'automatic',
21
+ defaultConfidence: 'high',
22
+ coverage: { facetsBySc: { '1.1.1': ['progressbar-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 getConservativeSubtreeText(document, container) {
45
+ // "Name from content" — recurses into descendants and uses each one's
46
+ // own accessible name (img alt, aria-label/aria-labelledby, title) when
47
+ // it has one, not just literal text nodes. See getContentNameInfo's
48
+ // header comment in src/core/dom-helpers.js for the full rationale
49
+ // (this replaced a text-node-only TreeWalker that missed the common
50
+ // "<a><img alt='...'></a>" logo-link / "<button><img alt='...'></button>"
51
+ // icon-button pattern).
52
+ if (helpers.getContentNameInfo) {
53
+ const info = helpers.getContentNameInfo(container, ctx);
54
+ return info && info.present ? info.value : '';
55
+ }
56
+ const t = (container && container.textContent) ? String(container.textContent) : '';
57
+ return t.replace(/\s+/g, ' ').trim();
58
+ }
59
+
60
+ function resolveAriaLabelledbyText(document, el, maxRefs) {
61
+ const raw = getAttr(el, 'aria-labelledby');
62
+ if (!raw) return '';
63
+ // Delegates to the shared getTextFromIdRefs helper instead of computing
64
+ // name-from-content of the referenced element — see dialog-name-
65
+ // present.js's identical fix for the full rationale (an <iframe>
66
+ // aria-labelledby target's only name source is its title attribute,
67
+ // which name-from-content alone can never see).
68
+ if (helpers.getTextFromIdRefs) {
69
+ try {
70
+ const r = helpers.getTextFromIdRefs(raw, ctx, { maxRefs: maxRefs || 8 });
71
+ return normalizeWs(r && r.text);
72
+ } catch {}
73
+ }
74
+ return '';
75
+ }
76
+
77
+ function isEligibleAcc(helpers, el, ctx) {
78
+ const fn = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
79
+ if (!fn) return true;
80
+ try {
81
+ const r = fn(el, ctx);
82
+ if (typeof r === 'boolean') return r;
83
+ return !!(r && r.eligible);
84
+ } catch {
85
+ return true;
86
+ }
87
+ }
88
+
89
+
90
+ const occurrences = [];
91
+ let applicableCount = 0;
92
+
93
+ const selector = '[role="progressbar"]';
94
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
95
+
96
+
97
+ function evaluate(el) {
98
+ const ariaLabel = getAttr(el, 'aria-label');
99
+ if (ariaLabel) return { ok: true, method: 'aria-label' };
100
+
101
+ const ariaLabelledby = resolveAriaLabelledbyText(document, el, 8);
102
+ if (ariaLabelledby) return { ok: true, method: 'aria-labelledby' };
103
+
104
+ const title = getAttr(el, 'title');
105
+ if (title) return { ok: true, method: 'title' };
106
+
107
+ // role="progressbar" is name-from-author-only per WAI-ARIA (verified
108
+ // against a widely-used reference engine's own aria-progressbar-name check: any:
109
+ // ['aria-label', 'aria-labelledby', title] — no content-based naming
110
+ // method at all). It must NOT fall back to subtree content: found on a
111
+ // real site, Instacart's <ul role="progressbar"> loading skeleton has
112
+ // no name of its own, but nested descendants carry their own
113
+ // aria-label="Loading Box" (for a completely different purpose) —
114
+ // falling back to subtree content picked that up and wrongly treated
115
+ // it as the progressbar's own name, when that reference engine correctly
116
+ // still flags it as unnamed.
117
+ return { ok: false, method: 'none' };
118
+ }
119
+
120
+ for (const el of nodes) {
121
+ if (!el) continue;
122
+ if (!isEligibleAcc(helpers, el, ctx)) continue;
123
+
124
+ const role = getAttr(el, 'role').toLowerCase();
125
+ if (role !== 'progressbar') continue;
126
+
127
+ applicableCount += 1;
128
+
129
+ const res = evaluate(el);
130
+ if (res.ok) continue;
131
+
132
+ const eligInfo = getEligibilityInfo
133
+ ? (() => { try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })()
134
+ : null;
135
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
136
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
137
+
138
+ occurrences.push({
139
+ selector: stableSelector,
140
+ html,
141
+ summary: 'This progress bar has no accessible name.',
142
+ hint: 'Provide progress bar text that is not hidden from assistive technologies, or provide aria-label or aria-labelledby.',
143
+ i18n: {
144
+ summaryKey: 'progressbarNamePresent_summary_fail',
145
+ hintKey: 'progressbarNamePresent_hint_fail',
146
+ params: {}
147
+ },
148
+ data: {
149
+ details: { reasonCode: 'name_missing', controlType: 'progressbar', methodTried: res.method },
150
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
151
+ }
152
+ });
153
+ }
154
+
155
+
156
+ if (applicableCount === 0) {
157
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
158
+ }
159
+ if (occurrences.length) {
160
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
161
+ }
162
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
163
+ }
164
+
165
+ module.exports = { id, meta, runInPage };