@jay-framework/a11y-validator 0.22.2 → 0.23.1

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 +196 -33
  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,20 +122,35 @@ 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
- if (!tag)
114
- return;
149
+ if (!tag) return;
115
150
  if (tag === "img") {
116
151
  const alt = el.getAttribute?.("alt");
117
152
  if (alt === void 0 || alt === null) {
118
- findings.push({
153
+ pushFinding(findings, {
119
154
  severity: "error",
120
155
  message: "Image missing alt attribute (WCAG 1.1.1)",
121
156
  suggestion: 'Add an alt attribute. Use descriptive text for informative images, or alt="" for purely decorative images.',
@@ -126,15 +161,14 @@ const validate = (ctx) => {
126
161
  }
127
162
  if (tag === "input") {
128
163
  const type = (el.getAttribute?.("type") || "text").toLowerCase();
129
- if (type === "hidden" || type === "submit" || type === "button" || type === "reset") {
164
+ if (IGNORED_INPUT_TYPES.has(type)) {
130
165
  return;
131
166
  }
132
- if (!LABELABLE_INPUTS.has(type))
133
- return;
134
- checkLabel(el, tag, findings, labelForIds);
167
+ if (!LABELABLE_INPUTS.has(type)) return;
168
+ checkLabel(el, tag, findings, labelForIds, allIds);
135
169
  }
136
170
  if (tag === "select" || tag === "textarea") {
137
- checkLabel(el, tag, findings, labelForIds);
171
+ checkLabel(el, tag, findings, labelForIds, allIds);
138
172
  }
139
173
  if (tag === "button") {
140
174
  const text = el.textContent?.trim();
@@ -142,7 +176,7 @@ const validate = (ctx) => {
142
176
  const ariaLabelledBy = el.getAttribute?.("aria-labelledby");
143
177
  const hasImg = el.querySelector?.("img[alt]");
144
178
  if (!text && !ariaLabel && !ariaLabelledBy && !hasImg) {
145
- findings.push({
179
+ pushFinding(findings, {
146
180
  severity: "error",
147
181
  message: "Button has no accessible name (WCAG 4.1.2)",
148
182
  suggestion: "Add text content, an aria-label, or an aria-labelledby attribute to the button.",
@@ -155,7 +189,7 @@ const validate = (ctx) => {
155
189
  if (tabindex !== void 0 && tabindex !== null) {
156
190
  const val = parseInt(tabindex, 10);
157
191
  if (!isNaN(val) && val > 0) {
158
- findings.push({
192
+ pushFinding(findings, {
159
193
  severity: "warning",
160
194
  message: `Positive tabindex="${tabindex}" disrupts natural tab order (WCAG 2.4.3)`,
161
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.',
@@ -170,7 +204,7 @@ const validate = (ctx) => {
170
204
  if (autoplay !== void 0 && autoplay !== null) {
171
205
  const muted = el.getAttribute?.("muted");
172
206
  if (muted === void 0 || muted === null) {
173
- findings.push({
207
+ pushFinding(findings, {
174
208
  severity: "error",
175
209
  message: `<${tag}> has autoplay without muted (WCAG 1.4.2)`,
176
210
  suggestion: `Add the muted attribute to <${tag} autoplay>, or remove autoplay. Autoplaying audio is disruptive to screen reader users.`,
@@ -183,7 +217,7 @@ const validate = (ctx) => {
183
217
  const role = el.getAttribute?.("role");
184
218
  if (role !== void 0 && role !== null) {
185
219
  if (!VALID_ARIA_ROLES.has(role)) {
186
- findings.push({
220
+ pushFinding(findings, {
187
221
  severity: "error",
188
222
  message: `Invalid ARIA role="${role}" (WCAG 4.1.2)`,
189
223
  suggestion: `"${role}" is not a valid WAI-ARIA role. Use a valid role such as "button", "link", "navigation", "dialog", etc.`,
@@ -197,7 +231,7 @@ const validate = (ctx) => {
197
231
  if (tabindex !== void 0 && tabindex !== null) {
198
232
  const val = parseInt(tabindex, 10);
199
233
  if (!isNaN(val) && val >= 0 && !role) {
200
- findings.push({
234
+ pushFinding(findings, {
201
235
  severity: "warning",
202
236
  message: `<${tag}> is focusable via tabindex but has no role (WCAG 4.1.2)`,
203
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">.`,
@@ -209,12 +243,13 @@ const validate = (ctx) => {
209
243
  }
210
244
  });
211
245
  checkDuplicateAdjacentText(ctx.body, findings);
246
+ checkNestedInteractive(ctx.body, findings);
212
247
  if (ctx.head) {
213
248
  const viewport = ctx.head.meta.find((m) => m.name?.toLowerCase() === "viewport");
214
249
  if (viewport) {
215
250
  const content = viewport.content.map((p) => p.value).join("").toLowerCase();
216
251
  if (/user-scalable\s*=\s*no/.test(content)) {
217
- findings.push({
252
+ pushFinding(findings, {
218
253
  severity: "error",
219
254
  message: "Viewport meta disables user scaling (WCAG 1.4.4)",
220
255
  suggestion: "Remove user-scalable=no from the viewport meta tag. Users must be able to zoom to at least 200%.",
@@ -224,7 +259,7 @@ const validate = (ctx) => {
224
259
  }
225
260
  const maxScaleMatch = content.match(/maximum-scale\s*=\s*([\d.]+)/);
226
261
  if (maxScaleMatch && parseFloat(maxScaleMatch[1]) < 2) {
227
- findings.push({
262
+ pushFinding(findings, {
228
263
  severity: "error",
229
264
  message: `Viewport meta restricts zoom to ${maxScaleMatch[1]}x (WCAG 1.4.4)`,
230
265
  suggestion: "Set maximum-scale to at least 2, or remove it entirely. Users must be able to zoom to at least 200%.",
@@ -236,21 +271,57 @@ const validate = (ctx) => {
236
271
  }
237
272
  return findings;
238
273
  };
239
- function checkLabel(el, tag, findings, labelForIds) {
274
+ function checkLabel(el, tag, findings, labelForIds, allIds) {
240
275
  const id = el.getAttribute?.("id");
241
276
  const ariaLabel = el.getAttribute?.("aria-label");
242
277
  const ariaLabelledBy = el.getAttribute?.("aria-labelledby");
243
- if (ariaLabel || ariaLabelledBy)
244
- return;
245
- if (id && labelForIds.has(id))
246
- return;
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;
247
319
  let parent = el.parentNode;
248
320
  while (parent) {
249
- if (parent.rawTagName?.toLowerCase() === "label")
250
- return;
321
+ if (parent.rawTagName?.toLowerCase() === "label") return;
251
322
  parent = parent.parentNode;
252
323
  }
253
- findings.push({
324
+ pushFinding(findings, {
254
325
  severity: "error",
255
326
  message: `<${tag}> has no associated label (WCAG 1.3.1)`,
256
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.`,
@@ -258,20 +329,112 @@ function checkLabel(el, tag, findings, labelForIds) {
258
329
  attribute: "id"
259
330
  });
260
331
  }
261
- function collectLabelForIds(el, ids) {
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
+ }
262
338
  if (el.rawTagName?.toLowerCase() === "label") {
263
339
  const forId = el.getAttribute?.("for");
264
- if (forId)
265
- ids.add(forId);
340
+ if (forId) labelForIds.add(forId);
266
341
  }
267
342
  for (const child of el.childNodes ?? []) {
268
- if (child.nodeType === 1)
269
- collectLabelForIds(child, ids);
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
+ }
270
433
  }
434
+ walk(root, void 0);
271
435
  }
272
436
  function getVisibleText(el) {
273
- if (el.getAttribute?.("aria-hidden") === "true")
274
- return "";
437
+ if (el.getAttribute?.("aria-hidden") === "true") return "";
275
438
  return (el.textContent ?? "").trim().replace(/\s+/g, " ");
276
439
  }
277
440
  function checkDuplicateAdjacentText(root, findings) {
@@ -284,7 +447,7 @@ function checkDuplicateAdjacentText(root, findings) {
284
447
  const nextText = getVisibleText(next);
285
448
  if (currentText && nextText && currentText === nextText && current.getAttribute?.("aria-hidden") !== "true" && next.getAttribute?.("aria-hidden") !== "true") {
286
449
  const tag = next.rawTagName?.toLowerCase() || "element";
287
- findings.push({
450
+ pushFinding(findings, {
288
451
  severity: "warning",
289
452
  message: `Adjacent <${current.rawTagName?.toLowerCase()}> and <${tag}> have identical text "${currentText.slice(0, 40)}${currentText.length > 40 ? "..." : ""}" — screen readers will announce it twice`,
290
453
  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.1",
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.1"
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.1",
31
+ "@jay-framework/jay-stack-cli": "^0.23.1",
32
32
  "@types/node": "^22.15.21",
33
33
  "node-html-parser": "^6.1.0",
34
34
  "rimraf": "^5.0.5",