semantica11y 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 +21 -0
- package/README.md +174 -0
- package/Semantically-logo.png +0 -0
- package/package.json +45 -0
- package/src/analyzer.js +84 -0
- package/src/engine/aria-structure-role-mappings.js +131 -0
- package/src/engine/definitions.js +37 -0
- package/src/engine/index.js +83 -0
- package/src/engine/reporter/index.js +127 -0
- package/src/engine/rules/README.md +232 -0
- package/src/engine/rules/aria-actions.js +109 -0
- package/src/engine/rules/aria-expanded.js +32 -0
- package/src/engine/rules/aria-landmarks.js +36 -0
- package/src/engine/rules/aria-modal.js +48 -0
- package/src/engine/rules/aria-structure.js +37 -0
- package/src/engine/rules/heading-hierarchy.js +45 -0
- package/src/engine/rules/image-alt.js +77 -0
- package/src/engine/rules/missing-form-labels.js +49 -0
- package/src/engine/rules/missing-key-landmark.js +63 -0
- package/src/engine/rules/missing-role-action.js +77 -0
- package/src/engine/rules/native-label.js +113 -0
- package/src/engine/semantic-role-mappings.js +51 -0
- package/src/engine/utils.js +27 -0
- package/src/index.js +14 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# Rule Reference
|
|
2
|
+
|
|
3
|
+
This folder contains the default rule implementations used by `RuleEngine`.
|
|
4
|
+
Each file exports one rule object. Some exported rules perform several related
|
|
5
|
+
checks; those are called out under the rule file.
|
|
6
|
+
|
|
7
|
+
Default rule order is defined in `../definitions.js`.
|
|
8
|
+
|
|
9
|
+
## `aria-landmarks.js`
|
|
10
|
+
|
|
11
|
+
Exports: `ariaLandmarksRule`
|
|
12
|
+
Rule id: `aria-landmarks`
|
|
13
|
+
Severity: `warning`
|
|
14
|
+
|
|
15
|
+
Checks `div` elements with ARIA landmark roles that should usually be native
|
|
16
|
+
landmark elements.
|
|
17
|
+
|
|
18
|
+
Covered roles:
|
|
19
|
+
- `role="banner"` suggests `<header>`
|
|
20
|
+
- `role="contentinfo"` suggests `<footer>`
|
|
21
|
+
- `role="navigation"` suggests `<nav>`
|
|
22
|
+
- `role="main"` suggests `<main>`
|
|
23
|
+
- `role="complementary"` suggests `<aside>`
|
|
24
|
+
- `role="article"` suggests `<article>`
|
|
25
|
+
- `role="region"` suggests `<section>`
|
|
26
|
+
|
|
27
|
+
## `missing-form-labels.js`
|
|
28
|
+
|
|
29
|
+
Exports: `missingFormLabelsRule`
|
|
30
|
+
Rule id: `missing-form-labels`
|
|
31
|
+
Severity: `error`
|
|
32
|
+
|
|
33
|
+
Checks `input`, `textarea`, and `select` elements for an accessible label.
|
|
34
|
+
|
|
35
|
+
Passes when:
|
|
36
|
+
- The control is a hidden input.
|
|
37
|
+
- The control has an `id` matched by `label[for]`.
|
|
38
|
+
- The control is wrapped by a `label` element.
|
|
39
|
+
- The control has `aria-label`.
|
|
40
|
+
- The control has `aria-labelledby`.
|
|
41
|
+
|
|
42
|
+
Fails when a visible form control has none of those label sources.
|
|
43
|
+
|
|
44
|
+
## `heading-hierarchy.js`
|
|
45
|
+
|
|
46
|
+
Exports: `headingHierarchyRule`
|
|
47
|
+
Rule id: `heading-hierarchy`
|
|
48
|
+
Severity: `warning`
|
|
49
|
+
|
|
50
|
+
Checks document heading structure.
|
|
51
|
+
|
|
52
|
+
Individual checks:
|
|
53
|
+
- Missing page `<h1>`: warns when the document has no `h1`.
|
|
54
|
+
- Skipped heading levels: warns when a heading jumps by more than one level
|
|
55
|
+
after a previous heading, such as `<h1>` followed by `<h3>`.
|
|
56
|
+
|
|
57
|
+
## `aria-actions.js`
|
|
58
|
+
|
|
59
|
+
Exports: `ariaActionsRule`
|
|
60
|
+
Rule id: `aria-actions`
|
|
61
|
+
Severity: `warning`
|
|
62
|
+
|
|
63
|
+
Checks elements with ARIA action roles that should use native interactive
|
|
64
|
+
elements when possible.
|
|
65
|
+
|
|
66
|
+
Covered roles:
|
|
67
|
+
- `role="button"` suggests `<button>`
|
|
68
|
+
- `role="checkbox"` suggests `<input type="checkbox">`
|
|
69
|
+
- `role="link"` suggests `<a>`
|
|
70
|
+
- `role="textbox"` suggests `<input>` or `<textarea>`
|
|
71
|
+
|
|
72
|
+
Individual checks:
|
|
73
|
+
- Non-native action role usage: warns when a non-semantic element uses one of
|
|
74
|
+
the covered action roles.
|
|
75
|
+
- Mismatched native action role: warns when a native interactive element has a
|
|
76
|
+
conflicting ARIA action role, such as an anchor with `role="button"`.
|
|
77
|
+
|
|
78
|
+
Passes when the element already matches the native semantic element for that
|
|
79
|
+
role.
|
|
80
|
+
|
|
81
|
+
## `aria-expanded.js`
|
|
82
|
+
|
|
83
|
+
Exports: `ariaExpandedRule`
|
|
84
|
+
Rule id: `aria-expanded`
|
|
85
|
+
Severity: `warning`
|
|
86
|
+
|
|
87
|
+
Checks elements with `aria-expanded="true"` or `aria-expanded="false"`.
|
|
88
|
+
|
|
89
|
+
Individual checks:
|
|
90
|
+
- Expanded disclosure state: warns when an element uses `aria-expanded="true"`.
|
|
91
|
+
- Collapsed disclosure state: warns when an element uses `aria-expanded="false"`.
|
|
92
|
+
|
|
93
|
+
The suggestion is to use native `<details>` and `<summary>` elements for
|
|
94
|
+
expandable content.
|
|
95
|
+
|
|
96
|
+
## `aria-modal.js`
|
|
97
|
+
|
|
98
|
+
Exports: `ariaModalRule`
|
|
99
|
+
Rule id: `aria-modal`
|
|
100
|
+
|
|
101
|
+
Checks modal dialog patterns.
|
|
102
|
+
|
|
103
|
+
Individual checks:
|
|
104
|
+
- ARIA modal usage: reports a `warning` when an element has `aria-modal`.
|
|
105
|
+
- ARIA dialog role usage: reports a `warning` when an element has `role="dialog"`.
|
|
106
|
+
- Dialog tabindex usage: reports an `error` when a `<dialog>` element has
|
|
107
|
+
`tabindex`.
|
|
108
|
+
|
|
109
|
+
The suggestion is to use native `<dialog>` for modal dialogs and remove
|
|
110
|
+
`tabindex` from `<dialog>`. MDN documents that `tabindex` must not be used on
|
|
111
|
+
the `<dialog>` element.
|
|
112
|
+
|
|
113
|
+
## `aria-structure.js`
|
|
114
|
+
|
|
115
|
+
Exports: `ariaStructureRule`
|
|
116
|
+
Rule id: `aria-structure`
|
|
117
|
+
Severity: `warning`
|
|
118
|
+
|
|
119
|
+
Checks elements with ARIA structure roles that should use native semantic
|
|
120
|
+
elements when possible.
|
|
121
|
+
|
|
122
|
+
Role mappings live in `../aria-structure-role-mappings.js`.
|
|
123
|
+
|
|
124
|
+
Covered roles:
|
|
125
|
+
- `role="article"` suggests `<article>`
|
|
126
|
+
- `role="blockquote"` suggests `<blockquote>`
|
|
127
|
+
- `role="caption"` suggests `<caption>`
|
|
128
|
+
- `role="cell"` suggests `<td>`
|
|
129
|
+
- `role="code"` suggests `<code>`
|
|
130
|
+
- `role="columnheader"` suggests `<th scope="col">`
|
|
131
|
+
- `role="definition"` suggests `<dfn>`
|
|
132
|
+
- `role="deletion"` suggests `<del>`
|
|
133
|
+
- `role="emphasis"` suggests `<em>`
|
|
134
|
+
- `role="figure"` suggests `<figure>`
|
|
135
|
+
- `role="generic"` suggests a more specific semantic element or removing `role="generic"`
|
|
136
|
+
- `role="heading"` suggests `<h1>` through `<h6>`
|
|
137
|
+
- `role="list"` suggests `<ul>` or `<ol>`
|
|
138
|
+
- `role="listitem"` suggests `<li>`
|
|
139
|
+
- `role="paragraph"` suggests `<p>`
|
|
140
|
+
- `role="row"` suggests `<tr>`
|
|
141
|
+
- `role="rowgroup"` suggests `<thead>`, `<tbody>`, or `<tfoot>`
|
|
142
|
+
- `role="rowheader"` suggests `<th scope="row">`
|
|
143
|
+
- `role="separator"` suggests `<hr>`
|
|
144
|
+
- `role="strong"` suggests `<strong>`
|
|
145
|
+
- `role="subscript"` suggests `<sub>`
|
|
146
|
+
- `role="superscript"` suggests `<sup>`
|
|
147
|
+
- `role="table"` suggests `<table>`
|
|
148
|
+
- `role="term"` suggests `<dt>`
|
|
149
|
+
- `role="time"` suggests `<time>`
|
|
150
|
+
|
|
151
|
+
Passes when the element already matches the native semantic element for that
|
|
152
|
+
role. `generic` always reports because there is no more specific native match.
|
|
153
|
+
|
|
154
|
+
## `missing-role-action.js`
|
|
155
|
+
|
|
156
|
+
Exports: `missingRoleActionRule`
|
|
157
|
+
Rule id: `missing-role-action`
|
|
158
|
+
Severity: `warning`
|
|
159
|
+
|
|
160
|
+
Checks non-semantic elements that appear interactive but do not have native
|
|
161
|
+
interactive semantics or an action role.
|
|
162
|
+
|
|
163
|
+
Individual checks:
|
|
164
|
+
- Focusable custom action: warns for elements with `tabindex="0"` that are not
|
|
165
|
+
native interactive elements and do not have an action role.
|
|
166
|
+
- Click-only custom action: warns for elements with `onclick` that are not
|
|
167
|
+
native interactive elements and do not have an action role.
|
|
168
|
+
|
|
169
|
+
Recognized action roles are `button`, `checkbox`, `link`, and `textbox`.
|
|
170
|
+
|
|
171
|
+
Native interactive elements pass, including `button`, `select`, `textarea`,
|
|
172
|
+
anchors with `href`, and non-hidden inputs.
|
|
173
|
+
|
|
174
|
+
## `image-alt.js`
|
|
175
|
+
|
|
176
|
+
Exports: `imageAltRule`
|
|
177
|
+
Rule id: `image-alt`
|
|
178
|
+
|
|
179
|
+
Checks `img` elements for `alt` usage and conflicting ARIA labels.
|
|
180
|
+
|
|
181
|
+
Individual checks:
|
|
182
|
+
- Missing `alt`: reports an `error` when an image has no `alt` attribute.
|
|
183
|
+
- `aria-label` without `alt`: reports a `warning`.
|
|
184
|
+
- `aria-label` with `alt=""`: reports a `warning`.
|
|
185
|
+
- `aria-label` overriding `alt`: reports a `warning` when normalized
|
|
186
|
+
`aria-label` text differs from normalized `alt` text.
|
|
187
|
+
|
|
188
|
+
Passes when an image has `alt` and no conflicting `aria-label`, or when
|
|
189
|
+
`aria-label` exactly matches the `alt` text.
|
|
190
|
+
|
|
191
|
+
## `native-label.js`
|
|
192
|
+
|
|
193
|
+
Exports: `nativeLabelRule`
|
|
194
|
+
Rule id: `native-label`
|
|
195
|
+
|
|
196
|
+
Checks native action and form elements with `aria-label` against their native
|
|
197
|
+
or visible label text.
|
|
198
|
+
|
|
199
|
+
Scanned elements:
|
|
200
|
+
- `button[aria-label]`
|
|
201
|
+
- `a[href][aria-label]`
|
|
202
|
+
- `input[aria-label]`
|
|
203
|
+
- `textarea[aria-label]`
|
|
204
|
+
- `select[aria-label]`
|
|
205
|
+
|
|
206
|
+
Individual checks:
|
|
207
|
+
- Duplicate label: reports a `warning` when `aria-label` duplicates the native
|
|
208
|
+
label text.
|
|
209
|
+
- Conflicting label: reports an `error` when `aria-label` does not match the
|
|
210
|
+
native label text.
|
|
211
|
+
|
|
212
|
+
Allowed cases:
|
|
213
|
+
- Elements with no native label text.
|
|
214
|
+
- `aria-label` values that include the native text.
|
|
215
|
+
- Approved helper text added after the native text, currently
|
|
216
|
+
"opens in a new window" and "opens in new window".
|
|
217
|
+
|
|
218
|
+
## `missing-key-landmark.js`
|
|
219
|
+
|
|
220
|
+
Exports: `missingKeyLandmarkRule`
|
|
221
|
+
Rule id: `missing-key-landmark`
|
|
222
|
+
Severity: `suggestion`
|
|
223
|
+
|
|
224
|
+
Checks whether the page has core structure landmarks and a heading.
|
|
225
|
+
|
|
226
|
+
Individual checks:
|
|
227
|
+
- Missing header landmark: passes with `<header>` or `role="banner"`.
|
|
228
|
+
- Missing main content landmark: passes with `<main>` or `role="main"`.
|
|
229
|
+
- Missing footer landmark: passes with `<footer>` or `role="contentinfo"`.
|
|
230
|
+
- Missing page heading: passes with any `h1` through `h6`, or `role="heading"`.
|
|
231
|
+
|
|
232
|
+
This rule intentionally reports suggestions rather than errors or warnings.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { getElementSignature, getLineNumber } from '../utils.js';
|
|
2
|
+
|
|
3
|
+
const ARIA_ACTION_ROLE_MAPPINGS = [
|
|
4
|
+
{
|
|
5
|
+
role: 'button',
|
|
6
|
+
semanticElement: '<button>',
|
|
7
|
+
isSemanticMatch: (element) => element.tagName.toLowerCase() === 'button',
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
role: 'checkbox',
|
|
11
|
+
semanticElement: '<input type="checkbox">',
|
|
12
|
+
isSemanticMatch: (element) =>
|
|
13
|
+
element.tagName.toLowerCase() === 'input' &&
|
|
14
|
+
element.getAttribute('type')?.toLowerCase() === 'checkbox',
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
role: 'link',
|
|
18
|
+
semanticElement: '<a>',
|
|
19
|
+
isSemanticMatch: (element) => element.tagName.toLowerCase() === 'a',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
role: 'textbox',
|
|
23
|
+
semanticElement: '<input> or <textarea>',
|
|
24
|
+
isSemanticMatch: (element) => {
|
|
25
|
+
const tagName = element.tagName.toLowerCase();
|
|
26
|
+
const type = element.getAttribute('type')?.toLowerCase() || 'text';
|
|
27
|
+
|
|
28
|
+
return tagName === 'textarea' || (tagName === 'input' && type === 'text');
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
function getNativeActionRole(element) {
|
|
34
|
+
const tagName = element.tagName.toLowerCase();
|
|
35
|
+
|
|
36
|
+
if (tagName === 'button') {
|
|
37
|
+
return 'button';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (tagName === 'a' && element.hasAttribute('href')) {
|
|
41
|
+
return 'link';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (tagName === 'textarea') {
|
|
45
|
+
return 'textbox';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (tagName === 'input') {
|
|
49
|
+
const type = element.getAttribute('type')?.toLowerCase() || 'text';
|
|
50
|
+
|
|
51
|
+
if (type === 'checkbox') {
|
|
52
|
+
return 'checkbox';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (['button', 'reset', 'submit'].includes(type)) {
|
|
56
|
+
return 'button';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!['hidden', 'radio'].includes(type)) {
|
|
60
|
+
return 'textbox';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const ariaActionsRule = {
|
|
68
|
+
id: 'aria-actions',
|
|
69
|
+
name: 'ARIA action usage',
|
|
70
|
+
enabled: true,
|
|
71
|
+
description: 'Detects elements using ARIA action roles that should use semantic elements',
|
|
72
|
+
check(document) {
|
|
73
|
+
const issues = [];
|
|
74
|
+
|
|
75
|
+
document.querySelectorAll('[role]').forEach((element) => {
|
|
76
|
+
const roles = element
|
|
77
|
+
.getAttribute('role')
|
|
78
|
+
.toLowerCase()
|
|
79
|
+
.split(/\s+/)
|
|
80
|
+
.filter(Boolean);
|
|
81
|
+
|
|
82
|
+
const nativeActionRole = getNativeActionRole(element);
|
|
83
|
+
|
|
84
|
+
ARIA_ACTION_ROLE_MAPPINGS.forEach(({ role, semanticElement, isSemanticMatch }) => {
|
|
85
|
+
if (!roles.includes(role) || isSemanticMatch(element)) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const hasMismatchedNativeRole =
|
|
90
|
+
nativeActionRole && nativeActionRole !== role;
|
|
91
|
+
|
|
92
|
+
issues.push({
|
|
93
|
+
severity: 'warning',
|
|
94
|
+
rule: 'aria-actions',
|
|
95
|
+
element: getElementSignature(element),
|
|
96
|
+
message: hasMismatchedNativeRole
|
|
97
|
+
? `Native ${nativeActionRole} element has mismatched ARIA ${role} role`
|
|
98
|
+
: `Element uses ARIA ${role} role instead of a semantic element`,
|
|
99
|
+
suggestion: hasMismatchedNativeRole
|
|
100
|
+
? `Remove role="${role}" or use ${semanticElement} if the element should behave as ${role}`
|
|
101
|
+
: `Consider using ${semanticElement} element instead`,
|
|
102
|
+
line: getLineNumber(element),
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return issues;
|
|
108
|
+
},
|
|
109
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { getElementSignature, getLineNumber } from '../utils.js';
|
|
2
|
+
|
|
3
|
+
const EXPANDED_VALUES = ['true', 'false'];
|
|
4
|
+
|
|
5
|
+
export const ariaExpandedRule = {
|
|
6
|
+
id: 'aria-expanded',
|
|
7
|
+
name: 'ARIA expanded disclosure',
|
|
8
|
+
enabled: true,
|
|
9
|
+
description: 'Detects aria-expanded usage that could use native disclosure elements',
|
|
10
|
+
check(document) {
|
|
11
|
+
const issues = [];
|
|
12
|
+
|
|
13
|
+
document.querySelectorAll('[aria-expanded]').forEach((element) => {
|
|
14
|
+
const value = element.getAttribute('aria-expanded')?.toLowerCase();
|
|
15
|
+
|
|
16
|
+
if (!EXPANDED_VALUES.includes(value)) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
issues.push({
|
|
21
|
+
severity: 'warning',
|
|
22
|
+
rule: 'aria-expanded',
|
|
23
|
+
element: getElementSignature(element),
|
|
24
|
+
message: `Element uses aria-expanded="${value}" for disclosure state`,
|
|
25
|
+
suggestion: 'Consider using native <details> and <summary> elements for expandable content',
|
|
26
|
+
line: getLineNumber(element),
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
return issues;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { SEMANTIC_ROLE_MAPPINGS } from '../semantic-role-mappings.js';
|
|
2
|
+
import { getElementSignature, getLineNumber } from '../utils.js';
|
|
3
|
+
|
|
4
|
+
export const ariaLandmarksRule = {
|
|
5
|
+
id: 'aria-landmarks',
|
|
6
|
+
name: 'ARIA landmark usage',
|
|
7
|
+
enabled: true,
|
|
8
|
+
description: 'Detects elements using ARIA landmark roles that should use semantic elements',
|
|
9
|
+
check(document) {
|
|
10
|
+
const issues = [];
|
|
11
|
+
|
|
12
|
+
document.querySelectorAll('div').forEach((div) => {
|
|
13
|
+
const roles = (div.getAttribute('role') || '')
|
|
14
|
+
.toLowerCase()
|
|
15
|
+
.split(/\s+/)
|
|
16
|
+
.filter(Boolean);
|
|
17
|
+
|
|
18
|
+
SEMANTIC_ROLE_MAPPINGS.forEach(({ element, ariaRole, message }) => {
|
|
19
|
+
if (!roles.includes(ariaRole)) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
issues.push({
|
|
24
|
+
severity: 'warning',
|
|
25
|
+
rule: 'aria-landmarks',
|
|
26
|
+
element: getElementSignature(div),
|
|
27
|
+
message,
|
|
28
|
+
suggestion: `Consider using <${element}> semantic element instead`,
|
|
29
|
+
line: getLineNumber(div),
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return issues;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { getElementSignature, getLineNumber } from '../utils.js';
|
|
2
|
+
|
|
3
|
+
function hasDialogRole(element) {
|
|
4
|
+
return (element.getAttribute('role') || '')
|
|
5
|
+
.toLowerCase()
|
|
6
|
+
.split(/\s+/)
|
|
7
|
+
.includes('dialog');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const ariaModalRule = {
|
|
11
|
+
id: 'aria-modal',
|
|
12
|
+
name: 'ARIA modal dialog usage',
|
|
13
|
+
enabled: true,
|
|
14
|
+
description: 'Detects ARIA dialog/modal usage and invalid tabindex on dialog elements',
|
|
15
|
+
check(document) {
|
|
16
|
+
const issues = [];
|
|
17
|
+
|
|
18
|
+
document.querySelectorAll('[aria-modal], [role]').forEach((element) => {
|
|
19
|
+
if (!element.hasAttribute('aria-modal') && !hasDialogRole(element)) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
issues.push({
|
|
24
|
+
severity: 'warning',
|
|
25
|
+
rule: 'aria-modal',
|
|
26
|
+
element: getElementSignature(element),
|
|
27
|
+
message: element.hasAttribute('aria-modal')
|
|
28
|
+
? 'Element uses aria-modal for modal dialog behavior'
|
|
29
|
+
: 'Element uses ARIA dialog role instead of a native dialog element',
|
|
30
|
+
suggestion: 'Consider using the native <dialog> element for modal dialogs',
|
|
31
|
+
line: getLineNumber(element),
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
document.querySelectorAll('dialog[tabindex]').forEach((dialog) => {
|
|
36
|
+
issues.push({
|
|
37
|
+
severity: 'error',
|
|
38
|
+
rule: 'aria-modal',
|
|
39
|
+
element: getElementSignature(dialog),
|
|
40
|
+
message: '<dialog> element must not use tabindex',
|
|
41
|
+
suggestion: 'Remove tabindex from the <dialog> element',
|
|
42
|
+
line: getLineNumber(dialog),
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return issues;
|
|
47
|
+
},
|
|
48
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ARIA_STRUCTURE_ROLE_MAPPINGS } from '../aria-structure-role-mappings.js';
|
|
2
|
+
import { getElementSignature, getLineNumber } from '../utils.js';
|
|
3
|
+
|
|
4
|
+
export const ariaStructureRule = {
|
|
5
|
+
id: 'aria-structure',
|
|
6
|
+
name: 'ARIA structure usage',
|
|
7
|
+
enabled: true,
|
|
8
|
+
description: 'Detects elements using ARIA structure roles that should use semantic elements',
|
|
9
|
+
check(document) {
|
|
10
|
+
const issues = [];
|
|
11
|
+
|
|
12
|
+
document.querySelectorAll('[role]').forEach((element) => {
|
|
13
|
+
const roles = element
|
|
14
|
+
.getAttribute('role')
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.split(/\s+/)
|
|
17
|
+
.filter(Boolean);
|
|
18
|
+
|
|
19
|
+
ARIA_STRUCTURE_ROLE_MAPPINGS.forEach(({ role, semanticElement, isSemanticMatch }) => {
|
|
20
|
+
if (!roles.includes(role) || isSemanticMatch(element)) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
issues.push({
|
|
25
|
+
severity: 'warning',
|
|
26
|
+
rule: 'aria-structure',
|
|
27
|
+
element: getElementSignature(element),
|
|
28
|
+
message: `Element uses ARIA ${role} role instead of a semantic element`,
|
|
29
|
+
suggestion: `Consider using ${semanticElement} element instead`,
|
|
30
|
+
line: getLineNumber(element),
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
return issues;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { getLineNumber } from '../utils.js';
|
|
2
|
+
|
|
3
|
+
export const headingHierarchyRule = {
|
|
4
|
+
id: 'heading-hierarchy',
|
|
5
|
+
name: 'Heading hierarchy issues',
|
|
6
|
+
enabled: true,
|
|
7
|
+
description: 'Detects improper heading hierarchy (e.g., h1 > h3 without h2)',
|
|
8
|
+
check(document) {
|
|
9
|
+
const issues = [];
|
|
10
|
+
const headings = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
|
11
|
+
|
|
12
|
+
if (!document.querySelector('h1')) {
|
|
13
|
+
issues.push({
|
|
14
|
+
severity: 'warning',
|
|
15
|
+
rule: 'heading-hierarchy',
|
|
16
|
+
element: 'document',
|
|
17
|
+
message: 'Page does not have an h1 heading',
|
|
18
|
+
suggestion: 'Add one <h1> that describes the main topic of the page',
|
|
19
|
+
line: 0,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let previousLevel = 0;
|
|
24
|
+
|
|
25
|
+
headings.forEach((heading) => {
|
|
26
|
+
const currentLevel = parseInt(heading.tagName[1]);
|
|
27
|
+
|
|
28
|
+
// Check for skipped levels (except first heading)
|
|
29
|
+
if (previousLevel > 0 && currentLevel > previousLevel + 1) {
|
|
30
|
+
issues.push({
|
|
31
|
+
severity: 'warning',
|
|
32
|
+
rule: 'heading-hierarchy',
|
|
33
|
+
element: heading.textContent.substring(0, 50),
|
|
34
|
+
message: `Heading hierarchy skipped: ${previousLevel > 0 ? `<h${previousLevel}>` : 'start'} -> <${heading.tagName.toLowerCase()}>`,
|
|
35
|
+
suggestion: `Use <h${previousLevel + 1}> instead of <${heading.tagName.toLowerCase()}>`,
|
|
36
|
+
line: getLineNumber(heading),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
previousLevel = currentLevel;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return issues;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { getElementSignature, getLineNumber } from '../utils.js';
|
|
2
|
+
|
|
3
|
+
function normalizeText(value) {
|
|
4
|
+
return value
|
|
5
|
+
.trim()
|
|
6
|
+
.replace(/\s+/g, ' ')
|
|
7
|
+
.toLowerCase();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const imageAltRule = {
|
|
11
|
+
id: 'image-alt',
|
|
12
|
+
name: 'Image alternative text',
|
|
13
|
+
enabled: true,
|
|
14
|
+
description: 'Detects images without alt attributes and conflicting image labels',
|
|
15
|
+
check(document) {
|
|
16
|
+
const issues = [];
|
|
17
|
+
|
|
18
|
+
document.querySelectorAll('img').forEach((image) => {
|
|
19
|
+
const hasAltAttribute = image.hasAttribute('alt');
|
|
20
|
+
const altText = image.getAttribute('alt') || '';
|
|
21
|
+
const ariaLabel = image.getAttribute('aria-label');
|
|
22
|
+
const hasAriaLabel = ariaLabel !== null;
|
|
23
|
+
|
|
24
|
+
if (!hasAltAttribute) {
|
|
25
|
+
issues.push({
|
|
26
|
+
severity: 'error',
|
|
27
|
+
rule: 'image-alt',
|
|
28
|
+
element: getElementSignature(image),
|
|
29
|
+
message: 'Image is missing an alt attribute',
|
|
30
|
+
suggestion: 'Add an alt attribute that describes the image, or use alt="" for decorative images',
|
|
31
|
+
line: getLineNumber(image),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!hasAriaLabel) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!hasAltAttribute) {
|
|
40
|
+
issues.push({
|
|
41
|
+
severity: 'warning',
|
|
42
|
+
rule: 'image-alt',
|
|
43
|
+
element: getElementSignature(image),
|
|
44
|
+
message: 'Image uses aria-label without an alt attribute',
|
|
45
|
+
suggestion: 'Use the alt attribute as the image accessible name instead of aria-label',
|
|
46
|
+
line: getLineNumber(image),
|
|
47
|
+
});
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (altText === '') {
|
|
52
|
+
issues.push({
|
|
53
|
+
severity: 'warning',
|
|
54
|
+
rule: 'image-alt',
|
|
55
|
+
element: getElementSignature(image),
|
|
56
|
+
message: 'Image uses aria-label while alt is empty',
|
|
57
|
+
suggestion: 'Remove aria-label for decorative images, or replace alt="" with meaningful alt text',
|
|
58
|
+
line: getLineNumber(image),
|
|
59
|
+
});
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (normalizeText(ariaLabel) !== normalizeText(altText)) {
|
|
64
|
+
issues.push({
|
|
65
|
+
severity: 'warning',
|
|
66
|
+
rule: 'image-alt',
|
|
67
|
+
element: getElementSignature(image),
|
|
68
|
+
message: 'aria-label overrides image alt text',
|
|
69
|
+
suggestion: 'Remove aria-label and keep the accessible name in the alt attribute',
|
|
70
|
+
line: getLineNumber(image),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return issues;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { getElementSignature, getLineNumber } from '../utils.js';
|
|
2
|
+
|
|
3
|
+
export const missingFormLabelsRule = {
|
|
4
|
+
id: 'missing-form-labels',
|
|
5
|
+
name: 'Form inputs without labels',
|
|
6
|
+
enabled: true,
|
|
7
|
+
description: 'Detects form inputs that are not properly labeled',
|
|
8
|
+
check(document) {
|
|
9
|
+
const issues = [];
|
|
10
|
+
|
|
11
|
+
document.querySelectorAll('input, textarea, select').forEach((input) => {
|
|
12
|
+
if (
|
|
13
|
+
input.tagName.toLowerCase() === 'input' &&
|
|
14
|
+
input.getAttribute('type')?.toLowerCase() === 'hidden'
|
|
15
|
+
) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const id = input.id;
|
|
20
|
+
const name = input.name;
|
|
21
|
+
const ariaLabel = input.getAttribute('aria-label');
|
|
22
|
+
const ariaLabelledBy = input.getAttribute('aria-labelledby');
|
|
23
|
+
const wrappingLabel = input.closest('label');
|
|
24
|
+
|
|
25
|
+
if (id && document.querySelector(`label[for="${id}"]`)) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (wrappingLabel) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (ariaLabel || ariaLabelledBy) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
issues.push({
|
|
38
|
+
severity: 'error',
|
|
39
|
+
rule: 'missing-form-labels',
|
|
40
|
+
element: getElementSignature(input),
|
|
41
|
+
message: 'Form input is not properly labeled',
|
|
42
|
+
suggestion: `Add label: <label for="${id || name}">Label text</label>`,
|
|
43
|
+
line: getLineNumber(input),
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return issues;
|
|
48
|
+
},
|
|
49
|
+
};
|