Skip to content

Commit 00f0667

Browse files
authored
Merge pull request #84381 from ShridharGoel/accessibilityLint
[NoQA] Enable linting for accessibility
2 parents 96365e1 + c3bee62 commit 00f0667

37 files changed

Lines changed: 563 additions & 7 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
const name = 'require-a11y-disable-justification';
2+
3+
const ISSUE_URL_REGEX = /https?:\/\/github\.com\/(?:Expensify\/App|FormidableLabs\/eslint-plugin-react-native-a11y)\/(?:issues|pull)\/\d+/i;
4+
const ISSUE_URL_GLOBAL_REGEX = /https?:\/\/github\.com\/(?:Expensify\/App|FormidableLabs\/eslint-plugin-react-native-a11y)\/(?:issues|pull)\/\d+/gi;
5+
const DISABLE_A11Y_REGEX = /eslint-disable(?:-next-line|-line)?\s+[\s\S]*?react-native-a11y\//i;
6+
const MIN_RATIONALE_LENGTH = 12;
7+
8+
const meta = {
9+
type: 'problem',
10+
docs: {
11+
description: 'Require react-native-a11y eslint-disable comments to include rationale or tracking issue link.',
12+
recommended: 'error',
13+
},
14+
schema: [],
15+
messages: {
16+
missingIssueOrRationale: 'react-native-a11y eslint-disable comments must include rationale or tracking issue link.',
17+
},
18+
};
19+
20+
function normalizeComment(comment) {
21+
return comment.value
22+
.split('\n')
23+
.map((line) => line.replace(/^\s*\*+\s?/, '').trim())
24+
.join(' ')
25+
.replaceAll(/\s+/g, ' ')
26+
.trim();
27+
}
28+
29+
function hasRationale(commentText) {
30+
const strippedComment = commentText
31+
.replaceAll(/eslint-disable(?:-next-line|-line)?/gi, ' ')
32+
.replaceAll(/[a-z0-9-]+\/[a-z0-9-]+/gi, ' ')
33+
.replaceAll(ISSUE_URL_GLOBAL_REGEX, ' ')
34+
.replaceAll('--', ' ')
35+
.replaceAll(/[,*]/g, ' ')
36+
.replaceAll(/\s+/g, ' ')
37+
.trim();
38+
39+
return strippedComment.length >= MIN_RATIONALE_LENGTH;
40+
}
41+
42+
function create(context) {
43+
const sourceCode = context.getSourceCode();
44+
45+
return {
46+
Program() {
47+
for (const comment of sourceCode.getAllComments()) {
48+
const commentText = normalizeComment(comment);
49+
if (!DISABLE_A11Y_REGEX.test(commentText)) {
50+
continue;
51+
}
52+
53+
const hasIssueLink = ISSUE_URL_REGEX.test(commentText);
54+
const hasDisableRationale = hasRationale(commentText);
55+
56+
if (!hasIssueLink && !hasDisableRationale) {
57+
context.report({
58+
loc: comment.loc,
59+
messageId: 'missingIssueOrRationale',
60+
});
61+
}
62+
}
63+
},
64+
};
65+
}
66+
67+
export {name, meta, create};
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
const name = 'require-live-region-for-status-updates';
2+
3+
const STATUS_ROLES = new Set(['alert', 'status']);
4+
5+
const meta = {
6+
type: 'problem',
7+
docs: {
8+
description: 'Require role/accessibilityRole and accessibilityLiveRegion to be used together for status updates.',
9+
recommended: 'error',
10+
},
11+
schema: [],
12+
messages: {
13+
missingLiveRegion: 'Elements with role/accessibilityRole set to "alert" or "status" must include accessibilityLiveRegion so updates are announced.',
14+
missingStatusRole: 'Elements with accessibilityLiveRegion must include role/accessibilityRole set to "alert" or "status" so assistive technologies treat updates as status messages.',
15+
invalidLiveRegion: 'Use accessibilityLiveRegion="polite" or "assertive" for status updates; "none" suppresses announcements.',
16+
},
17+
};
18+
19+
function getJSXAttribute(node, names) {
20+
return node.attributes.find((attribute) => attribute.type === 'JSXAttribute' && names.includes(attribute.name?.name));
21+
}
22+
23+
function getAttributeStringValue(attribute) {
24+
if (!attribute?.value) {
25+
return null;
26+
}
27+
28+
if (attribute.value.type === 'Literal' && typeof attribute.value.value === 'string') {
29+
return attribute.value.value;
30+
}
31+
32+
if (attribute.value.type !== 'JSXExpressionContainer') {
33+
return null;
34+
}
35+
36+
if (attribute.value.expression.type === 'Literal' && typeof attribute.value.expression.value === 'string') {
37+
return attribute.value.expression.value;
38+
}
39+
40+
return null;
41+
}
42+
43+
function getConstRoleValue(attribute) {
44+
if (attribute?.value?.type !== 'JSXExpressionContainer') {
45+
return null;
46+
}
47+
48+
return getConstRoleValueFromExpression(attribute.value.expression);
49+
}
50+
51+
function getConstRoleValueFromExpression(expression) {
52+
if (expression.type !== 'MemberExpression' || expression.computed || expression.property.type !== 'Identifier') {
53+
return null;
54+
}
55+
56+
if (
57+
expression.object.type !== 'MemberExpression' ||
58+
expression.object.computed ||
59+
expression.object.object.type !== 'Identifier' ||
60+
expression.object.object.name !== 'CONST' ||
61+
expression.object.property.type !== 'Identifier' ||
62+
expression.object.property.name !== 'ROLE'
63+
) {
64+
return null;
65+
}
66+
67+
return expression.property.name.toLowerCase();
68+
}
69+
70+
function getResolvedRolesFromExpression(expression) {
71+
if (expression.type === 'Literal' && typeof expression.value === 'string') {
72+
return new Set([expression.value.toLowerCase()]);
73+
}
74+
75+
if (expression.type === 'Identifier' && expression.name === 'undefined') {
76+
return new Set();
77+
}
78+
79+
if (expression.type === 'MemberExpression') {
80+
const constRoleValue = getConstRoleValueFromExpression(expression);
81+
return constRoleValue ? new Set([constRoleValue]) : null;
82+
}
83+
84+
if (expression.type === 'ConditionalExpression') {
85+
const consequentRoles = getResolvedRolesFromExpression(expression.consequent);
86+
const alternateRoles = getResolvedRolesFromExpression(expression.alternate);
87+
88+
if (!consequentRoles || !alternateRoles) {
89+
return null;
90+
}
91+
92+
return new Set([...consequentRoles, ...alternateRoles]);
93+
}
94+
95+
if (expression.type === 'LogicalExpression' && expression.operator === '&&') {
96+
const rightRoles = getResolvedRolesFromExpression(expression.right);
97+
98+
if (!rightRoles) {
99+
return null;
100+
}
101+
102+
return rightRoles;
103+
}
104+
105+
return null;
106+
}
107+
108+
function getResolvedRoles(attribute) {
109+
if (!attribute?.value) {
110+
return new Set();
111+
}
112+
113+
if (attribute.value.type === 'Literal' && typeof attribute.value.value === 'string') {
114+
return new Set([attribute.value.value.toLowerCase()]);
115+
}
116+
117+
if (attribute.value.type !== 'JSXExpressionContainer') {
118+
return null;
119+
}
120+
121+
return getResolvedRolesFromExpression(attribute.value.expression);
122+
}
123+
124+
function getResolvedRole(attribute) {
125+
return getAttributeStringValue(attribute)?.toLowerCase() ?? getConstRoleValue(attribute);
126+
}
127+
128+
function create(context) {
129+
return {
130+
JSXOpeningElement(node) {
131+
const roleAttribute = getJSXAttribute(node, ['role', 'accessibilityRole']);
132+
const liveRegionAttribute = getJSXAttribute(node, ['accessibilityLiveRegion']);
133+
134+
const resolvedRole = getResolvedRole(roleAttribute);
135+
const resolvedRoles = getResolvedRoles(roleAttribute);
136+
const hasKnownStatusRole = STATUS_ROLES.has(resolvedRole);
137+
const hasRoleAttribute = !!roleAttribute;
138+
const hasLiveRegion = !!liveRegionAttribute;
139+
const liveRegionValue = getAttributeStringValue(liveRegionAttribute)?.toLowerCase();
140+
const hasAnyResolvedStatusRole = !!resolvedRoles && [...resolvedRoles].some((role) => STATUS_ROLES.has(role));
141+
const hasOnlyResolvedNonStatusRoles = !!resolvedRoles && resolvedRoles.size > 0 && !hasAnyResolvedStatusRole;
142+
143+
if ((hasKnownStatusRole || hasAnyResolvedStatusRole) && !hasLiveRegion) {
144+
context.report({
145+
node,
146+
messageId: 'missingLiveRegion',
147+
});
148+
return;
149+
}
150+
151+
if (hasLiveRegion && (!hasRoleAttribute || hasOnlyResolvedNonStatusRoles)) {
152+
context.report({
153+
node,
154+
messageId: 'missingStatusRole',
155+
});
156+
return;
157+
}
158+
159+
if ((hasKnownStatusRole || hasAnyResolvedStatusRole) && liveRegionValue === 'none') {
160+
context.report({
161+
node,
162+
messageId: 'invalidLiveRegion',
163+
});
164+
}
165+
},
166+
};
167+
}
168+
169+
export {name, meta, create};

eslint.config.mjs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ import jsdoc from 'eslint-plugin-jsdoc';
66
import lodash from 'eslint-plugin-lodash';
77
import react from 'eslint-plugin-react';
88
import reactNativeA11Y from 'eslint-plugin-react-native-a11y';
9+
import rulesdir from 'eslint-plugin-rulesdir';
910
import testingLibrary from 'eslint-plugin-testing-library';
1011
import youDontNeedLodashUnderscore from 'eslint-plugin-you-dont-need-lodash-underscore';
1112
import {defineConfig, globalIgnores} from 'eslint/config';
1213
import globals from 'globals';
14+
import {createRequire} from 'node:module';
1315
import path from 'node:path';
1416
import {fileURLToPath} from 'node:url';
1517
import typescriptEslint from 'typescript-eslint';
@@ -18,6 +20,12 @@ import reportNameUtilsPlugin from './eslint-plugin-report-name-utils/index.mjs';
1820

1921
const filename = fileURLToPath(import.meta.url);
2022
const dirname = path.dirname(filename);
23+
const require = createRequire(import.meta.url);
24+
const expensifyConfigDirectory = path.dirname(require.resolve('eslint-config-expensify/package.json'));
25+
const expensifyRulesDir = path.resolve(expensifyConfigDirectory, 'eslint-plugin-expensify');
26+
const localRulesDir = path.resolve(dirname, 'eslint-plugin-local-rules');
27+
28+
rulesdir.RULES_DIR = [expensifyRulesDir, localRulesDir];
2129

2230
const restrictedImportPaths = [
2331
{
@@ -170,7 +178,7 @@ const config = defineConfig([
170178
extends: new FlatCompat({baseDirectory: dirname}).extends(
171179
'airbnb-typescript',
172180
'plugin:storybook/recommended',
173-
'plugin:react-native-a11y/basic',
181+
'plugin:react-native-a11y/all',
174182
'plugin:@dword-design/import-alias/recommended',
175183
'plugin:you-dont-need-lodash-underscore/all',
176184
'prettier',
@@ -286,6 +294,8 @@ const config = defineConfig([
286294
'rulesdir/prefer-underscore-method': 'off',
287295
'rulesdir/prefer-import-module-contents': 'off',
288296
'rulesdir/no-beta-handler': 'error',
297+
'rulesdir/require-live-region-for-status-updates': 'error',
298+
'rulesdir/require-a11y-disable-justification': 'error',
289299
'rulesdir/prefer-narrow-hook-dependencies': [
290300
'error',
291301
{
@@ -299,7 +309,8 @@ const config = defineConfig([
299309
],
300310

301311
// React and React Native specific rules
302-
'react-native-a11y/has-accessibility-hint': ['off'],
312+
'react-native-a11y/has-accessibility-hint': 'off',
313+
'react-native-a11y/has-valid-accessibility-ignores-invert-colors': 'error',
303314
'react/require-default-props': 'off',
304315
'react/prop-types': 'off',
305316
'react/jsx-key': 'error',

src/CONST/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6140,6 +6140,8 @@ const CONST = {
61406140
ROLE: {
61416141
/** Use for elements with important, time-sensitive information. */
61426142
ALERT: 'alert',
6143+
/** Use for elements with advisory information that should be announced without interrupting the user. */
6144+
STATUS: 'status',
61436145
/** Use for elements that act as buttons. */
61446146
BUTTON: 'button',
61456147
/** Use for elements representing checkboxes. */
@@ -8827,6 +8829,11 @@ const CONST = {
88278829
SEND_BUTTON: 'AttachmentModal-SendButton',
88288830
IMAGE_ZOOM: 'AttachmentModal-ImageZoom',
88298831
},
8832+
ATTACHMENT_PREVIEW: {
8833+
VIDEO_THUMBNAIL: 'AttachmentPreview-VideoThumbnail',
8834+
IMAGE_THUMBNAIL: 'AttachmentPreview-ImageThumbnail',
8835+
PDF_THUMBNAIL: 'AttachmentPreview-PDFThumbnail',
8836+
},
88308837
HEADER: {
88318838
BACK_BUTTON: 'Header-BackButton',
88328839
DOWNLOAD_BUTTON: 'Header-DownloadButton',
@@ -9582,6 +9589,9 @@ const CONST = {
95829589
PROFILE_PAGE: {
95839590
AVATAR: 'ProfilePage-Avatar',
95849591
},
9592+
BASE_AUTO_COMPLETE_SUGGESTIONS: {
9593+
MENU_ITEM: 'BaseAutoCompleteSuggestions-MenuItem',
9594+
},
95859595
SAFE_AREA: {
95869596
DISMISS_KEYBOARD_LANDSCAPE_MODE: 'SafeArea-DismissKeyboardLandscapeMode',
95879597
},

src/components/AttachmentPreview.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
88
import useThemeStyles from '@hooks/useThemeStyles';
99
import {cleanFileName, getFileName} from '@libs/fileDownload/FileUtils';
1010
import variables from '@styles/variables';
11+
import CONST from '@src/CONST';
1112
import {checkIsFileImage} from './Attachments/AttachmentView';
1213
import DefaultAttachmentView from './Attachments/AttachmentView/DefaultAttachmentView';
1314
import Icon from './Icon';
@@ -63,8 +64,10 @@ function AttachmentPreview({source, aspectRatio = 1, onPress, onLoadError}: Atta
6364
onPress={onPress}
6465
accessible
6566
accessibilityLabel="Attachment Thumbnail"
67+
sentryLabel={CONST.SENTRY_LABEL.ATTACHMENT_PREVIEW.VIDEO_THUMBNAIL}
6668
>
6769
{!!thumbnail && (
70+
/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-ignores-invert-colors -- Custom Image wrapper does not support this prop. */
6871
<Image
6972
source={thumbnail}
7073
style={[fillStyle, {aspectRatio}]}
@@ -93,8 +96,10 @@ function AttachmentPreview({source, aspectRatio = 1, onPress, onLoadError}: Atta
9396
onPress={onPress}
9497
accessible
9598
accessibilityLabel="Image Thumbnail"
99+
sentryLabel={CONST.SENTRY_LABEL.ATTACHMENT_PREVIEW.IMAGE_THUMBNAIL}
96100
>
97101
<View style={[fillStyle, styles.br4, styles.overflowHidden, {aspectRatio}]}>
102+
{/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-ignores-invert-colors -- Custom Image wrapper does not support this prop. */}
98103
<Image
99104
source={{uri: source}}
100105
style={[[styles.w100, styles.h100], styles.overflowHidden]}
@@ -112,6 +117,7 @@ function AttachmentPreview({source, aspectRatio = 1, onPress, onLoadError}: Atta
112117
onPress={onPress}
113118
accessible
114119
accessibilityLabel="PDF Thumbnail"
120+
sentryLabel={CONST.SENTRY_LABEL.ATTACHMENT_PREVIEW.PDF_THUMBNAIL}
115121
>
116122
<PDFThumbnail
117123
fitPolicy={1}

src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ function BaseAutoCompleteSuggestions<TSuggestion>({
4141
onLongPress={() => {}}
4242
accessibilityLabel={accessibilityLabelExtractor(item, index)}
4343
role={CONST.ROLE.MENUITEM}
44+
sentryLabel={CONST.SENTRY_LABEL.BASE_AUTO_COMPLETE_SUGGESTIONS.MENU_ITEM}
4445
>
4546
{renderSuggestionMenuItem(item, index)}
4647
</PressableWithFeedback>

src/components/Avatar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ function Avatar({
118118
>
119119
{typeof avatarSource === 'string' ? (
120120
<View style={[iconStyle, StyleUtils.getAvatarBorderStyle(size, type), iconAdditionalStyles]}>
121+
{/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-ignores-invert-colors -- Custom Image wrapper does not support this prop. */}
121122
<Image
122123
source={{uri: avatarSource}}
123124
style={imageStyle}

src/components/DisplayNames/DisplayNamesWithTooltip.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ function DisplayNamesWithToolTip({
2020
numberOfLines = 1,
2121
renderAdditionalText,
2222
forwardedFSClass,
23+
accessibilityLabel,
2324
}: DisplayNamesProps) {
2425
const styles = useThemeStyles();
2526
const containerRef = useRef<HTMLElementWithText>(null);
@@ -59,6 +60,7 @@ function DisplayNamesWithToolTip({
5960
return (
6061
// Tokenization of string only support prop numberOfLines on Web
6162
<Text
63+
accessibilityLabel={accessibilityLabel}
6264
style={[textStyles, styles.pRelative]}
6365
numberOfLines={numberOfLines || undefined}
6466
ref={containerRef}

0 commit comments

Comments
 (0)