@mrmeg/expo-ui 0.13.0-rsd.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/LLM_USAGE.md +15 -0
- package/README.md +15 -1
- package/dist/components/Accordion.d.ts +18 -38
- package/dist/components/Accordion.js +121 -137
- package/dist/components/AnimatedView.d.ts +24 -13
- package/dist/components/AnimatedView.js +29 -70
- package/dist/components/Badge.d.ts +2 -2
- package/dist/components/Badge.js +47 -45
- package/dist/components/BottomSheet.d.ts +73 -30
- package/dist/components/BottomSheet.js +225 -191
- package/dist/components/Button.d.ts +56 -43
- package/dist/components/Button.js +203 -189
- package/dist/components/Card.d.ts +10 -7
- package/dist/components/Card.js +105 -90
- package/dist/components/Checkbox.d.ts +8 -20
- package/dist/components/Checkbox.js +80 -77
- package/dist/components/Collapsible.d.ts +47 -47
- package/dist/components/Collapsible.js +29 -108
- package/dist/components/Dialog.d.ts +121 -80
- package/dist/components/Dialog.js +132 -225
- package/dist/components/DismissKeyboard.js +2 -33
- package/dist/components/Drawer.d.ts +83 -37
- package/dist/components/Drawer.js +512 -252
- package/dist/components/DropdownMenu.d.ts +113 -106
- package/dist/components/DropdownMenu.js +204 -350
- package/dist/components/EmptyState.d.ts +2 -2
- package/dist/components/EmptyState.js +34 -41
- package/dist/components/InputOTP.d.ts +53 -19
- package/dist/components/InputOTP.js +102 -81
- package/dist/components/KeyboardAvoidingView.d.ts +3 -6
- package/dist/components/KeyboardAvoidingView.js +5 -18
- package/dist/components/Label.d.ts +17 -12
- package/dist/components/Label.js +51 -38
- package/dist/components/MaxWidthContainer.d.ts +16 -5
- package/dist/components/MaxWidthContainer.js +29 -28
- package/dist/components/Notification.d.ts +9 -5
- package/dist/components/Notification.js +187 -190
- package/dist/components/Popover.d.ts +66 -38
- package/dist/components/Popover.js +69 -158
- package/dist/components/Progress.d.ts +4 -5
- package/dist/components/Progress.js +77 -65
- package/dist/components/RadioGroup.d.ts +24 -30
- package/dist/components/RadioGroup.js +94 -90
- package/dist/components/Select.d.ts +74 -62
- package/dist/components/Select.js +154 -241
- package/dist/components/Separator.d.ts +23 -13
- package/dist/components/Separator.js +36 -29
- package/dist/components/Skeleton.d.ts +7 -8
- package/dist/components/Skeleton.js +61 -74
- package/dist/components/StyledText.context.d.ts +2 -6
- package/dist/components/StyledText.context.js +0 -3
- package/dist/components/StyledText.d.ts +7 -29
- package/dist/components/StyledText.js +29 -92
- package/dist/components/Switch.d.ts +6 -18
- package/dist/components/Switch.js +106 -112
- package/dist/components/Tabs.d.ts +16 -26
- package/dist/components/Tabs.js +91 -189
- package/dist/components/TextInput.d.ts +19 -6
- package/dist/components/TextInput.js +198 -226
- package/dist/components/Toggle.d.ts +41 -23
- package/dist/components/Toggle.js +98 -84
- package/dist/components/ToggleGroup.d.ts +29 -37
- package/dist/components/ToggleGroup.js +108 -113
- package/dist/components/Tooltip.d.ts +111 -41
- package/dist/components/Tooltip.js +118 -156
- package/dist/components/UIProvider.d.ts +1 -1
- package/dist/components/UIProvider.js +1 -1
- package/dist/components/keyboardFocusRegistry.d.ts +15 -1
- package/dist/components/keyboardFocusRegistry.js +39 -2
- package/dist/hooks/useTheme.d.ts +10 -34
- package/dist/hooks/useTheme.js +8 -20
- package/dist/lib/index.d.ts +0 -2
- package/dist/lib/index.js +0 -2
- package/package.json +29 -11
- package/dist/lib/portal.d.ts +0 -16
- package/dist/lib/portal.js +0 -84
- package/dist/lib/styles.d.ts +0 -32
- package/dist/lib/styles.js +0 -91
- package/dist/lib/useAnchoredPosition.d.ts +0 -57
- package/dist/lib/useAnchoredPosition.js +0 -120
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import { Platform, ScrollView, useWindowDimensions, } from "react-native";
|
|
5
|
-
import { css, html } from "react-strict-dom";
|
|
2
|
+
import React, { createContext, use, useCallback, useEffect, useMemo, useReducer, useRef, useSyncExternalStore } from "react";
|
|
3
|
+
import { View, Pressable, StyleSheet, ScrollView, Platform, useWindowDimensions, } from "react-native";
|
|
6
4
|
import { BottomSheet as NativeBottomSheet } from "@expo/ui/community/bottom-sheet";
|
|
5
|
+
import { KeyboardController } from "react-native-keyboard-controller";
|
|
7
6
|
import { useSafeAreaInsets, initialWindowMetrics } from "react-native-safe-area-context";
|
|
8
7
|
import { useTheme } from "../hooks/useTheme.js";
|
|
9
8
|
import { spacing } from "../constants/spacing.js";
|
|
10
9
|
import { TextColorContext, TextClassContext } from "./StyledText.context";
|
|
11
10
|
import { Icon } from "./Icon.js";
|
|
12
|
-
import {
|
|
13
|
-
const IS_WEB = Platform.OS === "web";
|
|
11
|
+
import { dismissKeyboardFocusedInput, hasKeyboardFocusedInput, subscribeKeyboardFocus, } from "./keyboardFocusRegistry.js";
|
|
14
12
|
const DEFAULT_SNAP_POINTS = ["50%"];
|
|
15
13
|
function clampSnapIndex(index, maxSnapIndex) {
|
|
16
14
|
return Math.min(Math.max(index, 0), maxSnapIndex);
|
|
@@ -35,7 +33,12 @@ function bottomSheetRootReducer(state, action) {
|
|
|
35
33
|
state.snapDirection === snapDirection) {
|
|
36
34
|
return state;
|
|
37
35
|
}
|
|
38
|
-
return {
|
|
36
|
+
return {
|
|
37
|
+
...state,
|
|
38
|
+
internalOpen: action.open,
|
|
39
|
+
snapIndex,
|
|
40
|
+
snapDirection,
|
|
41
|
+
};
|
|
39
42
|
}
|
|
40
43
|
case "setScrollable":
|
|
41
44
|
if (state.scrollable === action.scrollable)
|
|
@@ -54,7 +57,11 @@ function bottomSheetRootReducer(state, action) {
|
|
|
54
57
|
const snapDirection = snapIndex === 0 ? 1 : snapIndex === action.maxSnapIndex ? -1 : state.snapDirection;
|
|
55
58
|
if (state.snapIndex === snapIndex && state.snapDirection === snapDirection)
|
|
56
59
|
return state;
|
|
57
|
-
return {
|
|
60
|
+
return {
|
|
61
|
+
...state,
|
|
62
|
+
snapIndex,
|
|
63
|
+
snapDirection,
|
|
64
|
+
};
|
|
58
65
|
}
|
|
59
66
|
case "cycleSnapPoint": {
|
|
60
67
|
if (action.maxSnapIndex === 0)
|
|
@@ -68,7 +75,11 @@ function bottomSheetRootReducer(state, action) {
|
|
|
68
75
|
snapDirection = 1;
|
|
69
76
|
if (state.snapIndex >= action.maxSnapIndex)
|
|
70
77
|
snapDirection = -1;
|
|
71
|
-
return {
|
|
78
|
+
return {
|
|
79
|
+
...state,
|
|
80
|
+
snapIndex: state.snapIndex + snapDirection,
|
|
81
|
+
snapDirection,
|
|
82
|
+
};
|
|
72
83
|
}
|
|
73
84
|
}
|
|
74
85
|
}
|
|
@@ -84,9 +95,13 @@ function useBottomSheetContext() {
|
|
|
84
95
|
return context;
|
|
85
96
|
}
|
|
86
97
|
/**
|
|
87
|
-
* Safe-area insets for content inside the sheet.
|
|
88
|
-
*
|
|
89
|
-
*
|
|
98
|
+
* Safe-area insets for content *inside* the sheet.
|
|
99
|
+
*
|
|
100
|
+
* The native sheet (SwiftUI `.sheet()` / Material `ModalBottomSheet`) is
|
|
101
|
+
* presented outside the React tree's `SafeAreaProvider`, so `useSafeAreaInsets()`
|
|
102
|
+
* reads all-zero in here. Fall back to `initialWindowMetrics` — the same trick
|
|
103
|
+
* the app uses for its full-screen Modals — so bottom padding actually clears
|
|
104
|
+
* the home indicator and the last row of a scroll body is reachable.
|
|
90
105
|
*/
|
|
91
106
|
function useSheetInsets() {
|
|
92
107
|
const insets = useSafeAreaInsets();
|
|
@@ -98,64 +113,95 @@ function useSheetInsets() {
|
|
|
98
113
|
right: insets.right || fallback?.right || 0,
|
|
99
114
|
};
|
|
100
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Interactive (pull-down / backdrop) dismiss is off only when the consumer
|
|
118
|
+
* explicitly turned it off (`closeOnBackdropPress={false}`). Scrollable bodies
|
|
119
|
+
* keep swipe-to-dismiss: the native sheet coordinates the gesture itself (a pull
|
|
120
|
+
* at the top of the scroll view dismisses; elsewhere it scrolls), so there's no
|
|
121
|
+
* conflict to work around once the content column is height-bounded.
|
|
122
|
+
*/
|
|
101
123
|
function useDismissDisabled() {
|
|
102
124
|
const { closeOnBackdropPress } = useBottomSheetContext();
|
|
103
125
|
return !closeOnBackdropPress;
|
|
104
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* Whether to surface an explicit close affordance (the auto X). True when:
|
|
129
|
+
* - swipe/backdrop dismiss is off (otherwise there'd be no way to close), or
|
|
130
|
+
* - the body scrolls — so a long sheet always has a one-tap close without
|
|
131
|
+
* hunting for a footer button or pulling the sheet down.
|
|
132
|
+
*/
|
|
105
133
|
function useShowClose() {
|
|
106
134
|
const { scrollable } = useBottomSheetContext();
|
|
107
135
|
return useDismissDisabled() || scrollable;
|
|
108
136
|
}
|
|
109
137
|
// ============================================================================
|
|
110
|
-
//
|
|
111
|
-
// ============================================================================
|
|
112
|
-
const slotStyles = css.create({
|
|
113
|
-
wrap: { display: "inline-flex", margin: 0, padding: 0, cursor: "pointer" },
|
|
114
|
-
});
|
|
115
|
-
function SlotPressable({ asChild, onActivate, ariaLabel, children, style, }) {
|
|
116
|
-
if (asChild && React.isValidElement(children)) {
|
|
117
|
-
const child = children;
|
|
118
|
-
return React.cloneElement(child, {
|
|
119
|
-
onPress: () => {
|
|
120
|
-
child.props.onPress?.();
|
|
121
|
-
onActivate();
|
|
122
|
-
},
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
return (_jsx(html.div, { role: "button", "aria-label": ariaLabel, onClick: onActivate, style: [slotStyles.wrap, sanitizeWebStyle(style)], children: children }));
|
|
126
|
-
}
|
|
127
|
-
// ============================================================================
|
|
128
|
-
// Auto close button — shown when interactive dismiss is off / body scrolls
|
|
138
|
+
// Auto close button — shown when interactive dismiss is off
|
|
129
139
|
// ============================================================================
|
|
130
|
-
const closeButtonStyles = css.create({
|
|
131
|
-
// spacing literals inlined: StyleX requires statically-resolvable values
|
|
132
|
-
// inside css.create (xl=32, md=16, sm=8, xs=4, touchTarget=44, radiusFull=9999).
|
|
133
|
-
base: (backgroundColor, borderColor) => ({
|
|
134
|
-
width: 32,
|
|
135
|
-
height: 32,
|
|
136
|
-
borderRadius: 16,
|
|
137
|
-
display: "flex",
|
|
138
|
-
alignItems: "center",
|
|
139
|
-
justifyContent: "center",
|
|
140
|
-
backgroundColor,
|
|
141
|
-
borderWidth: 1,
|
|
142
|
-
borderStyle: "solid",
|
|
143
|
-
borderColor,
|
|
144
|
-
margin: 0,
|
|
145
|
-
padding: 0,
|
|
146
|
-
appearance: "none",
|
|
147
|
-
cursor: "pointer",
|
|
148
|
-
}),
|
|
149
|
-
});
|
|
150
140
|
function SheetCloseButton({ style }) {
|
|
151
141
|
const { onOpenChange } = useBottomSheetContext();
|
|
152
142
|
const { theme } = useTheme();
|
|
153
|
-
return (_jsx(
|
|
154
|
-
|
|
155
|
-
|
|
143
|
+
return (_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Close", onPress: () => onOpenChange(false), hitSlop: spacing.sm, style: ({ pressed }) => [
|
|
144
|
+
{
|
|
145
|
+
width: spacing.xl,
|
|
146
|
+
height: spacing.xl,
|
|
147
|
+
borderRadius: spacing.xl / 2,
|
|
148
|
+
alignItems: "center",
|
|
149
|
+
justifyContent: "center",
|
|
150
|
+
// Higher-contrast than `muted`: a solid `secondary` fill with a
|
|
151
|
+
// bordered edge so the control reads clearly against the card, and a
|
|
152
|
+
// full-strength `foreground` glyph instead of the faint muted one.
|
|
153
|
+
backgroundColor: theme.colors.secondary,
|
|
154
|
+
borderWidth: 1,
|
|
155
|
+
borderColor: theme.colors.border,
|
|
156
|
+
opacity: pressed ? 0.7 : 1,
|
|
157
|
+
},
|
|
158
|
+
Platform.OS === "web" && { cursor: "pointer" },
|
|
159
|
+
style,
|
|
156
160
|
], children: _jsx(Icon, { name: "x", size: 22, color: "text" }) }));
|
|
157
161
|
}
|
|
158
162
|
// ============================================================================
|
|
163
|
+
// Keyboard dismiss overlay — tap-away dismissal inside the sheet
|
|
164
|
+
// ============================================================================
|
|
165
|
+
/**
|
|
166
|
+
* Tap-away keyboard dismissal for sheet content.
|
|
167
|
+
*
|
|
168
|
+
* The native sheet (SwiftUI `.sheet()` / Material `ModalBottomSheet`) hosts its
|
|
169
|
+
* RN children in a separate native window via @expo/ui's `RNHostView`, OUTSIDE
|
|
170
|
+
* the app's `KeyboardProvider` / `KeyboardDismissBoundary`. Two consequences,
|
|
171
|
+
* both worked around here:
|
|
172
|
+
*
|
|
173
|
+
* 1. Detection: `react-native-keyboard-controller`'s `useKeyboardState` watches
|
|
174
|
+
* the main window and does NOT see a keyboard raised inside the sheet's
|
|
175
|
+
* window (the same isolation that makes `useSafeAreaInsets()` read zero in
|
|
176
|
+
* here). So presence is read from the `keyboardFocusRegistry` instead, which
|
|
177
|
+
* is fed by @expo/ui's own native focus events and is window-independent.
|
|
178
|
+
* 2. Dispatch: RN's JS responder chain (onStartShouldSetResponder*) is NOT
|
|
179
|
+
* dispatched across the `RNHostView` boundary on Android — only `Pressable`
|
|
180
|
+
* hit-testing (native `measure()`) works inside the host. So this is a
|
|
181
|
+
* transparent `Pressable` mounted ONLY while a field is focused, mirroring
|
|
182
|
+
* the app's `DismissKeyboardOverlay`. The tap blurs the focused field via
|
|
183
|
+
* its own native ref (`dismissKeyboardFocusedInput`), which resigns the
|
|
184
|
+
* responder regardless of window; `KeyboardController.dismiss()` is a
|
|
185
|
+
* best-effort fallback for the (rare) case with no registered blur handle.
|
|
186
|
+
*
|
|
187
|
+
* iOS doesn't strictly need this (SwiftUI resigns first-responder on outside
|
|
188
|
+
* taps for free), but the same path is harmless and keeps behavior identical.
|
|
189
|
+
*/
|
|
190
|
+
function SheetKeyboardDismissOverlay() {
|
|
191
|
+
const hasFocus = useSyncExternalStore(subscribeKeyboardFocus, hasKeyboardFocusedInput, () => false);
|
|
192
|
+
if (Platform.OS === "web" || !hasFocus)
|
|
193
|
+
return null;
|
|
194
|
+
return (_jsx(Pressable, { style: [StyleSheet.absoluteFill, styles.keyboardDismissOverlay], onPressIn: () => {
|
|
195
|
+
if (!dismissKeyboardFocusedInput())
|
|
196
|
+
KeyboardController.dismiss();
|
|
197
|
+
}, accessibilityLabel: "Dismiss keyboard", accessibilityRole: "button" }));
|
|
198
|
+
}
|
|
199
|
+
const styles = StyleSheet.create({
|
|
200
|
+
keyboardDismissOverlay: {
|
|
201
|
+
zIndex: 999,
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
// ============================================================================
|
|
159
205
|
// Root
|
|
160
206
|
// ============================================================================
|
|
161
207
|
function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen = false, snapPoints = DEFAULT_SNAP_POINTS, closeOnBackdropPress = true, children, }) {
|
|
@@ -164,9 +210,15 @@ function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenC
|
|
|
164
210
|
const isControlled = controlledOpen !== undefined;
|
|
165
211
|
const open = isControlled ? controlledOpen : state.internalOpen;
|
|
166
212
|
const snapIndex = clampSnapIndex(state.snapIndex, maxSnapIndex);
|
|
167
|
-
const setSnapIndex = useCallback((index) =>
|
|
213
|
+
const setSnapIndex = useCallback((index) => {
|
|
214
|
+
dispatch({ type: "setSnapIndex", index, maxSnapIndex });
|
|
215
|
+
}, [maxSnapIndex]);
|
|
168
216
|
const cycleSnapPoint = useCallback(() => {
|
|
169
|
-
dispatch({
|
|
217
|
+
dispatch({
|
|
218
|
+
type: "cycleSnapPoint",
|
|
219
|
+
maxSnapIndex,
|
|
220
|
+
android: Platform.OS === "android",
|
|
221
|
+
});
|
|
170
222
|
}, [maxSnapIndex]);
|
|
171
223
|
const onOpenChange = useCallback((newOpen) => {
|
|
172
224
|
if (isControlled) {
|
|
@@ -222,29 +274,24 @@ function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenC
|
|
|
222
274
|
// ============================================================================
|
|
223
275
|
// Trigger
|
|
224
276
|
// ============================================================================
|
|
225
|
-
function BottomSheetTrigger({ asChild, children, style }) {
|
|
277
|
+
function BottomSheetTrigger({ asChild, children, style: styleOverride }) {
|
|
226
278
|
const { toggle } = useBottomSheetContext();
|
|
227
|
-
|
|
279
|
+
const handlePress = () => toggle();
|
|
280
|
+
if (asChild && React.isValidElement(children)) {
|
|
281
|
+
return React.cloneElement(children, {
|
|
282
|
+
onPress: handlePress,
|
|
283
|
+
style: [
|
|
284
|
+
children.props.style,
|
|
285
|
+
Platform.OS === "web" && { cursor: "pointer" },
|
|
286
|
+
styleOverride,
|
|
287
|
+
],
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
return (_jsx(Pressable, { onPress: handlePress, style: [Platform.OS === "web" && { cursor: "pointer" }, styleOverride], children: children }));
|
|
228
291
|
}
|
|
229
292
|
// ============================================================================
|
|
230
|
-
// Content — renders the native sheet, wraps children in
|
|
293
|
+
// Content — renders the native sheet, wraps children in themed flex column
|
|
231
294
|
// ============================================================================
|
|
232
|
-
const contentStyles = css.create({
|
|
233
|
-
column: (maxHeight, backgroundColor) => ({
|
|
234
|
-
display: "flex",
|
|
235
|
-
flexDirection: "column",
|
|
236
|
-
flexGrow: 1,
|
|
237
|
-
flexShrink: 1,
|
|
238
|
-
flexBasis: "0%",
|
|
239
|
-
maxHeight,
|
|
240
|
-
backgroundColor,
|
|
241
|
-
}),
|
|
242
|
-
floatingClose: {
|
|
243
|
-
position: "absolute",
|
|
244
|
-
top: 16,
|
|
245
|
-
right: 16,
|
|
246
|
-
},
|
|
247
|
-
});
|
|
248
295
|
function BottomSheetContent({
|
|
249
296
|
// Accepted-but-ignored ergonomics props (platform owns these behaviors):
|
|
250
297
|
swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDrag: _dismissKeyboardOnDrag, style: styleOverride, children, }) {
|
|
@@ -253,14 +300,26 @@ swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDra
|
|
|
253
300
|
const dismissDisabled = useDismissDisabled();
|
|
254
301
|
const showClose = useShowClose();
|
|
255
302
|
const { height: winH } = useWindowDimensions();
|
|
303
|
+
// Boolean open → native imperative index. The root resets snapIndex to the
|
|
304
|
+
// highest point while closed; -1 keeps the native sheet closed.
|
|
256
305
|
const index = open ? snapIndex : -1;
|
|
257
|
-
//
|
|
258
|
-
//
|
|
306
|
+
// The native RN-in-SwiftUI host does NOT clamp our RN column to the sheet's
|
|
307
|
+
// detent height — it lays the column out at its full intrinsic content height,
|
|
308
|
+
// SwiftUI then clips it at the sheet edge (footer/tail fall off-screen), and
|
|
309
|
+
// `flex:1` never gets a definite parent height so the ScrollView can't bound
|
|
310
|
+
// and scroll. Cap the column to the expanded detent height (highest snap
|
|
311
|
+
// point) so `flex:1` resolves and the Body becomes scrollable.
|
|
259
312
|
const expandedSnap = snapPoints[snapPoints.length - 1];
|
|
260
|
-
const detentHeight = typeof expandedSnap === "number"
|
|
313
|
+
const detentHeight = typeof expandedSnap === "number"
|
|
314
|
+
? expandedSnap
|
|
315
|
+
: (parseFloat(expandedSnap) / 100) * winH;
|
|
316
|
+
// When there's no Header to host the X (so a close affordance is wanted —
|
|
317
|
+
// dismiss is off, or the body scrolls), float one over the top-right corner.
|
|
318
|
+
// A Header, when present, renders its own (see BottomSheetHeader).
|
|
261
319
|
const showFloatingClose = showClose && !hasHeader;
|
|
262
320
|
const hasInteractiveHandle = containsHandle(children);
|
|
263
321
|
const handleChange = (newIndex) => {
|
|
322
|
+
// Native fires onChange(-1) on dismiss (swipe / backdrop / back button).
|
|
264
323
|
if (newIndex < 0) {
|
|
265
324
|
if (open)
|
|
266
325
|
onOpenChange(false);
|
|
@@ -268,13 +327,31 @@ swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDra
|
|
|
268
327
|
}
|
|
269
328
|
setSnapIndex(newIndex);
|
|
270
329
|
};
|
|
271
|
-
return (_jsx(NativeBottomSheet, { index: index, snapPoints: snapPoints,
|
|
330
|
+
return (_jsx(NativeBottomSheet, { index: index, snapPoints: snapPoints,
|
|
331
|
+
// The native indicator lives outside the hosted RN tree and cannot
|
|
332
|
+
// receive JS presses. Hide it only when the compound Handle supplies the
|
|
333
|
+
// interactive replacement; sheets without Handle retain native chrome.
|
|
334
|
+
handleComponent: hasInteractiveHandle ? null : undefined, enablePanDownToClose: !dismissDisabled, onChange: handleChange, onClose: () => {
|
|
272
335
|
if (open)
|
|
273
336
|
onOpenChange(false);
|
|
274
|
-
},
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
337
|
+
},
|
|
338
|
+
// Themes the scrim/background on web (vaul) and Android (containerColor).
|
|
339
|
+
backgroundStyle: { backgroundColor: theme.colors.card }, children: _jsx(TextColorContext.Provider, { value: theme.colors.foreground, children: _jsx(TextClassContext.Provider, { value: "", children: _jsxs(View, { style: [
|
|
340
|
+
{
|
|
341
|
+
flex: 1,
|
|
342
|
+
maxHeight: detentHeight,
|
|
343
|
+
// Themes the content surface across all platforms regardless of
|
|
344
|
+
// native sheet chrome.
|
|
345
|
+
backgroundColor: theme.colors.card,
|
|
346
|
+
},
|
|
347
|
+
styleOverride,
|
|
348
|
+
], children: [children, _jsx(SheetKeyboardDismissOverlay, {}), showFloatingClose && (_jsx(SheetCloseButton, { style: {
|
|
349
|
+
// The sheet card sits below the status bar, so no top inset
|
|
350
|
+
// is needed — just clear the sheet's own top edge / handle.
|
|
351
|
+
position: "absolute",
|
|
352
|
+
top: spacing.md,
|
|
353
|
+
right: spacing.md,
|
|
354
|
+
} }))] }) }) }) }));
|
|
278
355
|
}
|
|
279
356
|
// ============================================================================
|
|
280
357
|
// Handle — pressable snap-point control; native drag gestures remain active
|
|
@@ -289,37 +366,25 @@ function containsHandle(node) {
|
|
|
289
366
|
return containsHandle(child.props.children);
|
|
290
367
|
});
|
|
291
368
|
}
|
|
292
|
-
const handleStyles = css.create({
|
|
293
|
-
button: {
|
|
294
|
-
display: "flex",
|
|
295
|
-
minHeight: 44, // spacing.touchTarget
|
|
296
|
-
alignItems: "center",
|
|
297
|
-
justifyContent: "center",
|
|
298
|
-
margin: 0,
|
|
299
|
-
padding: 0,
|
|
300
|
-
borderWidth: 0,
|
|
301
|
-
backgroundColor: "transparent",
|
|
302
|
-
appearance: "none",
|
|
303
|
-
width: "100%",
|
|
304
|
-
},
|
|
305
|
-
enabled: { cursor: "pointer" },
|
|
306
|
-
disabled: { cursor: "default" },
|
|
307
|
-
bar: (backgroundColor) => ({
|
|
308
|
-
width: 32, // spacing.xl
|
|
309
|
-
height: 4, // spacing.xs
|
|
310
|
-
borderRadius: 9999, // spacing.radiusFull
|
|
311
|
-
backgroundColor,
|
|
312
|
-
}),
|
|
313
|
-
});
|
|
314
369
|
function BottomSheetHandle({ style }) {
|
|
315
370
|
const { cycleSnapPoint, snapPoints } = useBottomSheetContext();
|
|
316
371
|
const { theme } = useTheme();
|
|
317
372
|
const disabled = snapPoints.length < 2;
|
|
318
|
-
return (_jsx(
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
373
|
+
return (_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Change sheet height", accessibilityHint: "Moves the sheet to the next snap point", accessibilityState: { disabled }, disabled: disabled, onPress: cycleSnapPoint, style: ({ pressed }) => [
|
|
374
|
+
{
|
|
375
|
+
minHeight: spacing.touchTarget,
|
|
376
|
+
alignItems: "center",
|
|
377
|
+
justifyContent: "center",
|
|
378
|
+
opacity: pressed ? 0.65 : 1,
|
|
379
|
+
},
|
|
380
|
+
Platform.OS === "web" && !disabled && { cursor: "pointer" },
|
|
381
|
+
style,
|
|
382
|
+
], children: _jsx(View, { style: {
|
|
383
|
+
width: spacing.xl,
|
|
384
|
+
height: spacing.xs,
|
|
385
|
+
borderRadius: spacing.radiusFull,
|
|
386
|
+
backgroundColor: theme.colors.mutedForeground,
|
|
387
|
+
} }) }));
|
|
323
388
|
}
|
|
324
389
|
// ============================================================================
|
|
325
390
|
// Header
|
|
@@ -334,69 +399,55 @@ function containsClose(node) {
|
|
|
334
399
|
return containsClose(child.props.children);
|
|
335
400
|
});
|
|
336
401
|
}
|
|
337
|
-
|
|
338
|
-
row: (borderBottomColor) => ({
|
|
339
|
-
display: "flex",
|
|
340
|
-
flexDirection: "row",
|
|
341
|
-
alignItems: "center",
|
|
342
|
-
gap: 8, // spacing.sm
|
|
343
|
-
paddingLeft: 16, // spacing.md
|
|
344
|
-
paddingRight: 16,
|
|
345
|
-
paddingTop: 16,
|
|
346
|
-
paddingBottom: 16,
|
|
347
|
-
borderBottomWidth: 1,
|
|
348
|
-
borderBottomStyle: "solid",
|
|
349
|
-
borderBottomColor,
|
|
350
|
-
}),
|
|
351
|
-
fill: { flexGrow: 1, flexShrink: 1, flexBasis: "0%" },
|
|
352
|
-
});
|
|
353
|
-
function BottomSheetHeader({ children, style }) {
|
|
402
|
+
function BottomSheetHeader({ children, style, ...props }) {
|
|
354
403
|
const { theme } = useTheme();
|
|
355
404
|
const { setHasHeader } = useBottomSheetContext();
|
|
356
405
|
const showClose = useShowClose();
|
|
406
|
+
// Tell Content a Header exists so it doesn't also float a close button.
|
|
357
407
|
useEffect(() => {
|
|
358
408
|
setHasHeader(true);
|
|
359
409
|
return () => setHasHeader(false);
|
|
360
410
|
}, [setHasHeader]);
|
|
411
|
+
// Auto X is a fallback: render it when a close affordance is wanted (dismiss
|
|
412
|
+
// off, or scrollable body) AND the consumer didn't already provide their own
|
|
413
|
+
// Close inside the header (avoids a double X).
|
|
361
414
|
const showAutoClose = showClose && !containsClose(children);
|
|
362
|
-
return (_jsxs(
|
|
415
|
+
return (_jsxs(View, { style: [
|
|
416
|
+
{
|
|
417
|
+
flexDirection: "row",
|
|
418
|
+
alignItems: "center",
|
|
419
|
+
gap: spacing.sm,
|
|
420
|
+
paddingHorizontal: spacing.md,
|
|
421
|
+
paddingVertical: spacing.md,
|
|
422
|
+
borderBottomWidth: 1,
|
|
423
|
+
borderBottomColor: theme.colors.border,
|
|
424
|
+
},
|
|
425
|
+
style,
|
|
426
|
+
], ...props, children: [_jsx(View, { style: { flex: 1 }, children: children }), showAutoClose && _jsx(SheetCloseButton, {})] }));
|
|
363
427
|
}
|
|
364
428
|
// ============================================================================
|
|
365
|
-
// Body
|
|
429
|
+
// Body
|
|
366
430
|
// ============================================================================
|
|
367
|
-
|
|
368
|
-
webScroller: {
|
|
369
|
-
display: "flex",
|
|
370
|
-
flexDirection: "column",
|
|
371
|
-
flexGrow: 1,
|
|
372
|
-
flexShrink: 1,
|
|
373
|
-
flexBasis: "0%",
|
|
374
|
-
overflowY: "auto",
|
|
375
|
-
},
|
|
376
|
-
webContent: (paddingBottom) => ({
|
|
377
|
-
display: "flex",
|
|
378
|
-
flexDirection: "column",
|
|
379
|
-
paddingLeft: 16, // spacing.md
|
|
380
|
-
paddingRight: 16,
|
|
381
|
-
paddingTop: 16,
|
|
382
|
-
paddingBottom,
|
|
383
|
-
}),
|
|
384
|
-
});
|
|
385
|
-
/** Native body: RN ScrollView with onLayout/onContentSizeChange overflow detection. */
|
|
386
|
-
function NativeBottomSheetBody({ children, style, contentContainerStyle, onContentSizeChange, onLayout, ...props }) {
|
|
431
|
+
function BottomSheetBody({ children, style, contentContainerStyle, onContentSizeChange, onLayout, ...props }) {
|
|
387
432
|
const { setScrollable, hasFooter } = useBottomSheetContext();
|
|
388
433
|
const insets = useSheetInsets();
|
|
434
|
+
// Track viewport vs content height to know whether the body actually
|
|
435
|
+
// overflows. Only a genuinely-scrolling body needs the detent height cap and
|
|
436
|
+
// the auto close X; short bodies size naturally and skip both.
|
|
389
437
|
const viewportH = useRef(0);
|
|
390
438
|
const contentH = useRef(0);
|
|
391
439
|
const evaluate = useCallback(() => {
|
|
392
440
|
const overflowing = contentH.current > viewportH.current + 1;
|
|
393
441
|
setScrollable(overflowing);
|
|
394
442
|
}, [setScrollable]);
|
|
443
|
+
// Reset the shared flag on unmount so a reused root doesn't stay "scrollable".
|
|
395
444
|
useEffect(() => () => setScrollable(false), [setScrollable]);
|
|
396
445
|
return (_jsx(ScrollView, { style: [{ flex: 1 }, style], contentContainerStyle: [
|
|
397
446
|
{
|
|
398
447
|
paddingHorizontal: spacing.md,
|
|
399
448
|
paddingTop: spacing.md,
|
|
449
|
+
// When no Footer owns the bottom inset, the body must clear the home
|
|
450
|
+
// indicator so the last item is reachable.
|
|
400
451
|
paddingBottom: spacing.md + (hasFooter ? 0 : insets.bottom),
|
|
401
452
|
},
|
|
402
453
|
contentContainerStyle,
|
|
@@ -410,63 +461,46 @@ function NativeBottomSheetBody({ children, style, contentContainerStyle, onConte
|
|
|
410
461
|
onContentSizeChange?.(w, h);
|
|
411
462
|
}, ...props, children: children }));
|
|
412
463
|
}
|
|
413
|
-
/** Web body: html.div with overflow:auto; overflow detected via scrollHeight. */
|
|
414
|
-
function WebBottomSheetBody({ children, style, testID }) {
|
|
415
|
-
const { setScrollable, hasFooter } = useBottomSheetContext();
|
|
416
|
-
const insets = useSheetInsets();
|
|
417
|
-
const scrollerRef = useRef(null);
|
|
418
|
-
useLayoutEffect(() => {
|
|
419
|
-
const node = scrollerRef.current;
|
|
420
|
-
if (!node)
|
|
421
|
-
return;
|
|
422
|
-
const measure = () => setScrollable(node.scrollHeight > node.clientHeight + 1);
|
|
423
|
-
measure();
|
|
424
|
-
if (typeof window === "undefined")
|
|
425
|
-
return;
|
|
426
|
-
window.addEventListener("resize", measure);
|
|
427
|
-
return () => window.removeEventListener("resize", measure);
|
|
428
|
-
});
|
|
429
|
-
useEffect(() => () => setScrollable(false), [setScrollable]);
|
|
430
|
-
return (_jsx(html.div, { ref: scrollerRef, "data-testid": testID, style: [bodyStyles.webScroller, sanitizeWebStyle(style)], children: _jsx(html.div, { style: bodyStyles.webContent(spacing.md + (hasFooter ? 0 : insets.bottom)), children: children }) }));
|
|
431
|
-
}
|
|
432
|
-
function BottomSheetBody(props) {
|
|
433
|
-
return IS_WEB ? _jsx(WebBottomSheetBody, { ...props }) : _jsx(NativeBottomSheetBody, { ...props });
|
|
434
|
-
}
|
|
435
464
|
// ============================================================================
|
|
436
465
|
// Footer
|
|
437
466
|
// ============================================================================
|
|
438
|
-
|
|
439
|
-
base: (paddingBottom, borderTopColor) => ({
|
|
440
|
-
display: "flex",
|
|
441
|
-
flexDirection: "column",
|
|
442
|
-
paddingLeft: 16, // spacing.md
|
|
443
|
-
paddingRight: 16,
|
|
444
|
-
paddingTop: 16,
|
|
445
|
-
paddingBottom,
|
|
446
|
-
borderTopWidth: 1,
|
|
447
|
-
borderTopStyle: "solid",
|
|
448
|
-
borderTopColor,
|
|
449
|
-
}),
|
|
450
|
-
});
|
|
451
|
-
function BottomSheetFooter({ children, style }) {
|
|
467
|
+
function BottomSheetFooter({ children, style, ...props }) {
|
|
452
468
|
const { theme } = useTheme();
|
|
453
469
|
const insets = useSheetInsets();
|
|
454
470
|
const { setHasFooter } = useBottomSheetContext();
|
|
471
|
+
// Tell Body a Footer owns the bottom safe-area inset, so Body doesn't add it too.
|
|
455
472
|
useEffect(() => {
|
|
456
473
|
setHasFooter(true);
|
|
457
474
|
return () => setHasFooter(false);
|
|
458
475
|
}, [setHasFooter]);
|
|
459
|
-
return (_jsx(
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
476
|
+
return (_jsx(View, { style: [
|
|
477
|
+
{
|
|
478
|
+
paddingHorizontal: spacing.md,
|
|
479
|
+
paddingTop: spacing.md,
|
|
480
|
+
paddingBottom: spacing.md + insets.bottom,
|
|
481
|
+
borderTopWidth: 1,
|
|
482
|
+
borderTopColor: theme.colors.border,
|
|
483
|
+
},
|
|
484
|
+
style,
|
|
485
|
+
], ...props, children: children }));
|
|
463
486
|
}
|
|
464
487
|
// ============================================================================
|
|
465
488
|
// Close
|
|
466
489
|
// ============================================================================
|
|
467
|
-
function BottomSheetClose({ asChild, children, style }) {
|
|
490
|
+
function BottomSheetClose({ asChild, children, style: styleOverride }) {
|
|
468
491
|
const { onOpenChange } = useBottomSheetContext();
|
|
469
|
-
|
|
492
|
+
const handlePress = () => onOpenChange(false);
|
|
493
|
+
if (asChild && React.isValidElement(children)) {
|
|
494
|
+
return React.cloneElement(children, {
|
|
495
|
+
onPress: handlePress,
|
|
496
|
+
style: [
|
|
497
|
+
children.props.style,
|
|
498
|
+
Platform.OS === "web" && { cursor: "pointer" },
|
|
499
|
+
styleOverride,
|
|
500
|
+
],
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
return (_jsx(Pressable, { onPress: handlePress, style: [Platform.OS === "web" && { cursor: "pointer" }, styleOverride], children: children }));
|
|
470
504
|
}
|
|
471
505
|
// ============================================================================
|
|
472
506
|
// Compound export
|