wcag-a11y 0.1.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/README.md +44 -0
- package/dist/ai/gemini.js +57 -0
- package/dist/ai/index.js +12 -0
- package/dist/ai/ollama.js +55 -0
- package/dist/ai/prompt.js +20 -0
- package/dist/ai/types.js +1 -0
- package/dist/cli.js +67 -0
- package/dist/config.js +32 -0
- package/dist/crawler.js +54 -0
- package/dist/demo.js +89 -0
- package/dist/engine/index.js +56 -0
- package/dist/engine/rules/aria.js +293 -0
- package/dist/engine/rules/color-contrast.js +114 -0
- package/dist/engine/rules/forms.js +255 -0
- package/dist/engine/rules/keyboard.js +229 -0
- package/dist/engine/rules/language.js +31 -0
- package/dist/engine/rules/links.js +166 -0
- package/dist/engine/rules/media.js +104 -0
- package/dist/engine/rules/structure.js +301 -0
- package/dist/engine/rules/tables.js +159 -0
- package/dist/engine/rules/text-alternatives.js +202 -0
- package/dist/engine/types.js +1 -0
- package/dist/reporter/markdown.js +42 -0
- package/dist/reporter/terminal.js +55 -0
- package/package.json +33 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
export const ariaRules = [
|
|
2
|
+
{
|
|
3
|
+
id: 'aria-valid-role',
|
|
4
|
+
wcag: '4.1.2',
|
|
5
|
+
level: 'A',
|
|
6
|
+
impact: 'critical',
|
|
7
|
+
description: 'Elements must use valid ARIA roles',
|
|
8
|
+
check: () => {
|
|
9
|
+
const getCssPath = (el) => {
|
|
10
|
+
if (el.id)
|
|
11
|
+
return '#' + el.id;
|
|
12
|
+
const parts = [];
|
|
13
|
+
let node = el;
|
|
14
|
+
while (node && node.tagName !== 'BODY') {
|
|
15
|
+
const parent = node.parentElement;
|
|
16
|
+
if (!parent)
|
|
17
|
+
break;
|
|
18
|
+
const tag = node.tagName.toLowerCase();
|
|
19
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
20
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
21
|
+
if (parent.id) {
|
|
22
|
+
parts.unshift('#' + parent.id);
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
node = parent;
|
|
26
|
+
}
|
|
27
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
28
|
+
};
|
|
29
|
+
const validRoles = new Set([
|
|
30
|
+
'alert', 'alertdialog', 'application', 'article', 'banner', 'button', 'cell', 'checkbox',
|
|
31
|
+
'columnheader', 'combobox', 'complementary', 'contentinfo', 'definition', 'dialog',
|
|
32
|
+
'directory', 'document', 'feed', 'figure', 'form', 'grid', 'gridcell', 'group', 'heading',
|
|
33
|
+
'img', 'link', 'list', 'listbox', 'listitem', 'log', 'main', 'marquee', 'math', 'menu',
|
|
34
|
+
'menubar', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'navigation', 'none',
|
|
35
|
+
'note', 'option', 'presentation', 'progressbar', 'radio', 'radiogroup', 'region', 'row',
|
|
36
|
+
'rowgroup', 'rowheader', 'scrollbar', 'search', 'searchbox', 'separator', 'slider',
|
|
37
|
+
'spinbutton', 'status', 'switch', 'tab', 'table', 'tablist', 'tabpanel', 'term',
|
|
38
|
+
'textbox', 'timer', 'toolbar', 'tooltip', 'tree', 'treegrid', 'treeitem',
|
|
39
|
+
]);
|
|
40
|
+
const els = Array.from(document.querySelectorAll('[role]'));
|
|
41
|
+
return els
|
|
42
|
+
.filter((el) => !validRoles.has(el.getAttribute('role') ?? ''))
|
|
43
|
+
.map((el) => ({ selector: getCssPath(el), html: el.outerHTML.slice(0, 200) }));
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'aria-required-attr',
|
|
48
|
+
wcag: '4.1.2',
|
|
49
|
+
level: 'A',
|
|
50
|
+
impact: 'critical',
|
|
51
|
+
description: 'ARIA roles must include all required attributes',
|
|
52
|
+
check: () => {
|
|
53
|
+
const roleRequiredAttrs = {
|
|
54
|
+
checkbox: ['aria-checked'],
|
|
55
|
+
combobox: ['aria-expanded'],
|
|
56
|
+
option: ['aria-selected'],
|
|
57
|
+
radio: ['aria-checked'],
|
|
58
|
+
scrollbar: ['aria-controls', 'aria-valuenow'],
|
|
59
|
+
slider: ['aria-valuenow'],
|
|
60
|
+
spinbutton: ['aria-valuenow'],
|
|
61
|
+
switch: ['aria-checked'],
|
|
62
|
+
};
|
|
63
|
+
const results = [];
|
|
64
|
+
for (const [role, required] of Object.entries(roleRequiredAttrs)) {
|
|
65
|
+
const els = Array.from(document.querySelectorAll(`[role="${role}"]`));
|
|
66
|
+
for (const el of els) {
|
|
67
|
+
const missing = required.filter((attr) => !el.hasAttribute(attr));
|
|
68
|
+
if (missing.length > 0) {
|
|
69
|
+
results.push({
|
|
70
|
+
selector: el.id ? `#${el.id}` : `[role="${role}"]`,
|
|
71
|
+
html: el.outerHTML.slice(0, 200),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return results;
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: 'aria-hidden-focus',
|
|
81
|
+
wcag: '4.1.2',
|
|
82
|
+
level: 'A',
|
|
83
|
+
impact: 'serious',
|
|
84
|
+
description: 'Elements with aria-hidden="true" must not be keyboard focusable',
|
|
85
|
+
check: () => {
|
|
86
|
+
const getCssPath = (el) => {
|
|
87
|
+
if (el.id)
|
|
88
|
+
return '#' + el.id;
|
|
89
|
+
const parts = [];
|
|
90
|
+
let node = el;
|
|
91
|
+
while (node && node.tagName !== 'BODY') {
|
|
92
|
+
const parent = node.parentElement;
|
|
93
|
+
if (!parent)
|
|
94
|
+
break;
|
|
95
|
+
const tag = node.tagName.toLowerCase();
|
|
96
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
97
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
98
|
+
if (parent.id) {
|
|
99
|
+
parts.unshift('#' + parent.id);
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
node = parent;
|
|
103
|
+
}
|
|
104
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
105
|
+
};
|
|
106
|
+
const els = Array.from(document.querySelectorAll('[aria-hidden="true"]'));
|
|
107
|
+
return els
|
|
108
|
+
.filter((el) => {
|
|
109
|
+
const tabindex = el.getAttribute('tabindex');
|
|
110
|
+
const isFocusableTag = ['a', 'button', 'input', 'select', 'textarea'].includes(el.tagName.toLowerCase());
|
|
111
|
+
return (tabindex !== null && parseInt(tabindex) >= 0) || (isFocusableTag && tabindex === null);
|
|
112
|
+
})
|
|
113
|
+
.map((el) => ({ selector: getCssPath(el), html: el.outerHTML.slice(0, 200) }));
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
id: 'button-name',
|
|
118
|
+
wcag: '4.1.2',
|
|
119
|
+
level: 'A',
|
|
120
|
+
impact: 'critical',
|
|
121
|
+
description: 'Buttons must have an accessible name',
|
|
122
|
+
check: () => {
|
|
123
|
+
const getCssPath = (el) => {
|
|
124
|
+
if (el.id)
|
|
125
|
+
return '#' + el.id;
|
|
126
|
+
const parts = [];
|
|
127
|
+
let node = el;
|
|
128
|
+
while (node && node.tagName !== 'BODY') {
|
|
129
|
+
const parent = node.parentElement;
|
|
130
|
+
if (!parent)
|
|
131
|
+
break;
|
|
132
|
+
const tag = node.tagName.toLowerCase();
|
|
133
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
134
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
135
|
+
if (parent.id) {
|
|
136
|
+
parts.unshift('#' + parent.id);
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
node = parent;
|
|
140
|
+
}
|
|
141
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
142
|
+
};
|
|
143
|
+
const buttons = Array.from(document.querySelectorAll('button, [role="button"]'));
|
|
144
|
+
return buttons
|
|
145
|
+
.filter((el) => {
|
|
146
|
+
const text = el.textContent?.trim() ?? '';
|
|
147
|
+
const ariaLabel = el.getAttribute('aria-label')?.trim() ?? '';
|
|
148
|
+
const ariaLabelledby = el.getAttribute('aria-labelledby');
|
|
149
|
+
const labelledEl = ariaLabelledby ? document.getElementById(ariaLabelledby) : null;
|
|
150
|
+
return !text && !ariaLabel && !labelledEl?.textContent?.trim();
|
|
151
|
+
})
|
|
152
|
+
.map((el) => ({ selector: getCssPath(el), html: el.outerHTML.slice(0, 200) }));
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
id: 'aria-required-children',
|
|
157
|
+
wcag: '1.3.1',
|
|
158
|
+
level: 'A',
|
|
159
|
+
impact: 'critical',
|
|
160
|
+
description: 'Elements with ARIA owner roles must contain the required child roles',
|
|
161
|
+
check: () => {
|
|
162
|
+
const getCssPath = (el) => {
|
|
163
|
+
if (el.id)
|
|
164
|
+
return '#' + el.id;
|
|
165
|
+
const parts = [];
|
|
166
|
+
let node = el;
|
|
167
|
+
while (node && node.tagName !== 'BODY') {
|
|
168
|
+
const parent = node.parentElement;
|
|
169
|
+
if (!parent)
|
|
170
|
+
break;
|
|
171
|
+
const tag = node.tagName.toLowerCase();
|
|
172
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
173
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
174
|
+
if (parent.id) {
|
|
175
|
+
parts.unshift('#' + parent.id);
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
node = parent;
|
|
179
|
+
}
|
|
180
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
181
|
+
};
|
|
182
|
+
const required = {
|
|
183
|
+
listbox: ['option'],
|
|
184
|
+
radiogroup: ['radio'],
|
|
185
|
+
grid: ['row', 'rowgroup'],
|
|
186
|
+
menu: ['menuitem', 'menuitemcheckbox', 'menuitemradio'],
|
|
187
|
+
menubar: ['menuitem', 'menuitemcheckbox', 'menuitemradio'],
|
|
188
|
+
tablist: ['tab'],
|
|
189
|
+
tree: ['treeitem'],
|
|
190
|
+
treegrid: ['row'],
|
|
191
|
+
};
|
|
192
|
+
const results = [];
|
|
193
|
+
for (const [role, children] of Object.entries(required)) {
|
|
194
|
+
const els = Array.from(document.querySelectorAll(`[role="${role}"]`));
|
|
195
|
+
for (const el of els) {
|
|
196
|
+
if (el.getAttribute('aria-busy') === 'true')
|
|
197
|
+
continue;
|
|
198
|
+
const hasChild = children.some((childRole) => el.querySelector(`[role="${childRole}"]`) !== null);
|
|
199
|
+
if (!hasChild) {
|
|
200
|
+
results.push({ selector: getCssPath(el), html: el.outerHTML.slice(0, 200) });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return results;
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
id: 'aria-required-parent',
|
|
209
|
+
wcag: '1.3.1',
|
|
210
|
+
level: 'A',
|
|
211
|
+
impact: 'critical',
|
|
212
|
+
description: 'ARIA child roles must be contained in a required parent role',
|
|
213
|
+
check: () => {
|
|
214
|
+
const getCssPath = (el) => {
|
|
215
|
+
if (el.id)
|
|
216
|
+
return '#' + el.id;
|
|
217
|
+
const parts = [];
|
|
218
|
+
let node = el;
|
|
219
|
+
while (node && node.tagName !== 'BODY') {
|
|
220
|
+
const parent = node.parentElement;
|
|
221
|
+
if (!parent)
|
|
222
|
+
break;
|
|
223
|
+
const tag = node.tagName.toLowerCase();
|
|
224
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
225
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
226
|
+
if (parent.id) {
|
|
227
|
+
parts.unshift('#' + parent.id);
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
node = parent;
|
|
231
|
+
}
|
|
232
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
233
|
+
};
|
|
234
|
+
const parentMap = {
|
|
235
|
+
option: ['listbox', 'combobox'],
|
|
236
|
+
tab: ['tablist'],
|
|
237
|
+
treeitem: ['tree', 'group'],
|
|
238
|
+
menuitem: ['menu', 'menubar'],
|
|
239
|
+
menuitemcheckbox: ['menu', 'menubar'],
|
|
240
|
+
menuitemradio: ['menu', 'menubar'],
|
|
241
|
+
gridcell: ['row'],
|
|
242
|
+
row: ['grid', 'rowgroup', 'treegrid'],
|
|
243
|
+
columnheader: ['row'],
|
|
244
|
+
rowheader: ['row'],
|
|
245
|
+
};
|
|
246
|
+
const results = [];
|
|
247
|
+
for (const [role, parents] of Object.entries(parentMap)) {
|
|
248
|
+
const els = Array.from(document.querySelectorAll(`[role="${role}"]`));
|
|
249
|
+
for (const el of els) {
|
|
250
|
+
const hasParent = parents.some((parentRole) => el.closest(`[role="${parentRole}"]`) !== null);
|
|
251
|
+
if (!hasParent) {
|
|
252
|
+
results.push({ selector: getCssPath(el), html: el.outerHTML.slice(0, 200) });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return results;
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
id: 'aria-prohibited-attr',
|
|
261
|
+
wcag: '4.1.2',
|
|
262
|
+
level: 'A',
|
|
263
|
+
impact: 'moderate',
|
|
264
|
+
description: 'Elements with role="presentation" or role="none" must not carry ARIA semantics',
|
|
265
|
+
check: () => {
|
|
266
|
+
const getCssPath = (el) => {
|
|
267
|
+
if (el.id)
|
|
268
|
+
return '#' + el.id;
|
|
269
|
+
const parts = [];
|
|
270
|
+
let node = el;
|
|
271
|
+
while (node && node.tagName !== 'BODY') {
|
|
272
|
+
const parent = node.parentElement;
|
|
273
|
+
if (!parent)
|
|
274
|
+
break;
|
|
275
|
+
const tag = node.tagName.toLowerCase();
|
|
276
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
277
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
278
|
+
if (parent.id) {
|
|
279
|
+
parts.unshift('#' + parent.id);
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
node = parent;
|
|
283
|
+
}
|
|
284
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
285
|
+
};
|
|
286
|
+
const prohibited = ['aria-label', 'aria-labelledby', 'aria-describedby', 'aria-checked', 'aria-selected', 'aria-expanded', 'aria-required'];
|
|
287
|
+
const els = Array.from(document.querySelectorAll('[role="presentation"],[role="none"]'));
|
|
288
|
+
return els
|
|
289
|
+
.filter((el) => prohibited.some((attr) => el.hasAttribute(attr)))
|
|
290
|
+
.map((el) => ({ selector: getCssPath(el), html: el.outerHTML.slice(0, 200) }));
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
];
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
export const colorContrastRules = [
|
|
2
|
+
{
|
|
3
|
+
id: 'color-contrast-text',
|
|
4
|
+
wcag: '1.4.3',
|
|
5
|
+
level: 'AA',
|
|
6
|
+
impact: 'serious',
|
|
7
|
+
description: 'Text must have a contrast ratio of at least 4.5:1 against its background',
|
|
8
|
+
check: () => {
|
|
9
|
+
const getCssPath = (el) => {
|
|
10
|
+
if (el.id)
|
|
11
|
+
return '#' + el.id;
|
|
12
|
+
const parts = [];
|
|
13
|
+
let node = el;
|
|
14
|
+
while (node && node.tagName !== 'BODY') {
|
|
15
|
+
const parent = node.parentElement;
|
|
16
|
+
if (!parent)
|
|
17
|
+
break;
|
|
18
|
+
const tag = node.tagName.toLowerCase();
|
|
19
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
20
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
21
|
+
if (parent.id) {
|
|
22
|
+
parts.unshift('#' + parent.id);
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
node = parent;
|
|
26
|
+
}
|
|
27
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
28
|
+
};
|
|
29
|
+
const elements = Array.from(document.querySelectorAll('p, span, li, td, th, h1, h2, h3, h4, h5, h6, a, label'));
|
|
30
|
+
const violations = [];
|
|
31
|
+
for (const el of elements) {
|
|
32
|
+
if (!el.textContent?.trim())
|
|
33
|
+
continue;
|
|
34
|
+
const style = window.getComputedStyle(el);
|
|
35
|
+
const fontSize = parseFloat(style.fontSize);
|
|
36
|
+
const fontWeight = style.fontWeight;
|
|
37
|
+
const isLargeText = fontSize >= 24 || (fontSize >= 18.67 && (fontWeight === 'bold' || parseInt(fontWeight) >= 700));
|
|
38
|
+
if (isLargeText)
|
|
39
|
+
continue;
|
|
40
|
+
const fgRaw = style.color;
|
|
41
|
+
const bgRaw = style.backgroundColor;
|
|
42
|
+
const fgMatch = fgRaw.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
|
43
|
+
const bgMatch = bgRaw.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
|
44
|
+
if (!fgMatch || !bgMatch)
|
|
45
|
+
continue;
|
|
46
|
+
const toLinear = (c) => { const s = c / 255; return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); };
|
|
47
|
+
const lum = (r, g, b) => 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
|
|
48
|
+
const l1 = lum(+fgMatch[1], +fgMatch[2], +fgMatch[3]);
|
|
49
|
+
const l2 = lum(+bgMatch[1], +bgMatch[2], +bgMatch[3]);
|
|
50
|
+
const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
|
51
|
+
if (ratio < 4.5) {
|
|
52
|
+
violations.push({ selector: getCssPath(el), html: el.outerHTML.slice(0, 200) });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return violations;
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: 'color-contrast-large-text',
|
|
60
|
+
wcag: '1.4.3',
|
|
61
|
+
level: 'AA',
|
|
62
|
+
impact: 'serious',
|
|
63
|
+
description: 'Large text (18pt or 14pt bold) must have a contrast ratio of at least 3:1',
|
|
64
|
+
check: () => {
|
|
65
|
+
const getCssPath = (el) => {
|
|
66
|
+
if (el.id)
|
|
67
|
+
return '#' + el.id;
|
|
68
|
+
const parts = [];
|
|
69
|
+
let node = el;
|
|
70
|
+
while (node && node.tagName !== 'BODY') {
|
|
71
|
+
const parent = node.parentElement;
|
|
72
|
+
if (!parent)
|
|
73
|
+
break;
|
|
74
|
+
const tag = node.tagName.toLowerCase();
|
|
75
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
76
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
77
|
+
if (parent.id) {
|
|
78
|
+
parts.unshift('#' + parent.id);
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
node = parent;
|
|
82
|
+
}
|
|
83
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
84
|
+
};
|
|
85
|
+
const elements = Array.from(document.querySelectorAll('p, span, h1, h2, h3, h4, h5, h6'));
|
|
86
|
+
const violations = [];
|
|
87
|
+
for (const el of elements) {
|
|
88
|
+
if (!el.textContent?.trim())
|
|
89
|
+
continue;
|
|
90
|
+
const style = window.getComputedStyle(el);
|
|
91
|
+
const fontSize = parseFloat(style.fontSize);
|
|
92
|
+
const fontWeight = style.fontWeight;
|
|
93
|
+
const isLargeText = fontSize >= 24 || (fontSize >= 18.67 && (fontWeight === 'bold' || parseInt(fontWeight) >= 700));
|
|
94
|
+
if (!isLargeText)
|
|
95
|
+
continue;
|
|
96
|
+
const fgRaw = style.color;
|
|
97
|
+
const bgRaw = style.backgroundColor;
|
|
98
|
+
const fgMatch = fgRaw.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
|
99
|
+
const bgMatch = bgRaw.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
|
100
|
+
if (!fgMatch || !bgMatch)
|
|
101
|
+
continue;
|
|
102
|
+
const toLinear = (c) => { const s = c / 255; return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); };
|
|
103
|
+
const lum = (r, g, b) => 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
|
|
104
|
+
const l1 = lum(+fgMatch[1], +fgMatch[2], +fgMatch[3]);
|
|
105
|
+
const l2 = lum(+bgMatch[1], +bgMatch[2], +bgMatch[3]);
|
|
106
|
+
const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
|
107
|
+
if (ratio < 3) {
|
|
108
|
+
violations.push({ selector: getCssPath(el), html: el.outerHTML.slice(0, 200) });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return violations;
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
];
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
export const formRules = [
|
|
2
|
+
{
|
|
3
|
+
id: 'label-missing',
|
|
4
|
+
wcag: '1.3.1',
|
|
5
|
+
level: 'A',
|
|
6
|
+
impact: 'critical',
|
|
7
|
+
description: 'Form inputs must have an associated label',
|
|
8
|
+
check: () => {
|
|
9
|
+
const getCssPath = (el) => {
|
|
10
|
+
if (el.id)
|
|
11
|
+
return '#' + el.id;
|
|
12
|
+
const parts = [];
|
|
13
|
+
let node = el;
|
|
14
|
+
while (node && node.tagName !== 'BODY') {
|
|
15
|
+
const parent = node.parentElement;
|
|
16
|
+
if (!parent)
|
|
17
|
+
break;
|
|
18
|
+
const tag = node.tagName.toLowerCase();
|
|
19
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
20
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
21
|
+
if (parent.id) {
|
|
22
|
+
parts.unshift('#' + parent.id);
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
node = parent;
|
|
26
|
+
}
|
|
27
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
28
|
+
};
|
|
29
|
+
const inputs = Array.from(document.querySelectorAll('input:not([type="hidden"]):not([type="button"]):not([type="submit"]):not([type="reset"]), textarea, select'));
|
|
30
|
+
return inputs
|
|
31
|
+
.filter((el) => {
|
|
32
|
+
const id = el.getAttribute('id');
|
|
33
|
+
const hasLabel = id && document.querySelector(`label[for="${id}"]`);
|
|
34
|
+
const hasAriaLabel = el.hasAttribute('aria-label') || el.hasAttribute('aria-labelledby');
|
|
35
|
+
const isWrapped = el.closest('label') !== null;
|
|
36
|
+
return !hasLabel && !hasAriaLabel && !isWrapped;
|
|
37
|
+
})
|
|
38
|
+
.map((el) => ({ selector: getCssPath(el), html: el.outerHTML.slice(0, 200) }));
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'label-empty',
|
|
43
|
+
wcag: '1.3.1',
|
|
44
|
+
level: 'A',
|
|
45
|
+
impact: 'serious',
|
|
46
|
+
description: '<label> elements must have text content',
|
|
47
|
+
check: () => {
|
|
48
|
+
const getCssPath = (el) => {
|
|
49
|
+
if (el.id)
|
|
50
|
+
return '#' + el.id;
|
|
51
|
+
const parts = [];
|
|
52
|
+
let node = el;
|
|
53
|
+
while (node && node.tagName !== 'BODY') {
|
|
54
|
+
const parent = node.parentElement;
|
|
55
|
+
if (!parent)
|
|
56
|
+
break;
|
|
57
|
+
const tag = node.tagName.toLowerCase();
|
|
58
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
59
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
60
|
+
if (parent.id) {
|
|
61
|
+
parts.unshift('#' + parent.id);
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
node = parent;
|
|
65
|
+
}
|
|
66
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
67
|
+
};
|
|
68
|
+
const labels = Array.from(document.querySelectorAll('label'));
|
|
69
|
+
return labels
|
|
70
|
+
.filter((l) => !l.textContent?.trim())
|
|
71
|
+
.map((l) => ({ selector: getCssPath(l), html: l.outerHTML.slice(0, 200) }));
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: 'error-identification',
|
|
76
|
+
wcag: '3.3.1',
|
|
77
|
+
level: 'A',
|
|
78
|
+
impact: 'serious',
|
|
79
|
+
description: 'Inputs marked as invalid must have an error message linked via aria-describedby',
|
|
80
|
+
check: () => {
|
|
81
|
+
const getCssPath = (el) => {
|
|
82
|
+
if (el.id)
|
|
83
|
+
return '#' + el.id;
|
|
84
|
+
const parts = [];
|
|
85
|
+
let node = el;
|
|
86
|
+
while (node && node.tagName !== 'BODY') {
|
|
87
|
+
const parent = node.parentElement;
|
|
88
|
+
if (!parent)
|
|
89
|
+
break;
|
|
90
|
+
const tag = node.tagName.toLowerCase();
|
|
91
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
92
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
93
|
+
if (parent.id) {
|
|
94
|
+
parts.unshift('#' + parent.id);
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
node = parent;
|
|
98
|
+
}
|
|
99
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
100
|
+
};
|
|
101
|
+
const invalid = Array.from(document.querySelectorAll('[aria-invalid="true"]'));
|
|
102
|
+
return invalid
|
|
103
|
+
.filter((el) => {
|
|
104
|
+
const describedBy = el.getAttribute('aria-describedby');
|
|
105
|
+
if (!describedBy)
|
|
106
|
+
return true;
|
|
107
|
+
const errorEl = document.getElementById(describedBy);
|
|
108
|
+
return !errorEl || !errorEl.textContent?.trim();
|
|
109
|
+
})
|
|
110
|
+
.map((el) => ({ selector: getCssPath(el), html: el.outerHTML.slice(0, 200) }));
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: 'autocomplete',
|
|
115
|
+
wcag: '1.3.5',
|
|
116
|
+
level: 'AA',
|
|
117
|
+
impact: 'moderate',
|
|
118
|
+
description: 'Common form fields (name, email, phone) should have an autocomplete attribute',
|
|
119
|
+
check: () => {
|
|
120
|
+
const getCssPath = (el) => {
|
|
121
|
+
if (el.id)
|
|
122
|
+
return '#' + el.id;
|
|
123
|
+
const parts = [];
|
|
124
|
+
let node = el;
|
|
125
|
+
while (node && node.tagName !== 'BODY') {
|
|
126
|
+
const parent = node.parentElement;
|
|
127
|
+
if (!parent)
|
|
128
|
+
break;
|
|
129
|
+
const tag = node.tagName.toLowerCase();
|
|
130
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
131
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
132
|
+
if (parent.id) {
|
|
133
|
+
parts.unshift('#' + parent.id);
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
node = parent;
|
|
137
|
+
}
|
|
138
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
139
|
+
};
|
|
140
|
+
const nameFields = Array.from(document.querySelectorAll('input[type="text"][id*="name"], input[type="text"][name*="name"], input[type="email"], input[type="tel"]'));
|
|
141
|
+
return nameFields
|
|
142
|
+
.filter((el) => !el.hasAttribute('autocomplete'))
|
|
143
|
+
.map((el) => ({ selector: getCssPath(el), html: el.outerHTML.slice(0, 200) }));
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: 'input-button-name',
|
|
148
|
+
wcag: '4.1.2',
|
|
149
|
+
level: 'A',
|
|
150
|
+
impact: 'critical',
|
|
151
|
+
description: 'Input buttons must have discernible text via value or aria-label',
|
|
152
|
+
check: () => {
|
|
153
|
+
const getCssPath = (el) => {
|
|
154
|
+
if (el.id)
|
|
155
|
+
return '#' + el.id;
|
|
156
|
+
const parts = [];
|
|
157
|
+
let node = el;
|
|
158
|
+
while (node && node.tagName !== 'BODY') {
|
|
159
|
+
const parent = node.parentElement;
|
|
160
|
+
if (!parent)
|
|
161
|
+
break;
|
|
162
|
+
const tag = node.tagName.toLowerCase();
|
|
163
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
164
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
165
|
+
if (parent.id) {
|
|
166
|
+
parts.unshift('#' + parent.id);
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
node = parent;
|
|
170
|
+
}
|
|
171
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
172
|
+
};
|
|
173
|
+
const inputs = Array.from(document.querySelectorAll('input[type="button"], input[type="submit"], input[type="reset"]'));
|
|
174
|
+
return inputs
|
|
175
|
+
.filter((el) => {
|
|
176
|
+
const value = el.getAttribute('value')?.trim() ?? '';
|
|
177
|
+
const ariaLabel = el.getAttribute('aria-label')?.trim() ?? '';
|
|
178
|
+
const ariaLabelledby = el.getAttribute('aria-labelledby');
|
|
179
|
+
return !value && !ariaLabel && !ariaLabelledby;
|
|
180
|
+
})
|
|
181
|
+
.map((el) => ({ selector: getCssPath(el), html: el.outerHTML.slice(0, 200) }));
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
id: 'fieldset-legend',
|
|
186
|
+
wcag: '1.3.1',
|
|
187
|
+
level: 'A',
|
|
188
|
+
impact: 'moderate',
|
|
189
|
+
description: '<fieldset> elements must have a <legend> with non-empty text',
|
|
190
|
+
check: () => {
|
|
191
|
+
const getCssPath = (el) => {
|
|
192
|
+
if (el.id)
|
|
193
|
+
return '#' + el.id;
|
|
194
|
+
const parts = [];
|
|
195
|
+
let node = el;
|
|
196
|
+
while (node && node.tagName !== 'BODY') {
|
|
197
|
+
const parent = node.parentElement;
|
|
198
|
+
if (!parent)
|
|
199
|
+
break;
|
|
200
|
+
const tag = node.tagName.toLowerCase();
|
|
201
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
202
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
203
|
+
if (parent.id) {
|
|
204
|
+
parts.unshift('#' + parent.id);
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
node = parent;
|
|
208
|
+
}
|
|
209
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
210
|
+
};
|
|
211
|
+
const fieldsets = Array.from(document.querySelectorAll('fieldset'));
|
|
212
|
+
return fieldsets
|
|
213
|
+
.filter((fs) => {
|
|
214
|
+
const legend = fs.querySelector('legend');
|
|
215
|
+
const ariaLabel = fs.getAttribute('aria-label')?.trim() ?? '';
|
|
216
|
+
const ariaLabelledby = fs.getAttribute('aria-labelledby');
|
|
217
|
+
return !ariaLabel && !ariaLabelledby && (!legend || !legend.textContent?.trim());
|
|
218
|
+
})
|
|
219
|
+
.map((fs) => ({ selector: getCssPath(fs), html: fs.outerHTML.slice(0, 200) }));
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
id: 'form-field-required-label',
|
|
224
|
+
wcag: '3.3.2',
|
|
225
|
+
level: 'A',
|
|
226
|
+
impact: 'moderate',
|
|
227
|
+
description: 'Required form fields should expose their required state via aria-required',
|
|
228
|
+
check: () => {
|
|
229
|
+
const getCssPath = (el) => {
|
|
230
|
+
if (el.id)
|
|
231
|
+
return '#' + el.id;
|
|
232
|
+
const parts = [];
|
|
233
|
+
let node = el;
|
|
234
|
+
while (node && node.tagName !== 'BODY') {
|
|
235
|
+
const parent = node.parentElement;
|
|
236
|
+
if (!parent)
|
|
237
|
+
break;
|
|
238
|
+
const tag = node.tagName.toLowerCase();
|
|
239
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
240
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
241
|
+
if (parent.id) {
|
|
242
|
+
parts.unshift('#' + parent.id);
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
node = parent;
|
|
246
|
+
}
|
|
247
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
248
|
+
};
|
|
249
|
+
const requiredInputs = Array.from(document.querySelectorAll('input[required], textarea[required], select[required]'));
|
|
250
|
+
return requiredInputs
|
|
251
|
+
.filter((el) => !el.hasAttribute('aria-required'))
|
|
252
|
+
.map((el) => ({ selector: getCssPath(el), html: el.outerHTML.slice(0, 200) }));
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
];
|