@surea11y/core 1.7.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/CHANGELOG.md +103 -1
  2. package/README.md +157 -54
  3. package/docs/ACT_RULE_MAPPING.md +8 -7
  4. package/docs/API_STABILITY.md +18 -5
  5. package/docs/BINDING_AUTHORS_GUIDE.md +2 -2
  6. package/docs/CI_INTEGRATIONS.md +43 -0
  7. package/docs/DESIGN_CHALLENGES.md +97 -3
  8. package/docs/EARL.md +2 -2
  9. package/docs/ENGINE_OPTIONS.md +81 -3
  10. package/docs/I18N.md +62 -20
  11. package/docs/JUNIT.md +73 -0
  12. package/docs/LIMITATIONS.md +1 -0
  13. package/docs/OUTPUT_SCHEMA.md +19 -6
  14. package/docs/REPORT.md +7 -2
  15. package/docs/RULE_AUTHORING.md +73 -6
  16. package/docs/RULE_CATALOG.md +139 -116
  17. package/docs/RULE_EXAMPLES.md +2189 -0
  18. package/docs/RULE_HELPERS.md +62 -5
  19. package/docs/RULE_TAXONOMY.md +2 -2
  20. package/docs/SARIF.md +2 -1
  21. package/docs/WCAG_CONFORMANCE.md +56 -3
  22. package/package.json +34 -11
  23. package/profiles/index.js +14 -0
  24. package/src/checks/automatic/area-alt-present.js +87 -31
  25. package/src/checks/automatic/aria-braille-equivalent.js +25 -7
  26. package/src/checks/automatic/aria-hidden-focus.js +74 -18
  27. package/src/checks/automatic/aria-prohibited-attr.js +17 -4
  28. package/src/checks/automatic/aria-required-attr.js +29 -0
  29. package/src/checks/automatic/aria-role-name-present.js +19 -2
  30. package/src/checks/automatic/aria-valid-attr-value.js +28 -16
  31. package/src/checks/automatic/autocomplete-valid.js +26 -11
  32. package/src/checks/automatic/avoid-inline-spacing.js +105 -40
  33. package/src/checks/automatic/button-name-present.js +2 -1
  34. package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
  35. package/src/checks/automatic/combobox-name-present.js +34 -51
  36. package/src/checks/automatic/contrast-computable.js +35 -4
  37. package/src/checks/automatic/contrast-enhanced.js +4 -4
  38. package/src/checks/automatic/contrast-minimum.js +45 -11
  39. package/src/checks/automatic/css-orientation-lock.js +152 -30
  40. package/src/checks/automatic/definition-list-children-valid.js +67 -23
  41. package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
  42. package/src/checks/automatic/dialog-name-present.js +28 -9
  43. package/src/checks/automatic/duplicate-id.js +6 -2
  44. package/src/checks/automatic/identical-iframes-same-purpose.js +4 -4
  45. package/src/checks/automatic/iframe-focusable-content.js +7 -4
  46. package/src/checks/automatic/iframe-title-unique.js +36 -81
  47. package/src/checks/automatic/input-image-alt-present.js +32 -20
  48. package/src/checks/automatic/label-in-name.js +40 -13
  49. package/src/checks/automatic/language-page-present.js +12 -6
  50. package/src/checks/automatic/link-in-text-block.js +272 -60
  51. package/src/checks/automatic/link-name-present.js +13 -5
  52. package/src/checks/automatic/list-children-valid.js +18 -1
  53. package/src/checks/automatic/listbox-name-present.js +19 -49
  54. package/src/checks/automatic/listitem-parent-valid.js +4 -3
  55. package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
  56. package/src/checks/automatic/page-title-present.js +16 -4
  57. package/src/checks/automatic/progressbar-name-present.js +11 -1
  58. package/src/checks/automatic/role-img-text-alternative-present.js +9 -5
  59. package/src/checks/automatic/searchbox-name-present.js +32 -49
  60. package/src/checks/automatic/server-side-image-map-absent.js +48 -28
  61. package/src/checks/automatic/slider-name-present.js +38 -52
  62. package/src/checks/automatic/spinbutton-name-present.js +32 -49
  63. package/src/checks/automatic/target-size-minimum.js +0 -11
  64. package/src/checks/automatic/td-has-header.js +41 -5
  65. package/src/checks/automatic/text-spacing-content-loss.js +548 -0
  66. package/src/checks/automatic/textbox-name-present.js +32 -49
  67. package/src/checks/automatic/valid-lang.js +15 -10
  68. package/src/checks/manual/area-alt-quality-manual.js +113 -31
  69. package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
  70. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
  71. package/src/checks/manual/css-hidden-focus.js +215 -7
  72. package/src/checks/manual/form-control-label-quality-manual.js +109 -5
  73. package/src/checks/manual/heading-order-manual.js +9 -1
  74. package/src/checks/manual/heading-quality-manual.js +143 -9
  75. package/src/checks/manual/img-alt-decorative-manual.js +6 -3
  76. package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
  77. package/src/checks/manual/link-name-quality-manual.js +130 -4
  78. package/src/checks/manual/media-transcript-present-manual.js +65 -8
  79. package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
  80. package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
  81. package/src/checks/manual/p-as-heading-manual.js +89 -44
  82. package/src/checks/manual/page-title-patterns-manual.js +77 -8
  83. package/src/checks/manual/skip-link-manual.js +42 -14
  84. package/src/checks/manual/table-fake-caption-manual.js +32 -1
  85. package/src/checks/manual/video-caption-manual.js +47 -24
  86. package/src/checks/manual-review.js +0 -4
  87. package/src/core.js +14285 -2219
  88. package/src/coverage/en301549-map.js +187 -0
  89. package/src/coverage/standards.js +279 -0
  90. package/src/coverage/wcag-facets.js +1119 -0
  91. package/src/coverage/wcag-version-map.js +101 -0
  92. package/src/en301549.js +33 -0
  93. package/src/junit.js +321 -0
  94. package/src/profile-kit.js +163 -0
  95. package/src/report.js +343 -74
  96. package/src/sarif.js +34 -3
  97. package/src/wcag.js +105 -0
  98. package/surea11y.browser.js +5 -4
  99. package/surea11y.i18n.de.js +1 -1
  100. package/surea11y.i18n.es.js +1 -1
  101. package/surea11y.i18n.fr.js +1 -1
  102. package/surea11y.i18n.ja.js +3 -0
  103. package/src/checks/manual/area-alt-decorative-manual.js +0 -255
@@ -386,10 +386,13 @@ function runInPage(ctx) {
386
386
  if (runtimeProbe && runtimeProbe.redirected) {
387
387
  cantTellOccurrences.push(
388
388
  helpers.reportOccurrence(el, {
389
- summary:
390
- 'This frame has tabindex="-1" and a focusable candidate, but focus moves immediately to another target. Verify keyboard reachability in a real browser.',
391
- hint: 'If this is an intentional focus handoff, ensure keyboard users cannot remain on hidden/intermediate frame content.',
392
- i18n: null,
389
+ summary: `This <${tag}> has tabindex="-1" and focusable content, but focus moves immediately to another target. Verify keyboard reachability in a real browser.`,
390
+ hint: 'If this is an intentional focus handoff, make sure keyboard users cannot remain on hidden or intermediate frame content.',
391
+ i18n: {
392
+ summaryKey: 'iframeFocusableContent_summary_cantTell_redirect',
393
+ hintKey: 'iframeFocusableContent_hint_cantTell_redirect',
394
+ params: { element: tag }
395
+ },
393
396
  uncertainty: {
394
397
  code: 'runtime-dependent',
395
398
  needed: 'Whether a keyboard user can reach this frame’s content in a real browser.',
@@ -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) : '';
@@ -435,8 +444,6 @@ function runInPage(ctx) {
435
444
  // Applicability: only when we can deterministically extract visible label text.
436
445
  if (!visibleNorm) continue;
437
446
 
438
- applicableCount += 1;
439
-
440
447
  let acc = { present: false, value: '', mechanism: 'none', flags: [] };
441
448
  try {
442
449
  acc = helpers.getAccessibleNameInfo
@@ -445,6 +452,26 @@ function runInPage(ctx) {
445
452
  } catch {
446
453
  acc = { present: false, value: '', mechanism: 'none', flags: ['exception'] };
447
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;
448
475
  const accName = acc && acc.value != null ? String(acc.value) : '';
449
476
  const accNorm = norm(accName);
450
477
 
@@ -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',