@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.
- package/CHANGELOG.md +28 -0
- package/CLAUDE.md +4 -3
- package/lib/aria-prop-requires-role/index.js +2 -2
- package/lib/attr-eligibility.d.ts +21 -0
- package/lib/attr-eligibility.js +86 -6
- package/lib/attr-order/index.js +2 -1
- package/lib/attr-value-quotes/index.js +1 -1
- package/lib/consistent-table-row-length/index.js +1 -1
- package/lib/element-supports-aria-prop/index.js +2 -2
- package/lib/form-attr-references-form/index.js +2 -2
- package/lib/helpers.d.ts +43 -5
- package/lib/helpers.js +88 -26
- package/lib/input-list-references-datalist/index.js +2 -2
- package/lib/itemprop-requires-itemscope/index.js +27 -10
- package/lib/label-for-references-labelable/index.js +2 -2
- package/lib/label-has-control/index.d.ts +17 -0
- package/lib/label-has-control/index.js +28 -1
- package/lib/label-no-multiple-controls/index.d.ts +4 -5
- package/lib/label-no-multiple-controls/index.js +6 -6
- package/lib/landmark-roles/roles.d.ts +13 -1
- package/lib/landmark-roles/roles.js +22 -0
- package/lib/no-ambiguous-navigable-target-names/index.js +4 -0
- package/lib/no-aria-on-presentational-children/index.js +7 -1
- package/lib/no-aria-on-unsupported-element/index.js +2 -2
- package/lib/no-boolean-attr-value/index.js +2 -1
- package/lib/no-broken-fragment-link/index.d.ts +5 -0
- package/lib/no-broken-fragment-link/index.js +8 -2
- package/lib/no-consecutive-br/index.js +1 -1
- package/lib/no-contradictory-aria-prop/index.js +2 -2
- package/lib/no-default-aria-value/index.js +2 -2
- package/lib/no-default-value/index.js +4 -0
- package/lib/no-deprecated-aria-prop/index.js +2 -2
- package/lib/no-disallowed-ancestor/index.d.ts +16 -1
- package/lib/no-disallowed-ancestor/index.js +78 -4
- package/lib/no-disallowed-attr/index.js +4 -0
- package/lib/no-duplicate-dt/index.js +1 -1
- package/lib/no-duplicate-h1/index.js +18 -14
- package/lib/no-duplicate-id/index.d.ts +9 -0
- package/lib/no-duplicate-id/index.js +23 -4
- package/lib/no-empty-table-track/index.js +1 -1
- package/lib/no-ineffective-attr/index.js +4 -0
- package/lib/no-invalid-aria-prop-value/index.js +3 -3
- package/lib/no-invalid-attr-value/index.js +12 -0
- package/lib/no-nested-top-level-landmark/index.js +20 -17
- package/lib/no-prohibited-naming/index.js +2 -2
- package/lib/no-redundant-accessible-name/index.js +8 -7
- package/lib/no-redundant-aria-prop/index.js +2 -2
- package/lib/no-refer-to-non-existent-id/index.d.ts +15 -0
- package/lib/no-refer-to-non-existent-id/index.js +47 -16
- package/lib/no-restricted-element/index.js +4 -2
- package/lib/no-skipped-heading-level/index.js +9 -4
- package/lib/no-stray-head-or-body-tag/index.d.ts +2 -1
- package/lib/no-stray-head-or-body-tag/index.js +2 -1
- package/lib/no-table-cell-overlap/index.js +1 -1
- package/lib/no-table-span-overflow/index.js +1 -1
- package/lib/no-unescaped-char/index.js +1 -1
- package/lib/permitted-contents/index.js +2 -4
- package/lib/permitted-contents/matches-selector.js +28 -5
- package/lib/permitted-contents/represent-transparent-nodes.js +100 -68
- package/lib/permitted-contents/slot-content.d.ts +2 -1
- package/lib/permitted-contents/slot-content.js +28 -2
- package/lib/permitted-contents/start.js +4 -2
- package/lib/permitted-contents/utils.d.ts +11 -0
- package/lib/permitted-contents/utils.js +15 -0
- package/lib/placeholder-label-option/index.js +4 -26
- package/lib/require-accessible-name/index.js +2 -2
- package/lib/require-adjacent-popover/index.js +4 -2
- package/lib/require-ancestor/index.d.ts +20 -0
- package/lib/require-ancestor/index.js +33 -7
- package/lib/require-datetime/index.js +1 -1
- package/lib/require-dialog-autofocus/index.js +8 -6
- package/lib/require-h1/index.js +4 -8
- package/lib/require-landmark-label/index.js +17 -15
- package/lib/role-supports-aria-prop/index.js +2 -2
- package/lib/sizes-auto-requires-lazy-loading/index.d.ts +4 -2
- package/lib/sizes-auto-requires-lazy-loading/index.js +4 -2
- package/lib/usemap-references-map/index.js +11 -2
- package/lib/valid-speculation-rules/index.js +1 -1
- package/lib/wai-aria/checkings/interaction-in-hidden.js +6 -2
- package/lib/wai-aria/checkings/presentational-children.d.ts +17 -6
- package/lib/wai-aria/checkings/presentational-children.js +259 -25
- package/lib/wai-aria/checkings/required-accessibility-parent-role.js +2 -1
- package/lib/wai-aria/checkings/required-prop.js +2 -1
- package/lib/wai-aria/checkings/tab-requires-tabpanel.js +10 -6
- package/lib/wai-aria/checkings/value.d.ts +1 -4
- package/lib/wai-aria/checkings/value.js +5 -17
- 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
|
|
13
|
-
*
|
|
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
|
|
17
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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://
|
|
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
|
|
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 {
|
|
2
|
+
import type { AttrChecker, Element } from '@markuplint/ml-core';
|
|
3
3
|
/**
|
|
4
|
-
* Checks whether ARIA
|
|
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
|
-
*
|
|
33
|
-
*
|
|
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:
|
|
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
|
|
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
|
-
*
|
|
33
|
-
*
|
|
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
|
-
|
|
37
|
-
if (!ancestor) {
|
|
44
|
+
export const checkingPresentationalChildren = ({ attr, el }) => t => {
|
|
45
|
+
if (!isAriaAttr(attr) && attr.nameKey !== 'role') {
|
|
38
46
|
return;
|
|
39
47
|
}
|
|
40
|
-
|
|
48
|
+
const context = getContext(el);
|
|
49
|
+
if (!context) {
|
|
41
50
|
return;
|
|
42
51
|
}
|
|
43
|
-
|
|
44
|
-
if (!hasAriaAttr) {
|
|
52
|
+
if (takesEffect(attr, el, context)) {
|
|
45
53
|
return;
|
|
46
54
|
}
|
|
47
55
|
return {
|
|
48
|
-
scope:
|
|
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}',
|
|
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
|
-
*
|
|
54
|
-
*
|
|
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
|
-
* @
|
|
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
|
|
85
|
+
function getClosestPresentationalChildrenRole(
|
|
60
86
|
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
61
|
-
el) {
|
|
62
|
-
|
|
63
|
-
let current = el.parentElement;
|
|
87
|
+
el, ariaVersion) {
|
|
88
|
+
let current = getComposedParentElement(el);
|
|
64
89
|
while (current) {
|
|
65
|
-
const
|
|
66
|
-
if (
|
|
67
|
-
return
|
|
90
|
+
const role = getComputedRole(el.ownerMLDocument.specs, current, ariaVersion).role;
|
|
91
|
+
if (role?.childrenPresentational) {
|
|
92
|
+
return role;
|
|
68
93
|
}
|
|
69
|
-
current = current
|
|
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
|
-
// (
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const hasLabellingTabpanel =
|
|
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
|
|
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[]
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
"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.
|
|
35
|
-
"@markuplint/ml-config": "5.
|
|
36
|
-
"@markuplint/ml-core": "5.
|
|
37
|
-
"@markuplint/ml-spec": "5.
|
|
38
|
-
"@markuplint/selector": "5.
|
|
39
|
-
"@markuplint/shared": "5.
|
|
40
|
-
"@markuplint/types": "5.
|
|
41
|
-
"@mdn/browser-compat-data": "^8.1.
|
|
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.
|
|
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": "
|
|
50
|
+
"gitHead": "fedebcfd56eaec4642e68643b2f9e10750d810e1"
|
|
51
51
|
}
|