@mrmeg/expo-ui 0.27.1 → 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 (140) hide show
  1. package/CHANGELOG.md +598 -0
  2. package/LLM_USAGE.md +233 -35
  3. package/README.md +504 -103
  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 +29 -1
  11. package/dist/components/BottomSheet.js +107 -30
  12. package/dist/components/Button.d.ts +20 -4
  13. package/dist/components/Button.js +60 -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 +87 -16
  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 +5 -2
  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 +73 -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 +20 -30
  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 +71 -26
  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/index.d.ts +46 -44
  71. package/dist/components/index.js +2 -0
  72. package/dist/components/keyboardDismiss.js +12 -4
  73. package/dist/components/nativeTextField.android.js +13 -9
  74. package/dist/constants/colors.d.ts +40 -5
  75. package/dist/constants/colors.js +26 -8
  76. package/dist/constants/fonts.d.ts +41 -2
  77. package/dist/constants/fonts.js +46 -7
  78. package/dist/constants/index.d.ts +5 -4
  79. package/dist/constants/index.js +1 -0
  80. package/dist/constants/interaction.d.ts +18 -0
  81. package/dist/constants/interaction.js +18 -0
  82. package/dist/constants/spacing.d.ts +2 -1
  83. package/dist/constants/spacing.js +2 -1
  84. package/dist/design-system.json +154 -2
  85. package/dist/hooks/index.d.ts +12 -7
  86. package/dist/hooks/index.js +5 -0
  87. package/dist/hooks/useDimensions.d.ts +13 -8
  88. package/dist/hooks/useDimensions.js +123 -48
  89. package/dist/hooks/useFocusVisible.d.ts +30 -0
  90. package/dist/hooks/useFocusVisible.js +45 -0
  91. package/dist/hooks/useFontStyle.d.ts +2 -2
  92. package/dist/hooks/useFontStyle.js +3 -2
  93. package/dist/hooks/useHydrated.d.ts +13 -0
  94. package/dist/hooks/useHydrated.js +19 -0
  95. package/dist/hooks/useNotificationOffset.d.ts +20 -0
  96. package/dist/hooks/useNotificationOffset.js +34 -0
  97. package/dist/hooks/useResources.d.ts +21 -20
  98. package/dist/hooks/useResources.js +72 -68
  99. package/dist/hooks/useScalePress.d.ts +8 -5
  100. package/dist/hooks/useScalePress.js +16 -9
  101. package/dist/hooks/useShape.d.ts +24 -0
  102. package/dist/hooks/useShape.js +24 -0
  103. package/dist/hooks/useStaggeredEntrance.js +9 -7
  104. package/dist/hooks/useTheme.d.ts +3 -3
  105. package/dist/hooks/useTheme.js +177 -66
  106. package/dist/hooks/useWindowInsets.d.ts +13 -0
  107. package/dist/hooks/useWindowInsets.js +23 -0
  108. package/dist/index.d.ts +5 -5
  109. package/dist/lib/composeRefs.d.ts +8 -0
  110. package/dist/lib/composeRefs.js +20 -0
  111. package/dist/lib/haptics.d.ts +12 -0
  112. package/dist/lib/haptics.js +21 -0
  113. package/dist/lib/index.d.ts +6 -5
  114. package/dist/lib/index.js +1 -0
  115. package/dist/lib/interFonts.d.ts +15 -0
  116. package/dist/lib/interFonts.js +1 -0
  117. package/dist/lib/interFonts.native.d.ts +3 -0
  118. package/dist/lib/interFonts.native.js +20 -0
  119. package/dist/lib/popoverPlacement.d.ts +36 -0
  120. package/dist/lib/popoverPlacement.js +29 -0
  121. package/dist/lib/themedStyles.d.ts +1 -1
  122. package/dist/lib/useAnimatedValue.d.ts +15 -0
  123. package/dist/lib/useAnimatedValue.js +19 -0
  124. package/dist/lib/webHitTarget.d.ts +40 -0
  125. package/dist/lib/webHitTarget.js +37 -0
  126. package/dist/state/feedbackStore.d.ts +24 -0
  127. package/dist/state/feedbackStore.js +16 -0
  128. package/dist/state/globalUIStore.d.ts +27 -0
  129. package/dist/state/globalUIStore.js +23 -0
  130. package/dist/state/index.d.ts +7 -5
  131. package/dist/state/index.js +2 -0
  132. package/dist/state/initialScheme.d.ts +27 -0
  133. package/dist/state/initialScheme.js +29 -0
  134. package/dist/state/notify.d.ts +1 -1
  135. package/dist/state/themeColorScope.d.ts +1 -1
  136. package/dist/state/themeStore.d.ts +69 -16
  137. package/dist/state/themeStore.js +65 -38
  138. package/llms-full.md +56 -8
  139. package/llms.txt +10 -1
  140. 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 };
@@ -61,6 +61,18 @@ import { ViewProps, StyleProp, ViewStyle, ScrollViewProps } from "react-native";
61
61
  * `keyboardShouldPersistTaps="always"` / `"handled"` on scroll views that
62
62
  * contain a sheet, or `DismissKeyboard`, which already sets it.
63
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`).
75
+ *
64
76
  * Scrollable bodies: the native sheet doesn't bound the hosted RN content to
65
77
  * the detent height, so a tall `Body` overflows and clips its footer/tail. When
66
78
  * `Body` detects overflow, `Content` caps the column to the detent height so the
@@ -116,12 +128,28 @@ interface BottomSheetContextValue {
116
128
  /** Whether a `Footer` is mounted, so `Body` can own the bottom safe-area inset when it isn't. */
117
129
  hasFooter: boolean;
118
130
  setHasFooter: (present: boolean) => void;
131
+ /** Called by `Content` once the native sheet has fully dismissed; invokes the root's `onDismissed`. */
132
+ notifyDismissed: () => void;
119
133
  }
120
134
  interface BottomSheetProps {
121
135
  /** Controlled open state. Omit to use uncontrolled mode with `defaultOpen`. */
122
136
  open?: boolean;
123
137
  /** Callback when open state changes. */
124
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;
125
153
  /** Initial open state for uncontrolled mode. Default: false. */
126
154
  defaultOpen?: boolean;
127
155
  /** Snap point heights (px or percentage strings). Default: ["50%"]. */
@@ -195,7 +223,7 @@ interface BottomSheetCloseProps {
195
223
  */
196
224
  export declare const bottomSheetAncestorClaimWarning: string;
197
225
  declare function useBottomSheetContext(): BottomSheetContextValue;
198
- 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;
199
227
  declare function BottomSheetTrigger({ asChild, children, style: styleOverride }: BottomSheetTriggerProps): React.JSX.Element;
200
228
  declare function BottomSheetContent({ swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDrag: _dismissKeyboardOnDrag, backgroundStyle: backgroundStyleOverride, style: styleOverride, testID, children, }: BottomSheetContentProps): React.JSX.Element;
201
229
  declare function BottomSheetHandle({ style }: BottomSheetHandleProps): React.JSX.Element;
@@ -1,16 +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
12
  import { useAncestorClaimWarning, useKeyboardDismissResponder } from "./keyboardDismiss.js";
12
13
  import { warnIfBottomSheetHostDropsResize } from "./bottomSheetHostSupport.js";
14
+ import { awaitDialogClose } from "./bottomSheetDismiss.js";
13
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;
14
26
  function clampSnapIndex(index, maxSnapIndex) {
15
27
  return Math.min(Math.max(index, 0), maxSnapIndex);
16
28
  }
@@ -108,23 +120,23 @@ function useBottomSheetContext() {
108
120
  return context;
109
121
  }
110
122
  /**
111
- * Safe-area insets for content *inside* the sheet.
112
- *
113
- * The native sheet (SwiftUI `.sheet()` / Material `ModalBottomSheet`) is
114
- * presented outside the React tree's `SafeAreaProvider`, so `useSafeAreaInsets()`
115
- * reads all-zero in here. Fall back to `initialWindowMetrics` — the same trick
116
- * the app uses for its full-screen Modals — so bottom padding actually clears
117
- * 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.
118
136
  */
119
137
  function useSheetInsets() {
120
- const insets = useSafeAreaInsets();
121
- const fallback = initialWindowMetrics?.insets;
122
- return {
123
- top: insets.top || fallback?.top || 0,
124
- bottom: insets.bottom || fallback?.bottom || 0,
125
- left: insets.left || fallback?.left || 0,
126
- right: insets.right || fallback?.right || 0,
127
- };
138
+ const insets = useWindowInsets();
139
+ return Platform.OS === "web" ? insets : { ...insets, bottom: 0 };
128
140
  }
129
141
  /**
130
142
  * Interactive (pull-down / backdrop) dismiss is off only when the consumer
@@ -160,7 +172,7 @@ function SheetCloseButton({ style }) {
160
172
  {
161
173
  width: spacing.xl,
162
174
  height: spacing.xl,
163
- borderRadius: spacing.xl / 2,
175
+ borderRadius: spacing.radiusFull,
164
176
  alignItems: "center",
165
177
  justifyContent: "center",
166
178
  // Higher-contrast than `muted`: a solid `secondary` fill with a
@@ -176,7 +188,7 @@ function SheetCloseButton({ style }) {
176
188
  // ============================================================================
177
189
  // Root
178
190
  // ============================================================================
179
- 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, }) {
180
192
  const maxSnapIndex = Math.max(snapPoints.length - 1, 0);
181
193
  const [state, dispatch] = useReducer(bottomSheetRootReducer, undefined, () => createInitialRootState(defaultOpen, maxSnapIndex));
182
194
  const isControlled = controlledOpen !== undefined;
@@ -201,6 +213,16 @@ function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenC
201
213
  }
202
214
  }, [controlledOnOpenChange, isControlled, maxSnapIndex]);
203
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
+ }, []);
204
226
  const setScrollable = useCallback((scrollable) => {
205
227
  dispatch({ type: "setScrollable", scrollable });
206
228
  }, []);
@@ -225,6 +247,7 @@ function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenC
225
247
  setHasHeader,
226
248
  hasFooter: state.hasFooter,
227
249
  setHasFooter,
250
+ notifyDismissed,
228
251
  }), [
229
252
  open,
230
253
  onOpenChange,
@@ -240,6 +263,7 @@ function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenC
240
263
  setHasHeader,
241
264
  state.hasFooter,
242
265
  setHasFooter,
266
+ notifyDismissed,
243
267
  ]);
244
268
  return (_jsx(BottomSheetContext.Provider, { value: contextValue, children: children }));
245
269
  }
@@ -267,11 +291,15 @@ function BottomSheetTrigger({ asChild, children, style: styleOverride }) {
267
291
  function BottomSheetContent({
268
292
  // Accepted-but-ignored ergonomics props (platform owns these behaviors):
269
293
  swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDrag: _dismissKeyboardOnDrag, backgroundStyle: backgroundStyleOverride, style: styleOverride, testID, children, }) {
270
- const { open, onOpenChange, snapPoints, snapIndex, setSnapIndex, hasHeader } = useBottomSheetContext();
294
+ const { open, onOpenChange, snapPoints, snapIndex, setSnapIndex, hasHeader, notifyDismissed } = useBottomSheetContext();
271
295
  const { theme } = useTheme();
296
+ const sheetShape = useShape("sheet");
272
297
  const dismissDisabled = useDismissDisabled();
273
298
  const showClose = useShowClose();
274
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();
275
303
  // Tap-away keyboard dismissal inside the sheet. The native sheet (SwiftUI
276
304
  // `.sheet()` / Material `ModalBottomSheet`) hosts its RN children in a separate
277
305
  // native window via @expo/ui's `RNHostView`, OUTSIDE the app's
@@ -314,17 +342,30 @@ swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDra
314
342
  // with `imePadding()` and `RNHostView` re-reports its Compose size to the
315
343
  // shadow tree, which needs `expo-modules-core` >= 57.0.4 to always land
316
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);
317
356
  const expandedSnap = snapPoints[snapPoints.length - 1];
318
- const detentHeight = typeof expandedSnap === "number"
319
- ? expandedSnap
320
- : (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);
321
362
  // When there's no Header to host the X (so a close affordance is wanted —
322
363
  // dismiss is off, or the body scrolls), float one over the top-right corner.
323
364
  // A Header, when present, renders its own (see BottomSheetHeader).
324
365
  const showFloatingClose = showClose && !hasHeader;
325
- const hasInteractiveHandle = containsHandle(children);
326
366
  const handleChange = (newIndex) => {
327
367
  // Native fires onChange(-1) on dismiss (swipe / backdrop / back button).
368
+ // The same close also reaches `handleNativeClose`, which owns `onDismissed`.
328
369
  if (newIndex < 0) {
329
370
  if (open)
330
371
  onOpenChange(false);
@@ -332,21 +373,57 @@ swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDra
332
373
  }
333
374
  setSnapIndex(newIndex);
334
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
+ };
335
408
  return (_jsx(NativeBottomSheet, { index: index, snapPoints: snapPoints,
336
409
  // The native indicator lives outside the hosted RN tree and cannot
337
410
  // receive JS presses. Hide it only when the compound Handle supplies the
338
411
  // interactive replacement; sheets without Handle retain native chrome.
339
- handleComponent: hasInteractiveHandle ? null : undefined, enablePanDownToClose: !dismissDisabled, onChange: handleChange, onClose: () => {
340
- if (open)
341
- onOpenChange(false);
342
- },
412
+ handleComponent: hasInteractiveHandle ? null : undefined, enablePanDownToClose: !dismissDisabled, onChange: handleChange, onClose: handleNativeClose,
343
413
  // Themes the scrim/background on web (vaul), Android (containerColor),
344
414
  // and iOS (presentationBackground). Flattened so native readers that
345
415
  // expect a plain object (not a style array) keep working.
346
416
  backgroundStyle: StyleSheet.flatten([
347
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
+ },
348
425
  backgroundStyleOverride,
349
- ]), children: _jsx(TextColorContext.Provider, { value: theme.colors.foreground, children: _jsx(TextClassContext.Provider, { value: "", children: _jsxs(View, { testID: testID, style: [
426
+ ]), children: _jsx(TextColorContext.Provider, { value: theme.colors.foreground, children: _jsx(TextClassContext.Provider, { value: "", children: _jsxs(View, { ref: columnRef, testID: testID, style: [
350
427
  // No fill of its own: the native surface (`backgroundStyle`) is
351
428
  // the sheet's only background. On iOS the hosted column starts
352
429
  // 16 pt below the sheet's top edge when the native grabber is
@@ -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.