@markuplint/rules 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 (87) hide show
  1. package/CHANGELOG.md +28 -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 +1 -1
  8. package/lib/consistent-table-row-length/index.js +1 -1
  9. package/lib/element-supports-aria-prop/index.js +2 -2
  10. package/lib/form-attr-references-form/index.js +2 -2
  11. package/lib/helpers.d.ts +43 -5
  12. package/lib/helpers.js +88 -26
  13. package/lib/input-list-references-datalist/index.js +2 -2
  14. package/lib/itemprop-requires-itemscope/index.js +27 -10
  15. package/lib/label-for-references-labelable/index.js +2 -2
  16. package/lib/label-has-control/index.d.ts +17 -0
  17. package/lib/label-has-control/index.js +28 -1
  18. package/lib/label-no-multiple-controls/index.d.ts +4 -5
  19. package/lib/label-no-multiple-controls/index.js +6 -6
  20. package/lib/landmark-roles/roles.d.ts +13 -1
  21. package/lib/landmark-roles/roles.js +22 -0
  22. package/lib/no-ambiguous-navigable-target-names/index.js +4 -0
  23. package/lib/no-aria-on-presentational-children/index.js +7 -1
  24. package/lib/no-aria-on-unsupported-element/index.js +2 -2
  25. package/lib/no-boolean-attr-value/index.js +2 -1
  26. package/lib/no-broken-fragment-link/index.d.ts +5 -0
  27. package/lib/no-broken-fragment-link/index.js +8 -2
  28. package/lib/no-consecutive-br/index.js +1 -1
  29. package/lib/no-contradictory-aria-prop/index.js +2 -2
  30. package/lib/no-default-aria-value/index.js +2 -2
  31. package/lib/no-default-value/index.js +4 -0
  32. package/lib/no-deprecated-aria-prop/index.js +2 -2
  33. package/lib/no-disallowed-ancestor/index.d.ts +16 -1
  34. package/lib/no-disallowed-ancestor/index.js +78 -4
  35. package/lib/no-disallowed-attr/index.js +4 -0
  36. package/lib/no-duplicate-dt/index.js +1 -1
  37. package/lib/no-duplicate-h1/index.js +18 -14
  38. package/lib/no-duplicate-id/index.d.ts +9 -0
  39. package/lib/no-duplicate-id/index.js +23 -4
  40. package/lib/no-empty-table-track/index.js +1 -1
  41. package/lib/no-ineffective-attr/index.js +4 -0
  42. package/lib/no-invalid-aria-prop-value/index.js +3 -3
  43. package/lib/no-invalid-attr-value/index.js +12 -0
  44. package/lib/no-nested-top-level-landmark/index.js +20 -17
  45. package/lib/no-prohibited-naming/index.js +2 -2
  46. package/lib/no-redundant-accessible-name/index.js +8 -7
  47. package/lib/no-redundant-aria-prop/index.js +2 -2
  48. package/lib/no-refer-to-non-existent-id/index.d.ts +15 -0
  49. package/lib/no-refer-to-non-existent-id/index.js +47 -16
  50. package/lib/no-restricted-element/index.js +4 -2
  51. package/lib/no-skipped-heading-level/index.js +9 -4
  52. package/lib/no-stray-head-or-body-tag/index.d.ts +2 -1
  53. package/lib/no-stray-head-or-body-tag/index.js +2 -1
  54. package/lib/no-table-cell-overlap/index.js +1 -1
  55. package/lib/no-table-span-overflow/index.js +1 -1
  56. package/lib/no-unescaped-char/index.js +1 -1
  57. package/lib/permitted-contents/index.js +2 -4
  58. package/lib/permitted-contents/matches-selector.js +28 -5
  59. package/lib/permitted-contents/represent-transparent-nodes.js +100 -68
  60. package/lib/permitted-contents/slot-content.d.ts +2 -1
  61. package/lib/permitted-contents/slot-content.js +28 -2
  62. package/lib/permitted-contents/start.js +4 -2
  63. package/lib/permitted-contents/utils.d.ts +11 -0
  64. package/lib/permitted-contents/utils.js +15 -0
  65. package/lib/placeholder-label-option/index.js +4 -26
  66. package/lib/require-accessible-name/index.js +2 -2
  67. package/lib/require-adjacent-popover/index.js +4 -2
  68. package/lib/require-ancestor/index.d.ts +20 -0
  69. package/lib/require-ancestor/index.js +33 -7
  70. package/lib/require-datetime/index.js +1 -1
  71. package/lib/require-dialog-autofocus/index.js +8 -6
  72. package/lib/require-h1/index.js +4 -8
  73. package/lib/require-landmark-label/index.js +17 -15
  74. package/lib/role-supports-aria-prop/index.js +2 -2
  75. package/lib/sizes-auto-requires-lazy-loading/index.d.ts +4 -2
  76. package/lib/sizes-auto-requires-lazy-loading/index.js +4 -2
  77. package/lib/usemap-references-map/index.js +11 -2
  78. package/lib/valid-speculation-rules/index.js +1 -1
  79. package/lib/wai-aria/checkings/interaction-in-hidden.js +6 -2
  80. package/lib/wai-aria/checkings/presentational-children.d.ts +17 -6
  81. package/lib/wai-aria/checkings/presentational-children.js +259 -25
  82. package/lib/wai-aria/checkings/required-accessibility-parent-role.js +2 -1
  83. package/lib/wai-aria/checkings/required-prop.js +2 -1
  84. package/lib/wai-aria/checkings/tab-requires-tabpanel.js +10 -6
  85. package/lib/wai-aria/checkings/value.d.ts +1 -4
  86. package/lib/wai-aria/checkings/value.js +5 -17
  87. package/package.json +11 -11
@@ -9,8 +9,10 @@
9
9
  * `auto` (`allowsAutoSizes`), not `loading="lazy"` alone.
10
10
  *
11
11
  * Gated on `srcset` being present, matching the source rule this was split
12
- * from: `sizes="auto"` has nothing to auto-size without candidates to choose
13
- * from.
12
+ * from. An `<img>` without `srcset` is left to the `@markuplint/html-spec`
13
+ * data of its `sizes`: the condition allows it only on a lazily loaded image
14
+ * (`no-disallowed-attr`) and the type only as the value `auto`
15
+ * (`no-invalid-attr-value`).
14
16
  */
15
17
  declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<boolean, undefined>>;
16
18
  export default _default;
@@ -13,8 +13,10 @@ import meta from './meta.js';
13
13
  * `auto` (`allowsAutoSizes`), not `loading="lazy"` alone.
14
14
  *
15
15
  * Gated on `srcset` being present, matching the source rule this was split
16
- * from: `sizes="auto"` has nothing to auto-size without candidates to choose
17
- * from.
16
+ * from. An `<img>` without `srcset` is left to the `@markuplint/html-spec`
17
+ * data of its `sizes`: the condition allows it only on a lazily loaded image
18
+ * (`no-disallowed-attr`) and the type only as the value `auto`
19
+ * (`no-invalid-attr-value`).
18
20
  */
19
21
  export default createRule({
20
22
  meta,
@@ -1,4 +1,5 @@
1
1
  import { createRule } from '@markuplint/ml-core';
2
+ import { getDOMValue, querySelectorAllInTree } from '../helpers.js';
2
3
  import meta from './meta.js';
3
4
  /**
4
5
  * Require the `usemap` attribute on an `<img>` element to be a valid
@@ -27,11 +28,19 @@ export default createRule({
27
28
  return;
28
29
  if (usemapAttr.isDynamicValue)
29
30
  return;
30
- const value = usemapAttr.value;
31
+ // The character references are resolved on both sides, as the DOM has the strings
32
+ const value = getDOMValue(usemapAttr) ?? usemapAttr.value;
31
33
  if (!value.startsWith('#') || value.length <= 1)
32
34
  return;
33
35
  const targetName = value.slice(1);
34
- const target = [...document.querySelectorAll('map')].find(candidate => candidate.getAttribute('name') === targetName);
36
+ const target = querySelectorAllInTree(el, 'map').find(candidate => {
37
+ const name = candidate.getAttributeNode('name');
38
+ if (!name) {
39
+ return false;
40
+ }
41
+ // A name that is not known until it is rendered may be the one that the reference means
42
+ return name.isDynamicValue || getDOMValue(name) === targetName;
43
+ });
35
44
  if (!target) {
36
45
  report({
37
46
  scope: usemapAttr,
@@ -216,7 +216,7 @@ function validateUrls(urls, label, reportAt, t) {
216
216
  * Validate a document-rule `where` predicate. A predicate object must contain
217
217
  * exactly one of `and` / `or` / `not` / `href_matches` / `selector_matches`.
218
218
  *
219
- * @see https://wicg.github.io/nav-speculation/speculation-rules.html#document-rule-predicates
219
+ * @see https://html.spec.whatwg.org/multipage/speculative-loading.html#parse-a-document-rule-predicate
220
220
  */
221
221
  function validatePredicate(predicate, label, reportAt, t) {
222
222
  if (!isPlainObject(predicate)) {
@@ -1,4 +1,4 @@
1
- import { mayBeFocusable } from '@markuplint/ml-spec';
1
+ import { getComposedParentElement, mayBeFocusable } from '@markuplint/ml-spec';
2
2
  /**
3
3
  * Checks whether a focusable interactive element is inside an `aria-hidden=true` subtree.
4
4
  *
@@ -38,6 +38,10 @@ export const checkingInteractionInHidden = ({ el }) => t => {
38
38
  /**
39
39
  * Traverses the element's ancestor chain to find the closest element with `aria-hidden="true"`.
40
40
  *
41
+ * The chain is the one the accessibility tree is built from ({@link getComposedParentElement}): it ends at the
42
+ * top of the content of an ordinary `template` element, which is not rendered and whose placement is not known,
43
+ * and it goes on from a declarative shadow root to its host.
44
+ *
41
45
  * @param el - The element to start searching from (inclusive).
42
46
  * @returns The `aria-hidden` attribute node if found, or `null` if no ancestor is hidden.
43
47
  */
@@ -50,7 +54,7 @@ el) {
50
54
  if (ariaHidden?.value === 'true') {
51
55
  return ariaHidden;
52
56
  }
53
- current = current.parentElement;
57
+ current = getComposedParentElement(current);
54
58
  }
55
59
  return null;
56
60
  }
@@ -1,8 +1,8 @@
1
1
  import type { Options } from '../types.js';
2
- import type { ElementChecker } from '@markuplint/ml-core';
2
+ import type { AttrChecker, Element } from '@markuplint/ml-core';
3
3
  /**
4
- * Checks whether ARIA attributes are applied to descendants of an element whose
5
- * role has the `childrenArePresentational` characteristic.
4
+ * Checks whether an ARIA attribute or `role` is applied to a descendant of an
5
+ * element whose role has the `childrenArePresentational` characteristic.
6
6
  *
7
7
  * Presentational Children
8
8
  *
@@ -29,7 +29,18 @@ import type { ElementChecker } from '@markuplint/ml-core';
29
29
  * > can still be used in the name and description computation
30
30
  * > even when not included in the accessibility tree.
31
31
  *
32
- * @param el - The element node to inspect for presentational ancestor context.
33
- * @returns A violation if the element has ARIA attributes and an ancestor with presentational children.
32
+ * Because the text of the excluded descendants is still used, an attribute that
33
+ * changes what a descendant contributes to a text alternative is not
34
+ * ineffective, and is not reported (see {@link takesEffect}). The computations
35
+ * that can read the descendant are listed by {@link getVisits};
36
+ * `MLElement#getAccessibleNameVisits` tells which elements each of them reaches
37
+ * and how.
38
+ *
39
+ * @param attr - The attribute to inspect.
40
+ * @param el - The element the attribute is read on. For a pretended component it is
41
+ * the component, while the attribute belongs to the element it pretends to be.
42
+ * @returns A violation if the attribute is an ARIA attribute or `role` that takes no effect under an ancestor with presentational children.
34
43
  */
35
- export declare const checkingPresentationalChildren: ElementChecker<boolean, Options>;
44
+ export declare const checkingPresentationalChildren: AttrChecker<boolean, Options, {
45
+ readonly el: Element<boolean, Options>;
46
+ }>;
@@ -1,8 +1,8 @@
1
- import { isAriaAttr } from '../../helpers.js';
2
- import { ARIA_RECOMMENDED_VERSION, getComputedRole } 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
- * Checks whether ARIA attributes are applied to descendants of an element whose
5
- * role has the `childrenArePresentational` characteristic.
4
+ * Checks whether an ARIA attribute or `role` is applied to a descendant of an
5
+ * element whose role has the `childrenArePresentational` characteristic.
6
6
  *
7
7
  * Presentational Children
8
8
  *
@@ -29,44 +29,278 @@ import { ARIA_RECOMMENDED_VERSION, getComputedRole } from '@markuplint/ml-spec';
29
29
  * > can still be used in the name and description computation
30
30
  * > even when not included in the accessibility tree.
31
31
  *
32
- * @param el - The element node to inspect for presentational ancestor context.
33
- * @returns A violation if the element has ARIA attributes and an ancestor with presentational children.
32
+ * Because the text of the excluded descendants is still used, an attribute that
33
+ * changes what a descendant contributes to a text alternative is not
34
+ * ineffective, and is not reported (see {@link takesEffect}). The computations
35
+ * that can read the descendant are listed by {@link getVisits};
36
+ * `MLElement#getAccessibleNameVisits` tells which elements each of them reaches
37
+ * and how.
38
+ *
39
+ * @param attr - The attribute to inspect.
40
+ * @param el - The element the attribute is read on. For a pretended component it is
41
+ * the component, while the attribute belongs to the element it pretends to be.
42
+ * @returns A violation if the attribute is an ARIA attribute or `role` that takes no effect under an ancestor with presentational children.
34
43
  */
35
- export const checkingPresentationalChildren = ({ el }) => t => {
36
- const ancestor = getAncestorHasPresentationalChildren(el);
37
- if (!ancestor) {
44
+ export const checkingPresentationalChildren = ({ attr, el }) => t => {
45
+ if (!isAriaAttr(attr) && attr.nameKey !== 'role') {
38
46
  return;
39
47
  }
40
- if (!ancestor.role) {
48
+ const context = getContext(el);
49
+ if (!context) {
41
50
  return;
42
51
  }
43
- const hasAriaAttr = [...el.attributes].some(attr => isAriaAttr(attr) || attr.nameKey === 'role');
44
- if (!hasAriaAttr) {
52
+ if (takesEffect(attr, el, context)) {
45
53
  return;
46
54
  }
47
55
  return {
48
- scope: el,
49
- message: t('it may be ineffective because {0}', t("it has {0} as an ancestor that doesn't expose its descendants to the accessibility tree", t('the "{0*}" {1}', ancestor.role.name, 'role'))),
56
+ scope: attr,
57
+ message: t('it may be ineffective because {0}', t("it has {0} as an ancestor that doesn't expose its descendants to the accessibility tree", t('the "{0*}" {1}', context.role.name, 'role'))),
50
58
  };
51
59
  };
52
60
  /**
53
- * Traverses the element's ancestor chain to find one whose computed role
54
- * has the `childrenArePresentational` characteristic.
61
+ * The closest ancestor role with presentational children, and the visits of the
62
+ * element by the computations that can read it, once per element.
63
+ */
64
+ const getContext = memoizeByObject(
65
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
66
+ (el) => {
67
+ const ariaVersion = el.rule.options?.version ?? el.ownerMLDocument.ruleCommonSettings?.ariaVersion ?? ARIA_RECOMMENDED_VERSION;
68
+ const role = getClosestPresentationalChildrenRole(el, ariaVersion);
69
+ if (!role) {
70
+ return null;
71
+ }
72
+ return { ariaVersion, role, visits: getVisits(el, ariaVersion) };
73
+ });
74
+ /**
75
+ * Finds the closest ancestor whose computed role has the
76
+ * `childrenArePresentational` characteristic.
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.
55
80
  *
56
81
  * @param el - The element to start searching from (exclusive, starts from parent).
57
- * @returns The computed role of the ancestor with presentational children, or `null` if none found.
82
+ * @param ariaVersion - The ARIA version to compute the roles with.
83
+ * @returns The role of that ancestor, or `null` if none is found.
58
84
  */
59
- function getAncestorHasPresentationalChildren(
85
+ function getClosestPresentationalChildrenRole(
60
86
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
61
- el) {
62
- const ariaVersion = el.rule.options?.version ?? el.ownerMLDocument.ruleCommonSettings?.ariaVersion ?? ARIA_RECOMMENDED_VERSION;
63
- let current = el.parentElement;
87
+ el, ariaVersion) {
88
+ let current = getComposedParentElement(el);
64
89
  while (current) {
65
- const computed = getComputedRole(el.ownerMLDocument.specs, current, ariaVersion);
66
- if (computed.role?.childrenPresentational) {
67
- return computed;
90
+ const role = getComputedRole(el.ownerMLDocument.specs, current, ariaVersion).role;
91
+ if (role?.childrenPresentational) {
92
+ return role;
68
93
  }
69
- current = current.parentElement;
94
+ current = getComposedParentElement(current);
70
95
  }
71
96
  return null;
72
97
  }
98
+ /**
99
+ * The visits of the element by every computation that can read it. Each reader
100
+ * is taken whether or not its computation reaches the element; one that does
101
+ * not contributes no visit.
102
+ *
103
+ * - the accessible name of every ancestor, as any of them can collect the text
104
+ * of its subtree (Step 2F and the recursion into descendants)
105
+ * - the text alternative of the element or of an ancestor as a node that
106
+ * `aria-labelledby` or `aria-describedby` refers to
107
+ * - the accessible name of every control that a `label` ancestor can name: the
108
+ * element its `for` refers to, and its labelable descendants (HTML-AAM)
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
+ *
114
+ * @see https://w3c.github.io/accname/#comp_name_from_content
115
+ * @see https://w3c.github.io/accname/#comp_labelledby_traversal
116
+ * @see https://w3c.github.io/html-aam/#accessible-name-and-description-computation
117
+ *
118
+ * @param el - The descendant
119
+ * @param ariaVersion - The ARIA version to compute the names with
120
+ * @returns The visits
121
+ */
122
+ function getVisits(
123
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
124
+ el, ariaVersion) {
125
+ const visits = [];
126
+ const add = (
127
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
128
+ reader, asReferenced = false) => {
129
+ visits.push(...(reader.getAccessibleNameVisits(ariaVersion, asReferenced).get(el) ?? []));
130
+ };
131
+ const document = el.ownerMLDocument;
132
+ const referenced = getReferencedIds(document);
133
+ let current = el;
134
+ while (current) {
135
+ if (current !== el) {
136
+ add(current);
137
+ }
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) {
143
+ add(current, true);
144
+ }
145
+ for (const control of getLabeledControls(current)) {
146
+ add(control);
147
+ }
148
+ current = getComposedParentElement(current);
149
+ }
150
+ return visits;
151
+ }
152
+ /**
153
+ * The controls that the element can name if it is a `label`, as the accessible
154
+ * name computation finds labels: by `for` or as the closest `label` ancestor.
155
+ * Once per label.
156
+ *
157
+ * @see https://html.spec.whatwg.org/multipage/forms.html#labeled-control
158
+ */
159
+ const getLabeledControls = memoizeByObject(
160
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
161
+ (el) => {
162
+ if (el.localName !== 'label' || el.namespaceURI !== 'http://www.w3.org/1999/xhtml') {
163
+ return [];
164
+ }
165
+ const forAttr = el.getAttributeNode('for');
166
+ const target = forAttr == null ? null : getElementByIdInTree(el, forAttr.value, forAttr.isExpressionValue);
167
+ return [...(target ? [target] : []), ...el.querySelectorAll(labelable.join(', '))];
168
+ });
169
+ /**
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.
172
+ */
173
+ const getReferencedIds = memoizeByObject(
174
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
175
+ (document) => {
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
+ }
184
+ for (const name of ['aria-labelledby', 'aria-describedby']) {
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);
190
+ }
191
+ }
192
+ }
193
+ }
194
+ return idsOfTree;
195
+ });
196
+ /**
197
+ * Whether the attribute changes what its element contributes to a text
198
+ * alternative that reads it, given how the computations reach the element.
199
+ *
200
+ * - `aria-hidden="true"` makes the element hidden, so it and its subtree
201
+ * contribute nothing (Step 2A), unless the `hidden` attribute hides it already
202
+ * (the other way `@markuplint/ml-core` tells an element hidden).
203
+ * - An `aria-labelledby` replaces the element's subtree (Step 2B) when the
204
+ * elements it refers to have any text, except inside an ongoing
205
+ * `aria-labelledby` traversal.
206
+ * - A non-empty `aria-label` replaces it the same way (Step 2D), unless the
207
+ * element is hidden, an `aria-labelledby` of it takes effect first (one that
208
+ * refers to the element itself reads the `aria-label` again), or it is an
209
+ * embedded control, whose `aria-label` the recursion ignores.
210
+ * - `role="none"` or `role="presentation"` drops the host language text
211
+ * alternative of the element, such as the `alt` of an image (Step 2E), when the
212
+ * element has one and the Presentational Roles Conflict Resolution does not
213
+ * ignore the role.
214
+ * - A role that makes the element an embedded control, or keeps a native one
215
+ * from being it, changes what it contributes between its value and its text
216
+ * (Step 2C).
217
+ * - The `aria-valuetext`, or else the `aria-valuenow`, of a range embedded
218
+ * control is its value (Step 2C).
219
+ *
220
+ * @see https://w3c.github.io/accname/#comp_embedded_control
221
+ * @see https://w3c.github.io/accname/#comp_hidden_not_referenced
222
+ * @see https://w3c.github.io/accname/#comp_labelledby
223
+ * @see https://w3c.github.io/accname/#comp_label
224
+ * @see https://w3c.github.io/accname/#comp_host_language_label
225
+ *
226
+ * > Otherwise, if the current node's native markup provides an attribute (e.g. alt) or element (e.g. HTML label or SVG title) that defines a text alternative, return that alternative in the form of a flat string as defined by the host language […], unless the current node is exposed as presentational (role="presentation" or role="none", alt="").
227
+ *
228
+ * @param attr - The attribute on a descendant of a presentational ancestor
229
+ * @param el - The descendant
230
+ * @param context - The ARIA version and the visits of the descendant
231
+ * @returns `true` if the attribute takes effect on a text alternative
232
+ */
233
+ function takesEffect(
234
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
235
+ attr,
236
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
237
+ el, { ariaVersion, visits }) {
238
+ const shown = visits.filter(visit => !visit.hidden);
239
+ switch (namespaceKeyOf(attr)) {
240
+ case 'aria-hidden': {
241
+ return attr.value === 'true' && visits.some(visit => visit.hidden) && !el.hasAttribute('hidden');
242
+ }
243
+ case 'aria-labelledby': {
244
+ return labelledbyGivesText(el, ariaVersion) && shown.some(visit => !visit.inLabelledbyTraversal);
245
+ }
246
+ case 'aria-label': {
247
+ const overridden = labelledbyGivesText(el, ariaVersion) &&
248
+ !(el.id !== '' && el.getAttribute('aria-labelledby')?.split(/\s+/).includes(el.id));
249
+ return (attr.value.trim() !== '' &&
250
+ shown.some(visit => !visit.embeddedControl && (visit.inLabelledbyTraversal || !overridden)));
251
+ }
252
+ case 'aria-valuetext':
253
+ case 'aria-valuenow': {
254
+ // The value of a range embedded control: `aria-valuetext`, else `aria-valuenow`
255
+ const isRange = ['slider', 'spinbutton'].includes(el.getAttribute('role')?.trim().split(/\s+/)[0] ?? '') ||
256
+ (el.localName === 'input' && el.getAttribute('type')?.trim().toLowerCase() === 'range');
257
+ const valuetext = el.getAttribute('aria-valuetext')?.trim() ?? '';
258
+ const shadowed = namespaceKeyOf(attr) === 'aria-valuenow' && valuetext !== '';
259
+ // Step 2B comes first outside an ongoing aria-labelledby traversal
260
+ const labelledby = labelledbyGivesText(el, ariaVersion);
261
+ return (isRange &&
262
+ attr.value.trim() !== '' &&
263
+ !shadowed &&
264
+ shown.some(visit => visit.embeddedControl && (visit.inLabelledbyTraversal || !labelledby)));
265
+ }
266
+ case 'role': {
267
+ if (shown.length === 0) {
268
+ return false;
269
+ }
270
+ const { specs } = el.ownerMLDocument;
271
+ const computed = getComputedRole(specs, el, ariaVersion);
272
+ if (isEmbeddedControl(el, computed) !== isEmbeddedControl(el, getImplicitRole(specs, el, ariaVersion))) {
273
+ return true;
274
+ }
275
+ return (shown.some(visit => !visit.embeddedControl) &&
276
+ isExposedAsPresentational(computed) &&
277
+ el.getHostLanguageLabelSource(ariaVersion) !== null);
278
+ }
279
+ }
280
+ return false;
281
+ }
282
+ /**
283
+ * Whether the element is an embedded control with the role, as the resolver of
284
+ * `@markuplint/ml-core` tells it: by the role, or as a native control without one.
285
+ */
286
+ function isEmbeddedControl(
287
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
288
+ el, computed) {
289
+ return computed.role ? EMBEDDED_CONTROL_ROLES.has(computed.role.name) : isNativeEmbeddedControl(el);
290
+ }
291
+ /**
292
+ * Whether the `aria-labelledby` of the element gives a text: Step 2B returns the
293
+ * accumulated text of the elements it refers to only when that text is not empty.
294
+ */
295
+ function labelledbyGivesText(
296
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
297
+ el, ariaVersion) {
298
+ const labelledbyAttr = el.getAttributeNode('aria-labelledby');
299
+ return (labelledbyAttr?.value.split(/\s+/) ?? []).some(id => {
300
+ if (id === '') {
301
+ return false;
302
+ }
303
+ const referenced = getElementByIdInTree(el, id, labelledbyAttr?.isExpressionValue);
304
+ return referenced != null && referenced.getReferencedTextAlternative(ariaVersion) !== '';
305
+ });
306
+ }
@@ -23,7 +23,8 @@ export const checkingRequiredAccessibilityParentRole = ({ el, computed }) => t =
23
23
  (computed.errorType !== 'INVALID_REQUIRED_CONTEXT_ROLE' &&
24
24
  // NO_OWNER with role: null means all ancestors were transparent
25
25
  // and no valid context could be found. NO_OWNER with role kept
26
- // (parentElement === null) is a root fragment — skip.
26
+ // (no parent in the accessibility tree) is a root fragment, or the top
27
+ // of the content of an ordinary `template` element — skip.
27
28
  !(computed.errorType === 'NO_OWNER' && !computed.role))) {
28
29
  return;
29
30
  }
@@ -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;
@@ -9,15 +9,14 @@
9
9
  * @param attr - The ARIA attribute node to inspect.
10
10
  * @param role - The computed ARIA role, used to resolve conditional value types.
11
11
  * @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
12
  * @returns A violation if the attribute value does not match the expected type.
14
13
  */
15
- export const checkingValue = ({ attr, role, propSpecs, booleanish }) => t => {
14
+ export const checkingValue = ({ attr, role, propSpecs }) => t => {
16
15
  if (attr.isDynamicValue) {
17
16
  return;
18
17
  }
19
18
  const propSpec = propSpecs.find(p => p.name === attr.nameKey);
20
- const result = checkAria(propSpec, attr.value, role?.name, booleanish);
19
+ const result = checkAria(propSpec, attr.value, role?.name);
21
20
  if (result.isValid) {
22
21
  return;
23
22
  }
@@ -35,10 +34,9 @@ export const checkingValue = ({ attr, role, propSpecs, booleanish }) => t => {
35
34
  * @param propSpec - The ARIA property specification, or `undefined` if the property is unknown.
36
35
  * @param currentValue - The current attribute value to validate.
37
36
  * @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
37
  * @returns An object containing the validation result and, for enum types, the allowed values.
40
38
  */
41
- function checkAria(propSpec, currentValue, role, booleanish) {
39
+ function checkAria(propSpec, currentValue, role) {
42
40
  if (!propSpec) {
43
41
  return {
44
42
  currentValue,
@@ -55,7 +53,7 @@ function checkAria(propSpec, currentValue, role, booleanish) {
55
53
  }
56
54
  }
57
55
  }
58
- const isValid = checkAriaValue(valueType, currentValue, propSpec.enum, booleanish);
56
+ const isValid = checkAriaValue(valueType, currentValue, propSpec.enum);
59
57
  return {
60
58
  ...propSpec,
61
59
  currentValue,
@@ -72,10 +70,9 @@ function checkAria(propSpec, currentValue, role, booleanish) {
72
70
  * @param type - The ARIA value type (e.g., `"token"`, `"true/false"`, `"integer"`).
73
71
  * @param value - The raw attribute value to validate.
74
72
  * @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
73
  * @returns `true` if the value is valid for the given type, `false` otherwise.
77
74
  */
78
- export function checkAriaValue(type, value, tokenEnum, booleanish) {
75
+ export function checkAriaValue(type, value, tokenEnum) {
79
76
  switch (type) {
80
77
  case 'token': {
81
78
  return tokenEnum.includes(value);
@@ -90,21 +87,12 @@ export function checkAriaValue(type, value, tokenEnum, booleanish) {
90
87
  return true;
91
88
  }
92
89
  case 'true/false': {
93
- if (booleanish && value === '') {
94
- return true;
95
- }
96
90
  return ['true', 'false'].includes(value);
97
91
  }
98
92
  case 'tristate': {
99
- if (booleanish && value === '') {
100
- return true;
101
- }
102
93
  return ['mixed', 'true', 'false', 'undefined'].includes(value);
103
94
  }
104
95
  case 'true/false/undefined': {
105
- if (booleanish && value === '') {
106
- return true;
107
- }
108
96
  return ['true', 'false', 'undefined'].includes(value);
109
97
  }
110
98
  case 'integer': {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markuplint/rules",
3
- "version": "5.3.0",
3
+ "version": "5.5.0",
4
4
  "description": "Built-in rules of markuplint",
5
5
  "repository": {
6
6
  "type": "git",
@@ -31,21 +31,21 @@
31
31
  "./lib/permitted-contents/debug.js": "./lib/permitted-contents/debug.browser.js"
32
32
  },
33
33
  "dependencies": {
34
- "@markuplint/html-spec": "5.3.0",
35
- "@markuplint/ml-config": "5.3.0",
36
- "@markuplint/ml-core": "5.3.0",
37
- "@markuplint/ml-spec": "5.3.0",
38
- "@markuplint/selector": "5.3.0",
39
- "@markuplint/shared": "5.3.0",
40
- "@markuplint/types": "5.3.0",
41
- "@mdn/browser-compat-data": "^8.1.5",
34
+ "@markuplint/html-spec": "5.5.0",
35
+ "@markuplint/ml-config": "5.5.0",
36
+ "@markuplint/ml-core": "5.5.0",
37
+ "@markuplint/ml-spec": "5.5.0",
38
+ "@markuplint/selector": "5.5.0",
39
+ "@markuplint/shared": "5.5.0",
40
+ "@markuplint/types": "5.5.0",
41
+ "@mdn/browser-compat-data": "^8.1.8",
42
42
  "@types/debug": "^4.1.13",
43
43
  "ansi-colors": "^4.1.3",
44
- "browserslist": "^4.29.3",
44
+ "browserslist": "^4.29.5",
45
45
  "chrono-node": "^2.10.3",
46
46
  "debug": "^4.4.3",
47
47
  "image-size": "^2.0.4",
48
48
  "type-fest": "^5.10.0"
49
49
  },
50
- "gitHead": "f587cf0206c8426e57369a48d6d5fb22306eafde"
50
+ "gitHead": "fedebcfd56eaec4642e68643b2f9e10750d810e1"
51
51
  }