@markuplint/rules 5.4.0 → 5.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 (116) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/CLAUDE.md +4 -3
  3. package/lib/aria-prop-requires-role/index.js +2 -2
  4. package/lib/attr-eligibility.d.ts +21 -0
  5. package/lib/attr-eligibility.js +86 -6
  6. package/lib/attr-order/index.js +2 -1
  7. package/lib/attr-value-quotes/index.js +2 -2
  8. package/lib/consistent-table-row-length/index.js +1 -1
  9. package/lib/create-message.js +6 -5
  10. package/lib/element-supports-aria-prop/index.js +2 -2
  11. package/lib/form-attr-references-form/index.js +3 -3
  12. package/lib/helpers.d.ts +96 -5
  13. package/lib/helpers.js +187 -29
  14. package/lib/input-list-references-datalist/index.js +3 -3
  15. package/lib/itemprop-requires-itemscope/index.js +28 -11
  16. package/lib/label-for-references-labelable/index.js +3 -3
  17. package/lib/label-has-control/index.d.ts +17 -0
  18. package/lib/label-has-control/index.js +28 -1
  19. package/lib/label-no-multiple-controls/index.d.ts +4 -5
  20. package/lib/label-no-multiple-controls/index.js +8 -8
  21. package/lib/landmark-roles/roles.d.ts +13 -1
  22. package/lib/landmark-roles/roles.js +22 -0
  23. package/lib/link-types/index.js +8 -8
  24. package/lib/map-id-name-match/index.js +1 -1
  25. package/lib/meter-value-bounds/index.js +3 -3
  26. package/lib/no-ambiguous-navigable-target-names/index.js +5 -1
  27. package/lib/no-aria-hidden-on-hidden-until-found/index.js +1 -1
  28. package/lib/no-aria-on-presentational-children/index.js +4 -0
  29. package/lib/no-aria-on-unsupported-element/index.js +2 -2
  30. package/lib/no-boolean-attr-value/index.js +2 -1
  31. package/lib/no-broken-fragment-link/index.d.ts +5 -0
  32. package/lib/no-broken-fragment-link/index.js +8 -2
  33. package/lib/no-consecutive-br/index.js +1 -1
  34. package/lib/no-content-after-body/index.js +2 -2
  35. package/lib/no-contradictory-aria-prop/index.js +2 -2
  36. package/lib/no-default-aria-value/index.js +2 -2
  37. package/lib/no-default-value/index.js +4 -0
  38. package/lib/no-deprecated-aria-prop/index.js +2 -2
  39. package/lib/no-disallowed-ancestor/index.d.ts +16 -1
  40. package/lib/no-disallowed-ancestor/index.js +68 -5
  41. package/lib/no-disallowed-attr/index.js +5 -1
  42. package/lib/no-duplicate-autofocus/index.js +1 -1
  43. package/lib/no-duplicate-dt/index.js +1 -1
  44. package/lib/no-duplicate-h1/index.js +18 -14
  45. package/lib/no-duplicate-id/index.d.ts +9 -0
  46. package/lib/no-duplicate-id/index.js +23 -4
  47. package/lib/no-duplicate-sibling-attr/index.js +1 -1
  48. package/lib/no-duplicate-visible-main/index.js +1 -1
  49. package/lib/no-empty-table-track/index.js +1 -1
  50. package/lib/no-extra-selected-options/index.js +1 -1
  51. package/lib/no-ineffective-attr/index.js +4 -0
  52. package/lib/no-input-file-value/index.js +1 -1
  53. package/lib/no-invalid-aria-prop-value/index.js +3 -3
  54. package/lib/no-invalid-attr-value/index.js +12 -0
  55. package/lib/no-malformed-character-reference/index.js +1 -1
  56. package/lib/no-nested-top-level-landmark/index.js +20 -17
  57. package/lib/no-prohibited-naming/index.js +2 -2
  58. package/lib/no-redundant-accessible-name/index.js +15 -8
  59. package/lib/no-redundant-aria-prop/index.js +2 -2
  60. package/lib/no-refer-to-non-existent-id/index.d.ts +15 -0
  61. package/lib/no-refer-to-non-existent-id/index.js +47 -16
  62. package/lib/no-restricted-attr/index.js +2 -1
  63. package/lib/no-restricted-element/index.js +4 -2
  64. package/lib/no-skipped-heading-level/index.js +9 -4
  65. package/lib/no-stray-head-or-body-tag/index.d.ts +2 -1
  66. package/lib/no-stray-head-or-body-tag/index.js +4 -3
  67. package/lib/no-table-cell-overlap/index.js +3 -2
  68. package/lib/no-table-span-overflow/index.js +1 -1
  69. package/lib/no-translate.d.ts +17 -0
  70. package/lib/no-translate.js +29 -0
  71. package/lib/no-unclosed-element-at-eof/index.js +1 -1
  72. package/lib/no-unescaped-char/index.js +1 -1
  73. package/lib/no-unsupported-browser-features/index.d.ts +9 -0
  74. package/lib/no-unsupported-browser-features/index.js +10 -7
  75. package/lib/permitted-contents/content-model.d.ts +14 -0
  76. package/lib/permitted-contents/content-model.js +21 -1
  77. package/lib/permitted-contents/index.js +6 -8
  78. package/lib/permitted-contents/matches-selector.js +32 -5
  79. package/lib/permitted-contents/represent-transparent-nodes.js +100 -68
  80. package/lib/permitted-contents/slot-content.d.ts +18 -1
  81. package/lib/permitted-contents/slot-content.js +49 -2
  82. package/lib/permitted-contents/start.d.ts +8 -1
  83. package/lib/permitted-contents/start.js +39 -6
  84. package/lib/permitted-contents/utils.d.ts +11 -0
  85. package/lib/permitted-contents/utils.js +15 -0
  86. package/lib/placeholder-label-option/index.js +4 -26
  87. package/lib/progress-value-bounds/index.js +2 -2
  88. package/lib/require-accessible-name/index.js +2 -2
  89. package/lib/require-adjacent-popover/index.js +4 -2
  90. package/lib/require-ancestor/index.d.ts +37 -1
  91. package/lib/require-ancestor/index.js +82 -9
  92. package/lib/require-ancestor/meta.d.ts +5 -0
  93. package/lib/require-ancestor/meta.js +5 -0
  94. package/lib/require-attr/index.js +4 -1
  95. package/lib/require-datetime/index.js +3 -2
  96. package/lib/require-dialog-autofocus/index.js +8 -6
  97. package/lib/require-element/index.js +8 -2
  98. package/lib/require-h1/index.js +4 -8
  99. package/lib/require-landmark-label/index.js +17 -15
  100. package/lib/role-supports-aria-prop/index.js +2 -2
  101. package/lib/sizes-auto-requires-lazy-loading/index.d.ts +4 -2
  102. package/lib/sizes-auto-requires-lazy-loading/index.js +4 -2
  103. package/lib/usemap-references-map/index.js +12 -3
  104. package/lib/valid-speculation-rules/index.js +1 -1
  105. package/lib/wai-aria/checkings/contradictory-aria-prop.js +2 -2
  106. package/lib/wai-aria/checkings/interaction-in-hidden.js +6 -2
  107. package/lib/wai-aria/checkings/presentational-children.js +38 -17
  108. package/lib/wai-aria/checkings/redundant-aria-prop.js +1 -1
  109. package/lib/wai-aria/checkings/required-accessibility-parent-role.js +4 -2
  110. package/lib/wai-aria/checkings/required-owned-elements.d.ts +2 -0
  111. package/lib/wai-aria/checkings/required-owned-elements.js +175 -21
  112. package/lib/wai-aria/checkings/required-prop.js +2 -1
  113. package/lib/wai-aria/checkings/tab-requires-tabpanel.js +10 -6
  114. package/lib/wai-aria/checkings/value.d.ts +1 -4
  115. package/lib/wai-aria/checkings/value.js +8 -19
  116. package/package.json +14 -11
@@ -1,5 +1,5 @@
1
- import { isAriaAttr, labelable, memoizeByObject, namespaceKeyOf } from '../../helpers.js';
2
- import { ARIA_RECOMMENDED_VERSION, EMBEDDED_CONTROL_ROLES, getComputedRole, getImplicitRole, isExposedAsPresentational, isNativeEmbeddedControl, } from '@markuplint/ml-spec';
1
+ import { getDOMValue, getElementByIdInTree, isAriaAttr, labelable, memoizeByObject, namespaceKeyOf, } from '../../helpers.js';
2
+ import { ARIA_RECOMMENDED_VERSION, EMBEDDED_CONTROL_ROLES, getComposedParentElement, getComputedRole, getImplicitRole, isExposedAsPresentational, isNativeEmbeddedControl, } from '@markuplint/ml-spec';
3
3
  /**
4
4
  * Checks whether an ARIA attribute or `role` is applied to a descendant of an
5
5
  * element whose role has the `childrenArePresentational` characteristic.
@@ -75,6 +75,9 @@ const getContext = memoizeByObject(
75
75
  * Finds the closest ancestor whose computed role has the
76
76
  * `childrenArePresentational` characteristic.
77
77
  *
78
+ * The ancestors are those of the accessibility tree ({@link getComposedParentElement}): the search ends at the
79
+ * top of the content of an ordinary `template` element, and it goes on from a declarative shadow root to its host.
80
+ *
78
81
  * @param el - The element to start searching from (exclusive, starts from parent).
79
82
  * @param ariaVersion - The ARIA version to compute the roles with.
80
83
  * @returns The role of that ancestor, or `null` if none is found.
@@ -82,13 +85,13 @@ const getContext = memoizeByObject(
82
85
  function getClosestPresentationalChildrenRole(
83
86
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
84
87
  el, ariaVersion) {
85
- let current = el.parentElement;
88
+ let current = getComposedParentElement(el);
86
89
  while (current) {
87
90
  const role = getComputedRole(el.ownerMLDocument.specs, current, ariaVersion).role;
88
91
  if (role?.childrenPresentational) {
89
92
  return role;
90
93
  }
91
- current = current.parentElement;
94
+ current = getComposedParentElement(current);
92
95
  }
93
96
  return null;
94
97
  }
@@ -104,6 +107,10 @@ el, ariaVersion) {
104
107
  * - the accessible name of every control that a `label` ancestor can name: the
105
108
  * element its `for` refers to, and its labelable descendants (HTML-AAM)
106
109
  *
110
+ * The ancestors are those of the accessibility tree ({@link getComposedParentElement}): an ancestor
111
+ * outside of the content of an ordinary `template` element reads nothing in it, as the content is not
112
+ * rendered there.
113
+ *
107
114
  * @see https://w3c.github.io/accname/#comp_name_from_content
108
115
  * @see https://w3c.github.io/accname/#comp_labelledby_traversal
109
116
  * @see https://w3c.github.io/html-aam/#accessible-name-and-description-computation
@@ -128,13 +135,17 @@ el, ariaVersion) {
128
135
  if (current !== el) {
129
136
  add(current);
130
137
  }
131
- if (current.id && referenced.has(current.id) && document.getElementById(current.id) === current) {
138
+ // An `aria-labelledby` refers to an element of its own tree
139
+ const idValue = getDOMValue(current.getAttributeNode('id'));
140
+ if (idValue &&
141
+ referenced.get(current.getRootNode())?.has(idValue) &&
142
+ getElementByIdInTree(current, idValue, true) === current) {
132
143
  add(current, true);
133
144
  }
134
145
  for (const control of getLabeledControls(current)) {
135
146
  add(control);
136
147
  }
137
- current = current.parentElement;
148
+ current = getComposedParentElement(current);
138
149
  }
139
150
  return visits;
140
151
  }
@@ -151,27 +162,36 @@ const getLabeledControls = memoizeByObject(
151
162
  if (el.localName !== 'label' || el.namespaceURI !== 'http://www.w3.org/1999/xhtml') {
152
163
  return [];
153
164
  }
154
- const htmlFor = el.getAttribute('for');
155
- const target = htmlFor == null ? null : el.ownerMLDocument.getElementById(htmlFor);
165
+ const forAttr = el.getAttributeNode('for');
166
+ const target = forAttr == null ? null : getElementByIdInTree(el, forAttr.value, forAttr.isExpressionValue);
156
167
  return [...(target ? [target] : []), ...el.querySelectorAll(labelable.join(', '))];
157
168
  });
158
169
  /**
159
- * The IDs that an `aria-labelledby` or `aria-describedby` of the document refers to.
170
+ * The IDs that an `aria-labelledby` or `aria-describedby` refers to, by the tree of the referrer: an
171
+ * id is resolved in the tree of the element that refers to it.
160
172
  */
161
173
  const getReferencedIds = memoizeByObject(
162
174
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
163
175
  (document) => {
164
- const ids = new Set();
165
- for (const referrer of document.querySelectorAll('[aria-labelledby], [aria-describedby]')) {
176
+ const idsOfTree = new Map();
177
+ for (const referrer of document.deepQuerySelectorAll('[aria-labelledby], [aria-describedby]')) {
178
+ const root = referrer.getRootNode();
179
+ let ids = idsOfTree.get(root);
180
+ if (!ids) {
181
+ ids = new Set();
182
+ idsOfTree.set(root, ids);
183
+ }
166
184
  for (const name of ['aria-labelledby', 'aria-describedby']) {
167
- for (const id of referrer.getAttribute(name)?.split(/\s+/) ?? []) {
168
- if (id !== '') {
169
- ids.add(id);
185
+ const attr = referrer.getAttributeNode(name);
186
+ for (const id of attr?.value.split(/\s+/) ?? []) {
187
+ const domId = id === '' ? null : getDOMValue(attr, id);
188
+ if (domId != null) {
189
+ ids.add(domId);
170
190
  }
171
191
  }
172
192
  }
173
193
  }
174
- return ids;
194
+ return idsOfTree;
175
195
  });
176
196
  /**
177
197
  * Whether the attribute changes what its element contributes to a text
@@ -275,11 +295,12 @@ el, computed) {
275
295
  function labelledbyGivesText(
276
296
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
277
297
  el, ariaVersion) {
278
- return (el.getAttribute('aria-labelledby')?.split(/\s+/) ?? []).some(id => {
298
+ const labelledbyAttr = el.getAttributeNode('aria-labelledby');
299
+ return (labelledbyAttr?.value.split(/\s+/) ?? []).some(id => {
279
300
  if (id === '') {
280
301
  return false;
281
302
  }
282
- const referenced = el.ownerMLDocument.getElementById(id);
303
+ const referenced = getElementByIdInTree(el, id, labelledbyAttr?.isExpressionValue);
283
304
  return referenced != null && referenced.getReferencedTextAlternative(ariaVersion) !== '';
284
305
  });
285
306
  }
@@ -44,7 +44,7 @@ export const checkingRedundantAriaProp = ({ attr, propSpecs, attrSpecs }) => t =
44
44
  if ((equivalentHtmlAttr.value == null && targetAttrValue === value) || equivalentHtmlAttr.value === value) {
45
45
  return {
46
46
  scope: attr,
47
- message: t('{0} has {1}', t('the "{0*}" {1}', attr.name, `ARIA ${propSpec.type}`), t('the same {0} as {1}', 'semantics', t('{0} or {1}', t('the current "{0}" {1}', equivalentHtmlAttr.htmlAttrName, 'attribute'), t('the implicit "{0}" {1}', equivalentHtmlAttr.htmlAttrName, 'attribute')))),
47
+ message: t('{0} has {1}', t('the "{0*}" {1}', attr.name, `ARIA ${propSpec.type}`), t('the same {0} as {1}', 'semantics', t('{0} or {1}', t('the current "{0*}" {1}', equivalentHtmlAttr.htmlAttrName, 'attribute'), t('the implicit "{0*}" {1}', equivalentHtmlAttr.htmlAttrName, 'attribute')))),
48
48
  };
49
49
  }
50
50
  }
@@ -1,4 +1,5 @@
1
1
  import { ARIA_RECOMMENDED_VERSION, ariaSpecs } from '@markuplint/ml-spec';
2
+ import { noTranslate } from '../../no-translate.js';
2
3
  /**
3
4
  * Checks whether an element with an explicit role satisfies its
4
5
  * "Required Accessibility Parent Role" (called "Required Context Role" in ARIA 1.2).
@@ -23,7 +24,8 @@ export const checkingRequiredAccessibilityParentRole = ({ el, computed }) => t =
23
24
  (computed.errorType !== 'INVALID_REQUIRED_CONTEXT_ROLE' &&
24
25
  // NO_OWNER with role: null means all ancestors were transparent
25
26
  // and no valid context could be found. NO_OWNER with role kept
26
- // (parentElement === null) is a root fragment — skip.
27
+ // (no parent in the accessibility tree) is a root fragment, or the top
28
+ // of the content of an ordinary `template` element — skip.
27
29
  !(computed.errorType === 'NO_OWNER' && !computed.role))) {
28
30
  return;
29
31
  }
@@ -49,7 +51,7 @@ export const checkingRequiredAccessibilityParentRole = ({ el, computed }) => t =
49
51
  message: t('{0} requires {1}', t('the "{0*}" {1}', roleSpec.name, 'role'), parentRoles.length === 1 && parentRoles[0]
50
52
  ? t('an accessibility parent with the "{0*}" {1}', parentRoles[0], 'role')
51
53
  : t('an accessibility parent with one of the {0}', 'roles') +
52
- `: ${t(parentRoles)}`),
54
+ `: ${t(parentRoles.map(noTranslate))}`),
53
55
  };
54
56
  }
55
57
  }
@@ -11,6 +11,8 @@ import type { ARIARole } from '@markuplint/ml-spec';
11
11
  * preprocessor blocks, and mutable children from template engines.
12
12
  * For a pretended component, the owned elements are the ones the component renders
13
13
  * itself (`contents`) together with the children given to it, not the usage site alone.
14
+ * For a shadow host, they are the elements of its declarative shadow root, which is
15
+ * rendered in place of its light children (see `getOwnedChildNodes`).
14
16
  *
15
17
  * @see https://w3c.github.io/aria/#mustContain
16
18
  * @param el - The element node to inspect for allowed accessibility child roles.
@@ -1,5 +1,6 @@
1
- import { ARIA_RECOMMENDED_VERSION, getComputedRole, isRequiredOwnedElement, isTransparentForOwnership, } from '@markuplint/ml-spec';
1
+ import { ARIA_RECOMMENDED_VERSION, getComposedParentElement, getComputedRole, isRequiredOwnedElement, isSlotElement, isTransparentForOwnership, } from '@markuplint/ml-spec';
2
2
  import { expandChildren, isContentMutable } from '../../permitted-contents/slot-content.js';
3
+ import { noTranslate } from '../../no-translate.js';
3
4
  /**
4
5
  * Checks whether an element with a role that has "Allowed Accessibility Child Roles"
5
6
  * (called "Required Owned Elements" in ARIA 1.2) actually contains children
@@ -10,6 +11,8 @@ import { expandChildren, isContentMutable } from '../../permitted-contents/slot-
10
11
  * preprocessor blocks, and mutable children from template engines.
11
12
  * For a pretended component, the owned elements are the ones the component renders
12
13
  * itself (`contents`) together with the children given to it, not the usage site alone.
14
+ * For a shadow host, they are the elements of its declarative shadow root, which is
15
+ * rendered in place of its light children (see `getOwnedChildNodes`).
13
16
  *
14
17
  * @see https://w3c.github.io/aria/#mustContain
15
18
  * @param el - The element node to inspect for allowed accessibility child roles.
@@ -37,10 +40,12 @@ export const checkingRequiredOwnedElements = ({ el, role }) => t => {
37
40
  return;
38
41
  }
39
42
  // TODO: Needs to resolve `aria-owns` references to include virtually owned elements.
40
- // Currently, only DOM-tree children are checked. Elements referenced via `aria-owns`
41
- // should also be considered as owned elements per the ARIA specification.
43
+ // Currently, only the child nodes (those of the shadow root for a shadow host) are checked.
44
+ // Elements referenced via `aria-owns` should also be considered as owned elements per the
45
+ // ARIA specification.
42
46
  const ariaVersion = el.rule.options?.version ?? el.ownerMLDocument.ruleCommonSettings?.ariaVersion ?? ARIA_RECOMMENDED_VERSION;
43
- const children = classifyChildren(el, role, ariaVersion);
47
+ const ownedChildNodes = getOwnedChildNodes(el);
48
+ const children = classifyChildren(el, ownedChildNodes, role, ariaVersion);
44
49
  if (children.some(([, type]) => type === 'BUSY' || type === 'UNKNOWN')) {
45
50
  return;
46
51
  }
@@ -71,30 +76,30 @@ export const checkingRequiredOwnedElements = ({ el, role }) => t => {
71
76
  */
72
77
  // A virtual slot wrapper stands for the component, whose nodes are the ones given to it.
73
78
  const component = el.pretenderContext?.type === 'origin' ? el.pretenderContext.origin : el;
74
- if (component.hasMutableChildren(true) || isContentMutable(component, 'pretended')) {
79
+ if (isOwnedContentMutable(component)) {
75
80
  return;
76
81
  }
77
- if (mayBeBeforeCreated(getOwnedChildNodes(el) ?? [])) {
82
+ if (mayBeBeforeCreated(ownedChildNodes ?? [])) {
78
83
  return {
79
84
  scope: component,
80
85
  message: t('{0}. Or, {1}', t('{0} requires {1}', t('the {0}', 'child element'), role.allowedAccessibilityChildRoles.length === 1 && role.allowedAccessibilityChildRoles[0]
81
86
  ? t('the "{0*}" {1}', role.allowedAccessibilityChildRoles[0], 'role')
82
- : t('the {0}', 'roles') + `: ${t(role.allowedAccessibilityChildRoles)}`), t('require {0}', 'aria-busy="true"')),
87
+ : t('the {0}', 'roles') + `: ${t(role.allowedAccessibilityChildRoles.map(noTranslate))}`), t('require {0*}', 'aria-busy="true"')),
83
88
  };
84
89
  }
85
90
  return {
86
91
  scope: component,
87
92
  message: t('{0} expects {1}', t('the "{0*}" {1}', role.name, 'role'), role.allowedAccessibilityChildRoles.length === 1 && role.allowedAccessibilityChildRoles[0]
88
93
  ? t('the "{0*}" {1}', role.allowedAccessibilityChildRoles[0], 'role')
89
- : t('the {0}', 'roles') + `: ${t(role.allowedAccessibilityChildRoles)}`),
94
+ : t('the {0}', 'roles') + `: ${t(role.allowedAccessibilityChildRoles.map(noTranslate))}`),
90
95
  };
91
96
  };
92
97
  /**
93
- * Determines whether the element's children may not yet exist (e.g., empty or
98
+ * Determines whether the owned child nodes may not yet exist (e.g., empty or
94
99
  * containing only `<script>` / `<template>` elements that could dynamically create content).
95
100
  *
96
- * @param el - The element to inspect.
97
- * @returns `true` if the element is empty or only contains script/template children.
101
+ * @param childNodes - The child nodes the element owns (see `getOwnedChildNodes`).
102
+ * @returns `true` if the nodes are empty or only contain script/template elements.
98
103
  */
99
104
  function mayBeBeforeCreated(childNodes) {
100
105
  if (childNodes.every(child => child.is(child.TEXT_NODE) && child.textContent?.trim() === '')) {
@@ -112,14 +117,14 @@ function mayBeBeforeCreated(childNodes) {
112
117
  * are evaluated as if they were direct children of the owning element.
113
118
  *
114
119
  * @param el - The parent element whose children to classify.
120
+ * @param childNodes - The child nodes `el` owns, from `getOwnedChildNodes`.
115
121
  * @param role - The ARIA role of the parent, which defines allowed child roles.
116
122
  * @param version - The WAI-ARIA version for version-gated transparency behavior.
117
123
  * @returns An array of classified child nodes.
118
124
  */
119
125
  function classifyChildren(
120
126
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
121
- el, role, version) {
122
- const childNodes = getOwnedChildNodes(el);
127
+ el, childNodes, role, version) {
123
128
  if (!childNodes) {
124
129
  return [[el, 'UNKNOWN']];
125
130
  }
@@ -130,24 +135,30 @@ el, role, version) {
130
135
  result.push([child, 'BUSY']);
131
136
  continue;
132
137
  }
138
+ if (isSlotElement(child)) {
139
+ result.push(...classifySlot(child, role, version));
140
+ continue;
141
+ }
133
142
  /**
134
- * The role of a child is resolved against its DOM parent. A child that a pretender
135
- * places somewhere other than under its DOM parent (a node given at the usage site
136
- * that the component renders inside its slot wrapper, or an element a fragment
137
- * component renders in place) would be judged against the wrong parent and lose
138
- * its role, so it is resolved on its own.
143
+ * The role of a child is resolved against its parent in the accessibility tree,
144
+ * which is the host for a child at the top of a declarative shadow root. A child
145
+ * that a pretender places somewhere other than under that parent (a node given at
146
+ * the usage site that the component renders inside its slot wrapper, or an element
147
+ * a fragment component renders in place) would be judged against the wrong parent
148
+ * and lose its role, so it is resolved on its own.
139
149
  */
140
- const computedChild = getComputedRole(child.ownerMLDocument.specs, child, version, child.parentElement !== el);
150
+ const computedChild = getComputedRole(child.ownerMLDocument.specs, child, version, getComposedParentElement(child) !== el);
141
151
  if (role.allowedAccessibilityChildRoles.some(ownedRole => isRequiredOwnedElement(computedChild.el, computedChild.role, ownedRole, child.ownerMLDocument.specs, version))) {
142
152
  result.push([child, 'REQUIRED']);
143
153
  continue;
144
154
  }
145
155
  if (isTransparentForOwnership(computedChild.role?.name, version)) {
146
- if (isContentMutable(child, 'pretended')) {
156
+ const shadowRootTemplate = getShadowRootTemplate(child);
157
+ if (shadowRootTemplate !== 'unknown' && isContentMutable(shadowRootTemplate ?? child, 'pretended')) {
147
158
  result.push([child, 'UNKNOWN']);
148
159
  continue;
149
160
  }
150
- result.push(...classifyChildren(child, role, version));
161
+ result.push(...classifyChildren(child, getOwnedChildNodes(child), role, version));
151
162
  continue;
152
163
  }
153
164
  result.push([child, 'OTHER']);
@@ -161,6 +172,45 @@ el, role, version) {
161
172
  }
162
173
  return result;
163
174
  }
175
+ /**
176
+ * Classifies what a `slot` that the owned elements are looked for through renders: the nodes assigned to
177
+ * it, or its own children when none is, in its place. A slot is not an element of the accessibility tree.
178
+ *
179
+ * The slot is reached only through the elements that are transparent for ownership, so what it renders
180
+ * is owned by the element as if it were written in place of the slot. A slot that is not reached (one
181
+ * in a `p`, or in the content of a nested `template`) places nothing as an owned element, and is not
182
+ * looked at. Where the assignment is not known (a slot that is not in a shadow root, a framework's, manual
183
+ * assignment, template syntax among the light children), what it renders is not known: `UNKNOWN`.
184
+ *
185
+ * The light children assigned to the slot are not in the shadow root that the mutability gate reads
186
+ * (`isOwnedContentMutable`), so the template syntax and the mutable attributes in them are looked at
187
+ * here: a node that may not be what it is written as keeps the rule from deciding.
188
+ *
189
+ * > If slottables is the empty list, then append each slottable child of slot, in tree order, to slottables.
190
+ *
191
+ * @see https://dom.spec.whatwg.org/#find-flattened-slotables
192
+ * @param slot - The `slot` element
193
+ * @param role - The ARIA role of the owner, which defines allowed child roles.
194
+ * @param version - The WAI-ARIA version for version-gated transparency behavior.
195
+ * @returns The classified nodes the slot renders.
196
+ */
197
+ function classifySlot(
198
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
199
+ slot, role, version) {
200
+ const assigned = slot.getAssignedNodes();
201
+ if (!assigned) {
202
+ return [[slot, 'UNKNOWN']];
203
+ }
204
+ if (assigned.length === 0) {
205
+ return classifyChildren(slot, getOwnedChildNodes(slot), role, version);
206
+ }
207
+ const mutable = assigned.some(node => node.is(node.ELEMENT_NODE) &&
208
+ (node.hasMutableAttributes() || node.hasMutableChildren(true, isFlattenedSlot)));
209
+ if (mutable) {
210
+ return [[slot, 'UNKNOWN']];
211
+ }
212
+ return classifyChildren(slot, assigned, role, version);
213
+ }
164
214
  /**
165
215
  * The child nodes that `el` owns as the page renders them.
166
216
  *
@@ -174,12 +224,37 @@ el, role, version) {
174
224
  * - Several slot wrappers leave the owner of each node unknown, so `null` is returned
175
225
  * and nothing is claimed about the element.
176
226
  *
227
+ * A shadow host owns the child nodes of its declarative shadow root, of either mode, and
228
+ * nothing else: the shadow root is rendered in place of the light children, which include
229
+ * the `template` that declares it and what a pretended component renders itself. This
230
+ * comes first, as ml-core attaches the root to a pretended host that renders the children
231
+ * written in it in itself. A `template` written in a block or a `template` with a directive
232
+ * may not be rendered, so `null` is returned for it (`getShadowRootTemplate`). The light children
233
+ * are rendered only where a `slot` in the shadow root places them: the nodes assigned to it
234
+ * (`MLElement#getAssignedNodes`) are classified in its place, and the light children that
235
+ * no slot takes are not owned (`classifySlot`).
236
+ *
237
+ * `getRenderedChildNodes` of `@markuplint/ml-core` (the accessible name computation)
238
+ * resolves the rendered children in the same order, but does not expand fragment
239
+ * components.
240
+ *
241
+ * > […] the flat tree is the top-level DOM tree, but shadow hosts are filled with their shadow tree children instead of their light tree children […]
242
+ *
243
+ * @see https://drafts.csswg.org/css-shadow-1/#flattening
177
244
  * @param el - The element, or the virtual slot wrapper of a pretended component.
178
245
  * @returns The child nodes, or `null` when they cannot be known.
179
246
  */
180
247
  function getOwnedChildNodes(
181
248
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
182
249
  el) {
250
+ // The content of the `template` is the shadow root, and holds the same nodes as its children.
251
+ const shadowRootTemplate = getShadowRootTemplate(el);
252
+ if (shadowRootTemplate === 'unknown') {
253
+ return null;
254
+ }
255
+ if (shadowRootTemplate) {
256
+ return expandChildren(shadowRootTemplate, [...shadowRootTemplate.childNodes], 'pretended');
257
+ }
183
258
  const context = el.pretenderContext;
184
259
  if (context?.type === 'pretender') {
185
260
  const wrapper = context.slotContent?.wrapper;
@@ -201,3 +276,82 @@ el) {
201
276
  }
202
277
  return expandChildren(el, [...el.childNodes], 'pretended');
203
278
  }
279
+ /**
280
+ * Whether a `slot` is one that `classifyChildren` cannot see: a parser that makes the `slot` of its
281
+ * framework a fragment (Vue, Astro) has it flattened out of the child nodes, and what it renders is
282
+ * not known. Any other `slot` is classified where the owned elements reach it (`classifySlot`), and
283
+ * one that is not reached places nothing as an owned element.
284
+ *
285
+ * @param slot - A `slot` element
286
+ * @returns `true` if the slot is flattened out of the child nodes
287
+ */
288
+ function isFlattenedSlot(
289
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
290
+ slot) {
291
+ return slot.isFragment;
292
+ }
293
+ /**
294
+ * Whether the nodes `el` owns may be rendered by something the markup does not show:
295
+ * template syntax, a mutable attribute or a component that no pretender resolved among them
296
+ * (`hasMutableChildren`), or a pretender whose `contents` are unknown (`isContentMutable`).
297
+ * For a shadow host they are read in the shadow root, as that is what it owns: a light
298
+ * child that no `slot` places is not rendered, mutable or not.
299
+ *
300
+ * A `slot` is not counted here unless it is flattened out of the child nodes (`isFlattenedSlot`):
301
+ * `classifySlot` reads what it renders where the owned elements are looked for through it, and a slot
302
+ * that is not reached places nothing as an owned element.
303
+ *
304
+ * @param el - The element, or the component a virtual slot wrapper stands for.
305
+ * @returns `true` when the owned nodes may differ from the markup.
306
+ */
307
+ function isOwnedContentMutable(
308
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
309
+ el) {
310
+ const template = getShadowRootTemplate(el);
311
+ if (template === 'unknown') {
312
+ return true;
313
+ }
314
+ const scope = template ?? el;
315
+ return scope.hasMutableChildren(true, isFlattenedSlot) || isContentMutable(scope, 'pretended');
316
+ }
317
+ /**
318
+ * The `template` element whose content is the declarative shadow root of `host`, which ml-core
319
+ * exposes without the element.
320
+ *
321
+ * ml-core finds the `template` through what a template engine renders away (a block, a
322
+ * `template` with a directive such as `v-if`), as the id lookups need a tree either way. Such
323
+ * a wrapper, or a conditional branch that leaves the `template` out, may render it or not,
324
+ * and the host owns the shadow root or its light children accordingly. So what the host owns
325
+ * is unknown unless the `template` is written right in the host and in every branch of its
326
+ * children (`conditionalChildNodes`), and no block among them has a range that the syntax
327
+ * tree does not give.
328
+ *
329
+ * @param host - An element.
330
+ * @returns The `template` element; `'unknown'` when it is written in such a wrapper; `null`
331
+ * when no shadow root is attached to `host`.
332
+ */
333
+ function getShadowRootTemplate(
334
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
335
+ host) {
336
+ const shadowRoot = host.declarativeShadowRoot;
337
+ if (!shadowRoot) {
338
+ return null;
339
+ }
340
+ // A block without a behavior may be a marker of a language whose blocks the syntax tree does
341
+ // not nest (`{% if %}` … `{% endif %}`), so whether it encloses the `template` is not known.
342
+ for (const child of host.getPureChildNodes()) {
343
+ if (child.is(child.MARKUPLINT_PREPROCESSOR_BLOCK) && !child.blockBehavior) {
344
+ return 'unknown';
345
+ }
346
+ }
347
+ for (const child of host.childNodes) {
348
+ if (child.is(child.ELEMENT_NODE) && child.content === shadowRoot) {
349
+ if (child.syntacticalParentNode !== host) {
350
+ return 'unknown';
351
+ }
352
+ const branches = host.conditionalChildNodes();
353
+ return branches.every(branch => [...branch].includes(child)) ? child : 'unknown';
354
+ }
355
+ }
356
+ return 'unknown';
357
+ }
@@ -34,7 +34,8 @@ export const checkingRequiredProp = ({ el, role, propSpecs }) => t => {
34
34
  return true;
35
35
  });
36
36
  for (const { name: requiredProp } of requiredProps) {
37
- const has = el.attributes.some(attr => attr.nameKey === requiredProp);
37
+ // An attribute that the framework does not output (`aria-checked={null}`) is not there
38
+ const has = el.attributes.some(attr => !attr.isAbsent && attr.nameKey === requiredProp);
38
39
  if (!has) {
39
40
  const propSpec = propSpecs.find(p => p.name === requiredProp);
40
41
  const ariaVersion = el.rule.options?.version ??
@@ -1,4 +1,5 @@
1
1
  import { ARIA_RECOMMENDED_VERSION, getComputedRole } from '@markuplint/ml-spec';
2
+ import { getDOMValue, getElementByIdInTree, querySelectorAllInTree } from '../../helpers.js';
2
3
  /**
3
4
  * Checks that an active `tab` role element has a corresponding `tabpanel` role element.
4
5
  *
@@ -30,21 +31,24 @@ export const checkingTabRequiresTabpanel = ({ el, role }) => t => {
30
31
  .split(/\s+/)
31
32
  .filter(Boolean)
32
33
  .some(id => {
33
- const target = document.getElementById(id);
34
+ const target = getElementByIdInTree(el, id, controlsAttr.isExpressionValue);
34
35
  return !!target && isTabpanel(target);
35
36
  });
36
37
  if (hasControlledTabpanel) {
37
38
  return;
38
39
  }
39
40
  }
40
- const idAttr = el.getAttributeNode('id');
41
- if (idAttr && !idAttr.isDynamicValue) {
42
- const tabId = idAttr.value;
43
- const hasLabellingTabpanel = [...document.querySelectorAll('[aria-labelledby]')].some(candidate => {
41
+ // The tabpanel refers to the tab in the tree of the tab, and the ids are compared as the DOM has them
42
+ const tabId = getDOMValue(el.getAttributeNode('id'));
43
+ if (tabId != null) {
44
+ const hasLabellingTabpanel = querySelectorAllInTree(el, '[aria-labelledby]').some(candidate => {
44
45
  const labelledbyAttr = candidate.getAttributeNode('aria-labelledby');
45
46
  if (!labelledbyAttr || labelledbyAttr.isDynamicValue)
46
47
  return false;
47
- return labelledbyAttr.value.split(/\s+/).includes(tabId) && isTabpanel(candidate);
48
+ return (labelledbyAttr.value
49
+ .split(/\s+/)
50
+ .map(id => getDOMValue(labelledbyAttr, id))
51
+ .includes(tabId) && isTabpanel(candidate));
48
52
  });
49
53
  if (hasLabellingTabpanel) {
50
54
  return;
@@ -12,13 +12,11 @@ import type { ARIAProperty, ARIARole } from '@markuplint/ml-spec';
12
12
  * @param attr - The ARIA attribute node to inspect.
13
13
  * @param role - The computed ARIA role, used to resolve conditional value types.
14
14
  * @param propSpecs - The list of ARIA property specifications for value type lookup.
15
- * @param booleanish - Whether the document supports booleanish attribute values (e.g., JSX).
16
15
  * @returns A violation if the attribute value does not match the expected type.
17
16
  */
18
17
  export declare const checkingValue: AttrChecker<boolean, Options, {
19
18
  role?: ARIARole | null;
20
19
  propSpecs: readonly ARIAProperty[];
21
- booleanish?: boolean;
22
20
  }>;
23
21
  /**
24
22
  * Validates a raw ARIA value against the expected value type defined in the specification.
@@ -30,7 +28,6 @@ export declare const checkingValue: AttrChecker<boolean, Options, {
30
28
  * @param type - The ARIA value type (e.g., `"token"`, `"true/false"`, `"integer"`).
31
29
  * @param value - The raw attribute value to validate.
32
30
  * @param tokenEnum - The list of allowed token values for token-based types.
33
- * @param booleanish - Whether empty string is accepted as a boolean `true` value.
34
31
  * @returns `true` if the value is valid for the given type, `false` otherwise.
35
32
  */
36
- export declare function checkAriaValue(type: string, value: string, tokenEnum: readonly string[], booleanish?: boolean): boolean;
33
+ export declare function checkAriaValue(type: string, value: string, tokenEnum: readonly string[]): boolean;
@@ -1,3 +1,4 @@
1
+ import { noTranslate } from '../../no-translate.js';
1
2
  /**
2
3
  * Checks whether the value of an ARIA property or state conforms to its expected type.
3
4
  *
@@ -9,23 +10,22 @@
9
10
  * @param attr - The ARIA attribute node to inspect.
10
11
  * @param role - The computed ARIA role, used to resolve conditional value types.
11
12
  * @param propSpecs - The list of ARIA property specifications for value type lookup.
12
- * @param booleanish - Whether the document supports booleanish attribute values (e.g., JSX).
13
13
  * @returns A violation if the attribute value does not match the expected type.
14
14
  */
15
- export const checkingValue = ({ attr, role, propSpecs, booleanish }) => t => {
15
+ export const checkingValue = ({ attr, role, propSpecs }) => t => {
16
16
  if (attr.isDynamicValue) {
17
17
  return;
18
18
  }
19
19
  const propSpec = propSpecs.find(p => p.name === attr.nameKey);
20
- const result = checkAria(propSpec, attr.value, role?.name, booleanish);
20
+ const result = checkAria(propSpec, attr.value, role?.name);
21
21
  if (result.isValid) {
22
22
  return;
23
23
  }
24
24
  return {
25
25
  scope: attr,
26
- message: t('{0:c} on {1}', t('{0} is {1:c}', t('the "{0}"', attr.value), 'disallowed'), t('the "{0*}" {1}', attr.name, `ARIA ${propSpec?.type ?? 'property'}`)) +
26
+ message: t('{0:c} on {1}', t('{0} is {1:c}', t('the "{0*}"', attr.value), 'disallowed'), t('the "{0*}" {1}', attr.name, `ARIA ${propSpec?.type ?? 'property'}`)) +
27
27
  ('enum' in result && result.enum.length > 0
28
- ? t('. ') + t('Allowed values are: {0}', t(result.enum))
28
+ ? t('. ') + t('Allowed values are: {0}', t(result.enum.map(noTranslate)))
29
29
  : ''),
30
30
  };
31
31
  };
@@ -35,10 +35,9 @@ export const checkingValue = ({ attr, role, propSpecs, booleanish }) => t => {
35
35
  * @param propSpec - The ARIA property specification, or `undefined` if the property is unknown.
36
36
  * @param currentValue - The current attribute value to validate.
37
37
  * @param role - The name of the computed role, used to resolve conditional value types.
38
- * @param booleanish - Whether booleanish values (empty string as `true`) are accepted.
39
38
  * @returns An object containing the validation result and, for enum types, the allowed values.
40
39
  */
41
- function checkAria(propSpec, currentValue, role, booleanish) {
40
+ function checkAria(propSpec, currentValue, role) {
42
41
  if (!propSpec) {
43
42
  return {
44
43
  currentValue,
@@ -55,7 +54,7 @@ function checkAria(propSpec, currentValue, role, booleanish) {
55
54
  }
56
55
  }
57
56
  }
58
- const isValid = checkAriaValue(valueType, currentValue, propSpec.enum, booleanish);
57
+ const isValid = checkAriaValue(valueType, currentValue, propSpec.enum);
59
58
  return {
60
59
  ...propSpec,
61
60
  currentValue,
@@ -72,10 +71,9 @@ function checkAria(propSpec, currentValue, role, booleanish) {
72
71
  * @param type - The ARIA value type (e.g., `"token"`, `"true/false"`, `"integer"`).
73
72
  * @param value - The raw attribute value to validate.
74
73
  * @param tokenEnum - The list of allowed token values for token-based types.
75
- * @param booleanish - Whether empty string is accepted as a boolean `true` value.
76
74
  * @returns `true` if the value is valid for the given type, `false` otherwise.
77
75
  */
78
- export function checkAriaValue(type, value, tokenEnum, booleanish) {
76
+ export function checkAriaValue(type, value, tokenEnum) {
79
77
  switch (type) {
80
78
  case 'token': {
81
79
  return tokenEnum.includes(value);
@@ -90,21 +88,12 @@ export function checkAriaValue(type, value, tokenEnum, booleanish) {
90
88
  return true;
91
89
  }
92
90
  case 'true/false': {
93
- if (booleanish && value === '') {
94
- return true;
95
- }
96
91
  return ['true', 'false'].includes(value);
97
92
  }
98
93
  case 'tristate': {
99
- if (booleanish && value === '') {
100
- return true;
101
- }
102
94
  return ['mixed', 'true', 'false', 'undefined'].includes(value);
103
95
  }
104
96
  case 'true/false/undefined': {
105
- if (booleanish && value === '') {
106
- return true;
107
- }
108
97
  return ['true', 'false', 'undefined'].includes(value);
109
98
  }
110
99
  case 'integer': {