uiaudit.js 1.0.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/auditor.d.ts +11 -0
- package/dist/auditor.d.ts.map +1 -0
- package/dist/auditor.js +70 -0
- package/dist/auditor.js.map +1 -0
- package/dist/auditors/accessibility.d.ts +8 -0
- package/dist/auditors/accessibility.d.ts.map +1 -0
- package/dist/auditors/accessibility.js +1769 -0
- package/dist/auditors/accessibility.js.map +1 -0
- package/dist/auditors/performance.d.ts +8 -0
- package/dist/auditors/performance.d.ts.map +1 -0
- package/dist/auditors/performance.js +168 -0
- package/dist/auditors/performance.js.map +1 -0
- package/dist/auditors/seo.d.ts +8 -0
- package/dist/auditors/seo.d.ts.map +1 -0
- package/dist/auditors/seo.js +171 -0
- package/dist/auditors/seo.js.map +1 -0
- package/dist/cli.d.ts +10 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +115 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/parser/index.d.ts +18 -0
- package/dist/parser/index.d.ts.map +1 -0
- package/dist/parser/index.js +87 -0
- package/dist/parser/index.js.map +1 -0
- package/dist/parser/traverse.d.ts +10 -0
- package/dist/parser/traverse.d.ts.map +1 -0
- package/dist/parser/traverse.js +13 -0
- package/dist/parser/traverse.js.map +1 -0
- package/dist/reporter/terminal.d.ts +3 -0
- package/dist/reporter/terminal.d.ts.map +1 -0
- package/dist/reporter/terminal.js +200 -0
- package/dist/reporter/terminal.js.map +1 -0
- package/dist/types.d.ts +38 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +39 -0
- package/src/auditor.ts +100 -0
- package/src/auditors/accessibility.ts +2125 -0
- package/src/auditors/performance.ts +212 -0
- package/src/auditors/seo.ts +212 -0
- package/src/cli.ts +162 -0
- package/src/index.ts +22 -0
- package/src/parser/index.ts +106 -0
- package/src/parser/traverse.ts +14 -0
- package/src/reporter/terminal.ts +247 -0
- package/src/types.ts +51 -0
- package/tsconfig.json +47 -0
|
@@ -0,0 +1,1769 @@
|
|
|
1
|
+
import { traverse } from '../parser/traverse.js';
|
|
2
|
+
/**
|
|
3
|
+
* Audits parsed files for accessibility (a11y) violations detectable via
|
|
4
|
+
* static JSX analysis. Checks map directly to WCAG 2.1 success criteria.
|
|
5
|
+
*/
|
|
6
|
+
export function auditAccessibility(parsedFiles) {
|
|
7
|
+
const issues = [];
|
|
8
|
+
for (const { ast, filePath } of parsedFiles) {
|
|
9
|
+
const declaredIds = new Set();
|
|
10
|
+
const headingLevels = [];
|
|
11
|
+
let hasH1 = false;
|
|
12
|
+
let mainCount = 0;
|
|
13
|
+
traverse(ast, {
|
|
14
|
+
JSXOpeningElement(path) {
|
|
15
|
+
const tagName = path.node.name?.type === 'JSXIdentifier' ? path.node.name.name : '';
|
|
16
|
+
// Count <main> elements
|
|
17
|
+
if (tagName === 'main') {
|
|
18
|
+
mainCount++;
|
|
19
|
+
}
|
|
20
|
+
const attr = (path.node.attributes || []).find((a) => a.type === 'JSXAttribute' &&
|
|
21
|
+
a.name.type === 'JSXIdentifier' &&
|
|
22
|
+
a.name.name === 'id');
|
|
23
|
+
const value = attr?.value?.type === 'StringLiteral'
|
|
24
|
+
? attr.value.value
|
|
25
|
+
: attr?.value?.type === 'JSXExpressionContainer' &&
|
|
26
|
+
attr.value.expression?.type === 'StringLiteral'
|
|
27
|
+
? attr.value.expression.value
|
|
28
|
+
: null;
|
|
29
|
+
if (value)
|
|
30
|
+
declaredIds.add(value);
|
|
31
|
+
// Track heading levels for hierarchy check
|
|
32
|
+
if (tagName.match(/^h[1-6]$/)) {
|
|
33
|
+
const level = parseInt(tagName[1]);
|
|
34
|
+
if (level === 1)
|
|
35
|
+
hasH1 = true;
|
|
36
|
+
headingLevels.push(level);
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
traverse(ast, {
|
|
41
|
+
JSXOpeningElement(path) {
|
|
42
|
+
const node = path.node;
|
|
43
|
+
if (node.name.type !== 'JSXIdentifier')
|
|
44
|
+
return;
|
|
45
|
+
const tagName = node.name.name;
|
|
46
|
+
// ─── Attribute helpers ─────────────────────────────────────────────
|
|
47
|
+
/** Returns true if the element has an attribute with this name. */
|
|
48
|
+
const hasAttr = (name) => node.attributes.some((a) => a.type === 'JSXAttribute' &&
|
|
49
|
+
a.name.type === 'JSXIdentifier' &&
|
|
50
|
+
a.name.name === name);
|
|
51
|
+
/** Returns an attribute node (or undefined) by name. */
|
|
52
|
+
const getAttr = (name) => node.attributes.find((a) => a.type === 'JSXAttribute' &&
|
|
53
|
+
a.name.type === 'JSXIdentifier' &&
|
|
54
|
+
a.name.name === name);
|
|
55
|
+
/** Returns a string attribute value, or null if dynamic/absent. */
|
|
56
|
+
const getStringValue = (name) => {
|
|
57
|
+
const attr = getAttr(name);
|
|
58
|
+
return attr?.value?.type === 'StringLiteral'
|
|
59
|
+
? attr.value.value
|
|
60
|
+
: null;
|
|
61
|
+
};
|
|
62
|
+
// ── Check 1: <img> without alt attribute ─────────────────────────
|
|
63
|
+
//
|
|
64
|
+
// WCAG 2.1 SC 1.1.1 — Non-text Content (Level A)
|
|
65
|
+
// Screen readers announce images to users who cannot see them.
|
|
66
|
+
// Without alt text, the image is meaningless noise or completely
|
|
67
|
+
// invisible, depending on the screen reader.
|
|
68
|
+
if (tagName === 'img') {
|
|
69
|
+
if (!hasAttr('alt')) {
|
|
70
|
+
issues.push({
|
|
71
|
+
id: 'img-missing-alt-a11y',
|
|
72
|
+
category: 'accessibility',
|
|
73
|
+
title: '<img> is missing an alt attribute',
|
|
74
|
+
description: 'Screen readers announce images using their alt text. Without it, users who rely on assistive technology cannot understand the image. Violates WCAG 2.1 SC 1.1.1 (Level A).',
|
|
75
|
+
impact: 'critical',
|
|
76
|
+
status: 'fail',
|
|
77
|
+
suggestion: 'Describe what the image shows: alt="A bar chart showing revenue growth from 2022 to 2024"\nFor purely decorative images (borders, spacers, backgrounds): alt="" — this tells screen readers to skip it.',
|
|
78
|
+
codeSnippet: '<img src={chart} />',
|
|
79
|
+
fixSnippet: '<img src={chart} alt="Bar chart showing Q4 revenue" />',
|
|
80
|
+
file: filePath,
|
|
81
|
+
line: node.loc?.start.line,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// ── Check 2: Clickable <div>/<span> without role and tabIndex ────
|
|
86
|
+
//
|
|
87
|
+
// WCAG 2.1 SC 2.1.1 — Keyboard (Level A)
|
|
88
|
+
// <div> and <span> are not natively focusable or keyboard-operable.
|
|
89
|
+
// A user navigating by keyboard (or using Switch Access) cannot
|
|
90
|
+
// reach or activate a click handler on these elements.
|
|
91
|
+
if (tagName === 'div' || tagName === 'span') {
|
|
92
|
+
const hasOnClick = hasAttr('onClick');
|
|
93
|
+
const hasRole = hasAttr('role');
|
|
94
|
+
const hasTabIndex = hasAttr('tabIndex');
|
|
95
|
+
if (hasOnClick && (!hasRole || !hasTabIndex)) {
|
|
96
|
+
const missing = [
|
|
97
|
+
!hasRole && 'role="button"',
|
|
98
|
+
!hasTabIndex && 'tabIndex={0}',
|
|
99
|
+
]
|
|
100
|
+
.filter(Boolean)
|
|
101
|
+
.join(' and ');
|
|
102
|
+
issues.push({
|
|
103
|
+
id: 'clickable-div-no-role',
|
|
104
|
+
category: 'accessibility',
|
|
105
|
+
title: `<${tagName}> with onClick is missing ${missing}`,
|
|
106
|
+
description: `<${tagName}> elements are not in the tab order and have no semantic role. Keyboard users and screen reader users cannot find or activate this element. Violates WCAG 2.1 SC 2.1.1 and SC 4.1.2 (both Level A).`,
|
|
107
|
+
impact: 'critical',
|
|
108
|
+
status: 'fail',
|
|
109
|
+
suggestion: `Best fix: Replace <${tagName}> with <button> — it is focusable, keyboard-operable, and announces itself to screen readers automatically.\n\nIf you must use <${tagName}>:\n<${tagName} role="button" tabIndex={0} onClick={handler} onKeyDown={(e) => e.key === 'Enter' && handler()}>`,
|
|
110
|
+
codeSnippet: `<${tagName} onClick={handleClick}>Submit</${tagName}>`,
|
|
111
|
+
fixSnippet: `<button onClick={handleClick}>Submit</button>`,
|
|
112
|
+
file: filePath,
|
|
113
|
+
line: node.loc?.start.line,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// ── Check 3: <label> without htmlFor ─────────────────────────────
|
|
118
|
+
//
|
|
119
|
+
// WCAG 2.1 SC 1.3.1 — Info and Relationships (Level A)
|
|
120
|
+
// A label must be programmatically linked to its input so that
|
|
121
|
+
// screen readers can announce "Email address, edit field" instead
|
|
122
|
+
// of just "edit field" when the input is focused.
|
|
123
|
+
if (tagName === 'label') {
|
|
124
|
+
const hasHtmlFor = hasAttr('htmlFor');
|
|
125
|
+
const hasAriaLabelledBy = hasAttr('aria-labelledby');
|
|
126
|
+
// Allow implicit label wrapping (<label><input /></label>)
|
|
127
|
+
const parentNode = path.parentPath?.node;
|
|
128
|
+
const hasWrappedInput = parentNode?.type === 'JSXElement' &&
|
|
129
|
+
parentNode.children?.some((c) => c.type === 'JSXElement' &&
|
|
130
|
+
c.openingElement?.name?.name === 'input');
|
|
131
|
+
if (!hasHtmlFor && !hasAriaLabelledBy && !hasWrappedInput) {
|
|
132
|
+
issues.push({
|
|
133
|
+
id: 'label-missing-htmlfor',
|
|
134
|
+
category: 'accessibility',
|
|
135
|
+
title: '<label> is not linked to an input',
|
|
136
|
+
description: 'This label has no htmlFor attribute and is not wrapping an input. Screen readers cannot associate the label text with any form field, so the input gets announced with no description. Violates WCAG 2.1 SC 1.3.1 (Level A).',
|
|
137
|
+
impact: 'major',
|
|
138
|
+
status: 'fail',
|
|
139
|
+
suggestion: 'Link the label to its input via matching htmlFor / id:\n\n<label htmlFor="email-input">Email address</label>\n<input id="email-input" type="email" />\n\nOr use an implicit label by wrapping the input:\n<label>Email address <input type="email" /></label>',
|
|
140
|
+
codeSnippet: '<label>Email</label>\n<input type="email" />',
|
|
141
|
+
fixSnippet: '<label htmlFor="email">Email</label>\n<input id="email" type="email" />',
|
|
142
|
+
file: filePath,
|
|
143
|
+
line: node.loc?.start.line,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
const htmlFor = getStringValue('htmlFor');
|
|
147
|
+
if (htmlFor && !declaredIds.has(htmlFor)) {
|
|
148
|
+
issues.push({
|
|
149
|
+
id: 'label-htmlfor-invalid',
|
|
150
|
+
category: 'accessibility',
|
|
151
|
+
title: '<label> htmlFor references a missing id',
|
|
152
|
+
description: 'A label with htmlFor must point to an existing form control id. When the referenced id does not exist, assistive technology cannot associate the label with its input. Violates WCAG 2.1 SC 1.3.1 (Level A).',
|
|
153
|
+
impact: 'major',
|
|
154
|
+
status: 'fail',
|
|
155
|
+
suggestion: 'Ensure htmlFor matches the id of a form control, or remove the attribute if it does not reference an existing element.',
|
|
156
|
+
codeSnippet: '<label htmlFor="missing-id">Email</label>',
|
|
157
|
+
fixSnippet: '<label htmlFor="email">Email</label>\n<input id="email" type="email" />',
|
|
158
|
+
file: filePath,
|
|
159
|
+
line: node.loc?.start.line,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const hasNonEmptyStringAttr = (name) => {
|
|
164
|
+
const value = getStringValue(name);
|
|
165
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
166
|
+
};
|
|
167
|
+
const containsVisibleText = (node) => {
|
|
168
|
+
if (!node)
|
|
169
|
+
return false;
|
|
170
|
+
if (node.type === 'JSXText')
|
|
171
|
+
return node.value.trim().length > 0;
|
|
172
|
+
if (node.type === 'JSXExpressionContainer') {
|
|
173
|
+
return (node.expression?.type === 'StringLiteral' &&
|
|
174
|
+
node.expression.value.trim().length > 0);
|
|
175
|
+
}
|
|
176
|
+
if (node.type === 'JSXElement') {
|
|
177
|
+
return node.children.some(containsVisibleText);
|
|
178
|
+
}
|
|
179
|
+
return false;
|
|
180
|
+
};
|
|
181
|
+
const hasVisibleTextName = () => {
|
|
182
|
+
const parent = path.parentPath?.node;
|
|
183
|
+
if (!parent || parent.type !== 'JSXElement')
|
|
184
|
+
return false;
|
|
185
|
+
return parent.children.some(containsVisibleText);
|
|
186
|
+
};
|
|
187
|
+
const hasDescendantElement = (element, tag) => {
|
|
188
|
+
if (!element || element.type !== 'JSXElement')
|
|
189
|
+
return false;
|
|
190
|
+
return element.children.some((child) => {
|
|
191
|
+
if (child.type !== 'JSXElement')
|
|
192
|
+
return false;
|
|
193
|
+
const childName = child.openingElement?.name?.name;
|
|
194
|
+
return childName === tag || hasDescendantElement(child, tag);
|
|
195
|
+
});
|
|
196
|
+
};
|
|
197
|
+
const hasTrackCaptions = (element) => {
|
|
198
|
+
if (!element || element.type !== 'JSXElement')
|
|
199
|
+
return false;
|
|
200
|
+
return element.children.some((child) => {
|
|
201
|
+
if (child.type !== 'JSXElement' || child.openingElement?.name?.name !== 'track')
|
|
202
|
+
return false;
|
|
203
|
+
return child.openingElement.attributes.some((attr) => attr.type === 'JSXAttribute' &&
|
|
204
|
+
attr.name.type === 'JSXIdentifier' &&
|
|
205
|
+
attr.name.name === 'kind' &&
|
|
206
|
+
attr.value?.type === 'StringLiteral' &&
|
|
207
|
+
attr.value.value.toLowerCase() === 'captions');
|
|
208
|
+
});
|
|
209
|
+
};
|
|
210
|
+
const hasAccessibleName = () => hasNonEmptyStringAttr('aria-label') ||
|
|
211
|
+
hasNonEmptyStringAttr('aria-labelledby') ||
|
|
212
|
+
hasNonEmptyStringAttr('title') ||
|
|
213
|
+
hasNonEmptyStringAttr('alt') ||
|
|
214
|
+
hasVisibleTextName();
|
|
215
|
+
const hasKeyboardSupport = () => hasAttr('onKeyDown') || hasAttr('onKeyUp') || hasAttr('onKeyPress');
|
|
216
|
+
const role = getStringValue('role');
|
|
217
|
+
const elementHasAction = hasAttr('onClick');
|
|
218
|
+
const isHiddenFromAT = getStringValue('aria-hidden')?.toLowerCase() === 'true';
|
|
219
|
+
const hasEmptyAriaReference = (name) => hasAttr(name) && !hasNonEmptyStringAttr(name);
|
|
220
|
+
const hasInvalidAriaReference = (name) => {
|
|
221
|
+
const value = getStringValue(name)?.trim();
|
|
222
|
+
if (!value)
|
|
223
|
+
return false;
|
|
224
|
+
return value
|
|
225
|
+
.split(/\s+/)
|
|
226
|
+
.filter(Boolean)
|
|
227
|
+
.some((id) => !declaredIds.has(id));
|
|
228
|
+
};
|
|
229
|
+
const interactiveAriaRoles = new Set([
|
|
230
|
+
'button',
|
|
231
|
+
'link',
|
|
232
|
+
'checkbox',
|
|
233
|
+
'switch',
|
|
234
|
+
'radio',
|
|
235
|
+
'menuitem',
|
|
236
|
+
'tab',
|
|
237
|
+
]);
|
|
238
|
+
const needsTabIndex = role !== null &&
|
|
239
|
+
interactiveAriaRoles.has(role) &&
|
|
240
|
+
!['button', 'a', 'input', 'select', 'textarea'].includes(tagName);
|
|
241
|
+
const isInteractiveRole = role !== null && interactiveAriaRoles.has(role);
|
|
242
|
+
if (hasEmptyAriaReference('aria-labelledby') || hasEmptyAriaReference('aria-describedby')) {
|
|
243
|
+
issues.push({
|
|
244
|
+
id: 'aria-reference-empty',
|
|
245
|
+
category: 'accessibility',
|
|
246
|
+
title: 'aria-labelledby or aria-describedby has an empty reference',
|
|
247
|
+
description: 'aria-labelledby and aria-describedby must reference a non-empty ID. An empty reference does not provide a usable accessible name or description. Violates WCAG 2.1 SC 4.1.2 (Level A).',
|
|
248
|
+
impact: 'critical',
|
|
249
|
+
status: 'fail',
|
|
250
|
+
suggestion: 'Provide a valid ID reference for aria-labelledby or aria-describedby, or remove the empty attribute.',
|
|
251
|
+
codeSnippet: '<div aria-labelledby=""></div>',
|
|
252
|
+
fixSnippet: '<div aria-labelledby="label-id"></div>',
|
|
253
|
+
file: filePath,
|
|
254
|
+
line: node.loc?.start.line,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
if (hasInvalidAriaReference('aria-labelledby') || hasInvalidAriaReference('aria-describedby')) {
|
|
258
|
+
issues.push({
|
|
259
|
+
id: 'aria-reference-invalid',
|
|
260
|
+
category: 'accessibility',
|
|
261
|
+
title: 'aria-labelledby or aria-describedby references a missing ID',
|
|
262
|
+
description: 'aria-labelledby and aria-describedby must point to an existing element ID. Missing IDs mean the accessible name or description cannot be resolved. Violates WCAG 2.1 SC 4.1.2 (Level A).',
|
|
263
|
+
impact: 'critical',
|
|
264
|
+
status: 'fail',
|
|
265
|
+
suggestion: 'Use a valid ID that exists in the document, or remove the invalid aria-labelledby / aria-describedby attribute.',
|
|
266
|
+
codeSnippet: '<div aria-labelledby="missing-id"></div>',
|
|
267
|
+
fixSnippet: '<div aria-labelledby="existing-id"></div>',
|
|
268
|
+
file: filePath,
|
|
269
|
+
line: node.loc?.start.line,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
if (needsTabIndex && !hasAttr('tabIndex')) {
|
|
273
|
+
issues.push({
|
|
274
|
+
id: 'role-interactive-missing-tabindex',
|
|
275
|
+
category: 'accessibility',
|
|
276
|
+
title: `element with role="${role}" is missing tabIndex`,
|
|
277
|
+
description: 'Custom role-based controls must be keyboard focusable. Elements with an interactive ARIA role need tabIndex=0 when they are not native focusable elements. Violates WCAG 2.1 SC 2.1.1 (Level A).',
|
|
278
|
+
impact: 'critical',
|
|
279
|
+
status: 'fail',
|
|
280
|
+
suggestion: 'Add tabIndex={0} to the element so keyboard users can focus and interact with it.',
|
|
281
|
+
codeSnippet: `<div role="${role}"></div>`,
|
|
282
|
+
fixSnippet: `<div role="${role}" tabIndex={0}></div>`,
|
|
283
|
+
file: filePath,
|
|
284
|
+
line: node.loc?.start.line,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
if (hasNonEmptyStringAttr('title') && !hasNonEmptyStringAttr('aria-label') && !hasNonEmptyStringAttr('aria-labelledby') && !hasVisibleTextName()) {
|
|
288
|
+
issues.push({
|
|
289
|
+
id: 'title-only-accessible-name',
|
|
290
|
+
category: 'accessibility',
|
|
291
|
+
title: 'Interactive control relies only on title for accessible name',
|
|
292
|
+
description: 'The title attribute alone is not a reliable accessible name for interactive controls. Screen readers may not announce it consistently. Violates WCAG 2.1 SC 4.1.2 (Level A).',
|
|
293
|
+
impact: 'critical',
|
|
294
|
+
status: 'fail',
|
|
295
|
+
suggestion: 'Provide a visible label, aria-label, or aria-labelledby instead of relying solely on title.',
|
|
296
|
+
codeSnippet: '<button title="Submit"></button>',
|
|
297
|
+
fixSnippet: '<button aria-label="Submit"></button>',
|
|
298
|
+
file: filePath,
|
|
299
|
+
line: node.loc?.start.line,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
if ((tagName === 'button' || tagName === 'a') && !hasAccessibleName()) {
|
|
303
|
+
const id = tagName === 'button' ? 'button-missing-accessible-name' : 'anchor-missing-accessible-name';
|
|
304
|
+
const title = tagName === 'button' ? '<button> has no accessible name' : '<a> has no accessible name';
|
|
305
|
+
const description = tagName === 'button'
|
|
306
|
+
? 'Buttons must have a visible label or an accessible name so screen reader users can understand their purpose. Violates WCAG 2.1 SC 4.1.2 (Level A).'
|
|
307
|
+
: 'Links must have visible text or an accessible name so screen reader users can understand their destination. Violates WCAG 2.1 SC 4.1.2 (Level A).';
|
|
308
|
+
const suggestion = tagName === 'button'
|
|
309
|
+
? 'Add visible text, aria-label, or aria-labelledby to describe the button action.'
|
|
310
|
+
: 'Provide visible link text or an aria-label/aria-labelledby value that describes the link target.';
|
|
311
|
+
const codeSnippet = tagName === 'button' ? '<button></button>' : '<a href="/about"></a>';
|
|
312
|
+
const fixSnippet = tagName === 'button' ? '<button>Submit</button>' : '<a href="/about">About us</a>';
|
|
313
|
+
issues.push({
|
|
314
|
+
id,
|
|
315
|
+
category: 'accessibility',
|
|
316
|
+
title,
|
|
317
|
+
description,
|
|
318
|
+
impact: 'critical',
|
|
319
|
+
status: 'fail',
|
|
320
|
+
suggestion,
|
|
321
|
+
codeSnippet,
|
|
322
|
+
fixSnippet,
|
|
323
|
+
file: filePath,
|
|
324
|
+
line: node.loc?.start.line,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
if (role !== null && interactiveAriaRoles.has(role) && elementHasAction && !hasKeyboardSupport()) {
|
|
328
|
+
issues.push({
|
|
329
|
+
id: 'role-interactive-missing-keyboard',
|
|
330
|
+
category: 'accessibility',
|
|
331
|
+
title: `interactive element with role="${role}" is missing keyboard support`,
|
|
332
|
+
description: 'Elements with interactive ARIA roles that are activated by pointer events must also support keyboard interaction. Violates WCAG 2.1 SC 2.1.1 (Level A).',
|
|
333
|
+
impact: 'critical',
|
|
334
|
+
status: 'fail',
|
|
335
|
+
suggestion: 'Add keyboard event handlers such as onKeyDown or onKeyUp to support Enter / Space activation for keyboard users.',
|
|
336
|
+
codeSnippet: `<div role="${role}" onClick={handler}></div>`,
|
|
337
|
+
fixSnippet: `<div role="${role}" onClick={handler} onKeyDown={(e) => e.key === 'Enter' && handler()}></div>`,
|
|
338
|
+
file: filePath,
|
|
339
|
+
line: node.loc?.start.line,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
if (role === 'img' && tagName !== 'img' && !hasAccessibleName()) {
|
|
343
|
+
issues.push({
|
|
344
|
+
id: 'role-img-missing-accessible-name',
|
|
345
|
+
category: 'accessibility',
|
|
346
|
+
title: 'element with role="img" has no accessible name',
|
|
347
|
+
description: 'Elements with role="img" must provide an accessible name so screen readers can announce the image content. Violates WCAG 2.1 SC 4.1.2 (Level A).',
|
|
348
|
+
impact: 'critical',
|
|
349
|
+
status: 'fail',
|
|
350
|
+
suggestion: 'Provide an accessible name using alt, aria-label, or aria-labelledby for the image role.',
|
|
351
|
+
codeSnippet: `<div role="img"></div>`,
|
|
352
|
+
fixSnippet: `<div role="img" aria-label="Company logo"></div>`,
|
|
353
|
+
file: filePath,
|
|
354
|
+
line: node.loc?.start.line,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
if (isHiddenFromAT && (hasAttr('tabIndex') || elementHasAction || isInteractiveRole)) {
|
|
358
|
+
issues.push({
|
|
359
|
+
id: 'focusable-aria-hidden',
|
|
360
|
+
category: 'accessibility',
|
|
361
|
+
title: 'Focusable or interactive element is hidden from assistive technology',
|
|
362
|
+
description: 'An element with aria-hidden="true" should not be interactive or focusable because it is hidden from assistive technology. This creates a confusing experience for keyboard and screen reader users. Violates WCAG 2.1 SC 1.3.1 (Level A).',
|
|
363
|
+
impact: 'critical',
|
|
364
|
+
status: 'fail',
|
|
365
|
+
suggestion: 'Remove aria-hidden="true" from interactive elements, or make the element non-focusable and non-interactive when it is hidden from assistive technology.',
|
|
366
|
+
codeSnippet: '<div aria-hidden="true" tabIndex={0} onClick={handler}>',
|
|
367
|
+
fixSnippet: '<div tabIndex={-1}>...</div>',
|
|
368
|
+
file: filePath,
|
|
369
|
+
line: node.loc?.start.line,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
if ((role === 'button' || role === 'link') && tagName !== 'button' && tagName !== 'a') {
|
|
373
|
+
if (!hasAccessibleName()) {
|
|
374
|
+
issues.push({
|
|
375
|
+
id: 'role-missing-accessible-name',
|
|
376
|
+
category: 'accessibility',
|
|
377
|
+
title: `element with role="${role}" has no accessible name`,
|
|
378
|
+
description: 'Elements with role="button" or role="link" must have an accessible name so assistive technology can announce their purpose. Violates WCAG 2.1 SC 4.1.2 (Level A).',
|
|
379
|
+
impact: 'critical',
|
|
380
|
+
status: 'fail',
|
|
381
|
+
suggestion: 'Provide an accessible name using visible text, aria-label, or aria-labelledby for the role-based control.',
|
|
382
|
+
codeSnippet: `<div role="${role}"></div>`,
|
|
383
|
+
fixSnippet: `<div role="${role}" aria-label="Submit form"></div>`,
|
|
384
|
+
file: filePath,
|
|
385
|
+
line: node.loc?.start.line,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
// ── Check 4: <a> without href or with placeholder href ────────────
|
|
390
|
+
//
|
|
391
|
+
// WCAG 2.1 SC 4.1.2 and general accessible navigation
|
|
392
|
+
// Anchors without a valid href are not real links and confuse
|
|
393
|
+
// keyboard and assistive technology users.
|
|
394
|
+
if (tagName === 'a') {
|
|
395
|
+
const href = getStringValue('href');
|
|
396
|
+
if (!hasAttr('href') || !href || href === '#' || href.toLowerCase().startsWith('javascript:')) {
|
|
397
|
+
issues.push({
|
|
398
|
+
id: 'anchor-missing-href',
|
|
399
|
+
category: 'accessibility',
|
|
400
|
+
title: '<a> is missing a valid href attribute',
|
|
401
|
+
description: 'An anchor without a valid href is not a real navigational link, and keyboard or screen reader users cannot use it like a normal link. Use a <button> for actions or provide a real URL for navigation.',
|
|
402
|
+
impact: 'critical',
|
|
403
|
+
status: 'fail',
|
|
404
|
+
suggestion: 'Use a real href for navigation, or replace the anchor with a button if the element performs an action instead of linking to another page.',
|
|
405
|
+
codeSnippet: '<a>Learn more</a>',
|
|
406
|
+
fixSnippet: '<a href="/about">Learn more</a>',
|
|
407
|
+
file: filePath,
|
|
408
|
+
line: node.loc?.start.line,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// ── Check 5: <input> with no accessible label ─────────────────────
|
|
413
|
+
//
|
|
414
|
+
// WCAG 2.1 SC 4.1.2 (Level A)
|
|
415
|
+
// Every input must have a visible or programmatically associated label.
|
|
416
|
+
if (tagName === 'input') {
|
|
417
|
+
const inputType = getStringValue('type')?.toLowerCase();
|
|
418
|
+
if (inputType === 'image') {
|
|
419
|
+
if (!hasAttr('alt')) {
|
|
420
|
+
issues.push({
|
|
421
|
+
id: 'input-image-missing-alt',
|
|
422
|
+
category: 'accessibility',
|
|
423
|
+
title: '<input type="image"> is missing an alt attribute',
|
|
424
|
+
description: 'Image buttons must have alternative text so screen readers can announce their purpose. Violates WCAG 2.1 SC 1.1.1 (Level A).',
|
|
425
|
+
impact: 'critical',
|
|
426
|
+
status: 'fail',
|
|
427
|
+
suggestion: 'Add a meaningful alt attribute to describe the button action, e.g. alt="Submit form".',
|
|
428
|
+
codeSnippet: '<input type="image" src={sendIcon} />',
|
|
429
|
+
fixSnippet: '<input type="image" src={sendIcon} alt="Send message" />',
|
|
430
|
+
file: filePath,
|
|
431
|
+
line: node.loc?.start.line,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
// Check for checkbox missing label
|
|
437
|
+
if (inputType === 'checkbox') {
|
|
438
|
+
const hasLabel = hasAccessibleName();
|
|
439
|
+
if (!hasLabel) {
|
|
440
|
+
issues.push({
|
|
441
|
+
id: 'checkbox-missing-label',
|
|
442
|
+
category: 'accessibility',
|
|
443
|
+
title: '<input type="checkbox"> is missing an accessible label',
|
|
444
|
+
description: 'Checkboxes must have an associated <label> element with a matching htmlFor attribute or be wrapped by a label. Without a label, screen reader users cannot identify the checkbox\'s purpose. Violates WCAG 2.1 SC 1.3.1 (Level A) and 4.1.2 (Level A).',
|
|
445
|
+
impact: 'major',
|
|
446
|
+
status: 'fail',
|
|
447
|
+
suggestion: 'Associate a label with the checkbox using:\n<label htmlFor="checkbox1">\n <input type="checkbox" id="checkbox1" /> Option\n</label>',
|
|
448
|
+
codeSnippet: '<input type="checkbox" />',
|
|
449
|
+
fixSnippet: '<label htmlFor="cb1">\n <input type="checkbox" id="cb1" /> Label text\n</label>',
|
|
450
|
+
file: filePath,
|
|
451
|
+
line: node.loc?.start.line,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
// Check for radio button missing label
|
|
457
|
+
if (inputType === 'radio') {
|
|
458
|
+
const hasLabel = hasAccessibleName();
|
|
459
|
+
if (!hasLabel) {
|
|
460
|
+
issues.push({
|
|
461
|
+
id: 'radio-missing-label',
|
|
462
|
+
category: 'accessibility',
|
|
463
|
+
title: '<input type="radio"> is missing an accessible label',
|
|
464
|
+
description: 'Radio buttons must have an associated <label> element with a matching htmlFor attribute or be wrapped by a label. Without a label, screen reader users cannot identify the radio button\'s purpose. Violates WCAG 2.1 SC 1.3.1 (Level A) and 4.1.2 (Level A).',
|
|
465
|
+
impact: 'major',
|
|
466
|
+
status: 'fail',
|
|
467
|
+
suggestion: 'Associate a label with the radio button using:\n<label htmlFor="radio1">\n <input type="radio" id="radio1" name="group" /> Option\n</label>',
|
|
468
|
+
codeSnippet: '<input type="radio" name="group" />',
|
|
469
|
+
fixSnippet: '<label htmlFor="r1">\n <input type="radio" id="r1" name="group" /> Option\n</label>',
|
|
470
|
+
file: filePath,
|
|
471
|
+
line: node.loc?.start.line,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
// hidden, submit, button, reset have implicit labels or no need
|
|
477
|
+
const EXEMPT_TYPES = new Set(['hidden', 'submit', 'button', 'reset']);
|
|
478
|
+
if (inputType && EXEMPT_TYPES.has(inputType))
|
|
479
|
+
return;
|
|
480
|
+
const hasId = hasAttr('id');
|
|
481
|
+
const hasAriaLabel = hasAttr('aria-label');
|
|
482
|
+
const hasAriaLabelledBy = hasAttr('aria-labelledby');
|
|
483
|
+
if (!hasId && !hasAriaLabel && !hasAriaLabelledBy) {
|
|
484
|
+
issues.push({
|
|
485
|
+
id: 'input-no-accessible-label',
|
|
486
|
+
category: 'accessibility',
|
|
487
|
+
title: `<input${inputType ? ` type="${inputType}"` : ''}> has no accessible label`,
|
|
488
|
+
description: 'This input has no id (for a <label htmlFor> to point at), no aria-label, and no aria-labelledby. Screen readers will announce it as just "edit field" with no context — the user cannot tell what to type. Violates WCAG 2.1 SC 4.1.2 (Level A).',
|
|
489
|
+
impact: 'major',
|
|
490
|
+
status: 'fail',
|
|
491
|
+
suggestion: 'Option A — Link to a visible label (preferred):\n <label htmlFor="username">Username</label>\n <input id="username" type="text" />\n\nOption B — Inline label for space-constrained UI:\n <input type="search" aria-label="Search products" />\n\nOption C — Label from another element:\n <h2 id="results-heading">Search results</h2>\n <input aria-labelledby="results-heading" />',
|
|
492
|
+
codeSnippet: `<input type="${inputType || 'text'}" />`,
|
|
493
|
+
fixSnippet: `<input type="${inputType || 'text'}" aria-label="Describe this field" />`,
|
|
494
|
+
file: filePath,
|
|
495
|
+
line: node.loc?.start.line,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
// ── Check 6: <select> with no accessible label ────────────────────
|
|
500
|
+
//
|
|
501
|
+
// WCAG 2.1 SC 4.1.2 (Level A)
|
|
502
|
+
if (tagName === 'select') {
|
|
503
|
+
const hasId = hasAttr('id');
|
|
504
|
+
const hasAriaLabel = hasAttr('aria-label');
|
|
505
|
+
const hasAriaLabelledBy = hasAttr('aria-labelledby');
|
|
506
|
+
if (!hasId && !hasAriaLabel && !hasAriaLabelledBy) {
|
|
507
|
+
issues.push({
|
|
508
|
+
id: 'select-no-accessible-label',
|
|
509
|
+
category: 'accessibility',
|
|
510
|
+
title: '<select> has no accessible label',
|
|
511
|
+
description: 'A select dropdown without a label or accessible name is ambiguous to screen reader users. Violates WCAG 2.1 SC 4.1.2 (Level A).',
|
|
512
|
+
impact: 'major',
|
|
513
|
+
status: 'fail',
|
|
514
|
+
suggestion: 'Add a visible <label> paired with the select, or provide aria-label / aria-labelledby.',
|
|
515
|
+
codeSnippet: '<select>...</select>',
|
|
516
|
+
fixSnippet: '<label htmlFor="country">Country</label>\n<select id="country">...</select>',
|
|
517
|
+
file: filePath,
|
|
518
|
+
line: node.loc?.start.line,
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
// ── Check 7: <textarea> with no accessible label ──────────────────
|
|
523
|
+
//
|
|
524
|
+
// WCAG 2.1 SC 4.1.2 (Level A)
|
|
525
|
+
if (tagName === 'textarea') {
|
|
526
|
+
const hasId = hasAttr('id');
|
|
527
|
+
const hasAriaLabel = hasAttr('aria-label');
|
|
528
|
+
const hasAriaLabelledBy = hasAttr('aria-labelledby');
|
|
529
|
+
if (!hasId && !hasAriaLabel && !hasAriaLabelledBy) {
|
|
530
|
+
issues.push({
|
|
531
|
+
id: 'textarea-no-accessible-label',
|
|
532
|
+
category: 'accessibility',
|
|
533
|
+
title: '<textarea> has no accessible label',
|
|
534
|
+
description: 'A textarea without a label or accessible name is ambiguous to screen reader users. Violates WCAG 2.1 SC 4.1.2 (Level A).',
|
|
535
|
+
impact: 'major',
|
|
536
|
+
status: 'fail',
|
|
537
|
+
suggestion: 'Add a visible <label> paired with the textarea, or provide aria-label / aria-labelledby.',
|
|
538
|
+
codeSnippet: '<textarea></textarea>',
|
|
539
|
+
fixSnippet: '<label htmlFor="message">Message</label>\n<textarea id="message"></textarea>',
|
|
540
|
+
file: filePath,
|
|
541
|
+
line: node.loc?.start.line,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
// ── Check 8: <iframe> missing accessible title ───────────────────
|
|
546
|
+
//
|
|
547
|
+
// WCAG 2.1 SC 1.1.1 and 2.4.1 (Level A)
|
|
548
|
+
if (tagName === 'iframe') {
|
|
549
|
+
const hasTitle = hasAttr('title');
|
|
550
|
+
const hasAriaLabel = hasAttr('aria-label');
|
|
551
|
+
const hasAriaLabelledBy = hasAttr('aria-labelledby');
|
|
552
|
+
if (!hasTitle && !hasAriaLabel && !hasAriaLabelledBy) {
|
|
553
|
+
issues.push({
|
|
554
|
+
id: 'iframe-missing-accessible-title',
|
|
555
|
+
category: 'accessibility',
|
|
556
|
+
title: '<iframe> is missing an accessible title',
|
|
557
|
+
description: 'Frames must have an accessible name so screen reader users understand the embedded content. Violates WCAG 2.1 SC 1.1.1 and SC 2.4.1 (Level A).',
|
|
558
|
+
impact: 'major',
|
|
559
|
+
status: 'fail',
|
|
560
|
+
suggestion: 'Add a descriptive title, aria-label, or aria-labelledby to the iframe.',
|
|
561
|
+
codeSnippet: '<iframe src="map.html"></iframe>',
|
|
562
|
+
fixSnippet: '<iframe src="map.html" title="Map of downtown Seattle"></iframe>',
|
|
563
|
+
file: filePath,
|
|
564
|
+
line: node.loc?.start.line,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
// ── Check 8b: <embed> or <object> missing accessible alternative ──
|
|
569
|
+
//
|
|
570
|
+
// WCAG 2.1 SC 1.1.1 (Level A)
|
|
571
|
+
if (tagName === 'embed' || tagName === 'object') {
|
|
572
|
+
const hasAlt = hasAttr('alt') || hasAttr('aria-label') || hasAttr('title');
|
|
573
|
+
if (!hasAlt) {
|
|
574
|
+
issues.push({
|
|
575
|
+
id: 'embed-object-missing-accessible-alternative',
|
|
576
|
+
category: 'accessibility',
|
|
577
|
+
title: `<${tagName}> is missing an accessible alternative`,
|
|
578
|
+
description: `The <${tagName}> element is used to embed external content (plugins, documents, etc.). It must have an accessible alternative such as alt text, aria-label, or descriptive surrounding text. Without this, screen reader users cannot access the content. Violates WCAG 2.1 SC 1.1.1 (Level A).`,
|
|
579
|
+
impact: 'major',
|
|
580
|
+
status: 'fail',
|
|
581
|
+
suggestion: `Provide an accessible name or description:\n<${tagName} src="content" aria-label="Description of content" />\nor\n<${tagName} src="content" title="Description of content" />`,
|
|
582
|
+
codeSnippet: `<${tagName} src="file.pdf" />`,
|
|
583
|
+
fixSnippet: `<${tagName} src="file.pdf" aria-label="Annual Report PDF" />`,
|
|
584
|
+
file: filePath,
|
|
585
|
+
line: node.loc?.start.line,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
// ── Check 9: <table> missing caption or accessible name ─────────
|
|
590
|
+
//
|
|
591
|
+
// WCAG 2.1 SC 1.3.1 and 2.4.2 (Level A)
|
|
592
|
+
if (tagName === 'table') {
|
|
593
|
+
const parent = path.parentPath?.node;
|
|
594
|
+
const hasCaption = parent?.children?.some((child) => child.type === 'JSXElement' &&
|
|
595
|
+
child.openingElement?.name?.name === 'caption');
|
|
596
|
+
const hasAriaLabel = hasAttr('aria-label');
|
|
597
|
+
const hasAriaLabelledBy = hasAttr('aria-labelledby');
|
|
598
|
+
if (!hasCaption && !hasAriaLabel && !hasAriaLabelledBy) {
|
|
599
|
+
issues.push({
|
|
600
|
+
id: 'table-missing-caption-or-label',
|
|
601
|
+
category: 'accessibility',
|
|
602
|
+
title: '<table> is missing a caption or accessible name',
|
|
603
|
+
description: 'Data tables need a caption or an accessible name so screen reader users can understand the table purpose. Violates WCAG 2.1 SC 1.3.1 and SC 2.4.2 (Level A).',
|
|
604
|
+
impact: 'major',
|
|
605
|
+
status: 'fail',
|
|
606
|
+
suggestion: 'Add a <caption> to the table or provide aria-label / aria-labelledby.',
|
|
607
|
+
codeSnippet: '<table>...</table>',
|
|
608
|
+
fixSnippet: '<table aria-label="Quarterly sales data">...</table>',
|
|
609
|
+
file: filePath,
|
|
610
|
+
line: node.loc?.start.line,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
// ── Check 10: <fieldset> missing legend or accessible label ──────
|
|
615
|
+
//
|
|
616
|
+
// WCAG 2.1 SC 1.3.1 (Level A)
|
|
617
|
+
if (tagName === 'fieldset') {
|
|
618
|
+
const parent = path.parentPath?.node;
|
|
619
|
+
const hasLegend = parent?.children?.some((child) => child.type === 'JSXElement' &&
|
|
620
|
+
child.openingElement?.name?.name === 'legend');
|
|
621
|
+
const hasAriaLabel = hasAttr('aria-label');
|
|
622
|
+
const hasAriaLabelledBy = hasAttr('aria-labelledby');
|
|
623
|
+
if (!hasLegend && !hasAriaLabel && !hasAriaLabelledBy) {
|
|
624
|
+
issues.push({
|
|
625
|
+
id: 'fieldset-missing-legend-or-label',
|
|
626
|
+
category: 'accessibility',
|
|
627
|
+
title: '<fieldset> is missing a legend or accessible label',
|
|
628
|
+
description: 'Fieldset groups need a legend or an accessible name so users understand the form grouping. Violates WCAG 2.1 SC 1.3.1 (Level A).',
|
|
629
|
+
impact: 'major',
|
|
630
|
+
status: 'fail',
|
|
631
|
+
suggestion: 'Add a <legend> inside the fieldset or provide aria-label / aria-labelledby.',
|
|
632
|
+
codeSnippet: '<fieldset>...</fieldset>',
|
|
633
|
+
fixSnippet: '<fieldset>\n <legend>Payment information</legend>\n ...\n</fieldset>',
|
|
634
|
+
file: filePath,
|
|
635
|
+
line: node.loc?.start.line,
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
// ── Check 10.1: <details> missing <summary> or accessible name ────
|
|
640
|
+
//
|
|
641
|
+
// WCAG 2.1 SC 2.4.3 (Level A)
|
|
642
|
+
if (tagName === 'details') {
|
|
643
|
+
const parent = path.parentPath?.node;
|
|
644
|
+
const hasSummary = parent?.children?.some((child) => child.type === 'JSXElement' &&
|
|
645
|
+
child.openingElement?.name?.name === 'summary');
|
|
646
|
+
const hasAriaLabel = hasAttr('aria-label');
|
|
647
|
+
const hasAriaLabelledBy = hasAttr('aria-labelledby');
|
|
648
|
+
if (!hasSummary && !hasAriaLabel && !hasAriaLabelledBy) {
|
|
649
|
+
issues.push({
|
|
650
|
+
id: 'details-missing-summary',
|
|
651
|
+
category: 'accessibility',
|
|
652
|
+
title: '<details> is missing a <summary> or accessible name',
|
|
653
|
+
description: 'A <details> element must include a <summary> or accessible name to describe its purpose and make the disclosure control available to keyboard and screen reader users. Violates WCAG 2.1 SC 2.4.3 (Level A).',
|
|
654
|
+
impact: 'major',
|
|
655
|
+
status: 'fail',
|
|
656
|
+
suggestion: 'Add a <summary> child or provide aria-label / aria-labelledby for the details element.',
|
|
657
|
+
codeSnippet: '<details>\n <p>More info</p>\n</details>',
|
|
658
|
+
fixSnippet: '<details>\n <summary>More information</summary>\n <p>More info</p>\n</details>',
|
|
659
|
+
file: filePath,
|
|
660
|
+
line: node.loc?.start.line,
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
// ── Check 11: <html> missing lang attribute ──────────────────────
|
|
665
|
+
//
|
|
666
|
+
// WCAG 2.1 SC 3.1.1 (Level A)
|
|
667
|
+
if (tagName === 'html') {
|
|
668
|
+
if (!hasAttr('lang')) {
|
|
669
|
+
issues.push({
|
|
670
|
+
id: 'html-missing-lang',
|
|
671
|
+
category: 'accessibility',
|
|
672
|
+
title: '<html> is missing a lang attribute',
|
|
673
|
+
description: 'The document language must be declared so screen readers can use the correct pronunciation. Violates WCAG 2.1 SC 3.1.1 (Level A).',
|
|
674
|
+
impact: 'major',
|
|
675
|
+
status: 'fail',
|
|
676
|
+
suggestion: 'Add a lang attribute to the <html> element, for example lang="en".',
|
|
677
|
+
codeSnippet: '<html>...</html>',
|
|
678
|
+
fixSnippet: '<html lang="en">...</html>',
|
|
679
|
+
file: filePath,
|
|
680
|
+
line: node.loc?.start.line,
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
// ── Check 12: <area> missing an accessible name ────────────────
|
|
685
|
+
//
|
|
686
|
+
// WCAG 2.1 SC 1.1.1 (Level A)
|
|
687
|
+
if (tagName === 'area') {
|
|
688
|
+
if (!hasAttr('alt') && !hasAttr('aria-label') && !hasAttr('aria-labelledby')) {
|
|
689
|
+
issues.push({
|
|
690
|
+
id: 'area-missing-accessible-name',
|
|
691
|
+
category: 'accessibility',
|
|
692
|
+
title: '<area> is missing an accessible name',
|
|
693
|
+
description: 'Image map areas must have a text alternative so screen reader users can understand each target. Violates WCAG 2.1 SC 1.1.1 (Level A).',
|
|
694
|
+
impact: 'major',
|
|
695
|
+
status: 'fail',
|
|
696
|
+
suggestion: 'Add alt, aria-label, or aria-labelledby to the <area> element.',
|
|
697
|
+
codeSnippet: '<area shape="rect" coords="0,0,100,100" href="/shop" />',
|
|
698
|
+
fixSnippet: '<area shape="rect" coords="0,0,100,100" href="/shop" alt="Shop section" />',
|
|
699
|
+
file: filePath,
|
|
700
|
+
line: node.loc?.start.line,
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
// ── Check 13: <video> missing captions ─────────────────────────
|
|
705
|
+
//
|
|
706
|
+
// WCAG 2.1 SC 1.2.2 (Level A)
|
|
707
|
+
if (tagName === 'video') {
|
|
708
|
+
const parent = path.parentPath?.node;
|
|
709
|
+
if (!hasTrackCaptions(parent)) {
|
|
710
|
+
issues.push({
|
|
711
|
+
id: 'video-missing-captions',
|
|
712
|
+
category: 'accessibility',
|
|
713
|
+
title: '<video> is missing captions',
|
|
714
|
+
description: 'Pre-recorded video content must provide captions so users who cannot hear the audio can understand it. Violates WCAG 2.1 SC 1.2.2 (Level A).',
|
|
715
|
+
impact: 'major',
|
|
716
|
+
status: 'fail',
|
|
717
|
+
suggestion: 'Add a <track kind="captions" src="..."> child to the video element.',
|
|
718
|
+
codeSnippet: '<video src="promo.mp4" controls></video>',
|
|
719
|
+
fixSnippet: '<video src="promo.mp4" controls>\n <track kind="captions" src="promo-captions.vtt" label="English captions" />\n</video>',
|
|
720
|
+
file: filePath,
|
|
721
|
+
line: node.loc?.start.line,
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
// ── Check 14: <audio> missing captions/transcript ───────────────
|
|
726
|
+
//
|
|
727
|
+
// WCAG 2.1 SC 1.2.2 (Level A)
|
|
728
|
+
if (tagName === 'audio') {
|
|
729
|
+
const parent = path.parentPath?.node;
|
|
730
|
+
if (!hasTrackCaptions(parent)) {
|
|
731
|
+
issues.push({
|
|
732
|
+
id: 'audio-missing-captions',
|
|
733
|
+
category: 'accessibility',
|
|
734
|
+
title: '<audio> is missing captions or a transcript',
|
|
735
|
+
description: 'Pre-recorded audio content requires captions or a transcript for users who cannot hear the audio. Violates WCAG 2.1 SC 1.2.2 (Level A).',
|
|
736
|
+
impact: 'major',
|
|
737
|
+
status: 'fail',
|
|
738
|
+
suggestion: 'Add a caption track or provide a transcript for the audio content.',
|
|
739
|
+
codeSnippet: '<audio src="podcast.mp3" controls></audio>',
|
|
740
|
+
fixSnippet: '<audio src="podcast.mp3" controls>\n <track kind="captions" src="podcast-captions.vtt" label="English captions" />\n</audio>',
|
|
741
|
+
file: filePath,
|
|
742
|
+
line: node.loc?.start.line,
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
// ── Check 15: aria landmark / region missing accessible name ─────
|
|
747
|
+
//
|
|
748
|
+
// WCAG 2.1 SC 2.4.2 (Level A)
|
|
749
|
+
if ((tagName === 'nav' || tagName === 'aside' ||
|
|
750
|
+
role === 'navigation' || role === 'region' || role === 'search' ||
|
|
751
|
+
role === 'banner' || role === 'complementary') &&
|
|
752
|
+
!hasAccessibleName()) {
|
|
753
|
+
issues.push({
|
|
754
|
+
id: 'landmark-missing-accessible-name',
|
|
755
|
+
category: 'accessibility',
|
|
756
|
+
title: 'Landmark region is missing an accessible name',
|
|
757
|
+
description: 'Landmark regions such as navigation and region elements need an accessible label so screen reader users can distinguish them. Violates WCAG 2.1 SC 2.4.2 (Level A).',
|
|
758
|
+
impact: 'major',
|
|
759
|
+
status: 'fail',
|
|
760
|
+
suggestion: 'Add an accessible name using aria-label or aria-labelledby to the landmark.',
|
|
761
|
+
codeSnippet: '<nav>...</nav>',
|
|
762
|
+
fixSnippet: '<nav aria-label="Main navigation">...</nav>',
|
|
763
|
+
file: filePath,
|
|
764
|
+
line: node.loc?.start.line,
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
// ── Check 16: <table> missing header cells ──────────────────────
|
|
768
|
+
//
|
|
769
|
+
// WCAG 2.1 SC 1.3.1 (Level A)
|
|
770
|
+
if (tagName === 'table') {
|
|
771
|
+
const parent = path.parentPath?.node;
|
|
772
|
+
const hasCaption = parent?.children?.some((child) => child.type === 'JSXElement' &&
|
|
773
|
+
child.openingElement?.name?.name === 'caption');
|
|
774
|
+
const hasTableLabel = hasAttr('aria-label') || hasAttr('aria-labelledby');
|
|
775
|
+
if (!hasTableLabel && !hasCaption && !hasDescendantElement(parent, 'th')) {
|
|
776
|
+
issues.push({
|
|
777
|
+
id: 'table-missing-headers',
|
|
778
|
+
category: 'accessibility',
|
|
779
|
+
title: '<table> is missing header cells',
|
|
780
|
+
description: 'Data tables should include header cells (<th>) so screen reader users can understand the relationships between row and column data. Violates WCAG 2.1 SC 1.3.1 (Level A).',
|
|
781
|
+
impact: 'major',
|
|
782
|
+
status: 'fail',
|
|
783
|
+
suggestion: 'Use <th> for header cells in the table header row or provide row/column headers using scope.',
|
|
784
|
+
codeSnippet: '<table>...</table>',
|
|
785
|
+
fixSnippet: '<table>\n <thead>\n <tr><th scope="col">Name</th><th scope="col">Role</th></tr>\n </thead>\n ...\n</table>',
|
|
786
|
+
file: filePath,
|
|
787
|
+
line: node.loc?.start.line,
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
// ── Check 17: <details> missing <summary> ────────────────────────
|
|
792
|
+
//
|
|
793
|
+
// WCAG 2.1 SC 1.3.1 (Level A)
|
|
794
|
+
if (tagName === 'details') {
|
|
795
|
+
const parent = path.parentPath?.node;
|
|
796
|
+
const hasSummary = hasDescendantElement(parent, 'summary');
|
|
797
|
+
if (!hasSummary) {
|
|
798
|
+
issues.push({
|
|
799
|
+
id: 'details-missing-summary',
|
|
800
|
+
category: 'accessibility',
|
|
801
|
+
title: '<details> is missing a <summary>',
|
|
802
|
+
description: 'The <details> element requires a <summary> child so users can understand what the collapsible section contains. Violates WCAG 2.1 SC 1.3.1 (Level A).',
|
|
803
|
+
impact: 'major',
|
|
804
|
+
status: 'fail',
|
|
805
|
+
suggestion: 'Add a <summary> as the first child of the <details> element.',
|
|
806
|
+
codeSnippet: '<details>Hidden content</details>',
|
|
807
|
+
fixSnippet: '<details>\n <summary>Additional information</summary>\n Hidden content\n</details>',
|
|
808
|
+
file: filePath,
|
|
809
|
+
line: node.loc?.start.line,
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
// ── Check 18: <label> htmlFor pointing to missing id ──────────────
|
|
814
|
+
//
|
|
815
|
+
// WCAG 2.1 SC 4.1.2 (Level A)
|
|
816
|
+
if (tagName === 'label') {
|
|
817
|
+
const htmlFor = getStringValue('htmlFor');
|
|
818
|
+
if (htmlFor && !declaredIds.has(htmlFor)) {
|
|
819
|
+
issues.push({
|
|
820
|
+
id: 'label-htmlfor-invalid-id',
|
|
821
|
+
category: 'accessibility',
|
|
822
|
+
title: '<label> htmlFor points to a non-existent element',
|
|
823
|
+
description: 'A label\'s htmlFor attribute must reference an existing form control id. If the id does not exist, the label cannot associate with the form control. Violates WCAG 2.1 SC 4.1.2 (Level A).',
|
|
824
|
+
impact: 'major',
|
|
825
|
+
status: 'fail',
|
|
826
|
+
suggestion: 'Ensure the referenced id exists on the form control, or remove the htmlFor attribute.',
|
|
827
|
+
codeSnippet: '<label htmlFor="missing-id">Email</label>',
|
|
828
|
+
fixSnippet: '<label htmlFor="email-input">Email</label>\n<input id="email-input" type="email" />',
|
|
829
|
+
file: filePath,
|
|
830
|
+
line: node.loc?.start.line,
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
// ── Check 19: <img> with empty alt not marked as decorative ──────
|
|
835
|
+
//
|
|
836
|
+
// WCAG 2.1 SC 1.1.1 (Level A)
|
|
837
|
+
if (tagName === 'img') {
|
|
838
|
+
const alt = getStringValue('alt');
|
|
839
|
+
const role = getStringValue('role');
|
|
840
|
+
if (alt === '' && role !== 'presentation' && role !== 'none') {
|
|
841
|
+
issues.push({
|
|
842
|
+
id: 'img-empty-alt-not-decorative',
|
|
843
|
+
category: 'accessibility',
|
|
844
|
+
title: '<img> has empty alt but is not marked as decorative',
|
|
845
|
+
description: 'An image with alt="" is marked as decorative, but if the image has semantic meaning, use role="presentation" or role="none". If it is truly decorative, no additional attributes are needed. Violates WCAG 2.1 SC 1.1.1 (Level A).',
|
|
846
|
+
impact: 'major',
|
|
847
|
+
status: 'fail',
|
|
848
|
+
suggestion: 'If the image is decorative, no fix is needed. If the image conveys meaning, provide a meaningful alt text instead of an empty string.',
|
|
849
|
+
codeSnippet: '<img src="spacer.png" alt="" />',
|
|
850
|
+
fixSnippet: '<img src="icon.png" alt="Important notification" />',
|
|
851
|
+
file: filePath,
|
|
852
|
+
line: node.loc?.start.line,
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
// ── Check 20: <form> without programmatically associated labels ──
|
|
857
|
+
//
|
|
858
|
+
// WCAG 2.1 SC 3.3.2 (Level A)
|
|
859
|
+
if (tagName === 'form') {
|
|
860
|
+
const parent = path.parentPath?.node;
|
|
861
|
+
const inputs = parent?.children?.filter((child) => child.type === 'JSXElement' &&
|
|
862
|
+
(child.openingElement?.name?.name === 'input' ||
|
|
863
|
+
child.openingElement?.name?.name === 'select' ||
|
|
864
|
+
child.openingElement?.name?.name === 'textarea')) || [];
|
|
865
|
+
const hasLabels = inputs.some((input) => {
|
|
866
|
+
const inputId = input.openingElement.attributes.find((attr) => attr.type === 'JSXAttribute' &&
|
|
867
|
+
attr.name.type === 'JSXIdentifier' &&
|
|
868
|
+
attr.name.name === 'id');
|
|
869
|
+
return inputId;
|
|
870
|
+
});
|
|
871
|
+
if (inputs.length > 0 && !hasLabels) {
|
|
872
|
+
issues.push({
|
|
873
|
+
id: 'form-missing-associated-labels',
|
|
874
|
+
category: 'accessibility',
|
|
875
|
+
title: '<form> has controls without associated labels',
|
|
876
|
+
description: 'Form controls should have programmatically associated labels using <label> with htmlFor, or aria-label / aria-labelledby. Violates WCAG 2.1 SC 3.3.2 (Level A).',
|
|
877
|
+
impact: 'major',
|
|
878
|
+
status: 'fail',
|
|
879
|
+
suggestion: 'Add <label> elements paired to form control ids, or provide aria-label / aria-labelledby to each control.',
|
|
880
|
+
codeSnippet: '<form>\n <input type="text" />\n <button>Submit</button>\n</form>',
|
|
881
|
+
fixSnippet: '<form>\n <label htmlFor="name">Name</label>\n <input id="name" type="text" />\n <button>Submit</button>\n</form>',
|
|
882
|
+
file: filePath,
|
|
883
|
+
line: node.loc?.start.line,
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
// ── Check 21: <input> with aria-invalid without error message ────
|
|
888
|
+
//
|
|
889
|
+
// WCAG 2.1 SC 3.3.1 (Level A)
|
|
890
|
+
if (tagName === 'input' &&
|
|
891
|
+
getStringValue('aria-invalid')?.toLowerCase() === 'true') {
|
|
892
|
+
const hasAriaDescribedBy = hasAttr('aria-describedby');
|
|
893
|
+
const hasErrorId = hasAriaDescribedBy
|
|
894
|
+
? getStringValue('aria-describedby')
|
|
895
|
+
?.split(/\s+/)
|
|
896
|
+
.some((id) => declaredIds.has(id))
|
|
897
|
+
: false;
|
|
898
|
+
if (!hasErrorId) {
|
|
899
|
+
issues.push({
|
|
900
|
+
id: 'input-invalid-without-error-message',
|
|
901
|
+
category: 'accessibility',
|
|
902
|
+
title: '<input> marked as invalid but has no error message',
|
|
903
|
+
description: 'When aria-invalid="true" is used to mark a form control as invalid, the control should also have aria-describedby pointing to an error message. Screen reader users need to know why the field failed validation. Violates WCAG 2.1 SC 3.3.1 (Level A).',
|
|
904
|
+
impact: 'major',
|
|
905
|
+
status: 'fail',
|
|
906
|
+
suggestion: 'Add aria-describedby to point to an error message element:\n\n<input aria-invalid="true" aria-describedby="error-msg" />\n<div id="error-msg">Email address is invalid</div>',
|
|
907
|
+
codeSnippet: '<input aria-invalid="true" />',
|
|
908
|
+
fixSnippet: '<input aria-invalid="true" aria-describedby="email-error" />\n<div id="email-error">Please enter a valid email</div>',
|
|
909
|
+
file: filePath,
|
|
910
|
+
line: node.loc?.start.line,
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
// ── Check 22: Page missing <title> element ──────────────────────
|
|
915
|
+
//
|
|
916
|
+
// WCAG 2.1 SC 2.4.2 (Level A)
|
|
917
|
+
if (tagName === 'head') {
|
|
918
|
+
const parent = path.parentPath?.node;
|
|
919
|
+
const hasTitle = parent?.children?.some((child) => child.type === 'JSXElement' &&
|
|
920
|
+
child.openingElement?.name?.name === 'title');
|
|
921
|
+
if (!hasTitle) {
|
|
922
|
+
issues.push({
|
|
923
|
+
id: 'page-missing-title',
|
|
924
|
+
category: 'accessibility',
|
|
925
|
+
title: '<head> is missing a <title> element',
|
|
926
|
+
description: 'Every page must have a unique, descriptive <title> that identifies the page purpose. Screen reader users rely on the page title to understand the page context. Violates WCAG 2.1 SC 2.4.2 (Level A).',
|
|
927
|
+
impact: 'major',
|
|
928
|
+
status: 'fail',
|
|
929
|
+
suggestion: 'Add a descriptive <title> to the <head> that describes the page content, for example: <title>Contact Us — Example Company</title>',
|
|
930
|
+
codeSnippet: '<head>...</head>',
|
|
931
|
+
fixSnippet: '<head>\n <title>Contact Us — Example Company</title>\n ...\n</head>',
|
|
932
|
+
file: filePath,
|
|
933
|
+
line: node.loc?.start.line,
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
// ── Check 23: Missing skip link to main content ──────────────────
|
|
938
|
+
//
|
|
939
|
+
// WCAG 2.1 SC 2.4.1 (Level A)
|
|
940
|
+
if (tagName === 'body') {
|
|
941
|
+
const parent = path.parentPath?.node;
|
|
942
|
+
const hasSkipLink = parent?.children?.some((child) => {
|
|
943
|
+
if (child.type !== 'JSXElement' || child.openingElement?.name?.name !== 'a') {
|
|
944
|
+
return false;
|
|
945
|
+
}
|
|
946
|
+
const href = child.openingElement.attributes?.find((attr) => attr.type === 'JSXAttribute' &&
|
|
947
|
+
attr.name.type === 'JSXIdentifier' &&
|
|
948
|
+
attr.name.name === 'href');
|
|
949
|
+
const hrefValue = href?.value?.type === 'StringLiteral'
|
|
950
|
+
? href.value.value
|
|
951
|
+
: null;
|
|
952
|
+
return hrefValue?.startsWith('#');
|
|
953
|
+
});
|
|
954
|
+
if (!hasSkipLink) {
|
|
955
|
+
issues.push({
|
|
956
|
+
id: 'missing-skip-link',
|
|
957
|
+
category: 'accessibility',
|
|
958
|
+
title: 'Page is missing a skip link to main content',
|
|
959
|
+
description: 'A skip link allows keyboard and screen reader users to bypass repetitive navigation and jump directly to main content. Without it, users must navigate through all navigation elements on every page. Violates WCAG 2.1 SC 2.4.1 (Level A).',
|
|
960
|
+
impact: 'major',
|
|
961
|
+
status: 'fail',
|
|
962
|
+
suggestion: 'Add a skip link as the first element in the body, typically styled to be visually hidden until focused:\n\n<a href="#main-content" className="skip-link">Skip to main content</a>\n\nThen ensure your main content has id="main-content".',
|
|
963
|
+
codeSnippet: '<body>...</body>',
|
|
964
|
+
fixSnippet: '<body>\n <a href="#main-content" className="sr-only">Skip to main content</a>\n ...\n <main id="main-content">...</main>\n</body>',
|
|
965
|
+
file: filePath,
|
|
966
|
+
line: node.loc?.start.line,
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
// ── Check 24: <meta> viewport missing or unoptimized ──────────────
|
|
971
|
+
//
|
|
972
|
+
// WCAG 2.1 SC 1.4.4 (Level A)
|
|
973
|
+
if (tagName === 'meta') {
|
|
974
|
+
const name = getStringValue('name');
|
|
975
|
+
if (name?.toLowerCase() === 'viewport') {
|
|
976
|
+
const content = getStringValue('content');
|
|
977
|
+
if (!content || !content.includes('width') || content.includes('user-scalable=no')) {
|
|
978
|
+
issues.push({
|
|
979
|
+
id: 'meta-viewport-unoptimized',
|
|
980
|
+
category: 'accessibility',
|
|
981
|
+
title: '<meta viewport> is missing or disables zoom',
|
|
982
|
+
description: 'The viewport meta tag should allow users to zoom the page. Disabling zoom (user-scalable=no) prevents users with low vision from enlarging content. Violates WCAG 2.1 SC 1.4.4 (Level A).',
|
|
983
|
+
impact: 'major',
|
|
984
|
+
status: 'fail',
|
|
985
|
+
suggestion: 'Use a standard viewport tag that allows zoom:\n<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=yes" />',
|
|
986
|
+
codeSnippet: '<meta name="viewport" content="user-scalable=no" />',
|
|
987
|
+
fixSnippet: '<meta name="viewport" content="width=device-width, initial-scale=1" />',
|
|
988
|
+
file: filePath,
|
|
989
|
+
line: node.loc?.start.line,
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
// ── Check 25: <select> or <input> with optgroup missing labels ────
|
|
995
|
+
//
|
|
996
|
+
// WCAG 2.1 SC 1.3.1 (Level A)
|
|
997
|
+
if (tagName === 'optgroup') {
|
|
998
|
+
const parent = path.parentPath?.node;
|
|
999
|
+
const label = parent?.openingElement?.attributes?.find((attr) => attr.type === 'JSXAttribute' &&
|
|
1000
|
+
attr.name.type === 'JSXIdentifier' &&
|
|
1001
|
+
attr.name.name === 'label');
|
|
1002
|
+
if (!label) {
|
|
1003
|
+
issues.push({
|
|
1004
|
+
id: 'optgroup-missing-label',
|
|
1005
|
+
category: 'accessibility',
|
|
1006
|
+
title: '<optgroup> is missing a label attribute',
|
|
1007
|
+
description: 'Option groups in select elements must have a label so screen reader users understand the grouping. Violates WCAG 2.1 SC 1.3.1 (Level A).',
|
|
1008
|
+
impact: 'major',
|
|
1009
|
+
status: 'fail',
|
|
1010
|
+
suggestion: 'Add a descriptive label attribute to the optgroup element.',
|
|
1011
|
+
codeSnippet: '<optgroup>\n <option>Option 1</option>\n</optgroup>',
|
|
1012
|
+
fixSnippet: '<optgroup label="Group Name">\n <option>Option 1</option>\n</optgroup>',
|
|
1013
|
+
file: filePath,
|
|
1014
|
+
line: node.loc?.start.line,
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
// ── Check 26: <button> inside <form> without type attribute ───────
|
|
1019
|
+
//
|
|
1020
|
+
// WCAG 2.1 SC 4.1.2 (Level A)
|
|
1021
|
+
if (tagName === 'button') {
|
|
1022
|
+
const formParent = path.findParent((p) => p.isJSXElement && p.node.openingElement?.name?.name === 'form');
|
|
1023
|
+
if (formParent && !hasAttr('type')) {
|
|
1024
|
+
issues.push({
|
|
1025
|
+
id: 'button-in-form-missing-type',
|
|
1026
|
+
category: 'accessibility',
|
|
1027
|
+
title: '<button> in <form> should have an explicit type attribute',
|
|
1028
|
+
description: 'Buttons inside forms should explicitly specify their type (submit, reset, button) to avoid unexpected form submission. Screen readers also announce the button type more clearly. Violates WCAG 2.1 SC 4.1.2 (Level A).',
|
|
1029
|
+
impact: 'major',
|
|
1030
|
+
status: 'fail',
|
|
1031
|
+
suggestion: 'Add type="submit", type="reset", or type="button" to clarify the button\'s action.',
|
|
1032
|
+
codeSnippet: '<button>Submit</button>',
|
|
1033
|
+
fixSnippet: '<button type="submit">Submit</button>',
|
|
1034
|
+
file: filePath,
|
|
1035
|
+
line: node.loc?.start.line,
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
// ── Check 27: Image with alt longer than expected or too vague ────
|
|
1040
|
+
//
|
|
1041
|
+
// WCAG 2.1 SC 1.1.1 (Level A)
|
|
1042
|
+
if (tagName === 'img') {
|
|
1043
|
+
const alt = getStringValue('alt');
|
|
1044
|
+
if (typeof alt === 'string') {
|
|
1045
|
+
if (alt.toLowerCase().includes('image') || alt.toLowerCase().includes('picture') || alt.toLowerCase() === 'icon') {
|
|
1046
|
+
issues.push({
|
|
1047
|
+
id: 'img-alt-text-redundant',
|
|
1048
|
+
category: 'accessibility',
|
|
1049
|
+
title: '<img> alt text is redundant or too vague',
|
|
1050
|
+
description: 'Alt text should describe what the image shows, not use generic words like "image" or "picture". Screen readers already announce images as images. Violates WCAG 2.1 SC 1.1.1 (Level A).',
|
|
1051
|
+
impact: 'major',
|
|
1052
|
+
status: 'fail',
|
|
1053
|
+
suggestion: 'Describe what the image shows specifically: alt="A golden retriever playing fetch in the park"',
|
|
1054
|
+
codeSnippet: `<img src="dog.jpg" alt="image of a dog" />`,
|
|
1055
|
+
fixSnippet: `<img src="dog.jpg" alt="Golden retriever running in grass" />`,
|
|
1056
|
+
file: filePath,
|
|
1057
|
+
line: node.loc?.start.line,
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
// ── Check 28: Form input with invalid autocomplete value ──────────
|
|
1063
|
+
//
|
|
1064
|
+
// WCAG 2.1 SC 1.3.5 (Level AA)
|
|
1065
|
+
if (tagName === 'input') {
|
|
1066
|
+
const autocomplete = getStringValue('autocomplete');
|
|
1067
|
+
const validAutocompletes = new Set([
|
|
1068
|
+
'off', 'on', 'name', 'email', 'username', 'new-password',
|
|
1069
|
+
'current-password', 'one-time-code', 'organization-title',
|
|
1070
|
+
'organization', 'street-address', 'address-line1', 'address-line2',
|
|
1071
|
+
'address-line3', 'address-level4', 'address-level3', 'address-level2',
|
|
1072
|
+
'address-level1', 'country', 'country-name', 'postal-code', 'cc-name',
|
|
1073
|
+
'cc-given-name', 'cc-family-name', 'cc-number', 'cc-exp', 'cc-exp-month',
|
|
1074
|
+
'cc-exp-year', 'cc-csc', 'cc-type', 'transaction-currency', 'transaction-amount',
|
|
1075
|
+
'language', 'bday', 'bday-day', 'bday-month', 'bday-year', 'sex', 'url',
|
|
1076
|
+
'photo', 'tel', 'tel-country-code', 'tel-national', 'tel-area-code',
|
|
1077
|
+
'tel-local', 'tel-extension', 'impp', 'nickname', 'given-name', 'family-name',
|
|
1078
|
+
'additional-name', 'honorific-prefix', 'honorific-suffix', 'webauthn'
|
|
1079
|
+
]);
|
|
1080
|
+
if (autocomplete && !validAutocompletes.has(autocomplete.toLowerCase())) {
|
|
1081
|
+
issues.push({
|
|
1082
|
+
id: 'input-invalid-autocomplete',
|
|
1083
|
+
category: 'accessibility',
|
|
1084
|
+
title: '<input> has an invalid autocomplete value',
|
|
1085
|
+
description: 'The autocomplete attribute should use valid values from the HTML spec. Invalid values may confuse assistive technology. Violates WCAG 2.1 SC 1.3.5 (Level AA).',
|
|
1086
|
+
impact: 'major',
|
|
1087
|
+
status: 'fail',
|
|
1088
|
+
suggestion: 'Use a valid autocomplete value like "email", "password", "given-name", etc.',
|
|
1089
|
+
codeSnippet: `<input autocomplete="invalid-value" />`,
|
|
1090
|
+
fixSnippet: `<input type="email" autocomplete="email" />`,
|
|
1091
|
+
file: filePath,
|
|
1092
|
+
line: node.loc?.start.line,
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
// ── Check 29: <li> not inside <ul> or <ol> ──────────────────────
|
|
1097
|
+
//
|
|
1098
|
+
// WCAG 2.1 SC 1.3.1 (Level A)
|
|
1099
|
+
if (tagName === 'li') {
|
|
1100
|
+
const parent = path.parentPath?.node;
|
|
1101
|
+
const parentTag = parent?.type === 'JSXElement' ? parent.openingElement?.name?.name : '';
|
|
1102
|
+
if (parentTag !== 'ul' && parentTag !== 'ol') {
|
|
1103
|
+
issues.push({
|
|
1104
|
+
id: 'list-item-not-in-list',
|
|
1105
|
+
category: 'accessibility',
|
|
1106
|
+
title: '<li> is not inside <ul> or <ol>',
|
|
1107
|
+
description: 'List items must be direct children of <ul> or <ol>. Using <li> outside of a list breaks semantic structure and confuses screen readers. Violates WCAG 2.1 SC 1.3.1 (Level A).',
|
|
1108
|
+
impact: 'major',
|
|
1109
|
+
status: 'fail',
|
|
1110
|
+
suggestion: 'Move the <li> inside a <ul> (unordered) or <ol> (ordered) list, or convert <li> to a different element if it is not a list item.',
|
|
1111
|
+
codeSnippet: '<div>\n <li>Item 1</li>\n</div>',
|
|
1112
|
+
fixSnippet: '<ul>\n <li>Item 1</li>\n</ul>',
|
|
1113
|
+
file: filePath,
|
|
1114
|
+
line: node.loc?.start.line,
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
// ── Check 30: <select> with only one <option> ────────────────────
|
|
1119
|
+
//
|
|
1120
|
+
// WCAG 2.1 SC 1.3.1 (Level A)
|
|
1121
|
+
if (tagName === 'select') {
|
|
1122
|
+
const parent = path.parentPath?.node;
|
|
1123
|
+
const optionCount = parent?.children?.filter((child) => child.type === 'JSXElement' &&
|
|
1124
|
+
child.openingElement?.name?.name === 'option').length || 0;
|
|
1125
|
+
if (optionCount === 1) {
|
|
1126
|
+
issues.push({
|
|
1127
|
+
id: 'select-with-single-option',
|
|
1128
|
+
category: 'accessibility',
|
|
1129
|
+
title: '<select> has only one <option>',
|
|
1130
|
+
description: 'A dropdown with a single choice is not a true selection control and can confuse users. If there is only one choice, use a different UI pattern or explain why selection is needed.',
|
|
1131
|
+
impact: 'major',
|
|
1132
|
+
status: 'fail',
|
|
1133
|
+
suggestion: 'Either add more options to the dropdown, or replace it with static text or a different UI component if selection is not needed.',
|
|
1134
|
+
codeSnippet: '<select>\n <option>Only choice</option>\n</select>',
|
|
1135
|
+
fixSnippet: '<select>\n <option>Option 1</option>\n <option>Option 2</option>\n</select>',
|
|
1136
|
+
file: filePath,
|
|
1137
|
+
line: node.loc?.start.line,
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
// ── Check 31: <form> missing submit button ──────────────────────
|
|
1142
|
+
//
|
|
1143
|
+
// WCAG 2.1 SC 3.3.2 (Level A)
|
|
1144
|
+
if (tagName === 'form') {
|
|
1145
|
+
const parent = path.parentPath?.node;
|
|
1146
|
+
const hasSubmitButton = parent?.children?.some((child) => {
|
|
1147
|
+
if (child.type !== 'JSXElement' || child.openingElement?.name?.name !== 'button') {
|
|
1148
|
+
return false;
|
|
1149
|
+
}
|
|
1150
|
+
const typeAttr = child.openingElement.attributes?.find((attr) => attr.type === 'JSXAttribute' &&
|
|
1151
|
+
attr.name.type === 'JSXIdentifier' &&
|
|
1152
|
+
attr.name.name === 'type');
|
|
1153
|
+
const typeValue = typeAttr?.value?.type === 'StringLiteral' ? typeAttr.value.value : 'submit';
|
|
1154
|
+
return typeValue === 'submit' || !typeAttr;
|
|
1155
|
+
});
|
|
1156
|
+
if (!hasSubmitButton) {
|
|
1157
|
+
issues.push({
|
|
1158
|
+
id: 'form-missing-submit-button',
|
|
1159
|
+
category: 'accessibility',
|
|
1160
|
+
title: '<form> is missing an explicit submit button',
|
|
1161
|
+
description: 'Forms should include a button with type="submit" to provide users with a clear way to submit the form. Without one, it may be unclear how to complete the action. Violates WCAG 2.1 SC 3.3.2 (Level A).',
|
|
1162
|
+
impact: 'major',
|
|
1163
|
+
status: 'fail',
|
|
1164
|
+
suggestion: 'Add a submit button to the form:\n<button type="submit">Submit</button>',
|
|
1165
|
+
codeSnippet: '<form>\n <input type="text" />\n</form>',
|
|
1166
|
+
fixSnippet: '<form>\n <input type="text" />\n <button type="submit">Submit</button>\n</form>',
|
|
1167
|
+
file: filePath,
|
|
1168
|
+
line: node.loc?.start.line,
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
// ── Check 34: <figure> without <figcaption> ────────────────────
|
|
1173
|
+
//
|
|
1174
|
+
// WCAG 2.1 SC 1.3.1 (Level A)
|
|
1175
|
+
if (tagName === 'figure') {
|
|
1176
|
+
const parent = path.parentPath?.node;
|
|
1177
|
+
const hasFigcaption = parent?.children?.some((child) => child.type === 'JSXElement' &&
|
|
1178
|
+
child.openingElement?.name?.name === 'figcaption');
|
|
1179
|
+
if (!hasFigcaption && hasDescendantElement(parent, 'img')) {
|
|
1180
|
+
issues.push({
|
|
1181
|
+
id: 'figure-missing-figcaption',
|
|
1182
|
+
category: 'accessibility',
|
|
1183
|
+
title: '<figure> with image is missing a <figcaption>',
|
|
1184
|
+
description: 'Figures containing images or complex content should include a <figcaption> to describe the figure purpose. Without it, screen reader users cannot understand the relationship between the figure and the content. Violates WCAG 2.1 SC 1.3.1 (Level A).',
|
|
1185
|
+
impact: 'major',
|
|
1186
|
+
status: 'fail',
|
|
1187
|
+
suggestion: 'Add a descriptive <figcaption> inside the figure element.',
|
|
1188
|
+
codeSnippet: '<figure>\n <img src="chart.png" alt="Bar chart" />\n</figure>',
|
|
1189
|
+
fixSnippet: '<figure>\n <img src="chart.png" alt="Bar chart" />\n <figcaption>Figure 1: Revenue growth 2022-2024</figcaption>\n</figure>',
|
|
1190
|
+
file: filePath,
|
|
1191
|
+
line: node.loc?.start.line,
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
// ── Check 35: <noscript> element present ──────────────────────
|
|
1196
|
+
//
|
|
1197
|
+
// WCAG 2.1 SC 4.1.1 (Level A)
|
|
1198
|
+
if (tagName === 'noscript') {
|
|
1199
|
+
issues.push({
|
|
1200
|
+
id: 'noscript-element-present',
|
|
1201
|
+
category: 'accessibility',
|
|
1202
|
+
title: '<noscript> element found in JavaScript application',
|
|
1203
|
+
description: '<noscript> is typically used to provide fallback content when JavaScript is disabled. In modern React/Next.js applications, script dependencies are often required, but if they are truly optional, ensure <noscript> content is accessible. Violates WCAG 2.1 SC 4.1.1 (Level A) if critical content is script-dependent.',
|
|
1204
|
+
impact: 'major',
|
|
1205
|
+
status: 'fail',
|
|
1206
|
+
suggestion: 'If JavaScript is truly required for functionality, document this. If JavaScript is optional, ensure <noscript> contains helpful fallback instructions or information.',
|
|
1207
|
+
codeSnippet: '<noscript>Please enable JavaScript</noscript>',
|
|
1208
|
+
fixSnippet: '<noscript>This application requires JavaScript. Please enable it to continue.</noscript>',
|
|
1209
|
+
file: filePath,
|
|
1210
|
+
line: node.loc?.start.line,
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
// ── Check 36: <a> with only icon or aria-label without visible purpose ──
|
|
1214
|
+
//
|
|
1215
|
+
// WCAG 2.1 SC 2.4.4 (Level A)
|
|
1216
|
+
if (tagName === 'a') {
|
|
1217
|
+
const hasVisibleText = hasVisibleTextName();
|
|
1218
|
+
const hasAriaLabel = hasNonEmptyStringAttr('aria-label');
|
|
1219
|
+
const hasTitle = hasNonEmptyStringAttr('title');
|
|
1220
|
+
if (!hasVisibleText && !hasAriaLabel && !hasTitle) {
|
|
1221
|
+
issues.push({
|
|
1222
|
+
id: 'link-missing-purpose',
|
|
1223
|
+
category: 'accessibility',
|
|
1224
|
+
title: '<a> link has no visible purpose',
|
|
1225
|
+
description: 'Links must have a clear, understandable purpose. This link has no visible text, aria-label, or title. Screen reader users cannot determine where the link goes. Violates WCAG 2.1 SC 2.4.4 (Level A).',
|
|
1226
|
+
impact: 'major',
|
|
1227
|
+
status: 'fail',
|
|
1228
|
+
suggestion: 'Provide visible link text or an aria-label that describes the link destination. For icon-only links:\n\n<a href="/about" aria-label="Learn more about us">\n <IconComponent />\n</a>',
|
|
1229
|
+
codeSnippet: '<a href="/about"><IconComponent /></a>',
|
|
1230
|
+
fixSnippet: '<a href="/about" aria-label="Learn more about us"><IconComponent /></a>',
|
|
1231
|
+
file: filePath,
|
|
1232
|
+
line: node.loc?.start.line,
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
// ── Check 37: <video> missing audio description track ────────────
|
|
1237
|
+
//
|
|
1238
|
+
// WCAG 2.1 SC 1.2.5 (Level AA)
|
|
1239
|
+
if (tagName === 'video') {
|
|
1240
|
+
const parent = path.parentPath?.node;
|
|
1241
|
+
const hasDescTrack = parent?.children?.some((child) => {
|
|
1242
|
+
if (child.type !== 'JSXElement' || child.openingElement?.name?.name !== 'track') {
|
|
1243
|
+
return false;
|
|
1244
|
+
}
|
|
1245
|
+
const kindAttr = child.openingElement.attributes?.find((attr) => attr.type === 'JSXAttribute' &&
|
|
1246
|
+
attr.name.type === 'JSXIdentifier' &&
|
|
1247
|
+
attr.name.name === 'kind');
|
|
1248
|
+
return kindAttr?.value?.type === 'StringLiteral' &&
|
|
1249
|
+
kindAttr.value.value.toLowerCase() === 'descriptions';
|
|
1250
|
+
});
|
|
1251
|
+
if (!hasDescTrack) {
|
|
1252
|
+
issues.push({
|
|
1253
|
+
id: 'video-missing-audio-description',
|
|
1254
|
+
category: 'accessibility',
|
|
1255
|
+
title: '<video> is missing an audio description track',
|
|
1256
|
+
description: 'Videos with important visual content should provide audio descriptions for users who cannot see the video. Add a <track kind="descriptions"> with a WebVTT file describing visual elements. Violates WCAG 2.1 SC 1.2.5 (Level AA).',
|
|
1257
|
+
impact: 'major',
|
|
1258
|
+
status: 'fail',
|
|
1259
|
+
suggestion: 'Add a descriptive audio track or text description:\n<video controls>\n <track kind="descriptions" src="video-descriptions.vtt" label="English descriptions" />\n <source src="video.mp4" type="video/mp4" />\n</video>',
|
|
1260
|
+
codeSnippet: '<video src="video.mp4" controls></video>',
|
|
1261
|
+
fixSnippet: '<video controls>\n <track kind="descriptions" src="descriptions.vtt" label="English" />\n <source src="video.mp4" type="video/mp4" />\n</video>',
|
|
1262
|
+
file: filePath,
|
|
1263
|
+
line: node.loc?.start.line,
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
// ── Check 38: Live region element without aria-live ───────────────
|
|
1268
|
+
//
|
|
1269
|
+
// WCAG 2.1 SC 4.1.3 (Level A)
|
|
1270
|
+
if ((tagName === 'div' || tagName === 'span' || tagName === 'section') &&
|
|
1271
|
+
(role === 'status' || role === 'alert' || role === 'log' || role === 'region')) {
|
|
1272
|
+
if (!hasAttr('aria-live')) {
|
|
1273
|
+
issues.push({
|
|
1274
|
+
id: 'live-region-missing-aria-live',
|
|
1275
|
+
category: 'accessibility',
|
|
1276
|
+
title: 'Live region element missing aria-live attribute',
|
|
1277
|
+
description: 'Elements with live region roles (status, alert, log, region) should have an aria-live attribute to notify screen readers of dynamic content changes. Violates WCAG 2.1 SC 4.1.3 (Level A).',
|
|
1278
|
+
impact: 'major',
|
|
1279
|
+
status: 'fail',
|
|
1280
|
+
suggestion: 'Add aria-live attribute with appropriate politeness level:\n- aria-live="polite" — for general updates (default for most cases)\n- aria-live="assertive" — for urgent updates like error messages\n- aria-live="off" — if not a live region',
|
|
1281
|
+
codeSnippet: `<div role="${role}">Status message</div>`,
|
|
1282
|
+
fixSnippet: `<div role="${role}" aria-live="polite">Status message</div>`,
|
|
1283
|
+
file: filePath,
|
|
1284
|
+
line: node.loc?.start.line,
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
// ── Check 39: aria-label hiding required text alternative ────────
|
|
1289
|
+
//
|
|
1290
|
+
// WCAG 2.1 SC 1.3.1 (Level A)
|
|
1291
|
+
if (hasAttr('aria-label') && hasVisibleTextName()) {
|
|
1292
|
+
const ariaLabel = getStringValue('aria-label');
|
|
1293
|
+
if (ariaLabel) {
|
|
1294
|
+
issues.push({
|
|
1295
|
+
id: 'aria-label-overriding-visible-text',
|
|
1296
|
+
category: 'accessibility',
|
|
1297
|
+
title: 'aria-label is overriding visible text',
|
|
1298
|
+
description: 'When both aria-label and visible text are present, aria-label takes precedence for screen readers. If they differ, it creates confusion between what sighted and non-sighted users see. Violates WCAG 2.1 SC 1.3.1 (Level A).',
|
|
1299
|
+
impact: 'major',
|
|
1300
|
+
status: 'fail',
|
|
1301
|
+
suggestion: 'Either remove aria-label if visible text is sufficient, or ensure aria-label matches or extends the visible text meaningfully.',
|
|
1302
|
+
codeSnippet: '<button aria-label="Save">Submit</button>',
|
|
1303
|
+
fixSnippet: '<button>Save</button>',
|
|
1304
|
+
file: filePath,
|
|
1305
|
+
line: node.loc?.start.line,
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
// ── Check 40: Foreign language text without lang attribute ────────
|
|
1310
|
+
//
|
|
1311
|
+
// WCAG 2.1 SC 3.1.2 (Level AA)
|
|
1312
|
+
if ((tagName === 'span' || tagName === 'div' || tagName === 'p') &&
|
|
1313
|
+
!hasAttr('lang') && hasVisibleTextName()) {
|
|
1314
|
+
// Note: This is a heuristic check and may have false positives
|
|
1315
|
+
const textContent = path.node.children?.filter((c) => c.type === 'JSXText' && c.value.trim().length > 0);
|
|
1316
|
+
if (textContent && textContent.length > 0) {
|
|
1317
|
+
// Only report if element has substantial text content and lang attribute is missing
|
|
1318
|
+
const hasLang = path.findParent((p) => p.isJSXElement && p.node.openingElement?.attributes?.some((a) => a.type === 'JSXAttribute' && a.name.name === 'lang'));
|
|
1319
|
+
if (!hasLang && role !== 'img') {
|
|
1320
|
+
// Only flag if it's likely a text element that could have language-specific content
|
|
1321
|
+
if (textContent.some((c) => /[àâäéèêëìîïòôöùûüç]/.test(c.value))) {
|
|
1322
|
+
issues.push({
|
|
1323
|
+
id: 'missing-lang-attribute-for-text',
|
|
1324
|
+
category: 'accessibility',
|
|
1325
|
+
title: 'Text with non-ASCII characters missing lang attribute',
|
|
1326
|
+
description: 'Content in languages other than the page language should be marked with a lang attribute. This helps screen readers pronounce text correctly. Violates WCAG 2.1 SC 3.1.2 (Level AA).',
|
|
1327
|
+
impact: 'major',
|
|
1328
|
+
status: 'fail',
|
|
1329
|
+
suggestion: 'Add lang attribute with the appropriate language code:\n<span lang="fr">Bonjour</span>',
|
|
1330
|
+
codeSnippet: '<span>Café</span>',
|
|
1331
|
+
fixSnippet: '<span lang="fr">Café</span>',
|
|
1332
|
+
file: filePath,
|
|
1333
|
+
line: node.loc?.start.line,
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
// ── Check 41: <video> with autoplay without muted or controls ────
|
|
1340
|
+
//
|
|
1341
|
+
// WCAG 2.1 SC 2.2.2 (Level A)
|
|
1342
|
+
if (tagName === 'video') {
|
|
1343
|
+
const hasAutoplay = hasAttr('autoplay');
|
|
1344
|
+
const hasMuted = hasAttr('muted');
|
|
1345
|
+
const hasControls = hasAttr('controls');
|
|
1346
|
+
if (hasAutoplay && !hasMuted) {
|
|
1347
|
+
issues.push({
|
|
1348
|
+
id: 'video-autoplay-without-muted',
|
|
1349
|
+
category: 'accessibility',
|
|
1350
|
+
title: '<video> with autoplay is not muted',
|
|
1351
|
+
description: 'Videos should not autoplay with unmuted audio as it can disorient users and violate their control preferences. If autoplay is needed, mute the audio or provide controls to stop playback. Violates WCAG 2.1 SC 2.2.2 (Level A).',
|
|
1352
|
+
impact: 'major',
|
|
1353
|
+
status: 'fail',
|
|
1354
|
+
suggestion: 'Either remove autoplay, add muted attribute, or provide explicit user controls:\n<video autoplay muted controls>...',
|
|
1355
|
+
codeSnippet: '<video autoplay>\n <source src="video.mp4" />\n</video>',
|
|
1356
|
+
fixSnippet: '<video autoplay muted controls>\n <source src="video.mp4" />\n</video>',
|
|
1357
|
+
file: filePath,
|
|
1358
|
+
line: node.loc?.start.line,
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
// ── Check 44: <button> or <a> with aria-disabled without role ────
|
|
1363
|
+
//
|
|
1364
|
+
// WCAG 2.1 SC 4.1.2 (Level A)
|
|
1365
|
+
if ((tagName === 'button' || tagName === 'a') && hasAttr('aria-disabled')) {
|
|
1366
|
+
if (!hasAttr('role')) {
|
|
1367
|
+
issues.push({
|
|
1368
|
+
id: 'aria-disabled-without-role',
|
|
1369
|
+
category: 'accessibility',
|
|
1370
|
+
title: `<${tagName}> with aria-disabled is missing a role`,
|
|
1371
|
+
description: `Buttons and links should not use aria-disabled as they are already semantic elements. If you need to disable a button, use the disabled attribute. For links, removing the href is better than using aria-disabled. Violates WCAG 2.1 SC 4.1.2 (Level A).`,
|
|
1372
|
+
impact: 'major',
|
|
1373
|
+
status: 'fail',
|
|
1374
|
+
suggestion: `Use the native disabled attribute for buttons or remove href from links instead of aria-disabled:\n<button disabled>Submit</button>\nor\n<button role="button" aria-disabled="true">Submit</button> (only for non-native elements)`,
|
|
1375
|
+
codeSnippet: `<${tagName} aria-disabled="true">Action</${tagName}>`,
|
|
1376
|
+
fixSnippet: tagName === 'button' ? '<button disabled>Submit</button>' : '<a href="/path">Link</a>',
|
|
1377
|
+
file: filePath,
|
|
1378
|
+
line: node.loc?.start.line,
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
// ── Check 45: Empty <ul> or <ol> ───────────────────────────────
|
|
1383
|
+
//
|
|
1384
|
+
// WCAG 2.1 SC 1.3.1 (Level A)
|
|
1385
|
+
if (tagName === 'ul' || tagName === 'ol') {
|
|
1386
|
+
const parent = path.parentPath?.node;
|
|
1387
|
+
const hasItems = parent?.children?.some((child) => child.type === 'JSXElement' &&
|
|
1388
|
+
child.openingElement?.name?.name === 'li');
|
|
1389
|
+
if (!hasItems) {
|
|
1390
|
+
issues.push({
|
|
1391
|
+
id: 'empty-list',
|
|
1392
|
+
category: 'accessibility',
|
|
1393
|
+
title: `<${tagName}> is empty or has no <li> children`,
|
|
1394
|
+
description: `Lists should contain at least one list item (<li>). An empty list confuses users and serves no semantic purpose. Violates WCAG 2.1 SC 1.3.1 (Level A).`,
|
|
1395
|
+
impact: 'major',
|
|
1396
|
+
status: 'fail',
|
|
1397
|
+
suggestion: `Add list items to the list or remove the empty list element:\n<${tagName}>\n <li>Item 1</li>\n <li>Item 2</li>\n</${tagName}>`,
|
|
1398
|
+
codeSnippet: `<${tagName}></${tagName}>`,
|
|
1399
|
+
fixSnippet: `<${tagName}>\n <li>Item 1</li>\n <li>Item 2</li>\n</${tagName}>`,
|
|
1400
|
+
file: filePath,
|
|
1401
|
+
line: node.loc?.start.line,
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
// ── Check 46: <option> outside <select> or <optgroup> ─────────
|
|
1406
|
+
//
|
|
1407
|
+
// WCAG 2.1 SC 1.3.1 (Level A)
|
|
1408
|
+
if (tagName === 'option') {
|
|
1409
|
+
const parent = path.parentPath?.node;
|
|
1410
|
+
const parentTag = parent?.type === 'JSXElement' ? parent.openingElement?.name?.name : '';
|
|
1411
|
+
if (parentTag !== 'select' && parentTag !== 'optgroup') {
|
|
1412
|
+
issues.push({
|
|
1413
|
+
id: 'option-outside-select',
|
|
1414
|
+
category: 'accessibility',
|
|
1415
|
+
title: '<option> is not inside <select> or <optgroup>',
|
|
1416
|
+
description: 'Options must be direct children of <select> or <optgroup>. Using <option> outside of these containers breaks semantic structure. Violates WCAG 2.1 SC 1.3.1 (Level A).',
|
|
1417
|
+
impact: 'major',
|
|
1418
|
+
status: 'fail',
|
|
1419
|
+
suggestion: 'Move the <option> inside a <select> or <optgroup>:\n<select>\n <option>Choice 1</option>\n</select>',
|
|
1420
|
+
codeSnippet: '<div><option>Invalid</option></div>',
|
|
1421
|
+
fixSnippet: '<select>\n <option>Choice 1</option>\n</select>',
|
|
1422
|
+
file: filePath,
|
|
1423
|
+
line: node.loc?.start.line,
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
// ── Check 47: <input type="checkbox"> or radio without label ────
|
|
1428
|
+
//
|
|
1429
|
+
// WCAG 2.1 SC 4.1.2 (Level A)
|
|
1430
|
+
if (tagName === 'input') {
|
|
1431
|
+
const inputType = getStringValue('type')?.toLowerCase();
|
|
1432
|
+
if (inputType === 'checkbox' || inputType === 'radio') {
|
|
1433
|
+
const hasId = hasAttr('id');
|
|
1434
|
+
const hasAriaLabel = hasAttr('aria-label');
|
|
1435
|
+
const hasAriaLabelledBy = hasAttr('aria-labelledby');
|
|
1436
|
+
if (!hasId && !hasAriaLabel && !hasAriaLabelledBy) {
|
|
1437
|
+
issues.push({
|
|
1438
|
+
id: `${inputType}-missing-label`,
|
|
1439
|
+
category: 'accessibility',
|
|
1440
|
+
title: `<input type="${inputType}"> has no accessible label`,
|
|
1441
|
+
description: `Checkboxes and radio buttons must have an associated label so users and screen readers can understand their purpose. Violates WCAG 2.1 SC 4.1.2 (Level A).`,
|
|
1442
|
+
impact: 'major',
|
|
1443
|
+
status: 'fail',
|
|
1444
|
+
suggestion: `Link the input to a label using matching id and htmlFor:\n<label htmlFor="agree"><input id="agree" type="${inputType}" /> I agree</label>`,
|
|
1445
|
+
codeSnippet: `<input type="${inputType}" />`,
|
|
1446
|
+
fixSnippet: `<label><input type="${inputType}" /> Option</label>`,
|
|
1447
|
+
file: filePath,
|
|
1448
|
+
line: node.loc?.start.line,
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
// ── Check 48: <th> without scope attribute ──────────────────────
|
|
1454
|
+
//
|
|
1455
|
+
// WCAG 2.1 SC 1.3.1 (Level A)
|
|
1456
|
+
if (tagName === 'th') {
|
|
1457
|
+
if (!hasAttr('scope')) {
|
|
1458
|
+
issues.push({
|
|
1459
|
+
id: 'th-missing-scope',
|
|
1460
|
+
category: 'accessibility',
|
|
1461
|
+
title: '<th> is missing a scope attribute',
|
|
1462
|
+
description: 'Table header cells should have a scope attribute to indicate if they are row or column headers. This helps screen readers understand table structure. Violates WCAG 2.1 SC 1.3.1 (Level A).',
|
|
1463
|
+
impact: 'major',
|
|
1464
|
+
status: 'fail',
|
|
1465
|
+
suggestion: 'Add scope="col" for column headers or scope="row" for row headers:\n<th scope="col">Column Name</th>',
|
|
1466
|
+
codeSnippet: '<th>Header</th>',
|
|
1467
|
+
fixSnippet: '<th scope="col">Header</th>',
|
|
1468
|
+
file: filePath,
|
|
1469
|
+
line: node.loc?.start.line,
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
// ── Check 49: <section> without heading ────────────────────────
|
|
1474
|
+
//
|
|
1475
|
+
// WCAG 2.1 SC 1.3.1 (Level A)
|
|
1476
|
+
if (tagName === 'section') {
|
|
1477
|
+
const parent = path.parentPath?.node;
|
|
1478
|
+
const hasHeading = parent?.children?.some((child) => {
|
|
1479
|
+
if (child.type !== 'JSXElement')
|
|
1480
|
+
return false;
|
|
1481
|
+
const childTag = child.openingElement?.name?.name;
|
|
1482
|
+
return childTag?.match(/^h[1-6]$/);
|
|
1483
|
+
});
|
|
1484
|
+
const hasAriaLabel = hasAttr('aria-label');
|
|
1485
|
+
const hasAriaLabelledBy = hasAttr('aria-labelledby');
|
|
1486
|
+
if (!hasHeading && !hasAriaLabel && !hasAriaLabelledBy) {
|
|
1487
|
+
issues.push({
|
|
1488
|
+
id: 'section-missing-heading',
|
|
1489
|
+
category: 'accessibility',
|
|
1490
|
+
title: '<section> is missing a heading or accessible name',
|
|
1491
|
+
description: 'Sections should have an associated heading or accessible name so screen reader users can understand the section purpose. Without it, the section is unlabeled and confusing. Violates WCAG 2.1 SC 1.3.1 (Level A).',
|
|
1492
|
+
impact: 'major',
|
|
1493
|
+
status: 'fail',
|
|
1494
|
+
suggestion: 'Add a heading inside the section or provide aria-label / aria-labelledby:\n<section>\n <h2>Section Title</h2>\n ...\n</section>',
|
|
1495
|
+
codeSnippet: '<section>...</section>',
|
|
1496
|
+
fixSnippet: '<section>\n <h2>Section Title</h2>\n ...\n</section>',
|
|
1497
|
+
file: filePath,
|
|
1498
|
+
line: node.loc?.start.line,
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
// ── Check 50: <aside> without accessible name ──────────────────
|
|
1503
|
+
//
|
|
1504
|
+
// WCAG 2.1 SC 1.3.1 (Level A)
|
|
1505
|
+
if (tagName === 'aside') {
|
|
1506
|
+
const hasAriaLabel = hasAttr('aria-label');
|
|
1507
|
+
const hasAriaLabelledBy = hasAttr('aria-labelledby');
|
|
1508
|
+
const hasHeading = hasVisibleTextName();
|
|
1509
|
+
if (!hasAriaLabel && !hasAriaLabelledBy && !hasHeading) {
|
|
1510
|
+
issues.push({
|
|
1511
|
+
id: 'aside-missing-accessible-name',
|
|
1512
|
+
category: 'accessibility',
|
|
1513
|
+
title: '<aside> is missing an accessible name',
|
|
1514
|
+
description: 'Aside elements (complementary content) should have an accessible name to identify their purpose. This helps screen reader users understand the relationship to main content. Violates WCAG 2.1 SC 1.3.1 (Level A).',
|
|
1515
|
+
impact: 'major',
|
|
1516
|
+
status: 'fail',
|
|
1517
|
+
suggestion: 'Add aria-label or aria-labelledby to the aside:\n<aside aria-label="Related articles">...</aside>',
|
|
1518
|
+
codeSnippet: '<aside>...</aside>',
|
|
1519
|
+
fixSnippet: '<aside aria-label="Related information">...</aside>',
|
|
1520
|
+
file: filePath,
|
|
1521
|
+
line: node.loc?.start.line,
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
// ── Check 51: <label> wrapping multiple form controls ──────────
|
|
1526
|
+
//
|
|
1527
|
+
// WCAG 2.1 SC 4.1.2 (Level A)
|
|
1528
|
+
if (tagName === 'label') {
|
|
1529
|
+
const parent = path.parentPath?.node;
|
|
1530
|
+
const formControlCount = parent?.children?.filter((child) => child.type === 'JSXElement' &&
|
|
1531
|
+
['input', 'select', 'textarea'].includes(child.openingElement?.name?.name)).length || 0;
|
|
1532
|
+
if (formControlCount > 1) {
|
|
1533
|
+
issues.push({
|
|
1534
|
+
id: 'label-wrapping-multiple-controls',
|
|
1535
|
+
category: 'accessibility',
|
|
1536
|
+
title: '<label> is wrapping multiple form controls',
|
|
1537
|
+
description: 'A label should be associated with only one form control. When a label wraps multiple controls, it creates ambiguity about which control the label describes. Violates WCAG 2.1 SC 4.1.2 (Level A).',
|
|
1538
|
+
impact: 'major',
|
|
1539
|
+
status: 'fail',
|
|
1540
|
+
suggestion: 'Use separate labels for each form control, or use aria-label / aria-labelledby:\n<label htmlFor="first">First name</label>\n<input id="first" />\n<label htmlFor="last">Last name</label>\n<input id="last" />',
|
|
1541
|
+
codeSnippet: '<label>\n <input type="text" />\n <input type="text" />\n</label>',
|
|
1542
|
+
fixSnippet: '<label htmlFor="input1">Field 1</label>\n<input id="input1" />\n<label htmlFor="input2">Field 2</label>\n<input id="input2" />',
|
|
1543
|
+
file: filePath,
|
|
1544
|
+
line: node.loc?.start.line,
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
// ── Check 52: <img> with title but no alt ──────────────────────
|
|
1549
|
+
//
|
|
1550
|
+
// WCAG 2.1 SC 1.1.1 (Level A)
|
|
1551
|
+
if (tagName === 'img') {
|
|
1552
|
+
const hasAlt = hasAttr('alt');
|
|
1553
|
+
const hasTitle = hasAttr('title');
|
|
1554
|
+
if (!hasAlt && hasTitle) {
|
|
1555
|
+
issues.push({
|
|
1556
|
+
id: 'img-title-without-alt',
|
|
1557
|
+
category: 'accessibility',
|
|
1558
|
+
title: '<img> has title but no alt attribute',
|
|
1559
|
+
description: 'The title attribute is not a substitute for alt text. Screen readers do not announce title by default, and it is not visible on screen. Alt text is required for all images. Violates WCAG 2.1 SC 1.1.1 (Level A).',
|
|
1560
|
+
impact: 'major',
|
|
1561
|
+
status: 'fail',
|
|
1562
|
+
suggestion: 'Always provide an alt attribute in addition to title. For most images, alt can be the same as or similar to the title.',
|
|
1563
|
+
codeSnippet: '<img src="photo.jpg" title="Family portrait" />',
|
|
1564
|
+
fixSnippet: '<img src="photo.jpg" alt="Family portrait" title="Family portrait" />',
|
|
1565
|
+
file: filePath,
|
|
1566
|
+
line: node.loc?.start.line,
|
|
1567
|
+
});
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
// ── Check 53: <embed> or <object> without accessible alternative
|
|
1571
|
+
//
|
|
1572
|
+
// WCAG 2.1 SC 1.1.1 (Level A)
|
|
1573
|
+
if (tagName === 'embed' || tagName === 'object') {
|
|
1574
|
+
const hasAriaLabel = hasAttr('aria-label');
|
|
1575
|
+
const hasAriaLabelledBy = hasAttr('aria-labelledby');
|
|
1576
|
+
const hasTitle = hasAttr('title');
|
|
1577
|
+
if (!hasAriaLabel && !hasAriaLabelledBy && !hasTitle) {
|
|
1578
|
+
issues.push({
|
|
1579
|
+
id: `${tagName}-missing-accessible-alternative`,
|
|
1580
|
+
category: 'accessibility',
|
|
1581
|
+
title: `<${tagName}> is missing an accessible alternative`,
|
|
1582
|
+
description: `Embedded content and objects should have an accessible name or alternative text. Without it, screen reader users cannot understand the embedded content. Violates WCAG 2.1 SC 1.1.1 (Level A).`,
|
|
1583
|
+
impact: 'major',
|
|
1584
|
+
status: 'fail',
|
|
1585
|
+
suggestion: `Add aria-label, aria-labelledby, or title:\n<${tagName} src="file.pdf" aria-label="Project report" />`,
|
|
1586
|
+
codeSnippet: `<${tagName} src="content" />`,
|
|
1587
|
+
fixSnippet: `<${tagName} src="content" aria-label="Content description" />`,
|
|
1588
|
+
file: filePath,
|
|
1589
|
+
line: node.loc?.start.line,
|
|
1590
|
+
});
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
// ── Check 54: <svg> without title and description ───────────────
|
|
1594
|
+
//
|
|
1595
|
+
// WCAG 2.1 SC 1.1.1 (Level A)
|
|
1596
|
+
if (tagName === 'svg') {
|
|
1597
|
+
const hasRole = getStringValue('role');
|
|
1598
|
+
const hasAriaLabel = hasAttr('aria-label');
|
|
1599
|
+
const hasAriaLabelledBy = hasAttr('aria-labelledby');
|
|
1600
|
+
// Only check SVGs with img role or no role (which act as images)
|
|
1601
|
+
if ((!hasRole || hasRole === 'img') && !hasAriaLabel && !hasAriaLabelledBy) {
|
|
1602
|
+
const parent = path.parentPath?.node;
|
|
1603
|
+
const hasTitle = parent?.children?.some((child) => child.type === 'JSXElement' &&
|
|
1604
|
+
child.openingElement?.name?.name === 'title');
|
|
1605
|
+
if (!hasTitle) {
|
|
1606
|
+
issues.push({
|
|
1607
|
+
id: 'svg-missing-accessible-name',
|
|
1608
|
+
category: 'accessibility',
|
|
1609
|
+
title: '<svg> image is missing an accessible name',
|
|
1610
|
+
description: 'SVG images should have an accessible name via <title> element, aria-label, or aria-labelledby. Without it, screen reader users cannot understand the image. Violates WCAG 2.1 SC 1.1.1 (Level A).',
|
|
1611
|
+
impact: 'major',
|
|
1612
|
+
status: 'fail',
|
|
1613
|
+
suggestion: 'Add a <title> element inside the SVG or use aria-label:\n<svg aria-label="Chart of sales data">\n ...\n</svg>',
|
|
1614
|
+
codeSnippet: '<svg>...</svg>',
|
|
1615
|
+
fixSnippet: '<svg aria-label="Icon description">\n <title>Icon description</title>\n ...\n</svg>',
|
|
1616
|
+
file: filePath,
|
|
1617
|
+
line: node.loc?.start.line,
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
// ── Check 55: <iframe> with restrictive sandbox ───────────────
|
|
1623
|
+
//
|
|
1624
|
+
// WCAG 2.1 SC 2.1.1 (Level A)
|
|
1625
|
+
if (tagName === 'iframe') {
|
|
1626
|
+
const sandbox = getStringValue('sandbox');
|
|
1627
|
+
if (sandbox && !sandbox.includes('allow-keyboard')) {
|
|
1628
|
+
issues.push({
|
|
1629
|
+
id: 'iframe-sandbox-restricts-keyboard',
|
|
1630
|
+
category: 'accessibility',
|
|
1631
|
+
title: '<iframe> sandbox may restrict keyboard access',
|
|
1632
|
+
description: 'The sandbox attribute with restrictive permissions (missing "allow-keyboard") may prevent keyboard users from accessing iframe content. Ensure keyboard access is preserved. Violates WCAG 2.1 SC 2.1.1 (Level A).',
|
|
1633
|
+
impact: 'major',
|
|
1634
|
+
status: 'fail',
|
|
1635
|
+
suggestion: 'Add "allow-keyboard" to the sandbox attribute to allow keyboard navigation:\n<iframe sandbox="allow-same-origin allow-keyboard" ...></iframe>',
|
|
1636
|
+
codeSnippet: '<iframe sandbox="allow-same-origin" ...></iframe>',
|
|
1637
|
+
fixSnippet: '<iframe sandbox="allow-same-origin allow-keyboard" ...></iframe>',
|
|
1638
|
+
file: filePath,
|
|
1639
|
+
line: node.loc?.start.line,
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
// ── Check 56: Table missing <tbody> or <thead> ─────────────────
|
|
1644
|
+
//
|
|
1645
|
+
// WCAG 2.1 SC 1.3.1 (Level A)
|
|
1646
|
+
if (tagName === 'table') {
|
|
1647
|
+
const parent = path.parentPath?.node;
|
|
1648
|
+
const hasHead = parent?.children?.some((child) => child.type === 'JSXElement' &&
|
|
1649
|
+
child.openingElement?.name?.name === 'thead');
|
|
1650
|
+
const hasBody = parent?.children?.some((child) => child.type === 'JSXElement' &&
|
|
1651
|
+
child.openingElement?.name?.name === 'tbody');
|
|
1652
|
+
if (!hasHead || !hasBody) {
|
|
1653
|
+
const missing = [!hasHead && 'thead', !hasBody && 'tbody']
|
|
1654
|
+
.filter(Boolean)
|
|
1655
|
+
.join(' and ');
|
|
1656
|
+
issues.push({
|
|
1657
|
+
id: 'table-missing-structure-elements',
|
|
1658
|
+
category: 'accessibility',
|
|
1659
|
+
title: `<table> is missing <${missing}> element(s)`,
|
|
1660
|
+
description: `Tables should use <thead> and <tbody> to properly structure header and body content. This helps screen readers understand table relationships. Violates WCAG 2.1 SC 1.3.1 (Level A).`,
|
|
1661
|
+
impact: 'major',
|
|
1662
|
+
status: 'fail',
|
|
1663
|
+
suggestion: 'Organize table structure with <thead> and <tbody>:\n<table>\n <thead><tr><th scope="col">Header</th></tr></thead>\n <tbody><tr><td>Data</td></tr></tbody>\n</table>',
|
|
1664
|
+
codeSnippet: '<table>\n <tr><th>Header</th></tr>\n <tr><td>Data</td></tr>\n</table>',
|
|
1665
|
+
fixSnippet: '<table>\n <thead><tr><th scope="col">Header</th></tr></thead>\n <tbody><tr><td>Data</td></tr></tbody>\n</table>',
|
|
1666
|
+
file: filePath,
|
|
1667
|
+
line: node.loc?.start.line,
|
|
1668
|
+
});
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
// ── Check 57: <td> with colspan/rowspan without proper headers ──
|
|
1672
|
+
//
|
|
1673
|
+
// WCAG 2.1 SC 1.3.1 (Level A)
|
|
1674
|
+
if (tagName === 'td') {
|
|
1675
|
+
const hasColspan = getStringValue('colspan');
|
|
1676
|
+
const hasRowspan = getStringValue('rowspan');
|
|
1677
|
+
const hasHeaders = hasAttr('headers');
|
|
1678
|
+
if ((hasColspan || hasRowspan) && !hasHeaders) {
|
|
1679
|
+
issues.push({
|
|
1680
|
+
id: 'td-complex-header-without-headers-attr',
|
|
1681
|
+
category: 'accessibility',
|
|
1682
|
+
title: '<td> with colspan or rowspan is missing headers attribute',
|
|
1683
|
+
description: 'Data cells that span multiple rows or columns should have a headers attribute that references the corresponding header cells. This helps screen readers map complex table relationships. Violates WCAG 2.1 SC 1.3.1 (Level A).',
|
|
1684
|
+
impact: 'major',
|
|
1685
|
+
status: 'fail',
|
|
1686
|
+
suggestion: 'Add headers attribute referencing the header cell IDs:\n<td headers="header1 header2">Data</td>',
|
|
1687
|
+
codeSnippet: '<td colspan="2">Merged cell</td>',
|
|
1688
|
+
fixSnippet: '<td colspan="2" headers="col1 col2">Merged cell</td>',
|
|
1689
|
+
file: filePath,
|
|
1690
|
+
line: node.loc?.start.line,
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
}, // end JSXOpeningElement
|
|
1695
|
+
}); // end traverse
|
|
1696
|
+
// ── Check 28: Page missing <h1> element ────────────────────────────
|
|
1697
|
+
//
|
|
1698
|
+
// WCAG 2.1 SC 1.3.1 (Level A)
|
|
1699
|
+
if (!hasH1) {
|
|
1700
|
+
issues.push({
|
|
1701
|
+
id: 'page-missing-h1',
|
|
1702
|
+
category: 'accessibility',
|
|
1703
|
+
title: 'Page is missing an <h1> element',
|
|
1704
|
+
description: 'Every page should have exactly one <h1> that describes the main purpose or topic. The <h1> is the primary heading and helps users and assistive technology understand page structure. Violates WCAG 2.1 SC 1.3.1 (Level A).',
|
|
1705
|
+
impact: 'major',
|
|
1706
|
+
status: 'fail',
|
|
1707
|
+
suggestion: 'Add a descriptive <h1> at the beginning of your main content:\n<h1>Welcome to My Site</h1>',
|
|
1708
|
+
codeSnippet: '<main>...</main>',
|
|
1709
|
+
fixSnippet: '<main>\n <h1>Page Title</h1>\n ...\n</main>',
|
|
1710
|
+
file: filePath,
|
|
1711
|
+
line: 1,
|
|
1712
|
+
});
|
|
1713
|
+
}
|
|
1714
|
+
// ── Check 33: Heading hierarchy broken ──────────────────────────────
|
|
1715
|
+
//
|
|
1716
|
+
// WCAG 2.1 SC 1.3.1 (Level A)
|
|
1717
|
+
for (let i = 1; i < headingLevels.length; i++) {
|
|
1718
|
+
const prev = headingLevels[i - 1];
|
|
1719
|
+
const curr = headingLevels[i];
|
|
1720
|
+
if (curr > prev + 1) {
|
|
1721
|
+
issues.push({
|
|
1722
|
+
id: 'heading-hierarchy-skipped',
|
|
1723
|
+
category: 'accessibility',
|
|
1724
|
+
title: `Heading hierarchy skipped from <h${prev}> to <h${curr}>`,
|
|
1725
|
+
description: `Heading levels should be sequential (h1 → h2 → h3, etc.). Skipping levels breaks the document structure and confuses screen reader users. You jumped from <h${prev}> to <h${curr}>. Violates WCAG 2.1 SC 1.3.1 (Level A).`,
|
|
1726
|
+
impact: 'major',
|
|
1727
|
+
status: 'fail',
|
|
1728
|
+
suggestion: `Use <h${prev + 1}> instead of <h${curr}>, or add a missing intermediate heading level.`,
|
|
1729
|
+
codeSnippet: `<h${prev}>Section</h${prev}>\n<h${curr}>Subsection</h${curr}>`,
|
|
1730
|
+
fixSnippet: `<h${prev}>Section</h${prev}>\n<h${prev + 1}>Subsection</h${prev + 1}>`,
|
|
1731
|
+
file: filePath,
|
|
1732
|
+
line: 1,
|
|
1733
|
+
});
|
|
1734
|
+
break; // Report only the first hierarchy issue per file
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
// ── Check 43: Multiple <main> elements ──────────────────────────────
|
|
1738
|
+
//
|
|
1739
|
+
// WCAG 2.1 SC 1.3.1 (Level A)
|
|
1740
|
+
if (mainCount > 1) {
|
|
1741
|
+
issues.push({
|
|
1742
|
+
id: 'multiple-main-elements',
|
|
1743
|
+
category: 'accessibility',
|
|
1744
|
+
title: `Page has ${mainCount} <main> elements (should have 1)`,
|
|
1745
|
+
description: 'Pages should have exactly one <main> element that contains the main content. Multiple <main> elements confuse screen reader users and violate semantic structure. Violates WCAG 2.1 SC 1.3.1 (Level A).',
|
|
1746
|
+
impact: 'major',
|
|
1747
|
+
status: 'fail',
|
|
1748
|
+
suggestion: 'Keep only one <main> element and move secondary content out of additional <main> tags, or use <section> with aria-label instead.',
|
|
1749
|
+
codeSnippet: '<main>...</main>\n<main>...</main>',
|
|
1750
|
+
fixSnippet: '<main>...</main>',
|
|
1751
|
+
file: filePath,
|
|
1752
|
+
line: 1,
|
|
1753
|
+
});
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
return deduplicate(issues);
|
|
1757
|
+
}
|
|
1758
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
1759
|
+
function deduplicate(issues) {
|
|
1760
|
+
const seen = new Set();
|
|
1761
|
+
return issues.filter((i) => {
|
|
1762
|
+
const key = `${i.id}::${i.file}::${i.line}`;
|
|
1763
|
+
if (seen.has(key))
|
|
1764
|
+
return false;
|
|
1765
|
+
seen.add(key);
|
|
1766
|
+
return true;
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1769
|
+
//# sourceMappingURL=accessibility.js.map
|