@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.
@@ -1,48 +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;
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
- * Lifts the sheet above the keyboard while keeping its top edge on-screen.
22
+ * Safe-area insets for content *inside* the sheet.
73
23
  *
74
- * The naive approach translates the whole rigid box up by the keyboard height,
75
- * which shoves the header (and any inputs near the top) off the top of the
76
- * screen on tall sheets. Instead we lift the sheet's bottom to sit just above
77
- * the keyboard and shrink its height by the same amount, so the top edge holds
78
- * steady. The flex Body soaks up the lost height and scrolls, leaving the
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 KeyboardAvoidingBottomSheetPanel(props) {
87
- const { height: keyboardHeight } = useBottomSheetKeyboardAnimation();
88
- // sheetStyle.height is the resolved max snap height (a number, set by Content).
89
- const baseHeight = typeof props.sheetStyle.height === "number" ? props.sheetStyle.height : undefined;
90
- const animatedHeight = useMemo(() => {
91
- if (baseHeight === undefined)
92
- return undefined;
93
- // keyboardHeight: 0 (closed) → keyboard px (open). Shrink the sheet by it,
94
- // clamped to a usable minimum so it never collapses on short screens.
95
- const shrunk = Animated.subtract(baseHeight, keyboardHeight);
96
- return shrunk.interpolate({
97
- inputRange: [MIN_KEYBOARD_SHEET_HEIGHT, baseHeight],
98
- outputRange: [MIN_KEYBOARD_SHEET_HEIGHT, baseHeight],
99
- extrapolate: "clamp",
100
- });
101
- }, [baseHeight, keyboardHeight]);
102
- return (_jsx(BottomSheetPanel, { ...props, animatedHeight: animatedHeight, animatedBottom: keyboardHeight }));
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
- // Utility Functions
62
+ // Auto close button — shown when interactive dismiss is off
106
63
  // ============================================================================
107
- function resolveSnapPoints(points, screenHeight) {
108
- return points.map((p) => {
109
- if (typeof p === "number")
110
- return p;
111
- return (parseFloat(p) / 100) * screenHeight;
112
- });
113
- }
114
- function sheetReducer(state, action) {
115
- switch (action.type) {
116
- case "OPEN": return true;
117
- case "CLOSE": return false;
118
- case "TOGGLE": return !state;
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
- // BottomSheet Root
87
+ // Root
123
88
  // ============================================================================
124
- function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen = false, snapPoints: rawSnapPoints = ["50%"], closeOnBackdropPress = true, children, }) {
125
- 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);
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
- dispatch({ type: newOpen ? "OPEN" : "CLOSE" });
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({ swipeEnabled = true, velocityThreshold = 500, avoidKeyboard = true, dismissKeyboardOnDrag = true, style: styleOverride, children, ...props }) {
182
- const sheetContext = useBottomSheetContext();
183
- 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();
184
145
  const { theme } = useTheme();
185
- // Highest snap point is the max height
186
- const maxHeight = Math.max(...snapPoints);
187
- // With bottom:0 positioning, translateY=0 means visible, translateY=maxHeight means hidden below
188
- const closedPosition = maxHeight;
189
- // Initialize lazily so each Animated.Value is allocated once on first render
190
- // instead of being rebuilt and discarded on every render.
191
- const translateYRef = useRef(null);
192
- if (translateYRef.current === null) {
193
- translateYRef.current = new Animated.Value(open ? 0 : closedPosition);
194
- }
195
- const translateY = translateYRef.current;
196
- const backdropOpacityRef = useRef(null);
197
- if (backdropOpacityRef.current === null) {
198
- backdropOpacityRef.current = new Animated.Value(open ? 1 : 0);
199
- }
200
- const backdropOpacity = backdropOpacityRef.current;
201
- const [isVisible, setIsVisible] = useState(open);
202
- const lastOpenRef = useRef(null);
203
- const runningAnimationRef = useRef(null);
204
- const currentHeightRef = useRef(maxHeight);
205
- // Track which snap we're at
206
- const currentSnapRef = useRef(snapPoints.length - 1);
207
- const textColor = theme.colors.foreground;
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
- const sheetContent = (_jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextClassContext.Provider, { value: "", children: children }) }));
398
- const PanelComponent = Platform.OS !== "web" && avoidKeyboard
399
- ? KeyboardAvoidingBottomSheetPanel
400
- : BottomSheetPanel;
401
- 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 }));
402
- }
403
- function BottomSheetContentPortal({ sheetContext, theme, backdropOpacity, onBackdropPress, PanelComponent, sheetStyle, styleOverride, translateY, panHandlers, panelProps, dragContextValue, sheetContent, }) {
404
- 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: [
405
- StyleSheet.absoluteFill,
406
- {
407
- backgroundColor: theme.colors.overlay,
408
- opacity: backdropOpacity,
409
- },
410
- Platform.OS === "web" && { zIndex: 50 },
411
- ], children: _jsx(Pressable, { style: StyleSheet.absoluteFill, onPress: onBackdropPress }) }), _jsx(PanelComponent, { sheetStyle: sheetStyle, styleOverride: styleOverride, translateY: translateY, accessibilityViewIsModal: true, ...(Platform.OS === "web" && {
412
- role: "dialog",
413
- "aria-modal": true,
414
- }), 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
+ } }))] }) }) }) }));
415
192
  }
416
193
  // ============================================================================
417
- // Handle
194
+ // Handle — no-op (the platform draws the drag indicator)
418
195
  // ============================================================================
419
- function BottomSheetHandle({ style }) {
420
- const { theme } = useTheme();
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
- 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: [
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
- return (_jsx(ScrollView, { style: [{ flex: 1 }, style], contentContainerStyle: {
503
- paddingHorizontal: spacing.md,
504
- paddingVertical: spacing.md,
505
- }, 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 }));
506
273
  }
507
274
  // ============================================================================
508
275
  // Footer
509
276
  // ============================================================================
510
277
  function BottomSheetFooter({ children, style, ...props }) {
511
278
  const { theme } = useTheme();
512
- 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]);
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 (_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,
532
308
  Platform.OS === "web" && { cursor: "pointer" },
533
309
  styleOverride,
534
- ], children: children }));
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, {