Refresh lightbox designs (#10330)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -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)',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user