@markuplint/rules 4.12.0 → 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/CHANGELOG.md +58 -0
- package/lib/attr-check.d.ts +5 -2
- package/lib/attr-check.js +13 -3
- package/lib/character-reference/index.d.ts +2 -1
- package/lib/character-reference/index.js +5 -1
- package/lib/correct-aspect-ratio/index.d.ts +5 -0
- package/lib/correct-aspect-ratio/index.js +80 -0
- package/lib/correct-aspect-ratio/meta.d.ts +4 -0
- package/lib/correct-aspect-ratio/meta.js +3 -0
- package/lib/correct-aspect-ratio/resolve-image-size.d.ts +24 -0
- package/lib/correct-aspect-ratio/resolve-image-size.js +165 -0
- package/lib/create-message.d.ts +2 -2
- package/lib/create-message.js +4 -1
- package/lib/deprecated-element/index.d.ts +2 -4
- package/lib/deprecated-element/index.js +4 -6
- package/lib/end-tag/index.js +1 -1
- package/lib/heading-levels/index.js +0 -1
- package/lib/helpers.d.ts +1 -0
- package/lib/helpers.js +8 -15
- package/lib/index.d.ts +30 -41
- package/lib/index.js +12 -0
- package/lib/invalid-attr/index.d.ts +12 -28
- package/lib/invalid-attr/index.js +50 -116
- package/lib/link-types/index.d.ts +10 -0
- package/lib/link-types/index.js +180 -0
- package/lib/link-types/meta.d.ts +5 -0
- package/lib/link-types/meta.js +4 -0
- package/lib/neighbor-popovers/index.js +3 -5
- package/lib/no-orphaned-end-tag/index.d.ts +2 -2
- package/lib/no-orphaned-end-tag/index.js +3 -4
- package/lib/no-refer-to-non-existent-id/index.d.ts +1 -1
- package/lib/no-refer-to-non-existent-id/index.js +2 -2
- package/lib/no-unsupported-features/compat-data.d.ts +71 -0
- package/lib/no-unsupported-features/compat-data.js +204 -0
- package/lib/no-unsupported-features/index.d.ts +24 -0
- package/lib/no-unsupported-features/index.js +139 -0
- package/lib/no-unsupported-features/meta.d.ts +5 -0
- package/lib/no-unsupported-features/meta.js +4 -0
- package/lib/no-unsupported-features/resolve-browsers.d.ts +30 -0
- package/lib/no-unsupported-features/resolve-browsers.js +125 -0
- package/lib/permitted-contents/choice.js +1 -1
- package/lib/permitted-contents/count-pattern.js +1 -1
- package/lib/permitted-contents/order.js +1 -1
- package/lib/permitted-contents/represent-transparent-nodes.d.ts +26 -1
- package/lib/permitted-contents/represent-transparent-nodes.js +96 -13
- package/lib/permitted-contents/utils.d.ts +1 -1
- package/lib/permitted-contents/utils.js +22 -35
- package/lib/redundant-accessible-name/index.d.ts +10 -0
- package/lib/redundant-accessible-name/index.js +123 -0
- package/lib/redundant-accessible-name/meta.d.ts +5 -0
- package/lib/redundant-accessible-name/meta.js +4 -0
- package/lib/require-accessible-name/index.d.ts +3 -8
- package/lib/require-accessible-name/index.js +6 -5
- package/lib/require-dialog-autofocus/index.d.ts +22 -0
- package/lib/require-dialog-autofocus/index.js +62 -0
- package/lib/require-dialog-autofocus/meta.d.ts +5 -0
- package/lib/require-dialog-autofocus/meta.js +4 -0
- package/lib/required-attr/index.d.ts +8 -1
- package/lib/required-attr/index.js +27 -4
- package/lib/required-element/index.js +1 -1
- package/lib/srcset-sizes-constraint/index.d.ts +6 -0
- package/lib/srcset-sizes-constraint/index.js +108 -0
- package/lib/srcset-sizes-constraint/meta.d.ts +5 -0
- package/lib/srcset-sizes-constraint/meta.js +4 -0
- package/lib/srcset-sizes-constraint/parse-srcset.d.ts +40 -0
- package/lib/srcset-sizes-constraint/parse-srcset.js +56 -0
- package/lib/table-row-column-alignment/grid.js +22 -29
- package/lib/wai-aria/checkings/abstract-role.js +5 -2
- package/lib/wai-aria/checkings/disallowed-prop.js +5 -2
- package/lib/wai-aria/checkings/implicit-role.js +5 -2
- package/lib/wai-aria/checkings/non-existent-role.d.ts +1 -0
- package/lib/wai-aria/checkings/non-existent-role.js +9 -2
- package/lib/wai-aria/checkings/permitted-roles.js +5 -2
- package/lib/wai-aria/checkings/presentational-children.js +3 -2
- package/lib/wai-aria/checkings/required-accessibility-parent-role.d.ts +25 -0
- package/lib/wai-aria/checkings/required-accessibility-parent-role.js +56 -0
- package/lib/wai-aria/checkings/required-owned-elements.d.ts +5 -4
- package/lib/wai-aria/checkings/required-owned-elements.js +57 -28
- package/lib/wai-aria/checkings/required-prop.js +5 -2
- package/lib/wai-aria/index.js +12 -4
- package/lib/wai-aria/types.d.ts +9 -2
- package/package.json +15 -10
- package/schema.json +18 -0
|
@@ -1,15 +1,3 @@
|
|
|
1
|
-
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
|
|
2
|
-
if (kind === "m") throw new TypeError("Private method is not writable");
|
|
3
|
-
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
|
|
4
|
-
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
|
|
5
|
-
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
|
|
6
|
-
};
|
|
7
|
-
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
|
|
8
|
-
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
|
|
9
|
-
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
|
|
10
|
-
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
11
|
-
};
|
|
12
|
-
var _Collection_locked, _Collection_matched, _Collection_nodes, _Collection_origin;
|
|
13
1
|
import { createSelector } from '@markuplint/selector';
|
|
14
2
|
import { bgGreen, green, bgRed, bgBlue, blue, bgMagenta, cyan } from './debug.js';
|
|
15
3
|
import { transparentMode } from './represent-transparent-nodes.js';
|
|
@@ -229,7 +217,7 @@ export function mergeHints(
|
|
|
229
217
|
a,
|
|
230
218
|
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
231
219
|
b) {
|
|
232
|
-
const missing = [a.missing, b.missing].
|
|
220
|
+
const missing = [a.missing, b.missing].toSorted((a, b) => (b?.barelyMatchedElements ?? 0) - (a?.barelyMatchedElements ?? 0))[0];
|
|
233
221
|
return cleanObject({
|
|
234
222
|
...a,
|
|
235
223
|
...b,
|
|
@@ -263,6 +251,10 @@ export function cleanObject(object) {
|
|
|
263
251
|
* into matched and unmatched sets as the validation algorithm progresses.
|
|
264
252
|
*/
|
|
265
253
|
export class Collection {
|
|
254
|
+
#locked = new Set();
|
|
255
|
+
#matched = new Set();
|
|
256
|
+
#nodes;
|
|
257
|
+
#origin;
|
|
266
258
|
/**
|
|
267
259
|
* Creates a new Collection from the given child nodes.
|
|
268
260
|
*
|
|
@@ -271,36 +263,32 @@ export class Collection {
|
|
|
271
263
|
constructor(
|
|
272
264
|
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
273
265
|
origin) {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
_Collection_nodes.set(this, void 0);
|
|
277
|
-
_Collection_origin.set(this, void 0);
|
|
278
|
-
__classPrivateFieldSet(this, _Collection_origin, [...origin], "f");
|
|
279
|
-
__classPrivateFieldSet(this, _Collection_nodes, new Set(__classPrivateFieldGet(this, _Collection_origin, "f")), "f");
|
|
266
|
+
this.#origin = [...origin];
|
|
267
|
+
this.#nodes = new Set(this.#origin);
|
|
280
268
|
}
|
|
281
269
|
/**
|
|
282
270
|
* Returns a copy of the currently matched nodes in insertion order.
|
|
283
271
|
*/
|
|
284
272
|
get matched() {
|
|
285
|
-
return [...
|
|
273
|
+
return [...this.#matched];
|
|
286
274
|
}
|
|
287
275
|
/**
|
|
288
276
|
* Returns the number of currently matched nodes.
|
|
289
277
|
*/
|
|
290
278
|
get matchedCount() {
|
|
291
|
-
return
|
|
279
|
+
return this.#matched.size;
|
|
292
280
|
}
|
|
293
281
|
/**
|
|
294
282
|
* Returns a copy of all original nodes in their original order.
|
|
295
283
|
*/
|
|
296
284
|
get nodes() {
|
|
297
|
-
return [...
|
|
285
|
+
return [...this.#origin];
|
|
298
286
|
}
|
|
299
287
|
/**
|
|
300
288
|
* Returns the nodes that have not yet been matched, preserving original order.
|
|
301
289
|
*/
|
|
302
290
|
get unmatched() {
|
|
303
|
-
return [...
|
|
291
|
+
return [...this.#nodes].filter(n => !this.#matched.has(n));
|
|
304
292
|
}
|
|
305
293
|
/**
|
|
306
294
|
* Adds nodes to the matched set. All nodes must belong to the original
|
|
@@ -312,14 +300,14 @@ export class Collection {
|
|
|
312
300
|
addMatched(
|
|
313
301
|
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
314
302
|
nodes) {
|
|
315
|
-
const i =
|
|
303
|
+
const i = this.#matched.size;
|
|
316
304
|
for (const node of nodes) {
|
|
317
|
-
if (!
|
|
305
|
+
if (!this.#nodes.has(node)) {
|
|
318
306
|
throw new ReferenceError(`External Node: ${node.nodeName}`);
|
|
319
307
|
}
|
|
320
|
-
|
|
308
|
+
this.#matched.add(node);
|
|
321
309
|
}
|
|
322
|
-
return i <
|
|
310
|
+
return i < this.#matched.size;
|
|
323
311
|
}
|
|
324
312
|
/**
|
|
325
313
|
* Reverts the matched set to the last locked state, discarding
|
|
@@ -327,7 +315,7 @@ export class Collection {
|
|
|
327
315
|
* when a pattern match attempt fails after a zero-match.
|
|
328
316
|
*/
|
|
329
317
|
back() {
|
|
330
|
-
|
|
318
|
+
this.#matched = new Set(this.#locked);
|
|
331
319
|
}
|
|
332
320
|
/**
|
|
333
321
|
* Saves the current matched set as a checkpoint that `back()` can
|
|
@@ -335,7 +323,7 @@ export class Collection {
|
|
|
335
323
|
* the known-good state.
|
|
336
324
|
*/
|
|
337
325
|
lock() {
|
|
338
|
-
|
|
326
|
+
this.#locked = new Set(this.#matched);
|
|
339
327
|
}
|
|
340
328
|
/**
|
|
341
329
|
* Trims the matched set to at most `max` entries by removing
|
|
@@ -345,9 +333,9 @@ export class Collection {
|
|
|
345
333
|
* @param max - The maximum number of matched nodes to retain.
|
|
346
334
|
*/
|
|
347
335
|
max(max) {
|
|
348
|
-
const sliced = [...
|
|
336
|
+
const sliced = [...this.#matched].slice(max);
|
|
349
337
|
for (const n of sliced)
|
|
350
|
-
|
|
338
|
+
this.#matched.delete(n);
|
|
351
339
|
}
|
|
352
340
|
/**
|
|
353
341
|
* Returns a colored string representation of all nodes for debug logging.
|
|
@@ -360,9 +348,9 @@ export class Collection {
|
|
|
360
348
|
*/
|
|
361
349
|
toString(highlightExtraNodes = false) {
|
|
362
350
|
const out = [];
|
|
363
|
-
for (const n of
|
|
351
|
+
for (const n of this.#origin) {
|
|
364
352
|
const raw = n.is(n.TEXT_NODE) ? `:text(${n.raw.replaceAll('\n', '\\n')})` : n.raw;
|
|
365
|
-
if (
|
|
353
|
+
if (this.#locked.has(n)) {
|
|
366
354
|
if (transparentMode.has(n)) {
|
|
367
355
|
out.push(bgBlue.bold(raw));
|
|
368
356
|
}
|
|
@@ -371,7 +359,7 @@ export class Collection {
|
|
|
371
359
|
}
|
|
372
360
|
continue;
|
|
373
361
|
}
|
|
374
|
-
if (
|
|
362
|
+
if (this.#matched.has(n)) {
|
|
375
363
|
if (transparentMode.has(n)) {
|
|
376
364
|
out.push(blue.bold(raw));
|
|
377
365
|
}
|
|
@@ -399,7 +387,6 @@ export class Collection {
|
|
|
399
387
|
return `[ ${out.join(', ')} ]`;
|
|
400
388
|
}
|
|
401
389
|
}
|
|
402
|
-
_Collection_locked = new WeakMap(), _Collection_matched = new WeakMap(), _Collection_nodes = new WeakMap(), _Collection_origin = new WeakMap();
|
|
403
390
|
/**
|
|
404
391
|
* Error class representing an unsupported content model feature.
|
|
405
392
|
* Thrown when the validation engine encounters a pattern type or
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { RuleConfigValue } from '@markuplint/ml-core';
|
|
2
|
+
/**
|
|
3
|
+
* Rule that detects elements with multiple accessible name sources
|
|
4
|
+
* where a higher-priority source overrides a lower-priority one.
|
|
5
|
+
*/
|
|
6
|
+
declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<RuleConfigValue, {
|
|
7
|
+
checkTitleFallback: boolean;
|
|
8
|
+
checkPlaceholderFallback: boolean;
|
|
9
|
+
}>>;
|
|
10
|
+
export default _default;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createRule, getComputedRole } from '@markuplint/ml-core';
|
|
2
|
+
import { ARIA_RECOMMENDED_VERSION, isExposed } from '@markuplint/ml-spec';
|
|
3
|
+
import { accnameMayBeMutable, getOwnedLabel } from '../helpers.js';
|
|
4
|
+
import meta from './meta.js';
|
|
5
|
+
/**
|
|
6
|
+
* Rule that detects elements with multiple accessible name sources
|
|
7
|
+
* where a higher-priority source overrides a lower-priority one.
|
|
8
|
+
*/
|
|
9
|
+
export default createRule({
|
|
10
|
+
meta,
|
|
11
|
+
defaultSeverity: 'warning',
|
|
12
|
+
defaultOptions: {
|
|
13
|
+
checkTitleFallback: false,
|
|
14
|
+
checkPlaceholderFallback: false,
|
|
15
|
+
},
|
|
16
|
+
async verify({ document, report, t }) {
|
|
17
|
+
await document.walkOn('Element', el => {
|
|
18
|
+
const ariaVersion = ARIA_RECOMMENDED_VERSION;
|
|
19
|
+
// Skip mutable elements (template expressions etc.)
|
|
20
|
+
if (accnameMayBeMutable(el, document)) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
// Skip hidden elements
|
|
24
|
+
if (!isExposed(el, document.specs, ariaVersion)) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
// Skip nameFrom: "prohibited" roles (generic, none, presentation)
|
|
28
|
+
const computed = getComputedRole(document.specs, el, ariaVersion);
|
|
29
|
+
if (computed.role?.accessibleNameProhibited) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const sources = collectNamingSources(el, document, el.rule.options, computed);
|
|
33
|
+
if (sources.length >= 2) {
|
|
34
|
+
const winner = sources[0];
|
|
35
|
+
const overridden = sources.slice(1);
|
|
36
|
+
for (const loser of overridden) {
|
|
37
|
+
report({
|
|
38
|
+
scope: el,
|
|
39
|
+
message: t('The accessible name from "{0*}" overrides "{1*}"', winner.label, loser.label),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
function collectNamingSources(
|
|
47
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
48
|
+
el,
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
50
|
+
document, options,
|
|
51
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
52
|
+
computed) {
|
|
53
|
+
const sources = [];
|
|
54
|
+
// 1. aria-labelledby
|
|
55
|
+
const ariaLabelledby = el.getAttribute('aria-labelledby');
|
|
56
|
+
if (ariaLabelledby && ariaLabelledby.trim()) {
|
|
57
|
+
const ids = ariaLabelledby.trim().split(/\s+/);
|
|
58
|
+
const hasResolvableId = ids.some(id => document.querySelector(`[id="${id}"]`));
|
|
59
|
+
if (hasResolvableId) {
|
|
60
|
+
sources.push({ label: 'aria-labelledby' });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// 2. aria-label
|
|
64
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
65
|
+
if (ariaLabel && ariaLabel.trim()) {
|
|
66
|
+
sources.push({ label: 'aria-label' });
|
|
67
|
+
}
|
|
68
|
+
// 3. label (explicit or implicit) — only for labelable elements
|
|
69
|
+
const ownedLabel = getOwnedLabel(el, document);
|
|
70
|
+
if (ownedLabel) {
|
|
71
|
+
sources.push({ label: 'label' });
|
|
72
|
+
}
|
|
73
|
+
// 4. alt — img, area, input[type=image]
|
|
74
|
+
if (el.matches('img, area, input[type=image]')) {
|
|
75
|
+
const alt = el.getAttribute('alt');
|
|
76
|
+
if (alt != null && alt.trim()) {
|
|
77
|
+
sources.push({ label: 'alt' });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// 5. content — role allows nameFromContent
|
|
81
|
+
if (computed.role?.accessibleNameFromContent) {
|
|
82
|
+
const text = el.textContent.trim();
|
|
83
|
+
if (text) {
|
|
84
|
+
sources.push({ label: 'content' });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// 6. value — input[type=button/submit/reset]
|
|
88
|
+
if (el.matches('input[type=button], input[type=submit], input[type=reset]')) {
|
|
89
|
+
const value = el.getAttribute('value');
|
|
90
|
+
if (value != null && value.trim()) {
|
|
91
|
+
sources.push({ label: 'value' });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// 7. legend — fieldset (direct child only)
|
|
95
|
+
if (el.matches('fieldset')) {
|
|
96
|
+
const legend = el.querySelector(':scope > legend');
|
|
97
|
+
if (legend && legend.textContent.trim()) {
|
|
98
|
+
sources.push({ label: 'legend' });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// 8. caption — table (direct child only)
|
|
102
|
+
if (el.matches('table')) {
|
|
103
|
+
const caption = el.querySelector(':scope > caption');
|
|
104
|
+
if (caption && caption.textContent.trim()) {
|
|
105
|
+
sources.push({ label: 'caption' });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// 9. title (optional)
|
|
109
|
+
if (options.checkTitleFallback) {
|
|
110
|
+
const title = el.getAttribute('title');
|
|
111
|
+
if (title != null && title.trim()) {
|
|
112
|
+
sources.push({ label: 'title' });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// 10. placeholder (optional)
|
|
116
|
+
if (options.checkPlaceholderFallback) {
|
|
117
|
+
const placeholder = el.getAttribute('placeholder');
|
|
118
|
+
if (placeholder != null && placeholder.trim()) {
|
|
119
|
+
sources.push({ label: 'placeholder' });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return sources;
|
|
123
|
+
}
|
|
@@ -1,11 +1,4 @@
|
|
|
1
1
|
import type { ARIAVersion } from '@markuplint/ml-spec';
|
|
2
|
-
/**
|
|
3
|
-
* Configuration options for the require-accessible-name rule.
|
|
4
|
-
*/
|
|
5
|
-
type Option = {
|
|
6
|
-
/** The ARIA specification version to use for role and accessible name computation. */
|
|
7
|
-
ariaVersion: ARIAVersion;
|
|
8
|
-
};
|
|
9
2
|
/**
|
|
10
3
|
* Rule that requires elements with roles that need accessible names to have one.
|
|
11
4
|
*
|
|
@@ -13,5 +6,7 @@ type Option = {
|
|
|
13
6
|
* has `accessibleNameRequired` set to `true`, this rule verifies that the element
|
|
14
7
|
* provides a non-empty accessible name.
|
|
15
8
|
*/
|
|
16
|
-
declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<
|
|
9
|
+
declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<import("@markuplint/ml-core").RuleConfigValue, {
|
|
10
|
+
ariaVersion: ARIAVersion | undefined;
|
|
11
|
+
}>>;
|
|
17
12
|
export default _default;
|
|
@@ -12,25 +12,26 @@ import meta from './meta.js';
|
|
|
12
12
|
export default createRule({
|
|
13
13
|
meta: meta,
|
|
14
14
|
defaultOptions: {
|
|
15
|
-
ariaVersion:
|
|
15
|
+
ariaVersion: undefined,
|
|
16
16
|
},
|
|
17
17
|
async verify({ document, report, t }) {
|
|
18
18
|
await document.walkOn('Element', el => {
|
|
19
|
+
const ariaVersion = el.rule.options?.ariaVersion ?? document.ruleCommonSettings?.ariaVersion ?? ARIA_RECOMMENDED_VERSION;
|
|
19
20
|
if (accnameMayBeMutable(el, document)) {
|
|
20
21
|
return;
|
|
21
22
|
}
|
|
22
|
-
if (!isExposed(el, document.specs,
|
|
23
|
+
if (!isExposed(el, document.specs, ariaVersion)) {
|
|
23
24
|
return;
|
|
24
25
|
}
|
|
25
|
-
const computed = getComputedRole(document.specs, el,
|
|
26
|
+
const computed = getComputedRole(document.specs, el, ariaVersion);
|
|
26
27
|
if (!computed.role) {
|
|
27
28
|
return;
|
|
28
29
|
}
|
|
29
|
-
const roleSpec = getRoleSpec(document.specs, computed.role.name, el.namespaceURI,
|
|
30
|
+
const roleSpec = getRoleSpec(document.specs, computed.role.name, el.namespaceURI, ariaVersion);
|
|
30
31
|
if (!roleSpec || !roleSpec.accessibleNameRequired) {
|
|
31
32
|
return;
|
|
32
33
|
}
|
|
33
|
-
const hasAccessibleName = !!el.getAccessibleName(
|
|
34
|
+
const hasAccessibleName = !!el.getAccessibleName(ariaVersion).trim();
|
|
34
35
|
if (!hasAccessibleName) {
|
|
35
36
|
report({ scope: el, message: t('Require {0}', 'accessible name') });
|
|
36
37
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule that requires a `<dialog>` element referenced by a `show-modal` command
|
|
3
|
+
* to contain a descendant (or itself) with the `autofocus` attribute.
|
|
4
|
+
*
|
|
5
|
+
* Without `autofocus`, the browser's dialog focusing steps fall back to the
|
|
6
|
+
* `<dialog>` element itself, which is not ideal for accessibility — screen
|
|
7
|
+
* reader users may miss the dialog content, and keyboard users may need
|
|
8
|
+
* extra tab presses to reach interactive elements.
|
|
9
|
+
*
|
|
10
|
+
* Detection algorithm:
|
|
11
|
+
* 1. Find all `<button>` elements with both `command` and `commandfor` attributes
|
|
12
|
+
* 2. Filter to triggers whose `command` value is `show-modal` (case-insensitive)
|
|
13
|
+
* 3. Resolve the referenced `<dialog>` via `document.getElementById(commandfor)`
|
|
14
|
+
* 4. Skip non-dialog targets and already-reported dialogs (deduplication)
|
|
15
|
+
* 5. Report if neither the dialog nor any descendant has the `autofocus` attribute
|
|
16
|
+
*
|
|
17
|
+
* @see https://html.spec.whatwg.org/multipage/interactive-elements.html#dialog-focusing-steps
|
|
18
|
+
* @see https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element
|
|
19
|
+
* @see https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
|
|
20
|
+
*/
|
|
21
|
+
declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<import("@markuplint/ml-core").RuleConfigValue, undefined>>;
|
|
22
|
+
export default _default;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createRule } from '@markuplint/ml-core';
|
|
2
|
+
import meta from './meta.js';
|
|
3
|
+
/**
|
|
4
|
+
* Rule that requires a `<dialog>` element referenced by a `show-modal` command
|
|
5
|
+
* to contain a descendant (or itself) with the `autofocus` attribute.
|
|
6
|
+
*
|
|
7
|
+
* Without `autofocus`, the browser's dialog focusing steps fall back to the
|
|
8
|
+
* `<dialog>` element itself, which is not ideal for accessibility — screen
|
|
9
|
+
* reader users may miss the dialog content, and keyboard users may need
|
|
10
|
+
* extra tab presses to reach interactive elements.
|
|
11
|
+
*
|
|
12
|
+
* Detection algorithm:
|
|
13
|
+
* 1. Find all `<button>` elements with both `command` and `commandfor` attributes
|
|
14
|
+
* 2. Filter to triggers whose `command` value is `show-modal` (case-insensitive)
|
|
15
|
+
* 3. Resolve the referenced `<dialog>` via `document.getElementById(commandfor)`
|
|
16
|
+
* 4. Skip non-dialog targets and already-reported dialogs (deduplication)
|
|
17
|
+
* 5. Report if neither the dialog nor any descendant has the `autofocus` attribute
|
|
18
|
+
*
|
|
19
|
+
* @see https://html.spec.whatwg.org/multipage/interactive-elements.html#dialog-focusing-steps
|
|
20
|
+
* @see https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element
|
|
21
|
+
* @see https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
|
|
22
|
+
*/
|
|
23
|
+
export default createRule({
|
|
24
|
+
defaultSeverity: 'warning',
|
|
25
|
+
meta: meta,
|
|
26
|
+
verify({ document, report, t }) {
|
|
27
|
+
const triggers = document.querySelectorAll('button[command][commandfor]');
|
|
28
|
+
const reportedDialogIds = new Set();
|
|
29
|
+
for (const trigger of triggers) {
|
|
30
|
+
const command = trigger.getAttribute('command');
|
|
31
|
+
if (!command || command.toLowerCase() !== 'show-modal') {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const targetId = trigger.getAttribute('commandfor');
|
|
35
|
+
if (!targetId) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (reportedDialogIds.has(targetId)) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const target = document.getElementById(targetId);
|
|
42
|
+
if (!target) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (target.localName !== 'dialog') {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (target.hasAttribute('autofocus')) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const autofocusDescendants = target.querySelectorAll('[autofocus]');
|
|
52
|
+
if (autofocusDescendants.length > 0) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
reportedDialogIds.add(targetId);
|
|
56
|
+
report({
|
|
57
|
+
scope: target,
|
|
58
|
+
message: t('The "{0*}" element referenced by a "{1*}" command requires an element with the "{2*}" attribute', 'dialog', 'show-modal', 'autofocus'),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
});
|
|
@@ -9,6 +9,13 @@ type Attr = {
|
|
|
9
9
|
/** One or more allowed values for the attribute, if constrained. */
|
|
10
10
|
value?: string | string[];
|
|
11
11
|
};
|
|
12
|
+
/**
|
|
13
|
+
* Configuration options for the `required-attr` rule.
|
|
14
|
+
*/
|
|
15
|
+
type Options = {
|
|
16
|
+
/** Attribute names to exclude from required-attribute checks. */
|
|
17
|
+
readonly ignoreAttrs?: readonly string[];
|
|
18
|
+
};
|
|
12
19
|
/**
|
|
13
20
|
* Rule that validates elements have all required attributes.
|
|
14
21
|
*
|
|
@@ -16,5 +23,5 @@ type Attr = {
|
|
|
16
23
|
* specified in the rule configuration. Also validates that required attribute values
|
|
17
24
|
* match expected patterns when value constraints are provided.
|
|
18
25
|
*/
|
|
19
|
-
declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<RequiredAttributes,
|
|
26
|
+
declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<RequiredAttributes, Options>>;
|
|
20
27
|
export default _default;
|
|
@@ -11,6 +11,7 @@ import meta from './meta.js';
|
|
|
11
11
|
export default createRule({
|
|
12
12
|
meta: meta,
|
|
13
13
|
defaultValue: [],
|
|
14
|
+
defaultOptions: {},
|
|
14
15
|
async verify({ document, report, t }) {
|
|
15
16
|
await document.walkOn('Element', el => {
|
|
16
17
|
if (el.hasSpreadAttr) {
|
|
@@ -18,6 +19,7 @@ export default createRule({
|
|
|
18
19
|
}
|
|
19
20
|
const customRequiredAttrs = typeof el.rule.value === 'string' ? [el.rule.value] : el.rule.value;
|
|
20
21
|
const attrSpec = getAttrSpecs(el, document.specs);
|
|
22
|
+
const ignoreAttrs = new Set(el.rule.options.ignoreAttrs);
|
|
21
23
|
const attributeSpecs = {};
|
|
22
24
|
if (attrSpec && el.elementType === 'html') {
|
|
23
25
|
for (const spec of attrSpec) {
|
|
@@ -51,12 +53,33 @@ export default createRule({
|
|
|
51
53
|
};
|
|
52
54
|
}
|
|
53
55
|
for (const spec of Object.values(attributeSpecs)) {
|
|
56
|
+
if (ignoreAttrs.has(spec.name)) {
|
|
57
|
+
if (spec.requiredEither) {
|
|
58
|
+
const activeCandidates = spec.requiredEither.filter(n => !ignoreAttrs.has(n));
|
|
59
|
+
if (activeCandidates.length > 0) {
|
|
60
|
+
const invalid = !activeCandidates.some(attrName => el.hasAttribute(attrName));
|
|
61
|
+
if (invalid) {
|
|
62
|
+
const sortedCandidate = activeCandidates.toSorted();
|
|
63
|
+
const expects = sortedCandidate.length === 1
|
|
64
|
+
? t('the "{0*}" {1}', sortedCandidate[0], 'attribute')
|
|
65
|
+
: t('{0} {1}', sortedCandidate
|
|
66
|
+
.map(attrName => t('the "{0*}"', attrName))
|
|
67
|
+
// eslint-disable-next-line unicorn/no-array-reduce
|
|
68
|
+
.reduce((a, b) => t('{0} or {1}', a, b)), t('attribute'));
|
|
69
|
+
const message = t('{0} expects {1}', t('the "{0*}" {1}', el.localName, 'element'), expects);
|
|
70
|
+
report({ scope: el, message });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
54
76
|
const didntHave = !el.hasAttribute(spec.name);
|
|
55
77
|
const candidate = [spec.name];
|
|
56
78
|
let invalid = false;
|
|
57
79
|
if (spec.requiredEither) {
|
|
58
80
|
candidate.push(...spec.requiredEither);
|
|
59
|
-
|
|
81
|
+
const activeCandidates = candidate.filter(n => !ignoreAttrs.has(n));
|
|
82
|
+
invalid = !activeCandidates.some(attrName => el.hasAttribute(attrName));
|
|
60
83
|
}
|
|
61
84
|
else if (spec.required === true) {
|
|
62
85
|
invalid = attrMatches(el, spec.condition) && didntHave;
|
|
@@ -65,11 +88,11 @@ export default createRule({
|
|
|
65
88
|
const selector = typeof spec.required === 'string' ? spec.required : spec.required.join(',');
|
|
66
89
|
invalid = el.matches(selector) && didntHave;
|
|
67
90
|
}
|
|
68
|
-
candidate.
|
|
91
|
+
const sortedCandidate = candidate.filter(n => !ignoreAttrs.has(n)).toSorted();
|
|
69
92
|
if (invalid) {
|
|
70
|
-
const expects =
|
|
93
|
+
const expects = sortedCandidate.length === 1
|
|
71
94
|
? t('the "{0*}" {1}', spec.name, 'attribute')
|
|
72
|
-
: t('{0} {1}',
|
|
95
|
+
: t('{0} {1}', sortedCandidate
|
|
73
96
|
.map(attrName => t('the "{0*}"', attrName))
|
|
74
97
|
// eslint-disable-next-line unicorn/no-array-reduce
|
|
75
98
|
.reduce((a, b) => t('{0} or {1}', a, b)), t('attribute'));
|
|
@@ -13,7 +13,7 @@ export default createRule({
|
|
|
13
13
|
defaultValue: [],
|
|
14
14
|
defaultOptions: {
|
|
15
15
|
ignoreHasMutableContents: true,
|
|
16
|
-
ignoreOmittedElements:
|
|
16
|
+
ignoreOmittedElements: true,
|
|
17
17
|
},
|
|
18
18
|
async verify({ document, report, t }) {
|
|
19
19
|
const hasMutableContent = document.nodeList.some(n => n.is(n.ELEMENT_NODE) && n.hasMutableChildren());
|