diff options
author | Ollie H <renahlee@outlook.com> | 2023-05-01 18:38:47 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-05-01 20:38:47 -0500 |
commit | 83959c595d52ceb7aa4e3f68441c5ac41c389ebc (patch) | |
tree | 3385d9a16e90fc8d5290ebdef104f922c17642a9 /src | |
parent | c75c888de2407d3314cad07989174201313facaa (diff) | |
download | voidsky-83959c595d52ceb7aa4e3f68441c5ac41c389ebc.tar.zst |
React Native accessibility (#539)
* React Native accessibility * First round of changes * Latest update * Checkpoint * Wrap up * Lint * Remove unhelpful image hints * Fix navigation * Fix rebase and lint * Mitigate an known issue with the password entry in login * Fix composer dismiss * Remove focus on input elements for web * Remove i and npm * pls work * Remove stray declaration * Regenerate yarn.lock --------- Co-authored-by: Paul Frazee <pfrazee@gmail.com>
Diffstat (limited to 'src')
81 files changed, 1241 insertions, 709 deletions
diff --git a/src/lib/strings/display-names.ts b/src/lib/strings/display-names.ts index 5b58dec3d..555151b55 100644 --- a/src/lib/strings/display-names.ts +++ b/src/lib/strings/display-names.ts @@ -6,7 +6,7 @@ const CHECK_MARKS_RE = /[\u2705\u2713\u2714\u2611]/gu export function sanitizeDisplayName(str: string): string { if (typeof str === 'string') { - return str.replace(CHECK_MARKS_RE, '') + return str.replace(CHECK_MARKS_RE, '').trim() } return '' } diff --git a/src/lib/styles.ts b/src/lib/styles.ts index 37d169679..1ff2d520d 100644 --- a/src/lib/styles.ts +++ b/src/lib/styles.ts @@ -118,6 +118,7 @@ export const s = StyleSheet.create({ mr2: {marginRight: 2}, mr5: {marginRight: 5}, mr10: {marginRight: 10}, + mr20: {marginRight: 20}, ml2: {marginLeft: 2}, ml5: {marginLeft: 5}, ml10: {marginLeft: 10}, @@ -149,6 +150,7 @@ export const s = StyleSheet.create({ pb5: {paddingBottom: 5}, pb10: {paddingBottom: 10}, pb20: {paddingBottom: 20}, + px5: {paddingHorizontal: 5}, // flex flexRow: {flexDirection: 'row'}, diff --git a/src/view/com/auth/SplashScreen.tsx b/src/view/com/auth/SplashScreen.tsx index f98bed120..41787bb5f 100644 --- a/src/view/com/auth/SplashScreen.tsx +++ b/src/view/com/auth/SplashScreen.tsx @@ -28,7 +28,10 @@ export const SplashScreen = ({ <TouchableOpacity testID="createAccountButton" style={[styles.btn, {backgroundColor: colors.blue3}]} - onPress={onPressCreateAccount}> + onPress={onPressCreateAccount} + accessibilityRole="button" + accessibilityLabel="Create new account" + accessibilityHint="Opens flow to create a new Bluesky account"> <Text style={[s.white, styles.btnLabel]}> Create a new account </Text> @@ -36,7 +39,10 @@ export const SplashScreen = ({ <TouchableOpacity testID="signInButton" style={[styles.btn, pal.btn]} - onPress={onPressSignin}> + onPress={onPressSignin} + accessibilityRole="button" + accessibilityLabel="Sign in" + accessibilityHint="Opens flow to sign into your existing Bluesky account"> <Text style={[pal.text, styles.btnLabel]}>Sign in</Text> </TouchableOpacity> </View> diff --git a/src/view/com/auth/SplashScreen.web.tsx b/src/view/com/auth/SplashScreen.web.tsx index 7fac5a8c0..9236968c4 100644 --- a/src/view/com/auth/SplashScreen.web.tsx +++ b/src/view/com/auth/SplashScreen.web.tsx @@ -43,7 +43,9 @@ export const SplashScreen = ({ <TouchableOpacity testID="createAccountButton" style={[styles.btn, {backgroundColor: colors.blue3}]} - onPress={onPressCreateAccount}> + onPress={onPressCreateAccount} + // TODO: web accessibility + accessibilityRole="button"> <Text style={[s.white, styles.btnLabel]}> Create a new account </Text> @@ -51,7 +53,9 @@ export const SplashScreen = ({ <TouchableOpacity testID="signInButton" style={[styles.btn, pal.btn]} - onPress={onPressSignin}> + onPress={onPressSignin} + // TODO: web accessibility + accessibilityRole="button"> <Text style={[pal.text, styles.btnLabel]}>Sign in</Text> </TouchableOpacity> </View> @@ -60,7 +64,10 @@ export const SplashScreen = ({ style={[styles.notice, pal.textLight]} lineHeight={1.3}> Bluesky will launch soon.{' '} - <TouchableOpacity onPress={onPressWaitlist}> + <TouchableOpacity + onPress={onPressWaitlist} + // TODO: web accessibility + accessibilityRole="button"> <Text type="xl" style={pal.link}> Join the waitlist </Text> diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx index 467b87948..ac03081df 100644 --- a/src/view/com/auth/create/CreateAccount.tsx +++ b/src/view/com/auth/create/CreateAccount.tsx @@ -72,14 +72,24 @@ export const CreateAccount = observer( {model.step === 3 && <Step3 model={model} />} </View> <View style={[s.flexRow, s.pl20, s.pr20]}> - <TouchableOpacity onPress={onPressBackInner} testID="backBtn"> + <TouchableOpacity + onPress={onPressBackInner} + testID="backBtn" + accessibilityRole="button" + accessibilityLabel="Go back" + accessibilityHint="Navigates to the previous screen"> <Text type="xl" style={pal.link}> Back </Text> </TouchableOpacity> <View style={s.flex1} /> {model.canNext ? ( - <TouchableOpacity testID="nextBtn" onPress={onPressNext}> + <TouchableOpacity + testID="nextBtn" + onPress={onPressNext} + accessibilityRole="button" + accessibilityLabel="Go to next" + accessibilityHint="Navigates to the next screen"> {model.isProcessing ? ( <ActivityIndicator /> ) : ( @@ -91,7 +101,11 @@ export const CreateAccount = observer( ) : model.didServiceDescriptionFetchFail ? ( <TouchableOpacity testID="retryConnectBtn" - onPress={onPressRetryConnect}> + onPress={onPressRetryConnect} + accessibilityRole="button" + accessibilityLabel="Retry" + accessibilityHint="Retries account creation" + accessibilityLiveRegion="polite"> <Text type="xl-bold" style={[pal.link, s.pr5]}> Retry </Text> diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx index ca964ede2..ac0d706d7 100644 --- a/src/view/com/auth/create/Step1.tsx +++ b/src/view/com/auth/create/Step1.tsx @@ -57,7 +57,7 @@ export const Step1 = observer(({model}: {model: CreateAccountModel}) => { <View> <StepHeader step="1" title="Your hosting provider" /> <Text style={[pal.text, s.mb10]}> - This is the company that keeps you online. + This is the service that keeps you online. </Text> <Option testID="blueskyServerBtn" @@ -72,7 +72,7 @@ export const Step1 = observer(({model}: {model: CreateAccountModel}) => { label="Other" onPress={onPressOther}> <View style={styles.otherForm}> - <Text style={[pal.text, s.mb5]}> + <Text nativeID="addressProvider" style={[pal.text, s.mb5]}> Enter the address of your provider: </Text> <TextInput @@ -82,6 +82,9 @@ export const Step1 = observer(({model}: {model: CreateAccountModel}) => { value={model.serviceUrl} editable onChange={onChangeServiceUrl} + accessibilityHint="Input hosting provider address" + accessibilityLabel="Hosting provider address" + accessibilityLabelledBy="addressProvider" /> {LOGIN_INCLUDE_DEV_SERVERS && ( <View style={[s.flexRow, s.mt10]}> @@ -136,7 +139,12 @@ function Option({ return ( <View style={[styles.option, pal.border]}> - <TouchableWithoutFeedback onPress={onPress} testID={testID}> + <TouchableWithoutFeedback + onPress={onPress} + testID={testID} + accessibilityRole="button" + accessibilityLabel={label} + accessibilityHint={`Sets hosting provider to ${label}`}> <View style={styles.optionHeading}> <View style={[styles.circle, pal.border]}> {isSelected ? ( diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx index 375f80796..eceee50d3 100644 --- a/src/view/com/auth/create/Step2.tsx +++ b/src/view/com/auth/create/Step2.tsx @@ -41,6 +41,9 @@ export const Step2 = observer(({model}: {model: CreateAccountModel}) => { value={model.inviteCode} editable onChange={model.setInviteCode} + accessibilityRole="button" + accessibilityLabel="Invite code" + accessibilityHint="Input invite code to proceed" /> </View> )} @@ -48,7 +51,11 @@ export const Step2 = observer(({model}: {model: CreateAccountModel}) => { {!model.inviteCode && model.isInviteCodeRequired ? ( <Text style={[s.alignBaseline, pal.text]}> Don't have an invite code?{' '} - <TouchableWithoutFeedback onPress={onPressWaitlist}> + <TouchableWithoutFeedback + onPress={onPressWaitlist} + accessibilityRole="button" + accessibilityLabel="Waitlist" + accessibilityHint="Opens Bluesky waitlist form"> <Text style={pal.link}>Join the waitlist</Text> </TouchableWithoutFeedback>{' '} to try the beta before it's publicly available. @@ -56,7 +63,7 @@ export const Step2 = observer(({model}: {model: CreateAccountModel}) => { ) : ( <> <View style={s.pb20}> - <Text type="md-medium" style={[pal.text, s.mb2]}> + <Text type="md-medium" style={[pal.text, s.mb2]} nativeID="email"> Email address </Text> <TextInput @@ -66,11 +73,17 @@ export const Step2 = observer(({model}: {model: CreateAccountModel}) => { value={model.email} editable onChange={model.setEmail} + accessibilityLabel="Email" + accessibilityHint="Input email for Bluesky waitlist" + accessibilityLabelledBy="email" /> </View> <View style={s.pb20}> - <Text type="md-medium" style={[pal.text, s.mb2]}> + <Text + type="md-medium" + style={[pal.text, s.mb2]} + nativeID="password"> Password </Text> <TextInput @@ -81,17 +94,27 @@ export const Step2 = observer(({model}: {model: CreateAccountModel}) => { editable secureTextEntry onChange={model.setPassword} + accessibilityLabel="Password" + accessibilityHint="Set password" + accessibilityLabelledBy="password" /> </View> <View style={s.pb20}> - <Text type="md-medium" style={[pal.text, s.mb2]}> + <Text + type="md-medium" + style={[pal.text, s.mb2]} + nativeID="legalCheck"> Legal check </Text> <TouchableOpacity testID="is13Input" style={[styles.toggleBtn, pal.border]} - onPress={() => model.setIs13(!model.is13)}> + onPress={() => model.setIs13(!model.is13)} + accessibilityRole="checkbox" + accessibilityLabel="Verify age" + accessibilityHint="Verifies that I am at least 13 years of age" + accessibilityLabelledBy="legalCheck"> <View style={[pal.borderDark, styles.checkbox]}> {model.is13 && ( <FontAwesomeIcon icon="check" style={s.blue3} size={16} /> diff --git a/src/view/com/auth/create/Step3.tsx b/src/view/com/auth/create/Step3.tsx index 13ab39a10..3d9d47628 100644 --- a/src/view/com/auth/create/Step3.tsx +++ b/src/view/com/auth/create/Step3.tsx @@ -23,6 +23,9 @@ export const Step3 = observer(({model}: {model: CreateAccountModel}) => { value={model.handle} editable onChange={model.setHandle} + // TODO: Add explicit text label + accessibilityLabel="User handle" + accessibilityHint="Input your user handle" /> <Text type="lg" style={[pal.text, s.pl5, s.pt10]}> Your full handle will be{' '} diff --git a/src/view/com/auth/login/Login.tsx b/src/view/com/auth/login/Login.tsx index eff1642f0..37558fb54 100644 --- a/src/view/com/auth/login/Login.tsx +++ b/src/view/com/auth/login/Login.tsx @@ -195,7 +195,10 @@ const ChooseAccountForm = ({ testID={`chooseAccountBtn-${account.handle}`} key={account.did} style={[pal.view, pal.border, styles.account]} - onPress={() => onTryAccount(account)}> + onPress={() => onTryAccount(account)} + accessibilityRole="button" + accessibilityLabel={`Sign in as ${account.handle}`} + accessibilityHint="Double tap to sign in"> <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> <View style={s.p10}> @@ -220,7 +223,10 @@ const ChooseAccountForm = ({ <TouchableOpacity testID="chooseNewAccountBtn" style={[pal.view, pal.border, styles.account, styles.accountLast]} - onPress={() => onSelectAccount(undefined)}> + onPress={() => onSelectAccount(undefined)} + accessibilityRole="button" + accessibilityLabel="Login to account that is not listed" + accessibilityHint=""> <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> <Text style={[styles.accountText, styles.accountTextOther]}> <Text type="lg" style={pal.text}> @@ -235,7 +241,11 @@ const ChooseAccountForm = ({ </View> </TouchableOpacity> <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> - <TouchableOpacity onPress={onPressBack}> + <TouchableOpacity + onPress={onPressBack} + accessibilityRole="button" + accessibilityLabel="Go back" + accessibilityHint="Navigates to the previous screen"> <Text type="xl" style={[pal.link, s.pl5]}> Back </Text> @@ -351,7 +361,10 @@ const LoginForm = ({ <TouchableOpacity testID="loginSelectServiceButton" style={styles.textBtn} - onPress={onPressSelectService}> + onPress={onPressSelectService} + accessibilityRole="button" + accessibilityLabel="Select service" + accessibilityHint="Sets server for the Bluesky client"> <Text type="xl" style={[pal.text, styles.textBtnLabel]}> {toNiceDomain(serviceUrl)} </Text> @@ -386,6 +399,8 @@ const LoginForm = ({ value={identifier} onChangeText={str => setIdentifier((str || '').toLowerCase())} editable={!isProcessing} + accessibilityLabel="Username or email address" + accessibilityHint="Input the username or email address you used at signup" /> </View> <View style={[pal.borderDark, styles.groupContent]}> @@ -402,14 +417,28 @@ const LoginForm = ({ autoCorrect={false} keyboardAppearance={theme.colorScheme} secureTextEntry + // HACK + // mitigates a known issue where the secure password prompt interferes + // https://github.com/facebook/react-native/issues/21911 + // prf + textContentType="oneTimeCode" value={password} onChangeText={setPassword} editable={!isProcessing} + accessibilityLabel="Password" + accessibilityHint={ + identifier === '' + ? 'Input your password' + : `Input the password tied to ${identifier}` + } /> <TouchableOpacity testID="forgotPasswordButton" style={styles.textInputInnerBtn} - onPress={onPressForgotPassword}> + onPress={onPressForgotPassword} + accessibilityRole="button" + accessibilityLabel="Forgot password" + accessibilityHint="Opens password reset form"> <Text style={pal.link}>Forgot</Text> </TouchableOpacity> </View> @@ -425,7 +454,11 @@ const LoginForm = ({ </View> ) : undefined} <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> - <TouchableOpacity onPress={onPressBack}> + <TouchableOpacity + onPress={onPressBack} + accessibilityRole="button" + accessibilityLabel="Go back" + accessibilityHint="Navigates to the previous screen"> <Text type="xl" style={[pal.link, s.pl5]}> Back </Text> @@ -434,7 +467,10 @@ const LoginForm = ({ {!serviceDescription && error ? ( <TouchableOpacity testID="loginRetryButton" - onPress={onPressRetryConnect}> + onPress={onPressRetryConnect} + accessibilityRole="button" + accessibilityLabel="Retry" + accessibilityHint="Retries login"> <Text type="xl-bold" style={[pal.link, s.pr5]}> Retry </Text> @@ -449,7 +485,12 @@ const LoginForm = ({ ) : isProcessing ? ( <ActivityIndicator /> ) : isReady ? ( - <TouchableOpacity testID="loginNextButton" onPress={onPressNext}> + <TouchableOpacity + testID="loginNextButton" + onPress={onPressNext} + accessibilityRole="button" + accessibilityLabel="Go to next" + accessibilityHint="Navigates to the next screen"> <Text type="xl-bold" style={[pal.link, s.pr5]}> Next </Text> @@ -539,7 +580,10 @@ const ForgotPasswordForm = ({ <TouchableOpacity testID="forgotPasswordSelectServiceButton" style={[pal.borderDark, styles.groupContent, styles.noTopBorder]} - onPress={onPressSelectService}> + onPress={onPressSelectService} + accessibilityRole="button" + accessibilityLabel="Hosting provider" + accessibilityHint="Sets hosting provider for password reset"> <FontAwesomeIcon icon="globe" style={[pal.textLight, styles.groupContentIcon]} @@ -572,6 +616,8 @@ const ForgotPasswordForm = ({ value={email} onChangeText={setEmail} editable={!isProcessing} + accessibilityLabel="Email" + accessibilityHint="Sets email for password reset" /> </View> </View> @@ -586,7 +632,11 @@ const ForgotPasswordForm = ({ </View> ) : undefined} <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> - <TouchableOpacity onPress={onPressBack}> + <TouchableOpacity + onPress={onPressBack} + accessibilityRole="button" + accessibilityLabel="Go back" + accessibilityHint="Navigates to the previous screen"> <Text type="xl" style={[pal.link, s.pl5]}> Back </Text> @@ -599,7 +649,12 @@ const ForgotPasswordForm = ({ Next </Text> ) : ( - <TouchableOpacity testID="newPasswordButton" onPress={onPressNext}> + <TouchableOpacity + testID="newPasswordButton" + onPress={onPressNext} + accessibilityRole="button" + accessibilityLabel="Go to next" + accessibilityHint="Navigates to the next screen"> <Text type="xl-bold" style={[pal.link, s.pr5]}> Next </Text> @@ -699,6 +754,9 @@ const SetNewPasswordForm = ({ value={resetCode} onChangeText={setResetCode} editable={!isProcessing} + accessible={true} + accessibilityLabel="Reset code" + accessibilityHint="Input code sent to your email for password reset" /> </View> <View style={[pal.borderDark, styles.groupContent]}> @@ -718,6 +776,9 @@ const SetNewPasswordForm = ({ value={password} onChangeText={setPassword} editable={!isProcessing} + accessible={true} + accessibilityLabel="Password" + accessibilityHint="Input new password" /> </View> </View> @@ -732,7 +793,11 @@ const SetNewPasswordForm = ({ </View> ) : undefined} <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> - <TouchableOpacity onPress={onPressBack}> + <TouchableOpacity + onPress={onPressBack} + accessibilityRole="button" + accessibilityLabel="Go back" + accessibilityHint="Navigates to the previous screen"> <Text type="xl" style={[pal.link, s.pl5]}> Back </Text> @@ -747,7 +812,10 @@ const SetNewPasswordForm = ({ ) : ( <TouchableOpacity testID="setNewPasswordButton" - onPress={onPressNext}> + onPress={onPressNext} + accessibilityRole="button" + accessibilityLabel="Go to next" + accessibilityHint="Navigates to the next screen"> <Text type="xl-bold" style={[pal.link, s.pr5]}> Next </Text> @@ -783,7 +851,11 @@ const PasswordUpdatedForm = ({onPressNext}: {onPressNext: () => void}) => { </Text> <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> <View style={s.flex1} /> - <TouchableOpacity onPress={onPressNext}> + <TouchableOpacity + onPress={onPressNext} + accessibilityRole="button" + accessibilityLabel="Close alert" + accessibilityHint="Closes password update alert"> <Text type="xl-bold" style={[pal.link, s.pr5]}> Okay </Text> diff --git a/src/view/com/auth/util/TextInput.tsx b/src/view/com/auth/util/TextInput.tsx index 934bf2acf..38aff0384 100644 --- a/src/view/com/auth/util/TextInput.tsx +++ b/src/view/com/auth/util/TextInput.tsx @@ -1,27 +1,17 @@ -import React from 'react' +import React, {ComponentProps} from 'react' import {StyleSheet, TextInput as RNTextInput, View} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {IconProp} from '@fortawesome/fontawesome-svg-core' import {usePalette} from 'lib/hooks/usePalette' import {useTheme} from 'lib/ThemeContext' -export function TextInput({ - testID, - icon, - value, - placeholder, - editable, - secureTextEntry, - onChange, -}: { +interface Props extends Omit<ComponentProps<typeof RNTextInput>, 'onChange'> { testID?: string icon: IconProp - value: string - placeholder: string - editable: boolean - secureTextEntry?: boolean onChange: (v: string) => void -}) { +} + +export function TextInput({testID, icon, onChange, ...props}: Props) { const theme = useTheme() const pal = usePalette('default') return ( @@ -30,15 +20,12 @@ export function TextInput({ <RNTextInput testID={testID} style={[pal.text, styles.textInput]} - placeholder={placeholder} placeholderTextColor={pal.colors.textLight} autoCapitalize="none" autoCorrect={false} keyboardAppearance={theme.colorScheme} - secureTextEntry={secureTextEntry} - value={value} onChangeText={v => onChange(v)} - editable={editable} + {...props} /> </View> ) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 5ccc229d6..45e67d7cb 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -7,7 +7,6 @@ import { ScrollView, StyleSheet, TouchableOpacity, - TouchableWithoutFeedback, View, } from 'react-native' import {useSafeAreaInsets} from 'react-native-safe-area-context' @@ -19,6 +18,8 @@ import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' import {ExternalEmbed} from './ExternalEmbed' import {Text} from '../util/text/Text' import * as Toast from '../util/Toast' +// TODO: Prevent naming components that coincide with RN primitives +// due to linting false positives import {TextInput, TextInputRef} from './text-input/TextInput' import {CharProgress} from './char-progress/CharProgress' import {UserAvatar} from '../util/UserAvatar' @@ -87,27 +88,6 @@ export const ComposePost = observer(function ComposePost({ autocompleteView.setup() }, [autocompleteView]) - useEffect(() => { - // HACK - // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view - // -prf - let to: NodeJS.Timeout | undefined - if (textInput.current) { - to = setTimeout(() => { - textInput.current?.focus() - }, 250) - } - return () => { - if (to) { - clearTimeout(to) - } - } - }, []) - - const onPressContainer = useCallback(() => { - textInput.current?.focus() - }, [textInput]) - const onPressAddLinkCard = useCallback( (uri: string) => { setExtLink({uri, isLoading: true}) @@ -133,7 +113,7 @@ export const ComposePost = observer(function ComposePost({ if (rt.text.trim().length === 0 && gallery.isEmpty) { setError('Did you want to say anything?') - return false + return } setIsProcessing(true) @@ -203,133 +183,149 @@ export const ComposePost = observer(function ComposePost({ testID="composePostView" behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={styles.outer}> - <TouchableWithoutFeedback onPressIn={onPressContainer}> - <View style={[s.flex1, viewStyles]}> - <View style={styles.topbar}> + <View style={[s.flex1, viewStyles]} aria-modal accessibilityViewIsModal> + <View style={styles.topbar}> + <TouchableOpacity + testID="composerCancelButton" + onPress={hackfixOnClose} + onAccessibilityEscape={hackfixOnClose} + accessibilityRole="button" + accessibilityLabel="Cancel" + accessibilityHint="Closes post composer"> + <Text style={[pal.link, s.f18]}>Cancel</Text> + </TouchableOpacity> + <View style={s.flex1} /> + {isProcessing ? ( + <View style={styles.postBtn}> + <ActivityIndicator /> + </View> + ) : canPost ? ( <TouchableOpacity - testID="composerCancelButton" - onPress={hackfixOnClose}> - <Text style={[pal.link, s.f18]}>Cancel</Text> + testID="composerPublishBtn" + onPress={() => { + onPressPublish(richtext) + }} + accessibilityRole="button" + accessibilityLabel={replyTo ? 'Publish reply' : 'Publish post'} + accessibilityHint={ + replyTo + ? 'Double tap to publish your reply' + : 'Double tap to publish your post' + }> + <LinearGradient + colors={[gradients.blueLight.start, gradients.blueLight.end]} + start={{x: 0, y: 0}} + end={{x: 1, y: 1}} + style={styles.postBtn}> + <Text style={[s.white, s.f16, s.bold]}> + {replyTo ? 'Reply' : 'Post'} + </Text> + </LinearGradient> </TouchableOpacity> - <View style={s.flex1} /> - {isProcessing ? ( - <View style={styles.postBtn}> - <ActivityIndicator /> - </View> - ) : canPost ? ( - <TouchableOpacity - testID="composerPublishBtn" - onPress={() => { - onPressPublish(richtext) - }}> - <LinearGradient - colors={[gradients.blueLight.start, gradients.blueLight.end]} - start={{x: 0, y: 0}} - end={{x: 1, y: 1}} - style={styles.postBtn}> - <Text style={[s.white, s.f16, s.bold]}> - {replyTo ? 'Reply' : 'Post'} - </Text> - </LinearGradient> - </TouchableOpacity> - ) : ( - <View style={[styles.postBtn, pal.btn]}> - <Text style={[pal.textLight, s.f16, s.bold]}>Post</Text> - </View> - )} + ) : ( + <View style={[styles.postBtn, pal.btn]}> + <Text style={[pal.textLight, s.f16, s.bold]}>Post</Text> + </View> + )} + </View> + {isProcessing ? ( + <View style={[pal.btn, styles.processingLine]}> + <Text style={pal.text}>{processingState}</Text> </View> - {isProcessing ? ( - <View style={[pal.btn, styles.processingLine]}> - <Text style={pal.text}>{processingState}</Text> + ) : undefined} + {error !== '' && ( + <View style={styles.errorLine}> + <View style={styles.errorIcon}> + <FontAwesomeIcon + icon="exclamation" + style={{color: colors.red4}} + size={10} + /> </View> - ) : undefined} - {error !== '' && ( - <View style={styles.errorLine}> - <View style={styles.errorIcon}> - <FontAwesomeIcon - icon="exclamation" - style={{color: colors.red4}} - size={10} - /> + <Text style={[s.red4, s.flex1]}>{error}</Text> + </View> + )} + <ScrollView + style={styles.scrollView} + keyboardShouldPersistTaps="always"> + {replyTo ? ( + <View style={[pal.border, styles.replyToLayout]}> + <UserAvatar avatar={replyTo.author.avatar} size={50} /> + <View style={styles.replyToPost}> + <Text type="xl-medium" style={[pal.text]}> + {sanitizeDisplayName( + replyTo.author.displayName || replyTo.author.handle, + )} + </Text> + <Text type="post-text" style={pal.text} numberOfLines={6}> + {replyTo.text} + </Text> </View> - <Text style={[s.red4, s.flex1]}>{error}</Text> </View> - )} - <ScrollView - style={styles.scrollView} - keyboardShouldPersistTaps="always"> - {replyTo ? ( - <View style={[pal.border, styles.replyToLayout]}> - <UserAvatar avatar={replyTo.author.avatar} size={50} /> - <View style={styles.replyToPost}> - <Text type="xl-medium" style={[pal.text]}> - {sanitizeDisplayName( - replyTo.author.displayName || replyTo.author.handle, - )} - </Text> - <Text type="post-text" style={pal.text} numberOfLines={6}> - {replyTo.text} - </Text> - </View> - </View> - ) : undefined} + ) : undefined} - <View style={[pal.border, styles.textInputLayout]}> - <UserAvatar avatar={store.me.avatar} size={50} /> - <TextInput - ref={textInput} - richtext={richtext} - placeholder={selectTextInputPlaceholder} - suggestedLinks={suggestedLinks} - autocompleteView={autocompleteView} - setRichText={setRichText} - onPhotoPasted={onPhotoPasted} - onPressPublish={onPressPublish} - onSuggestedLinksChanged={setSuggestedLinks} - onError={setError} - /> - </View> + <View style={[pal.border, styles.textInputLayout]}> + <UserAvatar avatar={store.me.avatar} size={50} /> + <TextInput + ref={textInput} + richtext={richtext} + placeholder={selectTextInputPlaceholder} + suggestedLinks={suggestedLinks} + autocompleteView={autocompleteView} + autoFocus={true} + setRichText={setRichText} + onPhotoPasted={onPhotoPasted} + onPressPublish={onPressPublish} + onSuggestedLinksChanged={setSuggestedLinks} + onError={setError} + accessible={true} + accessibilityLabel="Write post" + accessibilityHint="Compose posts up to 300 characters in length" + /> + </View> - <Gallery gallery={gallery} /> - {gallery.isEmpty && extLink && ( - <ExternalEmbed - link={extLink} - onRemove={() => setExtLink(undefined)} - /> - )} - {quote ? ( - <View style={s.mt5}> - <QuoteEmbed quote={quote} /> - </View> - ) : undefined} - </ScrollView> - {!extLink && suggestedLinks.size > 0 ? ( - <View style={s.mb5}> - {Array.from(suggestedLinks).map(url => ( - <TouchableOpacity - key={`suggested-${url}`} - testID="addLinkCardBtn" - style={[pal.borderDark, styles.addExtLinkBtn]} - onPress={() => onPressAddLinkCard(url)}> - <Text style={pal.text}> - Add link card: <Text style={pal.link}>{url}</Text> - </Text> - </TouchableOpacity> - ))} + <Gallery gallery={gallery} /> + {gallery.isEmpty && extLink && ( + <ExternalEmbed + link={extLink} + onRemove={() => setExtLink(undefined)} + /> + )} + {quote ? ( + <View style={s.mt5}> + <QuoteEmbed quote={quote} /> </View> - ) : null} - <View style={[pal.border, styles.bottomBar]}> - {canSelectImages ? ( - <> - <SelectPhotoBtn gallery={gallery} /> - <OpenCameraBtn gallery={gallery} /> - </> - ) : null} - <View style={s.flex1} /> - <CharProgress count={graphemeLength} /> + ) : undefined} + </ScrollView> + {!extLink && suggestedLinks.size > 0 ? ( + <View style={s.mb5}> + {Array.from(suggestedLinks).map(url => ( + <TouchableOpacity + key={`suggested-${url}`} + testID="addLinkCardBtn" + style={[pal.borderDark, styles.addExtLinkBtn]} + onPress={() => onPressAddLinkCard(url)} + accessibilityRole="button" + accessibilityLabel="Add link card" + accessibilityHint={`Creates a card with a thumbnail. The card links to ${url}`}> + <Text style={pal.text}> + Add link card: <Text style={pal.link}>{url}</Text> + </Text> + </TouchableOpacity> + ))} </View> + ) : null} + <View style={[pal.border, styles.bottomBar]}> + {canSelectImages ? ( + <> + <SelectPhotoBtn gallery={gallery} /> + <OpenCameraBtn gallery={gallery} /> + </> + ) : null} + <View style={s.flex1} /> + <CharProgress count={graphemeLength} /> </View> - </TouchableWithoutFeedback> + </View> </KeyboardAvoidingView> ) }) diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index b6a45f6a3..a938562bd 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -60,7 +60,13 @@ export const ExternalEmbed = ({ </Text> )} </View> - <TouchableOpacity style={styles.removeBtn} onPress={onRemove}> + <TouchableOpacity + style={styles.removeBtn} + onPress={onRemove} + accessibilityRole="button" + accessibilityLabel="Remove image preview" + accessibilityHint={`Removes default thumbnail from ${link.uri}`} + onAccessibilityEscape={onRemove}> <FontAwesomeIcon size={18} icon="xmark" style={s.white} /> </TouchableOpacity> </View> diff --git a/src/view/com/composer/Prompt.tsx b/src/view/com/composer/Prompt.tsx index 301b90093..98a10b0f5 100644 --- a/src/view/com/composer/Prompt.tsx +++ b/src/view/com/composer/Prompt.tsx @@ -13,7 +13,10 @@ export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) { <TouchableOpacity testID="replyPromptBtn" style={[pal.view, pal.border, styles.prompt]} - onPress={() => onPressCompose()}> + onPress={() => onPressCompose()} + accessibilityRole="button" + accessibilityLabel="Compose reply" + accessibilityHint="Opens composer"> <UserAvatar avatar={store.me.avatar} size={38} /> <Text type="xl" diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index 98f0824fd..e2d95b2a4 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -107,6 +107,9 @@ export const Gallery = observer(function ({gallery}: Props) { <View key={`selected-image-${image.path}`} style={[imageStyle]}> <TouchableOpacity testID="altTextButton" + accessibilityRole="button" + accessibilityLabel="Add alt text" + accessibilityHint="Opens modal for inputting image alt text" onPress={() => { handleAddImageAltText(image) }} @@ -116,6 +119,9 @@ export const Gallery = observer(function ({gallery}: Props) { <View style={imageControlsSubgroupStyle}> <TouchableOpacity testID="cropPhotoButton" + accessibilityRole="button" + accessibilityLabel="Crop image" + accessibilityHint="Opens modal for cropping image" onPress={() => { handleEditPhoto(image) }} @@ -128,6 +134,9 @@ export const Gallery = observer(function ({gallery}: Props) { </TouchableOpacity> <TouchableOpacity testID="removePhotoButton" + accessibilityRole="button" + accessibilityLabel="Remove image" + accessibilityHint="" onPress={() => handleRemovePhoto(image)} style={styles.imageControl}> <FontAwesomeIcon @@ -144,6 +153,8 @@ export const Gallery = observer(function ({gallery}: Props) { source={{ uri: image.compressed.path, }} + accessible={true} + accessibilityIgnoresInvertColors /> </View> ) : null, diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx index 809c41783..bfcfa6b78 100644 --- a/src/view/com/composer/photos/OpenCameraBtn.tsx +++ b/src/view/com/composer/photos/OpenCameraBtn.tsx @@ -1,5 +1,5 @@ import React, {useCallback} from 'react' -import {TouchableOpacity} from 'react-native' +import {TouchableOpacity, StyleSheet} from 'react-native' import { FontAwesomeIcon, FontAwesomeIconStyle, @@ -7,7 +7,6 @@ import { import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics' import {useStores} from 'state/index' -import {s} from 'lib/styles' import {isDesktopWeb} from 'platform/detection' import {openCamera} from 'lib/media/picker' import {useCameraPermission} from 'lib/hooks/usePermissions' @@ -54,8 +53,11 @@ export function OpenCameraBtn({gallery}: Props) { <TouchableOpacity testID="openCameraButton" onPress={onPressTakePicture} - style={[s.pl5]} - hitSlop={HITSLOP}> + style={styles.button} + hitSlop={HITSLOP} + accessibilityRole="button" + accessibilityLabel="Camera" + accessibilityHint="Opens camera on device"> <FontAwesomeIcon icon="camera" style={pal.link as FontAwesomeIconStyle} @@ -64,3 +66,9 @@ export function OpenCameraBtn({gallery}: Props) { </TouchableOpacity> ) } + +const styles = StyleSheet.create({ + button: { + paddingHorizontal: 15, + }, +}) diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx index 9569e08ad..0b8046a4b 100644 --- a/src/view/com/composer/photos/SelectPhotoBtn.tsx +++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx @@ -1,12 +1,11 @@ import React, {useCallback} from 'react' -import {TouchableOpacity} from 'react-native' +import {TouchableOpacity, StyleSheet} from 'react-native' import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics' -import {s} from 'lib/styles' import {isDesktopWeb} from 'platform/detection' import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions' import {GalleryModel} from 'state/models/media/gallery' @@ -36,8 +35,11 @@ export function SelectPhotoBtn({gallery}: Props) { <TouchableOpacity testID="openGalleryBtn" onPress={onPressSelectPhotos} - style={[s.pl5, s.pr20]} - hitSlop={HITSLOP}> + style={styles.button} + hitSlop={HITSLOP} + accessibilityRole="button" + accessibilityLabel="Gallery" + accessibilityHint="Opens device photo gallery"> <FontAwesomeIcon icon={['far', 'image']} style={pal.link as FontAwesomeIconStyle} @@ -46,3 +48,9 @@ export function SelectPhotoBtn({gallery}: Props) { </TouchableOpacity> ) } + +const styles = StyleSheet.create({ + button: { + paddingHorizontal: 15, + }, +}) diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 10ac52b5d..7b09da93d 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -1,7 +1,14 @@ -import React, {forwardRef, useCallback, useEffect, useRef, useMemo} from 'react' +import React, { + forwardRef, + useCallback, + useRef, + useMemo, + ComponentProps, +} from 'react' import { NativeSyntheticEvent, StyleSheet, + TextInput as RNTextInput, TextInputSelectionChangeEventData, View, } from 'react-native' @@ -27,14 +34,14 @@ export interface TextInputRef { blur: () => void } -interface TextInputProps { +interface TextInputProps extends ComponentProps<typeof RNTextInput> { richtext: RichText placeholder: string suggestedLinks: Set<string> autocompleteView: UserAutocompleteModel - setRichText: (v: RichText) => void + setRichText: (v: RichText | ((v: RichText) => RichText)) => void onPhotoPasted: (uri: string) => void - onPressPublish: (richtext: RichText) => Promise<false | undefined> + onPressPublish: (richtext: RichText) => Promise<void> onSuggestedLinksChanged: (uris: Set<string>) => void onError: (err: string) => void } @@ -55,6 +62,7 @@ export const TextInput = forwardRef( onPhotoPasted, onSuggestedLinksChanged, onError, + ...props }: TextInputProps, ref, ) => { @@ -65,26 +73,11 @@ export const TextInput = forwardRef( React.useImperativeHandle(ref, () => ({ focus: () => textInput.current?.focus(), - blur: () => textInput.current?.blur(), + blur: () => { + textInput.current?.blur() + }, })) - useEffect(() => { - // HACK - // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view - // -prf - let to: NodeJS.Timeout | undefined - if (textInput.current) { - to = setTimeout(() => { - textInput.current?.focus() - }, 250) - } - return () => { - if (to) { - clearTimeout(to) - } - } - }, []) - const onChangeText = useCallback( async (newText: string) => { const newRt = new RichText({text: newText}) @@ -206,8 +199,10 @@ export const TextInput = forwardRef( placeholder={placeholder} placeholderTextColor={pal.colors.textLight} keyboardAppearance={theme.colorScheme} + autoFocus={true} multiline - style={[pal.text, styles.textInput, styles.textInputFormatting]}> + style={[pal.text, styles.textInput, styles.textInputFormatting]} + {...props}> {textDecorated} </PasteInput> <Autocomplete diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 3f98a3595..4abedb3e2 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -25,9 +25,9 @@ interface TextInputProps { placeholder: string suggestedLinks: Set<string> autocompleteView: UserAutocompleteModel - setRichText: (v: RichText) => void + setRichText: (v: RichText | ((v: RichText) => RichText)) => void onPhotoPasted: (uri: string) => void - onPressPublish: (richtext: RichText) => Promise<false | undefined> + onPressPublish: (richtext: RichText) => Promise<void> onSuggestedLinksChanged: (uris: Set<string>) => void onError: (err: string) => void } diff --git a/src/view/com/composer/text-input/mobile/Autocomplete.tsx b/src/view/com/composer/text-input/mobile/Autocomplete.tsx index 879bac071..7806241f1 100644 --- a/src/view/com/composer/text-input/mobile/Autocomplete.tsx +++ b/src/view/com/composer/text-input/mobile/Autocomplete.tsx @@ -50,7 +50,9 @@ export const Autocomplete = observer( testID="autocompleteButton" key={item.handle} style={[pal.border, styles.item]} - onPress={() => onSelect(item.handle)}> + onPress={() => onSelect(item.handle)} + accessibilityLabel={`Select ${item.handle}`} + accessibilityHint={`Autocompletes to ${item.handle}`}> <Text type="md-medium" style={pal.text}> {item.displayName || item.handle} <Text type="sm" style={pal.textLight}> diff --git a/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx b/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx index 6880008e4..84e5f90fb 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx @@ -20,7 +20,11 @@ const ImageDefaultHeader = ({onRequestClose}: Props) => ( <TouchableOpacity style={styles.closeButton} onPress={onRequestClose} - hitSlop={HIT_SLOP}> + hitSlop={HIT_SLOP} + accessibilityRole="button" + accessibilityLabel="Close image" + accessibilityHint="Closes viewer for header image" + onAccessibilityEscape={onRequestClose}> <Text style={styles.closeText}>✕</Text> </TouchableOpacity> </SafeAreaView> diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx index 12d37e283..658735724 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx @@ -127,7 +127,8 @@ const ImageItem = ({ <TouchableWithoutFeedback onPress={doubleTapToZoomEnabled ? handleDoubleTap : undefined} onLongPress={onLongPressHandler} - delayLongPress={delayLongPress}> + delayLongPress={delayLongPress} + accessibilityRole="image"> <Animated.Image source={imageSrc} style={imageStylesWithOpacity} diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx index c10d57b37..531df129e 100644 --- a/src/view/com/lightbox/ImageViewing/index.tsx +++ b/src/view/com/lightbox/ImageViewing/index.tsx @@ -112,7 +112,12 @@ function ImageViewing({ } return ( - <SafeAreaView style={styles.screen} onLayout={onLayout} edges={edges}> + <SafeAreaView + style={styles.screen} + onLayout={onLayout} + edges={edges} + aria-modal + accessibilityViewIsModal> <ModalsContainer /> <View style={[styles.container, {opacity, backgroundColor}]}> <Animated.View style={[styles.header, {transform: headerTransform}]}> diff --git a/src/view/com/lightbox/Lightbox.web.tsx b/src/view/com/lightbox/Lightbox.web.tsx index c17356d94..1d4a9c215 100644 --- a/src/view/com/lightbox/Lightbox.web.tsx +++ b/src/view/com/lightbox/Lightbox.web.tsx @@ -89,13 +89,25 @@ function LightboxInner({ return ( <View style={styles.mask}> - <TouchableWithoutFeedback onPress={onClose}> + <TouchableWithoutFeedback + onPress={onClose} + accessibilityRole="button" + accessibilityLabel="Close image viewer" + accessibilityHint="Exits image view" + onAccessibilityEscape={onClose}> <View style={styles.imageCenterer}> - <Image source={imgs[index]} style={styles.image} /> + <Image + accessibilityIgnoresInvertColors + source={imgs[index]} + style={styles.image} + /> {canGoLeft && ( <TouchableOpacity onPress={onPressLeft} - style={[styles.btn, styles.leftBtn]}> + style={[styles.btn, styles.leftBtn]} + accessibilityRole="button" + accessibilityLabel="Go back" + accessibilityHint="Navigates to previous image in viewer"> <FontAwesomeIcon icon="angle-left" style={styles.icon} @@ -106,7 +118,10 @@ function LightboxInner({ {canGoRight && ( <TouchableOpacity onPress={onPressRight} - style={[styles.btn, styles.rightBtn]}> + style={[styles.btn, styles.rightBtn]} + accessibilityRole="button" + accessibilityLabel="Go to next" + accessibilityHint="Navigates to next image in viewer"> <FontAwesomeIcon icon="angle-right" style={styles.icon} diff --git a/src/view/com/modals/AddAppPasswords.tsx b/src/view/com/modals/AddAppPasswords.tsx index 89ae81f5e..58b53586b 100644 --- a/src/view/com/modals/AddAppPasswords.tsx +++ b/src/view/com/modals/AddAppPasswords.tsx @@ -122,12 +122,18 @@ export function Component({}: {}) { editable={!appPassword} returnKeyType="done" onEndEditing={createAppPassword} + accessible={true} + accessibilityLabel="Name" + accessibilityHint="Input name for app password" /> </View> ) : ( <TouchableOpacity style={[pal.border, styles.passwordContainer, pal.btn]} - onPress={onCopy}> + onPress={onCopy} + accessibilityRole="button" + accessibilityLabel="Copy" + accessibilityHint="Copies app password"> <Text type="2xl-bold" style={[pal.text]}> {appPassword} </Text> diff --git a/src/view/com/modals/AltImage.tsx b/src/view/com/modals/AltImage.tsx index 639303c98..ba05a7d62 100644 --- a/src/view/com/modals/AltImage.tsx +++ b/src/view/com/modals/AltImage.tsx @@ -37,7 +37,8 @@ export function Component({prevAltText, onAltTextSet}: Props) { return ( <View testID="altTextImageModal" - style={[pal.view, styles.container, s.flex1]}> + style={[pal.view, styles.container, s.flex1]} + nativeID="imageAltText"> <Text style={[styles.title, pal.text]}>Add alt text</Text> <TextInput testID="altTextImageInput" @@ -46,9 +47,17 @@ export function Component({prevAltText, onAltTextSet}: Props) { multiline value={altText} onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))} + accessibilityLabel="Image alt text" + accessibilityHint="Sets image alt text for screenreaders" + accessibilityLabelledBy="imageAltText" /> <View style={styles.buttonControls}> - <TouchableOpacity testID="altTextImageSaveBtn" onPress={onPressSave}> + <TouchableOpacity + testID="altTextImageSaveBtn" + onPress={onPressSave} + accessibilityLabel="Save alt text" + accessibilityHint={`Saves alt text, which reads: ${altText}`} + accessibilityRole="button"> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} @@ -61,7 +70,11 @@ export function Component({prevAltText, onAltTextSet}: Props) { </TouchableOpacity> <TouchableOpacity testID="altTextImageCancelBtn" - onPress={onPressCancel}> + onPress={onPressCancel} + accessibilityRole="button" + accessibilityLabel="Cancel add image alt text" + accessibilityHint="Exits adding alt text to image" + onAccessibilityEscape={onPressCancel}> <View style={[styles.button]}> <Text type="button-lg" style={[pal.textLight]}> Cancel diff --git a/src/view/com/modals/AltImageRead.tsx b/src/view/com/modals/AltImageRead.tsx index e7b4797ee..4dde8f58b 100644 --- a/src/view/com/modals/AltImageRead.tsx +++ b/src/view/com/modals/AltImageRead.tsx @@ -30,7 +30,12 @@ export function Component({altText}: Props) { <View style={[styles.text, pal.viewLight]}> <Text style={pal.text}>{altText}</Text> </View> - <TouchableOpacity testID="altTextImageSaveBtn" onPress={onPress}> + <TouchableOpacity + testID="altTextImageSaveBtn" + onPress={onPress} + accessibilityRole="button" + accessibilityLabel="Save" + accessibilityHint="Save alt text"> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} diff --git a/src/view/com/modals/ChangeHandle.tsx b/src/view/com/modals/ChangeHandle.tsx index 37bad6957..ad7ff5a6d 100644 --- a/src/view/com/modals/ChangeHandle.tsx +++ b/src/view/com/modals/ChangeHandle.tsx @@ -133,7 +133,12 @@ export function Component({onChanged}: {onChanged: () => void}) { <View style={[s.flex1, pal.view]}> <View style={[styles.title, pal.border]}> <View style={styles.titleLeft}> - <TouchableOpacity onPress={onPressCancel}> + <TouchableOpacity + onPress={onPressCancel} + accessibilityRole="button" + accessibilityLabel="Cancel change handle" + accessibilityHint="Exits handle change process" + onAccessibilityEscape={onPressCancel}> <Text type="lg" style={pal.textLight}> Cancel </Text> @@ -148,13 +153,20 @@ export function Component({onChanged}: {onChanged: () => void}) { ) : error && !serviceDescription ? ( <TouchableOpacity testID="retryConnectButton" - onPress={onPressRetryConnect}> + onPress={onPressRetryConnect} + accessibilityRole="button" + accessibilityLabel="Retry change handle" + accessibilityHint={`Retries handle change to ${handle}`}> <Text type="xl-bold" style={[pal.link, s.pr5]}> Retry </Text> </TouchableOpacity> ) : canSave ? ( - <TouchableOpacity onPress={onPressSave}> + <TouchableOpacity + onPress={onPressSave} + accessibilityRole="button" + accessibilityLabel="Save handle change" + accessibilityHint={`Saves handle change to ${handle}`}> <Text type="2xl-medium" style={pal.link}> Save </Text> @@ -245,6 +257,9 @@ function ProvidedHandleForm({ value={handle} onChangeText={onChangeHandle} editable={!isProcessing} + accessible={true} + accessibilityLabel="Handle" + accessibilityHint="Sets Bluesky username" /> </View> <Text type="md" style={[pal.textLight, s.pl10, s.pt10]}> @@ -253,7 +268,11 @@ function ProvidedHandleForm({ @{createFullHandle(handle, userDomain)} </Text> </Text> - <TouchableOpacity onPress={onToggleCustom}> + <TouchableOpacity + onPress={onToggleCustom} + accessibilityRole="button" + accessibilityHint="Hosting provider" + accessibilityLabel="Opens modal for using custom domain"> <Text type="md-medium" style={[pal.link, s.pl10, s.pt5]}> I have my own domain </Text> @@ -338,7 +357,7 @@ function CustomHandleForm({ // = return ( <> - <Text type="md" style={[pal.text, s.pb5, s.pl5]}> + <Text type="md" style={[pal.text, s.pb5, s.pl5]} nativeID="customDomain"> Enter the domain you want to use </Text> <View style={[pal.btn, styles.textInputWrapper]}> @@ -356,6 +375,9 @@ function CustomHandleForm({ value={handle} onChangeText={onChangeHandle} editable={!isProcessing} + accessibilityLabelledBy="customDomain" + accessibilityLabel="Custom domain" + accessibilityHint="Input your preferred hosting provider" /> </View> <View style={styles.spacer} /> @@ -421,7 +443,10 @@ function CustomHandleForm({ )} </Button> <View style={styles.spacer} /> - <TouchableOpacity onPress={onToggleCustom}> + <TouchableOpacity + onPress={onToggleCustom} + accessibilityLabel="Use default provider" + accessibilityHint="Use bsky.social as hosting provider"> <Text type="md-medium" style={[pal.link, s.pl10, s.pt5]}> Nevermind, create a handle for me </Text> diff --git a/src/view/com/modals/Confirm.tsx b/src/view/com/modals/Confirm.tsx index 6f7b062cf..f0c905d04 100644 --- a/src/view/com/modals/Confirm.tsx +++ b/src/view/com/modals/Confirm.tsx @@ -66,7 +66,12 @@ export function Component({ <TouchableOpacity testID="confirmBtn" onPress={onPress} - style={[styles.btn]}> + style={[styles.btn]} + accessibilityRole="button" + accessibilityLabel="Confirm" + // TODO: This needs to be updated so that modal roles are clear; + // Currently there is only one usage for the confirm modal: post deletion + accessibilityHint="Confirms a potentially destructive action"> <Text style={[s.white, s.bold, s.f18]}>Confirm</Text> </TouchableOpacity> )} diff --git a/src/view/com/modals/ContentFilteringSettings.tsx b/src/view/com/modals/ContentFilteringSettings.tsx index 735de85a7..c683e43f8 100644 --- a/src/view/com/modals/ContentFilteringSettings.tsx +++ b/src/view/com/modals/ContentFilteringSettings.tsx @@ -34,7 +34,12 @@ export function Component({}: {}) { <View style={styles.bottomSpacer} /> </ScrollView> <View style={[styles.btnContainer, pal.borderDark]}> - <Pressable testID="sendReportBtn" onPress={onPressDone}> + <Pressable + testID="sendReportBtn" + onPress={onPressDone} + accessibilityRole="button" + accessibilityLabel="Confirm content moderation settings" + accessibilityHint=""> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} @@ -48,6 +53,7 @@ export function Component({}: {}) { ) } +// TODO: Refactor this component to pass labels down to each tab const ContentLabelPref = observer( ({group}: {group: keyof typeof CONFIGURABLE_LABEL_GROUPS}) => { const store = useStores() @@ -67,19 +73,20 @@ const ContentLabelPref = observer( <SelectGroup current={store.preferences.contentLabels[group]} onChange={v => store.preferences.setContentLabelPref(group, v)} + group={group} /> </View> ) }, ) -function SelectGroup({ - current, - onChange, -}: { +interface SelectGroupProps { current: LabelPreference onChange: (v: LabelPreference) => void -}) { + group: keyof typeof CONFIGURABLE_LABEL_GROUPS +} + +function SelectGroup({current, onChange, group}: SelectGroupProps) { return ( <View style={styles.selectableBtns}> <SelectableBtn @@ -88,12 +95,14 @@ function SelectGroup({ label="Hide" left onChange={onChange} + group={group} /> <SelectableBtn current={current} value="warn" label="Warn" onChange={onChange} + group={group} /> <SelectableBtn current={current} @@ -101,11 +110,22 @@ function SelectGroup({ label="Show" right onChange={onChange} + group={group} /> </View> ) } +interface SelectableBtnProps { + current: string + value: LabelPreference + label: string + left?: boolean + right?: boolean + onChange: (v: LabelPreference) => void + group: keyof typeof CONFIGURABLE_LABEL_GROUPS +} + function SelectableBtn({ current, value, @@ -113,14 +133,8 @@ function SelectableBtn({ left, right, onChange, -}: { - current: string - value: LabelPreference - label: string - left?: boolean - right?: boolean - onChange: (v: LabelPreference) => void -}) { + group, +}: SelectableBtnProps) { const pal = usePalette('default') const palPrimary = usePalette('inverted') return ( @@ -132,7 +146,10 @@ function SelectableBtn({ pal.border, current === value ? palPrimary.view : pal.view, ]} - onPress={() => onChange(value)}> + onPress={() => onChange(value)} + accessibilityRole="button" + accessibilityLabel={value} + accessibilityHint={`Set ${value} for ${group} content moderation policy`}> <Text style={current === value ? palPrimary.text : pal.text}> {label} </Text> diff --git a/src/view/com/modals/DeleteAccount.tsx b/src/view/com/modals/DeleteAccount.tsx index 353122163..f1febc2ea 100644 --- a/src/view/com/modals/DeleteAccount.tsx +++ b/src/view/com/modals/DeleteAccount.tsx @@ -86,7 +86,10 @@ export function Component({}: {}) { <> <TouchableOpacity style={styles.mt20} - onPress={onPressSendEmail}> + onPress={onPressSendEmail} + accessibilityRole="button" + accessibilityLabel="Send email" + accessibilityHint="Sends email with confirmation code for account deletion"> <LinearGradient colors={[ gradients.blueLight.start, @@ -102,7 +105,11 @@ export function Component({}: {}) { </TouchableOpacity> <TouchableOpacity style={[styles.btn, s.mt10]} - onPress={onCancel}> + onPress={onCancel} + accessibilityRole="button" + accessibilityLabel="Cancel account deletion" + accessibilityHint="" + onAccessibilityEscape={onCancel}> <Text type="button-lg" style={pal.textLight}> Cancel </Text> @@ -112,7 +119,11 @@ export function Component({}: {}) { </> ) : ( <> - <Text type="lg" style={styles.description}> + {/* TODO: Update this label to be more concise */} + <Text + type="lg" + style={styles.description} + nativeID="confirmationCode"> Check your inbox for an email with the confirmation code to enter below: </Text> @@ -123,8 +134,11 @@ export function Component({}: {}) { keyboardAppearance={theme.colorScheme} value={confirmCode} onChangeText={setConfirmCode} + accessibilityLabelledBy="confirmationCode" + accessibilityLabel="Confirmation code" + accessibilityHint="Input confirmation code for account deletion" /> - <Text type="lg" style={styles.description}> + <Text type="lg" style={styles.description} nativeID="password"> Please enter your password as well: </Text> <TextInput @@ -135,6 +149,9 @@ export function Component({}: {}) { secureTextEntry value={password} onChangeText={setPassword} + accessibilityLabelledBy="password" + accessibilityLabel="Password" + accessibilityHint="Input password for account deletion" /> {error ? ( <View style={styles.mt20}> @@ -149,14 +166,21 @@ export function Component({}: {}) { <> <TouchableOpacity style={[styles.btn, styles.evilBtn, styles.mt20]} - onPress={onPressConfirmDelete}> + onPress={onPressConfirmDelete} + accessibilityRole="button" + accessibilityLabel="Confirm delete account" + accessibilityHint=""> <Text type="button-lg" style={[s.white, s.bold]}> Delete my account </Text> </TouchableOpacity> <TouchableOpacity style={[styles.btn, s.mt10]} - onPress={onCancel}> + onPress={onCancel} + accessibilityRole="button" + accessibilityLabel="Cancel account deletion" + accessibilityHint="Exits account deletion process" + onAccessibilityEscape={onCancel}> <Text type="button-lg" style={pal.textLight}> Cancel </Text> diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx index 9bd572cc0..c26592fa9 100644 --- a/src/view/com/modals/EditProfile.tsx +++ b/src/view/com/modals/EditProfile.tsx @@ -175,6 +175,9 @@ export function Component({ onChangeText={v => setDisplayName(enforceLen(v, MAX_DISPLAY_NAME)) } + accessible={true} + accessibilityLabel="Display name" + accessibilityHint="Edit your display name" /> </View> <View style={s.pb10}> @@ -188,6 +191,9 @@ export function Component({ multiline value={description} onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))} + accessible={true} + accessibilityLabel="Description" + accessibilityHint="Edit your profile description" /> </View> {isProcessing ? ( @@ -198,7 +204,10 @@ export function Component({ <TouchableOpacity testID="editProfileSaveBtn" style={s.mt10} - onPress={onPressSave}> + onPress={onPressSave} + accessibilityRole="button" + accessibilityLabel="Save" + accessibilityHint="Saves any changes to your profile"> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} @@ -211,7 +220,11 @@ export function Component({ <TouchableOpacity testID="editProfileCancelBtn" style={s.mt5} - onPress={onPressCancel}> + onPress={onPressCancel} + accessibilityRole="button" + accessibilityLabel="Cancel profile editing" + accessibilityHint="" + onAccessibilityEscape={onPressCancel}> <View style={[styles.btn]}> <Text style={[s.black, s.bold, pal.text]}>Cancel</Text> </View> diff --git a/src/view/com/modals/InviteCodes.tsx b/src/view/com/modals/InviteCodes.tsx index 992439ebc..52d6fa46a 100644 --- a/src/view/com/modals/InviteCodes.tsx +++ b/src/view/com/modals/InviteCodes.tsx @@ -87,6 +87,7 @@ const InviteCode = observer( ({testID, code, used}: {testID: string; code: string; used?: boolean}) => { const pal = usePalette('default') const store = useStores() + const {invitesAvailable} = store.me const onPress = React.useCallback(() => { Clipboard.setString(code) @@ -98,7 +99,14 @@ const InviteCode = observer( <TouchableOpacity testID={testID} style={[styles.inviteCode, pal.border]} - onPress={onPress}> + onPress={onPress} + accessibilityRole="button" + accessibilityLabel={ + invitesAvailable === 1 + ? 'Invite codes: 1 available' + : `Invite codes: ${invitesAvailable} available` + } + accessibilityHint="Opens list of invite codes"> <Text testID={`${testID}-code`} type={used ? 'md' : 'md-bold'} diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index de748b3a8..e850c9f21 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -53,6 +53,7 @@ function Modal({modal}: {modal: ModalIface}) { store.shell.closeModal() } const onInnerPress = () => { + // TODO: can we use prevent default? // do nothing, we just want to stop it from bubbling } @@ -92,8 +93,10 @@ function Modal({modal}: {modal: ModalIface}) { } return ( + // eslint-disable-next-line <TouchableWithoutFeedback onPress={onPressMask}> <View style={styles.mask}> + {/* eslint-disable-next-line */} <TouchableWithoutFeedback onPress={onInnerPress}> <View style={[ diff --git a/src/view/com/modals/ReportAccount.tsx b/src/view/com/modals/ReportAccount.tsx index e03f06bde..b59a1b699 100644 --- a/src/view/com/modals/ReportAccount.tsx +++ b/src/view/com/modals/ReportAccount.tsx @@ -110,7 +110,10 @@ export function Component({did}: {did: string}) { <TouchableOpacity testID="sendReportBtn" style={s.mt10} - onPress={onPress}> + onPress={onPress} + accessibilityRole="button" + accessibilityLabel="Report account" + accessibilityHint={`Reports account with reason ${issue}`}> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} diff --git a/src/view/com/modals/ReportPost.tsx b/src/view/com/modals/ReportPost.tsx index c2c89202b..0695eed8e 100644 --- a/src/view/com/modals/ReportPost.tsx +++ b/src/view/com/modals/ReportPost.tsx @@ -153,7 +153,10 @@ export function Component({ <TouchableOpacity testID="sendReportBtn" style={s.mt10} - onPress={onPress}> + onPress={onPress} + accessibilityRole="button" + accessibilityLabel="Report post" + accessibilityHint={`Reports post with reason ${issue}`}> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} diff --git a/src/view/com/modals/Repost.tsx b/src/view/com/modals/Repost.tsx index d5ed66b70..b1862ecbd 100644 --- a/src/view/com/modals/Repost.tsx +++ b/src/view/com/modals/Repost.tsx @@ -18,6 +18,7 @@ export function Component({ onRepost: () => void onQuote: () => void isReposted: boolean + // TODO: Add author into component }) { const store = useStores() const pal = usePalette('default') @@ -31,7 +32,10 @@ export function Component({ <TouchableOpacity testID="repostBtn" style={[styles.actionBtn]} - onPress={onRepost}> + onPress={onRepost} + accessibilityRole="button" + accessibilityLabel={isReposted ? 'Undo repost' : 'Repost'} + accessibilityHint={isReposted ? 'Remove repost' : 'Repost '}> <RepostIcon strokeWidth={2} size={24} style={s.blue3} /> <Text type="title-lg" style={[styles.actionBtnLabel, pal.text]}> {!isReposted ? 'Repost' : 'Undo repost'} @@ -40,14 +44,23 @@ export function Component({ <TouchableOpacity testID="quoteBtn" style={[styles.actionBtn]} - onPress={onQuote}> + onPress={onQuote} + accessibilityRole="button" + accessibilityLabel="Quote post" + accessibilityHint=""> <FontAwesomeIcon icon="quote-left" size={24} style={s.blue3} /> <Text type="title-lg" style={[styles.actionBtnLabel, pal.text]}> Quote Post </Text> </TouchableOpacity> </View> - <TouchableOpacity testID="cancelBtn" onPress={onPress}> + <TouchableOpacity + testID="cancelBtn" + onPress={onPress} + accessibilityRole="button" + accessibilityLabel="Cancel quote post" + accessibilityHint="" + onAccessibilityEscape={onPress}> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} diff --git a/src/view/com/modals/ServerInput.tsx b/src/view/com/modals/ServerInput.tsx index 078abbf64..13b21fe22 100644 --- a/src/view/com/modals/ServerInput.tsx +++ b/src/view/com/modals/ServerInput.tsx @@ -41,7 +41,8 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) { <TouchableOpacity testID="localDevServerButton" style={styles.btn} - onPress={() => doSelect(LOCAL_DEV_SERVICE)}> + onPress={() => doSelect(LOCAL_DEV_SERVICE)} + accessibilityRole="button"> <Text style={styles.btnText}>Local dev server</Text> <FontAwesomeIcon icon="arrow-right" @@ -50,7 +51,8 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) { </TouchableOpacity> <TouchableOpacity style={styles.btn} - onPress={() => doSelect(STAGING_SERVICE)}> + onPress={() => doSelect(STAGING_SERVICE)} + accessibilityRole="button"> <Text style={styles.btnText}>Staging</Text> <FontAwesomeIcon icon="arrow-right" @@ -61,7 +63,10 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) { ) : undefined} <TouchableOpacity style={styles.btn} - onPress={() => doSelect(PROD_SERVICE)}> + onPress={() => doSelect(PROD_SERVICE)} + accessibilityRole="button" + accessibilityLabel="Select Bluesky Social" + accessibilityHint="Sets Bluesky Social as your service provider"> <Text style={styles.btnText}>Bluesky.Social</Text> <FontAwesomeIcon icon="arrow-right" @@ -83,11 +88,23 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) { keyboardAppearance={theme.colorScheme} value={customUrl} onChangeText={setCustomUrl} + accessibilityLabel="Custom domain" + // TODO: Simplify this wording further to be understandable by everyone + accessibilityHint="Use your domain as your Bluesky client service provider" /> <TouchableOpacity testID="customServerSelectBtn" style={[pal.borderDark, pal.text, styles.textInputBtn]} - onPress={() => doSelect(customUrl)}> + onPress={() => doSelect(customUrl)} + accessibilityRole="button" + accessibilityLabel={`Confirm service. ${ + customUrl === '' + ? 'Button disabled. Input custom domain to proceed.' + : '' + }`} + accessibilityHint="" + // TODO - accessibility: Need to inform state change on failure + disabled={customUrl === ''}> <FontAwesomeIcon icon="check" style={[pal.text as FontAwesomeIconStyle, styles.checkIcon]} diff --git a/src/view/com/modals/Waitlist.tsx b/src/view/com/modals/Waitlist.tsx index 2795dcffe..7cc78a35f 100644 --- a/src/view/com/modals/Waitlist.tsx +++ b/src/view/com/modals/Waitlist.tsx @@ -77,6 +77,9 @@ export function Component({}: {}) { keyboardAppearance={theme.colorScheme} value={email} onChangeText={setEmail} + accessible={true} + accessibilityLabel="Email" + accessibilityHint="Input your email to get on the Bluesky waitlist" /> {error ? ( <View style={s.mt10}> @@ -99,7 +102,10 @@ export function Component({}: {}) { </View> ) : ( <> - <TouchableOpacity onPress={onPressSignup}> + <TouchableOpacity + onPress={onPressSignup} + accessibilityRole="button" + accessibilityHint={`Confirms signing up ${email} to the waitlist`}> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} @@ -110,7 +116,13 @@ export function Component({}: {}) { </Text> </LinearGradient> </TouchableOpacity> - <TouchableOpacity style={[styles.btn, s.mt10]} onPress={onCancel}> + <TouchableOpacity + style={[styles.btn, s.mt10]} + onPress={onCancel} + accessibilityRole="button" + accessibilityLabel="Cancel waitlist signup" + accessibilityHint={`Exits signing up for waitlist with ${email}`} + onAccessibilityEscape={onCancel}> <Text type="button-lg" style={pal.textLight}> Cancel </Text> diff --git a/src/view/com/modals/crop-image/CropImage.web.tsx b/src/view/com/modals/crop-image/CropImage.web.tsx index 8a9b4bf62..c5959cf4c 100644 --- a/src/view/com/modals/crop-image/CropImage.web.tsx +++ b/src/view/com/modals/crop-image/CropImage.web.tsx @@ -4,12 +4,13 @@ import ImageEditor from 'react-avatar-editor' import {Slider} from '@miblanchard/react-native-slider' import LinearGradient from 'react-native-linear-gradient' import {Text} from 'view/com/util/text/Text' -import {Dimensions, Image} from 'lib/media/types' +import {Dimensions} from 'lib/media/types' import {getDataUriSize} from 'lib/media/util' import {s, gradients} from 'lib/styles' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {SquareIcon, RectWideIcon, RectTallIcon} from 'lib/icons' +import {Image as RNImage} from 'react-native-image-crop-picker' enum AspectRatio { Square = 'square', @@ -30,7 +31,7 @@ export function Component({ onSelect, }: { uri: string - onSelect: (img?: Image) => void + onSelect: (img?: RNImage) => void }) { const store = useStores() const pal = usePalette('default') @@ -92,19 +93,31 @@ export function Component({ maximumValue={3} containerStyle={styles.slider} /> - <TouchableOpacity onPress={doSetAs(AspectRatio.Wide)}> + <TouchableOpacity + onPress={doSetAs(AspectRatio.Wide)} + accessibilityRole="button" + accessibilityLabel="Wide" + accessibilityHint="Sets image aspect ratio to wide"> <RectWideIcon size={24} style={as === AspectRatio.Wide ? s.blue3 : undefined} /> </TouchableOpacity> - <TouchableOpacity onPress={doSetAs(AspectRatio.Tall)}> + <TouchableOpacity + onPress={doSetAs(AspectRatio.Tall)} + accessibilityRole="button" + accessibilityLabel="Tall" + accessibilityHint="Sets image aspect ratio to tall"> <RectTallIcon size={24} style={as === AspectRatio.Tall ? s.blue3 : undefined} /> </TouchableOpacity> - <TouchableOpacity onPress={doSetAs(AspectRatio.Square)}> + <TouchableOpacity + onPress={doSetAs(AspectRatio.Square)} + accessibilityRole="button" + accessibilityLabel="Square" + accessibilityHint="Sets image aspect ratio to square"> <SquareIcon size={24} style={as === AspectRatio.Square ? s.blue3 : undefined} @@ -112,13 +125,21 @@ export function Component({ </TouchableOpacity> </View> <View style={styles.btns}> - <TouchableOpacity onPress={onPressCancel}> + <TouchableOpacity + onPress={onPressCancel} + accessibilityRole="button" + accessibilityLabel="Cancel image crop" + accessibilityHint="Exits image cropping process"> <Text type="xl" style={pal.link}> Cancel </Text> </TouchableOpacity> <View style={s.flex1} /> - <TouchableOpacity onPress={onPressDone}> + <TouchableOpacity + onPress={onPressDone} + accessibilityRole="button" + accessibilityLabel="Save image crop" + accessibilityHint="Saves image crop settings"> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index 8a6578a3c..4ca5fb01a 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -123,7 +123,8 @@ export const FeedItem = observer(function ({ testID={`feedItem-by-${item.author.handle}`} href={itemHref} title={itemTitle} - noFeedback> + noFeedback + accessible={false}> <Post uri={item.uri} initView={item.additionalPost} @@ -163,6 +164,7 @@ export const FeedItem = observer(function ({ } return ( + // eslint-disable-next-line <Link testID={`feedItem-by-${item.author.handle}`} style={[ @@ -178,8 +180,11 @@ export const FeedItem = observer(function ({ ]} href={itemHref} title={itemTitle} - noFeedback> + noFeedback + accessible={(item.isLike && authors.length === 1) || item.isRepost}> <View style={styles.layoutIcon}> + {/* TODO: Prevent conditional rendering and move toward composable + notifications for clearer accessibility labeling */} {icon === 'HeartIconSolid' ? ( <HeartIconSolid size={28} style={[styles.icon, ...iconStyle]} /> ) : ( @@ -192,17 +197,18 @@ export const FeedItem = observer(function ({ </View> <View style={styles.layoutContent}> <Pressable - onPress={authors.length > 1 ? onToggleAuthorsExpanded : () => {}}> + onPress={authors.length > 1 ? onToggleAuthorsExpanded : undefined} + accessible={false}> <CondensedAuthorsList visible={!isAuthorsExpanded} authors={authors} onToggleAuthorsExpanded={onToggleAuthorsExpanded} /> <ExpandedAuthorsList visible={isAuthorsExpanded} authors={authors} /> - <View style={styles.meta}> + <Text style={styles.meta}> <TextLink key={authors[0].href} - style={[pal.text, s.bold, styles.metaItem]} + style={[pal.text, s.bold]} href={authors[0].href} text={sanitizeDisplayName( authors[0].displayName || authors[0].handle, @@ -210,17 +216,15 @@ export const FeedItem = observer(function ({ /> {authors.length > 1 ? ( <> - <Text style={[styles.metaItem, pal.text]}>and</Text> - <Text style={[styles.metaItem, pal.text, s.bold]}> + <Text style={[pal.text]}> and </Text> + <Text style={[pal.text, s.bold]}> {authors.length - 1} {pluralize(authors.length - 1, 'other')} </Text> </> ) : undefined} - <Text style={[styles.metaItem, pal.text]}>{action}</Text> - <Text style={[styles.metaItem, pal.textLight]}> - {ago(item.indexedAt)} - </Text> - </View> + <Text style={[pal.text]}> {action}</Text> + <Text style={[pal.textLight]}> {ago(item.indexedAt)}</Text> + </Text> </Pressable> {item.isLike || item.isRepost || item.isQuote ? ( <AdditionalPostText additionalPost={item.additionalPost} /> @@ -245,7 +249,10 @@ function CondensedAuthorsList({ <View style={styles.avis}> <TouchableOpacity style={styles.expandedAuthorsCloseBtn} - onPress={onToggleAuthorsExpanded}> + onPress={onToggleAuthorsExpanded} + accessibilityRole="button" + accessibilityLabel="Hide user list" + accessibilityHint="Collapses list of users for a given notification"> <FontAwesomeIcon icon="angle-up" size={18} @@ -276,27 +283,32 @@ function CondensedAuthorsList({ ) } return ( - <View style={styles.avis}> - {authors.slice(0, MAX_AUTHORS).map(author => ( - <View key={author.href} style={s.mr5}> - <UserAvatar - size={35} - avatar={author.avatar} - moderation={author.moderation.avatar} - /> - </View> - ))} - {authors.length > MAX_AUTHORS ? ( - <Text style={[styles.aviExtraCount, pal.textLight]}> - +{authors.length - MAX_AUTHORS} - </Text> - ) : undefined} - <FontAwesomeIcon - icon="angle-down" - size={18} - style={[styles.expandedAuthorsCloseBtnIcon, pal.textLight]} - /> - </View> + <TouchableOpacity + accessibilityLabel="Show users" + accessibilityHint="Opens an expanded list of users in this notification" + onPress={onToggleAuthorsExpanded}> + <View style={styles.avis}> + {authors.slice(0, MAX_AUTHORS).map(author => ( + <View key={author.href} style={s.mr5}> + <UserAvatar + size={35} + avatar={author.avatar} + moderation={author.moderation.avatar} + /> + </View> + ))} + {authors.length > MAX_AUTHORS ? ( + <Text style={[styles.aviExtraCount, pal.textLight]}> + +{authors.length - MAX_AUTHORS} + </Text> + ) : undefined} + <FontAwesomeIcon + icon="angle-down" + size={18} + style={[styles.expandedAuthorsCloseBtnIcon, pal.textLight]} + /> + </View> + </TouchableOpacity> ) } @@ -426,9 +438,6 @@ const styles = StyleSheet.create({ paddingTop: 6, paddingBottom: 2, }, - metaItem: { - paddingRight: 3, - }, postText: { paddingBottom: 5, color: colors.black, diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx index e7d2ec104..725c44603 100644 --- a/src/view/com/pager/FeedsTabBarMobile.tsx +++ b/src/view/com/pager/FeedsTabBarMobile.tsx @@ -37,7 +37,10 @@ export const FeedsTabBar = observer( <TouchableOpacity testID="viewHeaderDrawerBtn" style={styles.tabBarAvi} - onPress={onPressAvi}> + onPress={onPressAvi} + accessibilityRole="button" + accessibilityLabel="Open navigation" + accessibilityHint="Access profile and other navigation links"> <UserAvatar avatar={store.me.avatar} size={30} /> </TouchableOpacity> <TabBar diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index fe1822acb..b886e61e8 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -180,7 +180,11 @@ export const PostThread = observer(function PostThread({ <Text type="md" style={[pal.text, s.mb10]}> The post may have been deleted. </Text> - <TouchableOpacity onPress={onPressBack}> + <TouchableOpacity + onPress={onPressBack} + accessibilityRole="button" + accessibilityLabel="Go back" + accessibilityHint="Navigates to the previous screen"> <Text type="2xl" style={pal.link}> <FontAwesomeIcon icon="angle-left" @@ -210,7 +214,11 @@ export const PostThread = observer(function PostThread({ <Text type="md" style={[pal.text, s.mb10]}> You have blocked the author or you have been blocked by the author. </Text> - <TouchableOpacity onPress={onPressBack}> + <TouchableOpacity + onPress={onPressBack} + accessibilityRole="button" + accessibilityLabel="Go back" + accessibilityHint="Navigates to the previous screen"> <Text type="2xl" style={pal.link}> <FontAwesomeIcon icon="angle-left" diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 8fdcce8ad..191151193 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -151,7 +151,12 @@ export const PostThreadItem = observer(function PostThreadItem({ moderation={item.moderation.thread}> <View style={styles.layout}> <View style={styles.layoutAvi}> - <Link href={authorHref} title={authorTitle} asAnchor> + <Link + href={authorHref} + title={authorTitle} + asAnchor + accessibilityLabel={`${item.post.author.handle}'s avatar`} + accessibilityHint=""> <UserAvatar size={52} avatar={item.post.author.avatar} @@ -183,7 +188,7 @@ export const PostThreadItem = observer(function PostThreadItem({ <View style={s.flex1} /> <PostDropdownBtn testID="postDropdownBtn" - style={styles.metaItem} + style={[styles.metaItem, s.mt2, s.px5]} itemUri={itemUri} itemCid={itemCid} itemHref={itemHref} @@ -197,7 +202,7 @@ export const PostThreadItem = observer(function PostThreadItem({ <FontAwesomeIcon icon="ellipsis-h" size={14} - style={[s.mt2, s.mr5, pal.textLight]} + style={[pal.textLight]} /> </PostDropdownBtn> </View> @@ -435,10 +440,10 @@ const styles = StyleSheet.create({ flexDirection: 'row', }, layoutAvi: { - width: 70, paddingLeft: 10, paddingTop: 10, paddingBottom: 10, + marginRight: 10, }, layoutContent: { flex: 1, diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index 4accd7aba..d8c4b9d8f 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -282,7 +282,10 @@ const ProfileHeaderLoaded = observer( <TouchableOpacity testID="profileHeaderEditProfileButton" onPress={onPressEditProfile} - style={[styles.btn, styles.mainBtn, pal.btn]}> + style={[styles.btn, styles.mainBtn, pal.btn]} + accessibilityRole="button" + accessibilityLabel="Edit profile" + accessibilityHint="Opens editor for profile display name, avatar, background image, and description"> <Text type="button" style={pal.text}> Edit Profile </Text> @@ -291,7 +294,10 @@ const ProfileHeaderLoaded = observer( <TouchableOpacity testID="unblockBtn" onPress={onPressUnblockAccount} - style={[styles.btn, styles.mainBtn, pal.btn]}> + style={[styles.btn, styles.mainBtn, pal.btn]} + accessibilityRole="button" + accessibilityLabel="Unblock" + accessibilityHint=""> <Text type="button" style={[pal.text, s.bold]}> Unblock </Text> @@ -303,7 +309,10 @@ const ProfileHeaderLoaded = observer( <TouchableOpacity testID="unfollowBtn" onPress={onPressToggleFollow} - style={[styles.btn, styles.mainBtn, pal.btn]}> + style={[styles.btn, styles.mainBtn, pal.btn]} + accessibilityRole="button" + accessibilityLabel={`Unfollow ${view.handle}`} + accessibilityHint={`Hides direct posts from ${view.handle} in your feed`}> <FontAwesomeIcon icon="check" style={[pal.text, s.mr5]} @@ -317,7 +326,10 @@ const ProfileHeaderLoaded = observer( <TouchableOpacity testID="followBtn" onPress={onPressToggleFollow} - style={[styles.btn, styles.primaryBtn]}> + style={[styles.btn, styles.primaryBtn]} + accessibilityRole="button" + accessibilityLabel={`Follow ${view.handle}`} + accessibilityHint={`Shows direct posts from ${view.handle} in your feed`}> <FontAwesomeIcon icon="plus" style={[s.white as FontAwesomeIconStyle, s.mr5]} @@ -363,7 +375,10 @@ const ProfileHeaderLoaded = observer( <TouchableOpacity testID="profileHeaderFollowersButton" style={[s.flexRow, s.mr10]} - onPress={onPressFollowers}> + onPress={onPressFollowers} + accessibilityRole="button" + accessibilityLabel={`Show ${view.handle}'s followers`} + accessibilityHint={`Shows folks following ${view.handle}`}> <Text type="md" style={[s.bold, s.mr2, pal.text]}> {formatCount(view.followersCount)} </Text> @@ -374,7 +389,10 @@ const ProfileHeaderLoaded = observer( <TouchableOpacity testID="profileHeaderFollowsButton" style={[s.flexRow, s.mr10]} - onPress={onPressFollows}> + onPress={onPressFollows} + accessibilityRole="button" + accessibilityLabel={`Show ${view.handle}'s follows`} + accessibilityHint={`Shows folks followed by ${view.handle}`}> <Text type="md" style={[s.bold, s.mr2, pal.text]}> {formatCount(view.followsCount)} </Text> @@ -382,14 +400,12 @@ const ProfileHeaderLoaded = observer( following </Text> </TouchableOpacity> - <View style={[s.flexRow, s.mr10]}> - <Text type="md" style={[s.bold, s.mr2, pal.text]}> - {view.postsCount} - </Text> + <Text type="md" style={[s.bold, pal.text]}> + {view.postsCount}{' '} <Text type="md" style={[pal.textLight]}> {pluralize(view.postsCount, 'post')} </Text> - </View> + </Text> </View> {view.descriptionRichText ? ( <RichText @@ -440,7 +456,10 @@ const ProfileHeaderLoaded = observer( {!isDesktopWeb && !hideBackButton && ( <TouchableWithoutFeedback onPress={onPressBack} - hitSlop={BACK_HITSLOP}> + hitSlop={BACK_HITSLOP} + accessibilityRole="button" + accessibilityLabel="Go back" + accessibilityHint="Navigates to the previous screen"> <View style={styles.backBtnWrapper}> <BlurView style={styles.backBtn} blurType="dark"> <FontAwesomeIcon size={18} icon="angle-left" style={s.white} /> @@ -450,7 +469,10 @@ const ProfileHeaderLoaded = observer( )} <TouchableWithoutFeedback testID="profileHeaderAviButton" - onPress={onPressAvi}> + onPress={onPressAvi} + accessibilityRole="image" + accessibilityLabel={`View ${view.handle}'s avatar`} + accessibilityHint={`Opens ${view.handle}'s avatar in an image viewer`}> <View style={[ pal.view, diff --git a/src/view/com/search/HeaderWithInput.tsx b/src/view/com/search/HeaderWithInput.tsx index cc0b90af7..bd92e974a 100644 --- a/src/view/com/search/HeaderWithInput.tsx +++ b/src/view/com/search/HeaderWithInput.tsx @@ -54,7 +54,9 @@ export function HeaderWithInput({ testID="viewHeaderBackOrMenuBtn" onPress={onPressMenu} hitSlop={MENU_HITSLOP} - style={styles.headerMenuBtn}> + style={styles.headerMenuBtn} + accessibilityLabel="Go back" + accessibilityHint="Navigates to the previous screen"> <UserAvatar size={30} avatar={store.me.avatar} /> </TouchableOpacity> <View @@ -80,9 +82,15 @@ export function HeaderWithInput({ onBlur={() => setIsInputFocused(false)} onChangeText={onChangeQuery} onSubmitEditing={onSubmitQuery} + autoFocus={true} + accessibilityRole="search" /> {query ? ( - <TouchableOpacity onPress={onPressClearQuery}> + <TouchableOpacity + onPress={onPressClearQuery} + accessibilityRole="button" + accessibilityLabel="Clear search query" + accessibilityHint=""> <FontAwesomeIcon icon="xmark" size={16} @@ -93,7 +101,9 @@ export function HeaderWithInput({ </View> {query || isInputFocused ? ( <View style={styles.headerCancelBtn}> - <TouchableOpacity onPress={onPressCancelSearchInner}> + <TouchableOpacity + onPress={onPressCancelSearchInner} + accessibilityRole="button"> <Text style={pal.text}>Cancel</Text> </TouchableOpacity> </View> @@ -110,9 +120,10 @@ const styles = StyleSheet.create({ paddingVertical: 4, }, headerMenuBtn: { - width: 40, + width: 30, height: 30, - marginLeft: 6, + borderRadius: 30, + marginHorizontal: 6, }, headerSearchContainer: { flex: 1, diff --git a/src/view/com/util/BottomSheetCustomBackdrop.tsx b/src/view/com/util/BottomSheetCustomBackdrop.tsx index e175b33a5..91379f1c9 100644 --- a/src/view/com/util/BottomSheetCustomBackdrop.tsx +++ b/src/view/com/util/BottomSheetCustomBackdrop.tsx @@ -1,5 +1,5 @@ import React, {useMemo} from 'react' -import {GestureResponderEvent, TouchableWithoutFeedback} from 'react-native' +import {TouchableWithoutFeedback} from 'react-native' import {BottomSheetBackdropProps} from '@gorhom/bottom-sheet' import Animated, { Extrapolate, @@ -8,7 +8,7 @@ import Animated, { } from 'react-native-reanimated' export function createCustomBackdrop( - onClose?: ((event: GestureResponderEvent) => void) | undefined, + onClose?: (() => void) | undefined, ): React.FC<BottomSheetBackdropProps> { const CustomBackdrop = ({animatedIndex, style}: BottomSheetBackdropProps) => { // animated variables @@ -27,7 +27,15 @@ export function createCustomBackdrop( ) return ( - <TouchableWithoutFeedback onPress={onClose}> + <TouchableWithoutFeedback + onPress={onClose} + accessibilityLabel="Close bottom drawer" + accessibilityHint="" + onAccessibilityEscape={() => { + if (onClose !== undefined) { + onClose() + } + }}> <Animated.View style={containerStyle} /> </TouchableWithoutFeedback> ) diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index 5110acf48..503e22084 100644 --- a/src/view/com/util/Link.tsx +++ b/src/view/com/util/Link.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, {ComponentProps} from 'react' import {observer} from 'mobx-react-lite' import { Linking, @@ -29,6 +29,16 @@ type Event = | React.MouseEvent<HTMLAnchorElement, MouseEvent> | GestureResponderEvent +interface Props extends ComponentProps<typeof TouchableOpacity> { + testID?: string + style?: StyleProp<ViewStyle> + href?: string + title?: string + children?: React.ReactNode + noFeedback?: boolean + asAnchor?: boolean +} + export const Link = observer(function Link({ testID, style, @@ -37,15 +47,9 @@ export const Link = observer(function Link({ children, noFeedback, asAnchor, -}: { - testID?: string - style?: StyleProp<ViewStyle> - href?: string - title?: string - children?: React.ReactNode - noFeedback?: boolean - asAnchor?: boolean -}) { + accessible, + ...props +}: Props) { const store = useStores() const navigation = useNavigation<NavigationProp>() @@ -64,7 +68,10 @@ export const Link = observer(function Link({ testID={testID} onPress={onPress} // @ts-ignore web only -prf - href={asAnchor ? sanitizeUrl(href) : undefined}> + href={asAnchor ? sanitizeUrl(href) : undefined} + accessible={accessible} + accessibilityRole="link" + {...props}> <View style={style}> {children ? children : <Text>{title || 'link'}</Text>} </View> @@ -76,8 +83,11 @@ export const Link = observer(function Link({ testID={testID} style={style} onPress={onPress} + accessible={accessible} + accessibilityRole="link" // @ts-ignore web only -prf - href={asAnchor ? sanitizeUrl(href) : undefined}> + href={asAnchor ? sanitizeUrl(href) : undefined} + {...props}> {children ? children : <Text>{title || 'link'}</Text>} </TouchableOpacity> ) diff --git a/src/view/com/util/Picker.tsx b/src/view/com/util/Picker.tsx deleted file mode 100644 index 9007cb1f0..000000000 --- a/src/view/com/util/Picker.tsx +++ /dev/null @@ -1,157 +0,0 @@ -// TODO: replaceme with something in the design system - -import React, {useRef} from 'react' -import { - StyleProp, - StyleSheet, - TextStyle, - TouchableOpacity, - TouchableWithoutFeedback, - View, - ViewStyle, -} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import RootSiblings from 'react-native-root-siblings' -import {Text} from './text/Text' -import {colors} from 'lib/styles' - -interface PickerItem { - value: string - label: string -} - -interface PickerOpts { - style?: StyleProp<ViewStyle> - labelStyle?: StyleProp<TextStyle> - iconStyle?: FontAwesomeIconStyle - items: PickerItem[] - value: string - onChange: (value: string) => void - enabled?: boolean -} - -const MENU_WIDTH = 200 - -export function Picker({ - style, - labelStyle, - iconStyle, - items, - value, - onChange, - enabled, -}: PickerOpts) { - const ref = useRef<View>(null) - const valueLabel = items.find(item => item.value === value)?.label || value - const onPress = () => { - if (!enabled) { - return - } - ref.current?.measure( - ( - _x: number, - _y: number, - width: number, - height: number, - pageX: number, - pageY: number, - ) => { - createDropdownMenu(pageX, pageY + height, MENU_WIDTH, items, onChange) - }, - ) - } - return ( - <TouchableWithoutFeedback onPress={onPress}> - <View style={[styles.outer, style]} ref={ref}> - <View style={styles.label}> - <Text style={labelStyle}>{valueLabel}</Text> - </View> - <FontAwesomeIcon icon="angle-down" style={[styles.icon, iconStyle]} /> - </View> - </TouchableWithoutFeedback> - ) -} - -function createDropdownMenu( - x: number, - y: number, - width: number, - items: PickerItem[], - onChange: (value: string) => void, -): RootSiblings { - const onPressItem = (index: number) => { - sibling.destroy() - onChange(items[index].value) - } - const onOuterPress = () => sibling.destroy() - const sibling = new RootSiblings( - ( - <> - <TouchableWithoutFeedback onPress={onOuterPress}> - <View style={styles.bg} /> - </TouchableWithoutFeedback> - <View style={[styles.menu, {left: x, top: y, width}]}> - {items.map((item, index) => ( - <TouchableOpacity - key={index} - style={[styles.menuItem, index !== 0 && styles.menuItemBorder]} - onPress={() => onPressItem(index)}> - <Text style={styles.menuItemLabel}>{item.label}</Text> - </TouchableOpacity> - ))} - </View> - </> - ), - ) - return sibling -} - -const styles = StyleSheet.create({ - outer: { - flexDirection: 'row', - alignItems: 'center', - }, - label: { - marginRight: 5, - }, - icon: {}, - bg: { - position: 'absolute', - top: 0, - right: 0, - bottom: 0, - left: 0, - backgroundColor: '#000', - opacity: 0.1, - }, - menu: { - position: 'absolute', - backgroundColor: '#fff', - borderRadius: 14, - opacity: 1, - paddingVertical: 6, - }, - menuItem: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 6, - paddingLeft: 15, - paddingRight: 30, - }, - menuItemBorder: { - borderTopWidth: 1, - borderTopColor: colors.gray2, - marginTop: 4, - paddingTop: 12, - }, - menuItemIcon: { - marginLeft: 6, - marginRight: 8, - }, - menuItemLabel: { - fontSize: 15, - }, -}) diff --git a/src/view/com/util/PostCtrls.tsx b/src/view/com/util/PostCtrls.tsx index 07a67fd8a..725f3bbbe 100644 --- a/src/view/com/util/PostCtrls.tsx +++ b/src/view/com/util/PostCtrls.tsx @@ -170,83 +170,94 @@ export function PostCtrls(opts: PostCtrlsOpts) { return ( <View style={[styles.ctrls, opts.style]}> - <View> - <TouchableOpacity - testID="replyBtn" - style={styles.ctrl} - hitSlop={HITSLOP} - onPress={opts.onPressReply}> - <CommentBottomArrow - style={[defaultCtrlColor, opts.big ? s.mt2 : styles.mt1]} - strokeWidth={3} - size={opts.big ? 20 : 15} - /> - {typeof opts.replyCount !== 'undefined' ? ( - <Text style={[defaultCtrlColor, s.ml5, s.f15]}> - {opts.replyCount} - </Text> - ) : undefined} - </TouchableOpacity> - </View> - <View> - <TouchableOpacity - testID="repostBtn" - hitSlop={HITSLOP} - onPress={onPressToggleRepostWrapper} - style={styles.ctrl}> - <RepostIcon + <TouchableOpacity + testID="replyBtn" + style={styles.ctrl} + hitSlop={HITSLOP} + onPress={opts.onPressReply} + accessibilityRole="button" + accessibilityLabel="Reply" + accessibilityHint="Opens reply composer"> + <CommentBottomArrow + style={[defaultCtrlColor, opts.big ? s.mt2 : styles.mt1]} + strokeWidth={3} + size={opts.big ? 20 : 15} + /> + {typeof opts.replyCount !== 'undefined' ? ( + <Text style={[defaultCtrlColor, s.ml5, s.f15]}> + {opts.replyCount} + </Text> + ) : undefined} + </TouchableOpacity> + <TouchableOpacity + testID="repostBtn" + hitSlop={HITSLOP} + onPress={onPressToggleRepostWrapper} + style={styles.ctrl} + accessibilityRole="button" + accessibilityLabel={opts.isReposted ? 'Undo repost' : 'Repost'} + accessibilityHint={ + opts.isReposted + ? `Remove your repost of ${opts.author}'s post` + : `Repost or quote post ${opts.author}'s post` + }> + <RepostIcon + style={ + opts.isReposted + ? (styles.ctrlIconReposted as StyleProp<ViewStyle>) + : defaultCtrlColor + } + strokeWidth={2.4} + size={opts.big ? 24 : 20} + /> + {typeof opts.repostCount !== 'undefined' ? ( + <Text + testID="repostCount" style={ opts.isReposted - ? (styles.ctrlIconReposted as StyleProp<ViewStyle>) - : defaultCtrlColor - } - strokeWidth={2.4} - size={opts.big ? 24 : 20} + ? [s.bold, s.green3, s.f15, s.ml5] + : [defaultCtrlColor, s.f15, s.ml5] + }> + {opts.repostCount} + </Text> + ) : undefined} + </TouchableOpacity> + <TouchableOpacity + testID="likeBtn" + style={styles.ctrl} + hitSlop={HITSLOP} + onPress={onPressToggleLikeWrapper} + accessibilityRole="button" + accessibilityLabel={opts.isLiked ? 'Unlike' : 'Like'} + accessibilityHint={ + opts.isReposted + ? `Removes like from ${opts.author}'s post` + : `Like ${opts.author}'s post` + }> + {opts.isLiked ? ( + <HeartIconSolid + style={styles.ctrlIconLiked as StyleProp<ViewStyle>} + size={opts.big ? 22 : 16} /> - {typeof opts.repostCount !== 'undefined' ? ( - <Text - testID="repostCount" - style={ - opts.isReposted - ? [s.bold, s.green3, s.f15, s.ml5] - : [defaultCtrlColor, s.f15, s.ml5] - }> - {opts.repostCount} - </Text> - ) : undefined} - </TouchableOpacity> - </View> - <View> - <TouchableOpacity - testID="likeBtn" - style={styles.ctrl} - hitSlop={HITSLOP} - onPress={onPressToggleLikeWrapper}> - {opts.isLiked ? ( - <HeartIconSolid - style={styles.ctrlIconLiked as StyleProp<ViewStyle>} - size={opts.big ? 22 : 16} - /> - ) : ( - <HeartIcon - style={[defaultCtrlColor, opts.big ? styles.mt1 : undefined]} - strokeWidth={3} - size={opts.big ? 20 : 16} - /> - )} - {typeof opts.likeCount !== 'undefined' ? ( - <Text - testID="likeCount" - style={ - opts.isLiked - ? [s.bold, s.red3, s.f15, s.ml5] - : [defaultCtrlColor, s.f15, s.ml5] - }> - {opts.likeCount} - </Text> - ) : undefined} - </TouchableOpacity> - </View> + ) : ( + <HeartIcon + style={[defaultCtrlColor, opts.big ? styles.mt1 : undefined]} + strokeWidth={3} + size={opts.big ? 20 : 16} + /> + )} + {typeof opts.likeCount !== 'undefined' ? ( + <Text + testID="likeCount" + style={ + opts.isLiked + ? [s.bold, s.red3, s.f15, s.ml5] + : [defaultCtrlColor, s.f15, s.ml5] + }> + {opts.likeCount} + </Text> + ) : undefined} + </TouchableOpacity> <View> {opts.big ? undefined : ( <PostDropdownBtn diff --git a/src/view/com/util/Selector.tsx b/src/view/com/util/Selector.tsx index 016ea77b8..223a069c8 100644 --- a/src/view/com/util/Selector.tsx +++ b/src/view/com/util/Selector.tsx @@ -85,6 +85,8 @@ export function Selector({ onSelect?.(index) } + const numItems = items.length + return ( <View style={[pal.view, styles.outer]} @@ -97,7 +99,9 @@ export function Selector({ <Pressable testID={`selector-${i}`} key={item} - onPress={() => onPressItem(i)}> + onPress={() => onPressItem(i)} + accessibilityLabel={`Select ${item}`} + accessibilityHint={`Select option ${i} of ${numItems}`}> <View style={styles.item} ref={itemRefs[i]}> <Text style={ diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 7f55bf773..a2e607e47 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -150,6 +150,7 @@ export function UserAvatar({ borderRadius: Math.floor(size / 2), }} source={{uri: avatar}} + accessibilityRole="image" /> ) : ( <DefaultAvatar size={size} /> @@ -167,7 +168,11 @@ export function UserAvatar({ <View style={{width: size, height: size}}> <HighPriorityImage testID="userAvatarImage" - style={{width: size, height: size, borderRadius: Math.floor(size / 2)}} + style={{ + width: size, + height: size, + borderRadius: Math.floor(size / 2), + }} contentFit="cover" source={{uri: avatar}} blurRadius={moderation?.blur ? BLUR_AMOUNT : 0} diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index 14459bf77..51cfbccbb 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -5,7 +5,6 @@ import {IconProp} from '@fortawesome/fontawesome-svg-core' import {Image} from 'expo-image' import {colors} from 'lib/styles' import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' -import {Image as TImage} from 'lib/media/types' import {useStores} from 'state/index' import { usePhotoLibraryPermission, @@ -15,6 +14,7 @@ import {DropdownButton} from './forms/DropdownButton' import {usePalette} from 'lib/hooks/usePalette' import {AvatarModeration} from 'lib/labeling/types' import {isWeb, isAndroid} from 'platform/detection' +import {Image as RNImage} from 'react-native-image-crop-picker' export function UserBanner({ banner, @@ -23,7 +23,7 @@ export function UserBanner({ }: { banner?: string | null moderation?: AvatarModeration - onSelectNewBanner?: (img: TImage | null) => void + onSelectNewBanner?: (img: RNImage | null) => void }) { const store = useStores() const pal = usePalette('default') @@ -94,6 +94,8 @@ export function UserBanner({ testID="userBannerImage" style={styles.bannerImage} source={{uri: banner}} + accessible={true} + accessibilityIgnoresInvertColors /> ) : ( <View @@ -118,6 +120,8 @@ export function UserBanner({ resizeMode="cover" source={{uri: banner}} blurRadius={moderation?.blur ? 100 : 0} + accessible={true} + accessibilityIgnoresInvertColors /> ) : ( <View diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx index 816c835cc..9c85cfa24 100644 --- a/src/view/com/util/ViewHeader.tsx +++ b/src/view/com/util/ViewHeader.tsx @@ -60,7 +60,14 @@ export const ViewHeader = observer(function ({ testID="viewHeaderDrawerBtn" onPress={canGoBack ? onPressBack : onPressMenu} hitSlop={BACK_HITSLOP} - style={canGoBack ? styles.backBtn : styles.backBtnWide}> + style={canGoBack ? styles.backBtn : styles.backBtnWide} + accessibilityRole="button" + accessibilityLabel={canGoBack ? 'Go back' : 'Go to menu'} + accessibilityHint={ + canGoBack + ? 'Navigates to the previous screen' + : 'Navigates to the menu' + }> {canGoBack ? ( <FontAwesomeIcon size={18} @@ -171,9 +178,9 @@ const styles = StyleSheet.create({ height: 30, }, backBtnWide: { - width: 40, + width: 30, height: 30, - marginLeft: 6, + paddingHorizontal: 6, }, backIcon: { marginTop: 6, diff --git a/src/view/com/util/ViewSelector.tsx b/src/view/com/util/ViewSelector.tsx index 02717053d..f9ef0945d 100644 --- a/src/view/com/util/ViewSelector.tsx +++ b/src/view/com/util/ViewSelector.tsx @@ -132,7 +132,12 @@ export function Selector({ <Pressable testID={`selector-${i}`} key={item} - onPress={() => onPressItem(i)}> + onPress={() => onPressItem(i)} + accessibilityLabel={item} + accessibilityHint={`Selects ${item}`} + // TODO: Modify the component API such that lint fails + // at the invocation site as well + > <View style={[ styles.item, diff --git a/src/view/com/util/error/ErrorMessage.tsx b/src/view/com/util/error/ErrorMessage.tsx index cc0df1b59..370f10ae3 100644 --- a/src/view/com/util/error/ErrorMessage.tsx +++ b/src/view/com/util/error/ErrorMessage.tsx @@ -47,7 +47,10 @@ export function ErrorMessage({ <TouchableOpacity testID="errorMessageTryAgainButton" style={styles.btn} - onPress={onPressTryAgain}> + onPress={onPressTryAgain} + accessibilityRole="button" + accessibilityLabel="Retry" + accessibilityHint="Retries the last action, which errored out"> <FontAwesomeIcon icon="arrows-rotate" style={{color: theme.palette.error.icon}} diff --git a/src/view/com/util/error/ErrorScreen.tsx b/src/view/com/util/error/ErrorScreen.tsx index c849e37db..a5deeb18f 100644 --- a/src/view/com/util/error/ErrorScreen.tsx +++ b/src/view/com/util/error/ErrorScreen.tsx @@ -57,7 +57,9 @@ export function ErrorScreen({ testID="errorScreenTryAgainButton" type="default" style={[styles.btn]} - onPress={onPressTryAgain}> + onPress={onPressTryAgain} + accessibilityLabel="Retry" + accessibilityHint="Retries the last action, which errored out"> <FontAwesomeIcon icon="arrows-rotate" style={pal.link as FontAwesomeIconStyle} diff --git a/src/view/com/util/fab/FABInner.tsx b/src/view/com/util/fab/FABInner.tsx index 3d44c0dd4..5eb4a6588 100644 --- a/src/view/com/util/fab/FABInner.tsx +++ b/src/view/com/util/fab/FABInner.tsx @@ -1,25 +1,19 @@ -import React from 'react' +import React, {ComponentProps} from 'react' import {observer} from 'mobx-react-lite' -import { - Animated, - GestureResponderEvent, - StyleSheet, - TouchableWithoutFeedback, -} from 'react-native' +import {Animated, StyleSheet, TouchableWithoutFeedback} from 'react-native' import LinearGradient from 'react-native-linear-gradient' import {gradients} from 'lib/styles' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {useStores} from 'state/index' import {isMobileWeb} from 'platform/detection' -type OnPress = ((event: GestureResponderEvent) => void) | undefined -export interface FABProps { +export interface FABProps + extends ComponentProps<typeof TouchableWithoutFeedback> { testID?: string icon: JSX.Element - onPress: OnPress } -export const FABInner = observer(({testID, icon, onPress}: FABProps) => { +export const FABInner = observer(({testID, icon, ...props}: FABProps) => { const store = useStores() const interp = useAnimatedValue(0) React.useEffect(() => { @@ -34,7 +28,7 @@ export const FABInner = observer(({testID, icon, onPress}: FABProps) => { transform: [{translateY: Animated.multiply(interp, 60)}], } return ( - <TouchableWithoutFeedback testID={testID} onPress={onPress}> + <TouchableWithoutFeedback testID={testID} {...props}> <Animated.View style={[styles.outer, isMobileWeb && styles.mobileWebOuter, transform]}> <LinearGradient diff --git a/src/view/com/util/forms/Button.tsx b/src/view/com/util/forms/Button.tsx index 8548860d0..3b5b00284 100644 --- a/src/view/com/util/forms/Button.tsx +++ b/src/view/com/util/forms/Button.tsx @@ -26,6 +26,7 @@ export type ButtonType = | 'secondary-light' | 'default-light' +// TODO: Enforce that button always has a label export function Button({ type = 'primary', label, @@ -131,7 +132,8 @@ export function Button({ <Pressable style={[typeOuterStyle, styles.outer, style]} onPress={onPressWrapped} - testID={testID}> + testID={testID} + accessibilityRole="button"> {label ? ( <Text type="button" style={[typeLabelStyle, labelStyle]}> {label} diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx index 725d45c1b..04346d91f 100644 --- a/src/view/com/util/forms/DropdownButton.tsx +++ b/src/view/com/util/forms/DropdownButton.tsx @@ -1,4 +1,4 @@ -import React, {useRef} from 'react' +import React, {PropsWithChildren, useMemo, useRef} from 'react' import { Dimensions, StyleProp, @@ -39,6 +39,19 @@ type MaybeDropdownItem = DropdownItem | false | undefined export type DropdownButtonType = ButtonType | 'bare' +interface DropdownButtonProps { + testID?: string + type?: DropdownButtonType + style?: StyleProp<ViewStyle> + items: MaybeDropdownItem[] + label?: string + menuWidth?: number + children?: React.ReactNode + openToRight?: boolean + rightOffset?: number + bottomOffset?: number +} + export function DropdownButton({ testID, type = 'bare', @@ -50,18 +63,7 @@ export function DropdownButton({ openToRight = false, rightOffset = 0, bottomOffset = 0, -}: { - testID?: string - type?: DropdownButtonType - style?: StyleProp<ViewStyle> - items: MaybeDropdownItem[] - label?: string - menuWidth?: number - children?: React.ReactNode - openToRight?: boolean - rightOffset?: number - bottomOffset?: number -}) { +}: PropsWithChildren<DropdownButtonProps>) { const ref1 = useRef<TouchableOpacity>(null) const ref2 = useRef<View>(null) @@ -105,6 +107,18 @@ export function DropdownButton({ ) } + const numItems = useMemo( + () => + items.filter(item => { + if (item === undefined || item === false) { + return false + } + + return isBtn(item) + }).length, + [items], + ) + if (type === 'bare') { return ( <TouchableOpacity @@ -112,7 +126,10 @@ export function DropdownButton({ style={style} onPress={onPress} hitSlop={HITSLOP} - ref={ref1}> + ref={ref1} + accessibilityRole="button" + accessibilityLabel={`Opens ${numItems} options`} + accessibilityHint={`Opens ${numItems} options`}> {children} </TouchableOpacity> ) @@ -283,9 +300,20 @@ const DropdownItems = ({ const separatorColor = theme.colorScheme === 'dark' ? pal.borderDark : pal.border + const numItems = items.filter(isBtn).length + return ( <> - <TouchableWithoutFeedback onPress={onOuterPress}> + <TouchableWithoutFeedback + onPress={onOuterPress} + // TODO: Refactor dropdown components to: + // - (On web, if not handled by React Native) use semantic <select /> + // and <option /> elements for keyboard navigation out of the box + // - (On mobile) be buttons by default, accept `label` and `nativeID` + // props, and always have an explicit label + accessibilityRole="button" + accessibilityLabel="Toggle dropdown" + accessibilityHint=""> <View style={[styles.bg]} /> </TouchableWithoutFeedback> <View @@ -301,7 +329,9 @@ const DropdownItems = ({ testID={item.testID} key={index} style={[styles.menuItem]} - onPress={() => onPressItem(index)}> + onPress={() => onPressItem(index)} + accessibilityLabel={item.label} + accessibilityHint={`Option ${index + 1} of ${numItems}`}> {item.icon && ( <FontAwesomeIcon style={styles.icon} diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx index 8c31f5614..e6aba46f3 100644 --- a/src/view/com/util/images/AutoSizedImage.tsx +++ b/src/view/com/util/images/AutoSizedImage.tsx @@ -62,12 +62,17 @@ export function AutoSizedImage({ onLongPress={onLongPress} onPressIn={onPressIn} delayPressIn={DELAY_PRESS_IN} - style={[styles.container, style]}> + style={[styles.container, style]} + accessible={true} + accessibilityLabel="Share image" + accessibilityHint="Opens ways of sharing image"> <Image style={[styles.image, {aspectRatio}]} source={uri} accessible={true} // Must set for `accessibilityLabel` to work + accessibilityIgnoresInvertColors accessibilityLabel={alt} + accessibilityHint="" /> {children} </TouchableOpacity> @@ -80,7 +85,9 @@ export function AutoSizedImage({ style={[styles.image, {aspectRatio}]} source={{uri}} accessible={true} // Must set for `accessibilityLabel` to work + accessibilityIgnoresInvertColors accessibilityLabel={alt} + accessibilityHint="" /> {children} </View> diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx index 78ced0668..5b6c3384d 100644 --- a/src/view/com/util/images/Gallery.tsx +++ b/src/view/com/util/images/Gallery.tsx @@ -41,16 +41,25 @@ export const GalleryItem: FC<GalleryItemProps> = ({ delayPressIn={DELAY_PRESS_IN} onPress={() => onPress?.(index)} onPressIn={() => onPressIn?.(index)} - onLongPress={() => onLongPress?.(index)}> + onLongPress={() => onLongPress?.(index)} + accessibilityRole="button" + accessibilityLabel="View image" + accessibilityHint=""> <Image source={{uri: image.thumb}} style={imageStyle} accessible={true} accessibilityLabel={image.alt} + accessibilityHint="" + accessibilityIgnoresInvertColors /> </TouchableOpacity> {image.alt === '' ? null : ( - <Pressable onPress={onPressAltText}> + <Pressable + onPress={onPressAltText} + accessibilityRole="button" + accessibilityLabel="View alt text" + accessibilityHint="Opens modal with alt text"> <Text style={styles.alt}>ALT</Text> </Pressable> )} diff --git a/src/view/com/util/images/Image.tsx b/src/view/com/util/images/Image.tsx index e3d0d7fcc..e779fa378 100644 --- a/src/view/com/util/images/Image.tsx +++ b/src/view/com/util/images/Image.tsx @@ -8,5 +8,7 @@ export function HighPriorityImage({source, ...props}: HighPriorityImageProps) { const updatedSource = { uri: typeof source === 'object' && source ? source.uri : '', } satisfies ImageSource - return <Image source={updatedSource} {...props} /> + return ( + <Image accessibilityIgnoresInvertColors source={updatedSource} {...props} /> + ) } diff --git a/src/view/com/util/images/ImageHorzList.tsx b/src/view/com/util/images/ImageHorzList.tsx index 5c232e0b4..88494bba3 100644 --- a/src/view/com/util/images/ImageHorzList.tsx +++ b/src/view/com/util/images/ImageHorzList.tsx @@ -16,15 +16,33 @@ interface Props { } export function ImageHorzList({images, onPress, style}: Props) { + const numImages = images.length return ( <View style={[styles.flexRow, style]}> {images.map(({thumb, alt}, i) => ( - <TouchableWithoutFeedback key={i} onPress={() => onPress?.(i)}> + <TouchableWithoutFeedback + key={i} + onPress={() => onPress?.(i)} + accessible={true} + accessibilityLabel={`Open image ${i} of ${numImages}`} + accessibilityHint="Opens image in viewer" + accessibilityActions={[{name: 'press', label: 'Press'}]} + onAccessibilityAction={action => { + switch (action.nativeEvent.actionName) { + case 'press': + onPress?.(0) + break + default: + break + } + }}> <Image source={{uri: thumb}} style={styles.image} accessible={true} - accessibilityLabel={alt} + accessibilityIgnoresInvertColors + accessibilityHint={alt} + accessibilityLabel="" /> </TouchableWithoutFeedback> ))} diff --git a/src/view/com/util/load-latest/LoadLatestBtn.web.tsx b/src/view/com/util/load-latest/LoadLatestBtn.web.tsx index 1b6f18b62..839685029 100644 --- a/src/view/com/util/load-latest/LoadLatestBtn.web.tsx +++ b/src/view/com/util/load-latest/LoadLatestBtn.web.tsx @@ -23,7 +23,10 @@ export const LoadLatestBtn = ({ <TouchableOpacity style={[pal.view, pal.borderDark, styles.loadLatest]} onPress={onPress} - hitSlop={HITSLOP}> + hitSlop={HITSLOP} + accessibilityRole="button" + accessibilityLabel={`Load new ${label}`} + accessibilityHint=""> <Text type="md-bold" style={pal.text}> <UpIcon size={16} strokeWidth={1} style={[pal.text, styles.icon]} /> Load new {label} diff --git a/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx b/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx index 75a812760..5279696a2 100644 --- a/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx +++ b/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx @@ -23,7 +23,10 @@ export const LoadLatestBtn = observer( }, ]} onPress={onPress} - hitSlop={HITSLOP}> + hitSlop={HITSLOP} + accessibilityRole="button" + accessibilityLabel={`Load new ${label}`} + accessibilityHint={`Loads new ${label}`}> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} diff --git a/src/view/com/util/moderation/ContentHider.tsx b/src/view/com/util/moderation/ContentHider.tsx index 74fb479ad..0f3e47d61 100644 --- a/src/view/com/util/moderation/ContentHider.tsx +++ b/src/view/com/util/moderation/ContentHider.tsx @@ -55,7 +55,14 @@ export function ContentHider({ </Text> <TouchableOpacity style={styles.showBtn} - onPress={() => setOverride(v => !v)}> + onPress={() => setOverride(v => !v)} + accessibilityLabel={override ? 'Hide post' : 'Show post'} + // TODO: The text labelling should be split up so controls have unique roles + accessibilityHint={ + override + ? 'Re-hide post' + : 'Shows post hidden based on your moderation settings' + }> <Text type="md" style={pal.link}> {override ? 'Hide' : 'Show'} </Text> diff --git a/src/view/com/util/moderation/PostHider.tsx b/src/view/com/util/moderation/PostHider.tsx index b3c4c9593..2cc7ea62b 100644 --- a/src/view/com/util/moderation/PostHider.tsx +++ b/src/view/com/util/moderation/PostHider.tsx @@ -46,7 +46,8 @@ export function PostHider({ </Text> <TouchableOpacity style={styles.showBtn} - onPress={() => setOverride(v => !v)}> + onPress={() => setOverride(v => !v)} + accessibilityRole="button"> <Text type="md" style={pal.link}> {override ? 'Hide' : 'Show'} post </Text> diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index 6a7759840..929c85adc 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -136,7 +136,10 @@ export function PostEmbeds({ <Pressable onPress={() => { onPressAltText(alt) - }}> + }} + accessibilityRole="button" + accessibilityLabel="View alt text" + accessibilityHint="Opens modal with alt text"> <Text style={styles.alt}>ALT</Text> </Pressable> )} diff --git a/src/view/screens/AppPasswords.tsx b/src/view/screens/AppPasswords.tsx index 4e20558b7..a4bea68f7 100644 --- a/src/view/screens/AppPasswords.tsx +++ b/src/view/screens/AppPasswords.tsx @@ -184,7 +184,10 @@ function AppPassword({ <TouchableOpacity testID={testID} style={[styles.item, pal.border]} - onPress={onDelete}> + onPress={onDelete} + accessibilityRole="button" + accessibilityLabel="Delete" + accessibilityHint="Deletes app password"> <Text type="md-bold" style={pal.text}> {name} </Text> @@ -250,7 +253,6 @@ const styles = StyleSheet.create({ pr10: { marginRight: 10, }, - btnContainer: { flexDirection: 'row', justifyContent: 'center', diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 53bef813d..ba9b05c43 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -226,6 +226,9 @@ const FeedPage = observer( testID="composeFAB" onPress={onPressCompose} icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} + accessibilityRole="button" + accessibilityLabel="Compose" + accessibilityHint="Opens post composer" /> </View> ) diff --git a/src/view/screens/Log.tsx b/src/view/screens/Log.tsx index 8e0fe8dd3..4a747e5bf 100644 --- a/src/view/screens/Log.tsx +++ b/src/view/screens/Log.tsx @@ -46,7 +46,9 @@ export const LogScreen = observer(function Log({}: NativeStackScreenProps< <View key={`entry-${entry.id}`}> <TouchableOpacity style={[styles.entry, pal.border, pal.view]} - onPress={toggler(entry.id)}> + onPress={toggler(entry.id)} + accessibilityLabel="View debug entry" + accessibilityHint="Opens additional details for a debug entry"> {entry.type === 'debug' ? ( <FontAwesomeIcon icon="info" /> ) : ( diff --git a/src/view/screens/SearchMobile.tsx b/src/view/screens/SearchMobile.tsx index 4522d79ee..6152038d3 100644 --- a/src/view/screens/SearchMobile.tsx +++ b/src/view/screens/SearchMobile.tsx @@ -118,10 +118,10 @@ export const SearchScreen = withAuthRequired( }, []) return ( - <TouchableWithoutFeedback onPress={onPress}> + <TouchableWithoutFeedback onPress={onPress} accessible={false}> <View style={[pal.view, styles.container]}> <HeaderWithInput - isInputFocused={isInputFocused} + isInputFocused={true} query={query} setIsInputFocused={setIsInputFocused} onChangeQuery={onChangeQuery} diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index ef02e8189..4d21f8e2c 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -161,7 +161,9 @@ export const SettingsScreen = withAuthRequired( <Link href={`/profile/${store.me.handle}`} title="Your profile" - noFeedback> + noFeedback + accessibilityLabel={`Signed in as ${store.me.handle}`} + accessibilityHint="Double tap to sign out"> <View style={[pal.view, styles.linkCard]}> <View style={styles.avi}> <UserAvatar size={40} avatar={store.me.avatar} /> @@ -176,7 +178,10 @@ export const SettingsScreen = withAuthRequired( </View> <TouchableOpacity testID="signOutBtn" - onPress={isSwitching ? undefined : onPressSignout}> + onPress={isSwitching ? undefined : onPressSignout} + accessibilityRole="button" + accessibilityLabel="Sign out" + accessibilityHint={`Signs ${store.me.displayName} out of Bluesky`}> <Text type="lg" style={pal.link}> Sign out </Text> @@ -191,7 +196,10 @@ export const SettingsScreen = withAuthRequired( style={[pal.view, styles.linkCard, isSwitching && styles.dimmed]} onPress={ isSwitching ? undefined : () => onPressSwitchAccount(account) - }> + } + accessibilityRole="button" + accessibilityLabel={`Switch to ${account.handle}`} + accessibilityHint="Switches the account you are logged in to"> <View style={styles.avi}> <UserAvatar size={40} avatar={account.aviUrl} /> </View> @@ -209,7 +217,10 @@ export const SettingsScreen = withAuthRequired( <TouchableOpacity testID="switchToNewAccountBtn" style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - onPress={isSwitching ? undefined : onPressAddAccount}> + onPress={isSwitching ? undefined : onPressAddAccount} + accessibilityRole="button" + accessibilityLabel="Add account" + accessibilityHint="Create a new Bluesky account"> <View style={[styles.iconContainer, pal.btn]}> <FontAwesomeIcon icon="plus" @@ -229,7 +240,10 @@ export const SettingsScreen = withAuthRequired( <TouchableOpacity testID="inviteFriendBtn" style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - onPress={isSwitching ? undefined : onPressInviteCodes}> + onPress={isSwitching ? undefined : onPressInviteCodes} + accessibilityRole="button" + accessibilityLabel="Invite" + accessibilityHint="Opens invite code list"> <View style={[ styles.iconContainer, @@ -260,7 +274,9 @@ export const SettingsScreen = withAuthRequired( <TouchableOpacity testID="contentFilteringBtn" style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - onPress={isSwitching ? undefined : onPressContentFiltering}> + onPress={isSwitching ? undefined : onPressContentFiltering} + accessibilityHint="Content moderation" + accessibilityLabel="Opens configurable content moderation settings"> <View style={[styles.iconContainer, pal.btn]}> <FontAwesomeIcon icon="eye" @@ -308,7 +324,10 @@ export const SettingsScreen = withAuthRequired( <TouchableOpacity testID="changeHandleBtn" style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - onPress={isSwitching ? undefined : onPressChangeHandle}> + onPress={isSwitching ? undefined : onPressChangeHandle} + accessibilityRole="button" + accessibilityLabel="Change handle" + accessibilityHint="Choose a new Bluesky username or create"> <View style={[styles.iconContainer, pal.btn]}> <FontAwesomeIcon icon="at" @@ -327,7 +346,11 @@ export const SettingsScreen = withAuthRequired( </Text> <TouchableOpacity style={[pal.view, styles.linkCard]} - onPress={onPressDeleteAccount}> + onPress={onPressDeleteAccount} + accessible={true} + accessibilityRole="button" + accessibilityLabel="Delete account" + accessibilityHint="Opens modal for account deletion confirmation. Requires email code."> <View style={[styles.iconContainer, dangerBg]}> <FontAwesomeIcon icon={['far', 'trash-can']} diff --git a/src/view/shell/Composer.tsx b/src/view/shell/Composer.tsx index e0a75090d..e87fea647 100644 --- a/src/view/shell/Composer.tsx +++ b/src/view/shell/Composer.tsx @@ -56,7 +56,10 @@ export const Composer = observer( } return ( - <Animated.View style={[styles.wrapper, pal.view, wrapperAnimStyle]}> + <Animated.View + style={[styles.wrapper, pal.view, wrapperAnimStyle]} + aria-modal + accessibilityViewIsModal> <ComposePost replyTo={replyTo} onPost={onPost} diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx index 0e5b82423..1f458472c 100644 --- a/src/view/shell/Composer.web.tsx +++ b/src/view/shell/Composer.web.tsx @@ -31,7 +31,7 @@ export const Composer = observer( } return ( - <View style={styles.mask}> + <View style={styles.mask} aria-modal accessibilityViewIsModal> <View style={[styles.container, pal.view, pal.border]}> <ComposePost replyTo={replyTo} diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index 81ee005c8..404374b95 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, {ComponentProps} from 'react' import { Linking, SafeAreaView, @@ -50,6 +50,8 @@ export const DrawerContent = observer(() => { const {isAtHome, isAtSearch, isAtNotifications, isAtMyProfile} = useNavigationTabState() + const {notifications} = store.me + // events // = @@ -120,7 +122,11 @@ export const DrawerContent = observer(() => { ]}> <SafeAreaView style={s.flex1}> <View style={styles.main}> - <TouchableOpacity testID="profileCardButton" onPress={onPressProfile}> + <TouchableOpacity + testID="profileCardButton" + accessibilityLabel="Profile" + accessibilityHint="Navigates to your profile" + onPress={onPressProfile}> <UserAvatar size={80} avatar={store.me.avatar} /> <Text type="title-lg" @@ -164,6 +170,8 @@ export const DrawerContent = observer(() => { ) } label="Search" + accessibilityLabel="Search" + accessibilityHint="Search through users and posts" bold={isAtSearch} onPress={onPressSearch} /> @@ -184,6 +192,8 @@ export const DrawerContent = observer(() => { ) } label="Home" + accessibilityLabel="Home" + accessibilityHint="Navigates to default feed" bold={isAtHome} onPress={onPressHome} /> @@ -204,7 +214,13 @@ export const DrawerContent = observer(() => { ) } label="Notifications" - count={store.me.notifications.unreadCountLabel} + accessibilityLabel={ + notifications.unreadCountLabel === '1' + ? 'Notifications: 1 unread notification' + : `Notifications: ${notifications.unreadCountLabel} unread notifications` + } + accessibilityHint="Opens notification feed" + count={notifications.unreadCountLabel} bold={isAtNotifications} onPress={onPressNotifications} /> @@ -225,6 +241,8 @@ export const DrawerContent = observer(() => { ) } label="Profile" + accessibilityLabel="Profile" + accessibilityHint="See profile display name, avatar, description, and other profile items" onPress={onPressProfile} /> <MenuItem @@ -236,6 +254,8 @@ export const DrawerContent = observer(() => { /> } label="Settings" + accessibilityLabel="Settings" + accessibilityHint="Manage settings for your account, like handle, content moderation, and app passwords" onPress={onPressSettings} /> </View> @@ -243,6 +263,13 @@ export const DrawerContent = observer(() => { <View style={styles.footer}> {!isWeb && ( <TouchableOpacity + accessibilityRole="button" + accessibilityLabel="Toggle dark mode" + accessibilityHint={ + theme.colorScheme === 'dark' + ? 'Sets display to light mode' + : 'Sets display to dark mode' + } onPress={onDarkmodePress} style={[ styles.footerBtn, @@ -258,6 +285,9 @@ export const DrawerContent = observer(() => { </TouchableOpacity> )} <TouchableOpacity + accessibilityRole="link" + accessibilityLabel="Send feedback" + accessibilityHint="Opens Google Forms feedback link" onPress={onPressFeedback} style={[ styles.footerBtn, @@ -281,25 +311,30 @@ export const DrawerContent = observer(() => { ) }) +interface MenuItemProps extends ComponentProps<typeof TouchableOpacity> { + icon: JSX.Element + label: string + count?: string + bold?: boolean +} + function MenuItem({ icon, label, + accessibilityLabel, count, bold, onPress, -}: { - icon: JSX.Element - label: string - count?: string - bold?: boolean - onPress: () => void -}) { +}: MenuItemProps) { const pal = usePalette('default') return ( <TouchableOpacity testID={`menuItemButton-${label}`} style={styles.menuItem} - onPress={onPress}> + onPress={onPress} + accessibilityRole="menuitem" + accessibilityLabel={accessibilityLabel} + accessibilityHint=""> <View style={[styles.menuItemIconWrapper]}> {icon} {count ? ( @@ -332,6 +367,7 @@ const InviteCodes = observer(() => { const {track} = useAnalytics() const store = useStores() const pal = usePalette('default') + const {invitesAvailable} = store.me const onPress = React.useCallback(() => { track('Menu:ItemClicked', {url: '#invite-codes'}) store.shell.closeDrawer() @@ -341,7 +377,14 @@ const InviteCodes = observer(() => { <TouchableOpacity testID="menuItemInviteCodes" style={[styles.inviteCodes]} - onPress={onPress}> + onPress={onPress} + accessibilityRole="button" + accessibilityLabel={ + invitesAvailable === 1 + ? 'Invite codes: 1 available' + : `Invite codes: ${invitesAvailable} available` + } + accessibilityHint="Opens list of invite codes"> <FontAwesomeIcon icon="ticket" style={[ diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index a7d11d81d..b32072d5a 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, {ComponentProps} from 'react' import { Animated, GestureResponderEvent, @@ -94,6 +94,8 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => { ) } onPress={onPressHome} + accessibilityLabel="Go home" + accessibilityHint="Navigates to feed home" /> <Btn testID="bottomBarSearchBtn" @@ -113,6 +115,7 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => { ) } onPress={onPressSearch} + accessibilityRole="search" /> <Btn testID="bottomBarNotificationsBtn" @@ -133,6 +136,8 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => { } onPress={onPressNotifications} notificationCount={store.me.notifications.unreadCountLabel} + accessibilityLabel="Notifications" + accessibilityHint="Navigates to notifications" /> <Btn testID="bottomBarProfileBtn" @@ -154,31 +159,43 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => { </View> } onPress={onPressProfile} + accessibilityLabel="Profile" + accessibilityHint="Navigates to profile" /> </Animated.View> ) }) +interface BtnProps + extends Pick< + ComponentProps<typeof TouchableOpacity>, + 'accessibilityRole' | 'accessibilityHint' | 'accessibilityLabel' + > { + testID?: string + icon: JSX.Element + notificationCount?: string + onPress?: (event: GestureResponderEvent) => void + onLongPress?: (event: GestureResponderEvent) => void +} + function Btn({ testID, icon, notificationCount, onPress, onLongPress, -}: { - testID?: string - icon: JSX.Element - notificationCount?: string - onPress?: (event: GestureResponderEvent) => void - onLongPress?: (event: GestureResponderEvent) => void -}) { + accessibilityHint, + accessibilityLabel, +}: BtnProps) { return ( <TouchableOpacity testID={testID} style={styles.ctrl} onPress={onLongPress ? onPress : undefined} onPressIn={onLongPress ? undefined : onPress} - onLongPress={onLongPress}> + onLongPress={onLongPress} + accessibilityLabel={accessibilityLabel} + accessibilityHint={accessibilityHint}> {notificationCount ? ( <View style={[styles.notificationCount]}> <Text style={styles.notificationCountLabel}>{notificationCount}</Text> diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index b4b219023..86f1a3ef3 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -2,7 +2,11 @@ import React from 'react' import {observer} from 'mobx-react-lite' import {StyleSheet, TouchableOpacity, View} from 'react-native' import {PressableWithHover} from 'view/com/util/PressableWithHover' -import {useNavigation, useNavigationState} from '@react-navigation/native' +import { + useLinkProps, + useNavigation, + useNavigationState, +} from '@react-navigation/native' import { FontAwesomeIcon, FontAwesomeIconStyle, @@ -59,7 +63,10 @@ function BackBtn() { <TouchableOpacity testID="viewHeaderBackOrMenuBtn" onPress={onPressBack} - style={styles.backBtn}> + style={styles.backBtn} + accessibilityRole="button" + accessibilityLabel="Go back" + accessibilityHint="Navigates to the previous screen"> <FontAwesomeIcon size={24} icon="angle-left" @@ -86,25 +93,28 @@ const NavItem = observer( } return getCurrentRoute(state).name }) + const isCurrent = isTab(currentRouteName, pathName) + const {onPress} = useLinkProps({to: href}) return ( <PressableWithHover style={styles.navItemWrapper} - hoverStyle={pal.viewLight}> - <Link href={href} style={styles.navItem}> - <View style={[styles.navItemIconWrapper]}> - {isCurrent ? iconFilled : icon} - {typeof count === 'string' && count ? ( - <Text type="button" style={styles.navItemCount}> - {count} - </Text> - ) : null} - </View> - <Text type="title" style={[isCurrent ? s.bold : s.normal, pal.text]}> - {label} - </Text> - </Link> + hoverStyle={pal.viewLight} + onPress={onPress} + accessibilityLabel={label} + accessibilityHint={`Navigates to ${label}`}> + <View style={[styles.navItemIconWrapper]}> + {isCurrent ? iconFilled : icon} + {typeof count === 'string' && count ? ( + <Text type="button" style={styles.navItemCount}> + {count} + </Text> + ) : null} + </View> + <Text type="title" style={[isCurrent ? s.bold : s.normal, pal.text]}> + {label} + </Text> </PressableWithHover> ) }, @@ -115,7 +125,12 @@ function ComposeBtn() { const onPressCompose = () => store.shell.openComposer({}) return ( - <TouchableOpacity style={[styles.newPostBtn]} onPress={onPressCompose}> + <TouchableOpacity + style={[styles.newPostBtn]} + onPress={onPressCompose} + accessibilityRole="button" + accessibilityLabel="New post" + accessibilityHint="Opens post composer"> <View style={styles.newPostBtnIconWrapper}> <ComposeIcon2 size={19} @@ -202,7 +217,7 @@ const styles = StyleSheet.create({ profileCard: { marginVertical: 10, - width: 60, + width: 90, paddingLeft: 12, }, @@ -215,21 +230,18 @@ const styles = StyleSheet.create({ }, navItemWrapper: { - paddingHorizontal: 12, - borderRadius: 8, - }, - navItem: { flexDirection: 'row', alignItems: 'center', - paddingTop: 12, - paddingBottom: 12, + paddingHorizontal: 12, + padding: 12, + borderRadius: 8, + gap: 10, }, navItemIconWrapper: { alignItems: 'center', justifyContent: 'center', width: 28, height: 28, - marginRight: 10, marginTop: 2, }, navItemCount: { diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx index 7a3388f88..142f01163 100644 --- a/src/view/shell/desktop/RightNav.tsx +++ b/src/view/shell/desktop/RightNav.tsx @@ -61,7 +61,14 @@ export const DesktopRightNav = observer(function DesktopRightNav() { <View> <TouchableOpacity style={[styles.darkModeToggle]} - onPress={onDarkmodePress}> + onPress={onDarkmodePress} + accessibilityRole="button" + accessibilityLabel="Toggle dark mode" + accessibilityHint={ + mode === 'Dark' + ? 'Sets display to light mode' + : 'Sets display to dark mode' + }> <View style={[pal.viewLight, styles.darkModeToggleIcon]}> <MoonIcon size={18} style={pal.textLight} /> </View> @@ -78,13 +85,22 @@ const InviteCodes = observer(() => { const store = useStores() const pal = usePalette('default') + const {invitesAvailable} = store.me + const onPress = React.useCallback(() => { store.shell.openModal({name: 'invite-codes'}) }, [store]) return ( <TouchableOpacity style={[styles.inviteCodes, pal.border]} - onPress={onPress}> + onPress={onPress} + accessibilityRole="button" + accessibilityLabel={ + invitesAvailable === 1 + ? 'Invite codes: 1 available' + : `Invite codes: ${invitesAvailable} available` + } + accessibilityHint="Opens list of invite codes"> <FontAwesomeIcon icon="ticket" style={[ diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx index 5504e9415..a58a68fbf 100644 --- a/src/view/shell/desktop/Search.tsx +++ b/src/view/shell/desktop/Search.tsx @@ -67,10 +67,16 @@ export const DesktopSearch = observer(function DesktopSearch() { onBlur={() => setIsInputFocused(false)} onChangeText={onChangeQuery} onSubmitEditing={onSubmit} + accessibilityRole="search" /> {query ? ( <View style={styles.cancelBtn}> - <TouchableOpacity onPress={onPressCancelSearch}> + <TouchableOpacity + onPress={onPressCancelSearch} + accessibilityRole="button" + accessibilityLabel="Cancel search" + accessibilityHint="Exits inputting search query" + onAccessibilityEscape={onPressCancelSearch}> <Text type="lg" style={[pal.link]}> Cancel </Text> diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 3d790febc..349376436 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -46,7 +46,9 @@ const ShellInner = observer(() => { {!isDesktop && store.shell.isDrawerOpen && ( <TouchableOpacity onPress={() => store.shell.closeDrawer()} - style={styles.drawerMask}> + style={styles.drawerMask} + accessibilityLabel="Close navigation footer" + accessibilityHint="Closes bottom navigation bar"> <View style={styles.drawerContainer}> <DrawerContent /> </View> |