[APP-724] Collection of accessibility fixes (#949)

* Fix: include alt text on the web lightbox image

* a11y: Dont read the 'ALT' label

* a11y: remove a wrapper behavior from posts

This appears to have been introduced with the goal of creating meta
actions on posts, but the behavior seems counter-productive. The
accessibility inspector was unable to access individual items within
the post and therefore most content was simply skipped.

There may be a way to support the post actions without losing the
ability to access the inner elements but I couldnt find it. -prf

* a11y: apply alt tags to image wrappers so they get read

* a11y: set Link accessibilityLabel to the title if none set

* a11y: skip the SANDBOX watermark

* a11y: improve post meta to not read UI and give a useful date

* ally: improve post controls

* a11y: add labels to lightbox images on mobile

* fix types
zio/stable
Paul Frazee 2023-07-03 15:57:53 -05:00 committed by GitHub
parent 0163ba0af8
commit bc55241c9a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 80 additions and 148 deletions

View File

@ -89,7 +89,9 @@ export const Gallery = observer(function ({gallery}: Props) {
openAltTextModal(store, image) openAltTextModal(store, image)
}} }}
style={[styles.altTextControl, altTextControlStyle]}> style={[styles.altTextControl, altTextControlStyle]}>
<Text style={styles.altTextControlLabel}>ALT</Text> <Text style={styles.altTextControlLabel} accessible={false}>
ALT
</Text>
{image.altText.length > 0 ? ( {image.altText.length > 0 ? (
<FontAwesomeIcon <FontAwesomeIcon
icon="check" icon="check"

View File

@ -6,8 +6,6 @@
* *
*/ */
import {ImageURISource, ImageRequireSource} from 'react-native'
export type Dimensions = { export type Dimensions = {
width: number width: number
height: number height: number
@ -18,4 +16,4 @@ export type Position = {
y: number y: number
} }
export type ImageSource = ImageURISource | ImageRequireSource export type ImageSource = {uri: string; alt?: string}

View File

@ -133,6 +133,8 @@ const ImageItem = ({
source={imageSrc} source={imageSrc}
style={imageStylesWithOpacity} style={imageStylesWithOpacity}
onLoad={onLoaded} onLoad={onLoaded}
accessibilityLabel={imageSrc.alt}
accessibilityHint=""
/> />
{(!isLoaded || !imageDimensions) && <ImageLoading />} {(!isLoaded || !imageDimensions) && <ImageLoading />}
</ScrollView> </ScrollView>

View File

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

View File

@ -109,7 +109,7 @@ export const Lightbox = observer(function Lightbox() {
const opts = store.shell.activeLightbox as models.ProfileImageLightbox const opts = store.shell.activeLightbox as models.ProfileImageLightbox
return ( return (
<ImageView <ImageView
images={[{uri: opts.profileView.avatar}]} images={[{uri: opts.profileView.avatar || ''}]}
imageIndex={0} imageIndex={0}
visible visible
onRequestClose={onClose} onRequestClose={onClose}
@ -120,7 +120,7 @@ export const Lightbox = observer(function Lightbox() {
const opts = store.shell.activeLightbox as models.ImagesLightbox const opts = store.shell.activeLightbox as models.ImagesLightbox
return ( return (
<ImageView <ImageView
images={opts.images.map(({uri}) => ({uri}))} images={opts.images.map(img => ({...img}))}
imageIndex={opts.index} imageIndex={opts.index}
visible visible
onRequestClose={onClose} onRequestClose={onClose}

View File

@ -109,6 +109,8 @@ function LightboxInner({
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
source={imgs[index]} source={imgs[index]}
style={styles.image} style={styles.image}
accessibilityLabel={imgs[index].alt}
accessibilityHint=""
/> />
{canGoLeft && ( {canGoLeft && (
<TouchableOpacity <TouchableOpacity

View File

@ -1,6 +1,6 @@
import React, {useCallback, useMemo} from 'react' import React, {useMemo} from 'react'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {AccessibilityActionEvent, Linking, StyleSheet, View} from 'react-native' import {Linking, StyleSheet, View} from 'react-native'
import Clipboard from '@react-native-clipboard/clipboard' import Clipboard from '@react-native-clipboard/clipboard'
import {AtUri, AppBskyFeedDefs} from '@atproto/api' import {AtUri, AppBskyFeedDefs} from '@atproto/api'
import { import {
@ -138,40 +138,6 @@ export const PostThreadItem = observer(function PostThreadItem({
) )
}, [item, store]) }, [item, store])
const accessibilityActions = useMemo(
() => [
{
name: 'reply',
label: 'Reply',
},
{
name: 'repost',
label: item.post.viewer?.repost ? 'Undo repost' : 'Repost',
},
{name: 'like', label: item.post.viewer?.like ? 'Unlike' : 'Like'},
],
[item.post.viewer?.like, item.post.viewer?.repost],
)
const onAccessibilityAction = useCallback(
(event: AccessibilityActionEvent) => {
switch (event.nativeEvent.actionName) {
case 'like':
onPressToggleLike()
break
case 'reply':
onPressReply()
break
case 'repost':
onPressToggleRepost()
break
default:
break
}
},
[onPressReply, onPressToggleLike, onPressToggleRepost],
)
if (!record) { if (!record) {
return <ErrorMessage message="Invalid or unsupported post record" /> return <ErrorMessage message="Invalid or unsupported post record" />
} }
@ -193,9 +159,7 @@ export const PostThreadItem = observer(function PostThreadItem({
<PostHider <PostHider
testID={`postThreadItem-by-${item.post.author.handle}`} testID={`postThreadItem-by-${item.post.author.handle}`}
style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]} style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}
moderation={item.moderation.thread} moderation={item.moderation.thread}>
accessibilityActions={accessibilityActions}
onAccessibilityAction={onAccessibilityAction}>
<PostSandboxWarning /> <PostSandboxWarning />
<View style={styles.layout}> <View style={styles.layout}>
<View style={styles.layoutAvi}> <View style={styles.layoutAvi}>
@ -369,9 +333,7 @@ export const PostThreadItem = observer(function PostThreadItem({
pal.view, pal.view,
item._showParentReplyLine && styles.noTopBorder, item._showParentReplyLine && styles.noTopBorder,
]} ]}
moderation={item.moderation.thread} moderation={item.moderation.thread}>
accessibilityActions={accessibilityActions}
onAccessibilityAction={onAccessibilityAction}>
{item._showParentReplyLine && ( {item._showParentReplyLine && (
<View <View
style={[ style={[

View File

@ -1,6 +1,5 @@
import React, {useCallback, useEffect, useMemo, useState} from 'react' import React, {useEffect, useState} from 'react'
import { import {
AccessibilityActionEvent,
ActivityIndicator, ActivityIndicator,
Linking, Linking,
StyleProp, StyleProp,
@ -200,47 +199,11 @@ const PostLoaded = observer(
) )
}, [item, setDeleted, store]) }, [item, setDeleted, store])
const accessibilityActions = useMemo(
() => [
{
name: 'reply',
label: 'Reply',
},
{
name: 'repost',
label: item.post.viewer?.repost ? 'Undo repost' : 'Repost',
},
{name: 'like', label: item.post.viewer?.like ? 'Unlike' : 'Like'},
],
[item.post.viewer?.like, item.post.viewer?.repost],
)
const onAccessibilityAction = useCallback(
(event: AccessibilityActionEvent) => {
switch (event.nativeEvent.actionName) {
case 'like':
onPressToggleLike()
break
case 'reply':
onPressReply()
break
case 'repost':
onPressToggleRepost()
break
default:
break
}
},
[onPressReply, onPressToggleLike, onPressToggleRepost],
)
return ( return (
<PostHider <PostHider
href={itemHref} href={itemHref}
style={[styles.outer, pal.view, pal.border, style]} style={[styles.outer, pal.view, pal.border, style]}
moderation={item.moderation.list} moderation={item.moderation.list}>
accessibilityActions={accessibilityActions}
onAccessibilityAction={onAccessibilityAction}>
{showReplyLine && <View style={styles.replyLine} />} {showReplyLine && <View style={styles.replyLine} />}
<View style={styles.layout}> <View style={styles.layout}>
<View style={styles.layoutAvi}> <View style={styles.layoutAvi}>

View File

@ -1,6 +1,6 @@
import React, {useCallback, useMemo, useState} from 'react' import React, {useMemo, useState} from 'react'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {AccessibilityActionEvent, Linking, StyleSheet, View} from 'react-native' import {Linking, StyleSheet, View} from 'react-native'
import Clipboard from '@react-native-clipboard/clipboard' import Clipboard from '@react-native-clipboard/clipboard'
import {AtUri} from '@atproto/api' import {AtUri} from '@atproto/api'
import { import {
@ -158,40 +158,6 @@ export const FeedItem = observer(function ({
moderation = {behavior: ModerationBehaviorCode.Show} moderation = {behavior: ModerationBehaviorCode.Show}
} }
const accessibilityActions = useMemo(
() => [
{
name: 'reply',
label: 'Reply',
},
{
name: 'repost',
label: item.post.viewer?.repost ? 'Undo repost' : 'Repost',
},
{name: 'like', label: item.post.viewer?.like ? 'Unlike' : 'Like'},
],
[item.post.viewer?.like, item.post.viewer?.repost],
)
const onAccessibilityAction = useCallback(
(event: AccessibilityActionEvent) => {
switch (event.nativeEvent.actionName) {
case 'like':
onPressToggleLike()
break
case 'reply':
onPressReply()
break
case 'repost':
onPressToggleRepost()
break
default:
break
}
},
[onPressReply, onPressToggleLike, onPressToggleRepost],
)
if (!record || deleted) { if (!record || deleted) {
return <View /> return <View />
} }
@ -201,9 +167,7 @@ export const FeedItem = observer(function ({
testID={`feedItem-by-${item.post.author.handle}`} testID={`feedItem-by-${item.post.author.handle}`}
style={outerStyles} style={outerStyles}
href={itemHref} href={itemHref}
moderation={moderation} moderation={moderation}>
accessibilityActions={accessibilityActions}
onAccessibilityAction={onAccessibilityAction}>
{isThreadChild && ( {isThreadChild && (
<View <View
style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]} style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]}

View File

@ -88,6 +88,10 @@ export const Link = observer(function Link({
props.dataSet.noUnderline = 1 props.dataSet.noUnderline = 1
} }
if (title && !props.accessibilityLabel) {
props.accessibilityLabel = title
}
return ( return (
<TouchableOpacity <TouchableOpacity
testID={testID} testID={testID}
@ -171,6 +175,7 @@ export const DesktopWebTextLink = observer(function DesktopWebTextLink({
text, text,
numberOfLines, numberOfLines,
lineHeight, lineHeight,
...props
}: { }: {
testID?: string testID?: string
type?: TypographyVariant type?: TypographyVariant
@ -179,6 +184,9 @@ export const DesktopWebTextLink = observer(function DesktopWebTextLink({
text: string | JSX.Element text: string | JSX.Element
numberOfLines?: number numberOfLines?: number
lineHeight?: number lineHeight?: number
accessible?: boolean
accessibilityLabel?: string
accessibilityHint?: string
}) { }) {
if (isDesktopWeb) { if (isDesktopWeb) {
return ( return (
@ -190,6 +198,7 @@ export const DesktopWebTextLink = observer(function DesktopWebTextLink({
text={text} text={text}
numberOfLines={numberOfLines} numberOfLines={numberOfLines}
lineHeight={lineHeight} lineHeight={lineHeight}
{...props}
/> />
) )
} }
@ -199,7 +208,8 @@ export const DesktopWebTextLink = observer(function DesktopWebTextLink({
type={type} type={type}
style={style} style={style}
numberOfLines={numberOfLines} numberOfLines={numberOfLines}
lineHeight={lineHeight}> lineHeight={lineHeight}
{...props}>
{text} {text}
</Text> </Text>
) )

View File

@ -2,7 +2,7 @@ import React from 'react'
import {StyleSheet, View} from 'react-native' import {StyleSheet, View} from 'react-native'
import {Text} from './text/Text' import {Text} from './text/Text'
import {DesktopWebTextLink} from './Link' import {DesktopWebTextLink} from './Link'
import {ago} from 'lib/strings/time' import {ago, niceDate} from 'lib/strings/time'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {UserAvatar} from './UserAvatar' import {UserAvatar} from './UserAvatar'
@ -57,7 +57,11 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
text={sanitizeDisplayName(displayName)} text={sanitizeDisplayName(displayName)}
href={`/profile/${opts.authorHandle}`} href={`/profile/${opts.authorHandle}`}
/> />
<Text type="md" style={pal.textLight} lineHeight={1.2}> <Text
type="md"
style={pal.textLight}
lineHeight={1.2}
accessible={false}>
&nbsp;&middot;&nbsp; &nbsp;&middot;&nbsp;
</Text> </Text>
<DesktopWebTextLink <DesktopWebTextLink
@ -65,6 +69,8 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
style={[styles.metaItem, pal.textLight]} style={[styles.metaItem, pal.textLight]}
lineHeight={1.2} lineHeight={1.2}
text={ago(opts.timestamp)} text={ago(opts.timestamp)}
accessibilityLabel={niceDate(opts.timestamp)}
accessibilityHint=""
href={opts.postHref} href={opts.postHref}
/> />
</View> </View>
@ -122,7 +128,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
href={`/profile/${opts.authorHandle}`} href={`/profile/${opts.authorHandle}`}
/> />
</View> </View>
<Text type="md" style={pal.textLight} lineHeight={1.2}> <Text type="md" style={pal.textLight} lineHeight={1.2} accessible={false}>
&middot;&nbsp; &middot;&nbsp;
</Text> </Text>
<DesktopWebTextLink <DesktopWebTextLink
@ -130,6 +136,8 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
style={[styles.metaItem, pal.textLight]} style={[styles.metaItem, pal.textLight]}
lineHeight={1.2} lineHeight={1.2}
text={ago(opts.timestamp)} text={ago(opts.timestamp)}
accessibilityLabel={niceDate(opts.timestamp)}
accessibilityHint=""
href={opts.postHref} href={opts.postHref}
/> />
</View> </View>

View File

@ -10,7 +10,10 @@ export function PostSandboxWarning() {
if (store.session.isSandbox) { if (store.session.isSandbox) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Text type="title-2xl" style={[pal.text, styles.text]}> <Text
type="title-2xl"
style={[pal.text, styles.text]}
accessible={false}>
SANDBOX SANDBOX
</Text> </Text>
</View> </View>

View File

@ -50,6 +50,8 @@ interface DropdownButtonProps {
openToRight?: boolean openToRight?: boolean
rightOffset?: number rightOffset?: number
bottomOffset?: number bottomOffset?: number
accessibilityLabel?: string
accessibilityHint?: string
} }
export function DropdownButton({ export function DropdownButton({
@ -63,6 +65,7 @@ export function DropdownButton({
openToRight = false, openToRight = false,
rightOffset = 0, rightOffset = 0,
bottomOffset = 0, bottomOffset = 0,
accessibilityLabel,
}: PropsWithChildren<DropdownButtonProps>) { }: PropsWithChildren<DropdownButtonProps>) {
const ref1 = useRef<TouchableOpacity>(null) const ref1 = useRef<TouchableOpacity>(null)
const ref2 = useRef<View>(null) const ref2 = useRef<View>(null)
@ -128,8 +131,8 @@ export function DropdownButton({
hitSlop={HITSLOP} hitSlop={HITSLOP}
ref={ref1} ref={ref1}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={`Opens ${numItems} options`} accessibilityLabel={accessibilityLabel || `Opens ${numItems} options`}
accessibilityHint={`Opens ${numItems} options`}> accessibilityHint="">
{children} {children}
</TouchableOpacity> </TouchableOpacity>
) )
@ -246,7 +249,9 @@ export function PostDropdownBtn({
testID={testID} testID={testID}
style={style} style={style}
items={dropdownItems} items={dropdownItems}
menuWidth={isWeb ? 220 : 200}> menuWidth={isWeb ? 220 : 200}
accessibilityLabel="Additional post actions"
accessibilityHint="">
{children} {children}
</DropdownButton> </DropdownButton>
) )
@ -335,6 +340,7 @@ const DropdownItems = ({
key={index} key={index}
style={[styles.menuItem]} style={[styles.menuItem]}
onPress={() => onPressItem(index)} onPress={() => onPressItem(index)}
accessibilityRole="button"
accessibilityLabel={item.label} accessibilityLabel={item.label}
accessibilityHint={`Option ${index + 1} of ${numItems}`}> accessibilityHint={`Option ${index + 1} of ${numItems}`}>
{item.icon && ( {item.icon && (

View File

@ -64,15 +64,14 @@ export function AutoSizedImage({
delayPressIn={DELAY_PRESS_IN} delayPressIn={DELAY_PRESS_IN}
style={[styles.container, style]} style={[styles.container, style]}
accessible={true} accessible={true}
accessibilityLabel="Share image" accessibilityRole="button"
accessibilityHint="Opens ways of sharing image"> accessibilityLabel={alt || 'Image'}
accessibilityHint="Tap to view fully">
<Image <Image
style={[styles.image, {aspectRatio}]} style={[styles.image, {aspectRatio}]}
source={uri} source={uri}
accessible={true} // Must set for `accessibilityLabel` to work accessible={false} // Must set for `accessibilityLabel` to work
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
accessibilityLabel={alt}
accessibilityHint=""
/> />
{children} {children}
</TouchableOpacity> </TouchableOpacity>

View File

@ -34,7 +34,7 @@ export const GalleryItem: FC<GalleryItemProps> = ({
onPressIn={onPressIn ? () => onPressIn(index) : undefined} onPressIn={onPressIn ? () => onPressIn(index) : undefined}
onLongPress={onLongPress ? () => onLongPress(index) : undefined} onLongPress={onLongPress ? () => onLongPress(index) : undefined}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="View image" accessibilityLabel={image.alt || 'Image'}
accessibilityHint=""> accessibilityHint="">
<Image <Image
source={{uri: image.thumb}} source={{uri: image.thumb}}
@ -47,7 +47,9 @@ export const GalleryItem: FC<GalleryItemProps> = ({
</TouchableOpacity> </TouchableOpacity>
{image.alt === '' ? null : ( {image.alt === '' ? null : (
<View style={styles.altContainer}> <View style={styles.altContainer}>
<Text style={styles.alt}>ALT</Text> <Text style={styles.alt} accessible={false}>
ALT
</Text>
</View> </View>
)} )}
</View> </View>

View File

@ -72,8 +72,7 @@ export function PostHider({
style={style} style={style}
href={href} href={href}
noFeedback noFeedback
accessible={true} accessible={false}
accessibilityRole="none"
{...props}> {...props}>
{children} {children}
</Link> </Link>

View File

@ -19,6 +19,7 @@ import {Text} from '../text/Text'
import {PostDropdownBtn} from '../forms/DropdownButton' import {PostDropdownBtn} from '../forms/DropdownButton'
import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons' import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons'
import {s, colors} from 'lib/styles' import {s, colors} from 'lib/styles'
import {pluralize} from 'lib/strings/helpers'
import {useTheme} from 'lib/ThemeContext' import {useTheme} from 'lib/ThemeContext'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {RepostButton} from './RepostButton' import {RepostButton} from './RepostButton'
@ -170,7 +171,9 @@ export function PostCtrls(opts: PostCtrlsOpts) {
hitSlop={HITSLOP} hitSlop={HITSLOP}
onPress={opts.onPressReply} onPress={opts.onPressReply}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Reply" accessibilityLabel={`Reply (${opts.replyCount} ${
opts.replyCount === 1 ? 'reply' : 'replies'
})`}
accessibilityHint="reply composer"> accessibilityHint="reply composer">
<CommentBottomArrow <CommentBottomArrow
style={[defaultCtrlColor, opts.big ? s.mt2 : styles.mt1]} style={[defaultCtrlColor, opts.big ? s.mt2 : styles.mt1]}
@ -190,7 +193,9 @@ export function PostCtrls(opts: PostCtrlsOpts) {
hitSlop={HITSLOP} hitSlop={HITSLOP}
onPress={onPressToggleLikeWrapper} onPress={onPressToggleLikeWrapper}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={opts.isLiked ? 'Unlike' : 'Like'} accessibilityLabel={`${opts.isLiked ? 'Unlike' : 'Like'} (${
opts.likeCount
} ${pluralize(opts.likeCount || 0, 'like')})`}
accessibilityHint=""> accessibilityHint="">
{opts.isLiked ? ( {opts.isLiked ? (
<HeartIconSolid <HeartIconSolid

View File

@ -4,6 +4,7 @@ import {RepostIcon} from 'lib/icons'
import {s, colors} from 'lib/styles' import {s, colors} from 'lib/styles'
import {useTheme} from 'lib/ThemeContext' import {useTheme} from 'lib/ThemeContext'
import {Text} from '../text/Text' import {Text} from '../text/Text'
import {pluralize} from 'lib/strings/helpers'
import {useStores} from 'state/index' import {useStores} from 'state/index'
const HITSLOP = {top: 5, left: 5, bottom: 5, right: 5} const HITSLOP = {top: 5, left: 5, bottom: 5, right: 5}
@ -49,7 +50,9 @@ export const RepostButton = ({
onPress={onPressToggleRepostWrapper} onPress={onPressToggleRepostWrapper}
style={styles.control} style={styles.control}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={isReposted ? 'Undo repost' : 'Repost'} accessibilityLabel={`${
isReposted ? 'Undo repost' : 'Repost'
} (${repostCount} ${pluralize(repostCount || 0, 'repost')})`}
accessibilityHint=""> accessibilityHint="">
<RepostIcon <RepostIcon
style={ style={

View File

@ -129,7 +129,9 @@ export function PostEmbeds({
style={styles.singleImage}> style={styles.singleImage}>
{alt === '' ? null : ( {alt === '' ? null : (
<View style={styles.altContainer}> <View style={styles.altContainer}>
<Text style={styles.alt}>ALT</Text> <Text style={styles.alt} accessible={false}>
ALT
</Text>
</View> </View>
)} )}
</AutoSizedImage> </AutoSizedImage>