praxis-kit 0.1.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/LICENSE +21 -0
- package/README.md +77 -0
- package/dist/_shared/diagnostics.d.ts +312 -0
- package/dist/_shared/diagnostics.js +360 -0
- package/dist/build-runtime-CJ_nQEaZ.js +5065 -0
- package/dist/codemod/index.d.ts +2 -0
- package/dist/codemod/index.js +176520 -0
- package/dist/contract/index.d.ts +677 -0
- package/dist/contract/index.js +341 -0
- package/dist/eslint/index.d.ts +90 -0
- package/dist/eslint/index.js +1047 -0
- package/dist/guards/index.d.ts +78 -0
- package/dist/guards/index.js +118 -0
- package/dist/html/index.d.ts +151 -0
- package/dist/html/index.js +1244 -0
- package/dist/index-BIBd_iPD.d.ts +951 -0
- package/dist/lit/index.d.ts +862 -0
- package/dist/lit/index.js +4893 -0
- package/dist/preact/index.d.ts +796 -0
- package/dist/preact/index.js +5043 -0
- package/dist/react/index.d.ts +28 -0
- package/dist/react/index.js +205 -0
- package/dist/react/legacy.d.ts +29 -0
- package/dist/react/legacy.js +80 -0
- package/dist/solid/index.d.ts +728 -0
- package/dist/solid/index.js +4821 -0
- package/dist/svelte/Polymorphic.svelte +190 -0
- package/dist/svelte/_polymorphic-runtime.d.ts +102 -0
- package/dist/svelte/_polymorphic-runtime.js +371 -0
- package/dist/svelte/index.d.ts +994 -0
- package/dist/svelte/index.js +4482 -0
- package/dist/tailwind/index.d.ts +197 -0
- package/dist/tailwind/index.js +767 -0
- package/dist/tailwind/safelist.css +20 -0
- package/dist/ts-plugin/index.cjs +166 -0
- package/dist/ts-plugin/index.d.cts +9 -0
- package/dist/utils/index.d.ts +19 -0
- package/dist/utils/index.js +21 -0
- package/dist/vite-plugin/index.d.ts +200 -0
- package/dist/vite-plugin/index.js +2106 -0
- package/dist/vue/index.d.ts +729 -0
- package/dist/vue/index.js +4945 -0
- package/dist/web/index.d.ts +832 -0
- package/dist/web/index.js +4868 -0
- package/package.json +258 -0
|
@@ -0,0 +1,1244 @@
|
|
|
1
|
+
import { DiagnosticCategory, DiagnosticCode } from "../_shared/diagnostics.js";
|
|
2
|
+
//#region ../../lib/foundation/src/type-guards.ts
|
|
3
|
+
function isString(value) {
|
|
4
|
+
return typeof value === "string";
|
|
5
|
+
}
|
|
6
|
+
function isNumber(value) {
|
|
7
|
+
return typeof value === "number";
|
|
8
|
+
}
|
|
9
|
+
function isDefined(value) {
|
|
10
|
+
return value !== void 0;
|
|
11
|
+
}
|
|
12
|
+
function isUndefined(value) {
|
|
13
|
+
return value === void 0;
|
|
14
|
+
}
|
|
15
|
+
function isNull(value) {
|
|
16
|
+
return value === null;
|
|
17
|
+
}
|
|
18
|
+
function isNonNull(value) {
|
|
19
|
+
return value != null;
|
|
20
|
+
}
|
|
21
|
+
function isNullish(value) {
|
|
22
|
+
return isNull(value) || isUndefined(value);
|
|
23
|
+
}
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region ../../lib/contract/src/diagnostics/anchor-accessibility.ts
|
|
26
|
+
function createDiagnostic(input) {
|
|
27
|
+
return {
|
|
28
|
+
category: DiagnosticCategory.Accessibility,
|
|
29
|
+
...input
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const AnchorAccessibilityDiagnostics = {
|
|
33
|
+
roleButtonWithHref() {
|
|
34
|
+
return createDiagnostic({
|
|
35
|
+
code: DiagnosticCode.A11yAnchorRoleButtonWithHref,
|
|
36
|
+
severity: "warning",
|
|
37
|
+
message: "role=\"button\" on an <a> with an href overrides its navigation semantics for assistive technology; use a real <button> element, or remove href, if this element should not navigate.",
|
|
38
|
+
rationale: "Assistive technology announces role=\"button\" as a button, not a link — but the element still follows the link when activated by mouse or keyboard, which is confusing and inconsistent with how a button is expected to behave.",
|
|
39
|
+
suggestions: [{
|
|
40
|
+
title: "Use a real <button>",
|
|
41
|
+
description: "If this element should not navigate, render a <button> instead of an <a>."
|
|
42
|
+
}, {
|
|
43
|
+
title: "Remove role=\"button\"",
|
|
44
|
+
description: "If this element should navigate, keep the default link role."
|
|
45
|
+
}]
|
|
46
|
+
});
|
|
47
|
+
},
|
|
48
|
+
ariaDisabledInert() {
|
|
49
|
+
return createDiagnostic({
|
|
50
|
+
code: DiagnosticCode.A11yAnchorAriaDisabledInert,
|
|
51
|
+
severity: "warning",
|
|
52
|
+
message: "aria-disabled does not prevent an <a> from being focused, clicked, or navigated via keyboard; remove href, or prevent navigation yourself, if this link should be inert.",
|
|
53
|
+
rationale: "Unlike a native form control, an <a> has no disabled state the browser enforces — aria-disabled only changes what assistive technology announces, not what actually happens when the link is activated by mouse or keyboard.",
|
|
54
|
+
suggestions: [{
|
|
55
|
+
title: "Remove href",
|
|
56
|
+
description: "If this element is temporarily unavailable, remove href until it can be activated."
|
|
57
|
+
}, {
|
|
58
|
+
title: "Prevent navigation when disabled",
|
|
59
|
+
description: "Prevent the click and keyboard activation when the link is disabled so it behaves consistently for all users."
|
|
60
|
+
}]
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
//#endregion
|
|
65
|
+
//#region ../../lib/contract/src/diagnostics/aria.ts
|
|
66
|
+
const AriaDiagnostics = {
|
|
67
|
+
/** Generic bridge for violations produced by external AriaRule functions. */
|
|
68
|
+
fromViolation(v) {
|
|
69
|
+
return {
|
|
70
|
+
code: DiagnosticCode.AriaViolation,
|
|
71
|
+
category: DiagnosticCategory.ARIA,
|
|
72
|
+
message: v.message
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
attributeInvalid(key, role) {
|
|
76
|
+
return {
|
|
77
|
+
code: DiagnosticCode.AriaAttributeInvalid,
|
|
78
|
+
category: DiagnosticCategory.ARIA,
|
|
79
|
+
message: `"${key}" is not valid on role="${role}". It will be removed.`,
|
|
80
|
+
rationale: "Invalid ARIA attributes are ignored by assistive technology and may trigger accessibility-tree warnings in browser devtools.",
|
|
81
|
+
suggestions: [{
|
|
82
|
+
title: "Remove the attribute",
|
|
83
|
+
description: `"${key}" is not in the allowed attribute set for role="${role}".`
|
|
84
|
+
}]
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
missingLiveRegion(role, impliedLive) {
|
|
88
|
+
return {
|
|
89
|
+
code: DiagnosticCode.AriaMissingLiveRegion,
|
|
90
|
+
category: DiagnosticCategory.ARIA,
|
|
91
|
+
message: `role="${role}" implies aria-live="${impliedLive}" but it is missing. It has been injected.`,
|
|
92
|
+
rationale: "Live-region roles announce dynamic content changes to screen readers. Without aria-live the politeness level is unspecified and announcements may be silent.",
|
|
93
|
+
suggestions: [{
|
|
94
|
+
title: `Add aria-live="${impliedLive}"`,
|
|
95
|
+
description: `role="${role}" conventionally implies aria-live="${impliedLive}".`,
|
|
96
|
+
fix: `aria-live="${impliedLive}"`
|
|
97
|
+
}]
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
missingAtomic(role) {
|
|
101
|
+
return {
|
|
102
|
+
code: DiagnosticCode.AriaMissingAtomic,
|
|
103
|
+
category: DiagnosticCategory.ARIA,
|
|
104
|
+
message: `role="${role}" is a live region. Consider setting aria-atomic="true" if the full region should be announced as a unit, or aria-atomic="false" if only changed nodes should be read.`,
|
|
105
|
+
rationale: "aria-atomic controls whether assistive technology announces the entire live region or only the changed nodes. Omitting it leaves the behaviour browser-defined."
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
relevantInvalidTokens(invalid) {
|
|
109
|
+
const quoted = invalid.map((t) => `"${t}"`).join(", ");
|
|
110
|
+
return {
|
|
111
|
+
code: DiagnosticCode.AriaRelevantInvalidToken,
|
|
112
|
+
category: DiagnosticCategory.ARIA,
|
|
113
|
+
message: `aria-relevant contains invalid token(s): ${quoted}. Valid tokens are: additions, removals, text, all.`,
|
|
114
|
+
rationale: "aria-relevant accepts a space-separated list of change types. Unrecognised tokens are silently ignored by assistive technology, making the attribute ineffective.",
|
|
115
|
+
suggestions: [{
|
|
116
|
+
title: "Use only valid tokens",
|
|
117
|
+
description: "Valid values are: additions, removals, text, all (or a space-separated combination)."
|
|
118
|
+
}]
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
relevantSuperseded() {
|
|
122
|
+
return {
|
|
123
|
+
code: DiagnosticCode.AriaRelevantSuperseded,
|
|
124
|
+
category: DiagnosticCategory.ARIA,
|
|
125
|
+
message: "aria-relevant includes \"all\" alongside other tokens. \"all\" supersedes additions, removals, and text — use aria-relevant=\"all\" alone.",
|
|
126
|
+
rationale: "\"all\" is equivalent to \"additions removals text\". Combining it with other tokens is redundant and may confuse readers of the markup.",
|
|
127
|
+
suggestions: [{
|
|
128
|
+
title: "Use aria-relevant=\"all\"",
|
|
129
|
+
fix: "aria-relevant=\"all\""
|
|
130
|
+
}]
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
missingAccessibleName(tag) {
|
|
134
|
+
return {
|
|
135
|
+
code: DiagnosticCode.AriaMissingAccessibleName,
|
|
136
|
+
category: DiagnosticCategory.ARIA,
|
|
137
|
+
message: `<${tag}> has no accessible name. Add aria-label or aria-labelledby.`,
|
|
138
|
+
rationale: "Elements with a landmark or interactive role must have an accessible name so that assistive technology can identify them when presenting the page outline.",
|
|
139
|
+
suggestions: [{
|
|
140
|
+
title: "Add aria-label",
|
|
141
|
+
description: `Add aria-label="…" directly to the <${tag}> element.`
|
|
142
|
+
}, {
|
|
143
|
+
title: "Add aria-labelledby",
|
|
144
|
+
description: "Point aria-labelledby at the id of an existing heading or label element."
|
|
145
|
+
}]
|
|
146
|
+
};
|
|
147
|
+
},
|
|
148
|
+
nameProhibited(attr, role) {
|
|
149
|
+
return {
|
|
150
|
+
code: DiagnosticCode.AriaNameProhibited,
|
|
151
|
+
category: DiagnosticCategory.ARIA,
|
|
152
|
+
message: `"${attr}" is prohibited on role="${role}" — this role does not support a name from the author. It will be removed.`,
|
|
153
|
+
rationale: "WAI-ARIA 1.2 lists a set of roles (generic, presentation/none, and the inline text-level roles) as Name Prohibited: aria-label / aria-labelledby on them are a conformance error and are ignored by assistive technology.",
|
|
154
|
+
suggestions: [{
|
|
155
|
+
title: "Remove the attribute",
|
|
156
|
+
description: `role="${role}" cannot be named. If the element needs an accessible name, give it a role that supports one (or use visible text / a wrapping labelled element).`
|
|
157
|
+
}]
|
|
158
|
+
};
|
|
159
|
+
},
|
|
160
|
+
attributeOnPresentational(attr, tag) {
|
|
161
|
+
return {
|
|
162
|
+
code: DiagnosticCode.AriaAttributeOnPresentational,
|
|
163
|
+
category: DiagnosticCategory.ARIA,
|
|
164
|
+
message: `"${attr}" is not allowed on a presentational <${tag}>. Presentational elements are invisible to assistive technology.`,
|
|
165
|
+
rationale: "role=\"none\" and role=\"presentation\" (including <img alt=\"\">) remove an element from the accessibility tree. ARIA attributes on such elements are ignored by assistive technology.",
|
|
166
|
+
suggestions: [{
|
|
167
|
+
title: "Remove the attribute",
|
|
168
|
+
description: `"${attr}" has no effect when the element has role="none" or role="presentation".`
|
|
169
|
+
}]
|
|
170
|
+
};
|
|
171
|
+
},
|
|
172
|
+
ariaHiddenOnFocusable(tag) {
|
|
173
|
+
return {
|
|
174
|
+
code: DiagnosticCode.AriaHiddenOnFocusable,
|
|
175
|
+
category: DiagnosticCategory.ARIA,
|
|
176
|
+
message: `aria-hidden="true" must not be used on focusable <${tag}> elements. Screen reader users who navigate by keyboard will encounter the element but receive no information about it.`,
|
|
177
|
+
rationale: "aria-hidden removes an element from the accessibility tree while leaving it keyboard-reachable. This creates a \"ghost\" — a focusable element assistive technology cannot describe.",
|
|
178
|
+
suggestions: [{
|
|
179
|
+
title: "Remove aria-hidden",
|
|
180
|
+
description: "If the element should be hidden from all users, use the HTML hidden attribute or CSS display:none instead."
|
|
181
|
+
}, {
|
|
182
|
+
title: "Make the element non-focusable",
|
|
183
|
+
description: "If the element is intentionally decorative, add tabindex=\"-1\" and disable it so it is not reachable by keyboard."
|
|
184
|
+
}]
|
|
185
|
+
};
|
|
186
|
+
},
|
|
187
|
+
invalidAttributeValue(attr, value, expected) {
|
|
188
|
+
const got = value === null ? "null" : value === void 0 ? "undefined" : typeof value === "string" ? `"${value}"` : String(value);
|
|
189
|
+
return {
|
|
190
|
+
code: DiagnosticCode.AriaInvalidAttributeValue,
|
|
191
|
+
category: DiagnosticCategory.ARIA,
|
|
192
|
+
message: `"${attr}" has an invalid value (${got}). Expected: ${expected}.`,
|
|
193
|
+
rationale: "ARIA attributes with invalid values are silently ignored by assistive technology, making the markup semantically inert.",
|
|
194
|
+
suggestions: [{
|
|
195
|
+
title: `Use a valid value for ${attr}`,
|
|
196
|
+
description: `Valid values are: ${expected}.`
|
|
197
|
+
}]
|
|
198
|
+
};
|
|
199
|
+
},
|
|
200
|
+
redundantAriaLevel(tag, level) {
|
|
201
|
+
return {
|
|
202
|
+
code: DiagnosticCode.AriaRedundantLevelAttribute,
|
|
203
|
+
category: DiagnosticCategory.ARIA,
|
|
204
|
+
message: `aria-level="${level}" is redundant on <${tag}>: the element already has an implicit heading level of ${level}. Remove the attribute.`,
|
|
205
|
+
rationale: "Restating the implicit aria-level adds noise without semantic value. Use aria-level only to override the native heading level (e.g. aria-level=\"3\" on <h2>).",
|
|
206
|
+
suggestions: [{
|
|
207
|
+
title: "Remove aria-level",
|
|
208
|
+
description: `<${tag}> already implies aria-level="${level}".`
|
|
209
|
+
}]
|
|
210
|
+
};
|
|
211
|
+
},
|
|
212
|
+
requiredProperty(attr, role) {
|
|
213
|
+
return {
|
|
214
|
+
code: DiagnosticCode.AriaRequiredProperty,
|
|
215
|
+
category: DiagnosticCategory.ARIA,
|
|
216
|
+
message: `"${attr}" is required for role="${role}" but is missing.`,
|
|
217
|
+
rationale: `WAI-ARIA 1.2 specifies required states and properties for certain roles. Without "${attr}", assistive technology cannot correctly communicate the element's state to users.`,
|
|
218
|
+
suggestions: [{
|
|
219
|
+
title: `Add ${attr}`,
|
|
220
|
+
description: `role="${role}" requires "${attr}" to be present.`
|
|
221
|
+
}]
|
|
222
|
+
};
|
|
223
|
+
},
|
|
224
|
+
invalidRole(role, tag) {
|
|
225
|
+
return {
|
|
226
|
+
code: DiagnosticCode.AriaInvalidRole,
|
|
227
|
+
category: DiagnosticCategory.ARIA,
|
|
228
|
+
message: `Invalid role "${role ?? ""}" on <${tag}>.`,
|
|
229
|
+
rationale: "An unrecognised or misapplied ARIA role is ignored by assistive technology and may degrade the accessibility of the element."
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
//#endregion
|
|
234
|
+
//#region ../../lib/contract/src/diagnostics/html.ts
|
|
235
|
+
const ATTRIBUTE_IGNORED_CODES = {
|
|
236
|
+
checked: DiagnosticCode.HtmlInputCheckedIgnoredForType,
|
|
237
|
+
multiple: DiagnosticCode.HtmlInputMultipleIgnoredForType,
|
|
238
|
+
maxLength: DiagnosticCode.HtmlInputMaxLengthIgnoredForType,
|
|
239
|
+
minLength: DiagnosticCode.HtmlInputMinLengthIgnoredForType,
|
|
240
|
+
pattern: DiagnosticCode.HtmlInputPatternIgnoredForType,
|
|
241
|
+
min: DiagnosticCode.HtmlInputMinIgnoredForType,
|
|
242
|
+
max: DiagnosticCode.HtmlInputMaxIgnoredForType,
|
|
243
|
+
step: DiagnosticCode.HtmlInputStepIgnoredForType,
|
|
244
|
+
accept: DiagnosticCode.HtmlInputAcceptIgnoredForType,
|
|
245
|
+
capture: DiagnosticCode.HtmlInputCaptureIgnoredForType,
|
|
246
|
+
size: DiagnosticCode.HtmlInputSizeIgnoredForType,
|
|
247
|
+
alt: DiagnosticCode.HtmlInputAltIgnoredForType,
|
|
248
|
+
height: DiagnosticCode.HtmlInputHeightIgnoredForType,
|
|
249
|
+
width: DiagnosticCode.HtmlInputWidthIgnoredForType
|
|
250
|
+
};
|
|
251
|
+
const HtmlDiagnostics = {
|
|
252
|
+
emptyRole(tag) {
|
|
253
|
+
return {
|
|
254
|
+
code: DiagnosticCode.HtmlEmptyRole,
|
|
255
|
+
category: DiagnosticCategory.HTML,
|
|
256
|
+
severity: "warning",
|
|
257
|
+
message: `<${tag}> has an explicit empty role="". Omit the attribute instead.`
|
|
258
|
+
};
|
|
259
|
+
},
|
|
260
|
+
implicitRoleRedundant(tag, implicitRole) {
|
|
261
|
+
return {
|
|
262
|
+
code: DiagnosticCode.HtmlImplicitRoleRedundant,
|
|
263
|
+
category: DiagnosticCategory.HTML,
|
|
264
|
+
severity: "warning",
|
|
265
|
+
message: `<${tag}> already has implicit role="${implicitRole}". Avoid redundant role assignment.`
|
|
266
|
+
};
|
|
267
|
+
},
|
|
268
|
+
implicitRoleOverride(tag, implicitRole, role) {
|
|
269
|
+
return {
|
|
270
|
+
code: DiagnosticCode.HtmlImplicitRoleOverride,
|
|
271
|
+
category: DiagnosticCategory.HTML,
|
|
272
|
+
severity: "error",
|
|
273
|
+
message: `<${tag}> should not override its implicit role="${implicitRole}" with role="${role}".`
|
|
274
|
+
};
|
|
275
|
+
},
|
|
276
|
+
standaloneRegionOverride(tag, implicitRole) {
|
|
277
|
+
return {
|
|
278
|
+
code: DiagnosticCode.HtmlStandaloneRegionOverride,
|
|
279
|
+
category: DiagnosticCategory.HTML,
|
|
280
|
+
severity: "error",
|
|
281
|
+
message: `<${tag}> is a self-contained element with implicit role="${implicitRole}". Assigning role="region" has been removed.`
|
|
282
|
+
};
|
|
283
|
+
},
|
|
284
|
+
landmarkRoleOverride(tag, implicitRole, role) {
|
|
285
|
+
return {
|
|
286
|
+
code: DiagnosticCode.HtmlLandmarkRoleOverride,
|
|
287
|
+
category: DiagnosticCategory.HTML,
|
|
288
|
+
severity: "error",
|
|
289
|
+
message: `<${tag}> has a fixed landmark role="${implicitRole}". role="${role}" overrides it and confuses assistive technology. The override has been removed.`
|
|
290
|
+
};
|
|
291
|
+
},
|
|
292
|
+
invalidChild(child, parent, allowed) {
|
|
293
|
+
return {
|
|
294
|
+
code: DiagnosticCode.HtmlInvalidChild,
|
|
295
|
+
category: DiagnosticCategory.HTML,
|
|
296
|
+
severity: "error",
|
|
297
|
+
message: `<${child}> is not a valid direct child of <${parent}>. Allowed: ${allowed}.`
|
|
298
|
+
};
|
|
299
|
+
},
|
|
300
|
+
roleNotPermitted(tag, role, allowedRoles) {
|
|
301
|
+
const allowed = allowedRoles.length > 0 ? allowedRoles.map((r) => `"${r}"`).join(", ") : "none — no explicit role is permitted on this element";
|
|
302
|
+
return {
|
|
303
|
+
code: DiagnosticCode.HtmlRoleNotPermitted,
|
|
304
|
+
category: DiagnosticCategory.HTML,
|
|
305
|
+
severity: "error",
|
|
306
|
+
message: `role="${role}" is not permitted on <${tag}>. Allowed alternate role(s): ${allowed}.`,
|
|
307
|
+
rationale: "The WAI-ARIA \"ARIA in HTML\" specification restricts which explicit roles a native element may take. A role outside that set is ignored or produces undefined behavior in assistive technology."
|
|
308
|
+
};
|
|
309
|
+
},
|
|
310
|
+
input: {
|
|
311
|
+
unsupportedType(type) {
|
|
312
|
+
return {
|
|
313
|
+
code: DiagnosticCode.HtmlInputUnsupportedType,
|
|
314
|
+
category: DiagnosticCategory.HTML,
|
|
315
|
+
severity: "warning",
|
|
316
|
+
message: `type="${type}" is not a value defined by the HTML specification. Browsers silently fall back to type="text".`,
|
|
317
|
+
rationale: "An unrecognized input type is not invalid markup — the spec requires the \"text\" fallback — but it usually means a typo, since the input keeps working while silently losing the intended type-specific behavior (validation, virtual keyboard, picker UI).",
|
|
318
|
+
suggestions: [{
|
|
319
|
+
title: "Check for a typo in the type value",
|
|
320
|
+
description: `"${type}" does not match any HTML5 input type.`
|
|
321
|
+
}]
|
|
322
|
+
};
|
|
323
|
+
},
|
|
324
|
+
attributeIgnoredForType(attribute, type, allowedTypes) {
|
|
325
|
+
const allowed = allowedTypes.map((t) => `"${t}"`).join(", ");
|
|
326
|
+
return {
|
|
327
|
+
code: ATTRIBUTE_IGNORED_CODES[attribute],
|
|
328
|
+
category: DiagnosticCategory.HTML,
|
|
329
|
+
severity: "warning",
|
|
330
|
+
message: `"${attribute}" is ignored on <input type="${type}">.`,
|
|
331
|
+
rationale: `"${attribute}" only has an effect when type is one of: ${allowed}. Browsers silently ignore it on other input types.`,
|
|
332
|
+
suggestions: [{
|
|
333
|
+
title: `Remove "${attribute}"`,
|
|
334
|
+
description: `"${attribute}" only affects <input> when type is one of: ${allowed}.`
|
|
335
|
+
}]
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
anchor: { dangerousHref(href) {
|
|
340
|
+
return {
|
|
341
|
+
code: DiagnosticCode.HtmlAnchorDangerousHref,
|
|
342
|
+
category: DiagnosticCategory.HTML,
|
|
343
|
+
severity: "warning",
|
|
344
|
+
message: `href="${href}" uses a scheme that executes attacker-controlled content when navigated to. It has been removed.`,
|
|
345
|
+
rationale: "\"javascript:\", \"data:\", and \"vbscript:\" URLs are a common XSS vector when href comes from untrusted input — none of the three are needed for legitimate navigation."
|
|
346
|
+
};
|
|
347
|
+
} }
|
|
348
|
+
};
|
|
349
|
+
//#endregion
|
|
350
|
+
//#region ../../lib/contract/src/diagnostics/input-accessibility.ts
|
|
351
|
+
function accessibilityFact(input) {
|
|
352
|
+
return {
|
|
353
|
+
category: DiagnosticCategory.Accessibility,
|
|
354
|
+
...input
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
const InputAccessibilityDiagnostics = {
|
|
358
|
+
missingAccessibleName() {
|
|
359
|
+
return accessibilityFact({
|
|
360
|
+
code: DiagnosticCode.A11yInputMissingAccessibleName,
|
|
361
|
+
severity: "warning",
|
|
362
|
+
message: "This input has no accessible name. Add an associated <label>, aria-label, or aria-labelledby.",
|
|
363
|
+
rationale: "Assistive technology announces a form field by its accessible name; without one, users of screen readers cannot tell what the field is for.",
|
|
364
|
+
suggestions: [{
|
|
365
|
+
title: "Add aria-label",
|
|
366
|
+
description: "Set aria-label=\"…\" directly on the input."
|
|
367
|
+
}, {
|
|
368
|
+
title: "Add an associated <label>",
|
|
369
|
+
description: "Wrap the input in a <label>, or point a <label for=\"…\"> at its id."
|
|
370
|
+
}]
|
|
371
|
+
});
|
|
372
|
+
},
|
|
373
|
+
placeholderIsNotLabel() {
|
|
374
|
+
return accessibilityFact({
|
|
375
|
+
code: DiagnosticCode.A11yInputPlaceholderNotLabel,
|
|
376
|
+
severity: "warning",
|
|
377
|
+
message: "Placeholder text does not provide an accessible name. Add an associated <label>, aria-label, or aria-labelledby.",
|
|
378
|
+
rationale: "Placeholder text disappears as users interact with the field and is not treated as the control's accessible name by many assistive technologies.",
|
|
379
|
+
suggestions: [{
|
|
380
|
+
title: "Add aria-label",
|
|
381
|
+
description: "Set aria-label=\"…\" directly on the input."
|
|
382
|
+
}, {
|
|
383
|
+
title: "Add an associated <label>",
|
|
384
|
+
description: "Wrap the input in a <label>, or point a <label for=\"…\"> at its id."
|
|
385
|
+
}]
|
|
386
|
+
});
|
|
387
|
+
},
|
|
388
|
+
passwordMissingAutocomplete() {
|
|
389
|
+
return accessibilityFact({
|
|
390
|
+
code: DiagnosticCode.A11yInputPasswordAutocomplete,
|
|
391
|
+
severity: "warning",
|
|
392
|
+
message: "Password inputs should specify an autoComplete value.",
|
|
393
|
+
rationale: "Without an explicit autocomplete hint, password managers and browsers cannot reliably tell a sign-in field apart from a password-creation field.",
|
|
394
|
+
suggestions: [{
|
|
395
|
+
title: "Set autoComplete=\"current-password\"",
|
|
396
|
+
description: "Use this for sign-in forms."
|
|
397
|
+
}, {
|
|
398
|
+
title: "Set autoComplete=\"new-password\"",
|
|
399
|
+
description: "Use this for sign-up / change-password forms."
|
|
400
|
+
}]
|
|
401
|
+
});
|
|
402
|
+
},
|
|
403
|
+
requiredReadOnlyConflict() {
|
|
404
|
+
return accessibilityFact({
|
|
405
|
+
code: DiagnosticCode.A11yInputRequiredReadOnlyConflict,
|
|
406
|
+
severity: "warning",
|
|
407
|
+
message: "\"required\" has no effect while this field is readOnly — the two attributes together usually signal an unintended state.",
|
|
408
|
+
rationale: "The combination is valid HTML, but a readOnly control cannot be edited, so its \"required\" constraint can never be satisfied or violated through user interaction. Consider using disabled instead of readOnly, or only applying required when the field is editable."
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
//#endregion
|
|
413
|
+
//#region ../../lib/contract/src/aria/factories.ts
|
|
414
|
+
/**
|
|
415
|
+
* Builds a correctly-literal-typed `fixable: false` `AriaResult`. Exists so a rule author can
|
|
416
|
+
* extract shared branch logic (severity/attribute/message computed once, reused across multiple
|
|
417
|
+
* `return`s) without TypeScript silently widening `valid: false`/`fixable: false` to `boolean`
|
|
418
|
+
* the moment those values leave an object-literal-in-return-position — the widening only happens
|
|
419
|
+
* on plain object literals; a function's declared return type narrows unconditionally.
|
|
420
|
+
*/
|
|
421
|
+
function invalidWithoutFix(input) {
|
|
422
|
+
return {
|
|
423
|
+
valid: false,
|
|
424
|
+
fixable: false,
|
|
425
|
+
severity: input.severity,
|
|
426
|
+
...isDefined(input.attribute) && { attribute: input.attribute },
|
|
427
|
+
...isDefined(input.message) && { message: input.message },
|
|
428
|
+
...isDefined(input.diagnostic) && { diagnostic: input.diagnostic }
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
/** Same as {@link invalidWithoutFix}, for the `fixable: true` branch — requires a `fix`. */
|
|
432
|
+
function invalidWithFix(input) {
|
|
433
|
+
return {
|
|
434
|
+
valid: false,
|
|
435
|
+
fixable: true,
|
|
436
|
+
severity: input.severity,
|
|
437
|
+
...isDefined(input.attribute) && { attribute: input.attribute },
|
|
438
|
+
...isDefined(input.message) && { message: input.message },
|
|
439
|
+
...isDefined(input.diagnostic) && { diagnostic: input.diagnostic },
|
|
440
|
+
fix: input.fix
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
function removeProp(props, key) {
|
|
444
|
+
const next = { ...props };
|
|
445
|
+
delete next[key];
|
|
446
|
+
return next;
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Builds an `AriaFix` that strips a single attribute — the shape `dangerousHrefRule`-style
|
|
450
|
+
* "strip this attribute when it's dangerous/redundant" rules need. A no-op (`applied: false`) when
|
|
451
|
+
* the attribute isn't present, so applying the fix twice (or applying it when nothing triggered it)
|
|
452
|
+
* is always safe. Frozen — a fix is a value object; nothing should mutate `kind`/`attribute`/`apply`
|
|
453
|
+
* after construction.
|
|
454
|
+
*/
|
|
455
|
+
function removeAttributeFix(attribute) {
|
|
456
|
+
return Object.freeze({
|
|
457
|
+
kind: "removeAttribute",
|
|
458
|
+
attribute,
|
|
459
|
+
apply: ({ props }) => {
|
|
460
|
+
if (!(attribute in props)) return {
|
|
461
|
+
applied: false,
|
|
462
|
+
next: props
|
|
463
|
+
};
|
|
464
|
+
return {
|
|
465
|
+
applied: true,
|
|
466
|
+
next: removeProp(props, attribute),
|
|
467
|
+
previous: props
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
function defineRuleMetadata(rule, metadata) {
|
|
473
|
+
const descriptors = {};
|
|
474
|
+
for (const key of Object.keys(metadata)) descriptors[key] = {
|
|
475
|
+
value: metadata[key],
|
|
476
|
+
enumerable: false
|
|
477
|
+
};
|
|
478
|
+
Object.defineProperties(rule, descriptors);
|
|
479
|
+
return rule;
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Convenience factory for the single most common `enforcement.aria`/`enforcement.rules` shape:
|
|
483
|
+
* "strip this attribute when some condition on the element's own props holds" — covers
|
|
484
|
+
* security-style guards (a dangerous URL scheme on `href`) and redundant-attribute rules alike,
|
|
485
|
+
* without hand-writing the rule function, the `AriaFix`, and the `invalidWithFix` call each time.
|
|
486
|
+
* A rule with no fix (a warn-only advisory) still needs the raw `AriaRule` shape directly — this
|
|
487
|
+
* factory is deliberately scoped to the strip-on-match case, not a general rule builder.
|
|
488
|
+
*/
|
|
489
|
+
function createRemoveAttributeRule(attribute, options) {
|
|
490
|
+
const { when, severity = "warning", message, diagnostic, readsProps, tags } = options;
|
|
491
|
+
const fix = removeAttributeFix(attribute);
|
|
492
|
+
const rule = (context) => {
|
|
493
|
+
if (!when(context)) return [];
|
|
494
|
+
return [invalidWithFix({
|
|
495
|
+
severity,
|
|
496
|
+
attribute,
|
|
497
|
+
...isDefined(message) && { message },
|
|
498
|
+
...isDefined(diagnostic) && { diagnostic: diagnostic(context) },
|
|
499
|
+
fix
|
|
500
|
+
})];
|
|
501
|
+
};
|
|
502
|
+
return defineRuleMetadata(rule, {
|
|
503
|
+
...isDefined(readsProps) && { readsProps },
|
|
504
|
+
...isDefined(tags) && { tags }
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
//#endregion
|
|
508
|
+
//#region ../core/src/html/contracts/categories.ts
|
|
509
|
+
/**
|
|
510
|
+
* HTML elements with implicit landmark roles.
|
|
511
|
+
*
|
|
512
|
+
* `<section>` and `<form>` are intentionally excluded because their landmark
|
|
513
|
+
* semantics depend on having an accessible name.
|
|
514
|
+
*/
|
|
515
|
+
const LANDMARK_TAGS = [
|
|
516
|
+
"article",
|
|
517
|
+
"aside",
|
|
518
|
+
"footer",
|
|
519
|
+
"header",
|
|
520
|
+
"main",
|
|
521
|
+
"nav"
|
|
522
|
+
];
|
|
523
|
+
//#endregion
|
|
524
|
+
//#region ../core/src/html/anchor-rules.ts
|
|
525
|
+
const DANGEROUS_URL_SCHEMES = [
|
|
526
|
+
"javascript:",
|
|
527
|
+
"data:",
|
|
528
|
+
"vbscript:"
|
|
529
|
+
];
|
|
530
|
+
function normalizeForSchemeCheck(href) {
|
|
531
|
+
return href.replace(/[\x00-\x20]/g, "").toLowerCase();
|
|
532
|
+
}
|
|
533
|
+
function isDangerousUrl(href) {
|
|
534
|
+
if (!isString(href)) return false;
|
|
535
|
+
const normalized = normalizeForSchemeCheck(href);
|
|
536
|
+
return DANGEROUS_URL_SCHEMES.some((scheme) => normalized.startsWith(scheme));
|
|
537
|
+
}
|
|
538
|
+
const dangerousHrefRule = createRemoveAttributeRule("href", {
|
|
539
|
+
when: ({ props }) => isDangerousUrl(props.href),
|
|
540
|
+
severity: "warning",
|
|
541
|
+
diagnostic: ({ props }) => HtmlDiagnostics.anchor.dangerousHref(props.href),
|
|
542
|
+
readsProps: ["href"],
|
|
543
|
+
tags: ["a"]
|
|
544
|
+
});
|
|
545
|
+
const roleButtonWithHrefRule = Object.assign(({ props }) => {
|
|
546
|
+
if (props.role !== "button" || !isString(props.href) || props.href.length === 0) return [];
|
|
547
|
+
const diagnostic = AnchorAccessibilityDiagnostics.roleButtonWithHref();
|
|
548
|
+
return [invalidWithoutFix({
|
|
549
|
+
severity: diagnostic.severity,
|
|
550
|
+
attribute: "role",
|
|
551
|
+
diagnostic
|
|
552
|
+
})];
|
|
553
|
+
}, {
|
|
554
|
+
readsProps: ["role", "href"],
|
|
555
|
+
tags: ["a"]
|
|
556
|
+
});
|
|
557
|
+
const ariaDisabledInertRule = Object.assign(({ props }) => {
|
|
558
|
+
const ariaDisabled = props["aria-disabled"];
|
|
559
|
+
const isDisabled = ariaDisabled === true || ariaDisabled === "true";
|
|
560
|
+
const hasHref = isString(props.href) && props.href.length > 0;
|
|
561
|
+
if (!isDisabled || !hasHref) return [];
|
|
562
|
+
const diagnostic = AnchorAccessibilityDiagnostics.ariaDisabledInert();
|
|
563
|
+
return [invalidWithoutFix({
|
|
564
|
+
severity: diagnostic.severity,
|
|
565
|
+
attribute: "aria-disabled",
|
|
566
|
+
diagnostic
|
|
567
|
+
})];
|
|
568
|
+
}, {
|
|
569
|
+
readsProps: ["aria-disabled", "href"],
|
|
570
|
+
tags: ["a"]
|
|
571
|
+
});
|
|
572
|
+
const ANCHOR_RULES = [
|
|
573
|
+
dangerousHrefRule,
|
|
574
|
+
roleButtonWithHrefRule,
|
|
575
|
+
ariaDisabledInertRule
|
|
576
|
+
];
|
|
577
|
+
//#endregion
|
|
578
|
+
//#region ../core/src/html/spec/vocabulary/input.ts
|
|
579
|
+
const TEXT_INPUT_TYPES = [
|
|
580
|
+
"text",
|
|
581
|
+
"search",
|
|
582
|
+
"url",
|
|
583
|
+
"tel",
|
|
584
|
+
"email",
|
|
585
|
+
"password"
|
|
586
|
+
];
|
|
587
|
+
const NUMERIC_INPUT_TYPES = [
|
|
588
|
+
"number",
|
|
589
|
+
"range",
|
|
590
|
+
"date",
|
|
591
|
+
"month",
|
|
592
|
+
"week",
|
|
593
|
+
"time",
|
|
594
|
+
"datetime-local"
|
|
595
|
+
];
|
|
596
|
+
const HTML_INPUT_TYPES = /* @__PURE__ */ new Set([
|
|
597
|
+
...TEXT_INPUT_TYPES,
|
|
598
|
+
...NUMERIC_INPUT_TYPES,
|
|
599
|
+
"checkbox",
|
|
600
|
+
"radio",
|
|
601
|
+
"file",
|
|
602
|
+
"color",
|
|
603
|
+
"hidden",
|
|
604
|
+
"button",
|
|
605
|
+
"submit",
|
|
606
|
+
"reset",
|
|
607
|
+
"image"
|
|
608
|
+
]);
|
|
609
|
+
//#endregion
|
|
610
|
+
//#region ../core/src/html/spec/attributes/input.ts
|
|
611
|
+
const INPUT_ATTRIBUTE_TYPE_POLICIES = [
|
|
612
|
+
{
|
|
613
|
+
attribute: "checked",
|
|
614
|
+
allowedTypes: ["checkbox", "radio"]
|
|
615
|
+
},
|
|
616
|
+
{
|
|
617
|
+
attribute: "multiple",
|
|
618
|
+
allowedTypes: ["email", "file"]
|
|
619
|
+
},
|
|
620
|
+
{
|
|
621
|
+
attribute: "maxLength",
|
|
622
|
+
allowedTypes: TEXT_INPUT_TYPES
|
|
623
|
+
},
|
|
624
|
+
{
|
|
625
|
+
attribute: "minLength",
|
|
626
|
+
allowedTypes: TEXT_INPUT_TYPES
|
|
627
|
+
},
|
|
628
|
+
{
|
|
629
|
+
attribute: "pattern",
|
|
630
|
+
allowedTypes: TEXT_INPUT_TYPES
|
|
631
|
+
},
|
|
632
|
+
{
|
|
633
|
+
attribute: "min",
|
|
634
|
+
allowedTypes: NUMERIC_INPUT_TYPES
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
attribute: "max",
|
|
638
|
+
allowedTypes: NUMERIC_INPUT_TYPES
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
attribute: "step",
|
|
642
|
+
allowedTypes: NUMERIC_INPUT_TYPES
|
|
643
|
+
},
|
|
644
|
+
{
|
|
645
|
+
attribute: "accept",
|
|
646
|
+
allowedTypes: ["file"]
|
|
647
|
+
},
|
|
648
|
+
{
|
|
649
|
+
attribute: "capture",
|
|
650
|
+
allowedTypes: ["file"]
|
|
651
|
+
},
|
|
652
|
+
{
|
|
653
|
+
attribute: "size",
|
|
654
|
+
allowedTypes: TEXT_INPUT_TYPES
|
|
655
|
+
},
|
|
656
|
+
{
|
|
657
|
+
attribute: "alt",
|
|
658
|
+
allowedTypes: ["image"]
|
|
659
|
+
},
|
|
660
|
+
{
|
|
661
|
+
attribute: "height",
|
|
662
|
+
allowedTypes: ["image"]
|
|
663
|
+
},
|
|
664
|
+
{
|
|
665
|
+
attribute: "width",
|
|
666
|
+
allowedTypes: ["image"]
|
|
667
|
+
}
|
|
668
|
+
];
|
|
669
|
+
//#endregion
|
|
670
|
+
//#region ../core/src/html/spec/constraints/input.ts
|
|
671
|
+
const REQUIRED_READONLY_CONFLICT = {
|
|
672
|
+
props: ["required", "readOnly"],
|
|
673
|
+
diagnostic: () => InputAccessibilityDiagnostics.requiredReadOnlyConflict()
|
|
674
|
+
};
|
|
675
|
+
const INPUT_MUTUALLY_EXCLUSIVE_POLICIES = [REQUIRED_READONLY_CONFLICT];
|
|
676
|
+
//#endregion
|
|
677
|
+
//#region ../core/src/html/spec/validators/attribute-type-validator.ts
|
|
678
|
+
const DEFAULT_INPUT_TYPE = "text";
|
|
679
|
+
function createInputAttributeTypeRule({ attribute, allowedTypes }) {
|
|
680
|
+
const rule = ({ tag, props, variantKeys }) => {
|
|
681
|
+
if (tag !== "input" || !(attribute in props)) return [];
|
|
682
|
+
if (variantKeys?.has(attribute)) return [];
|
|
683
|
+
const type = typeof props.type === "string" ? props.type : DEFAULT_INPUT_TYPE;
|
|
684
|
+
if (allowedTypes.includes(type)) return [];
|
|
685
|
+
const diagnostic = HtmlDiagnostics.input.attributeIgnoredForType(attribute, type, allowedTypes);
|
|
686
|
+
return [{
|
|
687
|
+
valid: false,
|
|
688
|
+
fixable: true,
|
|
689
|
+
severity: diagnostic.severity,
|
|
690
|
+
fix: removeAttributeFix(attribute),
|
|
691
|
+
diagnostic
|
|
692
|
+
}];
|
|
693
|
+
};
|
|
694
|
+
return Object.assign(rule, {
|
|
695
|
+
readsProps: ["type", attribute],
|
|
696
|
+
tags: ["input"]
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
//#endregion
|
|
700
|
+
//#region ../core/src/html/spec/validators/mutually-exclusive-validator.ts
|
|
701
|
+
function isBooleanAttrSet(value) {
|
|
702
|
+
return value !== void 0 && value !== null && value !== false;
|
|
703
|
+
}
|
|
704
|
+
function createMutuallyExclusiveRule({ props: conflictingProps, diagnostic: createDiagnostic }) {
|
|
705
|
+
const [first, second] = conflictingProps;
|
|
706
|
+
const rule = ({ tag, props }) => {
|
|
707
|
+
if (tag !== "input" || !isBooleanAttrSet(props[first]) || !isBooleanAttrSet(props[second])) return [];
|
|
708
|
+
const diagnostic = createDiagnostic();
|
|
709
|
+
return [{
|
|
710
|
+
valid: false,
|
|
711
|
+
fixable: false,
|
|
712
|
+
severity: diagnostic.severity,
|
|
713
|
+
diagnostic
|
|
714
|
+
}];
|
|
715
|
+
};
|
|
716
|
+
return Object.assign(rule, {
|
|
717
|
+
readsProps: conflictingProps,
|
|
718
|
+
tags: ["input"]
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
//#endregion
|
|
722
|
+
//#region ../core/src/html/input-rules.ts
|
|
723
|
+
const policyByAttribute = Object.fromEntries(INPUT_ATTRIBUTE_TYPE_POLICIES.map((policy) => [policy.attribute, policy]));
|
|
724
|
+
function policyFor(attribute) {
|
|
725
|
+
return policyByAttribute[attribute];
|
|
726
|
+
}
|
|
727
|
+
const supportedInputTypeRule = Object.assign(({ tag, props }) => {
|
|
728
|
+
if (tag !== "input" || typeof props.type !== "string") return [];
|
|
729
|
+
const type = props.type;
|
|
730
|
+
if (HTML_INPUT_TYPES.has(type)) return [];
|
|
731
|
+
const diagnostic = HtmlDiagnostics.input.unsupportedType(type);
|
|
732
|
+
return [{
|
|
733
|
+
valid: false,
|
|
734
|
+
fixable: false,
|
|
735
|
+
severity: diagnostic.severity,
|
|
736
|
+
diagnostic
|
|
737
|
+
}];
|
|
738
|
+
}, {
|
|
739
|
+
readsProps: ["type"],
|
|
740
|
+
tags: ["input"]
|
|
741
|
+
});
|
|
742
|
+
const checkedRequiresCheckableTypeRule = createInputAttributeTypeRule(policyFor("checked"));
|
|
743
|
+
const multipleRequiresSupportedTypeRule = createInputAttributeTypeRule(policyFor("multiple"));
|
|
744
|
+
const maxLengthRequiresTextTypeRule = createInputAttributeTypeRule(policyFor("maxLength"));
|
|
745
|
+
const minLengthRequiresTextTypeRule = createInputAttributeTypeRule(policyFor("minLength"));
|
|
746
|
+
const patternRequiresTextTypeRule = createInputAttributeTypeRule(policyFor("pattern"));
|
|
747
|
+
const minRequiresNumericTypeRule = createInputAttributeTypeRule(policyFor("min"));
|
|
748
|
+
const maxRequiresNumericTypeRule = createInputAttributeTypeRule(policyFor("max"));
|
|
749
|
+
const stepRequiresNumericTypeRule = createInputAttributeTypeRule(policyFor("step"));
|
|
750
|
+
const acceptRequiresFileTypeRule = createInputAttributeTypeRule(policyFor("accept"));
|
|
751
|
+
const captureRequiresFileTypeRule = createInputAttributeTypeRule(policyFor("capture"));
|
|
752
|
+
const sizeRequiresTextTypeRule = createInputAttributeTypeRule(policyFor("size"));
|
|
753
|
+
const altRequiresImageTypeRule = createInputAttributeTypeRule(policyFor("alt"));
|
|
754
|
+
const heightRequiresImageTypeRule = createInputAttributeTypeRule(policyFor("height"));
|
|
755
|
+
const widthRequiresImageTypeRule = createInputAttributeTypeRule(policyFor("width"));
|
|
756
|
+
const inputAccessibleNameRule = Object.assign(({ tag, props }) => {
|
|
757
|
+
if (tag !== "input" || props.type === "hidden") return [];
|
|
758
|
+
if ("aria-label" in props || "aria-labelledby" in props) return [];
|
|
759
|
+
const diagnostic = typeof props.placeholder === "string" && props.placeholder.length > 0 ? InputAccessibilityDiagnostics.placeholderIsNotLabel() : InputAccessibilityDiagnostics.missingAccessibleName();
|
|
760
|
+
return [{
|
|
761
|
+
valid: false,
|
|
762
|
+
fixable: false,
|
|
763
|
+
severity: diagnostic.severity,
|
|
764
|
+
diagnostic
|
|
765
|
+
}];
|
|
766
|
+
}, {
|
|
767
|
+
readsProps: [
|
|
768
|
+
"type",
|
|
769
|
+
"aria-label",
|
|
770
|
+
"aria-labelledby",
|
|
771
|
+
"placeholder"
|
|
772
|
+
],
|
|
773
|
+
tags: ["input"]
|
|
774
|
+
});
|
|
775
|
+
const PASSWORD_AUTOCOMPLETE_VALUES = ["current-password", "new-password"];
|
|
776
|
+
const passwordAutocompleteRule = Object.assign(({ tag, props }) => {
|
|
777
|
+
if (tag !== "input" || props.type !== "password") return [];
|
|
778
|
+
const autoComplete = props.autoComplete;
|
|
779
|
+
const tokens = typeof autoComplete === "string" ? autoComplete.split(" ") : [];
|
|
780
|
+
if (PASSWORD_AUTOCOMPLETE_VALUES.some((value) => tokens.includes(value))) return [];
|
|
781
|
+
const diagnostic = InputAccessibilityDiagnostics.passwordMissingAutocomplete();
|
|
782
|
+
return [{
|
|
783
|
+
valid: false,
|
|
784
|
+
fixable: false,
|
|
785
|
+
severity: diagnostic.severity,
|
|
786
|
+
diagnostic
|
|
787
|
+
}];
|
|
788
|
+
}, {
|
|
789
|
+
readsProps: ["type", "autoComplete"],
|
|
790
|
+
tags: ["input"]
|
|
791
|
+
});
|
|
792
|
+
const requiredReadOnlyConflictRule = createMutuallyExclusiveRule(REQUIRED_READONLY_CONFLICT);
|
|
793
|
+
const INPUT_RULES = [
|
|
794
|
+
supportedInputTypeRule,
|
|
795
|
+
checkedRequiresCheckableTypeRule,
|
|
796
|
+
multipleRequiresSupportedTypeRule,
|
|
797
|
+
maxLengthRequiresTextTypeRule,
|
|
798
|
+
minLengthRequiresTextTypeRule,
|
|
799
|
+
patternRequiresTextTypeRule,
|
|
800
|
+
minRequiresNumericTypeRule,
|
|
801
|
+
maxRequiresNumericTypeRule,
|
|
802
|
+
stepRequiresNumericTypeRule,
|
|
803
|
+
acceptRequiresFileTypeRule,
|
|
804
|
+
captureRequiresFileTypeRule,
|
|
805
|
+
sizeRequiresTextTypeRule,
|
|
806
|
+
altRequiresImageTypeRule,
|
|
807
|
+
heightRequiresImageTypeRule,
|
|
808
|
+
widthRequiresImageTypeRule,
|
|
809
|
+
inputAccessibleNameRule,
|
|
810
|
+
passwordAutocompleteRule,
|
|
811
|
+
requiredReadOnlyConflictRule
|
|
812
|
+
];
|
|
813
|
+
//#endregion
|
|
814
|
+
//#region ../core/src/html/spec/types.ts
|
|
815
|
+
function resolveAllowedRoles(spec, props) {
|
|
816
|
+
const policy = spec.allowedRoles;
|
|
817
|
+
if (!policy) return void 0;
|
|
818
|
+
switch (policy.kind) {
|
|
819
|
+
case "fixed": return policy.roles;
|
|
820
|
+
case "byProp": {
|
|
821
|
+
const raw = props[policy.prop];
|
|
822
|
+
const value = typeof raw === "string" && raw in policy.map ? raw : policy.fallback;
|
|
823
|
+
return policy.map[value];
|
|
824
|
+
}
|
|
825
|
+
case "dynamic": return policy.resolve({ props });
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
//#endregion
|
|
829
|
+
//#region ../core/src/html/spec/roles/input.ts
|
|
830
|
+
const ALLOWED_INPUT_ROLES = {
|
|
831
|
+
checkbox: [
|
|
832
|
+
"menuitemcheckbox",
|
|
833
|
+
"option",
|
|
834
|
+
"switch",
|
|
835
|
+
"button"
|
|
836
|
+
],
|
|
837
|
+
radio: ["menuitemradio"],
|
|
838
|
+
range: [],
|
|
839
|
+
number: [],
|
|
840
|
+
search: [],
|
|
841
|
+
text: [
|
|
842
|
+
"combobox",
|
|
843
|
+
"searchbox",
|
|
844
|
+
"spinbutton"
|
|
845
|
+
],
|
|
846
|
+
email: [],
|
|
847
|
+
tel: [],
|
|
848
|
+
url: [],
|
|
849
|
+
button: [
|
|
850
|
+
"checkbox",
|
|
851
|
+
"combobox",
|
|
852
|
+
"gridcell",
|
|
853
|
+
"link",
|
|
854
|
+
"menuitem",
|
|
855
|
+
"menuitemcheckbox",
|
|
856
|
+
"menuitemradio",
|
|
857
|
+
"option",
|
|
858
|
+
"radio",
|
|
859
|
+
"separator",
|
|
860
|
+
"slider",
|
|
861
|
+
"switch",
|
|
862
|
+
"tab",
|
|
863
|
+
"treeitem"
|
|
864
|
+
],
|
|
865
|
+
submit: [
|
|
866
|
+
"checkbox",
|
|
867
|
+
"combobox",
|
|
868
|
+
"gridcell",
|
|
869
|
+
"link",
|
|
870
|
+
"menuitem",
|
|
871
|
+
"menuitemcheckbox",
|
|
872
|
+
"menuitemradio",
|
|
873
|
+
"option",
|
|
874
|
+
"radio",
|
|
875
|
+
"separator",
|
|
876
|
+
"slider",
|
|
877
|
+
"switch",
|
|
878
|
+
"tab",
|
|
879
|
+
"treeitem"
|
|
880
|
+
],
|
|
881
|
+
reset: [
|
|
882
|
+
"checkbox",
|
|
883
|
+
"combobox",
|
|
884
|
+
"gridcell",
|
|
885
|
+
"link",
|
|
886
|
+
"menuitem",
|
|
887
|
+
"menuitemcheckbox",
|
|
888
|
+
"menuitemradio",
|
|
889
|
+
"option",
|
|
890
|
+
"radio",
|
|
891
|
+
"separator",
|
|
892
|
+
"slider",
|
|
893
|
+
"switch",
|
|
894
|
+
"tab",
|
|
895
|
+
"treeitem"
|
|
896
|
+
],
|
|
897
|
+
image: [
|
|
898
|
+
"checkbox",
|
|
899
|
+
"gridcell",
|
|
900
|
+
"link",
|
|
901
|
+
"menuitem",
|
|
902
|
+
"menuitemcheckbox",
|
|
903
|
+
"menuitemradio",
|
|
904
|
+
"option",
|
|
905
|
+
"radio",
|
|
906
|
+
"separator",
|
|
907
|
+
"slider",
|
|
908
|
+
"switch",
|
|
909
|
+
"tab",
|
|
910
|
+
"treeitem"
|
|
911
|
+
],
|
|
912
|
+
hidden: []
|
|
913
|
+
};
|
|
914
|
+
//#endregion
|
|
915
|
+
//#region ../core/src/html/spec/elements/input.ts
|
|
916
|
+
const LIST_ELIGIBLE_TYPES = /* @__PURE__ */ new Set([
|
|
917
|
+
"text",
|
|
918
|
+
"search",
|
|
919
|
+
"tel",
|
|
920
|
+
"url",
|
|
921
|
+
"email"
|
|
922
|
+
]);
|
|
923
|
+
function rolesForType(type) {
|
|
924
|
+
return ALLOWED_INPUT_ROLES[type in ALLOWED_INPUT_ROLES ? type : "text"];
|
|
925
|
+
}
|
|
926
|
+
const inputElementSpec = {
|
|
927
|
+
tag: "input",
|
|
928
|
+
allowedRoles: {
|
|
929
|
+
kind: "dynamic",
|
|
930
|
+
resolve: ({ props }) => {
|
|
931
|
+
const type = isString(props.type) ? props.type : "text";
|
|
932
|
+
if (isNonNull(props.list) && LIST_ELIGIBLE_TYPES.has(type)) return [];
|
|
933
|
+
return rolesForType(type);
|
|
934
|
+
}
|
|
935
|
+
},
|
|
936
|
+
attributes: INPUT_ATTRIBUTE_TYPE_POLICIES,
|
|
937
|
+
mutuallyExclusive: INPUT_MUTUALLY_EXCLUSIVE_POLICIES
|
|
938
|
+
};
|
|
939
|
+
//#endregion
|
|
940
|
+
//#region ../core/src/html/spec/roles/img.ts
|
|
941
|
+
const IMG_DECORATIVE_ROLES = ["none", "presentation"];
|
|
942
|
+
const IMG_NAMED_ROLES = [
|
|
943
|
+
"button",
|
|
944
|
+
"checkbox",
|
|
945
|
+
"link",
|
|
946
|
+
"math",
|
|
947
|
+
"menuitem",
|
|
948
|
+
"menuitemcheckbox",
|
|
949
|
+
"menuitemradio",
|
|
950
|
+
"meter",
|
|
951
|
+
"option",
|
|
952
|
+
"progressbar",
|
|
953
|
+
"radio",
|
|
954
|
+
"scrollbar",
|
|
955
|
+
"separator",
|
|
956
|
+
"slider",
|
|
957
|
+
"switch",
|
|
958
|
+
"tab",
|
|
959
|
+
"treeitem"
|
|
960
|
+
];
|
|
961
|
+
//#endregion
|
|
962
|
+
//#region ../core/src/html/spec/elements/img.ts
|
|
963
|
+
const imgElementSpec = {
|
|
964
|
+
tag: "img",
|
|
965
|
+
allowedRoles: {
|
|
966
|
+
kind: "dynamic",
|
|
967
|
+
resolve: ({ props }) => {
|
|
968
|
+
if (isNullish(props.alt)) return IMG_DECORATIVE_ROLES;
|
|
969
|
+
return props.alt === "" ? [] : IMG_NAMED_ROLES;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
};
|
|
973
|
+
//#endregion
|
|
974
|
+
//#region ../core/src/html/spec/roles/select.ts
|
|
975
|
+
const ALLOWED_SELECT_ROLES = ["menu"];
|
|
976
|
+
//#endregion
|
|
977
|
+
//#region ../core/src/html/spec/elements/select.ts
|
|
978
|
+
function isListBoxSelect(props) {
|
|
979
|
+
const { multiple, size } = props;
|
|
980
|
+
if (isNonNull(multiple) && multiple !== false && (!isString(multiple) || multiple.toLowerCase() !== "false")) return true;
|
|
981
|
+
const parsed = isNumber(size) ? size : isString(size) ? Number(size) : NaN;
|
|
982
|
+
return Number.isFinite(parsed) && parsed > 1;
|
|
983
|
+
}
|
|
984
|
+
const selectElementSpec = {
|
|
985
|
+
tag: "select",
|
|
986
|
+
allowedRoles: {
|
|
987
|
+
kind: "dynamic",
|
|
988
|
+
resolve: ({ props }) => isListBoxSelect(props) ? [] : ALLOWED_SELECT_ROLES
|
|
989
|
+
}
|
|
990
|
+
};
|
|
991
|
+
//#endregion
|
|
992
|
+
//#region ../core/src/html/spec/elements/table.ts
|
|
993
|
+
const tableElementSpec = {
|
|
994
|
+
tag: "table",
|
|
995
|
+
allowedRoles: {
|
|
996
|
+
kind: "fixed",
|
|
997
|
+
roles: ["grid", "treegrid"]
|
|
998
|
+
}
|
|
999
|
+
};
|
|
1000
|
+
//#endregion
|
|
1001
|
+
//#region ../core/src/html/role-restrictions.ts
|
|
1002
|
+
const ALLOWED_ROLES = {
|
|
1003
|
+
article: [
|
|
1004
|
+
"application",
|
|
1005
|
+
"document",
|
|
1006
|
+
"feed",
|
|
1007
|
+
"main",
|
|
1008
|
+
"none",
|
|
1009
|
+
"presentation",
|
|
1010
|
+
"region"
|
|
1011
|
+
],
|
|
1012
|
+
aside: [
|
|
1013
|
+
"feed",
|
|
1014
|
+
"none",
|
|
1015
|
+
"note",
|
|
1016
|
+
"presentation",
|
|
1017
|
+
"region",
|
|
1018
|
+
"search"
|
|
1019
|
+
],
|
|
1020
|
+
footer: [
|
|
1021
|
+
"group",
|
|
1022
|
+
"none",
|
|
1023
|
+
"presentation"
|
|
1024
|
+
],
|
|
1025
|
+
header: [
|
|
1026
|
+
"group",
|
|
1027
|
+
"none",
|
|
1028
|
+
"presentation"
|
|
1029
|
+
],
|
|
1030
|
+
main: [],
|
|
1031
|
+
nav: [
|
|
1032
|
+
"menu",
|
|
1033
|
+
"menubar",
|
|
1034
|
+
"none",
|
|
1035
|
+
"presentation",
|
|
1036
|
+
"tablist"
|
|
1037
|
+
],
|
|
1038
|
+
a: [
|
|
1039
|
+
"button",
|
|
1040
|
+
"checkbox",
|
|
1041
|
+
"menuitem",
|
|
1042
|
+
"menuitemcheckbox",
|
|
1043
|
+
"menuitemradio",
|
|
1044
|
+
"option",
|
|
1045
|
+
"radio",
|
|
1046
|
+
"switch",
|
|
1047
|
+
"tab",
|
|
1048
|
+
"treeitem"
|
|
1049
|
+
],
|
|
1050
|
+
button: [
|
|
1051
|
+
"checkbox",
|
|
1052
|
+
"combobox",
|
|
1053
|
+
"gridcell",
|
|
1054
|
+
"link",
|
|
1055
|
+
"menuitem",
|
|
1056
|
+
"menuitemcheckbox",
|
|
1057
|
+
"menuitemradio",
|
|
1058
|
+
"option",
|
|
1059
|
+
"radio",
|
|
1060
|
+
"separator",
|
|
1061
|
+
"slider",
|
|
1062
|
+
"switch",
|
|
1063
|
+
"tab",
|
|
1064
|
+
"treeitem"
|
|
1065
|
+
],
|
|
1066
|
+
h1: [
|
|
1067
|
+
"tab",
|
|
1068
|
+
"presentation",
|
|
1069
|
+
"none"
|
|
1070
|
+
],
|
|
1071
|
+
h2: [
|
|
1072
|
+
"tab",
|
|
1073
|
+
"presentation",
|
|
1074
|
+
"none"
|
|
1075
|
+
],
|
|
1076
|
+
h3: [
|
|
1077
|
+
"tab",
|
|
1078
|
+
"presentation",
|
|
1079
|
+
"none"
|
|
1080
|
+
],
|
|
1081
|
+
h4: [
|
|
1082
|
+
"tab",
|
|
1083
|
+
"presentation",
|
|
1084
|
+
"none"
|
|
1085
|
+
],
|
|
1086
|
+
h5: [
|
|
1087
|
+
"tab",
|
|
1088
|
+
"presentation",
|
|
1089
|
+
"none"
|
|
1090
|
+
],
|
|
1091
|
+
h6: [
|
|
1092
|
+
"tab",
|
|
1093
|
+
"presentation",
|
|
1094
|
+
"none"
|
|
1095
|
+
],
|
|
1096
|
+
ul: [
|
|
1097
|
+
"group",
|
|
1098
|
+
"listbox",
|
|
1099
|
+
"menu",
|
|
1100
|
+
"menubar",
|
|
1101
|
+
"none",
|
|
1102
|
+
"presentation",
|
|
1103
|
+
"radiogroup",
|
|
1104
|
+
"tablist",
|
|
1105
|
+
"toolbar",
|
|
1106
|
+
"tree"
|
|
1107
|
+
],
|
|
1108
|
+
ol: [
|
|
1109
|
+
"group",
|
|
1110
|
+
"listbox",
|
|
1111
|
+
"menu",
|
|
1112
|
+
"menubar",
|
|
1113
|
+
"none",
|
|
1114
|
+
"presentation",
|
|
1115
|
+
"radiogroup",
|
|
1116
|
+
"tablist",
|
|
1117
|
+
"toolbar",
|
|
1118
|
+
"tree"
|
|
1119
|
+
],
|
|
1120
|
+
li: [
|
|
1121
|
+
"menuitem",
|
|
1122
|
+
"menuitemcheckbox",
|
|
1123
|
+
"menuitemradio",
|
|
1124
|
+
"option",
|
|
1125
|
+
"none",
|
|
1126
|
+
"presentation",
|
|
1127
|
+
"radio",
|
|
1128
|
+
"separator",
|
|
1129
|
+
"tab",
|
|
1130
|
+
"treeitem"
|
|
1131
|
+
],
|
|
1132
|
+
dialog: ["alertdialog"],
|
|
1133
|
+
fieldset: [
|
|
1134
|
+
"none",
|
|
1135
|
+
"presentation",
|
|
1136
|
+
"radiogroup"
|
|
1137
|
+
],
|
|
1138
|
+
label: []
|
|
1139
|
+
};
|
|
1140
|
+
const ELEMENT_SPECS = {
|
|
1141
|
+
input: inputElementSpec,
|
|
1142
|
+
img: imgElementSpec,
|
|
1143
|
+
select: selectElementSpec,
|
|
1144
|
+
table: tableElementSpec
|
|
1145
|
+
};
|
|
1146
|
+
function getAllowedRoles(tag, props) {
|
|
1147
|
+
const spec = ELEMENT_SPECS[tag];
|
|
1148
|
+
if (spec) return resolveAllowedRoles(spec, props);
|
|
1149
|
+
return ALLOWED_ROLES[tag];
|
|
1150
|
+
}
|
|
1151
|
+
const removeRoleFix = {
|
|
1152
|
+
kind: "removeRole",
|
|
1153
|
+
apply: ({ props }) => {
|
|
1154
|
+
if (!("role" in props)) return {
|
|
1155
|
+
applied: false,
|
|
1156
|
+
next: props
|
|
1157
|
+
};
|
|
1158
|
+
const { role: _role, ...rest } = props;
|
|
1159
|
+
return {
|
|
1160
|
+
applied: true,
|
|
1161
|
+
next: rest,
|
|
1162
|
+
previous: props
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
};
|
|
1166
|
+
const roleNotPermittedRule = Object.assign(({ tag, props, implicitRole }) => {
|
|
1167
|
+
const role = props.role;
|
|
1168
|
+
if (typeof role !== "string" || role.length === 0 || role === implicitRole) return [];
|
|
1169
|
+
const allowed = getAllowedRoles(tag, props);
|
|
1170
|
+
if (allowed === void 0 || allowed.includes(role)) return [];
|
|
1171
|
+
const diagnostic = HtmlDiagnostics.roleNotPermitted(tag, role, allowed);
|
|
1172
|
+
return [{
|
|
1173
|
+
valid: false,
|
|
1174
|
+
fixable: true,
|
|
1175
|
+
severity: diagnostic.severity,
|
|
1176
|
+
fix: removeRoleFix,
|
|
1177
|
+
diagnostic
|
|
1178
|
+
}];
|
|
1179
|
+
}, { readsProps: [
|
|
1180
|
+
"role",
|
|
1181
|
+
"type",
|
|
1182
|
+
"alt",
|
|
1183
|
+
"list",
|
|
1184
|
+
"multiple",
|
|
1185
|
+
"size"
|
|
1186
|
+
] });
|
|
1187
|
+
//#endregion
|
|
1188
|
+
//#region ../core/src/html/aria-rules.ts
|
|
1189
|
+
function defineAriaRule(tags, rule) {
|
|
1190
|
+
return Object.assign(rule, { tags });
|
|
1191
|
+
}
|
|
1192
|
+
const LANDMARK_TAG_SET = new Set(LANDMARK_TAGS);
|
|
1193
|
+
const removeLandmarkRoleOverride = {
|
|
1194
|
+
kind: "removeRole",
|
|
1195
|
+
apply: ({ props }) => {
|
|
1196
|
+
if (!("role" in props)) return {
|
|
1197
|
+
applied: false,
|
|
1198
|
+
next: props
|
|
1199
|
+
};
|
|
1200
|
+
const { role: _r, ...rest } = props;
|
|
1201
|
+
return {
|
|
1202
|
+
applied: true,
|
|
1203
|
+
next: rest,
|
|
1204
|
+
previous: props
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
1207
|
+
};
|
|
1208
|
+
const landmarkRoleRule = defineAriaRule(LANDMARK_TAGS, ({ tag, props, implicitRole }) => {
|
|
1209
|
+
if (!LANDMARK_TAG_SET.has(tag) || !implicitRole) return [];
|
|
1210
|
+
const { role } = props;
|
|
1211
|
+
if (!role || role === implicitRole) return [];
|
|
1212
|
+
const diagnostic = HtmlDiagnostics.landmarkRoleOverride(tag, implicitRole, role);
|
|
1213
|
+
return [{
|
|
1214
|
+
valid: false,
|
|
1215
|
+
fixable: true,
|
|
1216
|
+
severity: diagnostic.severity,
|
|
1217
|
+
fix: removeLandmarkRoleOverride,
|
|
1218
|
+
diagnostic
|
|
1219
|
+
}];
|
|
1220
|
+
});
|
|
1221
|
+
function requireAccessibleName({ tag, props }) {
|
|
1222
|
+
if ("aria-label" in props || "aria-labelledby" in props) return [];
|
|
1223
|
+
return [{
|
|
1224
|
+
valid: false,
|
|
1225
|
+
fixable: false,
|
|
1226
|
+
severity: "warning",
|
|
1227
|
+
diagnostic: AriaDiagnostics.missingAccessibleName(tag)
|
|
1228
|
+
}];
|
|
1229
|
+
}
|
|
1230
|
+
const NAMED_LANDMARK_TAGS = ["nav", "aside"];
|
|
1231
|
+
const NAMED_LANDMARK_TAG_SET = new Set(NAMED_LANDMARK_TAGS);
|
|
1232
|
+
const landmarkAccessibleNameRule = defineAriaRule(NAMED_LANDMARK_TAGS, (ctx) => {
|
|
1233
|
+
if (!ctx.implicitRole || !NAMED_LANDMARK_TAG_SET.has(ctx.tag)) return [];
|
|
1234
|
+
return requireAccessibleName(ctx);
|
|
1235
|
+
});
|
|
1236
|
+
const HTML_ARIA_RULES = [
|
|
1237
|
+
landmarkRoleRule,
|
|
1238
|
+
landmarkAccessibleNameRule,
|
|
1239
|
+
roleNotPermittedRule,
|
|
1240
|
+
...INPUT_RULES,
|
|
1241
|
+
...ANCHOR_RULES
|
|
1242
|
+
];
|
|
1243
|
+
//#endregion
|
|
1244
|
+
export { ANCHOR_RULES, HTML_ARIA_RULES, INPUT_RULES, acceptRequiresFileTypeRule, altRequiresImageTypeRule, ariaDisabledInertRule, captureRequiresFileTypeRule, checkedRequiresCheckableTypeRule, dangerousHrefRule, heightRequiresImageTypeRule, inputAccessibleNameRule, landmarkAccessibleNameRule, landmarkRoleRule, maxLengthRequiresTextTypeRule, maxRequiresNumericTypeRule, minLengthRequiresTextTypeRule, minRequiresNumericTypeRule, multipleRequiresSupportedTypeRule, passwordAutocompleteRule, patternRequiresTextTypeRule, requireAccessibleName, requiredReadOnlyConflictRule, roleButtonWithHrefRule, roleNotPermittedRule, sizeRequiresTextTypeRule, stepRequiresNumericTypeRule, supportedInputTypeRule, widthRequiresImageTypeRule };
|