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(); 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.', }), ), }, ];