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.
@@ -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
+ ];