@mrmeg/expo-ui 0.27.0 → 0.28.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 (145) hide show
  1. package/CHANGELOG.md +712 -0
  2. package/LLM_USAGE.md +260 -32
  3. package/README.md +558 -101
  4. package/dist/components/Accordion.js +12 -6
  5. package/dist/components/AnimatedView.d.ts +1 -1
  6. package/dist/components/AnimatedView.js +5 -2
  7. package/dist/components/Avatar.d.ts +1 -1
  8. package/dist/components/Avatar.js +2 -2
  9. package/dist/components/Badge.js +5 -1
  10. package/dist/components/BottomSheet.d.ts +66 -9
  11. package/dist/components/BottomSheet.js +143 -39
  12. package/dist/components/Button.d.ts +20 -4
  13. package/dist/components/Button.js +64 -43
  14. package/dist/components/Card.d.ts +1 -1
  15. package/dist/components/Card.js +24 -4
  16. package/dist/components/Carousel.js +2 -2
  17. package/dist/components/Checkbox.js +51 -44
  18. package/dist/components/Collapsible.js +7 -3
  19. package/dist/components/Dialog.js +178 -29
  20. package/dist/components/Drawer.d.ts +1 -1
  21. package/dist/components/Drawer.js +65 -55
  22. package/dist/components/DropdownMenu.js +11 -7
  23. package/dist/components/EmptyState.d.ts +2 -2
  24. package/dist/components/EmptyState.js +5 -2
  25. package/dist/components/Hydrated.d.ts +31 -0
  26. package/dist/components/Hydrated.js +30 -0
  27. package/dist/components/Icon.d.ts +17 -5
  28. package/dist/components/Icon.js +31 -10
  29. package/dist/components/InputOTP.js +7 -3
  30. package/dist/components/Item.d.ts +103 -4
  31. package/dist/components/Item.js +168 -15
  32. package/dist/components/KeyboardAvoidingView.d.ts +1 -1
  33. package/dist/components/KeyboardAvoidingView.js +4 -1
  34. package/dist/components/Label.js +3 -2
  35. package/dist/components/MaxWidthContainer.js +1 -1
  36. package/dist/components/Notification.js +45 -27
  37. package/dist/components/Popover.d.ts +18 -5
  38. package/dist/components/Popover.js +72 -8
  39. package/dist/components/Progress.js +8 -6
  40. package/dist/components/RadioGroup.js +52 -49
  41. package/dist/components/Screen.d.ts +61 -0
  42. package/dist/components/Screen.js +62 -0
  43. package/dist/components/SectionHeader.d.ts +1 -1
  44. package/dist/components/SegmentedControl.d.ts +12 -4
  45. package/dist/components/SegmentedControl.js +94 -4
  46. package/dist/components/Select.js +21 -26
  47. package/dist/components/Skeleton.js +24 -10
  48. package/dist/components/Slider.d.ts +11 -3
  49. package/dist/components/Slider.js +74 -6
  50. package/dist/components/StatCard.d.ts +4 -2
  51. package/dist/components/StatCard.js +3 -1
  52. package/dist/components/StyledText.d.ts +7 -1
  53. package/dist/components/StyledText.js +5 -3
  54. package/dist/components/Switch.d.ts +1 -1
  55. package/dist/components/Switch.js +23 -32
  56. package/dist/components/Tabs.d.ts +2 -2
  57. package/dist/components/Tabs.js +13 -24
  58. package/dist/components/TextInput.d.ts +1 -1
  59. package/dist/components/TextInput.js +73 -27
  60. package/dist/components/Toggle.d.ts +2 -42
  61. package/dist/components/Toggle.js +38 -27
  62. package/dist/components/ToggleGroup.d.ts +1 -1
  63. package/dist/components/ToggleGroup.js +37 -32
  64. package/dist/components/Tooltip.d.ts +1 -1
  65. package/dist/components/Tooltip.js +9 -3
  66. package/dist/components/UIProvider.d.ts +22 -2
  67. package/dist/components/UIProvider.js +15 -3
  68. package/dist/components/bottomSheetDismiss.d.ts +19 -0
  69. package/dist/components/bottomSheetDismiss.js +44 -0
  70. package/dist/components/bottomSheetHostSupport.d.ts +46 -0
  71. package/dist/components/bottomSheetHostSupport.js +67 -0
  72. package/dist/components/index.d.ts +46 -44
  73. package/dist/components/index.js +2 -0
  74. package/dist/components/keyboardDismiss.d.ts +25 -0
  75. package/dist/components/keyboardDismiss.js +60 -3
  76. package/dist/components/nativeTextField.android.js +13 -9
  77. package/dist/constants/colors.d.ts +40 -5
  78. package/dist/constants/colors.js +26 -8
  79. package/dist/constants/fonts.d.ts +41 -2
  80. package/dist/constants/fonts.js +46 -7
  81. package/dist/constants/index.d.ts +5 -4
  82. package/dist/constants/index.js +1 -0
  83. package/dist/constants/interaction.d.ts +18 -0
  84. package/dist/constants/interaction.js +18 -0
  85. package/dist/constants/spacing.d.ts +2 -1
  86. package/dist/constants/spacing.js +2 -1
  87. package/dist/design-system.json +154 -2
  88. package/dist/hooks/index.d.ts +12 -7
  89. package/dist/hooks/index.js +5 -0
  90. package/dist/hooks/useDimensions.d.ts +13 -8
  91. package/dist/hooks/useDimensions.js +123 -48
  92. package/dist/hooks/useFocusVisible.d.ts +30 -0
  93. package/dist/hooks/useFocusVisible.js +45 -0
  94. package/dist/hooks/useFontStyle.d.ts +2 -2
  95. package/dist/hooks/useFontStyle.js +3 -2
  96. package/dist/hooks/useHydrated.d.ts +13 -0
  97. package/dist/hooks/useHydrated.js +19 -0
  98. package/dist/hooks/useNotificationOffset.d.ts +20 -0
  99. package/dist/hooks/useNotificationOffset.js +34 -0
  100. package/dist/hooks/useResources.d.ts +21 -20
  101. package/dist/hooks/useResources.js +72 -68
  102. package/dist/hooks/useScalePress.d.ts +8 -5
  103. package/dist/hooks/useScalePress.js +16 -9
  104. package/dist/hooks/useShape.d.ts +24 -0
  105. package/dist/hooks/useShape.js +24 -0
  106. package/dist/hooks/useStaggeredEntrance.js +9 -7
  107. package/dist/hooks/useTheme.d.ts +3 -3
  108. package/dist/hooks/useTheme.js +177 -66
  109. package/dist/hooks/useWindowInsets.d.ts +13 -0
  110. package/dist/hooks/useWindowInsets.js +23 -0
  111. package/dist/index.d.ts +5 -5
  112. package/dist/lib/composeRefs.d.ts +8 -0
  113. package/dist/lib/composeRefs.js +20 -0
  114. package/dist/lib/haptics.d.ts +12 -0
  115. package/dist/lib/haptics.js +21 -0
  116. package/dist/lib/index.d.ts +6 -4
  117. package/dist/lib/index.js +2 -0
  118. package/dist/lib/interFonts.d.ts +15 -0
  119. package/dist/lib/interFonts.js +1 -0
  120. package/dist/lib/interFonts.native.d.ts +3 -0
  121. package/dist/lib/interFonts.native.js +20 -0
  122. package/dist/lib/popoverPlacement.d.ts +36 -0
  123. package/dist/lib/popoverPlacement.js +29 -0
  124. package/dist/lib/stateSurface.d.ts +28 -0
  125. package/dist/lib/stateSurface.js +30 -0
  126. package/dist/lib/themedStyles.d.ts +1 -1
  127. package/dist/lib/useAnimatedValue.d.ts +15 -0
  128. package/dist/lib/useAnimatedValue.js +19 -0
  129. package/dist/lib/webHitTarget.d.ts +40 -0
  130. package/dist/lib/webHitTarget.js +37 -0
  131. package/dist/state/feedbackStore.d.ts +24 -0
  132. package/dist/state/feedbackStore.js +16 -0
  133. package/dist/state/globalUIStore.d.ts +27 -0
  134. package/dist/state/globalUIStore.js +23 -0
  135. package/dist/state/index.d.ts +7 -5
  136. package/dist/state/index.js +2 -0
  137. package/dist/state/initialScheme.d.ts +27 -0
  138. package/dist/state/initialScheme.js +29 -0
  139. package/dist/state/notify.d.ts +1 -1
  140. package/dist/state/themeColorScope.d.ts +1 -1
  141. package/dist/state/themeStore.d.ts +69 -16
  142. package/dist/state/themeStore.js +65 -38
  143. package/llms-full.md +56 -8
  144. package/llms.txt +10 -1
  145. package/package.json +69 -2
@@ -1,12 +1,15 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useRef, useState } from "react";
2
+ import { useEffect, useState } from "react";
3
3
  import { Animated, Platform, Pressable, View } from "react-native";
4
4
  import { Icon } from "./Icon.js";
5
- import { TextClassContext, TextSelectabilityContext } from "./StyledText.context";
5
+ import { TextClassContext, TextSelectabilityContext } from "./StyledText.context.js";
6
6
  import { useTheme } from "../hooks/useTheme.js";
7
7
  import { useReducedMotion } from "../hooks/useReduceMotion.js";
8
8
  import { useScalePress } from "../hooks/useScalePress.js";
9
+ import { useFocusVisible } from "../hooks/useFocusVisible.js";
10
+ import { useAnimatedValue } from "../lib/useAnimatedValue.js";
9
11
  import { spacing } from "../constants/spacing.js";
12
+ import { shouldUseNativeDriver } from "../lib/animations.js";
10
13
  import * as AccordionPrimitive from "@rn-primitives/accordion";
11
14
  function normalizeSingleValue(value) {
12
15
  return value ?? "";
@@ -98,21 +101,22 @@ const Trigger = Platform.OS === "web" ? View : Pressable;
98
101
  * Includes animated chevron icon
99
102
  */
100
103
  function AccordionTrigger({ children, style: styleOverride, disabled, ...props }) {
101
- const { theme } = useTheme();
104
+ const { theme, getFocusRingStyle } = useTheme();
102
105
  const reduceMotion = useReducedMotion();
103
106
  const { isExpanded } = AccordionPrimitive.useItemContext();
104
- const rotation = useRef(new Animated.Value(isExpanded ? 1 : 0)).current;
107
+ const rotation = useAnimatedValue(isExpanded ? 1 : 0);
105
108
  const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
106
109
  disabled: !!disabled,
107
110
  scaleTo: 0.97,
108
111
  haptic: false,
109
112
  });
113
+ const focus = useFocusVisible();
110
114
  useEffect(() => {
111
115
  const target = isExpanded ? 1 : 0;
112
116
  Animated.timing(rotation, {
113
117
  toValue: target,
114
118
  duration: reduceMotion ? 0 : isExpanded ? 200 : 150,
115
- useNativeDriver: true,
119
+ useNativeDriver: shouldUseNativeDriver,
116
120
  }).start();
117
121
  }, [isExpanded, reduceMotion, rotation]);
118
122
  const chevronStyle = {
@@ -125,7 +129,7 @@ function AccordionTrigger({ children, style: styleOverride, disabled, ...props }
125
129
  },
126
130
  ],
127
131
  };
128
- return (_jsx(TextClassContext.Provider, { value: "", children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(AccordionPrimitive.Header, { children: _jsx(Animated.View, { style: scaleStyle, children: _jsx(AccordionPrimitive.Trigger, { ...props, disabled: disabled, asChild: true, children: _jsxs(Trigger, { onPressIn: pressHandlers.onPressIn, onPressOut: pressHandlers.onPressOut, style: [
132
+ return (_jsx(TextClassContext.Provider, { value: "", children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(AccordionPrimitive.Header, { children: _jsx(Animated.View, { style: scaleStyle, children: _jsx(AccordionPrimitive.Trigger, { ...props, disabled: disabled, asChild: true, children: _jsxs(Trigger, { onPressIn: pressHandlers.onPressIn, onPressOut: pressHandlers.onPressOut, onFocus: focus.onFocus, onBlur: focus.onBlur, style: [
129
133
  {
130
134
  flexDirection: "row",
131
135
  alignItems: "center",
@@ -136,8 +140,10 @@ function AccordionTrigger({ children, style: styleOverride, disabled, ...props }
136
140
  ...(Platform.OS === "web" && {
137
141
  cursor: "pointer",
138
142
  userSelect: "none",
143
+ outlineStyle: "none",
139
144
  }),
140
145
  },
146
+ focus.focused && !disabled && getFocusRingStyle(),
141
147
  // Spread array styles from primitives to prevent nested arrays on web
142
148
  ...(styleOverride && typeof styleOverride !== "function"
143
149
  ? (Array.isArray(styleOverride) ? styleOverride : [styleOverride])
@@ -51,5 +51,5 @@ interface AnimatedViewProps extends ViewProps {
51
51
  * </AnimatedView>
52
52
  * ```
53
53
  */
54
- export declare function AnimatedView({ children, type, enterDuration, delay, style, ...props }: AnimatedViewProps): React.JSX.Element;
54
+ export declare function AnimatedView({ children, type, enterDuration, delay, style, pointerEvents, ...props }: AnimatedViewProps): React.JSX.Element;
55
55
  export {};
@@ -29,11 +29,14 @@ import { useStaggeredEntrance } from "../hooks/useStaggeredEntrance.js";
29
29
  * </AnimatedView>
30
30
  * ```
31
31
  */
32
- export function AnimatedView({ children, type = "fade", enterDuration = 200, delay = 0, style, ...props }) {
32
+ export function AnimatedView({ children, type = "fade", enterDuration = 200, delay = 0, style, pointerEvents, ...props }) {
33
33
  const entranceStyle = useStaggeredEntrance({
34
34
  type,
35
35
  delay,
36
36
  duration: enterDuration,
37
37
  });
38
- return (_jsx(Animated.View, { style: [style, entranceStyle], ...props, children: children }));
38
+ // The `pointerEvents` prop is deprecated on web (react-native-web warns and
39
+ // lazily loads its warning module mid-render, which overflowed the first SSR
40
+ // render of deep routes); keep accepting it for consumers, but as a style.
41
+ return (_jsx(Animated.View, { style: [style, pointerEvents ? { pointerEvents } : null, entranceStyle], ...props, children: children }));
39
42
  }
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
2
  import { type ImageSourcePropType, type StyleProp, type ViewStyle } from "react-native";
3
- import { type IconName } from "./Icon";
3
+ import { type IconName } from "./Icon.js";
4
4
  /** Named size tokens, or an explicit pixel diameter. */
5
5
  export type AvatarSize = "sm" | "md" | "lg" | number;
6
6
  export type AvatarShape = "circle" | "square";
@@ -223,7 +223,7 @@ const SIZE_TOKEN_FOR_PX = {
223
223
  // Geometry carries no theme values, so a plain module-scope sheet is enough to
224
224
  // get these rules into the prerendered <head>. Only colors go through
225
225
  // createThemedStyles below. See "Enable Server Output" in docs/server-guide.md.
226
- const geometry = StyleSheet.create({
226
+ const geometry = /*#__PURE__*/ StyleSheet.create({
227
227
  base: {
228
228
  alignItems: "center",
229
229
  justifyContent: "center",
@@ -297,5 +297,5 @@ const createStyles = (theme) => StyleSheet.create({
297
297
  color: theme.colors.accentForeground,
298
298
  },
299
299
  });
300
- const themedStyles = createThemedStyles(createStyles);
300
+ const themedStyles = /*#__PURE__*/ createThemedStyles(createStyles);
301
301
  export { Avatar, AvatarGroup };
@@ -2,6 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import React from "react";
3
3
  import { View, StyleSheet } from "react-native";
4
4
  import { useTheme } from "../hooks/useTheme.js";
5
+ import { shapeRadius, useShape } from "../hooks/useShape.js";
5
6
  import { spacing } from "../constants/spacing.js";
6
7
  import { StyledText } from "./StyledText.js";
7
8
  import { createThemedStyles } from "../lib/themedStyles.js";
@@ -21,6 +22,8 @@ import { createThemedStyles } from "../lib/themedStyles.js";
21
22
  function Badge({ children, text, variant = "default", style: styleOverride }) {
22
23
  const { theme } = useTheme();
23
24
  const styles = themedStyles(theme);
25
+ // Host-app `setShape({ badge })`; the default is a pill.
26
+ const badgeRadius = shapeRadius(useShape("badge"));
24
27
  const badgeContent = text ?? children;
25
28
  const textStyle = [
26
29
  styles.text,
@@ -43,6 +46,7 @@ function Badge({ children, text, variant = "default", style: styleOverride }) {
43
46
  }));
44
47
  return (_jsx(View, { accessibilityRole: "text", style: [
45
48
  styles.badge,
49
+ badgeRadius,
46
50
  variant === "default" && styles.default,
47
51
  variant === "secondary" && styles.secondary,
48
52
  variant === "outline" && styles.outline,
@@ -75,5 +79,5 @@ const createStyles = (theme) => StyleSheet.create({
75
79
  userSelect: "none",
76
80
  },
77
81
  });
78
- const themedStyles = createThemedStyles(createStyles);
82
+ const themedStyles = /*#__PURE__*/ createThemedStyles(createStyles);
79
83
  export { Badge };
@@ -20,8 +20,18 @@ import { ViewProps, StyleProp, ViewStyle, ScrollViewProps } from "react-native";
20
20
  * nothing in JS can measure the column in screen space (`measureInWindow`
21
21
  * inside the `layoutRoot` sheet host reports host-relative coordinates), and a
22
22
  * window-height estimate would lift a lifted sheet twice. Android: Material3's
23
- * `ModalBottomSheet` owns it (no JS keyboard signal exists inside the Compose
24
- * dialog window). Web: none. Never nest a `KeyboardAvoidingView` in a sheet.
23
+ * `ModalBottomSheet` shrinks the sheet with `imePadding()`, `@expo/ui`'s
24
+ * `RNHostView` re-reports its Compose size to the shadow tree, and the
25
+ * `flexGrow: 1, height: 0` column the host wraps our children in follows, so
26
+ * `Footer` and the tail of `Body` stay above the keyboard. That report is
27
+ * delivered reliably only by `expo-modules-core` >= 57.0.4 (expo/expo#47778,
28
+ * fixed by #47810): older cores flushed it from a pre-draw listener on the
29
+ * activity window, which does not draw while the sheet's dialog window animates
30
+ * the IME, so the column kept its detent height until the activity redrew. The
31
+ * package warns once in dev on Android below that floor
32
+ * (`bottomSheetHostSupport.ts`). No JS keyboard signal exists inside the
33
+ * Compose dialog window, and none is needed. Web: none. Never nest a
34
+ * `KeyboardAvoidingView` in a sheet.
25
35
  *
26
36
  * Platform-owned behaviors (props accepted for ergonomics, but the platform
27
37
  * decides):
@@ -37,7 +47,31 @@ import { ViewProps, StyleProp, ViewStyle, ScrollViewProps } from "react-native";
37
47
  * - `Body` sets `keyboardShouldPersistTaps="always"` on its ScrollView so RN's
38
48
  * own tap-dismissal never claims the first tap on a chip, button or field
39
49
  * while a sheet field is focused; the `Content` boundary owns tap-away
40
- * dismissal instead.
50
+ * dismissal instead. That covers ScrollViews *inside* the sheet only. The
51
+ * sheet's content is drawn in another native window but stays in the
52
+ * screen's React tree, and RN's responder negotiation walks that tree, so a
53
+ * ScrollView *around* the `BottomSheet` with the default
54
+ * `keyboardShouldPersistTaps="never"` claims a tap on any sheet control
55
+ * (`Footer` included) in the capture phase once a field is focused and
56
+ * blurs the field on release: the keyboard closes and the control never
57
+ * fires (Pixel_10 / API 36: 0 of 3 taps inside a default ScrollView, 3 of 3
58
+ * inside `always` or a plain View). Nothing inside the tree can preempt a
59
+ * capture-phase claim, so `Content` warns once in dev on Android when it
60
+ * observes one (`useAncestorClaimWarning`); the remedy is
61
+ * `keyboardShouldPersistTaps="always"` / `"handled"` on scroll views that
62
+ * contain a sheet, or `DismissKeyboard`, which already sets it.
63
+ *
64
+ * Dismiss completion: `onDismissed` (root prop) fires once per close after the
65
+ * sheet is fully gone, so the next modal (an RN `Modal`-backed `Dialog`, a
66
+ * native stack modal) can be presented from it without UIKit rejecting the
67
+ * presentation while the sheet is still animating out. iOS: `@expo/ui` raises
68
+ * its close callback from the native `onDismiss` event of SwiftUI
69
+ * `.sheet(isPresented:onDismiss:)`, which runs after the dismissal transition.
70
+ * Android: `@expo/ui` raises it after Material's hide animation for a swipe /
71
+ * back / scrim dismissal, and with the removal of the Compose sheet for a
72
+ * prop-driven close (which has no exit animation). Web: `@expo/ui` raises it
73
+ * before its exit animation, so the package waits for the HTML `<dialog>`'s
74
+ * `close` event instead (`bottomSheetDismiss.ts`).
41
75
  *
42
76
  * Scrollable bodies: the native sheet doesn't bound the hosted RN content to
43
77
  * the detent height, so a tall `Body` overflows and clips its footer/tail. When
@@ -94,12 +128,28 @@ interface BottomSheetContextValue {
94
128
  /** Whether a `Footer` is mounted, so `Body` can own the bottom safe-area inset when it isn't. */
95
129
  hasFooter: boolean;
96
130
  setHasFooter: (present: boolean) => void;
131
+ /** Called by `Content` once the native sheet has fully dismissed; invokes the root's `onDismissed`. */
132
+ notifyDismissed: () => void;
97
133
  }
98
134
  interface BottomSheetProps {
99
135
  /** Controlled open state. Omit to use uncontrolled mode with `defaultOpen`. */
100
136
  open?: boolean;
101
137
  /** Callback when open state changes. */
102
138
  onOpenChange?: (open: boolean) => void;
139
+ /**
140
+ * Called once per close, after the sheet has fully dismissed on every
141
+ * platform. Present the next modal (a `Dialog`, a native stack modal) from
142
+ * here rather than from `onOpenChange(false)` or a timer: on iOS the
143
+ * `UISheetPresentationController` is still dismissing when `onOpenChange`
144
+ * fires, and a `Modal` presented then is rejected ("already presenting")
145
+ * without retry. Detection — iOS: `@expo/ui`'s native `onDismiss` event
146
+ * (SwiftUI `.sheet(onDismiss:)`, after the transition). Android: `@expo/ui`'s
147
+ * close callback (after Material's hide animation for swipe / back / scrim; a
148
+ * prop-driven close removes the Compose sheet with no exit animation). Web:
149
+ * the HTML `<dialog>`'s `close` event, which `@expo/ui` raises when its exit
150
+ * animation ends.
151
+ */
152
+ onDismissed?: () => void;
103
153
  /** Initial open state for uncontrolled mode. Default: false. */
104
154
  defaultOpen?: boolean;
105
155
  /** Snap point heights (px or percentage strings). Default: ["50%"]. */
@@ -138,11 +188,12 @@ interface BottomSheetContentProps extends ViewProps {
138
188
  /** Accepted for call-site ergonomics; ignored (platform owns keyboard). */
139
189
  dismissKeyboardOnDrag?: boolean;
140
190
  /**
141
- * Style for the native sheet surface behind the RN content — the web (vaul)
142
- * panel, Android `containerColor`, iOS `presentationBackground`. Merged over
143
- * the themed card default, so `{ backgroundColor: "transparent" }` lets a
144
- * custom chrome (e.g. a glass backdrop) show through. The RN content column
145
- * paints its own card fill too; clear that via `style`.
191
+ * Style for the native sheet surface — the web (vaul) panel, Android
192
+ * `containerColor`, iOS `presentationBackground`. Merged over the themed card
193
+ * default. This is the sheet's only background: the RN content column paints
194
+ * no fill of its own, so a translucent color here reads as one layer from the
195
+ * grabber down, and `{ backgroundColor: "transparent" }` lets custom chrome
196
+ * (e.g. a glass backdrop) show through with nothing to clear via `style`.
146
197
  */
147
198
  backgroundStyle?: StyleProp<ViewStyle>;
148
199
  style?: StyleProp<ViewStyle>;
@@ -165,8 +216,14 @@ interface BottomSheetCloseProps {
165
216
  children: React.ReactNode;
166
217
  style?: StyleProp<ViewStyle>;
167
218
  }
219
+ /**
220
+ * Dev warning (Android) when an ancestor claimed a tap inside the sheet before
221
+ * the sheet was asked, while a package field held focus. See the header and
222
+ * `useAncestorClaimWarning`.
223
+ */
224
+ export declare const bottomSheetAncestorClaimWarning: string;
168
225
  declare function useBottomSheetContext(): BottomSheetContextValue;
169
- declare function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen, snapPoints, closeOnBackdropPress, children, }: BottomSheetProps): React.JSX.Element;
226
+ declare function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenChange, onDismissed, defaultOpen, snapPoints, closeOnBackdropPress, children, }: BottomSheetProps): React.JSX.Element;
170
227
  declare function BottomSheetTrigger({ asChild, children, style: styleOverride }: BottomSheetTriggerProps): React.JSX.Element;
171
228
  declare function BottomSheetContent({ swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDrag: _dismissKeyboardOnDrag, backgroundStyle: backgroundStyleOverride, style: styleOverride, testID, children, }: BottomSheetContentProps): React.JSX.Element;
172
229
  declare function BottomSheetHandle({ style }: BottomSheetHandleProps): React.JSX.Element;
@@ -1,15 +1,28 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React, { createContext, use, useCallback, useEffect, useMemo, useReducer, useRef } from "react";
2
+ import React, { createContext, use, useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef } from "react";
3
3
  import { View, Pressable, StyleSheet, ScrollView, Platform, Animated, useWindowDimensions, } from "react-native";
4
4
  import { BottomSheet as NativeBottomSheet } from "@expo/ui/community/bottom-sheet";
5
- import { useSafeAreaInsets, initialWindowMetrics } from "react-native-safe-area-context";
5
+ import { useWindowInsets } from "../hooks/useWindowInsets.js";
6
6
  import { useTheme } from "../hooks/useTheme.js";
7
+ import { useShape } from "../hooks/useShape.js";
7
8
  import { spacing } from "../constants/spacing.js";
8
9
  import { useScalePress } from "../hooks/useScalePress.js";
9
- import { TextColorContext, TextClassContext } from "./StyledText.context";
10
+ import { TextColorContext, TextClassContext } from "./StyledText.context.js";
10
11
  import { Icon } from "./Icon.js";
11
- import { useKeyboardDismissResponder } from "./keyboardDismiss.js";
12
+ import { useAncestorClaimWarning, useKeyboardDismissResponder } from "./keyboardDismiss.js";
13
+ import { warnIfBottomSheetHostDropsResize } from "./bottomSheetHostSupport.js";
14
+ import { awaitDialogClose } from "./bottomSheetDismiss.js";
12
15
  const DEFAULT_SNAP_POINTS = ["50%"];
16
+ /**
17
+ * UIKit keeps a `.large` sheet's top edge 10 pt below the top safe-area inset;
18
+ * SwiftUI's `.fraction()` detents are fractions of the height that remains.
19
+ */
20
+ const IOS_SHEET_TOP_GAP = 10;
21
+ /**
22
+ * `@expo/ui` pads the hosted RN column 16 pt below the native drag indicator
23
+ * (`paddingTop: 16` in its iOS `BottomSheet` when `handleComponent !== null`).
24
+ */
25
+ const IOS_HOST_HANDLE_PADDING = 16;
13
26
  function clampSnapIndex(index, maxSnapIndex) {
14
27
  return Math.min(Math.max(index, 0), maxSnapIndex);
15
28
  }
@@ -86,6 +99,18 @@ function bottomSheetRootReducer(state, action) {
86
99
  // ============================================================================
87
100
  // Context
88
101
  // ============================================================================
102
+ /**
103
+ * Dev warning (Android) when an ancestor claimed a tap inside the sheet before
104
+ * the sheet was asked, while a package field held focus. See the header and
105
+ * `useAncestorClaimWarning`.
106
+ */
107
+ export const bottomSheetAncestorClaimWarning = "@mrmeg/expo-ui BottomSheet: an ancestor claimed a tap inside the sheet before the sheet " +
108
+ "was asked, while a sheet field was focused, so the tapped control did not fire and the " +
109
+ "keyboard was dismissed instead. That is usually a ScrollView around the BottomSheet on React " +
110
+ 'Native\'s default keyboardShouldPersistTaps="never" (sheet content stays in the screen\'s ' +
111
+ "React tree even though it is drawn in another window). Set " +
112
+ 'keyboardShouldPersistTaps="always" or "handled" on scroll views that contain the ' +
113
+ "BottomSheet, or wrap the screen in DismissKeyboard.";
89
114
  const BottomSheetContext = createContext(null);
90
115
  function useBottomSheetContext() {
91
116
  const context = use(BottomSheetContext);
@@ -95,23 +120,23 @@ function useBottomSheetContext() {
95
120
  return context;
96
121
  }
97
122
  /**
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.
123
+ * Safe-area insets for content *inside* the sheet. The native sheet (SwiftUI
124
+ * `.sheet()` / Material `ModalBottomSheet`) is presented outside the React
125
+ * tree's `SafeAreaProvider`, and inside a tab screen the provider context even
126
+ * carries the tab bar (83 pt on an iPhone with a home indicator), so the raw
127
+ * context is the wrong number here. `useWindowInsets` gives the window's
128
+ * insets; `top` sizes the iOS detent cap below. Both native hosts already lay
129
+ * the column out inside the safe area — SwiftUI keeps `RNHostView` 34 pt above
130
+ * the home indicator, and Material3's `ModalBottomSheet` applies the system
131
+ * bars as `contentWindowInsets`, so on an edge-to-edge Pixel the hosted column
132
+ * ends exactly at the navigation bar's top — so `bottom` is 0 on iOS and
133
+ * Android: padding it again pushed the footer up by the inset (measured 64 dp
134
+ * under Save instead of 40 on a Pixel 6a) and squeezed the body. Web is the
135
+ * kit's own overlay inside the React tree and keeps the window's bottom inset.
105
136
  */
106
137
  function useSheetInsets() {
107
- const insets = useSafeAreaInsets();
108
- const fallback = initialWindowMetrics?.insets;
109
- return {
110
- top: insets.top || fallback?.top || 0,
111
- bottom: insets.bottom || fallback?.bottom || 0,
112
- left: insets.left || fallback?.left || 0,
113
- right: insets.right || fallback?.right || 0,
114
- };
138
+ const insets = useWindowInsets();
139
+ return Platform.OS === "web" ? insets : { ...insets, bottom: 0 };
115
140
  }
116
141
  /**
117
142
  * Interactive (pull-down / backdrop) dismiss is off only when the consumer
@@ -147,7 +172,7 @@ function SheetCloseButton({ style }) {
147
172
  {
148
173
  width: spacing.xl,
149
174
  height: spacing.xl,
150
- borderRadius: spacing.xl / 2,
175
+ borderRadius: spacing.radiusFull,
151
176
  alignItems: "center",
152
177
  justifyContent: "center",
153
178
  // Higher-contrast than `muted`: a solid `secondary` fill with a
@@ -163,7 +188,7 @@ function SheetCloseButton({ style }) {
163
188
  // ============================================================================
164
189
  // Root
165
190
  // ============================================================================
166
- function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen = false, snapPoints = DEFAULT_SNAP_POINTS, closeOnBackdropPress = true, children, }) {
191
+ function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenChange, onDismissed, defaultOpen = false, snapPoints = DEFAULT_SNAP_POINTS, closeOnBackdropPress = true, children, }) {
167
192
  const maxSnapIndex = Math.max(snapPoints.length - 1, 0);
168
193
  const [state, dispatch] = useReducer(bottomSheetRootReducer, undefined, () => createInitialRootState(defaultOpen, maxSnapIndex));
169
194
  const isControlled = controlledOpen !== undefined;
@@ -188,6 +213,16 @@ function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenC
188
213
  }
189
214
  }, [controlledOnOpenChange, isControlled, maxSnapIndex]);
190
215
  const toggle = useCallback(() => onOpenChange(!open), [onOpenChange, open]);
216
+ // Latest callback without re-creating the context value on every render.
217
+ // Synced after each commit (the sheet reports its dismissal from a native
218
+ // or DOM event, never during render) instead of written during render.
219
+ const onDismissedRef = useRef(onDismissed);
220
+ useLayoutEffect(() => {
221
+ onDismissedRef.current = onDismissed;
222
+ });
223
+ const notifyDismissed = useCallback(() => {
224
+ onDismissedRef.current?.();
225
+ }, []);
191
226
  const setScrollable = useCallback((scrollable) => {
192
227
  dispatch({ type: "setScrollable", scrollable });
193
228
  }, []);
@@ -212,6 +247,7 @@ function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenC
212
247
  setHasHeader,
213
248
  hasFooter: state.hasFooter,
214
249
  setHasFooter,
250
+ notifyDismissed,
215
251
  }), [
216
252
  open,
217
253
  onOpenChange,
@@ -227,6 +263,7 @@ function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenC
227
263
  setHasHeader,
228
264
  state.hasFooter,
229
265
  setHasFooter,
266
+ notifyDismissed,
230
267
  ]);
231
268
  return (_jsx(BottomSheetContext.Provider, { value: contextValue, children: children }));
232
269
  }
@@ -254,11 +291,15 @@ function BottomSheetTrigger({ asChild, children, style: styleOverride }) {
254
291
  function BottomSheetContent({
255
292
  // Accepted-but-ignored ergonomics props (platform owns these behaviors):
256
293
  swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDrag: _dismissKeyboardOnDrag, backgroundStyle: backgroundStyleOverride, style: styleOverride, testID, children, }) {
257
- const { open, onOpenChange, snapPoints, snapIndex, setSnapIndex, hasHeader } = useBottomSheetContext();
294
+ const { open, onOpenChange, snapPoints, snapIndex, setSnapIndex, hasHeader, notifyDismissed } = useBottomSheetContext();
258
295
  const { theme } = useTheme();
296
+ const sheetShape = useShape("sheet");
259
297
  const dismissDisabled = useDismissDisabled();
260
298
  const showClose = useShowClose();
261
299
  const { height: winH } = useWindowDimensions();
300
+ // Window insets (not the provider context, which reads zero inside the
301
+ // native sheet's window): the top inset sizes the iOS detent cap below.
302
+ const insets = useSheetInsets();
262
303
  // Tap-away keyboard dismissal inside the sheet. The native sheet (SwiftUI
263
304
  // `.sheet()` / Material `ModalBottomSheet`) hosts its RN children in a separate
264
305
  // native window via @expo/ui's `RNHostView`, OUTSIDE the app's
@@ -272,6 +313,15 @@ swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDra
272
313
  // dead-space tap dismisses on release only, through the registered field's
273
314
  // own window-independent blur handle.
274
315
  const dismissResponderProps = useKeyboardDismissResponder();
316
+ // Android, dev, once per session: name an ancestor ScrollView that claimed a
317
+ // tap inside the sheet in the capture phase (nothing in here can preempt it).
318
+ const columnResponderProps = useAncestorClaimWarning(dismissResponderProps, bottomSheetAncestorClaimWarning);
319
+ // Android only, dev only, once per session: name the host floor the column's
320
+ // keyboard avoidance depends on when the app was built against an
321
+ // `expo-modules-core` that drops `RNHostView`'s size update (see the header).
322
+ useEffect(() => {
323
+ warnIfBottomSheetHostDropsResize();
324
+ }, []);
275
325
  // Boolean open → native imperative index. The root resets snapIndex to the
276
326
  // highest point while closed; -1 keeps the native sheet closed.
277
327
  const index = open ? snapIndex : -1;
@@ -287,18 +337,35 @@ swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDra
287
337
  // and Material's `ModalBottomSheet` ignores percentage snap points
288
338
  // (partial / expanded only) — a window-percentage cap would leave the Body
289
339
  // short of the rendered sheet with a blank strip below it. Android therefore
290
- // keeps `flex:1` alone (see the content column style).
340
+ // keeps `flex:1` alone (see the content column style). The same measured
341
+ // height is how the column follows the keyboard: Material shrinks the sheet
342
+ // with `imePadding()` and `RNHostView` re-reports its Compose size to the
343
+ // shadow tree, which needs `expo-modules-core` >= 57.0.4 to always land
344
+ // (expo/expo#47778) — see `warnIfBottomSheetHostDropsResize` above.
345
+ //
346
+ // The cap has to be the height SwiftUI really gives the sheet, not a share
347
+ // of the window. `@expo/ui` maps "50%" to `.fraction(0.5)`, a fraction of
348
+ // the *maximum* sheet height — the window minus the top safe-area inset
349
+ // minus UIKit's 10 pt gap above a `.large` sheet — and a numeric snap to
350
+ // `.height(n)`, clamped to that maximum. `@expo/ui` then pads the hosted
351
+ // column 16 pt below the native grabber whenever that grabber is shown. A
352
+ // cap of `fraction × window height` overshot by `fraction × (inset + 10) +
353
+ // 16` ≈ 52 pt on a Dynamic Island phone at 50 %, so the sheet clipped its
354
+ // last row on every such device.
355
+ const hasInteractiveHandle = containsHandle(children);
291
356
  const expandedSnap = snapPoints[snapPoints.length - 1];
292
- const detentHeight = typeof expandedSnap === "number"
293
- ? expandedSnap
294
- : (parseFloat(expandedSnap) / 100) * winH;
357
+ const availableSheetHeight = winH - insets.top - IOS_SHEET_TOP_GAP;
358
+ const sheetHeight = typeof expandedSnap === "number"
359
+ ? Math.min(expandedSnap, availableSheetHeight)
360
+ : (parseFloat(expandedSnap) / 100) * availableSheetHeight;
361
+ const detentHeight = sheetHeight - (hasInteractiveHandle ? 0 : IOS_HOST_HANDLE_PADDING);
295
362
  // When there's no Header to host the X (so a close affordance is wanted —
296
363
  // dismiss is off, or the body scrolls), float one over the top-right corner.
297
364
  // A Header, when present, renders its own (see BottomSheetHeader).
298
365
  const showFloatingClose = showClose && !hasHeader;
299
- const hasInteractiveHandle = containsHandle(children);
300
366
  const handleChange = (newIndex) => {
301
367
  // Native fires onChange(-1) on dismiss (swipe / backdrop / back button).
368
+ // The same close also reaches `handleNativeClose`, which owns `onDismissed`.
302
369
  if (newIndex < 0) {
303
370
  if (open)
304
371
  onOpenChange(false);
@@ -306,30 +373,67 @@ swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDra
306
373
  }
307
374
  setSnapIndex(newIndex);
308
375
  };
376
+ // `onDismissed`: once per close. `@expo/ui` fires `onClose` once per close
377
+ // itself (post-dismissal on iOS and Android, see the header); the guard here
378
+ // is defensive and is re-armed when the sheet opens again. On web the close
379
+ // callback precedes the exit animation, so completion is the enclosing
380
+ // `<dialog>`'s `close` event, found from the content column's DOM node.
381
+ const columnRef = useRef(null);
382
+ const dismissedRef = useRef(false);
383
+ const cancelDismissWaitRef = useRef(null);
384
+ useEffect(() => {
385
+ if (open)
386
+ dismissedRef.current = false;
387
+ }, [open]);
388
+ useEffect(() => () => {
389
+ cancelDismissWaitRef.current?.();
390
+ cancelDismissWaitRef.current = null;
391
+ }, []);
392
+ const handleNativeClose = () => {
393
+ if (open)
394
+ onOpenChange(false);
395
+ if (dismissedRef.current)
396
+ return;
397
+ dismissedRef.current = true;
398
+ if (Platform.OS !== "web") {
399
+ notifyDismissed();
400
+ return;
401
+ }
402
+ cancelDismissWaitRef.current?.();
403
+ cancelDismissWaitRef.current = awaitDialogClose(columnRef.current, () => {
404
+ cancelDismissWaitRef.current = null;
405
+ notifyDismissed();
406
+ });
407
+ };
309
408
  return (_jsx(NativeBottomSheet, { index: index, snapPoints: snapPoints,
310
409
  // The native indicator lives outside the hosted RN tree and cannot
311
410
  // receive JS presses. Hide it only when the compound Handle supplies the
312
411
  // interactive replacement; sheets without Handle retain native chrome.
313
- handleComponent: hasInteractiveHandle ? null : undefined, enablePanDownToClose: !dismissDisabled, onChange: handleChange, onClose: () => {
314
- if (open)
315
- onOpenChange(false);
316
- },
412
+ handleComponent: hasInteractiveHandle ? null : undefined, enablePanDownToClose: !dismissDisabled, onChange: handleChange, onClose: handleNativeClose,
317
413
  // Themes the scrim/background on web (vaul), Android (containerColor),
318
414
  // and iOS (presentationBackground). Flattened so native readers that
319
415
  // expect a plain object (not a style array) keep working.
320
416
  backgroundStyle: StyleSheet.flatten([
321
417
  { backgroundColor: theme.colors.card },
418
+ // Host-app `setShape({ sheet })`: the top corners, where the platform
419
+ // lets the sheet draw them (web's drawer, Android). iOS system sheets
420
+ // keep the system corner radius.
421
+ sheetShape?.borderRadius !== undefined && {
422
+ borderTopLeftRadius: sheetShape.borderRadius,
423
+ borderTopRightRadius: sheetShape.borderRadius,
424
+ },
322
425
  backgroundStyleOverride,
323
- ]), children: _jsx(TextColorContext.Provider, { value: theme.colors.foreground, children: _jsx(TextClassContext.Provider, { value: "", children: _jsxs(View, { testID: testID, style: [
324
- {
325
- flex: 1,
326
- // Themes the content surface across all platforms regardless of
327
- // native sheet chrome.
328
- backgroundColor: theme.colors.card,
329
- },
426
+ ]), children: _jsx(TextColorContext.Provider, { value: theme.colors.foreground, children: _jsx(TextClassContext.Provider, { value: "", children: _jsxs(View, { ref: columnRef, testID: testID, style: [
427
+ // No fill of its own: the native surface (`backgroundStyle`) is
428
+ // the sheet's only background. On iOS the hosted column starts
429
+ // 16 pt below the sheet's top edge when the native grabber is
430
+ // shown, so a second, column-level fill made a translucent card
431
+ // read as two layers below that line and one above it — a
432
+ // differently colored strip behind the grabber.
433
+ { flex: 1 },
330
434
  Platform.OS !== "android" && { maxHeight: detentHeight },
331
435
  styleOverride,
332
- ], ...dismissResponderProps, children: [children, showFloatingClose && (_jsx(SheetCloseButton, { style: {
436
+ ], ...columnResponderProps, children: [children, showFloatingClose && (_jsx(SheetCloseButton, { style: {
333
437
  // The sheet card sits below the status bar, so no top inset
334
438
  // is needed — just clear the sheet's own top edge / handle.
335
439
  position: "absolute",
@@ -1,7 +1,7 @@
1
1
  import React, { ComponentType } from "react";
2
- import { PressableProps, PressableStateCallbackType, DimensionValue, StyleProp, TextStyle, ViewStyle } from "react-native";
3
- import { TextProps } from "./StyledText";
4
- import { type IconProps } from "./Icon";
2
+ import { Pressable, PressableProps, PressableStateCallbackType, DimensionValue, StyleProp, TextStyle, ViewStyle } from "react-native";
3
+ import { TextProps } from "./StyledText.js";
4
+ import { type IconProps } from "./Icon.js";
5
5
  /**
6
6
  * Button variants
7
7
  */
@@ -27,6 +27,12 @@ export interface ButtonAccessoryProps {
27
27
  disabled?: boolean;
28
28
  }
29
29
  export interface ButtonProps extends PressableProps {
30
+ /**
31
+ * Reaches the underlying `Pressable`. React 19 passes `ref` as a prop; the
32
+ * button composes it with its own host ref (web `aria-disabled`), so
33
+ * `asChild` slots and consumers keep theirs.
34
+ */
35
+ ref?: React.Ref<React.ComponentRef<typeof Pressable>>;
30
36
  /**
31
37
  * Text which is looked up via i18n.
32
38
  */
@@ -82,13 +88,23 @@ export interface ButtonProps extends PressableProps {
82
88
  */
83
89
  children?: React.ReactNode;
84
90
  /**
85
- * disabled prop, accessed directly for declarative styling reasons.
91
+ * Blocks presses: no `onPress`, no scale, no haptic, dimmed, and out of the
92
+ * web tab order. To announce and dim a button that must stay pressable so
93
+ * the press can say why the action is unavailable ("Add a title first"),
94
+ * pass `aria-disabled` (or `accessibilityState={{ disabled: true }}`)
95
+ * instead — ARIA's contract: announced as disabled, still focusable, still
96
+ * fires `onPress`.
86
97
  */
87
98
  disabled?: boolean;
88
99
  /**
89
100
  * An optional style override for the disabled state
90
101
  */
91
102
  disabledStyle?: StyleProp<ViewStyle>;
103
+ /**
104
+ * Haptic on press-in. Omit it to follow the provider-level `haptics` setting
105
+ * (a light tap only under `"all"`); `true` always taps, `false` never does.
106
+ */
107
+ haptic?: boolean;
92
108
  /**
93
109
  * Whether to show shadow.
94
110
  * @default true for the `default` preset, `false` for every other preset.