@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,107 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check meta-refresh-timing-absent
5
+ * @atomic true
6
+ * @summary <meta http-equiv="refresh"> must not impose a delayed page refresh
7
+ * @standard WCAG 2.2
8
+ * @sc 2.2.1
9
+ * @applicability
10
+ * Applies to <meta http-equiv="refresh"> elements that carry a non-empty
11
+ * content attribute with a parseable leading delay value.
12
+ * @expectation
13
+ * The delay is 0 (an immediate redirect, which users cannot be caught by
14
+ * mid-read), or exceeds 20 hours. Any other positive delay refreshes or
15
+ * redirects the page on a timer the user did not initiate and cannot
16
+ * pause, stop, or extend, which WCAG 2.2.1 (Timing Adjustable) requires
17
+ * be possible.
18
+ * @implementation-notes
19
+ * - An unparseable content value (no leading numeric delay) is not
20
+ * flagged — this rule only reports a clearly-detected timed refresh,
21
+ * matching this engine's no-false-positives policy.
22
+ * - WCAG 2.2.1 Exception 3 exempts time limits longer than 20 hours (the
23
+ * rationale being that a delay this long gives users enough real-world
24
+ * time to act, so "adjustable" ceases to be a meaningful requirement).
25
+ * A delay over 72000 seconds is therefore not flagged.
26
+ * - A <meta> nested inside <noscript> is excluded — see
27
+ * meta-refresh-no-exceptions's header comment for why.
28
+ */
29
+
30
+ const id = 'meta-refresh-timing-absent';
31
+
32
+ const meta = {
33
+ title: 'Page must not use a timed meta refresh',
34
+ description: 'Checks that <meta http-equiv="refresh"> does not impose a positive delay of 20 hours or less.',
35
+ i18n: {
36
+ titleKey: 'metaRefreshTimingAbsent_title',
37
+ descriptionKey: 'metaRefreshTimingAbsent_description'
38
+ },
39
+ helpUrl: null,
40
+ tags: ['wcag2a', 'wcag221', 'structure', 'atomic', 'automatic'],
41
+ wcagSc: ['2.2.1'],
42
+ normativeMappings: [
43
+ { standard: 'WCAG', version: '2.2', requirement: '2.2.1', title: 'Timing Adjustable', conformanceLevel: 'A' }
44
+ ],
45
+ defaultSeverity: 'serious',
46
+ category: 'operable',
47
+ type: 'automatic',
48
+ defaultConfidence: 'high',
49
+ coverage: { facetsBySc: { '2.2.1': ['meta-refresh-timing-absent'] } }
50
+ };
51
+
52
+ function runInPage(ctx) {
53
+ const { document, helpers, rule } = ctx;
54
+
55
+ // WCAG 2.2.1 Exception 3: time limits longer than 20 hours are exempt.
56
+ const EXEMPT_DELAY_SECONDS = 20 * 60 * 60;
57
+
58
+ const nodes = document.querySelectorAll ? document.querySelectorAll('meta[http-equiv="refresh" i]') : [];
59
+
60
+ const occurrences = [];
61
+ let applicableCount = 0;
62
+
63
+ for (const el of nodes) {
64
+ if (!el || !el.getAttribute) continue;
65
+ if (el.closest && el.closest('noscript')) continue; // never applies with scripting enabled — see meta-refresh-no-exceptions.js's header comment
66
+ const raw = String(el.getAttribute('content') || '').trim();
67
+ if (!raw) continue;
68
+
69
+ const match = raw.match(/^([0-9]*\.?[0-9]+)/);
70
+ if (!match) continue;
71
+ const delay = parseFloat(match[1]);
72
+ if (Number.isNaN(delay)) continue;
73
+
74
+ applicableCount += 1;
75
+
76
+ if (!(delay > 0)) continue;
77
+ if (delay > EXEMPT_DELAY_SECONDS) continue; // WCAG 2.2.1 Exception 3 (>20 hours)
78
+
79
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
80
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
81
+
82
+ occurrences.push({
83
+ selector: stableSelector,
84
+ html,
85
+ summary: 'This page refreshes itself automatically after a delay.',
86
+ hint: 'Remove the timed meta refresh, or provide a way for users to turn it off, extend it, or pause it before it triggers.',
87
+ i18n: {
88
+ summaryKey: 'metaRefreshTimingAbsent_summary_fail',
89
+ hintKey: 'metaRefreshTimingAbsent_hint_fail',
90
+ params: { delay: String(delay) }
91
+ },
92
+ data: {
93
+ details: { reasonCode: 'META_REFRESH_DELAYED', delay }
94
+ }
95
+ });
96
+ }
97
+
98
+ if (applicableCount === 0) {
99
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
100
+ }
101
+ if (occurrences.length) {
102
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
103
+ }
104
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
105
+ }
106
+
107
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,118 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check meta-viewport-zoom-enabled
5
+ * @atomic true
6
+ * @summary <meta name="viewport"> must not disable or cap pinch-zoom below 200%
7
+ * @standard WCAG 2.2
8
+ * @sc 1.4.4
9
+ * @applicability
10
+ * Applies to <meta name="viewport"> elements that carry a non-empty
11
+ * content attribute.
12
+ * @expectation
13
+ * The content attribute does not set user-scalable to "no"/"0", and does
14
+ * not set maximum-scale below 2 (200%). Either disables the user's
15
+ * ability to zoom text to at least 200%, which WCAG 1.4.4 (Resize Text)
16
+ * requires be possible.
17
+ * @implementation-notes
18
+ * - Malformed/unparseable maximum-scale values are ignored (not flagged) —
19
+ * this rule only reports a clearly-detected zoom restriction, matching
20
+ * this engine's no-false-positives policy.
21
+ */
22
+
23
+ const id = 'meta-viewport-zoom-enabled';
24
+
25
+ const meta = {
26
+ title: 'Viewport meta tag must not disable zoom',
27
+ description: 'Checks that <meta name="viewport"> does not set user-scalable=no or maximum-scale below 2 (200%).',
28
+ i18n: {
29
+ titleKey: 'metaViewportZoomEnabled_title',
30
+ descriptionKey: 'metaViewportZoomEnabled_description'
31
+ },
32
+ helpUrl: null,
33
+ tags: ['wcag2aa', 'wcag144', 'structure', 'atomic', 'automatic'],
34
+ wcagSc: ['1.4.4'],
35
+ normativeMappings: [
36
+ { standard: 'WCAG', version: '2.2', requirement: '1.4.4', title: 'Resize Text', conformanceLevel: 'AA' }
37
+ ],
38
+ defaultSeverity: 'serious',
39
+ category: 'perceivable',
40
+ type: 'automatic',
41
+ defaultConfidence: 'high',
42
+ coverage: { facetsBySc: { '1.4.4': ['meta-viewport-zoom-enabled'] } }
43
+ };
44
+
45
+ function runInPage(ctx) {
46
+ const { document, helpers, rule } = ctx;
47
+
48
+ function parseContent(raw) {
49
+ const out = {};
50
+ for (const pair of String(raw || '').split(/[,;]/)) {
51
+ const eq = pair.indexOf('=');
52
+ if (eq === -1) continue;
53
+ const key = pair.slice(0, eq).trim().toLowerCase();
54
+ const value = pair.slice(eq + 1).trim().toLowerCase();
55
+ if (key) out[key] = value;
56
+ }
57
+ return out;
58
+ }
59
+
60
+ const nodes = document.querySelectorAll ? document.querySelectorAll('meta[name="viewport" i]') : [];
61
+
62
+ const occurrences = [];
63
+ let applicableCount = 0;
64
+
65
+ for (const el of nodes) {
66
+ if (!el || !el.getAttribute) continue;
67
+ const raw = String(el.getAttribute('content') || '').trim();
68
+ if (!raw) continue;
69
+
70
+ applicableCount += 1;
71
+
72
+ const parsed = parseContent(raw);
73
+ const reasons = [];
74
+
75
+ const userScalable = parsed['user-scalable'];
76
+ if (userScalable === 'no' || userScalable === '0') {
77
+ reasons.push('user-scalable=' + userScalable);
78
+ }
79
+
80
+ const maxScaleRaw = parsed['maximum-scale'];
81
+ if (maxScaleRaw !== undefined) {
82
+ const maxScale = parseFloat(maxScaleRaw);
83
+ if (!Number.isNaN(maxScale) && maxScale < 2) {
84
+ reasons.push('maximum-scale=' + maxScaleRaw);
85
+ }
86
+ }
87
+
88
+ if (!reasons.length) continue;
89
+
90
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
91
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
92
+
93
+ occurrences.push({
94
+ selector: stableSelector,
95
+ html,
96
+ summary: 'This viewport meta tag restricts the user’s ability to zoom.',
97
+ hint: 'Remove user-scalable=no and any maximum-scale below 2 from the viewport meta content.',
98
+ i18n: {
99
+ summaryKey: 'metaViewportZoomEnabled_summary_fail',
100
+ hintKey: 'metaViewportZoomEnabled_hint_fail',
101
+ params: { reasons: reasons.join(', ') }
102
+ },
103
+ data: {
104
+ details: { reasonCode: 'VIEWPORT_ZOOM_RESTRICTED', reasons }
105
+ }
106
+ });
107
+ }
108
+
109
+ if (applicableCount === 0) {
110
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
111
+ }
112
+ if (occurrences.length) {
113
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
114
+ }
115
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
116
+ }
117
+
118
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,160 @@
1
+ 'use strict';
2
+
3
+ const id = 'meter-name-present';
4
+
5
+ const meta = {
6
+ title: 'Meters have an accessible name',
7
+ description: 'Checks that elements with role="meter" expose a non-empty accessible name.',
8
+ i18n: {
9
+ titleKey: 'meterNamePresent_title',
10
+ descriptionKey: 'meterNamePresent_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': ['meter-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="meter"]';
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="meter" is name-from-author-only per WAI-ARIA (verified against
108
+ // a widely-used reference engine's own aria-meter-name check: any: ['aria-label',
109
+ // 'aria-labelledby', title] — no content-based naming method at all).
110
+ // It must NOT fall back to subtree content — visible text near/inside a
111
+ // custom meter widget is not reliably exposed as its accessible name.
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
+ const role = getAttr(el, 'role').toLowerCase();
120
+ if (role !== 'meter') continue;
121
+
122
+ applicableCount += 1;
123
+
124
+ const res = evaluate(el);
125
+ if (res.ok) continue;
126
+
127
+ const eligInfo = getEligibilityInfo
128
+ ? (() => { try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })()
129
+ : null;
130
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
131
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
132
+
133
+ occurrences.push({
134
+ selector: stableSelector,
135
+ html,
136
+ summary: 'This meter has no accessible name.',
137
+ hint: 'Provide meter text that is not hidden from assistive technologies, or provide aria-label or aria-labelledby.',
138
+ i18n: {
139
+ summaryKey: 'meterNamePresent_summary_fail',
140
+ hintKey: 'meterNamePresent_hint_fail',
141
+ params: {}
142
+ },
143
+ data: {
144
+ details: { reasonCode: 'name_missing', controlType: 'meter', methodTried: res.method },
145
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
146
+ }
147
+ });
148
+ }
149
+
150
+
151
+ if (applicableCount === 0) {
152
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
153
+ }
154
+ if (occurrences.length) {
155
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
156
+ }
157
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
158
+ }
159
+
160
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,135 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check nested-interactive-controls-absent
5
+ * @atomic true
6
+ * @summary An interactive control must not contain another interactive control
7
+ * @standard WCAG 2.2
8
+ * @sc 4.1.2
9
+ * @applicability
10
+ * Applies to elements matching the interactive-control set (native
11
+ * a[href], button, input (not hidden), select, textarea; or an explicit
12
+ * ARIA widget role: button, link, checkbox, radio, switch, tab, textbox,
13
+ * combobox, listbox, menuitem, menuitemcheckbox, menuitemradio, option,
14
+ * slider, spinbutton, searchbox, treeitem).
15
+ * @expectation
16
+ * The element does not contain, as a descendant, another element from
17
+ * that same interactive-control set (e.g. a <button> wrapping a
18
+ * <select>, or a link containing a checkbox). Nested interactive
19
+ * controls are not reliably announced or operable via assistive
20
+ * technology — activating the outer control and the inner one become
21
+ * ambiguous, and some AT only exposes one of the two.
22
+ * @implementation-notes
23
+ * - Matches a widely-used reference engine's nested-interactive. Reports on the outer
24
+ * (containing) control, not the nested descendant — this engine
25
+ * considers the container the fixable unit ("move the nested control
26
+ * outside this element").
27
+ * - A container can be reported once even with multiple nested
28
+ * descendants (listed together); a deeply nested chain (A > B > C, all
29
+ * interactive) reports both A and B as separate occurrences, since each
30
+ * genuinely contains a nested interactive control.
31
+ */
32
+
33
+ const id = 'nested-interactive-controls-absent';
34
+
35
+ const meta = {
36
+ title: 'Interactive controls must not be nested',
37
+ description: 'Checks that an interactive control (link, button, form control, or ARIA widget role) does not contain another interactive control.',
38
+ i18n: {
39
+ titleKey: 'nestedInteractiveControlsAbsent_title',
40
+ descriptionKey: 'nestedInteractiveControlsAbsent_description'
41
+ },
42
+ helpUrl: null,
43
+ tags: ['wcag2a', 'wcag412', 'structure', 'atomic', 'automatic'],
44
+ wcagSc: ['4.1.2'],
45
+ normativeMappings: [
46
+ { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
47
+ ],
48
+ defaultSeverity: 'serious',
49
+ category: 'robust',
50
+ type: 'automatic',
51
+ defaultConfidence: 'high',
52
+ coverage: { facetsBySc: { '4.1.2': ['nested-interactive-controls-absent'] } }
53
+ };
54
+
55
+ function runInPage(ctx) {
56
+ const { document, root, helpers, rule } = ctx;
57
+ const safeRoot = root || document;
58
+
59
+ // Declared inside runInPage — see scripts/build-core.js header
60
+ // ("runInPage MUST be self-contained").
61
+ const INTERACTIVE_SELECTOR = [
62
+ 'a[href]',
63
+ 'button',
64
+ 'input:not([type="hidden"])',
65
+ 'select',
66
+ 'textarea',
67
+ '[role="button"]',
68
+ '[role="link"]',
69
+ '[role="checkbox"]',
70
+ '[role="radio"]',
71
+ '[role="switch"]',
72
+ '[role="tab"]',
73
+ '[role="textbox"]',
74
+ '[role="combobox"]',
75
+ '[role="listbox"]',
76
+ '[role="menuitem"]',
77
+ '[role="menuitemcheckbox"]',
78
+ '[role="menuitemradio"]',
79
+ '[role="option"]',
80
+ '[role="slider"]',
81
+ '[role="spinbutton"]',
82
+ '[role="searchbox"]',
83
+ '[role="treeitem"]'
84
+ ].join(', ');
85
+
86
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(INTERACTIVE_SELECTOR, safeRoot) : helpers.queryAll(INTERACTIVE_SELECTOR, safeRoot);
87
+
88
+ const occurrences = [];
89
+ let applicableCount = 0;
90
+
91
+ for (const el of nodes) {
92
+ if (!el || !el.querySelectorAll) continue;
93
+
94
+ applicableCount += 1;
95
+
96
+ let nested = [];
97
+ try {
98
+ nested = Array.from(el.querySelectorAll(INTERACTIVE_SELECTOR));
99
+ } catch {
100
+ nested = [];
101
+ }
102
+ if (!nested.length) continue;
103
+
104
+ const nestedTags = nested.map((n) => (n && n.tagName ? n.tagName.toLowerCase() : 'unknown'));
105
+
106
+ const tag = el.tagName.toLowerCase();
107
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
108
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
109
+
110
+ occurrences.push({
111
+ selector: stableSelector,
112
+ html,
113
+ summary: 'This interactive control contains one or more other interactive controls.',
114
+ hint: 'Move the nested interactive control(s) outside this element; nested interactive controls are not reliably operable via assistive technology.',
115
+ i18n: {
116
+ summaryKey: 'nestedInteractiveControlsAbsent_summary_fail',
117
+ hintKey: 'nestedInteractiveControlsAbsent_hint_fail',
118
+ params: { element: tag, nestedElements: nestedTags.join(', ') }
119
+ },
120
+ data: {
121
+ details: { reasonCode: 'NESTED_INTERACTIVE_CONTROL', element: tag, nestedElements: nestedTags }
122
+ }
123
+ });
124
+ }
125
+
126
+ if (applicableCount === 0) {
127
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
128
+ }
129
+ if (occurrences.length) {
130
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
131
+ }
132
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
133
+ }
134
+
135
+ module.exports = { id, meta, runInPage };