wcag-scanner 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/LICENSE +674 -0
- package/README.md +42 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +51 -0
- package/dist/rules/aria.d.ts +15 -0
- package/dist/rules/aria.js +318 -0
- package/dist/rules/contrast.d.ts +15 -0
- package/dist/rules/contrast.js +217 -0
- package/dist/rules/forms.d.ts +15 -0
- package/dist/rules/forms.js +277 -0
- package/dist/rules/images.d.ts +15 -0
- package/dist/rules/images.js +390 -0
- package/dist/rules/keyboard.d.ts +15 -0
- package/dist/rules/keyboard.js +225 -0
- package/dist/rules/structure.d.ts +15 -0
- package/dist/rules/structure.js +500 -0
- package/dist/scanner.d.ts +44 -0
- package/dist/scanner.js +131 -0
- package/dist/types/index.d.ts +118 -0
- package/dist/types/index.js +2 -0
- package/package.json +54 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* Check image accessibility (alt text, etc.)
|
|
5
|
+
*/
|
|
6
|
+
exports.default = {
|
|
7
|
+
/**
|
|
8
|
+
* Run image accessibility checks
|
|
9
|
+
* @param document DOM document
|
|
10
|
+
* @param window Browser window
|
|
11
|
+
* @param options Scanner options
|
|
12
|
+
* @returns Promise<ScanResults> Results from image checks
|
|
13
|
+
*/
|
|
14
|
+
async check(document, window, options) {
|
|
15
|
+
const results = {
|
|
16
|
+
passes: [],
|
|
17
|
+
violations: [],
|
|
18
|
+
warnings: []
|
|
19
|
+
};
|
|
20
|
+
// Check <img> elements
|
|
21
|
+
checkImageElements(document, results);
|
|
22
|
+
// Check <svg> elements
|
|
23
|
+
checkSvgElements(document, results);
|
|
24
|
+
// Check background images
|
|
25
|
+
checkBackgroundImages(document, window, results);
|
|
26
|
+
// Check image maps
|
|
27
|
+
checkImageMaps(document, results);
|
|
28
|
+
return results;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Check all <img> elements for accessibility issues
|
|
33
|
+
* @param document DOM document
|
|
34
|
+
* @param results DOM results
|
|
35
|
+
*/
|
|
36
|
+
function checkImageElements(document, results) {
|
|
37
|
+
const images = document.querySelectorAll('img');
|
|
38
|
+
images.forEach(img => {
|
|
39
|
+
const info = {
|
|
40
|
+
tagName: 'img',
|
|
41
|
+
id: img.id || null,
|
|
42
|
+
className: img.className || null,
|
|
43
|
+
src: img.getAttribute('src') || null
|
|
44
|
+
};
|
|
45
|
+
// Check for alt attribute
|
|
46
|
+
if (!img.hasAttribute('alt')) {
|
|
47
|
+
results.violations.push({
|
|
48
|
+
rule: 'img-alt',
|
|
49
|
+
element: info,
|
|
50
|
+
impact: 'critical',
|
|
51
|
+
description: 'Image is missing alt text',
|
|
52
|
+
snippet: img.outerHTML,
|
|
53
|
+
wcag: ['1.1.1'],
|
|
54
|
+
help: 'Images must have alternative text',
|
|
55
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html'
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
const altText = img.getAttribute('alt') || '';
|
|
60
|
+
if (altText === '') {
|
|
61
|
+
// Empty alt is valid for decorative images
|
|
62
|
+
if (!isLikelyDecorativeImage(img)) {
|
|
63
|
+
results.warnings.push({
|
|
64
|
+
rule: 'img-alt-decorative',
|
|
65
|
+
element: info,
|
|
66
|
+
impact: 'moderate',
|
|
67
|
+
description: 'Image has empty alt text but may not be decorative',
|
|
68
|
+
snippet: img.outerHTML,
|
|
69
|
+
wcag: ['1.1.1'],
|
|
70
|
+
help: 'Verify this image is decorative; if not, add descriptive alt text'
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
results.passes.push({
|
|
75
|
+
rule: 'img-alt-decorative',
|
|
76
|
+
element: info,
|
|
77
|
+
description: 'Decorative image has appropriate empty alt text',
|
|
78
|
+
snippet: img.outerHTML
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
else if (hasGenericAltText(altText)) {
|
|
83
|
+
// Check for generic/placeholder alt text
|
|
84
|
+
results.warnings.push({
|
|
85
|
+
rule: 'img-alt-generic',
|
|
86
|
+
element: info,
|
|
87
|
+
impact: 'moderate',
|
|
88
|
+
description: 'Image may have generic/placeholder alt text',
|
|
89
|
+
snippet: img.outerHTML,
|
|
90
|
+
wcag: ['1.1.1'],
|
|
91
|
+
help: 'Replace generic alt text with specific description'
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
else if (altText.length > 125) {
|
|
95
|
+
// Long alt text warning
|
|
96
|
+
results.warnings.push({
|
|
97
|
+
rule: 'img-alt-long',
|
|
98
|
+
element: info,
|
|
99
|
+
impact: 'minor',
|
|
100
|
+
description: 'Alt text is unusually long (over 125 characters)',
|
|
101
|
+
snippet: img.outerHTML,
|
|
102
|
+
wcag: ['1.1.1'],
|
|
103
|
+
help: 'Consider using a more concise alt text or using a longdesc attribute'
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
results.passes.push({
|
|
108
|
+
rule: 'img-alt',
|
|
109
|
+
element: info,
|
|
110
|
+
description: 'Image has appropriate alt text',
|
|
111
|
+
snippet: img.outerHTML
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Check for responsive images
|
|
116
|
+
if (!img.hasAttribute('width') || !img.hasAttribute('height')) {
|
|
117
|
+
results.warnings.push({
|
|
118
|
+
rule: 'img-dimensions',
|
|
119
|
+
element: info,
|
|
120
|
+
impact: 'minor',
|
|
121
|
+
description: 'Image is missing width and/or height attributes',
|
|
122
|
+
snippet: img.outerHTML,
|
|
123
|
+
help: 'Set explicit width and height to prevent layout shifts'
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Check SVG elements for accessibility
|
|
130
|
+
* @param document DOM document
|
|
131
|
+
* @param results Scan results
|
|
132
|
+
*/
|
|
133
|
+
function checkSvgElements(document, results) {
|
|
134
|
+
const svgs = document.querySelectorAll('svg');
|
|
135
|
+
svgs.forEach(svg => {
|
|
136
|
+
var _a, _b;
|
|
137
|
+
const info = {
|
|
138
|
+
tagName: 'svg',
|
|
139
|
+
id: svg.id || null,
|
|
140
|
+
className: ((_a = svg.classList) === null || _a === void 0 ? void 0 : _a.toString()) || null
|
|
141
|
+
};
|
|
142
|
+
// Check for role="img"
|
|
143
|
+
if (!svg.hasAttribute('role') || svg.getAttribute('role') !== 'img') {
|
|
144
|
+
results.warnings.push({
|
|
145
|
+
rule: 'svg-role',
|
|
146
|
+
element: info,
|
|
147
|
+
impact: 'moderate',
|
|
148
|
+
description: 'SVG element should have role="img"',
|
|
149
|
+
snippet: svg.outerHTML.slice(0, 150) + (svg.outerHTML.length > 150 ? '...' : ''),
|
|
150
|
+
wcag: ['1.1.1'],
|
|
151
|
+
help: 'Add role="img" to SVG elements'
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
// Check for accessible name via title or aria-label
|
|
155
|
+
const title = svg.querySelector('title');
|
|
156
|
+
const ariaLabel = svg.getAttribute('aria-label');
|
|
157
|
+
const ariaLabelledby = svg.getAttribute('aria-labelledby');
|
|
158
|
+
if (!title && !ariaLabel && !ariaLabelledby) {
|
|
159
|
+
results.violations.push({
|
|
160
|
+
rule: 'svg-accessible-name',
|
|
161
|
+
element: info,
|
|
162
|
+
impact: 'serious',
|
|
163
|
+
description: 'SVG lacks accessible name (title, aria-label, or aria-labelledby)',
|
|
164
|
+
snippet: svg.outerHTML.slice(0, 150) + (svg.outerHTML.length > 150 ? '...' : ''),
|
|
165
|
+
wcag: ['1.1.1'],
|
|
166
|
+
help: 'Add a <title> element or aria-label attribute to SVG'
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
else if (title && !((_b = title.textContent) === null || _b === void 0 ? void 0 : _b.trim())) {
|
|
170
|
+
results.violations.push({
|
|
171
|
+
rule: 'svg-title-empty',
|
|
172
|
+
element: info,
|
|
173
|
+
impact: 'serious',
|
|
174
|
+
description: 'SVG title element is empty',
|
|
175
|
+
snippet: svg.outerHTML.slice(0, 150) + (svg.outerHTML.length > 150 ? '...' : ''),
|
|
176
|
+
wcag: ['1.1.1'],
|
|
177
|
+
help: 'Add content to the SVG title element'
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
results.passes.push({
|
|
182
|
+
rule: 'svg-accessible-name',
|
|
183
|
+
element: info,
|
|
184
|
+
description: 'SVG has an accessible name',
|
|
185
|
+
snippet: svg.outerHTML.slice(0, 150) + (svg.outerHTML.length > 150 ? '...' : '')
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Check background images for accessibility issues
|
|
192
|
+
* @param document DOM document
|
|
193
|
+
* @param window Window object
|
|
194
|
+
* @param results Scan results
|
|
195
|
+
*/
|
|
196
|
+
function checkBackgroundImages(document, window, results) {
|
|
197
|
+
// Find elements with background image
|
|
198
|
+
const allElements = document.querySelectorAll('*');
|
|
199
|
+
allElements.forEach(element => {
|
|
200
|
+
var _a, _b, _c;
|
|
201
|
+
const style = window.getComputedStyle(element);
|
|
202
|
+
const backgroundImage = style.backgroundImage;
|
|
203
|
+
// Skip if no background image or if it's "none"
|
|
204
|
+
if (!backgroundImage || backgroundImage === 'none') {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
// Skip if the element is hidden/decorative
|
|
208
|
+
if (isElementHidden(element) || element.getAttribute('aria-hidden') === 'true') {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const info = {
|
|
212
|
+
tagName: element.tagName.toLowerCase(),
|
|
213
|
+
id: element.id || null,
|
|
214
|
+
className: ((_a = element.className) === null || _a === void 0 ? void 0 : _a.toString()) || null,
|
|
215
|
+
textContent: ((_b = element.textContent) === null || _b === void 0 ? void 0 : _b.substring(0, 50)) || null
|
|
216
|
+
};
|
|
217
|
+
// Check if meaningful background image has text alternative
|
|
218
|
+
const hasTextContent = ((_c = element.textContent) === null || _c === void 0 ? void 0 : _c.trim()) !== '';
|
|
219
|
+
const hasAriaLabel = element.hasAttribute('aria-label');
|
|
220
|
+
const hasAriaLabelledby = element.hasAttribute('aria-labelledby');
|
|
221
|
+
const hasTitle = element.hasAttribute('title');
|
|
222
|
+
if (!hasTextContent && !hasAriaLabel && !hasAriaLabelledby && !hasTitle) {
|
|
223
|
+
// Only warn if the background image looks like content (not decoration)
|
|
224
|
+
if (backgroundImage.includes('url(') && !isBackgroundLikelyDecorative(element)) {
|
|
225
|
+
results.warnings.push({
|
|
226
|
+
rule: 'background-image',
|
|
227
|
+
element: info,
|
|
228
|
+
impact: 'moderate',
|
|
229
|
+
description: 'Element with background image may need text alternative',
|
|
230
|
+
snippet: element.outerHTML.slice(0, 150) + (element.outerHTML.length > 150 ? '...' : ''),
|
|
231
|
+
wcag: ['1.1.1'],
|
|
232
|
+
help: 'If the background image conveys meaning, add text alternative via aria-label or text content'
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Check image maps for accessibility
|
|
240
|
+
* @param document DOM document
|
|
241
|
+
* @param results Scan results
|
|
242
|
+
*/
|
|
243
|
+
function checkImageMaps(document, results) {
|
|
244
|
+
const maps = document.querySelectorAll('map');
|
|
245
|
+
maps.forEach(map => {
|
|
246
|
+
const info = {
|
|
247
|
+
tagName: 'map',
|
|
248
|
+
id: map.id || null,
|
|
249
|
+
name: map.getAttribute('name') || null
|
|
250
|
+
};
|
|
251
|
+
// Check if the map is used
|
|
252
|
+
const usedByImages = document.querySelectorAll(`img[usemap="#${map.name}"]`);
|
|
253
|
+
if (usedByImages.length === 0) {
|
|
254
|
+
results.warnings.push({
|
|
255
|
+
rule: 'map-unused',
|
|
256
|
+
element: info,
|
|
257
|
+
impact: 'minor',
|
|
258
|
+
description: 'Image map appears to be unused',
|
|
259
|
+
snippet: map.outerHTML,
|
|
260
|
+
help: 'Remove unused image maps'
|
|
261
|
+
});
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
// Check area elements
|
|
265
|
+
const areas = map.querySelectorAll('area');
|
|
266
|
+
areas.forEach(area => {
|
|
267
|
+
const areaInfo = {
|
|
268
|
+
tagName: 'area',
|
|
269
|
+
shape: area.getAttribute('shape') || null,
|
|
270
|
+
coords: area.getAttribute('coords') || null,
|
|
271
|
+
href: area.getAttribute('href') || null
|
|
272
|
+
};
|
|
273
|
+
// Check for alt text
|
|
274
|
+
if (!area.hasAttribute('alt')) {
|
|
275
|
+
results.violations.push({
|
|
276
|
+
rule: 'area-alt',
|
|
277
|
+
element: areaInfo,
|
|
278
|
+
impact: 'critical',
|
|
279
|
+
description: 'Area element in image map is missing alt text',
|
|
280
|
+
snippet: area.outerHTML,
|
|
281
|
+
wcag: ['1.1.1', '2.4.4'],
|
|
282
|
+
help: 'Add alt text to all area elements'
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
results.passes.push({
|
|
287
|
+
rule: 'area-alt',
|
|
288
|
+
element: areaInfo,
|
|
289
|
+
description: 'Area element has alt text',
|
|
290
|
+
snippet: area.outerHTML
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Check if an element is hidden
|
|
298
|
+
* @param element Element to check
|
|
299
|
+
*/
|
|
300
|
+
function isElementHidden(element) {
|
|
301
|
+
// This is a simplified check - a real implementation would be more comprehensive
|
|
302
|
+
if (element.hasAttribute('hidden') || element.getAttribute('aria-hidden') === 'true') {
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
// Check computed style if available
|
|
306
|
+
try {
|
|
307
|
+
const style = getComputedStyle(element);
|
|
308
|
+
return style.display === 'none' || style.visibility === 'hidden';
|
|
309
|
+
}
|
|
310
|
+
catch (e) {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Check if a background image is likely decorative
|
|
316
|
+
* @param element Element to check
|
|
317
|
+
*/
|
|
318
|
+
function isBackgroundLikelyDecorative(element) {
|
|
319
|
+
var _a, _b;
|
|
320
|
+
// Common decorative pattern elements
|
|
321
|
+
const decorativeElements = ['header', 'footer', 'section', 'article', 'div'];
|
|
322
|
+
const tagName = ((_a = element.tagName) === null || _a === void 0 ? void 0 : _a.toLowerCase()) || '';
|
|
323
|
+
if (decorativeElements.includes(tagName)) {
|
|
324
|
+
// Check for certain classes that might indicate decorative backgrounds
|
|
325
|
+
const className = ((_b = element.className) === null || _b === void 0 ? void 0 : _b.toString()) || '';
|
|
326
|
+
if (className.includes('background') ||
|
|
327
|
+
className.includes('banner') ||
|
|
328
|
+
className.includes('hero') ||
|
|
329
|
+
className.includes('container') ||
|
|
330
|
+
className.includes('wrapper') ||
|
|
331
|
+
className.includes('section')) {
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Determine if an image is likely decorative
|
|
339
|
+
* @param img Image to check
|
|
340
|
+
*/
|
|
341
|
+
function isLikelyDecorativeImage(img) {
|
|
342
|
+
var _a;
|
|
343
|
+
// Images with role="presentation" are explicitly decorative
|
|
344
|
+
if (img.getAttribute('role') === 'presentation' || img.getAttribute('role') === 'none') {
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
// Very small images are often decorative
|
|
348
|
+
const width = parseInt(img.getAttribute('width') || img.width.toString());
|
|
349
|
+
const height = parseInt(img.getAttribute('height') || img.height.toString());
|
|
350
|
+
if ((width > 0 && width < 16) || (height > 0 && height < 16)) {
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
// Check class names for hints
|
|
354
|
+
const className = img.className || '';
|
|
355
|
+
if (className.includes('decoration') ||
|
|
356
|
+
className.includes('ornament') ||
|
|
357
|
+
className.includes('icon') ||
|
|
358
|
+
className.includes('separator') ||
|
|
359
|
+
className.includes('bg') ||
|
|
360
|
+
className.includes('background')) {
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
// Check if the image is inside a link with text
|
|
364
|
+
const parentLink = img.closest('a');
|
|
365
|
+
if (parentLink && ((_a = parentLink.textContent) === null || _a === void 0 ? void 0 : _a.trim()) && parentLink.textContent.trim().length > 0) {
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Check if alt text appears to be generic or placeholder text
|
|
372
|
+
* @param altText Alt text to check
|
|
373
|
+
*/
|
|
374
|
+
function hasGenericAltText(altText) {
|
|
375
|
+
const genericTerms = ['image', 'picture', 'photo', 'img', 'graphic', 'icon', 'logo', 'photo.jpg', 'untitled'];
|
|
376
|
+
const lowerAlt = altText.toLowerCase().trim();
|
|
377
|
+
// Check for file extensions in alt text
|
|
378
|
+
if (lowerAlt.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i)) {
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
// Check for generic terms used alone
|
|
382
|
+
if (genericTerms.includes(lowerAlt)) {
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
// Check for very short alt text that's not meaningful
|
|
386
|
+
if (lowerAlt.length < 5 && !['ok', 'yes', 'no'].includes(lowerAlt)) {
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ScannerOptions, ScanResults } from '../types';
|
|
2
|
+
/**
|
|
3
|
+
* Accessibility checker for tab index, keyboard events, focus indicators, and interactive elements
|
|
4
|
+
*/
|
|
5
|
+
declare const _default: {
|
|
6
|
+
/**
|
|
7
|
+
* Run accessibility checks on tab index, keyboard events, focus indicators, and interactive elements
|
|
8
|
+
* @param document DOM document
|
|
9
|
+
* @param window Browser window
|
|
10
|
+
* @param options Scanner options
|
|
11
|
+
* @returns Promise<ScanResults> Results from accessibility checks
|
|
12
|
+
*/
|
|
13
|
+
check(document: Document, window: Window, options: ScannerOptions): Promise<ScanResults>;
|
|
14
|
+
};
|
|
15
|
+
export default _default;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* Accessibility checker for tab index, keyboard events, focus indicators, and interactive elements
|
|
5
|
+
*/
|
|
6
|
+
exports.default = {
|
|
7
|
+
/**
|
|
8
|
+
* Run accessibility checks on tab index, keyboard events, focus indicators, and interactive elements
|
|
9
|
+
* @param document DOM document
|
|
10
|
+
* @param window Browser window
|
|
11
|
+
* @param options Scanner options
|
|
12
|
+
* @returns Promise<ScanResults> Results from accessibility checks
|
|
13
|
+
*/
|
|
14
|
+
async check(document, window, options) {
|
|
15
|
+
const results = {
|
|
16
|
+
passes: [],
|
|
17
|
+
violations: [],
|
|
18
|
+
warnings: []
|
|
19
|
+
};
|
|
20
|
+
// Check tabindex values
|
|
21
|
+
checkTabindex(document, results);
|
|
22
|
+
// Check keyboard event handlers
|
|
23
|
+
checkKeyboardEvents(document, results);
|
|
24
|
+
// Check focus indicators
|
|
25
|
+
checkFocusIndicators(document, window, results);
|
|
26
|
+
// Check interactive elements
|
|
27
|
+
checkInteractiveElements(document, results);
|
|
28
|
+
return results;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Check for valid tabindex values
|
|
33
|
+
* @param document DOM document
|
|
34
|
+
* @param results Scan results
|
|
35
|
+
*/
|
|
36
|
+
function checkTabindex(document, results) {
|
|
37
|
+
const elements = document.querySelectorAll('[tabindex]');
|
|
38
|
+
elements.forEach(element => {
|
|
39
|
+
const tabindex = element.getAttribute('tabindex');
|
|
40
|
+
const tabindexNum = parseInt(tabindex || '0');
|
|
41
|
+
const info = {
|
|
42
|
+
tagName: element.tagName.toLowerCase(),
|
|
43
|
+
id: element.id || null,
|
|
44
|
+
tabindex: tabindex || null
|
|
45
|
+
};
|
|
46
|
+
// Check for positive tabindex values (creates a custom tab order)
|
|
47
|
+
if (tabindexNum > 0) {
|
|
48
|
+
results.warnings.push({
|
|
49
|
+
rule: 'tabindex-positive',
|
|
50
|
+
element: info,
|
|
51
|
+
impact: 'moderate',
|
|
52
|
+
description: `Element has a positive tabindex value (${tabindexNum})`,
|
|
53
|
+
snippet: element.outerHTML,
|
|
54
|
+
wcag: ['2.4.3'],
|
|
55
|
+
help: 'Avoid positive tabindex values as they create a custom tab order'
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
// Check if non-interactive elements are focusable
|
|
59
|
+
const isNativelyFocusable = [
|
|
60
|
+
'a', 'button', 'input', 'select', 'textarea', 'summary', 'details'
|
|
61
|
+
].includes(element.tagName.toLowerCase());
|
|
62
|
+
const hasInteractiveRole = element.hasAttribute('role') && [
|
|
63
|
+
'button', 'checkbox', 'link', 'menuitem', 'menuitemcheckbox',
|
|
64
|
+
'menuitemradio', 'option', 'radio', 'slider', 'tab', 'textbox',
|
|
65
|
+
'switch', 'searchbox', 'combobox'
|
|
66
|
+
].includes(element.getAttribute('role') || '');
|
|
67
|
+
if (!isNativelyFocusable && !hasInteractiveRole && tabindexNum >= 0) {
|
|
68
|
+
results.warnings.push({
|
|
69
|
+
rule: 'tabindex-non-interactive',
|
|
70
|
+
element: info,
|
|
71
|
+
impact: 'moderate',
|
|
72
|
+
description: 'Non-interactive element has a tabindex making it focusable',
|
|
73
|
+
snippet: element.outerHTML,
|
|
74
|
+
wcag: ['2.1.1'],
|
|
75
|
+
help: 'Only make interactive elements focusable or add appropriate ARIA roles'
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Check keyboard event handlers
|
|
82
|
+
* @param document DOM document
|
|
83
|
+
* @param results Scan results
|
|
84
|
+
*/
|
|
85
|
+
function checkKeyboardEvents(document, results) {
|
|
86
|
+
// Elements with mouse event handlers but no keyboard equivalent
|
|
87
|
+
const allElements = document.querySelectorAll('*');
|
|
88
|
+
allElements.forEach(element => {
|
|
89
|
+
// This is a simple check - a full implementation would analyze event handlers
|
|
90
|
+
// through JavaScript frameworks, which is complex
|
|
91
|
+
const hasOnClick = element.hasAttribute('onclick');
|
|
92
|
+
const hasOnMousedown = element.hasAttribute('onmousedown');
|
|
93
|
+
const hasOnMouseup = element.hasAttribute('onmouseup');
|
|
94
|
+
const hasOnKeydown = element.hasAttribute('onkeydown');
|
|
95
|
+
const hasOnKeyup = element.hasAttribute('onkeyup');
|
|
96
|
+
const hasOnKeypress = element.hasAttribute('onkeypress');
|
|
97
|
+
const info = {
|
|
98
|
+
tagName: element.tagName.toLowerCase(),
|
|
99
|
+
id: element.id || null,
|
|
100
|
+
className: element.className || null
|
|
101
|
+
};
|
|
102
|
+
// Check if element has mouse event but no keyboard event
|
|
103
|
+
if ((hasOnClick || hasOnMousedown || hasOnMouseup) &&
|
|
104
|
+
!(hasOnKeydown || hasOnKeyup || hasOnKeypress)) {
|
|
105
|
+
// Skip natively clickable elements (browsers handle keyboard events)
|
|
106
|
+
if (['a', 'button', 'input', 'select', 'textarea'].includes(element.tagName.toLowerCase())) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
results.warnings.push({
|
|
110
|
+
rule: 'keyboard-event-equivalents',
|
|
111
|
+
element: info,
|
|
112
|
+
impact: 'moderate',
|
|
113
|
+
description: 'Element has mouse event handlers but no keyboard event handlers',
|
|
114
|
+
snippet: element.outerHTML.slice(0, 150) + (element.outerHTML.length > 150 ? '...' : ''),
|
|
115
|
+
wcag: ['2.1.1'],
|
|
116
|
+
help: 'Ensure all functionality is operable through keyboard'
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Check for focus indicators
|
|
123
|
+
* @param document DOM document
|
|
124
|
+
* @param window Browser window
|
|
125
|
+
* @param results Scan results
|
|
126
|
+
*/
|
|
127
|
+
function checkFocusIndicators(document, window, results) {
|
|
128
|
+
// Elements that should have visible focus indicators
|
|
129
|
+
const focusableElements = document.querySelectorAll('a[href], button, input, textarea, select, [tabindex]:not([tabindex="-1"])');
|
|
130
|
+
focusableElements.forEach(element => {
|
|
131
|
+
// Without actually applying focus, we can't check the exact appearance
|
|
132
|
+
// But we can check for CSS that might suppress focus styles
|
|
133
|
+
const style = window.getComputedStyle(element);
|
|
134
|
+
const info = {
|
|
135
|
+
tagName: element.tagName.toLowerCase(),
|
|
136
|
+
id: element.id || null,
|
|
137
|
+
className: element.className || null
|
|
138
|
+
};
|
|
139
|
+
// Check for CSS that might remove the focus outline
|
|
140
|
+
if (style.outlineStyle === 'none' || style.outlineWidth === '0px') {
|
|
141
|
+
// Look for alternative focus indicators like background-color changes or borders
|
|
142
|
+
// This is a simplified check
|
|
143
|
+
results.warnings.push({
|
|
144
|
+
rule: 'focus-visible',
|
|
145
|
+
element: info,
|
|
146
|
+
impact: 'moderate',
|
|
147
|
+
description: 'Element may be missing visible focus indicator',
|
|
148
|
+
snippet: element.outerHTML.slice(0, 150) + (element.outerHTML.length > 150 ? '...' : ''),
|
|
149
|
+
wcag: ['2.4.7'],
|
|
150
|
+
help: 'Ensure all focusable elements have visible focus indicators'
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Check interactive elements
|
|
157
|
+
* @param document DOM document
|
|
158
|
+
* @param results Scan results
|
|
159
|
+
*/
|
|
160
|
+
function checkInteractiveElements(document, results) {
|
|
161
|
+
// Check for non-button elements that act as buttons
|
|
162
|
+
const clickableItems = document.querySelectorAll('div[onclick], span[onclick], a:not([href])');
|
|
163
|
+
clickableItems.forEach(element => {
|
|
164
|
+
const info = {
|
|
165
|
+
tagName: element.tagName.toLowerCase(),
|
|
166
|
+
id: element.id || null,
|
|
167
|
+
className: element.className || null
|
|
168
|
+
};
|
|
169
|
+
// Check for button role
|
|
170
|
+
const hasButtonRole = element.getAttribute('role') === 'button';
|
|
171
|
+
// Check for interactivity properties
|
|
172
|
+
const isInteractive = element.hasAttribute('tabindex') ||
|
|
173
|
+
element.hasAttribute('onclick') ||
|
|
174
|
+
element.hasAttribute('onkeyup') ||
|
|
175
|
+
element.hasAttribute('onkeydown');
|
|
176
|
+
if (isInteractive && !hasButtonRole && element.tagName.toLowerCase() !== 'button') {
|
|
177
|
+
results.violations.push({
|
|
178
|
+
rule: 'interactive-semantics',
|
|
179
|
+
element: info,
|
|
180
|
+
impact: 'serious',
|
|
181
|
+
description: 'Interactive element is missing semantic role',
|
|
182
|
+
snippet: element.outerHTML.slice(0, 150) + (element.outerHTML.length > 150 ? '...' : ''),
|
|
183
|
+
wcag: ['4.1.2'],
|
|
184
|
+
help: 'Add role="button" to non-button elements that act as buttons'
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
// Check if interactive elements are focusable
|
|
188
|
+
if (isInteractive && !element.hasAttribute('tabindex') && element.tagName.toLowerCase() !== 'a') {
|
|
189
|
+
results.violations.push({
|
|
190
|
+
rule: 'interactive-focusable',
|
|
191
|
+
element: info,
|
|
192
|
+
impact: 'serious',
|
|
193
|
+
description: 'Interactive element is not keyboard focusable',
|
|
194
|
+
snippet: element.outerHTML.slice(0, 150) + (element.outerHTML.length > 150 ? '...' : ''),
|
|
195
|
+
wcag: ['2.1.1'],
|
|
196
|
+
help: 'Add tabindex="0" to make interactive elements focusable'
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
// Check for links that open in new windows
|
|
201
|
+
const linksNewWindow = document.querySelectorAll('a[target="_blank"]');
|
|
202
|
+
linksNewWindow.forEach(link => {
|
|
203
|
+
var _a, _b;
|
|
204
|
+
const info = {
|
|
205
|
+
tagName: 'a',
|
|
206
|
+
id: link.id || null,
|
|
207
|
+
href: link.getAttribute('href') || null,
|
|
208
|
+
target: '_blank'
|
|
209
|
+
};
|
|
210
|
+
const hasWarning = ((_a = link.textContent) === null || _a === void 0 ? void 0 : _a.toLowerCase().includes('new window')) ||
|
|
211
|
+
((_b = link.textContent) === null || _b === void 0 ? void 0 : _b.toLowerCase().includes('new tab')) ||
|
|
212
|
+
link.querySelector('svg, img[alt*="new window"], img[alt*="external"]');
|
|
213
|
+
if (!hasWarning) {
|
|
214
|
+
results.warnings.push({
|
|
215
|
+
rule: 'link-new-window',
|
|
216
|
+
element: info,
|
|
217
|
+
impact: 'moderate',
|
|
218
|
+
description: 'Link opens in new window without warning',
|
|
219
|
+
snippet: link.outerHTML,
|
|
220
|
+
wcag: ['3.2.2'],
|
|
221
|
+
help: 'Indicate in the link text that it opens in a new window'
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ScannerOptions, ScanResults } from '../types';
|
|
2
|
+
/**
|
|
3
|
+
* Check accessibility of structural elements (headings, landmarks, etc.)
|
|
4
|
+
*/
|
|
5
|
+
declare const _default: {
|
|
6
|
+
/**
|
|
7
|
+
* Run accessibility checks on the entire document
|
|
8
|
+
* @param document DOM document
|
|
9
|
+
* @param window Browser window
|
|
10
|
+
* @param options Scanner options
|
|
11
|
+
* @returns Promise<ScanResults> Results from accessibility checks
|
|
12
|
+
*/
|
|
13
|
+
check(document: Document, window: Window, options: ScannerOptions): Promise<ScanResults>;
|
|
14
|
+
};
|
|
15
|
+
export default _default;
|