@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,225 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check area-alt-present
5
+ * @atomic true
6
+ * @summary Accessible <area> elements must have an alt attribute
7
+ * @standard WCAG 2.2
8
+ * @sc 1.1.1
9
+ * @applicability
10
+ * Applies to <area> elements that:
11
+ * 1) are in a <map> that is referenced by an <img usemap>, AND
12
+ * 2) the referencing <img> is eligible in the accessibility tree (best-effort), AND
13
+ * 3) the <area> itself is eligible in the accessibility tree (with engine exceptions).
14
+ * @expectation
15
+ * Each applicable <area> element has an alt attribute.
16
+ * The alt attribute may be empty (alt="").
17
+ */
18
+
19
+ const id = 'area-alt-present';
20
+
21
+ const meta = {
22
+ title: '&lt;area&gt; must have an alt attribute',
23
+ description: 'Checks that &lt;area&gt; elements provide an alt attribute to support a text alternative mechanism.',
24
+ i18n: {
25
+ titleKey: 'area_altPresent_title',
26
+ descriptionKey: 'area_altPresent_description'
27
+ },
28
+ helpUrl: null,
29
+ tags: ['wcag2a', 'wcag111', 'nontext', 'images', 'imagemap', 'atomic', 'automatic'],
30
+ wcagSc: ['1.1.1'],
31
+ normativeMappings: [
32
+ { standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A' }
33
+ ],
34
+ defaultSeverity: 'serious',
35
+ category: 'perceivable',
36
+ type: 'automatic',
37
+ defaultConfidence: 'high',
38
+ coverage: {
39
+ facetsBySc: {
40
+ '1.1.1': ['area-alt-attr-present']
41
+ }
42
+ }
43
+ };
44
+
45
+ function runInPage(ctx) {
46
+ const { document, root, helpers, rule } = ctx;
47
+ const safeRoot = root || document;
48
+
49
+ const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
50
+ const queryAll = helpers && typeof helpers.queryAll === 'function'
51
+ ? helpers.queryAll
52
+ : (sel) => {
53
+ try { return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : []; }
54
+ catch { return []; }
55
+ };
56
+
57
+ const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
58
+ ? helpers.getEligibilityInfo
59
+ : null;
60
+
61
+ const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
62
+ ? helpers.isAccTreeEligible
63
+ : null;
64
+
65
+ const getAriaNameInfo = helpers && typeof helpers.getAriaNameInfo === 'function'
66
+ ? helpers.getAriaNameInfo
67
+ : null;
68
+
69
+ // --- image-map semantics (rule-local) ---
70
+
71
+ function normUsemap(val) {
72
+ try {
73
+ const s = String(val || '').trim();
74
+ if (!s) return '';
75
+ return (s[0] === '#') ? s.slice(1).trim().toLowerCase() : s.toLowerCase();
76
+ } catch {
77
+ return '';
78
+ }
79
+ }
80
+
81
+ function getMapName(mapEl) {
82
+ try {
83
+ if (!mapEl || !mapEl.getAttribute) return '';
84
+ const n = String(mapEl.getAttribute('name') || mapEl.getAttribute('id') || '').trim();
85
+ return n ? n.toLowerCase() : '';
86
+ } catch {
87
+ return '';
88
+ }
89
+ }
90
+
91
+ // Cache mapName -> first referencing <img> in document order (deterministic)
92
+ const __usemapIndex = (() => {
93
+ const idx = new Map();
94
+ try {
95
+ const imgs = document && document.querySelectorAll ? document.querySelectorAll('img[usemap]') : [];
96
+ for (const img of imgs) {
97
+ if (!img || !img.getAttribute) continue;
98
+ const u = normUsemap(img.getAttribute('usemap'));
99
+ if (!u) continue;
100
+ if (!idx.has(u)) idx.set(u, img); // first in document order wins
101
+ }
102
+ } catch {
103
+ // ignore
104
+ }
105
+ return idx;
106
+ })();
107
+
108
+ function getReferencingImgForArea(areaEl) {
109
+ try {
110
+ if (!areaEl || !areaEl.closest) return null;
111
+ const map = areaEl.closest('map');
112
+ if (!map) return null;
113
+
114
+ const mapName = getMapName(map);
115
+ if (!mapName) return null;
116
+
117
+ return __usemapIndex.get(mapName) || null;
118
+ } catch {}
119
+ return null;
120
+ }
121
+
122
+ const areas = (() => {
123
+ try { return Array.from((queryAllSmart ? queryAllSmart('area') : queryAll('area')) || []); }
124
+ catch { return queryAll('area'); }
125
+ })();
126
+
127
+ if (!areas.length) {
128
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
129
+ }
130
+
131
+ const occurrences = [];
132
+ let applicableCount = 0;
133
+
134
+ for (const el of areas) {
135
+ if (!el || !el.getAttribute) continue;
136
+
137
+ // 0) Must belong to a *used* image map: an <img usemap> must reference its <map>.
138
+ // If not used, <area> is not applicable (matches your observed focus behavior).
139
+ const img = getReferencingImgForArea(el);
140
+ if (!img) continue;
141
+
142
+ // 1) The referencing <img> must itself be eligible in the acc tree.
143
+ // This is the "visibility of map/area doesn't matter; the image does" policy.
144
+ if (isAccTreeEligible) {
145
+ const imgElig = (() => {
146
+ try { return isAccTreeEligible(img, ctx); } catch { return { eligible: true, reasons: [] }; }
147
+ })();
148
+ if (imgElig && imgElig.eligible === false) continue;
149
+ }
150
+
151
+ // 2) The <area> itself must be eligible (aria-hidden/inert exceptions handled by helper).
152
+ if (isAccTreeEligible) {
153
+ const elig = (() => {
154
+ try { return isAccTreeEligible(el, ctx); } catch { return { eligible: true, reasons: [] }; }
155
+ })();
156
+ if (elig && elig.eligible === false) continue;
157
+ }
158
+
159
+ // From here: applicable
160
+ applicableCount += 1;
161
+
162
+ const hasAlt = el.getAttribute('alt') !== null;
163
+ if (hasAlt) continue;
164
+
165
+ // aria-label / aria-labelledby is also a valid, standards-recognized
166
+ // text-alternative mechanism for <area> (HTML-AAM accessible name
167
+ // computation includes ARIA naming before falling back to alt).
168
+ if (getAriaNameInfo) {
169
+ let ariaName = null;
170
+ try {
171
+ ariaName = getAriaNameInfo(el, ctx);
172
+ } catch {
173
+ ariaName = null;
174
+ }
175
+ if (ariaName && ariaName.present) continue;
176
+ }
177
+
178
+ // A non-empty title attribute is HTML-AAM's own next fallback naming
179
+ // source once alt is entirely absent -- also accepted by a widely-used
180
+ // reference engine's equivalent area-alt rule (non-empty-title, same "any" list as
181
+ // non-empty-alt/aria-label/aria-labelledby). See img-alt-present's
182
+ // sibling fix (2026-07-23, AliExpress's title-only logo <img>) for
183
+ // the real page this was found via -- same gap, same fix, different
184
+ // element.
185
+ const titleRaw = (() => { try { return el.getAttribute('title'); } catch { return null; } })();
186
+ if (titleRaw !== null && String(titleRaw).trim()) continue;
187
+
188
+ const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
189
+
190
+ const baseOccurrence = {
191
+ // Leave selector/html empty so the engine can fill them from __node.
192
+ selector: '',
193
+ html: '',
194
+ summary: 'Missing alt attribute on &lt;area&gt;.',
195
+ hint: 'Add an alt attribute (use alt="" only for decorative areas).',
196
+ i18n: {
197
+ summaryKey: 'area_altPresent_summary_fail',
198
+ hintKey: 'area_altPresent_hint_fail',
199
+ params: { element: 'area' }
200
+ },
201
+ data: {
202
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
203
+ }
204
+ };
205
+
206
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
207
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
208
+ } else {
209
+ // Never compute selector/snippet in the rule.
210
+ occurrences.push({ ...baseOccurrence });
211
+ }
212
+ }
213
+
214
+ if (applicableCount === 0) {
215
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
216
+ }
217
+
218
+ if (!occurrences.length) {
219
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
220
+ }
221
+
222
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
223
+ }
224
+
225
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,206 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check aria-allowed-attr
5
+ * @atomic true
6
+ * @summary aria-* attributes present must be permitted for the element's role
7
+ * @standard WCAG 2.2
8
+ * @sc 4.1.2
9
+ * @applicability
10
+ * Applies to elements with an explicit, valid, non-abstract role that
11
+ * also carry at least one recognized aria-* attribute.
12
+ * @expectation
13
+ * Every recognized aria-* attribute present is either: (a) globally
14
+ * supported on any element (the "global" ARIA states/properties, e.g.
15
+ * aria-label/aria-hidden/aria-describedby), or (b) explicitly listed as
16
+ * a required or supported state/property for the element's role.
17
+ * @implementation-notes
18
+ * - Only evaluated for elements with an explicit role, since the global
19
+ * set already covers implicit-role elements without asserting anything
20
+ * role-specific; scope kept deliberately narrow to avoid false positives
21
+ * on implicit-role ARIA-in-HTML edge cases not modeled here.
22
+ * - SUPPORTED_ATTRS_BY_ROLE widened 2026-07-21 (7 new roles), cross-checked
23
+ * against a widely-used reference engine's own per-role `allowedAttrs`
24
+ * table AND verified each addition is an unambiguous, well-established ARIA
25
+ * fact rather than a blind import — that engine's own table has ~68 roles,
26
+ * far more than were reconciled here; this pass deliberately took only the
27
+ * additions with clear, specific supported attributes (not just the
28
+ * near-universal, thin `aria-expanded` that engine allows on most roles), and
29
+ * left the rest for a dedicated future full-reconciliation pass rather
30
+ * than rushing all 68 through at lower confidence:
31
+ * - `searchbox`: identical to the already-covered `textbox` (ARIA
32
+ * explicitly defines searchbox as textbox's subclass, same supported
33
+ * set).
34
+ * - `meter`: valuenow/valuemax/valuemin/valuetext, matching the
35
+ * already-covered slider/progressbar/scrollbar/spinbutton pattern
36
+ * (valuenow is also separately *required* on meter — see
37
+ * `aria-required-attr`'s own 2026-07-21 widening — but the two tables
38
+ * aren't unioned automatically, so it needs listing in both places).
39
+ * - `columnheader`/`rowheader`: same table-role family already covered
40
+ * by `gridcell`/`row` — sort/col-row-index/span are standard WAI-ARIA
41
+ * grid-model properties.
42
+ * - `dialog`/`alertdialog`: `aria-modal` — the spec-defining property
43
+ * that distinguishes a modal from a non-modal dialog; unambiguous.
44
+ * - `listitem`: level/posinset/setsize — standard flat-representation-
45
+ * of-hierarchy properties, same family as the already-covered
46
+ * `treeitem`.
47
+ * - `menu`/`menubar`/`toolbar`: activedescendant/orientation — standard
48
+ * composite-widget properties, same family as the already-covered
49
+ * `tablist`.
50
+ * - Not gated on isAccTreeEligible: this is a static markup property.
51
+ */
52
+
53
+ const id = 'aria-allowed-attr';
54
+
55
+ const meta = {
56
+ title: 'aria-* attributes must be permitted for the element’s role',
57
+ description: 'Checks that every recognized aria-* attribute present on an element with an explicit role is either globally supported or supported by that role.',
58
+ i18n: {
59
+ titleKey: 'ariaAllowedAttr_title',
60
+ descriptionKey: 'ariaAllowedAttr_description'
61
+ },
62
+ helpUrl: null,
63
+ tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
64
+ wcagSc: ['4.1.2'],
65
+ normativeMappings: [
66
+ { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
67
+ ],
68
+ defaultSeverity: 'moderate',
69
+ category: 'robust',
70
+ type: 'automatic',
71
+ defaultConfidence: 'medium',
72
+ coverage: { facetsBySc: { '4.1.2': ['aria-attr-allowed-for-role'] } }
73
+ };
74
+
75
+ function runInPage(ctx) {
76
+ const { document, root, helpers, rule } = ctx;
77
+ const safeRoot = root || document;
78
+
79
+ const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
80
+ if (!ariaHelpers) {
81
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
82
+ }
83
+
84
+ // Global ARIA states/properties supported on (almost) any element,
85
+ // regardless of role, per the WAI-ARIA "Global States and Properties" list.
86
+ // Declared inside runInPage (rather than at module scope) because the
87
+ // build inlines only this function's own source text — see
88
+ // scripts/build-core.js header ("runInPage MUST be self-contained").
89
+ const GLOBAL_ATTRS = [
90
+ 'aria-atomic', 'aria-braillelabel', 'aria-brailleroledescription', 'aria-busy',
91
+ 'aria-controls', 'aria-current', 'aria-describedby', 'aria-description',
92
+ 'aria-details', 'aria-disabled', 'aria-dropeffect', 'aria-errormessage',
93
+ 'aria-flowto', 'aria-grabbed', 'aria-haspopup', 'aria-hidden', 'aria-invalid',
94
+ 'aria-keyshortcuts', 'aria-label', 'aria-labelledby', 'aria-live', 'aria-owns',
95
+ 'aria-relevant', 'aria-roledescription'
96
+ ];
97
+
98
+ // Per-role supported (non-global) states/properties. Deliberately
99
+ // conservative — see src/core/aria-helpers.js file header for the same
100
+ // confidence-scoping rationale; only well-established, unambiguous
101
+ // role/attribute pairings from the WAI-ARIA role definitions are listed.
102
+ const SUPPORTED_ATTRS_BY_ROLE = {
103
+ alertdialog: ['aria-modal'],
104
+ checkbox: ['aria-checked', 'aria-readonly', 'aria-required'],
105
+ columnheader: ['aria-sort', 'aria-colindex', 'aria-colspan', 'aria-readonly', 'aria-required', 'aria-rowindex', 'aria-rowspan', 'aria-selected'],
106
+ combobox: ['aria-expanded', 'aria-autocomplete', 'aria-readonly', 'aria-required'],
107
+ dialog: ['aria-modal'],
108
+ grid: ['aria-multiselectable', 'aria-readonly', 'aria-colcount', 'aria-rowcount'],
109
+ gridcell: ['aria-selected', 'aria-readonly', 'aria-required', 'aria-colindex', 'aria-colspan', 'aria-rowindex', 'aria-rowspan'],
110
+ heading: ['aria-level'],
111
+ listbox: ['aria-multiselectable', 'aria-readonly', 'aria-required', 'aria-orientation'],
112
+ listitem: ['aria-level', 'aria-posinset', 'aria-setsize'],
113
+ menu: ['aria-activedescendant', 'aria-orientation'],
114
+ menubar: ['aria-activedescendant', 'aria-orientation'],
115
+ menuitemcheckbox: ['aria-checked'],
116
+ menuitemradio: ['aria-checked'],
117
+ meter: ['aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'aria-valuetext'],
118
+ option: ['aria-selected', 'aria-checked', 'aria-posinset', 'aria-setsize'],
119
+ progressbar: ['aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'aria-valuetext'],
120
+ radio: ['aria-checked'],
121
+ radiogroup: ['aria-readonly', 'aria-required', 'aria-orientation'],
122
+ row: ['aria-selected', 'aria-level', 'aria-posinset', 'aria-setsize', 'aria-colindex', 'aria-rowindex'],
123
+ rowheader: ['aria-sort', 'aria-colindex', 'aria-colspan', 'aria-readonly', 'aria-required', 'aria-rowindex', 'aria-rowspan', 'aria-selected'],
124
+ scrollbar: ['aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'aria-valuetext', 'aria-orientation', 'aria-controls'],
125
+ searchbox: ['aria-activedescendant', 'aria-autocomplete', 'aria-multiline', 'aria-placeholder', 'aria-readonly', 'aria-required'],
126
+ separator: ['aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'aria-valuetext', 'aria-orientation'],
127
+ slider: ['aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'aria-valuetext', 'aria-orientation', 'aria-readonly'],
128
+ spinbutton: ['aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'aria-valuetext', 'aria-readonly', 'aria-required'],
129
+ switch: ['aria-checked'],
130
+ tab: ['aria-selected'],
131
+ table: ['aria-colcount', 'aria-rowcount'],
132
+ tablist: ['aria-multiselectable', 'aria-orientation'],
133
+ textbox: ['aria-activedescendant', 'aria-autocomplete', 'aria-multiline', 'aria-placeholder', 'aria-readonly', 'aria-required'],
134
+ toolbar: ['aria-activedescendant', 'aria-orientation'],
135
+ tree: ['aria-multiselectable', 'aria-readonly', 'aria-required', 'aria-orientation'],
136
+ treegrid: ['aria-multiselectable', 'aria-readonly', 'aria-required', 'aria-orientation', 'aria-colcount', 'aria-rowcount'],
137
+ treeitem: ['aria-checked', 'aria-selected', 'aria-expanded', 'aria-level', 'aria-posinset', 'aria-setsize']
138
+ };
139
+
140
+ const globalSet = new Set(GLOBAL_ATTRS);
141
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]', safeRoot) : helpers.queryAll('[role]', safeRoot);
142
+
143
+ const occurrences = [];
144
+ let applicableCount = 0;
145
+
146
+ for (const el of nodes) {
147
+ if (!el || !el.attributes) continue;
148
+
149
+ const role = ariaHelpers.getExplicitRole(el);
150
+ if (!role || !ariaHelpers.isValidConcreteRole(role)) continue; // aria-roles-valid's concern
151
+
152
+ // Deliberately scoped: only roles with an explicit supported-attrs
153
+ // entry are evaluated (see file header — kept narrow to avoid
154
+ // over-claiming constraints for roles not yet modeled here).
155
+ const roleSupported = SUPPORTED_ATTRS_BY_ROLE[role];
156
+ if (!roleSupported) continue;
157
+ const roleSupportedSet = new Set(roleSupported);
158
+
159
+ let disallowed = null;
160
+ const attrs = el.attributes;
161
+ for (let i = 0; i < attrs.length; i++) {
162
+ const name = String(attrs[i].name || '').toLowerCase();
163
+ if (name.slice(0, 5) !== 'aria-') continue;
164
+ if (!ariaHelpers.isValidAriaAttrName(name)) continue; // aria-valid-attr's concern
165
+
166
+ applicableCount += 1;
167
+
168
+ if (globalSet.has(name) || roleSupportedSet.has(name)) continue;
169
+
170
+ if (!disallowed) disallowed = [];
171
+ disallowed.push(name);
172
+ }
173
+
174
+ if (!disallowed || !disallowed.length) continue;
175
+
176
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
177
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
178
+
179
+ for (const name of disallowed) {
180
+ occurrences.push({
181
+ selector: stableSelector,
182
+ html,
183
+ summary: 'This ARIA attribute is not permitted for this element’s role.',
184
+ hint: 'Remove this attribute, or use a role that supports it.',
185
+ i18n: {
186
+ summaryKey: 'ariaAllowedAttr_summary_fail',
187
+ hintKey: 'ariaAllowedAttr_hint_fail',
188
+ params: { attr: name, role }
189
+ },
190
+ data: {
191
+ details: { reasonCode: 'ARIA_ATTR_NOT_ALLOWED', attr: name, role }
192
+ }
193
+ });
194
+ }
195
+ }
196
+
197
+ if (applicableCount === 0) {
198
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
199
+ }
200
+ if (occurrences.length) {
201
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'moderate', occurrences };
202
+ }
203
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
204
+ }
205
+
206
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check aria-allowed-role
5
+ * @atomic true
6
+ * @summary Explicit role must be permitted by the ARIA-in-HTML spec for its host element
7
+ * @standard WCAG 2.2
8
+ * @sc 4.1.2
9
+ * @applicability
10
+ * Applies to elements with an explicit, valid, non-abstract role, where
11
+ * the host element/attribute combination has an asserted permitted-roles
12
+ * constraint in the ARIA-in-HTML table (src/core/aria-helpers.js
13
+ * ALLOWED_ROLES_BY_ELEMENT).
14
+ * @expectation
15
+ * The explicit role is one of the roles the ARIA-in-HTML specification
16
+ * permits for that host element.
17
+ * @implementation-notes
18
+ * - Deliberately scoped to elements present in ALLOWED_ROLES_BY_ELEMENT;
19
+ * elements without an asserted constraint are treated as "no constraint"
20
+ * (not flagged) rather than guessed at — see that table's header comment.
21
+ * - Not gated on isAccTreeEligible: this is a static markup property.
22
+ */
23
+
24
+ const id = 'aria-allowed-role';
25
+
26
+ const meta = {
27
+ title: 'Explicit role must be permitted for its host element',
28
+ description: 'Checks that an explicit role="" attribute is one of the roles the ARIA-in-HTML specification permits for the host element (e.g. role="tab" is not permitted on <nav>).',
29
+ i18n: {
30
+ titleKey: 'ariaAllowedRole_title',
31
+ descriptionKey: 'ariaAllowedRole_description'
32
+ },
33
+ helpUrl: null,
34
+ tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
35
+ wcagSc: ['4.1.2'],
36
+ normativeMappings: [
37
+ { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
38
+ ],
39
+ defaultSeverity: 'moderate',
40
+ category: 'robust',
41
+ type: 'automatic',
42
+ defaultConfidence: 'high',
43
+ coverage: { facetsBySc: { '4.1.2': ['aria-role-allowed-for-element'] } }
44
+ };
45
+
46
+ function runInPage(ctx) {
47
+ const { document, root, helpers, rule } = ctx;
48
+ const safeRoot = root || document;
49
+
50
+ const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
51
+ if (!ariaHelpers) {
52
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
53
+ }
54
+
55
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]', safeRoot) : helpers.queryAll('[role]', safeRoot);
56
+
57
+ const occurrences = [];
58
+ let applicableCount = 0;
59
+
60
+ for (const el of nodes) {
61
+ if (!el || !el.getAttribute) continue;
62
+
63
+ const role = ariaHelpers.getExplicitRole(el);
64
+ if (!role || !ariaHelpers.isValidConcreteRole(role)) continue; // aria-roles-valid's concern
65
+
66
+ const info = ariaHelpers.isRoleAllowedOnElement(el, role);
67
+ if (!info.constrained) continue; // no asserted constraint for this element
68
+
69
+ applicableCount += 1;
70
+
71
+ if (info.allowed) continue;
72
+
73
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
74
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
75
+ const tag = (el.tagName || '').toLowerCase();
76
+
77
+ occurrences.push({
78
+ selector: stableSelector,
79
+ html,
80
+ summary: 'This role is not permitted on this element.',
81
+ hint: 'Use a role permitted for this element, or change the host element.',
82
+ i18n: {
83
+ summaryKey: 'ariaAllowedRole_summary_fail',
84
+ hintKey: 'ariaAllowedRole_hint_fail',
85
+ params: { role, element: tag }
86
+ },
87
+ data: {
88
+ details: { reasonCode: 'ARIA_ROLE_NOT_ALLOWED_FOR_ELEMENT', role, element: tag }
89
+ }
90
+ });
91
+ }
92
+
93
+ if (applicableCount === 0) {
94
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
95
+ }
96
+ if (occurrences.length) {
97
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'moderate', occurrences };
98
+ }
99
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
100
+ }
101
+
102
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,139 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check aria-braille-equivalent
5
+ * @atomic true
6
+ * @summary aria-braillelabel/aria-brailleroledescription must not be the only naming mechanism
7
+ * @standard WCAG 2.2
8
+ * @sc 4.1.2
9
+ * @applicability
10
+ * Elements with a non-empty `aria-braillelabel` and/or non-empty
11
+ * `aria-brailleroledescription` attribute.
12
+ * @expectation
13
+ * Per the ARIA specification, `aria-braillelabel` is a Braille-specific
14
+ * SUPPLEMENT to (not a replacement for) the element's regular
15
+ * accessible name, and `aria-brailleroledescription` is a supplement
16
+ * to `aria-roledescription`. An element must therefore also have:
17
+ * - a non-empty accessible name from a non-braille mechanism, if it
18
+ * declares `aria-braillelabel`;
19
+ * - a non-empty `aria-roledescription`, if it declares
20
+ * `aria-brailleroledescription`.
21
+ * Using either braille-specific attribute as the ONLY naming mechanism
22
+ * leaves non-braille assistive technology (most screen readers, voice
23
+ * control, etc.) with no accessible name/role description at all.
24
+ * @implementation-notes
25
+ * - `aria-braillelabel`/`aria-brailleroledescription` do not participate
26
+ * in the standard accessible-name computation, so
27
+ * `helpers.getAccessibleNameInfo` already reflects only the "regular"
28
+ * (non-braille) name — no special-casing needed there.
29
+ */
30
+
31
+ const id = 'aria-braille-equivalent';
32
+
33
+ const meta = {
34
+ title: 'aria-braillelabel/aria-brailleroledescription must have a non-braille equivalent',
35
+ description:
36
+ 'Checks that elements using aria-braillelabel also have a regular accessible name, and elements using aria-brailleroledescription also have aria-roledescription.',
37
+ i18n: {
38
+ titleKey: 'ariaBrailleEquivalent_title',
39
+ descriptionKey: 'ariaBrailleEquivalent_description'
40
+ },
41
+ helpUrl: null,
42
+ tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
43
+ wcagSc: ['4.1.2'],
44
+ normativeMappings: [
45
+ { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
46
+ ],
47
+ defaultSeverity: 'serious',
48
+ category: 'robust',
49
+ type: 'automatic',
50
+ defaultConfidence: 'high',
51
+ coverage: { facetsBySc: { '4.1.2': ['aria-braille-equivalent'] } }
52
+ };
53
+
54
+ function runInPage(ctx) {
55
+ const { document, root, helpers, rule } = ctx;
56
+ const safeRoot = root || document;
57
+
58
+ function trim(v) { return (v == null ? '' : String(v)).trim(); }
59
+
60
+ function getConservativeSubtreeText(container) {
61
+ // "Name from content" — recurses into descendants and uses each one's
62
+ // own accessible name (img alt, aria-label/aria-labelledby, title) when
63
+ // it has one, not just literal text nodes. See getContentNameInfo's
64
+ // header comment in src/core/dom-helpers.js for the full rationale
65
+ // (this replaced a text-node-only TreeWalker that missed the common
66
+ // "<a><img alt='...'></a>" logo-link / "<button><img alt='...'></button>"
67
+ // icon-button pattern).
68
+ if (helpers.getContentNameInfo) {
69
+ const info = helpers.getContentNameInfo(container, ctx);
70
+ return info && info.present ? info.value : '';
71
+ }
72
+ const t = (container && container.textContent) ? String(container.textContent) : '';
73
+ return t.replace(/\s+/g, ' ').trim();
74
+ }
75
+
76
+ const nodes = helpers.queryAllSmart
77
+ ? helpers.queryAllSmart('[aria-braillelabel], [aria-brailleroledescription]', safeRoot)
78
+ : helpers.queryAll('[aria-braillelabel], [aria-brailleroledescription]', safeRoot);
79
+
80
+ const occurrences = [];
81
+ let applicableCount = 0;
82
+
83
+ for (const el of nodes) {
84
+ if (!el || !el.getAttribute) continue;
85
+
86
+ const brailleLabel = trim(el.getAttribute('aria-braillelabel'));
87
+ const brailleRoleDesc = trim(el.getAttribute('aria-brailleroledescription'));
88
+ if (!brailleLabel && !brailleRoleDesc) continue;
89
+
90
+ applicableCount += 1;
91
+
92
+ const missing = [];
93
+
94
+ if (brailleLabel) {
95
+ const nameInfo = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(el, ctx) : null;
96
+ const programmaticName = trim(nameInfo && typeof nameInfo.value === 'string' ? nameInfo.value : '');
97
+ const name = programmaticName || getConservativeSubtreeText(el);
98
+ if (!name) missing.push({ attr: 'aria-braillelabel', requires: 'an accessible name' });
99
+ }
100
+
101
+ if (brailleRoleDesc) {
102
+ const roleDesc = trim(el.getAttribute('aria-roledescription'));
103
+ if (!roleDesc) missing.push({ attr: 'aria-brailleroledescription', requires: 'aria-roledescription' });
104
+ }
105
+
106
+ if (!missing.length) continue;
107
+
108
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
109
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
110
+ const tag = (el.tagName || '').toLowerCase();
111
+
112
+ for (const m of missing) {
113
+ occurrences.push({
114
+ selector: stableSelector,
115
+ html,
116
+ summary: `This element has ${m.attr} but no ${m.requires}, its non-braille equivalent.`,
117
+ hint: `${m.attr} is a Braille-specific supplement, not a replacement — also provide ${m.requires}.`,
118
+ i18n: {
119
+ summaryKey: 'ariaBrailleEquivalent_summary_fail',
120
+ hintKey: 'ariaBrailleEquivalent_hint_fail',
121
+ params: { element: tag, attr: m.attr, requires: m.requires }
122
+ },
123
+ data: {
124
+ details: { reasonCode: 'BRAILLE_ATTR_WITHOUT_EQUIVALENT', attr: m.attr, requires: m.requires }
125
+ }
126
+ });
127
+ }
128
+ }
129
+
130
+ if (applicableCount === 0) {
131
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
132
+ }
133
+ if (occurrences.length) {
134
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
135
+ }
136
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
137
+ }
138
+
139
+ module.exports = { id, meta, runInPage };