@surea11y/core 1.7.0 → 1.8.1

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 (103) hide show
  1. package/CHANGELOG.md +103 -1
  2. package/README.md +157 -54
  3. package/docs/ACT_RULE_MAPPING.md +8 -7
  4. package/docs/API_STABILITY.md +18 -5
  5. package/docs/BINDING_AUTHORS_GUIDE.md +2 -2
  6. package/docs/CI_INTEGRATIONS.md +43 -0
  7. package/docs/DESIGN_CHALLENGES.md +97 -3
  8. package/docs/EARL.md +2 -2
  9. package/docs/ENGINE_OPTIONS.md +81 -3
  10. package/docs/I18N.md +62 -20
  11. package/docs/JUNIT.md +73 -0
  12. package/docs/LIMITATIONS.md +1 -0
  13. package/docs/OUTPUT_SCHEMA.md +19 -6
  14. package/docs/REPORT.md +7 -2
  15. package/docs/RULE_AUTHORING.md +73 -6
  16. package/docs/RULE_CATALOG.md +139 -116
  17. package/docs/RULE_EXAMPLES.md +2189 -0
  18. package/docs/RULE_HELPERS.md +62 -5
  19. package/docs/RULE_TAXONOMY.md +2 -2
  20. package/docs/SARIF.md +2 -1
  21. package/docs/WCAG_CONFORMANCE.md +56 -3
  22. package/package.json +34 -11
  23. package/profiles/index.js +14 -0
  24. package/src/checks/automatic/area-alt-present.js +87 -31
  25. package/src/checks/automatic/aria-braille-equivalent.js +25 -7
  26. package/src/checks/automatic/aria-hidden-focus.js +74 -18
  27. package/src/checks/automatic/aria-prohibited-attr.js +17 -4
  28. package/src/checks/automatic/aria-required-attr.js +29 -0
  29. package/src/checks/automatic/aria-role-name-present.js +19 -2
  30. package/src/checks/automatic/aria-valid-attr-value.js +28 -16
  31. package/src/checks/automatic/autocomplete-valid.js +26 -11
  32. package/src/checks/automatic/avoid-inline-spacing.js +105 -40
  33. package/src/checks/automatic/button-name-present.js +2 -1
  34. package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
  35. package/src/checks/automatic/combobox-name-present.js +34 -51
  36. package/src/checks/automatic/contrast-computable.js +35 -4
  37. package/src/checks/automatic/contrast-enhanced.js +4 -4
  38. package/src/checks/automatic/contrast-minimum.js +45 -11
  39. package/src/checks/automatic/css-orientation-lock.js +152 -30
  40. package/src/checks/automatic/definition-list-children-valid.js +67 -23
  41. package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
  42. package/src/checks/automatic/dialog-name-present.js +28 -9
  43. package/src/checks/automatic/duplicate-id.js +6 -2
  44. package/src/checks/automatic/identical-iframes-same-purpose.js +4 -4
  45. package/src/checks/automatic/iframe-focusable-content.js +7 -4
  46. package/src/checks/automatic/iframe-title-unique.js +36 -81
  47. package/src/checks/automatic/input-image-alt-present.js +32 -20
  48. package/src/checks/automatic/label-in-name.js +40 -13
  49. package/src/checks/automatic/language-page-present.js +12 -6
  50. package/src/checks/automatic/link-in-text-block.js +272 -60
  51. package/src/checks/automatic/link-name-present.js +13 -5
  52. package/src/checks/automatic/list-children-valid.js +18 -1
  53. package/src/checks/automatic/listbox-name-present.js +19 -49
  54. package/src/checks/automatic/listitem-parent-valid.js +4 -3
  55. package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
  56. package/src/checks/automatic/page-title-present.js +16 -4
  57. package/src/checks/automatic/progressbar-name-present.js +11 -1
  58. package/src/checks/automatic/role-img-text-alternative-present.js +9 -5
  59. package/src/checks/automatic/searchbox-name-present.js +32 -49
  60. package/src/checks/automatic/server-side-image-map-absent.js +48 -28
  61. package/src/checks/automatic/slider-name-present.js +38 -52
  62. package/src/checks/automatic/spinbutton-name-present.js +32 -49
  63. package/src/checks/automatic/target-size-minimum.js +0 -11
  64. package/src/checks/automatic/td-has-header.js +41 -5
  65. package/src/checks/automatic/text-spacing-content-loss.js +548 -0
  66. package/src/checks/automatic/textbox-name-present.js +32 -49
  67. package/src/checks/automatic/valid-lang.js +15 -10
  68. package/src/checks/manual/area-alt-quality-manual.js +113 -31
  69. package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
  70. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
  71. package/src/checks/manual/css-hidden-focus.js +215 -7
  72. package/src/checks/manual/form-control-label-quality-manual.js +109 -5
  73. package/src/checks/manual/heading-order-manual.js +9 -1
  74. package/src/checks/manual/heading-quality-manual.js +143 -9
  75. package/src/checks/manual/img-alt-decorative-manual.js +6 -3
  76. package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
  77. package/src/checks/manual/link-name-quality-manual.js +130 -4
  78. package/src/checks/manual/media-transcript-present-manual.js +65 -8
  79. package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
  80. package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
  81. package/src/checks/manual/p-as-heading-manual.js +89 -44
  82. package/src/checks/manual/page-title-patterns-manual.js +77 -8
  83. package/src/checks/manual/skip-link-manual.js +42 -14
  84. package/src/checks/manual/table-fake-caption-manual.js +32 -1
  85. package/src/checks/manual/video-caption-manual.js +47 -24
  86. package/src/checks/manual-review.js +0 -4
  87. package/src/core.js +14285 -2219
  88. package/src/coverage/en301549-map.js +187 -0
  89. package/src/coverage/standards.js +279 -0
  90. package/src/coverage/wcag-facets.js +1119 -0
  91. package/src/coverage/wcag-version-map.js +101 -0
  92. package/src/en301549.js +33 -0
  93. package/src/junit.js +321 -0
  94. package/src/profile-kit.js +163 -0
  95. package/src/report.js +343 -74
  96. package/src/sarif.js +34 -3
  97. package/src/wcag.js +105 -0
  98. package/surea11y.browser.js +5 -4
  99. package/surea11y.i18n.de.js +1 -1
  100. package/surea11y.i18n.es.js +1 -1
  101. package/surea11y.i18n.fr.js +1 -1
  102. package/surea11y.i18n.ja.js +3 -0
  103. package/src/checks/manual/area-alt-decorative-manual.js +0 -255
@@ -5,14 +5,15 @@
5
5
  /**
6
6
  * @check p-as-heading
7
7
  * @atomic true
8
- * @summary A <p> styled to look like a heading should probably be a real heading
8
+ * @summary Text styled to look like a heading should probably be a real heading
9
9
  * @standard WCAG 2.2
10
10
  * @sc 1.3.1
11
11
  * @applicability
12
- * `<p>` elements with short (<=120 char), non-empty trimmed text
13
- * content that is entirely bold (the `<p>`'s own computed
14
- * `font-weight` >= 700, OR its entire text is wrapped in a single
15
- * `<strong>`/`<b>` child) and rendered at >=18px.
12
+ * `<p>` elements, and `<div>` elements that hold only text and inline
13
+ * markup, with short (<=120 char), non-empty trimmed text in which every
14
+ * piece of text is bold (computed `font-weight` >= 700, however it got
15
+ * there: on the element itself, a `<strong>`/`<b>` or a styled `<span>`)
16
+ * and rendered at >=18px.
16
17
  * @expectation
17
18
  * Text styled to visually read as a heading (bold, larger-than-body
18
19
  * size, short) should be marked up with a real heading element
@@ -30,14 +31,22 @@
30
31
  * surrounding text (unlike `link-in-text-block`); it's simpler on
31
32
  * purpose, since "looks like a heading" is closer to an absolute
32
33
  * judgment than a relative-contrast one.
34
+ * - Weight and size are read from the element that holds each piece of
35
+ * text, so `<p><span style="font-weight:bold">` counts and a `<p>` with
36
+ * one normal-weight word does not.
37
+ * - A `<div>` is only considered when it has no block, list, table, form
38
+ * control or image inside it, so only the innermost block is asked
39
+ * about. A `<div>` with a role, and text inside a heading, button,
40
+ * label, legend, caption, table header or `<summary>`, are left out:
41
+ * that text already has a role of its own.
33
42
  */
34
43
 
35
44
  const id = 'p-as-heading';
36
45
 
37
46
  const meta = {
38
- title: 'A <p> styled to look like a heading should probably be a real heading',
47
+ title: 'Text styled to look like a heading should probably be a real heading',
39
48
  description:
40
- 'Flags short <p> elements whose entire text is bold and rendered at >=18px, for manual review of whether a real heading element should be used instead.',
49
+ 'Flags short <p> and <div> elements whose text is all bold and rendered at >=18px, for manual review of whether a real heading element should be used instead.',
41
50
  i18n: {
42
51
  titleKey: 'pAsHeading_title',
43
52
  descriptionKey: 'pAsHeading_description'
@@ -93,65 +102,101 @@ function runInPage(ctx) {
93
102
  return Number.isFinite(n) && n >= 700;
94
103
  }
95
104
 
96
- function isEntirelyBold(p, text) {
97
- const cs = safeComputedStyle(p);
98
- if (isBoldWeight(cs)) return true;
99
-
100
- // A single <strong>/<b> child that wraps the whole text also counts.
101
- const children = Array.from(p.children || []);
102
- const boldWrap = children.find((c) => {
103
- const tag = (c.tagName || '').toLowerCase();
104
- return tag === 'strong' || tag === 'b';
105
- });
106
- if (boldWrap && children.length === 1) {
107
- const wrapText = trim(boldWrap.textContent || '');
108
- if (wrapText && wrapText === text) return true;
105
+ // Elements whose text already has a role of its own.
106
+ const OWN_ROLE_ANCESTORS =
107
+ 'h1, h2, h3, h4, h5, h6, [role="heading"], button, [role="button"], label, legend, caption, th, [role="columnheader"], [role="rowheader"], summary';
108
+
109
+ // Anything but text and inline markup makes a <div> a container, not a
110
+ // passage of text.
111
+ const NOT_INLINE =
112
+ 'address, article, aside, blockquote, details, dialog, div, dl, fieldset, figure, figcaption, footer, form, h1, h2, h3, h4, h5, h6, header, hgroup, hr, li, main, nav, ol, p, pre, section, table, ul, img, svg, picture, video, audio, canvas, iframe, object, embed, input, select, textarea, button';
113
+
114
+ function textPieces(el) {
115
+ const pieces = [];
116
+ const doc = el.ownerDocument;
117
+ const walker = doc.createTreeWalker(el, 4 /* NodeFilter.SHOW_TEXT */);
118
+ let node = walker.nextNode();
119
+ while (node) {
120
+ if (trim(node.nodeValue) && node.parentElement) pieces.push(node.parentElement);
121
+ node = walker.nextNode();
109
122
  }
110
- return false;
123
+ return pieces;
111
124
  }
112
125
 
113
- function getFontSizePx(p) {
114
- const cs = safeComputedStyle(p);
115
- if (!cs) return 0;
116
- const px = Number.parseFloat(cs.fontSize);
117
- return Number.isFinite(px) ? px : 0;
126
+ // Every piece of text is bold, and the smallest is the size reported.
127
+ function boldSize(el) {
128
+ let minPx = Infinity;
129
+ for (const holder of textPieces(el)) {
130
+ const cs = safeComputedStyle(holder);
131
+ if (!isBoldWeight(cs)) return 0;
132
+ const px = Number.parseFloat(cs.fontSize);
133
+ if (!Number.isFinite(px)) return 0;
134
+ minPx = Math.min(minPx, px);
135
+ }
136
+ return Number.isFinite(minPx) ? minPx : 0;
137
+ }
138
+
139
+ function isCandidate(el) {
140
+ const tag = (el.tagName || '').toLowerCase();
141
+ if (el.closest && el.closest(OWN_ROLE_ANCESTORS)) return false;
142
+ if (tag === 'p') return true;
143
+ if (tag !== 'div') return false;
144
+ if (trim(el.getAttribute('role'))) return false;
145
+ return !el.querySelector(NOT_INLINE);
118
146
  }
119
147
 
120
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('p') : helpers.queryAll('p');
148
+ const nodes = helpers.queryAllSmart
149
+ ? helpers.queryAllSmart('p, div')
150
+ : helpers.queryAll('p, div');
121
151
 
122
152
  const occurrences = [];
123
153
  let applicableCount = 0;
124
154
 
125
155
  for (const el of nodes) {
126
156
  if (!el || !el.getAttribute) continue;
157
+ if (!isCandidate(el)) continue;
127
158
 
128
159
  const text = trim(el.textContent || '');
129
160
  if (!text || text.length > MAX_HEADING_LIKE_CHARS) continue;
130
161
 
131
162
  applicableCount += 1;
132
163
 
133
- if (!isEntirelyBold(el, text)) continue;
134
-
135
- const fontSizePx = getFontSizePx(el);
164
+ const fontSizePx = boldSize(el);
136
165
  if (fontSizePx < MIN_FONT_SIZE_PX) continue;
137
166
 
167
+ const isParagraph = (el.tagName || '').toLowerCase() === 'p';
138
168
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
139
169
  const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
140
170
 
141
- const baseOccurrence = {
142
- selector: stableSelector,
143
- html,
144
- summary: 'This paragraph is entirely bold and rendered at a heading-like size.',
145
- hint: 'If this text introduces a new section, use a real heading element (<h1>-<h6> or role="heading") instead of styling a paragraph to look like one.',
146
- i18n: {
147
- summaryKey: 'pAsHeading_summary_cantTell',
148
- hintKey: 'pAsHeading_hint_cantTell',
149
- params: { fontSizePx: String(fontSizePx) }
150
- },
151
- data: {
152
- details: { reasonCode: 'BOLD_LARGE_PARAGRAPH', fontSizePx }
153
- }
154
- };
171
+ const baseOccurrence = isParagraph
172
+ ? {
173
+ selector: stableSelector,
174
+ html,
175
+ summary: 'This paragraph is entirely bold and rendered at a heading-like size.',
176
+ hint: 'If this text introduces a new section, use a real heading element (<h1>-<h6> or role="heading") instead of styling a paragraph to look like one.',
177
+ i18n: {
178
+ summaryKey: 'pAsHeading_summary_cantTell',
179
+ hintKey: 'pAsHeading_hint_cantTell',
180
+ params: { fontSizePx: String(fontSizePx) }
181
+ },
182
+ data: {
183
+ details: { reasonCode: 'BOLD_LARGE_PARAGRAPH', fontSizePx }
184
+ }
185
+ }
186
+ : {
187
+ selector: stableSelector,
188
+ html,
189
+ summary: 'This block of text is entirely bold and rendered at a heading-like size.',
190
+ hint: 'If this text introduces a new section, use a real heading element (<h1>-<h6> or role="heading") instead of styling a <div> to look like one.',
191
+ i18n: {
192
+ summaryKey: 'pAsHeading_summary_cantTell_div',
193
+ hintKey: 'pAsHeading_hint_cantTell_div',
194
+ params: { fontSizePx: String(fontSizePx) }
195
+ },
196
+ data: {
197
+ details: { reasonCode: 'BOLD_LARGE_DIV', fontSizePx }
198
+ }
199
+ };
155
200
 
156
201
  if (helpers && typeof helpers.reportOccurrence === 'function') {
157
202
  occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
@@ -10,7 +10,9 @@
10
10
  * @sc 2.4.2
11
11
  * @applicability
12
12
  * Applies to a run over a whole document whose <title> resolves to
13
- * non-empty text; a missing or empty title is page-title-present's
13
+ * non-empty text. The title element is the first HTML <title> anywhere in
14
+ * the document, as for document.title, so one the parser left in <body>
15
+ * counts; a missing or empty title is page-title-present's
14
16
  * failure, not a pattern to review. A run narrowed by contextSelector or
15
17
  * by engineOptions.fragment is notApplicable, as is a title matching none
16
18
  * of the patterns below.
@@ -78,7 +80,24 @@ function runInPage(ctx) {
78
80
  const occurrences = [];
79
81
  let applicableCount = 1;
80
82
 
81
- const titleEl = document.querySelector('head > title');
83
+ // The document's title element, found as page-title-present finds it: the
84
+ // first HTML-namespace <title> anywhere in the document, since a <title>
85
+ // the parser leaves in <body> is still what document.title reads. An inline
86
+ // <svg><title> is not the page title.
87
+ const HTML_NS = 'http://www.w3.org/1999/xhtml';
88
+ let titleEl = null;
89
+ for (const t of Array.from(document.getElementsByTagName('title'))) {
90
+ if (!t.namespaceURI || t.namespaceURI === HTML_NS) {
91
+ titleEl = t;
92
+ break;
93
+ }
94
+ }
95
+ // Kept as the stable selector for the usual place; a <title> elsewhere gets
96
+ // the selector the engine builds for the node.
97
+ const titleSelector =
98
+ titleEl && titleEl.parentElement && titleEl.parentElement.localName === 'head'
99
+ ? 'head > title'
100
+ : undefined;
82
101
  const rawTitle = document.title || '';
83
102
  const titleText = rawTitle.replace(/\s+/g, ' ').trim();
84
103
  const titleLc = titleText.toLowerCase();
@@ -176,7 +195,7 @@ function runInPage(ctx) {
176
195
  };
177
196
 
178
197
  const occBase = {
179
- selector: 'head > title',
198
+ selector: titleSelector,
180
199
  html: '',
181
200
  summary:
182
201
  'The set of page titles may not be descriptive enough to distinguish pages by topic or purpose.',
@@ -244,18 +263,68 @@ function runInPage(ctx) {
244
263
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
245
264
  }
246
265
 
247
- const GENERIC_TITLES = new Set(['home', 'homepage', 'welcome', 'untitled', 'page', 'document']);
266
+ const GENERIC_TITLES = {
267
+ en: new Set(['home', 'homepage', 'welcome', 'untitled', 'page', 'document']),
268
+ de: new Set(['startseite', 'willkommen', 'unbenannt', 'ohne titel', 'seite', 'dokument']),
269
+ es: new Set([
270
+ 'inicio',
271
+ 'página de inicio',
272
+ 'bienvenido',
273
+ 'bienvenida',
274
+ 'sin título',
275
+ 'página',
276
+ 'documento'
277
+ ]),
278
+ fr: new Set(['accueil', "page d'accueil", 'bienvenue', 'sans titre', 'page', 'document']),
279
+ ja: new Set(['ホーム', 'トップページ', 'トップ', 'ようこそ', '無題', 'ページ', 'ドキュメント'])
280
+ };
281
+
282
+ // The page-specific half of "Brand | Home" in languages other than
283
+ // English. English keeps its word-boundary patterns below.
284
+ const TEMPLATE_TOKENS = {
285
+ de: ['startseite', 'willkommen'],
286
+ es: ['inicio', 'página de inicio', 'bienvenido', 'bienvenida'],
287
+ fr: ['accueil', "page d'accueil", 'bienvenue'],
288
+ ja: ['ホーム', 'トップページ', 'トップ', 'ようこそ']
289
+ };
290
+
291
+ const htmlEl = document.documentElement;
292
+ const pageLang =
293
+ htmlEl && htmlEl.getAttribute && htmlEl.getAttribute('lang')
294
+ ? htmlEl.getAttribute('lang').trim().split('-')[0].toLowerCase()
295
+ : '';
296
+ const titleNorm = titleLc.normalize('NFKC').replace(/[\u2018\u2019]/g, "'");
297
+
298
+ // Chinese, Japanese and Korean characters each carry roughly a word, so
299
+ // they count double: 「お問い合わせ」 is a full title in six characters.
300
+ function effectiveLength(s) {
301
+ let n = 0;
302
+ for (const ch of s)
303
+ n += /[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af\uf900-\ufaff]/.test(ch) ? 2 : 1;
304
+ return n;
305
+ }
248
306
 
249
307
  // Conservative signals:
250
308
  // - very short title (likely non-descriptive)
251
309
  // - title is one of a small set of generic titles
252
- const isVeryShort = titleText.length > 0 && titleText.length < 8;
253
- const isGeneric = GENERIC_TITLES.has(titleLc);
310
+ const isVeryShort = titleText.length > 0 && effectiveLength(titleText) < 8;
311
+ const isGeneric =
312
+ GENERIC_TITLES.en.has(titleNorm) ||
313
+ !!(pageLang && GENERIC_TITLES[pageLang] && GENERIC_TITLES[pageLang].has(titleNorm));
314
+
315
+ function isLocalTemplate(title) {
316
+ const tokens = TEMPLATE_TOKENS[pageLang];
317
+ if (!tokens) return false;
318
+ const parts = title.split(/\s*(?:\||-|—|:)\s*/).filter(Boolean);
319
+ if (parts.length < 2) return false;
320
+ return tokens.includes(parts[0]) || tokens.includes(parts[parts.length - 1]);
321
+ }
254
322
 
255
323
  // Template-like: "Brand | Home" or "Home - Brand" where the page-specific part is a generic token.
256
324
  const templateLike =
257
325
  /\b(home|homepage|welcome)\b\s*(\||-|—|:)\s*.+/i.test(titleText) ||
258
- /.+\s*(\||-|—|:)\s*\b(home|homepage|welcome)\b/i.test(titleText);
326
+ /.+\s*(\||-|—|:)\s*\b(home|homepage|welcome)\b/i.test(titleText) ||
327
+ isLocalTemplate(titleNorm);
259
328
 
260
329
  if (isGeneric || isVeryShort || templateLike) {
261
330
  const reasonCode = isGeneric
@@ -271,7 +340,7 @@ function runInPage(ctx) {
271
340
  ? 'pageTitlePatterns_summary_cantTell_veryShort'
272
341
  : 'pageTitlePatterns_summary_cantTell_templateLike';
273
342
  const occBase = {
274
- selector: 'head > title',
343
+ selector: titleSelector,
275
344
  html: '',
276
345
  summary:
277
346
  'The page title may not be descriptive enough to identify the page topic or purpose.',
@@ -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',