@surea11y/core 1.5.0 → 1.7.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 (157) hide show
  1. package/CHANGELOG.md +240 -149
  2. package/README.md +51 -44
  3. package/docs/ACT_RULE_MAPPING.md +245 -0
  4. package/docs/API_STABILITY.md +53 -5
  5. package/docs/BINDING_AUTHORS_GUIDE.md +106 -4
  6. package/docs/DESIGN_CHALLENGES.md +367 -0
  7. package/docs/EARL.md +100 -0
  8. package/docs/ENGINE_OPTIONS.md +42 -4
  9. package/docs/I18N.md +4 -4
  10. package/docs/INTEGRATION.md +4 -2
  11. package/docs/LIMITATIONS.md +9 -5
  12. package/docs/OUTPUT_SCHEMA.md +44 -6
  13. package/docs/POLICY.md +1 -1
  14. package/docs/REPORT.md +1 -1
  15. package/docs/RULE_AUTHORING.md +63 -36
  16. package/docs/RULE_CATALOG.md +1928 -169
  17. package/docs/RULE_HELPERS.md +333 -0
  18. package/docs/RULE_TAXONOMY.md +27 -6
  19. package/docs/SARIF.md +21 -2
  20. package/docs/TROUBLESHOOTING.md +2 -2
  21. package/docs/WCAG_CONFORMANCE.md +34 -10
  22. package/package.json +11 -9
  23. package/src/baseline.js +3 -3
  24. package/src/checks/automatic/area-alt-present.js +2 -2
  25. package/src/checks/automatic/aria-allowed-attr.js +74 -10
  26. package/src/checks/automatic/aria-allowed-role.js +34 -25
  27. package/src/checks/automatic/aria-braille-equivalent.js +21 -13
  28. package/src/checks/automatic/aria-conditional-attr.js +22 -15
  29. package/src/checks/automatic/aria-deprecated-role.js +13 -1
  30. package/src/checks/automatic/aria-hidden-body.js +3 -3
  31. package/src/checks/automatic/aria-hidden-focus.js +5 -5
  32. package/src/checks/automatic/aria-prohibited-attr.js +23 -18
  33. package/src/checks/automatic/aria-prohibited-children.js +136 -43
  34. package/src/checks/automatic/aria-required-attr.js +119 -24
  35. package/src/checks/automatic/aria-required-children.js +54 -30
  36. package/src/checks/automatic/aria-required-parent.js +93 -15
  37. package/src/checks/automatic/aria-role-name-present.js +37 -23
  38. package/src/checks/automatic/aria-roles-valid.js +52 -21
  39. package/src/checks/automatic/aria-valid-attr-value.js +89 -33
  40. package/src/checks/automatic/aria-valid-attr.js +15 -10
  41. package/src/checks/automatic/autocomplete-valid.js +2 -2
  42. package/src/checks/automatic/avoid-inline-spacing.js +133 -6
  43. package/src/checks/automatic/binary-control-name-present.js +27 -5
  44. package/src/checks/automatic/button-name-present.js +92 -6
  45. package/src/checks/automatic/combobox-name-present.js +26 -6
  46. package/src/checks/automatic/contrast-computable.js +42 -0
  47. package/src/checks/automatic/contrast-enhanced.js +33 -1
  48. package/src/checks/automatic/contrast-minimum.js +33 -1
  49. package/src/checks/automatic/css-orientation-lock.js +138 -24
  50. package/src/checks/automatic/definition-list-children-valid.js +7 -8
  51. package/src/checks/automatic/deprecated-elements-not-used.js +1 -1
  52. package/src/checks/automatic/dialog-name-present.js +20 -2
  53. package/src/checks/automatic/duplicate-id-aria.js +10 -3
  54. package/src/checks/automatic/duplicate-id.js +203 -0
  55. package/src/checks/automatic/embed-text-alternative-present.js +2 -2
  56. package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
  57. package/src/checks/automatic/form-control-single-label.js +10 -1
  58. package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
  59. package/src/checks/automatic/iframe-focusable-content.js +68 -7
  60. package/src/checks/automatic/iframe-name-present.js +37 -3
  61. package/src/checks/automatic/iframe-title-unique.js +1 -1
  62. package/src/checks/automatic/img-alt-present.js +12 -4
  63. package/src/checks/automatic/label-in-name.js +204 -68
  64. package/src/checks/automatic/link-in-text-block.js +285 -29
  65. package/src/checks/automatic/link-name-present.js +22 -1
  66. package/src/checks/automatic/list-children-valid.js +6 -6
  67. package/src/checks/automatic/listbox-name-present.js +28 -8
  68. package/src/checks/automatic/listitem-parent-valid.js +4 -4
  69. package/src/checks/automatic/menuitem-name-present.js +20 -2
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +25 -14
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +14 -7
  72. package/src/checks/automatic/meter-name-present.js +23 -4
  73. package/src/checks/automatic/nested-interactive-controls-absent.js +5 -5
  74. package/src/checks/automatic/option-name-present.js +23 -4
  75. package/src/checks/automatic/page-title-present.js +21 -3
  76. package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
  77. package/src/checks/automatic/progressbar-name-present.js +23 -4
  78. package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +64 -16
  79. package/src/checks/automatic/searchbox-name-present.js +28 -8
  80. package/src/checks/automatic/server-side-image-map-absent.js +1 -1
  81. package/src/checks/automatic/slider-name-present.js +27 -6
  82. package/src/checks/automatic/spinbutton-name-present.js +28 -8
  83. package/src/checks/automatic/summary-name-present.js +18 -2
  84. package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
  85. package/src/checks/automatic/svg-text-alternative-present.js +13 -10
  86. package/src/checks/automatic/tab-name-present.js +21 -2
  87. package/src/checks/automatic/table-headers-attr-valid.js +43 -8
  88. package/src/checks/automatic/table-th-has-data-cells.js +61 -5
  89. package/src/checks/automatic/target-size-minimum.js +155 -58
  90. package/src/checks/automatic/td-has-header.js +24 -23
  91. package/src/checks/automatic/textbox-name-present.js +28 -8
  92. package/src/checks/automatic/tooltip-name-present.js +21 -2
  93. package/src/checks/automatic/treeitem-name-present.js +23 -4
  94. package/src/checks/automatic/valid-lang.js +92 -7
  95. package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
  96. package/src/checks/manual/accesskeys-manual.js +3 -3
  97. package/src/checks/manual/area-alt-decorative-manual.js +7 -0
  98. package/src/checks/manual/area-alt-quality-manual.js +6 -0
  99. package/src/checks/manual/aria-checked-state-mismatch-manual.js +5 -5
  100. package/src/checks/manual/aria-text-manual.js +4 -4
  101. package/src/checks/manual/bypass-blocks-present-manual.js +44 -26
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
  103. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
  105. package/src/checks/manual/empty-heading-manual.js +58 -11
  106. package/src/checks/manual/empty-table-header-manual.js +8 -8
  107. package/src/checks/manual/focus-order-semantics-manual.js +15 -15
  108. package/src/checks/manual/form-control-label-quality-manual.js +563 -0
  109. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -2
  110. package/src/checks/manual/heading-order-manual.js +3 -3
  111. package/src/checks/manual/heading-quality-manual.js +338 -0
  112. package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
  113. package/src/checks/manual/image-redundant-alt-manual.js +4 -4
  114. package/src/checks/manual/img-alt-decorative-manual.js +211 -52
  115. package/src/checks/manual/img-alt-quality-manual.js +7 -0
  116. package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
  117. package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
  118. package/src/checks/manual/label-title-only-manual.js +4 -4
  119. package/src/checks/manual/landmark-banner-is-top-level-manual.js +7 -7
  120. package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
  121. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +9 -9
  122. package/src/checks/manual/landmark-main-is-top-level-manual.js +6 -6
  123. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +6 -6
  124. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +6 -6
  125. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -2
  126. package/src/checks/manual/landmark-one-main-manual.js +6 -6
  127. package/src/checks/manual/landmark-unique-manual.js +9 -9
  128. package/src/checks/manual/link-name-quality-manual.js +161 -32
  129. package/src/checks/manual/media-transcript-present-manual.js +2 -3
  130. package/src/checks/manual/meta-viewport-large-manual.js +2 -2
  131. package/src/checks/manual/mouse-only-event-handlers-manual.js +9 -9
  132. package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
  133. package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
  134. package/src/checks/manual/p-as-heading-manual.js +4 -4
  135. package/src/checks/manual/page-has-heading-one-manual.js +6 -6
  136. package/src/checks/manual/page-title-patterns-manual.js +26 -3
  137. package/src/checks/manual/password-paste-enabled-manual.js +255 -0
  138. package/src/checks/manual/presentation-role-conflict-manual.js +55 -25
  139. package/src/checks/manual/region-manual.js +19 -19
  140. package/src/checks/manual/scope-attr-valid-manual.js +2 -2
  141. package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
  142. package/src/checks/manual/skip-link-manual.js +5 -5
  143. package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
  144. package/src/checks/manual/tabindex-manual.js +2 -2
  145. package/src/checks/manual/table-duplicate-name-manual.js +2 -2
  146. package/src/checks/manual/table-fake-caption-manual.js +2 -2
  147. package/src/checks/manual/video-caption-manual.js +3 -3
  148. package/src/checks/manual-review.js +17 -1
  149. package/src/core.js +8880 -41883
  150. package/src/earl.js +144 -0
  151. package/src/report.js +2 -2
  152. package/src/sarif.js +22 -2
  153. package/surea11y.browser.js +10 -37882
  154. package/surea11y.i18n.de.js +2 -21
  155. package/surea11y.i18n.es.js +2 -21
  156. package/surea11y.i18n.fr.js +2 -21
  157. 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;
@@ -221,59 +254,28 @@ function runInPage(ctx) {
221
254
  return parts.join(' ').replace(/\s+/g, ' ').trim();
222
255
  }
223
256
 
257
+ // Real <label> elements associated with a native form control -- the
258
+ // shared dom-helpers.js implementation (a `for`-attribute index plus a
259
+ // bounded closest('label') walk), not the native `.labels`/`.control`
260
+ // pair: jsdom implements those as a whole-document walk on every access
261
+ // (see that function's own header comment for the full explanation),
262
+ // real cost this engine's Node/jsdom runtime pays, not just a browser
263
+ // realm this code happens to also run in.
224
264
  function getAssociatedLabelElements(control) {
225
- const labels = [];
226
- try {
227
- if (control && control.labels && typeof control.labels.length === 'number') {
228
- for (const l of control.labels) labels.push(l);
229
- }
230
- } catch {
231
- // ignore
232
- }
233
-
234
- // Fallback: wrapped label
235
- try {
236
- const w = control && control.closest ? control.closest('label') : null;
237
- if (w) labels.push(w);
238
- } catch {
239
- // ignore
240
- }
241
-
242
- // Fallback: label[for=id]. Uses `document` directly rather than
243
- // ctx.root -- label[for] association is a document-wide relationship
244
- // (IDs are document-unique), not bounded by whatever contextSelector
245
- // region happens to be scanned, and ctx.root is an array (multi-region
246
- // contextSelector support), not a single element with its own
247
- // .querySelector to call directly.
248
- try {
249
- const idAttribute = control && control.getAttribute ? control.getAttribute('id') || '' : '';
250
- const key = String(idAttribute || '').trim();
251
- if (key && document && document.querySelector) {
252
- const l = document.querySelector('label[for="' + CSS.escape(key) + '"]');
253
- if (l) labels.push(l);
254
- }
255
- } catch {
256
- // ignore
257
- }
258
-
259
- // De-dupe in document order
260
- const seen = new Set();
261
- const out = [];
262
- for (const l of labels) {
265
+ if (helpers && typeof helpers.getAssociatedLabelElements === 'function') {
263
266
  try {
264
- if (!l || !l.tagName) continue;
265
- if (seen.has(l)) continue;
266
- seen.add(l);
267
- out.push(l);
267
+ return helpers.getAssociatedLabelElements(control) || [];
268
268
  } catch {
269
- // ignore
269
+ return [];
270
270
  }
271
271
  }
272
- return out;
272
+ return [];
273
273
  }
274
274
 
275
275
  function getVisibleTextLabelInfo(el) {
276
- // Returns { text, source } where source helps reporting.
276
+ // Returns { text, source, sourceElements } where source helps reporting
277
+ // and sourceElements (the actual DOM nodes the visible text came from)
278
+ // lets the icon-font check below look at the right element's own style.
277
279
  // Source policy (deterministic):
278
280
  // 1) <label> association for form controls
279
281
  // 2) visible text inside the element
@@ -288,18 +290,22 @@ function runInPage(ctx) {
288
290
  if (isFormControl) {
289
291
  const labels = getAssociatedLabelElements(el);
290
292
  const labelParts = [];
293
+ const contributing = [];
291
294
  for (const l of labels) {
292
295
  if (!l || !isDomVisible(l)) continue;
293
296
  const t = collectVisibleTextUnder(l);
294
- if (t) labelParts.push(t);
297
+ if (t) {
298
+ labelParts.push(t);
299
+ contributing.push(l);
300
+ }
295
301
  }
296
302
  const joined = labelParts.join(' ').replace(/\s+/g, ' ').trim();
297
- if (joined) return { text: joined, source: 'label' };
303
+ if (joined) return { text: joined, source: 'label', sourceElements: contributing };
298
304
  }
299
305
 
300
306
  // 2) Text inside the control itself
301
307
  text = collectVisibleTextUnder(el);
302
- if (text) return { text, source: 'self' };
308
+ if (text) return { text, source: 'self', sourceElements: [el] };
303
309
 
304
310
  // 3) aria-labelledby referenced visible text (only if refs exist and are visible)
305
311
  try {
@@ -307,20 +313,115 @@ function runInPage(ctx) {
307
313
  if (idrefs && helpers.resolveIdRefs) {
308
314
  const r = helpers.resolveIdRefs(idrefs, ctx, { maxRefs: 8 });
309
315
  const parts = [];
316
+ const contributing = [];
310
317
  for (const ref of r && Array.isArray(r.refs) ? r.refs : []) {
311
318
  if (!ref || !ref.tagName) continue;
312
319
  if (!isDomVisible(ref)) continue;
313
320
  const t = collectVisibleTextUnder(ref);
314
- if (t) parts.push(t);
321
+ if (t) {
322
+ parts.push(t);
323
+ contributing.push(ref);
324
+ }
315
325
  }
316
326
  const joined = parts.join(' ').replace(/\s+/g, ' ').trim();
317
- if (joined) return { text: joined, source: 'aria-labelledby' };
327
+ if (joined)
328
+ return { text: joined, source: 'aria-labelledby', sourceElements: contributing };
318
329
  }
319
330
  } catch {
320
331
  // ignore
321
332
  }
322
333
 
323
- return { text: '', source };
334
+ return { text: '', source, sourceElements: [] };
335
+ }
336
+
337
+ // Curated real-world icon-font family names. These fonts remap ordinary
338
+ // word glyphs to unrelated symbols via ligatures/PUA codepoints, so the
339
+ // DOM text is real words but nothing readable actually renders, ACT
340
+ // 2ee8b8's own passed example is exactly this (a button's DOM text
341
+ // "search" rendered as a magnifying-glass icon by "Material Icons").
342
+ // Same curated-list tradeoff as link-name-quality's phrase list.
343
+ const ICON_FONT_FAMILIES = new Set([
344
+ 'material icons',
345
+ 'material icons outlined',
346
+ 'material icons round',
347
+ 'material icons sharp',
348
+ 'material icons two tone',
349
+ 'material symbols outlined',
350
+ 'material symbols rounded',
351
+ 'material symbols sharp',
352
+ 'font awesome 5 free',
353
+ 'font awesome 5 brands',
354
+ 'font awesome 5 pro',
355
+ 'font awesome 6 free',
356
+ 'font awesome 6 brands',
357
+ 'font awesome 6 pro',
358
+ 'fontawesome',
359
+ 'glyphicons halflings',
360
+ 'ionicons',
361
+ 'icomoon',
362
+ 'bootstrap-icons',
363
+ 'bootstrap icons',
364
+ 'feather'
365
+ ]);
366
+
367
+ // Same two-tier lookup as avoid-inline-spacing.js's computedStyleOf:
368
+ // helpers.computedStyle isn't actually part of the public helpers API
369
+ // (it's internal to dom-helpers.js), so the realm's own getComputedStyle
370
+ // is what actually resolves a stylesheet-declared font-family.
371
+ function computedStyleOf(node) {
372
+ if (helpers && typeof helpers.computedStyle === 'function') {
373
+ try {
374
+ const cs = helpers.computedStyle(node);
375
+ if (cs) return cs;
376
+ } catch {
377
+ // fall through to the realm's own view
378
+ }
379
+ }
380
+ try {
381
+ const view = node.ownerDocument && node.ownerDocument.defaultView;
382
+ if (view && typeof view.getComputedStyle === 'function') return view.getComputedStyle(node);
383
+ } catch {
384
+ // no computed style available
385
+ }
386
+ return null;
387
+ }
388
+
389
+ function isIconFontElement(node) {
390
+ if (!node) return false;
391
+ const cs = computedStyleOf(node);
392
+ if (!cs) return false;
393
+ let family;
394
+ try {
395
+ family =
396
+ cs.fontFamily || (cs.getPropertyValue ? cs.getPropertyValue('font-family') : '') || '';
397
+ } catch {
398
+ family = '';
399
+ }
400
+ if (!family) return false;
401
+ const names = String(family)
402
+ .split(',')
403
+ .map((s) =>
404
+ s
405
+ .trim()
406
+ .replace(/^['"]|['"]$/g, '')
407
+ .toLowerCase()
408
+ );
409
+ return names.some((n) => ICON_FONT_FAMILIES.has(n));
410
+ }
411
+
412
+ // ACT 2ee8b8's own passed example `<button aria-label="close">X</button>`:
413
+ // a single character standing in for an icon ("x" meaning "close") is
414
+ // "non-text content" per the rule's own background text, which names no
415
+ // algorithmic test for it. Scoped narrowly to a whole visible label of
416
+ // exactly one character that doesn't even appear inside the accessible
417
+ // name: "x" has no relation at all to "close", which is the icon-glyph
418
+ // shape. A single character that DOES appear in the name (e.g. visible
419
+ // "1" against aria-label "1a") is a real word-boundary mismatch, not a
420
+ // symbol standing in for something else, and still fails outright.
421
+ function isSingleSymbolicCharacter(text, accessibleNameNorm) {
422
+ const t = norm(text);
423
+ if (!t || Array.from(t).length !== 1) return false;
424
+ return accessibleNameNorm.indexOf(t) === -1;
324
425
  }
325
426
 
326
427
  for (const el of nodes) {
@@ -351,33 +452,54 @@ function runInPage(ctx) {
351
452
  const nameTokens = tokenize(accName, false);
352
453
  const contains = containsWordRun(labelTokens, nameTokens, null);
353
454
 
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.
357
- let uncertainty = '';
455
+ // An abbreviation, a word hyphenated differently, or visible text that
456
+ // may not be literal text at all (an icon-font glyph, a single symbolic
457
+ // character) is not something markup settles: the author may have meant
458
+ // either. Report without asserting a defect instead of failing or
459
+ // staying silent.
460
+ let uncertainReason = '';
358
461
  if (!contains) {
359
462
  if (containsWordRun(tokenize(visibleLabel, true), tokenize(accName, true), null)) {
360
- uncertainty = 'HYPHENATION_DIFFERS';
463
+ uncertainReason = 'HYPHENATION_DIFFERS';
361
464
  } else {
362
465
  const abbreviated = abbreviatedWords(visibleLabel);
363
- if (abbreviated.size && containsWordRun(labelTokens, nameTokens, abbreviated))
364
- uncertainty = 'POSSIBLE_ABBREVIATION';
466
+ if (abbreviated.size && containsWordRun(labelTokens, nameTokens, abbreviated)) {
467
+ uncertainReason = 'POSSIBLE_ABBREVIATION';
468
+ } else if ((labelInfo.sourceElements || []).some(isIconFontElement)) {
469
+ uncertainReason = 'POSSIBLE_ICON_FONT_GLYPH';
470
+ } else if (isSingleSymbolicCharacter(visibleLabel, accNorm)) {
471
+ uncertainReason = 'POSSIBLE_SYMBOLIC_CHARACTER';
472
+ }
365
473
  }
366
474
  }
367
475
 
476
+ const isSymbolicUncertainty =
477
+ uncertainReason === 'POSSIBLE_ICON_FONT_GLYPH' ||
478
+ uncertainReason === 'POSSIBLE_SYMBOLIC_CHARACTER';
479
+
368
480
  if (!contains) {
369
481
  occurrences.push(
370
482
  helpers.reportOccurrence(el, {
371
- ...(uncertainty ? { outcome: 'cantTell' } : null),
372
- summary: uncertainty
483
+ ...(uncertainReason ? { outcome: 'cantTell' } : null),
484
+ summary: uncertainReason
373
485
  ? 'Accessible name may not contain the visible label text.'
374
486
  : '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).',
487
+ hint: !uncertainReason
488
+ ? 'Ensure the accessible name includes the visible text label (e.g., update aria-label/aria-labelledby to include the visible wording).'
489
+ : isSymbolicUncertainty
490
+ ? 'Check by hand: the visible text may render as an icon or symbol rather than literal words, which markup cannot settle.'
491
+ : 'Check by hand: the two differ only by an abbreviation or by hyphenation, which markup cannot settle.',
378
492
  i18n: {
379
- summaryKey: uncertainty ? 'labelInName_summary_cantTell' : 'labelInName_summary_fail',
380
- hintKey: uncertainty ? 'labelInName_hint_cantTell' : 'labelInName_hint_fail',
493
+ summaryKey: !uncertainReason
494
+ ? 'labelInName_summary_fail'
495
+ : isSymbolicUncertainty
496
+ ? 'labelInName_summary_cantTell_symbolic'
497
+ : 'labelInName_summary_cantTell',
498
+ hintKey: !uncertainReason
499
+ ? 'labelInName_hint_fail'
500
+ : isSymbolicUncertainty
501
+ ? 'labelInName_hint_cantTell_symbolic'
502
+ : 'labelInName_hint_cantTell',
381
503
  params: {
382
504
  element: getElementDescriptor(el),
383
505
  visibleLabel: clipForSummary(visibleLabel),
@@ -385,9 +507,23 @@ function runInPage(ctx) {
385
507
  nameMechanism: acc && acc.mechanism ? acc.mechanism : 'none'
386
508
  }
387
509
  },
510
+ ...(uncertainReason
511
+ ? {
512
+ uncertainty: {
513
+ code: 'equivalence-unknown',
514
+ needed:
515
+ 'Whether the accessible name and the visible label say the same thing to a user.',
516
+ evidence: {
517
+ visibleLabel,
518
+ accessibleName: accName,
519
+ difference: uncertainReason
520
+ }
521
+ }
522
+ }
523
+ : null),
388
524
  data: {
389
525
  details: {
390
- reasonCode: uncertainty || 'VISIBLE_LABEL_NOT_IN_ACCESSIBLE_NAME',
526
+ reasonCode: uncertainReason || 'VISIBLE_LABEL_NOT_IN_ACCESSIBLE_NAME',
391
527
  visibleLabel,
392
528
  accessibleName: accName,
393
529
  normalized: { visibleLabel: visibleNorm, accessibleName: accNorm },