@markuplint/ml-spec 5.3.0 → 5.5.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 (50) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +4 -0
  3. package/lib/algorithm/aria/accname/aria-steps.d.ts +26 -9
  4. package/lib/algorithm/aria/accname/aria-steps.js +38 -9
  5. package/lib/algorithm/aria/accname/compute.d.ts +38 -6
  6. package/lib/algorithm/aria/accname/compute.js +60 -12
  7. package/lib/algorithm/aria/accname/element-names.d.ts +4 -4
  8. package/lib/algorithm/aria/accname/element-names.js +31 -30
  9. package/lib/algorithm/aria/accname/helpers.d.ts +52 -12
  10. package/lib/algorithm/aria/accname/helpers.js +78 -23
  11. package/lib/algorithm/aria/accname/index.d.ts +2 -2
  12. package/lib/algorithm/aria/accname/index.js +1 -1
  13. package/lib/algorithm/aria/accname/label-steps.d.ts +4 -4
  14. package/lib/algorithm/aria/accname/label-steps.js +14 -5
  15. package/lib/algorithm/aria/accname/result.d.ts +21 -0
  16. package/lib/algorithm/aria/accname/result.js +28 -0
  17. package/lib/algorithm/aria/accname/svg-helpers.d.ts +2 -2
  18. package/lib/algorithm/aria/accname/svg-helpers.js +2 -2
  19. package/lib/algorithm/aria/accname/types.d.ts +51 -0
  20. package/lib/algorithm/aria/accname-computation.d.ts +1 -1
  21. package/lib/algorithm/aria/accname-computation.js +12 -6
  22. package/lib/algorithm/aria/get-aria.js +2 -2
  23. package/lib/algorithm/aria/get-computed-role.js +18 -14
  24. package/lib/algorithm/aria/get-explicit-role.d.ts +6 -0
  25. package/lib/algorithm/aria/get-explicit-role.js +82 -9
  26. package/lib/algorithm/aria/get-non-presentational-ancestor.d.ts +2 -0
  27. package/lib/algorithm/aria/get-non-presentational-ancestor.js +5 -2
  28. package/lib/algorithm/aria/is-exposed.d.ts +3 -1
  29. package/lib/algorithm/aria/is-exposed.js +15 -7
  30. package/lib/algorithm/aria/is-presentational.d.ts +15 -4
  31. package/lib/algorithm/aria/is-presentational.js +16 -3
  32. package/lib/algorithm/aria/matches-context-role.d.ts +3 -1
  33. package/lib/algorithm/aria/matches-context-role.js +10 -7
  34. package/lib/algorithm/dom/tree-boundary.d.ts +93 -0
  35. package/lib/algorithm/dom/tree-boundary.js +103 -0
  36. package/lib/algorithm/html/content-model-category-to-tag-names.d.ts +7 -3
  37. package/lib/algorithm/html/content-model-category-to-tag-names.js +7 -3
  38. package/lib/algorithm/html/select-display-size.d.ts +29 -0
  39. package/lib/algorithm/html/select-display-size.js +38 -0
  40. package/lib/const/accname.d.ts +1 -1
  41. package/lib/const/accname.js +1 -1
  42. package/lib/index.d.ts +2 -0
  43. package/lib/index.js +3 -0
  44. package/lib/types/index.d.ts +67 -2
  45. package/lib/types/permitted-structures.d.ts +5 -1
  46. package/lib/utils/directive-resolver.d.ts +8 -0
  47. package/lib/utils/directive-resolver.js +1 -0
  48. package/lib/utils/schema-to-spec.d.ts +4 -0
  49. package/lib/utils/schema-to-spec.js +42 -0
  50. package/package.json +6 -5
@@ -3,6 +3,7 @@ import { isPresentational } from './is-presentational.js';
3
3
  import { SVG_NAMESPACE } from '../../const/index.js';
4
4
  import { hasSvgAccessibleNameSource } from './accname/svg-helpers.js';
5
5
  import { getAttrSpecs } from '../../utils/get-attr-specs.js';
6
+ import { getComposedParentElement } from '../dom/tree-boundary.js';
6
7
  import { getExplicitRole } from './get-explicit-role.js';
7
8
  import { getImplicitRole } from './get-implicit-role.js';
8
9
  import { getNonPresentationalAncestor } from './get-non-presentational-ancestor.js';
@@ -98,6 +99,7 @@ el, version, assumeSingleNode) {
98
99
  * "accessibility child" and "accessibility parent", and
99
100
  * `matchesContextRole` additionally skips `generic` elements.
100
101
  *
102
+ * @see https://w3c.github.io/aria/#conflict_resolution_presentation_none
101
103
  * @see https://w3c.github.io/aria/#scope
102
104
  * @see https://github.com/w3c/aria/issues/1033
103
105
  * @see https://github.com/w3c/aria/pull/1454
@@ -115,12 +117,14 @@ el, version, assumeSingleNode) {
115
117
  !isNativeContextIntact(computedRole, el, specs, version)) {
116
118
  /**
117
119
  * An element fragment that serves as the root without a parent element
118
- * cannot satisfy the "Required Accessibility Parent Role" condition.
120
+ * (or the top of the content of an ordinary `template` element, whose
121
+ * placement is not known) cannot satisfy the "Required Accessibility
122
+ * Parent Role" condition.
119
123
  * Therefore, under normal circumstances, the `role` will disappear.
120
124
  * However, in this specific case, it will fall back to both explicit
121
125
  * and implicit roles. Note that the explicit role takes precedence.
122
126
  */
123
- if (el.parentElement === null) {
127
+ if (getComposedParentElement(el) === null) {
124
128
  return {
125
129
  ...computedRole,
126
130
  errorType: 'NO_OWNER',
@@ -145,8 +149,7 @@ el, version, assumeSingleNode) {
145
149
  /**
146
150
  * SVG: Including Elements in the Accessibility Tree
147
151
  *
148
- * > Many SVG elements—although rendered to the screen—
149
- * > do not have an intrinsic semantic meaning. Instead,
152
+ * > Many SVG elements—although rendered to the screen—do not have an intrinsic semantic meaning. Instead,
150
153
  * > they represent components of the visual presentation of the document.
151
154
  * > To simplify the accessible representation of the document,
152
155
  * > these purely presentational elements should normally be omitted
@@ -166,15 +169,14 @@ el, version, assumeSingleNode) {
166
169
  * > - the use element
167
170
  * > - the grouping (g) element
168
171
  * > - the image element
169
- * > - the mesh element
170
172
  * > - text formatting elements (textPath, tspan)
171
173
  * > - the foreignObject element
172
174
  *
173
- * @see https://www.w3.org/TR/svg-aam-1.0/#include_elements
175
+ * @see https://www.w3.org/TR/svg-aam/#include_elements
174
176
  */
175
177
  if (
176
178
  // It doesn't been specified a valid explicit role.
177
- (explicitRole.role === null || explicitRole.errorType != null) &&
179
+ explicitRole.role === null &&
178
180
  // It is an SVG element.
179
181
  el.namespaceURI === SVG_NAMESPACE &&
180
182
  !hasSvgAccessibleNameSource(el)) {
@@ -189,10 +191,12 @@ el, version, assumeSingleNode) {
189
191
  const implicitRole = lazyImplicitRole ?? getImplicitRole(specs, el, version);
190
192
  /**
191
193
  * > If an element is focusable,
192
- * > user agents MUST ignore the presentation/none role
194
+ * > user agents MUST ignore the none/presentation role
193
195
  * > and expose the element with its implicit role,
194
196
  * > in order to ensure that the element is operable.
195
197
  *
198
+ * @see https://w3c.github.io/aria/#conflict_resolution_presentation_none
199
+ *
196
200
  * With the issue: What does focusable or otherwise interactive mean](https://github.com/w3c/aria/issues/1192)
197
201
  *
198
202
  * - Focus: https://html.spec.whatwg.org/multipage/interaction.html#focus
@@ -204,10 +208,9 @@ el, version, assumeSingleNode) {
204
208
  *
205
209
  * 1. It may be focusable
206
210
  *
207
- * THIS CONDITION IS ALMOST MEANINGLESS.
208
- * Because it has already been determined that
209
- * the interactive elements can not specify the presentational role
210
- * in the previous processing that computes the permitted role (`getExplicitRole`).
211
+ * ARIA in HTML does not permit the presentational roles on most
212
+ * interactive elements, but `getExplicitRole` keeps such a role with
213
+ * `NO_PERMITTED`, as user agents expose it, so this resolves it.
211
214
  */
212
215
  mayBeFocusable(el, specs) &&
213
216
  /**
@@ -287,6 +290,7 @@ el, specs, attrName) {
287
290
  }
288
291
  /**
289
292
  * Determines whether some ancestors match the condition that the specified callback function.
293
+ * The ancestors are those of the accessibility tree: see {@link getComposedParentElement}.
290
294
  */
291
295
  function someAncestors(
292
296
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
@@ -295,7 +299,7 @@ el, predicate) {
295
299
  let current = el;
296
300
  while (current) {
297
301
  list.push(current);
298
- current = current.parentElement;
302
+ current = getComposedParentElement(current);
299
303
  }
300
304
  return list.some(predicate);
301
305
  }
@@ -321,7 +325,7 @@ el, specs, version) {
321
325
  if (!computedRole.role?.isImplicit) {
322
326
  return false;
323
327
  }
324
- const parent = el.parentElement;
328
+ const parent = getComposedParentElement(el);
325
329
  if (!parent) {
326
330
  return false;
327
331
  }
@@ -2,6 +2,12 @@ import type { ARIAVersion, ComputedRole, MLMLSpec } from '../../types/index.js';
2
2
  /**
3
3
  * Implements the WAI-ARIA "Handling Author Errors" algorithm.
4
4
  *
5
+ * The roles that ARIA in HTML does not permit on the element are authoring
6
+ * errors that the user agent does not correct: such a role is returned with
7
+ * `errorType: 'NO_PERMITTED'` (Core-AAM, the conflicts between the mappings), except on an element that is
8
+ * never rendered.
9
+ *
5
10
  * @see https://w3c.github.io/aria/#document-handling_author-errors
11
+ * @see https://w3c.github.io/core-aam/#mapping_conflicts — Core-AAM §3.2
6
12
  */
7
13
  export declare function getExplicitRole(specs: MLMLSpec, el: Element, version: ARIAVersion): ComputedRole;
@@ -1,10 +1,18 @@
1
+ import { decodeEntities } from '@markuplint/shared';
2
+ import { XHTML_NAMESPACE } from '../../const/index.js';
1
3
  import { getRoleSpec } from './get-role-spec.js';
2
4
  import { resolveNamespace } from '../../utils/resolve-namespace.js';
3
5
  import { getPermittedRoles } from './get-permitted-roles.js';
4
6
  /**
5
7
  * Implements the WAI-ARIA "Handling Author Errors" algorithm.
6
8
  *
9
+ * The roles that ARIA in HTML does not permit on the element are authoring
10
+ * errors that the user agent does not correct: such a role is returned with
11
+ * `errorType: 'NO_PERMITTED'` (Core-AAM, the conflicts between the mappings), except on an element that is
12
+ * never rendered.
13
+ *
7
14
  * @see https://w3c.github.io/aria/#document-handling_author-errors
15
+ * @see https://w3c.github.io/core-aam/#mapping_conflicts — Core-AAM §3.2
8
16
  */
9
17
  export function getExplicitRole(specs,
10
18
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
@@ -46,13 +54,6 @@ el, version) {
46
54
  error = 'ABSTRACT';
47
55
  continue;
48
56
  }
49
- /**
50
- * Whether the role is the permitted role according to ARIA in HTML.
51
- */
52
- if (!permittedRoles.some(r => r.name === roleName)) {
53
- error = 'NO_PERMITTED';
54
- continue;
55
- }
56
57
  /**
57
58
  * > Certain landmark roles require names from authors.
58
59
  * > In situations where an author has not specified names for these landmarks,
@@ -66,6 +67,35 @@ el, version) {
66
67
  error = 'INVALID_LANDMARK';
67
68
  continue;
68
69
  }
70
+ /**
71
+ * A role that ARIA in HTML does not permit on the element is an
72
+ * authoring error, but user agents still expose it, so the role is
73
+ * kept and the error is reported alongside. An element that is never
74
+ * rendered is not in the accessibility tree at all (WAI-ARIA §7.1),
75
+ * so the role is dropped there.
76
+ *
77
+ * @see https://w3c.github.io/aria/#tree_exclusion
78
+ *
79
+ * > Elements, including their descendent elements, that have host language semantics specifying that the element is not displayed, such as CSS display:none, visibility:hidden, or the HTML hidden attribute.
80
+ *
81
+ * @see https://w3c.github.io/core-aam/#mapping_conflicts
82
+ *
83
+ * > While conformance checkers might signal an error or warning when a WAI-ARIA role is used on elements with strong native semantics, user agents MUST still use the value of the semantic of the WAI-ARIA role when exposing the element to accessibility APIs.
84
+ */
85
+ if (!permittedRoles.some(r => r.name === roleName)) {
86
+ if (isNeverRendered(el, namespaceURI)) {
87
+ error = 'NO_PERMITTED';
88
+ continue;
89
+ }
90
+ return {
91
+ el,
92
+ role: {
93
+ ...spec,
94
+ isImplicit: false,
95
+ },
96
+ errorType: 'NO_PERMITTED',
97
+ };
98
+ }
69
99
  /**
70
100
  * Otherwise
71
101
  */
@@ -83,6 +113,43 @@ el, version) {
83
113
  errorType: error,
84
114
  };
85
115
  }
116
+ /**
117
+ * HTML elements that the user agent style sheet never renders (`display: none`),
118
+ * together with `input[type=hidden]` from the same rules (`isNeverRendered`).
119
+ * `area` is left out: it is rendered through its image map. The rules that
120
+ * depend on state (`[hidden]`, `noscript` with scripting) are left out too: the
121
+ * role stays on such an element, as its `hidden` attribute is a matter of the
122
+ * accessible name computation (Step 2A), not of the role.
123
+ *
124
+ * @see https://html.spec.whatwg.org/multipage/rendering.html#hidden-elements
125
+ */
126
+ const NEVER_RENDERED_HTML_ELEMENTS = new Set([
127
+ 'base',
128
+ 'basefont',
129
+ 'datalist',
130
+ 'head',
131
+ 'link',
132
+ 'meta',
133
+ 'noembed',
134
+ 'noframes',
135
+ 'param',
136
+ 'rp',
137
+ 'script',
138
+ 'style',
139
+ 'template',
140
+ 'title',
141
+ ]);
142
+ function isNeverRendered(
143
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
144
+ el, namespaceURI) {
145
+ if (namespaceURI !== XHTML_NAMESPACE) {
146
+ return false;
147
+ }
148
+ if (NEVER_RENDERED_HTML_ELEMENTS.has(el.localName)) {
149
+ return true;
150
+ }
151
+ return el.localName === 'input' && el.getAttribute('type')?.trim().toLowerCase() === 'hidden';
152
+ }
86
153
  function isLandmarkRole(role) {
87
154
  return role?.superClassRoles.some(su => su.name === 'landmark');
88
155
  }
@@ -96,8 +163,14 @@ el, role) {
96
163
  if (el.getAttribute('aria-label')) {
97
164
  return true;
98
165
  }
99
- const id = el.getAttribute('aria-labelledby');
100
- if (id && el.ownerDocument.getElementById(id)) {
166
+ // `aria-labelledby` is a list of ids, and each is resolved in the tree of the element: the
167
+ // content of a `template` element and a shadow root are trees of their own. A DOM has resolved the
168
+ // character references of a token already, but the markuplint DOM gives it as written in the markup,
169
+ // so the token is looked for as it is, and then with its character references resolved.
170
+ const ids = el.getAttribute('aria-labelledby')?.split(/\s+/).filter(Boolean) ?? [];
171
+ const root = el.getRootNode();
172
+ if ((root.nodeType === 9 || root.nodeType === 11) &&
173
+ ids.some(id => root.getElementById(id) ?? root.getElementById(decodeEntities(id)))) {
101
174
  return true;
102
175
  }
103
176
  }
@@ -2,6 +2,8 @@ import type { ARIAVersion, MLMLSpec } from '../../types/index.js';
2
2
  /**
3
3
  * In ARIA 1.3, `generic` role elements are additionally transparent for
4
4
  * ownership traversal.
5
+ *
6
+ * The ancestors are those of the accessibility tree: see {@link getComposedParentElement}.
5
7
  */
6
8
  export declare function getNonPresentationalAncestor(el: Element, specs: MLMLSpec, version: ARIAVersion): import("../../types/index.js").ComputedRole | {
7
9
  el: null;
@@ -1,13 +1,16 @@
1
+ import { getComposedParentElement } from '../dom/tree-boundary.js';
1
2
  import { isTransparentForOwnership } from './is-presentational.js';
2
3
  import { getComputedRole } from './get-computed-role.js';
3
4
  /**
4
5
  * In ARIA 1.3, `generic` role elements are additionally transparent for
5
6
  * ownership traversal.
7
+ *
8
+ * The ancestors are those of the accessibility tree: see {@link getComposedParentElement}.
6
9
  */
7
10
  export function getNonPresentationalAncestor(
8
11
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
9
12
  el, specs, version) {
10
- let ancestor = el.parentElement;
13
+ let ancestor = getComposedParentElement(el);
11
14
  /**
12
15
  * In ARIA 1.1 and 1.2, the role of an element is not affected by the role of its ancestor elements.
13
16
  *
@@ -19,7 +22,7 @@ el, specs, version) {
19
22
  if (!isTransparentForOwnership(ancestorRole.role?.name, version)) {
20
23
  return ancestorRole;
21
24
  }
22
- ancestor = ancestor.parentElement;
25
+ ancestor = getComposedParentElement(ancestor);
23
26
  }
24
27
  return {
25
28
  el: ancestor,
@@ -4,7 +4,9 @@ import type { ARIAVersion, MLMLSpec } from '../../types/index.js';
4
4
  * Applies WAI-ARIA exclusion/inclusion rules, SVG rendering rules, and
5
5
  * HTML metadata element filtering.
6
6
  *
7
- * @see https://www.w3.org/TR/wai-aria-1.2/#accessibility_tree
7
+ * The ancestors it reads are those of the accessibility tree ({@link getComposedParentElement}).
8
+ *
9
+ * @see https://w3c.github.io/aria/#accessibility_tree
8
10
  *
9
11
  * @param el - The DOM element to check for accessibility tree exposure
10
12
  * @param specs - The full markup language specification
@@ -1,4 +1,5 @@
1
1
  import { ariaSpecs } from './aria-specs.js';
2
+ import { getComposedParentElement } from '../dom/tree-boundary.js';
2
3
  import { getSpecByTagName } from '../../utils/get-spec-by-tag-name.js';
3
4
  import { isPresentational } from './is-presentational.js';
4
5
  import { resolveNamespace } from '../../utils/resolve-namespace.js';
@@ -8,7 +9,9 @@ import { getComputedRole } from './get-computed-role.js';
8
9
  * Applies WAI-ARIA exclusion/inclusion rules, SVG rendering rules, and
9
10
  * HTML metadata element filtering.
10
11
  *
11
- * @see https://www.w3.org/TR/wai-aria-1.2/#accessibility_tree
12
+ * The ancestors it reads are those of the accessibility tree ({@link getComposedParentElement}).
13
+ *
14
+ * @see https://w3c.github.io/aria/#accessibility_tree
12
15
  *
13
16
  * @param el - The DOM element to check for accessibility tree exposure
14
17
  * @param specs - The full markup language specification
@@ -47,7 +50,7 @@ el, specs, version) {
47
50
  /**
48
51
  * Excluding Elements from the Accessibility Tree
49
52
  *
50
- * @see https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion
53
+ * @see https://w3c.github.io/aria/#tree_exclusion
51
54
  *
52
55
  * @param specs
53
56
  * @param el
@@ -70,7 +73,7 @@ el, specs, version) {
70
73
  if (hasDisplayNodeOrVisibilityHidden(currentEl) || currentEl.hasAttribute('hidden')) {
71
74
  return true;
72
75
  }
73
- currentEl = currentEl.parentElement;
76
+ currentEl = getComposedParentElement(currentEl);
74
77
  }
75
78
  }
76
79
  /**
@@ -97,7 +100,7 @@ el, specs, version) {
97
100
  if (currentEl.getAttribute('aria-hidden') === 'true') {
98
101
  return true;
99
102
  }
100
- currentEl = currentEl.parentElement;
103
+ currentEl = getComposedParentElement(currentEl);
101
104
  }
102
105
  }
103
106
  /**
@@ -109,13 +112,13 @@ el, specs, version) {
109
112
  * However, the text content of any excluded descendants is included.
110
113
  */
111
114
  {
112
- let currentEl = el.parentElement;
115
+ let currentEl = getComposedParentElement(el);
113
116
  while (currentEl) {
114
117
  const { role } = getComputedRole(specs, currentEl, version);
115
118
  if (role?.childrenPresentational) {
116
119
  return true;
117
120
  }
118
- currentEl = currentEl.parentElement;
121
+ currentEl = getComposedParentElement(currentEl);
119
122
  }
120
123
  }
121
124
  return false;
@@ -128,7 +131,7 @@ el, specs, version) {
128
131
  * user agents MUST provide an accessible object in the accessibility tree
129
132
  * for DOM elements that meet any of the following criteria:
130
133
  *
131
- * @see https://www.w3.org/TR/wai-aria-1.2/#tree_inclusion
134
+ * @see https://w3c.github.io/aria/#tree_inclusion
132
135
  *
133
136
  * @param specs
134
137
  * @param el
@@ -172,6 +175,11 @@ el, specs, version) {
172
175
  }
173
176
  // Has a global WAI-ARIA attribute
174
177
  for (const attr of el.attributes) {
178
+ // An attribute that the host does not output (`MLAttr#isAbsent`, e.g. `aria-label={null}`)
179
+ // is not an attribute of the DOM
180
+ if (attr.isAbsent) {
181
+ continue;
182
+ }
175
183
  if (globalAria.some(aria => aria.name === attr.localName)) {
176
184
  results.push(true);
177
185
  break;
@@ -1,4 +1,4 @@
1
- import type { ARIAVersion } from '../../types/index.js';
1
+ import type { ARIAVersion, ComputedRole } from '../../types/index.js';
2
2
  /**
3
3
  * Determines whether a given role name corresponds to a presentational role
4
4
  * (`"presentation"` or `"none"`), which indicates the element should be
@@ -8,6 +8,18 @@ import type { ARIAVersion } from '../../types/index.js';
8
8
  * @returns `true` if the role name is `"presentation"` or `"none"`, `false` otherwise
9
9
  */
10
10
  export declare function isPresentational(roleName?: string): boolean;
11
+ /**
12
+ * Determines whether an element is exposed with the `none` or `presentation`
13
+ * role that its author specified, after the Presentational Roles Conflict
14
+ * Resolution. The implicit `presentation` role of `<img alt="">` does not count:
15
+ * HTML-AAM gives that image no name by its own rule.
16
+ *
17
+ * @see https://w3c.github.io/accname/#comp_host_language_label
18
+ *
19
+ * @param computed - The computed role of the element
20
+ * @returns `true` if the computed role is an explicit presentational role
21
+ */
22
+ export declare function isExposedAsPresentational(computed: ComputedRole): boolean;
11
23
  /**
12
24
  * Determines whether a given role is transparent for ownership traversal.
13
25
  *
@@ -15,9 +27,8 @@ export declare function isPresentational(roleName?: string): boolean;
15
27
  * - **ARIA 1.1/1.2:** Only `presentation`/`none` are transparent.
16
28
  * This matches the pre-existing behavior of `getClosestNonPresentationalDescendants`
17
29
  * and `getNonPresentationalAncestor`.
18
- * - **ARIA 1.3:** `generic` is additionally transparent per the spec:
19
- * "user agents MUST ignore any intervening elements with the role
20
- * `generic` or `none`."
30
+ * - **ARIA 1.3:** `generic` is additionally transparent per the spec, which has
31
+ * the user agent ignore the intervening elements with the role `generic` or `none`.
21
32
  *
22
33
  * **Note:** `matchesContextRole` calls this function unconditionally
23
34
  * (without a version gate) so that `presentation`/`none` are always
@@ -12,6 +12,20 @@ export function isPresentational(roleName) {
12
12
  }
13
13
  return ['presentation', 'none'].includes(roleName);
14
14
  }
15
+ /**
16
+ * Determines whether an element is exposed with the `none` or `presentation`
17
+ * role that its author specified, after the Presentational Roles Conflict
18
+ * Resolution. The implicit `presentation` role of `<img alt="">` does not count:
19
+ * HTML-AAM gives that image no name by its own rule.
20
+ *
21
+ * @see https://w3c.github.io/accname/#comp_host_language_label
22
+ *
23
+ * @param computed - The computed role of the element
24
+ * @returns `true` if the computed role is an explicit presentational role
25
+ */
26
+ export function isExposedAsPresentational(computed) {
27
+ return !!computed.role && !computed.role.isImplicit && isPresentational(computed.role.name);
28
+ }
15
29
  /**
16
30
  * Determines whether a given role is transparent for ownership traversal.
17
31
  *
@@ -19,9 +33,8 @@ export function isPresentational(roleName) {
19
33
  * - **ARIA 1.1/1.2:** Only `presentation`/`none` are transparent.
20
34
  * This matches the pre-existing behavior of `getClosestNonPresentationalDescendants`
21
35
  * and `getNonPresentationalAncestor`.
22
- * - **ARIA 1.3:** `generic` is additionally transparent per the spec:
23
- * "user agents MUST ignore any intervening elements with the role
24
- * `generic` or `none`."
36
+ * - **ARIA 1.3:** `generic` is additionally transparent per the spec, which has
37
+ * the user agent ignore the intervening elements with the role `generic` or `none`.
25
38
  *
26
39
  * **Note:** `matchesContextRole` calls this function unconditionally
27
40
  * (without a version gate) so that `presentation`/`none` are always
@@ -4,11 +4,13 @@ import type { ARIAVersion, MLMLSpec } from '../../types/index.js';
4
4
  * Context Role" in ARIA 1.2). Each condition string describes a chain of
5
5
  * ancestor roles separated by ` > ` (e.g., `"list > group"`).
6
6
  *
7
- * TODO: This function only walks the DOM `parentElement` chain and does not
7
+ * TODO: This function only walks the composed parent chain and does not
8
8
  * consider `aria-owns` relationships. An element referenced by `aria-owns` on
9
9
  * a remote ancestor should be treated as if it were an accessibility child of
10
10
  * that ancestor. Implementing this requires a document-wide reverse lookup of
11
11
  * `aria-owns` attributes, which is a separate architectural concern.
12
12
  * See also: `has-required-owned-elements.ts` has a similar limitation.
13
+ *
14
+ * The chain is the one the accessibility tree is built from ({@link getComposedParentElement}).
13
15
  */
14
16
  export declare function matchesContextRole(conditions: readonly string[], ownedEl: Element, specs: MLMLSpec, version: ARIAVersion): boolean;
@@ -1,3 +1,4 @@
1
+ import { getComposedParentElement } from '../dom/tree-boundary.js';
1
2
  import { isTransparentForOwnership } from './is-presentational.js';
2
3
  import { getComputedRole } from './get-computed-role.js';
3
4
  /**
@@ -5,17 +6,19 @@ import { getComputedRole } from './get-computed-role.js';
5
6
  * Context Role" in ARIA 1.2). Each condition string describes a chain of
6
7
  * ancestor roles separated by ` > ` (e.g., `"list > group"`).
7
8
  *
8
- * TODO: This function only walks the DOM `parentElement` chain and does not
9
+ * TODO: This function only walks the composed parent chain and does not
9
10
  * consider `aria-owns` relationships. An element referenced by `aria-owns` on
10
11
  * a remote ancestor should be treated as if it were an accessibility child of
11
12
  * that ancestor. Implementing this requires a document-wide reverse lookup of
12
13
  * `aria-owns` attributes, which is a separate architectural concern.
13
14
  * See also: `has-required-owned-elements.ts` has a similar limitation.
15
+ *
16
+ * The chain is the one the accessibility tree is built from ({@link getComposedParentElement}).
14
17
  */
15
18
  export function matchesContextRole(conditions,
16
19
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
17
20
  ownedEl, specs, version) {
18
- return conditions.some(condition => matchesCondition(condition, ownedEl.parentElement, specs, version));
21
+ return conditions.some(condition => matchesCondition(condition, getComposedParentElement(ownedEl), specs, version));
19
22
  }
20
23
  function matchesCondition(condition,
21
24
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
@@ -27,9 +30,9 @@ parentEl, specs, version) {
27
30
  }
28
31
  const parentRole = getComputedRole(specs, parentEl, version, true).role;
29
32
  /**
30
- * ARIA 1.3 ("Required Accessibility Parent Role"): "To determine
31
- * whether an element has a parent with the required role, user agents
32
- * MUST ignore any elements with the role `generic` or `none`."
33
+ * ARIA 1.3 (Required Accessibility Parent Role): to determine whether an
34
+ * element has a parent with the required role, the user agent ignores the
35
+ * elements with the role `generic` or `none`.
33
36
  *
34
37
  * `presentation`/`none` are transparent in all ARIA versions to stay
35
38
  * consistent with `getNonPresentationalAncestor` (which already skips
@@ -47,14 +50,14 @@ parentEl, specs, version) {
47
50
  * @see https://w3c.github.io/aria/#scope
48
51
  */
49
52
  if (isTransparentForOwnership(parentRole?.name, version)) {
50
- parentEl = parentEl.parentElement;
53
+ parentEl = getComposedParentElement(parentEl);
51
54
  continue;
52
55
  }
53
56
  const condition = conditions.shift();
54
57
  if (condition !== parentRole?.name) {
55
58
  return false;
56
59
  }
57
- parentEl = parentEl.parentElement;
60
+ parentEl = getComposedParentElement(parentEl);
58
61
  }
59
62
  return true;
60
63
  }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * @module
3
+ *
4
+ * The parent of an element as the DOM sees it, for the algorithms that walk up from an element.
5
+ *
6
+ * The algorithms of this package are written against the DOM `Element`, where `parentElement`
7
+ * stops at the boundary of a tree: the parent element of a node whose parent is not an element
8
+ * (a document fragment, such as the content of a `template` element or a shadow root) is `null`.
9
+ * `@markuplint/ml-core` hands them its own elements, whose `parentElement` is the syntactical
10
+ * parent: the syntax tree has no other place for the content of a `template` element than its
11
+ * children, so the top of the content has the `template` element as its parent element. A walk
12
+ * that reads `parentElement` there leaves the content and enters the tree that holds the
13
+ * `template`. `getRootNode()` is where `@markuplint/ml-core` puts the boundary, and in a real DOM
14
+ * a parent is always in the tree of its child, so comparing the two roots gives the DOM's answer
15
+ * to both.
16
+ *
17
+ * This package cannot use `MLNode#treeParentElement` of `@markuplint/ml-core`, which depends on it,
18
+ * so the helpers derive the same answer from `getRootNode()`. The two are pinned to agree by
19
+ * `tree-scope.spec.ts` of `@markuplint/ml-core`: a change to the boundary in one belongs to both.
20
+ *
21
+ * Which of the two helpers a walk takes depends on what it follows:
22
+ *
23
+ * - {@link getTreeParentElement} follows a relation that the DOM defines in the tree, such as
24
+ * the ancestor `label` element of a labelable element (`findAncestorLabel`, the one walk of
25
+ * this package that does not follow the accessibility tree).
26
+ * - {@link getComposedParentElement} follows what the accessibility tree is built from. Its
27
+ * content is the flat tree: the content of a shadow root is rendered in its host, so the
28
+ * elements above it are the host and the ancestors of the host. The content of an ordinary
29
+ * `template` element is not rendered, and where it is inserted is not known from the markup,
30
+ * so a walk ends at its top.
31
+ *
32
+ * A `template` element that may have a declarative shadow root but whose attachment is not known
33
+ * from the markup (a host that is not in the file, a component, or a dynamic `shadowrootmode`),
34
+ * or that fails to attach one, is treated as an ordinary `template` element: a walk ends at the
35
+ * top of its content.
36
+ *
37
+ * Known limitation: the flat tree places the content of a shadow root where the `slot` element
38
+ * is, between the host and the nodes the host has as children. A walk goes from the top of the
39
+ * shadow root to the host and does not model the position of the `slot`.
40
+ *
41
+ * @see https://dom.spec.whatwg.org/#parent-element
42
+ * @see https://dom.spec.whatwg.org/#concept-shadow-including-ancestor
43
+ * @see https://html.spec.whatwg.org/multipage/scripting.html#the-template-element
44
+ */
45
+ /**
46
+ * What the helpers read of an element: the DOM `Element`, and `MLElement` of `@markuplint/ml-core`.
47
+ * An element without `getRootNode` (an adapter that has no notion of a tree boundary) is in one
48
+ * tree with its parent element.
49
+ */
50
+ type TreeElement = {
51
+ readonly parentElement: unknown;
52
+ getRootNode?(): unknown;
53
+ };
54
+ /**
55
+ * The parent element of an element in its own tree, which is what the DOM calls `parentElement`:
56
+ * `null` at the top of the content of a `template` element or of a shadow root, even where the
57
+ * `parentElement` of the element implementation is the `template` element.
58
+ *
59
+ * @template P - The type of the parent element the element reads
60
+ * @param el - The element to start from
61
+ * @returns The parent element, or `null` when the element is at the top of its tree
62
+ */
63
+ export declare function getTreeParentElement<P extends TreeElement>(el: {
64
+ readonly parentElement: P | null;
65
+ getRootNode?(): unknown;
66
+ }): P | null;
67
+ /**
68
+ * The parent element of an element as the accessibility tree is built: the parent element in its own
69
+ * tree, or, at the top of a shadow root, the host of the shadow root. `null` at the top of the content
70
+ * of an ordinary `template` element, and at the top of a document or of any other root that is not a
71
+ * shadow root.
72
+ *
73
+ * @template P - The type of the parent element the element reads
74
+ * @param el - The element to start from
75
+ * @returns The parent element or the shadow host, or `null` when the walk ends
76
+ */
77
+ export declare function getComposedParentElement<P extends TreeElement>(el: {
78
+ readonly parentElement: P | null;
79
+ getRootNode?(): unknown;
80
+ }): P | null;
81
+ /**
82
+ * Whether a root is a shadow root: a document fragment that has a host.
83
+ * The shadow root of `@markuplint/ml-core` is not a `ShadowRoot` of the DOM by class, and the
84
+ * content of an ordinary `template` element is a document fragment that has no host.
85
+ *
86
+ * @template P - The type of the host
87
+ * @param root - The root of a tree
88
+ * @returns `true` if the root has a host element
89
+ */
90
+ export declare function isShadowRoot<P = unknown>(root: unknown): root is {
91
+ readonly host: P;
92
+ };
93
+ export {};