@surea11y/core 1.6.0 → 1.8.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 (120) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/README.md +179 -90
  3. package/docs/ACT_RULE_MAPPING.md +10 -8
  4. package/docs/API_STABILITY.md +67 -6
  5. package/docs/BINDING_AUTHORS_GUIDE.md +104 -2
  6. package/docs/CI_INTEGRATIONS.md +43 -0
  7. package/docs/DESIGN_CHALLENGES.md +162 -2
  8. package/docs/EARL.md +100 -0
  9. package/docs/ENGINE_OPTIONS.md +109 -5
  10. package/docs/I18N.md +62 -20
  11. package/docs/INTEGRATION.md +4 -2
  12. package/docs/JUNIT.md +73 -0
  13. package/docs/LIMITATIONS.md +4 -1
  14. package/docs/OUTPUT_SCHEMA.md +62 -11
  15. package/docs/POLICY.md +1 -1
  16. package/docs/REPORT.md +7 -2
  17. package/docs/RULE_AUTHORING.md +83 -17
  18. package/docs/RULE_CATALOG.md +212 -139
  19. package/docs/RULE_EXAMPLES.md +2189 -0
  20. package/docs/RULE_HELPERS.md +390 -0
  21. package/docs/RULE_TAXONOMY.md +27 -6
  22. package/docs/SARIF.md +23 -3
  23. package/docs/WCAG_CONFORMANCE.md +64 -3
  24. package/package.json +41 -12
  25. package/profiles/index.js +14 -0
  26. package/src/checks/automatic/area-alt-present.js +87 -31
  27. package/src/checks/automatic/aria-allowed-attr.js +6 -0
  28. package/src/checks/automatic/aria-allowed-role.js +32 -23
  29. package/src/checks/automatic/aria-braille-equivalent.js +43 -17
  30. package/src/checks/automatic/aria-conditional-attr.js +17 -10
  31. package/src/checks/automatic/aria-deprecated-role.js +12 -0
  32. package/src/checks/automatic/aria-hidden-body.js +1 -1
  33. package/src/checks/automatic/aria-hidden-focus.js +74 -18
  34. package/src/checks/automatic/aria-prohibited-attr.js +22 -4
  35. package/src/checks/automatic/aria-prohibited-children.js +6 -6
  36. package/src/checks/automatic/aria-required-attr.js +88 -12
  37. package/src/checks/automatic/aria-required-children.js +33 -16
  38. package/src/checks/automatic/aria-required-parent.js +32 -6
  39. package/src/checks/automatic/aria-role-name-present.js +20 -3
  40. package/src/checks/automatic/aria-roles-valid.js +52 -21
  41. package/src/checks/automatic/aria-valid-attr-value.js +89 -24
  42. package/src/checks/automatic/aria-valid-attr.js +14 -9
  43. package/src/checks/automatic/autocomplete-valid.js +152 -26
  44. package/src/checks/automatic/avoid-inline-spacing.js +207 -15
  45. package/src/checks/automatic/button-name-present.js +2 -1
  46. package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
  47. package/src/checks/automatic/combobox-name-present.js +34 -51
  48. package/src/checks/automatic/contrast-computable.js +45 -4
  49. package/src/checks/automatic/contrast-enhanced.js +16 -4
  50. package/src/checks/automatic/contrast-minimum.js +57 -11
  51. package/src/checks/automatic/css-orientation-lock.js +171 -12
  52. package/src/checks/automatic/definition-list-children-valid.js +67 -23
  53. package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
  54. package/src/checks/automatic/dialog-name-present.js +28 -9
  55. package/src/checks/automatic/duplicate-id-aria.js +5 -0
  56. package/src/checks/automatic/duplicate-id.js +19 -10
  57. package/src/checks/automatic/form-control-single-label.js +9 -0
  58. package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
  59. package/src/checks/automatic/iframe-focusable-content.js +12 -4
  60. package/src/checks/automatic/iframe-title-unique.js +36 -81
  61. package/src/checks/automatic/input-image-alt-present.js +32 -20
  62. package/src/checks/automatic/label-in-name.js +78 -69
  63. package/src/checks/automatic/language-page-present.js +12 -6
  64. package/src/checks/automatic/link-in-text-block.js +512 -44
  65. package/src/checks/automatic/link-name-present.js +13 -5
  66. package/src/checks/automatic/list-children-valid.js +18 -1
  67. package/src/checks/automatic/listbox-name-present.js +19 -49
  68. package/src/checks/automatic/listitem-parent-valid.js +4 -3
  69. package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
  70. package/src/checks/automatic/page-title-present.js +16 -4
  71. package/src/checks/automatic/progressbar-name-present.js +11 -1
  72. package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +9 -5
  73. package/src/checks/automatic/searchbox-name-present.js +32 -49
  74. package/src/checks/automatic/server-side-image-map-absent.js +48 -28
  75. package/src/checks/automatic/slider-name-present.js +38 -52
  76. package/src/checks/automatic/spinbutton-name-present.js +32 -49
  77. package/src/checks/automatic/target-size-minimum.js +84 -16
  78. package/src/checks/automatic/td-has-header.js +60 -23
  79. package/src/checks/automatic/text-spacing-content-loss.js +548 -0
  80. package/src/checks/automatic/textbox-name-present.js +32 -49
  81. package/src/checks/automatic/valid-lang.js +15 -10
  82. package/src/checks/manual/area-alt-quality-manual.js +113 -31
  83. package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
  84. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
  85. package/src/checks/manual/css-hidden-focus.js +215 -7
  86. package/src/checks/manual/form-control-label-quality-manual.js +243 -29
  87. package/src/checks/manual/heading-order-manual.js +9 -1
  88. package/src/checks/manual/heading-quality-manual.js +143 -9
  89. package/src/checks/manual/img-alt-decorative-manual.js +6 -3
  90. package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
  91. package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
  92. package/src/checks/manual/link-name-quality-manual.js +130 -4
  93. package/src/checks/manual/media-transcript-present-manual.js +65 -8
  94. package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
  95. package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
  96. package/src/checks/manual/p-as-heading-manual.js +89 -44
  97. package/src/checks/manual/page-title-patterns-manual.js +77 -8
  98. package/src/checks/manual/password-paste-enabled-manual.js +255 -0
  99. package/src/checks/manual/skip-link-manual.js +42 -14
  100. package/src/checks/manual/table-fake-caption-manual.js +32 -1
  101. package/src/checks/manual/video-caption-manual.js +47 -24
  102. package/src/checks/manual-review.js +0 -4
  103. package/src/core.js +18061 -46194
  104. package/src/coverage/en301549-map.js +187 -0
  105. package/src/coverage/standards.js +279 -0
  106. package/src/coverage/wcag-facets.js +1119 -0
  107. package/src/coverage/wcag-version-map.js +101 -0
  108. package/src/earl.js +144 -0
  109. package/src/en301549.js +33 -0
  110. package/src/junit.js +321 -0
  111. package/src/profile-kit.js +163 -0
  112. package/src/report.js +343 -74
  113. package/src/sarif.js +56 -5
  114. package/src/wcag.js +105 -0
  115. package/surea11y.browser.js +11 -41039
  116. package/surea11y.i18n.de.js +2 -21
  117. package/surea11y.i18n.es.js +2 -21
  118. package/surea11y.i18n.fr.js +2 -21
  119. package/surea11y.i18n.ja.js +3 -0
  120. package/src/checks/manual/area-alt-decorative-manual.js +0 -255
@@ -0,0 +1,255 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
3
+ 'use strict';
4
+
5
+ /**
6
+ * @check password-paste-enabled
7
+ * @atomic true
8
+ * @summary An authentication field must not block pasting into it
9
+ * @standard WCAG 2.2
10
+ * @sc 3.3.8
11
+ * @applicability
12
+ * Applies to any control whose autocomplete token is current-password,
13
+ * new-password or one-time-code, and to <input type="password"> unless its
14
+ * autocomplete names another purpose. A disabled or readonly field takes no
15
+ * input to block, and one outside the accessibility tree is not being asked
16
+ * for, so neither is in scope.
17
+ * @expectation
18
+ * A reviewer confirms the field can still be pasted into. Remembering a
19
+ * password is a cognitive function test, and 3.3.8 asks for a mechanism
20
+ * that helps the user through one; a password manager, or the clipboard
21
+ * for a one-time code, is that mechanism.
22
+ * @implementation-notes
23
+ * - Advisory and capped at cantTell. Whether a handler really stops the user
24
+ * depends on script the markup does not carry, so no reading of it is safe
25
+ * enough to fail on: a split one-time-code field cancels the default paste
26
+ * and then spreads the digits across its boxes. The two cases are reported
27
+ * apart -- a handler that only cancels, and one that goes on to do more --
28
+ * so a reviewer knows which to look at first. `return true` allows the
29
+ * paste and reports nothing.
30
+ * - type="password" masks rather than authenticates, and a card security code
31
+ * is masked the same way, so an autocomplete naming another purpose puts the
32
+ * field out of scope. A masked field with no autocomplete cannot be told
33
+ * apart, which is the one case left.
34
+ * - `autocomplete="off"` is not reported. Browsers override it for password
35
+ * fields so password managers keep working, and the mechanism survives.
36
+ * - A handler attached with addEventListener leaves nothing to read, the
37
+ * limit every static scan shares.
38
+ * - 3.3.8 also excuses the test where the process offers another
39
+ * authentication method. A sibling button proves nothing about whether it
40
+ * works or replaces this step, so it is not inferred; the facet is recorded
41
+ * as partial for that reason.
42
+ */
43
+
44
+ const id = 'password-paste-enabled';
45
+
46
+ const meta = {
47
+ title: 'Authentication fields must not block pasting',
48
+ description:
49
+ 'Checks that a password or one-time-code field carries no inline paste handler that cancels the paste, which would remove the password manager or clipboard that WCAG 3.3.8 relies on as the assisting mechanism.',
50
+ i18n: {
51
+ titleKey: 'passwordPasteEnabled_title',
52
+ descriptionKey: 'passwordPasteEnabled_description'
53
+ },
54
+ helpUrl: null,
55
+ tags: ['wcag22aa', 'wcag338', 'forms', 'authentication', 'atomic', 'manual', 'acc'],
56
+ wcagSc: ['3.3.8'],
57
+ normativeMappings: [
58
+ {
59
+ standard: 'WCAG',
60
+ version: '2.2',
61
+ requirement: '3.3.8',
62
+ title: 'Accessible Authentication (Minimum)',
63
+ conformanceLevel: 'AA'
64
+ }
65
+ ],
66
+ defaultSeverity: 'serious',
67
+ category: 'understandable',
68
+ type: 'manual',
69
+ defaultConfidence: 'medium',
70
+ coverage: { facetsBySc: { '3.3.8': ['authentication-paste-not-blocked'] } }
71
+ };
72
+
73
+ function runInPage(ctx) {
74
+ const { helpers, rule } = ctx;
75
+
76
+ // Declared inside runInPage; see scripts/build-core.js header
77
+ // ("runInPage MUST be self-contained").
78
+ const AUTH_AUTOCOMPLETE_TOKENS = ['current-password', 'new-password', 'one-time-code'];
79
+
80
+ function normalizeWs(s) {
81
+ return String(s || '')
82
+ .replace(/\s+/g, ' ')
83
+ .trim();
84
+ }
85
+
86
+ function autocompleteTokens(el) {
87
+ return normalizeWs(el.getAttribute && el.getAttribute('autocomplete'))
88
+ .toLowerCase()
89
+ .split(' ')
90
+ .filter(Boolean);
91
+ }
92
+
93
+ // A field an authentication step reads: a password input, or any control
94
+ // whose autocomplete token names an authentication secret. The autocomplete
95
+ // route matters for one-time codes, which are ordinary text inputs.
96
+ //
97
+ // type="password" is a masking control, not only an authentication one: a
98
+ // card security code is routinely masked the same way. An explicit
99
+ // autocomplete purpose that is not an authentication one says so, and takes
100
+ // the field back out of scope.
101
+ function isAuthField(el) {
102
+ const tag = el.tagName ? el.tagName.toLowerCase() : '';
103
+ if (tag !== 'input' && tag !== 'textarea') return false;
104
+
105
+ const tokens = autocompleteTokens(el);
106
+ if (AUTH_AUTOCOMPLETE_TOKENS.some((t) => tokens.includes(t))) return true;
107
+
108
+ const type = normalizeWs(el.getAttribute && el.getAttribute('type')).toLowerCase();
109
+ if (tag !== 'input' || type !== 'password') return false;
110
+
111
+ const declaresOtherPurpose = tokens.some(
112
+ (t) => t !== 'on' && t !== 'off' && !AUTH_AUTOCOMPLETE_TOKENS.includes(t)
113
+ );
114
+ return !declaresOtherPurpose;
115
+ }
116
+
117
+ // A field that takes no input at all cannot be pasted into either, so a
118
+ // paste handler on it blocks nothing.
119
+ function acceptsInput(el) {
120
+ if (el.hasAttribute && (el.hasAttribute('disabled') || el.hasAttribute('readonly'))) {
121
+ return false;
122
+ }
123
+ return true;
124
+ }
125
+
126
+ // Classifies an inline handler as 'cancelOnly', 'opaque' or 'none'.
127
+ //
128
+ // Cancelling is not the same as blocking. Replacing the default paste is
129
+ // how a split one-time-code field distributes the digits across its boxes,
130
+ // and how a password field strips stray whitespace from what was pasted --
131
+ // both call preventDefault and then insert the text themselves, which
132
+ // helps the user rather than stopping them. So a handler counts as
133
+ // blocking only when cancelling is the whole of what it does. Anything
134
+ // further -- a call, an assignment, a condition -- may well be putting the
135
+ // text back, and the markup does not say, so it reports cantTell.
136
+ function classifyHandler(source) {
137
+ const src = String(source || '').trim();
138
+ if (!src) return 'none';
139
+
140
+ const CANCEL = [
141
+ /\breturn\s+false\b/gi,
142
+ /\breturn\s*!1\b/gi,
143
+ /\bpreventDefault\s*\(\s*\)/gi,
144
+ /\breturnValue\s*=\s*(?:false|!1)\b/gi
145
+ ];
146
+ // Says "yes, paste" outright, so it blocks nothing.
147
+ const ALLOW = [/\breturn\s+true\b/gi, /\breturn\s*!0\b/gi];
148
+ // Neither cancels nor re-inserts.
149
+ const NEUTRAL = [/\bstop(?:Immediate)?Propagation\s*\(\s*\)/gi, /\breturn\b/gi];
150
+ // What is left of an object reference once its method call is removed.
151
+ const REFS = /\b(?:window|document|this|event|evt|ev|e|arguments\[0\])\b/gi;
152
+ const ONLY_PUNCTUATION = /^[\s;.,()[\]{}]*$/;
153
+
154
+ let rest = src;
155
+ let sawCancel = false;
156
+ for (const re of CANCEL) {
157
+ const next = rest.replace(re, ' ');
158
+ if (next !== rest) sawCancel = true;
159
+ rest = next;
160
+ }
161
+
162
+ if (!sawCancel) {
163
+ for (const re of ALLOW) rest = rest.replace(re, ' ');
164
+ for (const re of NEUTRAL) rest = rest.replace(re, ' ');
165
+ rest = rest.replace(REFS, ' ');
166
+ return ONLY_PUNCTUATION.test(rest) ? 'none' : 'opaque';
167
+ }
168
+
169
+ for (const re of NEUTRAL) rest = rest.replace(re, ' ');
170
+ rest = rest.replace(REFS, ' ');
171
+ // Anything left beyond punctuation is the handler doing more than cancel.
172
+ return ONLY_PUNCTUATION.test(rest) ? 'cancelOnly' : 'opaque';
173
+ }
174
+
175
+ const nodes = helpers.queryAllSmart
176
+ ? helpers.queryAllSmart('input, textarea')
177
+ : helpers.queryAll('input, textarea');
178
+
179
+ const cancelling = [];
180
+ const undetermined = [];
181
+
182
+ for (const el of nodes) {
183
+ if (!el || !el.getAttribute) continue;
184
+ if (!isAuthField(el)) continue;
185
+
186
+ if (!acceptsInput(el)) continue;
187
+
188
+ const eligResult = helpers.isAccTreeEligible ? helpers.isAccTreeEligible(el, ctx) : true;
189
+ const eligible =
190
+ typeof eligResult === 'boolean' ? eligResult : !!(eligResult && eligResult.eligible);
191
+ if (!eligible) continue;
192
+
193
+ const handler = el.getAttribute('onpaste');
194
+ if (handler === null) continue;
195
+
196
+ const verdict = classifyHandler(handler);
197
+ if (verdict === 'none') continue;
198
+
199
+ const eligInfo = helpers.getEligibilityInfo
200
+ ? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' })
201
+ : { targetSet: 'acc', accEligible: null, reasons: [] };
202
+
203
+ if (verdict === 'cancelOnly') {
204
+ cancelling.push(
205
+ helpers.reportOccurrence(el, {
206
+ summary:
207
+ 'This authentication field has a paste handler whose only effect is to cancel the paste.',
208
+ hint: 'Confirm by hand whether pasting still works. If it is blocked, remove the handler so a password manager, or the clipboard for a one-time code, can fill the field.',
209
+ i18n: {
210
+ summaryKey: 'passwordPasteEnabled_summary_fail',
211
+ hintKey: 'passwordPasteEnabled_hint_fail',
212
+ params: {}
213
+ },
214
+ data: {
215
+ visibilityFilter: eligInfo,
216
+ details: { reasonCode: 'PASTE_CANCELLED', handler: normalizeWs(handler) }
217
+ }
218
+ })
219
+ );
220
+ continue;
221
+ }
222
+
223
+ // An inline handler that delegates: whether it cancels lives in code the
224
+ // markup does not carry.
225
+ undetermined.push(
226
+ helpers.reportOccurrence(el, {
227
+ summary:
228
+ 'This authentication field has a paste handler, and whether it cancels pasting could not be determined.',
229
+ hint: 'Check by hand that pasting into the field still works, so a password manager or the clipboard can fill it.',
230
+ i18n: {
231
+ summaryKey: 'passwordPasteEnabled_summary_cantTell',
232
+ hintKey: 'passwordPasteEnabled_hint_cantTell',
233
+ params: {}
234
+ },
235
+ data: {
236
+ visibilityFilter: eligInfo,
237
+ details: { reasonCode: 'PASTE_HANDLER_OPAQUE', handler: normalizeWs(handler) }
238
+ }
239
+ })
240
+ );
241
+ }
242
+
243
+ const occurrences = cancelling.concat(undetermined);
244
+ if (!occurrences.length) {
245
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
246
+ }
247
+ return {
248
+ ruleId: rule.ruleId,
249
+ outcome: 'cantTell',
250
+ severity: rule.defaultSeverity || 'serious',
251
+ occurrences
252
+ };
253
+ }
254
+
255
+ module.exports = { id, meta, runInPage };
@@ -8,15 +8,19 @@
8
8
  * @summary A "skip" link must resolve to a real, usable target
9
9
  * @standard Best Practices (no formal WCAG Success Criterion)
10
10
  * @applicability
11
- * Applies to <a href="#fragment"> elements whose accessible name
12
- * matches a common "skip to ..." / "jump to ..." authoring convention
13
- * (case-insensitive "skip" or "jump to" in the name), the recognizable
14
- * pattern for a skip-navigation link, not every same-page anchor link
15
- * on the page. "jump to" is included alongside "skip" since real skip
16
- * links use both conventions (e.g. a "Jump to section" link, which a
17
- * purely positional match would catch but a "skip"-only text pattern
18
- * would miss). Text-pattern matching itself stays intentional (see
19
- * implementation-notes); this only widens the known-convention list.
11
+ * Applies to <a href="#fragment"> elements that are skip links by one of
12
+ * two signs:
13
+ * - the accessible name follows a common skip-link wording in one of the
14
+ * shipped locales: "skip" or "jump to" (English); "aller au contenu",
15
+ * "passer au contenu", "accéder au contenu", "accès direct", "évitement"
16
+ * (French); "springen", "überspringen", "direkt zum", "zum Inhalt"
17
+ * (German); "saltar", "ir al contenido" (Spanish); "スキップ", "本文へ"
18
+ * (Japanese). "jump to" sits beside "skip" because real skip links use
19
+ * both conventions (e.g. a "Jump to section" link);
20
+ * - or it is the first link in the document, and it comes before the
21
+ * `main` element (or `[role="main"]`): the usual place of a skip link
22
+ * whatever its wording.
23
+ * Other same-page anchor links are not skip links and are left alone.
20
24
  * @expectation
21
25
  * The link's fragment resolves to a real element in the document
22
26
  * (via a matching id, or a legacy <a name="...">), and that target is
@@ -28,10 +32,12 @@
28
32
  * - Not WCAG-normative, authored as an advisory, cantTell-capped
29
33
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
30
34
  * header comment for the shared rationale/precedent.
31
- * - Keyed on the "skip" text-pattern convention rather than positional
32
- * heuristics (first link in tab order, etc.), matching the same
33
- * deliberate-leniency reasoning documented in
34
- * bypass-blocks-present's implementation notes.
35
+ * - Keyed mainly on the wording, matching the same deliberate-leniency
36
+ * reasoning documented in bypass-blocks-present's implementation notes.
37
+ * The one positional sign is narrow on purpose: only the page's very
38
+ * first link, and only when a main landmark follows it, so an ordinary
39
+ * in-page link (a "Menu" toggle after other links, a table of contents)
40
+ * is not taken for a skip link.
35
41
  */
36
42
 
37
43
  const id = 'skip-link';
@@ -109,10 +115,32 @@ function runInPage(ctx) {
109
115
 
110
116
  const geometrySupported = hasReliableGeometrySupport();
111
117
 
118
+ // Skip-link wording in the shipped locales, one list for every rule that
119
+ // looks for a skip link (helpers.hasSkipLinkWording, docs/RULE_HELPERS.md).
120
+ const hasSkipWording = (name) => helpers.hasSkipLinkWording(name);
121
+
112
122
  const nodes = helpers.queryAllSmart
113
123
  ? helpers.queryAllSmart('a[href]')
114
124
  : helpers.queryAll('a[href]');
115
125
 
126
+ // The page's first link, when it comes before the main landmark, is where a
127
+ // skip link sits whatever its wording.
128
+ let positionalSkipLink = null;
129
+ try {
130
+ const main = document.querySelector('main, [role="main"]');
131
+ const first = nodes.length ? nodes[0] : null;
132
+ if (
133
+ main &&
134
+ first &&
135
+ typeof first.compareDocumentPosition === 'function' &&
136
+ first.compareDocumentPosition(main) & 4 // Node.DOCUMENT_POSITION_FOLLOWING
137
+ ) {
138
+ positionalSkipLink = first;
139
+ }
140
+ } catch {
141
+ positionalSkipLink = null;
142
+ }
143
+
116
144
  const occurrences = [];
117
145
  let applicableCount = 0;
118
146
 
@@ -123,7 +151,7 @@ function runInPage(ctx) {
123
151
  if (href.length < 2 || href.charAt(0) !== '#') continue;
124
152
 
125
153
  const name = getAccessibleNameText(el);
126
- if (!/skip/i.test(name) && !/jump\s*to/i.test(name)) continue;
154
+ if (el !== positionalSkipLink && !hasSkipWording(name)) continue;
127
155
 
128
156
  applicableCount += 1;
129
157
 
@@ -11,7 +11,12 @@
11
11
  * @applicability
12
12
  * `<table>` elements with no `<caption>` child, at least two rows, and
13
13
  * a first row containing exactly one non-empty-text cell while at
14
- * least one other row has more than one cell.
14
+ * least one other row has more than one cell. Left out: a table whose
15
+ * role (first token) is anything but table, grid or treegrid, such as a
16
+ * layout table with role="presentation", which needs no caption; and a
17
+ * table already named by a non-empty aria-label, an aria-labelledby that
18
+ * resolves to text, or a non-empty title, which WCAG accepts as the
19
+ * table's title.
15
20
  * @expectation
16
21
  * A single lone cell in the first row, sitting above rows that clearly
17
22
  * have multiple columns, strongly suggests the author is using it as a
@@ -79,6 +84,31 @@ function runInPage(ctx) {
79
84
  }
80
85
  }
81
86
 
87
+ const getAriaNameInfo =
88
+ helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
89
+
90
+ const TABLE_ROLES = ['table', 'grid', 'treegrid'];
91
+
92
+ function hasOtherRole(table) {
93
+ const role = trim(table.getAttribute('role')).toLowerCase().split(/\s+/)[0];
94
+ return !!role && !TABLE_ROLES.includes(role);
95
+ }
96
+
97
+ // A name from aria-labelledby, aria-label or title already gives the table
98
+ // a title that assistive technology announces.
99
+ function isNamed(table) {
100
+ if (trim(table.getAttribute('title'))) return true;
101
+ if (getAriaNameInfo) {
102
+ try {
103
+ const aria = getAriaNameInfo(table, ctx);
104
+ return !!(aria && aria.present && trim(aria.value));
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
109
+ return !!trim(table.getAttribute('aria-label'));
110
+ }
111
+
82
112
  const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('table') : helpers.queryAll('table');
83
113
 
84
114
  const occurrences = [];
@@ -89,6 +119,7 @@ function runInPage(ctx) {
89
119
 
90
120
  const hasCaption = !!(table.querySelector && table.querySelector('caption'));
91
121
  if (hasCaption) continue;
122
+ if (hasOtherRole(table) || isNamed(table)) continue;
92
123
 
93
124
  // An aria-hidden row (or cell) isn't part of the AT-perceived table
94
125
  // structure at all -- it must not be treated as the table's "first
@@ -19,14 +19,15 @@
19
19
  * fail), matching the precedent set by
20
20
  * `media-alternative-transcript-evidence` for the same class
21
21
  * of "normatively mapped but not statically verifiable" gap. A <video>
22
- * with a `<track kind="captions">` (or `kind="subtitles"`, commonly
23
- * used interchangeably in the wild even though captions and subtitles
24
- * serve technically distinct purposes) whose `src` is non-empty is not
25
- * flagged; everything else is flagged for human review.
22
+ * with a `<track kind="captions">` whose `src` is non-empty is not
23
+ * flagged; everything else is flagged for human review. A video whose
24
+ * only text tracks are subtitles (`kind="subtitles"`, or no `kind`,
25
+ * which HTML treats as subtitles) gets its own question: subtitles may
26
+ * be a translation of the dialogue only, without the speaker and sound
27
+ * information captions carry.
26
28
  * @implementation-notes
27
29
  * - Does not attempt to verify the referenced track file's content,
28
- * only that a captions/subtitles track is declared with a non-empty
29
- * `src`.
30
+ * only that a captions track is declared with a non-empty `src`.
30
31
  */
31
32
 
32
33
  const id = 'video-caption';
@@ -34,7 +35,7 @@ const id = 'video-caption';
34
35
  const meta = {
35
36
  title: 'Prerecorded video should provide a captions track',
36
37
  description:
37
- 'Flags <video> elements with no <track kind="captions"|"subtitles"> child, for manual review of whether the video has an audio track that needs captions.',
38
+ 'Flags <video> elements with no <track kind="captions"> child, for manual review of whether the video has an audio track that needs captions; a subtitles track alone may be a translation only.',
38
39
  i18n: {
39
40
  titleKey: 'videoCaption_title',
40
41
  descriptionKey: 'videoCaption_description'
@@ -72,14 +73,20 @@ function runInPage(ctx) {
72
73
  applicableCount += 1;
73
74
 
74
75
  let hasCaptionsTrack = false;
76
+ let hasSubtitlesTrack = false;
75
77
  const tracks = el.querySelectorAll('track');
76
78
  for (const t of tracks) {
77
- const kind = (t.getAttribute('kind') || '').trim().toLowerCase();
79
+ // A missing kind means subtitles (HTML's missing-value default).
80
+ const kind = t.hasAttribute('kind')
81
+ ? (t.getAttribute('kind') || '').trim().toLowerCase()
82
+ : 'subtitles';
78
83
  const src = (t.getAttribute('src') || '').trim();
79
- if ((kind === 'captions' || kind === 'subtitles') && src) {
84
+ if (!src) continue;
85
+ if (kind === 'captions') {
80
86
  hasCaptionsTrack = true;
81
87
  break;
82
88
  }
89
+ if (kind === 'subtitles') hasSubtitlesTrack = true;
83
90
  }
84
91
 
85
92
  if (hasCaptionsTrack) continue;
@@ -87,20 +94,36 @@ function runInPage(ctx) {
87
94
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
88
95
  const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
89
96
 
90
- const baseOccurrence = {
91
- selector: stableSelector,
92
- html,
93
- summary: 'This video has no captions (or subtitles) track.',
94
- hint: 'If this video has an audio track that conveys information, add a <track kind="captions" src="..."> with the captioned content.',
95
- i18n: {
96
- summaryKey: 'videoCaption_summary_cantTell',
97
- hintKey: 'videoCaption_hint_cantTell',
98
- params: {}
99
- },
100
- data: {
101
- details: { reasonCode: 'CAPTIONS_TRACK_NOT_DETECTED' }
102
- }
103
- };
97
+ const baseOccurrence = hasSubtitlesTrack
98
+ ? {
99
+ selector: stableSelector,
100
+ html,
101
+ summary:
102
+ 'This video has only subtitles tracks, which may translate the dialogue without the speaker and sound information captions carry.',
103
+ hint: 'If this video has an audio track that conveys information, check that a subtitles track is in fact captions, and mark it <track kind="captions">; otherwise add a captions track.',
104
+ i18n: {
105
+ summaryKey: 'videoCaption_summary_cantTell_subtitlesOnly',
106
+ hintKey: 'videoCaption_hint_cantTell_subtitlesOnly',
107
+ params: {}
108
+ },
109
+ data: {
110
+ details: { reasonCode: 'SUBTITLES_TRACK_ONLY' }
111
+ }
112
+ }
113
+ : {
114
+ selector: stableSelector,
115
+ html,
116
+ summary: 'This video has no captions track.',
117
+ hint: 'If this video has an audio track that conveys information, add a <track kind="captions" src="..."> with the captioned content.',
118
+ i18n: {
119
+ summaryKey: 'videoCaption_summary_cantTell',
120
+ hintKey: 'videoCaption_hint_cantTell',
121
+ params: {}
122
+ },
123
+ data: {
124
+ details: { reasonCode: 'CAPTIONS_TRACK_NOT_DETECTED' }
125
+ }
126
+ };
104
127
 
105
128
  if (helpers && typeof helpers.reportOccurrence === 'function') {
106
129
  occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
@@ -123,7 +146,7 @@ function runInPage(ctx) {
123
146
  }
124
147
 
125
148
  // Manual rules may only emit cantTell/notApplicable (never pass/fail):
126
- // every <video> already has a captions/subtitles track.
149
+ // every <video> already has a captions track.
127
150
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
128
151
  }
129
152
 
@@ -63,10 +63,6 @@ const meta = {
63
63
  url: 'https://www.w3.org/TR/WCAG22/#focus-visible'
64
64
  },
65
65
 
66
- { standard: 'EN 301 549', version: 'V3.2.1', requirement: '9.2.1.1', title: 'Keyboard' },
67
- { standard: 'EN 301 549', version: 'V3.2.1', requirement: '9.2.4.3', title: 'Focus Order' },
68
- { standard: 'EN 301 549', version: 'V3.2.1', requirement: '9.2.4.7', title: 'Focus Visible' },
69
-
70
66
  {
71
67
  standard: 'WCAG',
72
68
  version: '2.2',