@surea11y/core 1.5.0 → 1.6.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 (145) hide show
  1. package/CHANGELOG.md +193 -149
  2. package/README.md +27 -6
  3. package/docs/ACT_RULE_MAPPING.md +243 -0
  4. package/docs/API_STABILITY.md +2 -2
  5. package/docs/BINDING_AUTHORS_GUIDE.md +3 -3
  6. package/docs/DESIGN_CHALLENGES.md +301 -0
  7. package/docs/ENGINE_OPTIONS.md +16 -4
  8. package/docs/I18N.md +4 -4
  9. package/docs/INTEGRATION.md +1 -1
  10. package/docs/LIMITATIONS.md +6 -4
  11. package/docs/REPORT.md +1 -1
  12. package/docs/RULE_AUTHORING.md +53 -25
  13. package/docs/RULE_CATALOG.md +1878 -169
  14. package/docs/RULE_TAXONOMY.md +2 -2
  15. package/docs/TROUBLESHOOTING.md +2 -2
  16. package/docs/WCAG_CONFORMANCE.md +25 -9
  17. package/package.json +3 -7
  18. package/src/baseline.js +3 -3
  19. package/src/checks/automatic/area-alt-present.js +2 -2
  20. package/src/checks/automatic/aria-allowed-attr.js +68 -10
  21. package/src/checks/automatic/aria-allowed-role.js +2 -2
  22. package/src/checks/automatic/aria-braille-equivalent.js +3 -3
  23. package/src/checks/automatic/aria-conditional-attr.js +5 -5
  24. package/src/checks/automatic/aria-deprecated-role.js +1 -1
  25. package/src/checks/automatic/aria-hidden-body.js +2 -2
  26. package/src/checks/automatic/aria-hidden-focus.js +5 -5
  27. package/src/checks/automatic/aria-prohibited-attr.js +18 -18
  28. package/src/checks/automatic/aria-prohibited-children.js +130 -37
  29. package/src/checks/automatic/aria-required-attr.js +60 -12
  30. package/src/checks/automatic/aria-required-children.js +21 -14
  31. package/src/checks/automatic/aria-required-parent.js +61 -9
  32. package/src/checks/automatic/aria-role-name-present.js +36 -22
  33. package/src/checks/automatic/aria-valid-attr-value.js +15 -12
  34. package/src/checks/automatic/aria-valid-attr.js +1 -1
  35. package/src/checks/automatic/autocomplete-valid.js +2 -2
  36. package/src/checks/automatic/binary-control-name-present.js +27 -5
  37. package/src/checks/automatic/button-name-present.js +92 -6
  38. package/src/checks/automatic/combobox-name-present.js +26 -6
  39. package/src/checks/automatic/contrast-computable.js +32 -0
  40. package/src/checks/automatic/contrast-enhanced.js +21 -1
  41. package/src/checks/automatic/contrast-minimum.js +21 -1
  42. package/src/checks/automatic/css-orientation-lock.js +96 -19
  43. package/src/checks/automatic/definition-list-children-valid.js +7 -8
  44. package/src/checks/automatic/deprecated-elements-not-used.js +1 -1
  45. package/src/checks/automatic/dialog-name-present.js +20 -2
  46. package/src/checks/automatic/duplicate-id-aria.js +5 -3
  47. package/src/checks/automatic/duplicate-id.js +198 -0
  48. package/src/checks/automatic/embed-text-alternative-present.js +2 -2
  49. package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
  50. package/src/checks/automatic/form-control-single-label.js +1 -1
  51. package/src/checks/automatic/iframe-focusable-content.js +63 -7
  52. package/src/checks/automatic/iframe-name-present.js +37 -3
  53. package/src/checks/automatic/iframe-title-unique.js +1 -1
  54. package/src/checks/automatic/img-alt-present.js +12 -4
  55. package/src/checks/automatic/label-in-name.js +172 -18
  56. package/src/checks/automatic/link-in-text-block.js +10 -10
  57. package/src/checks/automatic/link-name-present.js +22 -1
  58. package/src/checks/automatic/list-children-valid.js +6 -6
  59. package/src/checks/automatic/listbox-name-present.js +28 -8
  60. package/src/checks/automatic/listitem-parent-valid.js +4 -4
  61. package/src/checks/automatic/menuitem-name-present.js +20 -2
  62. package/src/checks/automatic/meta-refresh-no-exceptions.js +25 -14
  63. package/src/checks/automatic/meta-refresh-timing-absent.js +14 -7
  64. package/src/checks/automatic/meter-name-present.js +23 -4
  65. package/src/checks/automatic/nested-interactive-controls-absent.js +5 -5
  66. package/src/checks/automatic/option-name-present.js +23 -4
  67. package/src/checks/automatic/page-title-present.js +21 -3
  68. package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
  69. package/src/checks/automatic/progressbar-name-present.js +23 -4
  70. package/src/checks/automatic/role-img-alt-present.js +64 -16
  71. package/src/checks/automatic/searchbox-name-present.js +28 -8
  72. package/src/checks/automatic/server-side-image-map-absent.js +1 -1
  73. package/src/checks/automatic/slider-name-present.js +27 -6
  74. package/src/checks/automatic/spinbutton-name-present.js +28 -8
  75. package/src/checks/automatic/summary-name-present.js +18 -2
  76. package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
  77. package/src/checks/automatic/svg-text-alternative-present.js +13 -10
  78. package/src/checks/automatic/tab-name-present.js +21 -2
  79. package/src/checks/automatic/table-headers-attr-valid.js +43 -8
  80. package/src/checks/automatic/table-th-has-data-cells.js +61 -5
  81. package/src/checks/automatic/target-size-minimum.js +71 -53
  82. package/src/checks/automatic/td-has-header.js +5 -5
  83. package/src/checks/automatic/textbox-name-present.js +28 -8
  84. package/src/checks/automatic/tooltip-name-present.js +21 -2
  85. package/src/checks/automatic/treeitem-name-present.js +23 -4
  86. package/src/checks/automatic/valid-lang.js +92 -7
  87. package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
  88. package/src/checks/manual/accesskeys-manual.js +3 -3
  89. package/src/checks/manual/area-alt-decorative-manual.js +7 -0
  90. package/src/checks/manual/area-alt-quality-manual.js +6 -0
  91. package/src/checks/manual/aria-checked-state-mismatch-manual.js +5 -5
  92. package/src/checks/manual/aria-text-manual.js +4 -4
  93. package/src/checks/manual/bypass-blocks-present-manual.js +44 -26
  94. package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
  95. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
  96. package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
  97. package/src/checks/manual/empty-heading-manual.js +58 -11
  98. package/src/checks/manual/empty-table-header-manual.js +8 -8
  99. package/src/checks/manual/focus-order-semantics-manual.js +15 -15
  100. package/src/checks/manual/form-control-label-quality-manual.js +453 -0
  101. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -2
  102. package/src/checks/manual/heading-order-manual.js +3 -3
  103. package/src/checks/manual/heading-quality-manual.js +338 -0
  104. package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
  105. package/src/checks/manual/image-redundant-alt-manual.js +4 -4
  106. package/src/checks/manual/img-alt-decorative-manual.js +211 -52
  107. package/src/checks/manual/img-alt-quality-manual.js +7 -0
  108. package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
  109. package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
  110. package/src/checks/manual/label-title-only-manual.js +4 -4
  111. package/src/checks/manual/landmark-banner-is-top-level-manual.js +7 -7
  112. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +9 -9
  113. package/src/checks/manual/landmark-main-is-top-level-manual.js +6 -6
  114. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +6 -6
  115. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +6 -6
  116. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -2
  117. package/src/checks/manual/landmark-one-main-manual.js +6 -6
  118. package/src/checks/manual/landmark-unique-manual.js +9 -9
  119. package/src/checks/manual/link-name-quality-manual.js +161 -32
  120. package/src/checks/manual/media-transcript-present-manual.js +2 -3
  121. package/src/checks/manual/meta-viewport-large-manual.js +2 -2
  122. package/src/checks/manual/mouse-only-event-handlers-manual.js +9 -9
  123. package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
  124. package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
  125. package/src/checks/manual/p-as-heading-manual.js +4 -4
  126. package/src/checks/manual/page-has-heading-one-manual.js +6 -6
  127. package/src/checks/manual/page-title-patterns-manual.js +26 -3
  128. package/src/checks/manual/presentation-role-conflict-manual.js +55 -25
  129. package/src/checks/manual/region-manual.js +19 -19
  130. package/src/checks/manual/scope-attr-valid-manual.js +2 -2
  131. package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
  132. package/src/checks/manual/skip-link-manual.js +5 -5
  133. package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
  134. package/src/checks/manual/tabindex-manual.js +2 -2
  135. package/src/checks/manual/table-duplicate-name-manual.js +2 -2
  136. package/src/checks/manual/table-fake-caption-manual.js +2 -2
  137. package/src/checks/manual/video-caption-manual.js +3 -3
  138. package/src/checks/manual-review.js +17 -1
  139. package/src/core.js +8965 -1647
  140. package/src/report.js +2 -2
  141. package/surea11y.browser.js +3768 -611
  142. package/surea11y.i18n.de.js +1 -1
  143. package/surea11y.i18n.es.js +1 -1
  144. package/surea11y.i18n.fr.js +1 -1
  145. package/bin/surea11y-core.js +0 -20
@@ -10,17 +10,34 @@
10
10
  * @sc 4.1.2
11
11
  * @applicability
12
12
  * Applies to <iframe>/<frame> elements that are eligible for the
13
- * accessibility tree (isAccTreeEligible).
13
+ * accessibility tree (isAccTreeEligible) AND reachable by sequential
14
+ * focus navigation, both conditions required, regardless of role.
14
15
  * @expectation
15
16
  * The element has a non-empty accessible name via aria-labelledby,
16
17
  * aria-label, or the title attribute. Unlike most interactive elements,
17
18
  * an iframe's name is never derived from its rendered content (the
18
- * embedded document is a separate browsing context) — this mirrors
19
+ * embedded document is a separate browsing context), this mirrors
19
20
  * dialog-name-present's "name-from-author-only" reasoning.
20
21
  * @implementation-notes
21
22
  * - Uses helpers.getAccessibleNameInfo, which already stops at
22
23
  * aria-label/aria-labelledby/label[for]/title without falling back to
23
- * subtree text content — the right shape for this element.
24
+ * subtree text content, the right shape for this element.
25
+ * - Per ACT cae760's own Applicability text, an iframe is only in scope
26
+ * when it is BOTH accessibility-tree-eligible AND reachable by
27
+ * sequential focus navigation, unconditionally, not only as a
28
+ * role="none"/"presentation" exception. Unlike most elements,
29
+ * <iframe>/<frame> are natively focusable by default (no tabindex
30
+ * needed), so `tabindex="-1"` (or any negative tabindex) exempts a
31
+ * frame regardless of whether it also carries a role, a plain
32
+ * `<iframe tabindex="-1">` with no role at all is just as out of scope
33
+ * as `<iframe role="none" tabindex="-1">`; ACT cae760's own passed
34
+ * example is the roleless case. (The role="none" reasoning, "because
35
+ * iframe elements are part of sequential focus navigation, the
36
+ * explicit semantic role of none will be ignored, due to Presentational
37
+ * Roles Conflict Resolution", is *why* a focusable role="none" iframe
38
+ * still needs a name, not a separate applicability path of its own.)
39
+ * helpers.getFocusableInfo doesn't model iframe/frame's native
40
+ * focusability at all, so it's computed locally here instead.
24
41
  */
25
42
 
26
43
  const id = 'iframe-name-present';
@@ -62,9 +79,26 @@ function runInPage(ctx) {
62
79
  const occurrences = [];
63
80
  let applicableCount = 0;
64
81
 
82
+ // <iframe>/<frame> are natively focusable by default (no tabindex
83
+ // needed), unlike most elements, only an explicit negative tabindex
84
+ // removes them from the tab order.
85
+ function isFrameFocusable(el) {
86
+ try {
87
+ const tabindexRaw = el.getAttribute ? el.getAttribute('tabindex') : null;
88
+ if (tabindexRaw == null) return true;
89
+ const n = Number(String(tabindexRaw).trim());
90
+ if (Number.isFinite(n) && n < 0) return false;
91
+ return true;
92
+ } catch {
93
+ return true;
94
+ }
95
+ }
96
+
65
97
  for (const el of nodes) {
66
98
  if (!el || !el.tagName) continue;
67
99
 
100
+ if (!isFrameFocusable(el)) continue;
101
+
68
102
  if (helpers.isAccTreeEligible) {
69
103
  const elig = helpers.isAccTreeEligible(el);
70
104
  const isEligible = typeof elig === 'boolean' ? elig : !!(elig && elig.eligible);
@@ -13,7 +13,7 @@
13
13
  * attribute.
14
14
  * @expectation
15
15
  * No two frames in scope share the same (trimmed, case-sensitive) title
16
- * attribute value — a duplicate title prevents assistive technology
16
+ * attribute value, a duplicate title prevents assistive technology
17
17
  * users from telling frames apart when scanning by name.
18
18
  * @implementation-notes
19
19
  * - Distinct, atomic decision from iframe-name-present (presence):
@@ -157,13 +157,21 @@ function runInPage(ctx) {
157
157
  // From here: applicable
158
158
  applicableCount += 1;
159
159
 
160
- // alt attribute presence check (empty allowed)
161
- let hasAlt;
160
+ // alt attribute presence check. A literally empty alt ("") is the
161
+ // HTML decorative marker and always satisfies this check. A present
162
+ // alt that is non-empty but trims to nothing (a lone space, a tab)
163
+ // does not get that treatment: per HTML-AAM the img-role conflict-
164
+ // resolution flip to presentation only triggers on the literal empty
165
+ // string, so the element keeps its img role while its computed
166
+ // accessible name is empty -- a real failure, not a decorative image.
167
+ let rawAlt;
162
168
  try {
163
- hasAlt = el.getAttribute('alt') !== null;
169
+ rawAlt = el.getAttribute('alt');
164
170
  } catch {
165
- hasAlt = false;
171
+ rawAlt = null;
166
172
  }
173
+ const isWhitespaceOnlyAlt = rawAlt !== null && rawAlt !== '' && trim(rawAlt) === '';
174
+ const hasAlt = rawAlt !== null && !isWhitespaceOnlyAlt;
167
175
  if (hasAlt) continue;
168
176
 
169
177
  // aria-label / aria-labelledby is also a valid, standards-recognized
@@ -2,12 +2,45 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check label-in-name
7
+ * @atomic true
8
+ * @summary A control's accessible name must contain its visible label text
9
+ * @standard WCAG 2.2
10
+ * @sc 2.5.3
11
+ * @applicability
12
+ * Applies to controls that carry aria-label or aria-labelledby, are
13
+ * visually rendered, and have visible label text this engine can extract
14
+ * deterministically, from an associated <label>, from the control's own
15
+ * rendered text, or from the elements aria-labelledby points at. The
16
+ * candidates are <button>, <a href>, <summary>, non-hidden form controls,
17
+ * and the button, link, checkbox, radio, switch, searchbox, tab, menuitem,
18
+ * menuitemcheckbox, menuitemradio, option, treeitem and gridcell roles,
19
+ * minus anything hidden or disabled. aria-hidden is not
20
+ * excluded: it changes nothing about what is rendered on screen, which is
21
+ * what this SC is about.
22
+ * @expectation
23
+ * The accessible name contains the visible label's words, adjacent and in
24
+ * order. The comparison is over words rather than characters:
25
+ * parenthesised text is dropped, case is folded, text is NFKD-normalised,
26
+ * and every non-letter/digit becomes a separator, so punctuation and
27
+ * spacing differences never decide the outcome. Four shapes markup cannot
28
+ * settle are reported as cantTell instead of fail: a word hyphenated
29
+ * differently in the two places; a visible word the author may have
30
+ * abbreviated, marked by its trailing period; visible text rendered
31
+ * through a known icon font (the DOM text is real words, but nothing
32
+ * readable actually renders); and a whole visible label of exactly one
33
+ * character that doesn't even appear inside the accessible name, which
34
+ * per ACT 2ee8b8 may be "non-text content" (e.g. "X" meaning "close")
35
+ * rather than literal text.
36
+ */
37
+
5
38
  const id = 'label-in-name';
6
39
 
7
40
  const meta = {
8
41
  title: 'Label in Name: accessible name contains visible text',
9
42
  description:
10
- 'Checks that when a control has a visible text label, the accessible name (from aria-label/aria-labelledby) contains that visible label text (WCAG 2.5.3).',
43
+ 'Checks that when a control has a visible text label, the accessible name contains that visible label text (WCAG 2.5.3).',
11
44
  i18n: {
12
45
  titleKey: 'labelInName_title',
13
46
  descriptionKey: 'labelInName_description'
@@ -147,7 +180,7 @@ function runInPage(ctx) {
147
180
  // rendering as "format_color_fill") is DOM-visible pixels but is never
148
181
  // perceived as literal readable words the way real visible text is.
149
182
  // Excluding aria-hidden content is a cheap static-markup signal that gets
150
- // the common case (decorative icon fonts) right — an icon-only button
183
+ // the common case (decorative icon fonts) right. An icon-only button
151
184
  // named via aria-label shouldn't have its glyph name counted as text.
152
185
  function isAccEligible(el) {
153
186
  if (!el) return false;
@@ -273,7 +306,9 @@ function runInPage(ctx) {
273
306
  }
274
307
 
275
308
  function getVisibleTextLabelInfo(el) {
276
- // Returns { text, source } where source helps reporting.
309
+ // Returns { text, source, sourceElements } where source helps reporting
310
+ // and sourceElements (the actual DOM nodes the visible text came from)
311
+ // lets the icon-font check below look at the right element's own style.
277
312
  // Source policy (deterministic):
278
313
  // 1) <label> association for form controls
279
314
  // 2) visible text inside the element
@@ -288,18 +323,22 @@ function runInPage(ctx) {
288
323
  if (isFormControl) {
289
324
  const labels = getAssociatedLabelElements(el);
290
325
  const labelParts = [];
326
+ const contributing = [];
291
327
  for (const l of labels) {
292
328
  if (!l || !isDomVisible(l)) continue;
293
329
  const t = collectVisibleTextUnder(l);
294
- if (t) labelParts.push(t);
330
+ if (t) {
331
+ labelParts.push(t);
332
+ contributing.push(l);
333
+ }
295
334
  }
296
335
  const joined = labelParts.join(' ').replace(/\s+/g, ' ').trim();
297
- if (joined) return { text: joined, source: 'label' };
336
+ if (joined) return { text: joined, source: 'label', sourceElements: contributing };
298
337
  }
299
338
 
300
339
  // 2) Text inside the control itself
301
340
  text = collectVisibleTextUnder(el);
302
- if (text) return { text, source: 'self' };
341
+ if (text) return { text, source: 'self', sourceElements: [el] };
303
342
 
304
343
  // 3) aria-labelledby referenced visible text (only if refs exist and are visible)
305
344
  try {
@@ -307,20 +346,115 @@ function runInPage(ctx) {
307
346
  if (idrefs && helpers.resolveIdRefs) {
308
347
  const r = helpers.resolveIdRefs(idrefs, ctx, { maxRefs: 8 });
309
348
  const parts = [];
349
+ const contributing = [];
310
350
  for (const ref of r && Array.isArray(r.refs) ? r.refs : []) {
311
351
  if (!ref || !ref.tagName) continue;
312
352
  if (!isDomVisible(ref)) continue;
313
353
  const t = collectVisibleTextUnder(ref);
314
- if (t) parts.push(t);
354
+ if (t) {
355
+ parts.push(t);
356
+ contributing.push(ref);
357
+ }
315
358
  }
316
359
  const joined = parts.join(' ').replace(/\s+/g, ' ').trim();
317
- if (joined) return { text: joined, source: 'aria-labelledby' };
360
+ if (joined)
361
+ return { text: joined, source: 'aria-labelledby', sourceElements: contributing };
318
362
  }
319
363
  } catch {
320
364
  // ignore
321
365
  }
322
366
 
323
- return { text: '', source };
367
+ return { text: '', source, sourceElements: [] };
368
+ }
369
+
370
+ // Curated real-world icon-font family names. These fonts remap ordinary
371
+ // word glyphs to unrelated symbols via ligatures/PUA codepoints, so the
372
+ // DOM text is real words but nothing readable actually renders, ACT
373
+ // 2ee8b8's own passed example is exactly this (a button's DOM text
374
+ // "search" rendered as a magnifying-glass icon by "Material Icons").
375
+ // Same curated-list tradeoff as link-name-quality's phrase list.
376
+ const ICON_FONT_FAMILIES = new Set([
377
+ 'material icons',
378
+ 'material icons outlined',
379
+ 'material icons round',
380
+ 'material icons sharp',
381
+ 'material icons two tone',
382
+ 'material symbols outlined',
383
+ 'material symbols rounded',
384
+ 'material symbols sharp',
385
+ 'font awesome 5 free',
386
+ 'font awesome 5 brands',
387
+ 'font awesome 5 pro',
388
+ 'font awesome 6 free',
389
+ 'font awesome 6 brands',
390
+ 'font awesome 6 pro',
391
+ 'fontawesome',
392
+ 'glyphicons halflings',
393
+ 'ionicons',
394
+ 'icomoon',
395
+ 'bootstrap-icons',
396
+ 'bootstrap icons',
397
+ 'feather'
398
+ ]);
399
+
400
+ // Same two-tier lookup as avoid-inline-spacing.js's computedStyleOf:
401
+ // helpers.computedStyle isn't actually part of the public helpers API
402
+ // (it's internal to dom-helpers.js), so the realm's own getComputedStyle
403
+ // is what actually resolves a stylesheet-declared font-family.
404
+ function computedStyleOf(node) {
405
+ if (helpers && typeof helpers.computedStyle === 'function') {
406
+ try {
407
+ const cs = helpers.computedStyle(node);
408
+ if (cs) return cs;
409
+ } catch {
410
+ // fall through to the realm's own view
411
+ }
412
+ }
413
+ try {
414
+ const view = node.ownerDocument && node.ownerDocument.defaultView;
415
+ if (view && typeof view.getComputedStyle === 'function') return view.getComputedStyle(node);
416
+ } catch {
417
+ // no computed style available
418
+ }
419
+ return null;
420
+ }
421
+
422
+ function isIconFontElement(node) {
423
+ if (!node) return false;
424
+ const cs = computedStyleOf(node);
425
+ if (!cs) return false;
426
+ let family;
427
+ try {
428
+ family =
429
+ cs.fontFamily || (cs.getPropertyValue ? cs.getPropertyValue('font-family') : '') || '';
430
+ } catch {
431
+ family = '';
432
+ }
433
+ if (!family) return false;
434
+ const names = String(family)
435
+ .split(',')
436
+ .map((s) =>
437
+ s
438
+ .trim()
439
+ .replace(/^['"]|['"]$/g, '')
440
+ .toLowerCase()
441
+ );
442
+ return names.some((n) => ICON_FONT_FAMILIES.has(n));
443
+ }
444
+
445
+ // ACT 2ee8b8's own passed example `<button aria-label="close">X</button>`:
446
+ // a single character standing in for an icon ("x" meaning "close") is
447
+ // "non-text content" per the rule's own background text, which names no
448
+ // algorithmic test for it. Scoped narrowly to a whole visible label of
449
+ // exactly one character that doesn't even appear inside the accessible
450
+ // name: "x" has no relation at all to "close", which is the icon-glyph
451
+ // shape. A single character that DOES appear in the name (e.g. visible
452
+ // "1" against aria-label "1a") is a real word-boundary mismatch, not a
453
+ // symbol standing in for something else, and still fails outright.
454
+ function isSingleSymbolicCharacter(text, accessibleNameNorm) {
455
+ const t = norm(text);
456
+ if (!t || Array.from(t).length !== 1) return false;
457
+ return accessibleNameNorm.indexOf(t) === -1;
324
458
  }
325
459
 
326
460
  for (const el of nodes) {
@@ -351,20 +485,30 @@ function runInPage(ctx) {
351
485
  const nameTokens = tokenize(accName, false);
352
486
  const contains = containsWordRun(labelTokens, nameTokens, null);
353
487
 
354
- // An abbreviation, or a word hyphenated differently in the two places, is
355
- // not something markup settles: the author may have meant either. Report
356
- // without asserting a defect instead of failing or staying silent.
488
+ // An abbreviation, a word hyphenated differently, or visible text that
489
+ // may not be literal text at all (an icon-font glyph, a single symbolic
490
+ // character) is not something markup settles: the author may have meant
491
+ // either. Report without asserting a defect instead of failing or
492
+ // staying silent.
357
493
  let uncertainty = '';
358
494
  if (!contains) {
359
495
  if (containsWordRun(tokenize(visibleLabel, true), tokenize(accName, true), null)) {
360
496
  uncertainty = 'HYPHENATION_DIFFERS';
361
497
  } else {
362
498
  const abbreviated = abbreviatedWords(visibleLabel);
363
- if (abbreviated.size && containsWordRun(labelTokens, nameTokens, abbreviated))
499
+ if (abbreviated.size && containsWordRun(labelTokens, nameTokens, abbreviated)) {
364
500
  uncertainty = 'POSSIBLE_ABBREVIATION';
501
+ } else if ((labelInfo.sourceElements || []).some(isIconFontElement)) {
502
+ uncertainty = 'POSSIBLE_ICON_FONT_GLYPH';
503
+ } else if (isSingleSymbolicCharacter(visibleLabel, accNorm)) {
504
+ uncertainty = 'POSSIBLE_SYMBOLIC_CHARACTER';
505
+ }
365
506
  }
366
507
  }
367
508
 
509
+ const isSymbolicUncertainty =
510
+ uncertainty === 'POSSIBLE_ICON_FONT_GLYPH' || uncertainty === 'POSSIBLE_SYMBOLIC_CHARACTER';
511
+
368
512
  if (!contains) {
369
513
  occurrences.push(
370
514
  helpers.reportOccurrence(el, {
@@ -372,12 +516,22 @@ function runInPage(ctx) {
372
516
  summary: uncertainty
373
517
  ? 'Accessible name may not contain the visible label text.'
374
518
  : 'Accessible name does not contain the visible label text.',
375
- hint: uncertainty
376
- ? 'Check by hand: the two differ only by an abbreviation or by hyphenation, which markup cannot settle.'
377
- : 'Ensure the accessible name includes the visible text label (e.g., update aria-label/aria-labelledby to include the visible wording).',
519
+ hint: !uncertainty
520
+ ? 'Ensure the accessible name includes the visible text label (e.g., update aria-label/aria-labelledby to include the visible wording).'
521
+ : isSymbolicUncertainty
522
+ ? 'Check by hand: the visible text may render as an icon or symbol rather than literal words, which markup cannot settle.'
523
+ : 'Check by hand: the two differ only by an abbreviation or by hyphenation, which markup cannot settle.',
378
524
  i18n: {
379
- summaryKey: uncertainty ? 'labelInName_summary_cantTell' : 'labelInName_summary_fail',
380
- hintKey: uncertainty ? 'labelInName_hint_cantTell' : 'labelInName_hint_fail',
525
+ summaryKey: !uncertainty
526
+ ? 'labelInName_summary_fail'
527
+ : isSymbolicUncertainty
528
+ ? 'labelInName_summary_cantTell_symbolic'
529
+ : 'labelInName_summary_cantTell',
530
+ hintKey: !uncertainty
531
+ ? 'labelInName_hint_fail'
532
+ : isSymbolicUncertainty
533
+ ? 'labelInName_hint_cantTell_symbolic'
534
+ : 'labelInName_hint_cantTell',
381
535
  params: {
382
536
  element: getElementDescriptor(el),
383
537
  visibleLabel: clipForSummary(visibleLabel),
@@ -12,7 +12,7 @@
12
12
  * Applies to <a href> elements whose immediate parent element also has
13
13
  * at least one direct-child text node with non-whitespace content
14
14
  * (i.e. the link sits inline within a run of plain text, not as a
15
- * standalone item — e.g. not the sole content of a <li> nav item).
15
+ * standalone item, e.g. not the sole content of a <li> nav item).
16
16
  * @expectation
17
17
  * A link inside a text block must be visually distinguishable from the
18
18
  * surrounding text by at least one non-color means:
@@ -20,29 +20,29 @@
20
20
  * - a different font-weight than the surrounding text, OR
21
21
  * - a different font-style than the surrounding text, OR
22
22
  * - a contrast ratio of at least 3:1 between the link's text color and
23
- * the surrounding text's color (WCAG technique G183's threshold —
23
+ * the surrounding text's color (WCAG technique G183's threshold,
24
24
  * sufficient contrast alone is an accepted alternative to underline).
25
25
  * Fails only when none of the above hold AND the color contrast between
26
- * link and surrounding text is confidently computable and below 3:1 —
26
+ * link and surrounding text is confidently computable and below 3:1,
27
27
  * i.e. color is demonstrably the only cue.
28
28
  * @implementation-notes
29
29
  * - "Surrounding text style" is approximated as the link's immediate
30
30
  * parent element's own computed style, not a full inline-context walk
31
- * of the actual adjacent text node(s) — a deliberate scope-down, since
31
+ * of the actual adjacent text node(s), a deliberate scope-down, since
32
32
  * plain text nodes inherit their rendering from the parent in the
33
33
  * overwhelming majority of real markup.
34
34
  * - When contrast is not confidently computable (background image/
35
- * gradient, blend mode, filter, non-opaque ancestor — same blockers
35
+ * gradient, blend mode, filter, non-opaque ancestor, same blockers
36
36
  * `contrast-minimum`/`contrast-computable` use), the link is silently
37
37
  * skipped rather than flagged or reported as cantTell, to keep `fail`
38
38
  * reserved for deterministic, high-confidence violations. This means
39
- * the rule never emits cantTell — outcome is notApplicable/pass/fail
39
+ * the rule never emits cantTell, outcome is notApplicable/pass/fail
40
40
  * only, matching this repo's other Tier 2 mechanical rules.
41
41
  * - Reuses the shared `helpers.contrast` subsystem (same
42
42
  * computeEffectiveForeground/Background, getComputabilityBlocker,
43
43
  * contrastRatio helpers as `contrast-minimum`), rather than re-deriving
44
44
  * color math independently.
45
- * - Scoped to `a[href]` only (not `area[href]` or `[role="link"]`) —
45
+ * - Scoped to `a[href]` only (not `area[href]` or `[role="link"]`),
46
46
  * matches the common real-world shape of this issue (prose links).
47
47
  */
48
48
 
@@ -52,7 +52,7 @@ const meta = {
52
52
  title:
53
53
  'Links in text blocks must be distinguishable from surrounding text without relying on color alone',
54
54
  description:
55
- 'Checks that a link inside a run of text is visually distinguishable from the surrounding text by underline, a font-weight/style difference, or a sufficient (>=3:1) color-contrast difference — not by color alone.',
55
+ 'Checks that a link inside a run of text is visually distinguishable from the surrounding text by underline, a font-weight/style difference, or a sufficient (>=3:1) color-contrast difference, not by color alone.',
56
56
  i18n: {
57
57
  titleKey: 'linkInTextBlock_title',
58
58
  descriptionKey: 'linkInTextBlock_description'
@@ -179,7 +179,7 @@ function runInPage(ctx) {
179
179
  try {
180
180
  const blocker = c.getComputabilityBlocker(el);
181
181
  if (blocker && blocker.ok === false) {
182
- // Not confidently computable — skip (benefit of the doubt).
182
+ // Not confidently computable, skip (benefit of the doubt).
183
183
  } else {
184
184
  const bg = c.computeEffectiveBackground(el, {
185
185
  contrast: { mode, rootCanvasFallback },
@@ -204,7 +204,7 @@ function runInPage(ctx) {
204
204
 
205
205
  if (!(ratio >= 3)) flagged = true;
206
206
  }
207
- // else: not confidently computable — skip.
207
+ // else: not confidently computable, skip.
208
208
  }
209
209
  } catch {
210
210
  // no-throw: treat as not computable, skip.
@@ -2,6 +2,27 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check link-name-present
7
+ * @atomic true
8
+ * @summary Links must have an accessible name
9
+ * @standard WCAG 2.2
10
+ * @sc 4.1.2
11
+ * @applicability
12
+ * Applies to <a href>, <area href> and elements with role="link" that are
13
+ * included in the accessibility tree. An <a> without an href is not a link
14
+ * and is not matched.
15
+ * @expectation
16
+ * The element has a non-empty accessible name. A programmatic name is
17
+ * taken first (aria-labelledby, aria-label, an associated <label>, title),
18
+ * and failing that the element falls back to its own subtree text,
19
+ * counting each descendant's own name (an <img alt>, aria-label or title),
20
+ * the shape behind the common <a><img alt="..."></a> logo link. The
21
+ * content fallback is suppressed when an explicit, known role that is not
22
+ * name-from-content is present; an unrecognized role token falls back to
23
+ * the implicit role.
24
+ */
25
+
5
26
  const id = 'link-name-present';
6
27
 
7
28
  const meta = {
@@ -38,7 +59,7 @@ function runInPage(ctx) {
38
59
  let applicableCount = 0;
39
60
 
40
61
  function getConservativeSubtreeText(container) {
41
- // "Name from content" — recurses into descendants and uses each one's
62
+ // "Name from content", recurses into descendants and uses each one's
42
63
  // own accessible name (img alt, aria-label/aria-labelledby, title) when
43
64
  // it has one, not just literal text nodes. See getContentNameInfo's
44
65
  // header comment in src/core/dom-helpers.js for the full rationale
@@ -12,7 +12,7 @@
12
12
  * Applies to <ul>/<ol> elements that have at least one direct element
13
13
  * child.
14
14
  * @expectation
15
- * Every direct element child is <li>, <script>, or <template> — UNLESS it
15
+ * Every direct element child is <li>, <script>, or <template>. UNLESS it
16
16
  * has an explicit `role` attribute, in which case the explicit role wins
17
17
  * over the tag entirely: a child is valid iff that role is "listitem"
18
18
  * (so `<li role="presentation">`/`<li role="menuitem">` are invalid
@@ -20,19 +20,19 @@
20
20
  * given `role="listitem"` is valid). A wrapper <div> used for styling
21
21
  * (no role at all) still breaks list semantics the same as before.
22
22
  * @implementation-notes
23
- * - Checked via el.children, which already excludes text/comment nodes —
23
+ * - Checked via el.children, which already excludes text/comment nodes,
24
24
  * no whitespace-node filtering needed.
25
25
  * - Distinct, atomic decision from listitem-parent-valid (the
26
26
  * inverse relationship: does a given <li> have a valid parent).
27
27
  * - Direct children that are not exposed to the accessibility tree (e.g.
28
28
  * display:none, [hidden], aria-hidden="true") are excluded from
29
- * consideration entirely — an element not reachable by assistive
29
+ * consideration entirely. An element not reachable by assistive
30
30
  * technology can't break the list semantics a screen reader announces.
31
31
  * Common cases: a stray `<input type="hidden">` as a direct <ul> child
32
32
  * (UA-stylesheet display:none by spec), or `<span style="display:none">`
33
33
  * hydration markers interleaved with real `<li>`s.
34
34
  * - Explicit-role-overrides-tag: if a child has an explicit role, only
35
- * `['listitem']` is consulted — the tag name is never checked. Only
35
+ * `['listitem']` is consulted, the tag name is never checked. Only
36
36
  * without an explicit role does the tag name matter. Catches cases a
37
37
  * tag-only check misses: `<li role="none">` hosting a list's own
38
38
  * visually-hidden label, `<li role="menuitem">` menu items, or a real
@@ -71,7 +71,7 @@ const meta = {
71
71
  function runInPage(ctx) {
72
72
  const { helpers, rule } = ctx;
73
73
 
74
- // Declared inside runInPage — see scripts/build-core.js header
74
+ // Declared inside runInPage, see scripts/build-core.js header
75
75
  // ("runInPage MUST be self-contained").
76
76
  const ALLOWED_CHILD_TAGS = new Set(['li', 'script', 'template']);
77
77
 
@@ -111,7 +111,7 @@ function runInPage(ctx) {
111
111
  const roleAttr = child.getAttribute ? String(child.getAttribute('role') || '').trim() : '';
112
112
  const explicitRole = roleAttr ? (roleAttr.split(/\s+/)[0] || '').toLowerCase() : '';
113
113
 
114
- // An explicit role always wins over the tag — see header comment.
114
+ // An explicit role always wins over the tag, see header comment.
115
115
  const valid = explicitRole ? explicitRole === 'listitem' : ALLOWED_CHILD_TAGS.has(tag);
116
116
 
117
117
  if (!valid) invalidTags.push(tag);
@@ -2,11 +2,31 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check listbox-name-present
7
+ * @atomic true
8
+ * @summary Elements with role="listbox" must have an accessible name
9
+ * @standard WCAG 2.2
10
+ * @sc 4.1.2
11
+ * @applicability
12
+ * Applies to elements carrying role="listbox" (the attribute must name
13
+ * that role alone, not a fallback list) that are included in the
14
+ * accessibility tree. An element with the matching implicit role but no
15
+ * role attribute is out of scope.
16
+ * @expectation
17
+ * The element has a non-empty accessible name from aria-label, from an
18
+ * aria-labelledby that resolves to non-empty text, or from title.
19
+ * role="listbox" is name-from-author-only, so subtree text is never
20
+ * accepted: text sitting inside a custom listbox widget is not reliably
21
+ * exposed as its name. On a labelable element (<select multiple
22
+ * role="listbox">) an associated <label> counts as well.
23
+ */
24
+
5
25
  const id = 'listbox-name-present';
6
26
 
7
27
  const meta = {
8
- title: 'Accessible name is present',
9
- description: 'Checks that elements expose a non-empty accessible name.',
28
+ title: 'Listboxes have an accessible name',
29
+ description: 'Checks that elements with role="listbox" expose a non-empty accessible name.',
10
30
  i18n: {
11
31
  titleKey: 'listboxNamePresent_title',
12
32
  descriptionKey: 'listboxNamePresent_description'
@@ -51,7 +71,7 @@ function runInPage(ctx) {
51
71
  }
52
72
 
53
73
  function getConservativeSubtreeText(document, container) {
54
- // "Name from content" — recurses into descendants and uses each one's
74
+ // "Name from content", recurses into descendants and uses each one's
55
75
  // own accessible name (img alt, aria-label/aria-labelledby, title) when
56
76
  // it has one, not just literal text nodes. See getContentNameInfo's
57
77
  // header comment in src/core/dom-helpers.js for the full rationale
@@ -68,7 +88,7 @@ function runInPage(ctx) {
68
88
 
69
89
  // A <label> contributes a name via its own aria-label/aria-labelledby
70
90
  // (checked first, same ARIA-over-content precedence any element's
71
- // accessible name gives — e.g. <label aria-label="Search"><svg
91
+ // accessible name gives, e.g. <label aria-label="Search"><svg
72
92
  // aria-hidden="true">...</svg></label> names its control "Search" even
73
93
  // though the label's only child content is aria-hidden) or, failing
74
94
  // that, its rendered content (getConservativeSubtreeText).
@@ -83,10 +103,10 @@ function runInPage(ctx) {
83
103
  if (content) return content;
84
104
  // Final fallback per the general accname text-alternative algorithm,
85
105
  // which applies to any element being asked for its name regardless of
86
- // why (own aria-label, an aria-labelledby reference, or — here — native
106
+ // why (own aria-label, an aria-labelledby reference, or, here, native
87
107
  // <label for> association): title, when nothing else yields a name.
88
108
  // Purely additive (only fills in a name where there was none before),
89
- // so it carries no false-positive risk — see dialog-name-present.js's
109
+ // so it carries no false-positive risk, see dialog-name-present.js's
90
110
  // identical <iframe>-title-fallback fix for the concrete real-world
91
111
  // trigger this same accname step covers elsewhere.
92
112
  return getAttr(lab, 'title');
@@ -96,7 +116,7 @@ function runInPage(ctx) {
96
116
  const raw = getAttr(el, 'aria-labelledby');
97
117
  if (!raw) return '';
98
118
  // Delegates to the shared getTextFromIdRefs helper instead of computing
99
- // name-from-content of the referenced element — see dialog-name-
119
+ // name-from-content of the referenced element, see dialog-name-
100
120
  // present.js's identical fix for the full rationale (an <iframe>
101
121
  // aria-labelledby target's only name source is its title attribute,
102
122
  // which name-from-content alone can never see).
@@ -231,7 +251,7 @@ function runInPage(ctx) {
231
251
  occurrences.push(
232
252
  helpers.reportOccurrence(el, {
233
253
  summary: 'This element has no accessible name.',
234
- hint: "Provide aria-label, aria-labelledby, or a title attribute — visible text content is not exposed as this listbox's accessible name.",
254
+ hint: "Provide aria-label, aria-labelledby, or a title attribute. Visible text content is not exposed as this listbox's accessible name.",
235
255
  i18n: {
236
256
  summaryKey: 'listboxNamePresent_summary_fail',
237
257
  hintKey: 'listboxNamePresent_hint_fail',
@@ -24,16 +24,16 @@
24
24
  * e.g. aria-helpers.js getExplicitRole).
25
25
  * - An explicit role on the parent WINS over its tag name, in either
26
26
  * direction: a <ul role="menu"> no longer exposes role "list" (its own
27
- * native role is fully replaced by the explicit one — the same "any
27
+ * native role is fully replaced by the explicit one, the same "any
28
28
  * explicit role overrides the element's native role" ARIA principle
29
29
  * applied elsewhere in this engine), so an <li> inside it is invalid
30
30
  * despite the <ul> tag (e.g. `<ul role="menu"><li>...`). Conversely
31
31
  * role="presentation"/"none" on the parent is still a valid
32
- * (list-semantics-suppressing) parent — the accepted parent roles are
32
+ * (list-semantics-suppressing) parent, the accepted parent roles are
33
33
  * presentation, none, and list.
34
34
  * - The SAME "explicit role wins" principle applies to the <li> ELEMENT
35
35
  * ITSELF: an <li role="tab">/role="menuitem">/role="presentation"> etc.
36
- * is exposed to AT with that role, never "listitem" — the whole point
36
+ * is exposed to AT with that role, never "listitem". The whole point
37
37
  * of this check (list items need a valid list-container parent) doesn't
38
38
  * apply when the element isn't claiming listitem semantics in the first
39
39
  * place. Any `<li>` with an explicit `role` attribute is excluded from
@@ -108,7 +108,7 @@ function runInPage(ctx) {
108
108
  let valid;
109
109
  if (explicitRole) {
110
110
  // An explicit role always wins over the tag's native role, in either
111
- // direction — see the header comment.
111
+ // direction, see the header comment.
112
112
  valid = explicitRole === 'list' || explicitRole === 'presentation' || explicitRole === 'none';
113
113
  } else {
114
114
  valid = parentTag === 'ul' || parentTag === 'ol';