@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,16 +1,152 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
- import { Platform, StyleSheet, View } from "react-native";
4
- import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens";
3
+ import { Modal, Platform, StyleSheet, View } from "react-native";
5
4
  import * as DialogPrimitive from "@rn-primitives/dialog";
6
5
  import * as AlertDialogPrimitive from "@rn-primitives/alert-dialog";
7
6
  import { AnimatedView } from "./AnimatedView.js";
8
- import { TextClassContext, TextColorContext } from "./StyledText.context";
7
+ import { KeyboardAvoidingView } from "./KeyboardAvoidingView.js";
8
+ import { useWindowInsets } from "../hooks/useWindowInsets.js";
9
+ import { useKeyboardDismissResponder } from "./keyboardDismiss.js";
10
+ import { TextClassContext, TextColorContext } from "./StyledText.context.js";
9
11
  import { StyledText } from "./StyledText.js";
10
12
  import { useTheme } from "../hooks/useTheme.js";
13
+ import { useShape } from "../hooks/useShape.js";
11
14
  import { spacing } from "../constants/spacing.js";
12
15
  import { palette } from "../constants/colors.js";
13
- const FullWindowOverlay = Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment;
16
+ // ============================================================================
17
+ // Presentation
18
+ // ============================================================================
19
+ /** The Modal follows the app's own orientation mask instead of RN's portrait-only default. */
20
+ const SUPPORTED_ORIENTATIONS = [
21
+ "portrait",
22
+ "portrait-upside-down",
23
+ "landscape",
24
+ "landscape-left",
25
+ "landscape-right",
26
+ ];
27
+ /**
28
+ * Presentation for dialog content.
29
+ *
30
+ * iOS presents through React Native's `Modal`, rendered inline where the
31
+ * dialog sits in the tree rather than through the portal host. Until 0.27.1
32
+ * both dialogs rendered into `UIProvider`'s portal host inside
33
+ * react-native-screens' `FullWindowOverlay`, which stacks above native stack
34
+ * modals by adding its container straight to the `UIWindow`. No
35
+ * `UIViewController` sits above that container, and `expo-modules-core`'s
36
+ * `ExpoSwiftUI.HostingView` — which attaches its `UIHostingController` only
37
+ * when `reactViewController()` finds a parent controller — removed the SwiftUI
38
+ * view instead. Every `@expo/ui`-hosted control inside (`TextInput`, `Slider`,
39
+ * `SegmentedControl`) was a zero-height box: no editable element, no focus, no
40
+ * keyboard (fieldnest on iOS 27, `@mrmeg/expo-ui` 0.27.0; reproduced in the
41
+ * template's `Dialog` `form` variant — the accessibility tree listed only the
42
+ * field labels, and the same content mounted with the overlay removed).
43
+ *
44
+ * `Modal` presents a real view controller, so hosted controls mount and take
45
+ * focus on the first tap. RN presents it from the view controller nearest the
46
+ * `Modal`'s own host view, which is why it renders inline: a `Modal` in the
47
+ * root portal host presents from the root controller and silently fails
48
+ * whenever that controller already presents a native stack modal or a sheet
49
+ * (device-verified: the trigger reported expanded, nothing appeared). Inline,
50
+ * the presenter is whatever screen, native stack modal or sheet contains the
51
+ * dialog, and the dialog stacks above it. The trade-off: content hosted by
52
+ * `FullWindowOverlay` (`Drawer`, `Popover`, `Select`, `DropdownMenu`,
53
+ * `Tooltip`) has no view controller either, so a dialog placed inside that
54
+ * content cannot present on iOS — render it at screen level and open it from
55
+ * the item's `onPress`.
56
+ *
57
+ * Android and web render into the portal host, as before.
58
+ */
59
+ function DialogPresentation({ Portal, open, portalHost, onRequestClose, children, }) {
60
+ if (Platform.OS !== "ios") {
61
+ return _jsx(Portal, { hostName: portalHost, children: children });
62
+ }
63
+ if (!open) {
64
+ return null;
65
+ }
66
+ return (_jsx(Modal, { visible: true, transparent: true, animationType: "none", presentationStyle: "overFullScreen", supportedOrientations: SUPPORTED_ORIENTATIONS, onRequestClose: onRequestClose, children: children }));
67
+ }
68
+ /**
69
+ * Keyboard avoidance owner for dialog content.
70
+ *
71
+ * Dialog content sits outside `UIProvider`'s root `KeyboardAvoidingView` on
72
+ * both native platforms — the iOS `Modal` above is presented outside it, and
73
+ * on Android the primitive `Portal` renders into `UIProvider`'s `PortalHost`,
74
+ * a sibling of the root avoidance — so nothing else can keep a focused dialog
75
+ * field and the footer above the keyboard. This wrapper pads the centered
76
+ * container by the keyboard height, and the card (capped at 85% of the
77
+ * remaining height) recenters in the space that is left. It is the package
78
+ * `KeyboardAvoidingView` (keyboard-controller on native, which observes the
79
+ * main window's IME on Android, where the portal-hosted dialog lives), so
80
+ * `useKeyboardAvoidance()` is `true` inside `DialogContent` and a
81
+ * `DismissKeyboard` in dialog content adds no second layer. Do not wrap dialog
82
+ * content in another `KeyboardAvoidingView`.
83
+ *
84
+ * Web has no software keyboard and keeps the portal-host tree unchanged.
85
+ */
86
+ /**
87
+ * On native the overlay (an iOS `Modal`, the Android portal) covers the whole
88
+ * window, so the centered container pads the safe-area insets: `sizer`'s 85 %
89
+ * is then 85 % of the area between the Dynamic Island and the home indicator,
90
+ * not of the screen. Web's overlay is the viewport; nothing to pad.
91
+ */
92
+ function useDialogSafeAreaPadding() {
93
+ const insets = useWindowInsets();
94
+ if (Platform.OS === "web")
95
+ return null;
96
+ return {
97
+ paddingTop: insets.top,
98
+ paddingBottom: insets.bottom,
99
+ paddingLeft: insets.left,
100
+ paddingRight: insets.right,
101
+ };
102
+ }
103
+ function DialogKeyboardAvoidance({ children }) {
104
+ if (Platform.OS === "web") {
105
+ return _jsx(_Fragment, { children: children });
106
+ }
107
+ return (_jsx(KeyboardAvoidingView, { style: overlayStyles.fill, behavior: "padding", keyboardVerticalOffset: 0, children: children }));
108
+ }
109
+ /**
110
+ * Tap-away keyboard dismissal for dialog content.
111
+ *
112
+ * The iOS `Modal` sits outside any app-level `DismissKeyboard`, and the
113
+ * portal-hosted tree on Android does too, so a tap on the card's dead space
114
+ * (padding, labels, the gap between fields and footer) left the keyboard up.
115
+ * The dialogs carry the same boundary as `DismissKeyboard` and
116
+ * `BottomSheet.Content` (`useKeyboardDismissResponder`): `Close` / `Action` /
117
+ * `Cancel`, buttons and fields win the negotiation and fire on the first tap,
118
+ * and the boundary dismisses on release of an otherwise unclaimed single-finger
119
+ * tap within the travel slop, through the registered field's blur handle with a
120
+ * `KeyboardController.dismiss()` fallback. Inert on web (returns `{}`).
121
+ *
122
+ * Where it sits differs per primitive. `@rn-primitives/dialog`'s native
123
+ * `Content` claims every touch that reaches it (`onStartShouldSetResponder`
124
+ * returning `true`), so the overlay `Pressable`'s `closeOnPress` never fires for
125
+ * a tap inside the card — and that claim ends the bubble negotiation before any
126
+ * ancestor is asked, so a boundary on the centered container was never armed
127
+ * for a card tap (Pixel 6a: dead-space taps left the keyboard up). `Dialog`
128
+ * therefore puts the boundary on the `Content` itself and keeps the claim
129
+ * (`claimsTouch`): the boundary arms, then answers `true` as the primitive
130
+ * did. `@rn-primitives/alert-dialog`'s `Content` claims nothing, so
131
+ * `AlertDialog` keeps the never-claiming boundary on its centered container,
132
+ * which also covers its backdrop (the alert overlay does not close on press).
133
+ * A `Dialog` backdrop tap is still the primitive `Overlay`'s: it closes the
134
+ * dialog and the keyboard drops with it.
135
+ */
136
+ function useDialogKeyboardDismissBoundary(claimsTouch = false) {
137
+ const boundary = useKeyboardDismissResponder();
138
+ return React.useMemo(() => {
139
+ if (!claimsTouch || Platform.OS === "web")
140
+ return boundary;
141
+ return {
142
+ ...boundary,
143
+ onStartShouldSetResponder: (event) => {
144
+ boundary.onStartShouldSetResponder?.(event);
145
+ return true;
146
+ },
147
+ };
148
+ }, [boundary, claimsTouch]);
149
+ }
14
150
  // ============================================================================
15
151
  // Dialog
16
152
  // ============================================================================
@@ -26,8 +162,21 @@ function DialogRoot({ children, ...props }) {
26
162
  }
27
163
  function DialogContent({ portalHost, style, children, ...props }) {
28
164
  const { theme, getShadowStyle, getContrastingColor } = useTheme();
165
+ const dialogRadius = useShape("dialog")?.borderRadius ?? spacing.radiusLg;
166
+ const safeAreaPadding = useDialogSafeAreaPadding();
167
+ const { open, onOpenChange } = DialogPrimitive.useRootContext();
168
+ const dismissBoundaryProps = useDialogKeyboardDismissBoundary(true);
29
169
  const textColor = getContrastingColor(theme.colors.popover, palette.white, palette.black);
30
- return (_jsx(DialogPrimitive.Portal, { hostName: portalHost, children: _jsx(FullWindowOverlay, { children: _jsx(DialogPrimitive.Overlay
170
+ return (_jsx(DialogPresentation, { Portal: DialogPrimitive.Portal, open: open, portalHost: portalHost, onRequestClose: () => onOpenChange(false), children: _jsx(DialogPrimitive.Overlay
171
+ // On web the primitive wraps the overlay in a react-native-web
172
+ // Pressable whose click handler stops propagation unconditionally.
173
+ // With that Pressable between the overlay and the Radix content,
174
+ // outside presses (mouse and touch) and Escape never dismissed the
175
+ // dialog, so a dialog without a Close button was stuck. `asChild`
176
+ // makes the primitive clone our fade wrapper instead, removing the
177
+ // Pressable; Radix dismissal then works. Native keeps the Pressable,
178
+ // which is what implements closeOnPress there.
179
+ , {
31
180
  // On web the primitive wraps the overlay in a react-native-web
32
181
  // Pressable whose click handler stops propagation unconditionally.
33
182
  // With that Pressable between the overlay and the Radix content,
@@ -36,32 +185,23 @@ function DialogContent({ portalHost, style, children, ...props }) {
36
185
  // makes the primitive clone our fade wrapper instead, removing the
37
186
  // Pressable; Radix dismissal then works. Native keeps the Pressable,
38
187
  // which is what implements closeOnPress there.
39
- , {
40
- // On web the primitive wraps the overlay in a react-native-web
41
- // Pressable whose click handler stops propagation unconditionally.
42
- // With that Pressable between the overlay and the Radix content,
43
- // outside presses (mouse and touch) and Escape never dismissed the
44
- // dialog, so a dialog without a Close button was stuck. `asChild`
45
- // makes the primitive clone our fade wrapper instead, removing the
46
- // Pressable; Radix dismissal then works. Native keeps the Pressable,
47
- // which is what implements closeOnPress there.
48
- asChild: Platform.OS === "web", style: StyleSheet.flatten([
49
- StyleSheet.absoluteFill,
50
- { backgroundColor: theme.colors.overlay },
51
- Platform.OS === "web" && { zIndex: 50 },
52
- ]), children: _jsx(AnimatedView, { type: "fade", enterDuration: 200, style: StyleSheet.absoluteFill, children: _jsx(View, { style: overlayStyles.centeredContainer, children: _jsx(AnimatedView, { type: "scale", enterDuration: 250, style: overlayStyles.sizer, children: _jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(DialogPrimitive.Content, { style: StyleSheet.flatten([
188
+ asChild: Platform.OS === "web", style: StyleSheet.flatten([
189
+ StyleSheet.absoluteFill,
190
+ { backgroundColor: theme.colors.overlay },
191
+ Platform.OS === "web" && { zIndex: 50 },
192
+ ]), children: _jsx(AnimatedView, { type: "fade", enterDuration: 200, style: StyleSheet.absoluteFill, children: _jsx(DialogKeyboardAvoidance, { children: _jsx(View, { style: [overlayStyles.centeredContainer, safeAreaPadding], testID: "dialog-safe-area", children: _jsx(AnimatedView, { type: "scale", enterDuration: 250, style: overlayStyles.sizer, children: _jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(DialogPrimitive.Content, { style: StyleSheet.flatten([
53
193
  {
54
194
  backgroundColor: theme.colors.popover,
55
195
  borderColor: theme.colors.border,
56
196
  borderWidth: 1,
57
- borderRadius: spacing.radiusLg,
197
+ borderRadius: dialogRadius,
58
198
  padding: spacing.dialogPadding,
59
199
  gap: spacing.md,
60
200
  width: "100%",
61
201
  ...getShadowStyle("soft"),
62
202
  },
63
203
  style,
64
- ]), ...props, children: children }) }) }) }) }) }) }) }) }));
204
+ ]), ...dismissBoundaryProps, ...props, children: children }) }) }) }) }) }) }) }) }));
65
205
  }
66
206
  function DialogHeader({ children, style, ...props }) {
67
207
  return (_jsx(View, { style: StyleSheet.flatten([{ gap: spacing.xs }, style]), ...props, children: children }));
@@ -119,17 +259,21 @@ function AlertDialogRoot({ children, ...props }) {
119
259
  }
120
260
  function AlertDialogContent({ portalHost, style, children, ...props }) {
121
261
  const { theme, getShadowStyle, getContrastingColor } = useTheme();
262
+ const dialogRadius = useShape("dialog")?.borderRadius ?? spacing.radiusLg;
263
+ const safeAreaPadding = useDialogSafeAreaPadding();
264
+ const { open, onOpenChange } = AlertDialogPrimitive.useRootContext();
265
+ const dismissBoundaryProps = useDialogKeyboardDismissBoundary();
122
266
  const textColor = getContrastingColor(theme.colors.popover, palette.white, palette.black);
123
- return (_jsx(AlertDialogPrimitive.Portal, { hostName: portalHost, children: _jsx(FullWindowOverlay, { children: _jsx(AlertDialogPrimitive.Overlay, { style: StyleSheet.flatten([
124
- StyleSheet.absoluteFill,
125
- { backgroundColor: theme.colors.overlay },
126
- Platform.OS === "web" && { zIndex: 52 },
127
- ]), children: _jsx(AnimatedView, { type: "fade", enterDuration: 200, style: StyleSheet.absoluteFill, children: _jsx(View, { style: overlayStyles.centeredContainer, children: _jsx(AnimatedView, { type: "scale", enterDuration: 250, style: overlayStyles.sizer, children: _jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(AlertDialogPrimitive.Content, { style: StyleSheet.flatten([
267
+ return (_jsx(DialogPresentation, { Portal: AlertDialogPrimitive.Portal, open: open, portalHost: portalHost, onRequestClose: () => onOpenChange(false), children: _jsx(AlertDialogPrimitive.Overlay, { style: StyleSheet.flatten([
268
+ StyleSheet.absoluteFill,
269
+ { backgroundColor: theme.colors.overlay },
270
+ Platform.OS === "web" && { zIndex: 52 },
271
+ ]), children: _jsx(AnimatedView, { type: "fade", enterDuration: 200, style: StyleSheet.absoluteFill, children: _jsx(DialogKeyboardAvoidance, { children: _jsx(View, { style: [overlayStyles.centeredContainer, safeAreaPadding], ...dismissBoundaryProps, children: _jsx(AnimatedView, { type: "scale", enterDuration: 250, style: overlayStyles.sizer, children: _jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(AlertDialogPrimitive.Content, { style: StyleSheet.flatten([
128
272
  {
129
273
  backgroundColor: theme.colors.popover,
130
274
  borderColor: theme.colors.border,
131
275
  borderWidth: 1,
132
- borderRadius: spacing.radiusLg,
276
+ borderRadius: dialogRadius,
133
277
  padding: spacing.dialogPadding,
134
278
  gap: spacing.md,
135
279
  width: "100%",
@@ -174,7 +318,12 @@ const AlertDialog = Object.assign(AlertDialogRoot, {
174
318
  // ============================================================================
175
319
  // Shared styles
176
320
  // ============================================================================
177
- const overlayStyles = StyleSheet.create({
321
+ const overlayStyles = /*#__PURE__*/ StyleSheet.create({
322
+ // The keyboard-avoiding wrapper fills the overlay so its bottom padding
323
+ // shrinks the centered container instead of the card.
324
+ fill: {
325
+ flex: 1,
326
+ },
178
327
  centeredContainer: {
179
328
  flex: 1,
180
329
  justifyContent: "center",
@@ -80,7 +80,7 @@ interface DrawerBodyProps extends ViewProps {
80
80
  interface DrawerFooterProps extends ViewProps {
81
81
  children: React.ReactNode;
82
82
  }
83
- declare function DrawerRoot({ open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen, side, width, closeOnBackdropPress, variant, collapsedWidth, expandedWidth, expandOnHover, defaultExpanded, expanded: controlledExpanded, onExpandedChange: controlledOnExpandedChange, children, }: DrawerProps): React.JSX.Element;
83
+ declare function DrawerRoot({ open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen, side, width, closeOnBackdropPress, variant, collapsedWidth, expandedWidth, expandOnHover: expandOnHoverProp, defaultExpanded, expanded: controlledExpanded, onExpandedChange: controlledOnExpandedChange, children, }: DrawerProps): React.JSX.Element;
84
84
  declare function DrawerTrigger({ asChild, children, style: styleOverride }: DrawerTriggerProps): React.JSX.Element;
85
85
  interface DrawerToggleCollapseProps {
86
86
  /** Use child component as the toggle */
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React, { createContext, use, useState, useReducer, useRef } from "react";
2
+ import React, { createContext, use, useEffectEvent, useLayoutEffect, useState, useReducer, useRef } from "react";
3
3
  import { View, Pressable, Animated, StyleSheet, Platform, PanResponder, } from "react-native";
4
4
  import { Portal } from "@rn-primitives/portal";
5
5
  import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens";
@@ -7,9 +7,10 @@ import { Pressable as SlotPressable } from "@rn-primitives/slot";
7
7
  import { useTheme } from "../hooks/useTheme.js";
8
8
  import { useDimensions } from "../hooks/useDimensions.js";
9
9
  import { shouldUseNativeDriver } from "../lib/animations.js";
10
+ import { useAnimatedValue } from "../lib/useAnimatedValue.js";
10
11
  import { spacing } from "../constants/spacing.js";
11
12
  import { durations } from "../constants/motion.js";
12
- import { TextColorContext, TextClassContext } from "./StyledText.context";
13
+ import { TextColorContext, TextClassContext } from "./StyledText.context.js";
13
14
  import { useSafeAreaInsets } from "react-native-safe-area-context";
14
15
  import { SansSerifBoldText } from "./StyledText.js";
15
16
  /**
@@ -80,7 +81,10 @@ function drawerReducer(state, action) {
80
81
  // ============================================================================
81
82
  // Drawer Root Component
82
83
  // ============================================================================
83
- function DrawerRoot({ open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen = false, side = "left", width = 300, closeOnBackdropPress = true, variant = "overlay", collapsedWidth = 72, expandedWidth = 240, expandOnHover = Platform.OS === "web", defaultExpanded = false, expanded: controlledExpanded, onExpandedChange: controlledOnExpandedChange, children, }) {
84
+ function DrawerRoot({ open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen = false, side = "left", width = 300, closeOnBackdropPress = true, variant = "overlay", collapsedWidth = 72, expandedWidth = 240, expandOnHover: expandOnHoverProp, defaultExpanded = false, expanded: controlledExpanded, onExpandedChange: controlledOnExpandedChange, children, }) {
85
+ // Resolved in the body rather than as a default parameter: the React
86
+ // Compiler can't reorder a computed default, and skipped the component.
87
+ const expandOnHover = expandOnHoverProp === undefined ? Platform.OS === "web" : expandOnHoverProp;
84
88
  // Use reducer for stable state management - dispatch is stable and reducer always gets current state
85
89
  const [internalOpen, dispatch] = useReducer(drawerReducer, defaultOpen);
86
90
  const isControlled = controlledOpen !== undefined;
@@ -219,42 +223,43 @@ function DrawerOverlayContent({ swipeEnabled = true, swipeThreshold = 0.3, veloc
219
223
  const { open, onOpenChange, side, width, closeOnBackdropPress } = drawerContext;
220
224
  const { theme, getShadowStyle } = useTheme();
221
225
  const insets = useSafeAreaInsets();
222
- // Animation values - initialize lazily so the Animated.Value is allocated
223
- // once on first render instead of being rebuilt and discarded every render.
226
+ // Animation values, allocated once. Both start closed: a drawer that mounts
227
+ // open slides in from there (see the open-change effect below).
224
228
  const closedPosition = side === "left" ? -width : width;
225
- const translateXRef = useRef(null);
226
- if (translateXRef.current === null) {
227
- translateXRef.current = new Animated.Value(open ? 0 : closedPosition);
228
- }
229
- const translateX = translateXRef.current;
230
- const backdropOpacityRef = useRef(null);
231
- if (backdropOpacityRef.current === null) {
232
- backdropOpacityRef.current = new Animated.Value(open ? 1 : 0);
233
- }
234
- const backdropOpacity = backdropOpacityRef.current;
229
+ const translateX = useAnimatedValue(closedPosition);
230
+ const backdropOpacity = useAnimatedValue(0);
235
231
  // Track if drawer is actually visible (for unmounting after close animation)
236
232
  const [isVisible, setIsVisible] = useState(open);
233
+ // Opening marks the panel visible in the same render, so it stays mounted
234
+ // through the close animation that follows. State adjusted during render
235
+ // (React re-runs the render before committing it), keyed on `open` changing.
236
+ const [previousOpenProp, setPreviousOpenProp] = useState(open);
237
+ if (open !== previousOpenProp) {
238
+ setPreviousOpenProp(open);
239
+ if (open && !isVisible) {
240
+ setIsVisible(true);
241
+ }
242
+ }
237
243
  // Track what we last animated to - persists across renders
238
244
  const lastOpenRef = useRef(null);
239
245
  // Track running animation to properly cancel it
240
246
  const runningAnimationRef = useRef(null);
241
247
  // Use semantic foreground color for text on background
242
248
  const textColor = theme.colors.foreground;
243
- // Trigger animation during render if open changed
244
- if (open !== lastOpenRef.current) {
249
+ // Animate when `open` changes. Runs in the commit's layout phase, before
250
+ // the frame paints, and reads that commit's position and width.
251
+ const animateOpenChange = useEffectEvent((nextOpen) => {
245
252
  const previousOpen = lastOpenRef.current;
246
- lastOpenRef.current = open;
253
+ if (nextOpen === previousOpen)
254
+ return;
255
+ lastOpenRef.current = nextOpen;
247
256
  // Stop any running animations immediately
248
257
  if (runningAnimationRef.current) {
249
258
  runningAnimationRef.current.stop();
250
259
  runningAnimationRef.current = null;
251
260
  }
252
- if (open) {
253
- // Opening - set visible immediately
254
- if (!isVisible) {
255
- setIsVisible(true);
256
- }
257
- // If this is first render (previousOpen is null), set initial position
261
+ if (nextOpen) {
262
+ // If this is the first run (previousOpen is null), start from closed.
258
263
  // Otherwise animate from current position (handles mid-animation toggle)
259
264
  if (previousOpen === null) {
260
265
  translateX.setValue(closedPosition);
@@ -303,9 +308,13 @@ function DrawerOverlayContent({ swipeEnabled = true, swipeThreshold = 0.3, veloc
303
308
  }
304
309
  });
305
310
  }
306
- }
307
- // Create pan responder for swipe gestures (native only)
308
- const panResponder = useRef(Platform.OS !== "web" && swipeEnabled
311
+ });
312
+ useLayoutEffect(() => {
313
+ animateOpenChange(open);
314
+ }, [open]);
315
+ // Create pan responder for swipe gestures (native only). Built once, from
316
+ // the first render's props, like the ref it replaces.
317
+ const [panResponder] = useState(() => Platform.OS !== "web" && swipeEnabled
309
318
  ? PanResponder.create({
310
319
  onStartShouldSetPanResponder: () => false,
311
320
  onMoveShouldSetPanResponder: (_evt, gestureState) => {
@@ -377,7 +386,7 @@ function DrawerOverlayContent({ swipeEnabled = true, swipeThreshold = 0.3, veloc
377
386
  }
378
387
  },
379
388
  })
380
- : null).current;
389
+ : null);
381
390
  // Handle backdrop press
382
391
  const handleBackdropPress = () => {
383
392
  if (closeOnBackdropPress) {
@@ -458,44 +467,45 @@ swipeEnabled: _swipeEnabled, swipeThreshold: _swipeThreshold, velocityThreshold:
458
467
  // `expanded` true→false) while the pointer is still over it, the active hover
459
468
  // would instantly re-expand it and the collapse would look like a no-op.
460
469
  // Suppress the current hover session in that case; a fresh mouse-enter clears
461
- // the suppression so peek-on-hover works again. Tracked in a ref, read during
462
- // the re-render that the `expanded` change already triggers (same pattern as
463
- // `lastExpandedRef` below).
464
- const hoverSuppressedRef = useRef(false);
465
- const prevExpandedRef = useRef(expanded);
466
- if (prevExpandedRef.current !== expanded) {
467
- const wasExpanded = prevExpandedRef.current;
468
- prevExpandedRef.current = expanded;
469
- if (wasExpanded && !expanded && hovered) {
470
- hoverSuppressedRef.current = true;
470
+ // the suppression so peek-on-hover works again. State adjusted during the
471
+ // render that the `expanded` change already triggers: React re-runs that
472
+ // render with the suppression applied before committing it.
473
+ const [hoverSuppressed, setHoverSuppressed] = useState(false);
474
+ const [previousExpanded, setPreviousExpanded] = useState(expanded);
475
+ if (previousExpanded !== expanded) {
476
+ setPreviousExpanded(expanded);
477
+ if (previousExpanded && !expanded && hovered) {
478
+ setHoverSuppressed(true);
471
479
  }
472
480
  }
473
- const effectiveExpanded = expanded || (expandOnHover && hovered && !hoverSuppressedRef.current);
481
+ const effectiveExpanded = expanded || (expandOnHover && hovered && !hoverSuppressed);
474
482
  const textColor = theme.colors.foreground;
475
483
  const targetWidth = effectiveExpanded ? expandedWidth : collapsedWidth;
476
484
  // Native animates width via Animated.Value (layout prop → useNativeDriver: false).
477
485
  // Web sets the width directly and lets the inline CSS `transition` animate it.
478
- const widthRef = useRef(null);
479
- if (widthRef.current === null) {
480
- widthRef.current = new Animated.Value(targetWidth);
481
- }
482
- const widthAnim = widthRef.current;
483
- // Trigger the native width animation during render when expansion changes,
484
- // mirroring the overlay's lastOpenRef pattern above. Skip the first render:
485
- // the Animated.Value is already initialized to the current target, so there is
486
- // nothing to animate toward on mount.
486
+ const widthAnim = useAnimatedValue(targetWidth);
487
+ // Start the native width animation when expansion changes, in the commit's
488
+ // layout phase (before the frame paints), mirroring the overlay's open-change
489
+ // effect above. Skip the first run: the Animated.Value is already
490
+ // initialized to the current target, so there is nothing to animate toward
491
+ // on mount.
487
492
  const lastExpandedRef = useRef(null);
488
- if (Platform.OS !== "web" && effectiveExpanded !== lastExpandedRef.current) {
489
- const previousExpanded = lastExpandedRef.current;
490
- lastExpandedRef.current = effectiveExpanded;
491
- if (previousExpanded !== null) {
493
+ const animateExpandedChange = useEffectEvent((nextExpanded) => {
494
+ if (Platform.OS === "web" || nextExpanded === lastExpandedRef.current)
495
+ return;
496
+ const previousAnimatedExpanded = lastExpandedRef.current;
497
+ lastExpandedRef.current = nextExpanded;
498
+ if (previousAnimatedExpanded !== null) {
492
499
  Animated.timing(widthAnim, {
493
500
  toValue: targetWidth,
494
501
  duration: durations.normal,
495
502
  useNativeDriver: false,
496
503
  }).start();
497
504
  }
498
- }
505
+ });
506
+ useLayoutEffect(() => {
507
+ animateExpandedChange(effectiveExpanded);
508
+ }, [effectiveExpanded]);
499
509
  const shadowStyle = effectiveExpanded
500
510
  ? StyleSheet.flatten(getShadowStyle("elevated"))
501
511
  : undefined;
@@ -521,11 +531,11 @@ swipeEnabled: _swipeEnabled, swipeThreshold: _swipeThreshold, velocityThreshold:
521
531
  const hoverHandlers = Platform.OS === "web" && expandOnHover
522
532
  ? {
523
533
  onMouseEnter: () => {
524
- hoverSuppressedRef.current = false;
534
+ setHoverSuppressed(false);
525
535
  setHovered(true);
526
536
  },
527
537
  onMouseLeave: () => {
528
- hoverSuppressedRef.current = false;
538
+ setHoverSuppressed(false);
529
539
  setHovered(false);
530
540
  },
531
541
  }
@@ -3,9 +3,10 @@ import * as React from "react";
3
3
  import { Animated, Platform, StyleSheet, Text, View } from "react-native";
4
4
  import { Icon } from "./Icon.js";
5
5
  import { AnimatedView } from "./AnimatedView.js";
6
- import { TextClassContext, TextSelectabilityContext } from "./StyledText.context";
6
+ import { TextClassContext, TextSelectabilityContext } from "./StyledText.context.js";
7
7
  import { useTheme } from "../hooks/useTheme.js";
8
8
  import { spacing } from "../constants/spacing.js";
9
+ import { interaction } from "../constants/interaction.js";
9
10
  import { useScalePress } from "../hooks/useScalePress.js";
10
11
  import * as DropdownMenuPrimitive from "@rn-primitives/dropdown-menu";
11
12
  import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens";
@@ -76,7 +77,7 @@ function DropdownMenuContent({ side, align = "start", sideOffset = 4, portalHost
76
77
  return (_jsx(DropdownMenuPrimitive.Portal, { hostName: portalHost, children: _jsx(FullWindowOverlay, { children: _jsx(DropdownMenuPrimitive.Overlay, { style: Platform.select({
77
78
  native: StyleSheet.absoluteFill,
78
79
  default: undefined,
79
- }), children: _jsx(AnimatedView, { type: "fade", style: StyleSheet.absoluteFill, pointerEvents: "box-none", children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(DropdownMenuPrimitive.Content, { side: side, align: align, sideOffset: sideOffset, insets: insets, avoidCollisions: true, ...props, style: {
80
+ }), children: _jsx(AnimatedView, { type: "fade", style: [StyleSheet.absoluteFill, styles.passThrough], children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(DropdownMenuPrimitive.Content, { side: side, align: align, sideOffset: sideOffset, insets: insets, avoidCollisions: true, ...props, style: {
80
81
  backgroundColor: theme.colors.popover,
81
82
  borderWidth: 1,
82
83
  borderColor: theme.colors.border,
@@ -109,7 +110,7 @@ function DropdownMenuItem({ inset = false, variant = "default", style: styleOver
109
110
  outlineStyle: "none",
110
111
  userSelect: "none",
111
112
  }),
112
- ...(props.disabled && { opacity: 0.5 }),
113
+ ...(props.disabled && { opacity: interaction.disabledOpacity }),
113
114
  ...(inset && { paddingLeft: spacing.xl }),
114
115
  ...(styleOverride && typeof styleOverride !== "function"
115
116
  ? StyleSheet.flatten(styleOverride)
@@ -130,7 +131,7 @@ function DropdownMenuCheckboxItem({ children, style: styleOverride, ...props })
130
131
  outlineStyle: "none",
131
132
  userSelect: "none",
132
133
  }),
133
- ...(props.disabled && { opacity: 0.5 }),
134
+ ...(props.disabled && { opacity: interaction.disabledOpacity }),
134
135
  ...(styleOverride && typeof styleOverride !== "function"
135
136
  ? StyleSheet.flatten(styleOverride)
136
137
  : {}),
@@ -157,7 +158,7 @@ function DropdownMenuRadioItem({ children, style: styleOverride, ...props }) {
157
158
  outlineStyle: "none",
158
159
  userSelect: "none",
159
160
  }),
160
- ...(props.disabled && { opacity: 0.5 }),
161
+ ...(props.disabled && { opacity: interaction.disabledOpacity }),
161
162
  ...(styleOverride && typeof styleOverride !== "function"
162
163
  ? StyleSheet.flatten(styleOverride)
163
164
  : {}),
@@ -172,7 +173,7 @@ function DropdownMenuRadioItem({ children, style: styleOverride, ...props }) {
172
173
  backgroundColor: theme.colors.text,
173
174
  height: 8,
174
175
  width: 8,
175
- borderRadius: 4,
176
+ borderRadius: spacing.radiusFull,
176
177
  } }) }) }), typeof children === "function" ? null : children] }) }) }) }));
177
178
  }
178
179
  function DropdownMenuLabel({ inset = false, style: styleOverride, ...props }) {
@@ -216,7 +217,10 @@ function DropdownMenuShortcut({ children, text, style: styleOverride, ...props }
216
217
  styleOverride,
217
218
  ], children: text ?? children }));
218
219
  }
219
- const styles = StyleSheet.create({
220
+ const styles = /*#__PURE__*/ StyleSheet.create({
221
+ // Overlay layer: covers the screen without swallowing touches (style, not the
222
+ // deprecated `pointerEvents` prop).
223
+ passThrough: { pointerEvents: "box-none" },
220
224
  item: {
221
225
  position: "relative",
222
226
  flexDirection: "row",
@@ -1,7 +1,7 @@
1
1
  import React from "react";
2
2
  import { StyleProp, ViewStyle } from "react-native";
3
- import { type ButtonProps } from "./Button";
4
- import { type IconName } from "./Icon";
3
+ import { type ButtonProps } from "./Button.js";
4
+ import { type IconName } from "./Icon.js";
5
5
  export interface EmptyStateProps {
6
6
  /** Icon name to display */
7
7
  icon?: IconName;
@@ -4,6 +4,7 @@ import { SansSerifBoldText, SansSerifText } from "./StyledText.js";
4
4
  import { Button } from "./Button.js";
5
5
  import { Icon } from "./Icon.js";
6
6
  import { useTheme } from "../hooks/useTheme.js";
7
+ import { shapeRadius, useShape } from "../hooks/useShape.js";
7
8
  import { spacing } from "../constants/spacing.js";
8
9
  import { createThemedStyles } from "../lib/themedStyles.js";
9
10
  /**
@@ -26,7 +27,9 @@ import { createThemedStyles } from "../lib/themedStyles.js";
26
27
  export function EmptyState({ icon, iconSize = spacing.iconMd, title, description, actionLabel, onAction, actionPreset = "default", style, children, bordered = false, }) {
27
28
  const { theme } = useTheme();
28
29
  const styles = themedStyles(theme);
29
- return (_jsxs(View, { style: [styles.container, bordered && styles.bordered, style], children: [!!icon && (_jsx(View, { style: styles.iconWrapper, children: _jsx(Icon, { name: icon, size: iconSize, color: theme.colors.mutedForeground }) })), _jsx(SansSerifBoldText, { selectable: false, size: "lg", style: styles.title, children: title }), !!description && (_jsx(SansSerifText, { selectable: false, size: "base", style: styles.description, children: description })), children, !!actionLabel && onAction && (_jsx(Button, { preset: actionPreset, onPress: onAction, text: actionLabel, style: styles.action }))] }));
30
+ // Host-app `setShape({ card })` shapes the bordered frame like a Card.
31
+ const cardRadius = shapeRadius(useShape("card"));
32
+ return (_jsxs(View, { style: [styles.container, bordered && styles.bordered, bordered && cardRadius, style], children: [!!icon && (_jsx(View, { style: styles.iconWrapper, children: _jsx(Icon, { name: icon, size: iconSize, color: theme.colors.mutedForeground }) })), _jsx(SansSerifBoldText, { selectable: false, size: "lg", style: styles.title, children: title }), !!description && (_jsx(SansSerifText, { selectable: false, size: "base", style: styles.description, children: description })), children, !!actionLabel && onAction && (_jsx(Button, { preset: actionPreset, onPress: onAction, text: actionLabel, style: styles.action }))] }));
30
33
  }
31
34
  const MEDIA_SIZE = 56;
32
35
  const createStyles = (theme) => StyleSheet.create({
@@ -74,4 +77,4 @@ const createStyles = (theme) => StyleSheet.create({
74
77
  alignSelf: "center",
75
78
  },
76
79
  });
77
- const themedStyles = createThemedStyles(createStyles);
80
+ const themedStyles = /*#__PURE__*/ createThemedStyles(createStyles);
@@ -0,0 +1,31 @@
1
+ import type { ReactNode } from "react";
2
+ export interface HydratedProps {
3
+ /** What the server HTML and the hydration pass show in place of the children. @default null */
4
+ fallback?: ReactNode;
5
+ children: ReactNode;
6
+ }
7
+ /**
8
+ * Hydrated
9
+ *
10
+ * Renders `fallback` on the server and through the hydration pass, then its
11
+ * children in the first client render — the gate for markup the server cannot
12
+ * reproduce. Under Expo Router's SSR the server renders the app inside
13
+ * `+html.tsx` while the client hydrates `#root`, so every `useId()` differs
14
+ * between the two trees; the kit defers its own ids, but a Radix-backed
15
+ * component (`Tabs`, `Accordion`, `Collapsible`, `Select`, `DropdownMenu`,
16
+ * `Popover`) emits ids on both sides and logs a hydration mismatch when it is
17
+ * part of a server-rendered route. Wrap that subtree, not the page: the rest of
18
+ * the screen keeps its server HTML. Give it a fallback that holds the layout
19
+ * (a placeholder the same height) so nothing shifts when the children mount.
20
+ *
21
+ * Do not use it for content that must be in the server HTML (a FAQ's questions,
22
+ * a product's description); accept the development-only log there instead. On
23
+ * native there is no hydration pass, so children render immediately.
24
+ *
25
+ * ```tsx
26
+ * <Hydrated fallback={<View style={{ height: 240 }} />}>
27
+ * <Tabs defaultValue="day">…</Tabs>
28
+ * </Hydrated>
29
+ * ```
30
+ */
31
+ export declare function Hydrated({ fallback, children }: HydratedProps): import("react").JSX.Element;