react-native-quick-preview 1.0.4 → 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 -37
- package/dist/index.d.ts +244 -37
- 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 -81
- 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/QuickLook.tsx +0 -222
- package/src/QuickLookProperties.ts +0 -48
package/package.json
CHANGED
|
@@ -1,81 +1,103 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "react-native-quick-preview",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
5
|
-
"author": "Oliver Lindblad",
|
|
6
|
-
"license": "MIT",
|
|
7
|
-
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
"
|
|
67
|
-
"@typescript-eslint/
|
|
68
|
-
"@typescript-eslint/
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
"
|
|
73
|
-
"
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
"
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "react-native-quick-preview",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Headless long-press quick preview for React Native. Peek at any content in a popover or sheet, swipe down to dismiss.",
|
|
5
|
+
"author": "Oliver Lindblad",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"module": "dist/index.mjs",
|
|
9
|
+
"types": "dist/index.d.ts",
|
|
10
|
+
"react-native": "src/index.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"react-native": "./src/index.ts",
|
|
15
|
+
"import": "./dist/index.mjs",
|
|
16
|
+
"require": "./dist/index.js",
|
|
17
|
+
"default": "./dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./package.json": "./package.json"
|
|
20
|
+
},
|
|
21
|
+
"sideEffects": false,
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"src",
|
|
25
|
+
"!src/**/__tests__/**",
|
|
26
|
+
"README.md",
|
|
27
|
+
"LICENSE"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsup",
|
|
31
|
+
"dev": "tsup --watch",
|
|
32
|
+
"type-check": "tsc --noEmit",
|
|
33
|
+
"lint": "eslint src --ext .ts,.tsx",
|
|
34
|
+
"clean": "rimraf dist",
|
|
35
|
+
"prepublishOnly": "rimraf dist && tsup",
|
|
36
|
+
"test": "jest"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"react-native",
|
|
40
|
+
"expo",
|
|
41
|
+
"modal",
|
|
42
|
+
"preview",
|
|
43
|
+
"quick-preview",
|
|
44
|
+
"quick-view",
|
|
45
|
+
"peek",
|
|
46
|
+
"long-press",
|
|
47
|
+
"bottom-sheet",
|
|
48
|
+
"popover",
|
|
49
|
+
"headless",
|
|
50
|
+
"typescript",
|
|
51
|
+
"ui",
|
|
52
|
+
"component"
|
|
53
|
+
],
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"react": ">=18.2.0 <21",
|
|
56
|
+
"react-native": ">=0.64.0 <1",
|
|
57
|
+
"react-native-gesture-handler": ">=2.0.0",
|
|
58
|
+
"react-native-portalize": ">=1.0.0",
|
|
59
|
+
"react-native-reanimated": ">=3.0.0",
|
|
60
|
+
"react-native-safe-area-context": ">=4.0.0"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@react-native/babel-preset": "0.81.5",
|
|
64
|
+
"@testing-library/react-native": "^13.3.3",
|
|
65
|
+
"@types/jest": "^29.5.14",
|
|
66
|
+
"@types/react": "^19.0.0",
|
|
67
|
+
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
|
68
|
+
"@typescript-eslint/parser": "^7.18.0",
|
|
69
|
+
"eslint": "^8.57.0",
|
|
70
|
+
"jest": "^29.7.0",
|
|
71
|
+
"react": "19.1.0",
|
|
72
|
+
"react-native": "0.81.5",
|
|
73
|
+
"react-native-gesture-handler": "~2.28.0",
|
|
74
|
+
"react-native-portalize": "^1.0.0",
|
|
75
|
+
"react-native-reanimated": "~4.1.1",
|
|
76
|
+
"react-native-safe-area-context": "~5.6.0",
|
|
77
|
+
"react-test-renderer": "19.1.0",
|
|
78
|
+
"rimraf": "^6.0.0",
|
|
79
|
+
"tsup": "^8.0.0",
|
|
80
|
+
"typescript": "^5.0.0",
|
|
81
|
+
"react-native-worklets": "~0.5.1"
|
|
82
|
+
},
|
|
83
|
+
"overrides": {
|
|
84
|
+
"eslint": "^8.57.0",
|
|
85
|
+
"@typescript-eslint/parser": "^7.18.0",
|
|
86
|
+
"@typescript-eslint/eslint-plugin": "^7.18.0"
|
|
87
|
+
},
|
|
88
|
+
"repository": {
|
|
89
|
+
"type": "git",
|
|
90
|
+
"url": "git+https://github.com/Hashtagsmile/react-native-quick-preview.git"
|
|
91
|
+
},
|
|
92
|
+
"bugs": {
|
|
93
|
+
"url": "https://github.com/Hashtagsmile/react-native-quick-preview/issues"
|
|
94
|
+
},
|
|
95
|
+
"homepage": "https://github.com/Hashtagsmile/react-native-quick-preview#readme",
|
|
96
|
+
"publishConfig": {
|
|
97
|
+
"access": "public"
|
|
98
|
+
},
|
|
99
|
+
"engines": {
|
|
100
|
+
"node": ">=18"
|
|
101
|
+
},
|
|
102
|
+
"source": "src/index.ts"
|
|
103
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// src/QuickPreviewAPI.ts
|
|
2
|
+
import type { QuickPreviewController, QuickPreviewOptions } from './types'
|
|
3
|
+
import type React from 'react'
|
|
4
|
+
|
|
5
|
+
// Persist across Fast Refresh / HMR
|
|
6
|
+
const STORE = Symbol.for('rnqp.controller')
|
|
7
|
+
type Store = { controller: QuickPreviewController | null }
|
|
8
|
+
const g = globalThis as Record<symbol, Store | undefined>
|
|
9
|
+
const store: Store = g[STORE] ?? (g[STORE] = { controller: null })
|
|
10
|
+
|
|
11
|
+
function warn() {
|
|
12
|
+
// __DEV__ is provided by RN/Metro; if TS complains, declare it in a globals.d.ts
|
|
13
|
+
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
14
|
+
// eslint-disable-next-line no-console
|
|
15
|
+
console.warn('[react-native-quick-preview] PreviewProvider not mounted yet.')
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A static handle to the same controller as {@link useQuickPreview} — callable
|
|
21
|
+
* from anywhere, including outside React (services, store actions, event handlers).
|
|
22
|
+
* Before `<PreviewProvider>` mounts it is a safe no-op (with a dev warning).
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```ts
|
|
26
|
+
* import { QuickPreview } from 'react-native-quick-preview'
|
|
27
|
+
*
|
|
28
|
+
* QuickPreview.present(<Card />, { variant: 'sheet' })
|
|
29
|
+
* QuickPreview.update({ size: { maxHeight: 480 } })
|
|
30
|
+
* QuickPreview.close()
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export const QuickPreview = {
|
|
34
|
+
/** @internal Registered by `<PreviewProvider />`. Not part of the public API. */
|
|
35
|
+
_set(c: QuickPreviewController | null) {
|
|
36
|
+
store.controller = c
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Present `node` as the preview. Replaces the content if one is already open.
|
|
41
|
+
* @param node - the React element to show inside the preview.
|
|
42
|
+
* @param opts - presentation options ({@link QuickPreviewOptions}).
|
|
43
|
+
*/
|
|
44
|
+
present(node: React.ReactNode, opts?: Partial<QuickPreviewOptions>) {
|
|
45
|
+
const c = store.controller
|
|
46
|
+
c ? c.present(node, opts) : warn()
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
/** Dismiss the current preview. No-op if nothing is open. */
|
|
50
|
+
close() {
|
|
51
|
+
const c = store.controller
|
|
52
|
+
c ? c.close() : warn()
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
/** Merge new options into the currently open preview. */
|
|
56
|
+
update(opts: Partial<QuickPreviewOptions>) {
|
|
57
|
+
const c = store.controller
|
|
58
|
+
c ? c.update(opts) : warn()
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
/** Whether a preview is currently open. */
|
|
62
|
+
isOpen() {
|
|
63
|
+
return !!store.controller?.isOpen()
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
/** @internal Dev/test convenience — don't use in app code. */
|
|
67
|
+
__unsafe_getController(): QuickPreviewController | null {
|
|
68
|
+
return store.controller
|
|
69
|
+
},
|
|
70
|
+
} as const
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import type { StyleProp, ViewStyle } from 'react-native'
|
|
3
|
+
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
|
4
|
+
import Animated, {
|
|
5
|
+
runOnJS,
|
|
6
|
+
useAnimatedStyle,
|
|
7
|
+
useSharedValue,
|
|
8
|
+
withTiming,
|
|
9
|
+
} from 'react-native-reanimated'
|
|
10
|
+
import { QuickPreview } from '../QuickPreviewAPI'
|
|
11
|
+
import type { QuickPreviewOptions } from '../types'
|
|
12
|
+
|
|
13
|
+
export type QuickPreviewPressableProps = {
|
|
14
|
+
children: React.ReactNode
|
|
15
|
+
/** Called for a normal tap (e.g., navigate) */
|
|
16
|
+
onPress?: () => void
|
|
17
|
+
/** Return the preview node to present */
|
|
18
|
+
renderPreview: () => React.ReactNode
|
|
19
|
+
/** Options forwarded to QuickPreview.present */
|
|
20
|
+
previewOptions?: Partial<QuickPreviewOptions>
|
|
21
|
+
/** Long-press delay (ms). Default 350 */
|
|
22
|
+
delay?: number
|
|
23
|
+
/** Press-down scale. Default 0.98 */
|
|
24
|
+
scale?: number
|
|
25
|
+
/**
|
|
26
|
+
* Called on the JS thread when the long press activates, right before the
|
|
27
|
+
* preview opens. Wire up haptics here, e.g.:
|
|
28
|
+
* onLongPressStart={() => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium)}
|
|
29
|
+
* The library deliberately does not import expo-haptics itself: an optional
|
|
30
|
+
* require would break Metro bundling for apps that don't have it installed.
|
|
31
|
+
*/
|
|
32
|
+
onLongPressStart?: () => void
|
|
33
|
+
/** Disable all interactions */
|
|
34
|
+
disabled?: boolean
|
|
35
|
+
/** Style applied to the animated wrapper (the thing that scales) */
|
|
36
|
+
style?: StyleProp<ViewStyle>
|
|
37
|
+
/** Accessibility */
|
|
38
|
+
accessible?: boolean
|
|
39
|
+
accessibilityLabel?: string
|
|
40
|
+
testID?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function QuickPreviewPressable({
|
|
44
|
+
children,
|
|
45
|
+
onPress,
|
|
46
|
+
renderPreview,
|
|
47
|
+
previewOptions,
|
|
48
|
+
delay = 350,
|
|
49
|
+
scale = 0.98,
|
|
50
|
+
onLongPressStart,
|
|
51
|
+
disabled = false,
|
|
52
|
+
style,
|
|
53
|
+
accessible,
|
|
54
|
+
accessibilityLabel,
|
|
55
|
+
testID,
|
|
56
|
+
}: QuickPreviewPressableProps) {
|
|
57
|
+
const s = useSharedValue(1)
|
|
58
|
+
|
|
59
|
+
// Runs on the JS thread: renderPreview() creates React elements, which must
|
|
60
|
+
// never happen on the UI thread inside a worklet.
|
|
61
|
+
const openPreview = () => {
|
|
62
|
+
onLongPressStart?.()
|
|
63
|
+
QuickPreview.present(renderPreview(), {
|
|
64
|
+
variant: 'popover',
|
|
65
|
+
dismissOnBackdropPress: true,
|
|
66
|
+
...previewOptions,
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const longPress = Gesture.LongPress()
|
|
71
|
+
.enabled(!disabled)
|
|
72
|
+
.minDuration(delay)
|
|
73
|
+
.onTouchesDown(() => {
|
|
74
|
+
s.value = withTiming(scale, { duration: 90 })
|
|
75
|
+
})
|
|
76
|
+
.onFinalize(() => {
|
|
77
|
+
s.value = withTiming(1, { duration: 120 })
|
|
78
|
+
})
|
|
79
|
+
.onStart(() => {
|
|
80
|
+
runOnJS(openPreview)()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const tap = Gesture.Tap()
|
|
84
|
+
.enabled(!disabled)
|
|
85
|
+
.maxDuration(220)
|
|
86
|
+
.onTouchesDown(() => {
|
|
87
|
+
s.value = withTiming(scale, { duration: 90 })
|
|
88
|
+
})
|
|
89
|
+
.onFinalize(() => {
|
|
90
|
+
s.value = withTiming(1, { duration: 120 })
|
|
91
|
+
})
|
|
92
|
+
.onEnd(() => {
|
|
93
|
+
if (onPress) runOnJS(onPress)()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const gesture = Gesture.Exclusive(longPress, tap)
|
|
97
|
+
|
|
98
|
+
const animated = useAnimatedStyle(() => ({
|
|
99
|
+
transform: [{ scale: s.value }],
|
|
100
|
+
}))
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<GestureDetector gesture={gesture}>
|
|
104
|
+
<Animated.View
|
|
105
|
+
style={[animated, style]}
|
|
106
|
+
accessible={accessible}
|
|
107
|
+
accessibilityRole="button"
|
|
108
|
+
accessibilityLabel={accessibilityLabel}
|
|
109
|
+
testID={testID}
|
|
110
|
+
>
|
|
111
|
+
{children}
|
|
112
|
+
</Animated.View>
|
|
113
|
+
</GestureDetector>
|
|
114
|
+
)
|
|
115
|
+
}
|
package/src/context.tsx
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { BackHandler, AccessibilityInfo } from 'react-native'
|
|
3
|
+
import { Host, Portal } from 'react-native-portalize'
|
|
4
|
+
import { QuickPreviewOptions, QuickPreviewController } from './types'
|
|
5
|
+
import { QuickPreviewRoot } from './internal/QuickPreviewRoot'
|
|
6
|
+
import { QuickPreview as QuickPreviewStatic } from './QuickPreviewAPI'
|
|
7
|
+
|
|
8
|
+
export const QuickPreviewContext = React.createContext<QuickPreviewController | null>(null)
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Hosts the preview layer and registers the controller. Mount this once, near the
|
|
12
|
+
* root of your app, inside a `<GestureHandlerRootView>`. After it's mounted you can
|
|
13
|
+
* present previews via {@link useQuickPreview} or the static `QuickPreview` handle.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```tsx
|
|
17
|
+
* <GestureHandlerRootView style={{ flex: 1 }}>
|
|
18
|
+
* <PreviewProvider>
|
|
19
|
+
* <YourApp />
|
|
20
|
+
* </PreviewProvider>
|
|
21
|
+
* </GestureHandlerRootView>
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export function PreviewProvider({ children }: { children: React.ReactNode }) {
|
|
25
|
+
const [content, setContent] = React.useState<React.ReactNode | null>(null)
|
|
26
|
+
const [options, setOptions] = React.useState<QuickPreviewOptions | undefined>(undefined)
|
|
27
|
+
const openRef = React.useRef(false)
|
|
28
|
+
|
|
29
|
+
const announce = React.useCallback((msg: string) => {
|
|
30
|
+
// eslint-disable-next-line no-empty
|
|
31
|
+
try { AccessibilityInfo.announceForAccessibility?.(msg) } catch {}
|
|
32
|
+
}, [])
|
|
33
|
+
|
|
34
|
+
const close = React.useCallback(() => {
|
|
35
|
+
if (!openRef.current) return
|
|
36
|
+
openRef.current = false
|
|
37
|
+
setContent(null)
|
|
38
|
+
setOptions(undefined)
|
|
39
|
+
announce('Quick preview closed')
|
|
40
|
+
}, [announce])
|
|
41
|
+
|
|
42
|
+
const present = React.useCallback(
|
|
43
|
+
(node: React.ReactNode, opts?: Partial<QuickPreviewOptions>) => {
|
|
44
|
+
// Replace, don't merge: options from a previous presentation must not
|
|
45
|
+
// leak into the next one. Merging is what update() is for.
|
|
46
|
+
setOptions(opts)
|
|
47
|
+
openRef.current = true
|
|
48
|
+
setContent(() => node)
|
|
49
|
+
announce('Quick preview opened')
|
|
50
|
+
},
|
|
51
|
+
[announce]
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const update = React.useCallback((opts: Partial<QuickPreviewOptions>) => {
|
|
55
|
+
setOptions(prev => ({ ...prev, ...opts }))
|
|
56
|
+
}, [])
|
|
57
|
+
|
|
58
|
+
React.useEffect(() => {
|
|
59
|
+
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
|
|
60
|
+
if (openRef.current) { close(); return true }
|
|
61
|
+
return false
|
|
62
|
+
})
|
|
63
|
+
return () => sub.remove()
|
|
64
|
+
}, [close])
|
|
65
|
+
|
|
66
|
+
const value = React.useMemo<QuickPreviewController>(
|
|
67
|
+
() => ({ present, close, update, isOpen: () => openRef.current }),
|
|
68
|
+
[present, close, update]
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
// Register the static API (layout effect = available ASAP)
|
|
72
|
+
React.useLayoutEffect(() => {
|
|
73
|
+
QuickPreviewStatic._set(value)
|
|
74
|
+
return () => QuickPreviewStatic._set(null)
|
|
75
|
+
}, [value])
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<QuickPreviewContext.Provider value={value}>
|
|
79
|
+
<Host>
|
|
80
|
+
{children}
|
|
81
|
+
{content ? (
|
|
82
|
+
<Portal>
|
|
83
|
+
<QuickPreviewRoot options={options} onRequestClose={close}>
|
|
84
|
+
{content}
|
|
85
|
+
</QuickPreviewRoot>
|
|
86
|
+
</Portal>
|
|
87
|
+
) : null}
|
|
88
|
+
</Host>
|
|
89
|
+
</QuickPreviewContext.Provider>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Portal } from 'react-native-portalize'
|
|
3
|
+
import { QuickPreviewOptions } from '../types'
|
|
4
|
+
import { QuickPreviewRoot } from '../internal/QuickPreviewRoot'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Props for the controlled, headless `QuickPreviewComponent`. Extends
|
|
8
|
+
* {@link QuickPreviewOptions} (so `variant`, `size`, `dismissOn*`, and the
|
|
9
|
+
* lifecycle callbacks are all accepted).
|
|
10
|
+
*/
|
|
11
|
+
export type HeadlessQuickPreviewProps = {
|
|
12
|
+
/** Whether the preview is shown. You own this state. */
|
|
13
|
+
visible: boolean
|
|
14
|
+
/** Called when the preview requests to close (backdrop press, swipe, back button). */
|
|
15
|
+
onClose: () => void
|
|
16
|
+
/**
|
|
17
|
+
* Render into a portal above the app. Disable only if you're providing your
|
|
18
|
+
* own host/overlay.
|
|
19
|
+
* @defaultValue `true`
|
|
20
|
+
*/
|
|
21
|
+
portal?: boolean
|
|
22
|
+
} & Partial<QuickPreviewOptions> & {
|
|
23
|
+
/** The preview content. */
|
|
24
|
+
children: React.ReactNode
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A controlled, headless preview component — you own the `visible` state. Useful
|
|
29
|
+
* when you prefer a declarative component over the imperative
|
|
30
|
+
* {@link useQuickPreview} hook or the static `QuickPreview` handle.
|
|
31
|
+
*
|
|
32
|
+
* Exported as `QuickPreviewComponent`.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```tsx
|
|
36
|
+
* const [visible, setVisible] = useState(false)
|
|
37
|
+
* <QuickPreviewComponent visible={visible} onClose={() => setVisible(false)} variant="sheet">
|
|
38
|
+
* <Card />
|
|
39
|
+
* </QuickPreviewComponent>
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export function QuickPreview({
|
|
43
|
+
visible,
|
|
44
|
+
onClose,
|
|
45
|
+
portal = true,
|
|
46
|
+
children,
|
|
47
|
+
...opts
|
|
48
|
+
}: HeadlessQuickPreviewProps) {
|
|
49
|
+
if (!visible) return null
|
|
50
|
+
const node = (
|
|
51
|
+
<QuickPreviewRoot options={opts} onRequestClose={onClose}>
|
|
52
|
+
{children}
|
|
53
|
+
</QuickPreviewRoot>
|
|
54
|
+
)
|
|
55
|
+
return portal ? <Portal>{node}</Portal> : node
|
|
56
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,2 +1,24 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
// src/index.ts
|
|
2
|
+
|
|
3
|
+
// core
|
|
4
|
+
export { PreviewProvider } from './context'
|
|
5
|
+
export { useQuickPreview } from './useQuickPreview'
|
|
6
|
+
|
|
7
|
+
// static API (Gorhom-style)
|
|
8
|
+
export { QuickPreview } from './QuickPreviewAPI'
|
|
9
|
+
|
|
10
|
+
// headless component exported under a different name
|
|
11
|
+
export { QuickPreview as QuickPreviewComponent } from './headless/QuickPreview'
|
|
12
|
+
|
|
13
|
+
// types / helpers
|
|
14
|
+
export type {
|
|
15
|
+
QuickPreviewOptions,
|
|
16
|
+
QuickPreviewVariant,
|
|
17
|
+
QuickPreviewSize,
|
|
18
|
+
QuickPreviewController,
|
|
19
|
+
} from './types'
|
|
20
|
+
export { QuickPreviewScrollView } from './internal/components/QuickPreviewScrollView'
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
// Addons
|
|
24
|
+
export { QuickPreviewPressable } from './addons/QuickPreviewPressable'
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { StyleSheet, View } from 'react-native'
|
|
3
|
+
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'
|
|
4
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
5
|
+
import { QuickPreviewOptions } from '../types'
|
|
6
|
+
import { Backdrop } from './components/Backdrop'
|
|
7
|
+
import { SheetContainer } from './components/SheetContainer'
|
|
8
|
+
import { PopoverContainer } from './components/PopoverContainer'
|
|
9
|
+
|
|
10
|
+
export function QuickPreviewRoot({
|
|
11
|
+
options,
|
|
12
|
+
onRequestClose,
|
|
13
|
+
children,
|
|
14
|
+
}: {
|
|
15
|
+
options?: QuickPreviewOptions
|
|
16
|
+
onRequestClose: () => void
|
|
17
|
+
children: React.ReactNode
|
|
18
|
+
}) {
|
|
19
|
+
const { top, bottom } = useSafeAreaInsets()
|
|
20
|
+
const variant = options?.variant ?? 'popover'
|
|
21
|
+
const dismissOnBackdropPress = options?.dismissOnBackdropPress ?? true
|
|
22
|
+
|
|
23
|
+
const opacity = useSharedValue(0)
|
|
24
|
+
|
|
25
|
+
const cbs = React.useRef({
|
|
26
|
+
onOpenStart: options?.onOpenStart,
|
|
27
|
+
onOpenEnd: options?.onOpenEnd,
|
|
28
|
+
onCloseStart: options?.onCloseStart,
|
|
29
|
+
onCloseEnd: options?.onCloseEnd,
|
|
30
|
+
})
|
|
31
|
+
React.useEffect(() => {
|
|
32
|
+
cbs.current = {
|
|
33
|
+
onOpenStart: options?.onOpenStart,
|
|
34
|
+
onOpenEnd: options?.onOpenEnd,
|
|
35
|
+
onCloseStart: options?.onCloseStart,
|
|
36
|
+
onCloseEnd: options?.onCloseEnd,
|
|
37
|
+
}
|
|
38
|
+
}, [options?.onOpenStart, options?.onOpenEnd, options?.onCloseStart, options?.onCloseEnd])
|
|
39
|
+
|
|
40
|
+
React.useEffect(() => {
|
|
41
|
+
cbs.current.onOpenStart?.()
|
|
42
|
+
opacity.value = withTiming(1, { duration: 180 }, finished => {
|
|
43
|
+
if (finished) cbs.current.onOpenEnd?.()
|
|
44
|
+
})
|
|
45
|
+
return () => {
|
|
46
|
+
cbs.current.onCloseStart?.()
|
|
47
|
+
cbs.current.onCloseEnd?.()
|
|
48
|
+
}
|
|
49
|
+
// run once (mount-only effect)
|
|
50
|
+
}, [])
|
|
51
|
+
|
|
52
|
+
const containerStyle = useAnimatedStyle(() => ({ opacity: opacity.value }))
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<View style={StyleSheet.absoluteFill} pointerEvents="box-none" accessibilityViewIsModal>
|
|
56
|
+
<Animated.View style={[StyleSheet.absoluteFill, containerStyle]} pointerEvents="box-none">
|
|
57
|
+
<Backdrop
|
|
58
|
+
onPress={dismissOnBackdropPress ? onRequestClose : undefined}
|
|
59
|
+
dismissOnBackdropPress={dismissOnBackdropPress}
|
|
60
|
+
/>
|
|
61
|
+
{variant === 'sheet' ? (
|
|
62
|
+
<SheetContainer insets={{ top, bottom }} options={options} onRequestClose={onRequestClose}>
|
|
63
|
+
{children}
|
|
64
|
+
</SheetContainer>
|
|
65
|
+
) : (
|
|
66
|
+
<PopoverContainer insets={{ top, bottom }} options={options} onRequestClose={onRequestClose}>
|
|
67
|
+
{children}
|
|
68
|
+
</PopoverContainer>
|
|
69
|
+
)}
|
|
70
|
+
</Animated.View>
|
|
71
|
+
</View>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Pressable, StyleSheet } from 'react-native'
|
|
3
|
+
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'
|
|
4
|
+
|
|
5
|
+
export function Backdrop({
|
|
6
|
+
onPress,
|
|
7
|
+
dismissOnBackdropPress = true,
|
|
8
|
+
}: {
|
|
9
|
+
onPress?: () => void
|
|
10
|
+
dismissOnBackdropPress?: boolean
|
|
11
|
+
}) {
|
|
12
|
+
const opacity = useSharedValue(0)
|
|
13
|
+
React.useEffect(() => {
|
|
14
|
+
opacity.value = withTiming(1, { duration: 160 })
|
|
15
|
+
return () => { opacity.value = 0 }
|
|
16
|
+
}, [opacity])
|
|
17
|
+
|
|
18
|
+
const style = useAnimatedStyle(() => ({
|
|
19
|
+
backgroundColor: 'rgba(0,0,0,0.5)',
|
|
20
|
+
...StyleSheet.absoluteFillObject,
|
|
21
|
+
opacity: opacity.value,
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<Animated.View style={style} pointerEvents={dismissOnBackdropPress && onPress ? 'auto' : 'none'}>
|
|
26
|
+
{dismissOnBackdropPress && onPress ? (
|
|
27
|
+
<Pressable
|
|
28
|
+
style={StyleSheet.absoluteFill}
|
|
29
|
+
onPress={onPress}
|
|
30
|
+
accessibilityRole="button"
|
|
31
|
+
accessibilityLabel="Close preview"
|
|
32
|
+
/>
|
|
33
|
+
) : null}
|
|
34
|
+
</Animated.View>
|
|
35
|
+
)
|
|
36
|
+
}
|