Redo web lightbox, add animations and keyboard a11y (#9481)

* redo web lightbox, add animations

* add rect-avi to shell

* fix lingui msgs
This commit is contained in:
Samuel Newman
2026-01-28 20:13:52 +02:00
committed by GitHub
parent fd49349b71
commit d45a2c832d
5 changed files with 259 additions and 181 deletions
+3 -1
View File
@@ -5,6 +5,8 @@ import {CARD_ASPECT_RATIO} from '#/lib/constants'
import {native, platform, web} from '#/alf/util/platform' import {native, platform, web} from '#/alf/util/platform'
import * as Layout from '#/components/Layout' import * as Layout from '#/components/Layout'
const EXP_CURVE = 'cubic-bezier(0.16, 1, 0.3, 1)'
export const atoms = { export const atoms = {
...baseAtoms, ...baseAtoms,
@@ -103,7 +105,7 @@ export const atoms = {
}), }),
// special composite animation for dialogs // special composite animation for dialogs
zoom_fade_in: web({ zoom_fade_in: web({
animation: 'zoomIn ease-out 0.1s, fadeIn ease-out 0.1s', animation: `zoomIn ${EXP_CURVE} 0.3s, fadeIn ${EXP_CURVE} 0.3s`,
}), }),
/** /**
+4
View File
@@ -409,3 +409,7 @@ export function Handle({
export function Close() { export function Close() {
return null return null
} }
export function Backdrop() {
return null
}
+4 -4
View File
@@ -3,8 +3,8 @@ import {
FlatList, FlatList,
type FlatListProps, type FlatListProps,
type GestureResponderEvent, type GestureResponderEvent,
Pressable,
type StyleProp, type StyleProp,
TouchableWithoutFeedback,
View, View,
type ViewStyle, type ViewStyle,
} from 'react-native' } from 'react-native'
@@ -113,7 +113,7 @@ export function Outer({
<Portal> <Portal>
<Context.Provider value={context}> <Context.Provider value={context}>
<RemoveScrollBar /> <RemoveScrollBar />
<TouchableWithoutFeedback <Pressable
accessibilityHint={undefined} accessibilityHint={undefined}
accessibilityLabel={_(msg`Close active dialog`)} accessibilityLabel={_(msg`Close active dialog`)}
onPress={handleBackgroundPress}> onPress={handleBackgroundPress}>
@@ -146,7 +146,7 @@ export function Outer({
{children} {children}
</View> </View>
</View> </View>
</TouchableWithoutFeedback> </Pressable>
</Context.Provider> </Context.Provider>
</Portal> </Portal>
)} )}
@@ -304,7 +304,7 @@ export function Handle() {
return null return null
} }
function Backdrop() { export function Backdrop() {
const t = useTheme() const t = useTheme()
const {reduceMotionEnabled} = useA11y() const {reduceMotionEnabled} = useA11y()
return ( return (
+4 -3
View File
@@ -78,7 +78,7 @@ let ProfileHeaderShell = ({
( (
uri: string, uri: string,
thumbRect: MeasuredDimensions | null, thumbRect: MeasuredDimensions | null,
type: 'circle-avi' | 'image' = 'circle-avi', type: 'circle-avi' | 'rect-avi' | 'image' = 'circle-avi',
) => { ) => {
openLightbox({ openLightbox({
images: [ images: [
@@ -87,7 +87,7 @@ let ProfileHeaderShell = ({
thumbUri: uri, thumbUri: uri,
thumbRect, thumbRect,
dimensions: dimensions:
type === 'circle-avi' type === 'circle-avi' || type === 'rect-avi'
? { ? {
// It's fine if it's actually smaller but we know it's 1:1. // It's fine if it's actually smaller but we know it's 1:1.
height: 1000, height: 1000,
@@ -129,11 +129,12 @@ let ProfileHeaderShell = ({
} else { } else {
const modui = moderation.ui('avatar') const modui = moderation.ui('avatar')
const avatar = profile.avatar const avatar = profile.avatar
const type = profile.associated?.labeler ? 'rect-avi' : 'circle-avi'
if (avatar && !(modui.blur && modui.noOverride)) { if (avatar && !(modui.blur && modui.noOverride)) {
runOnUI(() => { runOnUI(() => {
'worklet' 'worklet'
const rect = measure(aviRef) const rect = measure(aviRef)
runOnJS(_openLightbox)(avatar, rect) runOnJS(_openLightbox)(avatar, rect, type)
})() })()
} }
} }
+244 -173
View File
@@ -1,28 +1,30 @@
import React, {useCallback, useEffect, useState} from 'react' import {useCallback, useEffect, useState} from 'react'
import { import {Pressable, StyleSheet, View} from 'react-native'
Image, import {Image} from 'expo-image'
type ImageStyle,
Pressable,
StyleSheet,
TouchableOpacity,
TouchableWithoutFeedback,
View,
type ViewStyle,
} from 'react-native'
import {
FontAwesomeIcon,
type FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {FocusGuards, FocusScope} from 'radix-ui/internal'
import {RemoveScrollBar} from 'react-remove-scroll-bar' import {RemoveScrollBar} from 'react-remove-scroll-bar'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {useA11y} from '#/state/a11y'
import {colors, s} from '#/lib/styles'
import {useLightbox, useLightboxControls} from '#/state/lightbox' import {useLightbox, useLightboxControls} from '#/state/lightbox'
import {Text} from '../util/text/Text' import {
atoms as a,
flatten,
ThemeProvider,
useBreakpoints,
useTheme,
} from '#/alf'
import {Button} from '#/components/Button'
import {Backdrop} from '#/components/Dialog'
import {
ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeftIcon,
ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon,
} from '#/components/icons/Chevron'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {type ImageSource} from './ImageViewing/@types' import {type ImageSource} from './ImageViewing/@types'
import ImageDefaultHeader from './ImageViewing/components/ImageDefaultHeader'
export function Lightbox() { export function Lightbox() {
const {activeLightbox} = useLightbox() const {activeLightbox} = useLightbox()
@@ -36,18 +38,44 @@ export function Lightbox() {
const initialIndex = activeLightbox.index const initialIndex = activeLightbox.index
const imgs = activeLightbox.images const imgs = activeLightbox.images
return ( return (
<> <ThemeProvider theme="dark">
<RemoveScrollBar /> <LightboxContainer handleBackgroundPress={closeLightbox}>
<LightboxInner <LightboxGallery
imgs={imgs} key={activeLightbox.id}
initialIndex={initialIndex} imgs={imgs}
onClose={closeLightbox} initialIndex={initialIndex}
/> onClose={closeLightbox}
</> />
</LightboxContainer>
</ThemeProvider>
) )
} }
function LightboxInner({ function LightboxContainer({
children,
handleBackgroundPress,
}: {
children: React.ReactNode
handleBackgroundPress: () => void
}) {
const {_} = useLingui()
FocusGuards.useFocusGuards()
return (
<Pressable
accessibilityHint={undefined}
accessibilityLabel={_(msg`Close image lightbox`)}
onPress={handleBackgroundPress}
style={[a.fixed, a.inset_0, a.z_10]}>
<Backdrop />
<RemoveScrollBar />
<FocusScope.FocusScope loop trapped asChild>
<div style={{position: 'absolute', inset: 0}}>{children}</div>
</FocusScope.FocusScope>
</Pressable>
)
}
function LightboxGallery({
imgs, imgs,
initialIndex = 0, initialIndex = 0,
onClose, onClose,
@@ -56,10 +84,15 @@ function LightboxInner({
initialIndex: number initialIndex: number
onClose: () => void onClose: () => void
}) { }) {
const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const [index, setIndex] = useState<number>(initialIndex) const {reduceMotionEnabled} = useA11y()
const [index, setIndex] = useState(initialIndex)
const [hasAnyLoaded, setAnyHasLoaded] = useState(false)
const [isAltExpanded, setAltExpanded] = useState(false) const [isAltExpanded, setAltExpanded] = useState(false)
const {gtPhone} = useBreakpoints()
const canGoLeft = index >= 1 const canGoLeft = index >= 1
const canGoRight = index < imgs.length - 1 const canGoRight = index < imgs.length - 1
const onPressLeft = useCallback(() => { const onPressLeft = useCallback(() => {
@@ -92,95 +125,69 @@ function LightboxInner({
return () => window.removeEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown)
}, [onKeyDown]) }, [onKeyDown])
const {isTabletOrDesktop} = useWebMediaQueries() const delayedFadeInAnim = !reduceMotionEnabled && [
const btnStyle = React.useMemo(() => { a.fade_in,
return isTabletOrDesktop ? styles.btnTablet : styles.btnMobile {animationDelay: '0.2s', animationFillMode: 'both'},
}, [isTabletOrDesktop]) ]
const iconSize = React.useMemo(() => {
return isTabletOrDesktop ? 32 : 24
}, [isTabletOrDesktop])
const img = imgs[index] const img = imgs[index]
const isAvi = img.type === 'circle-avi' || img.type === 'rect-avi'
return ( return (
<View style={styles.mask}> <View style={[a.absolute, a.inset_0]}>
<TouchableWithoutFeedback <View style={[a.flex_1, a.justify_center, a.align_center]}>
onPress={onClose} <LightboxGalleryItem
accessibilityRole="button" key={index}
accessibilityLabel={_(msg`Close image viewer`)} source={img.uri}
accessibilityHint={_(msg`Exits image view`)} alt={img.alt}
onAccessibilityEscape={onClose}> type={img.type}
{isAvi ? ( hasAnyLoaded={hasAnyLoaded}
<View style={styles.aviCenterer}> onLoad={() => setAnyHasLoaded(true)}
<img />
src={img.uri} {canGoLeft && (
// @ts-ignore web-only <Button
style={ onPress={onPressLeft}
{ style={[
...styles.avi, a.absolute,
borderRadius: styles.leftBtn,
img.type === 'circle-avi' styles.blurredBackdrop,
? '50%' a.transition_color,
: img.type === 'rect-avi' delayedFadeInAnim,
? '10%' ]}
: 0, hoverStyle={styles.blurredBackdropHover}
} as ImageStyle color="secondary"
} label={_(msg`Previous image`)}
alt={img.alt} shape="round"
size={gtPhone ? 'large' : 'small'}>
<ChevronLeftIcon
size={gtPhone ? 'md' : 'sm'}
style={{color: t.palette.white}}
/> />
</View> </Button>
) : (
<View style={styles.imageCenterer}>
<Image
accessibilityIgnoresInvertColors
source={img}
style={styles.image as ImageStyle}
accessibilityLabel={img.alt}
accessibilityHint=""
/>
{canGoLeft && (
<TouchableOpacity
onPress={onPressLeft}
style={[
styles.btn,
btnStyle,
styles.leftBtn,
styles.blurredBackground,
]}
accessibilityRole="button"
accessibilityLabel={_(msg`Previous image`)}
accessibilityHint="">
<FontAwesomeIcon
icon="angle-left"
style={styles.icon as FontAwesomeIconStyle}
size={iconSize}
/>
</TouchableOpacity>
)}
{canGoRight && (
<TouchableOpacity
onPress={onPressRight}
style={[
styles.btn,
btnStyle,
styles.rightBtn,
styles.blurredBackground,
]}
accessibilityRole="button"
accessibilityLabel={_(msg`Next image`)}
accessibilityHint="">
<FontAwesomeIcon
icon="angle-right"
style={styles.icon as FontAwesomeIconStyle}
size={iconSize}
/>
</TouchableOpacity>
)}
</View>
)} )}
</TouchableWithoutFeedback> {canGoRight && (
<Button
onPress={onPressRight}
style={[
a.absolute,
styles.rightBtn,
styles.blurredBackdrop,
a.transition_color,
delayedFadeInAnim,
]}
hoverStyle={styles.blurredBackdropHover}
color="secondary"
label={_(msg`Next image`)}
shape="round"
size={gtPhone ? 'large' : 'small'}>
<ChevronRightIcon
size={gtPhone ? 'md' : 'sm'}
style={{color: t.palette.white}}
/>
</Button>
)}
</View>
{img.alt ? ( {img.alt ? (
<View style={styles.footer}> <View style={[a.px_4xl, a.py_2xl, t.atoms.bg, delayedFadeInAnim]}>
<Pressable <Pressable
accessibilityLabel={_(msg`Expand alt text`)} accessibilityLabel={_(msg`Expand alt text`)}
accessibilityHint={_( accessibilityHint={_(
@@ -190,7 +197,7 @@ function LightboxInner({
setAltExpanded(!isAltExpanded) setAltExpanded(!isAltExpanded)
}}> }}>
<Text <Text
style={s.white} style={[a.text_md, a.leading_snug]}
numberOfLines={isAltExpanded ? 0 : 3} numberOfLines={isAltExpanded ? 0 : 3}
ellipsizeMode="tail"> ellipsizeMode="tail">
{img.alt} {img.alt}
@@ -198,38 +205,125 @@ function LightboxInner({
</Pressable> </Pressable>
</View> </View>
) : null} ) : null}
<View style={styles.closeBtn}> <Button
<ImageDefaultHeader onRequestClose={onClose} /> onPress={onClose}
</View> style={[
a.absolute,
styles.closeBtn,
styles.blurredBackdrop,
a.transition_color,
delayedFadeInAnim,
]}
hoverStyle={styles.blurredBackdropHover}
color="secondary"
label={_(msg`Close lightbox`)}
shape="round"
size={gtPhone ? 'large' : 'small'}>
<XIcon size={gtPhone ? 'md' : 'sm'} style={{color: t.palette.white}} />
</Button>
</View> </View>
) )
} }
function LightboxGalleryItem({
source,
alt,
type,
onLoad,
hasAnyLoaded,
}: {
source: string
alt: string | undefined
type: ImageSource['type']
onLoad: () => void
hasAnyLoaded: boolean
}) {
const {reduceMotionEnabled} = useA11y()
const [hasLoaded, setHasLoaded] = useState(false)
const [isFirstToLoad] = useState(!hasAnyLoaded)
/**
* We want to show a zoom/fade in animation when the lightbox first opens.
* To avoid showing it as we switch between images, we keep track in the parent
* whether any image has loaded yet. We then save what the value of this is on first
* render (as when it changes, we don't want to then *remove* then animation). when
* the image loads, if this is the first image to load, we play the animation.
*
* We also use this `hasLoaded` state to show a loading indicator. This is on a 1s
* delay and then a slow fade in to avoid flicker. -sfn
*/
const zoomInWhenReady =
!reduceMotionEnabled &&
isFirstToLoad &&
(hasAnyLoaded
? [a.zoom_fade_in, {animationDuration: '0.5s'}]
: {opacity: 0})
const handleLoad = () => {
setHasLoaded(true)
onLoad()
}
let image = null
switch (type) {
case 'circle-avi':
case 'rect-avi':
image = (
<img
src={source}
style={flatten([
styles.avi,
{
borderRadius:
type === 'circle-avi' ? '50%' : type === 'rect-avi' ? '10%' : 0,
},
zoomInWhenReady,
])}
alt={alt}
onLoad={handleLoad}
/>
)
break
case 'image':
image = (
<Image
source={{uri: source}}
alt={alt}
style={[a.w_full, a.h_full, zoomInWhenReady]}
onLoad={handleLoad}
contentFit="contain"
accessibilityIgnoresInvertColors
/>
)
break
}
return (
<>
{image}
{!hasLoaded && (
<View
style={[
a.absolute,
a.inset_0,
a.justify_center,
a.align_center,
a.fade_in,
{
opacity: 0,
animationDuration: '500ms',
animationDelay: '1s',
animationFillMode: 'both',
},
]}>
<Loader size="xl" />
</View>
)}
</>
)
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
mask: {
// @ts-ignore
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
backgroundColor: '#000c',
},
imageCenterer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
image: {
width: '100%',
height: '100%',
resizeMode: 'contain',
},
aviCenterer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
avi: { avi: {
// @ts-ignore web-only // @ts-ignore web-only
maxWidth: `calc(min(400px, 100vw))`, maxWidth: `calc(min(400px, 100vw))`,
@@ -238,49 +332,26 @@ const styles = StyleSheet.create({
padding: 16, padding: 16,
boxSizing: 'border-box', boxSizing: 'border-box',
}, },
icon: {
color: colors.white,
},
closeBtn: { closeBtn: {
position: 'absolute', top: 20,
top: 10,
right: 10,
},
btn: {
position: 'absolute',
backgroundColor: '#00000077',
justifyContent: 'center',
alignItems: 'center',
},
btnTablet: {
width: 50,
height: 50,
borderRadius: 25,
left: 30,
right: 30,
},
btnMobile: {
width: 44,
height: 44,
borderRadius: 22,
left: 20,
right: 20, right: 20,
}, },
leftBtn: { leftBtn: {
left: 20,
right: 'auto', right: 'auto',
top: '50%', top: '50%',
}, },
rightBtn: { rightBtn: {
right: 20,
left: 'auto', left: 'auto',
top: '50%', top: '50%',
}, },
footer: { blurredBackdrop: {
paddingHorizontal: 32, backgroundColor: '#00000077',
paddingVertical: 24, // @ts-expect-error web only -sfn
backgroundColor: colors.black,
},
blurredBackground: {
backdropFilter: 'blur(10px)', backdropFilter: 'blur(10px)',
WebkitBackdropFilter: 'blur(10px)', },
} as ViewStyle, blurredBackdropHover: {
backgroundColor: '#00000088',
},
}) })