@markuplint/rules 5.3.0 → 5.4.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,13 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [5.4.0](https://github.com/markuplint/markuplint/compare/v5.3.0...v5.4.0) (2026-10-29)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **rules:** do not report descendant attributes that take part in a text alternative ([46002a7](https://github.com/markuplint/markuplint/commit/46002a75e06f877938d134ce0958a625e5d989d3)), closes [#4408](https://github.com/markuplint/markuplint/issues/4408)
11
+
12
+
6
13
  # [5.3.0](https://github.com/markuplint/markuplint/compare/v5.2.0...v5.3.0) (2026-10-21)
7
14
 
8
15
  ### Bug Fixes
@@ -13,7 +13,9 @@ export default createRule({
13
13
  return;
14
14
  if (!elSpec.globalAttrs['#ARIAAttrs'])
15
15
  return;
16
- report(checkingPresentationalChildren({ el }));
16
+ for (const attr of el.attributes) {
17
+ report(checkingPresentationalChildren({ attr, el }));
18
+ }
17
19
  });
18
20
  },
19
21
  });
@@ -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 { isAriaAttr, labelable, memoizeByObject, namespaceKeyOf } from '../../helpers.js';
2
+ import { ARIA_RECOMMENDED_VERSION, EMBEDDED_CONTROL_ROLES, 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,257 @@ 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.
55
77
  *
56
78
  * @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.
79
+ * @param ariaVersion - The ARIA version to compute the roles with.
80
+ * @returns The role of that ancestor, or `null` if none is found.
58
81
  */
59
- function getAncestorHasPresentationalChildren(
82
+ function getClosestPresentationalChildrenRole(
60
83
  // 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;
84
+ el, ariaVersion) {
63
85
  let current = el.parentElement;
64
86
  while (current) {
65
- const computed = getComputedRole(el.ownerMLDocument.specs, current, ariaVersion);
66
- if (computed.role?.childrenPresentational) {
67
- return computed;
87
+ const role = getComputedRole(el.ownerMLDocument.specs, current, ariaVersion).role;
88
+ if (role?.childrenPresentational) {
89
+ return role;
68
90
  }
69
91
  current = current.parentElement;
70
92
  }
71
93
  return null;
72
94
  }
95
+ /**
96
+ * The visits of the element by every computation that can read it. Each reader
97
+ * is taken whether or not its computation reaches the element; one that does
98
+ * not contributes no visit.
99
+ *
100
+ * - the accessible name of every ancestor, as any of them can collect the text
101
+ * of its subtree (Step 2F and the recursion into descendants)
102
+ * - the text alternative of the element or of an ancestor as a node that
103
+ * `aria-labelledby` or `aria-describedby` refers to
104
+ * - the accessible name of every control that a `label` ancestor can name: the
105
+ * element its `for` refers to, and its labelable descendants (HTML-AAM)
106
+ *
107
+ * @see https://w3c.github.io/accname/#comp_name_from_content
108
+ * @see https://w3c.github.io/accname/#comp_labelledby_traversal
109
+ * @see https://w3c.github.io/html-aam/#accessible-name-and-description-computation
110
+ *
111
+ * @param el - The descendant
112
+ * @param ariaVersion - The ARIA version to compute the names with
113
+ * @returns The visits
114
+ */
115
+ function getVisits(
116
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
117
+ el, ariaVersion) {
118
+ const visits = [];
119
+ const add = (
120
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
121
+ reader, asReferenced = false) => {
122
+ visits.push(...(reader.getAccessibleNameVisits(ariaVersion, asReferenced).get(el) ?? []));
123
+ };
124
+ const document = el.ownerMLDocument;
125
+ const referenced = getReferencedIds(document);
126
+ let current = el;
127
+ while (current) {
128
+ if (current !== el) {
129
+ add(current);
130
+ }
131
+ if (current.id && referenced.has(current.id) && document.getElementById(current.id) === current) {
132
+ add(current, true);
133
+ }
134
+ for (const control of getLabeledControls(current)) {
135
+ add(control);
136
+ }
137
+ current = current.parentElement;
138
+ }
139
+ return visits;
140
+ }
141
+ /**
142
+ * The controls that the element can name if it is a `label`, as the accessible
143
+ * name computation finds labels: by `for` or as the closest `label` ancestor.
144
+ * Once per label.
145
+ *
146
+ * @see https://html.spec.whatwg.org/multipage/forms.html#labeled-control
147
+ */
148
+ const getLabeledControls = memoizeByObject(
149
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
150
+ (el) => {
151
+ if (el.localName !== 'label' || el.namespaceURI !== 'http://www.w3.org/1999/xhtml') {
152
+ return [];
153
+ }
154
+ const htmlFor = el.getAttribute('for');
155
+ const target = htmlFor == null ? null : el.ownerMLDocument.getElementById(htmlFor);
156
+ return [...(target ? [target] : []), ...el.querySelectorAll(labelable.join(', '))];
157
+ });
158
+ /**
159
+ * The IDs that an `aria-labelledby` or `aria-describedby` of the document refers to.
160
+ */
161
+ const getReferencedIds = memoizeByObject(
162
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
163
+ (document) => {
164
+ const ids = new Set();
165
+ for (const referrer of document.querySelectorAll('[aria-labelledby], [aria-describedby]')) {
166
+ 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);
170
+ }
171
+ }
172
+ }
173
+ }
174
+ return ids;
175
+ });
176
+ /**
177
+ * Whether the attribute changes what its element contributes to a text
178
+ * alternative that reads it, given how the computations reach the element.
179
+ *
180
+ * - `aria-hidden="true"` makes the element hidden, so it and its subtree
181
+ * contribute nothing (Step 2A), unless the `hidden` attribute hides it already
182
+ * (the other way `@markuplint/ml-core` tells an element hidden).
183
+ * - An `aria-labelledby` replaces the element's subtree (Step 2B) when the
184
+ * elements it refers to have any text, except inside an ongoing
185
+ * `aria-labelledby` traversal.
186
+ * - A non-empty `aria-label` replaces it the same way (Step 2D), unless the
187
+ * element is hidden, an `aria-labelledby` of it takes effect first (one that
188
+ * refers to the element itself reads the `aria-label` again), or it is an
189
+ * embedded control, whose `aria-label` the recursion ignores.
190
+ * - `role="none"` or `role="presentation"` drops the host language text
191
+ * alternative of the element, such as the `alt` of an image (Step 2E), when the
192
+ * element has one and the Presentational Roles Conflict Resolution does not
193
+ * ignore the role.
194
+ * - A role that makes the element an embedded control, or keeps a native one
195
+ * from being it, changes what it contributes between its value and its text
196
+ * (Step 2C).
197
+ * - The `aria-valuetext`, or else the `aria-valuenow`, of a range embedded
198
+ * control is its value (Step 2C).
199
+ *
200
+ * @see https://w3c.github.io/accname/#comp_embedded_control
201
+ * @see https://w3c.github.io/accname/#comp_hidden_not_referenced
202
+ * @see https://w3c.github.io/accname/#comp_labelledby
203
+ * @see https://w3c.github.io/accname/#comp_label
204
+ * @see https://w3c.github.io/accname/#comp_host_language_label
205
+ *
206
+ * > 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="").
207
+ *
208
+ * @param attr - The attribute on a descendant of a presentational ancestor
209
+ * @param el - The descendant
210
+ * @param context - The ARIA version and the visits of the descendant
211
+ * @returns `true` if the attribute takes effect on a text alternative
212
+ */
213
+ function takesEffect(
214
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
215
+ attr,
216
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
217
+ el, { ariaVersion, visits }) {
218
+ const shown = visits.filter(visit => !visit.hidden);
219
+ switch (namespaceKeyOf(attr)) {
220
+ case 'aria-hidden': {
221
+ return attr.value === 'true' && visits.some(visit => visit.hidden) && !el.hasAttribute('hidden');
222
+ }
223
+ case 'aria-labelledby': {
224
+ return labelledbyGivesText(el, ariaVersion) && shown.some(visit => !visit.inLabelledbyTraversal);
225
+ }
226
+ case 'aria-label': {
227
+ const overridden = labelledbyGivesText(el, ariaVersion) &&
228
+ !(el.id !== '' && el.getAttribute('aria-labelledby')?.split(/\s+/).includes(el.id));
229
+ return (attr.value.trim() !== '' &&
230
+ shown.some(visit => !visit.embeddedControl && (visit.inLabelledbyTraversal || !overridden)));
231
+ }
232
+ case 'aria-valuetext':
233
+ case 'aria-valuenow': {
234
+ // The value of a range embedded control: `aria-valuetext`, else `aria-valuenow`
235
+ const isRange = ['slider', 'spinbutton'].includes(el.getAttribute('role')?.trim().split(/\s+/)[0] ?? '') ||
236
+ (el.localName === 'input' && el.getAttribute('type')?.trim().toLowerCase() === 'range');
237
+ const valuetext = el.getAttribute('aria-valuetext')?.trim() ?? '';
238
+ const shadowed = namespaceKeyOf(attr) === 'aria-valuenow' && valuetext !== '';
239
+ // Step 2B comes first outside an ongoing aria-labelledby traversal
240
+ const labelledby = labelledbyGivesText(el, ariaVersion);
241
+ return (isRange &&
242
+ attr.value.trim() !== '' &&
243
+ !shadowed &&
244
+ shown.some(visit => visit.embeddedControl && (visit.inLabelledbyTraversal || !labelledby)));
245
+ }
246
+ case 'role': {
247
+ if (shown.length === 0) {
248
+ return false;
249
+ }
250
+ const { specs } = el.ownerMLDocument;
251
+ const computed = getComputedRole(specs, el, ariaVersion);
252
+ if (isEmbeddedControl(el, computed) !== isEmbeddedControl(el, getImplicitRole(specs, el, ariaVersion))) {
253
+ return true;
254
+ }
255
+ return (shown.some(visit => !visit.embeddedControl) &&
256
+ isExposedAsPresentational(computed) &&
257
+ el.getHostLanguageLabelSource(ariaVersion) !== null);
258
+ }
259
+ }
260
+ return false;
261
+ }
262
+ /**
263
+ * Whether the element is an embedded control with the role, as the resolver of
264
+ * `@markuplint/ml-core` tells it: by the role, or as a native control without one.
265
+ */
266
+ function isEmbeddedControl(
267
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
268
+ el, computed) {
269
+ return computed.role ? EMBEDDED_CONTROL_ROLES.has(computed.role.name) : isNativeEmbeddedControl(el);
270
+ }
271
+ /**
272
+ * Whether the `aria-labelledby` of the element gives a text: Step 2B returns the
273
+ * accumulated text of the elements it refers to only when that text is not empty.
274
+ */
275
+ function labelledbyGivesText(
276
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
277
+ el, ariaVersion) {
278
+ return (el.getAttribute('aria-labelledby')?.split(/\s+/) ?? []).some(id => {
279
+ if (id === '') {
280
+ return false;
281
+ }
282
+ const referenced = el.ownerMLDocument.getElementById(id);
283
+ return referenced != null && referenced.getReferencedTextAlternative(ariaVersion) !== '';
284
+ });
285
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markuplint/rules",
3
- "version": "5.3.0",
3
+ "version": "5.4.0",
4
4
  "description": "Built-in rules of markuplint",
5
5
  "repository": {
6
6
  "type": "git",
@@ -31,14 +31,14 @@
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.4.0",
35
+ "@markuplint/ml-config": "5.4.0",
36
+ "@markuplint/ml-core": "5.4.0",
37
+ "@markuplint/ml-spec": "5.4.0",
38
+ "@markuplint/selector": "5.4.0",
39
+ "@markuplint/shared": "5.4.0",
40
+ "@markuplint/types": "5.4.0",
41
+ "@mdn/browser-compat-data": "^8.1.6",
42
42
  "@types/debug": "^4.1.13",
43
43
  "ansi-colors": "^4.1.3",
44
44
  "browserslist": "^4.29.3",
@@ -47,5 +47,5 @@
47
47
  "image-size": "^2.0.4",
48
48
  "type-fest": "^5.10.0"
49
49
  },
50
- "gitHead": "f587cf0206c8426e57369a48d6d5fb22306eafde"
50
+ "gitHead": "647f6fb5d5743f060678599c97e9c84b58970747"
51
51
  }