Improvements to the alt text behaviors in the composer (#910)

* Add an image preview in the alt modal

* Composer: add info about alt text and a green checkmark when done

* Shrink the alt visual indicator a bit so it doesnt obscure the image

* Fix typo

* Fix: avoid requiring multiple tabs to save alt text

* update react-native-screens

* Improve the alt text help tip

* Remove redundant hints

---------

Co-authored-by: Ansh Nanda <anshnanda10@gmail.com>
zio/stable
Paul Frazee 2023-06-27 09:52:49 -05:00 committed by GitHub
parent 25b3e14926
commit bfaa6d73f3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 274 additions and 185 deletions

View File

@ -126,7 +126,7 @@
"react-native-reanimated": "^3.3.0", "react-native-reanimated": "^3.3.0",
"react-native-root-siblings": "^4.1.1", "react-native-root-siblings": "^4.1.1",
"react-native-safe-area-context": "^4.4.1", "react-native-safe-area-context": "^4.4.1",
"react-native-screens": "^3.13.1", "react-native-screens": "^3.20.0",
"react-native-splash-screen": "^3.3.0", "react-native-splash-screen": "^3.3.0",
"react-native-svg": "13.4.0", "react-native-svg": "13.4.0",
"react-native-url-polyfill": "^1.3.0", "react-native-url-polyfill": "^1.3.0",

View File

@ -1,16 +1,16 @@
import React, {useCallback} from 'react' import React from 'react'
import {ImageStyle, Keyboard} from 'react-native' import {ImageStyle, Keyboard} from 'react-native'
import {GalleryModel} from 'state/models/media/gallery' import {GalleryModel} from 'state/models/media/gallery'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {colors} from 'lib/styles' import {s, colors} from 'lib/styles'
import {StyleSheet, TouchableOpacity, View} from 'react-native' import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {ImageModel} from 'state/models/media/image'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {Text} from 'view/com/util/text/Text' import {Text} from 'view/com/util/text/Text'
import {isDesktopWeb} from 'platform/detection' import {isDesktopWeb} from 'platform/detection'
import {openAltTextModal} from 'lib/media/alt-text' import {openAltTextModal} from 'lib/media/alt-text'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
interface Props { interface Props {
gallery: GalleryModel gallery: GalleryModel
@ -18,67 +18,39 @@ interface Props {
export const Gallery = observer(function ({gallery}: Props) { export const Gallery = observer(function ({gallery}: Props) {
const store = useStores() const store = useStores()
const getImageStyle = useCallback(() => { const pal = usePalette('default')
let side: number
if (gallery.size === 1) { let side: number
side = 250
} else {
side = (isDesktopWeb ? 560 : 350) / gallery.size
}
return { if (gallery.size === 1) {
height: side, side = 250
width: side, } else {
} side = (isDesktopWeb ? 560 : 350) / gallery.size
}, [gallery]) }
const imageStyle = getImageStyle() const imageStyle = {
const handleAddImageAltText = useCallback( height: side,
(image: ImageModel) => { width: side,
Keyboard.dismiss() }
openAltTextModal(store, image)
},
[store],
)
const handleRemovePhoto = useCallback(
(image: ImageModel) => {
gallery.remove(image)
},
[gallery],
)
const handleEditPhoto = useCallback(
(image: ImageModel) => {
gallery.edit(image)
},
[gallery],
)
const isOverflow = !isDesktopWeb && gallery.size > 2 const isOverflow = !isDesktopWeb && gallery.size > 2
const imageControlLabelStyle = { const altTextControlStyle = isOverflow
borderRadius: 5, ? {
paddingHorizontal: 10, left: 4,
position: 'absolute' as const, bottom: 4,
zIndex: 1, }
...(isOverflow : isDesktopWeb && gallery.size < 3
? { ? {
left: 4, left: 8,
bottom: 4, top: 8,
} }
: isDesktopWeb && gallery.size < 3 : {
? { left: 4,
left: 8, top: 4,
top: 8, }
}
: {
left: 4,
top: 4,
}),
}
const imageControlsSubgroupStyle = { const imageControlsStyle = {
display: 'flex' as const, display: 'flex' as const,
flexDirection: 'row' as const, flexDirection: 'row' as const,
position: 'absolute' as const, position: 'absolute' as const,
@ -103,63 +75,90 @@ export const Gallery = observer(function ({gallery}: Props) {
} }
return !gallery.isEmpty ? ( return !gallery.isEmpty ? (
<View testID="selectedPhotosView" style={styles.gallery}> <>
{gallery.images.map(image => ( <View testID="selectedPhotosView" style={styles.gallery}>
<View key={`selected-image-${image.path}`} style={[imageStyle]}> {gallery.images.map(image => (
<TouchableOpacity <View key={`selected-image-${image.path}`} style={[imageStyle]}>
testID="altTextButton"
accessibilityRole="button"
accessibilityLabel="Add alt text"
accessibilityHint=""
onPress={() => {
handleAddImageAltText(image)
}}
style={imageControlLabelStyle}>
<Text style={styles.imageControlTextContent}>ALT</Text>
</TouchableOpacity>
<View style={imageControlsSubgroupStyle}>
<TouchableOpacity <TouchableOpacity
testID="editPhotoButton" testID="altTextButton"
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Edit image" accessibilityLabel="Add alt text"
accessibilityHint="" accessibilityHint=""
onPress={() => { onPress={() => {
handleEditPhoto(image) Keyboard.dismiss()
openAltTextModal(store, image)
}} }}
style={styles.imageControl}> style={[styles.altTextControl, altTextControlStyle]}>
<FontAwesomeIcon <Text style={styles.altTextControlLabel}>ALT</Text>
icon="pen" {image.altText.length > 0 ? (
size={12} <FontAwesomeIcon
style={{color: colors.white}} icon="check"
/> size={10}
style={{color: colors.green3}}
/>
) : undefined}
</TouchableOpacity> </TouchableOpacity>
<View style={imageControlsStyle}>
<TouchableOpacity
testID="editPhotoButton"
accessibilityRole="button"
accessibilityLabel="Edit image"
accessibilityHint=""
onPress={() => gallery.edit(image)}
style={styles.imageControl}>
<FontAwesomeIcon
icon="pen"
size={12}
style={{color: colors.white}}
/>
</TouchableOpacity>
<TouchableOpacity
testID="removePhotoButton"
accessibilityRole="button"
accessibilityLabel="Remove image"
accessibilityHint=""
onPress={() => gallery.remove(image)}
style={styles.imageControl}>
<FontAwesomeIcon
icon="xmark"
size={16}
style={{color: colors.white}}
/>
</TouchableOpacity>
</View>
<TouchableOpacity <TouchableOpacity
testID="removePhotoButton"
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Remove image" accessibilityLabel="Add alt text"
accessibilityHint="" accessibilityHint=""
onPress={() => handleRemovePhoto(image)} onPress={() => {
style={styles.imageControl}> Keyboard.dismiss()
<FontAwesomeIcon openAltTextModal(store, image)
icon="xmark" }}
size={16} style={styles.altTextHiddenRegion}
style={{color: colors.white}} />
/>
</TouchableOpacity>
</View>
<Image <Image
testID="selectedPhotoImage" testID="selectedPhotoImage"
style={[styles.image, imageStyle] as ImageStyle} style={[styles.image, imageStyle] as ImageStyle}
source={{ source={{
uri: image.cropped?.path ?? image.path, uri: image.cropped?.path ?? image.path,
}} }}
accessible={true} accessible={true}
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
/> />
</View>
))}
</View>
<View style={[styles.reminder]}>
<View style={[styles.infoIcon, pal.viewLight]}>
<FontAwesomeIcon icon="info" size={12} color={pal.colors.text} />
</View> </View>
))} <Text type="sm" style={[pal.textLight, s.flex1]}>
</View> Alt text describes images for blind and low-vision users, and helps
give context to everyone.
</Text>
</View>
</>
) : null ) : null
}) })
@ -179,19 +178,46 @@ const styles = StyleSheet.create({
height: 24, height: 24,
borderRadius: 12, borderRadius: 12,
backgroundColor: 'rgba(0, 0, 0, 0.75)', backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderWidth: 0.5,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
imageControlTextContent: { altTextControl: {
position: 'absolute',
zIndex: 1,
borderRadius: 6, borderRadius: 6,
backgroundColor: 'rgba(0, 0, 0, 0.75)',
paddingHorizontal: 8,
paddingVertical: 3,
flexDirection: 'row',
alignItems: 'center',
},
altTextControlLabel: {
color: 'white', color: 'white',
fontSize: 12, fontSize: 12,
fontWeight: 'bold', fontWeight: 'bold',
letterSpacing: 1, letterSpacing: 1,
backgroundColor: 'rgba(0, 0, 0, 0.75)', },
borderWidth: 0.5, altTextHiddenRegion: {
paddingHorizontal: 10, position: 'absolute',
paddingVertical: 3, left: 4,
right: 4,
bottom: 4,
top: 30,
zIndex: 1,
},
reminder: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
borderRadius: 8,
paddingVertical: 14,
},
infoIcon: {
width: 22,
height: 22,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
}, },
}) })

View File

@ -1,5 +1,15 @@
import React, {useCallback, useState} from 'react' import React, {useMemo, useCallback, useState} from 'react'
import {StyleSheet, TextInput, TouchableOpacity, View} from 'react-native' import {
ImageStyle,
KeyboardAvoidingView,
ScrollView,
StyleSheet,
TextInput,
TouchableOpacity,
View,
useWindowDimensions,
} from 'react-native'
import {Image} from 'expo-image'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {gradients, s} from 'lib/styles' import {gradients, s} from 'lib/styles'
import {enforceLen} from 'lib/strings/helpers' import {enforceLen} from 'lib/strings/helpers'
@ -8,7 +18,7 @@ import {useTheme} from 'lib/ThemeContext'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import LinearGradient from 'react-native-linear-gradient' import LinearGradient from 'react-native-linear-gradient'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {isDesktopWeb} from 'platform/detection' import {isDesktopWeb, isAndroid} from 'platform/detection'
import {ImageModel} from 'state/models/media/image' import {ImageModel} from 'state/models/media/image'
export const snapPoints = ['fullscreen'] export const snapPoints = ['fullscreen']
@ -22,6 +32,24 @@ export function Component({image}: Props) {
const store = useStores() const store = useStores()
const theme = useTheme() const theme = useTheme()
const [altText, setAltText] = useState(image.altText) const [altText, setAltText] = useState(image.altText)
const windim = useWindowDimensions()
const imageStyles = useMemo<ImageStyle>(() => {
const maxWidth = isDesktopWeb ? 450 : windim.width
if (image.height > image.width) {
return {
resizeMode: 'contain',
width: '100%',
aspectRatio: 1,
borderRadius: 8,
}
}
return {
width: '100%',
height: (maxWidth / image.width) * image.height,
borderRadius: 8,
}
}, [image, windim])
const onPressSave = useCallback(() => { const onPressSave = useCallback(() => {
image.setAltText(altText) image.setAltText(altText)
@ -33,69 +61,94 @@ export function Component({image}: Props) {
} }
return ( return (
<View <KeyboardAvoidingView
testID="altTextImageModal" behavior={isAndroid ? 'height' : 'padding'}
style={[pal.view, styles.container, s.flex1]} style={[pal.view, styles.container]}>
nativeID="imageAltText"> <ScrollView
<Text style={[styles.title, pal.text]}>Add alt text</Text> testID="altTextImageModal"
<TextInput style={styles.scrollContainer}
testID="altTextImageInput" keyboardShouldPersistTaps="always"
style={[styles.textArea, pal.border, pal.text]} nativeID="imageAltText">
keyboardAppearance={theme.colorScheme} <View style={styles.scrollInner}>
multiline <View style={[pal.viewLight, styles.imageContainer]}>
value={altText} <Image
onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))} testID="selectedPhotoImage"
accessibilityLabel="Image alt text" style={imageStyles}
accessibilityHint="Sets image alt text for screenreaders" source={{
accessibilityLabelledBy="imageAltText" uri: image.cropped?.path ?? image.path,
/> }}
<View style={styles.buttonControls}> accessible={true}
<TouchableOpacity accessibilityIgnoresInvertColors
testID="altTextImageSaveBtn" />
onPress={onPressSave}
accessibilityLabel="Save alt text"
accessibilityHint={`Saves alt text, which reads: ${altText}`}
accessibilityRole="button">
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.button]}>
<Text type="button-lg" style={[s.white, s.bold]}>
Save
</Text>
</LinearGradient>
</TouchableOpacity>
<TouchableOpacity
testID="altTextImageCancelBtn"
onPress={onPressCancel}
accessibilityRole="button"
accessibilityLabel="Cancel add image alt text"
accessibilityHint="Exits adding alt text to image"
onAccessibilityEscape={onPressCancel}>
<View style={[styles.button]}>
<Text type="button-lg" style={[pal.textLight]}>
Cancel
</Text>
</View> </View>
</TouchableOpacity> <TextInput
</View> testID="altTextImageInput"
</View> style={[styles.textArea, pal.border, pal.text]}
keyboardAppearance={theme.colorScheme}
multiline
placeholder="Add alt text"
placeholderTextColor={pal.colors.textLight}
value={altText}
onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))}
accessibilityLabel="Image alt text"
accessibilityHint=""
accessibilityLabelledBy="imageAltText"
autoFocus
/>
<View style={styles.buttonControls}>
<TouchableOpacity
testID="altTextImageSaveBtn"
onPress={onPressSave}
accessibilityLabel="Save alt text"
accessibilityHint={`Saves alt text, which reads: ${altText}`}
accessibilityRole="button">
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.button]}>
<Text type="button-lg" style={[s.white, s.bold]}>
Save
</Text>
</LinearGradient>
</TouchableOpacity>
<TouchableOpacity
testID="altTextImageCancelBtn"
onPress={onPressCancel}
accessibilityRole="button"
accessibilityLabel="Cancel add image alt text"
accessibilityHint=""
onAccessibilityEscape={onPressCancel}>
<View style={[styles.button]}>
<Text type="button-lg" style={[pal.textLight]}>
Cancel
</Text>
</View>
</TouchableOpacity>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
) )
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
gap: 18, flex: 1,
paddingVertical: isDesktopWeb ? 0 : 18,
paddingHorizontal: isDesktopWeb ? 0 : 12,
height: '100%', height: '100%',
width: '100%', width: '100%',
paddingVertical: isDesktopWeb ? 0 : 18,
}, },
title: { scrollContainer: {
textAlign: 'center', flex: 1,
fontWeight: 'bold', height: '100%',
fontSize: 24, paddingHorizontal: isDesktopWeb ? 0 : 12,
},
scrollInner: {
gap: 12,
},
imageContainer: {
borderRadius: 8,
}, },
textArea: { textArea: {
borderWidth: 1, borderWidth: 1,

View File

@ -45,23 +45,28 @@ export const GalleryItem: FC<GalleryItemProps> = ({
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
/> />
</TouchableOpacity> </TouchableOpacity>
{image.alt === '' ? null : <Text style={styles.alt}>ALT</Text>} {image.alt === '' ? null : (
<View style={styles.altContainer}>
<Text style={styles.alt}>ALT</Text>
</View>
)}
</View> </View>
) )
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
alt: { altContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.75)', backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 6, borderRadius: 6,
color: 'white', paddingHorizontal: 6,
fontSize: 12,
fontWeight: 'bold',
letterSpacing: 1,
paddingHorizontal: 10,
paddingVertical: 3, paddingVertical: 3,
position: 'absolute', position: 'absolute',
left: 6, left: 6,
bottom: 6, bottom: 6,
}, },
alt: {
color: 'white',
fontSize: 10,
fontWeight: 'bold',
},
}) })

View File

@ -126,7 +126,11 @@ export function PostEmbeds({
onPress={() => openLightbox(0)} onPress={() => openLightbox(0)}
onPressIn={() => onPressIn(0)} onPressIn={() => onPressIn(0)}
style={styles.singleImage}> style={styles.singleImage}>
{alt === '' ? null : <Text style={styles.alt}>ALT</Text>} {alt === '' ? null : (
<View style={styles.altContainer}>
<Text style={styles.alt}>ALT</Text>
</View>
)}
</AutoSizedImage> </AutoSizedImage>
</View> </View>
) )
@ -201,17 +205,18 @@ const styles = StyleSheet.create({
borderRadius: 8, borderRadius: 8,
marginTop: 4, marginTop: 4,
}, },
alt: { altContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.75)', backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 6, borderRadius: 6,
color: 'white', paddingHorizontal: 6,
fontSize: 12,
fontWeight: 'bold',
letterSpacing: 1,
paddingHorizontal: 10,
paddingVertical: 3, paddingVertical: 3,
position: 'absolute', position: 'absolute',
left: 6, left: 6,
bottom: 6, bottom: 6,
}, },
alt: {
color: 'white',
fontSize: 10,
fontWeight: 'bold',
},
}) })

View File

@ -15339,10 +15339,10 @@ react-native-safe-area-context@^4.4.1:
resolved "https://registry.yarnpkg.com/react-native-safe-area-context/-/react-native-safe-area-context-4.5.3.tgz#e98eb1a73a6b3846d296545fe74760754dbaaa69" resolved "https://registry.yarnpkg.com/react-native-safe-area-context/-/react-native-safe-area-context-4.5.3.tgz#e98eb1a73a6b3846d296545fe74760754dbaaa69"
integrity sha512-ihYeGDEBSkYH+1aWnadNhVtclhppVgd/c0tm4mj0+HV11FoiWJ8N6ocnnZnRLvM5Fxc+hUqxR9bm5AXU3rXiyA== integrity sha512-ihYeGDEBSkYH+1aWnadNhVtclhppVgd/c0tm4mj0+HV11FoiWJ8N6ocnnZnRLvM5Fxc+hUqxR9bm5AXU3rXiyA==
react-native-screens@^3.13.1: react-native-screens@^3.20.0:
version "3.20.0" version "3.22.0"
resolved "https://registry.yarnpkg.com/react-native-screens/-/react-native-screens-3.20.0.tgz#4d154177395e5541387d9a05bc2e12e54d2fb5b1" resolved "https://registry.yarnpkg.com/react-native-screens/-/react-native-screens-3.22.0.tgz#7d892baf964fddb642b5eec71a09e2aeb501e578"
integrity sha512-joWUKWAVHxymP3mL9gYApFHAsbd9L6ZcmpoZa6Sl3W/82bvvNVMqcfP7MeNqVCg73qZ8yL4fW+J/syusHleUgg== integrity sha512-csLypBSXIt/egh37YJmokETptZJCtZdoZBsZNLR9n31GesDyVogprT+MM22dEPDuxPxt/mFWq+lSpVwk7khuTw==
dependencies: dependencies:
react-freeze "^1.0.0" react-freeze "^1.0.0"
warn-once "^0.1.0" warn-once "^0.1.0"