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:
parent
c75c888de2
commit
83959c595d
86 changed files with 2479 additions and 1827 deletions
|
@ -1,5 +1,5 @@
|
|||
import React, {useMemo} from 'react'
|
||||
import {GestureResponderEvent, TouchableWithoutFeedback} from 'react-native'
|
||||
import {TouchableWithoutFeedback} from 'react-native'
|
||||
import {BottomSheetBackdropProps} from '@gorhom/bottom-sheet'
|
||||
import Animated, {
|
||||
Extrapolate,
|
||||
|
@ -8,7 +8,7 @@ import Animated, {
|
|||
} from 'react-native-reanimated'
|
||||
|
||||
export function createCustomBackdrop(
|
||||
onClose?: ((event: GestureResponderEvent) => void) | undefined,
|
||||
onClose?: (() => void) | undefined,
|
||||
): React.FC<BottomSheetBackdropProps> {
|
||||
const CustomBackdrop = ({animatedIndex, style}: BottomSheetBackdropProps) => {
|
||||
// animated variables
|
||||
|
@ -27,7 +27,15 @@ export function createCustomBackdrop(
|
|||
)
|
||||
|
||||
return (
|
||||
<TouchableWithoutFeedback onPress={onClose}>
|
||||
<TouchableWithoutFeedback
|
||||
onPress={onClose}
|
||||
accessibilityLabel="Close bottom drawer"
|
||||
accessibilityHint=""
|
||||
onAccessibilityEscape={() => {
|
||||
if (onClose !== undefined) {
|
||||
onClose()
|
||||
}
|
||||
}}>
|
||||
<Animated.View style={containerStyle} />
|
||||
</TouchableWithoutFeedback>
|
||||
)
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react'
|
||||
import React, {ComponentProps} from 'react'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {
|
||||
Linking,
|
||||
|
@ -29,6 +29,16 @@ type Event =
|
|||
| React.MouseEvent<HTMLAnchorElement, MouseEvent>
|
||||
| GestureResponderEvent
|
||||
|
||||
interface Props extends ComponentProps<typeof TouchableOpacity> {
|
||||
testID?: string
|
||||
style?: StyleProp<ViewStyle>
|
||||
href?: string
|
||||
title?: string
|
||||
children?: React.ReactNode
|
||||
noFeedback?: boolean
|
||||
asAnchor?: boolean
|
||||
}
|
||||
|
||||
export const Link = observer(function Link({
|
||||
testID,
|
||||
style,
|
||||
|
@ -37,15 +47,9 @@ export const Link = observer(function Link({
|
|||
children,
|
||||
noFeedback,
|
||||
asAnchor,
|
||||
}: {
|
||||
testID?: string
|
||||
style?: StyleProp<ViewStyle>
|
||||
href?: string
|
||||
title?: string
|
||||
children?: React.ReactNode
|
||||
noFeedback?: boolean
|
||||
asAnchor?: boolean
|
||||
}) {
|
||||
accessible,
|
||||
...props
|
||||
}: Props) {
|
||||
const store = useStores()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
|
@ -64,7 +68,10 @@ export const Link = observer(function Link({
|
|||
testID={testID}
|
||||
onPress={onPress}
|
||||
// @ts-ignore web only -prf
|
||||
href={asAnchor ? sanitizeUrl(href) : undefined}>
|
||||
href={asAnchor ? sanitizeUrl(href) : undefined}
|
||||
accessible={accessible}
|
||||
accessibilityRole="link"
|
||||
{...props}>
|
||||
<View style={style}>
|
||||
{children ? children : <Text>{title || 'link'}</Text>}
|
||||
</View>
|
||||
|
@ -76,8 +83,11 @@ export const Link = observer(function Link({
|
|||
testID={testID}
|
||||
style={style}
|
||||
onPress={onPress}
|
||||
accessible={accessible}
|
||||
accessibilityRole="link"
|
||||
// @ts-ignore web only -prf
|
||||
href={asAnchor ? sanitizeUrl(href) : undefined}>
|
||||
href={asAnchor ? sanitizeUrl(href) : undefined}
|
||||
{...props}>
|
||||
{children ? children : <Text>{title || 'link'}</Text>}
|
||||
</TouchableOpacity>
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -170,83 +170,94 @@ export function PostCtrls(opts: PostCtrlsOpts) {
|
|||
|
||||
return (
|
||||
<View style={[styles.ctrls, opts.style]}>
|
||||
<View>
|
||||
<TouchableOpacity
|
||||
testID="replyBtn"
|
||||
style={styles.ctrl}
|
||||
hitSlop={HITSLOP}
|
||||
onPress={opts.onPressReply}>
|
||||
<CommentBottomArrow
|
||||
style={[defaultCtrlColor, opts.big ? s.mt2 : styles.mt1]}
|
||||
strokeWidth={3}
|
||||
size={opts.big ? 20 : 15}
|
||||
/>
|
||||
{typeof opts.replyCount !== 'undefined' ? (
|
||||
<Text style={[defaultCtrlColor, s.ml5, s.f15]}>
|
||||
{opts.replyCount}
|
||||
</Text>
|
||||
) : undefined}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View>
|
||||
<TouchableOpacity
|
||||
testID="repostBtn"
|
||||
hitSlop={HITSLOP}
|
||||
onPress={onPressToggleRepostWrapper}
|
||||
style={styles.ctrl}>
|
||||
<RepostIcon
|
||||
<TouchableOpacity
|
||||
testID="replyBtn"
|
||||
style={styles.ctrl}
|
||||
hitSlop={HITSLOP}
|
||||
onPress={opts.onPressReply}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Reply"
|
||||
accessibilityHint="Opens reply composer">
|
||||
<CommentBottomArrow
|
||||
style={[defaultCtrlColor, opts.big ? s.mt2 : styles.mt1]}
|
||||
strokeWidth={3}
|
||||
size={opts.big ? 20 : 15}
|
||||
/>
|
||||
{typeof opts.replyCount !== 'undefined' ? (
|
||||
<Text style={[defaultCtrlColor, s.ml5, s.f15]}>
|
||||
{opts.replyCount}
|
||||
</Text>
|
||||
) : undefined}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
testID="repostBtn"
|
||||
hitSlop={HITSLOP}
|
||||
onPress={onPressToggleRepostWrapper}
|
||||
style={styles.ctrl}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={opts.isReposted ? 'Undo repost' : 'Repost'}
|
||||
accessibilityHint={
|
||||
opts.isReposted
|
||||
? `Remove your repost of ${opts.author}'s post`
|
||||
: `Repost or quote post ${opts.author}'s post`
|
||||
}>
|
||||
<RepostIcon
|
||||
style={
|
||||
opts.isReposted
|
||||
? (styles.ctrlIconReposted as StyleProp<ViewStyle>)
|
||||
: defaultCtrlColor
|
||||
}
|
||||
strokeWidth={2.4}
|
||||
size={opts.big ? 24 : 20}
|
||||
/>
|
||||
{typeof opts.repostCount !== 'undefined' ? (
|
||||
<Text
|
||||
testID="repostCount"
|
||||
style={
|
||||
opts.isReposted
|
||||
? (styles.ctrlIconReposted as StyleProp<ViewStyle>)
|
||||
: defaultCtrlColor
|
||||
}
|
||||
strokeWidth={2.4}
|
||||
size={opts.big ? 24 : 20}
|
||||
? [s.bold, s.green3, s.f15, s.ml5]
|
||||
: [defaultCtrlColor, s.f15, s.ml5]
|
||||
}>
|
||||
{opts.repostCount}
|
||||
</Text>
|
||||
) : undefined}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
testID="likeBtn"
|
||||
style={styles.ctrl}
|
||||
hitSlop={HITSLOP}
|
||||
onPress={onPressToggleLikeWrapper}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={opts.isLiked ? 'Unlike' : 'Like'}
|
||||
accessibilityHint={
|
||||
opts.isReposted
|
||||
? `Removes like from ${opts.author}'s post`
|
||||
: `Like ${opts.author}'s post`
|
||||
}>
|
||||
{opts.isLiked ? (
|
||||
<HeartIconSolid
|
||||
style={styles.ctrlIconLiked as StyleProp<ViewStyle>}
|
||||
size={opts.big ? 22 : 16}
|
||||
/>
|
||||
{typeof opts.repostCount !== 'undefined' ? (
|
||||
<Text
|
||||
testID="repostCount"
|
||||
style={
|
||||
opts.isReposted
|
||||
? [s.bold, s.green3, s.f15, s.ml5]
|
||||
: [defaultCtrlColor, s.f15, s.ml5]
|
||||
}>
|
||||
{opts.repostCount}
|
||||
</Text>
|
||||
) : undefined}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View>
|
||||
<TouchableOpacity
|
||||
testID="likeBtn"
|
||||
style={styles.ctrl}
|
||||
hitSlop={HITSLOP}
|
||||
onPress={onPressToggleLikeWrapper}>
|
||||
{opts.isLiked ? (
|
||||
<HeartIconSolid
|
||||
style={styles.ctrlIconLiked as StyleProp<ViewStyle>}
|
||||
size={opts.big ? 22 : 16}
|
||||
/>
|
||||
) : (
|
||||
<HeartIcon
|
||||
style={[defaultCtrlColor, opts.big ? styles.mt1 : undefined]}
|
||||
strokeWidth={3}
|
||||
size={opts.big ? 20 : 16}
|
||||
/>
|
||||
)}
|
||||
{typeof opts.likeCount !== 'undefined' ? (
|
||||
<Text
|
||||
testID="likeCount"
|
||||
style={
|
||||
opts.isLiked
|
||||
? [s.bold, s.red3, s.f15, s.ml5]
|
||||
: [defaultCtrlColor, s.f15, s.ml5]
|
||||
}>
|
||||
{opts.likeCount}
|
||||
</Text>
|
||||
) : undefined}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<HeartIcon
|
||||
style={[defaultCtrlColor, opts.big ? styles.mt1 : undefined]}
|
||||
strokeWidth={3}
|
||||
size={opts.big ? 20 : 16}
|
||||
/>
|
||||
)}
|
||||
{typeof opts.likeCount !== 'undefined' ? (
|
||||
<Text
|
||||
testID="likeCount"
|
||||
style={
|
||||
opts.isLiked
|
||||
? [s.bold, s.red3, s.f15, s.ml5]
|
||||
: [defaultCtrlColor, s.f15, s.ml5]
|
||||
}>
|
||||
{opts.likeCount}
|
||||
</Text>
|
||||
) : undefined}
|
||||
</TouchableOpacity>
|
||||
<View>
|
||||
{opts.big ? undefined : (
|
||||
<PostDropdownBtn
|
||||
|
|
|
@ -85,6 +85,8 @@ export function Selector({
|
|||
onSelect?.(index)
|
||||
}
|
||||
|
||||
const numItems = items.length
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[pal.view, styles.outer]}
|
||||
|
@ -97,7 +99,9 @@ export function Selector({
|
|||
<Pressable
|
||||
testID={`selector-${i}`}
|
||||
key={item}
|
||||
onPress={() => onPressItem(i)}>
|
||||
onPress={() => onPressItem(i)}
|
||||
accessibilityLabel={`Select ${item}`}
|
||||
accessibilityHint={`Select option ${i} of ${numItems}`}>
|
||||
<View style={styles.item} ref={itemRefs[i]}>
|
||||
<Text
|
||||
style={
|
||||
|
|
|
@ -150,6 +150,7 @@ export function UserAvatar({
|
|||
borderRadius: Math.floor(size / 2),
|
||||
}}
|
||||
source={{uri: avatar}}
|
||||
accessibilityRole="image"
|
||||
/>
|
||||
) : (
|
||||
<DefaultAvatar size={size} />
|
||||
|
@ -167,7 +168,11 @@ export function UserAvatar({
|
|||
<View style={{width: size, height: size}}>
|
||||
<HighPriorityImage
|
||||
testID="userAvatarImage"
|
||||
style={{width: size, height: size, borderRadius: Math.floor(size / 2)}}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: Math.floor(size / 2),
|
||||
}}
|
||||
contentFit="cover"
|
||||
source={{uri: avatar}}
|
||||
blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
|
||||
|
|
|
@ -5,7 +5,6 @@ import {IconProp} from '@fortawesome/fontawesome-svg-core'
|
|||
import {Image} from 'expo-image'
|
||||
import {colors} from 'lib/styles'
|
||||
import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
|
||||
import {Image as TImage} from 'lib/media/types'
|
||||
import {useStores} from 'state/index'
|
||||
import {
|
||||
usePhotoLibraryPermission,
|
||||
|
@ -15,6 +14,7 @@ import {DropdownButton} from './forms/DropdownButton'
|
|||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {AvatarModeration} from 'lib/labeling/types'
|
||||
import {isWeb, isAndroid} from 'platform/detection'
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
|
||||
export function UserBanner({
|
||||
banner,
|
||||
|
@ -23,7 +23,7 @@ export function UserBanner({
|
|||
}: {
|
||||
banner?: string | null
|
||||
moderation?: AvatarModeration
|
||||
onSelectNewBanner?: (img: TImage | null) => void
|
||||
onSelectNewBanner?: (img: RNImage | null) => void
|
||||
}) {
|
||||
const store = useStores()
|
||||
const pal = usePalette('default')
|
||||
|
@ -94,6 +94,8 @@ export function UserBanner({
|
|||
testID="userBannerImage"
|
||||
style={styles.bannerImage}
|
||||
source={{uri: banner}}
|
||||
accessible={true}
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
|
@ -118,6 +120,8 @@ export function UserBanner({
|
|||
resizeMode="cover"
|
||||
source={{uri: banner}}
|
||||
blurRadius={moderation?.blur ? 100 : 0}
|
||||
accessible={true}
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
|
|
|
@ -60,7 +60,14 @@ export const ViewHeader = observer(function ({
|
|||
testID="viewHeaderDrawerBtn"
|
||||
onPress={canGoBack ? onPressBack : onPressMenu}
|
||||
hitSlop={BACK_HITSLOP}
|
||||
style={canGoBack ? styles.backBtn : styles.backBtnWide}>
|
||||
style={canGoBack ? styles.backBtn : styles.backBtnWide}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={canGoBack ? 'Go back' : 'Go to menu'}
|
||||
accessibilityHint={
|
||||
canGoBack
|
||||
? 'Navigates to the previous screen'
|
||||
: 'Navigates to the menu'
|
||||
}>
|
||||
{canGoBack ? (
|
||||
<FontAwesomeIcon
|
||||
size={18}
|
||||
|
@ -171,9 +178,9 @@ const styles = StyleSheet.create({
|
|||
height: 30,
|
||||
},
|
||||
backBtnWide: {
|
||||
width: 40,
|
||||
width: 30,
|
||||
height: 30,
|
||||
marginLeft: 6,
|
||||
paddingHorizontal: 6,
|
||||
},
|
||||
backIcon: {
|
||||
marginTop: 6,
|
||||
|
|
|
@ -132,7 +132,12 @@ export function Selector({
|
|||
<Pressable
|
||||
testID={`selector-${i}`}
|
||||
key={item}
|
||||
onPress={() => onPressItem(i)}>
|
||||
onPress={() => onPressItem(i)}
|
||||
accessibilityLabel={item}
|
||||
accessibilityHint={`Selects ${item}`}
|
||||
// TODO: Modify the component API such that lint fails
|
||||
// at the invocation site as well
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.item,
|
||||
|
|
|
@ -47,7 +47,10 @@ export function ErrorMessage({
|
|||
<TouchableOpacity
|
||||
testID="errorMessageTryAgainButton"
|
||||
style={styles.btn}
|
||||
onPress={onPressTryAgain}>
|
||||
onPress={onPressTryAgain}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Retry"
|
||||
accessibilityHint="Retries the last action, which errored out">
|
||||
<FontAwesomeIcon
|
||||
icon="arrows-rotate"
|
||||
style={{color: theme.palette.error.icon}}
|
||||
|
|
|
@ -57,7 +57,9 @@ export function ErrorScreen({
|
|||
testID="errorScreenTryAgainButton"
|
||||
type="default"
|
||||
style={[styles.btn]}
|
||||
onPress={onPressTryAgain}>
|
||||
onPress={onPressTryAgain}
|
||||
accessibilityLabel="Retry"
|
||||
accessibilityHint="Retries the last action, which errored out">
|
||||
<FontAwesomeIcon
|
||||
icon="arrows-rotate"
|
||||
style={pal.link as FontAwesomeIconStyle}
|
||||
|
|
|
@ -1,25 +1,19 @@
|
|||
import React from 'react'
|
||||
import React, {ComponentProps} from 'react'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {
|
||||
Animated,
|
||||
GestureResponderEvent,
|
||||
StyleSheet,
|
||||
TouchableWithoutFeedback,
|
||||
} from 'react-native'
|
||||
import {Animated, StyleSheet, TouchableWithoutFeedback} from 'react-native'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import {gradients} from 'lib/styles'
|
||||
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
||||
import {useStores} from 'state/index'
|
||||
import {isMobileWeb} from 'platform/detection'
|
||||
|
||||
type OnPress = ((event: GestureResponderEvent) => void) | undefined
|
||||
export interface FABProps {
|
||||
export interface FABProps
|
||||
extends ComponentProps<typeof TouchableWithoutFeedback> {
|
||||
testID?: string
|
||||
icon: JSX.Element
|
||||
onPress: OnPress
|
||||
}
|
||||
|
||||
export const FABInner = observer(({testID, icon, onPress}: FABProps) => {
|
||||
export const FABInner = observer(({testID, icon, ...props}: FABProps) => {
|
||||
const store = useStores()
|
||||
const interp = useAnimatedValue(0)
|
||||
React.useEffect(() => {
|
||||
|
@ -34,7 +28,7 @@ export const FABInner = observer(({testID, icon, onPress}: FABProps) => {
|
|||
transform: [{translateY: Animated.multiply(interp, 60)}],
|
||||
}
|
||||
return (
|
||||
<TouchableWithoutFeedback testID={testID} onPress={onPress}>
|
||||
<TouchableWithoutFeedback testID={testID} {...props}>
|
||||
<Animated.View
|
||||
style={[styles.outer, isMobileWeb && styles.mobileWebOuter, transform]}>
|
||||
<LinearGradient
|
||||
|
|
|
@ -26,6 +26,7 @@ export type ButtonType =
|
|||
| 'secondary-light'
|
||||
| 'default-light'
|
||||
|
||||
// TODO: Enforce that button always has a label
|
||||
export function Button({
|
||||
type = 'primary',
|
||||
label,
|
||||
|
@ -131,7 +132,8 @@ export function Button({
|
|||
<Pressable
|
||||
style={[typeOuterStyle, styles.outer, style]}
|
||||
onPress={onPressWrapped}
|
||||
testID={testID}>
|
||||
testID={testID}
|
||||
accessibilityRole="button">
|
||||
{label ? (
|
||||
<Text type="button" style={[typeLabelStyle, labelStyle]}>
|
||||
{label}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, {useRef} from 'react'
|
||||
import React, {PropsWithChildren, useMemo, useRef} from 'react'
|
||||
import {
|
||||
Dimensions,
|
||||
StyleProp,
|
||||
|
@ -39,6 +39,19 @@ type MaybeDropdownItem = DropdownItem | false | undefined
|
|||
|
||||
export type DropdownButtonType = ButtonType | 'bare'
|
||||
|
||||
interface DropdownButtonProps {
|
||||
testID?: string
|
||||
type?: DropdownButtonType
|
||||
style?: StyleProp<ViewStyle>
|
||||
items: MaybeDropdownItem[]
|
||||
label?: string
|
||||
menuWidth?: number
|
||||
children?: React.ReactNode
|
||||
openToRight?: boolean
|
||||
rightOffset?: number
|
||||
bottomOffset?: number
|
||||
}
|
||||
|
||||
export function DropdownButton({
|
||||
testID,
|
||||
type = 'bare',
|
||||
|
@ -50,18 +63,7 @@ export function DropdownButton({
|
|||
openToRight = false,
|
||||
rightOffset = 0,
|
||||
bottomOffset = 0,
|
||||
}: {
|
||||
testID?: string
|
||||
type?: DropdownButtonType
|
||||
style?: StyleProp<ViewStyle>
|
||||
items: MaybeDropdownItem[]
|
||||
label?: string
|
||||
menuWidth?: number
|
||||
children?: React.ReactNode
|
||||
openToRight?: boolean
|
||||
rightOffset?: number
|
||||
bottomOffset?: number
|
||||
}) {
|
||||
}: PropsWithChildren<DropdownButtonProps>) {
|
||||
const ref1 = useRef<TouchableOpacity>(null)
|
||||
const ref2 = useRef<View>(null)
|
||||
|
||||
|
@ -105,6 +107,18 @@ export function DropdownButton({
|
|||
)
|
||||
}
|
||||
|
||||
const numItems = useMemo(
|
||||
() =>
|
||||
items.filter(item => {
|
||||
if (item === undefined || item === false) {
|
||||
return false
|
||||
}
|
||||
|
||||
return isBtn(item)
|
||||
}).length,
|
||||
[items],
|
||||
)
|
||||
|
||||
if (type === 'bare') {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
|
@ -112,7 +126,10 @@ export function DropdownButton({
|
|||
style={style}
|
||||
onPress={onPress}
|
||||
hitSlop={HITSLOP}
|
||||
ref={ref1}>
|
||||
ref={ref1}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Opens ${numItems} options`}
|
||||
accessibilityHint={`Opens ${numItems} options`}>
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
)
|
||||
|
@ -283,9 +300,20 @@ const DropdownItems = ({
|
|||
const separatorColor =
|
||||
theme.colorScheme === 'dark' ? pal.borderDark : pal.border
|
||||
|
||||
const numItems = items.filter(isBtn).length
|
||||
|
||||
return (
|
||||
<>
|
||||
<TouchableWithoutFeedback onPress={onOuterPress}>
|
||||
<TouchableWithoutFeedback
|
||||
onPress={onOuterPress}
|
||||
// TODO: Refactor dropdown components to:
|
||||
// - (On web, if not handled by React Native) use semantic <select />
|
||||
// and <option /> elements for keyboard navigation out of the box
|
||||
// - (On mobile) be buttons by default, accept `label` and `nativeID`
|
||||
// props, and always have an explicit label
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Toggle dropdown"
|
||||
accessibilityHint="">
|
||||
<View style={[styles.bg]} />
|
||||
</TouchableWithoutFeedback>
|
||||
<View
|
||||
|
@ -301,7 +329,9 @@ const DropdownItems = ({
|
|||
testID={item.testID}
|
||||
key={index}
|
||||
style={[styles.menuItem]}
|
||||
onPress={() => onPressItem(index)}>
|
||||
onPress={() => onPressItem(index)}
|
||||
accessibilityLabel={item.label}
|
||||
accessibilityHint={`Option ${index + 1} of ${numItems}`}>
|
||||
{item.icon && (
|
||||
<FontAwesomeIcon
|
||||
style={styles.icon}
|
||||
|
|
|
@ -62,12 +62,17 @@ export function AutoSizedImage({
|
|||
onLongPress={onLongPress}
|
||||
onPressIn={onPressIn}
|
||||
delayPressIn={DELAY_PRESS_IN}
|
||||
style={[styles.container, style]}>
|
||||
style={[styles.container, style]}
|
||||
accessible={true}
|
||||
accessibilityLabel="Share image"
|
||||
accessibilityHint="Opens ways of sharing image">
|
||||
<Image
|
||||
style={[styles.image, {aspectRatio}]}
|
||||
source={uri}
|
||||
accessible={true} // Must set for `accessibilityLabel` to work
|
||||
accessibilityIgnoresInvertColors
|
||||
accessibilityLabel={alt}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
|
@ -80,7 +85,9 @@ export function AutoSizedImage({
|
|||
style={[styles.image, {aspectRatio}]}
|
||||
source={{uri}}
|
||||
accessible={true} // Must set for `accessibilityLabel` to work
|
||||
accessibilityIgnoresInvertColors
|
||||
accessibilityLabel={alt}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
{children}
|
||||
</View>
|
||||
|
|
|
@ -41,16 +41,25 @@ export const GalleryItem: FC<GalleryItemProps> = ({
|
|||
delayPressIn={DELAY_PRESS_IN}
|
||||
onPress={() => onPress?.(index)}
|
||||
onPressIn={() => onPressIn?.(index)}
|
||||
onLongPress={() => onLongPress?.(index)}>
|
||||
onLongPress={() => onLongPress?.(index)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="View image"
|
||||
accessibilityHint="">
|
||||
<Image
|
||||
source={{uri: image.thumb}}
|
||||
style={imageStyle}
|
||||
accessible={true}
|
||||
accessibilityLabel={image.alt}
|
||||
accessibilityHint=""
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
{image.alt === '' ? null : (
|
||||
<Pressable onPress={onPressAltText}>
|
||||
<Pressable
|
||||
onPress={onPressAltText}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="View alt text"
|
||||
accessibilityHint="Opens modal with alt text">
|
||||
<Text style={styles.alt}>ALT</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
|
|
@ -8,5 +8,7 @@ export function HighPriorityImage({source, ...props}: HighPriorityImageProps) {
|
|||
const updatedSource = {
|
||||
uri: typeof source === 'object' && source ? source.uri : '',
|
||||
} satisfies ImageSource
|
||||
return <Image source={updatedSource} {...props} />
|
||||
return (
|
||||
<Image accessibilityIgnoresInvertColors source={updatedSource} {...props} />
|
||||
)
|
||||
}
|
||||
|
|
|
@ -16,15 +16,33 @@ interface Props {
|
|||
}
|
||||
|
||||
export function ImageHorzList({images, onPress, style}: Props) {
|
||||
const numImages = images.length
|
||||
return (
|
||||
<View style={[styles.flexRow, style]}>
|
||||
{images.map(({thumb, alt}, i) => (
|
||||
<TouchableWithoutFeedback key={i} onPress={() => onPress?.(i)}>
|
||||
<TouchableWithoutFeedback
|
||||
key={i}
|
||||
onPress={() => onPress?.(i)}
|
||||
accessible={true}
|
||||
accessibilityLabel={`Open image ${i} of ${numImages}`}
|
||||
accessibilityHint="Opens image in viewer"
|
||||
accessibilityActions={[{name: 'press', label: 'Press'}]}
|
||||
onAccessibilityAction={action => {
|
||||
switch (action.nativeEvent.actionName) {
|
||||
case 'press':
|
||||
onPress?.(0)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}}>
|
||||
<Image
|
||||
source={{uri: thumb}}
|
||||
style={styles.image}
|
||||
accessible={true}
|
||||
accessibilityLabel={alt}
|
||||
accessibilityIgnoresInvertColors
|
||||
accessibilityHint={alt}
|
||||
accessibilityLabel=""
|
||||
/>
|
||||
</TouchableWithoutFeedback>
|
||||
))}
|
||||
|
|
|
@ -23,7 +23,10 @@ export const LoadLatestBtn = ({
|
|||
<TouchableOpacity
|
||||
style={[pal.view, pal.borderDark, styles.loadLatest]}
|
||||
onPress={onPress}
|
||||
hitSlop={HITSLOP}>
|
||||
hitSlop={HITSLOP}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Load new ${label}`}
|
||||
accessibilityHint="">
|
||||
<Text type="md-bold" style={pal.text}>
|
||||
<UpIcon size={16} strokeWidth={1} style={[pal.text, styles.icon]} />
|
||||
Load new {label}
|
||||
|
|
|
@ -23,7 +23,10 @@ export const LoadLatestBtn = observer(
|
|||
},
|
||||
]}
|
||||
onPress={onPress}
|
||||
hitSlop={HITSLOP}>
|
||||
hitSlop={HITSLOP}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Load new ${label}`}
|
||||
accessibilityHint={`Loads new ${label}`}>
|
||||
<LinearGradient
|
||||
colors={[gradients.blueLight.start, gradients.blueLight.end]}
|
||||
start={{x: 0, y: 0}}
|
||||
|
|
|
@ -55,7 +55,14 @@ export function ContentHider({
|
|||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.showBtn}
|
||||
onPress={() => setOverride(v => !v)}>
|
||||
onPress={() => setOverride(v => !v)}
|
||||
accessibilityLabel={override ? 'Hide post' : 'Show post'}
|
||||
// TODO: The text labelling should be split up so controls have unique roles
|
||||
accessibilityHint={
|
||||
override
|
||||
? 'Re-hide post'
|
||||
: 'Shows post hidden based on your moderation settings'
|
||||
}>
|
||||
<Text type="md" style={pal.link}>
|
||||
{override ? 'Hide' : 'Show'}
|
||||
</Text>
|
||||
|
|
|
@ -46,7 +46,8 @@ export function PostHider({
|
|||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.showBtn}
|
||||
onPress={() => setOverride(v => !v)}>
|
||||
onPress={() => setOverride(v => !v)}
|
||||
accessibilityRole="button">
|
||||
<Text type="md" style={pal.link}>
|
||||
{override ? 'Hide' : 'Show'} post
|
||||
</Text>
|
||||
|
|
|
@ -136,7 +136,10 @@ export function PostEmbeds({
|
|||
<Pressable
|
||||
onPress={() => {
|
||||
onPressAltText(alt)
|
||||
}}>
|
||||
}}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="View alt text"
|
||||
accessibilityHint="Opens modal with alt text">
|
||||
<Text style={styles.alt}>ALT</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue