@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,157 @@
1
+ 'use strict';
2
+
3
+ const id = 'treeitem-name-present';
4
+
5
+ const meta = {
6
+ title: 'Accessible name is present',
7
+ description: 'Checks that elements expose a non-empty accessible name.',
8
+ i18n: {
9
+ titleKey: 'treeitemNamePresent_title',
10
+ descriptionKey: 'treeitemNamePresent_description'
11
+ },
12
+ helpUrl: null,
13
+ tags: ["wcag2a", "wcag412", "navigation", "atomic", "automatic", "name", "treeitem"],
14
+ wcagSc: ['4.1.2'],
15
+ normativeMappings: [
16
+ { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
17
+ ],
18
+ defaultSeverity: 'serious',
19
+ category: 'robust',
20
+ type: 'automatic',
21
+ defaultConfidence: 'high',
22
+ coverage: { facetsBySc: { '4.1.2': ['treeitem-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=\"treeitem\"]";
94
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
95
+
96
+
97
+ function hasName(el) {
98
+ const ariaLabel = getAttr(el, 'aria-label');
99
+ if (ariaLabel) return { ok: true, method: 'aria-label' };
100
+
101
+ const labelled = resolveAriaLabelledbyText(document, el, 8);
102
+ if (labelled) return { ok: true, method: 'aria-labelledby' };
103
+
104
+ const title = getAttr(el, 'title');
105
+ if (title) return { ok: true, method: 'title' };
106
+
107
+
108
+ const t = getConservativeSubtreeText(document, el);
109
+ if (t) return { ok: true, method: 'content' };
110
+
111
+
112
+ return { ok: false, method: 'none' };
113
+ }
114
+
115
+ for (const el of nodes) {
116
+ if (!el) continue;
117
+ if (!isEligibleAcc(helpers, el, ctx)) continue;
118
+
119
+ applicableCount += 1;
120
+
121
+ const res = hasName(el);
122
+ if (res.ok) continue;
123
+
124
+ const eligInfo = getEligibilityInfo
125
+ ? (() => { try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })()
126
+ : null;
127
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
128
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
129
+
130
+ occurrences.push({
131
+ selector: stableSelector,
132
+ html,
133
+ summary: 'This element has no accessible name.',
134
+ hint: 'Provide aria-label or aria-labelledby (preferred), or provide visible text that is not hidden from assistive technologies.',
135
+ i18n: {
136
+ summaryKey: 'treeitemNamePresent_summary_fail',
137
+ hintKey: 'treeitemNamePresent_hint_fail',
138
+ params: { controlType: 'treeitem' }
139
+ },
140
+ data: {
141
+ details: { reasonCode: 'name_missing', controlType: 'treeitem', methodTried: res.method },
142
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
143
+ }
144
+ });
145
+ }
146
+
147
+
148
+ if (applicableCount === 0) {
149
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
150
+ }
151
+ if (occurrences.length) {
152
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
153
+ }
154
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
155
+ }
156
+
157
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,100 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check valid-lang
5
+ * @atomic true
6
+ * @summary Any element's lang attribute must be a syntactically valid language tag
7
+ * @standard WCAG 2.2
8
+ * @sc 3.1.2
9
+ * @applicability
10
+ * Applies to any element other than the root <html> with a non-empty
11
+ * lang attribute.
12
+ * @expectation
13
+ * The lang value matches a valid BCP47 language-tag syntax. WCAG 3.1.2
14
+ * (Language of Parts) requires that when a passage's language differs
15
+ * from the page's default, it is identified programmatically — an
16
+ * invalid tag fails to identify a real language at all.
17
+ * @implementation-notes
18
+ * - Distinct, atomic decision from html-lang-attr-present (that
19
+ * rule covers the root <html> element only, for SC 3.1.1); this rule
20
+ * covers every other element, for SC 3.1.2.
21
+ * - Same minimal BCP47 *syntax* check as html-lang-attr-present (primary
22
+ * subtag + optional subtags), not IANA Language Subtag Registry
23
+ * validation — same documented scope limitation (syntactically
24
+ * well-formed but unregistered tags like "xx-ZZ" are not flagged).
25
+ */
26
+
27
+ const id = 'valid-lang';
28
+
29
+ const meta = {
30
+ title: 'Element lang attribute must be syntactically valid',
31
+ description: 'Checks that any element (other than the root <html>) with a non-empty lang attribute uses a syntactically valid language tag.',
32
+ i18n: {
33
+ titleKey: 'validLang_title',
34
+ descriptionKey: 'validLang_description'
35
+ },
36
+ helpUrl: null,
37
+ tags: ['wcag2aa', 'wcag312', 'structure', 'language', 'atomic', 'automatic'],
38
+ wcagSc: ['3.1.2'],
39
+ normativeMappings: [
40
+ { standard: 'WCAG', version: '2.2', requirement: '3.1.2', title: 'Language of Parts', conformanceLevel: 'AA' }
41
+ ],
42
+ defaultSeverity: 'moderate',
43
+ category: 'understandable',
44
+ type: 'automatic',
45
+ defaultConfidence: 'high',
46
+ coverage: { facetsBySc: { '3.1.2': ['element-lang-valid'] } }
47
+ };
48
+
49
+ function runInPage(ctx) {
50
+ const { document, root, helpers, rule } = ctx;
51
+ const safeRoot = root || document;
52
+
53
+ const BCP47_RE = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/;
54
+
55
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[lang]', safeRoot) : helpers.queryAll('[lang]', safeRoot);
56
+
57
+ const occurrences = [];
58
+ let applicableCount = 0;
59
+
60
+ for (const el of nodes) {
61
+ if (!el || !el.getAttribute) continue;
62
+ if (el.tagName && el.tagName.toLowerCase() === 'html') continue;
63
+
64
+ const raw = String(el.getAttribute('lang') || '').trim();
65
+ if (!raw) continue;
66
+
67
+ applicableCount += 1;
68
+
69
+ if (BCP47_RE.test(raw)) continue;
70
+
71
+ const tag = el.tagName.toLowerCase();
72
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
73
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
74
+
75
+ occurrences.push({
76
+ selector: stableSelector,
77
+ html,
78
+ summary: `This lang attribute value ("${raw}") is not a syntactically valid language tag.`,
79
+ hint: 'Use a valid BCP47 language tag (e.g. "fr", "es-MX").',
80
+ i18n: {
81
+ summaryKey: 'validLang_summary_fail',
82
+ hintKey: 'validLang_hint_fail',
83
+ params: { element: tag, value: raw }
84
+ },
85
+ data: {
86
+ details: { reasonCode: 'ELEMENT_LANG_INVALID', element: tag, value: raw }
87
+ }
88
+ });
89
+ }
90
+
91
+ if (applicableCount === 0) {
92
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
93
+ }
94
+ if (occurrences.length) {
95
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'moderate', occurrences };
96
+ }
97
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
98
+ }
99
+
100
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,193 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check video-poster-text-alternative-present
5
+ * @atomic true
6
+ * @summary Accessible <video> elements with a poster must provide a text alternative
7
+ * @standard WCAG 2.2
8
+ * @sc 1.1.1
9
+ * @applicability
10
+ * Applies to <video> elements that:
11
+ * 1) have a non-empty poster attribute, AND
12
+ * 2) are exposed to assistive technologies (per engine eligibility checks).
13
+ * Elements otherwise hidden from the accessibility tree remain applicable
14
+ * if they are tabbable or referenced by IDREF relationships (per eligibility checks).
15
+ * Videos with role="presentation" or role="none" are excluded only when they are not focusable.
16
+ * @expectation
17
+ * Each applicable <video> element provides a text alternative for the poster image, via:
18
+ * - an accessible name (aria-label / aria-labelledby / title).
19
+ * Between-tag fallback content inside <video> is NOT accepted: it is only
20
+ * rendered by browsers that don't support <video>, so it is not reliably
21
+ * exposed to assistive technologies in practice. <video> is also not a
22
+ * labelable element, so native <label for="..."> associations are not
23
+ * accepted either.
24
+ */
25
+
26
+ const id = 'video-poster-text-alternative-present';
27
+
28
+ const meta = {
29
+ title: '<video> poster must have a text alternative',
30
+ description:
31
+ 'Checks that <video> elements with a poster image provide a text alternative (accessible name).',
32
+ i18n: {
33
+ titleKey: 'videoPoster_textAltPresent_title',
34
+ descriptionKey: 'videoPoster_textAltPresent_description'
35
+ },
36
+ helpUrl: null,
37
+ tags: ['wcag2a', 'wcag111', 'media', 'video', 'atomic', 'automatic'],
38
+ wcagSc: ['1.1.1'],
39
+ normativeMappings: [
40
+ { standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A' }
41
+ ],
42
+ defaultSeverity: 'serious',
43
+ category: 'perceivable',
44
+ type: 'automatic',
45
+ defaultConfidence: 'medium',
46
+ coverage: {
47
+ facetsBySc: {
48
+ '1.1.1': ['video-poster-text-alt-present']
49
+ }
50
+ }
51
+ };
52
+
53
+ function runInPage(ctx) {
54
+ const { document, root, helpers, rule } = ctx;
55
+ const safeRoot = root || document;
56
+
57
+ const reportOccurrence = helpers && typeof helpers.reportOccurrence === 'function'
58
+ ? helpers.reportOccurrence
59
+ : null;
60
+
61
+ const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
62
+ const queryAll = helpers && typeof helpers.queryAll === 'function'
63
+ ? helpers.queryAll
64
+ : null;
65
+
66
+ const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
67
+ ? helpers.getEligibilityInfo
68
+ : null;
69
+
70
+ const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
71
+ ? helpers.isAccTreeEligible
72
+ : null;
73
+
74
+ const isFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function'
75
+ ? helpers.getFocusableInfo
76
+ : null;
77
+
78
+ const getAriaNameInfo = helpers && typeof helpers.getAriaNameInfo === 'function'
79
+ ? helpers.getAriaNameInfo
80
+ : null;
81
+
82
+ function trim(v) { return (v == null ? '' : String(v)).trim(); }
83
+
84
+ function computeNameInfo(el) {
85
+ // <video> is not a labelable element (no browser computes an accessible
86
+ // name from <label for="...">), so only ARIA naming and title count —
87
+ // do not accept native <label> associations.
88
+ const flags = [];
89
+ let aria = null;
90
+
91
+ if (getAriaNameInfo) {
92
+ aria = (() => { try { return getAriaNameInfo(el, ctx); } catch { return null; } })();
93
+ }
94
+
95
+ if (aria && aria.present && trim(aria.value)) {
96
+ return { present: true, value: trim(aria.value), mechanism: aria.mechanism || 'aria', flags: (aria.flags || []).slice(0) };
97
+ }
98
+
99
+ const title = trim(el.getAttribute && el.getAttribute('title'));
100
+ if (title) {
101
+ flags.push('title-used');
102
+ return { present: true, value: title, mechanism: 'title', flags };
103
+ }
104
+
105
+ if (aria && aria.flags && aria.flags.length) {
106
+ for (const f of aria.flags) flags.push(f);
107
+ }
108
+
109
+ return { present: false, value: '', mechanism: 'none', flags };
110
+ }
111
+
112
+ const videos = (() => {
113
+ try {
114
+ if (queryAllSmart) return Array.from(queryAllSmart('video') || []);
115
+ if (queryAll) return Array.from(queryAll('video') || []);
116
+ return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll('video')) : [];
117
+ } catch {
118
+ try { return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll('video')) : []; }
119
+ catch { return []; }
120
+ }
121
+ })();
122
+
123
+ if (!videos.length) return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
124
+
125
+ const occurrences = [];
126
+ let applicableCount = 0;
127
+
128
+ for (const el of videos) {
129
+ if (!el || !el.getAttribute) continue;
130
+
131
+ const poster = trim(el.getAttribute('poster'));
132
+ if (!poster) continue; // not applicable: no poster image
133
+
134
+ // Eligibility: only elements exposed to AT (with helper exceptions)
135
+ if (isAccTreeEligible) {
136
+ const elig = (() => {
137
+ try { return isAccTreeEligible(el, ctx); } catch { return { eligible: true, reasons: [] }; }
138
+ })();
139
+ if (elig && elig.eligible === false) continue;
140
+ }
141
+
142
+ // Role presentation/none excluded ONLY if not focusable (mirrors img behavior)
143
+ const role = trim(el.getAttribute('role')).toLowerCase();
144
+ if (role === 'presentation' || role === 'none') {
145
+ let focusable = false;
146
+ if (isFocusableInfo) {
147
+ const fi = (() => { try { return isFocusableInfo(el, ctx); } catch { return null; } })();
148
+ focusable = !!(fi && fi.focusable);
149
+ } else {
150
+ const tabindex = el.getAttribute('tabindex');
151
+ focusable = tabindex != null && trim(tabindex) !== '' && !Number.isNaN(Number(trim(tabindex)));
152
+ }
153
+ if (!focusable) continue;
154
+ }
155
+
156
+ applicableCount += 1;
157
+
158
+ const nameInfo = computeNameInfo(el);
159
+ if (nameInfo.present) continue;
160
+
161
+ let eligInfo = null;
162
+ if (getEligibilityInfo) {
163
+ try { eligInfo = getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { eligInfo = null; }
164
+ }
165
+
166
+ const baseOccurrence = {
167
+ summary: 'Missing text alternative for <video> poster.',
168
+ hint: 'Provide an accessible name (e.g., aria-label/aria-labelledby) for the poster image.',
169
+ i18n: {
170
+ summaryKey: 'videoPoster_textAltPresent_summary_fail',
171
+ hintKey: 'videoPoster_textAltPresent_hint_fail',
172
+ params: { element: 'video' }
173
+ },
174
+ data: {
175
+ poster,
176
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
177
+ nameInfo: nameInfo || null
178
+ }
179
+ };
180
+
181
+ if (reportOccurrence) {
182
+ occurrences.push(reportOccurrence(el, baseOccurrence));
183
+ } else {
184
+ occurrences.push({ selector: '', html: '', ...baseOccurrence });
185
+ }
186
+ }
187
+
188
+ if (applicableCount === 0) return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
189
+ if (!occurrences.length) return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
190
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
191
+ }
192
+
193
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check accesskeys
5
+ * @atomic true
6
+ * @summary accesskey values must be unique on the page
7
+ * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
+ * @applicability
9
+ * Applies whenever two or more elements share the same non-empty
10
+ * accesskey attribute value (case-insensitive).
11
+ * @expectation
12
+ * Every accesskey value on the page is unique. Duplicate accesskeys
13
+ * make keyboard-shortcut activation ambiguous — only one of the
14
+ * elements sharing the key can actually be reached by it, and which
15
+ * one is browser/platform-dependent.
16
+ * @implementation-notes
17
+ * - Not WCAG-normative — authored as an advisory, cantTell-capped
18
+ * `type: 'manual'` rule; see landmark-banner-is-top-level's
19
+ * header comment for the shared rationale/precedent.
20
+ */
21
+
22
+ const id = 'accesskeys';
23
+
24
+ const meta = {
25
+ title: 'accesskey values must be unique',
26
+ description: 'Checks that no two elements on the page share the same accesskey attribute value.',
27
+ i18n: {
28
+ titleKey: 'accesskeys_title',
29
+ descriptionKey: 'accesskeys_description'
30
+ },
31
+ helpUrl: null,
32
+ tags: ['best-practice', 'keyboard', 'structure', 'atomic', 'manual'],
33
+ wcagSc: [],
34
+ normativeMappings: [],
35
+ defaultSeverity: 'minor',
36
+ category: 'operable',
37
+ type: 'manual',
38
+ defaultConfidence: 'medium',
39
+ coverage: {}
40
+ };
41
+
42
+ function runInPage(ctx) {
43
+ const { document, helpers, rule } = ctx;
44
+
45
+ let nodes = [];
46
+ try {
47
+ nodes = document.querySelectorAll('[accesskey]');
48
+ } catch {
49
+ nodes = [];
50
+ }
51
+
52
+ const groups = new Map(); // normalized key -> elements[]
53
+ for (const el of nodes) {
54
+ if (!el || !el.getAttribute) continue;
55
+ const raw = String(el.getAttribute('accesskey') || '').trim();
56
+ if (!raw) continue;
57
+ const key = raw.toLowerCase();
58
+ const list = groups.get(key) || [];
59
+ list.push(el);
60
+ groups.set(key, list);
61
+ }
62
+
63
+ const occurrences = [];
64
+ for (const [key, els] of groups) {
65
+ if (els.length <= 1) continue;
66
+ for (const el of els) {
67
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
68
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
69
+
70
+ occurrences.push({
71
+ selector: stableSelector,
72
+ html,
73
+ summary: 'This element\'s accesskey is shared with another element on the page.',
74
+ hint: 'Make each accesskey value unique across the page.',
75
+ i18n: {
76
+ summaryKey: 'accesskeys_summary_cantTell',
77
+ hintKey: 'accesskeys_hint_cantTell',
78
+ params: { accesskey: key, duplicateCount: String(els.length) }
79
+ },
80
+ data: {
81
+ details: { reasonCode: 'ACCESSKEY_DUPLICATE', accesskey: key, duplicateCount: els.length }
82
+ }
83
+ });
84
+ }
85
+ }
86
+
87
+ if (!occurrences.length) {
88
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
89
+ }
90
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
91
+ }
92
+
93
+ module.exports = { id, meta, runInPage };