@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,16 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import * as React from "react";
2
3
  import { Modal, Platform, StyleSheet, View } from "react-native";
3
4
  import * as DialogPrimitive from "@rn-primitives/dialog";
4
5
  import * as AlertDialogPrimitive from "@rn-primitives/alert-dialog";
5
6
  import { AnimatedView } from "./AnimatedView.js";
6
7
  import { KeyboardAvoidingView } from "./KeyboardAvoidingView.js";
7
- import { TextClassContext, TextColorContext } from "./StyledText.context";
8
+ import { useWindowInsets } from "../hooks/useWindowInsets.js";
9
+ import { useKeyboardDismissResponder } from "./keyboardDismiss.js";
10
+ import { TextClassContext, TextColorContext } from "./StyledText.context.js";
8
11
  import { StyledText } from "./StyledText.js";
9
12
  import { useTheme } from "../hooks/useTheme.js";
13
+ import { useShape } from "../hooks/useShape.js";
10
14
  import { spacing } from "../constants/spacing.js";
11
15
  import { palette } from "../constants/colors.js";
12
16
  // ============================================================================
@@ -64,24 +68,85 @@ function DialogPresentation({ Portal, open, portalHost, onRequestClose, children
64
68
  /**
65
69
  * Keyboard avoidance owner for dialog content.
66
70
  *
67
- * On iOS the `Modal` above is presented outside `UIProvider`'s root
68
- * `KeyboardAvoidingView`, so nothing else can keep a focused dialog field and
69
- * the footer above the keyboard: this wrapper pads the centered container by
70
- * the keyboard height, and the card (capped at 85% of the remaining height)
71
- * recenters in the space that is left. It is the package `KeyboardAvoidingView`,
72
- * so `useKeyboardAvoidance()` is `true` inside `DialogContent` and a
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
73
81
  * `DismissKeyboard` in dialog content adds no second layer. Do not wrap dialog
74
82
  * content in another `KeyboardAvoidingView`.
75
83
  *
76
- * Android and web keep the portal-host tree unchanged; the portal host sits
77
- * outside the root avoidance there, and a dialog is not keyboard-avoided yet.
84
+ * Web has no software keyboard and keeps the portal-host tree unchanged.
78
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
+ }
79
103
  function DialogKeyboardAvoidance({ children }) {
80
- if (Platform.OS !== "ios") {
104
+ if (Platform.OS === "web") {
81
105
  return _jsx(_Fragment, { children: children });
82
106
  }
83
107
  return (_jsx(KeyboardAvoidingView, { style: overlayStyles.fill, behavior: "padding", keyboardVerticalOffset: 0, children: children }));
84
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
+ }
85
150
  // ============================================================================
86
151
  // Dialog
87
152
  // ============================================================================
@@ -97,7 +162,10 @@ function DialogRoot({ children, ...props }) {
97
162
  }
98
163
  function DialogContent({ portalHost, style, children, ...props }) {
99
164
  const { theme, getShadowStyle, getContrastingColor } = useTheme();
165
+ const dialogRadius = useShape("dialog")?.borderRadius ?? spacing.radiusLg;
166
+ const safeAreaPadding = useDialogSafeAreaPadding();
100
167
  const { open, onOpenChange } = DialogPrimitive.useRootContext();
168
+ const dismissBoundaryProps = useDialogKeyboardDismissBoundary(true);
101
169
  const textColor = getContrastingColor(theme.colors.popover, palette.white, palette.black);
102
170
  return (_jsx(DialogPresentation, { Portal: DialogPrimitive.Portal, open: open, portalHost: portalHost, onRequestClose: () => onOpenChange(false), children: _jsx(DialogPrimitive.Overlay
103
171
  // On web the primitive wraps the overlay in a react-native-web
@@ -121,19 +189,19 @@ function DialogContent({ portalHost, style, children, ...props }) {
121
189
  StyleSheet.absoluteFill,
122
190
  { backgroundColor: theme.colors.overlay },
123
191
  Platform.OS === "web" && { zIndex: 50 },
124
- ]), children: _jsx(AnimatedView, { type: "fade", enterDuration: 200, style: StyleSheet.absoluteFill, children: _jsx(DialogKeyboardAvoidance, { 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([
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([
125
193
  {
126
194
  backgroundColor: theme.colors.popover,
127
195
  borderColor: theme.colors.border,
128
196
  borderWidth: 1,
129
- borderRadius: spacing.radiusLg,
197
+ borderRadius: dialogRadius,
130
198
  padding: spacing.dialogPadding,
131
199
  gap: spacing.md,
132
200
  width: "100%",
133
201
  ...getShadowStyle("soft"),
134
202
  },
135
203
  style,
136
- ]), ...props, children: children }) }) }) }) }) }) }) }) }));
204
+ ]), ...dismissBoundaryProps, ...props, children: children }) }) }) }) }) }) }) }) }));
137
205
  }
138
206
  function DialogHeader({ children, style, ...props }) {
139
207
  return (_jsx(View, { style: StyleSheet.flatten([{ gap: spacing.xs }, style]), ...props, children: children }));
@@ -191,18 +259,21 @@ function AlertDialogRoot({ children, ...props }) {
191
259
  }
192
260
  function AlertDialogContent({ portalHost, style, children, ...props }) {
193
261
  const { theme, getShadowStyle, getContrastingColor } = useTheme();
262
+ const dialogRadius = useShape("dialog")?.borderRadius ?? spacing.radiusLg;
263
+ const safeAreaPadding = useDialogSafeAreaPadding();
194
264
  const { open, onOpenChange } = AlertDialogPrimitive.useRootContext();
265
+ const dismissBoundaryProps = useDialogKeyboardDismissBoundary();
195
266
  const textColor = getContrastingColor(theme.colors.popover, palette.white, palette.black);
196
267
  return (_jsx(DialogPresentation, { Portal: AlertDialogPrimitive.Portal, open: open, portalHost: portalHost, onRequestClose: () => onOpenChange(false), children: _jsx(AlertDialogPrimitive.Overlay, { style: StyleSheet.flatten([
197
268
  StyleSheet.absoluteFill,
198
269
  { backgroundColor: theme.colors.overlay },
199
270
  Platform.OS === "web" && { zIndex: 52 },
200
- ]), children: _jsx(AnimatedView, { type: "fade", enterDuration: 200, style: StyleSheet.absoluteFill, children: _jsx(DialogKeyboardAvoidance, { 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([
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([
201
272
  {
202
273
  backgroundColor: theme.colors.popover,
203
274
  borderColor: theme.colors.border,
204
275
  borderWidth: 1,
205
- borderRadius: spacing.radiusLg,
276
+ borderRadius: dialogRadius,
206
277
  padding: spacing.dialogPadding,
207
278
  gap: spacing.md,
208
279
  width: "100%",
@@ -247,7 +318,7 @@ const AlertDialog = Object.assign(AlertDialogRoot, {
247
318
  // ============================================================================
248
319
  // Shared styles
249
320
  // ============================================================================
250
- const overlayStyles = StyleSheet.create({
321
+ const overlayStyles = /*#__PURE__*/ StyleSheet.create({
251
322
  // The keyboard-avoiding wrapper fills the overlay so its bottom padding
252
323
  // shrinks the centered container instead of the card.
253
324
  fill: {
@@ -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;
@@ -0,0 +1,30 @@
1
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useHydrated } from "../hooks/useHydrated.js";
3
+ /**
4
+ * Hydrated
5
+ *
6
+ * Renders `fallback` on the server and through the hydration pass, then its
7
+ * children in the first client render — the gate for markup the server cannot
8
+ * reproduce. Under Expo Router's SSR the server renders the app inside
9
+ * `+html.tsx` while the client hydrates `#root`, so every `useId()` differs
10
+ * between the two trees; the kit defers its own ids, but a Radix-backed
11
+ * component (`Tabs`, `Accordion`, `Collapsible`, `Select`, `DropdownMenu`,
12
+ * `Popover`) emits ids on both sides and logs a hydration mismatch when it is
13
+ * part of a server-rendered route. Wrap that subtree, not the page: the rest of
14
+ * the screen keeps its server HTML. Give it a fallback that holds the layout
15
+ * (a placeholder the same height) so nothing shifts when the children mount.
16
+ *
17
+ * Do not use it for content that must be in the server HTML (a FAQ's questions,
18
+ * a product's description); accept the development-only log there instead. On
19
+ * native there is no hydration pass, so children render immediately.
20
+ *
21
+ * ```tsx
22
+ * <Hydrated fallback={<View style={{ height: 240 }} />}>
23
+ * <Tabs defaultValue="day">…</Tabs>
24
+ * </Hydrated>
25
+ * ```
26
+ */
27
+ export function Hydrated({ fallback = null, children }) {
28
+ const hydrated = useHydrated();
29
+ return _jsx(_Fragment, { children: hydrated ? children : fallback });
30
+ }
@@ -1,7 +1,7 @@
1
1
  import * as React from "react";
2
- import type { StyleProp, TextStyle, ViewProps, ViewStyle } from "react-native";
3
- import type { ThemeColors } from "../constants/colors";
4
- import { type IconName } from "./iconRegistry.generated";
2
+ import { type StyleProp, type TextStyle, type ViewProps, type ViewStyle } from "react-native";
3
+ import type { ThemeColors } from "../constants/colors.js";
4
+ import { type IconName } from "./iconRegistry.generated.js";
5
5
  /**
6
6
  * Kebab-case Lucide icon names the package bundles, served from
7
7
  * `icon-names.json` through the generated registry. Add a name there and run
@@ -23,10 +23,22 @@ type IconBaseProps = {
23
23
  color?: string | ThemeColorName;
24
24
  /** Additional styles for positioning, transforms, etc. */
25
25
  style?: StyleProp<TextStyle>;
26
- /** When true, hides the icon from the accessibility tree. @default false */
26
+ /**
27
+ * Hides the icon from the accessibility tree. An icon with no
28
+ * `accessibilityLabel` is already hidden (see below), so this is only
29
+ * needed to silence a labelled icon. @default false
30
+ */
27
31
  decorative?: boolean;
32
+ /**
33
+ * What the icon means, for an icon that stands alone (no visible label next
34
+ * to it): `role="img"` + `aria-label` on web, `accessible` +
35
+ * `accessibilityLabel` on native. Omit it when the icon sits beside text
36
+ * that already says it — an unlabeled icon is hidden from assistive tech,
37
+ * which would otherwise announce an unnamed image.
38
+ */
39
+ accessibilityLabel?: string;
28
40
  };
29
- type IconAccessibilityProps = Pick<ViewProps, "accessible" | "importantForAccessibility" | "accessibilityElementsHidden" | "aria-hidden">;
41
+ type IconAccessibilityProps = Pick<ViewProps, "accessible" | "importantForAccessibility" | "accessibilityElementsHidden" | "accessibilityLabel" | "aria-hidden" | "aria-label" | "role">;
30
42
  /**
31
43
  * Props handed to a `component`. Wide enough that any `lucide-react-native`
32
44
  * component type-checks as-is (`LucideProps` extends `SvgProps`, whose `style`