@aishware/react-a11y-rules-web 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/LICENSE +21 -0
- package/README.md +31 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +66 -0
- package/dist/project/labels.d.ts +18 -0
- package/dist/project/labels.d.ts.map +1 -0
- package/dist/project/labels.js +90 -0
- package/dist/rules/aria.d.ts +24 -0
- package/dist/rules/aria.d.ts.map +1 -0
- package/dist/rules/aria.js +310 -0
- package/dist/rules/contrast.d.ts +13 -0
- package/dist/rules/contrast.d.ts.map +1 -0
- package/dist/rules/contrast.js +60 -0
- package/dist/rules/document.d.ts +7 -0
- package/dist/rules/document.d.ts.map +1 -0
- package/dist/rules/document.js +53 -0
- package/dist/rules/forms.d.ts +19 -0
- package/dist/rules/forms.d.ts.map +1 -0
- package/dist/rules/forms.js +102 -0
- package/dist/rules/interactions.d.ts +8 -0
- package/dist/rules/interactions.d.ts.map +1 -0
- package/dist/rules/interactions.js +52 -0
- package/dist/rules/names.d.ts +14 -0
- package/dist/rules/names.d.ts.map +1 -0
- package/dist/rules/names.js +82 -0
- package/dist/rules/roles.d.ts +33 -0
- package/dist/rules/roles.d.ts.map +1 -0
- package/dist/rules/roles.js +248 -0
- package/dist/rules/structure.d.ts +30 -0
- package/dist/rules/structure.d.ts.map +1 -0
- package/dist/rules/structure.js +223 -0
- package/dist/util.d.ts +4 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +14 -0
- package/package.json +36 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { INTERACTIVE_ROLES, INTERACTIVE_TAGS, hasAttr, isAriaHidden, isPresentational, staticString, staticValue, } from '@react-a11y/core';
|
|
2
|
+
import { defineRule } from '../util.js';
|
|
3
|
+
/** Interaction handlers that imply the element is meant to be operated. */
|
|
4
|
+
const HANDLER_PROPS = [
|
|
5
|
+
'onClick', 'onDoubleClick', 'onMouseDown', 'onMouseUp',
|
|
6
|
+
'onKeyDown', 'onKeyUp', 'onKeyPress', 'onTouchStart', 'onTouchEnd',
|
|
7
|
+
];
|
|
8
|
+
/**
|
|
9
|
+
* Semantic HTML elements with a non-interactive implicit role. `div`/`span`
|
|
10
|
+
* are deliberately excluded — they are generic and are the recommended escape
|
|
11
|
+
* hatch for taking on a role, so flagging them would be noise.
|
|
12
|
+
*/
|
|
13
|
+
const NONINTERACTIVE_TAGS = new Set([
|
|
14
|
+
'main', 'nav', 'article', 'section', 'aside', 'header', 'footer',
|
|
15
|
+
'ul', 'ol', 'li', 'dl', 'dt', 'dd',
|
|
16
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p',
|
|
17
|
+
'figure', 'figcaption', 'blockquote',
|
|
18
|
+
'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'caption',
|
|
19
|
+
'address', 'hr', 'output', 'img', 'fieldset', 'legend',
|
|
20
|
+
]);
|
|
21
|
+
/** ARIA roles that do not expose an interactive widget. */
|
|
22
|
+
const NONINTERACTIVE_ROLES = new Set([
|
|
23
|
+
'article', 'banner', 'complementary', 'contentinfo', 'definition', 'directory',
|
|
24
|
+
'document', 'feed', 'figure', 'group', 'heading', 'img', 'list', 'listitem',
|
|
25
|
+
'main', 'math', 'navigation', 'note', 'region', 'status', 'table', 'term',
|
|
26
|
+
'time', 'tooltip', 'paragraph',
|
|
27
|
+
]);
|
|
28
|
+
const ALWAYS_FOCUSABLE = new Set(['button', 'select', 'textarea', 'summary', 'iframe', 'embed']);
|
|
29
|
+
function isFocusable(el) {
|
|
30
|
+
if (staticValue(el, 'disabled') === true)
|
|
31
|
+
return false;
|
|
32
|
+
const tabIndex = staticValue(el, 'tabIndex');
|
|
33
|
+
if (typeof tabIndex === 'number')
|
|
34
|
+
return tabIndex >= 0;
|
|
35
|
+
if (typeof tabIndex === 'string' && tabIndex.trim() !== '')
|
|
36
|
+
return Number(tabIndex) >= 0;
|
|
37
|
+
if (el.isComponent)
|
|
38
|
+
return false;
|
|
39
|
+
if (ALWAYS_FOCUSABLE.has(el.name))
|
|
40
|
+
return true;
|
|
41
|
+
if ((el.name === 'a' || el.name === 'area') && hasAttr(el, 'href'))
|
|
42
|
+
return true;
|
|
43
|
+
if (el.name === 'input')
|
|
44
|
+
return staticString(el, 'type') !== 'hidden';
|
|
45
|
+
if ((el.name === 'audio' || el.name === 'video') && hasAttr(el, 'controls'))
|
|
46
|
+
return true;
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
/** Native HTML elements that are inherently interactive controls. */
|
|
50
|
+
function isInteractiveElement(el) {
|
|
51
|
+
if (el.isComponent)
|
|
52
|
+
return false;
|
|
53
|
+
if (el.name === 'a' || el.name === 'area')
|
|
54
|
+
return hasAttr(el, 'href');
|
|
55
|
+
if (el.name === 'input')
|
|
56
|
+
return staticString(el, 'type') !== 'hidden';
|
|
57
|
+
if (el.name === 'audio' || el.name === 'video')
|
|
58
|
+
return hasAttr(el, 'controls');
|
|
59
|
+
return INTERACTIVE_TAGS.has(el.name);
|
|
60
|
+
}
|
|
61
|
+
function firstHandler(el) {
|
|
62
|
+
return HANDLER_PROPS.find((h) => hasAttr(el, h));
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* An element given an interactive role but unreachable by keyboard: it needs
|
|
66
|
+
* tabIndex to be focusable, or assistive-technology users cannot operate it.
|
|
67
|
+
*/
|
|
68
|
+
export const interactiveSupportsFocus = defineRule({
|
|
69
|
+
id: 'interactive-supports-focus',
|
|
70
|
+
description: 'Elements with an interactive role and a handler must be focusable.',
|
|
71
|
+
severity: 'serious',
|
|
72
|
+
wcag: ['2.1.1', '4.1.2'],
|
|
73
|
+
}, (el, ctx) => {
|
|
74
|
+
if (el.isComponent || el.hasSpread)
|
|
75
|
+
return;
|
|
76
|
+
if (isAriaHidden(el) || isPresentational(el))
|
|
77
|
+
return;
|
|
78
|
+
const role = staticString(el, 'role')?.trim();
|
|
79
|
+
if (!role || !INTERACTIVE_ROLES.has(role))
|
|
80
|
+
return;
|
|
81
|
+
if (!firstHandler(el))
|
|
82
|
+
return;
|
|
83
|
+
if (isFocusable(el) || hasAttr(el, 'tabIndex'))
|
|
84
|
+
return;
|
|
85
|
+
ctx.report({
|
|
86
|
+
el,
|
|
87
|
+
message: `<${el.name} role="${role}"> handles interaction but is not focusable. Add tabIndex={0} so keyboard users can reach it.`,
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
/**
|
|
91
|
+
* Event handlers on semantic non-interactive elements (li, main, h2, …) are
|
|
92
|
+
* unreachable by keyboard and screen reader users. Move the handler to a
|
|
93
|
+
* <button>/<a>, or give the element an interactive role and focus support.
|
|
94
|
+
*/
|
|
95
|
+
export const noNoninteractiveElementInteractions = defineRule({
|
|
96
|
+
id: 'no-noninteractive-element-interactions',
|
|
97
|
+
description: 'Non-interactive elements should not have interaction handlers.',
|
|
98
|
+
severity: 'serious',
|
|
99
|
+
wcag: ['2.1.1', '4.1.2'],
|
|
100
|
+
}, (el, ctx) => {
|
|
101
|
+
if (el.isComponent || el.hasSpread)
|
|
102
|
+
return;
|
|
103
|
+
if (isAriaHidden(el))
|
|
104
|
+
return;
|
|
105
|
+
const role = staticString(el, 'role')?.trim();
|
|
106
|
+
if (role && INTERACTIVE_ROLES.has(role))
|
|
107
|
+
return;
|
|
108
|
+
if (hasAttr(el, 'tabIndex'))
|
|
109
|
+
return; // author opted into interactivity
|
|
110
|
+
const nonInteractive = NONINTERACTIVE_TAGS.has(el.name) || (role !== undefined && NONINTERACTIVE_ROLES.has(role));
|
|
111
|
+
if (!nonInteractive)
|
|
112
|
+
return;
|
|
113
|
+
const handler = firstHandler(el);
|
|
114
|
+
if (!handler)
|
|
115
|
+
return;
|
|
116
|
+
ctx.report({
|
|
117
|
+
el,
|
|
118
|
+
message: `<${el.name}> is non-interactive but has ${handler}. Move the action to a <button>/<a>, or add an interactive role with tabIndex and a keyboard handler.`,
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
/**
|
|
122
|
+
* An interactive element whose role removes its interactive semantics
|
|
123
|
+
* (e.g. <button role="article">) becomes invisible to assistive technology
|
|
124
|
+
* as a control.
|
|
125
|
+
*/
|
|
126
|
+
export const noInteractiveElementToNoninteractiveRole = defineRule({
|
|
127
|
+
id: 'no-interactive-element-to-noninteractive-role',
|
|
128
|
+
description: 'Interactive elements must not be given a non-interactive role.',
|
|
129
|
+
severity: 'serious',
|
|
130
|
+
wcag: ['4.1.2', '1.3.1'],
|
|
131
|
+
}, (el, ctx) => {
|
|
132
|
+
if (el.isComponent)
|
|
133
|
+
return;
|
|
134
|
+
if (!isInteractiveElement(el))
|
|
135
|
+
return;
|
|
136
|
+
const role = staticString(el, 'role')?.trim();
|
|
137
|
+
if (!role || INTERACTIVE_ROLES.has(role))
|
|
138
|
+
return;
|
|
139
|
+
if (!(NONINTERACTIVE_ROLES.has(role) || role === 'presentation' || role === 'none'))
|
|
140
|
+
return;
|
|
141
|
+
ctx.report({
|
|
142
|
+
el,
|
|
143
|
+
message: `<${el.name}> is an interactive control but role="${role}" strips its semantics — screen reader users lose the control.`,
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
/**
|
|
147
|
+
* A semantic non-interactive element given an interactive role (e.g.
|
|
148
|
+
* <li role="button">) should be a generic container or a native control —
|
|
149
|
+
* native elements carry behavior the role alone does not.
|
|
150
|
+
*/
|
|
151
|
+
export const noNoninteractiveElementToInteractiveRole = defineRule({
|
|
152
|
+
id: 'no-noninteractive-element-to-interactive-role',
|
|
153
|
+
description: 'Non-interactive elements must not be given an interactive role.',
|
|
154
|
+
severity: 'serious',
|
|
155
|
+
wcag: ['4.1.2', '1.3.1'],
|
|
156
|
+
}, (el, ctx) => {
|
|
157
|
+
if (el.isComponent)
|
|
158
|
+
return;
|
|
159
|
+
if (!NONINTERACTIVE_TAGS.has(el.name))
|
|
160
|
+
return;
|
|
161
|
+
const role = staticString(el, 'role')?.trim();
|
|
162
|
+
if (!role || !INTERACTIVE_ROLES.has(role))
|
|
163
|
+
return;
|
|
164
|
+
ctx.report({
|
|
165
|
+
el,
|
|
166
|
+
message: `<${el.name}> is non-interactive but has interactive role="${role}". Use a native control, or a <div>/<span> with the role plus tabIndex and key handlers.`,
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
/** tabIndex on a non-interactive element adds a tab stop that does nothing. */
|
|
170
|
+
export const noNoninteractiveTabindex = defineRule({
|
|
171
|
+
id: 'no-noninteractive-tabindex',
|
|
172
|
+
description: 'tabIndex must not be placed on non-interactive elements.',
|
|
173
|
+
severity: 'moderate',
|
|
174
|
+
wcag: ['2.4.3', '4.1.2'],
|
|
175
|
+
}, (el, ctx) => {
|
|
176
|
+
if (el.isComponent)
|
|
177
|
+
return;
|
|
178
|
+
const v = staticValue(el, 'tabIndex');
|
|
179
|
+
const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v) : NaN;
|
|
180
|
+
if (!Number.isFinite(n) || n < 0)
|
|
181
|
+
return;
|
|
182
|
+
const role = staticString(el, 'role')?.trim();
|
|
183
|
+
if (role && INTERACTIVE_ROLES.has(role))
|
|
184
|
+
return;
|
|
185
|
+
if (isInteractiveElement(el))
|
|
186
|
+
return;
|
|
187
|
+
const nonInteractive = NONINTERACTIVE_TAGS.has(el.name) || (role !== undefined && NONINTERACTIVE_ROLES.has(role));
|
|
188
|
+
if (!nonInteractive)
|
|
189
|
+
return;
|
|
190
|
+
ctx.report({
|
|
191
|
+
el,
|
|
192
|
+
message: `tabIndex={${n}} on non-interactive <${el.name}> creates a tab stop with no interactive purpose. Remove it, or make the element a real control.`,
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
/** Roles that re-implement a native element, with the element to prefer. */
|
|
196
|
+
const ROLE_TO_TAG = {
|
|
197
|
+
button: '<button>',
|
|
198
|
+
link: '<a href>',
|
|
199
|
+
checkbox: '<input type="checkbox">',
|
|
200
|
+
radio: '<input type="radio">',
|
|
201
|
+
heading: '<h1>–<h6>',
|
|
202
|
+
list: '<ul> or <ol>',
|
|
203
|
+
listitem: '<li>',
|
|
204
|
+
table: '<table>',
|
|
205
|
+
row: '<tr>',
|
|
206
|
+
cell: '<td>',
|
|
207
|
+
columnheader: '<th scope="col">',
|
|
208
|
+
rowheader: '<th scope="row">',
|
|
209
|
+
navigation: '<nav>',
|
|
210
|
+
main: '<main>',
|
|
211
|
+
banner: '<header>',
|
|
212
|
+
contentinfo: '<footer>',
|
|
213
|
+
complementary: '<aside>',
|
|
214
|
+
article: '<article>',
|
|
215
|
+
figure: '<figure>',
|
|
216
|
+
form: '<form>',
|
|
217
|
+
textbox: '<input> or <textarea>',
|
|
218
|
+
img: '<img>',
|
|
219
|
+
separator: '<hr>',
|
|
220
|
+
region: '<section>',
|
|
221
|
+
progressbar: '<progress>',
|
|
222
|
+
combobox: '<select>',
|
|
223
|
+
};
|
|
224
|
+
/**
|
|
225
|
+
* A role on a generic <div>/<span> that has a native element equivalent —
|
|
226
|
+
* prefer the native tag for built-in keyboard handling and semantics.
|
|
227
|
+
* (Redundant roles on the matching element itself are handled by
|
|
228
|
+
* no-redundant-roles.)
|
|
229
|
+
*/
|
|
230
|
+
export const preferTagOverRole = defineRule({
|
|
231
|
+
id: 'prefer-tag-over-role',
|
|
232
|
+
description: 'Prefer the native element over a role that re-implements it.',
|
|
233
|
+
severity: 'minor',
|
|
234
|
+
wcag: ['4.1.2'],
|
|
235
|
+
}, (el, ctx) => {
|
|
236
|
+
if (el.name !== 'div' && el.name !== 'span')
|
|
237
|
+
return;
|
|
238
|
+
const role = staticString(el, 'role')?.trim();
|
|
239
|
+
if (!role)
|
|
240
|
+
return;
|
|
241
|
+
const suggestion = ROLE_TO_TAG[role];
|
|
242
|
+
if (!suggestion)
|
|
243
|
+
return;
|
|
244
|
+
ctx.report({
|
|
245
|
+
el,
|
|
246
|
+
message: `role="${role}" re-implements a native element. Prefer ${suggestion} for built-in keyboard support and semantics.`,
|
|
247
|
+
});
|
|
248
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Rule } from '@aishware/react-a11y-core';
|
|
2
|
+
/**
|
|
3
|
+
* Skipped heading levels (h2 → h4) break screen reader outline navigation.
|
|
4
|
+
* Files are fragments, so only *relative* skips within a file are flagged —
|
|
5
|
+
* a component that starts at h3 is fine.
|
|
6
|
+
*/
|
|
7
|
+
export declare const headingOrder: Rule;
|
|
8
|
+
/** <ul>/<ol> may only contain <li>; stray wrappers break list semantics. */
|
|
9
|
+
export declare const listStructure: Rule;
|
|
10
|
+
/** Data tables need header cells for screen reader row/column context. */
|
|
11
|
+
export declare const tableHasHeader: Rule;
|
|
12
|
+
/** Grouped controls (especially radio groups) need a <legend>. */
|
|
13
|
+
export declare const fieldsetHasLegend: Rule;
|
|
14
|
+
/**
|
|
15
|
+
* <main> must be unique: it is the screen reader's "skip to content" target,
|
|
16
|
+
* and duplicates make landmark navigation ambiguous.
|
|
17
|
+
*/
|
|
18
|
+
export declare const noDuplicateMain: Rule;
|
|
19
|
+
/**
|
|
20
|
+
* WCAG 1.3.2: CSS `order` makes visual order diverge from DOM order, which
|
|
21
|
+
* is what screen readers and the tab sequence follow.
|
|
22
|
+
*/
|
|
23
|
+
export declare const meaningfulOrder: Rule;
|
|
24
|
+
/**
|
|
25
|
+
* Flags roles placed outside their required parent — but only when the full
|
|
26
|
+
* ancestor chain in the file is plain DOM, so composition through components
|
|
27
|
+
* never false-positives.
|
|
28
|
+
*/
|
|
29
|
+
export declare const ariaRequiredContext: Rule;
|
|
30
|
+
//# sourceMappingURL=structure.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"structure.d.ts","sourceRoot":"","sources":["../../src/rules/structure.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,IAAI,EAAe,MAAM,2BAA2B,CAAC;AAYnE;;;;GAIG;AACH,eAAO,MAAM,YAAY,EAAE,IAwB1B,CAAC;AAKF,4EAA4E;AAC5E,eAAO,MAAM,aAAa,MAwBzB,CAAC;AAEF,0EAA0E;AAC1E,eAAO,MAAM,cAAc,MA2B1B,CAAC;AAEF,kEAAkE;AAClE,eAAO,MAAM,iBAAiB,MAc7B,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,eAAe,EAAE,IAsB7B,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,eAAe,MAkB3B,CAAC;AAwBF;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,MAwB/B,CAAC"}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { inlineStyleNumber, isAriaHidden, isPresentational, staticString, staticValue, walkDescendants, } from '@aishware/react-a11y-core';
|
|
2
|
+
import { defineRule, isDomTag } from '../util.js';
|
|
3
|
+
function headingLevel(el) {
|
|
4
|
+
const m = /^h([1-6])$/.exec(el.name);
|
|
5
|
+
if (m && !el.isComponent)
|
|
6
|
+
return Number(m[1]);
|
|
7
|
+
if (staticString(el, 'role')?.trim() === 'heading') {
|
|
8
|
+
const level = staticValue(el, 'aria-level');
|
|
9
|
+
if (typeof level === 'number')
|
|
10
|
+
return level;
|
|
11
|
+
}
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Skipped heading levels (h2 → h4) break screen reader outline navigation.
|
|
16
|
+
* Files are fragments, so only *relative* skips within a file are flagged —
|
|
17
|
+
* a component that starts at h3 is fine.
|
|
18
|
+
*/
|
|
19
|
+
export const headingOrder = {
|
|
20
|
+
meta: {
|
|
21
|
+
id: 'heading-order',
|
|
22
|
+
description: 'Heading levels must not skip (e.g. h2 followed by h4).',
|
|
23
|
+
severity: 'moderate',
|
|
24
|
+
platforms: ['web'],
|
|
25
|
+
wcag: ['1.3.1', '2.4.6'],
|
|
26
|
+
},
|
|
27
|
+
create(ctx) {
|
|
28
|
+
let last;
|
|
29
|
+
return {
|
|
30
|
+
element(el) {
|
|
31
|
+
const level = headingLevel(el);
|
|
32
|
+
if (level === undefined)
|
|
33
|
+
return;
|
|
34
|
+
if (last !== undefined && level > last + 1) {
|
|
35
|
+
ctx.report({
|
|
36
|
+
el,
|
|
37
|
+
message: `Heading level skips from h${last} to h${level}. Screen reader users navigating by heading lose the document structure.`,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
last = level;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
const LIST_CONTAINERS = new Set(['ul', 'ol', 'menu']);
|
|
46
|
+
const LIST_IGNORED_CHILDREN = new Set(['li', 'script', 'template']);
|
|
47
|
+
/** <ul>/<ol> may only contain <li>; stray wrappers break list semantics. */
|
|
48
|
+
export const listStructure = defineRule({
|
|
49
|
+
id: 'list-structure',
|
|
50
|
+
description: 'Lists must contain only <li> children, and <li> must sit in a list.',
|
|
51
|
+
severity: 'moderate',
|
|
52
|
+
wcag: ['1.3.1'],
|
|
53
|
+
}, (el, ctx) => {
|
|
54
|
+
if (el.isComponent)
|
|
55
|
+
return;
|
|
56
|
+
if (LIST_CONTAINERS.has(el.name) && !isPresentational(el)) {
|
|
57
|
+
for (const child of el.childElements) {
|
|
58
|
+
if (child.isComponent || child.hasSpread)
|
|
59
|
+
continue; // may render <li>
|
|
60
|
+
if (!LIST_IGNORED_CHILDREN.has(child.name) && !isPresentational(child)) {
|
|
61
|
+
ctx.report({
|
|
62
|
+
el: child,
|
|
63
|
+
message: `<${child.name}> is a direct child of <${el.name}> — screen readers expect only <li> there and may misreport list size.`,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (el.name === 'li' && el.parent && !el.parent.isComponent && !LIST_CONTAINERS.has(el.parent.name)) {
|
|
69
|
+
ctx.report({ el, message: `<li> is inside <${el.parent.name}> — list items must be direct children of <ul>, <ol> or <menu>.` });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
/** Data tables need header cells for screen reader row/column context. */
|
|
73
|
+
export const tableHasHeader = defineRule({
|
|
74
|
+
id: 'table-has-header',
|
|
75
|
+
description: 'Data tables must have header cells (<th> or role="columnheader"/"rowheader").',
|
|
76
|
+
severity: 'moderate',
|
|
77
|
+
wcag: ['1.3.1'],
|
|
78
|
+
}, (el, ctx) => {
|
|
79
|
+
if (!isDomTag(el, 'table'))
|
|
80
|
+
return;
|
|
81
|
+
if (isPresentational(el) || isAriaHidden(el) || el.hasSpread)
|
|
82
|
+
return;
|
|
83
|
+
let hasHeader = false;
|
|
84
|
+
let hasData = false;
|
|
85
|
+
let unknowable = false;
|
|
86
|
+
walkDescendants(el, (child) => {
|
|
87
|
+
if (child.isComponent || child.hasSpread || child.hasExpressionChild)
|
|
88
|
+
unknowable = true;
|
|
89
|
+
if (child.name === 'th')
|
|
90
|
+
hasHeader = true;
|
|
91
|
+
const role = staticString(child, 'role')?.trim();
|
|
92
|
+
if (role === 'columnheader' || role === 'rowheader')
|
|
93
|
+
hasHeader = true;
|
|
94
|
+
if (child.name === 'td')
|
|
95
|
+
hasData = true;
|
|
96
|
+
});
|
|
97
|
+
if (el.hasExpressionChild)
|
|
98
|
+
unknowable = true;
|
|
99
|
+
if (hasHeader || unknowable || !hasData)
|
|
100
|
+
return;
|
|
101
|
+
ctx.report({
|
|
102
|
+
el,
|
|
103
|
+
message: '<table> has data cells but no header cells — screen readers cannot announce row/column context. Add <th scope="col"> / <th scope="row">.',
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
/** Grouped controls (especially radio groups) need a <legend>. */
|
|
107
|
+
export const fieldsetHasLegend = defineRule({
|
|
108
|
+
id: 'fieldset-has-legend',
|
|
109
|
+
description: '<fieldset> must contain a <legend> naming the group.',
|
|
110
|
+
severity: 'moderate',
|
|
111
|
+
wcag: ['1.3.1', '3.3.2'],
|
|
112
|
+
}, (el, ctx) => {
|
|
113
|
+
if (!isDomTag(el, 'fieldset'))
|
|
114
|
+
return;
|
|
115
|
+
if (el.hasSpread || el.hasExpressionChild)
|
|
116
|
+
return;
|
|
117
|
+
const hasLegend = el.childElements.some((c) => c.name === 'legend' || c.isComponent);
|
|
118
|
+
if (hasLegend)
|
|
119
|
+
return;
|
|
120
|
+
ctx.report({ el, message: '<fieldset> has no <legend> — screen readers announce the controls without their group name.' });
|
|
121
|
+
});
|
|
122
|
+
/**
|
|
123
|
+
* <main> must be unique: it is the screen reader's "skip to content" target,
|
|
124
|
+
* and duplicates make landmark navigation ambiguous.
|
|
125
|
+
*/
|
|
126
|
+
export const noDuplicateMain = {
|
|
127
|
+
meta: {
|
|
128
|
+
id: 'no-duplicate-main',
|
|
129
|
+
description: 'A page must have only one <main> landmark.',
|
|
130
|
+
severity: 'moderate',
|
|
131
|
+
platforms: ['web'],
|
|
132
|
+
wcag: ['1.3.1', '2.4.1'],
|
|
133
|
+
partial: true,
|
|
134
|
+
},
|
|
135
|
+
create(ctx) {
|
|
136
|
+
let seen = false;
|
|
137
|
+
return {
|
|
138
|
+
element(el) {
|
|
139
|
+
const isMain = (!el.isComponent && el.name === 'main') || staticString(el, 'role')?.trim() === 'main';
|
|
140
|
+
if (!isMain)
|
|
141
|
+
return;
|
|
142
|
+
if (seen) {
|
|
143
|
+
ctx.report({ el, message: 'Multiple <main> landmarks — screen reader users cannot tell which one is the page content.' });
|
|
144
|
+
}
|
|
145
|
+
seen = true;
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
/**
|
|
151
|
+
* WCAG 1.3.2: CSS `order` makes visual order diverge from DOM order, which
|
|
152
|
+
* is what screen readers and the tab sequence follow.
|
|
153
|
+
*/
|
|
154
|
+
export const meaningfulOrder = defineRule({
|
|
155
|
+
id: 'meaningful-order',
|
|
156
|
+
description: 'Avoid CSS order — it desynchronizes visual order from reading/tab order.',
|
|
157
|
+
severity: 'minor',
|
|
158
|
+
wcag: ['1.3.2', '2.4.3'],
|
|
159
|
+
partial: true,
|
|
160
|
+
}, (el, ctx) => {
|
|
161
|
+
if (el.isComponent)
|
|
162
|
+
return;
|
|
163
|
+
const order = inlineStyleNumber(el, 'order');
|
|
164
|
+
if (order !== undefined && order !== 0) {
|
|
165
|
+
ctx.report({
|
|
166
|
+
el,
|
|
167
|
+
message: `Inline style order: ${order} reorders content visually while screen readers and Tab follow DOM order — verify the reading sequence still makes sense.`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
/** Roles that are only valid inside a specific parent role (ARIA 1.2). */
|
|
172
|
+
const REQUIRED_CONTEXT = {
|
|
173
|
+
menuitem: ['menu', 'menubar'],
|
|
174
|
+
menuitemcheckbox: ['menu', 'menubar'],
|
|
175
|
+
menuitemradio: ['menu', 'menubar'],
|
|
176
|
+
tab: ['tablist'],
|
|
177
|
+
option: ['listbox'],
|
|
178
|
+
treeitem: ['tree', 'treeitem', 'group'],
|
|
179
|
+
listitem: ['list'],
|
|
180
|
+
row: ['table', 'grid', 'treegrid', 'rowgroup'],
|
|
181
|
+
gridcell: ['row'],
|
|
182
|
+
rowheader: ['row'],
|
|
183
|
+
columnheader: ['row'],
|
|
184
|
+
cell: ['row'],
|
|
185
|
+
};
|
|
186
|
+
/** Implicit roles of DOM containers, for matching required context. */
|
|
187
|
+
const IMPLICIT_CONTEXT_ROLES = {
|
|
188
|
+
ul: 'list', ol: 'list', menu: 'list',
|
|
189
|
+
table: 'table', tr: 'row', tbody: 'rowgroup', thead: 'rowgroup', tfoot: 'rowgroup',
|
|
190
|
+
};
|
|
191
|
+
/**
|
|
192
|
+
* Flags roles placed outside their required parent — but only when the full
|
|
193
|
+
* ancestor chain in the file is plain DOM, so composition through components
|
|
194
|
+
* never false-positives.
|
|
195
|
+
*/
|
|
196
|
+
export const ariaRequiredContext = defineRule({
|
|
197
|
+
id: 'aria-required-context',
|
|
198
|
+
description: 'ARIA roles that require a parent role must be inside it.',
|
|
199
|
+
severity: 'moderate',
|
|
200
|
+
wcag: ['1.3.1', '4.1.2'],
|
|
201
|
+
}, (el, ctx) => {
|
|
202
|
+
const role = staticString(el, 'role')?.trim();
|
|
203
|
+
if (!role)
|
|
204
|
+
return;
|
|
205
|
+
const required = REQUIRED_CONTEXT[role];
|
|
206
|
+
if (!required)
|
|
207
|
+
return;
|
|
208
|
+
if (!el.parent)
|
|
209
|
+
return; // file root — context supplied by the consumer
|
|
210
|
+
for (let a = el.parent; a; a = a.parent) {
|
|
211
|
+
if (a.isComponent || a.hasSpread)
|
|
212
|
+
return; // context may come from the component
|
|
213
|
+
const ancestorRole = staticString(a, 'role')?.trim() ?? IMPLICIT_CONTEXT_ROLES[a.name];
|
|
214
|
+
if (ancestorRole && required.includes(ancestorRole))
|
|
215
|
+
return;
|
|
216
|
+
if (!a.parent)
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
ctx.report({
|
|
220
|
+
el,
|
|
221
|
+
message: `role="${role}" must be inside ${required.map((r) => `role="${r}"`).join(' or ')} — without it, screen readers cannot relate it to its group.`,
|
|
222
|
+
});
|
|
223
|
+
});
|
package/dist/util.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ElementNode, Rule, RuleMeta, RuleContext } from '@aishware/react-a11y-core';
|
|
2
|
+
export declare function defineRule(meta: Omit<RuleMeta, 'platforms' | 'helpUrl'>, element: (el: ElementNode, ctx: RuleContext) => void): Rule;
|
|
3
|
+
export declare function isDomTag(el: ElementNode, ...names: string[]): boolean;
|
|
4
|
+
//# sourceMappingURL=util.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"util.d.ts","sourceRoot":"","sources":["../src/util.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,QAAQ,EAAE,WAAW,EAAe,MAAM,2BAA2B,CAAC;AAMvG,wBAAgB,UAAU,CACxB,IAAI,EAAE,IAAI,CAAC,QAAQ,EAAE,WAAW,GAAG,SAAS,CAAC,EAC7C,OAAO,EAAE,CAAC,EAAE,EAAE,WAAW,EAAE,GAAG,EAAE,WAAW,KAAK,IAAI,GACnD,IAAI,CAON;AAED,wBAAgB,QAAQ,CAAC,EAAE,EAAE,WAAW,EAAE,GAAG,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAErE"}
|
package/dist/util.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { readOwnPackageMeta } from '@aishware/react-a11y-core';
|
|
2
|
+
const homepage = readOwnPackageMeta(import.meta.url).homepage;
|
|
3
|
+
const HELP_BASE = homepage ? `${homepage}/blob/main/docs/rules/web.md#` : undefined;
|
|
4
|
+
export function defineRule(meta, element) {
|
|
5
|
+
return {
|
|
6
|
+
meta: { ...meta, platforms: ['web'], ...(HELP_BASE ? { helpUrl: `${HELP_BASE}${meta.id}` } : {}) },
|
|
7
|
+
create(ctx) {
|
|
8
|
+
return { element: (el) => element(el, ctx) };
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export function isDomTag(el, ...names) {
|
|
13
|
+
return !el.isComponent && names.includes(el.name);
|
|
14
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aishware/react-a11y-rules-web",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "WCAG 2.2 web accessibility rules that complement eslint-plugin-jsx-a11y — the criteria, structure and focus checks it doesn't cover.",
|
|
5
|
+
"keywords": ["accessibility", "a11y", "wcag", "wcag-2.2", "react", "jsx-a11y", "linter"],
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"main": "dist/index.js",
|
|
9
|
+
"types": "dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@aishware/react-a11y-core": "^0.1.0"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"homepage": "https://github.com/1aishwaryasharma/react-a11y",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/1aishwaryasharma/react-a11y.git",
|
|
34
|
+
"directory": "packages/rules-web"
|
|
35
|
+
}
|
|
36
|
+
}
|