Files
WRNexusJS/packages/dev-toolbar/src/rules/accessibility.ts
T
2026-07-21 12:30:46 +05:30

144 lines
4.8 KiB
TypeScript

import type { DevToolbarRule } from "./types.ts";
import { accessibleName, createIssue } from "./helpers.ts";
export const accessibilityRules: DevToolbarRule[] = [
{
id: "a11y/image-alt",
category: "accessibility",
defaultSeverity: "error",
description: "Images need alternative text.",
run: ({ root }) =>
[...root.querySelectorAll("img:not([alt])")].map((element) =>
createIssue({
ruleId: "a11y/image-alt",
category: "accessibility",
severity: "error",
title: "Image is missing alt text",
message: 'Add alt text for meaningful images, or alt="" for decorative images.',
element,
recommendation: "Describe the image's purpose in a concise alt attribute.",
}),
),
},
{
id: "a11y/control-name",
category: "accessibility",
defaultSeverity: "error",
description: "Interactive controls need accessible names.",
run: ({ root }) =>
[...root.querySelectorAll("button, a[href], [role='button']")]
.filter((element) => !accessibleName(element))
.map((element) =>
createIssue({
ruleId: "a11y/control-name",
category: "accessibility",
severity: "error",
title: "Control has no accessible name",
message: "Screen readers cannot identify this control.",
element,
recommendation: "Add visible text, aria-label, or aria-labelledby.",
}),
),
},
{
id: "a11y/form-label",
category: "accessibility",
defaultSeverity: "error",
description: "Form controls need labels.",
run: ({ root }) =>
[...root.querySelectorAll("input:not([type='hidden']), select, textarea")]
.filter((element) => {
const id = element.id;
return (
!element.getAttribute("aria-label") &&
!element.getAttribute("aria-labelledby") &&
!(id && root.querySelector(`label[for='${CSS.escape(id)}']`)) &&
!element.closest("label")
);
})
.map((element) =>
createIssue({
ruleId: "a11y/form-label",
category: "accessibility",
severity: "error",
title: "Form control has no label",
message: "Users of assistive technology may not know what this field is for.",
element,
recommendation: "Associate a label using for/id or aria-labelledby.",
}),
),
},
{
id: "a11y/duplicate-id",
category: "accessibility",
defaultSeverity: "error",
description: "IDs must be unique.",
run: ({ root }) => {
const seen = new Set<string>();
const duplicates: Element[] = [];
for (const element of root.querySelectorAll("[id]")) {
if (seen.has(element.id)) duplicates.push(element);
else seen.add(element.id);
}
return duplicates.map((element) =>
createIssue({
ruleId: "a11y/duplicate-id",
category: "accessibility",
severity: "error",
title: "Duplicate element ID",
message: `The ID “${element.id}” is used more than once.`,
element,
recommendation: "Use a unique ID for every element.",
}),
);
},
},
{
id: "a11y/heading-order",
category: "accessibility",
defaultSeverity: "warning",
description: "Heading levels should not skip.",
run: ({ root }) => {
const issues = [];
let previous = 0;
for (const element of root.querySelectorAll("h1,h2,h3,h4,h5,h6")) {
const level = Number(element.tagName.slice(1));
if (previous && level > previous + 1)
issues.push(
createIssue({
ruleId: "a11y/heading-order",
category: "accessibility",
severity: "warning",
title: "Heading level is skipped",
message: `Heading jumps from h${previous} to h${level}.`,
element,
recommendation: "Use headings in a logical hierarchy.",
}),
);
previous = level;
}
return issues;
},
},
{
id: "a11y/positive-tabindex",
category: "accessibility",
defaultSeverity: "warning",
description: "Positive tabindex disrupts natural keyboard order.",
run: ({ root }) =>
[...root.querySelectorAll("[tabindex]")]
.filter((el) => Number(el.getAttribute("tabindex")) > 0)
.map((element) =>
createIssue({
ruleId: "a11y/positive-tabindex",
category: "accessibility",
severity: "warning",
title: "Positive tabindex used",
message: "Positive tabindex values create an unexpected keyboard focus order.",
element,
recommendation: 'Use tabindex="0" or rely on native document order.',
}),
),
},
];