@aishware/react-a11y-rules-native 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 +26 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/rules/components.d.ts +31 -0
- package/dist/rules/components.d.ts.map +1 -0
- package/dist/rules/components.js +174 -0
- package/dist/rules/contrast.d.ts +7 -0
- package/dist/rules/contrast.d.ts.map +1 -0
- package/dist/rules/contrast.js +20 -0
- package/dist/rules/expo-config.d.ts +9 -0
- package/dist/rules/expo-config.d.ts.map +1 -0
- package/dist/rules/expo-config.js +137 -0
- package/dist/rules/focus.d.ts +17 -0
- package/dist/rules/focus.d.ts.map +1 -0
- package/dist/rules/focus.js +79 -0
- package/dist/rules/platform.d.ts +14 -0
- package/dist/rules/platform.d.ts.map +1 -0
- package/dist/rules/platform.js +59 -0
- package/dist/rules/state.d.ts +5 -0
- package/dist/rules/state.d.ts.map +1 -0
- package/dist/rules/state.js +52 -0
- package/dist/rules/touchables.d.ts +17 -0
- package/dist/rules/touchables.d.ts.map +1 -0
- package/dist/rules/touchables.js +96 -0
- package/dist/util.d.ts +22 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +67 -0
- package/package.json +37 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aishwarya Sharma
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# @aishware/react-a11y-rules-native
|
|
2
|
+
|
|
3
|
+
Accessibility rules for React Native and Expo, built on the shared
|
|
4
|
+
[`@aishware/react-a11y-core`](https://www.npmjs.com/package/@aishware/react-a11y-core) engine.
|
|
5
|
+
Covers touchable labels/roles, nested touchables, WCAG 2.5.8 touch-target size,
|
|
6
|
+
color contrast, images, text inputs, switches, modal keyboard traps, live
|
|
7
|
+
regions, accessibility state/value props, silent prop typos, accessibility
|
|
8
|
+
actions, cross-platform hiding (iOS vs Android), focus and reading order
|
|
9
|
+
(`accessible={true}` grouping), and orientation locks in project config
|
|
10
|
+
(`app.json`, `app.config.*`, `AndroidManifest.xml`, `Info.plist`).
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { analyze } from '@aishware/react-a11y-core';
|
|
14
|
+
import { nativeRules } from '@aishware/react-a11y-rules-native';
|
|
15
|
+
|
|
16
|
+
const diagnostics = analyze({ code, filename: 'App.tsx', platform: 'native', rules: nativeRules });
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Most users want the CLI instead:
|
|
20
|
+
[`@aishware/react-a11y`](https://www.npmjs.com/package/@aishware/react-a11y).
|
|
21
|
+
|
|
22
|
+
Full rule list: <https://github.com/1aishwaryasharma/react-a11y/blob/main/docs/rules/native.md>
|
|
23
|
+
|
|
24
|
+
## License
|
|
25
|
+
|
|
26
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Rule } from '@aishware/react-a11y-core';
|
|
2
|
+
import { touchableHasLabel, touchableHasRole, noNestedTouchables, touchTargetSize } from './rules/touchables.js';
|
|
3
|
+
import { imageHasLabel, textInputHasLabel, switchHasLabel, modalHasRequestClose, validAccessibilityRole, validAccessibilityProps, accessibilityActionsHandled } from './rules/components.js';
|
|
4
|
+
export { RN_ROLES } from './rules/components.js';
|
|
5
|
+
import { accessibilityStateValid, liveRegionValid } from './rules/state.js';
|
|
6
|
+
import { noHiddenInteractive, validImportantForAccessibility, hiddenCrossPlatform } from './rules/platform.js';
|
|
7
|
+
import { accessibleGroupingHidesInteractive, labelNeedsAccessible } from './rules/focus.js';
|
|
8
|
+
import { colorContrast } from './rules/contrast.js';
|
|
9
|
+
import { noOrientationLock } from './rules/expo-config.js';
|
|
10
|
+
export { touchableHasLabel, touchableHasRole, noNestedTouchables, touchTargetSize, imageHasLabel, textInputHasLabel, switchHasLabel, modalHasRequestClose, validAccessibilityRole, validAccessibilityProps, accessibilityStateValid, liveRegionValid, noHiddenInteractive, accessibilityActionsHandled, validImportantForAccessibility, hiddenCrossPlatform, accessibleGroupingHidesInteractive, labelNeedsAccessible, colorContrast, noOrientationLock, };
|
|
11
|
+
/** The recommended React Native / Expo preset. */
|
|
12
|
+
export declare const nativeRules: Rule[];
|
|
13
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,2BAA2B,CAAC;AACtD,OAAO,EACL,iBAAiB,EACjB,gBAAgB,EAChB,kBAAkB,EAClB,eAAe,EAChB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EACL,aAAa,EACb,iBAAiB,EACjB,cAAc,EACd,oBAAoB,EACpB,sBAAsB,EACtB,uBAAuB,EACvB,2BAA2B,EAC5B,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AACjD,OAAO,EAAE,uBAAuB,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAC5E,OAAO,EACL,mBAAmB,EACnB,8BAA8B,EAC9B,mBAAmB,EACpB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACL,kCAAkC,EAClC,oBAAoB,EACrB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAE3D,OAAO,EACL,iBAAiB,EACjB,gBAAgB,EAChB,kBAAkB,EAClB,eAAe,EACf,aAAa,EACb,iBAAiB,EACjB,cAAc,EACd,oBAAoB,EACpB,sBAAsB,EACtB,uBAAuB,EACvB,uBAAuB,EACvB,eAAe,EACf,mBAAmB,EACnB,2BAA2B,EAC3B,8BAA8B,EAC9B,mBAAmB,EACnB,kCAAkC,EAClC,oBAAoB,EACpB,aAAa,EACb,iBAAiB,GAClB,CAAC;AAEF,kDAAkD;AAClD,eAAO,MAAM,WAAW,EAAE,IAAI,EAsB7B,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { touchableHasLabel, touchableHasRole, noNestedTouchables, touchTargetSize, } from './rules/touchables.js';
|
|
2
|
+
import { imageHasLabel, textInputHasLabel, switchHasLabel, modalHasRequestClose, validAccessibilityRole, validAccessibilityProps, accessibilityActionsHandled, } from './rules/components.js';
|
|
3
|
+
export { RN_ROLES } from './rules/components.js';
|
|
4
|
+
import { accessibilityStateValid, liveRegionValid } from './rules/state.js';
|
|
5
|
+
import { noHiddenInteractive, validImportantForAccessibility, hiddenCrossPlatform, } from './rules/platform.js';
|
|
6
|
+
import { accessibleGroupingHidesInteractive, labelNeedsAccessible, } from './rules/focus.js';
|
|
7
|
+
import { colorContrast } from './rules/contrast.js';
|
|
8
|
+
import { noOrientationLock } from './rules/expo-config.js';
|
|
9
|
+
export { touchableHasLabel, touchableHasRole, noNestedTouchables, touchTargetSize, imageHasLabel, textInputHasLabel, switchHasLabel, modalHasRequestClose, validAccessibilityRole, validAccessibilityProps, accessibilityStateValid, liveRegionValid, noHiddenInteractive, accessibilityActionsHandled, validImportantForAccessibility, hiddenCrossPlatform, accessibleGroupingHidesInteractive, labelNeedsAccessible, colorContrast, noOrientationLock, };
|
|
10
|
+
/** The recommended React Native / Expo preset. */
|
|
11
|
+
export const nativeRules = [
|
|
12
|
+
touchableHasLabel,
|
|
13
|
+
touchableHasRole,
|
|
14
|
+
noNestedTouchables,
|
|
15
|
+
touchTargetSize,
|
|
16
|
+
imageHasLabel,
|
|
17
|
+
textInputHasLabel,
|
|
18
|
+
switchHasLabel,
|
|
19
|
+
modalHasRequestClose,
|
|
20
|
+
validAccessibilityRole,
|
|
21
|
+
validAccessibilityProps,
|
|
22
|
+
accessibilityStateValid,
|
|
23
|
+
liveRegionValid,
|
|
24
|
+
noHiddenInteractive,
|
|
25
|
+
accessibilityActionsHandled,
|
|
26
|
+
validImportantForAccessibility,
|
|
27
|
+
hiddenCrossPlatform,
|
|
28
|
+
// focus & reading order
|
|
29
|
+
accessibleGroupingHidesInteractive,
|
|
30
|
+
labelNeedsAccessible,
|
|
31
|
+
colorContrast,
|
|
32
|
+
noOrientationLock,
|
|
33
|
+
];
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RN Images are skipped by screen readers unless made accessible, so this is
|
|
3
|
+
* a prompt to make intent explicit: label informative images, mark decorative
|
|
4
|
+
* ones with accessible={false} or alt="".
|
|
5
|
+
*/
|
|
6
|
+
export declare const imageHasLabel: import("@aishware/react-a11y-core").Rule;
|
|
7
|
+
/** Placeholder text disappears on input and is not a label. */
|
|
8
|
+
export declare const textInputHasLabel: import("@aishware/react-a11y-core").Rule;
|
|
9
|
+
/** A Switch with no label is announced as just "switch, off". */
|
|
10
|
+
export declare const switchHasLabel: import("@aishware/react-a11y-core").Rule;
|
|
11
|
+
/**
|
|
12
|
+
* Without onRequestClose, the Android back button (and TV remote back) does
|
|
13
|
+
* nothing — the modal becomes a trap for hardware-navigation users.
|
|
14
|
+
*/
|
|
15
|
+
export declare const modalHasRequestClose: import("@aishware/react-a11y-core").Rule;
|
|
16
|
+
/**
|
|
17
|
+
* Valid values for accessibilityRole, per the React Native docs. A superset of
|
|
18
|
+
* eslint-plugin-react-native-a11y's role list (we add RN's Android-only roles),
|
|
19
|
+
* kept in sync by the parity test in test/upstream-parity.test.ts.
|
|
20
|
+
*/
|
|
21
|
+
export declare const RN_ROLES: Set<string>;
|
|
22
|
+
export declare const validAccessibilityRole: import("@aishware/react-a11y-core").Rule;
|
|
23
|
+
/** Misspelled accessibility props fail silently at runtime — catch them statically. */
|
|
24
|
+
export declare const validAccessibilityProps: import("@aishware/react-a11y-core").Rule;
|
|
25
|
+
/**
|
|
26
|
+
* accessibilityActions declares custom actions; onAccessibilityAction handles
|
|
27
|
+
* them. One without the other is a silent no-op (actions never reachable, or a
|
|
28
|
+
* handler that receives nothing).
|
|
29
|
+
*/
|
|
30
|
+
export declare const accessibilityActionsHandled: import("@aishware/react-a11y-core").Rule;
|
|
31
|
+
//# sourceMappingURL=components.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"components.d.ts","sourceRoot":"","sources":["../../src/rules/components.ts"],"names":[],"mappings":"AAQA;;;;GAIG;AACH,eAAO,MAAM,aAAa,0CAmBzB,CAAC;AAEF,+DAA+D;AAC/D,eAAO,MAAM,iBAAiB,0CAgB7B,CAAC;AAEF,iEAAiE;AACjE,eAAO,MAAM,cAAc,0CAgB1B,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,oBAAoB,0CAehC,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,QAAQ,aAQnB,CAAC;AAEH,eAAO,MAAM,sBAAsB,0CAelC,CAAC;AAaF,uFAAuF;AACvF,eAAO,MAAM,uBAAuB,0CAuBnC,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,2BAA2B,0CAmBvC,CAAC"}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { attrProvidesValue, fixRenameAttr, hasAttr, staticString, staticValue } from '@aishware/react-a11y-core';
|
|
2
|
+
import { defineRule, hasNativeLabel, isHiddenFromAT, isRNComponent } from '../util.js';
|
|
3
|
+
const IMAGE = new Set(['Image']);
|
|
4
|
+
const TEXT_INPUT = new Set(['TextInput']);
|
|
5
|
+
const SWITCH = new Set(['Switch']);
|
|
6
|
+
const MODAL = new Set(['Modal']);
|
|
7
|
+
/**
|
|
8
|
+
* RN Images are skipped by screen readers unless made accessible, so this is
|
|
9
|
+
* a prompt to make intent explicit: label informative images, mark decorative
|
|
10
|
+
* ones with accessible={false} or alt="".
|
|
11
|
+
*/
|
|
12
|
+
export const imageHasLabel = defineRule({
|
|
13
|
+
id: 'image-has-label',
|
|
14
|
+
description: 'Images need alt/accessibilityLabel, or an explicit decorative marker.',
|
|
15
|
+
severity: 'moderate',
|
|
16
|
+
wcag: ['1.1.1'],
|
|
17
|
+
}, (el, ctx) => {
|
|
18
|
+
if (!isRNComponent(el, IMAGE))
|
|
19
|
+
return;
|
|
20
|
+
if (el.hasSpread || isHiddenFromAT(el))
|
|
21
|
+
return;
|
|
22
|
+
if (staticValue(el, 'accessible') === false)
|
|
23
|
+
return; // explicitly decorative
|
|
24
|
+
if (attrProvidesValue(el, 'alt') || hasNativeLabel(el))
|
|
25
|
+
return;
|
|
26
|
+
const alt = el.attrs.get('alt');
|
|
27
|
+
if (alt?.kind === 'static' && alt.value === '')
|
|
28
|
+
return; // alt="" marks decorative
|
|
29
|
+
ctx.report({
|
|
30
|
+
el,
|
|
31
|
+
message: '<Image> has no alternative text. Add alt/accessibilityLabel if informative, or accessible={false} / alt="" if decorative.',
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
/** Placeholder text disappears on input and is not a label. */
|
|
35
|
+
export const textInputHasLabel = defineRule({
|
|
36
|
+
id: 'textinput-has-label',
|
|
37
|
+
description: 'TextInput must have an accessibility label.',
|
|
38
|
+
severity: 'serious',
|
|
39
|
+
wcag: ['3.3.2', '4.1.2'],
|
|
40
|
+
}, (el, ctx) => {
|
|
41
|
+
if (!isRNComponent(el, TEXT_INPUT))
|
|
42
|
+
return;
|
|
43
|
+
if (el.hasSpread || isHiddenFromAT(el))
|
|
44
|
+
return;
|
|
45
|
+
if (hasNativeLabel(el))
|
|
46
|
+
return;
|
|
47
|
+
const hint = hasAttr(el, 'placeholder')
|
|
48
|
+
? ' A placeholder is not a label — it disappears once the user types.'
|
|
49
|
+
: '';
|
|
50
|
+
ctx.report({ el, message: `<TextInput> has no accessibilityLabel.${hint}` });
|
|
51
|
+
});
|
|
52
|
+
/** A Switch with no label is announced as just "switch, off". */
|
|
53
|
+
export const switchHasLabel = defineRule({
|
|
54
|
+
id: 'switch-has-label',
|
|
55
|
+
description: 'Switch must have an accessibility label.',
|
|
56
|
+
severity: 'serious',
|
|
57
|
+
wcag: ['4.1.2', '3.3.2'],
|
|
58
|
+
}, (el, ctx) => {
|
|
59
|
+
if (!isRNComponent(el, SWITCH))
|
|
60
|
+
return;
|
|
61
|
+
if (el.hasSpread || isHiddenFromAT(el))
|
|
62
|
+
return;
|
|
63
|
+
if (hasNativeLabel(el))
|
|
64
|
+
return;
|
|
65
|
+
ctx.report({
|
|
66
|
+
el,
|
|
67
|
+
message: '<Switch> has no accessibilityLabel — screen readers announce only "switch, off/on" with no indication of what it controls.',
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
/**
|
|
71
|
+
* Without onRequestClose, the Android back button (and TV remote back) does
|
|
72
|
+
* nothing — the modal becomes a trap for hardware-navigation users.
|
|
73
|
+
*/
|
|
74
|
+
export const modalHasRequestClose = defineRule({
|
|
75
|
+
id: 'modal-has-request-close',
|
|
76
|
+
description: 'Modal must handle onRequestClose so hardware back can dismiss it.',
|
|
77
|
+
severity: 'serious',
|
|
78
|
+
wcag: ['2.1.2'],
|
|
79
|
+
}, (el, ctx) => {
|
|
80
|
+
if (!isRNComponent(el, MODAL))
|
|
81
|
+
return;
|
|
82
|
+
if (el.hasSpread || hasAttr(el, 'onRequestClose'))
|
|
83
|
+
return;
|
|
84
|
+
ctx.report({
|
|
85
|
+
el,
|
|
86
|
+
message: '<Modal> has no onRequestClose — Android back button users are trapped inside it.',
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
/**
|
|
90
|
+
* Valid values for accessibilityRole, per the React Native docs. A superset of
|
|
91
|
+
* eslint-plugin-react-native-a11y's role list (we add RN's Android-only roles),
|
|
92
|
+
* kept in sync by the parity test in test/upstream-parity.test.ts.
|
|
93
|
+
*/
|
|
94
|
+
export const RN_ROLES = new Set([
|
|
95
|
+
'none', 'button', 'togglebutton', 'link', 'search', 'image', 'img', 'keyboardkey',
|
|
96
|
+
'text', 'adjustable', 'imagebutton', 'header', 'summary', 'alert',
|
|
97
|
+
'checkbox', 'combobox', 'menu', 'menubar', 'menuitem', 'progressbar',
|
|
98
|
+
'radio', 'radiogroup', 'scrollbar', 'spinbutton', 'switch', 'tab',
|
|
99
|
+
'tabbar', 'tablist', 'timer', 'list', 'grid', 'pager', 'scrollview',
|
|
100
|
+
'horizontalscrollview', 'viewgroup', 'webview', 'drawerlayout',
|
|
101
|
+
'slidingdrawer', 'iconmenu', 'toast', 'toolbar',
|
|
102
|
+
]);
|
|
103
|
+
export const validAccessibilityRole = defineRule({
|
|
104
|
+
id: 'valid-accessibility-role',
|
|
105
|
+
description: 'accessibilityRole must be a value React Native recognizes.',
|
|
106
|
+
severity: 'serious',
|
|
107
|
+
wcag: ['4.1.2'],
|
|
108
|
+
}, (el, ctx) => {
|
|
109
|
+
const role = staticString(el, 'accessibilityRole')?.trim();
|
|
110
|
+
if (role === undefined || role === '' || RN_ROLES.has(role))
|
|
111
|
+
return;
|
|
112
|
+
ctx.report({
|
|
113
|
+
el,
|
|
114
|
+
message: `accessibilityRole="${role}" is not a valid React Native role — it will be silently ignored on device.`,
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
/** Every accessibility prop React Native supports; anything else is a typo. */
|
|
118
|
+
const KNOWN_A11Y_PROPS = new Set([
|
|
119
|
+
'accessibilityLabel', 'accessibilityHint', 'accessibilityRole',
|
|
120
|
+
'accessibilityState', 'accessibilityValue', 'accessibilityActions',
|
|
121
|
+
'accessibilityElementsHidden', 'accessibilityViewIsModal',
|
|
122
|
+
'accessibilityLiveRegion', 'accessibilityLanguage',
|
|
123
|
+
'accessibilityLabelledBy', 'accessibilityIgnoresInvertColors',
|
|
124
|
+
'accessibilityRespondsToUserInteraction',
|
|
125
|
+
'accessibilityShowsLargeContentViewer', 'accessibilityLargeContentTitle',
|
|
126
|
+
]);
|
|
127
|
+
/** Misspelled accessibility props fail silently at runtime — catch them statically. */
|
|
128
|
+
export const validAccessibilityProps = defineRule({
|
|
129
|
+
id: 'valid-accessibility-props',
|
|
130
|
+
description: 'accessibility* props must be ones React Native actually supports.',
|
|
131
|
+
severity: 'serious',
|
|
132
|
+
wcag: ['4.1.2'],
|
|
133
|
+
fixable: true,
|
|
134
|
+
}, (el, ctx) => {
|
|
135
|
+
for (const name of el.attrs.keys()) {
|
|
136
|
+
if (!name.startsWith('accessibility'))
|
|
137
|
+
continue;
|
|
138
|
+
if (KNOWN_A11Y_PROPS.has(name))
|
|
139
|
+
continue;
|
|
140
|
+
const lower = name.toLowerCase();
|
|
141
|
+
const match = [...KNOWN_A11Y_PROPS].find((k) => k.toLowerCase() === lower);
|
|
142
|
+
ctx.report({
|
|
143
|
+
el,
|
|
144
|
+
message: match
|
|
145
|
+
? `"${name}" is miscapitalized — React Native expects "${match}". The prop is silently ignored as written.`
|
|
146
|
+
: `"${name}" is not a React Native accessibility prop and is silently ignored.`,
|
|
147
|
+
...(match ? { fix: fixRenameAttr(el, name, match) } : {}),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
/**
|
|
152
|
+
* accessibilityActions declares custom actions; onAccessibilityAction handles
|
|
153
|
+
* them. One without the other is a silent no-op (actions never reachable, or a
|
|
154
|
+
* handler that receives nothing).
|
|
155
|
+
*/
|
|
156
|
+
export const accessibilityActionsHandled = defineRule({
|
|
157
|
+
id: 'accessibility-actions-handled',
|
|
158
|
+
description: 'accessibilityActions and onAccessibilityAction must be used together.',
|
|
159
|
+
severity: 'serious',
|
|
160
|
+
wcag: ['4.1.2'],
|
|
161
|
+
}, (el, ctx) => {
|
|
162
|
+
if (el.hasSpread)
|
|
163
|
+
return;
|
|
164
|
+
const hasActions = hasAttr(el, 'accessibilityActions');
|
|
165
|
+
const hasHandler = hasAttr(el, 'onAccessibilityAction');
|
|
166
|
+
if (hasActions === hasHandler)
|
|
167
|
+
return; // both or neither
|
|
168
|
+
ctx.report({
|
|
169
|
+
el,
|
|
170
|
+
message: hasActions
|
|
171
|
+
? 'accessibilityActions is set but onAccessibilityAction is missing — the declared actions are never handled.'
|
|
172
|
+
: 'onAccessibilityAction is set but accessibilityActions is missing — there are no actions for the handler to receive.',
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WCAG 1.4.3 contrast for inline styles where both colors are literal,
|
|
3
|
+
* e.g. <Text style={{ color: '#999', backgroundColor: '#fff' }}>.
|
|
4
|
+
* StyleSheet references and dynamic styles are skipped.
|
|
5
|
+
*/
|
|
6
|
+
export declare const colorContrast: import("@aishware/react-a11y-core").Rule;
|
|
7
|
+
//# sourceMappingURL=contrast.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"contrast.d.ts","sourceRoot":"","sources":["../../src/rules/contrast.ts"],"names":[],"mappings":"AAGA;;;;GAIG;AACH,eAAO,MAAM,aAAa,0CAazB,CAAC"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { colorContrastFinding } 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
|
+
* e.g. <Text style={{ color: '#999', backgroundColor: '#fff' }}>.
|
|
6
|
+
* StyleSheet references and dynamic styles are skipped.
|
|
7
|
+
*/
|
|
8
|
+
export const colorContrast = defineRule({
|
|
9
|
+
id: 'color-contrast',
|
|
10
|
+
description: 'Text color must meet WCAG contrast against its background (4.5:1, or 3:1 for large text).',
|
|
11
|
+
severity: 'serious',
|
|
12
|
+
wcag: ['1.4.3'],
|
|
13
|
+
partial: true,
|
|
14
|
+
}, (el, ctx) => {
|
|
15
|
+
if (!el.hasTextChild)
|
|
16
|
+
return;
|
|
17
|
+
const finding = colorContrastFinding(el);
|
|
18
|
+
if (finding)
|
|
19
|
+
ctx.report({ el, ...finding });
|
|
20
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type Rule } from '@aishware/react-a11y-core';
|
|
2
|
+
/**
|
|
3
|
+
* WCAG 1.3.4 Orientation: flags orientation locks wherever React Native
|
|
4
|
+
* projects declare them — Expo app.json / app.config.*, AndroidManifest.xml
|
|
5
|
+
* and iOS Info.plist. Runtime locks (expo-screen-orientation) are out of
|
|
6
|
+
* static reach, hence `partial`.
|
|
7
|
+
*/
|
|
8
|
+
export declare const noOrientationLock: Rule;
|
|
9
|
+
//# sourceMappingURL=expo-config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"expo-config.d.ts","sourceRoot":"","sources":["../../src/rules/expo-config.ts"],"names":[],"mappings":"AAEA,OAAO,EAAgC,KAAK,IAAI,EAAE,MAAM,2BAA2B,CAAC;AA2HpF;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,EAAE,IAW/B,CAAC"}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { resolveWcag } from '@aishware/react-a11y-core';
|
|
4
|
+
import { helpUrlFor } from '../util.js';
|
|
5
|
+
const RULE_META = {
|
|
6
|
+
id: 'no-orientation-lock',
|
|
7
|
+
description: 'Do not lock the app to a single orientation in project config.',
|
|
8
|
+
severity: 'moderate',
|
|
9
|
+
platforms: ['native'],
|
|
10
|
+
wcag: ['1.3.4'],
|
|
11
|
+
partial: true,
|
|
12
|
+
project: true,
|
|
13
|
+
helpUrl: helpUrlFor('no-orientation-lock'),
|
|
14
|
+
};
|
|
15
|
+
function lineColAt(text, index) {
|
|
16
|
+
const before = text.slice(0, index);
|
|
17
|
+
const line = before.split('\n').length;
|
|
18
|
+
const column = index - before.lastIndexOf('\n');
|
|
19
|
+
return { line, column };
|
|
20
|
+
}
|
|
21
|
+
function read(file) {
|
|
22
|
+
try {
|
|
23
|
+
return fs.readFileSync(file, 'utf8');
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function makeDiagnostic(file, text, index, matchLength, message) {
|
|
30
|
+
const { line, column } = lineColAt(text, index);
|
|
31
|
+
return {
|
|
32
|
+
ruleId: RULE_META.id,
|
|
33
|
+
message,
|
|
34
|
+
severity: RULE_META.severity,
|
|
35
|
+
file,
|
|
36
|
+
line,
|
|
37
|
+
column,
|
|
38
|
+
endLine: line,
|
|
39
|
+
endColumn: column + matchLength,
|
|
40
|
+
wcag: resolveWcag(RULE_META.wcag),
|
|
41
|
+
helpUrl: RULE_META.helpUrl,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const REMEDY = 'WCAG 1.3.4 (AA) requires content to work in both portrait and landscape — users with mounted devices cannot rotate. Lock only if a single orientation is truly essential.';
|
|
45
|
+
function checkExpoJson(root) {
|
|
46
|
+
const file = 'app.json';
|
|
47
|
+
const text = read(path.join(root, file));
|
|
48
|
+
if (!text)
|
|
49
|
+
return [];
|
|
50
|
+
let parsed;
|
|
51
|
+
try {
|
|
52
|
+
parsed = JSON.parse(text);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
const expo = (parsed.expo ?? parsed);
|
|
58
|
+
const orientation = expo?.orientation;
|
|
59
|
+
if (orientation !== 'portrait' && orientation !== 'landscape')
|
|
60
|
+
return [];
|
|
61
|
+
const index = Math.max(text.indexOf('"orientation"'), 0);
|
|
62
|
+
return [
|
|
63
|
+
makeDiagnostic(file, text, index, '"orientation"'.length, `Expo config locks orientation to "${orientation}". ${REMEDY} Use "default" to allow rotation.`),
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
function checkExpoConfigScript(root) {
|
|
67
|
+
for (const name of ['app.config.js', 'app.config.ts', 'app.config.mjs', 'app.config.cjs']) {
|
|
68
|
+
const text = read(path.join(root, name));
|
|
69
|
+
if (!text)
|
|
70
|
+
continue;
|
|
71
|
+
const match = /orientation\s*:\s*['"](portrait|landscape)['"]/.exec(text);
|
|
72
|
+
if (!match)
|
|
73
|
+
continue;
|
|
74
|
+
return [
|
|
75
|
+
makeDiagnostic(name, text, match.index, match[0].length, `Expo config locks orientation to "${match[1]}". ${REMEDY} Use 'default' to allow rotation.`),
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
const ANDROID_MANIFEST = 'android/app/src/main/AndroidManifest.xml';
|
|
81
|
+
const ANDROID_LOCKED = /android:screenOrientation\s*=\s*"(portrait|reversePortrait|sensorPortrait|userPortrait|landscape|reverseLandscape|sensorLandscape|userLandscape)"/;
|
|
82
|
+
function checkAndroidManifest(root) {
|
|
83
|
+
const text = read(path.join(root, ANDROID_MANIFEST));
|
|
84
|
+
if (!text)
|
|
85
|
+
return [];
|
|
86
|
+
const match = ANDROID_LOCKED.exec(text);
|
|
87
|
+
if (!match)
|
|
88
|
+
return [];
|
|
89
|
+
return [
|
|
90
|
+
makeDiagnostic(ANDROID_MANIFEST, text, match.index, match[0].length, `AndroidManifest locks the activity to "${match[1]}". ${REMEDY} Remove android:screenOrientation or use "fullUser".`),
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
function checkIosPlists(root) {
|
|
94
|
+
const iosDir = path.join(root, 'ios');
|
|
95
|
+
let entries;
|
|
96
|
+
try {
|
|
97
|
+
entries = fs.readdirSync(iosDir, { withFileTypes: true });
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
const diagnostics = [];
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
if (!entry.isDirectory())
|
|
105
|
+
continue;
|
|
106
|
+
const rel = `ios/${entry.name}/Info.plist`;
|
|
107
|
+
const text = read(path.join(root, rel));
|
|
108
|
+
if (!text || !text.includes('UISupportedInterfaceOrientations'))
|
|
109
|
+
continue;
|
|
110
|
+
const hasPortrait = text.includes('UIInterfaceOrientationPortrait');
|
|
111
|
+
const hasLandscape = text.includes('UIInterfaceOrientationLandscape');
|
|
112
|
+
if (hasPortrait === hasLandscape)
|
|
113
|
+
continue; // both (fine) or neither (unparseable)
|
|
114
|
+
const locked = hasPortrait ? 'portrait' : 'landscape';
|
|
115
|
+
const index = text.indexOf('UISupportedInterfaceOrientations');
|
|
116
|
+
diagnostics.push(makeDiagnostic(rel, text, index, 'UISupportedInterfaceOrientations'.length, `Info.plist supports only ${locked} orientations. ${REMEDY}`));
|
|
117
|
+
}
|
|
118
|
+
return diagnostics;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* WCAG 1.3.4 Orientation: flags orientation locks wherever React Native
|
|
122
|
+
* projects declare them — Expo app.json / app.config.*, AndroidManifest.xml
|
|
123
|
+
* and iOS Info.plist. Runtime locks (expo-screen-orientation) are out of
|
|
124
|
+
* static reach, hence `partial`.
|
|
125
|
+
*/
|
|
126
|
+
export const noOrientationLock = {
|
|
127
|
+
meta: RULE_META,
|
|
128
|
+
create: () => ({}),
|
|
129
|
+
projectCheck(root) {
|
|
130
|
+
return [
|
|
131
|
+
...checkExpoJson(root),
|
|
132
|
+
...checkExpoConfigScript(root),
|
|
133
|
+
...checkAndroidManifest(root),
|
|
134
|
+
...checkIosPlists(root),
|
|
135
|
+
];
|
|
136
|
+
},
|
|
137
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WCAG 2.4.3 / 1.3.2. `accessible={true}` collapses a view and ALL its children
|
|
3
|
+
* into a single focus stop and concatenates their labels — React Native's docs
|
|
4
|
+
* note a component "cannot be both an accessibility element and an accessibility
|
|
5
|
+
* container". When the grouped subtree contains interactive controls, those
|
|
6
|
+
* controls become unreachable and the reading order silently changes.
|
|
7
|
+
*/
|
|
8
|
+
export declare const accessibleGroupingHidesInteractive: import("@aishware/react-a11y-core").Rule;
|
|
9
|
+
/**
|
|
10
|
+
* WCAG 1.3.2 / 4.1.2. accessibilityLabel/Hint/Value/State describe an
|
|
11
|
+
* accessibility element, but a plain View is not one unless `accessible={true}`
|
|
12
|
+
* is set. Without it the descriptor is dropped and the screen reader reads each
|
|
13
|
+
* child in source order instead of the intended grouped label — a common cause
|
|
14
|
+
* of wrong or overly verbose reading order.
|
|
15
|
+
*/
|
|
16
|
+
export declare const labelNeedsAccessible: import("@aishware/react-a11y-core").Rule;
|
|
17
|
+
//# sourceMappingURL=focus.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"focus.d.ts","sourceRoot":"","sources":["../../src/rules/focus.ts"],"names":[],"mappings":"AAqBA;;;;;;GAMG;AACH,eAAO,MAAM,kCAAkC,0CAoB9C,CAAC;AAKF;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,0CAmBhC,CAAC"}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { attrProvidesValue, hasAttr, isStaticTrue, staticValue, walkDescendants } from '@aishware/react-a11y-core';
|
|
2
|
+
import { defineRule, isHiddenFromAT, isNativeInteractive, isRNComponent } from '../util.js';
|
|
3
|
+
/** Containers whose `accessible` prop groups (or fails to group) their subtree. */
|
|
4
|
+
const GROUPING_CONTAINERS = new Set(['View', 'SafeAreaView']);
|
|
5
|
+
const TAPPABLE = new Set(['Text', 'View', 'Image', 'Pressable']);
|
|
6
|
+
/**
|
|
7
|
+
* A descendant the screen reader would otherwise focus on its own: a native
|
|
8
|
+
* interactive control, an element explicitly marked accessible, or a tappable
|
|
9
|
+
* RN element with onPress.
|
|
10
|
+
*/
|
|
11
|
+
function isInteractiveDescendant(el) {
|
|
12
|
+
if (isNativeInteractive(el))
|
|
13
|
+
return true;
|
|
14
|
+
if (isStaticTrue(el, 'accessible'))
|
|
15
|
+
return true;
|
|
16
|
+
if (isRNComponent(el, TAPPABLE) && hasAttr(el, 'onPress'))
|
|
17
|
+
return true;
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* WCAG 2.4.3 / 1.3.2. `accessible={true}` collapses a view and ALL its children
|
|
22
|
+
* into a single focus stop and concatenates their labels — React Native's docs
|
|
23
|
+
* note a component "cannot be both an accessibility element and an accessibility
|
|
24
|
+
* container". When the grouped subtree contains interactive controls, those
|
|
25
|
+
* controls become unreachable and the reading order silently changes.
|
|
26
|
+
*/
|
|
27
|
+
export const accessibleGroupingHidesInteractive = defineRule({
|
|
28
|
+
id: 'accessible-grouping-hides-interactive',
|
|
29
|
+
description: 'accessible={true} containers must not wrap interactive children.',
|
|
30
|
+
severity: 'serious',
|
|
31
|
+
wcag: ['2.4.3', '4.1.2'],
|
|
32
|
+
}, (el, ctx) => {
|
|
33
|
+
if (!isRNComponent(el, GROUPING_CONTAINERS) || el.hasSpread)
|
|
34
|
+
return;
|
|
35
|
+
if (!isStaticTrue(el, 'accessible'))
|
|
36
|
+
return;
|
|
37
|
+
let found;
|
|
38
|
+
walkDescendants(el, (child) => {
|
|
39
|
+
if (!found && isInteractiveDescendant(child))
|
|
40
|
+
found = child;
|
|
41
|
+
});
|
|
42
|
+
if (!found)
|
|
43
|
+
return;
|
|
44
|
+
ctx.report({
|
|
45
|
+
el,
|
|
46
|
+
message: `<${el.name} accessible> groups its whole subtree into one focus stop, so the <${found.name}> inside is no longer separately focusable and the reading order changes. Remove accessible from the container, or move the grouping off the interactive content.`,
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
/** Descriptor props that only take effect on an accessibility element. */
|
|
50
|
+
const DESCRIPTOR_PROPS = ['accessibilityLabel', 'accessibilityHint', 'accessibilityValue', 'accessibilityState'];
|
|
51
|
+
/**
|
|
52
|
+
* WCAG 1.3.2 / 4.1.2. accessibilityLabel/Hint/Value/State describe an
|
|
53
|
+
* accessibility element, but a plain View is not one unless `accessible={true}`
|
|
54
|
+
* is set. Without it the descriptor is dropped and the screen reader reads each
|
|
55
|
+
* child in source order instead of the intended grouped label — a common cause
|
|
56
|
+
* of wrong or overly verbose reading order.
|
|
57
|
+
*/
|
|
58
|
+
export const labelNeedsAccessible = defineRule({
|
|
59
|
+
id: 'label-needs-accessible',
|
|
60
|
+
description: 'Views with accessibility descriptors must set accessible={true}.',
|
|
61
|
+
severity: 'moderate',
|
|
62
|
+
wcag: ['1.3.2', '4.1.2'],
|
|
63
|
+
}, (el, ctx) => {
|
|
64
|
+
if (!isRNComponent(el, GROUPING_CONTAINERS) || el.hasSpread)
|
|
65
|
+
return;
|
|
66
|
+
if (isStaticTrue(el, 'accessible') || staticValue(el, 'accessible') === false)
|
|
67
|
+
return;
|
|
68
|
+
if (hasAttr(el, 'accessible'))
|
|
69
|
+
return; // dynamic accessible — benefit of the doubt
|
|
70
|
+
if (isHiddenFromAT(el) || hasAttr(el, 'onPress'))
|
|
71
|
+
return;
|
|
72
|
+
const prop = DESCRIPTOR_PROPS.find((p) => attrProvidesValue(el, p) || hasAttr(el, p));
|
|
73
|
+
if (!prop)
|
|
74
|
+
return;
|
|
75
|
+
ctx.report({
|
|
76
|
+
el,
|
|
77
|
+
message: `<${el.name}> sets ${prop} but is not accessible={true}, so the screen reader ignores the descriptor and reads each child separately (a frequent source of wrong reading order). Add accessible={true} to make it a single focus stop.`,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A touchable or input hidden from assistive technology is still tappable —
|
|
3
|
+
* sighted users get a control that screen reader users cannot even discover.
|
|
4
|
+
*/
|
|
5
|
+
export declare const noHiddenInteractive: import("@aishware/react-a11y-core").Rule;
|
|
6
|
+
/** importantForAccessibility (Android) only accepts four values; others are ignored. */
|
|
7
|
+
export declare const validImportantForAccessibility: import("@aishware/react-a11y-core").Rule;
|
|
8
|
+
/**
|
|
9
|
+
* accessibilityElementsHidden hides a subtree on iOS only; importantForAccessibility
|
|
10
|
+
* ="no-hide-descendants" does it on Android only. Using one without the other (and
|
|
11
|
+
* without the unified aria-hidden) leaves the content exposed on the other platform.
|
|
12
|
+
*/
|
|
13
|
+
export declare const hiddenCrossPlatform: import("@aishware/react-a11y-core").Rule;
|
|
14
|
+
//# sourceMappingURL=platform.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"platform.d.ts","sourceRoot":"","sources":["../../src/rules/platform.ts"],"names":[],"mappings":"AASA;;;GAGG;AACH,eAAO,MAAM,mBAAmB,0CAc/B,CAAC;AAIF,wFAAwF;AACxF,eAAO,MAAM,8BAA8B,0CAe1C,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,0CAmB/B,CAAC"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { isStaticTrue, staticString } from '@aishware/react-a11y-core';
|
|
2
|
+
import { androidHidesSubtree, defineRule, iosHidesSubtree, isHiddenFromAT, isNativeInteractive, } from '../util.js';
|
|
3
|
+
/**
|
|
4
|
+
* A touchable or input hidden from assistive technology is still tappable —
|
|
5
|
+
* sighted users get a control that screen reader users cannot even discover.
|
|
6
|
+
*/
|
|
7
|
+
export const noHiddenInteractive = defineRule({
|
|
8
|
+
id: 'no-hidden-interactive',
|
|
9
|
+
description: 'Interactive elements must not be hidden from assistive technology.',
|
|
10
|
+
severity: 'serious',
|
|
11
|
+
wcag: ['4.1.2', '1.3.1'],
|
|
12
|
+
}, (el, ctx) => {
|
|
13
|
+
if (!isNativeInteractive(el) || !isHiddenFromAT(el))
|
|
14
|
+
return;
|
|
15
|
+
ctx.report({
|
|
16
|
+
el,
|
|
17
|
+
message: `<${el.name}> is interactive but hidden from assistive technology — screen reader users cannot discover or operate it. Remove the hiding prop or make the element non-interactive.`,
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
const IMPORTANT_FOR_ACCESSIBILITY_VALUES = new Set(['auto', 'yes', 'no', 'no-hide-descendants']);
|
|
21
|
+
/** importantForAccessibility (Android) only accepts four values; others are ignored. */
|
|
22
|
+
export const validImportantForAccessibility = defineRule({
|
|
23
|
+
id: 'valid-important-for-accessibility',
|
|
24
|
+
description: 'importantForAccessibility must be a value React Native recognizes.',
|
|
25
|
+
severity: 'moderate',
|
|
26
|
+
wcag: ['4.1.2', '1.3.1'],
|
|
27
|
+
}, (el, ctx) => {
|
|
28
|
+
const v = staticString(el, 'importantForAccessibility');
|
|
29
|
+
if (v === undefined || IMPORTANT_FOR_ACCESSIBILITY_VALUES.has(v.trim()))
|
|
30
|
+
return;
|
|
31
|
+
ctx.report({
|
|
32
|
+
el,
|
|
33
|
+
message: `importantForAccessibility="${v}" is not valid (allowed: auto, yes, no, no-hide-descendants) — it is silently ignored on Android.`,
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
/**
|
|
37
|
+
* accessibilityElementsHidden hides a subtree on iOS only; importantForAccessibility
|
|
38
|
+
* ="no-hide-descendants" does it on Android only. Using one without the other (and
|
|
39
|
+
* without the unified aria-hidden) leaves the content exposed on the other platform.
|
|
40
|
+
*/
|
|
41
|
+
export const hiddenCrossPlatform = defineRule({
|
|
42
|
+
id: 'hidden-cross-platform',
|
|
43
|
+
description: 'Hiding a subtree from assistive tech must cover both iOS and Android.',
|
|
44
|
+
severity: 'moderate',
|
|
45
|
+
wcag: ['1.3.1', '4.1.2'],
|
|
46
|
+
}, (el, ctx) => {
|
|
47
|
+
if (el.hasSpread || isStaticTrue(el, 'aria-hidden'))
|
|
48
|
+
return; // aria-hidden covers both
|
|
49
|
+
const ios = iosHidesSubtree(el);
|
|
50
|
+
const android = androidHidesSubtree(el);
|
|
51
|
+
if (ios === android)
|
|
52
|
+
return; // both or neither
|
|
53
|
+
ctx.report({
|
|
54
|
+
el,
|
|
55
|
+
message: ios
|
|
56
|
+
? 'accessibilityElementsHidden hides this subtree on iOS only. Add importantForAccessibility="no-hide-descendants" (or use aria-hidden) so TalkBack hides it on Android too.'
|
|
57
|
+
: 'importantForAccessibility="no-hide-descendants" hides this subtree on Android only. Add accessibilityElementsHidden (or use aria-hidden) so VoiceOver hides it on iOS too.',
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Unknown keys in accessibilityState/accessibilityValue are silently dropped. */
|
|
2
|
+
export declare const accessibilityStateValid: import("@aishware/react-a11y-core").Rule;
|
|
3
|
+
/** Status updates only reach screen readers when the live region value is valid. */
|
|
4
|
+
export declare const liveRegionValid: import("@aishware/react-a11y-core").Rule;
|
|
5
|
+
//# sourceMappingURL=state.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../../src/rules/state.ts"],"names":[],"mappings":"AAMA,kFAAkF;AAClF,eAAO,MAAM,uBAAuB,0CAwBnC,CAAC;AAKF,oFAAoF;AACpF,eAAO,MAAM,eAAe,0CAuB3B,CAAC"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { objectLiteralKeys, staticString } from '@aishware/react-a11y-core';
|
|
2
|
+
import { defineRule } from '../util.js';
|
|
3
|
+
const STATE_KEYS = new Set(['disabled', 'selected', 'checked', 'busy', 'expanded']);
|
|
4
|
+
const VALUE_KEYS = new Set(['min', 'max', 'now', 'text']);
|
|
5
|
+
/** Unknown keys in accessibilityState/accessibilityValue are silently dropped. */
|
|
6
|
+
export const accessibilityStateValid = defineRule({
|
|
7
|
+
id: 'accessibility-state-valid',
|
|
8
|
+
description: 'accessibilityState/accessibilityValue must use the keys React Native supports.',
|
|
9
|
+
severity: 'serious',
|
|
10
|
+
wcag: ['4.1.2'],
|
|
11
|
+
}, (el, ctx) => {
|
|
12
|
+
for (const [attrName, allowed] of [
|
|
13
|
+
['accessibilityState', STATE_KEYS],
|
|
14
|
+
['accessibilityValue', VALUE_KEYS],
|
|
15
|
+
]) {
|
|
16
|
+
const keys = objectLiteralKeys(el, attrName);
|
|
17
|
+
if (!keys)
|
|
18
|
+
continue;
|
|
19
|
+
for (const key of keys) {
|
|
20
|
+
if (!allowed.has(key)) {
|
|
21
|
+
ctx.report({
|
|
22
|
+
el,
|
|
23
|
+
message: `${attrName} key "${key}" is not supported (allowed: ${[...allowed].join(', ')}) — it is silently ignored on device.`,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
const LIVE_REGION_VALUES = new Set(['none', 'polite', 'assertive']);
|
|
30
|
+
const ARIA_LIVE_VALUES = new Set(['off', 'polite', 'assertive']);
|
|
31
|
+
/** Status updates only reach screen readers when the live region value is valid. */
|
|
32
|
+
export const liveRegionValid = defineRule({
|
|
33
|
+
id: 'live-region-valid',
|
|
34
|
+
description: 'accessibilityLiveRegion / aria-live must use a supported value.',
|
|
35
|
+
severity: 'serious',
|
|
36
|
+
wcag: ['4.1.3'],
|
|
37
|
+
}, (el, ctx) => {
|
|
38
|
+
const native = staticString(el, 'accessibilityLiveRegion');
|
|
39
|
+
if (native !== undefined && !LIVE_REGION_VALUES.has(native.trim())) {
|
|
40
|
+
ctx.report({
|
|
41
|
+
el,
|
|
42
|
+
message: `accessibilityLiveRegion="${native}" is not valid (allowed: none, polite, assertive) — status changes won't be announced.`,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
const aria = staticString(el, 'aria-live');
|
|
46
|
+
if (aria !== undefined && !ARIA_LIVE_VALUES.has(aria.trim())) {
|
|
47
|
+
ctx.report({
|
|
48
|
+
el,
|
|
49
|
+
message: `aria-live="${aria}" is not valid (allowed: off, polite, assertive) — status changes won't be announced.`,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Touchables with no label and no children are announced as nothing at all.
|
|
3
|
+
* RN aggregates Text descendants into the accessible name, so any child
|
|
4
|
+
* content is given the benefit of the doubt.
|
|
5
|
+
*/
|
|
6
|
+
export declare const touchableHasLabel: import("@aishware/react-a11y-core").Rule;
|
|
7
|
+
/** Without accessibilityRole, screen readers don't announce touchables as buttons. */
|
|
8
|
+
export declare const touchableHasRole: import("@aishware/react-a11y-core").Rule;
|
|
9
|
+
/** Nested touchables confuse screen readers: only one target is announced. */
|
|
10
|
+
export declare const noNestedTouchables: import("@aishware/react-a11y-core").Rule;
|
|
11
|
+
/**
|
|
12
|
+
* WCAG 2.5.8 (AA, new in 2.2) requires 24px minimum targets; Apple/Google
|
|
13
|
+
* guidelines and WCAG 2.5.5 (AAA) recommend 44pt. Only statically-sized
|
|
14
|
+
* inline styles are checked; hitSlop counts as mitigation.
|
|
15
|
+
*/
|
|
16
|
+
export declare const touchTargetSize: import("@aishware/react-a11y-core").Rule;
|
|
17
|
+
//# sourceMappingURL=touchables.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"touchables.d.ts","sourceRoot":"","sources":["../../src/rules/touchables.ts"],"names":[],"mappings":"AAGA;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,0CAkB7B,CAAC;AAEF,sFAAsF;AACtF,eAAO,MAAM,gBAAgB,0CAgB5B,CAAC;AAEF,8EAA8E;AAC9E,eAAO,MAAM,kBAAkB,0CAgB9B,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,eAAe,0CA2B3B,CAAC"}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { findAncestor, hasAttr, inlineStyleNumber, targetSizeTier } from '@aishware/react-a11y-core';
|
|
2
|
+
import { defineRule, hasNativeLabel, isHiddenFromAT, isTouchable } from '../util.js';
|
|
3
|
+
/**
|
|
4
|
+
* Touchables with no label and no children are announced as nothing at all.
|
|
5
|
+
* RN aggregates Text descendants into the accessible name, so any child
|
|
6
|
+
* content is given the benefit of the doubt.
|
|
7
|
+
*/
|
|
8
|
+
export const touchableHasLabel = defineRule({
|
|
9
|
+
id: 'touchable-has-label',
|
|
10
|
+
description: 'Touchables must have an accessible name (accessibilityLabel or text children).',
|
|
11
|
+
severity: 'critical',
|
|
12
|
+
wcag: ['1.1.1', '4.1.2'],
|
|
13
|
+
}, (el, ctx) => {
|
|
14
|
+
if (!isTouchable(el))
|
|
15
|
+
return;
|
|
16
|
+
if (el.hasSpread || isHiddenFromAT(el))
|
|
17
|
+
return;
|
|
18
|
+
if (hasNativeLabel(el))
|
|
19
|
+
return;
|
|
20
|
+
const hasChildren = el.hasTextChild || el.hasExpressionChild || el.childElements.length > 0;
|
|
21
|
+
if (hasChildren)
|
|
22
|
+
return;
|
|
23
|
+
ctx.report({
|
|
24
|
+
el,
|
|
25
|
+
message: `<${el.name}> has no accessible name — screen readers announce it as an unlabeled button. Add accessibilityLabel or text children.`,
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
/** Without accessibilityRole, screen readers don't announce touchables as buttons. */
|
|
29
|
+
export const touchableHasRole = defineRule({
|
|
30
|
+
id: 'touchable-has-role',
|
|
31
|
+
description: 'Touchables must declare an accessibilityRole so screen readers announce them as actionable.',
|
|
32
|
+
severity: 'serious',
|
|
33
|
+
wcag: ['4.1.2'],
|
|
34
|
+
}, (el, ctx) => {
|
|
35
|
+
if (!isTouchable(el))
|
|
36
|
+
return;
|
|
37
|
+
if (el.hasSpread || isHiddenFromAT(el))
|
|
38
|
+
return;
|
|
39
|
+
if (hasAttr(el, 'accessibilityRole') || hasAttr(el, 'role'))
|
|
40
|
+
return;
|
|
41
|
+
ctx.report({
|
|
42
|
+
el,
|
|
43
|
+
message: `<${el.name}> has no accessibilityRole — VoiceOver/TalkBack won't announce it as a button. Add accessibilityRole="button".`,
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
/** Nested touchables confuse screen readers: only one target is announced. */
|
|
47
|
+
export const noNestedTouchables = defineRule({
|
|
48
|
+
id: 'no-nested-touchables',
|
|
49
|
+
description: 'Touchables must not be nested inside other touchables.',
|
|
50
|
+
severity: 'serious',
|
|
51
|
+
wcag: ['4.1.2', '2.1.1'],
|
|
52
|
+
}, (el, ctx) => {
|
|
53
|
+
if (!isTouchable(el))
|
|
54
|
+
return;
|
|
55
|
+
const ancestor = findAncestor(el, isTouchable);
|
|
56
|
+
if (!ancestor)
|
|
57
|
+
return;
|
|
58
|
+
ctx.report({
|
|
59
|
+
el,
|
|
60
|
+
message: `<${el.name}> is nested inside <${ancestor.name}>. Screen readers expose only one of them — restructure so touch targets are siblings.`,
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
/**
|
|
64
|
+
* WCAG 2.5.8 (AA, new in 2.2) requires 24px minimum targets; Apple/Google
|
|
65
|
+
* guidelines and WCAG 2.5.5 (AAA) recommend 44pt. Only statically-sized
|
|
66
|
+
* inline styles are checked; hitSlop counts as mitigation.
|
|
67
|
+
*/
|
|
68
|
+
export const touchTargetSize = defineRule({
|
|
69
|
+
id: 'touch-target-size',
|
|
70
|
+
description: 'Touch targets should be at least 44×44pt (24px is the WCAG 2.5.8 floor).',
|
|
71
|
+
severity: 'moderate',
|
|
72
|
+
wcag: ['2.5.8', '2.5.5'],
|
|
73
|
+
}, (el, ctx) => {
|
|
74
|
+
if (!isTouchable(el))
|
|
75
|
+
return;
|
|
76
|
+
if (hasAttr(el, 'hitSlop'))
|
|
77
|
+
return;
|
|
78
|
+
const width = inlineStyleNumber(el, 'width');
|
|
79
|
+
const height = inlineStyleNumber(el, 'height');
|
|
80
|
+
if (width === undefined || height === undefined)
|
|
81
|
+
return;
|
|
82
|
+
const tier = targetSizeTier(width, height);
|
|
83
|
+
if (tier === 'below-min') {
|
|
84
|
+
ctx.report({
|
|
85
|
+
el,
|
|
86
|
+
message: `${width}×${height} target is below the WCAG 2.5.8 (AA) minimum of 24px. Aim for 44×44pt, or add hitSlop.`,
|
|
87
|
+
severity: 'serious',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
else if (tier === 'below-recommended') {
|
|
91
|
+
ctx.report({
|
|
92
|
+
el,
|
|
93
|
+
message: `${width}×${height} target is below the recommended 44×44pt (WCAG 2.5.5, Apple HIG, Material). Consider enlarging or adding hitSlop.`,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
});
|
package/dist/util.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ElementNode, Rule, RuleMeta, RuleContext } from '@aishware/react-a11y-core';
|
|
2
|
+
export declare function helpUrlFor(ruleId: string): string | undefined;
|
|
3
|
+
export declare function defineRule(meta: Omit<RuleMeta, 'platforms' | 'helpUrl'>, element: (el: ElementNode, ctx: RuleContext) => void): Rule;
|
|
4
|
+
/**
|
|
5
|
+
* True when `el` is the named React Native component. If the identifier was
|
|
6
|
+
* imported from an unrelated module (a custom wrapper that may handle a11y
|
|
7
|
+
* itself), the element is not matched — keeps false positives down.
|
|
8
|
+
*/
|
|
9
|
+
export declare function isRNComponent(el: ElementNode, names: ReadonlySet<string>): boolean;
|
|
10
|
+
export declare const TOUCHABLES: Set<string>;
|
|
11
|
+
export declare function isTouchable(el: ElementNode): boolean;
|
|
12
|
+
/** A touchable or a native control — the canonical "interactive element" test. */
|
|
13
|
+
export declare function isNativeInteractive(el: ElementNode): boolean;
|
|
14
|
+
/** iOS: accessibilityElementsHidden hides the element and its a11y subtree from VoiceOver. */
|
|
15
|
+
export declare function iosHidesSubtree(el: ElementNode): boolean;
|
|
16
|
+
/** Android: importantForAccessibility="no-hide-descendants" hides the subtree from TalkBack. */
|
|
17
|
+
export declare function androidHidesSubtree(el: ElementNode): boolean;
|
|
18
|
+
/** Element is hidden from assistive technology by any platform's mechanism. */
|
|
19
|
+
export declare function isHiddenFromAT(el: ElementNode): boolean;
|
|
20
|
+
/** Any of the label-bearing props provides a usable value. */
|
|
21
|
+
export declare function hasNativeLabel(el: ElementNode): boolean;
|
|
22
|
+
//# 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,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAE7D;AAED,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;AAKD;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,EAAE,EAAE,WAAW,EAAE,KAAK,EAAE,WAAW,CAAC,MAAM,CAAC,GAAG,OAAO,CAGlF;AAED,eAAO,MAAM,UAAU,aAMrB,CAAC;AAEH,wBAAgB,WAAW,CAAC,EAAE,EAAE,WAAW,GAAG,OAAO,CAEpD;AAKD,kFAAkF;AAClF,wBAAgB,mBAAmB,CAAC,EAAE,EAAE,WAAW,GAAG,OAAO,CAE5D;AAED,8FAA8F;AAC9F,wBAAgB,eAAe,CAAC,EAAE,EAAE,WAAW,GAAG,OAAO,CAExD;AAED,gGAAgG;AAChG,wBAAgB,mBAAmB,CAAC,EAAE,EAAE,WAAW,GAAG,OAAO,CAE5D;AAED,+EAA+E;AAC/E,wBAAgB,cAAc,CAAC,EAAE,EAAE,WAAW,GAAG,OAAO,CAMvD;AAED,8DAA8D;AAC9D,wBAAgB,cAAc,CAAC,EAAE,EAAE,WAAW,GAAG,OAAO,CAOvD"}
|
package/dist/util.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { attrProvidesValue, isStaticTrue, readOwnPackageMeta, staticString } from '@aishware/react-a11y-core';
|
|
2
|
+
const homepage = readOwnPackageMeta(import.meta.url).homepage;
|
|
3
|
+
const HELP_BASE = homepage ? `${homepage}/blob/main/docs/rules/native.md#` : undefined;
|
|
4
|
+
export function helpUrlFor(ruleId) {
|
|
5
|
+
return HELP_BASE ? `${HELP_BASE}${ruleId}` : undefined;
|
|
6
|
+
}
|
|
7
|
+
export function defineRule(meta, element) {
|
|
8
|
+
return {
|
|
9
|
+
meta: { ...meta, platforms: ['native'], ...(HELP_BASE ? { helpUrl: `${HELP_BASE}${meta.id}` } : {}) },
|
|
10
|
+
create(ctx) {
|
|
11
|
+
return { element: (el) => element(el, ctx) };
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/** Module specifiers we trust to export the stock RN components. */
|
|
16
|
+
const RN_SOURCES = new Set(['react-native', 'react-native-web', 'react-native-gesture-handler']);
|
|
17
|
+
/**
|
|
18
|
+
* True when `el` is the named React Native component. If the identifier was
|
|
19
|
+
* imported from an unrelated module (a custom wrapper that may handle a11y
|
|
20
|
+
* itself), the element is not matched — keeps false positives down.
|
|
21
|
+
*/
|
|
22
|
+
export function isRNComponent(el, names) {
|
|
23
|
+
if (!el.isComponent || !names.has(el.name))
|
|
24
|
+
return false;
|
|
25
|
+
return el.importSource === null || RN_SOURCES.has(el.importSource);
|
|
26
|
+
}
|
|
27
|
+
export const TOUCHABLES = new Set([
|
|
28
|
+
'Pressable',
|
|
29
|
+
'TouchableOpacity',
|
|
30
|
+
'TouchableHighlight',
|
|
31
|
+
'TouchableWithoutFeedback',
|
|
32
|
+
'TouchableNativeFeedback',
|
|
33
|
+
]);
|
|
34
|
+
export function isTouchable(el) {
|
|
35
|
+
return isRNComponent(el, TOUCHABLES);
|
|
36
|
+
}
|
|
37
|
+
/** Stock React Native controls that are interactive on their own. */
|
|
38
|
+
const NATIVE_CONTROLS = new Set(['TextInput', 'Switch', 'Button']);
|
|
39
|
+
/** A touchable or a native control — the canonical "interactive element" test. */
|
|
40
|
+
export function isNativeInteractive(el) {
|
|
41
|
+
return isTouchable(el) || isRNComponent(el, NATIVE_CONTROLS);
|
|
42
|
+
}
|
|
43
|
+
/** iOS: accessibilityElementsHidden hides the element and its a11y subtree from VoiceOver. */
|
|
44
|
+
export function iosHidesSubtree(el) {
|
|
45
|
+
return isStaticTrue(el, 'accessibilityElementsHidden');
|
|
46
|
+
}
|
|
47
|
+
/** Android: importantForAccessibility="no-hide-descendants" hides the subtree from TalkBack. */
|
|
48
|
+
export function androidHidesSubtree(el) {
|
|
49
|
+
return staticString(el, 'importantForAccessibility') === 'no-hide-descendants';
|
|
50
|
+
}
|
|
51
|
+
/** Element is hidden from assistive technology by any platform's mechanism. */
|
|
52
|
+
export function isHiddenFromAT(el) {
|
|
53
|
+
if (isStaticTrue(el, 'aria-hidden') || iosHidesSubtree(el))
|
|
54
|
+
return true;
|
|
55
|
+
const important = staticString(el, 'importantForAccessibility');
|
|
56
|
+
if (important === 'no' || important === 'no-hide-descendants')
|
|
57
|
+
return true;
|
|
58
|
+
const role = staticString(el, 'accessibilityRole') ?? staticString(el, 'role');
|
|
59
|
+
return role === 'none' || role === 'presentation';
|
|
60
|
+
}
|
|
61
|
+
/** Any of the label-bearing props provides a usable value. */
|
|
62
|
+
export function hasNativeLabel(el) {
|
|
63
|
+
return (attrProvidesValue(el, 'accessibilityLabel') ||
|
|
64
|
+
attrProvidesValue(el, 'aria-label') ||
|
|
65
|
+
attrProvidesValue(el, 'accessibilityLabelledBy') ||
|
|
66
|
+
attrProvidesValue(el, 'aria-labelledby'));
|
|
67
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aishware/react-a11y-rules-native",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Accessibility rules for React Native and Expo, built on the shared @aishware/react-a11y-core engine.",
|
|
5
|
+
"keywords": ["accessibility", "a11y", "wcag", "react-native", "expo", "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
|
+
"typescript": "^5.6.0"
|
|
29
|
+
},
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"homepage": "https://github.com/1aishwaryasharma/react-a11y",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/1aishwaryasharma/react-a11y.git",
|
|
35
|
+
"directory": "packages/rules-native"
|
|
36
|
+
}
|
|
37
|
+
}
|