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>
zio/stable
Ollie H 2023-05-01 18:38:47 -07:00 committed by GitHub
parent c75c888de2
commit 83959c595d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
86 changed files with 2479 additions and 1827 deletions

View File

@ -1,6 +1,6 @@
module.exports = { module.exports = {
root: true, root: true,
extends: '@react-native-community', extends: ['@react-native-community', 'plugin:react-native-a11y/ios'],
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint', 'detox'], plugins: ['@typescript-eslint', 'detox'],
ignorePatterns: [ ignorePatterns: [

View File

@ -57,8 +57,9 @@
} }
}*/ }*/
/* OLLIE: TODO -- this is not accessible */
/* Remove focus state on inputs */ /* Remove focus state on inputs */
*:focus { input:focus {
outline: 0; outline: 0;
} }
/* Remove default link styling */ /* Remove default link styling */

View File

@ -62,6 +62,7 @@
"await-lock": "^2.2.2", "await-lock": "^2.2.2",
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"email-validator": "^2.0.4", "email-validator": "^2.0.4",
"eslint-plugin-react-native-a11y": "^3.3.0",
"expo": "~48.0.15", "expo": "~48.0.15",
"expo-application": "~5.1.1", "expo-application": "~5.1.1",
"expo-build-properties": "~0.5.1", "expo-build-properties": "~0.5.1",

View File

@ -6,7 +6,7 @@ const CHECK_MARKS_RE = /[\u2705\u2713\u2714\u2611]/gu
export function sanitizeDisplayName(str: string): string { export function sanitizeDisplayName(str: string): string {
if (typeof str === 'string') { if (typeof str === 'string') {
return str.replace(CHECK_MARKS_RE, '') return str.replace(CHECK_MARKS_RE, '').trim()
} }
return '' return ''
} }

View File

@ -118,6 +118,7 @@ export const s = StyleSheet.create({
mr2: {marginRight: 2}, mr2: {marginRight: 2},
mr5: {marginRight: 5}, mr5: {marginRight: 5},
mr10: {marginRight: 10}, mr10: {marginRight: 10},
mr20: {marginRight: 20},
ml2: {marginLeft: 2}, ml2: {marginLeft: 2},
ml5: {marginLeft: 5}, ml5: {marginLeft: 5},
ml10: {marginLeft: 10}, ml10: {marginLeft: 10},
@ -149,6 +150,7 @@ export const s = StyleSheet.create({
pb5: {paddingBottom: 5}, pb5: {paddingBottom: 5},
pb10: {paddingBottom: 10}, pb10: {paddingBottom: 10},
pb20: {paddingBottom: 20}, pb20: {paddingBottom: 20},
px5: {paddingHorizontal: 5},
// flex // flex
flexRow: {flexDirection: 'row'}, flexRow: {flexDirection: 'row'},

View File

@ -28,7 +28,10 @@ export const SplashScreen = ({
<TouchableOpacity <TouchableOpacity
testID="createAccountButton" testID="createAccountButton"
style={[styles.btn, {backgroundColor: colors.blue3}]} 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]}> <Text style={[s.white, styles.btnLabel]}>
Create a new account Create a new account
</Text> </Text>
@ -36,7 +39,10 @@ export const SplashScreen = ({
<TouchableOpacity <TouchableOpacity
testID="signInButton" testID="signInButton"
style={[styles.btn, pal.btn]} 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> <Text style={[pal.text, styles.btnLabel]}>Sign in</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>

View File

@ -43,7 +43,9 @@ export const SplashScreen = ({
<TouchableOpacity <TouchableOpacity
testID="createAccountButton" testID="createAccountButton"
style={[styles.btn, {backgroundColor: colors.blue3}]} style={[styles.btn, {backgroundColor: colors.blue3}]}
onPress={onPressCreateAccount}> onPress={onPressCreateAccount}
// TODO: web accessibility
accessibilityRole="button">
<Text style={[s.white, styles.btnLabel]}> <Text style={[s.white, styles.btnLabel]}>
Create a new account Create a new account
</Text> </Text>
@ -51,7 +53,9 @@ export const SplashScreen = ({
<TouchableOpacity <TouchableOpacity
testID="signInButton" testID="signInButton"
style={[styles.btn, pal.btn]} style={[styles.btn, pal.btn]}
onPress={onPressSignin}> onPress={onPressSignin}
// TODO: web accessibility
accessibilityRole="button">
<Text style={[pal.text, styles.btnLabel]}>Sign in</Text> <Text style={[pal.text, styles.btnLabel]}>Sign in</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@ -60,7 +64,10 @@ export const SplashScreen = ({
style={[styles.notice, pal.textLight]} style={[styles.notice, pal.textLight]}
lineHeight={1.3}> lineHeight={1.3}>
Bluesky will launch soon.{' '} Bluesky will launch soon.{' '}
<TouchableOpacity onPress={onPressWaitlist}> <TouchableOpacity
onPress={onPressWaitlist}
// TODO: web accessibility
accessibilityRole="button">
<Text type="xl" style={pal.link}> <Text type="xl" style={pal.link}>
Join the waitlist Join the waitlist
</Text> </Text>

View File

@ -72,14 +72,24 @@ export const CreateAccount = observer(
{model.step === 3 && <Step3 model={model} />} {model.step === 3 && <Step3 model={model} />}
</View> </View>
<View style={[s.flexRow, s.pl20, s.pr20]}> <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}> <Text type="xl" style={pal.link}>
Back Back
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<View style={s.flex1} /> <View style={s.flex1} />
{model.canNext ? ( {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 ? ( {model.isProcessing ? (
<ActivityIndicator /> <ActivityIndicator />
) : ( ) : (
@ -91,7 +101,11 @@ export const CreateAccount = observer(
) : model.didServiceDescriptionFetchFail ? ( ) : model.didServiceDescriptionFetchFail ? (
<TouchableOpacity <TouchableOpacity
testID="retryConnectBtn" 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]}> <Text type="xl-bold" style={[pal.link, s.pr5]}>
Retry Retry
</Text> </Text>

View File

@ -57,7 +57,7 @@ export const Step1 = observer(({model}: {model: CreateAccountModel}) => {
<View> <View>
<StepHeader step="1" title="Your hosting provider" /> <StepHeader step="1" title="Your hosting provider" />
<Text style={[pal.text, s.mb10]}> <Text style={[pal.text, s.mb10]}>
This is the company that keeps you online. This is the service that keeps you online.
</Text> </Text>
<Option <Option
testID="blueskyServerBtn" testID="blueskyServerBtn"
@ -72,7 +72,7 @@ export const Step1 = observer(({model}: {model: CreateAccountModel}) => {
label="Other" label="Other"
onPress={onPressOther}> onPress={onPressOther}>
<View style={styles.otherForm}> <View style={styles.otherForm}>
<Text style={[pal.text, s.mb5]}> <Text nativeID="addressProvider" style={[pal.text, s.mb5]}>
Enter the address of your provider: Enter the address of your provider:
</Text> </Text>
<TextInput <TextInput
@ -82,6 +82,9 @@ export const Step1 = observer(({model}: {model: CreateAccountModel}) => {
value={model.serviceUrl} value={model.serviceUrl}
editable editable
onChange={onChangeServiceUrl} onChange={onChangeServiceUrl}
accessibilityHint="Input hosting provider address"
accessibilityLabel="Hosting provider address"
accessibilityLabelledBy="addressProvider"
/> />
{LOGIN_INCLUDE_DEV_SERVERS && ( {LOGIN_INCLUDE_DEV_SERVERS && (
<View style={[s.flexRow, s.mt10]}> <View style={[s.flexRow, s.mt10]}>
@ -136,7 +139,12 @@ function Option({
return ( return (
<View style={[styles.option, pal.border]}> <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.optionHeading}>
<View style={[styles.circle, pal.border]}> <View style={[styles.circle, pal.border]}>
{isSelected ? ( {isSelected ? (

View File

@ -41,6 +41,9 @@ export const Step2 = observer(({model}: {model: CreateAccountModel}) => {
value={model.inviteCode} value={model.inviteCode}
editable editable
onChange={model.setInviteCode} onChange={model.setInviteCode}
accessibilityRole="button"
accessibilityLabel="Invite code"
accessibilityHint="Input invite code to proceed"
/> />
</View> </View>
)} )}
@ -48,7 +51,11 @@ export const Step2 = observer(({model}: {model: CreateAccountModel}) => {
{!model.inviteCode && model.isInviteCodeRequired ? ( {!model.inviteCode && model.isInviteCodeRequired ? (
<Text style={[s.alignBaseline, pal.text]}> <Text style={[s.alignBaseline, pal.text]}>
Don't have an invite code?{' '} 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> <Text style={pal.link}>Join the waitlist</Text>
</TouchableWithoutFeedback>{' '} </TouchableWithoutFeedback>{' '}
to try the beta before it's publicly available. to try the beta before it's publicly available.
@ -56,7 +63,7 @@ export const Step2 = observer(({model}: {model: CreateAccountModel}) => {
) : ( ) : (
<> <>
<View style={s.pb20}> <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 Email address
</Text> </Text>
<TextInput <TextInput
@ -66,11 +73,17 @@ export const Step2 = observer(({model}: {model: CreateAccountModel}) => {
value={model.email} value={model.email}
editable editable
onChange={model.setEmail} onChange={model.setEmail}
accessibilityLabel="Email"
accessibilityHint="Input email for Bluesky waitlist"
accessibilityLabelledBy="email"
/> />
</View> </View>
<View style={s.pb20}> <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 Password
</Text> </Text>
<TextInput <TextInput
@ -81,17 +94,27 @@ export const Step2 = observer(({model}: {model: CreateAccountModel}) => {
editable editable
secureTextEntry secureTextEntry
onChange={model.setPassword} onChange={model.setPassword}
accessibilityLabel="Password"
accessibilityHint="Set password"
accessibilityLabelledBy="password"
/> />
</View> </View>
<View style={s.pb20}> <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 Legal check
</Text> </Text>
<TouchableOpacity <TouchableOpacity
testID="is13Input" testID="is13Input"
style={[styles.toggleBtn, pal.border]} 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]}> <View style={[pal.borderDark, styles.checkbox]}>
{model.is13 && ( {model.is13 && (
<FontAwesomeIcon icon="check" style={s.blue3} size={16} /> <FontAwesomeIcon icon="check" style={s.blue3} size={16} />

View File

@ -23,6 +23,9 @@ export const Step3 = observer(({model}: {model: CreateAccountModel}) => {
value={model.handle} value={model.handle}
editable editable
onChange={model.setHandle} 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]}> <Text type="lg" style={[pal.text, s.pl5, s.pt10]}>
Your full handle will be{' '} Your full handle will be{' '}

View File

@ -195,7 +195,10 @@ const ChooseAccountForm = ({
testID={`chooseAccountBtn-${account.handle}`} testID={`chooseAccountBtn-${account.handle}`}
key={account.did} key={account.did}
style={[pal.view, pal.border, styles.account]} 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 <View
style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
<View style={s.p10}> <View style={s.p10}>
@ -220,7 +223,10 @@ const ChooseAccountForm = ({
<TouchableOpacity <TouchableOpacity
testID="chooseNewAccountBtn" testID="chooseNewAccountBtn"
style={[pal.view, pal.border, styles.account, styles.accountLast]} 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]}> <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
<Text style={[styles.accountText, styles.accountTextOther]}> <Text style={[styles.accountText, styles.accountTextOther]}>
<Text type="lg" style={pal.text}> <Text type="lg" style={pal.text}>
@ -235,7 +241,11 @@ const ChooseAccountForm = ({
</View> </View>
</TouchableOpacity> </TouchableOpacity>
<View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> <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]}> <Text type="xl" style={[pal.link, s.pl5]}>
Back Back
</Text> </Text>
@ -351,7 +361,10 @@ const LoginForm = ({
<TouchableOpacity <TouchableOpacity
testID="loginSelectServiceButton" testID="loginSelectServiceButton"
style={styles.textBtn} 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]}> <Text type="xl" style={[pal.text, styles.textBtnLabel]}>
{toNiceDomain(serviceUrl)} {toNiceDomain(serviceUrl)}
</Text> </Text>
@ -386,6 +399,8 @@ const LoginForm = ({
value={identifier} value={identifier}
onChangeText={str => setIdentifier((str || '').toLowerCase())} onChangeText={str => setIdentifier((str || '').toLowerCase())}
editable={!isProcessing} editable={!isProcessing}
accessibilityLabel="Username or email address"
accessibilityHint="Input the username or email address you used at signup"
/> />
</View> </View>
<View style={[pal.borderDark, styles.groupContent]}> <View style={[pal.borderDark, styles.groupContent]}>
@ -402,14 +417,28 @@ const LoginForm = ({
autoCorrect={false} autoCorrect={false}
keyboardAppearance={theme.colorScheme} keyboardAppearance={theme.colorScheme}
secureTextEntry 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} value={password}
onChangeText={setPassword} onChangeText={setPassword}
editable={!isProcessing} editable={!isProcessing}
accessibilityLabel="Password"
accessibilityHint={
identifier === ''
? 'Input your password'
: `Input the password tied to ${identifier}`
}
/> />
<TouchableOpacity <TouchableOpacity
testID="forgotPasswordButton" testID="forgotPasswordButton"
style={styles.textInputInnerBtn} style={styles.textInputInnerBtn}
onPress={onPressForgotPassword}> onPress={onPressForgotPassword}
accessibilityRole="button"
accessibilityLabel="Forgot password"
accessibilityHint="Opens password reset form">
<Text style={pal.link}>Forgot</Text> <Text style={pal.link}>Forgot</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@ -425,7 +454,11 @@ const LoginForm = ({
</View> </View>
) : undefined} ) : undefined}
<View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> <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]}> <Text type="xl" style={[pal.link, s.pl5]}>
Back Back
</Text> </Text>
@ -434,7 +467,10 @@ const LoginForm = ({
{!serviceDescription && error ? ( {!serviceDescription && error ? (
<TouchableOpacity <TouchableOpacity
testID="loginRetryButton" testID="loginRetryButton"
onPress={onPressRetryConnect}> onPress={onPressRetryConnect}
accessibilityRole="button"
accessibilityLabel="Retry"
accessibilityHint="Retries login">
<Text type="xl-bold" style={[pal.link, s.pr5]}> <Text type="xl-bold" style={[pal.link, s.pr5]}>
Retry Retry
</Text> </Text>
@ -449,7 +485,12 @@ const LoginForm = ({
) : isProcessing ? ( ) : isProcessing ? (
<ActivityIndicator /> <ActivityIndicator />
) : isReady ? ( ) : 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]}> <Text type="xl-bold" style={[pal.link, s.pr5]}>
Next Next
</Text> </Text>
@ -539,7 +580,10 @@ const ForgotPasswordForm = ({
<TouchableOpacity <TouchableOpacity
testID="forgotPasswordSelectServiceButton" testID="forgotPasswordSelectServiceButton"
style={[pal.borderDark, styles.groupContent, styles.noTopBorder]} style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}
onPress={onPressSelectService}> onPress={onPressSelectService}
accessibilityRole="button"
accessibilityLabel="Hosting provider"
accessibilityHint="Sets hosting provider for password reset">
<FontAwesomeIcon <FontAwesomeIcon
icon="globe" icon="globe"
style={[pal.textLight, styles.groupContentIcon]} style={[pal.textLight, styles.groupContentIcon]}
@ -572,6 +616,8 @@ const ForgotPasswordForm = ({
value={email} value={email}
onChangeText={setEmail} onChangeText={setEmail}
editable={!isProcessing} editable={!isProcessing}
accessibilityLabel="Email"
accessibilityHint="Sets email for password reset"
/> />
</View> </View>
</View> </View>
@ -586,7 +632,11 @@ const ForgotPasswordForm = ({
</View> </View>
) : undefined} ) : undefined}
<View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> <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]}> <Text type="xl" style={[pal.link, s.pl5]}>
Back Back
</Text> </Text>
@ -599,7 +649,12 @@ const ForgotPasswordForm = ({
Next Next
</Text> </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]}> <Text type="xl-bold" style={[pal.link, s.pr5]}>
Next Next
</Text> </Text>
@ -699,6 +754,9 @@ const SetNewPasswordForm = ({
value={resetCode} value={resetCode}
onChangeText={setResetCode} onChangeText={setResetCode}
editable={!isProcessing} editable={!isProcessing}
accessible={true}
accessibilityLabel="Reset code"
accessibilityHint="Input code sent to your email for password reset"
/> />
</View> </View>
<View style={[pal.borderDark, styles.groupContent]}> <View style={[pal.borderDark, styles.groupContent]}>
@ -718,6 +776,9 @@ const SetNewPasswordForm = ({
value={password} value={password}
onChangeText={setPassword} onChangeText={setPassword}
editable={!isProcessing} editable={!isProcessing}
accessible={true}
accessibilityLabel="Password"
accessibilityHint="Input new password"
/> />
</View> </View>
</View> </View>
@ -732,7 +793,11 @@ const SetNewPasswordForm = ({
</View> </View>
) : undefined} ) : undefined}
<View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> <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]}> <Text type="xl" style={[pal.link, s.pl5]}>
Back Back
</Text> </Text>
@ -747,7 +812,10 @@ const SetNewPasswordForm = ({
) : ( ) : (
<TouchableOpacity <TouchableOpacity
testID="setNewPasswordButton" 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]}> <Text type="xl-bold" style={[pal.link, s.pr5]}>
Next Next
</Text> </Text>
@ -783,7 +851,11 @@ const PasswordUpdatedForm = ({onPressNext}: {onPressNext: () => void}) => {
</Text> </Text>
<View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
<View style={s.flex1} /> <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]}> <Text type="xl-bold" style={[pal.link, s.pr5]}>
Okay Okay
</Text> </Text>

View File

@ -1,27 +1,17 @@
import React from 'react' import React, {ComponentProps} from 'react'
import {StyleSheet, TextInput as RNTextInput, View} from 'react-native' import {StyleSheet, TextInput as RNTextInput, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {IconProp} from '@fortawesome/fontawesome-svg-core' import {IconProp} from '@fortawesome/fontawesome-svg-core'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useTheme} from 'lib/ThemeContext' import {useTheme} from 'lib/ThemeContext'
export function TextInput({ interface Props extends Omit<ComponentProps<typeof RNTextInput>, 'onChange'> {
testID,
icon,
value,
placeholder,
editable,
secureTextEntry,
onChange,
}: {
testID?: string testID?: string
icon: IconProp icon: IconProp
value: string
placeholder: string
editable: boolean
secureTextEntry?: boolean
onChange: (v: string) => void onChange: (v: string) => void
}) { }
export function TextInput({testID, icon, onChange, ...props}: Props) {
const theme = useTheme() const theme = useTheme()
const pal = usePalette('default') const pal = usePalette('default')
return ( return (
@ -30,15 +20,12 @@ export function TextInput({
<RNTextInput <RNTextInput
testID={testID} testID={testID}
style={[pal.text, styles.textInput]} style={[pal.text, styles.textInput]}
placeholder={placeholder}
placeholderTextColor={pal.colors.textLight} placeholderTextColor={pal.colors.textLight}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
keyboardAppearance={theme.colorScheme} keyboardAppearance={theme.colorScheme}
secureTextEntry={secureTextEntry}
value={value}
onChangeText={v => onChange(v)} onChangeText={v => onChange(v)}
editable={editable} {...props}
/> />
</View> </View>
) )

View File

@ -7,7 +7,6 @@ import {
ScrollView, ScrollView,
StyleSheet, StyleSheet,
TouchableOpacity, TouchableOpacity,
TouchableWithoutFeedback,
View, View,
} from 'react-native' } from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context' 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 {ExternalEmbed} from './ExternalEmbed'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import * as Toast from '../util/Toast' 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 {TextInput, TextInputRef} from './text-input/TextInput'
import {CharProgress} from './char-progress/CharProgress' import {CharProgress} from './char-progress/CharProgress'
import {UserAvatar} from '../util/UserAvatar' import {UserAvatar} from '../util/UserAvatar'
@ -87,27 +88,6 @@ export const ComposePost = observer(function ComposePost({
autocompleteView.setup() autocompleteView.setup()
}, [autocompleteView]) }, [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( const onPressAddLinkCard = useCallback(
(uri: string) => { (uri: string) => {
setExtLink({uri, isLoading: true}) setExtLink({uri, isLoading: true})
@ -133,7 +113,7 @@ export const ComposePost = observer(function ComposePost({
if (rt.text.trim().length === 0 && gallery.isEmpty) { if (rt.text.trim().length === 0 && gallery.isEmpty) {
setError('Did you want to say anything?') setError('Did you want to say anything?')
return false return
} }
setIsProcessing(true) setIsProcessing(true)
@ -203,133 +183,149 @@ export const ComposePost = observer(function ComposePost({
testID="composePostView" testID="composePostView"
behavior={Platform.OS === 'ios' ? 'padding' : 'height'} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.outer}> style={styles.outer}>
<TouchableWithoutFeedback onPressIn={onPressContainer}> <View style={[s.flex1, viewStyles]} aria-modal accessibilityViewIsModal>
<View style={[s.flex1, viewStyles]}> <View style={styles.topbar}>
<View style={styles.topbar}> <TouchableOpacity
<TouchableOpacity testID="composerCancelButton"
testID="composerCancelButton" onPress={hackfixOnClose}
onPress={hackfixOnClose}> onAccessibilityEscape={hackfixOnClose}
<Text style={[pal.link, s.f18]}>Cancel</Text> accessibilityRole="button"
</TouchableOpacity> accessibilityLabel="Cancel"
<View style={s.flex1} /> accessibilityHint="Closes post composer">
{isProcessing ? ( <Text style={[pal.link, s.f18]}>Cancel</Text>
<View style={styles.postBtn}> </TouchableOpacity>
<ActivityIndicator /> <View style={s.flex1} />
</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>
{isProcessing ? ( {isProcessing ? (
<View style={[pal.btn, styles.processingLine]}> <View style={styles.postBtn}>
<Text style={pal.text}>{processingState}</Text> <ActivityIndicator />
</View> </View>
) : undefined} ) : canPost ? (
{error !== '' && ( <TouchableOpacity
<View style={styles.errorLine}> testID="composerPublishBtn"
<View style={styles.errorIcon}> onPress={() => {
<FontAwesomeIcon onPressPublish(richtext)
icon="exclamation" }}
style={{color: colors.red4}} accessibilityRole="button"
size={10} accessibilityLabel={replyTo ? 'Publish reply' : 'Publish post'}
/> accessibilityHint={
</View> replyTo
<Text style={[s.red4, s.flex1]}>{error}</Text> ? '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={[styles.postBtn, pal.btn]}>
<Text style={[pal.textLight, s.f16, s.bold]}>Post</Text>
</View> </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}
<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>
<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>
))}
</View>
) : null}
<View style={[pal.border, styles.bottomBar]}>
{canSelectImages ? (
<>
<SelectPhotoBtn gallery={gallery} />
<OpenCameraBtn gallery={gallery} />
</>
) : null}
<View style={s.flex1} />
<CharProgress count={graphemeLength} />
</View>
</View> </View>
</TouchableWithoutFeedback> {isProcessing ? (
<View style={[pal.btn, styles.processingLine]}>
<Text style={pal.text}>{processingState}</Text>
</View>
) : undefined}
{error !== '' && (
<View style={styles.errorLine}>
<View style={styles.errorIcon}>
<FontAwesomeIcon
icon="exclamation"
style={{color: colors.red4}}
size={10}
/>
</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}
<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)}
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>
</View>
</KeyboardAvoidingView> </KeyboardAvoidingView>
) )
}) })

View File

@ -60,7 +60,13 @@ export const ExternalEmbed = ({
</Text> </Text>
)} )}
</View> </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} /> <FontAwesomeIcon size={18} icon="xmark" style={s.white} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>

View File

@ -13,7 +13,10 @@ export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) {
<TouchableOpacity <TouchableOpacity
testID="replyPromptBtn" testID="replyPromptBtn"
style={[pal.view, pal.border, styles.prompt]} 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} /> <UserAvatar avatar={store.me.avatar} size={38} />
<Text <Text
type="xl" type="xl"

View File

@ -107,6 +107,9 @@ export const Gallery = observer(function ({gallery}: Props) {
<View key={`selected-image-${image.path}`} style={[imageStyle]}> <View key={`selected-image-${image.path}`} style={[imageStyle]}>
<TouchableOpacity <TouchableOpacity
testID="altTextButton" testID="altTextButton"
accessibilityRole="button"
accessibilityLabel="Add alt text"
accessibilityHint="Opens modal for inputting image alt text"
onPress={() => { onPress={() => {
handleAddImageAltText(image) handleAddImageAltText(image)
}} }}
@ -116,6 +119,9 @@ export const Gallery = observer(function ({gallery}: Props) {
<View style={imageControlsSubgroupStyle}> <View style={imageControlsSubgroupStyle}>
<TouchableOpacity <TouchableOpacity
testID="cropPhotoButton" testID="cropPhotoButton"
accessibilityRole="button"
accessibilityLabel="Crop image"
accessibilityHint="Opens modal for cropping image"
onPress={() => { onPress={() => {
handleEditPhoto(image) handleEditPhoto(image)
}} }}
@ -128,6 +134,9 @@ export const Gallery = observer(function ({gallery}: Props) {
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
testID="removePhotoButton" testID="removePhotoButton"
accessibilityRole="button"
accessibilityLabel="Remove image"
accessibilityHint=""
onPress={() => handleRemovePhoto(image)} onPress={() => handleRemovePhoto(image)}
style={styles.imageControl}> style={styles.imageControl}>
<FontAwesomeIcon <FontAwesomeIcon
@ -144,6 +153,8 @@ export const Gallery = observer(function ({gallery}: Props) {
source={{ source={{
uri: image.compressed.path, uri: image.compressed.path,
}} }}
accessible={true}
accessibilityIgnoresInvertColors
/> />
</View> </View>
) : null, ) : null,

View File

@ -1,5 +1,5 @@
import React, {useCallback} from 'react' import React, {useCallback} from 'react'
import {TouchableOpacity} from 'react-native' import {TouchableOpacity, StyleSheet} from 'react-native'
import { import {
FontAwesomeIcon, FontAwesomeIcon,
FontAwesomeIconStyle, FontAwesomeIconStyle,
@ -7,7 +7,6 @@ import {
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics' import {useAnalytics} from 'lib/analytics'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {s} from 'lib/styles'
import {isDesktopWeb} from 'platform/detection' import {isDesktopWeb} from 'platform/detection'
import {openCamera} from 'lib/media/picker' import {openCamera} from 'lib/media/picker'
import {useCameraPermission} from 'lib/hooks/usePermissions' import {useCameraPermission} from 'lib/hooks/usePermissions'
@ -54,8 +53,11 @@ export function OpenCameraBtn({gallery}: Props) {
<TouchableOpacity <TouchableOpacity
testID="openCameraButton" testID="openCameraButton"
onPress={onPressTakePicture} onPress={onPressTakePicture}
style={[s.pl5]} style={styles.button}
hitSlop={HITSLOP}> hitSlop={HITSLOP}
accessibilityRole="button"
accessibilityLabel="Camera"
accessibilityHint="Opens camera on device">
<FontAwesomeIcon <FontAwesomeIcon
icon="camera" icon="camera"
style={pal.link as FontAwesomeIconStyle} style={pal.link as FontAwesomeIconStyle}
@ -64,3 +66,9 @@ export function OpenCameraBtn({gallery}: Props) {
</TouchableOpacity> </TouchableOpacity>
) )
} }
const styles = StyleSheet.create({
button: {
paddingHorizontal: 15,
},
})

View File

@ -1,12 +1,11 @@
import React, {useCallback} from 'react' import React, {useCallback} from 'react'
import {TouchableOpacity} from 'react-native' import {TouchableOpacity, StyleSheet} from 'react-native'
import { import {
FontAwesomeIcon, FontAwesomeIcon,
FontAwesomeIconStyle, FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome' } from '@fortawesome/react-native-fontawesome'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics' import {useAnalytics} from 'lib/analytics'
import {s} from 'lib/styles'
import {isDesktopWeb} from 'platform/detection' import {isDesktopWeb} from 'platform/detection'
import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions' import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions'
import {GalleryModel} from 'state/models/media/gallery' import {GalleryModel} from 'state/models/media/gallery'
@ -36,8 +35,11 @@ export function SelectPhotoBtn({gallery}: Props) {
<TouchableOpacity <TouchableOpacity
testID="openGalleryBtn" testID="openGalleryBtn"
onPress={onPressSelectPhotos} onPress={onPressSelectPhotos}
style={[s.pl5, s.pr20]} style={styles.button}
hitSlop={HITSLOP}> hitSlop={HITSLOP}
accessibilityRole="button"
accessibilityLabel="Gallery"
accessibilityHint="Opens device photo gallery">
<FontAwesomeIcon <FontAwesomeIcon
icon={['far', 'image']} icon={['far', 'image']}
style={pal.link as FontAwesomeIconStyle} style={pal.link as FontAwesomeIconStyle}
@ -46,3 +48,9 @@ export function SelectPhotoBtn({gallery}: Props) {
</TouchableOpacity> </TouchableOpacity>
) )
} }
const styles = StyleSheet.create({
button: {
paddingHorizontal: 15,
},
})

View File

@ -1,7 +1,14 @@
import React, {forwardRef, useCallback, useEffect, useRef, useMemo} from 'react' import React, {
forwardRef,
useCallback,
useRef,
useMemo,
ComponentProps,
} from 'react'
import { import {
NativeSyntheticEvent, NativeSyntheticEvent,
StyleSheet, StyleSheet,
TextInput as RNTextInput,
TextInputSelectionChangeEventData, TextInputSelectionChangeEventData,
View, View,
} from 'react-native' } from 'react-native'
@ -27,14 +34,14 @@ export interface TextInputRef {
blur: () => void blur: () => void
} }
interface TextInputProps { interface TextInputProps extends ComponentProps<typeof RNTextInput> {
richtext: RichText richtext: RichText
placeholder: string placeholder: string
suggestedLinks: Set<string> suggestedLinks: Set<string>
autocompleteView: UserAutocompleteModel autocompleteView: UserAutocompleteModel
setRichText: (v: RichText) => void setRichText: (v: RichText | ((v: RichText) => RichText)) => void
onPhotoPasted: (uri: string) => void onPhotoPasted: (uri: string) => void
onPressPublish: (richtext: RichText) => Promise<false | undefined> onPressPublish: (richtext: RichText) => Promise<void>
onSuggestedLinksChanged: (uris: Set<string>) => void onSuggestedLinksChanged: (uris: Set<string>) => void
onError: (err: string) => void onError: (err: string) => void
} }
@ -55,6 +62,7 @@ export const TextInput = forwardRef(
onPhotoPasted, onPhotoPasted,
onSuggestedLinksChanged, onSuggestedLinksChanged,
onError, onError,
...props
}: TextInputProps, }: TextInputProps,
ref, ref,
) => { ) => {
@ -65,26 +73,11 @@ export const TextInput = forwardRef(
React.useImperativeHandle(ref, () => ({ React.useImperativeHandle(ref, () => ({
focus: () => textInput.current?.focus(), 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( const onChangeText = useCallback(
async (newText: string) => { async (newText: string) => {
const newRt = new RichText({text: newText}) const newRt = new RichText({text: newText})
@ -206,8 +199,10 @@ export const TextInput = forwardRef(
placeholder={placeholder} placeholder={placeholder}
placeholderTextColor={pal.colors.textLight} placeholderTextColor={pal.colors.textLight}
keyboardAppearance={theme.colorScheme} keyboardAppearance={theme.colorScheme}
autoFocus={true}
multiline multiline
style={[pal.text, styles.textInput, styles.textInputFormatting]}> style={[pal.text, styles.textInput, styles.textInputFormatting]}
{...props}>
{textDecorated} {textDecorated}
</PasteInput> </PasteInput>
<Autocomplete <Autocomplete

View File

@ -25,9 +25,9 @@ interface TextInputProps {
placeholder: string placeholder: string
suggestedLinks: Set<string> suggestedLinks: Set<string>
autocompleteView: UserAutocompleteModel autocompleteView: UserAutocompleteModel
setRichText: (v: RichText) => void setRichText: (v: RichText | ((v: RichText) => RichText)) => void
onPhotoPasted: (uri: string) => void onPhotoPasted: (uri: string) => void
onPressPublish: (richtext: RichText) => Promise<false | undefined> onPressPublish: (richtext: RichText) => Promise<void>
onSuggestedLinksChanged: (uris: Set<string>) => void onSuggestedLinksChanged: (uris: Set<string>) => void
onError: (err: string) => void onError: (err: string) => void
} }

View File

@ -50,7 +50,9 @@ export const Autocomplete = observer(
testID="autocompleteButton" testID="autocompleteButton"
key={item.handle} key={item.handle}
style={[pal.border, styles.item]} 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}> <Text type="md-medium" style={pal.text}>
{item.displayName || item.handle} {item.displayName || item.handle}
<Text type="sm" style={pal.textLight}> <Text type="sm" style={pal.textLight}>

View File

@ -20,7 +20,11 @@ const ImageDefaultHeader = ({onRequestClose}: Props) => (
<TouchableOpacity <TouchableOpacity
style={styles.closeButton} style={styles.closeButton}
onPress={onRequestClose} 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> <Text style={styles.closeText}></Text>
</TouchableOpacity> </TouchableOpacity>
</SafeAreaView> </SafeAreaView>

View File

@ -127,7 +127,8 @@ const ImageItem = ({
<TouchableWithoutFeedback <TouchableWithoutFeedback
onPress={doubleTapToZoomEnabled ? handleDoubleTap : undefined} onPress={doubleTapToZoomEnabled ? handleDoubleTap : undefined}
onLongPress={onLongPressHandler} onLongPress={onLongPressHandler}
delayLongPress={delayLongPress}> delayLongPress={delayLongPress}
accessibilityRole="image">
<Animated.Image <Animated.Image
source={imageSrc} source={imageSrc}
style={imageStylesWithOpacity} style={imageStylesWithOpacity}

View File

@ -112,7 +112,12 @@ function ImageViewing({
} }
return ( return (
<SafeAreaView style={styles.screen} onLayout={onLayout} edges={edges}> <SafeAreaView
style={styles.screen}
onLayout={onLayout}
edges={edges}
aria-modal
accessibilityViewIsModal>
<ModalsContainer /> <ModalsContainer />
<View style={[styles.container, {opacity, backgroundColor}]}> <View style={[styles.container, {opacity, backgroundColor}]}>
<Animated.View style={[styles.header, {transform: headerTransform}]}> <Animated.View style={[styles.header, {transform: headerTransform}]}>

View File

@ -89,13 +89,25 @@ function LightboxInner({
return ( return (
<View style={styles.mask}> <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}> <View style={styles.imageCenterer}>
<Image source={imgs[index]} style={styles.image} /> <Image
accessibilityIgnoresInvertColors
source={imgs[index]}
style={styles.image}
/>
{canGoLeft && ( {canGoLeft && (
<TouchableOpacity <TouchableOpacity
onPress={onPressLeft} 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 <FontAwesomeIcon
icon="angle-left" icon="angle-left"
style={styles.icon} style={styles.icon}
@ -106,7 +118,10 @@ function LightboxInner({
{canGoRight && ( {canGoRight && (
<TouchableOpacity <TouchableOpacity
onPress={onPressRight} 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 <FontAwesomeIcon
icon="angle-right" icon="angle-right"
style={styles.icon} style={styles.icon}

View File

@ -122,12 +122,18 @@ export function Component({}: {}) {
editable={!appPassword} editable={!appPassword}
returnKeyType="done" returnKeyType="done"
onEndEditing={createAppPassword} onEndEditing={createAppPassword}
accessible={true}
accessibilityLabel="Name"
accessibilityHint="Input name for app password"
/> />
</View> </View>
) : ( ) : (
<TouchableOpacity <TouchableOpacity
style={[pal.border, styles.passwordContainer, pal.btn]} 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]}> <Text type="2xl-bold" style={[pal.text]}>
{appPassword} {appPassword}
</Text> </Text>

View File

@ -37,7 +37,8 @@ export function Component({prevAltText, onAltTextSet}: Props) {
return ( return (
<View <View
testID="altTextImageModal" 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> <Text style={[styles.title, pal.text]}>Add alt text</Text>
<TextInput <TextInput
testID="altTextImageInput" testID="altTextImageInput"
@ -46,9 +47,17 @@ export function Component({prevAltText, onAltTextSet}: Props) {
multiline multiline
value={altText} value={altText}
onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))} 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}> <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 <LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]} colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}} start={{x: 0, y: 0}}
@ -61,7 +70,11 @@ export function Component({prevAltText, onAltTextSet}: Props) {
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
testID="altTextImageCancelBtn" 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]}> <View style={[styles.button]}>
<Text type="button-lg" style={[pal.textLight]}> <Text type="button-lg" style={[pal.textLight]}>
Cancel Cancel

View File

@ -30,7 +30,12 @@ export function Component({altText}: Props) {
<View style={[styles.text, pal.viewLight]}> <View style={[styles.text, pal.viewLight]}>
<Text style={pal.text}>{altText}</Text> <Text style={pal.text}>{altText}</Text>
</View> </View>
<TouchableOpacity testID="altTextImageSaveBtn" onPress={onPress}> <TouchableOpacity
testID="altTextImageSaveBtn"
onPress={onPress}
accessibilityRole="button"
accessibilityLabel="Save"
accessibilityHint="Save alt text">
<LinearGradient <LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]} colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}} start={{x: 0, y: 0}}

View File

@ -133,7 +133,12 @@ export function Component({onChanged}: {onChanged: () => void}) {
<View style={[s.flex1, pal.view]}> <View style={[s.flex1, pal.view]}>
<View style={[styles.title, pal.border]}> <View style={[styles.title, pal.border]}>
<View style={styles.titleLeft}> <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}> <Text type="lg" style={pal.textLight}>
Cancel Cancel
</Text> </Text>
@ -148,13 +153,20 @@ export function Component({onChanged}: {onChanged: () => void}) {
) : error && !serviceDescription ? ( ) : error && !serviceDescription ? (
<TouchableOpacity <TouchableOpacity
testID="retryConnectButton" 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]}> <Text type="xl-bold" style={[pal.link, s.pr5]}>
Retry Retry
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
) : canSave ? ( ) : 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}> <Text type="2xl-medium" style={pal.link}>
Save Save
</Text> </Text>
@ -245,6 +257,9 @@ function ProvidedHandleForm({
value={handle} value={handle}
onChangeText={onChangeHandle} onChangeText={onChangeHandle}
editable={!isProcessing} editable={!isProcessing}
accessible={true}
accessibilityLabel="Handle"
accessibilityHint="Sets Bluesky username"
/> />
</View> </View>
<Text type="md" style={[pal.textLight, s.pl10, s.pt10]}> <Text type="md" style={[pal.textLight, s.pl10, s.pt10]}>
@ -253,7 +268,11 @@ function ProvidedHandleForm({
@{createFullHandle(handle, userDomain)} @{createFullHandle(handle, userDomain)}
</Text> </Text>
</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]}> <Text type="md-medium" style={[pal.link, s.pl10, s.pt5]}>
I have my own domain I have my own domain
</Text> </Text>
@ -338,7 +357,7 @@ function CustomHandleForm({
// = // =
return ( 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 Enter the domain you want to use
</Text> </Text>
<View style={[pal.btn, styles.textInputWrapper]}> <View style={[pal.btn, styles.textInputWrapper]}>
@ -356,6 +375,9 @@ function CustomHandleForm({
value={handle} value={handle}
onChangeText={onChangeHandle} onChangeText={onChangeHandle}
editable={!isProcessing} editable={!isProcessing}
accessibilityLabelledBy="customDomain"
accessibilityLabel="Custom domain"
accessibilityHint="Input your preferred hosting provider"
/> />
</View> </View>
<View style={styles.spacer} /> <View style={styles.spacer} />
@ -421,7 +443,10 @@ function CustomHandleForm({
)} )}
</Button> </Button>
<View style={styles.spacer} /> <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]}> <Text type="md-medium" style={[pal.link, s.pl10, s.pt5]}>
Nevermind, create a handle for me Nevermind, create a handle for me
</Text> </Text>

View File

@ -66,7 +66,12 @@ export function Component({
<TouchableOpacity <TouchableOpacity
testID="confirmBtn" testID="confirmBtn"
onPress={onPress} 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> <Text style={[s.white, s.bold, s.f18]}>Confirm</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}

View File

@ -34,7 +34,12 @@ export function Component({}: {}) {
<View style={styles.bottomSpacer} /> <View style={styles.bottomSpacer} />
</ScrollView> </ScrollView>
<View style={[styles.btnContainer, pal.borderDark]}> <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 <LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]} colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}} 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( const ContentLabelPref = observer(
({group}: {group: keyof typeof CONFIGURABLE_LABEL_GROUPS}) => { ({group}: {group: keyof typeof CONFIGURABLE_LABEL_GROUPS}) => {
const store = useStores() const store = useStores()
@ -67,19 +73,20 @@ const ContentLabelPref = observer(
<SelectGroup <SelectGroup
current={store.preferences.contentLabels[group]} current={store.preferences.contentLabels[group]}
onChange={v => store.preferences.setContentLabelPref(group, v)} onChange={v => store.preferences.setContentLabelPref(group, v)}
group={group}
/> />
</View> </View>
) )
}, },
) )
function SelectGroup({ interface SelectGroupProps {
current,
onChange,
}: {
current: LabelPreference current: LabelPreference
onChange: (v: LabelPreference) => void onChange: (v: LabelPreference) => void
}) { group: keyof typeof CONFIGURABLE_LABEL_GROUPS
}
function SelectGroup({current, onChange, group}: SelectGroupProps) {
return ( return (
<View style={styles.selectableBtns}> <View style={styles.selectableBtns}>
<SelectableBtn <SelectableBtn
@ -88,12 +95,14 @@ function SelectGroup({
label="Hide" label="Hide"
left left
onChange={onChange} onChange={onChange}
group={group}
/> />
<SelectableBtn <SelectableBtn
current={current} current={current}
value="warn" value="warn"
label="Warn" label="Warn"
onChange={onChange} onChange={onChange}
group={group}
/> />
<SelectableBtn <SelectableBtn
current={current} current={current}
@ -101,11 +110,22 @@ function SelectGroup({
label="Show" label="Show"
right right
onChange={onChange} onChange={onChange}
group={group}
/> />
</View> </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({ function SelectableBtn({
current, current,
value, value,
@ -113,14 +133,8 @@ function SelectableBtn({
left, left,
right, right,
onChange, onChange,
}: { group,
current: string }: SelectableBtnProps) {
value: LabelPreference
label: string
left?: boolean
right?: boolean
onChange: (v: LabelPreference) => void
}) {
const pal = usePalette('default') const pal = usePalette('default')
const palPrimary = usePalette('inverted') const palPrimary = usePalette('inverted')
return ( return (
@ -132,7 +146,10 @@ function SelectableBtn({
pal.border, pal.border,
current === value ? palPrimary.view : pal.view, 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}> <Text style={current === value ? palPrimary.text : pal.text}>
{label} {label}
</Text> </Text>

View File

@ -86,7 +86,10 @@ export function Component({}: {}) {
<> <>
<TouchableOpacity <TouchableOpacity
style={styles.mt20} style={styles.mt20}
onPress={onPressSendEmail}> onPress={onPressSendEmail}
accessibilityRole="button"
accessibilityLabel="Send email"
accessibilityHint="Sends email with confirmation code for account deletion">
<LinearGradient <LinearGradient
colors={[ colors={[
gradients.blueLight.start, gradients.blueLight.start,
@ -102,7 +105,11 @@ export function Component({}: {}) {
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.btn, s.mt10]} 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}> <Text type="button-lg" style={pal.textLight}>
Cancel Cancel
</Text> </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 Check your inbox for an email with the confirmation code to enter
below: below:
</Text> </Text>
@ -123,8 +134,11 @@ export function Component({}: {}) {
keyboardAppearance={theme.colorScheme} keyboardAppearance={theme.colorScheme}
value={confirmCode} value={confirmCode}
onChangeText={setConfirmCode} 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: Please enter your password as well:
</Text> </Text>
<TextInput <TextInput
@ -135,6 +149,9 @@ export function Component({}: {}) {
secureTextEntry secureTextEntry
value={password} value={password}
onChangeText={setPassword} onChangeText={setPassword}
accessibilityLabelledBy="password"
accessibilityLabel="Password"
accessibilityHint="Input password for account deletion"
/> />
{error ? ( {error ? (
<View style={styles.mt20}> <View style={styles.mt20}>
@ -149,14 +166,21 @@ export function Component({}: {}) {
<> <>
<TouchableOpacity <TouchableOpacity
style={[styles.btn, styles.evilBtn, styles.mt20]} 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]}> <Text type="button-lg" style={[s.white, s.bold]}>
Delete my account Delete my account
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.btn, s.mt10]} 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}> <Text type="button-lg" style={pal.textLight}>
Cancel Cancel
</Text> </Text>

View File

@ -175,6 +175,9 @@ export function Component({
onChangeText={v => onChangeText={v =>
setDisplayName(enforceLen(v, MAX_DISPLAY_NAME)) setDisplayName(enforceLen(v, MAX_DISPLAY_NAME))
} }
accessible={true}
accessibilityLabel="Display name"
accessibilityHint="Edit your display name"
/> />
</View> </View>
<View style={s.pb10}> <View style={s.pb10}>
@ -188,6 +191,9 @@ export function Component({
multiline multiline
value={description} value={description}
onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))} onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))}
accessible={true}
accessibilityLabel="Description"
accessibilityHint="Edit your profile description"
/> />
</View> </View>
{isProcessing ? ( {isProcessing ? (
@ -198,7 +204,10 @@ export function Component({
<TouchableOpacity <TouchableOpacity
testID="editProfileSaveBtn" testID="editProfileSaveBtn"
style={s.mt10} style={s.mt10}
onPress={onPressSave}> onPress={onPressSave}
accessibilityRole="button"
accessibilityLabel="Save"
accessibilityHint="Saves any changes to your profile">
<LinearGradient <LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]} colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}} start={{x: 0, y: 0}}
@ -211,7 +220,11 @@ export function Component({
<TouchableOpacity <TouchableOpacity
testID="editProfileCancelBtn" testID="editProfileCancelBtn"
style={s.mt5} style={s.mt5}
onPress={onPressCancel}> onPress={onPressCancel}
accessibilityRole="button"
accessibilityLabel="Cancel profile editing"
accessibilityHint=""
onAccessibilityEscape={onPressCancel}>
<View style={[styles.btn]}> <View style={[styles.btn]}>
<Text style={[s.black, s.bold, pal.text]}>Cancel</Text> <Text style={[s.black, s.bold, pal.text]}>Cancel</Text>
</View> </View>

View File

@ -87,6 +87,7 @@ const InviteCode = observer(
({testID, code, used}: {testID: string; code: string; used?: boolean}) => { ({testID, code, used}: {testID: string; code: string; used?: boolean}) => {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const {invitesAvailable} = store.me
const onPress = React.useCallback(() => { const onPress = React.useCallback(() => {
Clipboard.setString(code) Clipboard.setString(code)
@ -98,7 +99,14 @@ const InviteCode = observer(
<TouchableOpacity <TouchableOpacity
testID={testID} testID={testID}
style={[styles.inviteCode, pal.border]} 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 <Text
testID={`${testID}-code`} testID={`${testID}-code`}
type={used ? 'md' : 'md-bold'} type={used ? 'md' : 'md-bold'}

View File

@ -53,6 +53,7 @@ function Modal({modal}: {modal: ModalIface}) {
store.shell.closeModal() store.shell.closeModal()
} }
const onInnerPress = () => { const onInnerPress = () => {
// TODO: can we use prevent default?
// do nothing, we just want to stop it from bubbling // do nothing, we just want to stop it from bubbling
} }
@ -92,8 +93,10 @@ function Modal({modal}: {modal: ModalIface}) {
} }
return ( return (
// eslint-disable-next-line
<TouchableWithoutFeedback onPress={onPressMask}> <TouchableWithoutFeedback onPress={onPressMask}>
<View style={styles.mask}> <View style={styles.mask}>
{/* eslint-disable-next-line */}
<TouchableWithoutFeedback onPress={onInnerPress}> <TouchableWithoutFeedback onPress={onInnerPress}>
<View <View
style={[ style={[

View File

@ -110,7 +110,10 @@ export function Component({did}: {did: string}) {
<TouchableOpacity <TouchableOpacity
testID="sendReportBtn" testID="sendReportBtn"
style={s.mt10} style={s.mt10}
onPress={onPress}> onPress={onPress}
accessibilityRole="button"
accessibilityLabel="Report account"
accessibilityHint={`Reports account with reason ${issue}`}>
<LinearGradient <LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]} colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}} start={{x: 0, y: 0}}

View File

@ -153,7 +153,10 @@ export function Component({
<TouchableOpacity <TouchableOpacity
testID="sendReportBtn" testID="sendReportBtn"
style={s.mt10} style={s.mt10}
onPress={onPress}> onPress={onPress}
accessibilityRole="button"
accessibilityLabel="Report post"
accessibilityHint={`Reports post with reason ${issue}`}>
<LinearGradient <LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]} colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}} start={{x: 0, y: 0}}

View File

@ -18,6 +18,7 @@ export function Component({
onRepost: () => void onRepost: () => void
onQuote: () => void onQuote: () => void
isReposted: boolean isReposted: boolean
// TODO: Add author into component
}) { }) {
const store = useStores() const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
@ -31,7 +32,10 @@ export function Component({
<TouchableOpacity <TouchableOpacity
testID="repostBtn" testID="repostBtn"
style={[styles.actionBtn]} 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} /> <RepostIcon strokeWidth={2} size={24} style={s.blue3} />
<Text type="title-lg" style={[styles.actionBtnLabel, pal.text]}> <Text type="title-lg" style={[styles.actionBtnLabel, pal.text]}>
{!isReposted ? 'Repost' : 'Undo repost'} {!isReposted ? 'Repost' : 'Undo repost'}
@ -40,14 +44,23 @@ export function Component({
<TouchableOpacity <TouchableOpacity
testID="quoteBtn" testID="quoteBtn"
style={[styles.actionBtn]} style={[styles.actionBtn]}
onPress={onQuote}> onPress={onQuote}
accessibilityRole="button"
accessibilityLabel="Quote post"
accessibilityHint="">
<FontAwesomeIcon icon="quote-left" size={24} style={s.blue3} /> <FontAwesomeIcon icon="quote-left" size={24} style={s.blue3} />
<Text type="title-lg" style={[styles.actionBtnLabel, pal.text]}> <Text type="title-lg" style={[styles.actionBtnLabel, pal.text]}>
Quote Post Quote Post
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<TouchableOpacity testID="cancelBtn" onPress={onPress}> <TouchableOpacity
testID="cancelBtn"
onPress={onPress}
accessibilityRole="button"
accessibilityLabel="Cancel quote post"
accessibilityHint=""
onAccessibilityEscape={onPress}>
<LinearGradient <LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]} colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}} start={{x: 0, y: 0}}

View File

@ -41,7 +41,8 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) {
<TouchableOpacity <TouchableOpacity
testID="localDevServerButton" testID="localDevServerButton"
style={styles.btn} style={styles.btn}
onPress={() => doSelect(LOCAL_DEV_SERVICE)}> onPress={() => doSelect(LOCAL_DEV_SERVICE)}
accessibilityRole="button">
<Text style={styles.btnText}>Local dev server</Text> <Text style={styles.btnText}>Local dev server</Text>
<FontAwesomeIcon <FontAwesomeIcon
icon="arrow-right" icon="arrow-right"
@ -50,7 +51,8 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) {
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={styles.btn} style={styles.btn}
onPress={() => doSelect(STAGING_SERVICE)}> onPress={() => doSelect(STAGING_SERVICE)}
accessibilityRole="button">
<Text style={styles.btnText}>Staging</Text> <Text style={styles.btnText}>Staging</Text>
<FontAwesomeIcon <FontAwesomeIcon
icon="arrow-right" icon="arrow-right"
@ -61,7 +63,10 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) {
) : undefined} ) : undefined}
<TouchableOpacity <TouchableOpacity
style={styles.btn} 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> <Text style={styles.btnText}>Bluesky.Social</Text>
<FontAwesomeIcon <FontAwesomeIcon
icon="arrow-right" icon="arrow-right"
@ -83,11 +88,23 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) {
keyboardAppearance={theme.colorScheme} keyboardAppearance={theme.colorScheme}
value={customUrl} value={customUrl}
onChangeText={setCustomUrl} 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 <TouchableOpacity
testID="customServerSelectBtn" testID="customServerSelectBtn"
style={[pal.borderDark, pal.text, styles.textInputBtn]} 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 <FontAwesomeIcon
icon="check" icon="check"
style={[pal.text as FontAwesomeIconStyle, styles.checkIcon]} style={[pal.text as FontAwesomeIconStyle, styles.checkIcon]}

View File

@ -77,6 +77,9 @@ export function Component({}: {}) {
keyboardAppearance={theme.colorScheme} keyboardAppearance={theme.colorScheme}
value={email} value={email}
onChangeText={setEmail} onChangeText={setEmail}
accessible={true}
accessibilityLabel="Email"
accessibilityHint="Input your email to get on the Bluesky waitlist"
/> />
{error ? ( {error ? (
<View style={s.mt10}> <View style={s.mt10}>
@ -99,7 +102,10 @@ export function Component({}: {}) {
</View> </View>
) : ( ) : (
<> <>
<TouchableOpacity onPress={onPressSignup}> <TouchableOpacity
onPress={onPressSignup}
accessibilityRole="button"
accessibilityHint={`Confirms signing up ${email} to the waitlist`}>
<LinearGradient <LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]} colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}} start={{x: 0, y: 0}}
@ -110,7 +116,13 @@ export function Component({}: {}) {
</Text> </Text>
</LinearGradient> </LinearGradient>
</TouchableOpacity> </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}> <Text type="button-lg" style={pal.textLight}>
Cancel Cancel
</Text> </Text>

View File

@ -4,12 +4,13 @@ import ImageEditor from 'react-avatar-editor'
import {Slider} from '@miblanchard/react-native-slider' import {Slider} from '@miblanchard/react-native-slider'
import LinearGradient from 'react-native-linear-gradient' import LinearGradient from 'react-native-linear-gradient'
import {Text} from 'view/com/util/text/Text' 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 {getDataUriSize} from 'lib/media/util'
import {s, gradients} from 'lib/styles' import {s, gradients} from 'lib/styles'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {SquareIcon, RectWideIcon, RectTallIcon} from 'lib/icons' import {SquareIcon, RectWideIcon, RectTallIcon} from 'lib/icons'
import {Image as RNImage} from 'react-native-image-crop-picker'
enum AspectRatio { enum AspectRatio {
Square = 'square', Square = 'square',
@ -30,7 +31,7 @@ export function Component({
onSelect, onSelect,
}: { }: {
uri: string uri: string
onSelect: (img?: Image) => void onSelect: (img?: RNImage) => void
}) { }) {
const store = useStores() const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
@ -92,19 +93,31 @@ export function Component({
maximumValue={3} maximumValue={3}
containerStyle={styles.slider} 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 <RectWideIcon
size={24} size={24}
style={as === AspectRatio.Wide ? s.blue3 : undefined} style={as === AspectRatio.Wide ? s.blue3 : undefined}
/> />
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity onPress={doSetAs(AspectRatio.Tall)}> <TouchableOpacity
onPress={doSetAs(AspectRatio.Tall)}
accessibilityRole="button"
accessibilityLabel="Tall"
accessibilityHint="Sets image aspect ratio to tall">
<RectTallIcon <RectTallIcon
size={24} size={24}
style={as === AspectRatio.Tall ? s.blue3 : undefined} style={as === AspectRatio.Tall ? s.blue3 : undefined}
/> />
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity onPress={doSetAs(AspectRatio.Square)}> <TouchableOpacity
onPress={doSetAs(AspectRatio.Square)}
accessibilityRole="button"
accessibilityLabel="Square"
accessibilityHint="Sets image aspect ratio to square">
<SquareIcon <SquareIcon
size={24} size={24}
style={as === AspectRatio.Square ? s.blue3 : undefined} style={as === AspectRatio.Square ? s.blue3 : undefined}
@ -112,13 +125,21 @@ export function Component({
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<View style={styles.btns}> <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}> <Text type="xl" style={pal.link}>
Cancel Cancel
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<View style={s.flex1} /> <View style={s.flex1} />
<TouchableOpacity onPress={onPressDone}> <TouchableOpacity
onPress={onPressDone}
accessibilityRole="button"
accessibilityLabel="Save image crop"
accessibilityHint="Saves image crop settings">
<LinearGradient <LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]} colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}} start={{x: 0, y: 0}}

View File

@ -123,7 +123,8 @@ export const FeedItem = observer(function ({
testID={`feedItem-by-${item.author.handle}`} testID={`feedItem-by-${item.author.handle}`}
href={itemHref} href={itemHref}
title={itemTitle} title={itemTitle}
noFeedback> noFeedback
accessible={false}>
<Post <Post
uri={item.uri} uri={item.uri}
initView={item.additionalPost} initView={item.additionalPost}
@ -163,6 +164,7 @@ export const FeedItem = observer(function ({
} }
return ( return (
// eslint-disable-next-line
<Link <Link
testID={`feedItem-by-${item.author.handle}`} testID={`feedItem-by-${item.author.handle}`}
style={[ style={[
@ -178,8 +180,11 @@ export const FeedItem = observer(function ({
]} ]}
href={itemHref} href={itemHref}
title={itemTitle} title={itemTitle}
noFeedback> noFeedback
accessible={(item.isLike && authors.length === 1) || item.isRepost}>
<View style={styles.layoutIcon}> <View style={styles.layoutIcon}>
{/* TODO: Prevent conditional rendering and move toward composable
notifications for clearer accessibility labeling */}
{icon === 'HeartIconSolid' ? ( {icon === 'HeartIconSolid' ? (
<HeartIconSolid size={28} style={[styles.icon, ...iconStyle]} /> <HeartIconSolid size={28} style={[styles.icon, ...iconStyle]} />
) : ( ) : (
@ -192,17 +197,18 @@ export const FeedItem = observer(function ({
</View> </View>
<View style={styles.layoutContent}> <View style={styles.layoutContent}>
<Pressable <Pressable
onPress={authors.length > 1 ? onToggleAuthorsExpanded : () => {}}> onPress={authors.length > 1 ? onToggleAuthorsExpanded : undefined}
accessible={false}>
<CondensedAuthorsList <CondensedAuthorsList
visible={!isAuthorsExpanded} visible={!isAuthorsExpanded}
authors={authors} authors={authors}
onToggleAuthorsExpanded={onToggleAuthorsExpanded} onToggleAuthorsExpanded={onToggleAuthorsExpanded}
/> />
<ExpandedAuthorsList visible={isAuthorsExpanded} authors={authors} /> <ExpandedAuthorsList visible={isAuthorsExpanded} authors={authors} />
<View style={styles.meta}> <Text style={styles.meta}>
<TextLink <TextLink
key={authors[0].href} key={authors[0].href}
style={[pal.text, s.bold, styles.metaItem]} style={[pal.text, s.bold]}
href={authors[0].href} href={authors[0].href}
text={sanitizeDisplayName( text={sanitizeDisplayName(
authors[0].displayName || authors[0].handle, authors[0].displayName || authors[0].handle,
@ -210,17 +216,15 @@ export const FeedItem = observer(function ({
/> />
{authors.length > 1 ? ( {authors.length > 1 ? (
<> <>
<Text style={[styles.metaItem, pal.text]}>and</Text> <Text style={[pal.text]}> and </Text>
<Text style={[styles.metaItem, pal.text, s.bold]}> <Text style={[pal.text, s.bold]}>
{authors.length - 1} {pluralize(authors.length - 1, 'other')} {authors.length - 1} {pluralize(authors.length - 1, 'other')}
</Text> </Text>
</> </>
) : undefined} ) : undefined}
<Text style={[styles.metaItem, pal.text]}>{action}</Text> <Text style={[pal.text]}> {action}</Text>
<Text style={[styles.metaItem, pal.textLight]}> <Text style={[pal.textLight]}> {ago(item.indexedAt)}</Text>
{ago(item.indexedAt)} </Text>
</Text>
</View>
</Pressable> </Pressable>
{item.isLike || item.isRepost || item.isQuote ? ( {item.isLike || item.isRepost || item.isQuote ? (
<AdditionalPostText additionalPost={item.additionalPost} /> <AdditionalPostText additionalPost={item.additionalPost} />
@ -245,7 +249,10 @@ function CondensedAuthorsList({
<View style={styles.avis}> <View style={styles.avis}>
<TouchableOpacity <TouchableOpacity
style={styles.expandedAuthorsCloseBtn} style={styles.expandedAuthorsCloseBtn}
onPress={onToggleAuthorsExpanded}> onPress={onToggleAuthorsExpanded}
accessibilityRole="button"
accessibilityLabel="Hide user list"
accessibilityHint="Collapses list of users for a given notification">
<FontAwesomeIcon <FontAwesomeIcon
icon="angle-up" icon="angle-up"
size={18} size={18}
@ -276,27 +283,32 @@ function CondensedAuthorsList({
) )
} }
return ( return (
<View style={styles.avis}> <TouchableOpacity
{authors.slice(0, MAX_AUTHORS).map(author => ( accessibilityLabel="Show users"
<View key={author.href} style={s.mr5}> accessibilityHint="Opens an expanded list of users in this notification"
<UserAvatar onPress={onToggleAuthorsExpanded}>
size={35} <View style={styles.avis}>
avatar={author.avatar} {authors.slice(0, MAX_AUTHORS).map(author => (
moderation={author.moderation.avatar} <View key={author.href} style={s.mr5}>
/> <UserAvatar
</View> size={35}
))} avatar={author.avatar}
{authors.length > MAX_AUTHORS ? ( moderation={author.moderation.avatar}
<Text style={[styles.aviExtraCount, pal.textLight]}> />
+{authors.length - MAX_AUTHORS} </View>
</Text> ))}
) : undefined} {authors.length > MAX_AUTHORS ? (
<FontAwesomeIcon <Text style={[styles.aviExtraCount, pal.textLight]}>
icon="angle-down" +{authors.length - MAX_AUTHORS}
size={18} </Text>
style={[styles.expandedAuthorsCloseBtnIcon, pal.textLight]} ) : undefined}
/> <FontAwesomeIcon
</View> icon="angle-down"
size={18}
style={[styles.expandedAuthorsCloseBtnIcon, pal.textLight]}
/>
</View>
</TouchableOpacity>
) )
} }
@ -426,9 +438,6 @@ const styles = StyleSheet.create({
paddingTop: 6, paddingTop: 6,
paddingBottom: 2, paddingBottom: 2,
}, },
metaItem: {
paddingRight: 3,
},
postText: { postText: {
paddingBottom: 5, paddingBottom: 5,
color: colors.black, color: colors.black,

View File

@ -37,7 +37,10 @@ export const FeedsTabBar = observer(
<TouchableOpacity <TouchableOpacity
testID="viewHeaderDrawerBtn" testID="viewHeaderDrawerBtn"
style={styles.tabBarAvi} 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} /> <UserAvatar avatar={store.me.avatar} size={30} />
</TouchableOpacity> </TouchableOpacity>
<TabBar <TabBar

View File

@ -180,7 +180,11 @@ export const PostThread = observer(function PostThread({
<Text type="md" style={[pal.text, s.mb10]}> <Text type="md" style={[pal.text, s.mb10]}>
The post may have been deleted. The post may have been deleted.
</Text> </Text>
<TouchableOpacity onPress={onPressBack}> <TouchableOpacity
onPress={onPressBack}
accessibilityRole="button"
accessibilityLabel="Go back"
accessibilityHint="Navigates to the previous screen">
<Text type="2xl" style={pal.link}> <Text type="2xl" style={pal.link}>
<FontAwesomeIcon <FontAwesomeIcon
icon="angle-left" icon="angle-left"
@ -210,7 +214,11 @@ export const PostThread = observer(function PostThread({
<Text type="md" style={[pal.text, s.mb10]}> <Text type="md" style={[pal.text, s.mb10]}>
You have blocked the author or you have been blocked by the author. You have blocked the author or you have been blocked by the author.
</Text> </Text>
<TouchableOpacity onPress={onPressBack}> <TouchableOpacity
onPress={onPressBack}
accessibilityRole="button"
accessibilityLabel="Go back"
accessibilityHint="Navigates to the previous screen">
<Text type="2xl" style={pal.link}> <Text type="2xl" style={pal.link}>
<FontAwesomeIcon <FontAwesomeIcon
icon="angle-left" icon="angle-left"

View File

@ -151,7 +151,12 @@ export const PostThreadItem = observer(function PostThreadItem({
moderation={item.moderation.thread}> moderation={item.moderation.thread}>
<View style={styles.layout}> <View style={styles.layout}>
<View style={styles.layoutAvi}> <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 <UserAvatar
size={52} size={52}
avatar={item.post.author.avatar} avatar={item.post.author.avatar}
@ -183,7 +188,7 @@ export const PostThreadItem = observer(function PostThreadItem({
<View style={s.flex1} /> <View style={s.flex1} />
<PostDropdownBtn <PostDropdownBtn
testID="postDropdownBtn" testID="postDropdownBtn"
style={styles.metaItem} style={[styles.metaItem, s.mt2, s.px5]}
itemUri={itemUri} itemUri={itemUri}
itemCid={itemCid} itemCid={itemCid}
itemHref={itemHref} itemHref={itemHref}
@ -197,7 +202,7 @@ export const PostThreadItem = observer(function PostThreadItem({
<FontAwesomeIcon <FontAwesomeIcon
icon="ellipsis-h" icon="ellipsis-h"
size={14} size={14}
style={[s.mt2, s.mr5, pal.textLight]} style={[pal.textLight]}
/> />
</PostDropdownBtn> </PostDropdownBtn>
</View> </View>
@ -435,10 +440,10 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
}, },
layoutAvi: { layoutAvi: {
width: 70,
paddingLeft: 10, paddingLeft: 10,
paddingTop: 10, paddingTop: 10,
paddingBottom: 10, paddingBottom: 10,
marginRight: 10,
}, },
layoutContent: { layoutContent: {
flex: 1, flex: 1,

View File

@ -282,7 +282,10 @@ const ProfileHeaderLoaded = observer(
<TouchableOpacity <TouchableOpacity
testID="profileHeaderEditProfileButton" testID="profileHeaderEditProfileButton"
onPress={onPressEditProfile} 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}> <Text type="button" style={pal.text}>
Edit Profile Edit Profile
</Text> </Text>
@ -291,7 +294,10 @@ const ProfileHeaderLoaded = observer(
<TouchableOpacity <TouchableOpacity
testID="unblockBtn" testID="unblockBtn"
onPress={onPressUnblockAccount} 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]}> <Text type="button" style={[pal.text, s.bold]}>
Unblock Unblock
</Text> </Text>
@ -303,7 +309,10 @@ const ProfileHeaderLoaded = observer(
<TouchableOpacity <TouchableOpacity
testID="unfollowBtn" testID="unfollowBtn"
onPress={onPressToggleFollow} 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 <FontAwesomeIcon
icon="check" icon="check"
style={[pal.text, s.mr5]} style={[pal.text, s.mr5]}
@ -317,7 +326,10 @@ const ProfileHeaderLoaded = observer(
<TouchableOpacity <TouchableOpacity
testID="followBtn" testID="followBtn"
onPress={onPressToggleFollow} 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 <FontAwesomeIcon
icon="plus" icon="plus"
style={[s.white as FontAwesomeIconStyle, s.mr5]} style={[s.white as FontAwesomeIconStyle, s.mr5]}
@ -363,7 +375,10 @@ const ProfileHeaderLoaded = observer(
<TouchableOpacity <TouchableOpacity
testID="profileHeaderFollowersButton" testID="profileHeaderFollowersButton"
style={[s.flexRow, s.mr10]} 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]}> <Text type="md" style={[s.bold, s.mr2, pal.text]}>
{formatCount(view.followersCount)} {formatCount(view.followersCount)}
</Text> </Text>
@ -374,7 +389,10 @@ const ProfileHeaderLoaded = observer(
<TouchableOpacity <TouchableOpacity
testID="profileHeaderFollowsButton" testID="profileHeaderFollowsButton"
style={[s.flexRow, s.mr10]} 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]}> <Text type="md" style={[s.bold, s.mr2, pal.text]}>
{formatCount(view.followsCount)} {formatCount(view.followsCount)}
</Text> </Text>
@ -382,14 +400,12 @@ const ProfileHeaderLoaded = observer(
following following
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<View style={[s.flexRow, s.mr10]}> <Text type="md" style={[s.bold, pal.text]}>
<Text type="md" style={[s.bold, s.mr2, pal.text]}> {view.postsCount}{' '}
{view.postsCount}
</Text>
<Text type="md" style={[pal.textLight]}> <Text type="md" style={[pal.textLight]}>
{pluralize(view.postsCount, 'post')} {pluralize(view.postsCount, 'post')}
</Text> </Text>
</View> </Text>
</View> </View>
{view.descriptionRichText ? ( {view.descriptionRichText ? (
<RichText <RichText
@ -440,7 +456,10 @@ const ProfileHeaderLoaded = observer(
{!isDesktopWeb && !hideBackButton && ( {!isDesktopWeb && !hideBackButton && (
<TouchableWithoutFeedback <TouchableWithoutFeedback
onPress={onPressBack} onPress={onPressBack}
hitSlop={BACK_HITSLOP}> hitSlop={BACK_HITSLOP}
accessibilityRole="button"
accessibilityLabel="Go back"
accessibilityHint="Navigates to the previous screen">
<View style={styles.backBtnWrapper}> <View style={styles.backBtnWrapper}>
<BlurView style={styles.backBtn} blurType="dark"> <BlurView style={styles.backBtn} blurType="dark">
<FontAwesomeIcon size={18} icon="angle-left" style={s.white} /> <FontAwesomeIcon size={18} icon="angle-left" style={s.white} />
@ -450,7 +469,10 @@ const ProfileHeaderLoaded = observer(
)} )}
<TouchableWithoutFeedback <TouchableWithoutFeedback
testID="profileHeaderAviButton" 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 <View
style={[ style={[
pal.view, pal.view,

View File

@ -54,7 +54,9 @@ export function HeaderWithInput({
testID="viewHeaderBackOrMenuBtn" testID="viewHeaderBackOrMenuBtn"
onPress={onPressMenu} onPress={onPressMenu}
hitSlop={MENU_HITSLOP} 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} /> <UserAvatar size={30} avatar={store.me.avatar} />
</TouchableOpacity> </TouchableOpacity>
<View <View
@ -80,9 +82,15 @@ export function HeaderWithInput({
onBlur={() => setIsInputFocused(false)} onBlur={() => setIsInputFocused(false)}
onChangeText={onChangeQuery} onChangeText={onChangeQuery}
onSubmitEditing={onSubmitQuery} onSubmitEditing={onSubmitQuery}
autoFocus={true}
accessibilityRole="search"
/> />
{query ? ( {query ? (
<TouchableOpacity onPress={onPressClearQuery}> <TouchableOpacity
onPress={onPressClearQuery}
accessibilityRole="button"
accessibilityLabel="Clear search query"
accessibilityHint="">
<FontAwesomeIcon <FontAwesomeIcon
icon="xmark" icon="xmark"
size={16} size={16}
@ -93,7 +101,9 @@ export function HeaderWithInput({
</View> </View>
{query || isInputFocused ? ( {query || isInputFocused ? (
<View style={styles.headerCancelBtn}> <View style={styles.headerCancelBtn}>
<TouchableOpacity onPress={onPressCancelSearchInner}> <TouchableOpacity
onPress={onPressCancelSearchInner}
accessibilityRole="button">
<Text style={pal.text}>Cancel</Text> <Text style={pal.text}>Cancel</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@ -110,9 +120,10 @@ const styles = StyleSheet.create({
paddingVertical: 4, paddingVertical: 4,
}, },
headerMenuBtn: { headerMenuBtn: {
width: 40, width: 30,
height: 30, height: 30,
marginLeft: 6, borderRadius: 30,
marginHorizontal: 6,
}, },
headerSearchContainer: { headerSearchContainer: {
flex: 1, flex: 1,

View File

@ -1,5 +1,5 @@
import React, {useMemo} from 'react' import React, {useMemo} from 'react'
import {GestureResponderEvent, TouchableWithoutFeedback} from 'react-native' import {TouchableWithoutFeedback} from 'react-native'
import {BottomSheetBackdropProps} from '@gorhom/bottom-sheet' import {BottomSheetBackdropProps} from '@gorhom/bottom-sheet'
import Animated, { import Animated, {
Extrapolate, Extrapolate,
@ -8,7 +8,7 @@ import Animated, {
} from 'react-native-reanimated' } from 'react-native-reanimated'
export function createCustomBackdrop( export function createCustomBackdrop(
onClose?: ((event: GestureResponderEvent) => void) | undefined, onClose?: (() => void) | undefined,
): React.FC<BottomSheetBackdropProps> { ): React.FC<BottomSheetBackdropProps> {
const CustomBackdrop = ({animatedIndex, style}: BottomSheetBackdropProps) => { const CustomBackdrop = ({animatedIndex, style}: BottomSheetBackdropProps) => {
// animated variables // animated variables
@ -27,7 +27,15 @@ export function createCustomBackdrop(
) )
return ( return (
<TouchableWithoutFeedback onPress={onClose}> <TouchableWithoutFeedback
onPress={onClose}
accessibilityLabel="Close bottom drawer"
accessibilityHint=""
onAccessibilityEscape={() => {
if (onClose !== undefined) {
onClose()
}
}}>
<Animated.View style={containerStyle} /> <Animated.View style={containerStyle} />
</TouchableWithoutFeedback> </TouchableWithoutFeedback>
) )

View File

@ -1,4 +1,4 @@
import React from 'react' import React, {ComponentProps} from 'react'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import { import {
Linking, Linking,
@ -29,6 +29,16 @@ type Event =
| React.MouseEvent<HTMLAnchorElement, MouseEvent> | React.MouseEvent<HTMLAnchorElement, MouseEvent>
| GestureResponderEvent | 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({ export const Link = observer(function Link({
testID, testID,
style, style,
@ -37,15 +47,9 @@ export const Link = observer(function Link({
children, children,
noFeedback, noFeedback,
asAnchor, asAnchor,
}: { accessible,
testID?: string ...props
style?: StyleProp<ViewStyle> }: Props) {
href?: string
title?: string
children?: React.ReactNode
noFeedback?: boolean
asAnchor?: boolean
}) {
const store = useStores() const store = useStores()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
@ -64,7 +68,10 @@ export const Link = observer(function Link({
testID={testID} testID={testID}
onPress={onPress} onPress={onPress}
// @ts-ignore web only -prf // @ts-ignore web only -prf
href={asAnchor ? sanitizeUrl(href) : undefined}> href={asAnchor ? sanitizeUrl(href) : undefined}
accessible={accessible}
accessibilityRole="link"
{...props}>
<View style={style}> <View style={style}>
{children ? children : <Text>{title || 'link'}</Text>} {children ? children : <Text>{title || 'link'}</Text>}
</View> </View>
@ -76,8 +83,11 @@ export const Link = observer(function Link({
testID={testID} testID={testID}
style={style} style={style}
onPress={onPress} onPress={onPress}
accessible={accessible}
accessibilityRole="link"
// @ts-ignore web only -prf // @ts-ignore web only -prf
href={asAnchor ? sanitizeUrl(href) : undefined}> href={asAnchor ? sanitizeUrl(href) : undefined}
{...props}>
{children ? children : <Text>{title || 'link'}</Text>} {children ? children : <Text>{title || 'link'}</Text>}
</TouchableOpacity> </TouchableOpacity>
) )

View File

@ -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,
},
})

View File

@ -170,83 +170,94 @@ export function PostCtrls(opts: PostCtrlsOpts) {
return ( return (
<View style={[styles.ctrls, opts.style]}> <View style={[styles.ctrls, opts.style]}>
<View> <TouchableOpacity
<TouchableOpacity testID="replyBtn"
testID="replyBtn" style={styles.ctrl}
style={styles.ctrl} hitSlop={HITSLOP}
hitSlop={HITSLOP} onPress={opts.onPressReply}
onPress={opts.onPressReply}> accessibilityRole="button"
<CommentBottomArrow accessibilityLabel="Reply"
style={[defaultCtrlColor, opts.big ? s.mt2 : styles.mt1]} accessibilityHint="Opens reply composer">
strokeWidth={3} <CommentBottomArrow
size={opts.big ? 20 : 15} style={[defaultCtrlColor, opts.big ? s.mt2 : styles.mt1]}
/> strokeWidth={3}
{typeof opts.replyCount !== 'undefined' ? ( size={opts.big ? 20 : 15}
<Text style={[defaultCtrlColor, s.ml5, s.f15]}> />
{opts.replyCount} {typeof opts.replyCount !== 'undefined' ? (
</Text> <Text style={[defaultCtrlColor, s.ml5, s.f15]}>
) : undefined} {opts.replyCount}
</TouchableOpacity> </Text>
</View> ) : undefined}
<View> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
testID="repostBtn" testID="repostBtn"
hitSlop={HITSLOP} hitSlop={HITSLOP}
onPress={onPressToggleRepostWrapper} onPress={onPressToggleRepostWrapper}
style={styles.ctrl}> style={styles.ctrl}
<RepostIcon 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={ style={
opts.isReposted opts.isReposted
? (styles.ctrlIconReposted as StyleProp<ViewStyle>) ? [s.bold, s.green3, s.f15, s.ml5]
: defaultCtrlColor : [defaultCtrlColor, s.f15, s.ml5]
} }>
strokeWidth={2.4} {opts.repostCount}
size={opts.big ? 24 : 20} </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 <HeartIcon
testID="repostCount" style={[defaultCtrlColor, opts.big ? styles.mt1 : undefined]}
style={ strokeWidth={3}
opts.isReposted size={opts.big ? 20 : 16}
? [s.bold, s.green3, s.f15, s.ml5] />
: [defaultCtrlColor, s.f15, s.ml5] )}
}> {typeof opts.likeCount !== 'undefined' ? (
{opts.repostCount} <Text
</Text> testID="likeCount"
) : undefined} style={
</TouchableOpacity> opts.isLiked
</View> ? [s.bold, s.red3, s.f15, s.ml5]
<View> : [defaultCtrlColor, s.f15, s.ml5]
<TouchableOpacity }>
testID="likeBtn" {opts.likeCount}
style={styles.ctrl} </Text>
hitSlop={HITSLOP} ) : undefined}
onPress={onPressToggleLikeWrapper}> </TouchableOpacity>
{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>
<View> <View>
{opts.big ? undefined : ( {opts.big ? undefined : (
<PostDropdownBtn <PostDropdownBtn

View File

@ -85,6 +85,8 @@ export function Selector({
onSelect?.(index) onSelect?.(index)
} }
const numItems = items.length
return ( return (
<View <View
style={[pal.view, styles.outer]} style={[pal.view, styles.outer]}
@ -97,7 +99,9 @@ export function Selector({
<Pressable <Pressable
testID={`selector-${i}`} testID={`selector-${i}`}
key={item} key={item}
onPress={() => onPressItem(i)}> onPress={() => onPressItem(i)}
accessibilityLabel={`Select ${item}`}
accessibilityHint={`Select option ${i} of ${numItems}`}>
<View style={styles.item} ref={itemRefs[i]}> <View style={styles.item} ref={itemRefs[i]}>
<Text <Text
style={ style={

View File

@ -150,6 +150,7 @@ export function UserAvatar({
borderRadius: Math.floor(size / 2), borderRadius: Math.floor(size / 2),
}} }}
source={{uri: avatar}} source={{uri: avatar}}
accessibilityRole="image"
/> />
) : ( ) : (
<DefaultAvatar size={size} /> <DefaultAvatar size={size} />
@ -167,7 +168,11 @@ export function UserAvatar({
<View style={{width: size, height: size}}> <View style={{width: size, height: size}}>
<HighPriorityImage <HighPriorityImage
testID="userAvatarImage" testID="userAvatarImage"
style={{width: size, height: size, borderRadius: Math.floor(size / 2)}} style={{
width: size,
height: size,
borderRadius: Math.floor(size / 2),
}}
contentFit="cover" contentFit="cover"
source={{uri: avatar}} source={{uri: avatar}}
blurRadius={moderation?.blur ? BLUR_AMOUNT : 0} blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}

View File

@ -5,7 +5,6 @@ import {IconProp} from '@fortawesome/fontawesome-svg-core'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {colors} from 'lib/styles' import {colors} from 'lib/styles'
import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
import {Image as TImage} from 'lib/media/types'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import { import {
usePhotoLibraryPermission, usePhotoLibraryPermission,
@ -15,6 +14,7 @@ import {DropdownButton} from './forms/DropdownButton'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {AvatarModeration} from 'lib/labeling/types' import {AvatarModeration} from 'lib/labeling/types'
import {isWeb, isAndroid} from 'platform/detection' import {isWeb, isAndroid} from 'platform/detection'
import {Image as RNImage} from 'react-native-image-crop-picker'
export function UserBanner({ export function UserBanner({
banner, banner,
@ -23,7 +23,7 @@ export function UserBanner({
}: { }: {
banner?: string | null banner?: string | null
moderation?: AvatarModeration moderation?: AvatarModeration
onSelectNewBanner?: (img: TImage | null) => void onSelectNewBanner?: (img: RNImage | null) => void
}) { }) {
const store = useStores() const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
@ -94,6 +94,8 @@ export function UserBanner({
testID="userBannerImage" testID="userBannerImage"
style={styles.bannerImage} style={styles.bannerImage}
source={{uri: banner}} source={{uri: banner}}
accessible={true}
accessibilityIgnoresInvertColors
/> />
) : ( ) : (
<View <View
@ -118,6 +120,8 @@ export function UserBanner({
resizeMode="cover" resizeMode="cover"
source={{uri: banner}} source={{uri: banner}}
blurRadius={moderation?.blur ? 100 : 0} blurRadius={moderation?.blur ? 100 : 0}
accessible={true}
accessibilityIgnoresInvertColors
/> />
) : ( ) : (
<View <View

View File

@ -60,7 +60,14 @@ export const ViewHeader = observer(function ({
testID="viewHeaderDrawerBtn" testID="viewHeaderDrawerBtn"
onPress={canGoBack ? onPressBack : onPressMenu} onPress={canGoBack ? onPressBack : onPressMenu}
hitSlop={BACK_HITSLOP} 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 ? ( {canGoBack ? (
<FontAwesomeIcon <FontAwesomeIcon
size={18} size={18}
@ -171,9 +178,9 @@ const styles = StyleSheet.create({
height: 30, height: 30,
}, },
backBtnWide: { backBtnWide: {
width: 40, width: 30,
height: 30, height: 30,
marginLeft: 6, paddingHorizontal: 6,
}, },
backIcon: { backIcon: {
marginTop: 6, marginTop: 6,

View File

@ -132,7 +132,12 @@ export function Selector({
<Pressable <Pressable
testID={`selector-${i}`} testID={`selector-${i}`}
key={item} 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 <View
style={[ style={[
styles.item, styles.item,

View File

@ -47,7 +47,10 @@ export function ErrorMessage({
<TouchableOpacity <TouchableOpacity
testID="errorMessageTryAgainButton" testID="errorMessageTryAgainButton"
style={styles.btn} style={styles.btn}
onPress={onPressTryAgain}> onPress={onPressTryAgain}
accessibilityRole="button"
accessibilityLabel="Retry"
accessibilityHint="Retries the last action, which errored out">
<FontAwesomeIcon <FontAwesomeIcon
icon="arrows-rotate" icon="arrows-rotate"
style={{color: theme.palette.error.icon}} style={{color: theme.palette.error.icon}}

View File

@ -57,7 +57,9 @@ export function ErrorScreen({
testID="errorScreenTryAgainButton" testID="errorScreenTryAgainButton"
type="default" type="default"
style={[styles.btn]} style={[styles.btn]}
onPress={onPressTryAgain}> onPress={onPressTryAgain}
accessibilityLabel="Retry"
accessibilityHint="Retries the last action, which errored out">
<FontAwesomeIcon <FontAwesomeIcon
icon="arrows-rotate" icon="arrows-rotate"
style={pal.link as FontAwesomeIconStyle} style={pal.link as FontAwesomeIconStyle}

View File

@ -1,25 +1,19 @@
import React from 'react' import React, {ComponentProps} from 'react'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import { import {Animated, StyleSheet, TouchableWithoutFeedback} from 'react-native'
Animated,
GestureResponderEvent,
StyleSheet,
TouchableWithoutFeedback,
} from 'react-native'
import LinearGradient from 'react-native-linear-gradient' import LinearGradient from 'react-native-linear-gradient'
import {gradients} from 'lib/styles' import {gradients} from 'lib/styles'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {isMobileWeb} from 'platform/detection' import {isMobileWeb} from 'platform/detection'
type OnPress = ((event: GestureResponderEvent) => void) | undefined export interface FABProps
export interface FABProps { extends ComponentProps<typeof TouchableWithoutFeedback> {
testID?: string testID?: string
icon: JSX.Element 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 store = useStores()
const interp = useAnimatedValue(0) const interp = useAnimatedValue(0)
React.useEffect(() => { React.useEffect(() => {
@ -34,7 +28,7 @@ export const FABInner = observer(({testID, icon, onPress}: FABProps) => {
transform: [{translateY: Animated.multiply(interp, 60)}], transform: [{translateY: Animated.multiply(interp, 60)}],
} }
return ( return (
<TouchableWithoutFeedback testID={testID} onPress={onPress}> <TouchableWithoutFeedback testID={testID} {...props}>
<Animated.View <Animated.View
style={[styles.outer, isMobileWeb && styles.mobileWebOuter, transform]}> style={[styles.outer, isMobileWeb && styles.mobileWebOuter, transform]}>
<LinearGradient <LinearGradient

View File

@ -26,6 +26,7 @@ export type ButtonType =
| 'secondary-light' | 'secondary-light'
| 'default-light' | 'default-light'
// TODO: Enforce that button always has a label
export function Button({ export function Button({
type = 'primary', type = 'primary',
label, label,
@ -131,7 +132,8 @@ export function Button({
<Pressable <Pressable
style={[typeOuterStyle, styles.outer, style]} style={[typeOuterStyle, styles.outer, style]}
onPress={onPressWrapped} onPress={onPressWrapped}
testID={testID}> testID={testID}
accessibilityRole="button">
{label ? ( {label ? (
<Text type="button" style={[typeLabelStyle, labelStyle]}> <Text type="button" style={[typeLabelStyle, labelStyle]}>
{label} {label}

View File

@ -1,4 +1,4 @@
import React, {useRef} from 'react' import React, {PropsWithChildren, useMemo, useRef} from 'react'
import { import {
Dimensions, Dimensions,
StyleProp, StyleProp,
@ -39,6 +39,19 @@ type MaybeDropdownItem = DropdownItem | false | undefined
export type DropdownButtonType = ButtonType | 'bare' 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({ export function DropdownButton({
testID, testID,
type = 'bare', type = 'bare',
@ -50,18 +63,7 @@ export function DropdownButton({
openToRight = false, openToRight = false,
rightOffset = 0, rightOffset = 0,
bottomOffset = 0, bottomOffset = 0,
}: { }: PropsWithChildren<DropdownButtonProps>) {
testID?: string
type?: DropdownButtonType
style?: StyleProp<ViewStyle>
items: MaybeDropdownItem[]
label?: string
menuWidth?: number
children?: React.ReactNode
openToRight?: boolean
rightOffset?: number
bottomOffset?: number
}) {
const ref1 = useRef<TouchableOpacity>(null) const ref1 = useRef<TouchableOpacity>(null)
const ref2 = useRef<View>(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') { if (type === 'bare') {
return ( return (
<TouchableOpacity <TouchableOpacity
@ -112,7 +126,10 @@ export function DropdownButton({
style={style} style={style}
onPress={onPress} onPress={onPress}
hitSlop={HITSLOP} hitSlop={HITSLOP}
ref={ref1}> ref={ref1}
accessibilityRole="button"
accessibilityLabel={`Opens ${numItems} options`}
accessibilityHint={`Opens ${numItems} options`}>
{children} {children}
</TouchableOpacity> </TouchableOpacity>
) )
@ -283,9 +300,20 @@ const DropdownItems = ({
const separatorColor = const separatorColor =
theme.colorScheme === 'dark' ? pal.borderDark : pal.border theme.colorScheme === 'dark' ? pal.borderDark : pal.border
const numItems = items.filter(isBtn).length
return ( 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]} /> <View style={[styles.bg]} />
</TouchableWithoutFeedback> </TouchableWithoutFeedback>
<View <View
@ -301,7 +329,9 @@ const DropdownItems = ({
testID={item.testID} testID={item.testID}
key={index} key={index}
style={[styles.menuItem]} style={[styles.menuItem]}
onPress={() => onPressItem(index)}> onPress={() => onPressItem(index)}
accessibilityLabel={item.label}
accessibilityHint={`Option ${index + 1} of ${numItems}`}>
{item.icon && ( {item.icon && (
<FontAwesomeIcon <FontAwesomeIcon
style={styles.icon} style={styles.icon}

View File

@ -62,12 +62,17 @@ export function AutoSizedImage({
onLongPress={onLongPress} onLongPress={onLongPress}
onPressIn={onPressIn} onPressIn={onPressIn}
delayPressIn={DELAY_PRESS_IN} delayPressIn={DELAY_PRESS_IN}
style={[styles.container, style]}> style={[styles.container, style]}
accessible={true}
accessibilityLabel="Share image"
accessibilityHint="Opens ways of sharing image">
<Image <Image
style={[styles.image, {aspectRatio}]} style={[styles.image, {aspectRatio}]}
source={uri} source={uri}
accessible={true} // Must set for `accessibilityLabel` to work accessible={true} // Must set for `accessibilityLabel` to work
accessibilityIgnoresInvertColors
accessibilityLabel={alt} accessibilityLabel={alt}
accessibilityHint=""
/> />
{children} {children}
</TouchableOpacity> </TouchableOpacity>
@ -80,7 +85,9 @@ export function AutoSizedImage({
style={[styles.image, {aspectRatio}]} style={[styles.image, {aspectRatio}]}
source={{uri}} source={{uri}}
accessible={true} // Must set for `accessibilityLabel` to work accessible={true} // Must set for `accessibilityLabel` to work
accessibilityIgnoresInvertColors
accessibilityLabel={alt} accessibilityLabel={alt}
accessibilityHint=""
/> />
{children} {children}
</View> </View>

View File

@ -41,16 +41,25 @@ export const GalleryItem: FC<GalleryItemProps> = ({
delayPressIn={DELAY_PRESS_IN} delayPressIn={DELAY_PRESS_IN}
onPress={() => onPress?.(index)} onPress={() => onPress?.(index)}
onPressIn={() => onPressIn?.(index)} onPressIn={() => onPressIn?.(index)}
onLongPress={() => onLongPress?.(index)}> onLongPress={() => onLongPress?.(index)}
accessibilityRole="button"
accessibilityLabel="View image"
accessibilityHint="">
<Image <Image
source={{uri: image.thumb}} source={{uri: image.thumb}}
style={imageStyle} style={imageStyle}
accessible={true} accessible={true}
accessibilityLabel={image.alt} accessibilityLabel={image.alt}
accessibilityHint=""
accessibilityIgnoresInvertColors
/> />
</TouchableOpacity> </TouchableOpacity>
{image.alt === '' ? null : ( {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> <Text style={styles.alt}>ALT</Text>
</Pressable> </Pressable>
)} )}

View File

@ -8,5 +8,7 @@ export function HighPriorityImage({source, ...props}: HighPriorityImageProps) {
const updatedSource = { const updatedSource = {
uri: typeof source === 'object' && source ? source.uri : '', uri: typeof source === 'object' && source ? source.uri : '',
} satisfies ImageSource } satisfies ImageSource
return <Image source={updatedSource} {...props} /> return (
<Image accessibilityIgnoresInvertColors source={updatedSource} {...props} />
)
} }

View File

@ -16,15 +16,33 @@ interface Props {
} }
export function ImageHorzList({images, onPress, style}: Props) { export function ImageHorzList({images, onPress, style}: Props) {
const numImages = images.length
return ( return (
<View style={[styles.flexRow, style]}> <View style={[styles.flexRow, style]}>
{images.map(({thumb, alt}, i) => ( {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 <Image
source={{uri: thumb}} source={{uri: thumb}}
style={styles.image} style={styles.image}
accessible={true} accessible={true}
accessibilityLabel={alt} accessibilityIgnoresInvertColors
accessibilityHint={alt}
accessibilityLabel=""
/> />
</TouchableWithoutFeedback> </TouchableWithoutFeedback>
))} ))}

View File

@ -23,7 +23,10 @@ export const LoadLatestBtn = ({
<TouchableOpacity <TouchableOpacity
style={[pal.view, pal.borderDark, styles.loadLatest]} style={[pal.view, pal.borderDark, styles.loadLatest]}
onPress={onPress} onPress={onPress}
hitSlop={HITSLOP}> hitSlop={HITSLOP}
accessibilityRole="button"
accessibilityLabel={`Load new ${label}`}
accessibilityHint="">
<Text type="md-bold" style={pal.text}> <Text type="md-bold" style={pal.text}>
<UpIcon size={16} strokeWidth={1} style={[pal.text, styles.icon]} /> <UpIcon size={16} strokeWidth={1} style={[pal.text, styles.icon]} />
Load new {label} Load new {label}

View File

@ -23,7 +23,10 @@ export const LoadLatestBtn = observer(
}, },
]} ]}
onPress={onPress} onPress={onPress}
hitSlop={HITSLOP}> hitSlop={HITSLOP}
accessibilityRole="button"
accessibilityLabel={`Load new ${label}`}
accessibilityHint={`Loads new ${label}`}>
<LinearGradient <LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]} colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}} start={{x: 0, y: 0}}

View File

@ -55,7 +55,14 @@ export function ContentHider({
</Text> </Text>
<TouchableOpacity <TouchableOpacity
style={styles.showBtn} 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}> <Text type="md" style={pal.link}>
{override ? 'Hide' : 'Show'} {override ? 'Hide' : 'Show'}
</Text> </Text>

View File

@ -46,7 +46,8 @@ export function PostHider({
</Text> </Text>
<TouchableOpacity <TouchableOpacity
style={styles.showBtn} style={styles.showBtn}
onPress={() => setOverride(v => !v)}> onPress={() => setOverride(v => !v)}
accessibilityRole="button">
<Text type="md" style={pal.link}> <Text type="md" style={pal.link}>
{override ? 'Hide' : 'Show'} post {override ? 'Hide' : 'Show'} post
</Text> </Text>

View File

@ -136,7 +136,10 @@ export function PostEmbeds({
<Pressable <Pressable
onPress={() => { onPress={() => {
onPressAltText(alt) onPressAltText(alt)
}}> }}
accessibilityRole="button"
accessibilityLabel="View alt text"
accessibilityHint="Opens modal with alt text">
<Text style={styles.alt}>ALT</Text> <Text style={styles.alt}>ALT</Text>
</Pressable> </Pressable>
)} )}

View File

@ -184,7 +184,10 @@ function AppPassword({
<TouchableOpacity <TouchableOpacity
testID={testID} testID={testID}
style={[styles.item, pal.border]} style={[styles.item, pal.border]}
onPress={onDelete}> onPress={onDelete}
accessibilityRole="button"
accessibilityLabel="Delete"
accessibilityHint="Deletes app password">
<Text type="md-bold" style={pal.text}> <Text type="md-bold" style={pal.text}>
{name} {name}
</Text> </Text>
@ -250,7 +253,6 @@ const styles = StyleSheet.create({
pr10: { pr10: {
marginRight: 10, marginRight: 10,
}, },
btnContainer: { btnContainer: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'center', justifyContent: 'center',

View File

@ -226,6 +226,9 @@ const FeedPage = observer(
testID="composeFAB" testID="composeFAB"
onPress={onPressCompose} onPress={onPressCompose}
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
accessibilityRole="button"
accessibilityLabel="Compose"
accessibilityHint="Opens post composer"
/> />
</View> </View>
) )

View File

@ -46,7 +46,9 @@ export const LogScreen = observer(function Log({}: NativeStackScreenProps<
<View key={`entry-${entry.id}`}> <View key={`entry-${entry.id}`}>
<TouchableOpacity <TouchableOpacity
style={[styles.entry, pal.border, pal.view]} 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' ? ( {entry.type === 'debug' ? (
<FontAwesomeIcon icon="info" /> <FontAwesomeIcon icon="info" />
) : ( ) : (

View File

@ -118,10 +118,10 @@ export const SearchScreen = withAuthRequired(
}, []) }, [])
return ( return (
<TouchableWithoutFeedback onPress={onPress}> <TouchableWithoutFeedback onPress={onPress} accessible={false}>
<View style={[pal.view, styles.container]}> <View style={[pal.view, styles.container]}>
<HeaderWithInput <HeaderWithInput
isInputFocused={isInputFocused} isInputFocused={true}
query={query} query={query}
setIsInputFocused={setIsInputFocused} setIsInputFocused={setIsInputFocused}
onChangeQuery={onChangeQuery} onChangeQuery={onChangeQuery}

View File

@ -161,7 +161,9 @@ export const SettingsScreen = withAuthRequired(
<Link <Link
href={`/profile/${store.me.handle}`} href={`/profile/${store.me.handle}`}
title="Your profile" 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={[pal.view, styles.linkCard]}>
<View style={styles.avi}> <View style={styles.avi}>
<UserAvatar size={40} avatar={store.me.avatar} /> <UserAvatar size={40} avatar={store.me.avatar} />
@ -176,7 +178,10 @@ export const SettingsScreen = withAuthRequired(
</View> </View>
<TouchableOpacity <TouchableOpacity
testID="signOutBtn" 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}> <Text type="lg" style={pal.link}>
Sign out Sign out
</Text> </Text>
@ -191,7 +196,10 @@ export const SettingsScreen = withAuthRequired(
style={[pal.view, styles.linkCard, isSwitching && styles.dimmed]} style={[pal.view, styles.linkCard, isSwitching && styles.dimmed]}
onPress={ onPress={
isSwitching ? undefined : () => onPressSwitchAccount(account) isSwitching ? undefined : () => onPressSwitchAccount(account)
}> }
accessibilityRole="button"
accessibilityLabel={`Switch to ${account.handle}`}
accessibilityHint="Switches the account you are logged in to">
<View style={styles.avi}> <View style={styles.avi}>
<UserAvatar size={40} avatar={account.aviUrl} /> <UserAvatar size={40} avatar={account.aviUrl} />
</View> </View>
@ -209,7 +217,10 @@ export const SettingsScreen = withAuthRequired(
<TouchableOpacity <TouchableOpacity
testID="switchToNewAccountBtn" testID="switchToNewAccountBtn"
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} 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]}> <View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon <FontAwesomeIcon
icon="plus" icon="plus"
@ -229,7 +240,10 @@ export const SettingsScreen = withAuthRequired(
<TouchableOpacity <TouchableOpacity
testID="inviteFriendBtn" testID="inviteFriendBtn"
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} 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 <View
style={[ style={[
styles.iconContainer, styles.iconContainer,
@ -260,7 +274,9 @@ export const SettingsScreen = withAuthRequired(
<TouchableOpacity <TouchableOpacity
testID="contentFilteringBtn" testID="contentFilteringBtn"
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} 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]}> <View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon <FontAwesomeIcon
icon="eye" icon="eye"
@ -308,7 +324,10 @@ export const SettingsScreen = withAuthRequired(
<TouchableOpacity <TouchableOpacity
testID="changeHandleBtn" testID="changeHandleBtn"
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} 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]}> <View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon <FontAwesomeIcon
icon="at" icon="at"
@ -327,7 +346,11 @@ export const SettingsScreen = withAuthRequired(
</Text> </Text>
<TouchableOpacity <TouchableOpacity
style={[pal.view, styles.linkCard]} 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]}> <View style={[styles.iconContainer, dangerBg]}>
<FontAwesomeIcon <FontAwesomeIcon
icon={['far', 'trash-can']} icon={['far', 'trash-can']}

View File

@ -56,7 +56,10 @@ export const Composer = observer(
} }
return ( return (
<Animated.View style={[styles.wrapper, pal.view, wrapperAnimStyle]}> <Animated.View
style={[styles.wrapper, pal.view, wrapperAnimStyle]}
aria-modal
accessibilityViewIsModal>
<ComposePost <ComposePost
replyTo={replyTo} replyTo={replyTo}
onPost={onPost} onPost={onPost}

View File

@ -31,7 +31,7 @@ export const Composer = observer(
} }
return ( return (
<View style={styles.mask}> <View style={styles.mask} aria-modal accessibilityViewIsModal>
<View style={[styles.container, pal.view, pal.border]}> <View style={[styles.container, pal.view, pal.border]}>
<ComposePost <ComposePost
replyTo={replyTo} replyTo={replyTo}

View File

@ -1,4 +1,4 @@
import React from 'react' import React, {ComponentProps} from 'react'
import { import {
Linking, Linking,
SafeAreaView, SafeAreaView,
@ -50,6 +50,8 @@ export const DrawerContent = observer(() => {
const {isAtHome, isAtSearch, isAtNotifications, isAtMyProfile} = const {isAtHome, isAtSearch, isAtNotifications, isAtMyProfile} =
useNavigationTabState() useNavigationTabState()
const {notifications} = store.me
// events // events
// = // =
@ -120,7 +122,11 @@ export const DrawerContent = observer(() => {
]}> ]}>
<SafeAreaView style={s.flex1}> <SafeAreaView style={s.flex1}>
<View style={styles.main}> <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} /> <UserAvatar size={80} avatar={store.me.avatar} />
<Text <Text
type="title-lg" type="title-lg"
@ -164,6 +170,8 @@ export const DrawerContent = observer(() => {
) )
} }
label="Search" label="Search"
accessibilityLabel="Search"
accessibilityHint="Search through users and posts"
bold={isAtSearch} bold={isAtSearch}
onPress={onPressSearch} onPress={onPressSearch}
/> />
@ -184,6 +192,8 @@ export const DrawerContent = observer(() => {
) )
} }
label="Home" label="Home"
accessibilityLabel="Home"
accessibilityHint="Navigates to default feed"
bold={isAtHome} bold={isAtHome}
onPress={onPressHome} onPress={onPressHome}
/> />
@ -204,7 +214,13 @@ export const DrawerContent = observer(() => {
) )
} }
label="Notifications" 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} bold={isAtNotifications}
onPress={onPressNotifications} onPress={onPressNotifications}
/> />
@ -225,6 +241,8 @@ export const DrawerContent = observer(() => {
) )
} }
label="Profile" label="Profile"
accessibilityLabel="Profile"
accessibilityHint="See profile display name, avatar, description, and other profile items"
onPress={onPressProfile} onPress={onPressProfile}
/> />
<MenuItem <MenuItem
@ -236,6 +254,8 @@ export const DrawerContent = observer(() => {
/> />
} }
label="Settings" label="Settings"
accessibilityLabel="Settings"
accessibilityHint="Manage settings for your account, like handle, content moderation, and app passwords"
onPress={onPressSettings} onPress={onPressSettings}
/> />
</View> </View>
@ -243,6 +263,13 @@ export const DrawerContent = observer(() => {
<View style={styles.footer}> <View style={styles.footer}>
{!isWeb && ( {!isWeb && (
<TouchableOpacity <TouchableOpacity
accessibilityRole="button"
accessibilityLabel="Toggle dark mode"
accessibilityHint={
theme.colorScheme === 'dark'
? 'Sets display to light mode'
: 'Sets display to dark mode'
}
onPress={onDarkmodePress} onPress={onDarkmodePress}
style={[ style={[
styles.footerBtn, styles.footerBtn,
@ -258,6 +285,9 @@ export const DrawerContent = observer(() => {
</TouchableOpacity> </TouchableOpacity>
)} )}
<TouchableOpacity <TouchableOpacity
accessibilityRole="link"
accessibilityLabel="Send feedback"
accessibilityHint="Opens Google Forms feedback link"
onPress={onPressFeedback} onPress={onPressFeedback}
style={[ style={[
styles.footerBtn, styles.footerBtn,
@ -281,25 +311,30 @@ export const DrawerContent = observer(() => {
) )
}) })
function MenuItem({ interface MenuItemProps extends ComponentProps<typeof TouchableOpacity> {
icon,
label,
count,
bold,
onPress,
}: {
icon: JSX.Element icon: JSX.Element
label: string label: string
count?: string count?: string
bold?: boolean bold?: boolean
onPress: () => void }
}) {
function MenuItem({
icon,
label,
accessibilityLabel,
count,
bold,
onPress,
}: MenuItemProps) {
const pal = usePalette('default') const pal = usePalette('default')
return ( return (
<TouchableOpacity <TouchableOpacity
testID={`menuItemButton-${label}`} testID={`menuItemButton-${label}`}
style={styles.menuItem} style={styles.menuItem}
onPress={onPress}> onPress={onPress}
accessibilityRole="menuitem"
accessibilityLabel={accessibilityLabel}
accessibilityHint="">
<View style={[styles.menuItemIconWrapper]}> <View style={[styles.menuItemIconWrapper]}>
{icon} {icon}
{count ? ( {count ? (
@ -332,6 +367,7 @@ const InviteCodes = observer(() => {
const {track} = useAnalytics() const {track} = useAnalytics()
const store = useStores() const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
const {invitesAvailable} = store.me
const onPress = React.useCallback(() => { const onPress = React.useCallback(() => {
track('Menu:ItemClicked', {url: '#invite-codes'}) track('Menu:ItemClicked', {url: '#invite-codes'})
store.shell.closeDrawer() store.shell.closeDrawer()
@ -341,7 +377,14 @@ const InviteCodes = observer(() => {
<TouchableOpacity <TouchableOpacity
testID="menuItemInviteCodes" testID="menuItemInviteCodes"
style={[styles.inviteCodes]} 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 <FontAwesomeIcon
icon="ticket" icon="ticket"
style={[ style={[

View File

@ -1,4 +1,4 @@
import React from 'react' import React, {ComponentProps} from 'react'
import { import {
Animated, Animated,
GestureResponderEvent, GestureResponderEvent,
@ -94,6 +94,8 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
) )
} }
onPress={onPressHome} onPress={onPressHome}
accessibilityLabel="Go home"
accessibilityHint="Navigates to feed home"
/> />
<Btn <Btn
testID="bottomBarSearchBtn" testID="bottomBarSearchBtn"
@ -113,6 +115,7 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
) )
} }
onPress={onPressSearch} onPress={onPressSearch}
accessibilityRole="search"
/> />
<Btn <Btn
testID="bottomBarNotificationsBtn" testID="bottomBarNotificationsBtn"
@ -133,6 +136,8 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
} }
onPress={onPressNotifications} onPress={onPressNotifications}
notificationCount={store.me.notifications.unreadCountLabel} notificationCount={store.me.notifications.unreadCountLabel}
accessibilityLabel="Notifications"
accessibilityHint="Navigates to notifications"
/> />
<Btn <Btn
testID="bottomBarProfileBtn" testID="bottomBarProfileBtn"
@ -154,31 +159,43 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
</View> </View>
} }
onPress={onPressProfile} onPress={onPressProfile}
accessibilityLabel="Profile"
accessibilityHint="Navigates to profile"
/> />
</Animated.View> </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({ function Btn({
testID, testID,
icon, icon,
notificationCount, notificationCount,
onPress, onPress,
onLongPress, onLongPress,
}: { accessibilityHint,
testID?: string accessibilityLabel,
icon: JSX.Element }: BtnProps) {
notificationCount?: string
onPress?: (event: GestureResponderEvent) => void
onLongPress?: (event: GestureResponderEvent) => void
}) {
return ( return (
<TouchableOpacity <TouchableOpacity
testID={testID} testID={testID}
style={styles.ctrl} style={styles.ctrl}
onPress={onLongPress ? onPress : undefined} onPress={onLongPress ? onPress : undefined}
onPressIn={onLongPress ? undefined : onPress} onPressIn={onLongPress ? undefined : onPress}
onLongPress={onLongPress}> onLongPress={onLongPress}
accessibilityLabel={accessibilityLabel}
accessibilityHint={accessibilityHint}>
{notificationCount ? ( {notificationCount ? (
<View style={[styles.notificationCount]}> <View style={[styles.notificationCount]}>
<Text style={styles.notificationCountLabel}>{notificationCount}</Text> <Text style={styles.notificationCountLabel}>{notificationCount}</Text>

View File

@ -2,7 +2,11 @@ import React from 'react'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {StyleSheet, TouchableOpacity, View} from 'react-native' import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {PressableWithHover} from 'view/com/util/PressableWithHover' import {PressableWithHover} from 'view/com/util/PressableWithHover'
import {useNavigation, useNavigationState} from '@react-navigation/native' import {
useLinkProps,
useNavigation,
useNavigationState,
} from '@react-navigation/native'
import { import {
FontAwesomeIcon, FontAwesomeIcon,
FontAwesomeIconStyle, FontAwesomeIconStyle,
@ -59,7 +63,10 @@ function BackBtn() {
<TouchableOpacity <TouchableOpacity
testID="viewHeaderBackOrMenuBtn" testID="viewHeaderBackOrMenuBtn"
onPress={onPressBack} onPress={onPressBack}
style={styles.backBtn}> style={styles.backBtn}
accessibilityRole="button"
accessibilityLabel="Go back"
accessibilityHint="Navigates to the previous screen">
<FontAwesomeIcon <FontAwesomeIcon
size={24} size={24}
icon="angle-left" icon="angle-left"
@ -86,25 +93,28 @@ const NavItem = observer(
} }
return getCurrentRoute(state).name return getCurrentRoute(state).name
}) })
const isCurrent = isTab(currentRouteName, pathName) const isCurrent = isTab(currentRouteName, pathName)
const {onPress} = useLinkProps({to: href})
return ( return (
<PressableWithHover <PressableWithHover
style={styles.navItemWrapper} style={styles.navItemWrapper}
hoverStyle={pal.viewLight}> hoverStyle={pal.viewLight}
<Link href={href} style={styles.navItem}> onPress={onPress}
<View style={[styles.navItemIconWrapper]}> accessibilityLabel={label}
{isCurrent ? iconFilled : icon} accessibilityHint={`Navigates to ${label}`}>
{typeof count === 'string' && count ? ( <View style={[styles.navItemIconWrapper]}>
<Text type="button" style={styles.navItemCount}> {isCurrent ? iconFilled : icon}
{count} {typeof count === 'string' && count ? (
</Text> <Text type="button" style={styles.navItemCount}>
) : null} {count}
</View> </Text>
<Text type="title" style={[isCurrent ? s.bold : s.normal, pal.text]}> ) : null}
{label} </View>
</Text> <Text type="title" style={[isCurrent ? s.bold : s.normal, pal.text]}>
</Link> {label}
</Text>
</PressableWithHover> </PressableWithHover>
) )
}, },
@ -115,7 +125,12 @@ function ComposeBtn() {
const onPressCompose = () => store.shell.openComposer({}) const onPressCompose = () => store.shell.openComposer({})
return ( 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}> <View style={styles.newPostBtnIconWrapper}>
<ComposeIcon2 <ComposeIcon2
size={19} size={19}
@ -202,7 +217,7 @@ const styles = StyleSheet.create({
profileCard: { profileCard: {
marginVertical: 10, marginVertical: 10,
width: 60, width: 90,
paddingLeft: 12, paddingLeft: 12,
}, },
@ -215,21 +230,18 @@ const styles = StyleSheet.create({
}, },
navItemWrapper: { navItemWrapper: {
paddingHorizontal: 12,
borderRadius: 8,
},
navItem: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
paddingTop: 12, paddingHorizontal: 12,
paddingBottom: 12, padding: 12,
borderRadius: 8,
gap: 10,
}, },
navItemIconWrapper: { navItemIconWrapper: {
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
width: 28, width: 28,
height: 28, height: 28,
marginRight: 10,
marginTop: 2, marginTop: 2,
}, },
navItemCount: { navItemCount: {

View File

@ -61,7 +61,14 @@ export const DesktopRightNav = observer(function DesktopRightNav() {
<View> <View>
<TouchableOpacity <TouchableOpacity
style={[styles.darkModeToggle]} 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]}> <View style={[pal.viewLight, styles.darkModeToggleIcon]}>
<MoonIcon size={18} style={pal.textLight} /> <MoonIcon size={18} style={pal.textLight} />
</View> </View>
@ -78,13 +85,22 @@ const InviteCodes = observer(() => {
const store = useStores() const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
const {invitesAvailable} = store.me
const onPress = React.useCallback(() => { const onPress = React.useCallback(() => {
store.shell.openModal({name: 'invite-codes'}) store.shell.openModal({name: 'invite-codes'})
}, [store]) }, [store])
return ( return (
<TouchableOpacity <TouchableOpacity
style={[styles.inviteCodes, pal.border]} 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 <FontAwesomeIcon
icon="ticket" icon="ticket"
style={[ style={[

View File

@ -67,10 +67,16 @@ export const DesktopSearch = observer(function DesktopSearch() {
onBlur={() => setIsInputFocused(false)} onBlur={() => setIsInputFocused(false)}
onChangeText={onChangeQuery} onChangeText={onChangeQuery}
onSubmitEditing={onSubmit} onSubmitEditing={onSubmit}
accessibilityRole="search"
/> />
{query ? ( {query ? (
<View style={styles.cancelBtn}> <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]}> <Text type="lg" style={[pal.link]}>
Cancel Cancel
</Text> </Text>

View File

@ -46,7 +46,9 @@ const ShellInner = observer(() => {
{!isDesktop && store.shell.isDrawerOpen && ( {!isDesktop && store.shell.isDrawerOpen && (
<TouchableOpacity <TouchableOpacity
onPress={() => store.shell.closeDrawer()} onPress={() => store.shell.closeDrawer()}
style={styles.drawerMask}> style={styles.drawerMask}
accessibilityLabel="Close navigation footer"
accessibilityHint="Closes bottom navigation bar">
<View style={styles.drawerContainer}> <View style={styles.drawerContainer}>
<DrawerContent /> <DrawerContent />
</View> </View>

View File

@ -60,10 +60,6 @@
} }
}*/ }*/
/* Remove focus state on inputs */
*:focus {
outline: 0;
}
/* Remove default link styling */ /* Remove default link styling */
a { a {
color: inherit; color: inherit;
@ -102,6 +98,14 @@
color: #0085ff; color: #0085ff;
cursor: pointer; cursor: pointer;
} }
/* OLLIE: TODO -- this is not accessible */
/* Remove focus state on inputs */
.ProseMirror-focused {
outline: 0;
}
input:focus {
outline: 0;
}
.tippy-content .items { .tippy-content .items {
border-radius: 6px; border-radius: 6px;
background: #F3F3F8; background: #F3F3F8;

2322
yarn.lock

File diff suppressed because it is too large Load Diff