@markuplint/ml-spec 2.1.1 → 3.0.0-alpha.1
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/lib/dom-traverse/accname-computation.d.ts +1 -0
- package/lib/dom-traverse/accname-computation.js +8 -0
- package/lib/dom-traverse/get-attr-specs.d.ts +2 -0
- package/lib/dom-traverse/get-attr-specs.js +8 -0
- package/lib/dom-traverse/get-computed-role.d.ts +2 -0
- package/lib/dom-traverse/get-computed-role.js +170 -0
- package/lib/dom-traverse/get-content-model.d.ts +3 -0
- package/lib/dom-traverse/get-content-model.js +28 -0
- package/lib/dom-traverse/get-explicit-role.d.ts +2 -0
- package/lib/dom-traverse/get-explicit-role.js +102 -0
- package/lib/dom-traverse/get-implicit-role.d.ts +3 -0
- package/lib/dom-traverse/get-implicit-role.js +37 -0
- package/lib/dom-traverse/get-non-presentational-ancestor.d.ts +5 -0
- package/lib/dom-traverse/get-non-presentational-ancestor.js +21 -0
- package/lib/dom-traverse/get-permitted-roles.d.ts +5 -0
- package/lib/dom-traverse/get-permitted-roles.js +8 -0
- package/lib/dom-traverse/get-spec.d.ts +2 -0
- package/lib/dom-traverse/get-spec.js +8 -0
- package/lib/dom-traverse/has-required-owned-elements.d.ts +3 -0
- package/lib/dom-traverse/has-required-owned-elements.js +81 -0
- package/lib/dom-traverse/may-be-focusable.d.ts +2 -0
- package/lib/dom-traverse/may-be-focusable.js +22 -0
- package/lib/index.d.ts +18 -6
- package/lib/index.js +18 -6
- package/lib/specs/aria-specs.d.ts +6 -0
- package/lib/specs/aria-specs.js +8 -0
- package/lib/specs/content-model-category-to-tag-names.d.ts +3 -0
- package/lib/specs/content-model-category-to-tag-names.js +15 -0
- package/lib/specs/get-aria.d.ts +3 -0
- package/lib/specs/get-aria.js +71 -0
- package/lib/specs/get-attr-specs.d.ts +8 -0
- package/lib/{get-attr-specs.js → specs/get-attr-specs.js} +8 -6
- package/lib/specs/get-implicit-role.d.ts +2 -0
- package/lib/specs/get-implicit-role.js +12 -0
- package/lib/specs/get-permitted-roles.d.ts +12 -0
- package/lib/specs/get-permitted-roles.js +61 -0
- package/lib/specs/get-role-spec.d.ts +5 -0
- package/lib/specs/get-role-spec.js +52 -0
- package/lib/specs/get-selectors-by-content-model-category.d.ts +3 -0
- package/lib/specs/get-selectors-by-content-model-category.js +8 -0
- package/lib/specs/get-spec-by-tag-name.d.ts +2 -0
- package/lib/specs/get-spec-by-tag-name.js +16 -0
- package/lib/specs/is-presentational.d.ts +1 -0
- package/lib/specs/is-presentational.js +10 -0
- package/lib/specs/resolve-version.d.ts +3 -0
- package/lib/specs/resolve-version.js +21 -0
- package/lib/specs/schema-to-spec.d.ts +13 -0
- package/lib/{get-spec.js → specs/schema-to-spec.js} +19 -11
- package/lib/types/aria.d.ts +136 -0
- package/lib/{attributes.js → types/aria.js} +0 -0
- package/lib/{attributes.d.ts → types/attributes.d.ts} +95 -48
- package/lib/{permitted-structres.js → types/attributes.js} +0 -0
- package/lib/{types.d.ts → types/index.d.ts} +55 -70
- package/lib/{types.js → types/index.js} +0 -0
- package/lib/types/permitted-structres.d.ts +58 -0
- package/lib/types/permitted-structres.js +8 -0
- package/lib/types/permitted-structures.d.ts +54 -0
- package/lib/types/permitted-structures.js +8 -0
- package/lib/utils/get-ns.d.ts +2 -0
- package/lib/{get-ns.js → utils/get-ns.js} +3 -0
- package/lib/{utils.d.ts → utils/merge-array.d.ts} +0 -0
- package/lib/{utils.js → utils/merge-array.js} +0 -0
- package/lib/utils/resolve-namespace.d.ts +9 -0
- package/lib/utils/resolve-namespace.js +31 -0
- package/package.json +12 -8
- package/schema.json +6 -6
- package/schemas/aria.schema.json +258 -0
- package/schemas/attributes.schema.json +3 -22
- package/schemas/content-models.schema.json +200 -0
- package/schemas/element.schema.json +11 -0
- package/src/dom-traverse/accname-computation.spec.ts +69 -0
- package/src/dom-traverse/accname-computation.ts +5 -0
- package/src/dom-traverse/get-attr-specs.ts +8 -0
- package/src/dom-traverse/get-computed-role.spec.ts +134 -0
- package/src/dom-traverse/get-computed-role.ts +181 -0
- package/src/dom-traverse/get-content-model.ts +32 -0
- package/src/dom-traverse/get-explicit-role.ts +112 -0
- package/src/dom-traverse/get-implicit-role.ts +36 -0
- package/src/dom-traverse/get-non-presentational-ancestor.ts +20 -0
- package/src/dom-traverse/get-permitted-roles.ts +7 -0
- package/src/dom-traverse/get-spec.ts +7 -0
- package/src/dom-traverse/has-required-owned-elements.spec.ts +36 -0
- package/src/dom-traverse/has-required-owned-elements.ts +85 -0
- package/src/dom-traverse/may-be-focusable.ts +21 -0
- package/src/index.ts +18 -6
- package/src/specs/aria-specs.ts +6 -0
- package/src/specs/content-model-category-to-tag-names.ts +15 -0
- package/src/specs/get-aria.ts +85 -0
- package/src/{get-attr-specs.spec.ts → specs/get-attr-specs.spec.ts} +4 -7
- package/src/{get-attr-specs.ts → specs/get-attr-specs.ts} +12 -7
- package/src/specs/get-implicit-role.spec.ts +81 -0
- package/src/specs/get-implicit-role.ts +17 -0
- package/src/specs/get-permitted-roles.spec.ts +420 -0
- package/src/specs/get-permitted-roles.ts +87 -0
- package/src/specs/get-role-spec.spec.ts +67 -0
- package/src/specs/get-role-spec.ts +72 -0
- package/src/specs/get-selectors-by-content-model-category.ts +10 -0
- package/src/specs/get-spec-by-tag-name.spec.ts +68 -0
- package/src/specs/get-spec-by-tag-name.ts +20 -0
- package/src/specs/is-presentational.ts +6 -0
- package/src/specs/resolve-version.ts +20 -0
- package/src/{get-spec.spec.ts → specs/schema-to-spec.spec.ts} +2 -2
- package/src/{get-spec.ts → specs/schema-to-spec.ts} +23 -9
- package/src/types/aria.ts +153 -0
- package/src/{attributes.ts → types/attributes.ts} +162 -117
- package/src/{types.ts → types/index.ts} +75 -74
- package/src/types/permitted-structres.ts +101 -0
- package/src/types/permitted-structures.ts +97 -0
- package/src/{get-ns.ts → utils/get-ns.ts} +6 -1
- package/src/{utils.ts → utils/merge-array.ts} +0 -0
- package/src/utils/resolve-namespace.spec.ts +37 -0
- package/src/utils/resolve-namespace.ts +40 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/lib/get-attr-specs.d.ts +0 -7
- package/lib/get-ns.d.ts +0 -1
- package/lib/get-spec-by-tag-name.d.ts +0 -2
- package/lib/get-spec-by-tag-name.js +0 -31
- package/lib/get-spec.d.ts +0 -13
- package/lib/permitted-structres.d.ts +0 -73
- package/schemas/permitted-structures.schema.json +0 -244
- package/schemas/wai-aria.schema.json +0 -87
- package/src/get-spec-by-tag-name.ts +0 -32
- package/src/permitted-structres.ts +0 -118
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { ARIAVersion, ComputedRole, MLMLSpec, RoleComputationError } from '../types';
|
|
2
|
+
|
|
3
|
+
import { getRoleSpec } from '../specs/get-role-spec';
|
|
4
|
+
import { resolveNamespace } from '../utils/resolve-namespace';
|
|
5
|
+
|
|
6
|
+
import { getPermittedRoles } from './get-permitted-roles';
|
|
7
|
+
|
|
8
|
+
export function getExplicitRole(specs: MLMLSpec, el: Element, version: ARIAVersion): ComputedRole {
|
|
9
|
+
const roleValue = el.getAttribute('role');
|
|
10
|
+
const roleNames = roleValue?.toLowerCase().trim().split(/\s+/g) || [];
|
|
11
|
+
const permittedRoles = getPermittedRoles(el, version, specs);
|
|
12
|
+
const { namespaceURI } = resolveNamespace(el.localName, el.namespaceURI);
|
|
13
|
+
|
|
14
|
+
let error: RoleComputationError = 'NO_EXPLICIT';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve from values and **Handling Author Errors**
|
|
18
|
+
*
|
|
19
|
+
* @see https://w3c.github.io/aria/#document-handling_author-errors_roles
|
|
20
|
+
*/
|
|
21
|
+
for (const roleName of roleNames) {
|
|
22
|
+
const spec = getRoleSpec(specs, roleName, namespaceURI, version);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* If `spec` is null, the role DOES NOT EXIST.
|
|
26
|
+
*
|
|
27
|
+
* > If the role attribute contains no tokens matching the name
|
|
28
|
+
* > of a non-abstract WAI-ARIA role,
|
|
29
|
+
* > the user agent MUST treat the element as
|
|
30
|
+
* > if no role had been provided. For example,
|
|
31
|
+
* > <table role="foo"> should be exposed
|
|
32
|
+
* > in the same way as <table> and <input type="text" role="structure">
|
|
33
|
+
* > in the same way as <input type="text">.
|
|
34
|
+
*/
|
|
35
|
+
if (!spec) {
|
|
36
|
+
error = 'ROLE_NO_EXISTS';
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* > As stated in the Definition of Roles section,
|
|
42
|
+
* > it is considered an authoring error to use abstract roles in content.
|
|
43
|
+
* > User agents MUST NOT map abstract roles
|
|
44
|
+
* > via the standard role mechanism of the accessibility API.
|
|
45
|
+
*/
|
|
46
|
+
if (spec.isAbstract) {
|
|
47
|
+
error = 'ABSTRACT';
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Whether the role is the permitted role according to ARIA in HTML.
|
|
53
|
+
*/
|
|
54
|
+
if (!permittedRoles.some(r => r.name === roleName)) {
|
|
55
|
+
error = 'NO_PERMITTED';
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* > Certain landmark roles require names from authors.
|
|
61
|
+
* > In situations where an author has not specified names for these landmarks,
|
|
62
|
+
* > it is considered an authoring error.
|
|
63
|
+
* > The user agent MUST treat such elements as if no role had been provided.
|
|
64
|
+
* > If a valid fallback role had been specified,
|
|
65
|
+
* > or if the element had an implicit ARIA role,
|
|
66
|
+
* > then user agents would continue to expose that role, instead.
|
|
67
|
+
*/
|
|
68
|
+
if (isLandmarkRole(spec) && !isValidLandmarkRole(el, spec)) {
|
|
69
|
+
error = 'INVALID_LANDMARK';
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Otherwise
|
|
75
|
+
*/
|
|
76
|
+
return {
|
|
77
|
+
el,
|
|
78
|
+
role: {
|
|
79
|
+
...spec,
|
|
80
|
+
isImplicit: false,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
el,
|
|
87
|
+
role: null,
|
|
88
|
+
errorType: error,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isLandmarkRole(role: NonNullable<ReturnType<typeof getRoleSpec>>) {
|
|
93
|
+
return role?.superClassRoles.some(su => su.name === 'landmark');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isValidLandmarkRole(el: Element, role: NonNullable<ReturnType<typeof getRoleSpec>>) {
|
|
97
|
+
if (!role.accessibleNameRequired) {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
if (role.accessibleNameFromAuthor) {
|
|
101
|
+
if (el.getAttribute('aria-label')) {
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
const id = el.getAttribute('aria-labelledby');
|
|
105
|
+
if (id && el.ownerDocument.getElementById(id)) {
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// The landmark role does not require names from the content
|
|
110
|
+
// if (role.accessibleNameFromContent) {}
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ARIAVersion, ComputedRole, MLMLSpec } from '../types';
|
|
2
|
+
|
|
3
|
+
import { getImplicitRole as _getImplicitRole } from '../specs/get-implicit-role';
|
|
4
|
+
import { getRoleSpec } from '../specs/get-role-spec';
|
|
5
|
+
import { resolveNamespace } from '../utils/resolve-namespace';
|
|
6
|
+
|
|
7
|
+
export function getImplicitRole(specs: Readonly<MLMLSpec>, el: Element, version: ARIAVersion): ComputedRole {
|
|
8
|
+
const implicitRole = getImplicitRoleName(el, version, specs);
|
|
9
|
+
if (implicitRole === false) {
|
|
10
|
+
// No Corresponding Role
|
|
11
|
+
return {
|
|
12
|
+
el,
|
|
13
|
+
role: null,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
const { namespaceURI } = resolveNamespace(el.localName, el.namespaceURI);
|
|
17
|
+
const spec = getRoleSpec(specs, implicitRole, namespaceURI, version);
|
|
18
|
+
if (!spec) {
|
|
19
|
+
return {
|
|
20
|
+
el,
|
|
21
|
+
role: null,
|
|
22
|
+
errorType: 'IMPLICIT_ROLE_NAMESPACE_ERROR',
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
el,
|
|
27
|
+
role: {
|
|
28
|
+
...spec,
|
|
29
|
+
isImplicit: true,
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getImplicitRoleName(el: Element, version: ARIAVersion, specs: Readonly<MLMLSpec>) {
|
|
35
|
+
return _getImplicitRole(specs, el.localName, el.namespaceURI, version, el.matches.bind(el));
|
|
36
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ARIAVersion, MLMLSpec } from '../types';
|
|
2
|
+
|
|
3
|
+
import { isPresentational } from '../specs/is-presentational';
|
|
4
|
+
|
|
5
|
+
import { getComputedRole } from './get-computed-role';
|
|
6
|
+
|
|
7
|
+
export function getNonPresentationalAncestor(el: Element, specs: MLMLSpec, version: ARIAVersion) {
|
|
8
|
+
let ancestor: Element | null = el.parentElement;
|
|
9
|
+
while (ancestor) {
|
|
10
|
+
const ancestorRole = getComputedRole(specs, ancestor, version);
|
|
11
|
+
if (!isPresentational(ancestorRole.role?.name)) {
|
|
12
|
+
return ancestorRole;
|
|
13
|
+
}
|
|
14
|
+
ancestor = ancestor.parentElement;
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
el: ancestor,
|
|
18
|
+
role: null,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ARIAVersion, MLMLSpec } from '../types';
|
|
2
|
+
|
|
3
|
+
import { getPermittedRoles as _getPermittedRoles } from '../specs/get-permitted-roles';
|
|
4
|
+
|
|
5
|
+
export function getPermittedRoles(el: Element, version: ARIAVersion, specs: Readonly<MLMLSpec>) {
|
|
6
|
+
return _getPermittedRoles(specs, el.localName, el.namespaceURI, version, el.matches.bind(el));
|
|
7
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ElementSpec } from '../types';
|
|
2
|
+
|
|
3
|
+
import { getSpecByTagName } from '../specs/get-spec-by-tag-name';
|
|
4
|
+
|
|
5
|
+
export function getSpec<K extends keyof ElementSpec>(el: Element, specs: Pick<ElementSpec, 'name' | K>[]) {
|
|
6
|
+
return getSpecByTagName(specs, el.localName, el.namespaceURI);
|
|
7
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ARIAVersion } from '../types';
|
|
2
|
+
|
|
3
|
+
import specs from '@markuplint/html-spec';
|
|
4
|
+
import { createSelector } from '@markuplint/selector';
|
|
5
|
+
import { createJSDOMElement } from '@markuplint/test-tools';
|
|
6
|
+
|
|
7
|
+
import { hasRequiredOwnedElement } from './has-required-owned-elements';
|
|
8
|
+
|
|
9
|
+
function _(html: string, selector?: string) {
|
|
10
|
+
return createJSDOMElement(html, selector, function (selector) {
|
|
11
|
+
// JSDOM supports no level 4 selectors yet.
|
|
12
|
+
return !!createSelector(selector, specs).match(this);
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function m(html: string, version: ARIAVersion, selector?: string) {
|
|
17
|
+
const el = _(html, selector);
|
|
18
|
+
return hasRequiredOwnedElement(el, specs, version);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('matchesOwnedRole', () => {
|
|
22
|
+
test('rowgroup > row', () => {
|
|
23
|
+
expect(m('<table><tbody><tr><td></td></tr></tbody></table>', '1.2')).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
test('row', () => {
|
|
26
|
+
expect(m('<table><tr><td></td></tr></table>', '1.2', 'tr')).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
test('list > listitem', () => {
|
|
29
|
+
expect(m('<ul><li></li></ul>', '1.2')).toBe(true);
|
|
30
|
+
expect(m('<ul><div><li></li></div></ul>', '1.2')).toBe(false);
|
|
31
|
+
expect(m('<ul><div role="none"><li></li></div></ul>', '1.2')).toBe(true);
|
|
32
|
+
expect(
|
|
33
|
+
m('<ul><div role="none"><div role="none"><div role="none"><li></li></div></div></div></ul>', '1.2'),
|
|
34
|
+
).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { ARIAVersion, ComputedRole, MLMLSpec } from '../types';
|
|
2
|
+
|
|
3
|
+
import { isPresentational } from '../specs/is-presentational';
|
|
4
|
+
|
|
5
|
+
import { getComputedRole } from './get-computed-role';
|
|
6
|
+
import { getExplicitRole } from './get-explicit-role';
|
|
7
|
+
import { getImplicitRole } from './get-implicit-role';
|
|
8
|
+
|
|
9
|
+
export function hasRequiredOwnedElement(el: Element, specs: Readonly<MLMLSpec>, version: ARIAVersion): boolean {
|
|
10
|
+
/**
|
|
11
|
+
* The element has aria-owns which means it may have owned elements.
|
|
12
|
+
*
|
|
13
|
+
* THIS CONDITION IS PARTIAL SUPPORT.
|
|
14
|
+
*/
|
|
15
|
+
if (el.hasAttribute('aria-owns')) {
|
|
16
|
+
// FIXME
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Otherwise, traverses descendants to find owned elements.
|
|
22
|
+
*/
|
|
23
|
+
const computed = getComputedRole(specs, el, version);
|
|
24
|
+
if (!computed.role || computed.role.requiredOwnedElements.length === 0) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
for (const expectRole of computed.role.requiredOwnedElements) {
|
|
28
|
+
for (const owned of getClosestNonPresentationalDescendants(el, specs, version)) {
|
|
29
|
+
if (isRequiredOwnedElement(owned, expectRole, specs, version)) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isRequiredOwnedElement(owned: ComputedRole, query: string, specs: MLMLSpec, version: ARIAVersion) {
|
|
39
|
+
const [baseRole, owningRole] = query.split(' > ') as [string, string | undefined];
|
|
40
|
+
if (owned.role?.name !== baseRole) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!owningRole) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const owning of getClosestNonPresentationalDescendants(owned.el, specs, version)) {
|
|
49
|
+
if (owning.role?.name === owningRole) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Gets the list of closest non-presentational descendants.
|
|
59
|
+
* ⚠ THE SPECIFICATION HAS AN ISSUE
|
|
60
|
+
* that has not decided whether the owned element is a child or a descendant.
|
|
61
|
+
*
|
|
62
|
+
* @see https://github.com/w3c/aria/issues/1033
|
|
63
|
+
* @see https://github.com/w3c/aria/issues/748
|
|
64
|
+
* @see https://github.com/w3c/aria/pull/1162
|
|
65
|
+
* @see https://github.com/w3c/aria/pull/1213
|
|
66
|
+
*
|
|
67
|
+
* Currently, this process interprets that as A CHILD
|
|
68
|
+
* because it wants to be near to HTML semantics.
|
|
69
|
+
* However, the presentational role behaves transparently
|
|
70
|
+
* according to the sample code in WAI-ARIA specification.
|
|
71
|
+
*/
|
|
72
|
+
function getClosestNonPresentationalDescendants(el: Element, specs: MLMLSpec, version: ARIAVersion): ComputedRole[] {
|
|
73
|
+
const owned: ComputedRole[] = [];
|
|
74
|
+
for (const child of Array.from(el.children)) {
|
|
75
|
+
const implicitRole = getImplicitRole(specs, child, version);
|
|
76
|
+
const explicitRole = getExplicitRole(specs, child, version);
|
|
77
|
+
const computed = explicitRole.role ? explicitRole : implicitRole;
|
|
78
|
+
if (isPresentational(computed.role?.name)) {
|
|
79
|
+
owned.push(...getClosestNonPresentationalDescendants(child, specs, version));
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
owned.push({ ...computed, el: child });
|
|
83
|
+
}
|
|
84
|
+
return owned;
|
|
85
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { MLMLSpec } from '../types';
|
|
2
|
+
|
|
3
|
+
import { getSelectorsByContentModelCategory } from '../specs/get-selectors-by-content-model-category';
|
|
4
|
+
|
|
5
|
+
export function mayBeFocusable(el: Element, specs: Readonly<MLMLSpec>): boolean {
|
|
6
|
+
return [
|
|
7
|
+
/**
|
|
8
|
+
* Interactive content
|
|
9
|
+
*
|
|
10
|
+
* @see https://html.spec.whatwg.org/multipage/dom.html#interactive-content
|
|
11
|
+
*/
|
|
12
|
+
...getSelectorsByContentModelCategory(specs, '#interactive'),
|
|
13
|
+
/**
|
|
14
|
+
* Interaction
|
|
15
|
+
*
|
|
16
|
+
* @see https://html.spec.whatwg.org/multipage/interaction.html
|
|
17
|
+
*/
|
|
18
|
+
'[tabindex]',
|
|
19
|
+
'[contenteditable]:not([contenteditable="false" i])',
|
|
20
|
+
].some(selector => el.matches(selector));
|
|
21
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
|
-
export * from './
|
|
2
|
-
export * from './
|
|
3
|
-
export * from './get-
|
|
4
|
-
export * from './get-
|
|
5
|
-
export * from './get-
|
|
6
|
-
export * from './get-
|
|
1
|
+
export * from './dom-traverse/accname-computation';
|
|
2
|
+
export * from './dom-traverse/get-attr-specs';
|
|
3
|
+
export * from './dom-traverse/get-computed-role';
|
|
4
|
+
export * from './dom-traverse/get-content-model';
|
|
5
|
+
export * from './dom-traverse/get-implicit-role';
|
|
6
|
+
export * from './dom-traverse/get-permitted-roles';
|
|
7
|
+
export * from './dom-traverse/get-spec';
|
|
8
|
+
export * from './dom-traverse/has-required-owned-elements';
|
|
9
|
+
export * from './dom-traverse/may-be-focusable';
|
|
10
|
+
export * from './specs/aria-specs';
|
|
11
|
+
export * from './specs/content-model-category-to-tag-names';
|
|
12
|
+
export * from './specs/get-role-spec';
|
|
13
|
+
export * from './specs/get-spec-by-tag-name';
|
|
14
|
+
export * from './specs/schema-to-spec';
|
|
7
15
|
export * from './types';
|
|
16
|
+
export * from './types/aria';
|
|
17
|
+
export * from './types/attributes';
|
|
18
|
+
export * from './types/permitted-structures';
|
|
19
|
+
export * from './utils/resolve-namespace';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { MLMLSpec } from '../types';
|
|
2
|
+
import type { Category } from '../types/permitted-structures';
|
|
3
|
+
|
|
4
|
+
const cache = new Map<Category, ReadonlyArray<string>>();
|
|
5
|
+
|
|
6
|
+
export function contentModelCategoryToTagNames(contentModel: Category, def: MLMLSpec['def']): ReadonlyArray<string> {
|
|
7
|
+
const cached = cache.get(contentModel);
|
|
8
|
+
if (cached) {
|
|
9
|
+
return cached;
|
|
10
|
+
}
|
|
11
|
+
const tags: string[] | undefined = def['#contentModels'][contentModel];
|
|
12
|
+
const sortedTag = Object.freeze(tags && Array.isArray(tags) ? tags.sort() : []);
|
|
13
|
+
cache.set(contentModel, sortedTag);
|
|
14
|
+
return sortedTag;
|
|
15
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { ARIAVersion, Matches, MLMLSpec } from '../types';
|
|
2
|
+
import type { ARIA, PermittedRoles } from '../types/aria';
|
|
3
|
+
|
|
4
|
+
import { getSpecByTagName } from './get-spec-by-tag-name';
|
|
5
|
+
import { resolveVersion } from './resolve-version';
|
|
6
|
+
|
|
7
|
+
const cache = new Map<string, Omit<ARIA, ARIAVersion> | null>();
|
|
8
|
+
|
|
9
|
+
export function getARIA(
|
|
10
|
+
specs: Readonly<MLMLSpec>,
|
|
11
|
+
localName: string,
|
|
12
|
+
namespace: string | null,
|
|
13
|
+
version: ARIAVersion,
|
|
14
|
+
matches: Matches,
|
|
15
|
+
): Omit<ARIA, ARIAVersion | 'conditions'> | null {
|
|
16
|
+
const aria = getVersionResolvedARIA(specs, localName, namespace, version);
|
|
17
|
+
if (!aria) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const conditions = aria.conditions;
|
|
21
|
+
if (!conditions) {
|
|
22
|
+
return aria;
|
|
23
|
+
}
|
|
24
|
+
const conditionKeys = Object.keys(conditions);
|
|
25
|
+
let { implicitRole, permittedRoles, implicitProperties, properties, namingProhibited } = aria;
|
|
26
|
+
for (const cond of conditionKeys) {
|
|
27
|
+
if (!matches(cond)) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const condARIA = conditions[cond];
|
|
31
|
+
implicitRole = condARIA.implicitRole ?? implicitRole;
|
|
32
|
+
permittedRoles = condARIA.permittedRoles ?? permittedRoles;
|
|
33
|
+
implicitProperties = condARIA.implicitProperties ?? implicitProperties;
|
|
34
|
+
properties = condARIA.properties ?? properties;
|
|
35
|
+
namingProhibited = condARIA.namingProhibited ?? namingProhibited;
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
implicitRole,
|
|
39
|
+
permittedRoles,
|
|
40
|
+
implicitProperties,
|
|
41
|
+
properties,
|
|
42
|
+
namingProhibited,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getVersionResolvedARIA(
|
|
47
|
+
specs: Readonly<MLMLSpec>,
|
|
48
|
+
localName: string,
|
|
49
|
+
namespace: string | null,
|
|
50
|
+
version: ARIAVersion,
|
|
51
|
+
) {
|
|
52
|
+
const key = localName + namespace + version;
|
|
53
|
+
let aria = cache.get(key);
|
|
54
|
+
if (aria !== undefined) {
|
|
55
|
+
return aria;
|
|
56
|
+
}
|
|
57
|
+
const spec = getSpecByTagName(specs.specs, localName, namespace)?.aria;
|
|
58
|
+
if (!spec) {
|
|
59
|
+
cache.set(key, null);
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
aria = resolveVersion(spec, version);
|
|
63
|
+
if (aria.permittedRoles) {
|
|
64
|
+
aria.permittedRoles = optimizePermittedRoles(aria.permittedRoles);
|
|
65
|
+
}
|
|
66
|
+
cache.set(key, aria);
|
|
67
|
+
return aria;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function optimizePermittedRoles(permittedRoles: PermittedRoles) {
|
|
71
|
+
if (!Array.isArray(permittedRoles)) {
|
|
72
|
+
return permittedRoles;
|
|
73
|
+
}
|
|
74
|
+
const unique = new Set(permittedRoles);
|
|
75
|
+
|
|
76
|
+
// https://www.w3.org/TR/wai-aria-1.2/#note-regarding-the-aria-1-1-none-role
|
|
77
|
+
if (unique.has('presentation')) {
|
|
78
|
+
unique.add('none');
|
|
79
|
+
}
|
|
80
|
+
if (unique.has('none')) {
|
|
81
|
+
unique.add('presentation');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return Array.from(unique).sort();
|
|
85
|
+
}
|
|
@@ -4,10 +4,9 @@ import { getAttrSpecs } from './get-attr-specs';
|
|
|
4
4
|
|
|
5
5
|
describe('getSpec', () => {
|
|
6
6
|
test('svg:image[x]', () => {
|
|
7
|
-
const specs = getAttrSpecs('
|
|
7
|
+
const specs = getAttrSpecs('image', 'http://www.w3.org/2000/svg', htmlSpec);
|
|
8
8
|
const x = specs?.find(spec => spec.name === 'x');
|
|
9
9
|
expect(x).toStrictEqual({
|
|
10
|
-
ref: 'https://svgwg.org/svg2-draft/geometry.html#XProperty',
|
|
11
10
|
defaultValue: '0',
|
|
12
11
|
name: 'x',
|
|
13
12
|
type: ['<svg-length>', '<percentage>'],
|
|
@@ -16,10 +15,9 @@ describe('getSpec', () => {
|
|
|
16
15
|
});
|
|
17
16
|
|
|
18
17
|
test('svg:foreignObject[x]', () => {
|
|
19
|
-
const specs = getAttrSpecs('
|
|
18
|
+
const specs = getAttrSpecs('foreignObject', 'http://www.w3.org/2000/svg', htmlSpec);
|
|
20
19
|
const x = specs?.find(spec => spec.name === 'x');
|
|
21
20
|
expect(x).toStrictEqual({
|
|
22
|
-
ref: 'https://svgwg.org/svg2-draft/geometry.html#XProperty',
|
|
23
21
|
defaultValue: '0',
|
|
24
22
|
description:
|
|
25
23
|
'The x coordinate of the foreignObject. Value type: <length>|<percentage> ; Default value: 0; Animatable: yes',
|
|
@@ -30,10 +28,9 @@ describe('getSpec', () => {
|
|
|
30
28
|
});
|
|
31
29
|
|
|
32
30
|
test('svg:linearGradient[xlink:href]', () => {
|
|
33
|
-
const specs = getAttrSpecs('
|
|
31
|
+
const specs = getAttrSpecs('linearGradient', 'http://www.w3.org/2000/svg', htmlSpec);
|
|
34
32
|
const x = specs?.find(spec => spec.name === 'xlink:href');
|
|
35
33
|
expect(x).toStrictEqual({
|
|
36
|
-
ref: 'https://www.w3.org/TR/xlink11/#link-locators',
|
|
37
34
|
description:
|
|
38
35
|
'Deprecated: This feature is no longer recommended. Though some browsers might still support it, it may have already been removed from the relevant web standards, may be in the process of being dropped, or may only be kept for compatibility purposes. Avoid using it, and update existing code if possible; see the compatibility table at the bottom of this page to guide your decision. Be aware that this feature may cease to work at any time.An <IRI> reference to another <linearGradient> element that will be used as a template. Value type: <IRI> ; Default value: none; Animatable: yes',
|
|
39
36
|
name: 'xlink:href',
|
|
@@ -43,7 +40,7 @@ describe('getSpec', () => {
|
|
|
43
40
|
});
|
|
44
41
|
|
|
45
42
|
test('img[fetchpriority] (experimental spec)', () => {
|
|
46
|
-
const attrs = getAttrSpecs('img', htmlSpec);
|
|
43
|
+
const attrs = getAttrSpecs('img', null, htmlSpec);
|
|
47
44
|
const fetchpriority = attrs?.find(attr => attr.name === 'fetchpriority');
|
|
48
45
|
expect(fetchpriority?.experimental).toBe(true);
|
|
49
46
|
});
|
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
import type { MLMLSpec, Attribute } from '
|
|
1
|
+
import type { MLMLSpec, Attribute } from '../types';
|
|
2
|
+
import type { NamespaceURI } from '@markuplint/ml-ast';
|
|
3
|
+
|
|
4
|
+
import { resolveNamespace } from '../utils/resolve-namespace';
|
|
2
5
|
|
|
3
6
|
const cacheMap = new Map<string, Attribute[] | null>();
|
|
4
7
|
const schemaCache = new WeakSet<MLMLSpec>();
|
|
5
8
|
|
|
6
|
-
export function getAttrSpecs(
|
|
9
|
+
export function getAttrSpecs(localName: string, namespace: NamespaceURI | null, schema: MLMLSpec) {
|
|
7
10
|
if (!schemaCache.has(schema)) {
|
|
8
11
|
cacheMap.clear();
|
|
9
12
|
}
|
|
10
13
|
|
|
11
|
-
const
|
|
14
|
+
const { localNameWithNS } = resolveNamespace(localName, namespace || undefined);
|
|
15
|
+
|
|
16
|
+
const cache = cacheMap.get(localNameWithNS);
|
|
12
17
|
|
|
13
18
|
if (cache !== undefined) {
|
|
14
19
|
return cache;
|
|
@@ -16,9 +21,9 @@ export function getAttrSpecs(nameWithNS: string, schema: MLMLSpec) {
|
|
|
16
21
|
|
|
17
22
|
schemaCache.add(schema);
|
|
18
23
|
|
|
19
|
-
const elSpec = schema.specs.find(spec => spec.name ===
|
|
24
|
+
const elSpec = schema.specs.find(spec => spec.name === localNameWithNS);
|
|
20
25
|
if (!elSpec) {
|
|
21
|
-
cacheMap.set(
|
|
26
|
+
cacheMap.set(localNameWithNS, null);
|
|
22
27
|
return null;
|
|
23
28
|
}
|
|
24
29
|
|
|
@@ -78,7 +83,7 @@ export function getAttrSpecs(nameWithNS: string, schema: MLMLSpec) {
|
|
|
78
83
|
for (const attr of attrList) {
|
|
79
84
|
if (!attr.type) {
|
|
80
85
|
throw new Error(
|
|
81
|
-
`The type is empty in the ${attr.name} attribute of the ${
|
|
86
|
+
`The type is empty in the ${attr.name} attribute of the ${localName} element,
|
|
82
87
|
'packages',
|
|
83
88
|
'@markuplint',
|
|
84
89
|
'html-spec',
|
|
@@ -92,7 +97,7 @@ export function getAttrSpecs(nameWithNS: string, schema: MLMLSpec) {
|
|
|
92
97
|
|
|
93
98
|
attrList.sort(nameCompare);
|
|
94
99
|
|
|
95
|
-
cacheMap.set(
|
|
100
|
+
cacheMap.set(localNameWithNS, attrList);
|
|
96
101
|
return attrList;
|
|
97
102
|
}
|
|
98
103
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { ARIAVersion } from '../types';
|
|
2
|
+
|
|
3
|
+
import specs from '@markuplint/html-spec';
|
|
4
|
+
import { createSelector } from '@markuplint/selector';
|
|
5
|
+
import { createJSDOMElement } from '@markuplint/test-tools';
|
|
6
|
+
|
|
7
|
+
import { getImplicitRole } from './get-implicit-role';
|
|
8
|
+
|
|
9
|
+
function c(html: string, version: ARIAVersion) {
|
|
10
|
+
const el = createJSDOMElement(html);
|
|
11
|
+
return getImplicitRole(
|
|
12
|
+
specs,
|
|
13
|
+
el.localName,
|
|
14
|
+
el.namespaceURI,
|
|
15
|
+
version,
|
|
16
|
+
selector => !!createSelector(selector, specs).match(el),
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('getImplicitRole', () => {
|
|
21
|
+
test('the a element', () => {
|
|
22
|
+
expect(c('<a></a>', '1.2')).toBe(false);
|
|
23
|
+
expect(c('<a name="foo"></a>', '1.2')).toBe(false);
|
|
24
|
+
expect(c('<a href></a>', '1.2')).toBe('link');
|
|
25
|
+
expect(c('<a href=""></a>', '1.2')).toBe('link');
|
|
26
|
+
expect(c('<a href="path/to"></a>', '1.2')).toBe('link');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('the area element', () => {
|
|
30
|
+
expect(c('<area />', '1.2')).toBe(false);
|
|
31
|
+
expect(c('<area shape="rect" />', '1.2')).toBe(false);
|
|
32
|
+
expect(c('<area href />', '1.2')).toBe('link');
|
|
33
|
+
expect(c('<area href="" />', '1.2')).toBe('link');
|
|
34
|
+
expect(c('<area href="path/to" />', '1.2')).toBe('link');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('the article element', () => {
|
|
38
|
+
expect(c('<article></article>', '1.2')).toBe('article');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('the aside element', () => {
|
|
42
|
+
expect(c('<aside></aside>', '1.2')).toBe('complementary');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('the audio element', () => {
|
|
46
|
+
expect(c('<audio></audio>', '1.2')).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('the h1 element', () => {
|
|
50
|
+
expect(c('<h1></h1>', '1.2')).toBe('heading');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('the header element', () => {
|
|
54
|
+
expect(c('<header></header>', '1.2')).toBe('banner');
|
|
55
|
+
expect(c('<header><article></article></header>', '1.2')).toBe('generic');
|
|
56
|
+
expect(c('<header><article></article></header>', '1.1')).toBe(false);
|
|
57
|
+
expect(c('<header><div></div></header>', '1.2')).toBe('banner');
|
|
58
|
+
expect(c('<header><div role="article"></div></header>', '1.2')).toBe('generic');
|
|
59
|
+
expect(c('<header><div role="article"></div></header>', '1.1')).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('the img element', () => {
|
|
63
|
+
expect(c('<img />', '1.2')).toBe('img');
|
|
64
|
+
expect(c('<img alt />', '1.2')).toBe('presentation');
|
|
65
|
+
expect(c('<img alt="" />', '1.2')).toBe('presentation');
|
|
66
|
+
expect(c('<img alt="foo" />', '1.2')).toBe('img');
|
|
67
|
+
expect(c('<img aria-label="foo" />', '1.2')).toBe('img');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('the form element', () => {
|
|
71
|
+
expect(c('<form></form>', '1.2')).toBe('form');
|
|
72
|
+
expect(c('<form></form>', '1.1')).toBe(false);
|
|
73
|
+
expect(c('<form aria-label="foo"></form>', '1.2')).toBe('form');
|
|
74
|
+
expect(c('<form aria-label="foo"></form>', '1.1')).toBe('form');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('the section element', () => {
|
|
78
|
+
expect(c('<section></section>', '1.2')).toBe(false);
|
|
79
|
+
expect(c('<section aria-label="foo"></section>', '1.2')).toBe('region');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ARIAVersion, Matches, MLMLSpec } from '../types';
|
|
2
|
+
|
|
3
|
+
import { getARIA } from './get-aria';
|
|
4
|
+
|
|
5
|
+
export function getImplicitRole(
|
|
6
|
+
specs: Readonly<MLMLSpec>,
|
|
7
|
+
localName: string,
|
|
8
|
+
namespace: string | null,
|
|
9
|
+
version: ARIAVersion,
|
|
10
|
+
matches: Matches,
|
|
11
|
+
) {
|
|
12
|
+
const aria = getARIA(specs, localName, namespace, version, matches);
|
|
13
|
+
if (!aria) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
return aria.implicitRole;
|
|
17
|
+
}
|