@surea11y/core 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/README.md +179 -90
  3. package/docs/ACT_RULE_MAPPING.md +10 -8
  4. package/docs/API_STABILITY.md +67 -6
  5. package/docs/BINDING_AUTHORS_GUIDE.md +104 -2
  6. package/docs/CI_INTEGRATIONS.md +43 -0
  7. package/docs/DESIGN_CHALLENGES.md +162 -2
  8. package/docs/EARL.md +100 -0
  9. package/docs/ENGINE_OPTIONS.md +109 -5
  10. package/docs/I18N.md +62 -20
  11. package/docs/INTEGRATION.md +4 -2
  12. package/docs/JUNIT.md +73 -0
  13. package/docs/LIMITATIONS.md +4 -1
  14. package/docs/OUTPUT_SCHEMA.md +62 -11
  15. package/docs/POLICY.md +1 -1
  16. package/docs/REPORT.md +7 -2
  17. package/docs/RULE_AUTHORING.md +83 -17
  18. package/docs/RULE_CATALOG.md +212 -139
  19. package/docs/RULE_EXAMPLES.md +2189 -0
  20. package/docs/RULE_HELPERS.md +390 -0
  21. package/docs/RULE_TAXONOMY.md +27 -6
  22. package/docs/SARIF.md +23 -3
  23. package/docs/WCAG_CONFORMANCE.md +64 -3
  24. package/package.json +41 -12
  25. package/profiles/index.js +14 -0
  26. package/src/checks/automatic/area-alt-present.js +87 -31
  27. package/src/checks/automatic/aria-allowed-attr.js +6 -0
  28. package/src/checks/automatic/aria-allowed-role.js +32 -23
  29. package/src/checks/automatic/aria-braille-equivalent.js +43 -17
  30. package/src/checks/automatic/aria-conditional-attr.js +17 -10
  31. package/src/checks/automatic/aria-deprecated-role.js +12 -0
  32. package/src/checks/automatic/aria-hidden-body.js +1 -1
  33. package/src/checks/automatic/aria-hidden-focus.js +74 -18
  34. package/src/checks/automatic/aria-prohibited-attr.js +22 -4
  35. package/src/checks/automatic/aria-prohibited-children.js +6 -6
  36. package/src/checks/automatic/aria-required-attr.js +88 -12
  37. package/src/checks/automatic/aria-required-children.js +33 -16
  38. package/src/checks/automatic/aria-required-parent.js +32 -6
  39. package/src/checks/automatic/aria-role-name-present.js +20 -3
  40. package/src/checks/automatic/aria-roles-valid.js +52 -21
  41. package/src/checks/automatic/aria-valid-attr-value.js +89 -24
  42. package/src/checks/automatic/aria-valid-attr.js +14 -9
  43. package/src/checks/automatic/autocomplete-valid.js +152 -26
  44. package/src/checks/automatic/avoid-inline-spacing.js +207 -15
  45. package/src/checks/automatic/button-name-present.js +2 -1
  46. package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
  47. package/src/checks/automatic/combobox-name-present.js +34 -51
  48. package/src/checks/automatic/contrast-computable.js +45 -4
  49. package/src/checks/automatic/contrast-enhanced.js +16 -4
  50. package/src/checks/automatic/contrast-minimum.js +57 -11
  51. package/src/checks/automatic/css-orientation-lock.js +171 -12
  52. package/src/checks/automatic/definition-list-children-valid.js +67 -23
  53. package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
  54. package/src/checks/automatic/dialog-name-present.js +28 -9
  55. package/src/checks/automatic/duplicate-id-aria.js +5 -0
  56. package/src/checks/automatic/duplicate-id.js +19 -10
  57. package/src/checks/automatic/form-control-single-label.js +9 -0
  58. package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
  59. package/src/checks/automatic/iframe-focusable-content.js +12 -4
  60. package/src/checks/automatic/iframe-title-unique.js +36 -81
  61. package/src/checks/automatic/input-image-alt-present.js +32 -20
  62. package/src/checks/automatic/label-in-name.js +78 -69
  63. package/src/checks/automatic/language-page-present.js +12 -6
  64. package/src/checks/automatic/link-in-text-block.js +512 -44
  65. package/src/checks/automatic/link-name-present.js +13 -5
  66. package/src/checks/automatic/list-children-valid.js +18 -1
  67. package/src/checks/automatic/listbox-name-present.js +19 -49
  68. package/src/checks/automatic/listitem-parent-valid.js +4 -3
  69. package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
  70. package/src/checks/automatic/page-title-present.js +16 -4
  71. package/src/checks/automatic/progressbar-name-present.js +11 -1
  72. package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +9 -5
  73. package/src/checks/automatic/searchbox-name-present.js +32 -49
  74. package/src/checks/automatic/server-side-image-map-absent.js +48 -28
  75. package/src/checks/automatic/slider-name-present.js +38 -52
  76. package/src/checks/automatic/spinbutton-name-present.js +32 -49
  77. package/src/checks/automatic/target-size-minimum.js +84 -16
  78. package/src/checks/automatic/td-has-header.js +60 -23
  79. package/src/checks/automatic/text-spacing-content-loss.js +548 -0
  80. package/src/checks/automatic/textbox-name-present.js +32 -49
  81. package/src/checks/automatic/valid-lang.js +15 -10
  82. package/src/checks/manual/area-alt-quality-manual.js +113 -31
  83. package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
  84. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
  85. package/src/checks/manual/css-hidden-focus.js +215 -7
  86. package/src/checks/manual/form-control-label-quality-manual.js +243 -29
  87. package/src/checks/manual/heading-order-manual.js +9 -1
  88. package/src/checks/manual/heading-quality-manual.js +143 -9
  89. package/src/checks/manual/img-alt-decorative-manual.js +6 -3
  90. package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
  91. package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
  92. package/src/checks/manual/link-name-quality-manual.js +130 -4
  93. package/src/checks/manual/media-transcript-present-manual.js +65 -8
  94. package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
  95. package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
  96. package/src/checks/manual/p-as-heading-manual.js +89 -44
  97. package/src/checks/manual/page-title-patterns-manual.js +77 -8
  98. package/src/checks/manual/password-paste-enabled-manual.js +255 -0
  99. package/src/checks/manual/skip-link-manual.js +42 -14
  100. package/src/checks/manual/table-fake-caption-manual.js +32 -1
  101. package/src/checks/manual/video-caption-manual.js +47 -24
  102. package/src/checks/manual-review.js +0 -4
  103. package/src/core.js +18061 -46194
  104. package/src/coverage/en301549-map.js +187 -0
  105. package/src/coverage/standards.js +279 -0
  106. package/src/coverage/wcag-facets.js +1119 -0
  107. package/src/coverage/wcag-version-map.js +101 -0
  108. package/src/earl.js +144 -0
  109. package/src/en301549.js +33 -0
  110. package/src/junit.js +321 -0
  111. package/src/profile-kit.js +163 -0
  112. package/src/report.js +343 -74
  113. package/src/sarif.js +56 -5
  114. package/src/wcag.js +105 -0
  115. package/surea11y.browser.js +11 -41039
  116. package/surea11y.i18n.de.js +2 -21
  117. package/surea11y.i18n.es.js +2 -21
  118. package/surea11y.i18n.fr.js +2 -21
  119. package/surea11y.i18n.ja.js +3 -0
  120. package/src/checks/manual/area-alt-decorative-manual.js +0 -255
@@ -5,33 +5,43 @@
5
5
  /**
6
6
  * @check iframe-title-unique
7
7
  * @atomic true
8
- * @summary <iframe>/<frame> title attributes must be unique among frames
8
+ * @summary Deprecated since 1.8.0, reports notApplicable; see identical-iframes-same-purpose
9
9
  * @standard WCAG 2.2
10
10
  * @sc 4.1.2
11
11
  * @applicability
12
- * Applies to <iframe>/<frame> elements that carry a non-empty title
13
- * attribute.
12
+ * Nothing. The rule is deprecated and reports notApplicable on every
13
+ * page. Its id stays in the catalog, with meta.deprecated set and
14
+ * deprecation.replacedBy naming the successor, so a runOnly list, a
15
+ * stored baseline or an open Code Scanning alert that holds the id keeps
16
+ * resolving until the file is removed in 2.0.0 (docs/API_STABILITY.md,
17
+ * "Rule-ID deprecation policy").
14
18
  * @expectation
15
- * No two frames in scope share the same (trimmed, case-sensitive) title
16
- * attribute value, a duplicate title prevents assistive technology
17
- * users from telling frames apart when scanning by name.
19
+ * None. The check this rule used to make, that no two frames share a
20
+ * title attribute, is not a WCAG 4.1.2 requirement: the criterion asks
21
+ * that a frame's name be programmatically determinable, not unique, and
22
+ * ACT rule 4b1c6c accepts identical names on frames that embed equivalent
23
+ * resources. identical-iframes-same-purpose asks the question 4b1c6c does
24
+ * ask, of the computed accessible name, which for a frame is the title
25
+ * attribute unless aria-label or aria-labelledby overrides it.
18
26
  * @implementation-notes
19
- * - Distinct, atomic decision from iframe-name-present (presence):
20
- * a frame can have a non-empty title while still failing uniqueness.
21
- * - Compares the title ATTRIBUTE specifically, not the full computed
22
- * accessible name (aria-label could legitimately differ in wording even
23
- * when title happens to collide).
24
- * - Not rule-gated on isAccTreeEligible: duplicate titles are a static
25
- * markup property. Engine-level hidden-subtree filtering still applies
26
- * unless engineOptions.includeHiddenElements is true.
27
+ * - Reduced to notApplicable rather than only marked deprecated because a
28
+ * deprecated rule keeps running and reporting normally, and leaving the
29
+ * old fail alive until 2.0.0 would have kept reporting a violation WCAG
30
+ * does not define. docs/DESIGN_CHALLENGES.md records the decision.
31
+ * - IFRAME_TITLE_DUPLICATE is no longer emitted. It retired with the
32
+ * finding it named in 1.8.0 (CHANGELOG.md), so a stored baseline entry or
33
+ * alert for it closes.
34
+ * - The facet this rule covered under SC 4.1.2 is retired; the coverage
35
+ * entry points at the successor's facet, so the catalog still shows where
36
+ * the question is answered.
27
37
  */
28
38
 
29
39
  const id = 'iframe-title-unique';
30
40
 
31
41
  const meta = {
32
- title: 'Frame titles must be unique',
42
+ title: 'Frame title uniqueness (deprecated)',
33
43
  description:
34
- 'Checks that no two <iframe>/<frame> elements in scope share the same title attribute value.',
44
+ 'Deprecated since 1.8.0 and always notApplicable: whether frames sharing a name embed the same resource is checked by identical-iframes-same-purpose.',
35
45
  i18n: {
36
46
  titleKey: 'iframeTitleUnique_title',
37
47
  descriptionKey: 'iframeTitleUnique_description'
@@ -52,74 +62,19 @@ const meta = {
52
62
  category: 'robust',
53
63
  type: 'automatic',
54
64
  defaultConfidence: 'high',
55
- coverage: { facetsBySc: { '4.1.2': ['iframe-title-unique'] } }
65
+ deprecated: true,
66
+ deprecation: {
67
+ replacedBy: 'identical-iframes-same-purpose',
68
+ reason:
69
+ 'A repeated title attribute is not a WCAG 4.1.2 violation, and identical-iframes-same-purpose already checks what ACT rule 4b1c6c asks: that frames sharing a name embed the same resource.',
70
+ sinceVersion: '1.8.0'
71
+ },
72
+ coverage: { facetsBySc: { '4.1.2': ['identical-iframes-same-purpose'] } }
56
73
  };
57
74
 
58
75
  function runInPage(ctx) {
59
- const { helpers, rule } = ctx;
60
-
61
- const nodes = helpers.queryAllSmart
62
- ? helpers.queryAllSmart('iframe, frame')
63
- : helpers.queryAll('iframe, frame');
64
-
65
- const groups = new Map(); // trimmed title -> elements[]
66
- let applicableCount = 0;
67
-
68
- for (const el of nodes) {
69
- if (!el || !el.getAttribute) continue;
70
-
71
- const title = String(el.getAttribute('title') || '').trim();
72
- if (!title) continue;
73
-
74
- applicableCount += 1;
75
-
76
- const list = groups.get(title);
77
- if (list) list.push(el);
78
- else groups.set(title, [el]);
79
- }
80
-
81
- if (applicableCount === 0) {
82
- return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
83
- }
84
-
85
- const occurrences = [];
86
-
87
- for (const [title, els] of groups) {
88
- if (els.length < 2) continue;
89
-
90
- for (const el of els) {
91
- const tag = el.tagName.toLowerCase();
92
- occurrences.push(
93
- helpers.reportOccurrence(el, {
94
- summary: 'This frame’s title is not unique among the frames on this page.',
95
- hint: 'Give each frame a distinct title describing its specific content or purpose.',
96
- i18n: {
97
- summaryKey: 'iframeTitleUnique_summary_fail',
98
- hintKey: 'iframeTitleUnique_hint_fail',
99
- params: { element: tag, title }
100
- },
101
- data: {
102
- details: {
103
- reasonCode: 'IFRAME_TITLE_DUPLICATE',
104
- element: tag,
105
- title,
106
- duplicateCount: els.length
107
- }
108
- }
109
- })
110
- );
111
- }
112
- }
113
-
114
- if (occurrences.length) {
115
- return {
116
- ruleId: rule.ruleId,
117
- outcome: 'fail',
118
- severity: rule.defaultSeverity || 'moderate',
119
- occurrences
120
- };
121
- }
122
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
76
+ const { rule } = ctx;
77
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
123
78
  }
124
79
 
125
80
  module.exports = { id, meta, runInPage };
@@ -3,7 +3,7 @@
3
3
  'use strict';
4
4
 
5
5
  /**
6
- * @check inputImage-alt-present
6
+ * @check input-image-alt-present
7
7
  * @atomic true
8
8
  * @summary Accessible <input type="image"> elements must have an alt attribute
9
9
  * @standard WCAG 2.2
@@ -11,9 +11,12 @@
11
11
  * @applicability
12
12
  * Applies to <input type="image"> elements included in the accessibility tree.
13
13
  * @expectation
14
- * Each applicable <input type="image"> element has an alt attribute, and its
15
- * accessible name is not the browser default for an image button.
16
- * The alt attribute may be empty (alt="").
14
+ * Each applicable <input type="image"> element has a non-empty accessible
15
+ * name from aria-labelledby, aria-label, alt or title. An empty name
16
+ * fails, whether alt is missing or empty (alt="").
17
+ * An author-supplied name that equals a browser default for an image
18
+ * button ("Submit", "Submit Query") is not an empty name: it may describe
19
+ * the button, so it is reported as cantTell for a person to judge.
17
20
  */
18
21
 
19
22
  const id = 'input-image-alt-present';
@@ -99,6 +102,7 @@ function runInPage(ctx) {
99
102
  }
100
103
 
101
104
  const occurrences = [];
105
+ const cantTellOccurrences = [];
102
106
  let applicableCount = 0;
103
107
 
104
108
  for (const el of inputs) {
@@ -118,10 +122,12 @@ function runInPage(ctx) {
118
122
 
119
123
  applicableCount += 1;
120
124
 
121
- // The browser's own fallback name for an image button carries no
122
- // information, so an author-supplied name equal to it is treated as no
123
- // name at all (ACT 59796f). Only the English defaults are recognised:
124
- // "Submit Query" from HTML-AAM, "Submit" from Chrome.
125
+ // An author-supplied name equal to the browser's own fallback name for an
126
+ // image button ("Submit Query" from HTML-AAM, "Submit" from Chrome) may
127
+ // be the browser default copied by hand, or may describe a submit button
128
+ // accurately. It is a name, so ACT 59796f passes it; whether it conveys
129
+ // the button's purpose is asked, not failed. Only the English defaults
130
+ // are recognised. With no author name at all the control fails below.
125
131
  const effectiveName = (() => {
126
132
  let v = '';
127
133
  if (getAriaNameInfo) {
@@ -149,19 +155,25 @@ function runInPage(ctx) {
149
155
  : null;
150
156
  const defaultNameOccurrence = {
151
157
  summary:
152
- 'Accessible name is the browser default for an image button, which conveys nothing.',
153
- hint: 'Replace it with text describing what the button does, for example "Search".',
158
+ 'The accessible name matches the browser default for an image button; check that it describes what the button does.',
159
+ hint: 'If the name does not say what the button does, replace it with text that does, for example "Search".',
160
+ occurrenceOutcome: 'cantTell',
154
161
  i18n: {
155
- summaryKey: 'inputImage_altPresent_summary_defaultName',
156
- hintKey: 'inputImage_altPresent_hint_defaultName',
162
+ summaryKey: 'inputImage_altPresent_summary_cantTell_defaultName',
163
+ hintKey: 'inputImage_altPresent_hint_cantTell_defaultName',
157
164
  params: { element: 'input[type=image]' }
158
165
  },
166
+ uncertainty: {
167
+ code: 'judgement-required',
168
+ needed: 'Whether this default-looking name describes what the button does.',
169
+ evidence: { name: effectiveName }
170
+ },
159
171
  data: {
160
172
  visibilityFilter: eligInfoDefault || { targetSet: 'acc', accEligible: null, reasons: [] },
161
173
  details: { reasonCode: 'default_name' }
162
174
  }
163
175
  };
164
- occurrences.push(
176
+ cantTellOccurrences.push(
165
177
  helpers && typeof helpers.reportOccurrence === 'function'
166
178
  ? helpers.reportOccurrence(el, defaultNameOccurrence)
167
179
  : { selector: '', html: '', ...defaultNameOccurrence }
@@ -220,16 +232,16 @@ function runInPage(ctx) {
220
232
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
221
233
  }
222
234
 
223
- if (!occurrences.length) {
235
+ if (!occurrences.length && !cantTellOccurrences.length) {
224
236
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
225
237
  }
226
238
 
227
- return {
228
- ruleId: rule.ruleId,
229
- outcome: 'fail',
230
- severity: rule.defaultSeverity || 'minor',
231
- occurrences
232
- };
239
+ const resolved = helpers.resolveTieredOutcome(
240
+ occurrences,
241
+ cantTellOccurrences,
242
+ rule.defaultSeverity || 'minor'
243
+ );
244
+ return { ruleId: rule.ruleId, ...resolved };
233
245
  }
234
246
 
235
247
  module.exports = { id, meta, runInPage };
@@ -9,7 +9,7 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 2.5.3
11
11
  * @applicability
12
- * Applies to controls that carry aria-label or aria-labelledby, are
12
+ * Applies to controls named by aria-label or aria-labelledby, are
13
13
  * visually rendered, and have visible label text this engine can extract
14
14
  * deterministically, from an associated <label>, from the control's own
15
15
  * rendered text, or from the elements aria-labelledby points at. The
@@ -18,13 +18,20 @@
18
18
  * menuitemcheckbox, menuitemradio, option, treeitem and gridcell roles,
19
19
  * minus anything hidden or disabled. aria-hidden is not
20
20
  * excluded: it changes nothing about what is rendered on screen, which is
21
- * what this SC is about.
21
+ * what this SC is about. An aria-label that is empty once trimmed, or an
22
+ * aria-labelledby whose ids point at nothing or only at elements with no
23
+ * text, names nothing: the accessible name then comes from the next
24
+ * source (the content, a <label>, title...), as for a control without
25
+ * them, so the control is out of scope.
22
26
  * @expectation
23
27
  * The accessible name contains the visible label's words, adjacent and in
24
28
  * 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
29
+ * parenthesised text is dropped, case is folded, text is NFKC-normalised,
30
+ * and every character that is not a letter, digit or combining mark
31
+ * becomes a separator, so punctuation and spacing differences never
32
+ * decide the outcome. Accents are not folded: "Déposer" stays one word,
33
+ * and a name that drops an accent ("Deposer") does not contain it. Four
34
+ * shapes markup cannot
28
35
  * settle are reported as cantTell instead of fail: a word hyphenated
29
36
  * differently in the two places; a visible word the author may have
30
37
  * abbreviated, marked by its trailing period; visible text rendered
@@ -86,20 +93,23 @@ function runInPage(ctx) {
86
93
  }
87
94
 
88
95
  // WCAG 2.5.3's label-in-name comparison is over words, not characters: drop
89
- // parenthesised text, case-fold and NFKD-normalise, then reduce every
90
- // non-letter/digit to a space. `hyphensJoin` deletes hyphens instead of
96
+ // parenthesised text, case-fold and NFKC-normalise, then reduce every
97
+ // character that is not a letter, digit or combining mark to a space.
98
+ // NFKC folds compatibility forms (ligatures, full-width letters) but keeps
99
+ // accented letters whole; with NFKD, "déposer" split at its combining
100
+ // accent into "de" and "poser". `hyphensJoin` deletes hyphens instead of
91
101
  // splitting on them, which distinguishes a real mismatch from one that is
92
102
  // only a hyphenation difference.
93
103
  function tokenize(s, hyphensJoin) {
94
104
  let v = (s == null ? '' : String(s)).replace(/\([^)]*\)/g, ' ').toLowerCase();
95
105
  try {
96
- v = v.normalize('NFKD');
106
+ v = v.normalize('NFKC');
97
107
  } catch {
98
108
  // Realm without String#normalize: the word comparison below still holds.
99
109
  }
100
110
  if (hyphensJoin) v = v.replace(/[-‐-―−]/g, '');
101
111
  return v
102
- .replace(/[^\p{L}\p{N}]+/gu, ' ')
112
+ .replace(/[^\p{L}\p{N}\p{M}]+/gu, ' ')
103
113
  .split(' ')
104
114
  .filter(Boolean);
105
115
  }
@@ -129,7 +139,7 @@ function runInPage(ctx) {
129
139
  function abbreviatedWords(s) {
130
140
  const out = new Set();
131
141
  for (const w of (s == null ? '' : String(s)).split(/\s+/)) {
132
- const m = /^([\p{L}\p{N}]+)\.$/u.exec(w);
142
+ const m = /^([\p{L}\p{N}\p{M}]+)\.$/u.exec(w);
133
143
  if (m) out.add(m[1].toLowerCase());
134
144
  }
135
145
  return out;
@@ -231,7 +241,6 @@ function runInPage(ctx) {
231
241
 
232
242
  const parts = [];
233
243
  let n;
234
- // eslint-disable-next-line no-cond-assign
235
244
  while ((n = walker.nextNode())) {
236
245
  try {
237
246
  const raw = n && n.nodeValue != null ? String(n.nodeValue) : '';
@@ -254,55 +263,22 @@ function runInPage(ctx) {
254
263
  return parts.join(' ').replace(/\s+/g, ' ').trim();
255
264
  }
256
265
 
266
+ // Real <label> elements associated with a native form control -- the
267
+ // shared dom-helpers.js implementation (a `for`-attribute index plus a
268
+ // bounded closest('label') walk), not the native `.labels`/`.control`
269
+ // pair: jsdom implements those as a whole-document walk on every access
270
+ // (see that function's own header comment for the full explanation),
271
+ // real cost this engine's Node/jsdom runtime pays, not just a browser
272
+ // realm this code happens to also run in.
257
273
  function getAssociatedLabelElements(control) {
258
- const labels = [];
259
- try {
260
- if (control && control.labels && typeof control.labels.length === 'number') {
261
- for (const l of control.labels) labels.push(l);
262
- }
263
- } catch {
264
- // ignore
265
- }
266
-
267
- // Fallback: wrapped label
268
- try {
269
- const w = control && control.closest ? control.closest('label') : null;
270
- if (w) labels.push(w);
271
- } catch {
272
- // ignore
273
- }
274
-
275
- // Fallback: label[for=id]. Uses `document` directly rather than
276
- // ctx.root -- label[for] association is a document-wide relationship
277
- // (IDs are document-unique), not bounded by whatever contextSelector
278
- // region happens to be scanned, and ctx.root is an array (multi-region
279
- // contextSelector support), not a single element with its own
280
- // .querySelector to call directly.
281
- try {
282
- const idAttribute = control && control.getAttribute ? control.getAttribute('id') || '' : '';
283
- const key = String(idAttribute || '').trim();
284
- if (key && document && document.querySelector) {
285
- const l = document.querySelector('label[for="' + CSS.escape(key) + '"]');
286
- if (l) labels.push(l);
287
- }
288
- } catch {
289
- // ignore
290
- }
291
-
292
- // De-dupe in document order
293
- const seen = new Set();
294
- const out = [];
295
- for (const l of labels) {
274
+ if (helpers && typeof helpers.getAssociatedLabelElements === 'function') {
296
275
  try {
297
- if (!l || !l.tagName) continue;
298
- if (seen.has(l)) continue;
299
- seen.add(l);
300
- out.push(l);
276
+ return helpers.getAssociatedLabelElements(control) || [];
301
277
  } catch {
302
- // ignore
278
+ return [];
303
279
  }
304
280
  }
305
- return out;
281
+ return [];
306
282
  }
307
283
 
308
284
  function getVisibleTextLabelInfo(el) {
@@ -468,8 +444,6 @@ function runInPage(ctx) {
468
444
  // Applicability: only when we can deterministically extract visible label text.
469
445
  if (!visibleNorm) continue;
470
446
 
471
- applicableCount += 1;
472
-
473
447
  let acc = { present: false, value: '', mechanism: 'none', flags: [] };
474
448
  try {
475
449
  acc = helpers.getAccessibleNameInfo
@@ -478,6 +452,26 @@ function runInPage(ctx) {
478
452
  } catch {
479
453
  acc = { present: false, value: '', mechanism: 'none', flags: ['exception'] };
480
454
  }
455
+
456
+ // An aria-label empty once trimmed, or an aria-labelledby that yields no
457
+ // text, is skipped by the accessible name computation (AccName 1.2 steps
458
+ // 2B and 2C): the name comes from the next source, the content for a
459
+ // button or link, as if neither attribute were there. Such a control is
460
+ // not named by aria-label or aria-labelledby, so it is out of scope.
461
+ // getAccessibleNameInfo leaves name from content to its callers, which
462
+ // made `<button aria-labelledby="missing">Save</button>` fail with an
463
+ // empty name although its name is "Save".
464
+ const flags = acc && Array.isArray(acc.flags) ? acc.flags : [];
465
+ const ariaNamed =
466
+ acc && (acc.mechanism === 'aria-label' || acc.mechanism === 'aria-labelledby');
467
+ if (
468
+ !ariaNamed &&
469
+ (flags.includes('aria-labelledby-empty-or-unresolvable') ||
470
+ flags.includes('aria-label-empty'))
471
+ )
472
+ continue;
473
+
474
+ applicableCount += 1;
481
475
  const accName = acc && acc.value != null ? String(acc.value) : '';
482
476
  const accNorm = norm(accName);
483
477
 
@@ -490,44 +484,45 @@ function runInPage(ctx) {
490
484
  // character) is not something markup settles: the author may have meant
491
485
  // either. Report without asserting a defect instead of failing or
492
486
  // staying silent.
493
- let uncertainty = '';
487
+ let uncertainReason = '';
494
488
  if (!contains) {
495
489
  if (containsWordRun(tokenize(visibleLabel, true), tokenize(accName, true), null)) {
496
- uncertainty = 'HYPHENATION_DIFFERS';
490
+ uncertainReason = 'HYPHENATION_DIFFERS';
497
491
  } else {
498
492
  const abbreviated = abbreviatedWords(visibleLabel);
499
493
  if (abbreviated.size && containsWordRun(labelTokens, nameTokens, abbreviated)) {
500
- uncertainty = 'POSSIBLE_ABBREVIATION';
494
+ uncertainReason = 'POSSIBLE_ABBREVIATION';
501
495
  } else if ((labelInfo.sourceElements || []).some(isIconFontElement)) {
502
- uncertainty = 'POSSIBLE_ICON_FONT_GLYPH';
496
+ uncertainReason = 'POSSIBLE_ICON_FONT_GLYPH';
503
497
  } else if (isSingleSymbolicCharacter(visibleLabel, accNorm)) {
504
- uncertainty = 'POSSIBLE_SYMBOLIC_CHARACTER';
498
+ uncertainReason = 'POSSIBLE_SYMBOLIC_CHARACTER';
505
499
  }
506
500
  }
507
501
  }
508
502
 
509
503
  const isSymbolicUncertainty =
510
- uncertainty === 'POSSIBLE_ICON_FONT_GLYPH' || uncertainty === 'POSSIBLE_SYMBOLIC_CHARACTER';
504
+ uncertainReason === 'POSSIBLE_ICON_FONT_GLYPH' ||
505
+ uncertainReason === 'POSSIBLE_SYMBOLIC_CHARACTER';
511
506
 
512
507
  if (!contains) {
513
508
  occurrences.push(
514
509
  helpers.reportOccurrence(el, {
515
- ...(uncertainty ? { outcome: 'cantTell' } : null),
516
- summary: uncertainty
510
+ ...(uncertainReason ? { outcome: 'cantTell' } : null),
511
+ summary: uncertainReason
517
512
  ? 'Accessible name may not contain the visible label text.'
518
513
  : 'Accessible name does not contain the visible label text.',
519
- hint: !uncertainty
514
+ hint: !uncertainReason
520
515
  ? 'Ensure the accessible name includes the visible text label (e.g., update aria-label/aria-labelledby to include the visible wording).'
521
516
  : isSymbolicUncertainty
522
517
  ? 'Check by hand: the visible text may render as an icon or symbol rather than literal words, which markup cannot settle.'
523
518
  : 'Check by hand: the two differ only by an abbreviation or by hyphenation, which markup cannot settle.',
524
519
  i18n: {
525
- summaryKey: !uncertainty
520
+ summaryKey: !uncertainReason
526
521
  ? 'labelInName_summary_fail'
527
522
  : isSymbolicUncertainty
528
523
  ? 'labelInName_summary_cantTell_symbolic'
529
524
  : 'labelInName_summary_cantTell',
530
- hintKey: !uncertainty
525
+ hintKey: !uncertainReason
531
526
  ? 'labelInName_hint_fail'
532
527
  : isSymbolicUncertainty
533
528
  ? 'labelInName_hint_cantTell_symbolic'
@@ -539,9 +534,23 @@ function runInPage(ctx) {
539
534
  nameMechanism: acc && acc.mechanism ? acc.mechanism : 'none'
540
535
  }
541
536
  },
537
+ ...(uncertainReason
538
+ ? {
539
+ uncertainty: {
540
+ code: 'equivalence-unknown',
541
+ needed:
542
+ 'Whether the accessible name and the visible label say the same thing to a user.',
543
+ evidence: {
544
+ visibleLabel,
545
+ accessibleName: accName,
546
+ difference: uncertainReason
547
+ }
548
+ }
549
+ }
550
+ : null),
542
551
  data: {
543
552
  details: {
544
- reasonCode: uncertainty || 'VISIBLE_LABEL_NOT_IN_ACCESSIBLE_NAME',
553
+ reasonCode: uncertainReason || 'VISIBLE_LABEL_NOT_IN_ACCESSIBLE_NAME',
545
554
  visibleLabel,
546
555
  accessibleName: accName,
547
556
  normalized: { visibleLabel: visibleNorm, accessibleName: accNorm },
@@ -16,16 +16,21 @@
16
16
  *
17
17
  * @expectation
18
18
  * The <html> element has a lang attribute.
19
- * The lang attribute is not empty and contains a syntactically valid language tag.
19
+ * The lang attribute is not empty and its primary language subtag (the
20
+ * part before the first hyphen) is a registered language subtag, as ACT
21
+ * bf051a requires. A malformed later
22
+ * subtag (lang="fr-FR-!!") still identifies French, so it passes here;
23
+ * it is a markup validity error, not a missing language.
20
24
  *
21
25
  * @outcomes
22
26
  * Pass:
23
- * The <html> element has a non-empty lang attribute with a valid language tag.
27
+ * The <html> element has a non-empty lang attribute whose primary subtag is valid.
24
28
  *
25
29
  * Fail:
26
30
  * The <html> element does not have a lang attribute.
27
31
  * The <html> element has a lang attribute that is present but empty or whitespace.
28
- * The <html> element has a lang attribute with an invalid language tag.
32
+ * The <html> element has a lang attribute whose primary subtag is not a
33
+ * registered language subtag.
29
34
  *
30
35
  * @notes
31
36
  * This rule does not verify that the declared language matches the actual language
@@ -143,12 +148,13 @@ function runInPage(ctx) {
143
148
  };
144
149
  }
145
150
 
146
- // Minimal BCP47 primary subtag check
151
+ // Only the primary subtag is judged (ACT bf051a); the shared helper checks
152
+ // its shape and that the IANA registry lists it.
147
153
  const isValidTag =
148
154
  helpers && typeof helpers.isValidLanguageTag === 'function'
149
155
  ? helpers.isValidLanguageTag
150
- : (v) => /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/.test(String(v || ''));
151
- if (!isValidTag(lang)) {
156
+ : (v) => /^[a-zA-Z]{2,3}$/.test(String(v || ''));
157
+ if (!isValidTag(lang.split('-')[0])) {
152
158
  return {
153
159
  ruleId: rule.ruleId,
154
160
  outcome: 'fail',