react-native-quick-preview 1.0.5 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +288 -188
- package/dist/index.d.mts +244 -35
- package/dist/index.d.ts +244 -35
- package/dist/index.js +419 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +407 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +103 -98
- package/src/QuickPreviewAPI.ts +70 -0
- package/src/addons/QuickPreviewPressable.tsx +115 -0
- package/src/context.tsx +91 -0
- package/src/headless/QuickPreview.tsx +56 -0
- package/src/index.ts +24 -2
- package/src/internal/QuickPreviewRoot.tsx +73 -0
- package/src/internal/components/Backdrop.tsx +36 -0
- package/src/internal/components/PopoverContainer.tsx +58 -0
- package/src/internal/components/QuickPreviewScrollView.tsx +21 -0
- package/src/internal/components/SheetContainer.tsx +84 -0
- package/src/internal/resolveSize.ts +17 -0
- package/src/types.ts +93 -0
- package/src/useQuickPreview.ts +28 -0
- package/src/QuickPreview.tsx +0 -222
- package/src/QuickPreviewProperties.ts +0 -46
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { View, StyleSheet } from 'react-native'
|
|
3
|
+
import Animated, { Easing, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'
|
|
4
|
+
import type { QuickPreviewOptions } from '../../types'
|
|
5
|
+
import { resolveSizeStyle } from '../resolveSize'
|
|
6
|
+
|
|
7
|
+
export function PopoverContainer({
|
|
8
|
+
children,
|
|
9
|
+
options,
|
|
10
|
+
insets: _insets,
|
|
11
|
+
onRequestClose: _onRequestClose, // kept for API parity with SheetContainer
|
|
12
|
+
}: {
|
|
13
|
+
children: React.ReactNode
|
|
14
|
+
options?: QuickPreviewOptions
|
|
15
|
+
insets: { top: number; bottom: number }
|
|
16
|
+
onRequestClose: () => void
|
|
17
|
+
}) {
|
|
18
|
+
const scale = useSharedValue(0.96)
|
|
19
|
+
const opacity = useSharedValue(0)
|
|
20
|
+
|
|
21
|
+
React.useEffect(() => {
|
|
22
|
+
// Smooth ease-out, no spring overshoot — a clean, quick "pop" in.
|
|
23
|
+
scale.value = withTiming(1, { duration: 200, easing: Easing.out(Easing.cubic) })
|
|
24
|
+
opacity.value = withTiming(1, { duration: 160, easing: Easing.out(Easing.quad) })
|
|
25
|
+
}, [scale, opacity])
|
|
26
|
+
|
|
27
|
+
const container = useAnimatedStyle(() => ({
|
|
28
|
+
opacity: opacity.value,
|
|
29
|
+
transform: [{ scale: scale.value }],
|
|
30
|
+
}))
|
|
31
|
+
|
|
32
|
+
const sizeStyle = resolveSizeStyle(options?.size)
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<View style={[StyleSheet.absoluteFill, styles.center]} pointerEvents="box-none">
|
|
36
|
+
<Animated.View
|
|
37
|
+
style={[styles.card, sizeStyle, container]}
|
|
38
|
+
accessibilityRole={options?.accessibilityRole}
|
|
39
|
+
accessibilityLabel={options?.accessibilityLabel ?? 'Quick preview'}
|
|
40
|
+
>
|
|
41
|
+
{children}
|
|
42
|
+
</Animated.View>
|
|
43
|
+
</View>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const styles = StyleSheet.create({
|
|
48
|
+
center: { justifyContent: 'center', alignItems: 'center' },
|
|
49
|
+
card: {
|
|
50
|
+
maxWidth: 520,
|
|
51
|
+
width: '92%',
|
|
52
|
+
maxHeight: '86%',
|
|
53
|
+
borderRadius: 16,
|
|
54
|
+
backgroundColor: 'white',
|
|
55
|
+
overflow: 'hidden',
|
|
56
|
+
elevation: 3,
|
|
57
|
+
},
|
|
58
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { ScrollView, ScrollViewProps } from 'react-native'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A gesture-aware `ScrollView` for use inside preview content. Use it instead of
|
|
6
|
+
* a plain `ScrollView` so that scrolling the content and the swipe-to-dismiss
|
|
7
|
+
* gesture don't fight each other. Accepts all `ScrollViewProps`.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```tsx
|
|
11
|
+
* present(
|
|
12
|
+
* <QuickPreviewScrollView>
|
|
13
|
+
* <LongArticle />
|
|
14
|
+
* </QuickPreviewScrollView>,
|
|
15
|
+
* { variant: 'sheet', size: { maxHeight: 520 } }
|
|
16
|
+
* )
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export function QuickPreviewScrollView(props: ScrollViewProps) {
|
|
20
|
+
return <ScrollView nestedScrollEnabled {...props} />
|
|
21
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { View, StyleSheet } from 'react-native'
|
|
3
|
+
import Animated, { Easing, useAnimatedStyle, useSharedValue, withTiming, runOnJS } from 'react-native-reanimated'
|
|
4
|
+
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
|
5
|
+
import type { QuickPreviewOptions } from '../../types'
|
|
6
|
+
import { resolveSizeStyle } from '../resolveSize'
|
|
7
|
+
|
|
8
|
+
export function SheetContainer({
|
|
9
|
+
children,
|
|
10
|
+
options,
|
|
11
|
+
insets,
|
|
12
|
+
onRequestClose,
|
|
13
|
+
}: {
|
|
14
|
+
children: React.ReactNode
|
|
15
|
+
options?: QuickPreviewOptions
|
|
16
|
+
insets: { top: number; bottom: number }
|
|
17
|
+
onRequestClose: () => void
|
|
18
|
+
}) {
|
|
19
|
+
const translateY = useSharedValue(1000)
|
|
20
|
+
const dismissOnPanDown = options?.dismissOnPanDown ?? true
|
|
21
|
+
|
|
22
|
+
React.useEffect(() => {
|
|
23
|
+
// Smooth slide up, no spring bounce.
|
|
24
|
+
translateY.value = withTiming(0, { duration: 300, easing: Easing.out(Easing.cubic) })
|
|
25
|
+
}, [translateY])
|
|
26
|
+
|
|
27
|
+
const pan = Gesture.Pan()
|
|
28
|
+
.onUpdate((e) => {
|
|
29
|
+
if (!dismissOnPanDown) return
|
|
30
|
+
translateY.value = Math.max(0, e.translationY)
|
|
31
|
+
})
|
|
32
|
+
.onEnd((e) => {
|
|
33
|
+
const shouldClose = e.velocityY > 1000 || translateY.value > 140
|
|
34
|
+
if (shouldClose) {
|
|
35
|
+
translateY.value = withTiming(800, { duration: 200 }, (finished) => {
|
|
36
|
+
if (finished) runOnJS(onRequestClose)()
|
|
37
|
+
})
|
|
38
|
+
} else {
|
|
39
|
+
translateY.value = withTiming(0, { duration: 240, easing: Easing.out(Easing.cubic) })
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const style = useAnimatedStyle(() => ({
|
|
44
|
+
transform: [{ translateY: translateY.value }],
|
|
45
|
+
}))
|
|
46
|
+
|
|
47
|
+
const sizeStyle = resolveSizeStyle(options?.size)
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<GestureDetector gesture={pan}>
|
|
51
|
+
<Animated.View
|
|
52
|
+
style={[styles.sheet, sizeStyle, style, { paddingBottom: Math.max(insets.bottom, 12) }]}
|
|
53
|
+
accessibilityRole={options?.accessibilityRole}
|
|
54
|
+
accessibilityLabel={options?.accessibilityLabel ?? 'Quick preview'}
|
|
55
|
+
>
|
|
56
|
+
<View style={styles.grabber} />
|
|
57
|
+
{children}
|
|
58
|
+
</Animated.View>
|
|
59
|
+
</GestureDetector>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const styles = StyleSheet.create({
|
|
64
|
+
sheet: {
|
|
65
|
+
position: 'absolute',
|
|
66
|
+
left: 0,
|
|
67
|
+
right: 0,
|
|
68
|
+
bottom: 0,
|
|
69
|
+
borderTopLeftRadius: 16,
|
|
70
|
+
borderTopRightRadius: 16,
|
|
71
|
+
paddingTop: 8,
|
|
72
|
+
backgroundColor: 'white',
|
|
73
|
+
overflow: 'hidden',
|
|
74
|
+
elevation: 3,
|
|
75
|
+
},
|
|
76
|
+
grabber: {
|
|
77
|
+
alignSelf: 'center',
|
|
78
|
+
width: 40,
|
|
79
|
+
height: 4,
|
|
80
|
+
borderRadius: 2,
|
|
81
|
+
backgroundColor: 'rgba(0,0,0,0.2)',
|
|
82
|
+
marginBottom: 8,
|
|
83
|
+
},
|
|
84
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { QuickPreviewSize } from '../types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Turn the public `size` option into container style constraints.
|
|
5
|
+
* 'auto' (or undefined) applies nothing; a number caps the height;
|
|
6
|
+
* an object caps height and/or width.
|
|
7
|
+
*/
|
|
8
|
+
export function resolveSizeStyle(
|
|
9
|
+
size?: QuickPreviewSize
|
|
10
|
+
): { maxHeight?: number; maxWidth?: number } {
|
|
11
|
+
if (size == null || size === 'auto') return {}
|
|
12
|
+
if (typeof size === 'number') return { maxHeight: size }
|
|
13
|
+
const out: { maxHeight?: number; maxWidth?: number } = {}
|
|
14
|
+
if (typeof size.maxHeight === 'number') out.maxHeight = size.maxHeight
|
|
15
|
+
if (typeof size.maxWidth === 'number') out.maxWidth = size.maxWidth
|
|
16
|
+
return out
|
|
17
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type React from 'react'
|
|
2
|
+
import type { AccessibilityRole } from 'react-native'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* How the preview is presented.
|
|
6
|
+
* - `'popover'` — a centered card that scales in.
|
|
7
|
+
* - `'sheet'` — a panel that slides up from the bottom and can be swiped down to dismiss.
|
|
8
|
+
*
|
|
9
|
+
* @defaultValue `'popover'`
|
|
10
|
+
*/
|
|
11
|
+
export type QuickPreviewVariant = 'popover' | 'sheet'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Constrains the preview container's size.
|
|
15
|
+
* - `'auto'` — size to the content (default).
|
|
16
|
+
* - `number` — a maximum height in points.
|
|
17
|
+
* - `{ maxHeight, maxWidth }` — explicit maximum dimensions in points.
|
|
18
|
+
*
|
|
19
|
+
* @defaultValue `'auto'`
|
|
20
|
+
*/
|
|
21
|
+
export type QuickPreviewSize =
|
|
22
|
+
| 'auto'
|
|
23
|
+
| number
|
|
24
|
+
| { maxHeight?: number; maxWidth?: number }
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Options for a single presentation, passed to {@link QuickPreviewController.present}
|
|
28
|
+
* (or `QuickPreview.present`, or as `previewOptions` on `QuickPreviewPressable`).
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```tsx
|
|
32
|
+
* present(<Card />, {
|
|
33
|
+
* variant: 'sheet',
|
|
34
|
+
* size: { maxHeight: 480 },
|
|
35
|
+
* dismissOnPanDown: true,
|
|
36
|
+
* })
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export type QuickPreviewOptions = {
|
|
40
|
+
/**
|
|
41
|
+
* Centered `'popover'` or bottom `'sheet'`.
|
|
42
|
+
* @defaultValue `'popover'`
|
|
43
|
+
*/
|
|
44
|
+
variant?: QuickPreviewVariant
|
|
45
|
+
/**
|
|
46
|
+
* Constrain the container size. A number caps the height; an object caps
|
|
47
|
+
* `maxHeight`/`maxWidth`; `'auto'` sizes to the content.
|
|
48
|
+
* @defaultValue `'auto'`
|
|
49
|
+
*/
|
|
50
|
+
size?: QuickPreviewSize
|
|
51
|
+
/**
|
|
52
|
+
* Close the preview when the backdrop (the area outside it) is tapped.
|
|
53
|
+
* @defaultValue `true`
|
|
54
|
+
*/
|
|
55
|
+
dismissOnBackdropPress?: boolean
|
|
56
|
+
/**
|
|
57
|
+
* Close the preview when it is swiped down. Applies to the `'sheet'` variant only.
|
|
58
|
+
* @defaultValue `true`
|
|
59
|
+
*/
|
|
60
|
+
dismissOnPanDown?: boolean
|
|
61
|
+
/** Accessibility label announced for the preview container. */
|
|
62
|
+
accessibilityLabel?: string
|
|
63
|
+
/** Accessibility role for the preview container. */
|
|
64
|
+
accessibilityRole?: AccessibilityRole
|
|
65
|
+
/** Called on the JS thread when the open animation starts. */
|
|
66
|
+
onOpenStart?: () => void
|
|
67
|
+
/** Called when the open animation finishes. */
|
|
68
|
+
onOpenEnd?: () => void
|
|
69
|
+
/** Called when the close animation starts. */
|
|
70
|
+
onCloseStart?: () => void
|
|
71
|
+
/** Called when the close animation finishes and the preview is unmounted. */
|
|
72
|
+
onCloseEnd?: () => void
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The imperative controller returned by {@link useQuickPreview} and exposed as the
|
|
77
|
+
* static `QuickPreview` handle. Use it to open, update, and close previews.
|
|
78
|
+
*/
|
|
79
|
+
export type QuickPreviewController = {
|
|
80
|
+
/**
|
|
81
|
+
* Present `node` as the preview. Calling this while a preview is already open
|
|
82
|
+
* replaces its content and options.
|
|
83
|
+
* @param node - the React element to show inside the preview.
|
|
84
|
+
* @param opts - presentation options ({@link QuickPreviewOptions}).
|
|
85
|
+
*/
|
|
86
|
+
present: (node: React.ReactNode, opts?: Partial<QuickPreviewOptions>) => void
|
|
87
|
+
/** Dismiss the current preview. No-op if nothing is open. */
|
|
88
|
+
close: () => void
|
|
89
|
+
/** Merge new options into the currently open preview (e.g. switch `variant`). */
|
|
90
|
+
update: (opts: Partial<QuickPreviewOptions>) => void
|
|
91
|
+
/** Whether a preview is currently open. */
|
|
92
|
+
isOpen: () => boolean
|
|
93
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { QuickPreviewContext } from './context'
|
|
3
|
+
import type { QuickPreviewController } from './types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Access the {@link QuickPreviewController} to open, update, and close previews
|
|
7
|
+
* from inside a React component. Must be called under a mounted `<PreviewProvider>`.
|
|
8
|
+
*
|
|
9
|
+
* @throws if used outside of a `<PreviewProvider>`.
|
|
10
|
+
* @returns the controller: `{ present, close, update, isOpen }`.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```tsx
|
|
14
|
+
* function ProductCard({ product }) {
|
|
15
|
+
* const { present } = useQuickPreview()
|
|
16
|
+
* return (
|
|
17
|
+
* <Pressable onLongPress={() => present(<ProductPreview product={product} />, { variant: 'sheet' })}>
|
|
18
|
+
* <Thumbnail source={product.image} />
|
|
19
|
+
* </Pressable>
|
|
20
|
+
* )
|
|
21
|
+
* }
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export function useQuickPreview(): QuickPreviewController {
|
|
25
|
+
const ctx = React.useContext(QuickPreviewContext)
|
|
26
|
+
if (!ctx) throw new Error('useQuickPreview must be used within <PreviewProvider>')
|
|
27
|
+
return ctx
|
|
28
|
+
}
|
package/src/QuickPreview.tsx
DELETED
|
@@ -1,222 +0,0 @@
|
|
|
1
|
-
// src/QuickPreview.tsx
|
|
2
|
-
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
-
import {
|
|
4
|
-
View,
|
|
5
|
-
StyleSheet,
|
|
6
|
-
Modal,
|
|
7
|
-
Dimensions,
|
|
8
|
-
Animated,
|
|
9
|
-
Platform,
|
|
10
|
-
Pressable,
|
|
11
|
-
PanResponder,
|
|
12
|
-
Keyboard,
|
|
13
|
-
} from 'react-native';
|
|
14
|
-
import type { AccessibilityRole } from 'react-native';
|
|
15
|
-
import type { CloseReason, QuickPreviewProps, ThemeMode } from './QuickPreviewProperties';
|
|
16
|
-
|
|
17
|
-
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
|
|
18
|
-
|
|
19
|
-
// TS-safe across RN versions where "dialog" may not exist in AccessibilityRole
|
|
20
|
-
const dialogA11yRole = 'dialog' as unknown as AccessibilityRole;
|
|
21
|
-
|
|
22
|
-
const overlayColor = (theme: ThemeMode, custom?: number) =>
|
|
23
|
-
`rgba(0,0,0,${custom ?? (theme === 'dark' ? 0.8 : 0.5)})`;
|
|
24
|
-
|
|
25
|
-
export const QuickPreview: React.FC<QuickPreviewProps> = ({
|
|
26
|
-
visible,
|
|
27
|
-
onClose,
|
|
28
|
-
onOpen,
|
|
29
|
-
onClosed,
|
|
30
|
-
theme = 'light',
|
|
31
|
-
backdropOpacity,
|
|
32
|
-
animationDuration = 220,
|
|
33
|
-
closeOnBackdropPress = true,
|
|
34
|
-
closeOnBackButton = true,
|
|
35
|
-
enableSwipeToClose = true,
|
|
36
|
-
swipeThreshold = 80,
|
|
37
|
-
unmountOnExit = true,
|
|
38
|
-
avoidKeyboard = false,
|
|
39
|
-
children,
|
|
40
|
-
renderBackdrop,
|
|
41
|
-
onBackdropPress,
|
|
42
|
-
testID = 'quickpreview',
|
|
43
|
-
accessibilityLabel = 'Quick preview',
|
|
44
|
-
stylesOverride = {},
|
|
45
|
-
}) => {
|
|
46
|
-
const [mounted, setMounted] = useState(false);
|
|
47
|
-
const [kb, setKb] = useState(0); // keyboard height
|
|
48
|
-
|
|
49
|
-
const fade = useRef(new Animated.Value(0)).current;
|
|
50
|
-
const scale = useRef(new Animated.Value(0.96)).current;
|
|
51
|
-
const translateY = useRef(new Animated.Value(0)).current;
|
|
52
|
-
|
|
53
|
-
const baseOverlay = useMemo(() => overlayColor(theme, backdropOpacity), [theme, backdropOpacity]);
|
|
54
|
-
|
|
55
|
-
// Track why we closed so we can report it to onClosed
|
|
56
|
-
const closeReasonRef = useRef<CloseReason>('programmatic');
|
|
57
|
-
|
|
58
|
-
// Mount/unmount with animation
|
|
59
|
-
useEffect(() => {
|
|
60
|
-
if (visible && !mounted) {
|
|
61
|
-
setMounted(true);
|
|
62
|
-
onOpen?.(); // great place to trigger optional haptics in-app
|
|
63
|
-
|
|
64
|
-
requestAnimationFrame(() => {
|
|
65
|
-
Animated.parallel([
|
|
66
|
-
Animated.timing(fade, { toValue: 1, duration: animationDuration, useNativeDriver: true }),
|
|
67
|
-
Animated.spring(scale, { toValue: 1, useNativeDriver: true, friction: 7, tension: 80 }),
|
|
68
|
-
]).start();
|
|
69
|
-
});
|
|
70
|
-
} else if (!visible && mounted) {
|
|
71
|
-
Animated.parallel([
|
|
72
|
-
Animated.timing(fade, { toValue: 0, duration: animationDuration, useNativeDriver: true }),
|
|
73
|
-
Animated.timing(scale, { toValue: 0.96, duration: animationDuration, useNativeDriver: true }),
|
|
74
|
-
]).start(() => {
|
|
75
|
-
translateY.setValue(0);
|
|
76
|
-
onClosed?.(closeReasonRef.current);
|
|
77
|
-
closeReasonRef.current = 'programmatic';
|
|
78
|
-
if (unmountOnExit) setMounted(false);
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
}, [visible, mounted, animationDuration, fade, scale, translateY, unmountOnExit, onOpen, onClosed]);
|
|
82
|
-
|
|
83
|
-
// Keyboard avoidance (optional)
|
|
84
|
-
useEffect(() => {
|
|
85
|
-
if (!avoidKeyboard) return;
|
|
86
|
-
const show = Keyboard.addListener('keyboardDidShow', (e) => setKb(e.endCoordinates?.height ?? 0));
|
|
87
|
-
const hide = Keyboard.addListener('keyboardDidHide', () => setKb(0));
|
|
88
|
-
return () => {
|
|
89
|
-
show.remove();
|
|
90
|
-
hide.remove();
|
|
91
|
-
};
|
|
92
|
-
}, [avoidKeyboard]);
|
|
93
|
-
|
|
94
|
-
// Swipe to close
|
|
95
|
-
const panResponder = useRef(
|
|
96
|
-
PanResponder.create({
|
|
97
|
-
onMoveShouldSetPanResponder: (_evt, g) =>
|
|
98
|
-
enableSwipeToClose && Math.abs(g.dy) > Math.abs(g.dx) && Math.abs(g.dy) > 5,
|
|
99
|
-
onPanResponderMove: (_evt, g) => {
|
|
100
|
-
if (!enableSwipeToClose) return;
|
|
101
|
-
if (g.dy > 0) {
|
|
102
|
-
translateY.setValue(g.dy);
|
|
103
|
-
const p = Math.min(1, g.dy / (swipeThreshold * 2));
|
|
104
|
-
fade.setValue(1 - p * 0.25);
|
|
105
|
-
scale.setValue(1 - p * 0.03);
|
|
106
|
-
}
|
|
107
|
-
},
|
|
108
|
-
onPanResponderRelease: (_evt, g) => {
|
|
109
|
-
if (!enableSwipeToClose) return;
|
|
110
|
-
if (g.dy > swipeThreshold) {
|
|
111
|
-
closeReasonRef.current = 'swipe';
|
|
112
|
-
Animated.parallel([
|
|
113
|
-
Animated.timing(translateY, { toValue: screenHeight * 0.35, duration: 180, useNativeDriver: true }),
|
|
114
|
-
Animated.timing(fade, { toValue: 0, duration: 180, useNativeDriver: true }),
|
|
115
|
-
]).start(() => onClose?.());
|
|
116
|
-
} else {
|
|
117
|
-
Animated.parallel([
|
|
118
|
-
Animated.spring(translateY, { toValue: 0, useNativeDriver: true }),
|
|
119
|
-
Animated.spring(fade, { toValue: 1, useNativeDriver: true }),
|
|
120
|
-
Animated.spring(scale, { toValue: 1, useNativeDriver: true }),
|
|
121
|
-
]).start();
|
|
122
|
-
}
|
|
123
|
-
},
|
|
124
|
-
})
|
|
125
|
-
).current;
|
|
126
|
-
|
|
127
|
-
if (!mounted && !visible) return null;
|
|
128
|
-
|
|
129
|
-
return (
|
|
130
|
-
<Modal
|
|
131
|
-
transparent
|
|
132
|
-
visible={mounted || visible}
|
|
133
|
-
animationType="none"
|
|
134
|
-
onRequestClose={() => {
|
|
135
|
-
if (closeOnBackButton) {
|
|
136
|
-
closeReasonRef.current = 'backButton';
|
|
137
|
-
onClose?.();
|
|
138
|
-
}
|
|
139
|
-
}}
|
|
140
|
-
presentationStyle="overFullScreen"
|
|
141
|
-
statusBarTranslucent={Platform.OS === 'android'}
|
|
142
|
-
>
|
|
143
|
-
{/* Backdrop */}
|
|
144
|
-
<Animated.View
|
|
145
|
-
style={[
|
|
146
|
-
styles.overlay,
|
|
147
|
-
{ backgroundColor: baseOverlay, opacity: fade },
|
|
148
|
-
stylesOverride.overlay,
|
|
149
|
-
]}
|
|
150
|
-
accessibilityViewIsModal
|
|
151
|
-
>
|
|
152
|
-
{renderBackdrop ? (
|
|
153
|
-
// If you provide a custom backdrop, you control its hit area/closing
|
|
154
|
-
renderBackdrop(fade as unknown as Animated.AnimatedInterpolation<number>)
|
|
155
|
-
) : (
|
|
156
|
-
<Pressable
|
|
157
|
-
onPress={() => {
|
|
158
|
-
onBackdropPress?.();
|
|
159
|
-
if (closeOnBackdropPress) {
|
|
160
|
-
closeReasonRef.current = 'backdrop';
|
|
161
|
-
onClose?.();
|
|
162
|
-
}
|
|
163
|
-
}}
|
|
164
|
-
style={StyleSheet.absoluteFill}
|
|
165
|
-
android_disableSound
|
|
166
|
-
accessibilityRole="button"
|
|
167
|
-
accessibilityLabel="Close quick preview"
|
|
168
|
-
testID="qp-backdrop"
|
|
169
|
-
/>
|
|
170
|
-
)}
|
|
171
|
-
|
|
172
|
-
{/* Centered card */}
|
|
173
|
-
<View
|
|
174
|
-
style={[
|
|
175
|
-
styles.centerWrap,
|
|
176
|
-
{ paddingBottom: Math.max(16, avoidKeyboard ? kb : 16) },
|
|
177
|
-
stylesOverride.centerWrap,
|
|
178
|
-
]}
|
|
179
|
-
pointerEvents="box-none"
|
|
180
|
-
>
|
|
181
|
-
<Animated.View
|
|
182
|
-
{...(enableSwipeToClose ? panResponder.panHandlers : {})}
|
|
183
|
-
style={[
|
|
184
|
-
styles.container,
|
|
185
|
-
{ transform: [{ scale }, { translateY }] },
|
|
186
|
-
stylesOverride.container,
|
|
187
|
-
]}
|
|
188
|
-
testID={testID}
|
|
189
|
-
accessibilityRole={dialogA11yRole}
|
|
190
|
-
accessibilityViewIsModal
|
|
191
|
-
accessibilityLabel={accessibilityLabel}
|
|
192
|
-
>
|
|
193
|
-
{children}
|
|
194
|
-
</Animated.View>
|
|
195
|
-
</View>
|
|
196
|
-
</Animated.View>
|
|
197
|
-
</Modal>
|
|
198
|
-
);
|
|
199
|
-
};
|
|
200
|
-
|
|
201
|
-
const styles = StyleSheet.create({
|
|
202
|
-
overlay: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
|
203
|
-
centerWrap: {
|
|
204
|
-
flex: 1,
|
|
205
|
-
width: '100%',
|
|
206
|
-
justifyContent: 'center',
|
|
207
|
-
alignItems: 'center',
|
|
208
|
-
paddingHorizontal: 16,
|
|
209
|
-
},
|
|
210
|
-
container: {
|
|
211
|
-
width: Math.min(screenWidth * 0.92, 560),
|
|
212
|
-
maxHeight: screenHeight * 0.82,
|
|
213
|
-
borderRadius: 16,
|
|
214
|
-
overflow: 'hidden',
|
|
215
|
-
elevation: 8,
|
|
216
|
-
shadowColor: '#000',
|
|
217
|
-
shadowOpacity: 0.2,
|
|
218
|
-
shadowRadius: 12,
|
|
219
|
-
shadowOffset: { width: 0, height: 6 },
|
|
220
|
-
backgroundColor: '#fff',
|
|
221
|
-
},
|
|
222
|
-
});
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import type { ReactNode } from 'react';
|
|
2
|
-
import type { Animated } from 'react-native';
|
|
3
|
-
|
|
4
|
-
export type ThemeMode = 'light' | 'dark';
|
|
5
|
-
export type CloseReason = 'programmatic' | 'backdrop' | 'backButton' | 'swipe';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
export interface QuickPreviewProps {
|
|
9
|
-
visible: boolean;
|
|
10
|
-
onClose: () => void;
|
|
11
|
-
|
|
12
|
-
/** Lifecycle hooks (great for haptics/analytics) */
|
|
13
|
-
onOpen?: () => void; // fires when animating in
|
|
14
|
-
onClosed?: (reason: CloseReason) => void; // fires after fully closed
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
theme?: ThemeMode;
|
|
18
|
-
backdropOpacity?: number; // default 0.5 light / 0.8 dark
|
|
19
|
-
animationDuration?: number; // default 220
|
|
20
|
-
|
|
21
|
-
/** Behavior toggles */
|
|
22
|
-
closeOnBackdropPress?: boolean; // default true
|
|
23
|
-
closeOnBackButton?: boolean; // default true
|
|
24
|
-
enableSwipeToClose?: boolean; // default true
|
|
25
|
-
swipeThreshold?: number; // default 80
|
|
26
|
-
unmountOnExit?: boolean; // default true
|
|
27
|
-
avoidKeyboard?: boolean; // default false
|
|
28
|
-
|
|
29
|
-
/** Headless content */
|
|
30
|
-
children?: ReactNode;
|
|
31
|
-
|
|
32
|
-
/** Backdrop customization */
|
|
33
|
-
renderBackdrop?: (opacity: Animated.AnimatedInterpolation<number>) => ReactNode;
|
|
34
|
-
onBackdropPress?: () => void;
|
|
35
|
-
|
|
36
|
-
/** Testing & a11y */
|
|
37
|
-
testID?: string;
|
|
38
|
-
accessibilityLabel?: string;
|
|
39
|
-
|
|
40
|
-
/** Outer wrapper style overrides */
|
|
41
|
-
stylesOverride?: {
|
|
42
|
-
overlay?: any;
|
|
43
|
-
container?: any;
|
|
44
|
-
centerWrap?: any;
|
|
45
|
-
};
|
|
46
|
-
}
|