[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)
}}
style={[styles.altTextControl, altTextControlStyle]}>
<Text style={styles.altTextControlLabel}>ALT</Text>
<Text style={styles.altTextControlLabel} accessible={false}>
ALT
</Text>
{image.altText.length > 0 ? (
<FontAwesomeIcon
icon="check"

View File

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

View File

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

View File

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

View File

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

View File

@ -109,6 +109,8 @@ function LightboxInner({
accessibilityIgnoresInvertColors
source={imgs[index]}
style={styles.image}
accessibilityLabel={imgs[index].alt}
accessibilityHint=""
/>
{canGoLeft && (
<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 {AccessibilityActionEvent, Linking, StyleSheet, View} from 'react-native'
import {Linking, StyleSheet, View} from 'react-native'
import Clipboard from '@react-native-clipboard/clipboard'
import {AtUri, AppBskyFeedDefs} from '@atproto/api'
import {
@ -138,40 +138,6 @@ export const PostThreadItem = observer(function PostThreadItem({
)
}, [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) {
return <ErrorMessage message="Invalid or unsupported post record" />
}
@ -193,9 +159,7 @@ export const PostThreadItem = observer(function PostThreadItem({
<PostHider
testID={`postThreadItem-by-${item.post.author.handle}`}
style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}
moderation={item.moderation.thread}
accessibilityActions={accessibilityActions}
onAccessibilityAction={onAccessibilityAction}>
moderation={item.moderation.thread}>
<PostSandboxWarning />
<View style={styles.layout}>
<View style={styles.layoutAvi}>
@ -369,9 +333,7 @@ export const PostThreadItem = observer(function PostThreadItem({
pal.view,
item._showParentReplyLine && styles.noTopBorder,
]}
moderation={item.moderation.thread}
accessibilityActions={accessibilityActions}
onAccessibilityAction={onAccessibilityAction}>
moderation={item.moderation.thread}>
{item._showParentReplyLine && (
<View
style={[

View File

@ -1,6 +1,5 @@
import React, {useCallback, useEffect, useMemo, useState} from 'react'
import React, {useEffect, useState} from 'react'
import {
AccessibilityActionEvent,
ActivityIndicator,
Linking,
StyleProp,
@ -200,47 +199,11 @@ const PostLoaded = observer(
)
}, [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 (
<PostHider
href={itemHref}
style={[styles.outer, pal.view, pal.border, style]}
moderation={item.moderation.list}
accessibilityActions={accessibilityActions}
onAccessibilityAction={onAccessibilityAction}>
moderation={item.moderation.list}>
{showReplyLine && <View style={styles.replyLine} />}
<View style={styles.layout}>
<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 {AccessibilityActionEvent, Linking, StyleSheet, View} from 'react-native'
import {Linking, StyleSheet, View} from 'react-native'
import Clipboard from '@react-native-clipboard/clipboard'
import {AtUri} from '@atproto/api'
import {
@ -158,40 +158,6 @@ export const FeedItem = observer(function ({
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) {
return <View />
}
@ -201,9 +167,7 @@ export const FeedItem = observer(function ({
testID={`feedItem-by-${item.post.author.handle}`}
style={outerStyles}
href={itemHref}
moderation={moderation}
accessibilityActions={accessibilityActions}
onAccessibilityAction={onAccessibilityAction}>
moderation={moderation}>
{isThreadChild && (
<View
style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]}

View File

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

View File

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

View File

@ -10,7 +10,10 @@ export function PostSandboxWarning() {
if (store.session.isSandbox) {
return (
<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
</Text>
</View>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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