@mrmeg/expo-ui 0.7.1 → 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 +205 -411
- 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 +4 -2
- package/dist/components/BottomSheetKeyboard.d.ts +0 -7
- package/dist/components/BottomSheetKeyboard.js +0 -35
|
@@ -1,45 +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;
|
|
9
|
+
import { Icon } from "./Icon.js";
|
|
43
10
|
// ============================================================================
|
|
44
11
|
// Context
|
|
45
12
|
// ============================================================================
|
|
@@ -51,71 +18,102 @@ function useBottomSheetContext() {
|
|
|
51
18
|
}
|
|
52
19
|
return context;
|
|
53
20
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Safe-area insets for content *inside* the sheet.
|
|
23
|
+
*
|
|
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.
|
|
29
|
+
*/
|
|
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
|
+
};
|
|
63
39
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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;
|
|
68
60
|
}
|
|
69
61
|
// ============================================================================
|
|
70
|
-
//
|
|
62
|
+
// Auto close button — shown when interactive dismiss is off
|
|
71
63
|
// ============================================================================
|
|
72
|
-
function
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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" }) }));
|
|
85
85
|
}
|
|
86
86
|
// ============================================================================
|
|
87
|
-
//
|
|
87
|
+
// Root
|
|
88
88
|
// ============================================================================
|
|
89
|
-
function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen = false, snapPoints
|
|
90
|
-
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);
|
|
91
94
|
const isControlled = controlledOpen !== undefined;
|
|
92
95
|
const open = isControlled ? controlledOpen : internalOpen;
|
|
93
|
-
// useDimensions reacts to rotation / split-screen, unlike Dimensions.get.
|
|
94
|
-
const { height: screenHeight } = useDimensions();
|
|
95
|
-
const snapPoints = resolveSnapPoints(rawSnapPoints, screenHeight);
|
|
96
|
-
const toggle = () => {
|
|
97
|
-
if (isControlled) {
|
|
98
|
-
controlledOnOpenChange?.(!controlledOpen);
|
|
99
|
-
}
|
|
100
|
-
else {
|
|
101
|
-
dispatch({ type: "TOGGLE" });
|
|
102
|
-
}
|
|
103
|
-
};
|
|
104
96
|
const onOpenChange = (newOpen) => {
|
|
105
97
|
if (isControlled) {
|
|
106
98
|
controlledOnOpenChange?.(newOpen);
|
|
107
99
|
}
|
|
108
100
|
else {
|
|
109
|
-
|
|
101
|
+
setInternalOpen(newOpen);
|
|
110
102
|
}
|
|
111
103
|
};
|
|
104
|
+
const toggle = () => onOpenChange(!open);
|
|
112
105
|
const contextValue = {
|
|
113
106
|
open,
|
|
114
107
|
onOpenChange,
|
|
115
108
|
toggle,
|
|
116
109
|
snapPoints,
|
|
117
|
-
currentSnapIndex: 0,
|
|
118
110
|
closeOnBackdropPress,
|
|
111
|
+
scrollable,
|
|
112
|
+
setScrollable,
|
|
113
|
+
hasHeader,
|
|
114
|
+
setHasHeader,
|
|
115
|
+
hasFooter,
|
|
116
|
+
setHasFooter,
|
|
119
117
|
};
|
|
120
118
|
return (_jsx(BottomSheetContext.Provider, { value: contextValue, children: children }));
|
|
121
119
|
}
|
|
@@ -135,346 +133,156 @@ function BottomSheetTrigger({ asChild, children, style: styleOverride }) {
|
|
|
135
133
|
],
|
|
136
134
|
});
|
|
137
135
|
}
|
|
138
|
-
return (_jsx(Pressable, { onPress: handlePress, style: [
|
|
139
|
-
Platform.OS === "web" && { cursor: "pointer" },
|
|
140
|
-
styleOverride,
|
|
141
|
-
], children: children }));
|
|
136
|
+
return (_jsx(Pressable, { onPress: handlePress, style: [Platform.OS === "web" && { cursor: "pointer" }, styleOverride], children: children }));
|
|
142
137
|
}
|
|
143
138
|
// ============================================================================
|
|
144
|
-
// Content
|
|
139
|
+
// Content — renders the native sheet, wraps children in themed flex column
|
|
145
140
|
// ============================================================================
|
|
146
|
-
function BottomSheetContent({
|
|
147
|
-
|
|
148
|
-
|
|
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();
|
|
149
145
|
const { theme } = useTheme();
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const handleDragRelease = useCallback((dragDistance, velocity) => {
|
|
174
|
-
const visibleHeight = currentHeightRef.current - dragDistance;
|
|
175
|
-
if (velocity > velocityThreshold / 1000 || dragDistance > currentHeightRef.current * 0.4) {
|
|
176
|
-
const lowerSnaps = snapPoints.filter((s) => s < currentHeightRef.current);
|
|
177
|
-
if (lowerSnaps.length > 0 && dragDistance < currentHeightRef.current * 0.4) {
|
|
178
|
-
const nextSnap = lowerSnaps[lowerSnaps.length - 1];
|
|
179
|
-
currentHeightRef.current = nextSnap;
|
|
180
|
-
const targetY = maxHeight - nextSnap;
|
|
181
|
-
Animated.parallel([
|
|
182
|
-
Animated.spring(translateY, {
|
|
183
|
-
toValue: targetY,
|
|
184
|
-
tension: 65,
|
|
185
|
-
friction: 11,
|
|
186
|
-
useNativeDriver: shouldUseNativeDriver,
|
|
187
|
-
}),
|
|
188
|
-
Animated.timing(backdropOpacity, {
|
|
189
|
-
toValue: 1,
|
|
190
|
-
duration: 150,
|
|
191
|
-
useNativeDriver: shouldUseNativeDriver,
|
|
192
|
-
}),
|
|
193
|
-
]).start();
|
|
194
|
-
}
|
|
195
|
-
else {
|
|
196
|
-
Animated.parallel([
|
|
197
|
-
Animated.timing(translateY, {
|
|
198
|
-
toValue: closedPosition,
|
|
199
|
-
duration: 200,
|
|
200
|
-
useNativeDriver: shouldUseNativeDriver,
|
|
201
|
-
}),
|
|
202
|
-
Animated.timing(backdropOpacity, {
|
|
203
|
-
toValue: 0,
|
|
204
|
-
duration: 200,
|
|
205
|
-
useNativeDriver: shouldUseNativeDriver,
|
|
206
|
-
}),
|
|
207
|
-
]).start(() => {
|
|
208
|
-
onOpenChange(false);
|
|
209
|
-
setIsVisible(false);
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
else {
|
|
214
|
-
let nearestSnap = snapPoints[0];
|
|
215
|
-
let minDistance = Infinity;
|
|
216
|
-
for (const snap of snapPoints) {
|
|
217
|
-
const dist = Math.abs(visibleHeight - snap);
|
|
218
|
-
if (dist < minDistance) {
|
|
219
|
-
minDistance = dist;
|
|
220
|
-
nearestSnap = snap;
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
currentHeightRef.current = nearestSnap;
|
|
224
|
-
const targetY = maxHeight - nearestSnap;
|
|
225
|
-
Animated.parallel([
|
|
226
|
-
Animated.spring(translateY, {
|
|
227
|
-
toValue: targetY,
|
|
228
|
-
tension: 65,
|
|
229
|
-
friction: 11,
|
|
230
|
-
useNativeDriver: shouldUseNativeDriver,
|
|
231
|
-
}),
|
|
232
|
-
Animated.timing(backdropOpacity, {
|
|
233
|
-
toValue: 1,
|
|
234
|
-
duration: 150,
|
|
235
|
-
useNativeDriver: shouldUseNativeDriver,
|
|
236
|
-
}),
|
|
237
|
-
]).start();
|
|
238
|
-
}
|
|
239
|
-
}, [snapPoints, maxHeight, closedPosition, translateY, backdropOpacity, onOpenChange, velocityThreshold]);
|
|
240
|
-
const handleDragMove = useCallback((dy) => {
|
|
241
|
-
// Base offset: where the sheet sits at the current snap point
|
|
242
|
-
// translateY=0 means top snap (maxHeight visible), higher values = further down
|
|
243
|
-
const baseOffset = maxHeight - currentHeightRef.current;
|
|
244
|
-
const newY = Math.max(baseOffset, baseOffset + dy);
|
|
245
|
-
translateY.setValue(newY);
|
|
246
|
-
// Progress: 1 = fully at current snap, 0 = fully closed
|
|
247
|
-
const dragFromBase = newY - baseOffset;
|
|
248
|
-
const progress = 1 - dragFromBase / currentHeightRef.current;
|
|
249
|
-
backdropOpacity.setValue(Math.max(0, progress));
|
|
250
|
-
}, [translateY, backdropOpacity, maxHeight]);
|
|
251
|
-
const dismissKeyboardForDrag = useCallback(() => {
|
|
252
|
-
if (Platform.OS !== "web" && dismissKeyboardOnDrag) {
|
|
253
|
-
void BottomSheetKeyboardController.dismiss();
|
|
254
|
-
}
|
|
255
|
-
}, [dismissKeyboardOnDrag]);
|
|
256
|
-
if (open !== lastOpenRef.current) {
|
|
257
|
-
const previousOpen = lastOpenRef.current;
|
|
258
|
-
lastOpenRef.current = open;
|
|
259
|
-
if (runningAnimationRef.current) {
|
|
260
|
-
runningAnimationRef.current.stop();
|
|
261
|
-
runningAnimationRef.current = null;
|
|
262
|
-
}
|
|
263
|
-
if (open) {
|
|
264
|
-
if (!isVisible) {
|
|
265
|
-
setIsVisible(true);
|
|
266
|
-
}
|
|
267
|
-
currentSnapRef.current = snapPoints.length - 1;
|
|
268
|
-
currentHeightRef.current = maxHeight;
|
|
269
|
-
if (previousOpen === null) {
|
|
270
|
-
translateY.setValue(closedPosition);
|
|
271
|
-
backdropOpacity.setValue(0);
|
|
272
|
-
}
|
|
273
|
-
const animation = Animated.parallel([
|
|
274
|
-
Animated.spring(translateY, {
|
|
275
|
-
toValue: 0,
|
|
276
|
-
tension: 65,
|
|
277
|
-
friction: 11,
|
|
278
|
-
useNativeDriver: shouldUseNativeDriver,
|
|
279
|
-
}),
|
|
280
|
-
Animated.timing(backdropOpacity, {
|
|
281
|
-
toValue: 1,
|
|
282
|
-
duration: 200,
|
|
283
|
-
useNativeDriver: shouldUseNativeDriver,
|
|
284
|
-
}),
|
|
285
|
-
]);
|
|
286
|
-
runningAnimationRef.current = animation;
|
|
287
|
-
animation.start(({ finished }) => {
|
|
288
|
-
if (finished)
|
|
289
|
-
runningAnimationRef.current = null;
|
|
290
|
-
});
|
|
291
|
-
}
|
|
292
|
-
else if (previousOpen === true) {
|
|
293
|
-
const animation = Animated.parallel([
|
|
294
|
-
Animated.timing(translateY, {
|
|
295
|
-
toValue: closedPosition,
|
|
296
|
-
duration: 200,
|
|
297
|
-
useNativeDriver: shouldUseNativeDriver,
|
|
298
|
-
}),
|
|
299
|
-
Animated.timing(backdropOpacity, {
|
|
300
|
-
toValue: 0,
|
|
301
|
-
duration: 200,
|
|
302
|
-
useNativeDriver: shouldUseNativeDriver,
|
|
303
|
-
}),
|
|
304
|
-
]);
|
|
305
|
-
runningAnimationRef.current = animation;
|
|
306
|
-
animation.start(({ finished }) => {
|
|
307
|
-
runningAnimationRef.current = null;
|
|
308
|
-
if (finished)
|
|
309
|
-
setIsVisible(false);
|
|
310
|
-
});
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
const panResponder = useMemo(() => Platform.OS !== "web" && swipeEnabled
|
|
314
|
-
? PanResponder.create({
|
|
315
|
-
onStartShouldSetPanResponder: () => false,
|
|
316
|
-
onMoveShouldSetPanResponder: (_evt, gestureState) => {
|
|
317
|
-
const isVertical = Math.abs(gestureState.dy) > Math.abs(gestureState.dx);
|
|
318
|
-
const isSignificant = Math.abs(gestureState.dy) > 10;
|
|
319
|
-
const isDownward = gestureState.dy > 0;
|
|
320
|
-
return isVertical && isSignificant && isDownward;
|
|
321
|
-
},
|
|
322
|
-
onPanResponderGrant: dismissKeyboardForDrag,
|
|
323
|
-
onPanResponderMove: (_evt, gestureState) => {
|
|
324
|
-
handleDragMove(gestureState.dy);
|
|
325
|
-
},
|
|
326
|
-
onPanResponderRelease: (_evt, gestureState) => {
|
|
327
|
-
handleDragRelease(Math.max(0, gestureState.dy), gestureState.vy);
|
|
328
|
-
},
|
|
329
|
-
})
|
|
330
|
-
: null, [dismissKeyboardForDrag, handleDragMove, handleDragRelease, swipeEnabled]);
|
|
331
|
-
const dragContextValue = Platform.OS === "web" && swipeEnabled
|
|
332
|
-
? {
|
|
333
|
-
onDragMove: handleDragMove,
|
|
334
|
-
onDragEnd: (dy, velocity) => {
|
|
335
|
-
handleDragRelease(Math.max(0, dy), velocity);
|
|
336
|
-
},
|
|
337
|
-
}
|
|
338
|
-
: null;
|
|
339
|
-
const handleBackdropPress = () => {
|
|
340
|
-
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)
|
|
341
169
|
onOpenChange(false);
|
|
342
|
-
}
|
|
343
|
-
};
|
|
344
|
-
if (!isVisible && !open) {
|
|
345
|
-
return null;
|
|
346
|
-
}
|
|
347
|
-
const sheetStyle = {
|
|
348
|
-
position: "absolute",
|
|
349
|
-
left: 0,
|
|
350
|
-
right: 0,
|
|
351
|
-
bottom: 0,
|
|
352
|
-
height: maxHeight,
|
|
353
|
-
backgroundColor: theme.colors.card,
|
|
354
|
-
borderTopLeftRadius: spacing.radiusXl,
|
|
355
|
-
borderTopRightRadius: spacing.radiusXl,
|
|
356
|
-
borderTopWidth: 1,
|
|
357
|
-
borderLeftWidth: 1,
|
|
358
|
-
borderRightWidth: 1,
|
|
359
|
-
borderColor: theme.colors.border,
|
|
360
|
-
...(Platform.OS === "web" && { zIndex: 51 }),
|
|
361
170
|
};
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
+
} }))] }) }) }) }));
|
|
380
192
|
}
|
|
381
193
|
// ============================================================================
|
|
382
|
-
// Handle
|
|
194
|
+
// Handle — no-op (the platform draws the drag indicator)
|
|
383
195
|
// ============================================================================
|
|
384
|
-
function BottomSheetHandle(
|
|
385
|
-
|
|
386
|
-
const dragCtx = use(DragContext);
|
|
387
|
-
// Web pointer-event drag — attaches move/up listeners on document
|
|
388
|
-
const dragStartY = useRef(0);
|
|
389
|
-
const lastTimestamp = useRef(0);
|
|
390
|
-
const lastDy = useRef(0);
|
|
391
|
-
const isDragging = useRef(false);
|
|
392
|
-
useEffect(() => {
|
|
393
|
-
if (Platform.OS !== "web" || !dragCtx)
|
|
394
|
-
return;
|
|
395
|
-
const onPointerMove = (e) => {
|
|
396
|
-
if (!isDragging.current)
|
|
397
|
-
return;
|
|
398
|
-
const dy = e.clientY - dragStartY.current;
|
|
399
|
-
const now = Date.now();
|
|
400
|
-
const dt = (now - lastTimestamp.current) / 1000;
|
|
401
|
-
lastTimestamp.current = now;
|
|
402
|
-
lastDy.current = dy;
|
|
403
|
-
dragCtx.onDragMove(dy);
|
|
404
|
-
// Store velocity data on the event for release calculation
|
|
405
|
-
isDragging._lastDt = dt;
|
|
406
|
-
isDragging._lastDy = dy;
|
|
407
|
-
};
|
|
408
|
-
const onPointerUp = (e) => {
|
|
409
|
-
if (!isDragging.current)
|
|
410
|
-
return;
|
|
411
|
-
isDragging.current = false;
|
|
412
|
-
document.body.style.cursor = "";
|
|
413
|
-
document.body.style.userSelect = "";
|
|
414
|
-
const dy = e.clientY - dragStartY.current;
|
|
415
|
-
const dt = isDragging._lastDt || 0.016;
|
|
416
|
-
const prevDy = isDragging._lastDy || 0;
|
|
417
|
-
const velocity = dt > 0 ? (dy - prevDy) / dt / 1000 : 0;
|
|
418
|
-
dragCtx.onDragEnd(dy, velocity);
|
|
419
|
-
};
|
|
420
|
-
document.addEventListener("pointermove", onPointerMove);
|
|
421
|
-
document.addEventListener("pointerup", onPointerUp);
|
|
422
|
-
return () => {
|
|
423
|
-
document.removeEventListener("pointermove", onPointerMove);
|
|
424
|
-
document.removeEventListener("pointerup", onPointerUp);
|
|
425
|
-
};
|
|
426
|
-
}, [dragCtx]);
|
|
427
|
-
const handlePointerDown = useCallback((e) => {
|
|
428
|
-
if (Platform.OS !== "web" || !dragCtx)
|
|
429
|
-
return;
|
|
430
|
-
isDragging.current = true;
|
|
431
|
-
dragStartY.current = e.nativeEvent?.clientY ?? e.clientY;
|
|
432
|
-
lastTimestamp.current = Date.now();
|
|
433
|
-
lastDy.current = 0;
|
|
434
|
-
document.body.style.cursor = "grabbing";
|
|
435
|
-
document.body.style.userSelect = "none";
|
|
436
|
-
}, [dragCtx]);
|
|
437
|
-
return (_jsx(View, { style: [
|
|
438
|
-
staticStyles.handleContainer,
|
|
439
|
-
Platform.OS === "web" && { cursor: "grab" },
|
|
440
|
-
style,
|
|
441
|
-
], ...(Platform.OS === "web" && dragCtx
|
|
442
|
-
? { onPointerDown: handlePointerDown }
|
|
443
|
-
: {}), children: _jsx(View, { style: [
|
|
444
|
-
staticStyles.handle,
|
|
445
|
-
{ backgroundColor: theme.colors.muted },
|
|
446
|
-
] }) }));
|
|
196
|
+
function BottomSheetHandle(_props) {
|
|
197
|
+
return null;
|
|
447
198
|
}
|
|
448
199
|
// ============================================================================
|
|
449
200
|
// Header
|
|
450
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
|
+
}
|
|
451
212
|
function BottomSheetHeader({ children, style, ...props }) {
|
|
452
213
|
const { theme } = useTheme();
|
|
453
|
-
|
|
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: [
|
|
454
226
|
{
|
|
227
|
+
flexDirection: "row",
|
|
228
|
+
alignItems: "center",
|
|
229
|
+
gap: spacing.sm,
|
|
455
230
|
paddingHorizontal: spacing.md,
|
|
456
231
|
paddingVertical: spacing.md,
|
|
457
232
|
borderBottomWidth: 1,
|
|
458
233
|
borderBottomColor: theme.colors.border,
|
|
459
234
|
},
|
|
460
235
|
style,
|
|
461
|
-
], ...props, children: children }));
|
|
236
|
+
], ...props, children: [_jsx(View, { style: { flex: 1 }, children: children }), showAutoClose && _jsx(SheetCloseButton, {})] }));
|
|
462
237
|
}
|
|
463
238
|
// ============================================================================
|
|
464
239
|
// Body
|
|
465
240
|
// ============================================================================
|
|
466
|
-
function BottomSheetBody({ children, style, ...props }) {
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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 }));
|
|
471
273
|
}
|
|
472
274
|
// ============================================================================
|
|
473
275
|
// Footer
|
|
474
276
|
// ============================================================================
|
|
475
277
|
function BottomSheetFooter({ children, style, ...props }) {
|
|
476
278
|
const { theme } = useTheme();
|
|
477
|
-
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]);
|
|
478
286
|
return (_jsx(View, { style: [
|
|
479
287
|
{
|
|
480
288
|
paddingHorizontal: spacing.md,
|
|
@@ -492,33 +300,19 @@ function BottomSheetFooter({ children, style, ...props }) {
|
|
|
492
300
|
function BottomSheetClose({ asChild, children, style: styleOverride }) {
|
|
493
301
|
const { onOpenChange } = useBottomSheetContext();
|
|
494
302
|
const handlePress = () => onOpenChange(false);
|
|
495
|
-
if (asChild) {
|
|
496
|
-
return (
|
|
303
|
+
if (asChild && React.isValidElement(children)) {
|
|
304
|
+
return React.cloneElement(children, {
|
|
305
|
+
onPress: handlePress,
|
|
306
|
+
style: [
|
|
307
|
+
children.props.style,
|
|
497
308
|
Platform.OS === "web" && { cursor: "pointer" },
|
|
498
309
|
styleOverride,
|
|
499
|
-
],
|
|
310
|
+
],
|
|
311
|
+
});
|
|
500
312
|
}
|
|
501
|
-
return (_jsx(Pressable, { onPress: handlePress, style: [
|
|
502
|
-
Platform.OS === "web" && { cursor: "pointer" },
|
|
503
|
-
styleOverride,
|
|
504
|
-
], children: children }));
|
|
313
|
+
return (_jsx(Pressable, { onPress: handlePress, style: [Platform.OS === "web" && { cursor: "pointer" }, styleOverride], children: children }));
|
|
505
314
|
}
|
|
506
315
|
// ============================================================================
|
|
507
|
-
// Static styles
|
|
508
|
-
// ============================================================================
|
|
509
|
-
const staticStyles = StyleSheet.create({
|
|
510
|
-
handleContainer: {
|
|
511
|
-
alignItems: "center",
|
|
512
|
-
paddingTop: spacing.sm,
|
|
513
|
-
paddingBottom: spacing.xs,
|
|
514
|
-
},
|
|
515
|
-
handle: {
|
|
516
|
-
width: 36,
|
|
517
|
-
height: 4,
|
|
518
|
-
borderRadius: 2,
|
|
519
|
-
},
|
|
520
|
-
});
|
|
521
|
-
// ============================================================================
|
|
522
316
|
// Compound export
|
|
523
317
|
// ============================================================================
|
|
524
318
|
const BottomSheet = Object.assign(BottomSheetRoot, {
|