@markuplint/ml-spec 4.10.2 → 5.0.0-alpha.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/ARCHITECTURE.ja.md +24 -10
- package/ARCHITECTURE.md +24 -10
- package/CHANGELOG.md +18 -0
- package/README.md +2 -2
- package/docs/aria-algorithms.ja.md +180 -33
- package/docs/aria-algorithms.md +183 -34
- package/docs/html-algorithms.ja.md +2 -2
- package/docs/html-algorithms.md +2 -2
- package/docs/maintenance.ja.md +13 -22
- package/docs/maintenance.md +13 -22
- package/docs/spec-resolution.ja.md +37 -9
- package/docs/spec-resolution.md +45 -16
- package/docs/type-definitions.ja.md +8 -4
- package/docs/type-definitions.md +8 -4
- package/lib/algorithm/aria/accname/__tests__/test-helpers.d.ts +45 -0
- package/lib/algorithm/aria/accname/__tests__/test-helpers.js +120 -0
- package/lib/algorithm/aria/accname/aria-steps.d.ts +51 -0
- package/lib/algorithm/aria/accname/aria-steps.js +104 -0
- package/lib/algorithm/aria/accname/compute.d.ts +51 -0
- package/lib/algorithm/aria/accname/compute.js +101 -0
- package/lib/algorithm/aria/accname/element-names.d.ts +36 -0
- package/lib/algorithm/aria/accname/element-names.js +342 -0
- package/lib/algorithm/aria/accname/helpers.d.ts +98 -0
- package/lib/algorithm/aria/accname/helpers.js +330 -0
- package/lib/algorithm/aria/accname/index.d.ts +4 -0
- package/lib/algorithm/aria/accname/index.js +3 -0
- package/lib/algorithm/aria/accname/label-steps.d.ts +25 -0
- package/lib/algorithm/aria/accname/label-steps.js +66 -0
- package/lib/algorithm/aria/accname/svg-helpers.d.ts +17 -0
- package/lib/algorithm/aria/accname/svg-helpers.js +30 -0
- package/lib/algorithm/aria/accname/types.d.ts +70 -0
- package/lib/algorithm/aria/accname/types.js +2 -0
- package/lib/algorithm/aria/accname-computation.d.ts +14 -3
- package/lib/algorithm/aria/accname-computation.js +131 -8
- package/lib/algorithm/aria/aria-specs.d.ts +1 -0
- package/lib/algorithm/aria/get-aria.js +30 -4
- package/lib/algorithm/aria/get-computed-role.js +106 -26
- package/lib/algorithm/aria/get-explicit-role.d.ts +12 -0
- package/lib/algorithm/aria/get-explicit-role.js +12 -0
- package/lib/algorithm/aria/get-non-presentational-ancestor.d.ts +12 -0
- package/lib/algorithm/aria/get-non-presentational-ancestor.js +14 -2
- package/lib/algorithm/aria/get-permitted-roles-spec.d.ts +8 -4
- package/lib/algorithm/aria/get-permitted-roles-spec.js +19 -6
- package/lib/algorithm/aria/get-role-spec.js +10 -3
- package/lib/algorithm/aria/has-required-owned-elements.d.ts +2 -1
- package/lib/algorithm/aria/has-required-owned-elements.js +18 -15
- package/lib/algorithm/aria/is-presentational.d.ts +24 -0
- package/lib/algorithm/aria/is-presentational.js +31 -0
- package/lib/algorithm/aria/matches-context-role.d.ts +12 -4
- package/lib/algorithm/aria/matches-context-role.js +39 -6
- package/lib/algorithm/html/content-model-category-to-tag-names.js +1 -1
- package/lib/algorithm/html/get-content-model.d.ts +4 -2
- package/lib/algorithm/html/get-content-model.js +6 -7
- package/lib/const/accname.d.ts +29 -0
- package/lib/const/accname.js +76 -0
- package/lib/const/dom.d.ts +8 -0
- package/lib/const/dom.js +8 -0
- package/lib/const/index.d.ts +2 -0
- package/lib/const/index.js +2 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.js +4 -0
- package/lib/types/index.d.ts +65 -0
- package/lib/utils/directive-resolver.d.ts +23 -0
- package/lib/utils/directive-resolver.js +50 -0
- package/lib/utils/get-attr-specs-spec.js +4 -3
- package/lib/utils/get-ns.d.ts +7 -0
- package/lib/utils/get-ns.js +7 -0
- package/lib/utils/get-spec-by-tag-name.d.ts +1 -1
- package/lib/utils/merge-array.d.ts +10 -0
- package/lib/utils/merge-array.js +10 -0
- package/lib/utils/resolve-version.d.ts +11 -0
- package/lib/utils/resolve-version.js +11 -0
- package/lib/utils/schema-to-spec.d.ts +2 -0
- package/lib/utils/schema-to-spec.js +15 -4
- package/package.json +9 -7
|
@@ -1,18 +1,141 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
1
|
+
import { computeAccessibleName } from './accname/compute.js';
|
|
2
|
+
import { EMBEDDED_CONTROL_ROLES, escapeCSS } from './accname/helpers.js';
|
|
3
|
+
import { isNativeEmbeddedControl } from '../../const/index.js';
|
|
4
|
+
import { getComputedRole } from './get-computed-role.js';
|
|
3
5
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
+
* [Implementation-specific] Reentrant guard to prevent infinite recursion when
|
|
7
|
+
* getComputedRole evaluates `:aria(has name)` selectors, which call back into getAccname.
|
|
8
|
+
*
|
|
9
|
+
* This breaks the cycle: `getAccname → createDomResolver → allowsNameFromContent
|
|
10
|
+
* → getComputedRole → getARIA → matches(":aria(has name)") → getAccname`.
|
|
11
|
+
*
|
|
12
|
+
* Module-level state is intentional: the guard must span the entire call stack
|
|
13
|
+
* within a single synchronous computation. `WeakSet` ensures no memory leaks
|
|
14
|
+
* (entries are GC'd with their Element), and `try/finally` in `getAccname`
|
|
15
|
+
* guarantees cleanup even on exceptions.
|
|
16
|
+
*/
|
|
17
|
+
const computingElements = new WeakSet();
|
|
18
|
+
/**
|
|
19
|
+
* Computes the accessible name for a DOM `Element` using the AccName 1.2 algorithm.
|
|
20
|
+
*
|
|
21
|
+
* This is the public facade that bridges the pure AccName algorithm (in `accname/compute.ts`)
|
|
22
|
+
* with the DOM environment. It creates a DOM-based `AccnameResolver` and delegates to
|
|
23
|
+
* `computeAccessibleName`.
|
|
24
|
+
*
|
|
25
|
+
* **Reentrant guard**: Uses a `WeakSet<Element>` to detect and short-circuit
|
|
26
|
+
* recursive calls caused by the `:aria(has name)` pseudo-class selector in
|
|
27
|
+
* `getComputedRole` → `getARIA` → `matches` chain.
|
|
6
28
|
*
|
|
7
29
|
* @param el - The DOM element to compute the accessible name for
|
|
30
|
+
* @param specs - The ML specification data for role resolution
|
|
31
|
+
* @param version - The ARIA version to use for role computation
|
|
8
32
|
* @returns The computed accessible name string, or an empty string if none is found
|
|
33
|
+
* @see https://www.w3.org/TR/accname-1.2/#computation-steps — AccName 1.2 §4.3.2
|
|
9
34
|
*/
|
|
10
35
|
export function getAccname(
|
|
11
36
|
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
37
|
+
el, specs, version) {
|
|
38
|
+
if (computingElements.has(el)) {
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
computingElements.add(el);
|
|
42
|
+
const resolver = createDomResolver(el, specs, version);
|
|
43
|
+
// try/finally ensures the reentrant guard (WeakSet) is cleaned up even
|
|
44
|
+
// when computeAccessibleName throws. This is NOT a try/catch — exceptions
|
|
45
|
+
// propagate to the caller (ml-core's getAccname catches them).
|
|
46
|
+
try {
|
|
47
|
+
const result = computeAccessibleName(el, resolver);
|
|
48
|
+
return result.name;
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
computingElements.delete(el);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Creates an `AccnameResolver` from a DOM Element's owner document.
|
|
56
|
+
*
|
|
57
|
+
* [Implementation-specific] This resolver bridges the pure AccName algorithm
|
|
58
|
+
* with the DOM API. It uses `getComputedRole` for spec-driven role resolution
|
|
59
|
+
* (determining `allowsNameFromContent` and `isEmbeddedControl`) instead of
|
|
60
|
+
* hardcoded role lists, ensuring accuracy across ARIA versions.
|
|
61
|
+
*/
|
|
62
|
+
function createDomResolver(
|
|
63
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
64
|
+
el, specs, version) {
|
|
65
|
+
const root = getRootNode(el);
|
|
66
|
+
return {
|
|
67
|
+
getElementById(id) {
|
|
68
|
+
if ('getElementById' in root && typeof root.getElementById === 'function') {
|
|
69
|
+
return root.getElementById(id);
|
|
70
|
+
}
|
|
71
|
+
return root.querySelector(`[id="${escapeCSS(id)}"]`) ?? null;
|
|
72
|
+
},
|
|
73
|
+
getLabelsForId(id) {
|
|
74
|
+
const labels = root.querySelectorAll(`label[for="${escapeCSS(id)}"]`);
|
|
75
|
+
return [...labels];
|
|
76
|
+
},
|
|
77
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
78
|
+
allowsNameFromContent(target) {
|
|
79
|
+
// TODO: Remove cast when getComputedRole accepts AccnameElement (#3178)
|
|
80
|
+
const role = getComputedRole(specs, target, version);
|
|
81
|
+
return !!role.role?.accessibleNameFromContent;
|
|
82
|
+
},
|
|
83
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
84
|
+
isHidden(target) {
|
|
85
|
+
if (target.getAttribute('aria-hidden') === 'true') {
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
if (target.hasAttribute('hidden')) {
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
// Best-effort CSS visibility check (available in browser/JSDOM).
|
|
92
|
+
// getComputedStyle may throw on:
|
|
93
|
+
// - SecurityError: cross-origin iframes
|
|
94
|
+
// - TypeError: detached nodes not connected to a document
|
|
95
|
+
// These are expected operational failures. On error, fall through
|
|
96
|
+
// to return false (treat as visible). Attribute-based checks above
|
|
97
|
+
// (aria-hidden, hidden) are the primary detection for static analysis.
|
|
98
|
+
if (typeof getComputedStyle === 'function') {
|
|
99
|
+
try {
|
|
100
|
+
// TODO: Remove cast when getComputedStyle dependency is decoupled (#3178)
|
|
101
|
+
const style = getComputedStyle(target);
|
|
102
|
+
if (style.display === 'none' || style.visibility === 'hidden') {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Swallowed intentionally: SecurityError or TypeError from
|
|
108
|
+
// getComputedStyle is non-fatal. CSS hidden detection is
|
|
109
|
+
// supplementary; attribute checks above cover static analysis.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
},
|
|
114
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
115
|
+
isEmbeddedControl(target) {
|
|
116
|
+
// TODO: Remove cast when getComputedRole accepts AccnameElement (#3178)
|
|
117
|
+
const role = getComputedRole(specs, target, version);
|
|
118
|
+
if (role.role?.name && EMBEDDED_CONTROL_ROLES.has(role.role.name)) {
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
// Native HTML embedded controls without explicit role
|
|
122
|
+
return isNativeEmbeddedControl(target);
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Walks up the DOM tree to find the root node (Document or DocumentFragment).
|
|
128
|
+
*/
|
|
129
|
+
function getRootNode(
|
|
130
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
12
131
|
el) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
132
|
+
let root = el;
|
|
133
|
+
while (root.parentNode) {
|
|
134
|
+
root = root.parentNode;
|
|
135
|
+
}
|
|
136
|
+
if (root.nodeType === 9 || root.nodeType === 11) {
|
|
137
|
+
return root;
|
|
16
138
|
}
|
|
17
|
-
|
|
139
|
+
// Fallback: if we reach a disconnected element, use ownerDocument
|
|
140
|
+
return el.ownerDocument;
|
|
18
141
|
}
|
|
@@ -9,5 +9,6 @@ import type { ARIAVersion, MLMLSpec } from '../../types/index.js';
|
|
|
9
9
|
export declare function ariaSpecs(specs: MLMLSpec, version: ARIAVersion): {
|
|
10
10
|
readonly roles: readonly import("../../types/index.js").ARIARoleInSchema[];
|
|
11
11
|
readonly graphicsRoles: readonly import("../../types/index.js").ARIARoleInSchema[];
|
|
12
|
+
readonly dpubRoles: readonly import("../../types/index.js").ARIARoleInSchema[];
|
|
12
13
|
readonly props: readonly import("../../types/index.js").ARIAProperty[];
|
|
13
14
|
};
|
|
@@ -25,7 +25,23 @@ export function getARIA(specs, localName, namespace, version, matches) {
|
|
|
25
25
|
const conditionKeys = Object.keys(conditions);
|
|
26
26
|
let { implicitRole, permittedRoles, implicitProperties, properties, namingProhibited } = aria;
|
|
27
27
|
for (const cond of conditionKeys) {
|
|
28
|
-
|
|
28
|
+
let matched;
|
|
29
|
+
try {
|
|
30
|
+
matched = matches(cond);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
// Native Element.prototype.matches() cannot evaluate :aria()
|
|
34
|
+
// pseudo-class. When called from getImplicitRoleName() or
|
|
35
|
+
// getPermittedRoles() via accname-computation, native matches
|
|
36
|
+
// is used, which throws a DOMException (name: "SyntaxError").
|
|
37
|
+
// Note: JSDOM's DOMException is context-local, so instanceof
|
|
38
|
+
// checks against the global DOMException/SyntaxError fail.
|
|
39
|
+
if (error instanceof Error && error.name === 'SyntaxError') {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
if (!matched) {
|
|
29
45
|
continue;
|
|
30
46
|
}
|
|
31
47
|
const condARIA = conditions[cond];
|
|
@@ -61,13 +77,13 @@ function getVersionResolvedARIA(specs, localName, namespace, version) {
|
|
|
61
77
|
if (aria.permittedRoles !== false) {
|
|
62
78
|
aria = {
|
|
63
79
|
...aria,
|
|
64
|
-
permittedRoles: optimizePermittedRoles(aria.permittedRoles),
|
|
80
|
+
permittedRoles: optimizePermittedRoles(aria.permittedRoles, version),
|
|
65
81
|
};
|
|
66
82
|
}
|
|
67
83
|
cache.set(key, aria);
|
|
68
84
|
return aria;
|
|
69
85
|
}
|
|
70
|
-
function optimizePermittedRoles(permittedRoles) {
|
|
86
|
+
function optimizePermittedRoles(permittedRoles, version) {
|
|
71
87
|
if (!Array.isArray(permittedRoles)) {
|
|
72
88
|
return permittedRoles;
|
|
73
89
|
}
|
|
@@ -79,5 +95,15 @@ function optimizePermittedRoles(permittedRoles) {
|
|
|
79
95
|
if (unique.has('none')) {
|
|
80
96
|
unique.add('presentation');
|
|
81
97
|
}
|
|
82
|
-
|
|
98
|
+
// https://w3c.github.io/aria/#ref-for-image
|
|
99
|
+
// In ARIA 1.3, `image` is the primary role name and `img` is a synonym.
|
|
100
|
+
if (version === '1.3') {
|
|
101
|
+
if (unique.has('image')) {
|
|
102
|
+
unique.add('img');
|
|
103
|
+
}
|
|
104
|
+
if (unique.has('img')) {
|
|
105
|
+
unique.add('image');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return [...unique].toSorted();
|
|
83
109
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ariaSpecs } from './aria-specs.js';
|
|
2
2
|
import { isPresentational } from './is-presentational.js';
|
|
3
|
-
import {
|
|
3
|
+
import { SVG_NAMESPACE } from '../../const/index.js';
|
|
4
|
+
import { hasSvgAccessibleNameSource } from './accname/svg-helpers.js';
|
|
4
5
|
import { getAttrSpecs } from '../../utils/get-attr-specs.js';
|
|
5
6
|
import { getExplicitRole } from './get-explicit-role.js';
|
|
6
7
|
import { getImplicitRole } from './get-implicit-role.js';
|
|
@@ -8,6 +9,24 @@ import { getNonPresentationalAncestor } from './get-non-presentational-ancestor.
|
|
|
8
9
|
import { isRequiredOwnedElement } from './has-required-owned-elements.js';
|
|
9
10
|
import { matchesContextRole } from './matches-context-role.js';
|
|
10
11
|
import { mayBeFocusable } from '../html/may-be-focusable.js';
|
|
12
|
+
/**
|
|
13
|
+
* Module-level cache for computed role results.
|
|
14
|
+
*
|
|
15
|
+
* Uses WeakMap so entries are automatically garbage-collected
|
|
16
|
+
* when the Element is released. Cache key format: `${version}:${assumeSingleNode}`.
|
|
17
|
+
*
|
|
18
|
+
* Invariants:
|
|
19
|
+
* - `specs` is always the same `MLMLSpec` instance within a document traversal,
|
|
20
|
+
* so it is not included in the cache key.
|
|
21
|
+
* - Recursive calls from `computeRole` (via `getNonPresentationalAncestor`,
|
|
22
|
+
* `matchesContextRole`, `isNativeContextIntact`) always traverse upward
|
|
23
|
+
* in the DOM tree, so circular references cannot occur.
|
|
24
|
+
* - Cache entries become stale if an element's attributes are mutated after
|
|
25
|
+
* computation. This is acceptable because markuplint operates on a static
|
|
26
|
+
* document snapshot. External consumers that use the algorithm on a live
|
|
27
|
+
* DOM with dynamic attribute mutations should be aware of this limitation.
|
|
28
|
+
*/
|
|
29
|
+
const computedRoleCache = new WeakMap();
|
|
11
30
|
/**
|
|
12
31
|
* Computes the final ARIA role for an element according to the WAI-ARIA specification,
|
|
13
32
|
* applying the Presentational Roles Conflict Resolution algorithm. This considers
|
|
@@ -23,6 +42,25 @@ import { mayBeFocusable } from '../html/may-be-focusable.js';
|
|
|
23
42
|
export function getComputedRole(specs,
|
|
24
43
|
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
25
44
|
el, version, assumeSingleNode = false) {
|
|
45
|
+
const cacheKey = `${version}:${assumeSingleNode}`;
|
|
46
|
+
const elCache = computedRoleCache.get(el);
|
|
47
|
+
if (elCache) {
|
|
48
|
+
const cached = elCache.get(cacheKey);
|
|
49
|
+
if (cached) {
|
|
50
|
+
return cached;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const result = computeRole(specs, el, version, assumeSingleNode);
|
|
54
|
+
const cache = elCache ?? new Map();
|
|
55
|
+
cache.set(cacheKey, result);
|
|
56
|
+
if (!elCache) {
|
|
57
|
+
computedRoleCache.set(el, cache);
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
function computeRole(specs,
|
|
62
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
63
|
+
el, version, assumeSingleNode) {
|
|
26
64
|
let lazyImplicitRole;
|
|
27
65
|
const explicitRole = getExplicitRole(specs, el, version);
|
|
28
66
|
const computedRole = explicitRole.role
|
|
@@ -48,24 +86,36 @@ el, version, assumeSingleNode = false) {
|
|
|
48
86
|
* > causes the accessibility tree to be malformed,
|
|
49
87
|
* > the expected results are undefined.
|
|
50
88
|
*
|
|
51
|
-
* Determines whether the
|
|
52
|
-
*
|
|
53
|
-
* that has not decided whether the context is a parent or an ancestor.
|
|
89
|
+
* Determines whether the "Required Accessibility Parent Role" is valid
|
|
90
|
+
* (called "Required Context Role" in ARIA 1.2).
|
|
54
91
|
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
92
|
+
* In ARIA 1.1/1.2, the spec had not decided whether the context
|
|
93
|
+
* is a parent or an ancestor. This implementation interprets that
|
|
94
|
+
* as A PARENT, but `presentation`/`none` elements are treated as
|
|
95
|
+
* transparent by `getNonPresentationalAncestor`.
|
|
59
96
|
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
97
|
+
* ARIA 1.3 formally resolves this with the definitions of
|
|
98
|
+
* "accessibility child" and "accessibility parent", and
|
|
99
|
+
* `matchesContextRole` additionally skips `generic` elements.
|
|
100
|
+
*
|
|
101
|
+
* @see https://w3c.github.io/aria/#scope
|
|
102
|
+
* @see https://github.com/w3c/aria/issues/1033
|
|
103
|
+
* @see https://github.com/w3c/aria/pull/1454
|
|
64
104
|
*/
|
|
65
|
-
if (computedRole.role &&
|
|
105
|
+
if (computedRole.role &&
|
|
106
|
+
computedRole.role.requiredAccessibilityParentRole.length > 0 &&
|
|
107
|
+
// For implicit roles in native HTML contexts, skip the context role check
|
|
108
|
+
// when the direct parent retains its native semantics (no explicit role override
|
|
109
|
+
// and its own computed role is non-null). This handles spec data mismatches
|
|
110
|
+
// such as <option> inside <select>, where the ARIA spec requires "listbox"
|
|
111
|
+
// context but the HTML-ARIA mapping gives <select> the "combobox" role.
|
|
112
|
+
// When the parent HAS an explicit role or its computed role is null (cascaded
|
|
113
|
+
// from an ancestor override like <table role="none">), the check proceeds
|
|
114
|
+
// normally so that role nullification cascades correctly.
|
|
115
|
+
!isNativeContextIntact(computedRole, el, specs, version)) {
|
|
66
116
|
/**
|
|
67
117
|
* An element fragment that serves as the root without a parent element
|
|
68
|
-
* cannot satisfy the "Required
|
|
118
|
+
* cannot satisfy the "Required Accessibility Parent Role" condition.
|
|
69
119
|
* Therefore, under normal circumstances, the `role` will disappear.
|
|
70
120
|
* However, in this specific case, it will fall back to both explicit
|
|
71
121
|
* and implicit roles. Note that the explicit role takes precedence.
|
|
@@ -84,7 +134,7 @@ el, version, assumeSingleNode = false) {
|
|
|
84
134
|
errorType: 'NO_OWNER',
|
|
85
135
|
};
|
|
86
136
|
}
|
|
87
|
-
if (!matchesContextRole(computedRole.role.
|
|
137
|
+
if (!matchesContextRole(computedRole.role.requiredAccessibilityParentRole, el, specs, version)) {
|
|
88
138
|
return {
|
|
89
139
|
el,
|
|
90
140
|
role: null,
|
|
@@ -126,15 +176,12 @@ el, version, assumeSingleNode = false) {
|
|
|
126
176
|
// It doesn't been specified a valid explicit role.
|
|
127
177
|
(explicitRole.role === null || explicitRole.errorType != null) &&
|
|
128
178
|
// It is an SVG element.
|
|
129
|
-
el.namespaceURI ===
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
role: null,
|
|
136
|
-
};
|
|
137
|
-
}
|
|
179
|
+
el.namespaceURI === SVG_NAMESPACE &&
|
|
180
|
+
!hasSvgAccessibleNameSource(el)) {
|
|
181
|
+
return {
|
|
182
|
+
el,
|
|
183
|
+
role: null,
|
|
184
|
+
};
|
|
138
185
|
}
|
|
139
186
|
if (computedRole.role && !isPresentational(computedRole.role.name)) {
|
|
140
187
|
return computedRole;
|
|
@@ -193,8 +240,8 @@ el, version, assumeSingleNode = false) {
|
|
|
193
240
|
if (explicitRole.role) {
|
|
194
241
|
const nonPresentationalAncestor = getNonPresentationalAncestor(el, specs, version);
|
|
195
242
|
if (nonPresentationalAncestor.role &&
|
|
196
|
-
nonPresentationalAncestor.role?.
|
|
197
|
-
nonPresentationalAncestor.role.
|
|
243
|
+
nonPresentationalAncestor.role?.allowedAccessibilityChildRoles.length > 0 &&
|
|
244
|
+
nonPresentationalAncestor.role.allowedAccessibilityChildRoles.some(expected => {
|
|
198
245
|
// const ancestor = nonPresentationalAncestor.el;
|
|
199
246
|
// const ancestorImplicitRole = getImplicitRole(specs, ancestor, version);
|
|
200
247
|
// console.log({ nonPresentationalAncestor, ancestorImplicitRole });
|
|
@@ -252,3 +299,36 @@ el, predicate) {
|
|
|
252
299
|
}
|
|
253
300
|
return list.some(predicate);
|
|
254
301
|
}
|
|
302
|
+
/**
|
|
303
|
+
* Checks whether an element with an implicit role is in its native HTML
|
|
304
|
+
* context — i.e., the direct parent has no explicit role override and
|
|
305
|
+
* its own computed role is non-null.
|
|
306
|
+
*
|
|
307
|
+
* When this returns `true`, the context role check can be safely skipped
|
|
308
|
+
* because the native HTML parent-child relationship is intact, even if
|
|
309
|
+
* the ARIA spec data has a mismatch (e.g., `<option>` requires "listbox"
|
|
310
|
+
* but `<select>` maps to "combobox").
|
|
311
|
+
*
|
|
312
|
+
* When the parent has an explicit role (e.g., `<table role="none">`) or
|
|
313
|
+
* its computed role is `null` (cascaded from an ancestor override), the
|
|
314
|
+
* context is NOT considered intact and the check should proceed.
|
|
315
|
+
*/
|
|
316
|
+
function isNativeContextIntact(
|
|
317
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
318
|
+
computedRole,
|
|
319
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
320
|
+
el, specs, version) {
|
|
321
|
+
if (!computedRole.role?.isImplicit) {
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
const parent = el.parentElement;
|
|
325
|
+
if (!parent) {
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
const parentExplicit = getExplicitRole(specs, parent, version);
|
|
329
|
+
if (parentExplicit.role) {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
const parentComputed = getComputedRole(specs, parent, version);
|
|
333
|
+
return parentComputed.role !== null;
|
|
334
|
+
}
|
|
@@ -1,2 +1,14 @@
|
|
|
1
1
|
import type { ARIAVersion, ComputedRole, MLMLSpec } from '../../types/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Resolves the ARIA role from the `role` attribute value, implementing the
|
|
4
|
+
* WAI-ARIA "Handling Author Errors" algorithm. Iterates through whitespace-separated
|
|
5
|
+
* role tokens and returns the first one that passes all validation checks
|
|
6
|
+
* (exists, non-abstract, permitted, valid landmark).
|
|
7
|
+
*
|
|
8
|
+
* @see https://w3c.github.io/aria/#document-handling_author-errors
|
|
9
|
+
* @param specs - The full markup language specification
|
|
10
|
+
* @param el - The DOM element to resolve the explicit role for
|
|
11
|
+
* @param version - The ARIA specification version to use
|
|
12
|
+
* @returns The first valid role found as a `ComputedRole`, or `role: null` with the last encountered error type
|
|
13
|
+
*/
|
|
2
14
|
export declare function getExplicitRole(specs: MLMLSpec, el: Element, version: ARIAVersion): ComputedRole;
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { getRoleSpec } from './get-role-spec.js';
|
|
2
2
|
import { resolveNamespace } from '../../utils/resolve-namespace.js';
|
|
3
3
|
import { getPermittedRoles } from './get-permitted-roles.js';
|
|
4
|
+
/**
|
|
5
|
+
* Resolves the ARIA role from the `role` attribute value, implementing the
|
|
6
|
+
* WAI-ARIA "Handling Author Errors" algorithm. Iterates through whitespace-separated
|
|
7
|
+
* role tokens and returns the first one that passes all validation checks
|
|
8
|
+
* (exists, non-abstract, permitted, valid landmark).
|
|
9
|
+
*
|
|
10
|
+
* @see https://w3c.github.io/aria/#document-handling_author-errors
|
|
11
|
+
* @param specs - The full markup language specification
|
|
12
|
+
* @param el - The DOM element to resolve the explicit role for
|
|
13
|
+
* @param version - The ARIA specification version to use
|
|
14
|
+
* @returns The first valid role found as a `ComputedRole`, or `role: null` with the last encountered error type
|
|
15
|
+
*/
|
|
4
16
|
export function getExplicitRole(specs,
|
|
5
17
|
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
6
18
|
el, version) {
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
import type { ARIAVersion, MLMLSpec } from '../../types/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Traverses the parent element chain to find the nearest ancestor with a
|
|
4
|
+
* non-presentational role, skipping elements that are transparent for
|
|
5
|
+
* ownership traversal (via `isTransparentForOwnership`).
|
|
6
|
+
*
|
|
7
|
+
* In ARIA 1.3, `generic` role elements are additionally transparent.
|
|
8
|
+
*
|
|
9
|
+
* @param el - The DOM element whose ancestors to traverse
|
|
10
|
+
* @param specs - The full markup language specification
|
|
11
|
+
* @param version - The ARIA specification version to use
|
|
12
|
+
* @returns The nearest non-presentational ancestor's `ComputedRole`, or `{ el: null, role: null }` if none exists
|
|
13
|
+
*/
|
|
2
14
|
export declare function getNonPresentationalAncestor(el: Element, specs: MLMLSpec, version: ARIAVersion): import("../../types/index.js").ComputedRole | {
|
|
3
15
|
el: null;
|
|
4
16
|
role: null;
|
|
@@ -1,5 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isTransparentForOwnership } from './is-presentational.js';
|
|
2
2
|
import { getComputedRole } from './get-computed-role.js';
|
|
3
|
+
/**
|
|
4
|
+
* Traverses the parent element chain to find the nearest ancestor with a
|
|
5
|
+
* non-presentational role, skipping elements that are transparent for
|
|
6
|
+
* ownership traversal (via `isTransparentForOwnership`).
|
|
7
|
+
*
|
|
8
|
+
* In ARIA 1.3, `generic` role elements are additionally transparent.
|
|
9
|
+
*
|
|
10
|
+
* @param el - The DOM element whose ancestors to traverse
|
|
11
|
+
* @param specs - The full markup language specification
|
|
12
|
+
* @param version - The ARIA specification version to use
|
|
13
|
+
* @returns The nearest non-presentational ancestor's `ComputedRole`, or `{ el: null, role: null }` if none exists
|
|
14
|
+
*/
|
|
3
15
|
export function getNonPresentationalAncestor(
|
|
4
16
|
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
5
17
|
el, specs, version) {
|
|
@@ -12,7 +24,7 @@ el, specs, version) {
|
|
|
12
24
|
const assumeSingleNode = version !== '1.1' && version !== '1.2';
|
|
13
25
|
while (ancestor) {
|
|
14
26
|
const ancestorRole = getComputedRole(specs, ancestor, version, assumeSingleNode);
|
|
15
|
-
if (!
|
|
27
|
+
if (!isTransparentForOwnership(ancestorRole.role?.name, version)) {
|
|
16
28
|
return ancestorRole;
|
|
17
29
|
}
|
|
18
30
|
ancestor = ancestor.parentElement;
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import type { ARIAVersion, Matches, MLMLSpec } from '../../types/index.js';
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Computes the list of permitted ARIA roles for an element at the spec level,
|
|
4
|
+
* operating on tag name and namespace rather than a DOM element.
|
|
4
5
|
*
|
|
5
|
-
*
|
|
6
|
-
* -
|
|
7
|
-
*
|
|
6
|
+
* @param specs - The full markup language specification
|
|
7
|
+
* @param localName - The element's local tag name
|
|
8
|
+
* @param namespace - The element's namespace URI
|
|
9
|
+
* @param version - The ARIA specification version to use
|
|
10
|
+
* @param matches - A function that tests CSS selector matches for conditional role resolution
|
|
11
|
+
* @returns A list of permitted roles, each with a `name` and optional `deprecated` flag
|
|
8
12
|
*/
|
|
9
13
|
export declare function getPermittedRoles(specs: MLMLSpec, localName: string, namespace: string | null, version: ARIAVersion, matches: Matches): readonly {
|
|
10
14
|
readonly name: string;
|
|
@@ -4,11 +4,15 @@ import { mergeArray } from '../../utils/merge-array.js';
|
|
|
4
4
|
import { ariaSpecs } from './aria-specs.js';
|
|
5
5
|
import { getARIA } from './get-aria.js';
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
7
|
+
* Computes the list of permitted ARIA roles for an element at the spec level,
|
|
8
|
+
* operating on tag name and namespace rather than a DOM element.
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
* -
|
|
11
|
-
*
|
|
10
|
+
* @param specs - The full markup language specification
|
|
11
|
+
* @param localName - The element's local tag name
|
|
12
|
+
* @param namespace - The element's namespace URI
|
|
13
|
+
* @param version - The ARIA specification version to use
|
|
14
|
+
* @param matches - A function that tests CSS selector matches for conditional role resolution
|
|
15
|
+
* @returns A list of permitted roles, each with a `name` and optional `deprecated` flag
|
|
12
16
|
*/
|
|
13
17
|
export function getPermittedRoles(specs, localName, namespace, version, matches) {
|
|
14
18
|
const aria = getARIA(specs, localName, namespace, version, matches);
|
|
@@ -16,7 +20,7 @@ export function getPermittedRoles(specs, localName, namespace, version, matches)
|
|
|
16
20
|
return [];
|
|
17
21
|
}
|
|
18
22
|
const { implicitRole, permittedRoles } = aria;
|
|
19
|
-
const { roles, graphicsRoles } = ariaSpecs(specs, version);
|
|
23
|
+
const { roles, graphicsRoles, dpubRoles } = ariaSpecs(specs, version);
|
|
20
24
|
let permittedRoleList = [];
|
|
21
25
|
if (permittedRoles === true) {
|
|
22
26
|
permittedRoleList = mergeArray(permittedRoleList, roles
|
|
@@ -24,6 +28,11 @@ export function getPermittedRoles(specs, localName, namespace, version, matches)
|
|
|
24
28
|
.map(role => ({
|
|
25
29
|
name: role.name,
|
|
26
30
|
})));
|
|
31
|
+
permittedRoleList = mergeArray(permittedRoleList, dpubRoles
|
|
32
|
+
.filter(role => !role.isAbstract)
|
|
33
|
+
.map(role => ({
|
|
34
|
+
name: role.name,
|
|
35
|
+
})));
|
|
27
36
|
}
|
|
28
37
|
if (isAAMInfo(permittedRoles)) {
|
|
29
38
|
if (permittedRoles['core-aam']) {
|
|
@@ -54,7 +63,11 @@ export function getPermittedRoles(specs, localName, namespace, version, matches)
|
|
|
54
63
|
if (implicitRole === false) {
|
|
55
64
|
return permittedRoleList;
|
|
56
65
|
}
|
|
57
|
-
const implicitRoles = implicitRole === 'presentation' || implicitRole === 'none'
|
|
66
|
+
const implicitRoles = implicitRole === 'presentation' || implicitRole === 'none'
|
|
67
|
+
? ['none', 'presentation']
|
|
68
|
+
: version === '1.3' && (implicitRole === 'img' || implicitRole === 'image')
|
|
69
|
+
? ['image', 'img']
|
|
70
|
+
: [implicitRole];
|
|
58
71
|
return mergeArray(implicitRoles.map(r => ({ name: r })), permittedRoleList);
|
|
59
72
|
}
|
|
60
73
|
function isAAMInfo(permittedRoles) {
|
|
@@ -16,12 +16,16 @@ export function getRoleSpec(specs, roleName, namespace, version) {
|
|
|
16
16
|
return null;
|
|
17
17
|
}
|
|
18
18
|
const superClassRoles = recursiveTraverseSuperClassRoles(specs, roleName, namespace, version);
|
|
19
|
+
const requiredAccessibilityParentRole = role.requiredContextRole ?? role.requiredAccessibilityParentRole ?? [];
|
|
20
|
+
const allowedAccessibilityChildRoles = role.requiredOwnedElements ?? role.allowedAccessibilityChildRoles ?? [];
|
|
19
21
|
return {
|
|
20
22
|
name: role.name,
|
|
21
23
|
isAbstract: !!role.isAbstract,
|
|
22
24
|
deprecated: !!role.deprecated,
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
requiredAccessibilityParentRole,
|
|
26
|
+
allowedAccessibilityChildRoles,
|
|
27
|
+
requiredContextRole: requiredAccessibilityParentRole,
|
|
28
|
+
requiredOwnedElements: allowedAccessibilityChildRoles,
|
|
25
29
|
accessibleNameRequired: !!role.accessibleNameRequired,
|
|
26
30
|
accessibleNameFromAuthor: !!role.accessibleNameFromAuthor,
|
|
27
31
|
accessibleNameFromContent: !!role.accessibleNameFromContent,
|
|
@@ -51,10 +55,13 @@ function getSuperClassRoles(specs, roleName, namespace, version) {
|
|
|
51
55
|
.filter((role) => !!role) ?? null);
|
|
52
56
|
}
|
|
53
57
|
function getRoleByName(specs, roleName, namespace, version) {
|
|
54
|
-
const { roles, graphicsRoles } = ariaSpecs(specs, version);
|
|
58
|
+
const { roles, graphicsRoles, dpubRoles } = ariaSpecs(specs, version);
|
|
55
59
|
let role = roles.find(r => r.name === roleName);
|
|
56
60
|
if (!role && namespace === 'http://www.w3.org/2000/svg') {
|
|
57
61
|
role = graphicsRoles.find(r => r.name === roleName);
|
|
58
62
|
}
|
|
63
|
+
if (!role) {
|
|
64
|
+
role = dpubRoles.find(r => r.name === roleName);
|
|
65
|
+
}
|
|
59
66
|
return role;
|
|
60
67
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ARIAVersion, ComputedRole, MLMLSpec } from '../../types/index.js';
|
|
2
2
|
/**
|
|
3
|
-
* Checks whether an element satisfies the "
|
|
3
|
+
* Checks whether an element satisfies the "Allowed Accessibility Child Roles"
|
|
4
|
+
* constraint (called "Required Owned Elements" in ARIA 1.2)
|
|
4
5
|
* defined by its computed ARIA role. An element satisfies this constraint if it
|
|
5
6
|
* has `aria-owns`, or if any of its closest non-presentational descendants match
|
|
6
7
|
* the required owned element roles.
|