react-native-quick-preview 1.0.5 → 2.0.1

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.
@@ -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
+ }
@@ -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
- }