@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.
@@ -1,45 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React, { createContext, use, useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
3
- import { View, Pressable, Animated, StyleSheet, Platform, PanResponder, ScrollView, } from "react-native";
4
- import { Portal } from "@rn-primitives/portal";
5
- import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens";
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 { useSafeAreaInsets } from "react-native-safe-area-context";
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
- const DragContext = createContext(null);
55
- function BottomSheetPanel({ accessibilityViewIsModal, children, panHandlers, sheetStyle, styleOverride, translateY, ...props }) {
56
- return (_jsx(Animated.View, { style: [
57
- sheetStyle,
58
- { transform: [{ translateY }] },
59
- styleOverride && typeof styleOverride !== "function"
60
- ? StyleSheet.flatten(styleOverride)
61
- : undefined,
62
- ], accessibilityViewIsModal: accessibilityViewIsModal, ...panHandlers, ...props, children: children }));
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
- function KeyboardAvoidingBottomSheetPanel(props) {
65
- const { height: keyboardHeight } = useBottomSheetKeyboardAnimation();
66
- const composedTranslateY = useMemo(() => Animated.add(props.translateY, keyboardHeight), [keyboardHeight, props.translateY]);
67
- return _jsx(BottomSheetPanel, { ...props, translateY: composedTranslateY });
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
- // Utility Functions
62
+ // Auto close button — shown when interactive dismiss is off
71
63
  // ============================================================================
72
- function resolveSnapPoints(points, screenHeight) {
73
- return points.map((p) => {
74
- if (typeof p === "number")
75
- return p;
76
- return (parseFloat(p) / 100) * screenHeight;
77
- });
78
- }
79
- function sheetReducer(state, action) {
80
- switch (action.type) {
81
- case "OPEN": return true;
82
- case "CLOSE": return false;
83
- case "TOGGLE": return !state;
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
- // BottomSheet Root
87
+ // Root
88
88
  // ============================================================================
89
- function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen = false, snapPoints: rawSnapPoints = ["50%"], closeOnBackdropPress = true, children, }) {
90
- const [internalOpen, dispatch] = useReducer(sheetReducer, defaultOpen);
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
- dispatch({ type: newOpen ? "OPEN" : "CLOSE" });
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({ swipeEnabled = true, velocityThreshold = 500, avoidKeyboard = true, dismissKeyboardOnDrag = true, style: styleOverride, children, ...props }) {
147
- const sheetContext = useBottomSheetContext();
148
- const { open, onOpenChange, snapPoints, closeOnBackdropPress } = sheetContext;
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
- // Highest snap point is the max height
151
- const maxHeight = Math.max(...snapPoints);
152
- // With bottom:0 positioning, translateY=0 means visible, translateY=maxHeight means hidden below
153
- const closedPosition = maxHeight;
154
- // Initialize lazily so each Animated.Value is allocated once on first render
155
- // instead of being rebuilt and discarded on every render.
156
- const translateYRef = useRef(null);
157
- if (translateYRef.current === null) {
158
- translateYRef.current = new Animated.Value(open ? 0 : closedPosition);
159
- }
160
- const translateY = translateYRef.current;
161
- const backdropOpacityRef = useRef(null);
162
- if (backdropOpacityRef.current === null) {
163
- backdropOpacityRef.current = new Animated.Value(open ? 1 : 0);
164
- }
165
- const backdropOpacity = backdropOpacityRef.current;
166
- const [isVisible, setIsVisible] = useState(open);
167
- const lastOpenRef = useRef(null);
168
- const runningAnimationRef = useRef(null);
169
- const currentHeightRef = useRef(maxHeight);
170
- // Track which snap we're at
171
- const currentSnapRef = useRef(snapPoints.length - 1);
172
- const textColor = theme.colors.foreground;
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
- const sheetContent = (_jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextClassContext.Provider, { value: "", children: children }) }));
363
- const PanelComponent = Platform.OS !== "web" && avoidKeyboard
364
- ? KeyboardAvoidingBottomSheetPanel
365
- : BottomSheetPanel;
366
- return (_jsx(BottomSheetContentPortal, { sheetContext: sheetContext, theme: theme, backdropOpacity: backdropOpacity, onBackdropPress: handleBackdropPress, PanelComponent: PanelComponent, sheetStyle: sheetStyle, styleOverride: styleOverride, translateY: translateY, panHandlers: panResponder ? panResponder.panHandlers : undefined, panelProps: props, dragContextValue: dragContextValue, sheetContent: sheetContent }));
367
- }
368
- function BottomSheetContentPortal({ sheetContext, theme, backdropOpacity, onBackdropPress, PanelComponent, sheetStyle, styleOverride, translateY, panHandlers, panelProps, dragContextValue, sheetContent, }) {
369
- return (_jsx(Portal, { name: "bottom-sheet-portal", children: _jsx(FullWindowOverlay, { children: _jsx(BottomSheetContext.Provider, { value: sheetContext, children: _jsxs(View, { style: StyleSheet.absoluteFill, children: [_jsx(Animated.View, { style: [
370
- StyleSheet.absoluteFill,
371
- {
372
- backgroundColor: theme.colors.overlay,
373
- opacity: backdropOpacity,
374
- },
375
- Platform.OS === "web" && { zIndex: 50 },
376
- ], children: _jsx(Pressable, { style: StyleSheet.absoluteFill, onPress: onBackdropPress }) }), _jsx(PanelComponent, { sheetStyle: sheetStyle, styleOverride: styleOverride, translateY: translateY, accessibilityViewIsModal: true, ...(Platform.OS === "web" && {
377
- role: "dialog",
378
- "aria-modal": true,
379
- }), panHandlers: panHandlers, ...panelProps, children: dragContextValue ? (_jsx(DragContext.Provider, { value: dragContextValue, children: sheetContent })) : (sheetContent) })] }) }) }) }));
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({ style }) {
385
- const { theme } = useTheme();
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
- return (_jsx(View, { style: [
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
- return (_jsx(ScrollView, { style: [{ flex: 1 }, style], contentContainerStyle: {
468
- paddingHorizontal: spacing.md,
469
- paddingVertical: spacing.md,
470
- }, showsVerticalScrollIndicator: false, ...props, children: children }));
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 = useSafeAreaInsets();
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 (_jsx(SlotPressable, { onPress: handlePress, style: [
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
- ], children: children }));
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, {