Refresh lightbox designs (#10330)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Spence Pope
2026-05-05 12:25:29 -04:00
committed by GitHub
parent 48ea9e0b96
commit a7fabd9e0d
28 changed files with 653 additions and 263 deletions
@@ -0,0 +1,65 @@
import {type ComponentType} from 'react'
import {
Pressable,
type PressableProps,
type StyleProp,
StyleSheet,
type TextStyle,
} from 'react-native'
import {BlurView} from 'expo-blur'
import {HITSLOP_10} from '#/lib/constants'
import {type Props as IconProps} from '#/components/icons/common'
type Props = {
icon: ComponentType<IconProps>
iconStyle?: StyleProp<TextStyle>
label: string
onPress?: PressableProps['onPress']
testID?: string
}
const SIZE = 44
const RADIUS = 24
const ICON = 24
export function CircleChromeButton({
icon: Icon,
iconStyle,
label,
onPress,
testID,
}: Props) {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
accessibilityHint=""
hitSlop={HITSLOP_10}
onPress={onPress}
testID={testID}
style={({pressed}) => [styles.root, pressed && styles.pressed]}>
<BlurView intensity={20} tint="dark" style={styles.inner}>
<Icon width={ICON} fill="#fff" style={iconStyle} />
</BlurView>
</Pressable>
)
}
const styles = StyleSheet.create({
root: {
width: SIZE,
height: SIZE,
borderRadius: RADIUS,
overflow: 'hidden',
},
inner: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
pressed: {
opacity: 0.85,
},
})
@@ -0,0 +1,68 @@
import {type ComponentType} from 'react'
import {
Pressable,
type PressableProps,
type StyleProp,
StyleSheet,
type TextStyle,
View,
} from 'react-native'
import {HITSLOP_10} from '#/lib/constants'
import {type Props as IconProps} from '#/components/icons/common'
type Props = {
icon: ComponentType<IconProps>
iconStyle?: StyleProp<TextStyle>
label: string
onPress?: PressableProps['onPress']
testID?: string
}
const SIZE = 44
const RADIUS = 24
const ICON = 24
export function CircleChromeButton({
icon: Icon,
iconStyle,
label,
onPress,
testID,
}: Props) {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
accessibilityHint=""
hitSlop={HITSLOP_10}
onPress={onPress}
testID={testID}
style={({pressed}) => [styles.root, pressed && styles.pressed]}>
<View style={styles.inner}>
<Icon width={ICON} fill="#fff" style={iconStyle} />
</View>
</Pressable>
)
}
const styles = StyleSheet.create({
root: {
width: SIZE,
height: SIZE,
borderRadius: RADIUS,
overflow: 'hidden',
},
inner: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.75)',
// @ts-expect-error web-only
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
},
pressed: {
opacity: 0.85,
},
})
+84
View File
@@ -0,0 +1,84 @@
import {useRef} from 'react'
import {
LayoutAnimation,
Pressable,
ScrollView,
StyleSheet,
View,
} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {atoms as a} from '#/alf'
import {Text} from '#/components/Typography'
type Props = {
altText: string | undefined
isAltExpanded: boolean
onToggleAltExpanded: () => void
}
export function Footer({altText, isAltExpanded, onToggleAltExpanded}: Props) {
const {_} = useLingui()
const insets = useSafeAreaInsets()
const isMomentumScrolling = useRef(false)
if (!altText) return null
return (
<View
style={[styles.root, {paddingBottom: insets.bottom + 8}]}
pointerEvents="box-none">
<View style={[a.mx_md, styles.altWrap]}>
<ScrollView
scrollEnabled={isAltExpanded}
onMomentumScrollBegin={() => {
isMomentumScrolling.current = true
}}
onMomentumScrollEnd={() => {
isMomentumScrolling.current = false
}}
contentContainerStyle={[a.px_md, a.py_sm]}>
<Pressable
accessibilityRole="button"
accessibilityLabel={_(msg`Expand alt text`)}
accessibilityHint=""
onPress={() => {
if (isMomentumScrolling.current) return
LayoutAnimation.configureNext({
duration: 450,
update: {type: 'spring', springDamping: 1},
})
onToggleAltExpanded()
}}>
<Text
emoji
selectable
style={[a.text_sm, styles.altText]}
numberOfLines={isAltExpanded ? undefined : 3}>
{altText}
</Text>
</Pressable>
</ScrollView>
</View>
</View>
)
}
const styles = StyleSheet.create({
root: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
},
altWrap: {
backgroundColor: 'rgba(0, 0, 0, 0.45)',
borderRadius: 12,
overflow: 'hidden',
},
altText: {
color: '#fff',
},
})
+59
View File
@@ -0,0 +1,59 @@
import {StyleSheet, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {atoms as a} from '#/alf'
import {TimesLarge_Stroke2_Corner0_Rounded as CloseIcon} from '#/components/icons/Times'
import {CircleChromeButton} from './CircleChromeButton'
import {ImageMenu} from './ImageMenu'
import {PagerDots} from './PagerDots'
type Props = {
onRequestClose: () => void
onPressShare: () => void
onPressSave: () => void
imageCount: number
activeIndex: number
}
export function Header({
onRequestClose,
onPressShare,
onPressSave,
imageCount,
activeIndex,
}: Props) {
const {_} = useLingui()
const insets = useSafeAreaInsets()
return (
<View
style={[
styles.root,
a.flex_row,
a.justify_between,
a.align_center,
a.px_md,
{paddingTop: insets.top + 8},
]}
pointerEvents="box-none">
<ImageMenu onPressShare={onPressShare} onPressSave={onPressSave} />
<PagerDots count={imageCount} activeIndex={activeIndex} />
<CircleChromeButton
icon={CloseIcon}
label={_(msg`Close image`)}
onPress={onRequestClose}
/>
</View>
)
}
const styles = StyleSheet.create({
root: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
},
})
@@ -0,0 +1,195 @@
import {useRef, useState} from 'react'
import {Modal, Pressable, StyleSheet, View} from 'react-native'
import Animated, {
interpolate,
runOnJS,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from 'react-native-reanimated'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {atoms as a} from '#/alf'
import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight'
import {type Props as IconProps} from '#/components/icons/common'
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid'
import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download'
import {Text} from '#/components/Typography'
import {CircleChromeButton} from './CircleChromeButton'
type Props = {
onPressShare: () => void
onPressSave: () => void
}
type Anchor = {x: number; y: number; width: number; height: number}
const MENU_WIDTH = 160
const GAP = 6
const CARD_BG = '#000000'
const CARD_BORDER = '#232e3e'
const ITEM_TEXT = '#f9fafb'
const SPRING_IN = {damping: 18, mass: 0.6, stiffness: 240}
const TIMING_OUT = {duration: 150}
export function ImageMenu({onPressShare, onPressSave}: Props) {
const {_} = useLingui()
const triggerRef = useRef<View>(null)
const [isMounted, setIsMounted] = useState(false)
const [anchor, setAnchor] = useState<Anchor | null>(null)
const progress = useSharedValue(0)
const open = () => {
triggerRef.current?.measureInWindow((x, y, width, height) => {
setAnchor({x, y, width, height})
setIsMounted(true)
progress.set(withSpring(1, SPRING_IN))
})
}
const close = () => {
progress.set(
withTiming(0, TIMING_OUT, finished => {
if (finished) {
runOnJS(setIsMounted)(false)
}
}),
)
}
const runAction = (action: () => void) => {
close()
action()
}
return (
<>
<View ref={triggerRef} collapsable={false}>
<CircleChromeButton
icon={DotsIcon}
iconStyle={{transform: [{rotate: '90deg'}]}}
label={_(msg`Image options`)}
onPress={open}
/>
</View>
<Modal
transparent
visible={isMounted}
animationType="none"
onRequestClose={close}
statusBarTranslucent>
<Pressable
accessibilityRole="button"
accessibilityLabel={_(msg`Close menu`)}
accessibilityHint=""
style={StyleSheet.absoluteFill}
onPress={close}
/>
{anchor && (
<MenuCard anchor={anchor} progress={progress}>
<MenuItem
icon={ShareIcon}
label={_(msg`Share image`)}
onPress={() => runAction(onPressShare)}
/>
<MenuItem
icon={DownloadIcon}
label={_(msg`Save image`)}
onPress={() => runAction(onPressSave)}
/>
</MenuCard>
)}
</Modal>
</>
)
}
function MenuCard({
anchor,
progress,
children,
}: {
anchor: Anchor
progress: ReturnType<typeof useSharedValue<number>>
children: React.ReactNode
}) {
const animatedStyle = useAnimatedStyle(() => ({
opacity: progress.get(),
transform: [{scale: interpolate(progress.get(), [0, 1], [0.9, 1])}],
}))
return (
<Animated.View
style={[
a.absolute,
styles.card,
{
top: anchor.y + anchor.height + GAP,
left: anchor.x,
transformOrigin: 'top left',
},
animatedStyle,
]}>
{children}
</Animated.View>
)
}
function MenuItem({
icon: Icon,
label,
onPress,
}: {
icon: React.ComponentType<IconProps>
label: string
onPress: () => void
}) {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
accessibilityHint=""
onPress={onPress}
style={({pressed}) => [styles.item, pressed && styles.itemPressed]}>
<Icon width={18} fill={ITEM_TEXT} />
<Text style={styles.itemText}>{label}</Text>
</Pressable>
)
}
const styles = StyleSheet.create({
card: {
width: MENU_WIDTH,
padding: 8,
borderRadius: 16,
borderWidth: 0.5,
borderColor: CARD_BORDER,
backgroundColor: CARD_BG,
shadowColor: '#000',
shadowOffset: {width: 0, height: 4},
shadowOpacity: 0.16,
shadowRadius: 20,
elevation: 8,
},
item: {
height: 44,
paddingHorizontal: 12,
borderRadius: 8,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
itemPressed: {
backgroundColor: 'rgba(255, 255, 255, 0.08)',
},
itemText: {
flex: 1,
fontSize: 15,
fontWeight: '500',
lineHeight: 19.5,
color: ITEM_TEXT,
},
})
@@ -0,0 +1,54 @@
import {StyleSheet, View} from 'react-native'
import {atoms as a} from '#/alf'
type Props = {
count: number
activeIndex: number
}
const ACTIVE = 6
const INACTIVE = 4
const GAP = 5
export function PagerDots({count, activeIndex}: Props) {
if (count <= 1) return null
return (
<View style={[a.flex_row, a.align_center, a.justify_center, styles.row]}>
{Array.from({length: count}).map((_, i) => {
const isActive = i === activeIndex
return (
<View
key={i}
style={[
isActive ? styles.active : styles.inactive,
isActive ? styles.activeDot : styles.inactiveDot,
]}
/>
)
})}
</View>
)
}
const styles = StyleSheet.create({
row: {
gap: GAP,
},
activeDot: {
width: ACTIVE,
height: ACTIVE,
borderRadius: ACTIVE / 2,
},
inactiveDot: {
width: INACTIVE,
height: INACTIVE,
borderRadius: INACTIVE / 2,
},
active: {
backgroundColor: '#fff',
},
inactive: {
backgroundColor: 'rgba(255, 255, 255, 0.4)',
},
})