@mrmeg/expo-ui 0.7.2 → 0.7.3
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/dist/components/BottomSheet.d.ts +92 -16
- package/dist/components/BottomSheet.js +203 -444
- package/dist/components/Dialog.js +16 -8
- package/dist/components/SegmentedControl.d.ts +52 -0
- package/dist/components/SegmentedControl.js +25 -0
- package/dist/components/Slider.d.ts +23 -3
- package/dist/components/Slider.js +26 -147
- package/dist/components/Tabs.d.ts +1 -1
- package/dist/components/Tabs.js +10 -2
- package/dist/components/TextInput.d.ts +1 -1
- package/dist/components/TextInput.js +121 -2
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/package.json +2 -1
- package/dist/components/BottomSheetKeyboard.d.ts +0 -7
- package/dist/components/BottomSheetKeyboard.js +0 -39
|
@@ -1,48 +1,12 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import React, { createContext, use, useCallback, useEffect,
|
|
3
|
-
import { View, Pressable,
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { Pressable as SlotPressable } from "@rn-primitives/slot";
|
|
2
|
+
import React, { createContext, use, useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { View, Pressable, ScrollView, Platform, useWindowDimensions, } from "react-native";
|
|
4
|
+
import { BottomSheet as NativeBottomSheet } from "@expo/ui/community/bottom-sheet";
|
|
5
|
+
import { useSafeAreaInsets, initialWindowMetrics } from "react-native-safe-area-context";
|
|
7
6
|
import { useTheme } from "../hooks/useTheme.js";
|
|
8
|
-
import { useDimensions } from "../hooks/useDimensions.js";
|
|
9
7
|
import { spacing } from "../constants/spacing.js";
|
|
10
|
-
import { shouldUseNativeDriver } from "../lib/animations.js";
|
|
11
8
|
import { TextColorContext, TextClassContext } from "./StyledText.context";
|
|
12
|
-
import {
|
|
13
|
-
import { BottomSheetKeyboardController, useBottomSheetKeyboardAnimation, } from "./BottomSheetKeyboard.js";
|
|
14
|
-
/**
|
|
15
|
-
* BottomSheet Component with Sub-components
|
|
16
|
-
*
|
|
17
|
-
* A sliding bottom sheet overlay with snap points, swipe gestures,
|
|
18
|
-
* and compound component pattern matching Drawer.tsx.
|
|
19
|
-
*
|
|
20
|
-
* @example
|
|
21
|
-
* ```tsx
|
|
22
|
-
* <BottomSheet>
|
|
23
|
-
* <BottomSheet.Trigger asChild>
|
|
24
|
-
* <Button>Open Sheet</Button>
|
|
25
|
-
* </BottomSheet.Trigger>
|
|
26
|
-
* <BottomSheet.Content>
|
|
27
|
-
* <BottomSheet.Handle />
|
|
28
|
-
* <BottomSheet.Header>
|
|
29
|
-
* <SansSerifBoldText>Title</SansSerifBoldText>
|
|
30
|
-
* </BottomSheet.Header>
|
|
31
|
-
* <BottomSheet.Body>
|
|
32
|
-
* <SansSerifText>Content here</SansSerifText>
|
|
33
|
-
* </BottomSheet.Body>
|
|
34
|
-
* <BottomSheet.Footer>
|
|
35
|
-
* <Button>Action</Button>
|
|
36
|
-
* </BottomSheet.Footer>
|
|
37
|
-
* </BottomSheet.Content>
|
|
38
|
-
* </BottomSheet>
|
|
39
|
-
* ```
|
|
40
|
-
*/
|
|
41
|
-
// Platform-specific overlay wrapper
|
|
42
|
-
const FullWindowOverlay = Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment;
|
|
43
|
-
// Floor for the keyboard-avoiding height shrink, so a tall keyboard on a short
|
|
44
|
-
// screen can't collapse the sheet to nothing.
|
|
45
|
-
const MIN_KEYBOARD_SHEET_HEIGHT = 220;
|
|
9
|
+
import { Icon } from "./Icon.js";
|
|
46
10
|
// ============================================================================
|
|
47
11
|
// Context
|
|
48
12
|
// ============================================================================
|
|
@@ -54,103 +18,102 @@ function useBottomSheetContext() {
|
|
|
54
18
|
}
|
|
55
19
|
return context;
|
|
56
20
|
}
|
|
57
|
-
const DragContext = createContext(null);
|
|
58
|
-
function BottomSheetPanel({ accessibilityViewIsModal, animatedHeight, animatedBottom, children, panHandlers, sheetStyle, styleOverride, translateY, ...props }) {
|
|
59
|
-
return (_jsx(Animated.View, { style: [
|
|
60
|
-
sheetStyle,
|
|
61
|
-
// Layout overrides (JS-driven) must come after sheetStyle so they win.
|
|
62
|
-
animatedBottom ? { bottom: animatedBottom } : undefined,
|
|
63
|
-
animatedHeight ? { height: animatedHeight } : undefined,
|
|
64
|
-
// Open/close + drag (native-driven) live on transform, a separate node.
|
|
65
|
-
{ transform: [{ translateY }] },
|
|
66
|
-
styleOverride && typeof styleOverride !== "function"
|
|
67
|
-
? StyleSheet.flatten(styleOverride)
|
|
68
|
-
: undefined,
|
|
69
|
-
], accessibilityViewIsModal: accessibilityViewIsModal, ...panHandlers, ...props, children: children }));
|
|
70
|
-
}
|
|
71
21
|
/**
|
|
72
|
-
*
|
|
22
|
+
* Safe-area insets for content *inside* the sheet.
|
|
73
23
|
*
|
|
74
|
-
* The
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* the
|
|
78
|
-
*
|
|
79
|
-
* header, focused input, and footer all visible.
|
|
80
|
-
*
|
|
81
|
-
* This drives layout props (`bottom`/`height`) with a JS-driven keyboard value,
|
|
82
|
-
* intentionally separate from the native-driven open/close `translateY` — a
|
|
83
|
-
* single Animated.Value can't feed both a native transform and a JS layout
|
|
84
|
-
* prop, but distinct nodes on the same view can.
|
|
24
|
+
* The native sheet (SwiftUI `.sheet()` / Material `ModalBottomSheet`) is
|
|
25
|
+
* presented outside the React tree's `SafeAreaProvider`, so `useSafeAreaInsets()`
|
|
26
|
+
* reads all-zero in here. Fall back to `initialWindowMetrics` — the same trick
|
|
27
|
+
* the app uses for its full-screen Modals — so bottom padding actually clears
|
|
28
|
+
* the home indicator and the last row of a scroll body is reachable.
|
|
85
29
|
*/
|
|
86
|
-
function
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
30
|
+
function useSheetInsets() {
|
|
31
|
+
const insets = useSafeAreaInsets();
|
|
32
|
+
const fallback = initialWindowMetrics?.insets;
|
|
33
|
+
return {
|
|
34
|
+
top: insets.top || fallback?.top || 0,
|
|
35
|
+
bottom: insets.bottom || fallback?.bottom || 0,
|
|
36
|
+
left: insets.left || fallback?.left || 0,
|
|
37
|
+
right: insets.right || fallback?.right || 0,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Interactive (pull-down / backdrop) dismiss is off only when the consumer
|
|
42
|
+
* explicitly turned it off (`closeOnBackdropPress={false}`). Scrollable bodies
|
|
43
|
+
* keep swipe-to-dismiss: the native sheet coordinates the gesture itself (a pull
|
|
44
|
+
* at the top of the scroll view dismisses; elsewhere it scrolls), so there's no
|
|
45
|
+
* conflict to work around once the content column is height-bounded.
|
|
46
|
+
*/
|
|
47
|
+
function useDismissDisabled() {
|
|
48
|
+
const { closeOnBackdropPress } = useBottomSheetContext();
|
|
49
|
+
return !closeOnBackdropPress;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Whether to surface an explicit close affordance (the auto X). True when:
|
|
53
|
+
* - swipe/backdrop dismiss is off (otherwise there'd be no way to close), or
|
|
54
|
+
* - the body scrolls — so a long sheet always has a one-tap close without
|
|
55
|
+
* hunting for a footer button or pulling the sheet down.
|
|
56
|
+
*/
|
|
57
|
+
function useShowClose() {
|
|
58
|
+
const { scrollable } = useBottomSheetContext();
|
|
59
|
+
return useDismissDisabled() || scrollable;
|
|
103
60
|
}
|
|
104
61
|
// ============================================================================
|
|
105
|
-
//
|
|
62
|
+
// Auto close button — shown when interactive dismiss is off
|
|
106
63
|
// ============================================================================
|
|
107
|
-
function
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
64
|
+
function SheetCloseButton({ style }) {
|
|
65
|
+
const { onOpenChange } = useBottomSheetContext();
|
|
66
|
+
const { theme } = useTheme();
|
|
67
|
+
return (_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Close", onPress: () => onOpenChange(false), hitSlop: spacing.sm, style: ({ pressed }) => [
|
|
68
|
+
{
|
|
69
|
+
width: spacing.xl,
|
|
70
|
+
height: spacing.xl,
|
|
71
|
+
borderRadius: spacing.xl / 2,
|
|
72
|
+
alignItems: "center",
|
|
73
|
+
justifyContent: "center",
|
|
74
|
+
// Higher-contrast than `muted`: a solid `secondary` fill with a
|
|
75
|
+
// bordered edge so the control reads clearly against the card, and a
|
|
76
|
+
// full-strength `foreground` glyph instead of the faint muted one.
|
|
77
|
+
backgroundColor: theme.colors.secondary,
|
|
78
|
+
borderWidth: 1,
|
|
79
|
+
borderColor: theme.colors.border,
|
|
80
|
+
opacity: pressed ? 0.7 : 1,
|
|
81
|
+
},
|
|
82
|
+
Platform.OS === "web" && { cursor: "pointer" },
|
|
83
|
+
style,
|
|
84
|
+
], children: _jsx(Icon, { name: "x", size: 22, color: "text" }) }));
|
|
120
85
|
}
|
|
121
86
|
// ============================================================================
|
|
122
|
-
//
|
|
87
|
+
// Root
|
|
123
88
|
// ============================================================================
|
|
124
|
-
function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen = false, snapPoints
|
|
125
|
-
const [internalOpen,
|
|
89
|
+
function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen = false, snapPoints = ["50%"], closeOnBackdropPress = true, children, }) {
|
|
90
|
+
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
|
91
|
+
const [scrollable, setScrollable] = useState(false);
|
|
92
|
+
const [hasHeader, setHasHeader] = useState(false);
|
|
93
|
+
const [hasFooter, setHasFooter] = useState(false);
|
|
126
94
|
const isControlled = controlledOpen !== undefined;
|
|
127
95
|
const open = isControlled ? controlledOpen : internalOpen;
|
|
128
|
-
// useDimensions reacts to rotation / split-screen, unlike Dimensions.get.
|
|
129
|
-
const { height: screenHeight } = useDimensions();
|
|
130
|
-
const snapPoints = resolveSnapPoints(rawSnapPoints, screenHeight);
|
|
131
|
-
const toggle = () => {
|
|
132
|
-
if (isControlled) {
|
|
133
|
-
controlledOnOpenChange?.(!controlledOpen);
|
|
134
|
-
}
|
|
135
|
-
else {
|
|
136
|
-
dispatch({ type: "TOGGLE" });
|
|
137
|
-
}
|
|
138
|
-
};
|
|
139
96
|
const onOpenChange = (newOpen) => {
|
|
140
97
|
if (isControlled) {
|
|
141
98
|
controlledOnOpenChange?.(newOpen);
|
|
142
99
|
}
|
|
143
100
|
else {
|
|
144
|
-
|
|
101
|
+
setInternalOpen(newOpen);
|
|
145
102
|
}
|
|
146
103
|
};
|
|
104
|
+
const toggle = () => onOpenChange(!open);
|
|
147
105
|
const contextValue = {
|
|
148
106
|
open,
|
|
149
107
|
onOpenChange,
|
|
150
108
|
toggle,
|
|
151
109
|
snapPoints,
|
|
152
|
-
currentSnapIndex: 0,
|
|
153
110
|
closeOnBackdropPress,
|
|
111
|
+
scrollable,
|
|
112
|
+
setScrollable,
|
|
113
|
+
hasHeader,
|
|
114
|
+
setHasHeader,
|
|
115
|
+
hasFooter,
|
|
116
|
+
setHasFooter,
|
|
154
117
|
};
|
|
155
118
|
return (_jsx(BottomSheetContext.Provider, { value: contextValue, children: children }));
|
|
156
119
|
}
|
|
@@ -170,346 +133,156 @@ function BottomSheetTrigger({ asChild, children, style: styleOverride }) {
|
|
|
170
133
|
],
|
|
171
134
|
});
|
|
172
135
|
}
|
|
173
|
-
return (_jsx(Pressable, { onPress: handlePress, style: [
|
|
174
|
-
Platform.OS === "web" && { cursor: "pointer" },
|
|
175
|
-
styleOverride,
|
|
176
|
-
], children: children }));
|
|
136
|
+
return (_jsx(Pressable, { onPress: handlePress, style: [Platform.OS === "web" && { cursor: "pointer" }, styleOverride], children: children }));
|
|
177
137
|
}
|
|
178
138
|
// ============================================================================
|
|
179
|
-
// Content
|
|
139
|
+
// Content — renders the native sheet, wraps children in themed flex column
|
|
180
140
|
// ============================================================================
|
|
181
|
-
function BottomSheetContent({
|
|
182
|
-
|
|
183
|
-
|
|
141
|
+
function BottomSheetContent({
|
|
142
|
+
// Accepted-but-ignored ergonomics props (platform owns these behaviors):
|
|
143
|
+
swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDrag: _dismissKeyboardOnDrag, style: styleOverride, children, }) {
|
|
144
|
+
const { open, onOpenChange, snapPoints, hasHeader } = useBottomSheetContext();
|
|
184
145
|
const { theme } = useTheme();
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const handleDragRelease = useCallback((dragDistance, velocity) => {
|
|
209
|
-
const visibleHeight = currentHeightRef.current - dragDistance;
|
|
210
|
-
if (velocity > velocityThreshold / 1000 || dragDistance > currentHeightRef.current * 0.4) {
|
|
211
|
-
const lowerSnaps = snapPoints.filter((s) => s < currentHeightRef.current);
|
|
212
|
-
if (lowerSnaps.length > 0 && dragDistance < currentHeightRef.current * 0.4) {
|
|
213
|
-
const nextSnap = lowerSnaps[lowerSnaps.length - 1];
|
|
214
|
-
currentHeightRef.current = nextSnap;
|
|
215
|
-
const targetY = maxHeight - nextSnap;
|
|
216
|
-
Animated.parallel([
|
|
217
|
-
Animated.spring(translateY, {
|
|
218
|
-
toValue: targetY,
|
|
219
|
-
tension: 65,
|
|
220
|
-
friction: 11,
|
|
221
|
-
useNativeDriver: shouldUseNativeDriver,
|
|
222
|
-
}),
|
|
223
|
-
Animated.timing(backdropOpacity, {
|
|
224
|
-
toValue: 1,
|
|
225
|
-
duration: 150,
|
|
226
|
-
useNativeDriver: shouldUseNativeDriver,
|
|
227
|
-
}),
|
|
228
|
-
]).start();
|
|
229
|
-
}
|
|
230
|
-
else {
|
|
231
|
-
Animated.parallel([
|
|
232
|
-
Animated.timing(translateY, {
|
|
233
|
-
toValue: closedPosition,
|
|
234
|
-
duration: 200,
|
|
235
|
-
useNativeDriver: shouldUseNativeDriver,
|
|
236
|
-
}),
|
|
237
|
-
Animated.timing(backdropOpacity, {
|
|
238
|
-
toValue: 0,
|
|
239
|
-
duration: 200,
|
|
240
|
-
useNativeDriver: shouldUseNativeDriver,
|
|
241
|
-
}),
|
|
242
|
-
]).start(() => {
|
|
243
|
-
onOpenChange(false);
|
|
244
|
-
setIsVisible(false);
|
|
245
|
-
});
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
else {
|
|
249
|
-
let nearestSnap = snapPoints[0];
|
|
250
|
-
let minDistance = Infinity;
|
|
251
|
-
for (const snap of snapPoints) {
|
|
252
|
-
const dist = Math.abs(visibleHeight - snap);
|
|
253
|
-
if (dist < minDistance) {
|
|
254
|
-
minDistance = dist;
|
|
255
|
-
nearestSnap = snap;
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
currentHeightRef.current = nearestSnap;
|
|
259
|
-
const targetY = maxHeight - nearestSnap;
|
|
260
|
-
Animated.parallel([
|
|
261
|
-
Animated.spring(translateY, {
|
|
262
|
-
toValue: targetY,
|
|
263
|
-
tension: 65,
|
|
264
|
-
friction: 11,
|
|
265
|
-
useNativeDriver: shouldUseNativeDriver,
|
|
266
|
-
}),
|
|
267
|
-
Animated.timing(backdropOpacity, {
|
|
268
|
-
toValue: 1,
|
|
269
|
-
duration: 150,
|
|
270
|
-
useNativeDriver: shouldUseNativeDriver,
|
|
271
|
-
}),
|
|
272
|
-
]).start();
|
|
273
|
-
}
|
|
274
|
-
}, [snapPoints, maxHeight, closedPosition, translateY, backdropOpacity, onOpenChange, velocityThreshold]);
|
|
275
|
-
const handleDragMove = useCallback((dy) => {
|
|
276
|
-
// Base offset: where the sheet sits at the current snap point
|
|
277
|
-
// translateY=0 means top snap (maxHeight visible), higher values = further down
|
|
278
|
-
const baseOffset = maxHeight - currentHeightRef.current;
|
|
279
|
-
const newY = Math.max(baseOffset, baseOffset + dy);
|
|
280
|
-
translateY.setValue(newY);
|
|
281
|
-
// Progress: 1 = fully at current snap, 0 = fully closed
|
|
282
|
-
const dragFromBase = newY - baseOffset;
|
|
283
|
-
const progress = 1 - dragFromBase / currentHeightRef.current;
|
|
284
|
-
backdropOpacity.setValue(Math.max(0, progress));
|
|
285
|
-
}, [translateY, backdropOpacity, maxHeight]);
|
|
286
|
-
const dismissKeyboardForDrag = useCallback(() => {
|
|
287
|
-
if (Platform.OS !== "web" && dismissKeyboardOnDrag) {
|
|
288
|
-
void BottomSheetKeyboardController.dismiss();
|
|
289
|
-
}
|
|
290
|
-
}, [dismissKeyboardOnDrag]);
|
|
291
|
-
if (open !== lastOpenRef.current) {
|
|
292
|
-
const previousOpen = lastOpenRef.current;
|
|
293
|
-
lastOpenRef.current = open;
|
|
294
|
-
if (runningAnimationRef.current) {
|
|
295
|
-
runningAnimationRef.current.stop();
|
|
296
|
-
runningAnimationRef.current = null;
|
|
297
|
-
}
|
|
298
|
-
if (open) {
|
|
299
|
-
if (!isVisible) {
|
|
300
|
-
setIsVisible(true);
|
|
301
|
-
}
|
|
302
|
-
currentSnapRef.current = snapPoints.length - 1;
|
|
303
|
-
currentHeightRef.current = maxHeight;
|
|
304
|
-
if (previousOpen === null) {
|
|
305
|
-
translateY.setValue(closedPosition);
|
|
306
|
-
backdropOpacity.setValue(0);
|
|
307
|
-
}
|
|
308
|
-
const animation = Animated.parallel([
|
|
309
|
-
Animated.spring(translateY, {
|
|
310
|
-
toValue: 0,
|
|
311
|
-
tension: 65,
|
|
312
|
-
friction: 11,
|
|
313
|
-
useNativeDriver: shouldUseNativeDriver,
|
|
314
|
-
}),
|
|
315
|
-
Animated.timing(backdropOpacity, {
|
|
316
|
-
toValue: 1,
|
|
317
|
-
duration: 200,
|
|
318
|
-
useNativeDriver: shouldUseNativeDriver,
|
|
319
|
-
}),
|
|
320
|
-
]);
|
|
321
|
-
runningAnimationRef.current = animation;
|
|
322
|
-
animation.start(({ finished }) => {
|
|
323
|
-
if (finished)
|
|
324
|
-
runningAnimationRef.current = null;
|
|
325
|
-
});
|
|
326
|
-
}
|
|
327
|
-
else if (previousOpen === true) {
|
|
328
|
-
const animation = Animated.parallel([
|
|
329
|
-
Animated.timing(translateY, {
|
|
330
|
-
toValue: closedPosition,
|
|
331
|
-
duration: 200,
|
|
332
|
-
useNativeDriver: shouldUseNativeDriver,
|
|
333
|
-
}),
|
|
334
|
-
Animated.timing(backdropOpacity, {
|
|
335
|
-
toValue: 0,
|
|
336
|
-
duration: 200,
|
|
337
|
-
useNativeDriver: shouldUseNativeDriver,
|
|
338
|
-
}),
|
|
339
|
-
]);
|
|
340
|
-
runningAnimationRef.current = animation;
|
|
341
|
-
animation.start(({ finished }) => {
|
|
342
|
-
runningAnimationRef.current = null;
|
|
343
|
-
if (finished)
|
|
344
|
-
setIsVisible(false);
|
|
345
|
-
});
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
const panResponder = useMemo(() => Platform.OS !== "web" && swipeEnabled
|
|
349
|
-
? PanResponder.create({
|
|
350
|
-
onStartShouldSetPanResponder: () => false,
|
|
351
|
-
onMoveShouldSetPanResponder: (_evt, gestureState) => {
|
|
352
|
-
const isVertical = Math.abs(gestureState.dy) > Math.abs(gestureState.dx);
|
|
353
|
-
const isSignificant = Math.abs(gestureState.dy) > 10;
|
|
354
|
-
const isDownward = gestureState.dy > 0;
|
|
355
|
-
return isVertical && isSignificant && isDownward;
|
|
356
|
-
},
|
|
357
|
-
onPanResponderGrant: dismissKeyboardForDrag,
|
|
358
|
-
onPanResponderMove: (_evt, gestureState) => {
|
|
359
|
-
handleDragMove(gestureState.dy);
|
|
360
|
-
},
|
|
361
|
-
onPanResponderRelease: (_evt, gestureState) => {
|
|
362
|
-
handleDragRelease(Math.max(0, gestureState.dy), gestureState.vy);
|
|
363
|
-
},
|
|
364
|
-
})
|
|
365
|
-
: null, [dismissKeyboardForDrag, handleDragMove, handleDragRelease, swipeEnabled]);
|
|
366
|
-
const dragContextValue = Platform.OS === "web" && swipeEnabled
|
|
367
|
-
? {
|
|
368
|
-
onDragMove: handleDragMove,
|
|
369
|
-
onDragEnd: (dy, velocity) => {
|
|
370
|
-
handleDragRelease(Math.max(0, dy), velocity);
|
|
371
|
-
},
|
|
372
|
-
}
|
|
373
|
-
: null;
|
|
374
|
-
const handleBackdropPress = () => {
|
|
375
|
-
if (closeOnBackdropPress) {
|
|
146
|
+
const dismissDisabled = useDismissDisabled();
|
|
147
|
+
const showClose = useShowClose();
|
|
148
|
+
const { height: winH } = useWindowDimensions();
|
|
149
|
+
// Boolean open → native imperative index. Open at the highest snap point so
|
|
150
|
+
// the sheet starts fully expanded; -1 keeps it closed.
|
|
151
|
+
const index = open ? snapPoints.length - 1 : -1;
|
|
152
|
+
// The native RN-in-SwiftUI host does NOT clamp our RN column to the sheet's
|
|
153
|
+
// detent height — it lays the column out at its full intrinsic content height,
|
|
154
|
+
// SwiftUI then clips it at the sheet edge (footer/tail fall off-screen), and
|
|
155
|
+
// `flex:1` never gets a definite parent height so the ScrollView can't bound
|
|
156
|
+
// and scroll. Cap the column to the expanded detent height (highest snap
|
|
157
|
+
// point) so `flex:1` resolves and the Body becomes scrollable.
|
|
158
|
+
const expandedSnap = snapPoints[snapPoints.length - 1];
|
|
159
|
+
const detentHeight = typeof expandedSnap === "number"
|
|
160
|
+
? expandedSnap
|
|
161
|
+
: (parseFloat(expandedSnap) / 100) * winH;
|
|
162
|
+
// When there's no Header to host the X (so a close affordance is wanted —
|
|
163
|
+
// dismiss is off, or the body scrolls), float one over the top-right corner.
|
|
164
|
+
// A Header, when present, renders its own (see BottomSheetHeader).
|
|
165
|
+
const showFloatingClose = showClose && !hasHeader;
|
|
166
|
+
const handleChange = (newIndex) => {
|
|
167
|
+
// Native fires onChange(-1) on dismiss (swipe / backdrop / back button).
|
|
168
|
+
if (newIndex < 0 && open)
|
|
376
169
|
onOpenChange(false);
|
|
377
|
-
}
|
|
378
|
-
};
|
|
379
|
-
if (!isVisible && !open) {
|
|
380
|
-
return null;
|
|
381
|
-
}
|
|
382
|
-
const sheetStyle = {
|
|
383
|
-
position: "absolute",
|
|
384
|
-
left: 0,
|
|
385
|
-
right: 0,
|
|
386
|
-
bottom: 0,
|
|
387
|
-
height: maxHeight,
|
|
388
|
-
backgroundColor: theme.colors.card,
|
|
389
|
-
borderTopLeftRadius: spacing.radiusXl,
|
|
390
|
-
borderTopRightRadius: spacing.radiusXl,
|
|
391
|
-
borderTopWidth: 1,
|
|
392
|
-
borderLeftWidth: 1,
|
|
393
|
-
borderRightWidth: 1,
|
|
394
|
-
borderColor: theme.colors.border,
|
|
395
|
-
...(Platform.OS === "web" && { zIndex: 51 }),
|
|
396
170
|
};
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
171
|
+
return (_jsx(NativeBottomSheet, { index: index, snapPoints: snapPoints, enablePanDownToClose: !dismissDisabled, onChange: handleChange, onClose: () => {
|
|
172
|
+
if (open)
|
|
173
|
+
onOpenChange(false);
|
|
174
|
+
},
|
|
175
|
+
// Themes the scrim/background on web (vaul) and Android (containerColor).
|
|
176
|
+
backgroundStyle: { backgroundColor: theme.colors.card }, children: _jsx(TextColorContext.Provider, { value: theme.colors.foreground, children: _jsx(TextClassContext.Provider, { value: "", children: _jsxs(View, { style: [
|
|
177
|
+
{
|
|
178
|
+
flex: 1,
|
|
179
|
+
maxHeight: detentHeight,
|
|
180
|
+
// Themes the content surface across all platforms regardless of
|
|
181
|
+
// native sheet chrome.
|
|
182
|
+
backgroundColor: theme.colors.card,
|
|
183
|
+
},
|
|
184
|
+
styleOverride,
|
|
185
|
+
], children: [children, showFloatingClose && (_jsx(SheetCloseButton, { style: {
|
|
186
|
+
// The sheet card sits below the status bar, so no top inset
|
|
187
|
+
// is needed — just clear the sheet's own top edge / handle.
|
|
188
|
+
position: "absolute",
|
|
189
|
+
top: spacing.md,
|
|
190
|
+
right: spacing.md,
|
|
191
|
+
} }))] }) }) }) }));
|
|
415
192
|
}
|
|
416
193
|
// ============================================================================
|
|
417
|
-
// Handle
|
|
194
|
+
// Handle — no-op (the platform draws the drag indicator)
|
|
418
195
|
// ============================================================================
|
|
419
|
-
function BottomSheetHandle(
|
|
420
|
-
|
|
421
|
-
const dragCtx = use(DragContext);
|
|
422
|
-
// Web pointer-event drag — attaches move/up listeners on document
|
|
423
|
-
const dragStartY = useRef(0);
|
|
424
|
-
const lastTimestamp = useRef(0);
|
|
425
|
-
const lastDy = useRef(0);
|
|
426
|
-
const isDragging = useRef(false);
|
|
427
|
-
useEffect(() => {
|
|
428
|
-
if (Platform.OS !== "web" || !dragCtx)
|
|
429
|
-
return;
|
|
430
|
-
const onPointerMove = (e) => {
|
|
431
|
-
if (!isDragging.current)
|
|
432
|
-
return;
|
|
433
|
-
const dy = e.clientY - dragStartY.current;
|
|
434
|
-
const now = Date.now();
|
|
435
|
-
const dt = (now - lastTimestamp.current) / 1000;
|
|
436
|
-
lastTimestamp.current = now;
|
|
437
|
-
lastDy.current = dy;
|
|
438
|
-
dragCtx.onDragMove(dy);
|
|
439
|
-
// Store velocity data on the event for release calculation
|
|
440
|
-
isDragging._lastDt = dt;
|
|
441
|
-
isDragging._lastDy = dy;
|
|
442
|
-
};
|
|
443
|
-
const onPointerUp = (e) => {
|
|
444
|
-
if (!isDragging.current)
|
|
445
|
-
return;
|
|
446
|
-
isDragging.current = false;
|
|
447
|
-
document.body.style.cursor = "";
|
|
448
|
-
document.body.style.userSelect = "";
|
|
449
|
-
const dy = e.clientY - dragStartY.current;
|
|
450
|
-
const dt = isDragging._lastDt || 0.016;
|
|
451
|
-
const prevDy = isDragging._lastDy || 0;
|
|
452
|
-
const velocity = dt > 0 ? (dy - prevDy) / dt / 1000 : 0;
|
|
453
|
-
dragCtx.onDragEnd(dy, velocity);
|
|
454
|
-
};
|
|
455
|
-
document.addEventListener("pointermove", onPointerMove);
|
|
456
|
-
document.addEventListener("pointerup", onPointerUp);
|
|
457
|
-
return () => {
|
|
458
|
-
document.removeEventListener("pointermove", onPointerMove);
|
|
459
|
-
document.removeEventListener("pointerup", onPointerUp);
|
|
460
|
-
};
|
|
461
|
-
}, [dragCtx]);
|
|
462
|
-
const handlePointerDown = useCallback((e) => {
|
|
463
|
-
if (Platform.OS !== "web" || !dragCtx)
|
|
464
|
-
return;
|
|
465
|
-
isDragging.current = true;
|
|
466
|
-
dragStartY.current = e.nativeEvent?.clientY ?? e.clientY;
|
|
467
|
-
lastTimestamp.current = Date.now();
|
|
468
|
-
lastDy.current = 0;
|
|
469
|
-
document.body.style.cursor = "grabbing";
|
|
470
|
-
document.body.style.userSelect = "none";
|
|
471
|
-
}, [dragCtx]);
|
|
472
|
-
return (_jsx(View, { style: [
|
|
473
|
-
staticStyles.handleContainer,
|
|
474
|
-
Platform.OS === "web" && { cursor: "grab" },
|
|
475
|
-
style,
|
|
476
|
-
], ...(Platform.OS === "web" && dragCtx
|
|
477
|
-
? { onPointerDown: handlePointerDown }
|
|
478
|
-
: {}), children: _jsx(View, { style: [
|
|
479
|
-
staticStyles.handle,
|
|
480
|
-
{ backgroundColor: theme.colors.muted },
|
|
481
|
-
] }) }));
|
|
196
|
+
function BottomSheetHandle(_props) {
|
|
197
|
+
return null;
|
|
482
198
|
}
|
|
483
199
|
// ============================================================================
|
|
484
200
|
// Header
|
|
485
201
|
// ============================================================================
|
|
202
|
+
/** Walk a React children tree looking for a `BottomSheet.Close` element. */
|
|
203
|
+
function containsClose(node) {
|
|
204
|
+
return React.Children.toArray(node).some((child) => {
|
|
205
|
+
if (!React.isValidElement(child))
|
|
206
|
+
return false;
|
|
207
|
+
if (child.type === BottomSheetClose)
|
|
208
|
+
return true;
|
|
209
|
+
return containsClose(child.props.children);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
486
212
|
function BottomSheetHeader({ children, style, ...props }) {
|
|
487
213
|
const { theme } = useTheme();
|
|
488
|
-
|
|
214
|
+
const { setHasHeader } = useBottomSheetContext();
|
|
215
|
+
const showClose = useShowClose();
|
|
216
|
+
// Tell Content a Header exists so it doesn't also float a close button.
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
setHasHeader(true);
|
|
219
|
+
return () => setHasHeader(false);
|
|
220
|
+
}, [setHasHeader]);
|
|
221
|
+
// Auto X is a fallback: render it when a close affordance is wanted (dismiss
|
|
222
|
+
// off, or scrollable body) AND the consumer didn't already provide their own
|
|
223
|
+
// Close inside the header (avoids a double X).
|
|
224
|
+
const showAutoClose = showClose && !containsClose(children);
|
|
225
|
+
return (_jsxs(View, { style: [
|
|
489
226
|
{
|
|
227
|
+
flexDirection: "row",
|
|
228
|
+
alignItems: "center",
|
|
229
|
+
gap: spacing.sm,
|
|
490
230
|
paddingHorizontal: spacing.md,
|
|
491
231
|
paddingVertical: spacing.md,
|
|
492
232
|
borderBottomWidth: 1,
|
|
493
233
|
borderBottomColor: theme.colors.border,
|
|
494
234
|
},
|
|
495
235
|
style,
|
|
496
|
-
], ...props, children: children }));
|
|
236
|
+
], ...props, children: [_jsx(View, { style: { flex: 1 }, children: children }), showAutoClose && _jsx(SheetCloseButton, {})] }));
|
|
497
237
|
}
|
|
498
238
|
// ============================================================================
|
|
499
239
|
// Body
|
|
500
240
|
// ============================================================================
|
|
501
|
-
function BottomSheetBody({ children, style, ...props }) {
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
241
|
+
function BottomSheetBody({ children, style, contentContainerStyle, onContentSizeChange, onLayout, ...props }) {
|
|
242
|
+
const { setScrollable, hasFooter } = useBottomSheetContext();
|
|
243
|
+
const insets = useSheetInsets();
|
|
244
|
+
// Track viewport vs content height to know whether the body actually
|
|
245
|
+
// overflows. Only a genuinely-scrolling body needs the detent height cap and
|
|
246
|
+
// the auto close X; short bodies size naturally and skip both.
|
|
247
|
+
const viewportH = useRef(0);
|
|
248
|
+
const contentH = useRef(0);
|
|
249
|
+
const evaluate = useCallback(() => {
|
|
250
|
+
const overflowing = contentH.current > viewportH.current + 1;
|
|
251
|
+
setScrollable(overflowing);
|
|
252
|
+
}, [setScrollable]);
|
|
253
|
+
// Reset the shared flag on unmount so a reused root doesn't stay "scrollable".
|
|
254
|
+
useEffect(() => () => setScrollable(false), [setScrollable]);
|
|
255
|
+
return (_jsx(ScrollView, { style: [{ flex: 1 }, style], contentContainerStyle: [
|
|
256
|
+
{
|
|
257
|
+
paddingHorizontal: spacing.md,
|
|
258
|
+
paddingTop: spacing.md,
|
|
259
|
+
// When no Footer owns the bottom inset, the body must clear the home
|
|
260
|
+
// indicator so the last item is reachable.
|
|
261
|
+
paddingBottom: spacing.md + (hasFooter ? 0 : insets.bottom),
|
|
262
|
+
},
|
|
263
|
+
contentContainerStyle,
|
|
264
|
+
], showsVerticalScrollIndicator: false, onLayout: (e) => {
|
|
265
|
+
viewportH.current = e.nativeEvent.layout.height;
|
|
266
|
+
evaluate();
|
|
267
|
+
onLayout?.(e);
|
|
268
|
+
}, onContentSizeChange: (w, h) => {
|
|
269
|
+
contentH.current = h;
|
|
270
|
+
evaluate();
|
|
271
|
+
onContentSizeChange?.(w, h);
|
|
272
|
+
}, ...props, children: children }));
|
|
506
273
|
}
|
|
507
274
|
// ============================================================================
|
|
508
275
|
// Footer
|
|
509
276
|
// ============================================================================
|
|
510
277
|
function BottomSheetFooter({ children, style, ...props }) {
|
|
511
278
|
const { theme } = useTheme();
|
|
512
|
-
const insets =
|
|
279
|
+
const insets = useSheetInsets();
|
|
280
|
+
const { setHasFooter } = useBottomSheetContext();
|
|
281
|
+
// Tell Body a Footer owns the bottom safe-area inset, so Body doesn't add it too.
|
|
282
|
+
useEffect(() => {
|
|
283
|
+
setHasFooter(true);
|
|
284
|
+
return () => setHasFooter(false);
|
|
285
|
+
}, [setHasFooter]);
|
|
513
286
|
return (_jsx(View, { style: [
|
|
514
287
|
{
|
|
515
288
|
paddingHorizontal: spacing.md,
|
|
@@ -527,33 +300,19 @@ function BottomSheetFooter({ children, style, ...props }) {
|
|
|
527
300
|
function BottomSheetClose({ asChild, children, style: styleOverride }) {
|
|
528
301
|
const { onOpenChange } = useBottomSheetContext();
|
|
529
302
|
const handlePress = () => onOpenChange(false);
|
|
530
|
-
if (asChild) {
|
|
531
|
-
return (
|
|
303
|
+
if (asChild && React.isValidElement(children)) {
|
|
304
|
+
return React.cloneElement(children, {
|
|
305
|
+
onPress: handlePress,
|
|
306
|
+
style: [
|
|
307
|
+
children.props.style,
|
|
532
308
|
Platform.OS === "web" && { cursor: "pointer" },
|
|
533
309
|
styleOverride,
|
|
534
|
-
],
|
|
310
|
+
],
|
|
311
|
+
});
|
|
535
312
|
}
|
|
536
|
-
return (_jsx(Pressable, { onPress: handlePress, style: [
|
|
537
|
-
Platform.OS === "web" && { cursor: "pointer" },
|
|
538
|
-
styleOverride,
|
|
539
|
-
], children: children }));
|
|
313
|
+
return (_jsx(Pressable, { onPress: handlePress, style: [Platform.OS === "web" && { cursor: "pointer" }, styleOverride], children: children }));
|
|
540
314
|
}
|
|
541
315
|
// ============================================================================
|
|
542
|
-
// Static styles
|
|
543
|
-
// ============================================================================
|
|
544
|
-
const staticStyles = StyleSheet.create({
|
|
545
|
-
handleContainer: {
|
|
546
|
-
alignItems: "center",
|
|
547
|
-
paddingTop: spacing.sm,
|
|
548
|
-
paddingBottom: spacing.xs,
|
|
549
|
-
},
|
|
550
|
-
handle: {
|
|
551
|
-
width: 36,
|
|
552
|
-
height: 4,
|
|
553
|
-
borderRadius: 2,
|
|
554
|
-
},
|
|
555
|
-
});
|
|
556
|
-
// ============================================================================
|
|
557
316
|
// Compound export
|
|
558
317
|
// ============================================================================
|
|
559
318
|
const BottomSheet = Object.assign(BottomSheetRoot, {
|