@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,109 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check aria-text
5
+ * @atomic true
6
+ * @summary role="text" elements should have no focusable descendants
7
+ * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
+ * @applicability
9
+ * Elements with an explicit `role="text"`.
10
+ * @expectation
11
+ * `role="text"` tells assistive technology to treat an element's whole
12
+ * subtree as a single unit of plain text (e.g. text visually split
13
+ * across multiple `<span>`s by styling). Per the WAI-ARIA Authoring
14
+ * Practices, this only makes sense when that subtree contains no
15
+ * focusable content — a focusable descendant inside a "this is just
16
+ * text" region is unreachable or confusing for keyboard/AT users.
17
+ * @implementation-notes
18
+ * - Not WCAG-normative by a widely-used reference engine's own classification — authored as an
19
+ * advisory, cantTell-capped `type: 'manual'` rule, matching the Tier 1b
20
+ * precedent (see `landmark-unique`'s header comment for the shared
21
+ * rationale).
22
+ * - "Focusable descendant" is a presence check (link/button/form
23
+ * control/`[tabindex]`/`iframe`/`[contenteditable]`), the same
24
+ * conservative selector `scrollable-region-focusable` uses, not a full
25
+ * focusability computation (disabled state, visibility, etc.).
26
+ */
27
+
28
+ const id = 'aria-text';
29
+
30
+ const meta = {
31
+ title: 'role="text" elements should have no focusable descendants',
32
+ description: 'Checks that elements with role="text" contain no focusable descendant (link, button, form control, tabindex, iframe, or contenteditable).',
33
+ i18n: {
34
+ titleKey: 'ariaText_title',
35
+ descriptionKey: 'ariaText_description'
36
+ },
37
+ helpUrl: null,
38
+ tags: ['best-practice', 'aria', 'structure', 'atomic', 'manual'],
39
+ wcagSc: [],
40
+ normativeMappings: [],
41
+ defaultSeverity: 'minor',
42
+ category: 'robust',
43
+ type: 'manual',
44
+ defaultConfidence: 'medium',
45
+ coverage: {}
46
+ };
47
+
48
+ function runInPage(ctx) {
49
+ const { document, root, helpers, rule } = ctx;
50
+ const safeRoot = root || document;
51
+
52
+ const FOCUSABLE_DESCENDANT_SELECTOR =
53
+ 'a[href], button, input, select, textarea, [tabindex], iframe, [contenteditable]:not([contenteditable="false"])';
54
+
55
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role="text"]', safeRoot) : helpers.queryAll('[role="text"]', safeRoot);
56
+
57
+ const occurrences = [];
58
+ let applicableCount = 0;
59
+
60
+ for (const el of nodes) {
61
+ if (!el || !el.querySelector) continue;
62
+
63
+ applicableCount += 1;
64
+
65
+ let focusableDescendant = null;
66
+ try {
67
+ focusableDescendant = el.querySelector(FOCUSABLE_DESCENDANT_SELECTOR);
68
+ } catch {
69
+ focusableDescendant = null;
70
+ }
71
+ if (!focusableDescendant) continue;
72
+
73
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
74
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
75
+
76
+ const baseOccurrence = {
77
+ selector: stableSelector,
78
+ html,
79
+ summary: 'This role="text" element contains a focusable descendant.',
80
+ hint: 'Remove role="text" (or remove the focusable descendant) — a "plain text" region should not contain focusable content.',
81
+ i18n: {
82
+ summaryKey: 'ariaText_summary_cantTell',
83
+ hintKey: 'ariaText_hint_cantTell',
84
+ params: {}
85
+ },
86
+ data: {
87
+ details: { reasonCode: 'ROLE_TEXT_HAS_FOCUSABLE_DESCENDANT' }
88
+ }
89
+ };
90
+
91
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
92
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
93
+ } else {
94
+ occurrences.push(baseOccurrence);
95
+ }
96
+ }
97
+
98
+ if (applicableCount === 0) {
99
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
100
+ }
101
+
102
+ if (occurrences.length) {
103
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
104
+ }
105
+
106
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
107
+ }
108
+
109
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,170 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check canvas-text-alternative-quality
5
+ * @atomic true
6
+ * @summary Manual review: text alternative appropriateness (WCAG 1.1.1)
7
+ * @standard WCAG 2.2
8
+ * @sc 1.1.1
9
+ * @type manual
10
+ * @expectation
11
+ * Human review is required to confirm that the provided text alternative is accurate and appropriate.
12
+ */
13
+
14
+ const id = "canvas-text-alternative-quality";
15
+
16
+ const meta = {
17
+ title: "<canvas> text alternative must be appropriate (manual review)",
18
+ description: "Flags <canvas> elements with a detected text alternative for human review of equivalence and appropriateness.",
19
+ i18n: {
20
+ titleKey: "canvas_textAltQuality_title",
21
+ descriptionKey: "canvas_textAltQuality_description"
22
+ },
23
+ helpUrl: null,
24
+ tags: ["wcag2a", "wcag111", "nontext", "canvas", "manual", "atomic"],
25
+ wcagSc: ['1.1.1'],
26
+ normativeMappings: [
27
+ { standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A' }
28
+ ],
29
+ defaultSeverity: 'minor',
30
+ category: 'perceivable',
31
+ type: 'manual',
32
+ defaultConfidence: 'medium',
33
+ coverage: {
34
+ facetsBySc: {
35
+ '1.1.1': ['text-alternative-quality']
36
+ }
37
+ }
38
+ };
39
+
40
+ function runInPage(ctx) {
41
+
42
+ const { document, root, helpers, rule } = ctx;
43
+ const safeRoot = root || document;
44
+
45
+ const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
46
+ const queryAll = helpers && typeof helpers.queryAll === 'function'
47
+ ? helpers.queryAll
48
+ : (sel) => {
49
+ try { return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : []; }
50
+ catch { return []; }
51
+ };
52
+
53
+ const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
54
+ ? helpers.getEligibilityInfo
55
+ : null;
56
+
57
+ const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
58
+ ? helpers.isAccTreeEligible
59
+ : null;
60
+
61
+ const __accEligCache = new WeakMap();
62
+ function accEligibleCached(node) {
63
+ if (!isAccTreeEligible) return { eligible: true, reasons: [] };
64
+ if (!node || typeof node !== 'object') return { eligible: true, reasons: [] };
65
+ const c = __accEligCache.get(node);
66
+ if (c) return c;
67
+ let r;
68
+ try { r = isAccTreeEligible(node, ctx); } catch { r = { eligible: true, reasons: [] }; }
69
+ r = r && typeof r === 'object' ? r : { eligible: !!r, reasons: [] };
70
+ __accEligCache.set(node, r);
71
+ return r;
72
+ }
73
+
74
+ const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function'
75
+ ? helpers.getFocusableInfo
76
+ : null;
77
+
78
+ function isRolePresentationExcluded(el) {
79
+ const role = (() => {
80
+ try { return String(el.getAttribute('role') || '').trim().toLowerCase(); }
81
+ catch { return ''; }
82
+ })();
83
+ if (role !== 'presentation' && role !== 'none') return false;
84
+
85
+ // Exclude only when NOT focusable (mirrors img-alt-present policy)
86
+ let focusable = false;
87
+ if (getFocusableInfo) {
88
+ const fi = (() => { try { return getFocusableInfo(el, ctx); } catch { return null; } })();
89
+ focusable = !!(fi && fi.focusable);
90
+ } else {
91
+ let tabindex = null;
92
+ try { tabindex = el.getAttribute('tabindex'); } catch { tabindex = null; }
93
+ const t = tabindex == null ? '' : String(tabindex).trim();
94
+ focusable = t !== '' && !Number.isNaN(Number(t));
95
+ }
96
+ return !focusable;
97
+ }
98
+
99
+
100
+ const els = (() => {
101
+ try { return Array.from((queryAllSmart ? queryAllSmart('canvas') : queryAll('canvas')) || []); }
102
+ catch { return queryAll('canvas'); }
103
+ })();
104
+
105
+ if (!els.length) {
106
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
107
+ }
108
+
109
+ const occurrences = [];
110
+ let applicableCount = 0;
111
+
112
+ for (const el of els) {
113
+ if (!el || !el.getAttribute) continue;
114
+
115
+ if (isAccTreeEligible) {
116
+ const elig = accEligibleCached(el);
117
+ if (elig && elig.eligible === false) continue;
118
+ }
119
+
120
+ if (isRolePresentationExcluded(el)) continue;
121
+
122
+ const getTextAlternativeInfo =
123
+ helpers && typeof helpers.getTextAlternativeInfo === 'function'
124
+ ? helpers.getTextAlternativeInfo
125
+ : null;
126
+
127
+ const trim = (v) => (v == null ? '' : String(v)).trim();
128
+
129
+ // Rule-specific applicability (only elements that already have a text alternative mechanism)
130
+ let textAltInfo = null;
131
+ if (getTextAlternativeInfo) {
132
+ try { textAltInfo = getTextAlternativeInfo(el, ctx); } catch { textAltInfo = null; }
133
+ }
134
+
135
+ const hasTextAlt = !!(textAltInfo && textAltInfo.present);
136
+ if (!hasTextAlt) continue;
137
+
138
+ applicableCount += 1;
139
+
140
+ const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
141
+
142
+ const baseOccurrence = {
143
+ summary: 'Review text alternative for <canvas> for equivalence and appropriateness.',
144
+ hint: 'Confirm the fallback text or accessible name conveys the same information/function as the canvas content.',
145
+ i18n: {
146
+ summaryKey: 'canvas_textAltQuality_summary_cantTell',
147
+ hintKey: 'canvas_textAltQuality_hint_cantTell',
148
+ params: { element: (el.tagName || '').toLowerCase() }
149
+ },
150
+ data: {
151
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
152
+ details: textAltInfo || null
153
+ }
154
+ };
155
+
156
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
157
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
158
+ } else {
159
+ occurrences.push({ selector: '', html: '', ...baseOccurrence });
160
+ }
161
+ }
162
+
163
+ if (applicableCount === 0) {
164
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
165
+ }
166
+
167
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: 'minor', occurrences };
168
+ }
169
+
170
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,259 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check css-hidden-focus
5
+ * @atomic true
6
+ * @summary focusable elements must be visible to sighted keyboard users
7
+ * @standard WCAG 2.2
8
+ * @sc 2.4.7
9
+ * @applicability
10
+ * Applies to elements that are tabbable (keyboard-focusable) but are visually hidden
11
+ * via CSS techniques that can leave them in the tab order.
12
+ * @expectation
13
+ * No element should be tabbable while visually hidden (e.g., opacity:0, clipped, off-screen).
14
+ *
15
+ * Notes:
16
+ * - This rule intentionally targets CSS techniques that *can* keep an element focusable.
17
+ * - Elements removed from rendering (display:none, visibility:hidden, [hidden]) are excluded.
18
+ * - The rule uses deterministic heuristics (computed style parsing) and does not rely on layout geometry.
19
+ */
20
+
21
+ const id = 'css-hidden-focus';
22
+
23
+ const meta = {
24
+ title: 'Focusable elements must not be visually hidden',
25
+ description:
26
+ 'Checks that keyboard-focusable elements are not visually hidden by CSS techniques that can leave them in the tab order.',
27
+ i18n: {
28
+ titleKey: 'cssHidden_focus_title',
29
+ descriptionKey: 'cssHidden_focus_description'
30
+ },
31
+ helpUrl: null,
32
+ tags: ['wcag2aa', 'wcag247', 'navigation', 'focus', 'css', 'atomic', 'manual'],
33
+ wcagSc: ['2.4.7'],
34
+ normativeMappings: [
35
+ {standard: 'WCAG', version: '2.2', requirement: '2.4.7', title: 'Focus Visible', conformanceLevel: 'AA'}
36
+ ],
37
+ defaultSeverity: 'serious',
38
+ category: 'operable',
39
+ type: 'manual',
40
+ defaultConfidence: 'low',
41
+ coverage: {
42
+ facetsBySc: {
43
+ '2.4.7': ['css-hidden-focusable']
44
+ }
45
+ }
46
+ };
47
+
48
+ function runInPage(ctx) {
49
+ const {document, root, helpers, rule} = ctx;
50
+ const safeRoot = root || document;
51
+
52
+ const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
53
+ const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
54
+ const isDomVisibleEligible = helpers && typeof helpers.isDomVisibleEligible === 'function' ? helpers.isDomVisibleEligible : null;
55
+ const reportOccurrence = helpers && typeof helpers.reportOccurrence === 'function' ? helpers.reportOccurrence : null;
56
+
57
+ const trim = (v) => (v == null ? '' : String(v)).trim();
58
+ const lower = (v) => trim(v).toLowerCase();
59
+
60
+ function qAll(sel) {
61
+ try {
62
+ if (queryAllSmart) {
63
+ const r = queryAllSmart(sel);
64
+ if (Array.isArray(r)) return r;
65
+ return Array.from(r || []);
66
+ }
67
+ } catch {
68
+ // fall through
69
+ }
70
+ try {
71
+ if (safeRoot && typeof safeRoot.querySelectorAll === 'function') return Array.from(safeRoot.querySelectorAll(sel));
72
+ } catch {
73
+ // fall through
74
+ }
75
+ return [];
76
+ }
77
+
78
+ function getComputedStyleSafe(el) {
79
+ try {
80
+ const w = (document && document.defaultView) ? document.defaultView : (typeof window !== 'undefined' ? window : null);
81
+ return w && w.getComputedStyle ? w.getComputedStyle(el) : null;
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ // Returns deterministic "visually hidden but can remain focusable" hints.
88
+ function getVisibilityHints(el) {
89
+ const out = [];
90
+ if (!el) return out;
91
+ const cs = getComputedStyleSafe(el);
92
+
93
+ // opacity:0
94
+ try {
95
+ const rawOp = cs && cs.opacity != null ? String(cs.opacity).trim() : '';
96
+ const op = rawOp ? Number.parseFloat(rawOp) : 1;
97
+ if (Number.isFinite(op) && op <= 0.0001) out.push('opacityZero');
98
+ } catch {
99
+ }
100
+
101
+ // clip / clip-path
102
+ try {
103
+ const clip = cs && cs.clip != null ? String(cs.clip).trim() : '';
104
+ const clipPath = cs && cs.clipPath != null ? String(cs.clipPath).trim() : '';
105
+ const clipLow = (clip || '').toLowerCase();
106
+ const clipPathLow = (clipPath || '').toLowerCase();
107
+
108
+ if (clipLow && clipLow !== 'auto') {
109
+ if (clipLow.indexOf('rect(') !== -1 && clipLow.replace(/\s+/g, '').indexOf('rect(0') !== -1) out.push('clipped');
110
+ }
111
+ if (clipPathLow && clipPathLow !== 'none') {
112
+ if (clipPathLow.indexOf('inset(') !== -1 && (clipPathLow.indexOf('100%') !== -1 || clipPathLow.indexOf('50%') !== -1)) {
113
+ out.push('clipped');
114
+ }
115
+ }
116
+ } catch {
117
+ }
118
+
119
+ // zero-size + overflow hidden/clip
120
+ try {
121
+ const wv = cs && cs.width != null ? String(cs.width).trim() : '';
122
+ const hv = cs && cs.height != null ? String(cs.height).trim() : '';
123
+ const ov = cs && cs.overflow != null ? String(cs.overflow).trim().toLowerCase() : '';
124
+ const isZeroW = wv === '0px' || wv === '0';
125
+ const isZeroH = hv === '0px' || hv === '0';
126
+ const hidesOverflow = ov === 'hidden' || ov === 'clip';
127
+ if ((isZeroW || isZeroH) && hidesOverflow) out.push('zeroSizeOverflowHidden');
128
+ } catch {
129
+ }
130
+
131
+ // off-screen heuristic (absolute/fixed + left/top <= -5000 OR text-indent <= -5000)
132
+ try {
133
+ const pos = cs && cs.position != null ? String(cs.position).trim().toLowerCase() : '';
134
+ const left = cs && cs.left != null ? String(cs.left).trim().toLowerCase() : '';
135
+ const top = cs && cs.top != null ? String(cs.top).trim().toLowerCase() : '';
136
+ const ti = cs && cs.textIndent != null ? String(cs.textIndent).trim().toLowerCase() : '';
137
+
138
+ const parsePx = (s) => {
139
+ if (!s || s === 'auto') return null;
140
+ const m = String(s).match(/-?\d+(\.\d+)?/);
141
+ if (!m) return null;
142
+ const n = Number.parseFloat(m[0]);
143
+ return Number.isFinite(n) ? n : null;
144
+ };
145
+
146
+ const l = parsePx(left);
147
+ const t = parsePx(top);
148
+ const ind = parsePx(ti);
149
+
150
+ if (pos === 'absolute' || pos === 'fixed') {
151
+ if ((l != null && l <= -5000) || (t != null && t <= -5000)) out.push('offscreen');
152
+ }
153
+ if (ind != null && ind <= -5000) out.push('offscreen');
154
+ } catch {
155
+ }
156
+
157
+ // Dedup
158
+ const seen = new Set();
159
+ const uniq = [];
160
+ for (const k of out) {
161
+ const kk = String(k);
162
+ if (!seen.has(kk)) {
163
+ seen.add(kk);
164
+ uniq.push(kk);
165
+ }
166
+ }
167
+ return uniq;
168
+ }
169
+
170
+ function getFocusableInfoSafe(el) {
171
+ if (!getFocusableInfo) return null;
172
+ try {
173
+ return getFocusableInfo(el, ctx);
174
+ } catch {
175
+ return null;
176
+ }
177
+ }
178
+
179
+ function isTabbable(el, info) {
180
+ const f = info || getFocusableInfoSafe(el);
181
+ return !!(f && f.focusable && f.tabbable);
182
+ }
183
+
184
+ // Exclude elements that are not rendered / not in the visual rendering tree.
185
+ function isRendered(el) {
186
+ if (!isDomVisibleEligible) return true;
187
+ try {
188
+ const vis = isDomVisibleEligible(el, ctx, {visibilityMode: 'styleOnly', disableGeometry: true});
189
+ if (vis && vis.eligible === false) {
190
+ const rs = Array.isArray(vis.reasons) ? vis.reasons : [];
191
+ // We *include* opacityZero in this rule. Everything else is treated as not rendered for this purpose.
192
+ const nonOpacity = rs.filter((r) => String(r) !== 'opacityZero');
193
+ if (nonOpacity.length) return false;
194
+ }
195
+ } catch {
196
+ // If in doubt, keep deterministic behavior and consider it rendered.
197
+ }
198
+ return true;
199
+ }
200
+ const candidates = qAll('a[href],area[href],button,input,select,textarea,summary,[tabindex],[contenteditable]');
201
+ if (!candidates.length) {
202
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
203
+ }
204
+
205
+ const occurrences = [];
206
+
207
+ for (const el of candidates) {
208
+ if (!el || !el.getAttribute) continue;
209
+
210
+ const finfo = getFocusableInfoSafe(el);
211
+ if (!isTabbable(el, finfo)) continue;
212
+
213
+ // Skip elements not rendered (display:none, visibility:hidden, [hidden], etc.)
214
+ if (!isRendered(el)) continue;
215
+
216
+ const hints = getVisibilityHints(el);
217
+ if (!hints.length) continue; // <-- applicability gate
218
+
219
+ const tagName = (() => { try { return lower(el.tagName || ''); } catch { return ''; } })();
220
+
221
+ const hintOrder = ['opacityZero', 'offscreen', 'clipped', 'zeroSizeOverflowHidden'];
222
+ const hintsArr = [];
223
+ for (const k of hintOrder) if (hints.includes(k)) hintsArr.push(k);
224
+
225
+ const baseOccurrence = {
226
+ summary: `Focusable ${tagName} appears visually hidden (${hintsArr.join(',')}). Verify it becomes visible on keyboard focus.`,
227
+ hint: 'Manually tab to the element and confirm a visible focus indicator and that the element is visible when focused. If it remains hidden while focused, fix CSS/JS so it becomes visible or is removed from the tab order until visible.',
228
+ i18n: {
229
+ summaryKey: 'cssHidden_focus_summary_cantTell',
230
+ hintKey: 'cssHidden_focus_hint_cantTell',
231
+ params: { element: tagName, visibilityHints: hintsArr.join(',') }
232
+ },
233
+ data: {
234
+ details: {
235
+ reasonCode: 'cssHiddenTabbable_needsFocusStateVerification',
236
+ metrics: { visibilityHints: hintsArr.slice(0) }
237
+ }
238
+ }
239
+ };
240
+
241
+ occurrences.push(
242
+ reportOccurrence ? reportOccurrence(el, baseOccurrence) : { __node: el, selector: '', html: '', ...baseOccurrence }
243
+ );
244
+ }
245
+
246
+ // Manual rule + validator invariant: cantTell must have >= 1 occurrence
247
+ if (!occurrences.length) {
248
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
249
+ }
250
+
251
+ return {
252
+ ruleId: rule.ruleId,
253
+ outcome: 'cantTell',
254
+ severity: rule.defaultSeverity || 'minor',
255
+ occurrences
256
+ };
257
+ }
258
+
259
+ module.exports = {id, meta, runInPage};