From 83959c595d52ceb7aa4e3f68441c5ac41c389ebc Mon Sep 17 00:00:00 2001 From: Ollie H Date: Mon, 1 May 2023 18:38:47 -0700 Subject: 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 --- src/lib/strings/display-names.ts | 2 +- src/lib/styles.ts | 2 + src/view/com/auth/SplashScreen.tsx | 10 +- src/view/com/auth/SplashScreen.web.tsx | 13 +- src/view/com/auth/create/CreateAccount.tsx | 20 +- src/view/com/auth/create/Step1.tsx | 14 +- src/view/com/auth/create/Step2.tsx | 33 ++- src/view/com/auth/create/Step3.tsx | 3 + src/view/com/auth/login/Login.tsx | 100 ++++++-- src/view/com/auth/util/TextInput.tsx | 25 +- src/view/com/composer/Composer.tsx | 274 ++++++++++----------- src/view/com/composer/ExternalEmbed.tsx | 8 +- src/view/com/composer/Prompt.tsx | 5 +- src/view/com/composer/photos/Gallery.tsx | 11 + src/view/com/composer/photos/OpenCameraBtn.tsx | 16 +- src/view/com/composer/photos/SelectPhotoBtn.tsx | 16 +- src/view/com/composer/text-input/TextInput.tsx | 41 ++- src/view/com/composer/text-input/TextInput.web.tsx | 4 +- .../composer/text-input/mobile/Autocomplete.tsx | 4 +- .../ImageViewing/components/ImageDefaultHeader.tsx | 6 +- .../components/ImageItem/ImageItem.ios.tsx | 3 +- src/view/com/lightbox/ImageViewing/index.tsx | 7 +- src/view/com/lightbox/Lightbox.web.tsx | 23 +- src/view/com/modals/AddAppPasswords.tsx | 8 +- src/view/com/modals/AltImage.tsx | 19 +- src/view/com/modals/AltImageRead.tsx | 7 +- src/view/com/modals/ChangeHandle.tsx | 37 ++- src/view/com/modals/Confirm.tsx | 7 +- src/view/com/modals/ContentFilteringSettings.tsx | 47 ++-- src/view/com/modals/DeleteAccount.tsx | 36 ++- src/view/com/modals/EditProfile.tsx | 17 +- src/view/com/modals/InviteCodes.tsx | 10 +- src/view/com/modals/Modal.web.tsx | 3 + src/view/com/modals/ReportAccount.tsx | 5 +- src/view/com/modals/ReportPost.tsx | 5 +- src/view/com/modals/Repost.tsx | 19 +- src/view/com/modals/ServerInput.tsx | 25 +- src/view/com/modals/Waitlist.tsx | 16 +- src/view/com/modals/crop-image/CropImage.web.tsx | 35 ++- src/view/com/notifications/FeedItem.tsx | 83 ++++--- src/view/com/pager/FeedsTabBarMobile.tsx | 5 +- src/view/com/post-thread/PostThread.tsx | 12 +- src/view/com/post-thread/PostThreadItem.tsx | 13 +- src/view/com/profile/ProfileHeader.tsx | 48 +++- src/view/com/search/HeaderWithInput.tsx | 21 +- src/view/com/util/BottomSheetCustomBackdrop.tsx | 14 +- src/view/com/util/Link.tsx | 34 ++- src/view/com/util/Picker.tsx | 157 ------------ src/view/com/util/PostCtrls.tsx | 159 ++++++------ src/view/com/util/Selector.tsx | 6 +- src/view/com/util/UserAvatar.tsx | 7 +- src/view/com/util/UserBanner.tsx | 8 +- src/view/com/util/ViewHeader.tsx | 13 +- src/view/com/util/ViewSelector.tsx | 7 +- src/view/com/util/error/ErrorMessage.tsx | 5 +- src/view/com/util/error/ErrorScreen.tsx | 4 +- src/view/com/util/fab/FABInner.tsx | 18 +- src/view/com/util/forms/Button.tsx | 4 +- src/view/com/util/forms/DropdownButton.tsx | 62 +++-- src/view/com/util/images/AutoSizedImage.tsx | 9 +- src/view/com/util/images/Gallery.tsx | 13 +- src/view/com/util/images/Image.tsx | 4 +- src/view/com/util/images/ImageHorzList.tsx | 22 +- .../com/util/load-latest/LoadLatestBtn.web.tsx | 5 +- .../com/util/load-latest/LoadLatestBtnMobile.tsx | 5 +- src/view/com/util/moderation/ContentHider.tsx | 9 +- src/view/com/util/moderation/PostHider.tsx | 3 +- src/view/com/util/post-embeds/index.tsx | 5 +- src/view/screens/AppPasswords.tsx | 6 +- src/view/screens/Home.tsx | 3 + src/view/screens/Log.tsx | 4 +- src/view/screens/SearchMobile.tsx | 4 +- src/view/screens/Settings.tsx | 39 ++- src/view/shell/Composer.tsx | 5 +- src/view/shell/Composer.web.tsx | 2 +- src/view/shell/Drawer.tsx | 67 ++++- src/view/shell/bottom-bar/BottomBar.tsx | 35 ++- src/view/shell/desktop/LeftNav.tsx | 62 +++-- src/view/shell/desktop/RightNav.tsx | 20 +- src/view/shell/desktop/Search.tsx | 8 +- src/view/shell/index.web.tsx | 4 +- 81 files changed, 1241 insertions(+), 709 deletions(-) delete mode 100644 src/view/com/util/Picker.tsx (limited to 'src') diff --git a/src/lib/strings/display-names.ts b/src/lib/strings/display-names.ts index 5b58dec3d..555151b55 100644 --- a/src/lib/strings/display-names.ts +++ b/src/lib/strings/display-names.ts @@ -6,7 +6,7 @@ const CHECK_MARKS_RE = /[\u2705\u2713\u2714\u2611]/gu export function sanitizeDisplayName(str: string): string { if (typeof str === 'string') { - return str.replace(CHECK_MARKS_RE, '') + return str.replace(CHECK_MARKS_RE, '').trim() } return '' } diff --git a/src/lib/styles.ts b/src/lib/styles.ts index 37d169679..1ff2d520d 100644 --- a/src/lib/styles.ts +++ b/src/lib/styles.ts @@ -118,6 +118,7 @@ export const s = StyleSheet.create({ mr2: {marginRight: 2}, mr5: {marginRight: 5}, mr10: {marginRight: 10}, + mr20: {marginRight: 20}, ml2: {marginLeft: 2}, ml5: {marginLeft: 5}, ml10: {marginLeft: 10}, @@ -149,6 +150,7 @@ export const s = StyleSheet.create({ pb5: {paddingBottom: 5}, pb10: {paddingBottom: 10}, pb20: {paddingBottom: 20}, + px5: {paddingHorizontal: 5}, // flex flexRow: {flexDirection: 'row'}, diff --git a/src/view/com/auth/SplashScreen.tsx b/src/view/com/auth/SplashScreen.tsx index f98bed120..41787bb5f 100644 --- a/src/view/com/auth/SplashScreen.tsx +++ b/src/view/com/auth/SplashScreen.tsx @@ -28,7 +28,10 @@ export const SplashScreen = ({ + onPress={onPressCreateAccount} + accessibilityRole="button" + accessibilityLabel="Create new account" + accessibilityHint="Opens flow to create a new Bluesky account"> Create a new account @@ -36,7 +39,10 @@ export const SplashScreen = ({ + onPress={onPressSignin} + accessibilityRole="button" + accessibilityLabel="Sign in" + accessibilityHint="Opens flow to sign into your existing Bluesky account"> Sign in diff --git a/src/view/com/auth/SplashScreen.web.tsx b/src/view/com/auth/SplashScreen.web.tsx index 7fac5a8c0..9236968c4 100644 --- a/src/view/com/auth/SplashScreen.web.tsx +++ b/src/view/com/auth/SplashScreen.web.tsx @@ -43,7 +43,9 @@ export const SplashScreen = ({ + onPress={onPressCreateAccount} + // TODO: web accessibility + accessibilityRole="button"> Create a new account @@ -51,7 +53,9 @@ export const SplashScreen = ({ + onPress={onPressSignin} + // TODO: web accessibility + accessibilityRole="button"> Sign in @@ -60,7 +64,10 @@ export const SplashScreen = ({ style={[styles.notice, pal.textLight]} lineHeight={1.3}> Bluesky will launch soon.{' '} - + Join the waitlist diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx index 467b87948..ac03081df 100644 --- a/src/view/com/auth/create/CreateAccount.tsx +++ b/src/view/com/auth/create/CreateAccount.tsx @@ -72,14 +72,24 @@ export const CreateAccount = observer( {model.step === 3 && } - + Back {model.canNext ? ( - + {model.isProcessing ? ( ) : ( @@ -91,7 +101,11 @@ export const CreateAccount = observer( ) : model.didServiceDescriptionFetchFail ? ( + onPress={onPressRetryConnect} + accessibilityRole="button" + accessibilityLabel="Retry" + accessibilityHint="Retries account creation" + accessibilityLiveRegion="polite"> Retry diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx index ca964ede2..ac0d706d7 100644 --- a/src/view/com/auth/create/Step1.tsx +++ b/src/view/com/auth/create/Step1.tsx @@ -57,7 +57,7 @@ export const Step1 = observer(({model}: {model: CreateAccountModel}) => { - This is the company that keeps you online. + This is the service that keeps you online. diff --git a/src/view/com/composer/Prompt.tsx b/src/view/com/composer/Prompt.tsx index 301b90093..98a10b0f5 100644 --- a/src/view/com/composer/Prompt.tsx +++ b/src/view/com/composer/Prompt.tsx @@ -13,7 +13,10 @@ export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) { onPressCompose()}> + onPress={() => onPressCompose()} + accessibilityRole="button" + accessibilityLabel="Compose reply" + accessibilityHint="Opens composer"> { handleAddImageAltText(image) }} @@ -116,6 +119,9 @@ export const Gallery = observer(function ({gallery}: Props) { { handleEditPhoto(image) }} @@ -128,6 +134,9 @@ export const Gallery = observer(function ({gallery}: Props) { handleRemovePhoto(image)} style={styles.imageControl}> ) : null, diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx index 809c41783..bfcfa6b78 100644 --- a/src/view/com/composer/photos/OpenCameraBtn.tsx +++ b/src/view/com/composer/photos/OpenCameraBtn.tsx @@ -1,5 +1,5 @@ import React, {useCallback} from 'react' -import {TouchableOpacity} from 'react-native' +import {TouchableOpacity, StyleSheet} from 'react-native' import { FontAwesomeIcon, FontAwesomeIconStyle, @@ -7,7 +7,6 @@ import { import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics' import {useStores} from 'state/index' -import {s} from 'lib/styles' import {isDesktopWeb} from 'platform/detection' import {openCamera} from 'lib/media/picker' import {useCameraPermission} from 'lib/hooks/usePermissions' @@ -54,8 +53,11 @@ export function OpenCameraBtn({gallery}: Props) { + style={styles.button} + hitSlop={HITSLOP} + accessibilityRole="button" + accessibilityLabel="Camera" + accessibilityHint="Opens camera on device"> ) } + +const styles = StyleSheet.create({ + button: { + paddingHorizontal: 15, + }, +}) diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx index 9569e08ad..0b8046a4b 100644 --- a/src/view/com/composer/photos/SelectPhotoBtn.tsx +++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx @@ -1,12 +1,11 @@ import React, {useCallback} from 'react' -import {TouchableOpacity} from 'react-native' +import {TouchableOpacity, StyleSheet} from 'react-native' import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics' -import {s} from 'lib/styles' import {isDesktopWeb} from 'platform/detection' import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions' import {GalleryModel} from 'state/models/media/gallery' @@ -36,8 +35,11 @@ export function SelectPhotoBtn({gallery}: Props) { + style={styles.button} + hitSlop={HITSLOP} + accessibilityRole="button" + accessibilityLabel="Gallery" + accessibilityHint="Opens device photo gallery"> ) } + +const styles = StyleSheet.create({ + button: { + paddingHorizontal: 15, + }, +}) diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 10ac52b5d..7b09da93d 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -1,7 +1,14 @@ -import React, {forwardRef, useCallback, useEffect, useRef, useMemo} from 'react' +import React, { + forwardRef, + useCallback, + useRef, + useMemo, + ComponentProps, +} from 'react' import { NativeSyntheticEvent, StyleSheet, + TextInput as RNTextInput, TextInputSelectionChangeEventData, View, } from 'react-native' @@ -27,14 +34,14 @@ export interface TextInputRef { blur: () => void } -interface TextInputProps { +interface TextInputProps extends ComponentProps { richtext: RichText placeholder: string suggestedLinks: Set autocompleteView: UserAutocompleteModel - setRichText: (v: RichText) => void + setRichText: (v: RichText | ((v: RichText) => RichText)) => void onPhotoPasted: (uri: string) => void - onPressPublish: (richtext: RichText) => Promise + onPressPublish: (richtext: RichText) => Promise onSuggestedLinksChanged: (uris: Set) => 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} autocompleteView: UserAutocompleteModel - setRichText: (v: RichText) => void + setRichText: (v: RichText | ((v: RichText) => RichText)) => void onPhotoPasted: (uri: string) => void - onPressPublish: (richtext: RichText) => Promise + onPressPublish: (richtext: RichText) => Promise onSuggestedLinksChanged: (uris: Set) => void onError: (err: string) => void } diff --git a/src/view/com/composer/text-input/mobile/Autocomplete.tsx b/src/view/com/composer/text-input/mobile/Autocomplete.tsx index 879bac071..7806241f1 100644 --- a/src/view/com/composer/text-input/mobile/Autocomplete.tsx +++ b/src/view/com/composer/text-input/mobile/Autocomplete.tsx @@ -50,7 +50,9 @@ export const Autocomplete = observer( testID="autocompleteButton" key={item.handle} style={[pal.border, styles.item]} - onPress={() => onSelect(item.handle)}> + onPress={() => onSelect(item.handle)} + accessibilityLabel={`Select ${item.handle}`} + accessibilityHint={`Autocompletes to ${item.handle}`}> {item.displayName || item.handle} diff --git a/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx b/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx index 6880008e4..84e5f90fb 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx @@ -20,7 +20,11 @@ const ImageDefaultHeader = ({onRequestClose}: Props) => ( + hitSlop={HIT_SLOP} + accessibilityRole="button" + accessibilityLabel="Close image" + accessibilityHint="Closes viewer for header image" + onAccessibilityEscape={onRequestClose}> diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx index 12d37e283..658735724 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx @@ -127,7 +127,8 @@ const ImageItem = ({ + delayLongPress={delayLongPress} + accessibilityRole="image"> + diff --git a/src/view/com/lightbox/Lightbox.web.tsx b/src/view/com/lightbox/Lightbox.web.tsx index c17356d94..1d4a9c215 100644 --- a/src/view/com/lightbox/Lightbox.web.tsx +++ b/src/view/com/lightbox/Lightbox.web.tsx @@ -89,13 +89,25 @@ function LightboxInner({ return ( - + - + {canGoLeft && ( + style={[styles.btn, styles.leftBtn]} + accessibilityRole="button" + accessibilityLabel="Go back" + accessibilityHint="Navigates to previous image in viewer"> + style={[styles.btn, styles.rightBtn]} + accessibilityRole="button" + accessibilityLabel="Go to next" + accessibilityHint="Navigates to next image in viewer"> ) : ( + onPress={onCopy} + accessibilityRole="button" + accessibilityLabel="Copy" + accessibilityHint="Copies app password"> {appPassword} diff --git a/src/view/com/modals/AltImage.tsx b/src/view/com/modals/AltImage.tsx index 639303c98..ba05a7d62 100644 --- a/src/view/com/modals/AltImage.tsx +++ b/src/view/com/modals/AltImage.tsx @@ -37,7 +37,8 @@ export function Component({prevAltText, onAltTextSet}: Props) { return ( + style={[pal.view, styles.container, s.flex1]} + nativeID="imageAltText"> Add alt text setAltText(enforceLen(text, MAX_ALT_TEXT))} + accessibilityLabel="Image alt text" + accessibilityHint="Sets image alt text for screenreaders" + accessibilityLabelledBy="imageAltText" /> - + + onPress={onPressCancel} + accessibilityRole="button" + accessibilityLabel="Cancel add image alt text" + accessibilityHint="Exits adding alt text to image" + onAccessibilityEscape={onPressCancel}> Cancel diff --git a/src/view/com/modals/AltImageRead.tsx b/src/view/com/modals/AltImageRead.tsx index e7b4797ee..4dde8f58b 100644 --- a/src/view/com/modals/AltImageRead.tsx +++ b/src/view/com/modals/AltImageRead.tsx @@ -30,7 +30,12 @@ export function Component({altText}: Props) { {altText} - + void}) { - + Cancel @@ -148,13 +153,20 @@ export function Component({onChanged}: {onChanged: () => void}) { ) : error && !serviceDescription ? ( + onPress={onPressRetryConnect} + accessibilityRole="button" + accessibilityLabel="Retry change handle" + accessibilityHint={`Retries handle change to ${handle}`}> Retry ) : canSave ? ( - + Save @@ -245,6 +257,9 @@ function ProvidedHandleForm({ value={handle} onChangeText={onChangeHandle} editable={!isProcessing} + accessible={true} + accessibilityLabel="Handle" + accessibilityHint="Sets Bluesky username" /> @@ -253,7 +268,11 @@ function ProvidedHandleForm({ @{createFullHandle(handle, userDomain)} - + I have my own domain @@ -338,7 +357,7 @@ function CustomHandleForm({ // = return ( <> - + Enter the domain you want to use @@ -356,6 +375,9 @@ function CustomHandleForm({ value={handle} onChangeText={onChangeHandle} editable={!isProcessing} + accessibilityLabelledBy="customDomain" + accessibilityLabel="Custom domain" + accessibilityHint="Input your preferred hosting provider" /> @@ -421,7 +443,10 @@ function CustomHandleForm({ )} - + Nevermind, create a handle for me diff --git a/src/view/com/modals/Confirm.tsx b/src/view/com/modals/Confirm.tsx index 6f7b062cf..f0c905d04 100644 --- a/src/view/com/modals/Confirm.tsx +++ b/src/view/com/modals/Confirm.tsx @@ -66,7 +66,12 @@ export function Component({ + 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"> Confirm )} diff --git a/src/view/com/modals/ContentFilteringSettings.tsx b/src/view/com/modals/ContentFilteringSettings.tsx index 735de85a7..c683e43f8 100644 --- a/src/view/com/modals/ContentFilteringSettings.tsx +++ b/src/view/com/modals/ContentFilteringSettings.tsx @@ -34,7 +34,12 @@ export function Component({}: {}) { - + { const store = useStores() @@ -67,19 +73,20 @@ const ContentLabelPref = observer( store.preferences.setContentLabelPref(group, v)} + group={group} /> ) }, ) -function SelectGroup({ - current, - onChange, -}: { +interface SelectGroupProps { current: LabelPreference onChange: (v: LabelPreference) => void -}) { + group: keyof typeof CONFIGURABLE_LABEL_GROUPS +} + +function SelectGroup({current, onChange, group}: SelectGroupProps) { return ( ) } +interface SelectableBtnProps { + current: string + value: LabelPreference + label: string + left?: boolean + right?: boolean + onChange: (v: LabelPreference) => void + group: keyof typeof CONFIGURABLE_LABEL_GROUPS +} + function SelectableBtn({ current, value, @@ -113,14 +133,8 @@ function SelectableBtn({ left, right, onChange, -}: { - current: string - value: LabelPreference - label: string - left?: boolean - right?: boolean - onChange: (v: LabelPreference) => void -}) { + group, +}: SelectableBtnProps) { const pal = usePalette('default') const palPrimary = usePalette('inverted') return ( @@ -132,7 +146,10 @@ function SelectableBtn({ pal.border, current === value ? palPrimary.view : pal.view, ]} - onPress={() => onChange(value)}> + onPress={() => onChange(value)} + accessibilityRole="button" + accessibilityLabel={value} + accessibilityHint={`Set ${value} for ${group} content moderation policy`}> {label} diff --git a/src/view/com/modals/DeleteAccount.tsx b/src/view/com/modals/DeleteAccount.tsx index 353122163..f1febc2ea 100644 --- a/src/view/com/modals/DeleteAccount.tsx +++ b/src/view/com/modals/DeleteAccount.tsx @@ -86,7 +86,10 @@ export function Component({}: {}) { <> + onPress={onPressSendEmail} + accessibilityRole="button" + accessibilityLabel="Send email" + accessibilityHint="Sends email with confirmation code for account deletion"> + onPress={onCancel} + accessibilityRole="button" + accessibilityLabel="Cancel account deletion" + accessibilityHint="" + onAccessibilityEscape={onCancel}> Cancel @@ -112,7 +119,11 @@ export function Component({}: {}) { ) : ( <> - + {/* TODO: Update this label to be more concise */} + Check your inbox for an email with the confirmation code to enter below: @@ -123,8 +134,11 @@ export function Component({}: {}) { keyboardAppearance={theme.colorScheme} value={confirmCode} onChangeText={setConfirmCode} + accessibilityLabelledBy="confirmationCode" + accessibilityLabel="Confirmation code" + accessibilityHint="Input confirmation code for account deletion" /> - + Please enter your password as well: {error ? ( @@ -149,14 +166,21 @@ export function Component({}: {}) { <> + onPress={onPressConfirmDelete} + accessibilityRole="button" + accessibilityLabel="Confirm delete account" + accessibilityHint=""> Delete my account + onPress={onCancel} + accessibilityRole="button" + accessibilityLabel="Cancel account deletion" + accessibilityHint="Exits account deletion process" + onAccessibilityEscape={onCancel}> Cancel diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx index 9bd572cc0..c26592fa9 100644 --- a/src/view/com/modals/EditProfile.tsx +++ b/src/view/com/modals/EditProfile.tsx @@ -175,6 +175,9 @@ export function Component({ onChangeText={v => setDisplayName(enforceLen(v, MAX_DISPLAY_NAME)) } + accessible={true} + accessibilityLabel="Display name" + accessibilityHint="Edit your display name" /> @@ -188,6 +191,9 @@ export function Component({ multiline value={description} onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))} + accessible={true} + accessibilityLabel="Description" + accessibilityHint="Edit your profile description" /> {isProcessing ? ( @@ -198,7 +204,10 @@ export function Component({ + onPress={onPressSave} + accessibilityRole="button" + accessibilityLabel="Save" + accessibilityHint="Saves any changes to your profile"> + onPress={onPressCancel} + accessibilityRole="button" + accessibilityLabel="Cancel profile editing" + accessibilityHint="" + onAccessibilityEscape={onPressCancel}> Cancel diff --git a/src/view/com/modals/InviteCodes.tsx b/src/view/com/modals/InviteCodes.tsx index 992439ebc..52d6fa46a 100644 --- a/src/view/com/modals/InviteCodes.tsx +++ b/src/view/com/modals/InviteCodes.tsx @@ -87,6 +87,7 @@ const InviteCode = observer( ({testID, code, used}: {testID: string; code: string; used?: boolean}) => { const pal = usePalette('default') const store = useStores() + const {invitesAvailable} = store.me const onPress = React.useCallback(() => { Clipboard.setString(code) @@ -98,7 +99,14 @@ const InviteCode = observer( + onPress={onPress} + accessibilityRole="button" + accessibilityLabel={ + invitesAvailable === 1 + ? 'Invite codes: 1 available' + : `Invite codes: ${invitesAvailable} available` + } + accessibilityHint="Opens list of invite codes"> { + // TODO: can we use prevent default? // do nothing, we just want to stop it from bubbling } @@ -92,8 +93,10 @@ function Modal({modal}: {modal: ModalIface}) { } return ( + // eslint-disable-next-line + {/* eslint-disable-next-line */} + onPress={onPress} + accessibilityRole="button" + accessibilityLabel="Report account" + accessibilityHint={`Reports account with reason ${issue}`}> + onPress={onPress} + accessibilityRole="button" + accessibilityLabel="Report post" + accessibilityHint={`Reports post with reason ${issue}`}> void onQuote: () => void isReposted: boolean + // TODO: Add author into component }) { const store = useStores() const pal = usePalette('default') @@ -31,7 +32,10 @@ export function Component({ + onPress={onRepost} + accessibilityRole="button" + accessibilityLabel={isReposted ? 'Undo repost' : 'Repost'} + accessibilityHint={isReposted ? 'Remove repost' : 'Repost '}> {!isReposted ? 'Repost' : 'Undo repost'} @@ -40,14 +44,23 @@ export function Component({ + onPress={onQuote} + accessibilityRole="button" + accessibilityLabel="Quote post" + accessibilityHint=""> Quote Post - + void}) { doSelect(LOCAL_DEV_SERVICE)}> + onPress={() => doSelect(LOCAL_DEV_SERVICE)} + accessibilityRole="button"> Local dev server void}) { doSelect(STAGING_SERVICE)}> + onPress={() => doSelect(STAGING_SERVICE)} + accessibilityRole="button"> Staging void}) { ) : undefined} doSelect(PROD_SERVICE)}> + onPress={() => doSelect(PROD_SERVICE)} + accessibilityRole="button" + accessibilityLabel="Select Bluesky Social" + accessibilityHint="Sets Bluesky Social as your service provider"> Bluesky.Social void}) { keyboardAppearance={theme.colorScheme} value={customUrl} onChangeText={setCustomUrl} + accessibilityLabel="Custom domain" + // TODO: Simplify this wording further to be understandable by everyone + accessibilityHint="Use your domain as your Bluesky client service provider" /> 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 === ''}> {error ? ( @@ -99,7 +102,10 @@ export function Component({}: {}) { ) : ( <> - + - + Cancel diff --git a/src/view/com/modals/crop-image/CropImage.web.tsx b/src/view/com/modals/crop-image/CropImage.web.tsx index 8a9b4bf62..c5959cf4c 100644 --- a/src/view/com/modals/crop-image/CropImage.web.tsx +++ b/src/view/com/modals/crop-image/CropImage.web.tsx @@ -4,12 +4,13 @@ import ImageEditor from 'react-avatar-editor' import {Slider} from '@miblanchard/react-native-slider' import LinearGradient from 'react-native-linear-gradient' import {Text} from 'view/com/util/text/Text' -import {Dimensions, Image} from 'lib/media/types' +import {Dimensions} from 'lib/media/types' import {getDataUriSize} from 'lib/media/util' import {s, gradients} from 'lib/styles' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {SquareIcon, RectWideIcon, RectTallIcon} from 'lib/icons' +import {Image as RNImage} from 'react-native-image-crop-picker' enum AspectRatio { Square = 'square', @@ -30,7 +31,7 @@ export function Component({ onSelect, }: { uri: string - onSelect: (img?: Image) => void + onSelect: (img?: RNImage) => void }) { const store = useStores() const pal = usePalette('default') @@ -92,19 +93,31 @@ export function Component({ maximumValue={3} containerStyle={styles.slider} /> - + - + - + - + Cancel - + + noFeedback + accessible={false}> + noFeedback + accessible={(item.isLike && authors.length === 1) || item.isRepost}> + {/* TODO: Prevent conditional rendering and move toward composable + notifications for clearer accessibility labeling */} {icon === 'HeartIconSolid' ? ( ) : ( @@ -192,17 +197,18 @@ export const FeedItem = observer(function ({ 1 ? onToggleAuthorsExpanded : () => {}}> + onPress={authors.length > 1 ? onToggleAuthorsExpanded : undefined} + accessible={false}> - + {authors.length > 1 ? ( <> - and - + and + {authors.length - 1} {pluralize(authors.length - 1, 'other')} ) : undefined} - {action} - - {ago(item.indexedAt)} - - + {action} + {ago(item.indexedAt)} + {item.isLike || item.isRepost || item.isQuote ? ( @@ -245,7 +249,10 @@ function CondensedAuthorsList({ + onPress={onToggleAuthorsExpanded} + accessibilityRole="button" + accessibilityLabel="Hide user list" + accessibilityHint="Collapses list of users for a given notification"> - {authors.slice(0, MAX_AUTHORS).map(author => ( - - - - ))} - {authors.length > MAX_AUTHORS ? ( - - +{authors.length - MAX_AUTHORS} - - ) : undefined} - - + + + {authors.slice(0, MAX_AUTHORS).map(author => ( + + + + ))} + {authors.length > MAX_AUTHORS ? ( + + +{authors.length - MAX_AUTHORS} + + ) : undefined} + + + ) } @@ -426,9 +438,6 @@ const styles = StyleSheet.create({ paddingTop: 6, paddingBottom: 2, }, - metaItem: { - paddingRight: 3, - }, postText: { paddingBottom: 5, color: colors.black, diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx index e7d2ec104..725c44603 100644 --- a/src/view/com/pager/FeedsTabBarMobile.tsx +++ b/src/view/com/pager/FeedsTabBarMobile.tsx @@ -37,7 +37,10 @@ export const FeedsTabBar = observer( + onPress={onPressAvi} + accessibilityRole="button" + accessibilityLabel="Open navigation" + accessibilityHint="Access profile and other navigation links"> The post may have been deleted. - + You have blocked the author or you have been blocked by the author. - + - + @@ -435,10 +440,10 @@ const styles = StyleSheet.create({ flexDirection: 'row', }, layoutAvi: { - width: 70, paddingLeft: 10, paddingTop: 10, paddingBottom: 10, + marginRight: 10, }, layoutContent: { flex: 1, diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index 4accd7aba..d8c4b9d8f 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -282,7 +282,10 @@ const ProfileHeaderLoaded = observer( + style={[styles.btn, styles.mainBtn, pal.btn]} + accessibilityRole="button" + accessibilityLabel="Edit profile" + accessibilityHint="Opens editor for profile display name, avatar, background image, and description"> Edit Profile @@ -291,7 +294,10 @@ const ProfileHeaderLoaded = observer( + style={[styles.btn, styles.mainBtn, pal.btn]} + accessibilityRole="button" + accessibilityLabel="Unblock" + accessibilityHint=""> Unblock @@ -303,7 +309,10 @@ const ProfileHeaderLoaded = observer( + style={[styles.btn, styles.mainBtn, pal.btn]} + accessibilityRole="button" + accessibilityLabel={`Unfollow ${view.handle}`} + accessibilityHint={`Hides direct posts from ${view.handle} in your feed`}> + style={[styles.btn, styles.primaryBtn]} + accessibilityRole="button" + accessibilityLabel={`Follow ${view.handle}`} + accessibilityHint={`Shows direct posts from ${view.handle} in your feed`}> + onPress={onPressFollowers} + accessibilityRole="button" + accessibilityLabel={`Show ${view.handle}'s followers`} + accessibilityHint={`Shows folks following ${view.handle}`}> {formatCount(view.followersCount)} @@ -374,7 +389,10 @@ const ProfileHeaderLoaded = observer( + onPress={onPressFollows} + accessibilityRole="button" + accessibilityLabel={`Show ${view.handle}'s follows`} + accessibilityHint={`Shows folks followed by ${view.handle}`}> {formatCount(view.followsCount)} @@ -382,14 +400,12 @@ const ProfileHeaderLoaded = observer( following - - - {view.postsCount} - + + {view.postsCount}{' '} {pluralize(view.postsCount, 'post')} - + {view.descriptionRichText ? ( + hitSlop={BACK_HITSLOP} + accessibilityRole="button" + accessibilityLabel="Go back" + accessibilityHint="Navigates to the previous screen"> @@ -450,7 +469,10 @@ const ProfileHeaderLoaded = observer( )} + onPress={onPressAvi} + accessibilityRole="image" + accessibilityLabel={`View ${view.handle}'s avatar`} + accessibilityHint={`Opens ${view.handle}'s avatar in an image viewer`}> + style={styles.headerMenuBtn} + accessibilityLabel="Go back" + accessibilityHint="Navigates to the previous screen"> setIsInputFocused(false)} onChangeText={onChangeQuery} onSubmitEditing={onSubmitQuery} + autoFocus={true} + accessibilityRole="search" /> {query ? ( - + {query || isInputFocused ? ( - + Cancel @@ -110,9 +120,10 @@ const styles = StyleSheet.create({ paddingVertical: 4, }, headerMenuBtn: { - width: 40, + width: 30, height: 30, - marginLeft: 6, + borderRadius: 30, + marginHorizontal: 6, }, headerSearchContainer: { flex: 1, diff --git a/src/view/com/util/BottomSheetCustomBackdrop.tsx b/src/view/com/util/BottomSheetCustomBackdrop.tsx index e175b33a5..91379f1c9 100644 --- a/src/view/com/util/BottomSheetCustomBackdrop.tsx +++ b/src/view/com/util/BottomSheetCustomBackdrop.tsx @@ -1,5 +1,5 @@ import React, {useMemo} from 'react' -import {GestureResponderEvent, TouchableWithoutFeedback} from 'react-native' +import {TouchableWithoutFeedback} from 'react-native' import {BottomSheetBackdropProps} from '@gorhom/bottom-sheet' import Animated, { Extrapolate, @@ -8,7 +8,7 @@ import Animated, { } from 'react-native-reanimated' export function createCustomBackdrop( - onClose?: ((event: GestureResponderEvent) => void) | undefined, + onClose?: (() => void) | undefined, ): React.FC { const CustomBackdrop = ({animatedIndex, style}: BottomSheetBackdropProps) => { // animated variables @@ -27,7 +27,15 @@ export function createCustomBackdrop( ) return ( - + { + if (onClose !== undefined) { + onClose() + } + }}> ) diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index 5110acf48..503e22084 100644 --- a/src/view/com/util/Link.tsx +++ b/src/view/com/util/Link.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, {ComponentProps} from 'react' import {observer} from 'mobx-react-lite' import { Linking, @@ -29,6 +29,16 @@ type Event = | React.MouseEvent | GestureResponderEvent +interface Props extends ComponentProps { + testID?: string + style?: StyleProp + href?: string + title?: string + children?: React.ReactNode + noFeedback?: boolean + asAnchor?: boolean +} + export const Link = observer(function Link({ testID, style, @@ -37,15 +47,9 @@ export const Link = observer(function Link({ children, noFeedback, asAnchor, -}: { - testID?: string - style?: StyleProp - href?: string - title?: string - children?: React.ReactNode - noFeedback?: boolean - asAnchor?: boolean -}) { + accessible, + ...props +}: Props) { const store = useStores() const navigation = useNavigation() @@ -64,7 +68,10 @@ export const Link = observer(function Link({ testID={testID} onPress={onPress} // @ts-ignore web only -prf - href={asAnchor ? sanitizeUrl(href) : undefined}> + href={asAnchor ? sanitizeUrl(href) : undefined} + accessible={accessible} + accessibilityRole="link" + {...props}> {children ? children : {title || 'link'}} @@ -76,8 +83,11 @@ export const Link = observer(function Link({ testID={testID} style={style} onPress={onPress} + accessible={accessible} + accessibilityRole="link" // @ts-ignore web only -prf - href={asAnchor ? sanitizeUrl(href) : undefined}> + href={asAnchor ? sanitizeUrl(href) : undefined} + {...props}> {children ? children : {title || 'link'}} ) diff --git a/src/view/com/util/Picker.tsx b/src/view/com/util/Picker.tsx deleted file mode 100644 index 9007cb1f0..000000000 --- a/src/view/com/util/Picker.tsx +++ /dev/null @@ -1,157 +0,0 @@ -// TODO: replaceme with something in the design system - -import React, {useRef} from 'react' -import { - StyleProp, - StyleSheet, - TextStyle, - TouchableOpacity, - TouchableWithoutFeedback, - View, - ViewStyle, -} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import RootSiblings from 'react-native-root-siblings' -import {Text} from './text/Text' -import {colors} from 'lib/styles' - -interface PickerItem { - value: string - label: string -} - -interface PickerOpts { - style?: StyleProp - labelStyle?: StyleProp - 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(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 ( - - - - {valueLabel} - - - - - ) -} - -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( - ( - <> - - - - - {items.map((item, index) => ( - onPressItem(index)}> - {item.label} - - ))} - - - ), - ) - return sibling -} - -const styles = StyleSheet.create({ - outer: { - flexDirection: 'row', - alignItems: 'center', - }, - label: { - marginRight: 5, - }, - icon: {}, - bg: { - position: 'absolute', - top: 0, - right: 0, - bottom: 0, - left: 0, - backgroundColor: '#000', - opacity: 0.1, - }, - menu: { - position: 'absolute', - backgroundColor: '#fff', - borderRadius: 14, - opacity: 1, - paddingVertical: 6, - }, - menuItem: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 6, - paddingLeft: 15, - paddingRight: 30, - }, - menuItemBorder: { - borderTopWidth: 1, - borderTopColor: colors.gray2, - marginTop: 4, - paddingTop: 12, - }, - menuItemIcon: { - marginLeft: 6, - marginRight: 8, - }, - menuItemLabel: { - fontSize: 15, - }, -}) diff --git a/src/view/com/util/PostCtrls.tsx b/src/view/com/util/PostCtrls.tsx index 07a67fd8a..725f3bbbe 100644 --- a/src/view/com/util/PostCtrls.tsx +++ b/src/view/com/util/PostCtrls.tsx @@ -170,83 +170,94 @@ export function PostCtrls(opts: PostCtrlsOpts) { return ( - - - - {typeof opts.replyCount !== 'undefined' ? ( - - {opts.replyCount} - - ) : undefined} - - - - - + + {typeof opts.replyCount !== 'undefined' ? ( + + {opts.replyCount} + + ) : undefined} + + + ) + : defaultCtrlColor + } + strokeWidth={2.4} + size={opts.big ? 24 : 20} + /> + {typeof opts.repostCount !== 'undefined' ? ( + ) - : defaultCtrlColor - } - strokeWidth={2.4} - size={opts.big ? 24 : 20} + ? [s.bold, s.green3, s.f15, s.ml5] + : [defaultCtrlColor, s.f15, s.ml5] + }> + {opts.repostCount} + + ) : undefined} + + + {opts.isLiked ? ( + } + size={opts.big ? 22 : 16} /> - {typeof opts.repostCount !== 'undefined' ? ( - - {opts.repostCount} - - ) : undefined} - - - - - {opts.isLiked ? ( - } - size={opts.big ? 22 : 16} - /> - ) : ( - - )} - {typeof opts.likeCount !== 'undefined' ? ( - - {opts.likeCount} - - ) : undefined} - - + ) : ( + + )} + {typeof opts.likeCount !== 'undefined' ? ( + + {opts.likeCount} + + ) : undefined} + {opts.big ? undefined : ( onPressItem(i)}> + onPress={() => onPressItem(i)} + accessibilityLabel={`Select ${item}`} + accessibilityHint={`Select option ${i} of ${numItems}`}> ) : ( @@ -167,7 +168,11 @@ export function UserAvatar({ void + onSelectNewBanner?: (img: RNImage | null) => void }) { const store = useStores() const pal = usePalette('default') @@ -94,6 +94,8 @@ export function UserBanner({ testID="userBannerImage" style={styles.bannerImage} source={{uri: banner}} + accessible={true} + accessibilityIgnoresInvertColors /> ) : ( ) : ( + 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 ? ( 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 + > + onPress={onPressTryAgain} + accessibilityRole="button" + accessibilityLabel="Retry" + accessibilityHint="Retries the last action, which errored out"> + onPress={onPressTryAgain} + accessibilityLabel="Retry" + accessibilityHint="Retries the last action, which errored out"> void) | undefined -export interface FABProps { +export interface FABProps + extends ComponentProps { testID?: string icon: JSX.Element - onPress: OnPress } -export const FABInner = observer(({testID, icon, onPress}: FABProps) => { +export const FABInner = observer(({testID, icon, ...props}: FABProps) => { const store = useStores() const interp = useAnimatedValue(0) React.useEffect(() => { @@ -34,7 +28,7 @@ export const FABInner = observer(({testID, icon, onPress}: FABProps) => { transform: [{translateY: Animated.multiply(interp, 60)}], } return ( - + + testID={testID} + accessibilityRole="button"> {label ? ( {label} diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx index 725d45c1b..04346d91f 100644 --- a/src/view/com/util/forms/DropdownButton.tsx +++ b/src/view/com/util/forms/DropdownButton.tsx @@ -1,4 +1,4 @@ -import React, {useRef} from 'react' +import React, {PropsWithChildren, useMemo, useRef} from 'react' import { Dimensions, StyleProp, @@ -39,6 +39,19 @@ type MaybeDropdownItem = DropdownItem | false | undefined export type DropdownButtonType = ButtonType | 'bare' +interface DropdownButtonProps { + testID?: string + type?: DropdownButtonType + style?: StyleProp + items: MaybeDropdownItem[] + label?: string + menuWidth?: number + children?: React.ReactNode + openToRight?: boolean + rightOffset?: number + bottomOffset?: number +} + export function DropdownButton({ testID, type = 'bare', @@ -50,18 +63,7 @@ export function DropdownButton({ openToRight = false, rightOffset = 0, bottomOffset = 0, -}: { - testID?: string - type?: DropdownButtonType - style?: StyleProp - items: MaybeDropdownItem[] - label?: string - menuWidth?: number - children?: React.ReactNode - openToRight?: boolean - rightOffset?: number - bottomOffset?: number -}) { +}: PropsWithChildren) { const ref1 = useRef(null) const ref2 = useRef(null) @@ -105,6 +107,18 @@ export function DropdownButton({ ) } + const numItems = useMemo( + () => + items.filter(item => { + if (item === undefined || item === false) { + return false + } + + return isBtn(item) + }).length, + [items], + ) + if (type === 'bare') { return ( + ref={ref1} + accessibilityRole="button" + accessibilityLabel={`Opens ${numItems} options`} + accessibilityHint={`Opens ${numItems} options`}> {children} ) @@ -283,9 +300,20 @@ const DropdownItems = ({ const separatorColor = theme.colorScheme === 'dark' ? pal.borderDark : pal.border + const numItems = items.filter(isBtn).length + return ( <> - + + // and onPressItem(index)}> + onPress={() => onPressItem(index)} + accessibilityLabel={item.label} + accessibilityHint={`Option ${index + 1} of ${numItems}`}> {item.icon && ( + style={[styles.container, style]} + accessible={true} + accessibilityLabel="Share image" + accessibilityHint="Opens ways of sharing image"> {children} @@ -80,7 +85,9 @@ export function AutoSizedImage({ style={[styles.image, {aspectRatio}]} source={{uri}} accessible={true} // Must set for `accessibilityLabel` to work + accessibilityIgnoresInvertColors accessibilityLabel={alt} + accessibilityHint="" /> {children} diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx index 78ced0668..5b6c3384d 100644 --- a/src/view/com/util/images/Gallery.tsx +++ b/src/view/com/util/images/Gallery.tsx @@ -41,16 +41,25 @@ export const GalleryItem: FC = ({ delayPressIn={DELAY_PRESS_IN} onPress={() => onPress?.(index)} onPressIn={() => onPressIn?.(index)} - onLongPress={() => onLongPress?.(index)}> + onLongPress={() => onLongPress?.(index)} + accessibilityRole="button" + accessibilityLabel="View image" + accessibilityHint=""> {image.alt === '' ? null : ( - + ALT )} diff --git a/src/view/com/util/images/Image.tsx b/src/view/com/util/images/Image.tsx index e3d0d7fcc..e779fa378 100644 --- a/src/view/com/util/images/Image.tsx +++ b/src/view/com/util/images/Image.tsx @@ -8,5 +8,7 @@ export function HighPriorityImage({source, ...props}: HighPriorityImageProps) { const updatedSource = { uri: typeof source === 'object' && source ? source.uri : '', } satisfies ImageSource - return + return ( + + ) } diff --git a/src/view/com/util/images/ImageHorzList.tsx b/src/view/com/util/images/ImageHorzList.tsx index 5c232e0b4..88494bba3 100644 --- a/src/view/com/util/images/ImageHorzList.tsx +++ b/src/view/com/util/images/ImageHorzList.tsx @@ -16,15 +16,33 @@ interface Props { } export function ImageHorzList({images, onPress, style}: Props) { + const numImages = images.length return ( {images.map(({thumb, alt}, i) => ( - onPress?.(i)}> + 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 + } + }}> ))} diff --git a/src/view/com/util/load-latest/LoadLatestBtn.web.tsx b/src/view/com/util/load-latest/LoadLatestBtn.web.tsx index 1b6f18b62..839685029 100644 --- a/src/view/com/util/load-latest/LoadLatestBtn.web.tsx +++ b/src/view/com/util/load-latest/LoadLatestBtn.web.tsx @@ -23,7 +23,10 @@ export const LoadLatestBtn = ({ + hitSlop={HITSLOP} + accessibilityRole="button" + accessibilityLabel={`Load new ${label}`} + accessibilityHint=""> Load new {label} diff --git a/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx b/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx index 75a812760..5279696a2 100644 --- a/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx +++ b/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx @@ -23,7 +23,10 @@ export const LoadLatestBtn = observer( }, ]} onPress={onPress} - hitSlop={HITSLOP}> + hitSlop={HITSLOP} + accessibilityRole="button" + accessibilityLabel={`Load new ${label}`} + accessibilityHint={`Loads new ${label}`}> 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' + }> {override ? 'Hide' : 'Show'} diff --git a/src/view/com/util/moderation/PostHider.tsx b/src/view/com/util/moderation/PostHider.tsx index b3c4c9593..2cc7ea62b 100644 --- a/src/view/com/util/moderation/PostHider.tsx +++ b/src/view/com/util/moderation/PostHider.tsx @@ -46,7 +46,8 @@ export function PostHider({ setOverride(v => !v)}> + onPress={() => setOverride(v => !v)} + accessibilityRole="button"> {override ? 'Hide' : 'Show'} post diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index 6a7759840..929c85adc 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -136,7 +136,10 @@ export function PostEmbeds({ { onPressAltText(alt) - }}> + }} + accessibilityRole="button" + accessibilityLabel="View alt text" + accessibilityHint="Opens modal with alt text"> ALT )} diff --git a/src/view/screens/AppPasswords.tsx b/src/view/screens/AppPasswords.tsx index 4e20558b7..a4bea68f7 100644 --- a/src/view/screens/AppPasswords.tsx +++ b/src/view/screens/AppPasswords.tsx @@ -184,7 +184,10 @@ function AppPassword({ + onPress={onDelete} + accessibilityRole="button" + accessibilityLabel="Delete" + accessibilityHint="Deletes app password"> {name} @@ -250,7 +253,6 @@ const styles = StyleSheet.create({ pr10: { marginRight: 10, }, - btnContainer: { flexDirection: 'row', justifyContent: 'center', diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 53bef813d..ba9b05c43 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -226,6 +226,9 @@ const FeedPage = observer( testID="composeFAB" onPress={onPressCompose} icon={} + accessibilityRole="button" + accessibilityLabel="Compose" + accessibilityHint="Opens post composer" /> ) diff --git a/src/view/screens/Log.tsx b/src/view/screens/Log.tsx index 8e0fe8dd3..4a747e5bf 100644 --- a/src/view/screens/Log.tsx +++ b/src/view/screens/Log.tsx @@ -46,7 +46,9 @@ export const LogScreen = observer(function Log({}: NativeStackScreenProps< + onPress={toggler(entry.id)} + accessibilityLabel="View debug entry" + accessibilityHint="Opens additional details for a debug entry"> {entry.type === 'debug' ? ( ) : ( diff --git a/src/view/screens/SearchMobile.tsx b/src/view/screens/SearchMobile.tsx index 4522d79ee..6152038d3 100644 --- a/src/view/screens/SearchMobile.tsx +++ b/src/view/screens/SearchMobile.tsx @@ -118,10 +118,10 @@ export const SearchScreen = withAuthRequired( }, []) return ( - + + noFeedback + accessibilityLabel={`Signed in as ${store.me.handle}`} + accessibilityHint="Double tap to sign out"> @@ -176,7 +178,10 @@ export const SettingsScreen = withAuthRequired( + onPress={isSwitching ? undefined : onPressSignout} + accessibilityRole="button" + accessibilityLabel="Sign out" + accessibilityHint={`Signs ${store.me.displayName} out of Bluesky`}> Sign out @@ -191,7 +196,10 @@ export const SettingsScreen = withAuthRequired( style={[pal.view, styles.linkCard, isSwitching && styles.dimmed]} onPress={ isSwitching ? undefined : () => onPressSwitchAccount(account) - }> + } + accessibilityRole="button" + accessibilityLabel={`Switch to ${account.handle}`} + accessibilityHint="Switches the account you are logged in to"> @@ -209,7 +217,10 @@ export const SettingsScreen = withAuthRequired( + onPress={isSwitching ? undefined : onPressAddAccount} + accessibilityRole="button" + accessibilityLabel="Add account" + accessibilityHint="Create a new Bluesky account"> + onPress={isSwitching ? undefined : onPressInviteCodes} + accessibilityRole="button" + accessibilityLabel="Invite" + accessibilityHint="Opens invite code list"> + onPress={isSwitching ? undefined : onPressContentFiltering} + accessibilityHint="Content moderation" + accessibilityLabel="Opens configurable content moderation settings"> + onPress={isSwitching ? undefined : onPressChangeHandle} + accessibilityRole="button" + accessibilityLabel="Change handle" + accessibilityHint="Choose a new Bluesky username or create"> + onPress={onPressDeleteAccount} + accessible={true} + accessibilityRole="button" + accessibilityLabel="Delete account" + accessibilityHint="Opens modal for account deletion confirmation. Requires email code."> + + { const {isAtHome, isAtSearch, isAtNotifications, isAtMyProfile} = useNavigationTabState() + const {notifications} = store.me + // events // = @@ -120,7 +122,11 @@ export const DrawerContent = observer(() => { ]}> - + { ) } label="Search" + accessibilityLabel="Search" + accessibilityHint="Search through users and posts" bold={isAtSearch} onPress={onPressSearch} /> @@ -184,6 +192,8 @@ export const DrawerContent = observer(() => { ) } label="Home" + accessibilityLabel="Home" + accessibilityHint="Navigates to default feed" bold={isAtHome} onPress={onPressHome} /> @@ -204,7 +214,13 @@ export const DrawerContent = observer(() => { ) } label="Notifications" - count={store.me.notifications.unreadCountLabel} + accessibilityLabel={ + notifications.unreadCountLabel === '1' + ? 'Notifications: 1 unread notification' + : `Notifications: ${notifications.unreadCountLabel} unread notifications` + } + accessibilityHint="Opens notification feed" + count={notifications.unreadCountLabel} bold={isAtNotifications} onPress={onPressNotifications} /> @@ -225,6 +241,8 @@ export const DrawerContent = observer(() => { ) } label="Profile" + accessibilityLabel="Profile" + accessibilityHint="See profile display name, avatar, description, and other profile items" onPress={onPressProfile} /> { /> } label="Settings" + accessibilityLabel="Settings" + accessibilityHint="Manage settings for your account, like handle, content moderation, and app passwords" onPress={onPressSettings} /> @@ -243,6 +263,13 @@ export const DrawerContent = observer(() => { {!isWeb && ( { )} { ) }) +interface MenuItemProps extends ComponentProps { + icon: JSX.Element + label: string + count?: string + bold?: boolean +} + function MenuItem({ icon, label, + accessibilityLabel, count, bold, onPress, -}: { - icon: JSX.Element - label: string - count?: string - bold?: boolean - onPress: () => void -}) { +}: MenuItemProps) { const pal = usePalette('default') return ( + onPress={onPress} + accessibilityRole="menuitem" + accessibilityLabel={accessibilityLabel} + accessibilityHint=""> {icon} {count ? ( @@ -332,6 +367,7 @@ const InviteCodes = observer(() => { const {track} = useAnalytics() const store = useStores() const pal = usePalette('default') + const {invitesAvailable} = store.me const onPress = React.useCallback(() => { track('Menu:ItemClicked', {url: '#invite-codes'}) store.shell.closeDrawer() @@ -341,7 +377,14 @@ const InviteCodes = observer(() => { + onPress={onPress} + accessibilityRole="button" + accessibilityLabel={ + invitesAvailable === 1 + ? 'Invite codes: 1 available' + : `Invite codes: ${invitesAvailable} available` + } + accessibilityHint="Opens list of invite codes"> { ) } onPress={onPressHome} + accessibilityLabel="Go home" + accessibilityHint="Navigates to feed home" /> { ) } onPress={onPressSearch} + accessibilityRole="search" /> { } onPress={onPressNotifications} notificationCount={store.me.notifications.unreadCountLabel} + accessibilityLabel="Notifications" + accessibilityHint="Navigates to notifications" /> { } onPress={onPressProfile} + accessibilityLabel="Profile" + accessibilityHint="Navigates to profile" /> ) }) +interface BtnProps + extends Pick< + ComponentProps, + 'accessibilityRole' | 'accessibilityHint' | 'accessibilityLabel' + > { + testID?: string + icon: JSX.Element + notificationCount?: string + onPress?: (event: GestureResponderEvent) => void + onLongPress?: (event: GestureResponderEvent) => void +} + function Btn({ testID, icon, notificationCount, onPress, onLongPress, -}: { - testID?: string - icon: JSX.Element - notificationCount?: string - onPress?: (event: GestureResponderEvent) => void - onLongPress?: (event: GestureResponderEvent) => void -}) { + accessibilityHint, + accessibilityLabel, +}: BtnProps) { return ( + onLongPress={onLongPress} + accessibilityLabel={accessibilityLabel} + accessibilityHint={accessibilityHint}> {notificationCount ? ( {notificationCount} diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index b4b219023..86f1a3ef3 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -2,7 +2,11 @@ import React from 'react' import {observer} from 'mobx-react-lite' import {StyleSheet, TouchableOpacity, View} from 'react-native' import {PressableWithHover} from 'view/com/util/PressableWithHover' -import {useNavigation, useNavigationState} from '@react-navigation/native' +import { + useLinkProps, + useNavigation, + useNavigationState, +} from '@react-navigation/native' import { FontAwesomeIcon, FontAwesomeIconStyle, @@ -59,7 +63,10 @@ function BackBtn() { + style={styles.backBtn} + accessibilityRole="button" + accessibilityLabel="Go back" + accessibilityHint="Navigates to the previous screen"> - - - {isCurrent ? iconFilled : icon} - {typeof count === 'string' && count ? ( - - {count} - - ) : null} - - - {label} - - + hoverStyle={pal.viewLight} + onPress={onPress} + accessibilityLabel={label} + accessibilityHint={`Navigates to ${label}`}> + + {isCurrent ? iconFilled : icon} + {typeof count === 'string' && count ? ( + + {count} + + ) : null} + + + {label} + ) }, @@ -115,7 +125,12 @@ function ComposeBtn() { const onPressCompose = () => store.shell.openComposer({}) return ( - + + onPress={onDarkmodePress} + accessibilityRole="button" + accessibilityLabel="Toggle dark mode" + accessibilityHint={ + mode === 'Dark' + ? 'Sets display to light mode' + : 'Sets display to dark mode' + }> @@ -78,13 +85,22 @@ const InviteCodes = observer(() => { const store = useStores() const pal = usePalette('default') + const {invitesAvailable} = store.me + const onPress = React.useCallback(() => { store.shell.openModal({name: 'invite-codes'}) }, [store]) return ( + onPress={onPress} + accessibilityRole="button" + accessibilityLabel={ + invitesAvailable === 1 + ? 'Invite codes: 1 available' + : `Invite codes: ${invitesAvailable} available` + } + accessibilityHint="Opens list of invite codes"> setIsInputFocused(false)} onChangeText={onChangeQuery} onSubmitEditing={onSubmit} + accessibilityRole="search" /> {query ? ( - + Cancel diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 3d790febc..349376436 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -46,7 +46,9 @@ const ShellInner = observer(() => { {!isDesktop && store.shell.isDrawerOpen && ( store.shell.closeDrawer()} - style={styles.drawerMask}> + style={styles.drawerMask} + accessibilityLabel="Close navigation footer" + accessibilityHint="Closes bottom navigation bar"> -- cgit 1.4.1