@mindfiredigital/ignix-lite-engine 1.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/.turbo/turbo-build.log +22 -0
- package/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +283 -0
- package/dist/index.d.ts +171 -0
- package/dist/index.js +2540 -0
- package/dist/index.js.map +1 -0
- package/dist/manifests/accordion.json +61 -0
- package/dist/manifests/alert.json +69 -0
- package/dist/manifests/avatar.json +75 -0
- package/dist/manifests/badge.json +74 -0
- package/dist/manifests/breadcrumb.json +87 -0
- package/dist/manifests/button.json +85 -0
- package/dist/manifests/card.json +91 -0
- package/dist/manifests/checkbox.json +122 -0
- package/dist/manifests/codeblock.json +63 -0
- package/dist/manifests/combobox.json +33 -0
- package/dist/manifests/dialog.json +64 -0
- package/dist/manifests/divider.json +47 -0
- package/dist/manifests/dropdown.json +105 -0
- package/dist/manifests/form.json +81 -0
- package/dist/manifests/grid.json +143 -0
- package/dist/manifests/input.json +99 -0
- package/dist/manifests/meter.json +103 -0
- package/dist/manifests/navigation.json +70 -0
- package/dist/manifests/progress.json +88 -0
- package/dist/manifests/radio.json +121 -0
- package/dist/manifests/select.json +109 -0
- package/dist/manifests/skeleton.json +101 -0
- package/dist/manifests/tab.json +88 -0
- package/dist/manifests/table.json +92 -0
- package/dist/manifests/textarea.json +117 -0
- package/dist/manifests/toast.json +157 -0
- package/dist/manifests/tooltip.json +115 -0
- package/dist/vector-index.json +14015 -0
- package/package.json +33 -0
- package/src/global.d.ts +3 -0
- package/src/index.ts +14 -0
- package/src/manifests/accordion.json +61 -0
- package/src/manifests/alert.json +69 -0
- package/src/manifests/avatar.json +75 -0
- package/src/manifests/badge.json +74 -0
- package/src/manifests/breadcrumb.json +87 -0
- package/src/manifests/button.json +85 -0
- package/src/manifests/card.json +91 -0
- package/src/manifests/checkbox.json +122 -0
- package/src/manifests/codeblock.json +63 -0
- package/src/manifests/combobox.json +33 -0
- package/src/manifests/dialog.json +64 -0
- package/src/manifests/divider.json +47 -0
- package/src/manifests/dropdown.json +105 -0
- package/src/manifests/form.json +81 -0
- package/src/manifests/grid.json +143 -0
- package/src/manifests/index.ts +49 -0
- package/src/manifests/input.json +99 -0
- package/src/manifests/meter.json +103 -0
- package/src/manifests/navigation.json +70 -0
- package/src/manifests/progress.json +88 -0
- package/src/manifests/radio.json +121 -0
- package/src/manifests/select.json +109 -0
- package/src/manifests/skeleton.json +101 -0
- package/src/manifests/tab.json +88 -0
- package/src/manifests/table.json +92 -0
- package/src/manifests/textarea.json +117 -0
- package/src/manifests/toast.json +157 -0
- package/src/manifests/tooltip.json +115 -0
- package/src/tools/build-index.ts +43 -0
- package/src/tools/check-a11y.ts +96 -0
- package/src/tools/embedder.ts +18 -0
- package/src/tools/generate-theme.ts +42 -0
- package/src/tools/get-emmet.ts +64 -0
- package/src/tools/get-manifests.ts +55 -0
- package/src/tools/handoff.ts +302 -0
- package/src/tools/intent-engine.ts +215 -0
- package/src/tools/list-components.ts +20 -0
- package/src/tools/preview.ts +186 -0
- package/src/tools/search-index.ts +82 -0
- package/src/tools/theme-palette.ts +65 -0
- package/src/tools/theme-tokens.ts +176 -0
- package/src/tools/token-counter.ts +59 -0
- package/src/tools/validator.ts +353 -0
- package/src/types.ts +63 -0
- package/src/utils/a11y-rules.ts +873 -0
- package/src/utils/a11y-types.ts +15 -0
- package/src/utils/cosine.ts +15 -0
- package/src/utils/emmet-helpers.ts +283 -0
- package/src/utils/intent-helpers.ts +66 -0
- package/src/utils/intent-parser.ts +175 -0
- package/src/utils/tokenizer.ts +7 -0
- package/tsconfig.json +17 -0
- package/tsup.config.ts +10 -0
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
import type { HTMLElement } from 'node-html-parser'
|
|
2
|
+
import type { A11yIssue, RuleResult } from './a11y-types.js'
|
|
3
|
+
|
|
4
|
+
function injectAttr(outerHTML: string, attr: string, value: string): string {
|
|
5
|
+
return outerHTML.replace(/^(<[a-zA-Z][a-zA-Z0-9-]*)/, `$1 ${attr}="${value}"`)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function clip(str: string, max = 200): string {
|
|
9
|
+
return str.length > max ? str.slice(0, max) + '...' : str
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isDecorative(img: HTMLElement): boolean {
|
|
13
|
+
const role = img.getAttribute('role')
|
|
14
|
+
const ariaHidden = img.getAttribute('aria-hidden')
|
|
15
|
+
return role === 'presentation' || role === 'none' || ariaHidden === 'true'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Returns IDs from an ARIA reference attribute that do not exist in the DOM
|
|
19
|
+
function findBrokenAriaRefs(
|
|
20
|
+
el: HTMLElement,
|
|
21
|
+
attr: string,
|
|
22
|
+
root: HTMLElement
|
|
23
|
+
): string[] {
|
|
24
|
+
const val = el.getAttribute(attr)
|
|
25
|
+
if (!val?.trim()) return []
|
|
26
|
+
return val
|
|
27
|
+
.trim()
|
|
28
|
+
.split(/\s+/)
|
|
29
|
+
.filter((id) => !root.querySelector(`[id="${id}"]`))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Walks child nodes skipping any subtree rooted at aria-hidden="true"
|
|
33
|
+
function getVisibleText(el: HTMLElement): string {
|
|
34
|
+
if (el.getAttribute('aria-hidden') === 'true') return ''
|
|
35
|
+
let result = ''
|
|
36
|
+
for (const child of el.childNodes) {
|
|
37
|
+
if (child.nodeType === 1) {
|
|
38
|
+
result += getVisibleText(child as HTMLElement)
|
|
39
|
+
} else if (child.nodeType === 3) {
|
|
40
|
+
result += child.text ?? ''
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return result
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getAccessibleName(el: HTMLElement, root: HTMLElement): string {
|
|
47
|
+
const text = getVisibleText(el).trim()
|
|
48
|
+
if (text) return text
|
|
49
|
+
|
|
50
|
+
const ariaLabel = el.getAttribute('aria-label')?.trim()
|
|
51
|
+
if (ariaLabel) return ariaLabel
|
|
52
|
+
|
|
53
|
+
const title = el.getAttribute('title')?.trim()
|
|
54
|
+
if (title) return title
|
|
55
|
+
|
|
56
|
+
const childImg = el.querySelector('img')
|
|
57
|
+
const childAlt = childImg?.getAttribute('alt')?.trim()
|
|
58
|
+
if (childAlt) return childAlt
|
|
59
|
+
|
|
60
|
+
const labelledBy = el.getAttribute('aria-labelledby')
|
|
61
|
+
if (labelledBy) {
|
|
62
|
+
const name = labelledBy
|
|
63
|
+
.trim()
|
|
64
|
+
.split(/\s+/)
|
|
65
|
+
.map((id) => root.querySelector(`[id="${id}"]`)?.text.trim() ?? '')
|
|
66
|
+
.join(' ')
|
|
67
|
+
.trim()
|
|
68
|
+
if (name) return name
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return ''
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function checkImages(root: HTMLElement): RuleResult {
|
|
75
|
+
const ruleName = 'WCAG 1.1.1 Non-text Content'
|
|
76
|
+
const issues: A11yIssue[] = []
|
|
77
|
+
|
|
78
|
+
root.querySelectorAll('img').forEach((img) => {
|
|
79
|
+
const alt = img.getAttribute('alt')
|
|
80
|
+
const ariaLabel = img.getAttribute('aria-label')?.trim()
|
|
81
|
+
const labelledBy = img.getAttribute('aria-labelledby')
|
|
82
|
+
const hasAriaName = !!(ariaLabel || labelledBy)
|
|
83
|
+
|
|
84
|
+
if (alt === undefined) {
|
|
85
|
+
if (hasAriaName) {
|
|
86
|
+
issues.push({
|
|
87
|
+
type: 'warning',
|
|
88
|
+
rule: ruleName,
|
|
89
|
+
element: clip(img.outerHTML),
|
|
90
|
+
message:
|
|
91
|
+
'<img> uses aria-label/aria-labelledby but the alt attribute is recommended for broader screen reader compatibility',
|
|
92
|
+
fix: injectAttr(
|
|
93
|
+
img.outerHTML,
|
|
94
|
+
'alt',
|
|
95
|
+
ariaLabel ?? '[Describe the image]'
|
|
96
|
+
)
|
|
97
|
+
})
|
|
98
|
+
} else {
|
|
99
|
+
issues.push({
|
|
100
|
+
type: 'error',
|
|
101
|
+
rule: ruleName,
|
|
102
|
+
element: clip(img.outerHTML),
|
|
103
|
+
message: '<img> is missing the alt attribute',
|
|
104
|
+
fix: injectAttr(img.outerHTML, 'alt', '[Describe the image]')
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (alt.trim() === '' && !isDecorative(img)) {
|
|
111
|
+
issues.push({
|
|
112
|
+
type: 'warning',
|
|
113
|
+
rule: ruleName,
|
|
114
|
+
element: clip(img.outerHTML),
|
|
115
|
+
message:
|
|
116
|
+
'<img> has an empty alt but is not marked as decorative - add role="presentation" or provide a description',
|
|
117
|
+
fix: injectAttr(img.outerHTML, 'role', 'presentation')
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
return { ruleName, issues }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function checkFormLabels(root: HTMLElement): RuleResult {
|
|
126
|
+
const ruleName = 'WCAG 1.3.1 Form Labels'
|
|
127
|
+
const issues: A11yIssue[] = []
|
|
128
|
+
const UNLABELED_SKIP = new Set([
|
|
129
|
+
'hidden',
|
|
130
|
+
'submit',
|
|
131
|
+
'reset',
|
|
132
|
+
'button',
|
|
133
|
+
'image'
|
|
134
|
+
])
|
|
135
|
+
|
|
136
|
+
for (const tag of ['input', 'select', 'textarea'] as const) {
|
|
137
|
+
root.querySelectorAll(tag).forEach((el) => {
|
|
138
|
+
if (tag === 'input') {
|
|
139
|
+
const type = (el.getAttribute('type') ?? 'text').toLowerCase()
|
|
140
|
+
if (UNLABELED_SKIP.has(type)) return
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const id = el.getAttribute('id')
|
|
144
|
+
const hasExplicit = id
|
|
145
|
+
? !!root.querySelector(`label[for="${id}"]`)
|
|
146
|
+
: false
|
|
147
|
+
const hasImplicit = !!el.closest('label')
|
|
148
|
+
const ariaLabel = el.getAttribute('aria-label')?.trim()
|
|
149
|
+
const ariaLabelledBy = el.getAttribute('aria-labelledby')
|
|
150
|
+
const hasTitle = !!el.getAttribute('title')?.trim()
|
|
151
|
+
|
|
152
|
+
if (
|
|
153
|
+
!hasExplicit &&
|
|
154
|
+
!hasImplicit &&
|
|
155
|
+
!ariaLabel &&
|
|
156
|
+
!ariaLabelledBy &&
|
|
157
|
+
!hasTitle
|
|
158
|
+
) {
|
|
159
|
+
issues.push({
|
|
160
|
+
type: 'error',
|
|
161
|
+
rule: ruleName,
|
|
162
|
+
element: clip(el.outerHTML),
|
|
163
|
+
message: `<${tag}> is not associated with a <label> - use for/id, wrapping label, or aria-label`,
|
|
164
|
+
fix: id
|
|
165
|
+
? `<label for="${id}">[Label text]</label>\n${el.outerHTML}`
|
|
166
|
+
: `<label>[Label text] ${el.outerHTML}</label>`
|
|
167
|
+
})
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Verify aria-labelledby targets actually exist in the DOM
|
|
172
|
+
if (ariaLabelledBy) {
|
|
173
|
+
const broken = findBrokenAriaRefs(el, 'aria-labelledby', root)
|
|
174
|
+
if (broken.length > 0) {
|
|
175
|
+
issues.push({
|
|
176
|
+
type: 'error',
|
|
177
|
+
rule: ruleName,
|
|
178
|
+
element: clip(el.outerHTML),
|
|
179
|
+
message: `<${tag}> aria-labelledby references non-existent element(s): ${broken.map((id) => `#${id}`).join(', ')}`,
|
|
180
|
+
fix: `Ensure element(s) with id="${broken.join('", "')}" exist in the DOM, or use aria-label instead`
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
root.querySelectorAll('fieldset').forEach((fieldset) => {
|
|
188
|
+
const legend = fieldset.querySelector('legend')
|
|
189
|
+
if (!legend || !legend.text.trim()) {
|
|
190
|
+
issues.push({
|
|
191
|
+
type: 'error',
|
|
192
|
+
rule: ruleName,
|
|
193
|
+
element: '<fieldset>',
|
|
194
|
+
message:
|
|
195
|
+
'<fieldset> must have a non-empty <legend> to group related form controls',
|
|
196
|
+
fix: `<fieldset>\n <legend>[Group label]</legend>\n ...\n</fieldset>`
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
return { ruleName, issues }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function checkEmptyLabels(root: HTMLElement): RuleResult {
|
|
205
|
+
const ruleName = 'WCAG 2.4.6 Empty Labels'
|
|
206
|
+
const issues: A11yIssue[] = []
|
|
207
|
+
|
|
208
|
+
root.querySelectorAll('label').forEach((label) => {
|
|
209
|
+
const hasText = !!label.text.trim()
|
|
210
|
+
const hasAriaLabel = !!label.getAttribute('aria-label')?.trim()
|
|
211
|
+
|
|
212
|
+
if (!hasText && !hasAriaLabel) {
|
|
213
|
+
issues.push({
|
|
214
|
+
type: 'warning',
|
|
215
|
+
rule: ruleName,
|
|
216
|
+
element: clip(label.outerHTML),
|
|
217
|
+
message:
|
|
218
|
+
'<label> is empty and provides no accessible name for its control',
|
|
219
|
+
fix: label.outerHTML.replace('</label>', '[Label text]</label>')
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Check broken label[for] association
|
|
224
|
+
const forAttr = label.getAttribute('for')
|
|
225
|
+
if (forAttr && !root.querySelector(`[id="${forAttr}"]`)) {
|
|
226
|
+
issues.push({
|
|
227
|
+
type: 'error',
|
|
228
|
+
rule: ruleName,
|
|
229
|
+
element: clip(label.outerHTML),
|
|
230
|
+
message: `<label for="${forAttr}"> references id="${forAttr}" which does not exist in the DOM`,
|
|
231
|
+
fix: `Add id="${forAttr}" to the target form element, or correct the for attribute`
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
return { ruleName, issues }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function checkButtons(root: HTMLElement): RuleResult {
|
|
240
|
+
const ruleName = 'WCAG 4.1.2 Button Names'
|
|
241
|
+
const issues: A11yIssue[] = []
|
|
242
|
+
|
|
243
|
+
root.querySelectorAll('button').forEach((button) => {
|
|
244
|
+
if (!getAccessibleName(button, root)) {
|
|
245
|
+
issues.push({
|
|
246
|
+
type: 'error',
|
|
247
|
+
rule: ruleName,
|
|
248
|
+
element: clip(button.outerHTML),
|
|
249
|
+
message:
|
|
250
|
+
'<button> has no accessible name - add text content, aria-label, or a child <img> with alt',
|
|
251
|
+
fix: injectAttr(button.outerHTML, 'aria-label', '[Action description]')
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
return { ruleName, issues }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function checkLinks(root: HTMLElement): RuleResult {
|
|
260
|
+
const ruleName = 'WCAG 2.4.4 Link Purpose'
|
|
261
|
+
const issues: A11yIssue[] = []
|
|
262
|
+
const VAGUE = new Set([
|
|
263
|
+
'click here',
|
|
264
|
+
'here',
|
|
265
|
+
'read more',
|
|
266
|
+
'more',
|
|
267
|
+
'link',
|
|
268
|
+
'click',
|
|
269
|
+
'learn more',
|
|
270
|
+
'details',
|
|
271
|
+
'info'
|
|
272
|
+
])
|
|
273
|
+
|
|
274
|
+
root.querySelectorAll('a').forEach((link) => {
|
|
275
|
+
const name = getAccessibleName(link, root)
|
|
276
|
+
const href = link.getAttribute('href')
|
|
277
|
+
const isButtonRole = link.getAttribute('role') === 'button'
|
|
278
|
+
|
|
279
|
+
if (!name) {
|
|
280
|
+
issues.push({
|
|
281
|
+
type: 'error',
|
|
282
|
+
rule: ruleName,
|
|
283
|
+
element: clip(link.outerHTML),
|
|
284
|
+
message:
|
|
285
|
+
'<a> has no accessible name - add text, aria-label, or a child <img> with alt',
|
|
286
|
+
fix: injectAttr(
|
|
287
|
+
link.outerHTML,
|
|
288
|
+
'aria-label',
|
|
289
|
+
'[Describe the link destination]'
|
|
290
|
+
)
|
|
291
|
+
})
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (VAGUE.has(name.toLowerCase())) {
|
|
296
|
+
issues.push({
|
|
297
|
+
type: 'warning',
|
|
298
|
+
rule: ruleName,
|
|
299
|
+
element: clip(link.outerHTML),
|
|
300
|
+
message: `<a> has non-descriptive text "${name}" - use aria-label to clarify the destination`,
|
|
301
|
+
fix: injectAttr(
|
|
302
|
+
link.outerHTML,
|
|
303
|
+
'aria-label',
|
|
304
|
+
'[Describe where this link goes]'
|
|
305
|
+
)
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (!href && !isButtonRole) {
|
|
310
|
+
issues.push({
|
|
311
|
+
type: 'warning',
|
|
312
|
+
rule: ruleName,
|
|
313
|
+
element: clip(link.outerHTML),
|
|
314
|
+
message:
|
|
315
|
+
'<a> has no href - use <button> for actions, or add a valid href',
|
|
316
|
+
fix: link.outerHTML.replace(/^<a\b/, '<a href="#"')
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
return { ruleName, issues }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function checkAriaStates(root: HTMLElement): RuleResult {
|
|
325
|
+
const ruleName = 'WCAG 3.3.1 Error Identification'
|
|
326
|
+
const issues: A11yIssue[] = []
|
|
327
|
+
|
|
328
|
+
// aria-invalid="true" must have aria-describedby pointing to an existing element
|
|
329
|
+
root.querySelectorAll('[aria-invalid]').forEach((el) => {
|
|
330
|
+
const invalidVal = el.getAttribute('aria-invalid')
|
|
331
|
+
const VALID_INVALID_VALUES = new Set([
|
|
332
|
+
'false',
|
|
333
|
+
'true',
|
|
334
|
+
'grammar',
|
|
335
|
+
'spelling'
|
|
336
|
+
])
|
|
337
|
+
|
|
338
|
+
if (!VALID_INVALID_VALUES.has(invalidVal ?? '')) {
|
|
339
|
+
issues.push({
|
|
340
|
+
type: 'warning',
|
|
341
|
+
rule: 'WCAG 4.1.2 ARIA State Values',
|
|
342
|
+
element: clip(el.outerHTML),
|
|
343
|
+
message: `aria-invalid="${invalidVal}" is not valid - use "true", "false", "grammar", or "spelling"`,
|
|
344
|
+
fix: el.outerHTML.replace(
|
|
345
|
+
`aria-invalid="${invalidVal}"`,
|
|
346
|
+
'aria-invalid="true"'
|
|
347
|
+
)
|
|
348
|
+
})
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (invalidVal === 'true') {
|
|
353
|
+
const describedBy = el.getAttribute('aria-describedby')
|
|
354
|
+
if (!describedBy) {
|
|
355
|
+
issues.push({
|
|
356
|
+
type: 'error',
|
|
357
|
+
rule: ruleName,
|
|
358
|
+
element: clip(el.outerHTML),
|
|
359
|
+
message:
|
|
360
|
+
'Element with aria-invalid="true" must have aria-describedby pointing to the error message element',
|
|
361
|
+
fix: injectAttr(el.outerHTML, 'aria-describedby', 'error-message-id')
|
|
362
|
+
})
|
|
363
|
+
} else {
|
|
364
|
+
// Verify the describedby target exists
|
|
365
|
+
const broken = findBrokenAriaRefs(el, 'aria-describedby', root)
|
|
366
|
+
if (broken.length > 0) {
|
|
367
|
+
issues.push({
|
|
368
|
+
type: 'error',
|
|
369
|
+
rule: ruleName,
|
|
370
|
+
element: clip(el.outerHTML),
|
|
371
|
+
message: `aria-describedby references non-existent element(s): ${broken.map((id) => `#${id}`).join(', ')}`,
|
|
372
|
+
fix: `Ensure element(s) with id="${broken.join('", "')}" exist in the DOM`
|
|
373
|
+
})
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
// Strictly boolean ARIA attributes (only "true" or "false" valid)
|
|
380
|
+
const BOOLEAN_ARIA_ATTRS = [
|
|
381
|
+
'aria-busy',
|
|
382
|
+
'aria-expanded',
|
|
383
|
+
'aria-selected',
|
|
384
|
+
'aria-pressed'
|
|
385
|
+
]
|
|
386
|
+
for (const attr of BOOLEAN_ARIA_ATTRS) {
|
|
387
|
+
root.querySelectorAll(`[${attr}]`).forEach((el) => {
|
|
388
|
+
const val = el.getAttribute(attr)
|
|
389
|
+
if (val !== 'true' && val !== 'false') {
|
|
390
|
+
issues.push({
|
|
391
|
+
type: 'warning',
|
|
392
|
+
rule: 'WCAG 4.1.2 ARIA State Values',
|
|
393
|
+
element: clip(el.outerHTML),
|
|
394
|
+
message: `${attr} must be "true" or "false", got "${val}"`,
|
|
395
|
+
fix: el.outerHTML.replace(`${attr}="${val}"`, `${attr}="true"`)
|
|
396
|
+
})
|
|
397
|
+
}
|
|
398
|
+
})
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// aria-checked also allows "mixed" for tri-state checkboxes (WAI-ARIA spec)
|
|
402
|
+
root.querySelectorAll('[aria-checked]').forEach((el) => {
|
|
403
|
+
const val = el.getAttribute('aria-checked')
|
|
404
|
+
if (val !== 'true' && val !== 'false' && val !== 'mixed') {
|
|
405
|
+
issues.push({
|
|
406
|
+
type: 'warning',
|
|
407
|
+
rule: 'WCAG 4.1.2 ARIA State Values',
|
|
408
|
+
element: clip(el.outerHTML),
|
|
409
|
+
message: `aria-checked must be "true", "false", or "mixed" (for tri-state), got "${val}"`,
|
|
410
|
+
fix: el.outerHTML.replace(
|
|
411
|
+
`aria-checked="${val}"`,
|
|
412
|
+
'aria-checked="false"'
|
|
413
|
+
)
|
|
414
|
+
})
|
|
415
|
+
}
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
// aria-live with invalid value
|
|
419
|
+
const VALID_ARIA_LIVE = new Set(['off', 'polite', 'assertive'])
|
|
420
|
+
root.querySelectorAll('[aria-live]').forEach((el) => {
|
|
421
|
+
const val = el.getAttribute('aria-live') ?? ''
|
|
422
|
+
if (!VALID_ARIA_LIVE.has(val)) {
|
|
423
|
+
issues.push({
|
|
424
|
+
type: 'warning',
|
|
425
|
+
rule: 'WCAG 4.1.2 ARIA State Values',
|
|
426
|
+
element: clip(el.outerHTML),
|
|
427
|
+
message: `aria-live="${val}" is not valid - use "off", "polite", or "assertive"`,
|
|
428
|
+
fix: el.outerHTML.replace(`aria-live="${val}"`, 'aria-live="polite"')
|
|
429
|
+
})
|
|
430
|
+
}
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
return { ruleName, issues }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export function checkDuplicateIds(root: HTMLElement): RuleResult {
|
|
437
|
+
const ruleName = 'WCAG 4.1.1 Parsing'
|
|
438
|
+
const issues: A11yIssue[] = []
|
|
439
|
+
const seen = new Map<string, number>()
|
|
440
|
+
|
|
441
|
+
root.querySelectorAll('[id]').forEach((el) => {
|
|
442
|
+
const id = el.getAttribute('id')
|
|
443
|
+
|
|
444
|
+
// Empty id is invalid HTML and breaks all ARIA references
|
|
445
|
+
if (id !== undefined && id.trim() === '') {
|
|
446
|
+
issues.push({
|
|
447
|
+
type: 'error',
|
|
448
|
+
rule: ruleName,
|
|
449
|
+
element: clip(el.outerHTML),
|
|
450
|
+
message: 'id="" is an empty ID - IDs must have a non-empty value',
|
|
451
|
+
fix: el.outerHTML.replace('id=""', 'id="[unique-id]"')
|
|
452
|
+
})
|
|
453
|
+
return
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (id) seen.set(id, (seen.get(id) ?? 0) + 1)
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
seen.forEach((count, id) => {
|
|
460
|
+
if (count > 1) {
|
|
461
|
+
issues.push({
|
|
462
|
+
type: 'error',
|
|
463
|
+
rule: ruleName,
|
|
464
|
+
element: `id="${id}"`,
|
|
465
|
+
message: `id="${id}" is used ${count} times - IDs must be unique within a document`,
|
|
466
|
+
fix: `Keep one element with id="${id}" and rename all duplicates to unique IDs`
|
|
467
|
+
})
|
|
468
|
+
}
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
return { ruleName, issues }
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export function checkTabIndex(root: HTMLElement): RuleResult {
|
|
475
|
+
const ruleName = 'WCAG 2.1.1 Keyboard'
|
|
476
|
+
const issues: A11yIssue[] = []
|
|
477
|
+
|
|
478
|
+
root.querySelectorAll('[tabindex]').forEach((el) => {
|
|
479
|
+
const raw = el.getAttribute('tabindex') ?? ''
|
|
480
|
+
const val = parseInt(raw, 10)
|
|
481
|
+
|
|
482
|
+
if (isNaN(val)) {
|
|
483
|
+
issues.push({
|
|
484
|
+
type: 'warning',
|
|
485
|
+
rule: ruleName,
|
|
486
|
+
element: clip(el.outerHTML),
|
|
487
|
+
message: `tabindex="${raw}" is not a valid integer`,
|
|
488
|
+
fix: el.outerHTML.replace(`tabindex="${raw}"`, 'tabindex="0"')
|
|
489
|
+
})
|
|
490
|
+
return
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (val > 0) {
|
|
494
|
+
issues.push({
|
|
495
|
+
type: 'warning',
|
|
496
|
+
rule: ruleName,
|
|
497
|
+
element: clip(el.outerHTML),
|
|
498
|
+
message: `tabindex="${val}" disrupts natural tab order - use tabindex="0" or rely on DOM order`,
|
|
499
|
+
fix: el.outerHTML.replace(`tabindex="${val}"`, 'tabindex="0"')
|
|
500
|
+
})
|
|
501
|
+
}
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
return { ruleName, issues }
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export function checkHeadings(root: HTMLElement): RuleResult {
|
|
508
|
+
const ruleName = 'WCAG 2.4.6 Heading Hierarchy'
|
|
509
|
+
const issues: A11yIssue[] = []
|
|
510
|
+
const headings = root.querySelectorAll('h1, h2, h3, h4, h5, h6')
|
|
511
|
+
|
|
512
|
+
let lastLevel = 0
|
|
513
|
+
let h1Count = 0
|
|
514
|
+
|
|
515
|
+
headings.forEach((h) => {
|
|
516
|
+
const tag = h.tagName.toLowerCase()
|
|
517
|
+
const level = parseInt(h.tagName.slice(1), 10)
|
|
518
|
+
|
|
519
|
+
if (!h.text.trim()) {
|
|
520
|
+
issues.push({
|
|
521
|
+
type: 'error',
|
|
522
|
+
rule: ruleName,
|
|
523
|
+
element: clip(h.outerHTML),
|
|
524
|
+
message: `<${tag}> is empty - headings must have descriptive text`,
|
|
525
|
+
fix: `<${tag}>[Heading text]</${tag}>`
|
|
526
|
+
})
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (level === 1) h1Count++
|
|
530
|
+
|
|
531
|
+
if (lastLevel > 0 && level > lastLevel + 1) {
|
|
532
|
+
issues.push({
|
|
533
|
+
type: 'warning',
|
|
534
|
+
rule: ruleName,
|
|
535
|
+
element: clip(h.outerHTML),
|
|
536
|
+
message: `Heading skips from h${lastLevel} to h${level} - use h${lastLevel + 1} to maintain document outline`,
|
|
537
|
+
fix: `<h${lastLevel + 1}>${h.text.trim()}</h${lastLevel + 1}>`
|
|
538
|
+
})
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
lastLevel = level
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
if (h1Count > 1) {
|
|
545
|
+
issues.push({
|
|
546
|
+
type: 'warning',
|
|
547
|
+
rule: ruleName,
|
|
548
|
+
element: 'h1',
|
|
549
|
+
message: `${h1Count} <h1> elements found - a page should have exactly one <h1>`,
|
|
550
|
+
fix: 'Demote additional <h1> elements to <h2> or lower'
|
|
551
|
+
})
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return { ruleName, issues }
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export function checkTables(root: HTMLElement): RuleResult {
|
|
558
|
+
const ruleName = 'WCAG 1.3.1 Table Structure'
|
|
559
|
+
const issues: A11yIssue[] = []
|
|
560
|
+
|
|
561
|
+
root.querySelectorAll('table').forEach((table) => {
|
|
562
|
+
// Layout/presentational tables do not need captions or headers
|
|
563
|
+
const role = table.getAttribute('role')
|
|
564
|
+
if (role === 'presentation' || role === 'none') return
|
|
565
|
+
|
|
566
|
+
const caption = table.querySelector('caption')
|
|
567
|
+
const ariaLabel = table.getAttribute('aria-label')
|
|
568
|
+
const ariaLabelledBy = table.getAttribute('aria-labelledby')
|
|
569
|
+
|
|
570
|
+
if (!caption && !ariaLabel && !ariaLabelledBy) {
|
|
571
|
+
issues.push({
|
|
572
|
+
type: 'warning',
|
|
573
|
+
rule: ruleName,
|
|
574
|
+
element: '<table>',
|
|
575
|
+
message:
|
|
576
|
+
'<table> has no caption, aria-label, or aria-labelledby - screen readers cannot identify its purpose',
|
|
577
|
+
fix: injectAttr('<table>', 'aria-label', '[Describe the table]')
|
|
578
|
+
})
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Empty caption is as bad as no caption
|
|
582
|
+
if (caption && !caption.text.trim()) {
|
|
583
|
+
issues.push({
|
|
584
|
+
type: 'warning',
|
|
585
|
+
rule: ruleName,
|
|
586
|
+
element: clip(caption.outerHTML),
|
|
587
|
+
message:
|
|
588
|
+
'<caption> is empty - provide a meaningful description of the table',
|
|
589
|
+
fix: caption.outerHTML.replace(
|
|
590
|
+
'</caption>',
|
|
591
|
+
'[Table description]</caption>'
|
|
592
|
+
)
|
|
593
|
+
})
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
table.querySelectorAll('th').forEach((th) => {
|
|
597
|
+
if (!th.getAttribute('scope')) {
|
|
598
|
+
issues.push({
|
|
599
|
+
type: 'warning',
|
|
600
|
+
rule: ruleName,
|
|
601
|
+
element: clip(th.outerHTML),
|
|
602
|
+
message:
|
|
603
|
+
'<th> is missing the scope attribute - use scope="col" for column headers or scope="row" for row headers',
|
|
604
|
+
fix: injectAttr(th.outerHTML, 'scope', 'col')
|
|
605
|
+
})
|
|
606
|
+
}
|
|
607
|
+
})
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
return { ruleName, issues }
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
export function checkDialogs(root: HTMLElement): RuleResult {
|
|
614
|
+
const ruleName = 'WCAG 4.1.2 Dialog Accessibility'
|
|
615
|
+
const issues: A11yIssue[] = []
|
|
616
|
+
|
|
617
|
+
root.querySelectorAll('dialog').forEach((dialog) => {
|
|
618
|
+
const id = dialog.getAttribute('id')
|
|
619
|
+
const ariaLabel = dialog.getAttribute('aria-label')
|
|
620
|
+
const ariaLabelledBy = dialog.getAttribute('aria-labelledby')
|
|
621
|
+
|
|
622
|
+
if (!id) {
|
|
623
|
+
issues.push({
|
|
624
|
+
type: 'warning',
|
|
625
|
+
rule: ruleName,
|
|
626
|
+
element: '<dialog>',
|
|
627
|
+
message:
|
|
628
|
+
'<dialog> has no id - required by the ignix-lite button[onclick="dialogId.showModal()"] pattern',
|
|
629
|
+
fix: injectAttr('<dialog>', 'id', 'dialog-id')
|
|
630
|
+
})
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (!ariaLabel && !ariaLabelledBy) {
|
|
634
|
+
issues.push({
|
|
635
|
+
type: 'warning',
|
|
636
|
+
rule: ruleName,
|
|
637
|
+
element: '<dialog>',
|
|
638
|
+
message:
|
|
639
|
+
'<dialog> has no accessible name - add aria-labelledby pointing to a heading inside, or aria-label',
|
|
640
|
+
fix: id
|
|
641
|
+
? `<dialog id="${id}" aria-labelledby="dialog-title">...</dialog>`
|
|
642
|
+
: `<dialog aria-label="[Dialog purpose]">...</dialog>`
|
|
643
|
+
})
|
|
644
|
+
} else if (ariaLabelledBy) {
|
|
645
|
+
// Verify the labelledby target exists
|
|
646
|
+
const broken = findBrokenAriaRefs(dialog, 'aria-labelledby', root)
|
|
647
|
+
if (broken.length > 0) {
|
|
648
|
+
issues.push({
|
|
649
|
+
type: 'error',
|
|
650
|
+
rule: ruleName,
|
|
651
|
+
element: '<dialog>',
|
|
652
|
+
message: `dialog aria-labelledby references non-existent element(s): ${broken.map((id) => `#${id}`).join(', ')}`,
|
|
653
|
+
fix: `Ensure element(s) with id="${broken.join('", "')}" exist inside the dialog`
|
|
654
|
+
})
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
return { ruleName, issues }
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
export function checkRoles(root: HTMLElement): RuleResult {
|
|
663
|
+
const ruleName = 'WCAG 4.1.2 ARIA Role Requirements'
|
|
664
|
+
const issues: A11yIssue[] = []
|
|
665
|
+
const NATIVELY_INTERACTIVE = new Set([
|
|
666
|
+
'a',
|
|
667
|
+
'button',
|
|
668
|
+
'input',
|
|
669
|
+
'select',
|
|
670
|
+
'textarea',
|
|
671
|
+
'details',
|
|
672
|
+
'summary'
|
|
673
|
+
])
|
|
674
|
+
|
|
675
|
+
root.querySelectorAll('[role="button"]').forEach((el) => {
|
|
676
|
+
const tag = el.tagName.toLowerCase()
|
|
677
|
+
const tabIndex = el.getAttribute('tabindex')
|
|
678
|
+
if (!NATIVELY_INTERACTIVE.has(tag) && tabIndex !== '0') {
|
|
679
|
+
issues.push({
|
|
680
|
+
type: 'error',
|
|
681
|
+
rule: ruleName,
|
|
682
|
+
element: clip(el.outerHTML),
|
|
683
|
+
message:
|
|
684
|
+
'Element with role="button" must have tabindex="0" to be keyboard-accessible',
|
|
685
|
+
fix: injectAttr(el.outerHTML, 'tabindex', '0')
|
|
686
|
+
})
|
|
687
|
+
}
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
// Roles requiring aria-checked
|
|
691
|
+
for (const role of [
|
|
692
|
+
'checkbox',
|
|
693
|
+
'radio',
|
|
694
|
+
'menuitemcheckbox',
|
|
695
|
+
'menuitemradio'
|
|
696
|
+
] as const) {
|
|
697
|
+
root.querySelectorAll(`[role="${role}"]`).forEach((el) => {
|
|
698
|
+
if (!el.getAttribute('aria-checked')) {
|
|
699
|
+
issues.push({
|
|
700
|
+
type: 'error',
|
|
701
|
+
rule: ruleName,
|
|
702
|
+
element: clip(el.outerHTML),
|
|
703
|
+
message: `role="${role}" requires aria-checked attribute (values: "true", "false"${role === 'checkbox' ? ', "mixed"' : ''})`,
|
|
704
|
+
fix: injectAttr(el.outerHTML, 'aria-checked', 'false')
|
|
705
|
+
})
|
|
706
|
+
}
|
|
707
|
+
})
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
root.querySelectorAll('[role="combobox"]').forEach((el) => {
|
|
711
|
+
if (!el.getAttribute('aria-expanded')) {
|
|
712
|
+
issues.push({
|
|
713
|
+
type: 'error',
|
|
714
|
+
rule: ruleName,
|
|
715
|
+
element: clip(el.outerHTML),
|
|
716
|
+
message: 'role="combobox" requires aria-expanded attribute',
|
|
717
|
+
fix: injectAttr(el.outerHTML, 'aria-expanded', 'false')
|
|
718
|
+
})
|
|
719
|
+
}
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
// Roles requiring aria-selected
|
|
723
|
+
for (const role of ['tab', 'option', 'treeitem', 'gridcell'] as const) {
|
|
724
|
+
root.querySelectorAll(`[role="${role}"]`).forEach((el) => {
|
|
725
|
+
if (!el.getAttribute('aria-selected')) {
|
|
726
|
+
issues.push({
|
|
727
|
+
type: 'error',
|
|
728
|
+
rule: ruleName,
|
|
729
|
+
element: clip(el.outerHTML),
|
|
730
|
+
message: `role="${role}" requires aria-selected attribute`,
|
|
731
|
+
fix: injectAttr(el.outerHTML, 'aria-selected', 'false')
|
|
732
|
+
})
|
|
733
|
+
}
|
|
734
|
+
})
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
root.querySelectorAll('[role="slider"]').forEach((el) => {
|
|
738
|
+
const required = ['aria-valuenow', 'aria-valuemin', 'aria-valuemax']
|
|
739
|
+
const missing = required.filter((attr) => !el.getAttribute(attr))
|
|
740
|
+
if (missing.length > 0) {
|
|
741
|
+
issues.push({
|
|
742
|
+
type: 'error',
|
|
743
|
+
rule: ruleName,
|
|
744
|
+
element: clip(el.outerHTML),
|
|
745
|
+
message: `role="slider" is missing required attributes: ${missing.join(', ')}`,
|
|
746
|
+
fix: 'Add aria-valuenow="50" aria-valuemin="0" aria-valuemax="100" to the element'
|
|
747
|
+
})
|
|
748
|
+
}
|
|
749
|
+
})
|
|
750
|
+
|
|
751
|
+
root.querySelectorAll('[role="progressbar"]').forEach((el) => {
|
|
752
|
+
if (
|
|
753
|
+
!el.getAttribute('aria-valuenow') &&
|
|
754
|
+
!el.getAttribute('aria-valuetext')
|
|
755
|
+
) {
|
|
756
|
+
issues.push({
|
|
757
|
+
type: 'warning',
|
|
758
|
+
rule: ruleName,
|
|
759
|
+
element: clip(el.outerHTML),
|
|
760
|
+
message:
|
|
761
|
+
'role="progressbar" should have aria-valuenow or aria-valuetext to communicate current progress',
|
|
762
|
+
fix: injectAttr(el.outerHTML, 'aria-valuenow', '0')
|
|
763
|
+
})
|
|
764
|
+
}
|
|
765
|
+
})
|
|
766
|
+
|
|
767
|
+
root.querySelectorAll('[role="listbox"]').forEach((el) => {
|
|
768
|
+
if (!getAccessibleName(el, root)) {
|
|
769
|
+
issues.push({
|
|
770
|
+
type: 'warning',
|
|
771
|
+
rule: ruleName,
|
|
772
|
+
element: clip(el.outerHTML),
|
|
773
|
+
message:
|
|
774
|
+
'role="listbox" should have an accessible name via aria-label or aria-labelledby',
|
|
775
|
+
fix: injectAttr(
|
|
776
|
+
el.outerHTML,
|
|
777
|
+
'aria-label',
|
|
778
|
+
'[Describe the list options]'
|
|
779
|
+
)
|
|
780
|
+
})
|
|
781
|
+
}
|
|
782
|
+
})
|
|
783
|
+
|
|
784
|
+
return { ruleName, issues }
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
export function checkAutocomplete(root: HTMLElement): RuleResult {
|
|
788
|
+
const ruleName = 'WCAG 1.3.5 Input Purpose'
|
|
789
|
+
const issues: A11yIssue[] = []
|
|
790
|
+
|
|
791
|
+
const TYPE_AUTOCOMPLETE: Record<string, string> = {
|
|
792
|
+
email: 'email',
|
|
793
|
+
tel: 'tel',
|
|
794
|
+
url: 'url'
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
root.querySelectorAll('input').forEach((input) => {
|
|
798
|
+
const type = (input.getAttribute('type') ?? 'text').toLowerCase()
|
|
799
|
+
const expected = TYPE_AUTOCOMPLETE[type]
|
|
800
|
+
|
|
801
|
+
if (expected && !input.getAttribute('autocomplete')) {
|
|
802
|
+
issues.push({
|
|
803
|
+
type: 'warning',
|
|
804
|
+
rule: ruleName,
|
|
805
|
+
element: clip(input.outerHTML),
|
|
806
|
+
message: `<input type="${type}"> should have autocomplete="${expected}" to assist users with autofill`,
|
|
807
|
+
fix: injectAttr(input.outerHTML, 'autocomplete', expected)
|
|
808
|
+
})
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (type === 'password' && !input.getAttribute('autocomplete')) {
|
|
812
|
+
issues.push({
|
|
813
|
+
type: 'warning',
|
|
814
|
+
rule: ruleName,
|
|
815
|
+
element: clip(input.outerHTML),
|
|
816
|
+
message:
|
|
817
|
+
'<input type="password"> should have autocomplete="current-password" or autocomplete="new-password"',
|
|
818
|
+
fix: injectAttr(input.outerHTML, 'autocomplete', 'current-password')
|
|
819
|
+
})
|
|
820
|
+
}
|
|
821
|
+
})
|
|
822
|
+
|
|
823
|
+
return { ruleName, issues }
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
export function checkFocusStyle(root: HTMLElement): RuleResult {
|
|
827
|
+
const ruleName = 'WCAG 2.4.7 Focus Visible'
|
|
828
|
+
const issues: A11yIssue[] = []
|
|
829
|
+
const KILLS_FOCUS = [
|
|
830
|
+
/outline\s*:\s*none/i,
|
|
831
|
+
/outline\s*:\s*0(?:px)?/i,
|
|
832
|
+
/outline-width\s*:\s*0/i
|
|
833
|
+
]
|
|
834
|
+
|
|
835
|
+
root.querySelectorAll('[style]').forEach((el) => {
|
|
836
|
+
const style = el.getAttribute('style') ?? ''
|
|
837
|
+
if (KILLS_FOCUS.some((rx) => rx.test(style))) {
|
|
838
|
+
issues.push({
|
|
839
|
+
type: 'warning',
|
|
840
|
+
rule: ruleName,
|
|
841
|
+
element: clip(el.outerHTML),
|
|
842
|
+
message:
|
|
843
|
+
'Inline style removes the focus outline - keyboard users cannot see the focus indicator',
|
|
844
|
+
fix: el.outerHTML
|
|
845
|
+
.replace(/outline\s*:\s*(none|0(?:px)?)\s*;?/gi, '')
|
|
846
|
+
.replace(/outline-width\s*:\s*0\s*;?/gi, '')
|
|
847
|
+
})
|
|
848
|
+
}
|
|
849
|
+
})
|
|
850
|
+
|
|
851
|
+
return { ruleName, issues }
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
export function checkLang(root: HTMLElement): RuleResult {
|
|
855
|
+
const ruleName = 'WCAG 3.1.1 Language of Page'
|
|
856
|
+
const issues: A11yIssue[] = []
|
|
857
|
+
|
|
858
|
+
const htmlEl = root.querySelector('html')
|
|
859
|
+
if (htmlEl && !htmlEl.getAttribute('lang')?.trim()) {
|
|
860
|
+
issues.push({
|
|
861
|
+
type: 'error',
|
|
862
|
+
rule: ruleName,
|
|
863
|
+
element: '<html>',
|
|
864
|
+
message:
|
|
865
|
+
'<html> is missing the lang attribute - screen readers need this to select the correct voice/language',
|
|
866
|
+
fix: '<html lang="en">'
|
|
867
|
+
})
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
return { ruleName, issues }
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
|