@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.
Files changed (2) hide show
  1. package/dist/index.js +206 -20
  2. 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
- collectLabelForIds(ctx.body, labelForIds);
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.push({
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 === "hidden" || type === "submit" || type === "button" || type === "reset") {
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.push({
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.push({
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.push({
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.push({
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.push({
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.push({
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.push({
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
- if (ariaLabel || ariaLabelledBy)
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.push({
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 collectLabelForIds(el, ids) {
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
- ids.add(forId);
346
+ labelForIds.add(forId);
266
347
  }
267
348
  for (const child of el.childNodes ?? []) {
268
349
  if (child.nodeType === 1)
269
- collectLabelForIds(child, ids);
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.push({
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.22.2",
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.22.2"
27
+ "@jay-framework/compiler-shared": "^0.23.0"
28
28
  },
29
29
  "devDependencies": {
30
- "@jay-framework/dev-environment": "^0.22.2",
31
- "@jay-framework/jay-stack-cli": "^0.22.2",
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",