@jay-framework/a11y-validator 0.24.2 → 0.24.4
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/dist/index.d.ts +1 -4
- package/dist/index.js +1 -466
- package/dist/tools.d.ts +5 -0
- package/dist/tools.js +466 -0
- package/package.json +8 -6
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,466 +1 @@
|
|
|
1
|
-
|
|
2
|
-
const A11Y_GUIDE = "\nSee: agent-kit/designer/a11y-patterns.md";
|
|
3
|
-
function pushFinding(findings, finding) {
|
|
4
|
-
if (finding.suggestion) {
|
|
5
|
-
finding.suggestion += A11Y_GUIDE;
|
|
6
|
-
}
|
|
7
|
-
findings.push(finding);
|
|
8
|
-
}
|
|
9
|
-
const INTERACTIVE_ELEMENTS = /* @__PURE__ */ new Set(["a", "button", "input", "select", "textarea"]);
|
|
10
|
-
const NON_INTERACTIVE_ELEMENTS = /* @__PURE__ */ new Set([
|
|
11
|
-
"div",
|
|
12
|
-
"span",
|
|
13
|
-
"p",
|
|
14
|
-
"section",
|
|
15
|
-
"article",
|
|
16
|
-
"header",
|
|
17
|
-
"footer",
|
|
18
|
-
"main",
|
|
19
|
-
"nav",
|
|
20
|
-
"aside",
|
|
21
|
-
"li",
|
|
22
|
-
"ul",
|
|
23
|
-
"ol"
|
|
24
|
-
]);
|
|
25
|
-
const VALID_ARIA_ROLES = /* @__PURE__ */ new Set([
|
|
26
|
-
"alert",
|
|
27
|
-
"alertdialog",
|
|
28
|
-
"application",
|
|
29
|
-
"article",
|
|
30
|
-
"banner",
|
|
31
|
-
"button",
|
|
32
|
-
"cell",
|
|
33
|
-
"checkbox",
|
|
34
|
-
"columnheader",
|
|
35
|
-
"combobox",
|
|
36
|
-
"complementary",
|
|
37
|
-
"contentinfo",
|
|
38
|
-
"definition",
|
|
39
|
-
"dialog",
|
|
40
|
-
"directory",
|
|
41
|
-
"document",
|
|
42
|
-
"feed",
|
|
43
|
-
"figure",
|
|
44
|
-
"form",
|
|
45
|
-
"grid",
|
|
46
|
-
"gridcell",
|
|
47
|
-
"group",
|
|
48
|
-
"heading",
|
|
49
|
-
"img",
|
|
50
|
-
"link",
|
|
51
|
-
"list",
|
|
52
|
-
"listbox",
|
|
53
|
-
"listitem",
|
|
54
|
-
"log",
|
|
55
|
-
"main",
|
|
56
|
-
"marquee",
|
|
57
|
-
"math",
|
|
58
|
-
"menu",
|
|
59
|
-
"menubar",
|
|
60
|
-
"menuitem",
|
|
61
|
-
"menuitemcheckbox",
|
|
62
|
-
"menuitemradio",
|
|
63
|
-
"meter",
|
|
64
|
-
"navigation",
|
|
65
|
-
"none",
|
|
66
|
-
"note",
|
|
67
|
-
"option",
|
|
68
|
-
"presentation",
|
|
69
|
-
"progressbar",
|
|
70
|
-
"radio",
|
|
71
|
-
"radiogroup",
|
|
72
|
-
"region",
|
|
73
|
-
"row",
|
|
74
|
-
"rowgroup",
|
|
75
|
-
"rowheader",
|
|
76
|
-
"scrollbar",
|
|
77
|
-
"search",
|
|
78
|
-
"searchbox",
|
|
79
|
-
"separator",
|
|
80
|
-
"slider",
|
|
81
|
-
"spinbutton",
|
|
82
|
-
"status",
|
|
83
|
-
"switch",
|
|
84
|
-
"tab",
|
|
85
|
-
"table",
|
|
86
|
-
"tablist",
|
|
87
|
-
"tabpanel",
|
|
88
|
-
"term",
|
|
89
|
-
"textbox",
|
|
90
|
-
"timer",
|
|
91
|
-
"toolbar",
|
|
92
|
-
"tooltip",
|
|
93
|
-
"tree",
|
|
94
|
-
"treegrid",
|
|
95
|
-
"treeitem"
|
|
96
|
-
]);
|
|
97
|
-
const INTERACTIVE_CONTAINER_ROLES = /* @__PURE__ */ new Set(["button", "link"]);
|
|
98
|
-
const WIDGET_ROLES = /* @__PURE__ */ new Set([
|
|
99
|
-
"button",
|
|
100
|
-
"link",
|
|
101
|
-
"checkbox",
|
|
102
|
-
"radio",
|
|
103
|
-
"switch",
|
|
104
|
-
"tab",
|
|
105
|
-
"menuitem",
|
|
106
|
-
"option",
|
|
107
|
-
"textbox"
|
|
108
|
-
]);
|
|
109
|
-
const FOCUSABLE_ELEMENTS = /* @__PURE__ */ new Set(["button", "select", "textarea", "summary"]);
|
|
110
|
-
const LABELABLE_INPUTS = /* @__PURE__ */ new Set([
|
|
111
|
-
"text",
|
|
112
|
-
"password",
|
|
113
|
-
"email",
|
|
114
|
-
"tel",
|
|
115
|
-
"url",
|
|
116
|
-
"number",
|
|
117
|
-
"search",
|
|
118
|
-
"date",
|
|
119
|
-
"time",
|
|
120
|
-
"datetime-local",
|
|
121
|
-
"month",
|
|
122
|
-
"week",
|
|
123
|
-
"color",
|
|
124
|
-
"file",
|
|
125
|
-
"range",
|
|
126
|
-
"checkbox",
|
|
127
|
-
"radio"
|
|
128
|
-
]);
|
|
129
|
-
const IGNORED_INPUT_TYPES = /* @__PURE__ */ new Set(["hidden", "submit", "button", "reset"]);
|
|
130
|
-
const validate = (ctx) => {
|
|
131
|
-
const findings = [];
|
|
132
|
-
const labelForIds = /* @__PURE__ */ new Set();
|
|
133
|
-
const allIds = /* @__PURE__ */ new Set();
|
|
134
|
-
const idCounts = /* @__PURE__ */ new Map();
|
|
135
|
-
collectDomIndex(ctx.body, labelForIds, allIds, idCounts);
|
|
136
|
-
for (const [id, count] of idCounts) {
|
|
137
|
-
if (count > 1) {
|
|
138
|
-
pushFinding(findings, {
|
|
139
|
-
severity: "error",
|
|
140
|
-
message: `Duplicate id="${id}" used ${count} times (WCAG 4.1.1)`,
|
|
141
|
-
suggestion: "Give each element a unique id. Duplicate ids break label associations and ARIA references.",
|
|
142
|
-
attribute: "id"
|
|
143
|
-
});
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
checkLabelsStructure(ctx.body, allIds, findings);
|
|
147
|
-
walkElements(ctx.body, ctx, (el) => {
|
|
148
|
-
const tag = el.rawTagName?.toLowerCase();
|
|
149
|
-
if (!tag) return;
|
|
150
|
-
if (tag === "img") {
|
|
151
|
-
const alt = el.getAttribute?.("alt");
|
|
152
|
-
if (alt === void 0 || alt === null) {
|
|
153
|
-
pushFinding(findings, {
|
|
154
|
-
severity: "error",
|
|
155
|
-
message: "Image missing alt attribute (WCAG 1.1.1)",
|
|
156
|
-
suggestion: 'Add an alt attribute. Use descriptive text for informative images, or alt="" for purely decorative images.',
|
|
157
|
-
element: "<img>",
|
|
158
|
-
attribute: "alt"
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
if (tag === "input") {
|
|
163
|
-
const type = (el.getAttribute?.("type") || "text").toLowerCase();
|
|
164
|
-
if (IGNORED_INPUT_TYPES.has(type)) {
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
if (!LABELABLE_INPUTS.has(type)) return;
|
|
168
|
-
checkLabel(el, tag, findings, labelForIds, allIds);
|
|
169
|
-
}
|
|
170
|
-
if (tag === "select" || tag === "textarea") {
|
|
171
|
-
checkLabel(el, tag, findings, labelForIds, allIds);
|
|
172
|
-
}
|
|
173
|
-
if (tag === "button") {
|
|
174
|
-
const text = el.textContent?.trim();
|
|
175
|
-
const ariaLabel = el.getAttribute?.("aria-label");
|
|
176
|
-
const ariaLabelledBy = el.getAttribute?.("aria-labelledby");
|
|
177
|
-
const hasImg = el.querySelector?.("img[alt]");
|
|
178
|
-
if (!text && !ariaLabel && !ariaLabelledBy && !hasImg) {
|
|
179
|
-
pushFinding(findings, {
|
|
180
|
-
severity: "error",
|
|
181
|
-
message: "Button has no accessible name (WCAG 4.1.2)",
|
|
182
|
-
suggestion: "Add text content, an aria-label, or an aria-labelledby attribute to the button.",
|
|
183
|
-
element: "<button>"
|
|
184
|
-
});
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
if (INTERACTIVE_ELEMENTS.has(tag) || el.getAttribute?.("role")) {
|
|
188
|
-
const tabindex = el.getAttribute?.("tabindex");
|
|
189
|
-
if (tabindex !== void 0 && tabindex !== null) {
|
|
190
|
-
const val = parseInt(tabindex, 10);
|
|
191
|
-
if (!isNaN(val) && val > 0) {
|
|
192
|
-
pushFinding(findings, {
|
|
193
|
-
severity: "warning",
|
|
194
|
-
message: `Positive tabindex="${tabindex}" disrupts natural tab order (WCAG 2.4.3)`,
|
|
195
|
-
suggestion: 'Use tabindex="0" to add to natural tab order, or tabindex="-1" for programmatic focus. Avoid positive values — they override the DOM order and confuse keyboard users.',
|
|
196
|
-
element: `<${tag}>`,
|
|
197
|
-
attribute: "tabindex"
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
if (tag === "video" || tag === "audio") {
|
|
203
|
-
const autoplay = el.getAttribute?.("autoplay");
|
|
204
|
-
if (autoplay !== void 0 && autoplay !== null) {
|
|
205
|
-
const muted = el.getAttribute?.("muted");
|
|
206
|
-
if (muted === void 0 || muted === null) {
|
|
207
|
-
pushFinding(findings, {
|
|
208
|
-
severity: "error",
|
|
209
|
-
message: `<${tag}> has autoplay without muted (WCAG 1.4.2)`,
|
|
210
|
-
suggestion: `Add the muted attribute to <${tag} autoplay>, or remove autoplay. Autoplaying audio is disruptive to screen reader users.`,
|
|
211
|
-
element: `<${tag}>`,
|
|
212
|
-
attribute: "autoplay"
|
|
213
|
-
});
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
const role = el.getAttribute?.("role");
|
|
218
|
-
if (role !== void 0 && role !== null) {
|
|
219
|
-
if (!VALID_ARIA_ROLES.has(role)) {
|
|
220
|
-
pushFinding(findings, {
|
|
221
|
-
severity: "error",
|
|
222
|
-
message: `Invalid ARIA role="${role}" (WCAG 4.1.2)`,
|
|
223
|
-
suggestion: `"${role}" is not a valid WAI-ARIA role. Use a valid role such as "button", "link", "navigation", "dialog", etc.`,
|
|
224
|
-
element: `<${tag}>`,
|
|
225
|
-
attribute: "role"
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
if (NON_INTERACTIVE_ELEMENTS.has(tag)) {
|
|
230
|
-
const tabindex = el.getAttribute?.("tabindex");
|
|
231
|
-
if (tabindex !== void 0 && tabindex !== null) {
|
|
232
|
-
const val = parseInt(tabindex, 10);
|
|
233
|
-
if (!isNaN(val) && val >= 0 && !role) {
|
|
234
|
-
pushFinding(findings, {
|
|
235
|
-
severity: "warning",
|
|
236
|
-
message: `<${tag}> is focusable via tabindex but has no role (WCAG 4.1.2)`,
|
|
237
|
-
suggestion: `Add a role attribute to indicate the element's purpose to screen readers. Example: <div tabindex="0" role="button"> or <span tabindex="0" role="link">.`,
|
|
238
|
-
element: `<${tag}>`,
|
|
239
|
-
attribute: "role"
|
|
240
|
-
});
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
});
|
|
245
|
-
checkDuplicateAdjacentText(ctx.body, findings);
|
|
246
|
-
checkNestedInteractive(ctx.body, findings);
|
|
247
|
-
if (ctx.head) {
|
|
248
|
-
const viewport = ctx.head.meta.find((m) => m.name?.toLowerCase() === "viewport");
|
|
249
|
-
if (viewport) {
|
|
250
|
-
const content = viewport.content.map((p) => p.value).join("").toLowerCase();
|
|
251
|
-
if (/user-scalable\s*=\s*no/.test(content)) {
|
|
252
|
-
pushFinding(findings, {
|
|
253
|
-
severity: "error",
|
|
254
|
-
message: "Viewport meta disables user scaling (WCAG 1.4.4)",
|
|
255
|
-
suggestion: "Remove user-scalable=no from the viewport meta tag. Users must be able to zoom to at least 200%.",
|
|
256
|
-
element: "<meta>",
|
|
257
|
-
attribute: "content"
|
|
258
|
-
});
|
|
259
|
-
}
|
|
260
|
-
const maxScaleMatch = content.match(/maximum-scale\s*=\s*([\d.]+)/);
|
|
261
|
-
if (maxScaleMatch && parseFloat(maxScaleMatch[1]) < 2) {
|
|
262
|
-
pushFinding(findings, {
|
|
263
|
-
severity: "error",
|
|
264
|
-
message: `Viewport meta restricts zoom to ${maxScaleMatch[1]}x (WCAG 1.4.4)`,
|
|
265
|
-
suggestion: "Set maximum-scale to at least 2, or remove it entirely. Users must be able to zoom to at least 200%.",
|
|
266
|
-
element: "<meta>",
|
|
267
|
-
attribute: "content"
|
|
268
|
-
});
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
return findings;
|
|
273
|
-
};
|
|
274
|
-
function checkLabel(el, tag, findings, labelForIds, allIds) {
|
|
275
|
-
const id = el.getAttribute?.("id");
|
|
276
|
-
const ariaLabel = el.getAttribute?.("aria-label");
|
|
277
|
-
const ariaLabelledBy = el.getAttribute?.("aria-labelledby");
|
|
278
|
-
let hasAccessibleName = false;
|
|
279
|
-
if (ariaLabel !== void 0 && ariaLabel !== null) {
|
|
280
|
-
if (!String(ariaLabel).trim()) {
|
|
281
|
-
pushFinding(findings, {
|
|
282
|
-
severity: "error",
|
|
283
|
-
message: `<${tag}> has empty aria-label (WCAG 4.1.2)`,
|
|
284
|
-
suggestion: "Provide a non-empty aria-label, use aria-labelledby with an existing id, or associate a <label>.",
|
|
285
|
-
element: `<${tag}>`,
|
|
286
|
-
attribute: "aria-label"
|
|
287
|
-
});
|
|
288
|
-
} else {
|
|
289
|
-
hasAccessibleName = true;
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
if (ariaLabelledBy !== void 0 && ariaLabelledBy !== null) {
|
|
293
|
-
const tokens = String(ariaLabelledBy).trim().split(/\s+/).filter(Boolean);
|
|
294
|
-
if (tokens.length === 0) {
|
|
295
|
-
pushFinding(findings, {
|
|
296
|
-
severity: "error",
|
|
297
|
-
message: `<${tag}> has empty aria-labelledby (WCAG 4.1.2)`,
|
|
298
|
-
suggestion: "Set aria-labelledby to one or more element ids that exist in this file, or use a non-empty aria-label / <label>.",
|
|
299
|
-
element: `<${tag}>`,
|
|
300
|
-
attribute: "aria-labelledby"
|
|
301
|
-
});
|
|
302
|
-
} else {
|
|
303
|
-
const missing = tokens.filter((token) => !allIds.has(token));
|
|
304
|
-
if (missing.length > 0) {
|
|
305
|
-
pushFinding(findings, {
|
|
306
|
-
severity: "error",
|
|
307
|
-
message: `<${tag}> aria-labelledby references missing id(s): ${missing.join(", ")} (WCAG 1.3.1)`,
|
|
308
|
-
suggestion: `Add element(s) with id="${missing[0]}" (or fix the aria-labelledby tokens), or use a <label for="..."> / non-empty aria-label instead.`,
|
|
309
|
-
element: `<${tag}>`,
|
|
310
|
-
attribute: "aria-labelledby"
|
|
311
|
-
});
|
|
312
|
-
} else {
|
|
313
|
-
hasAccessibleName = true;
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
if (hasAccessibleName) return;
|
|
318
|
-
if (id && labelForIds.has(id)) return;
|
|
319
|
-
let parent = el.parentNode;
|
|
320
|
-
while (parent) {
|
|
321
|
-
if (parent.rawTagName?.toLowerCase() === "label") return;
|
|
322
|
-
parent = parent.parentNode;
|
|
323
|
-
}
|
|
324
|
-
pushFinding(findings, {
|
|
325
|
-
severity: "error",
|
|
326
|
-
message: `<${tag}> has no associated label (WCAG 1.3.1)`,
|
|
327
|
-
suggestion: `Add a <label for="${id || "inputId"}"> that references this ${tag}'s id, wrap it in a <label>, or add an aria-label attribute.`,
|
|
328
|
-
element: `<${tag}>`,
|
|
329
|
-
attribute: "id"
|
|
330
|
-
});
|
|
331
|
-
}
|
|
332
|
-
function collectDomIndex(el, labelForIds, allIds, idCounts) {
|
|
333
|
-
const id = el.getAttribute?.("id");
|
|
334
|
-
if (id) {
|
|
335
|
-
allIds.add(id);
|
|
336
|
-
idCounts.set(id, (idCounts.get(id) ?? 0) + 1);
|
|
337
|
-
}
|
|
338
|
-
if (el.rawTagName?.toLowerCase() === "label") {
|
|
339
|
-
const forId = el.getAttribute?.("for");
|
|
340
|
-
if (forId) labelForIds.add(forId);
|
|
341
|
-
}
|
|
342
|
-
for (const child of el.childNodes ?? []) {
|
|
343
|
-
if (child.nodeType === 1) collectDomIndex(child, labelForIds, allIds, idCounts);
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
function isLabelableControl(el) {
|
|
347
|
-
const tag = el.rawTagName?.toLowerCase();
|
|
348
|
-
if (tag === "select" || tag === "textarea") return true;
|
|
349
|
-
if (tag !== "input") return false;
|
|
350
|
-
const type = (el.getAttribute?.("type") || "text").toLowerCase();
|
|
351
|
-
if (IGNORED_INPUT_TYPES.has(type)) return false;
|
|
352
|
-
return LABELABLE_INPUTS.has(type);
|
|
353
|
-
}
|
|
354
|
-
function countLabelableDescendants(el) {
|
|
355
|
-
let count = 0;
|
|
356
|
-
for (const child of el.childNodes ?? []) {
|
|
357
|
-
if (child.nodeType !== 1) continue;
|
|
358
|
-
if (isLabelableControl(child)) count += 1;
|
|
359
|
-
count += countLabelableDescendants(child);
|
|
360
|
-
}
|
|
361
|
-
return count;
|
|
362
|
-
}
|
|
363
|
-
function checkLabelsStructure(root, allIds, findings) {
|
|
364
|
-
function walk(el) {
|
|
365
|
-
if (el.rawTagName?.toLowerCase() === "label") {
|
|
366
|
-
const forId = el.getAttribute?.("for");
|
|
367
|
-
if (forId && !allIds.has(forId)) {
|
|
368
|
-
pushFinding(findings, {
|
|
369
|
-
severity: "warning",
|
|
370
|
-
message: `<label for="${forId}"> has no matching id in this file (WCAG 1.3.1)`,
|
|
371
|
-
suggestion: `Add id="${forId}" to the related form control, or fix the for attribute.`,
|
|
372
|
-
element: "<label>",
|
|
373
|
-
attribute: "for"
|
|
374
|
-
});
|
|
375
|
-
}
|
|
376
|
-
const controlCount = countLabelableDescendants(el);
|
|
377
|
-
if (controlCount > 1) {
|
|
378
|
-
pushFinding(findings, {
|
|
379
|
-
severity: "warning",
|
|
380
|
-
message: `<label> contains ${controlCount} form controls — screen readers only associate the first (WCAG 1.3.1)`,
|
|
381
|
-
suggestion: 'Use a separate <label for="id"> for each input (or one wrapping label per control). multiple form controls inside one label is not reliable.',
|
|
382
|
-
element: "<label>"
|
|
383
|
-
});
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
for (const child of el.childNodes ?? []) {
|
|
387
|
-
if (child.nodeType === 1) walk(child);
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
walk(root);
|
|
391
|
-
}
|
|
392
|
-
function hasHref(el) {
|
|
393
|
-
const href = el.getAttribute?.("href");
|
|
394
|
-
return href !== void 0 && href !== null;
|
|
395
|
-
}
|
|
396
|
-
function isInteractiveContainer(el) {
|
|
397
|
-
const role = el.getAttribute?.("role")?.toLowerCase();
|
|
398
|
-
if (role) return INTERACTIVE_CONTAINER_ROLES.has(role);
|
|
399
|
-
const tag = el.rawTagName?.toLowerCase();
|
|
400
|
-
if (tag === "button") return true;
|
|
401
|
-
if (tag === "a") return hasHref(el);
|
|
402
|
-
return false;
|
|
403
|
-
}
|
|
404
|
-
function isFocusable(el) {
|
|
405
|
-
const role = el.getAttribute?.("role")?.toLowerCase();
|
|
406
|
-
if (role && WIDGET_ROLES.has(role)) return true;
|
|
407
|
-
const tag = el.rawTagName?.toLowerCase();
|
|
408
|
-
if (tag === "a") return hasHref(el);
|
|
409
|
-
if (tag === "input") return (el.getAttribute?.("type") || "text").toLowerCase() !== "hidden";
|
|
410
|
-
if (tag && FOCUSABLE_ELEMENTS.has(tag)) return true;
|
|
411
|
-
const tabindex = el.getAttribute?.("tabindex");
|
|
412
|
-
if (tabindex !== void 0 && tabindex !== null) {
|
|
413
|
-
const val = parseInt(tabindex, 10);
|
|
414
|
-
if (!isNaN(val) && val >= 0) return true;
|
|
415
|
-
}
|
|
416
|
-
return false;
|
|
417
|
-
}
|
|
418
|
-
function checkNestedInteractive(root, findings) {
|
|
419
|
-
function walk(el, ancestorTag) {
|
|
420
|
-
const tag = el.rawTagName?.toLowerCase();
|
|
421
|
-
if (tag && ancestorTag && isFocusable(el)) {
|
|
422
|
-
pushFinding(findings, {
|
|
423
|
-
severity: "error",
|
|
424
|
-
message: `Interactive <${tag}> is nested inside <${ancestorTag}> (WCAG 4.1.2)`,
|
|
425
|
-
suggestion: `Interactive elements cannot be nested — browsers restructure the DOM and screen readers announce an ambiguous control. Move the <${tag}> outside the <${ancestorTag}>, or make the outer element a non-interactive container such as <div>.`,
|
|
426
|
-
element: `<${tag}>`
|
|
427
|
-
});
|
|
428
|
-
}
|
|
429
|
-
const childAncestor = isInteractiveContainer(el) ? tag ?? ancestorTag : ancestorTag;
|
|
430
|
-
for (const child of el.childNodes ?? []) {
|
|
431
|
-
if (child.nodeType === 1) walk(child, childAncestor);
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
walk(root, void 0);
|
|
435
|
-
}
|
|
436
|
-
function getVisibleText(el) {
|
|
437
|
-
if (el.getAttribute?.("aria-hidden") === "true") return "";
|
|
438
|
-
return (el.textContent ?? "").trim().replace(/\s+/g, " ");
|
|
439
|
-
}
|
|
440
|
-
function checkDuplicateAdjacentText(root, findings) {
|
|
441
|
-
function walk(el) {
|
|
442
|
-
const children = (el.childNodes ?? []).filter((n) => n.nodeType === 1);
|
|
443
|
-
for (let i = 0; i < children.length - 1; i++) {
|
|
444
|
-
const current = children[i];
|
|
445
|
-
const next = children[i + 1];
|
|
446
|
-
const currentText = getVisibleText(current);
|
|
447
|
-
const nextText = getVisibleText(next);
|
|
448
|
-
if (currentText && nextText && currentText === nextText && current.getAttribute?.("aria-hidden") !== "true" && next.getAttribute?.("aria-hidden") !== "true") {
|
|
449
|
-
const tag = next.rawTagName?.toLowerCase() || "element";
|
|
450
|
-
pushFinding(findings, {
|
|
451
|
-
severity: "warning",
|
|
452
|
-
message: `Adjacent <${current.rawTagName?.toLowerCase()}> and <${tag}> have identical text "${currentText.slice(0, 40)}${currentText.length > 40 ? "..." : ""}" — screen readers will announce it twice`,
|
|
453
|
-
suggestion: 'Add aria-hidden="true" to the decorative duplicate. If both are meaningful, differentiate their text content.',
|
|
454
|
-
element: `<${tag}>`
|
|
455
|
-
});
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
for (const child of children) {
|
|
459
|
-
walk(child);
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
walk(root);
|
|
463
|
-
}
|
|
464
|
-
export {
|
|
465
|
-
validate
|
|
466
|
-
};
|
|
1
|
+
|
package/dist/tools.d.ts
ADDED
package/dist/tools.js
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import { walkElements } from "@jay-framework/compiler-shared";
|
|
2
|
+
const A11Y_GUIDE = "\nSee: agent-kit/designer/a11y-patterns.md";
|
|
3
|
+
function pushFinding(findings, finding) {
|
|
4
|
+
if (finding.suggestion) {
|
|
5
|
+
finding.suggestion += A11Y_GUIDE;
|
|
6
|
+
}
|
|
7
|
+
findings.push(finding);
|
|
8
|
+
}
|
|
9
|
+
const INTERACTIVE_ELEMENTS = /* @__PURE__ */ new Set(["a", "button", "input", "select", "textarea"]);
|
|
10
|
+
const NON_INTERACTIVE_ELEMENTS = /* @__PURE__ */ new Set([
|
|
11
|
+
"div",
|
|
12
|
+
"span",
|
|
13
|
+
"p",
|
|
14
|
+
"section",
|
|
15
|
+
"article",
|
|
16
|
+
"header",
|
|
17
|
+
"footer",
|
|
18
|
+
"main",
|
|
19
|
+
"nav",
|
|
20
|
+
"aside",
|
|
21
|
+
"li",
|
|
22
|
+
"ul",
|
|
23
|
+
"ol"
|
|
24
|
+
]);
|
|
25
|
+
const VALID_ARIA_ROLES = /* @__PURE__ */ new Set([
|
|
26
|
+
"alert",
|
|
27
|
+
"alertdialog",
|
|
28
|
+
"application",
|
|
29
|
+
"article",
|
|
30
|
+
"banner",
|
|
31
|
+
"button",
|
|
32
|
+
"cell",
|
|
33
|
+
"checkbox",
|
|
34
|
+
"columnheader",
|
|
35
|
+
"combobox",
|
|
36
|
+
"complementary",
|
|
37
|
+
"contentinfo",
|
|
38
|
+
"definition",
|
|
39
|
+
"dialog",
|
|
40
|
+
"directory",
|
|
41
|
+
"document",
|
|
42
|
+
"feed",
|
|
43
|
+
"figure",
|
|
44
|
+
"form",
|
|
45
|
+
"grid",
|
|
46
|
+
"gridcell",
|
|
47
|
+
"group",
|
|
48
|
+
"heading",
|
|
49
|
+
"img",
|
|
50
|
+
"link",
|
|
51
|
+
"list",
|
|
52
|
+
"listbox",
|
|
53
|
+
"listitem",
|
|
54
|
+
"log",
|
|
55
|
+
"main",
|
|
56
|
+
"marquee",
|
|
57
|
+
"math",
|
|
58
|
+
"menu",
|
|
59
|
+
"menubar",
|
|
60
|
+
"menuitem",
|
|
61
|
+
"menuitemcheckbox",
|
|
62
|
+
"menuitemradio",
|
|
63
|
+
"meter",
|
|
64
|
+
"navigation",
|
|
65
|
+
"none",
|
|
66
|
+
"note",
|
|
67
|
+
"option",
|
|
68
|
+
"presentation",
|
|
69
|
+
"progressbar",
|
|
70
|
+
"radio",
|
|
71
|
+
"radiogroup",
|
|
72
|
+
"region",
|
|
73
|
+
"row",
|
|
74
|
+
"rowgroup",
|
|
75
|
+
"rowheader",
|
|
76
|
+
"scrollbar",
|
|
77
|
+
"search",
|
|
78
|
+
"searchbox",
|
|
79
|
+
"separator",
|
|
80
|
+
"slider",
|
|
81
|
+
"spinbutton",
|
|
82
|
+
"status",
|
|
83
|
+
"switch",
|
|
84
|
+
"tab",
|
|
85
|
+
"table",
|
|
86
|
+
"tablist",
|
|
87
|
+
"tabpanel",
|
|
88
|
+
"term",
|
|
89
|
+
"textbox",
|
|
90
|
+
"timer",
|
|
91
|
+
"toolbar",
|
|
92
|
+
"tooltip",
|
|
93
|
+
"tree",
|
|
94
|
+
"treegrid",
|
|
95
|
+
"treeitem"
|
|
96
|
+
]);
|
|
97
|
+
const INTERACTIVE_CONTAINER_ROLES = /* @__PURE__ */ new Set(["button", "link"]);
|
|
98
|
+
const WIDGET_ROLES = /* @__PURE__ */ new Set([
|
|
99
|
+
"button",
|
|
100
|
+
"link",
|
|
101
|
+
"checkbox",
|
|
102
|
+
"radio",
|
|
103
|
+
"switch",
|
|
104
|
+
"tab",
|
|
105
|
+
"menuitem",
|
|
106
|
+
"option",
|
|
107
|
+
"textbox"
|
|
108
|
+
]);
|
|
109
|
+
const FOCUSABLE_ELEMENTS = /* @__PURE__ */ new Set(["button", "select", "textarea", "summary"]);
|
|
110
|
+
const LABELABLE_INPUTS = /* @__PURE__ */ new Set([
|
|
111
|
+
"text",
|
|
112
|
+
"password",
|
|
113
|
+
"email",
|
|
114
|
+
"tel",
|
|
115
|
+
"url",
|
|
116
|
+
"number",
|
|
117
|
+
"search",
|
|
118
|
+
"date",
|
|
119
|
+
"time",
|
|
120
|
+
"datetime-local",
|
|
121
|
+
"month",
|
|
122
|
+
"week",
|
|
123
|
+
"color",
|
|
124
|
+
"file",
|
|
125
|
+
"range",
|
|
126
|
+
"checkbox",
|
|
127
|
+
"radio"
|
|
128
|
+
]);
|
|
129
|
+
const IGNORED_INPUT_TYPES = /* @__PURE__ */ new Set(["hidden", "submit", "button", "reset"]);
|
|
130
|
+
const validate = (ctx) => {
|
|
131
|
+
const findings = [];
|
|
132
|
+
const labelForIds = /* @__PURE__ */ new Set();
|
|
133
|
+
const allIds = /* @__PURE__ */ new Set();
|
|
134
|
+
const idCounts = /* @__PURE__ */ new Map();
|
|
135
|
+
collectDomIndex(ctx.body, labelForIds, allIds, idCounts);
|
|
136
|
+
for (const [id, count] of idCounts) {
|
|
137
|
+
if (count > 1) {
|
|
138
|
+
pushFinding(findings, {
|
|
139
|
+
severity: "error",
|
|
140
|
+
message: `Duplicate id="${id}" used ${count} times (WCAG 4.1.1)`,
|
|
141
|
+
suggestion: "Give each element a unique id. Duplicate ids break label associations and ARIA references.",
|
|
142
|
+
attribute: "id"
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
checkLabelsStructure(ctx.body, allIds, findings);
|
|
147
|
+
walkElements(ctx.body, ctx, (el) => {
|
|
148
|
+
const tag = el.rawTagName?.toLowerCase();
|
|
149
|
+
if (!tag) return;
|
|
150
|
+
if (tag === "img") {
|
|
151
|
+
const alt = el.getAttribute?.("alt");
|
|
152
|
+
if (alt === void 0 || alt === null) {
|
|
153
|
+
pushFinding(findings, {
|
|
154
|
+
severity: "error",
|
|
155
|
+
message: "Image missing alt attribute (WCAG 1.1.1)",
|
|
156
|
+
suggestion: 'Add an alt attribute. Use descriptive text for informative images, or alt="" for purely decorative images.',
|
|
157
|
+
element: "<img>",
|
|
158
|
+
attribute: "alt"
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (tag === "input") {
|
|
163
|
+
const type = (el.getAttribute?.("type") || "text").toLowerCase();
|
|
164
|
+
if (IGNORED_INPUT_TYPES.has(type)) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (!LABELABLE_INPUTS.has(type)) return;
|
|
168
|
+
checkLabel(el, tag, findings, labelForIds, allIds);
|
|
169
|
+
}
|
|
170
|
+
if (tag === "select" || tag === "textarea") {
|
|
171
|
+
checkLabel(el, tag, findings, labelForIds, allIds);
|
|
172
|
+
}
|
|
173
|
+
if (tag === "button") {
|
|
174
|
+
const text = el.textContent?.trim();
|
|
175
|
+
const ariaLabel = el.getAttribute?.("aria-label");
|
|
176
|
+
const ariaLabelledBy = el.getAttribute?.("aria-labelledby");
|
|
177
|
+
const hasImg = el.querySelector?.("img[alt]");
|
|
178
|
+
if (!text && !ariaLabel && !ariaLabelledBy && !hasImg) {
|
|
179
|
+
pushFinding(findings, {
|
|
180
|
+
severity: "error",
|
|
181
|
+
message: "Button has no accessible name (WCAG 4.1.2)",
|
|
182
|
+
suggestion: "Add text content, an aria-label, or an aria-labelledby attribute to the button.",
|
|
183
|
+
element: "<button>"
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (INTERACTIVE_ELEMENTS.has(tag) || el.getAttribute?.("role")) {
|
|
188
|
+
const tabindex = el.getAttribute?.("tabindex");
|
|
189
|
+
if (tabindex !== void 0 && tabindex !== null) {
|
|
190
|
+
const val = parseInt(tabindex, 10);
|
|
191
|
+
if (!isNaN(val) && val > 0) {
|
|
192
|
+
pushFinding(findings, {
|
|
193
|
+
severity: "warning",
|
|
194
|
+
message: `Positive tabindex="${tabindex}" disrupts natural tab order (WCAG 2.4.3)`,
|
|
195
|
+
suggestion: 'Use tabindex="0" to add to natural tab order, or tabindex="-1" for programmatic focus. Avoid positive values — they override the DOM order and confuse keyboard users.',
|
|
196
|
+
element: `<${tag}>`,
|
|
197
|
+
attribute: "tabindex"
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (tag === "video" || tag === "audio") {
|
|
203
|
+
const autoplay = el.getAttribute?.("autoplay");
|
|
204
|
+
if (autoplay !== void 0 && autoplay !== null) {
|
|
205
|
+
const muted = el.getAttribute?.("muted");
|
|
206
|
+
if (muted === void 0 || muted === null) {
|
|
207
|
+
pushFinding(findings, {
|
|
208
|
+
severity: "error",
|
|
209
|
+
message: `<${tag}> has autoplay without muted (WCAG 1.4.2)`,
|
|
210
|
+
suggestion: `Add the muted attribute to <${tag} autoplay>, or remove autoplay. Autoplaying audio is disruptive to screen reader users.`,
|
|
211
|
+
element: `<${tag}>`,
|
|
212
|
+
attribute: "autoplay"
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const role = el.getAttribute?.("role");
|
|
218
|
+
if (role !== void 0 && role !== null) {
|
|
219
|
+
if (!VALID_ARIA_ROLES.has(role)) {
|
|
220
|
+
pushFinding(findings, {
|
|
221
|
+
severity: "error",
|
|
222
|
+
message: `Invalid ARIA role="${role}" (WCAG 4.1.2)`,
|
|
223
|
+
suggestion: `"${role}" is not a valid WAI-ARIA role. Use a valid role such as "button", "link", "navigation", "dialog", etc.`,
|
|
224
|
+
element: `<${tag}>`,
|
|
225
|
+
attribute: "role"
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (NON_INTERACTIVE_ELEMENTS.has(tag)) {
|
|
230
|
+
const tabindex = el.getAttribute?.("tabindex");
|
|
231
|
+
if (tabindex !== void 0 && tabindex !== null) {
|
|
232
|
+
const val = parseInt(tabindex, 10);
|
|
233
|
+
if (!isNaN(val) && val >= 0 && !role) {
|
|
234
|
+
pushFinding(findings, {
|
|
235
|
+
severity: "warning",
|
|
236
|
+
message: `<${tag}> is focusable via tabindex but has no role (WCAG 4.1.2)`,
|
|
237
|
+
suggestion: `Add a role attribute to indicate the element's purpose to screen readers. Example: <div tabindex="0" role="button"> or <span tabindex="0" role="link">.`,
|
|
238
|
+
element: `<${tag}>`,
|
|
239
|
+
attribute: "role"
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
checkDuplicateAdjacentText(ctx.body, findings);
|
|
246
|
+
checkNestedInteractive(ctx.body, findings);
|
|
247
|
+
if (ctx.head) {
|
|
248
|
+
const viewport = ctx.head.meta.find((m) => m.name?.toLowerCase() === "viewport");
|
|
249
|
+
if (viewport) {
|
|
250
|
+
const content = viewport.content.map((p) => p.value).join("").toLowerCase();
|
|
251
|
+
if (/user-scalable\s*=\s*no/.test(content)) {
|
|
252
|
+
pushFinding(findings, {
|
|
253
|
+
severity: "error",
|
|
254
|
+
message: "Viewport meta disables user scaling (WCAG 1.4.4)",
|
|
255
|
+
suggestion: "Remove user-scalable=no from the viewport meta tag. Users must be able to zoom to at least 200%.",
|
|
256
|
+
element: "<meta>",
|
|
257
|
+
attribute: "content"
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
const maxScaleMatch = content.match(/maximum-scale\s*=\s*([\d.]+)/);
|
|
261
|
+
if (maxScaleMatch && parseFloat(maxScaleMatch[1]) < 2) {
|
|
262
|
+
pushFinding(findings, {
|
|
263
|
+
severity: "error",
|
|
264
|
+
message: `Viewport meta restricts zoom to ${maxScaleMatch[1]}x (WCAG 1.4.4)`,
|
|
265
|
+
suggestion: "Set maximum-scale to at least 2, or remove it entirely. Users must be able to zoom to at least 200%.",
|
|
266
|
+
element: "<meta>",
|
|
267
|
+
attribute: "content"
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return findings;
|
|
273
|
+
};
|
|
274
|
+
function checkLabel(el, tag, findings, labelForIds, allIds) {
|
|
275
|
+
const id = el.getAttribute?.("id");
|
|
276
|
+
const ariaLabel = el.getAttribute?.("aria-label");
|
|
277
|
+
const ariaLabelledBy = el.getAttribute?.("aria-labelledby");
|
|
278
|
+
let hasAccessibleName = false;
|
|
279
|
+
if (ariaLabel !== void 0 && ariaLabel !== null) {
|
|
280
|
+
if (!String(ariaLabel).trim()) {
|
|
281
|
+
pushFinding(findings, {
|
|
282
|
+
severity: "error",
|
|
283
|
+
message: `<${tag}> has empty aria-label (WCAG 4.1.2)`,
|
|
284
|
+
suggestion: "Provide a non-empty aria-label, use aria-labelledby with an existing id, or associate a <label>.",
|
|
285
|
+
element: `<${tag}>`,
|
|
286
|
+
attribute: "aria-label"
|
|
287
|
+
});
|
|
288
|
+
} else {
|
|
289
|
+
hasAccessibleName = true;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (ariaLabelledBy !== void 0 && ariaLabelledBy !== null) {
|
|
293
|
+
const tokens = String(ariaLabelledBy).trim().split(/\s+/).filter(Boolean);
|
|
294
|
+
if (tokens.length === 0) {
|
|
295
|
+
pushFinding(findings, {
|
|
296
|
+
severity: "error",
|
|
297
|
+
message: `<${tag}> has empty aria-labelledby (WCAG 4.1.2)`,
|
|
298
|
+
suggestion: "Set aria-labelledby to one or more element ids that exist in this file, or use a non-empty aria-label / <label>.",
|
|
299
|
+
element: `<${tag}>`,
|
|
300
|
+
attribute: "aria-labelledby"
|
|
301
|
+
});
|
|
302
|
+
} else {
|
|
303
|
+
const missing = tokens.filter((token) => !allIds.has(token));
|
|
304
|
+
if (missing.length > 0) {
|
|
305
|
+
pushFinding(findings, {
|
|
306
|
+
severity: "error",
|
|
307
|
+
message: `<${tag}> aria-labelledby references missing id(s): ${missing.join(", ")} (WCAG 1.3.1)`,
|
|
308
|
+
suggestion: `Add element(s) with id="${missing[0]}" (or fix the aria-labelledby tokens), or use a <label for="..."> / non-empty aria-label instead.`,
|
|
309
|
+
element: `<${tag}>`,
|
|
310
|
+
attribute: "aria-labelledby"
|
|
311
|
+
});
|
|
312
|
+
} else {
|
|
313
|
+
hasAccessibleName = true;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (hasAccessibleName) return;
|
|
318
|
+
if (id && labelForIds.has(id)) return;
|
|
319
|
+
let parent = el.parentNode;
|
|
320
|
+
while (parent) {
|
|
321
|
+
if (parent.rawTagName?.toLowerCase() === "label") return;
|
|
322
|
+
parent = parent.parentNode;
|
|
323
|
+
}
|
|
324
|
+
pushFinding(findings, {
|
|
325
|
+
severity: "error",
|
|
326
|
+
message: `<${tag}> has no associated label (WCAG 1.3.1)`,
|
|
327
|
+
suggestion: `Add a <label for="${id || "inputId"}"> that references this ${tag}'s id, wrap it in a <label>, or add an aria-label attribute.`,
|
|
328
|
+
element: `<${tag}>`,
|
|
329
|
+
attribute: "id"
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
function collectDomIndex(el, labelForIds, allIds, idCounts) {
|
|
333
|
+
const id = el.getAttribute?.("id");
|
|
334
|
+
if (id) {
|
|
335
|
+
allIds.add(id);
|
|
336
|
+
idCounts.set(id, (idCounts.get(id) ?? 0) + 1);
|
|
337
|
+
}
|
|
338
|
+
if (el.rawTagName?.toLowerCase() === "label") {
|
|
339
|
+
const forId = el.getAttribute?.("for");
|
|
340
|
+
if (forId) labelForIds.add(forId);
|
|
341
|
+
}
|
|
342
|
+
for (const child of el.childNodes ?? []) {
|
|
343
|
+
if (child.nodeType === 1) collectDomIndex(child, labelForIds, allIds, idCounts);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
function isLabelableControl(el) {
|
|
347
|
+
const tag = el.rawTagName?.toLowerCase();
|
|
348
|
+
if (tag === "select" || tag === "textarea") return true;
|
|
349
|
+
if (tag !== "input") return false;
|
|
350
|
+
const type = (el.getAttribute?.("type") || "text").toLowerCase();
|
|
351
|
+
if (IGNORED_INPUT_TYPES.has(type)) return false;
|
|
352
|
+
return LABELABLE_INPUTS.has(type);
|
|
353
|
+
}
|
|
354
|
+
function countLabelableDescendants(el) {
|
|
355
|
+
let count = 0;
|
|
356
|
+
for (const child of el.childNodes ?? []) {
|
|
357
|
+
if (child.nodeType !== 1) continue;
|
|
358
|
+
if (isLabelableControl(child)) count += 1;
|
|
359
|
+
count += countLabelableDescendants(child);
|
|
360
|
+
}
|
|
361
|
+
return count;
|
|
362
|
+
}
|
|
363
|
+
function checkLabelsStructure(root, allIds, findings) {
|
|
364
|
+
function walk(el) {
|
|
365
|
+
if (el.rawTagName?.toLowerCase() === "label") {
|
|
366
|
+
const forId = el.getAttribute?.("for");
|
|
367
|
+
if (forId && !allIds.has(forId)) {
|
|
368
|
+
pushFinding(findings, {
|
|
369
|
+
severity: "warning",
|
|
370
|
+
message: `<label for="${forId}"> has no matching id in this file (WCAG 1.3.1)`,
|
|
371
|
+
suggestion: `Add id="${forId}" to the related form control, or fix the for attribute.`,
|
|
372
|
+
element: "<label>",
|
|
373
|
+
attribute: "for"
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
const controlCount = countLabelableDescendants(el);
|
|
377
|
+
if (controlCount > 1) {
|
|
378
|
+
pushFinding(findings, {
|
|
379
|
+
severity: "warning",
|
|
380
|
+
message: `<label> contains ${controlCount} form controls — screen readers only associate the first (WCAG 1.3.1)`,
|
|
381
|
+
suggestion: 'Use a separate <label for="id"> for each input (or one wrapping label per control). multiple form controls inside one label is not reliable.',
|
|
382
|
+
element: "<label>"
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
for (const child of el.childNodes ?? []) {
|
|
387
|
+
if (child.nodeType === 1) walk(child);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
walk(root);
|
|
391
|
+
}
|
|
392
|
+
function hasHref(el) {
|
|
393
|
+
const href = el.getAttribute?.("href");
|
|
394
|
+
return href !== void 0 && href !== null;
|
|
395
|
+
}
|
|
396
|
+
function isInteractiveContainer(el) {
|
|
397
|
+
const role = el.getAttribute?.("role")?.toLowerCase();
|
|
398
|
+
if (role) return INTERACTIVE_CONTAINER_ROLES.has(role);
|
|
399
|
+
const tag = el.rawTagName?.toLowerCase();
|
|
400
|
+
if (tag === "button") return true;
|
|
401
|
+
if (tag === "a") return hasHref(el);
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
function isFocusable(el) {
|
|
405
|
+
const role = el.getAttribute?.("role")?.toLowerCase();
|
|
406
|
+
if (role && WIDGET_ROLES.has(role)) return true;
|
|
407
|
+
const tag = el.rawTagName?.toLowerCase();
|
|
408
|
+
if (tag === "a") return hasHref(el);
|
|
409
|
+
if (tag === "input") return (el.getAttribute?.("type") || "text").toLowerCase() !== "hidden";
|
|
410
|
+
if (tag && FOCUSABLE_ELEMENTS.has(tag)) return true;
|
|
411
|
+
const tabindex = el.getAttribute?.("tabindex");
|
|
412
|
+
if (tabindex !== void 0 && tabindex !== null) {
|
|
413
|
+
const val = parseInt(tabindex, 10);
|
|
414
|
+
if (!isNaN(val) && val >= 0) return true;
|
|
415
|
+
}
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
function checkNestedInteractive(root, findings) {
|
|
419
|
+
function walk(el, ancestorTag) {
|
|
420
|
+
const tag = el.rawTagName?.toLowerCase();
|
|
421
|
+
if (tag && ancestorTag && isFocusable(el)) {
|
|
422
|
+
pushFinding(findings, {
|
|
423
|
+
severity: "error",
|
|
424
|
+
message: `Interactive <${tag}> is nested inside <${ancestorTag}> (WCAG 4.1.2)`,
|
|
425
|
+
suggestion: `Interactive elements cannot be nested — browsers restructure the DOM and screen readers announce an ambiguous control. Move the <${tag}> outside the <${ancestorTag}>, or make the outer element a non-interactive container such as <div>.`,
|
|
426
|
+
element: `<${tag}>`
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
const childAncestor = isInteractiveContainer(el) ? tag ?? ancestorTag : ancestorTag;
|
|
430
|
+
for (const child of el.childNodes ?? []) {
|
|
431
|
+
if (child.nodeType === 1) walk(child, childAncestor);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
walk(root, void 0);
|
|
435
|
+
}
|
|
436
|
+
function getVisibleText(el) {
|
|
437
|
+
if (el.getAttribute?.("aria-hidden") === "true") return "";
|
|
438
|
+
return (el.textContent ?? "").trim().replace(/\s+/g, " ");
|
|
439
|
+
}
|
|
440
|
+
function checkDuplicateAdjacentText(root, findings) {
|
|
441
|
+
function walk(el) {
|
|
442
|
+
const children = (el.childNodes ?? []).filter((n) => n.nodeType === 1);
|
|
443
|
+
for (let i = 0; i < children.length - 1; i++) {
|
|
444
|
+
const current = children[i];
|
|
445
|
+
const next = children[i + 1];
|
|
446
|
+
const currentText = getVisibleText(current);
|
|
447
|
+
const nextText = getVisibleText(next);
|
|
448
|
+
if (currentText && nextText && currentText === nextText && current.getAttribute?.("aria-hidden") !== "true" && next.getAttribute?.("aria-hidden") !== "true") {
|
|
449
|
+
const tag = next.rawTagName?.toLowerCase() || "element";
|
|
450
|
+
pushFinding(findings, {
|
|
451
|
+
severity: "warning",
|
|
452
|
+
message: `Adjacent <${current.rawTagName?.toLowerCase()}> and <${tag}> have identical text "${currentText.slice(0, 40)}${currentText.length > 40 ? "..." : ""}" — screen readers will announce it twice`,
|
|
453
|
+
suggestion: 'Add aria-hidden="true" to the decorative duplicate. If both are meaningful, differentiate their text content.',
|
|
454
|
+
element: `<${tag}>`
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
for (const child of children) {
|
|
459
|
+
walk(child);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
walk(root);
|
|
463
|
+
}
|
|
464
|
+
export {
|
|
465
|
+
validate
|
|
466
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jay-framework/a11y-validator",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Accessibility validation plugin for Jay Framework — checks jay-html templates for WCAG best practices",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -11,24 +11,26 @@
|
|
|
11
11
|
],
|
|
12
12
|
"exports": {
|
|
13
13
|
".": "./dist/index.js",
|
|
14
|
+
"./tools": "./dist/tools.js",
|
|
14
15
|
"./plugin.yaml": "./plugin.yaml"
|
|
15
16
|
},
|
|
16
17
|
"scripts": {
|
|
17
18
|
"build": "npm run clean && npm run build:server && npm run build:types && npm run validate",
|
|
18
19
|
"validate": "jay-stack-cli validate-plugin",
|
|
19
20
|
"build:server": "vite build --ssr",
|
|
20
|
-
"build:types": "tsup lib/index.ts --dts-only --format esm",
|
|
21
|
+
"build:types": "tsup lib/index.ts lib/tools.ts --dts-only --format esm",
|
|
21
22
|
"build:check-types": "tsc",
|
|
22
23
|
"clean": "rimraf dist",
|
|
23
24
|
"confirm": "npm run clean && npm run build && npm run test",
|
|
24
25
|
"test": "vitest run"
|
|
25
26
|
},
|
|
26
|
-
"
|
|
27
|
-
"@jay-framework/compiler-shared": "^0.24.
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@jay-framework/compiler-shared": "^0.24.4"
|
|
28
29
|
},
|
|
29
30
|
"devDependencies": {
|
|
30
|
-
"@jay-framework/
|
|
31
|
-
"@jay-framework/
|
|
31
|
+
"@jay-framework/compiler-shared": "^0.24.4",
|
|
32
|
+
"@jay-framework/dev-environment": "^0.24.4",
|
|
33
|
+
"@jay-framework/jay-stack-cli": "^0.24.4",
|
|
32
34
|
"@types/node": "^22.15.21",
|
|
33
35
|
"node-html-parser": "^6.1.0",
|
|
34
36
|
"rimraf": "^5.0.5",
|