@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,60 @@
|
|
|
1
|
+
import { colorContrastFinding, inlineStyleNumber, targetSizeTier, INTERACTIVE_TAGS, INTERACTIVE_ROLES, hasAttr, staticString, } from '@aishware/react-a11y-core';
|
|
2
|
+
import { defineRule } from '../util.js';
|
|
3
|
+
/**
|
|
4
|
+
* WCAG 1.4.3 contrast for inline styles where both colors are literal.
|
|
5
|
+
* Below 3:1 fails even for large text → serious. Between 3:1 and 4.5:1 is
|
|
6
|
+
* only flagged when the font size is also known to be small, so unknown-size
|
|
7
|
+
* text that might be large never false-positives.
|
|
8
|
+
*/
|
|
9
|
+
export const colorContrast = defineRule({
|
|
10
|
+
id: 'color-contrast',
|
|
11
|
+
description: 'Text color must meet WCAG contrast against its background (4.5:1, or 3:1 for large text).',
|
|
12
|
+
severity: 'serious',
|
|
13
|
+
wcag: ['1.4.3'],
|
|
14
|
+
partial: true,
|
|
15
|
+
}, (el, ctx) => {
|
|
16
|
+
if (el.isComponent || !el.hasTextChild)
|
|
17
|
+
return;
|
|
18
|
+
const finding = colorContrastFinding(el);
|
|
19
|
+
if (finding)
|
|
20
|
+
ctx.report({ el, ...finding });
|
|
21
|
+
});
|
|
22
|
+
/**
|
|
23
|
+
* WCAG 2.5.8 (AA, new in 2.2): pointer targets need 24×24 CSS px minimum.
|
|
24
|
+
* Only statically-sized inline styles are checked.
|
|
25
|
+
*/
|
|
26
|
+
export const targetSize = defineRule({
|
|
27
|
+
id: 'target-size',
|
|
28
|
+
description: 'Interactive targets should be at least 24×24px (44×44 recommended).',
|
|
29
|
+
severity: 'serious',
|
|
30
|
+
wcag: ['2.5.8', '2.5.5'],
|
|
31
|
+
partial: true,
|
|
32
|
+
}, (el, ctx) => {
|
|
33
|
+
if (el.isComponent)
|
|
34
|
+
return;
|
|
35
|
+
const interactive = ((el.name === 'a' || el.name === 'area') && hasAttr(el, 'href')) ||
|
|
36
|
+
el.name === 'button' ||
|
|
37
|
+
el.name === 'input' ||
|
|
38
|
+
INTERACTIVE_ROLES.has(staticString(el, 'role')?.trim() ?? '') ||
|
|
39
|
+
(INTERACTIVE_TAGS.has(el.name) && hasAttr(el, 'onClick'));
|
|
40
|
+
if (!interactive)
|
|
41
|
+
return;
|
|
42
|
+
const width = inlineStyleNumber(el, 'width');
|
|
43
|
+
const height = inlineStyleNumber(el, 'height');
|
|
44
|
+
if (width === undefined || height === undefined)
|
|
45
|
+
return;
|
|
46
|
+
const tier = targetSizeTier(width, height);
|
|
47
|
+
if (tier === 'below-min') {
|
|
48
|
+
ctx.report({
|
|
49
|
+
el,
|
|
50
|
+
message: `${width}×${height}px target is below the 24×24px WCAG 2.5.8 (AA) minimum — hard to hit for users with motor impairments.`,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
else if (tier === 'below-recommended') {
|
|
54
|
+
ctx.report({
|
|
55
|
+
el,
|
|
56
|
+
message: `${width}×${height}px target is below the recommended 44×44px (WCAG 2.5.5 AAA).`,
|
|
57
|
+
severity: 'minor',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Blocking pinch-zoom prevents low-vision users from reading the page. */
|
|
2
|
+
export declare const metaViewportZoomable: import("@aishware/react-a11y-core").Rule;
|
|
3
|
+
/** Meta refresh redirects/reloads on a timer users cannot control. */
|
|
4
|
+
export declare const noMetaRefresh: import("@aishware/react-a11y-core").Rule;
|
|
5
|
+
/** Empty <title> leaves the page unnamed in tabs, history and screen readers. */
|
|
6
|
+
export declare const titleHasContent: import("@aishware/react-a11y-core").Rule;
|
|
7
|
+
//# sourceMappingURL=document.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"document.d.ts","sourceRoot":"","sources":["../../src/rules/document.ts"],"names":[],"mappings":"AAGA,2EAA2E;AAC3E,eAAO,MAAM,oBAAoB,0CAsBhC,CAAC;AAEF,sEAAsE;AACtE,eAAO,MAAM,aAAa,0CAczB,CAAC;AAEF,iFAAiF;AACjF,eAAO,MAAM,eAAe,0CAY3B,CAAC"}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { staticString } from '@aishware/react-a11y-core';
|
|
2
|
+
import { defineRule, isDomTag } from '../util.js';
|
|
3
|
+
/** Blocking pinch-zoom prevents low-vision users from reading the page. */
|
|
4
|
+
export const metaViewportZoomable = defineRule({
|
|
5
|
+
id: 'meta-viewport-zoomable',
|
|
6
|
+
description: 'The viewport meta tag must not disable or limit zoom.',
|
|
7
|
+
severity: 'serious',
|
|
8
|
+
wcag: ['1.4.4'],
|
|
9
|
+
}, (el, ctx) => {
|
|
10
|
+
if (!isDomTag(el, 'meta'))
|
|
11
|
+
return;
|
|
12
|
+
if (staticString(el, 'name')?.trim().toLowerCase() !== 'viewport')
|
|
13
|
+
return;
|
|
14
|
+
const content = staticString(el, 'content');
|
|
15
|
+
if (content === undefined)
|
|
16
|
+
return;
|
|
17
|
+
const normalized = content.toLowerCase().replace(/\s/g, '');
|
|
18
|
+
if (/user-scalable=(no|0)/.test(normalized)) {
|
|
19
|
+
ctx.report({ el, message: 'user-scalable=no prevents pinch-zoom — low-vision users cannot enlarge text.' });
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const maxScale = normalized.match(/maximum-scale=([\d.]+)/);
|
|
23
|
+
if (maxScale && Number(maxScale[1]) < 2) {
|
|
24
|
+
ctx.report({ el, message: `maximum-scale=${maxScale[1]} limits zoom below 200% (WCAG requires text resizable to 200%).` });
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
/** Meta refresh redirects/reloads on a timer users cannot control. */
|
|
28
|
+
export const noMetaRefresh = defineRule({
|
|
29
|
+
id: 'no-meta-refresh',
|
|
30
|
+
description: 'Do not use <meta http-equiv="refresh">.',
|
|
31
|
+
severity: 'serious',
|
|
32
|
+
wcag: ['2.2.1'],
|
|
33
|
+
}, (el, ctx) => {
|
|
34
|
+
if (!isDomTag(el, 'meta'))
|
|
35
|
+
return;
|
|
36
|
+
const httpEquiv = staticString(el, 'httpEquiv') ?? staticString(el, 'http-equiv');
|
|
37
|
+
if (httpEquiv?.trim().toLowerCase() === 'refresh') {
|
|
38
|
+
ctx.report({ el, message: '<meta http-equiv="refresh"> reloads or redirects on a timer users cannot adjust or disable.' });
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
/** Empty <title> leaves the page unnamed in tabs, history and screen readers. */
|
|
42
|
+
export const titleHasContent = defineRule({
|
|
43
|
+
id: 'title-has-content',
|
|
44
|
+
description: '<title> must not be empty.',
|
|
45
|
+
severity: 'serious',
|
|
46
|
+
wcag: ['2.4.2'],
|
|
47
|
+
}, (el, ctx) => {
|
|
48
|
+
if (!isDomTag(el, 'title'))
|
|
49
|
+
return;
|
|
50
|
+
if (el.hasTextChild || el.hasExpressionChild || el.hasSpread)
|
|
51
|
+
return;
|
|
52
|
+
ctx.report({ el, message: '<title> is empty — the page has no name in tabs, bookmarks or screen reader announcements.' });
|
|
53
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** input type="button"/"image" have no default label. */
|
|
2
|
+
export declare const inputButtonHasName: import("@aishware/react-a11y-core").Rule;
|
|
3
|
+
/**
|
|
4
|
+
* WCAG 3.3.8 (new in 2.2): authentication must not rely on transcription.
|
|
5
|
+
* Blocking password managers or paste forces users to retype credentials.
|
|
6
|
+
*/
|
|
7
|
+
export declare const accessibleAuthentication: import("@aishware/react-a11y-core").Rule;
|
|
8
|
+
/**
|
|
9
|
+
* WCAG 3.3.1: a control marked invalid must point at a text description of
|
|
10
|
+
* the error, or screen reader users only hear "invalid" with no explanation.
|
|
11
|
+
*/
|
|
12
|
+
export declare const errorIdentification: import("@aishware/react-a11y-core").Rule;
|
|
13
|
+
/**
|
|
14
|
+
* WCAG 3.3.7 (new in 2.2): users must not re-enter information the site
|
|
15
|
+
* already has. autoComplete="off" on identity fields blocks the browser
|
|
16
|
+
* from re-filling data the user already provided.
|
|
17
|
+
*/
|
|
18
|
+
export declare const noAutocompleteOff: import("@aishware/react-a11y-core").Rule;
|
|
19
|
+
//# sourceMappingURL=forms.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"forms.d.ts","sourceRoot":"","sources":["../../src/rules/forms.ts"],"names":[],"mappings":"AAGA,yDAAyD;AACzD,eAAO,MAAM,kBAAkB,0CAkB9B,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,wBAAwB,0CAwBpC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,mBAAmB,0CAkB/B,CAAC;AAIF;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,0CAqB7B,CAAC"}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { attrProvidesValue, hasAttr, staticString } from '@aishware/react-a11y-core';
|
|
2
|
+
import { defineRule, isDomTag } from '../util.js';
|
|
3
|
+
/** input type="button"/"image" have no default label. */
|
|
4
|
+
export const inputButtonHasName = defineRule({
|
|
5
|
+
id: 'input-button-has-name',
|
|
6
|
+
description: '<input type="button"> needs a value; <input type="image"> needs alt.',
|
|
7
|
+
severity: 'serious',
|
|
8
|
+
wcag: ['4.1.2', '1.1.1'],
|
|
9
|
+
}, (el, ctx) => {
|
|
10
|
+
if (!isDomTag(el, 'input') || el.hasSpread)
|
|
11
|
+
return;
|
|
12
|
+
const type = staticString(el, 'type')?.trim().toLowerCase();
|
|
13
|
+
if (type === 'button') {
|
|
14
|
+
if (attrProvidesValue(el, 'value') || attrProvidesValue(el, 'aria-label') || attrProvidesValue(el, 'aria-labelledby'))
|
|
15
|
+
return;
|
|
16
|
+
ctx.report({ el, message: '<input type="button"> has no value or aria-label — it is announced as an unnamed button.' });
|
|
17
|
+
}
|
|
18
|
+
else if (type === 'image') {
|
|
19
|
+
if (attrProvidesValue(el, 'alt') || attrProvidesValue(el, 'aria-label') || attrProvidesValue(el, 'aria-labelledby'))
|
|
20
|
+
return;
|
|
21
|
+
ctx.report({ el, message: '<input type="image"> has no alt text — the button\'s purpose is invisible to screen readers.' });
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
/**
|
|
25
|
+
* WCAG 3.3.8 (new in 2.2): authentication must not rely on transcription.
|
|
26
|
+
* Blocking password managers or paste forces users to retype credentials.
|
|
27
|
+
*/
|
|
28
|
+
export const accessibleAuthentication = defineRule({
|
|
29
|
+
id: 'accessible-authentication',
|
|
30
|
+
description: 'Password fields must not block password managers or paste.',
|
|
31
|
+
severity: 'serious',
|
|
32
|
+
wcag: ['3.3.8'],
|
|
33
|
+
}, (el, ctx) => {
|
|
34
|
+
if (!isDomTag(el, 'input'))
|
|
35
|
+
return;
|
|
36
|
+
if (staticString(el, 'type')?.trim().toLowerCase() !== 'password')
|
|
37
|
+
return;
|
|
38
|
+
if (staticString(el, 'autoComplete')?.trim().toLowerCase() === 'off') {
|
|
39
|
+
ctx.report({
|
|
40
|
+
el,
|
|
41
|
+
message: 'autoComplete="off" on a password field blocks password managers, forcing users with cognitive or motor disabilities to transcribe. Use "current-password" or "new-password".',
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
if (hasAttr(el, 'onPaste')) {
|
|
45
|
+
ctx.report({
|
|
46
|
+
el,
|
|
47
|
+
message: 'onPaste on a password field — if it prevents pasting, users cannot use password managers (WCAG 3.3.8). Verify paste is not blocked.',
|
|
48
|
+
severity: 'moderate',
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
/**
|
|
53
|
+
* WCAG 3.3.1: a control marked invalid must point at a text description of
|
|
54
|
+
* the error, or screen reader users only hear "invalid" with no explanation.
|
|
55
|
+
*/
|
|
56
|
+
export const errorIdentification = defineRule({
|
|
57
|
+
id: 'error-identification',
|
|
58
|
+
description: 'Controls with aria-invalid must reference an error description.',
|
|
59
|
+
severity: 'moderate',
|
|
60
|
+
wcag: ['3.3.1'],
|
|
61
|
+
partial: true,
|
|
62
|
+
}, (el, ctx) => {
|
|
63
|
+
if (!isDomTag(el, 'input', 'select', 'textarea') || el.hasSpread)
|
|
64
|
+
return;
|
|
65
|
+
const invalid = el.attrs.get('aria-invalid');
|
|
66
|
+
if (!invalid || (invalid.kind === 'static' && (invalid.value === false || invalid.value === 'false')))
|
|
67
|
+
return;
|
|
68
|
+
if (hasAttr(el, 'aria-describedby') || hasAttr(el, 'aria-errormessage'))
|
|
69
|
+
return;
|
|
70
|
+
ctx.report({
|
|
71
|
+
el,
|
|
72
|
+
message: `<${el.name}> is marked aria-invalid but has no aria-describedby/aria-errormessage — screen reader users hear "invalid" with no explanation of what is wrong.`,
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
const IDENTITY_FIELD = /(name|email|phone|tel|address|city|country|zip|postal|company|organi[sz]ation)/i;
|
|
76
|
+
/**
|
|
77
|
+
* WCAG 3.3.7 (new in 2.2): users must not re-enter information the site
|
|
78
|
+
* already has. autoComplete="off" on identity fields blocks the browser
|
|
79
|
+
* from re-filling data the user already provided.
|
|
80
|
+
*/
|
|
81
|
+
export const noAutocompleteOff = defineRule({
|
|
82
|
+
id: 'no-autocomplete-off',
|
|
83
|
+
description: 'Do not disable autofill on fields asking for user information.',
|
|
84
|
+
severity: 'moderate',
|
|
85
|
+
wcag: ['3.3.7', '1.3.5'],
|
|
86
|
+
partial: true,
|
|
87
|
+
}, (el, ctx) => {
|
|
88
|
+
if (!isDomTag(el, 'input'))
|
|
89
|
+
return;
|
|
90
|
+
if (staticString(el, 'autoComplete')?.trim().toLowerCase() !== 'off')
|
|
91
|
+
return;
|
|
92
|
+
const type = staticString(el, 'type')?.trim().toLowerCase();
|
|
93
|
+
if (type === 'password')
|
|
94
|
+
return; // covered by accessible-authentication
|
|
95
|
+
const fieldHint = `${staticString(el, 'name') ?? ''} ${staticString(el, 'id') ?? ''} ${type ?? ''}`;
|
|
96
|
+
if (type === 'email' || type === 'tel' || IDENTITY_FIELD.test(fieldHint)) {
|
|
97
|
+
ctx.report({
|
|
98
|
+
el,
|
|
99
|
+
message: 'autoComplete="off" on a personal-data field forces users to retype information they already entered (WCAG 3.3.7) and defeats 1.3.5. Use a proper autofill token instead.',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WCAG 2.5.2: activating on down-events (mousedown/touchstart) means users
|
|
3
|
+
* cannot abort by sliding off the control before releasing.
|
|
4
|
+
*/
|
|
5
|
+
export declare const pointerCancellation: import("@aishware/react-a11y-core").Rule;
|
|
6
|
+
/** Removing the focus outline without a replacement hides keyboard position. */
|
|
7
|
+
export declare const noOutlineNone: import("@aishware/react-a11y-core").Rule;
|
|
8
|
+
//# sourceMappingURL=interactions.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"interactions.d.ts","sourceRoot":"","sources":["../../src/rules/interactions.ts"],"names":[],"mappings":"AASA;;;GAGG;AACH,eAAO,MAAM,mBAAmB,0CAoB/B,CAAC;AAEF,gFAAgF;AAChF,eAAO,MAAM,aAAa,0CA0BzB,CAAC"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { INTERACTIVE_ROLES, INTERACTIVE_TAGS, hasAttr, inlineStyleValue, staticString, } from '@aishware/react-a11y-core';
|
|
2
|
+
import { defineRule } from '../util.js';
|
|
3
|
+
/**
|
|
4
|
+
* WCAG 2.5.2: activating on down-events (mousedown/touchstart) means users
|
|
5
|
+
* cannot abort by sliding off the control before releasing.
|
|
6
|
+
*/
|
|
7
|
+
export const pointerCancellation = defineRule({
|
|
8
|
+
id: 'pointer-cancellation',
|
|
9
|
+
description: 'Do not trigger actions on mousedown/touchstart — use click/up events.',
|
|
10
|
+
severity: 'moderate',
|
|
11
|
+
wcag: ['2.5.2'],
|
|
12
|
+
partial: true,
|
|
13
|
+
}, (el, ctx) => {
|
|
14
|
+
if (el.isComponent || el.hasSpread)
|
|
15
|
+
return;
|
|
16
|
+
if (el.name !== 'button' && el.name !== 'a')
|
|
17
|
+
return;
|
|
18
|
+
const hasDown = hasAttr(el, 'onMouseDown') || hasAttr(el, 'onTouchStart');
|
|
19
|
+
const hasUp = hasAttr(el, 'onClick') || hasAttr(el, 'onMouseUp') || hasAttr(el, 'onTouchEnd') || hasAttr(el, 'onPointerUp');
|
|
20
|
+
if (hasDown && !hasUp) {
|
|
21
|
+
ctx.report({
|
|
22
|
+
el,
|
|
23
|
+
message: `<${el.name}> acts on a down-event only — users cannot cancel by sliding off before release. Move the action to onClick.`,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
/** Removing the focus outline without a replacement hides keyboard position. */
|
|
28
|
+
export const noOutlineNone = defineRule({
|
|
29
|
+
id: 'no-outline-none',
|
|
30
|
+
description: 'Do not remove the focus outline via inline styles without a visible replacement.',
|
|
31
|
+
severity: 'moderate',
|
|
32
|
+
wcag: ['2.4.7'],
|
|
33
|
+
}, (el, ctx) => {
|
|
34
|
+
if (el.isComponent)
|
|
35
|
+
return;
|
|
36
|
+
const interactive = INTERACTIVE_TAGS.has(el.name) ||
|
|
37
|
+
hasAttr(el, 'tabIndex') ||
|
|
38
|
+
hasAttr(el, 'onClick') ||
|
|
39
|
+
INTERACTIVE_ROLES.has(staticString(el, 'role')?.trim() ?? '');
|
|
40
|
+
if (!interactive)
|
|
41
|
+
return;
|
|
42
|
+
for (const prop of ['outline', 'outlineStyle', 'outlineWidth']) {
|
|
43
|
+
const v = inlineStyleValue(el, prop);
|
|
44
|
+
if (v === 'none' || v === 'hidden' || v === 0 || v === '0') {
|
|
45
|
+
ctx.report({
|
|
46
|
+
el,
|
|
47
|
+
message: `Inline style removes the focus outline (${prop}: ${JSON.stringify(v)}) — keyboard users lose track of where they are unless a visible :focus style replaces it.`,
|
|
48
|
+
});
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Buttons (and role="button" elements) must have an accessible name. */
|
|
2
|
+
export declare const buttonHasName: import("@aishware/react-a11y-core").Rule;
|
|
3
|
+
/**
|
|
4
|
+
* WCAG 2.5.3: the visible label must be contained in the accessible name,
|
|
5
|
+
* or voice-control users saying the visible text cannot activate the control.
|
|
6
|
+
* Only fires when both the aria-label and the entire visible text are static.
|
|
7
|
+
*/
|
|
8
|
+
export declare const labelInName: import("@aishware/react-a11y-core").Rule;
|
|
9
|
+
/**
|
|
10
|
+
* Auto-playing audio talks over screen readers. Muted media is fine;
|
|
11
|
+
* media with controls is downgraded since users can stop it.
|
|
12
|
+
*/
|
|
13
|
+
export declare const mediaNoAutoplay: import("@aishware/react-a11y-core").Rule;
|
|
14
|
+
//# sourceMappingURL=names.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"names.d.ts","sourceRoot":"","sources":["../../src/rules/names.ts"],"names":[],"mappings":"AAWA,yEAAyE;AACzE,eAAO,MAAM,aAAa,0CAkBzB,CAAC;AAIF;;;;GAIG;AACH,eAAO,MAAM,WAAW,0CAyBvB,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,eAAe,0CAwB3B,CAAC"}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { INTERACTIVE_ROLES, deepStaticText, hasAccessibleName, hasAttr, isAriaHidden, staticString, staticValue, } from '@aishware/react-a11y-core';
|
|
2
|
+
import { defineRule, isDomTag } from '../util.js';
|
|
3
|
+
/** Buttons (and role="button" elements) must have an accessible name. */
|
|
4
|
+
export const buttonHasName = defineRule({
|
|
5
|
+
id: 'button-has-accessible-name',
|
|
6
|
+
description: 'Buttons must have an accessible name.',
|
|
7
|
+
severity: 'critical',
|
|
8
|
+
wcag: ['4.1.2'],
|
|
9
|
+
}, (el, ctx) => {
|
|
10
|
+
const isButtonTag = isDomTag(el, 'button');
|
|
11
|
+
const hasButtonRole = !el.isComponent && staticString(el, 'role')?.trim() === 'button';
|
|
12
|
+
if (!isButtonTag && !hasButtonRole)
|
|
13
|
+
return;
|
|
14
|
+
if (isAriaHidden(el))
|
|
15
|
+
return;
|
|
16
|
+
if (hasAccessibleName(el))
|
|
17
|
+
return;
|
|
18
|
+
ctx.report({
|
|
19
|
+
el,
|
|
20
|
+
message: `<${el.name}${hasButtonRole && !isButtonTag ? ' role="button"' : ''}> has no accessible name. Icon-only buttons need aria-label.`,
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
const normalize = (s) => s.toLowerCase().replace(/[^\p{L}\p{N} ]/gu, '').replace(/\s+/g, ' ').trim();
|
|
24
|
+
/**
|
|
25
|
+
* WCAG 2.5.3: the visible label must be contained in the accessible name,
|
|
26
|
+
* or voice-control users saying the visible text cannot activate the control.
|
|
27
|
+
* Only fires when both the aria-label and the entire visible text are static.
|
|
28
|
+
*/
|
|
29
|
+
export const labelInName = defineRule({
|
|
30
|
+
id: 'label-in-name',
|
|
31
|
+
description: 'aria-label must contain the visible text of the control.',
|
|
32
|
+
severity: 'moderate',
|
|
33
|
+
wcag: ['2.5.3'],
|
|
34
|
+
}, (el, ctx) => {
|
|
35
|
+
const ariaLabel = staticString(el, 'aria-label');
|
|
36
|
+
if (!ariaLabel?.trim())
|
|
37
|
+
return;
|
|
38
|
+
const interactive = isDomTag(el, 'button', 'a', 'summary') ||
|
|
39
|
+
INTERACTIVE_ROLES.has(staticString(el, 'role')?.trim() ?? '');
|
|
40
|
+
if (!interactive)
|
|
41
|
+
return;
|
|
42
|
+
const visible = deepStaticText(el);
|
|
43
|
+
if (!visible)
|
|
44
|
+
return; // dynamic or empty — can't compare
|
|
45
|
+
const label = normalize(ariaLabel);
|
|
46
|
+
const text = normalize(visible);
|
|
47
|
+
if (text && !label.includes(text)) {
|
|
48
|
+
ctx.report({
|
|
49
|
+
el,
|
|
50
|
+
message: `aria-label="${ariaLabel}" does not contain the visible text "${visible}" — voice-control users saying what they see cannot activate it.`,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
/**
|
|
55
|
+
* Auto-playing audio talks over screen readers. Muted media is fine;
|
|
56
|
+
* media with controls is downgraded since users can stop it.
|
|
57
|
+
*/
|
|
58
|
+
export const mediaNoAutoplay = defineRule({
|
|
59
|
+
id: 'media-no-autoplay',
|
|
60
|
+
description: 'Media must not autoplay with sound.',
|
|
61
|
+
severity: 'serious',
|
|
62
|
+
wcag: ['1.4.2'],
|
|
63
|
+
}, (el, ctx) => {
|
|
64
|
+
if (!isDomTag(el, 'video', 'audio'))
|
|
65
|
+
return;
|
|
66
|
+
if (!hasAttr(el, 'autoPlay') || staticValue(el, 'autoPlay') === false)
|
|
67
|
+
return;
|
|
68
|
+
if (staticValue(el, 'muted') === true)
|
|
69
|
+
return;
|
|
70
|
+
if (hasAttr(el, 'controls')) {
|
|
71
|
+
ctx.report({
|
|
72
|
+
el,
|
|
73
|
+
message: `<${el.name}> autoplays with sound. Users can stop it via controls, but prefer starting paused.`,
|
|
74
|
+
severity: 'moderate',
|
|
75
|
+
});
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
ctx.report({
|
|
79
|
+
el,
|
|
80
|
+
message: `<${el.name}> autoplays with sound and has no controls — it talks over screen readers with no way to stop it. Add muted, or remove autoPlay.`,
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An element given an interactive role but unreachable by keyboard: it needs
|
|
3
|
+
* tabIndex to be focusable, or assistive-technology users cannot operate it.
|
|
4
|
+
*/
|
|
5
|
+
export declare const interactiveSupportsFocus: import("@react-a11y/core").Rule;
|
|
6
|
+
/**
|
|
7
|
+
* Event handlers on semantic non-interactive elements (li, main, h2, …) are
|
|
8
|
+
* unreachable by keyboard and screen reader users. Move the handler to a
|
|
9
|
+
* <button>/<a>, or give the element an interactive role and focus support.
|
|
10
|
+
*/
|
|
11
|
+
export declare const noNoninteractiveElementInteractions: import("@react-a11y/core").Rule;
|
|
12
|
+
/**
|
|
13
|
+
* An interactive element whose role removes its interactive semantics
|
|
14
|
+
* (e.g. <button role="article">) becomes invisible to assistive technology
|
|
15
|
+
* as a control.
|
|
16
|
+
*/
|
|
17
|
+
export declare const noInteractiveElementToNoninteractiveRole: import("@react-a11y/core").Rule;
|
|
18
|
+
/**
|
|
19
|
+
* A semantic non-interactive element given an interactive role (e.g.
|
|
20
|
+
* <li role="button">) should be a generic container or a native control —
|
|
21
|
+
* native elements carry behavior the role alone does not.
|
|
22
|
+
*/
|
|
23
|
+
export declare const noNoninteractiveElementToInteractiveRole: import("@react-a11y/core").Rule;
|
|
24
|
+
/** tabIndex on a non-interactive element adds a tab stop that does nothing. */
|
|
25
|
+
export declare const noNoninteractiveTabindex: import("@react-a11y/core").Rule;
|
|
26
|
+
/**
|
|
27
|
+
* A role on a generic <div>/<span> that has a native element equivalent —
|
|
28
|
+
* prefer the native tag for built-in keyboard handling and semantics.
|
|
29
|
+
* (Redundant roles on the matching element itself are handled by
|
|
30
|
+
* no-redundant-roles.)
|
|
31
|
+
*/
|
|
32
|
+
export declare const preferTagOverRole: import("@react-a11y/core").Rule;
|
|
33
|
+
//# sourceMappingURL=roles.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"roles.d.ts","sourceRoot":"","sources":["../../src/rules/roles.ts"],"names":[],"mappings":"AAoEA;;;GAGG;AACH,eAAO,MAAM,wBAAwB,iCAmBpC,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,mCAAmC,iCAsB/C,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,wCAAwC,iCAkBpD,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,wCAAwC,iCAiBpD,CAAC;AAEF,+EAA+E;AAC/E,eAAO,MAAM,wBAAwB,iCAsBpC,CAAC;AAgCF;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,iCAkB7B,CAAC"}
|