@jay-framework/a11y-validator 0.22.2 → 0.23.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/dist/index.js +206 -20
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
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
|
+
}
|
|
2
9
|
const INTERACTIVE_ELEMENTS = /* @__PURE__ */ new Set(["a", "button", "input", "select", "textarea"]);
|
|
3
10
|
const NON_INTERACTIVE_ELEMENTS = /* @__PURE__ */ new Set([
|
|
4
11
|
"div",
|
|
@@ -87,6 +94,19 @@ const VALID_ARIA_ROLES = /* @__PURE__ */ new Set([
|
|
|
87
94
|
"treegrid",
|
|
88
95
|
"treeitem"
|
|
89
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"]);
|
|
90
110
|
const LABELABLE_INPUTS = /* @__PURE__ */ new Set([
|
|
91
111
|
"text",
|
|
92
112
|
"password",
|
|
@@ -102,12 +122,28 @@ const LABELABLE_INPUTS = /* @__PURE__ */ new Set([
|
|
|
102
122
|
"week",
|
|
103
123
|
"color",
|
|
104
124
|
"file",
|
|
105
|
-
"range"
|
|
125
|
+
"range",
|
|
126
|
+
"checkbox",
|
|
127
|
+
"radio"
|
|
106
128
|
]);
|
|
129
|
+
const IGNORED_INPUT_TYPES = /* @__PURE__ */ new Set(["hidden", "submit", "button", "reset"]);
|
|
107
130
|
const validate = (ctx) => {
|
|
108
131
|
const findings = [];
|
|
109
132
|
const labelForIds = /* @__PURE__ */ new Set();
|
|
110
|
-
|
|
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);
|
|
111
147
|
walkElements(ctx.body, ctx, (el) => {
|
|
112
148
|
const tag = el.rawTagName?.toLowerCase();
|
|
113
149
|
if (!tag)
|
|
@@ -115,7 +151,7 @@ const validate = (ctx) => {
|
|
|
115
151
|
if (tag === "img") {
|
|
116
152
|
const alt = el.getAttribute?.("alt");
|
|
117
153
|
if (alt === void 0 || alt === null) {
|
|
118
|
-
findings
|
|
154
|
+
pushFinding(findings, {
|
|
119
155
|
severity: "error",
|
|
120
156
|
message: "Image missing alt attribute (WCAG 1.1.1)",
|
|
121
157
|
suggestion: 'Add an alt attribute. Use descriptive text for informative images, or alt="" for purely decorative images.',
|
|
@@ -126,15 +162,15 @@ const validate = (ctx) => {
|
|
|
126
162
|
}
|
|
127
163
|
if (tag === "input") {
|
|
128
164
|
const type = (el.getAttribute?.("type") || "text").toLowerCase();
|
|
129
|
-
if (type
|
|
165
|
+
if (IGNORED_INPUT_TYPES.has(type)) {
|
|
130
166
|
return;
|
|
131
167
|
}
|
|
132
168
|
if (!LABELABLE_INPUTS.has(type))
|
|
133
169
|
return;
|
|
134
|
-
checkLabel(el, tag, findings, labelForIds);
|
|
170
|
+
checkLabel(el, tag, findings, labelForIds, allIds);
|
|
135
171
|
}
|
|
136
172
|
if (tag === "select" || tag === "textarea") {
|
|
137
|
-
checkLabel(el, tag, findings, labelForIds);
|
|
173
|
+
checkLabel(el, tag, findings, labelForIds, allIds);
|
|
138
174
|
}
|
|
139
175
|
if (tag === "button") {
|
|
140
176
|
const text = el.textContent?.trim();
|
|
@@ -142,7 +178,7 @@ const validate = (ctx) => {
|
|
|
142
178
|
const ariaLabelledBy = el.getAttribute?.("aria-labelledby");
|
|
143
179
|
const hasImg = el.querySelector?.("img[alt]");
|
|
144
180
|
if (!text && !ariaLabel && !ariaLabelledBy && !hasImg) {
|
|
145
|
-
findings
|
|
181
|
+
pushFinding(findings, {
|
|
146
182
|
severity: "error",
|
|
147
183
|
message: "Button has no accessible name (WCAG 4.1.2)",
|
|
148
184
|
suggestion: "Add text content, an aria-label, or an aria-labelledby attribute to the button.",
|
|
@@ -155,7 +191,7 @@ const validate = (ctx) => {
|
|
|
155
191
|
if (tabindex !== void 0 && tabindex !== null) {
|
|
156
192
|
const val = parseInt(tabindex, 10);
|
|
157
193
|
if (!isNaN(val) && val > 0) {
|
|
158
|
-
findings
|
|
194
|
+
pushFinding(findings, {
|
|
159
195
|
severity: "warning",
|
|
160
196
|
message: `Positive tabindex="${tabindex}" disrupts natural tab order (WCAG 2.4.3)`,
|
|
161
197
|
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.',
|
|
@@ -170,7 +206,7 @@ const validate = (ctx) => {
|
|
|
170
206
|
if (autoplay !== void 0 && autoplay !== null) {
|
|
171
207
|
const muted = el.getAttribute?.("muted");
|
|
172
208
|
if (muted === void 0 || muted === null) {
|
|
173
|
-
findings
|
|
209
|
+
pushFinding(findings, {
|
|
174
210
|
severity: "error",
|
|
175
211
|
message: `<${tag}> has autoplay without muted (WCAG 1.4.2)`,
|
|
176
212
|
suggestion: `Add the muted attribute to <${tag} autoplay>, or remove autoplay. Autoplaying audio is disruptive to screen reader users.`,
|
|
@@ -183,7 +219,7 @@ const validate = (ctx) => {
|
|
|
183
219
|
const role = el.getAttribute?.("role");
|
|
184
220
|
if (role !== void 0 && role !== null) {
|
|
185
221
|
if (!VALID_ARIA_ROLES.has(role)) {
|
|
186
|
-
findings
|
|
222
|
+
pushFinding(findings, {
|
|
187
223
|
severity: "error",
|
|
188
224
|
message: `Invalid ARIA role="${role}" (WCAG 4.1.2)`,
|
|
189
225
|
suggestion: `"${role}" is not a valid WAI-ARIA role. Use a valid role such as "button", "link", "navigation", "dialog", etc.`,
|
|
@@ -197,7 +233,7 @@ const validate = (ctx) => {
|
|
|
197
233
|
if (tabindex !== void 0 && tabindex !== null) {
|
|
198
234
|
const val = parseInt(tabindex, 10);
|
|
199
235
|
if (!isNaN(val) && val >= 0 && !role) {
|
|
200
|
-
findings
|
|
236
|
+
pushFinding(findings, {
|
|
201
237
|
severity: "warning",
|
|
202
238
|
message: `<${tag}> is focusable via tabindex but has no role (WCAG 4.1.2)`,
|
|
203
239
|
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">.`,
|
|
@@ -209,12 +245,13 @@ const validate = (ctx) => {
|
|
|
209
245
|
}
|
|
210
246
|
});
|
|
211
247
|
checkDuplicateAdjacentText(ctx.body, findings);
|
|
248
|
+
checkNestedInteractive(ctx.body, findings);
|
|
212
249
|
if (ctx.head) {
|
|
213
250
|
const viewport = ctx.head.meta.find((m) => m.name?.toLowerCase() === "viewport");
|
|
214
251
|
if (viewport) {
|
|
215
252
|
const content = viewport.content.map((p) => p.value).join("").toLowerCase();
|
|
216
253
|
if (/user-scalable\s*=\s*no/.test(content)) {
|
|
217
|
-
findings
|
|
254
|
+
pushFinding(findings, {
|
|
218
255
|
severity: "error",
|
|
219
256
|
message: "Viewport meta disables user scaling (WCAG 1.4.4)",
|
|
220
257
|
suggestion: "Remove user-scalable=no from the viewport meta tag. Users must be able to zoom to at least 200%.",
|
|
@@ -224,7 +261,7 @@ const validate = (ctx) => {
|
|
|
224
261
|
}
|
|
225
262
|
const maxScaleMatch = content.match(/maximum-scale\s*=\s*([\d.]+)/);
|
|
226
263
|
if (maxScaleMatch && parseFloat(maxScaleMatch[1]) < 2) {
|
|
227
|
-
findings
|
|
264
|
+
pushFinding(findings, {
|
|
228
265
|
severity: "error",
|
|
229
266
|
message: `Viewport meta restricts zoom to ${maxScaleMatch[1]}x (WCAG 1.4.4)`,
|
|
230
267
|
suggestion: "Set maximum-scale to at least 2, or remove it entirely. Users must be able to zoom to at least 200%.",
|
|
@@ -236,11 +273,50 @@ const validate = (ctx) => {
|
|
|
236
273
|
}
|
|
237
274
|
return findings;
|
|
238
275
|
};
|
|
239
|
-
function checkLabel(el, tag, findings, labelForIds) {
|
|
276
|
+
function checkLabel(el, tag, findings, labelForIds, allIds) {
|
|
240
277
|
const id = el.getAttribute?.("id");
|
|
241
278
|
const ariaLabel = el.getAttribute?.("aria-label");
|
|
242
279
|
const ariaLabelledBy = el.getAttribute?.("aria-labelledby");
|
|
243
|
-
|
|
280
|
+
let hasAccessibleName = false;
|
|
281
|
+
if (ariaLabel !== void 0 && ariaLabel !== null) {
|
|
282
|
+
if (!String(ariaLabel).trim()) {
|
|
283
|
+
pushFinding(findings, {
|
|
284
|
+
severity: "error",
|
|
285
|
+
message: `<${tag}> has empty aria-label (WCAG 4.1.2)`,
|
|
286
|
+
suggestion: "Provide a non-empty aria-label, use aria-labelledby with an existing id, or associate a <label>.",
|
|
287
|
+
element: `<${tag}>`,
|
|
288
|
+
attribute: "aria-label"
|
|
289
|
+
});
|
|
290
|
+
} else {
|
|
291
|
+
hasAccessibleName = true;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (ariaLabelledBy !== void 0 && ariaLabelledBy !== null) {
|
|
295
|
+
const tokens = String(ariaLabelledBy).trim().split(/\s+/).filter(Boolean);
|
|
296
|
+
if (tokens.length === 0) {
|
|
297
|
+
pushFinding(findings, {
|
|
298
|
+
severity: "error",
|
|
299
|
+
message: `<${tag}> has empty aria-labelledby (WCAG 4.1.2)`,
|
|
300
|
+
suggestion: "Set aria-labelledby to one or more element ids that exist in this file, or use a non-empty aria-label / <label>.",
|
|
301
|
+
element: `<${tag}>`,
|
|
302
|
+
attribute: "aria-labelledby"
|
|
303
|
+
});
|
|
304
|
+
} else {
|
|
305
|
+
const missing = tokens.filter((token) => !allIds.has(token));
|
|
306
|
+
if (missing.length > 0) {
|
|
307
|
+
pushFinding(findings, {
|
|
308
|
+
severity: "error",
|
|
309
|
+
message: `<${tag}> aria-labelledby references missing id(s): ${missing.join(", ")} (WCAG 1.3.1)`,
|
|
310
|
+
suggestion: `Add element(s) with id="${missing[0]}" (or fix the aria-labelledby tokens), or use a <label for="..."> / non-empty aria-label instead.`,
|
|
311
|
+
element: `<${tag}>`,
|
|
312
|
+
attribute: "aria-labelledby"
|
|
313
|
+
});
|
|
314
|
+
} else {
|
|
315
|
+
hasAccessibleName = true;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (hasAccessibleName)
|
|
244
320
|
return;
|
|
245
321
|
if (id && labelForIds.has(id))
|
|
246
322
|
return;
|
|
@@ -250,7 +326,7 @@ function checkLabel(el, tag, findings, labelForIds) {
|
|
|
250
326
|
return;
|
|
251
327
|
parent = parent.parentNode;
|
|
252
328
|
}
|
|
253
|
-
findings
|
|
329
|
+
pushFinding(findings, {
|
|
254
330
|
severity: "error",
|
|
255
331
|
message: `<${tag}> has no associated label (WCAG 1.3.1)`,
|
|
256
332
|
suggestion: `Add a <label for="${id || "inputId"}"> that references this ${tag}'s id, wrap it in a <label>, or add an aria-label attribute.`,
|
|
@@ -258,16 +334,126 @@ function checkLabel(el, tag, findings, labelForIds) {
|
|
|
258
334
|
attribute: "id"
|
|
259
335
|
});
|
|
260
336
|
}
|
|
261
|
-
function
|
|
337
|
+
function collectDomIndex(el, labelForIds, allIds, idCounts) {
|
|
338
|
+
const id = el.getAttribute?.("id");
|
|
339
|
+
if (id) {
|
|
340
|
+
allIds.add(id);
|
|
341
|
+
idCounts.set(id, (idCounts.get(id) ?? 0) + 1);
|
|
342
|
+
}
|
|
262
343
|
if (el.rawTagName?.toLowerCase() === "label") {
|
|
263
344
|
const forId = el.getAttribute?.("for");
|
|
264
345
|
if (forId)
|
|
265
|
-
|
|
346
|
+
labelForIds.add(forId);
|
|
266
347
|
}
|
|
267
348
|
for (const child of el.childNodes ?? []) {
|
|
268
349
|
if (child.nodeType === 1)
|
|
269
|
-
|
|
350
|
+
collectDomIndex(child, labelForIds, allIds, idCounts);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
function isLabelableControl(el) {
|
|
354
|
+
const tag = el.rawTagName?.toLowerCase();
|
|
355
|
+
if (tag === "select" || tag === "textarea")
|
|
356
|
+
return true;
|
|
357
|
+
if (tag !== "input")
|
|
358
|
+
return false;
|
|
359
|
+
const type = (el.getAttribute?.("type") || "text").toLowerCase();
|
|
360
|
+
if (IGNORED_INPUT_TYPES.has(type))
|
|
361
|
+
return false;
|
|
362
|
+
return LABELABLE_INPUTS.has(type);
|
|
363
|
+
}
|
|
364
|
+
function countLabelableDescendants(el) {
|
|
365
|
+
let count = 0;
|
|
366
|
+
for (const child of el.childNodes ?? []) {
|
|
367
|
+
if (child.nodeType !== 1)
|
|
368
|
+
continue;
|
|
369
|
+
if (isLabelableControl(child))
|
|
370
|
+
count += 1;
|
|
371
|
+
count += countLabelableDescendants(child);
|
|
372
|
+
}
|
|
373
|
+
return count;
|
|
374
|
+
}
|
|
375
|
+
function checkLabelsStructure(root, allIds, findings) {
|
|
376
|
+
function walk(el) {
|
|
377
|
+
if (el.rawTagName?.toLowerCase() === "label") {
|
|
378
|
+
const forId = el.getAttribute?.("for");
|
|
379
|
+
if (forId && !allIds.has(forId)) {
|
|
380
|
+
pushFinding(findings, {
|
|
381
|
+
severity: "warning",
|
|
382
|
+
message: `<label for="${forId}"> has no matching id in this file (WCAG 1.3.1)`,
|
|
383
|
+
suggestion: `Add id="${forId}" to the related form control, or fix the for attribute.`,
|
|
384
|
+
element: "<label>",
|
|
385
|
+
attribute: "for"
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
const controlCount = countLabelableDescendants(el);
|
|
389
|
+
if (controlCount > 1) {
|
|
390
|
+
pushFinding(findings, {
|
|
391
|
+
severity: "warning",
|
|
392
|
+
message: `<label> contains ${controlCount} form controls — screen readers only associate the first (WCAG 1.3.1)`,
|
|
393
|
+
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.',
|
|
394
|
+
element: "<label>"
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
for (const child of el.childNodes ?? []) {
|
|
399
|
+
if (child.nodeType === 1)
|
|
400
|
+
walk(child);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
walk(root);
|
|
404
|
+
}
|
|
405
|
+
function hasHref(el) {
|
|
406
|
+
const href = el.getAttribute?.("href");
|
|
407
|
+
return href !== void 0 && href !== null;
|
|
408
|
+
}
|
|
409
|
+
function isInteractiveContainer(el) {
|
|
410
|
+
const role = el.getAttribute?.("role")?.toLowerCase();
|
|
411
|
+
if (role)
|
|
412
|
+
return INTERACTIVE_CONTAINER_ROLES.has(role);
|
|
413
|
+
const tag = el.rawTagName?.toLowerCase();
|
|
414
|
+
if (tag === "button")
|
|
415
|
+
return true;
|
|
416
|
+
if (tag === "a")
|
|
417
|
+
return hasHref(el);
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
function isFocusable(el) {
|
|
421
|
+
const role = el.getAttribute?.("role")?.toLowerCase();
|
|
422
|
+
if (role && WIDGET_ROLES.has(role))
|
|
423
|
+
return true;
|
|
424
|
+
const tag = el.rawTagName?.toLowerCase();
|
|
425
|
+
if (tag === "a")
|
|
426
|
+
return hasHref(el);
|
|
427
|
+
if (tag === "input")
|
|
428
|
+
return (el.getAttribute?.("type") || "text").toLowerCase() !== "hidden";
|
|
429
|
+
if (tag && FOCUSABLE_ELEMENTS.has(tag))
|
|
430
|
+
return true;
|
|
431
|
+
const tabindex = el.getAttribute?.("tabindex");
|
|
432
|
+
if (tabindex !== void 0 && tabindex !== null) {
|
|
433
|
+
const val = parseInt(tabindex, 10);
|
|
434
|
+
if (!isNaN(val) && val >= 0)
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
function checkNestedInteractive(root, findings) {
|
|
440
|
+
function walk(el, ancestorTag) {
|
|
441
|
+
const tag = el.rawTagName?.toLowerCase();
|
|
442
|
+
if (tag && ancestorTag && isFocusable(el)) {
|
|
443
|
+
pushFinding(findings, {
|
|
444
|
+
severity: "error",
|
|
445
|
+
message: `Interactive <${tag}> is nested inside <${ancestorTag}> (WCAG 4.1.2)`,
|
|
446
|
+
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>.`,
|
|
447
|
+
element: `<${tag}>`
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
const childAncestor = isInteractiveContainer(el) ? tag ?? ancestorTag : ancestorTag;
|
|
451
|
+
for (const child of el.childNodes ?? []) {
|
|
452
|
+
if (child.nodeType === 1)
|
|
453
|
+
walk(child, childAncestor);
|
|
454
|
+
}
|
|
270
455
|
}
|
|
456
|
+
walk(root, void 0);
|
|
271
457
|
}
|
|
272
458
|
function getVisibleText(el) {
|
|
273
459
|
if (el.getAttribute?.("aria-hidden") === "true")
|
|
@@ -284,7 +470,7 @@ function checkDuplicateAdjacentText(root, findings) {
|
|
|
284
470
|
const nextText = getVisibleText(next);
|
|
285
471
|
if (currentText && nextText && currentText === nextText && current.getAttribute?.("aria-hidden") !== "true" && next.getAttribute?.("aria-hidden") !== "true") {
|
|
286
472
|
const tag = next.rawTagName?.toLowerCase() || "element";
|
|
287
|
-
findings
|
|
473
|
+
pushFinding(findings, {
|
|
288
474
|
severity: "warning",
|
|
289
475
|
message: `Adjacent <${current.rawTagName?.toLowerCase()}> and <${tag}> have identical text "${currentText.slice(0, 40)}${currentText.length > 40 ? "..." : ""}" — screen readers will announce it twice`,
|
|
290
476
|
suggestion: 'Add aria-hidden="true" to the decorative duplicate. If both are meaningful, differentiate their text content.',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jay-framework/a11y-validator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
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",
|
|
@@ -24,11 +24,11 @@
|
|
|
24
24
|
"test": "vitest run"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@jay-framework/compiler-shared": "^0.
|
|
27
|
+
"@jay-framework/compiler-shared": "^0.23.0"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@jay-framework/dev-environment": "^0.
|
|
31
|
-
"@jay-framework/jay-stack-cli": "^0.
|
|
30
|
+
"@jay-framework/dev-environment": "^0.23.0",
|
|
31
|
+
"@jay-framework/jay-stack-cli": "^0.23.0",
|
|
32
32
|
"@types/node": "^22.15.21",
|
|
33
33
|
"node-html-parser": "^6.1.0",
|
|
34
34
|
"rimraf": "^5.0.5",
|