insyd-bottom-sheet 0.2.0 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insyd-bottom-sheet",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "A smooth, physics-based bottom sheet for React Native — by Ishan Gupta. Works with Expo Go, bare workflow, and React Native CLI.",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -42,11 +42,16 @@
42
42
  "react": ">=18.0.0",
43
43
  "react-native": ">=0.71.0",
44
44
  "react-native-gesture-handler": ">=2.0.0",
45
- "react-native-reanimated": ">=3.0.0"
45
+ "react-native-reanimated": ">=3.0.0",
46
+ "react-native-worklets": "*"
47
+ },
48
+ "peerDependenciesMeta": {
49
+ "react-native-worklets": {
50
+ "optional": true
51
+ }
46
52
  },
47
53
  "devDependencies": {
48
54
  "@types/react": "^18.2.0",
49
- "@types/react-native": "^0.73.0",
50
55
  "typescript": "^5.3.0"
51
56
  }
52
57
  }
@@ -0,0 +1,101 @@
1
+ import React, {
2
+ createContext,
3
+ useContext,
4
+ useEffect,
5
+ useId,
6
+ useMemo,
7
+ useState,
8
+ } from "react";
9
+
10
+ /**
11
+ * In-app portal for SmoothSheet. Sheets render their content HERE (a single
12
+ * layer near the app root, in the app's own window) instead of inside a native
13
+ * <Modal>. That's the whole reason the keyboard behaves correctly on Android
14
+ * edge-to-edge builds: a native Modal is a separate window whose keyboard
15
+ * insets React Native can't observe, so the sheet never rose. Rendered in the
16
+ * main window, ordinary keyboard handling just works.
17
+ *
18
+ * Mount <SheetHost> once, high in the tree but BELOW every provider your sheet
19
+ * content needs (theme, safe-area, query client, etc.) and inside your
20
+ * GestureHandlerRootView — portaled content resolves context from where the
21
+ * host sits, not from where the sheet element was created.
22
+ */
23
+
24
+ type Registry = {
25
+ set: (id: string, node: React.ReactNode) => void;
26
+ remove: (id: string) => void;
27
+ };
28
+
29
+ const SheetPortalContext = createContext<Registry | null>(
30
+ null,
31
+ );
32
+
33
+ export function SheetHost({
34
+ children,
35
+ }: {
36
+ children: React.ReactNode;
37
+ }) {
38
+ const [nodes, setNodes] = useState<
39
+ Record<string, React.ReactNode>
40
+ >({});
41
+
42
+ const registry = useMemo<Registry>(
43
+ () => ({
44
+ set: (id, node) =>
45
+ setNodes((prev) => ({ ...prev, [id]: node })),
46
+ remove: (id) =>
47
+ setNodes((prev) => {
48
+ if (!(id in prev)) return prev;
49
+ const next = { ...prev };
50
+ delete next[id];
51
+ return next;
52
+ }),
53
+ }),
54
+ [],
55
+ );
56
+
57
+ return (
58
+ <SheetPortalContext.Provider value={registry}>
59
+ {children}
60
+ {Object.entries(nodes).map(([id, node]) => (
61
+ <React.Fragment key={id}>{node}</React.Fragment>
62
+ ))}
63
+ </SheetPortalContext.Provider>
64
+ );
65
+ }
66
+
67
+ let warnedNoHost = false;
68
+
69
+ /**
70
+ * Teleports its children into the nearest <SheetHost>. Updates on every render
71
+ * so live content stays fresh; unmounts its slot on cleanup. Falls back to
72
+ * rendering inline (with a one-time dev warning) if no host is mounted, so a
73
+ * missing provider degrades instead of crashing.
74
+ */
75
+ export function SheetPortal({
76
+ children,
77
+ }: {
78
+ children: React.ReactNode;
79
+ }) {
80
+ const registry = useContext(SheetPortalContext);
81
+ const id = useId();
82
+
83
+ useEffect(() => {
84
+ if (!registry) return;
85
+ registry.set(id, children);
86
+ return () => registry.remove(id);
87
+ });
88
+
89
+ if (!registry) {
90
+ if (__DEV__ && !warnedNoHost) {
91
+ warnedNoHost = true;
92
+ console.warn(
93
+ "[insyd-bottom-sheet] No <SheetHost> found — rendering the sheet inline. " +
94
+ "Wrap your app in <SheetHost> (inside GestureHandlerRootView, below your providers) " +
95
+ "for correct overlaying and keyboard behavior.",
96
+ );
97
+ }
98
+ return <>{children}</>;
99
+ }
100
+ return null;
101
+ }
@@ -1,17 +1,17 @@
1
- import React, { useEffect, useState } from "react";
1
+ import type React from "react";
2
+ import { useEffect, useRef, useState } from "react";
2
3
  import {
3
- Dimensions,
4
- KeyboardAvoidingView,
5
- Modal,
4
+ BackHandler,
5
+ Keyboard,
6
6
  Platform,
7
7
  Pressable,
8
8
  StyleSheet,
9
+ useWindowDimensions,
9
10
  View,
10
11
  } from "react-native";
11
12
  import {
12
13
  Gesture,
13
14
  GestureDetector,
14
- GestureHandlerRootView,
15
15
  } from "react-native-gesture-handler";
16
16
  import Animated, {
17
17
  runOnJS,
@@ -21,9 +21,13 @@ import Animated, {
21
21
  withTiming,
22
22
  } from "react-native-reanimated";
23
23
 
24
- const SCREEN_HEIGHT = Dimensions.get("window").height;
24
+ import { SheetPortal } from "./SheetHost";
25
25
 
26
- const DEFAULT_SPRING = { damping: 28, stiffness: 160, mass: 0.9 };
26
+ const DEFAULT_SPRING = {
27
+ damping: 28,
28
+ stiffness: 160,
29
+ mass: 0.9,
30
+ };
27
31
 
28
32
  export interface SpringConfig {
29
33
  damping?: number;
@@ -39,12 +43,12 @@ export interface SmoothSheetProps {
39
43
  /** Content rendered inside the sheet. */
40
44
  children: React.ReactNode;
41
45
  /**
42
- * Minimum height of the sheet as a fraction of screen height (0–1).
46
+ * Minimum height of the sheet as a fraction of window height (0–1).
43
47
  * @default 0.4
44
48
  */
45
49
  minHeightFraction?: number;
46
50
  /**
47
- * Maximum height of the sheet as a fraction of screen height (0–1).
51
+ * Maximum height of the sheet as a fraction of window height (0–1).
48
52
  * @default 0.97
49
53
  */
50
54
  maxHeightFraction?: number;
@@ -83,6 +87,18 @@ export interface SmoothSheetProps {
83
87
  * Merged over the default: { damping: 28, stiffness: 160, mass: 0.9 }
84
88
  */
85
89
  springConfig?: SpringConfig;
90
+ /**
91
+ * Extra padding applied below the sheet content, e.g. the bottom safe-area
92
+ * inset on gesture-nav Android devices / notched iPhones. Pass
93
+ * `useSafeAreaInsets().bottom` from the host app.
94
+ * @default 0
95
+ */
96
+ bottomInset?: number;
97
+ /**
98
+ * Accessibility label announced for the backdrop's dismiss action.
99
+ * @default "Close"
100
+ */
101
+ dismissAccessibilityLabel?: string;
86
102
  }
87
103
 
88
104
  export function SmoothSheet({
@@ -98,35 +114,113 @@ export function SmoothSheet({
98
114
  dismissThreshold = 80,
99
115
  dismissVelocityThreshold = 800,
100
116
  springConfig,
117
+ bottomInset = 0,
118
+ dismissAccessibilityLabel = "Close",
101
119
  }: SmoothSheetProps) {
102
120
  const spring = { ...DEFAULT_SPRING, ...springConfig };
103
121
 
104
- const translateY = useSharedValue(SCREEN_HEIGHT);
122
+ // Live window height — correct across rotation, split-screen, and foldables.
123
+ const { height: windowHeight } = useWindowDimensions();
124
+
125
+ // The sheet is *translated* above the keyboard, driven by global Keyboard
126
+ // events. Because the sheet now renders in the app's own window (via
127
+ // SheetPortal, not a native <Modal>), the focused TextInput lives in the same
128
+ // window these events observe — so this fires and reports the right height in
129
+ // Expo Go, dev clients, AND release builds, including Android 15+ forced
130
+ // edge-to-edge where a Modal's separate window used to break it.
131
+ const keyboardHeight = useSharedValue(0);
132
+
133
+ useEffect(() => {
134
+ const showEvent =
135
+ Platform.OS === "ios"
136
+ ? "keyboardWillShow"
137
+ : "keyboardDidShow";
138
+ const hideEvent =
139
+ Platform.OS === "ios"
140
+ ? "keyboardWillHide"
141
+ : "keyboardDidHide";
142
+ const showSub = Keyboard.addListener(showEvent, (e) => {
143
+ keyboardHeight.value = withTiming(
144
+ e.endCoordinates.height,
145
+ {
146
+ duration:
147
+ Platform.OS === "ios"
148
+ ? (e.duration ?? 250)
149
+ : 160,
150
+ },
151
+ );
152
+ });
153
+ const hideSub = Keyboard.addListener(hideEvent, (e) => {
154
+ keyboardHeight.value = withTiming(0, {
155
+ duration:
156
+ Platform.OS === "ios" ? (e.duration ?? 250) : 160,
157
+ });
158
+ });
159
+ return () => {
160
+ showSub.remove();
161
+ hideSub.remove();
162
+ };
163
+ // eslint-disable-next-line react-hooks/exhaustive-deps
164
+ }, []);
165
+
166
+ const translateY = useSharedValue(windowHeight);
105
167
  const backdropOpacity = useSharedValue(0);
106
- // Keep Modal mounted during the closing animation so the sheet can slide out
107
- const [modalVisible, setModalVisible] = useState(isVisible);
168
+ // Keep the sheet mounted through the closing animation so it can slide out.
169
+ const [mounted, setMounted] = useState(isVisible);
170
+ // Guards against double-dismiss (back button mashing, backdrop tap during close)
171
+ const isClosing = useRef(false);
108
172
 
109
173
  useEffect(() => {
110
174
  if (isVisible) {
111
- setModalVisible(true);
175
+ isClosing.current = false;
176
+ setMounted(true);
112
177
  translateY.value = withSpring(0, spring);
113
- backdropOpacity.value = withTiming(1, { duration: 250 });
114
- } else {
115
- translateY.value = withSpring(SCREEN_HEIGHT, spring);
116
- backdropOpacity.value = withTiming(0, { duration: 200 }, () => {
117
- runOnJS(setModalVisible)(false);
178
+ backdropOpacity.value = withTiming(1, {
179
+ duration: 250,
118
180
  });
181
+ } else {
182
+ translateY.value = withSpring(windowHeight, spring);
183
+ backdropOpacity.value = withTiming(
184
+ 0,
185
+ { duration: 200 },
186
+ (finished) => {
187
+ if (finished) runOnJS(setMounted)(false);
188
+ },
189
+ );
119
190
  }
120
191
  // eslint-disable-next-line react-hooks/exhaustive-deps
121
192
  }, [isVisible]);
122
193
 
123
194
  function dismiss() {
124
- translateY.value = withSpring(SCREEN_HEIGHT, spring);
125
- backdropOpacity.value = withTiming(0, { duration: 200 }, () => {
126
- runOnJS(onDismiss)();
127
- });
195
+ if (isClosing.current) return;
196
+ isClosing.current = true;
197
+ translateY.value = withSpring(windowHeight, spring);
198
+ backdropOpacity.value = withTiming(
199
+ 0,
200
+ { duration: 200 },
201
+ (finished) => {
202
+ if (finished) {
203
+ runOnJS(setMounted)(false);
204
+ runOnJS(onDismiss)();
205
+ }
206
+ },
207
+ );
128
208
  }
129
209
 
210
+ // Android hardware back closes the sheet (replaces Modal's onRequestClose).
211
+ useEffect(() => {
212
+ if (!mounted || Platform.OS !== "android") return;
213
+ const sub = BackHandler.addEventListener(
214
+ "hardwareBackPress",
215
+ () => {
216
+ dismiss();
217
+ return true;
218
+ },
219
+ );
220
+ return () => sub.remove();
221
+ // eslint-disable-next-line react-hooks/exhaustive-deps
222
+ }, [mounted]);
223
+
130
224
  const panGesture = Gesture.Pan()
131
225
  .onUpdate((e) => {
132
226
  if (e.translationY > 0) {
@@ -145,22 +239,25 @@ export function SmoothSheet({
145
239
  });
146
240
 
147
241
  const sheetStyle = useAnimatedStyle(() => ({
148
- transform: [{ translateY: translateY.value }],
242
+ transform: [
243
+ {
244
+ translateY: translateY.value - keyboardHeight.value,
245
+ },
246
+ ],
149
247
  }));
150
248
 
151
249
  const backdropStyle = useAnimatedStyle(() => ({
152
250
  opacity: backdropOpacity.value,
153
251
  }));
154
252
 
253
+ if (!mounted) return null;
254
+
155
255
  return (
156
- <Modal
157
- visible={modalVisible}
158
- transparent
159
- animationType="none"
160
- statusBarTranslucent
161
- onRequestClose={dismiss}
162
- >
163
- <GestureHandlerRootView style={StyleSheet.absoluteFill}>
256
+ <SheetPortal>
257
+ <View
258
+ style={StyleSheet.absoluteFill}
259
+ pointerEvents="box-none"
260
+ >
164
261
  {/* Backdrop */}
165
262
  <Animated.View
166
263
  style={[
@@ -169,7 +266,12 @@ export function SmoothSheet({
169
266
  backdropStyle,
170
267
  ]}
171
268
  >
172
- <Pressable style={StyleSheet.absoluteFill} onPress={dismiss} />
269
+ <Pressable
270
+ style={StyleSheet.absoluteFill}
271
+ onPress={dismiss}
272
+ accessibilityRole="button"
273
+ accessibilityLabel={dismissAccessibilityLabel}
274
+ />
173
275
  </Animated.View>
174
276
 
175
277
  {/* Sheet */}
@@ -180,32 +282,33 @@ export function SmoothSheet({
180
282
  borderTopLeftRadius: borderRadius,
181
283
  borderTopRightRadius: borderRadius,
182
284
  backgroundColor,
183
- minHeight: SCREEN_HEIGHT * minHeightFraction,
184
- maxHeight: SCREEN_HEIGHT * maxHeightFraction,
285
+ minHeight: windowHeight * minHeightFraction,
286
+ maxHeight: windowHeight * maxHeightFraction,
287
+ paddingBottom: bottomInset,
185
288
  },
186
289
  sheetStyle,
187
290
  ]}
188
291
  pointerEvents="box-none"
292
+ accessibilityViewIsModal
189
293
  >
190
294
  {/* Drag handle */}
191
295
  <GestureDetector gesture={panGesture}>
192
296
  <View style={styles.handleArea}>
193
297
  <View
194
- style={[styles.handle, { backgroundColor: handleColor }]}
298
+ style={[
299
+ styles.handle,
300
+ { backgroundColor: handleColor },
301
+ ]}
195
302
  />
196
303
  </View>
197
304
  </GestureDetector>
198
305
 
199
- {/* Content */}
200
- <KeyboardAvoidingView
201
- behavior={Platform.OS === "ios" ? "padding" : "height"}
202
- style={styles.content}
203
- >
204
- {children}
205
- </KeyboardAvoidingView>
306
+ {/* Content — keyboard avoidance is handled by translating the whole
307
+ sheet (see sheetStyle); no KeyboardAvoidingView needed. */}
308
+ <View style={styles.content}>{children}</View>
206
309
  </Animated.View>
207
- </GestureHandlerRootView>
208
- </Modal>
310
+ </View>
311
+ </SheetPortal>
209
312
  );
210
313
  }
211
314
 
package/src/index.ts CHANGED
@@ -1,2 +1,6 @@
1
+ export { SheetHost } from "./SheetHost";
2
+ export type {
3
+ SmoothSheetProps,
4
+ SpringConfig,
5
+ } from "./SmoothSheet";
1
6
  export { SmoothSheet } from "./SmoothSheet";
2
- export type { SmoothSheetProps, SpringConfig } from "./SmoothSheet";