@markuplint/ml-spec 3.0.0-alpha.3 → 3.0.0-alpha.5

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.
@@ -0,0 +1,368 @@
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 { getComputedAriaProps } from './get-computed-aria-props';
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 c(html: string, version: ARIAVersion, selector?: string) {
17
+ return getComputedAriaProps(specs, _(html, selector), version);
18
+ }
19
+
20
+ describe('getComputedAriaProps', () => {
21
+ test('the link role', () => {
22
+ expect(c('<a href></a>', '1.2')).toStrictEqual({
23
+ 'aria-atomic': {
24
+ name: 'aria-atomic',
25
+ value: 'false',
26
+ deprecated: false,
27
+ required: false,
28
+ from: 'default',
29
+ },
30
+ 'aria-busy': {
31
+ name: 'aria-busy',
32
+ value: 'false',
33
+ deprecated: false,
34
+ required: false,
35
+ from: 'default',
36
+ },
37
+ 'aria-controls': {
38
+ name: 'aria-controls',
39
+ value: undefined,
40
+ deprecated: false,
41
+ required: false,
42
+ from: 'default',
43
+ },
44
+ 'aria-current': {
45
+ name: 'aria-current',
46
+ value: 'false',
47
+ deprecated: false,
48
+ required: false,
49
+ from: 'default',
50
+ },
51
+ 'aria-describedby': {
52
+ name: 'aria-describedby',
53
+ value: undefined,
54
+ deprecated: false,
55
+ required: false,
56
+ from: 'default',
57
+ },
58
+ 'aria-details': {
59
+ name: 'aria-details',
60
+ value: undefined,
61
+ deprecated: false,
62
+ required: false,
63
+ from: 'default',
64
+ },
65
+ 'aria-disabled': {
66
+ name: 'aria-disabled',
67
+ value: 'false',
68
+ deprecated: false,
69
+ required: false,
70
+ from: 'default',
71
+ },
72
+ 'aria-dropeffect': {
73
+ name: 'aria-dropeffect',
74
+ value: 'none',
75
+ deprecated: true,
76
+ required: false,
77
+ from: 'default',
78
+ },
79
+ 'aria-errormessage': {
80
+ name: 'aria-errormessage',
81
+ value: undefined,
82
+ deprecated: false,
83
+ required: false,
84
+ from: 'default',
85
+ },
86
+ 'aria-expanded': {
87
+ name: 'aria-expanded',
88
+ value: 'undefined',
89
+ deprecated: false,
90
+ required: false,
91
+ from: 'default',
92
+ },
93
+ 'aria-flowto': {
94
+ name: 'aria-flowto',
95
+ value: undefined,
96
+ deprecated: false,
97
+ required: false,
98
+ from: 'default',
99
+ },
100
+ 'aria-grabbed': {
101
+ name: 'aria-grabbed',
102
+ value: 'undefined',
103
+ deprecated: true,
104
+ required: false,
105
+ from: 'default',
106
+ },
107
+ 'aria-haspopup': {
108
+ name: 'aria-haspopup',
109
+ value: 'false',
110
+ deprecated: false,
111
+ required: false,
112
+ from: 'default',
113
+ },
114
+ 'aria-hidden': {
115
+ name: 'aria-hidden',
116
+ value: 'undefined',
117
+ deprecated: false,
118
+ required: false,
119
+ from: 'default',
120
+ },
121
+ 'aria-invalid': {
122
+ name: 'aria-invalid',
123
+ value: 'false',
124
+ deprecated: false,
125
+ required: false,
126
+ from: 'default',
127
+ },
128
+ 'aria-keyshortcuts': {
129
+ name: 'aria-keyshortcuts',
130
+ value: undefined,
131
+ deprecated: false,
132
+ required: false,
133
+ from: 'default',
134
+ },
135
+ 'aria-label': {
136
+ name: 'aria-label',
137
+ value: undefined,
138
+ deprecated: false,
139
+ required: false,
140
+ from: 'default',
141
+ },
142
+ 'aria-labelledby': {
143
+ name: 'aria-labelledby',
144
+ value: undefined,
145
+ deprecated: false,
146
+ required: false,
147
+ from: 'default',
148
+ },
149
+ 'aria-live': {
150
+ name: 'aria-live',
151
+ value: 'off',
152
+ deprecated: false,
153
+ required: false,
154
+ from: 'default',
155
+ },
156
+ 'aria-owns': {
157
+ name: 'aria-owns',
158
+ value: undefined,
159
+ deprecated: false,
160
+ required: false,
161
+ from: 'default',
162
+ },
163
+ 'aria-relevant': {
164
+ name: 'aria-relevant',
165
+ value: 'additions text',
166
+ deprecated: false,
167
+ required: false,
168
+ from: 'default',
169
+ },
170
+ 'aria-roledescription': {
171
+ name: 'aria-roledescription',
172
+ value: undefined,
173
+ deprecated: false,
174
+ required: false,
175
+ from: 'default',
176
+ },
177
+ });
178
+ });
179
+
180
+ test('the heading role', () => {
181
+ expect(c('<h1></h1>', '1.2')).toStrictEqual({
182
+ 'aria-atomic': {
183
+ name: 'aria-atomic',
184
+ value: 'false',
185
+ deprecated: false,
186
+ required: false,
187
+ from: 'default',
188
+ },
189
+ 'aria-busy': {
190
+ name: 'aria-busy',
191
+ value: 'false',
192
+ deprecated: false,
193
+ required: false,
194
+ from: 'default',
195
+ },
196
+ 'aria-controls': {
197
+ name: 'aria-controls',
198
+ value: undefined,
199
+ deprecated: false,
200
+ required: false,
201
+ from: 'default',
202
+ },
203
+ 'aria-current': {
204
+ name: 'aria-current',
205
+ value: 'false',
206
+ deprecated: false,
207
+ required: false,
208
+ from: 'default',
209
+ },
210
+ 'aria-describedby': {
211
+ name: 'aria-describedby',
212
+ value: undefined,
213
+ deprecated: false,
214
+ required: false,
215
+ from: 'default',
216
+ },
217
+ 'aria-details': {
218
+ name: 'aria-details',
219
+ value: undefined,
220
+ deprecated: false,
221
+ required: false,
222
+ from: 'default',
223
+ },
224
+ 'aria-disabled': {
225
+ name: 'aria-disabled',
226
+ value: 'false',
227
+ deprecated: false,
228
+ required: false,
229
+ from: 'default',
230
+ },
231
+ 'aria-dropeffect': {
232
+ name: 'aria-dropeffect',
233
+ value: 'none',
234
+ deprecated: true,
235
+ required: false,
236
+ from: 'default',
237
+ },
238
+ 'aria-errormessage': {
239
+ name: 'aria-errormessage',
240
+ value: undefined,
241
+ deprecated: false,
242
+ required: false,
243
+ from: 'default',
244
+ },
245
+ 'aria-flowto': {
246
+ name: 'aria-flowto',
247
+ value: undefined,
248
+ deprecated: false,
249
+ required: false,
250
+ from: 'default',
251
+ },
252
+ 'aria-grabbed': {
253
+ name: 'aria-grabbed',
254
+ value: 'undefined',
255
+ deprecated: true,
256
+ required: false,
257
+ from: 'default',
258
+ },
259
+ 'aria-haspopup': {
260
+ name: 'aria-haspopup',
261
+ value: 'false',
262
+ deprecated: false,
263
+ required: false,
264
+ from: 'default',
265
+ },
266
+ 'aria-hidden': {
267
+ name: 'aria-hidden',
268
+ value: 'undefined',
269
+ deprecated: false,
270
+ required: false,
271
+ from: 'default',
272
+ },
273
+ 'aria-invalid': {
274
+ name: 'aria-invalid',
275
+ value: 'false',
276
+ deprecated: false,
277
+ required: false,
278
+ from: 'default',
279
+ },
280
+ 'aria-keyshortcuts': {
281
+ name: 'aria-keyshortcuts',
282
+ value: undefined,
283
+ deprecated: false,
284
+ required: false,
285
+ from: 'default',
286
+ },
287
+ 'aria-label': {
288
+ name: 'aria-label',
289
+ value: undefined,
290
+ deprecated: false,
291
+ required: false,
292
+ from: 'default',
293
+ },
294
+ 'aria-labelledby': {
295
+ name: 'aria-labelledby',
296
+ value: undefined,
297
+ deprecated: false,
298
+ required: false,
299
+ from: 'default',
300
+ },
301
+ 'aria-level': {
302
+ name: 'aria-level',
303
+ value: '1',
304
+ deprecated: false,
305
+ required: true,
306
+ from: 'default',
307
+ },
308
+ 'aria-live': {
309
+ name: 'aria-live',
310
+ value: 'off',
311
+ deprecated: false,
312
+ required: false,
313
+ from: 'default',
314
+ },
315
+ 'aria-owns': {
316
+ name: 'aria-owns',
317
+ value: undefined,
318
+ deprecated: false,
319
+ required: false,
320
+ from: 'default',
321
+ },
322
+ 'aria-relevant': {
323
+ name: 'aria-relevant',
324
+ value: 'additions text',
325
+ deprecated: false,
326
+ required: false,
327
+ from: 'default',
328
+ },
329
+ 'aria-roledescription': {
330
+ name: 'aria-roledescription',
331
+ value: undefined,
332
+ deprecated: false,
333
+ required: false,
334
+ from: 'default',
335
+ },
336
+ });
337
+ });
338
+
339
+ test('from html-attr', () => {
340
+ expect(c('<input type="checkbox" checked>', '1.2')['aria-checked']).toStrictEqual({
341
+ name: 'aria-checked',
342
+ value: 'true',
343
+ deprecated: false,
344
+ required: true,
345
+ from: 'html-attr',
346
+ });
347
+ });
348
+
349
+ test('overwrite aria-attr', () => {
350
+ expect(c('<input type="checkbox" checked aria-checked="false">', '1.2')['aria-checked']).toStrictEqual({
351
+ name: 'aria-checked',
352
+ value: 'false',
353
+ deprecated: false,
354
+ required: true,
355
+ from: 'aria-attr',
356
+ });
357
+ });
358
+
359
+ test('overwrite aria-attr', () => {
360
+ expect(c('<input type="checkbox" checked aria-checked="mixed">', '1.2')['aria-checked']).toStrictEqual({
361
+ name: 'aria-checked',
362
+ value: 'mixed',
363
+ deprecated: false,
364
+ required: true,
365
+ from: 'aria-attr',
366
+ });
367
+ });
368
+ });
@@ -0,0 +1,130 @@
1
+ import type { ARIAAttributeValue, ARIAProperty, ARIAVersion, MLMLSpec } from '../types';
2
+
3
+ import { ariaSpecs as _ariaSpecs } from '../specs/aria-specs';
4
+
5
+ import { getComputedRole } from './get-computed-role';
6
+
7
+ type ARIAProps = Record<string, ARIAProp>;
8
+
9
+ type ARIAProp = {
10
+ name: string;
11
+ value: string | undefined;
12
+ required: boolean;
13
+ deprecated: boolean;
14
+ from: ARIAPropReferenceType;
15
+ };
16
+
17
+ type ARIAPropReferenceType = 'default' | 'html-attr' | 'aria-attr';
18
+
19
+ export function getComputedAriaProps(specs: Readonly<MLMLSpec>, el: Element, version: ARIAVersion): ARIAProps {
20
+ const ariaSpecs = _ariaSpecs(specs, version);
21
+
22
+ const { role } = getComputedRole(specs, el, version);
23
+
24
+ if (!role) {
25
+ return {};
26
+ }
27
+
28
+ const props: ARIAProps = {};
29
+
30
+ role.ownedProperties.forEach(ownedProp => {
31
+ const spec = ariaSpecs.props.find(propSpec => propSpec.name === ownedProp.name);
32
+ if (!spec) {
33
+ return;
34
+ }
35
+
36
+ if (el.hasAttribute(spec.name)) {
37
+ const attrValue = el.getAttribute(spec.name);
38
+ if (attrValue && isValidAriaValue(spec, role.name, attrValue, spec.enum)) {
39
+ props[ownedProp.name] = {
40
+ name: ownedProp.name,
41
+ value: attrValue,
42
+ deprecated: !!spec.deprecated,
43
+ required: !!ownedProp.required,
44
+ from: 'aria-attr',
45
+ };
46
+ return;
47
+ }
48
+ }
49
+
50
+ const equivalentHtmlAttr = spec.equivalentHtmlAttrs?.[0];
51
+ if (equivalentHtmlAttr && el.hasAttribute(equivalentHtmlAttr.htmlAttrName)) {
52
+ const value =
53
+ equivalentHtmlAttr.value === null
54
+ ? el.getAttribute(equivalentHtmlAttr.htmlAttrName)
55
+ : equivalentHtmlAttr.value;
56
+ if (value != null) {
57
+ props[ownedProp.name] = {
58
+ name: ownedProp.name,
59
+ value,
60
+ deprecated: !!spec.deprecated,
61
+ required: !!ownedProp.required,
62
+ from: 'html-attr',
63
+ };
64
+ return;
65
+ }
66
+ }
67
+
68
+ let defaultValue = spec.defaultValue;
69
+
70
+ /**
71
+ * @see https://www.w3.org/TR/html-aria/#el-h1-h6
72
+ */
73
+ if (ownedProp.name === 'aria-level' && /^H[1-6]$/.test(el.nodeName)) {
74
+ defaultValue = el.nodeName.replace('H', '');
75
+ }
76
+
77
+ props[ownedProp.name] = {
78
+ name: ownedProp.name,
79
+ value: defaultValue,
80
+ deprecated: !!spec.deprecated,
81
+ required: !!ownedProp.required,
82
+ from: 'default',
83
+ };
84
+ });
85
+
86
+ return props;
87
+ }
88
+
89
+ function isValidAriaValue(spec: ARIAProperty, role: string, value: string | undefined, enumList: string[]) {
90
+ let type: ARIAAttributeValue = spec.value;
91
+ if (spec.conditionalValue) {
92
+ for (const cond of spec.conditionalValue) {
93
+ if (cond.role.includes(role)) {
94
+ type = cond.value;
95
+ }
96
+ }
97
+ }
98
+
99
+ value = (value ?? '').trim();
100
+ switch (type) {
101
+ case 'string': {
102
+ return true;
103
+ }
104
+ case 'ID reference':
105
+ case 'ID reference list':
106
+ case 'URI': {
107
+ return !!value;
108
+ }
109
+ case 'integer':
110
+ case 'number': {
111
+ return !isNaN(parseFloat(value));
112
+ }
113
+ case 'token':
114
+ case 'token list': {
115
+ if (!enumList.length) {
116
+ throw new Error('Need an enum list in token and token list types');
117
+ }
118
+ return enumList.includes(value.toLowerCase());
119
+ }
120
+ case 'tristate': {
121
+ return ['true', 'false', 'mixed'].includes(value.toLowerCase());
122
+ }
123
+ case 'true/false': {
124
+ return ['true', 'false'].includes(value.toLowerCase());
125
+ }
126
+ case 'true/false/undefined': {
127
+ return ['true', 'false', 'undefined'].includes(value.toLowerCase());
128
+ }
129
+ }
130
+ }
@@ -0,0 +1,50 @@
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 { isExposed } from './is-exposed';
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 x(html: string, selector?: string, version: ARIAVersion = '1.2') {
17
+ const el = _(html, selector);
18
+ return isExposed(el, specs, version);
19
+ }
20
+
21
+ describe('isExposed', () => {
22
+ test('Basic', () => {
23
+ expect(x('<div></div>')).toBe(true);
24
+ expect(x('<div aria-hidden="true"></div>')).toBe(false);
25
+ expect(x('<div role="presentation"></div>')).toBe(false);
26
+ expect(x('<div role="presentation" aria-hidden="true"></div>')).toBe(false);
27
+ expect(x('<div role="presentation"><span></span></div>', 'span')).toBe(true);
28
+ expect(x('<div role="presentation" aria-hidden="true"><span></span></div>', 'span')).toBe(false);
29
+ expect(x('<div style="display: none;"><span></span></div>', 'span')).toBe(false);
30
+ expect(x('<div hidden><span></span></div>', 'span')).toBe(false);
31
+ expect(x('<div hidden="until-found"><span></span></div>', 'span')).toBe(false);
32
+ expect(x('<html><body>content</body></html>', 'body')).toBe(true);
33
+ expect(x('<ul><li></li></ul>', 'li')).toBe(true);
34
+ });
35
+
36
+ test('Children Presentational: True', () => {
37
+ expect(x('<button></button>')).toBe(true);
38
+ expect(x('<button hidden></button>')).toBe(false);
39
+ expect(x('<button><span></span></button>', 'span')).toBe(false);
40
+ expect(x('<button hidden><span></span></button>', 'span')).toBe(false);
41
+ });
42
+
43
+ test('Hidden elements', () => {
44
+ expect(x('<input type="text">')).toBe(true);
45
+ expect(x('<input type="hidden">')).toBe(false);
46
+ expect(x('<meta>')).toBe(false);
47
+ expect(x('<style>a{}</style>')).toBe(false);
48
+ expect(x('<script>"";</script>')).toBe(false);
49
+ });
50
+ });