@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.
Files changed (81) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LLM_USAGE.md +15 -0
  3. package/README.md +15 -1
  4. package/dist/components/Accordion.d.ts +18 -38
  5. package/dist/components/Accordion.js +121 -137
  6. package/dist/components/AnimatedView.d.ts +24 -13
  7. package/dist/components/AnimatedView.js +29 -70
  8. package/dist/components/Badge.d.ts +2 -2
  9. package/dist/components/Badge.js +47 -45
  10. package/dist/components/BottomSheet.d.ts +73 -30
  11. package/dist/components/BottomSheet.js +225 -191
  12. package/dist/components/Button.d.ts +56 -43
  13. package/dist/components/Button.js +203 -189
  14. package/dist/components/Card.d.ts +10 -7
  15. package/dist/components/Card.js +105 -90
  16. package/dist/components/Checkbox.d.ts +8 -20
  17. package/dist/components/Checkbox.js +80 -77
  18. package/dist/components/Collapsible.d.ts +47 -47
  19. package/dist/components/Collapsible.js +29 -108
  20. package/dist/components/Dialog.d.ts +121 -80
  21. package/dist/components/Dialog.js +132 -225
  22. package/dist/components/DismissKeyboard.js +2 -33
  23. package/dist/components/Drawer.d.ts +83 -37
  24. package/dist/components/Drawer.js +512 -252
  25. package/dist/components/DropdownMenu.d.ts +113 -106
  26. package/dist/components/DropdownMenu.js +204 -350
  27. package/dist/components/EmptyState.d.ts +2 -2
  28. package/dist/components/EmptyState.js +34 -41
  29. package/dist/components/InputOTP.d.ts +53 -19
  30. package/dist/components/InputOTP.js +102 -81
  31. package/dist/components/KeyboardAvoidingView.d.ts +3 -6
  32. package/dist/components/KeyboardAvoidingView.js +5 -18
  33. package/dist/components/Label.d.ts +17 -12
  34. package/dist/components/Label.js +51 -38
  35. package/dist/components/MaxWidthContainer.d.ts +16 -5
  36. package/dist/components/MaxWidthContainer.js +29 -28
  37. package/dist/components/Notification.d.ts +9 -5
  38. package/dist/components/Notification.js +187 -190
  39. package/dist/components/Popover.d.ts +66 -38
  40. package/dist/components/Popover.js +69 -158
  41. package/dist/components/Progress.d.ts +4 -5
  42. package/dist/components/Progress.js +77 -65
  43. package/dist/components/RadioGroup.d.ts +24 -30
  44. package/dist/components/RadioGroup.js +94 -90
  45. package/dist/components/Select.d.ts +74 -62
  46. package/dist/components/Select.js +154 -241
  47. package/dist/components/Separator.d.ts +23 -13
  48. package/dist/components/Separator.js +36 -29
  49. package/dist/components/Skeleton.d.ts +7 -8
  50. package/dist/components/Skeleton.js +61 -74
  51. package/dist/components/StyledText.context.d.ts +2 -6
  52. package/dist/components/StyledText.context.js +0 -3
  53. package/dist/components/StyledText.d.ts +7 -29
  54. package/dist/components/StyledText.js +29 -92
  55. package/dist/components/Switch.d.ts +6 -18
  56. package/dist/components/Switch.js +106 -112
  57. package/dist/components/Tabs.d.ts +16 -26
  58. package/dist/components/Tabs.js +91 -189
  59. package/dist/components/TextInput.d.ts +19 -6
  60. package/dist/components/TextInput.js +198 -226
  61. package/dist/components/Toggle.d.ts +41 -23
  62. package/dist/components/Toggle.js +98 -84
  63. package/dist/components/ToggleGroup.d.ts +29 -37
  64. package/dist/components/ToggleGroup.js +108 -113
  65. package/dist/components/Tooltip.d.ts +111 -41
  66. package/dist/components/Tooltip.js +118 -156
  67. package/dist/components/UIProvider.d.ts +1 -1
  68. package/dist/components/UIProvider.js +1 -1
  69. package/dist/components/keyboardFocusRegistry.d.ts +15 -1
  70. package/dist/components/keyboardFocusRegistry.js +39 -2
  71. package/dist/hooks/useTheme.d.ts +10 -34
  72. package/dist/hooks/useTheme.js +8 -20
  73. package/dist/lib/index.d.ts +0 -2
  74. package/dist/lib/index.js +0 -2
  75. package/package.json +29 -11
  76. package/dist/lib/portal.d.ts +0 -16
  77. package/dist/lib/portal.js +0 -84
  78. package/dist/lib/styles.d.ts +0 -32
  79. package/dist/lib/styles.js +0 -91
  80. package/dist/lib/useAnchoredPosition.d.ts +0 -57
  81. 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 * as React from "react";
3
- import { createContext, use, useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, } from "react";
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 { sanitizeWebStyle } from "../lib/styles.js";
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 { ...state, internalOpen: action.open, snapIndex, snapDirection };
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 { ...state, snapIndex, snapDirection };
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 { ...state, snapIndex: state.snapIndex + snapDirection, snapDirection };
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. The native sheet is presented
88
- * outside the React tree's SafeAreaProvider, so useSafeAreaInsets() reads zero;
89
- * fall back to initialWindowMetrics (the same trick the app uses for Modals).
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
- // Shared slot pressable (asChild → clone+inject onPress, like Dialog/Drawer)
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(html.button, { type: "button", "aria-label": "Close", onClick: () => onOpenChange(false), style: [
154
- closeButtonStyles.base(theme.colors.secondary, theme.colors.border),
155
- sanitizeWebStyle(style),
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) => dispatch({ type: "setSnapIndex", index, maxSnapIndex }), [maxSnapIndex]);
213
+ const setSnapIndex = useCallback((index) => {
214
+ dispatch({ type: "setSnapIndex", index, maxSnapIndex });
215
+ }, [maxSnapIndex]);
168
216
  const cycleSnapPoint = useCallback(() => {
169
- dispatch({ type: "cycleSnapPoint", maxSnapIndex, android: Platform.OS === "android" });
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
- return (_jsx(SlotPressable, { asChild: asChild, onActivate: toggle, style: style, children: children }));
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 a themed flex column
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
- // Cap the RN column to the expanded detent height so flex:1 resolves and the
258
- // Body becomes scrollable (the native host doesn't clamp it for us).
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" ? expandedSnap : (parseFloat(expandedSnap) / 100) * winH;
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, handleComponent: hasInteractiveHandle ? null : undefined, enablePanDownToClose: !dismissDisabled, onChange: handleChange, onClose: () => {
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
- }, backgroundStyle: { backgroundColor: theme.colors.card }, children: _jsx(TextColorContext.Provider, { value: theme.colors.foreground, children: _jsx(TextClassContext.Provider, { value: "", children: _jsxs(html.div, { style: [
275
- contentStyles.column(detentHeight, theme.colors.card),
276
- sanitizeWebStyle(styleOverride),
277
- ], children: [children, showFloatingClose && _jsx(SheetCloseButton, { style: contentStyles.floatingClose })] }) }) }) }));
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(html.button, { type: "button", "aria-label": "Change sheet height", "aria-disabled": disabled, disabled: disabled, onClick: () => !disabled && cycleSnapPoint(), style: [
319
- handleStyles.button,
320
- disabled ? handleStyles.disabled : handleStyles.enabled,
321
- sanitizeWebStyle(style),
322
- ], children: _jsx(html.div, { style: handleStyles.bar(theme.colors.mutedForeground) }) }));
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
- const headerStyles = css.create({
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(html.div, { style: [headerStyles.row(theme.colors.border), sanitizeWebStyle(style)], children: [_jsx(html.div, { style: headerStyles.fill, children: children }), showAutoClose && _jsx(SheetCloseButton, {})] }));
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 — platform split (native RN ScrollView / web html.div overflow)
429
+ // Body
366
430
  // ============================================================================
367
- const bodyStyles = css.create({
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
- const footerStyles = css.create({
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(html.div, { style: [
460
- footerStyles.base(spacing.md + insets.bottom, theme.colors.border),
461
- sanitizeWebStyle(style),
462
- ], children: children }));
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
- return (_jsx(SlotPressable, { asChild: asChild, onActivate: () => onOpenChange(false), style: style, children: children }));
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