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>
This commit is contained in:
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

@ -7,7 +7,6 @@ import {
ScrollView,
StyleSheet,
TouchableOpacity,
TouchableWithoutFeedback,
View,
} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
@ -19,6 +18,8 @@ import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
import {ExternalEmbed} from './ExternalEmbed'
import {Text} from '../util/text/Text'
import * as Toast from '../util/Toast'
// TODO: Prevent naming components that coincide with RN primitives
// due to linting false positives
import {TextInput, TextInputRef} from './text-input/TextInput'
import {CharProgress} from './char-progress/CharProgress'
import {UserAvatar} from '../util/UserAvatar'
@ -87,27 +88,6 @@ export const ComposePost = observer(function ComposePost({
autocompleteView.setup()
}, [autocompleteView])
useEffect(() => {
// HACK
// wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view
// -prf
let to: NodeJS.Timeout | undefined
if (textInput.current) {
to = setTimeout(() => {
textInput.current?.focus()
}, 250)
}
return () => {
if (to) {
clearTimeout(to)
}
}
}, [])
const onPressContainer = useCallback(() => {
textInput.current?.focus()
}, [textInput])
const onPressAddLinkCard = useCallback(
(uri: string) => {
setExtLink({uri, isLoading: true})
@ -133,7 +113,7 @@ export const ComposePost = observer(function ComposePost({
if (rt.text.trim().length === 0 && gallery.isEmpty) {
setError('Did you want to say anything?')
return false
return
}
setIsProcessing(true)
@ -203,133 +183,149 @@ export const ComposePost = observer(function ComposePost({
testID="composePostView"
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.outer}>
<TouchableWithoutFeedback onPressIn={onPressContainer}>
<View style={[s.flex1, viewStyles]}>
<View style={styles.topbar}>
<TouchableOpacity
testID="composerCancelButton"
onPress={hackfixOnClose}>
<Text style={[pal.link, s.f18]}>Cancel</Text>
</TouchableOpacity>
<View style={s.flex1} />
{isProcessing ? (
<View style={styles.postBtn}>
<ActivityIndicator />
</View>
) : canPost ? (
<TouchableOpacity
testID="composerPublishBtn"
onPress={() => {
onPressPublish(richtext)
}}>
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={styles.postBtn}>
<Text style={[s.white, s.f16, s.bold]}>
{replyTo ? 'Reply' : 'Post'}
</Text>
</LinearGradient>
</TouchableOpacity>
) : (
<View style={[styles.postBtn, pal.btn]}>
<Text style={[pal.textLight, s.f16, s.bold]}>Post</Text>
</View>
)}
</View>
<View style={[s.flex1, viewStyles]} aria-modal accessibilityViewIsModal>
<View style={styles.topbar}>
<TouchableOpacity
testID="composerCancelButton"
onPress={hackfixOnClose}
onAccessibilityEscape={hackfixOnClose}
accessibilityRole="button"
accessibilityLabel="Cancel"
accessibilityHint="Closes post composer">
<Text style={[pal.link, s.f18]}>Cancel</Text>
</TouchableOpacity>
<View style={s.flex1} />
{isProcessing ? (
<View style={[pal.btn, styles.processingLine]}>
<Text style={pal.text}>{processingState}</Text>
<View style={styles.postBtn}>
<ActivityIndicator />
</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>
) : canPost ? (
<TouchableOpacity
testID="composerPublishBtn"
onPress={() => {
onPressPublish(richtext)
}}
accessibilityRole="button"
accessibilityLabel={replyTo ? 'Publish reply' : 'Publish post'}
accessibilityHint={
replyTo
? 'Double tap to publish your reply'
: 'Double tap to publish your post'
}>
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={styles.postBtn}>
<Text style={[s.white, s.f16, s.bold]}>
{replyTo ? 'Reply' : 'Post'}
</Text>
</LinearGradient>
</TouchableOpacity>
) : (
<View style={[styles.postBtn, pal.btn]}>
<Text style={[pal.textLight, s.f16, s.bold]}>Post</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}
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>
</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>
)
})

View file

@ -60,7 +60,13 @@ export const ExternalEmbed = ({
</Text>
)}
</View>
<TouchableOpacity style={styles.removeBtn} onPress={onRemove}>
<TouchableOpacity
style={styles.removeBtn}
onPress={onRemove}
accessibilityRole="button"
accessibilityLabel="Remove image preview"
accessibilityHint={`Removes default thumbnail from ${link.uri}`}
onAccessibilityEscape={onRemove}>
<FontAwesomeIcon size={18} icon="xmark" style={s.white} />
</TouchableOpacity>
</View>

View file

@ -13,7 +13,10 @@ export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) {
<TouchableOpacity
testID="replyPromptBtn"
style={[pal.view, pal.border, styles.prompt]}
onPress={() => onPressCompose()}>
onPress={() => onPressCompose()}
accessibilityRole="button"
accessibilityLabel="Compose reply"
accessibilityHint="Opens composer">
<UserAvatar avatar={store.me.avatar} size={38} />
<Text
type="xl"

View file

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

View file

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

View file

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

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 {
NativeSyntheticEvent,
StyleSheet,
TextInput as RNTextInput,
TextInputSelectionChangeEventData,
View,
} from 'react-native'
@ -27,14 +34,14 @@ export interface TextInputRef {
blur: () => void
}
interface TextInputProps {
interface TextInputProps extends ComponentProps<typeof RNTextInput> {
richtext: RichText
placeholder: string
suggestedLinks: Set<string>
autocompleteView: UserAutocompleteModel
setRichText: (v: RichText) => void
setRichText: (v: RichText | ((v: RichText) => RichText)) => void
onPhotoPasted: (uri: string) => void
onPressPublish: (richtext: RichText) => Promise<false | undefined>
onPressPublish: (richtext: RichText) => Promise<void>
onSuggestedLinksChanged: (uris: Set<string>) => void
onError: (err: string) => void
}
@ -55,6 +62,7 @@ export const TextInput = forwardRef(
onPhotoPasted,
onSuggestedLinksChanged,
onError,
...props
}: TextInputProps,
ref,
) => {
@ -65,26 +73,11 @@ export const TextInput = forwardRef(
React.useImperativeHandle(ref, () => ({
focus: () => textInput.current?.focus(),
blur: () => textInput.current?.blur(),
blur: () => {
textInput.current?.blur()
},
}))
useEffect(() => {
// HACK
// wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view
// -prf
let to: NodeJS.Timeout | undefined
if (textInput.current) {
to = setTimeout(() => {
textInput.current?.focus()
}, 250)
}
return () => {
if (to) {
clearTimeout(to)
}
}
}, [])
const onChangeText = useCallback(
async (newText: string) => {
const newRt = new RichText({text: newText})
@ -206,8 +199,10 @@ export const TextInput = forwardRef(
placeholder={placeholder}
placeholderTextColor={pal.colors.textLight}
keyboardAppearance={theme.colorScheme}
autoFocus={true}
multiline
style={[pal.text, styles.textInput, styles.textInputFormatting]}>
style={[pal.text, styles.textInput, styles.textInputFormatting]}
{...props}>
{textDecorated}
</PasteInput>
<Autocomplete

View file

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

View file

@ -50,7 +50,9 @@ export const Autocomplete = observer(
testID="autocompleteButton"
key={item.handle}
style={[pal.border, styles.item]}
onPress={() => onSelect(item.handle)}>
onPress={() => onSelect(item.handle)}
accessibilityLabel={`Select ${item.handle}`}
accessibilityHint={`Autocompletes to ${item.handle}`}>
<Text type="md-medium" style={pal.text}>
{item.displayName || item.handle}
<Text type="sm" style={pal.textLight}>