@mrmeg/expo-ui 0.27.1 → 0.29.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 (155) hide show
  1. package/CHANGELOG.md +666 -0
  2. package/LLM_USAGE.md +241 -37
  3. package/README.md +591 -136
  4. package/bin/expo-ui-icons.mjs +509 -0
  5. package/dist/components/Accordion.js +12 -6
  6. package/dist/components/AnimatedView.d.ts +1 -1
  7. package/dist/components/AnimatedView.js +5 -2
  8. package/dist/components/Avatar.d.ts +1 -1
  9. package/dist/components/Avatar.js +2 -2
  10. package/dist/components/Badge.js +5 -1
  11. package/dist/components/BottomSheet.d.ts +29 -1
  12. package/dist/components/BottomSheet.js +107 -30
  13. package/dist/components/Button.d.ts +20 -4
  14. package/dist/components/Button.js +60 -43
  15. package/dist/components/Card.d.ts +1 -1
  16. package/dist/components/Card.js +24 -4
  17. package/dist/components/Carousel.js +2 -2
  18. package/dist/components/Checkbox.js +51 -44
  19. package/dist/components/Collapsible.js +7 -3
  20. package/dist/components/Dialog.js +87 -16
  21. package/dist/components/Drawer.d.ts +1 -1
  22. package/dist/components/Drawer.js +65 -55
  23. package/dist/components/DropdownMenu.js +11 -7
  24. package/dist/components/EmptyState.d.ts +2 -2
  25. package/dist/components/EmptyState.js +5 -2
  26. package/dist/components/Hydrated.d.ts +31 -0
  27. package/dist/components/Hydrated.js +30 -0
  28. package/dist/components/Icon.d.ts +71 -28
  29. package/dist/components/Icon.js +70 -28
  30. package/dist/components/IconGlyph.d.ts +15 -0
  31. package/dist/components/IconGlyph.js +32 -0
  32. package/dist/components/IconGlyph.native.d.ts +10 -0
  33. package/dist/components/IconGlyph.native.js +30 -0
  34. package/dist/components/InputOTP.js +5 -2
  35. package/dist/components/Item.d.ts +103 -4
  36. package/dist/components/Item.js +168 -15
  37. package/dist/components/KeyboardAvoidingView.d.ts +1 -1
  38. package/dist/components/KeyboardAvoidingView.js +4 -1
  39. package/dist/components/Label.js +3 -2
  40. package/dist/components/MaxWidthContainer.js +1 -1
  41. package/dist/components/Notification.js +45 -27
  42. package/dist/components/Popover.d.ts +18 -5
  43. package/dist/components/Popover.js +72 -8
  44. package/dist/components/Progress.js +8 -6
  45. package/dist/components/RadioGroup.js +52 -49
  46. package/dist/components/Screen.d.ts +61 -0
  47. package/dist/components/Screen.js +62 -0
  48. package/dist/components/SectionHeader.d.ts +1 -1
  49. package/dist/components/SegmentedControl.d.ts +12 -4
  50. package/dist/components/SegmentedControl.js +94 -4
  51. package/dist/components/Select.js +21 -26
  52. package/dist/components/Skeleton.js +24 -10
  53. package/dist/components/Slider.d.ts +11 -3
  54. package/dist/components/Slider.js +73 -6
  55. package/dist/components/StatCard.d.ts +4 -2
  56. package/dist/components/StatCard.js +3 -1
  57. package/dist/components/StyledText.d.ts +7 -1
  58. package/dist/components/StyledText.js +5 -3
  59. package/dist/components/Switch.d.ts +1 -1
  60. package/dist/components/Switch.js +20 -30
  61. package/dist/components/Tabs.d.ts +2 -2
  62. package/dist/components/Tabs.js +13 -24
  63. package/dist/components/TextInput.d.ts +1 -1
  64. package/dist/components/TextInput.js +71 -26
  65. package/dist/components/ThemeSelector.d.ts +52 -0
  66. package/dist/components/ThemeSelector.js +90 -0
  67. package/dist/components/Toggle.d.ts +5 -49
  68. package/dist/components/Toggle.js +53 -29
  69. package/dist/components/ToggleGroup.d.ts +4 -8
  70. package/dist/components/ToggleGroup.js +52 -34
  71. package/dist/components/Tooltip.d.ts +1 -1
  72. package/dist/components/Tooltip.js +9 -3
  73. package/dist/components/UIProvider.d.ts +22 -2
  74. package/dist/components/UIProvider.js +15 -3
  75. package/dist/components/bottomSheetDismiss.d.ts +19 -0
  76. package/dist/components/bottomSheetDismiss.js +44 -0
  77. package/dist/components/iconBuiltins.generated.d.ts +143 -0
  78. package/dist/components/iconBuiltins.generated.js +25 -0
  79. package/dist/components/iconTypes.d.ts +37 -0
  80. package/dist/components/iconTypes.js +1 -0
  81. package/dist/components/index.d.ts +47 -44
  82. package/dist/components/index.js +3 -0
  83. package/dist/components/keyboardDismiss.js +12 -4
  84. package/dist/components/nativeTextField.android.js +13 -9
  85. package/dist/constants/colors.d.ts +40 -5
  86. package/dist/constants/colors.js +26 -8
  87. package/dist/constants/fonts.d.ts +41 -2
  88. package/dist/constants/fonts.js +46 -7
  89. package/dist/constants/index.d.ts +5 -4
  90. package/dist/constants/index.js +1 -0
  91. package/dist/constants/interaction.d.ts +18 -0
  92. package/dist/constants/interaction.js +18 -0
  93. package/dist/constants/spacing.d.ts +2 -1
  94. package/dist/constants/spacing.js +2 -1
  95. package/dist/design-system.json +429 -132
  96. package/dist/hooks/index.d.ts +12 -7
  97. package/dist/hooks/index.js +5 -0
  98. package/dist/hooks/useDimensions.d.ts +13 -8
  99. package/dist/hooks/useDimensions.js +123 -48
  100. package/dist/hooks/useFocusVisible.d.ts +30 -0
  101. package/dist/hooks/useFocusVisible.js +45 -0
  102. package/dist/hooks/useFontStyle.d.ts +2 -2
  103. package/dist/hooks/useFontStyle.js +3 -2
  104. package/dist/hooks/useHydrated.d.ts +13 -0
  105. package/dist/hooks/useHydrated.js +19 -0
  106. package/dist/hooks/useNotificationOffset.d.ts +20 -0
  107. package/dist/hooks/useNotificationOffset.js +34 -0
  108. package/dist/hooks/useResources.d.ts +21 -20
  109. package/dist/hooks/useResources.js +72 -68
  110. package/dist/hooks/useScalePress.d.ts +8 -5
  111. package/dist/hooks/useScalePress.js +16 -9
  112. package/dist/hooks/useShape.d.ts +24 -0
  113. package/dist/hooks/useShape.js +24 -0
  114. package/dist/hooks/useStaggeredEntrance.js +9 -7
  115. package/dist/hooks/useTheme.d.ts +3 -3
  116. package/dist/hooks/useTheme.js +177 -66
  117. package/dist/hooks/useWindowInsets.d.ts +13 -0
  118. package/dist/hooks/useWindowInsets.js +23 -0
  119. package/dist/icons/LICENSE.lucide +43 -0
  120. package/dist/icons/lucide.json +2144 -0
  121. package/dist/index.d.ts +5 -5
  122. package/dist/lib/composeRefs.d.ts +8 -0
  123. package/dist/lib/composeRefs.js +20 -0
  124. package/dist/lib/haptics.d.ts +12 -0
  125. package/dist/lib/haptics.js +21 -0
  126. package/dist/lib/index.d.ts +6 -5
  127. package/dist/lib/index.js +1 -0
  128. package/dist/lib/interFonts.d.ts +15 -0
  129. package/dist/lib/interFonts.js +1 -0
  130. package/dist/lib/interFonts.native.d.ts +3 -0
  131. package/dist/lib/interFonts.native.js +20 -0
  132. package/dist/lib/popoverPlacement.d.ts +36 -0
  133. package/dist/lib/popoverPlacement.js +29 -0
  134. package/dist/lib/themedStyles.d.ts +1 -1
  135. package/dist/lib/useAnimatedValue.d.ts +15 -0
  136. package/dist/lib/useAnimatedValue.js +19 -0
  137. package/dist/lib/webHitTarget.d.ts +40 -0
  138. package/dist/lib/webHitTarget.js +37 -0
  139. package/dist/state/feedbackStore.d.ts +24 -0
  140. package/dist/state/feedbackStore.js +16 -0
  141. package/dist/state/globalUIStore.d.ts +27 -0
  142. package/dist/state/globalUIStore.js +23 -0
  143. package/dist/state/index.d.ts +7 -5
  144. package/dist/state/index.js +2 -0
  145. package/dist/state/initialScheme.d.ts +27 -0
  146. package/dist/state/initialScheme.js +29 -0
  147. package/dist/state/notify.d.ts +1 -1
  148. package/dist/state/themeColorScope.d.ts +1 -1
  149. package/dist/state/themeStore.d.ts +69 -16
  150. package/dist/state/themeStore.js +65 -38
  151. package/llms-full.md +59 -10
  152. package/llms.txt +10 -1
  153. package/package.json +79 -4
  154. package/dist/components/iconRegistry.generated.d.ts +0 -148
  155. package/dist/components/iconRegistry.generated.js +0 -296
@@ -1,14 +1,19 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { useCallback, useEffect } from "react";
3
3
  import { View, StyleSheet, Pressable, Platform, Animated } from "react-native";
4
4
  import { Icon } from "./Icon.js";
5
5
  import { StyledText } from "./StyledText.js";
6
6
  import { useTheme } from "../hooks/useTheme.js";
7
+ import { useFocusVisible } from "../hooks/useFocusVisible.js";
7
8
  import { spacing } from "../constants/spacing.js";
8
- import { hapticLight } from "../lib/haptics.js";
9
+ import { hapticSelection } from "../lib/haptics.js";
10
+ import { interaction } from "../constants/interaction.js";
11
+ import { useAnimatedValue } from "../lib/useAnimatedValue.js";
9
12
  import { useReducedMotion } from "../hooks/useReduceMotion.js";
10
13
  import { useScalePress } from "../hooks/useScalePress.js";
14
+ import { shouldUseNativeDriver } from "../lib/animations.js";
11
15
  import * as CheckboxPrimitive from "@rn-primitives/checkbox";
16
+ import { useItemControlLabel } from "./Item.js";
12
17
  const DEFAULT_HIT_SLOP = 8;
13
18
  const SIZE_CONFIGS = {
14
19
  sm: { size: 16, iconSize: 12, strokeWidth: 2.5 },
@@ -20,38 +25,20 @@ function Checkbox({ size = "md", label, indeterminate = false, error = false, st
20
25
  const reduceMotion = useReducedMotion();
21
26
  const sizeConfig = SIZE_CONFIGS[size];
22
27
  const focusRingStyle = getFocusRingStyle();
23
- const [focused, setFocused] = useState(false);
24
28
  const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
25
29
  disabled: !!disabled,
26
30
  scaleTo: 0.92,
27
31
  haptic: false,
28
32
  });
29
- const showFocusRing = (event) => {
30
- let ringVisible = true;
31
- if (Platform.OS === "web") {
32
- const target = event?.nativeEvent?.target;
33
- if (target && typeof target.matches === "function") {
34
- try {
35
- ringVisible = target.matches(":focus-visible");
36
- }
37
- catch {
38
- ringVisible = true;
39
- }
40
- }
41
- }
42
- setFocused(ringVisible);
43
- };
44
- const hideFocusRing = () => {
45
- setFocused(false);
46
- };
33
+ const { focused, onFocus: showFocusRing, onBlur: hideFocusRing } = useFocusVisible();
47
34
  // Simple fast opacity for the checkmark icon
48
- const checkOpacity = useRef(new Animated.Value(checked || indeterminate ? 1 : 0)).current;
35
+ const checkOpacity = useAnimatedValue(checked || indeterminate ? 1 : 0);
49
36
  const isVisuallyChecked = !!checked || indeterminate;
50
37
  const animateCheckOpacity = useCallback((nextVisible) => {
51
38
  Animated.timing(checkOpacity, {
52
39
  toValue: nextVisible ? 1 : 0,
53
40
  duration: reduceMotion ? 0 : 60,
54
- useNativeDriver: true,
41
+ useNativeDriver: shouldUseNativeDriver,
55
42
  }).start();
56
43
  }, [checkOpacity, reduceMotion]);
57
44
  useEffect(() => {
@@ -59,7 +46,7 @@ function Checkbox({ size = "md", label, indeterminate = false, error = false, st
59
46
  }, [animateCheckOpacity, isVisuallyChecked]);
60
47
  const wrappedOnCheckedChange = (next) => {
61
48
  if (next)
62
- hapticLight();
49
+ hapticSelection();
63
50
  animateCheckOpacity(next);
64
51
  onCheckedChange?.(next);
65
52
  };
@@ -71,39 +58,59 @@ function Checkbox({ size = "md", label, indeterminate = false, error = false, st
71
58
  : getContrastingColor(theme.colors.background, theme.colors.text, theme.colors.textDim);
72
59
  // Flatten style override for web compatibility
73
60
  const flattenedStyle = styleOverride ? StyleSheet.flatten(styleOverride) : undefined;
74
- const checkboxElement = (_jsx(Animated.View, { style: scaleStyle, children: _jsx(CheckboxPrimitive.Root, { ...props, checked: checked, onCheckedChange: wrappedOnCheckedChange, disabled: disabled, onPressIn: pressHandlers.onPressIn, onPressOut: pressHandlers.onPressOut, onFocus: showFocusRing, onBlur: hideFocusRing, style: {
75
- ...styles.box,
76
- borderColor,
77
- backgroundColor: isVisuallyChecked ? theme.colors.primary : theme.colors.background,
78
- width: sizeConfig.size,
79
- height: sizeConfig.size,
80
- opacity: disabled ? 0.5 : 1,
61
+ // The primitive Root is the ONE control (role, state, name); the visual box
62
+ // is a child so the control can be larger than what it draws. On web, where
63
+ // `hitSlop` does nothing, a 16/20px box is below the 24px WCAG 2.5.8 minimum,
64
+ // so the control grows to `spacing.minTarget` and takes the extra back in
65
+ // negative margins — the row's layout does not move.
66
+ const hitSize = Platform.OS === "web" ? Math.max(sizeConfig.size, spacing.minTarget) : sizeConfig.size;
67
+ const hitOverflow = (hitSize - sizeConfig.size) / 2;
68
+ // The label text names the control on every platform (react-native-web
69
+ // maps `accessibilityLabel` to `aria-label`); no `useId` link, because ids
70
+ // do not survive Expo Router's streamed hydration (see hooks/useHydrated).
71
+ const rowLabel = useItemControlLabel({ ...props, accessibilityLabel: label });
72
+ const labelLink = label !== undefined ? { accessibilityLabel: label } : rowLabel;
73
+ const checkboxElement = (_jsx(Animated.View, { style: scaleStyle, children: _jsx(CheckboxPrimitive.Root, { ...labelLink, ...props, checked: checked, onCheckedChange: wrappedOnCheckedChange, disabled: disabled, onPressIn: pressHandlers.onPressIn, onPressOut: pressHandlers.onPressOut, onFocus: showFocusRing, onBlur: hideFocusRing, style: {
74
+ ...styles.control,
75
+ minWidth: hitSize,
76
+ minHeight: hitSize,
77
+ ...(hitOverflow > 0 && { margin: -hitOverflow }),
81
78
  ...(Platform.OS === "web" && { cursor: disabled ? "not-allowed" : "pointer" }),
82
- ...(focused && !disabled ? focusRingStyle : null),
83
- ...(flattenedStyle || {}),
84
79
  }, hitSlop: DEFAULT_HIT_SLOP, accessibilityRole: "checkbox", accessibilityState: {
85
80
  checked: indeterminate ? "mixed" : checked,
86
81
  disabled: !!disabled,
87
- }, accessibilityLabel: label, children: _jsx(CheckboxPrimitive.Indicator, { style: {
88
- justifyContent: "center",
89
- alignItems: "center",
90
- }, children: _jsx(Animated.View, { style: { opacity: checkOpacity }, children: indeterminate ? (_jsx(Icon, { name: "minus", size: sizeConfig.iconSize, color: theme.colors.primaryForeground })) : (_jsx(Icon, { name: "check", size: sizeConfig.iconSize, color: theme.colors.primaryForeground })) }) }) }) }));
82
+ }, children: _jsx(View, { style: {
83
+ ...styles.box,
84
+ borderColor,
85
+ backgroundColor: isVisuallyChecked ? theme.colors.primary : theme.colors.background,
86
+ width: sizeConfig.size,
87
+ height: sizeConfig.size,
88
+ opacity: disabled ? 0.5 : 1,
89
+ ...(focused && !disabled ? focusRingStyle : null),
90
+ ...(flattenedStyle || {}),
91
+ }, children: _jsx(CheckboxPrimitive.Indicator, { style: {
92
+ justifyContent: "center",
93
+ alignItems: "center",
94
+ }, children: _jsx(Animated.View, { style: { opacity: checkOpacity }, children: indeterminate ? (_jsx(Icon, { name: "minus", size: sizeConfig.iconSize, color: theme.colors.primaryForeground })) : (_jsx(Icon, { name: "check", size: sizeConfig.iconSize, color: theme.colors.primaryForeground })) }) }) }) }) }));
91
95
  // If no label, return just the checkbox
92
96
  if (!label) {
93
97
  return checkboxElement;
94
98
  }
95
- // With label, wrap in a container
96
- return (_jsxs(Pressable, { onPress: () => !disabled && wrappedOnCheckedChange(!checked), style: [styles.container, labelStyle], disabled: disabled, accessibilityRole: "checkbox", accessibilityState: {
97
- checked: indeterminate ? "mixed" : checked,
98
- disabled: !!disabled,
99
- }, accessibilityLabel: label, children: [checkboxElement, _jsx(View, { style: styles.labelContainer, children: _jsxs(StyledText, { selectable: false, style: [
99
+ // With a label, a non-accessible wrapper extends the tap area to the text.
100
+ // It carries no role or state: the primitive Root above is the only control
101
+ // a screen reader meets, and it carries the label text as its name.
102
+ return (_jsxs(Pressable, { onPress: () => !disabled && wrappedOnCheckedChange(!checked), style: [styles.container, labelStyle], disabled: disabled, accessible: false, focusable: false, children: [checkboxElement, _jsx(View, { style: styles.labelContainer, children: _jsxs(StyledText, { selectable: false, style: [
100
103
  styles.label,
101
104
  { color: theme.colors.text },
102
105
  disabled && styles.disabledLabel,
103
106
  error && { color: theme.colors.destructive },
104
107
  ], children: [label, required && (_jsx(StyledText, { selectable: false, style: [styles.required, { color: theme.colors.destructive }], children: " *" }))] }) })] }));
105
108
  }
106
- const styles = StyleSheet.create({
109
+ const styles = /*#__PURE__*/ StyleSheet.create({
110
+ control: {
111
+ justifyContent: "center",
112
+ alignItems: "center",
113
+ },
107
114
  box: {
108
115
  // radiusXs (not radiusSm) — at the checkbox's 16-24px sizes, radiusSm
109
116
  // post-rebase (8px) reads as over-rounded; radiusXs keeps the same
@@ -126,7 +133,7 @@ const styles = StyleSheet.create({
126
133
  fontSize: 14,
127
134
  },
128
135
  disabledLabel: {
129
- opacity: 0.5,
136
+ opacity: interaction.disabledOpacity,
130
137
  },
131
138
  required: {
132
139
  fontWeight: "bold",
@@ -1,30 +1,34 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Animated, Platform, StyleSheet, View } from "react-native";
3
- import { TextClassContext, TextSelectabilityContext } from "./StyledText.context";
3
+ import { TextClassContext, TextSelectabilityContext } from "./StyledText.context.js";
4
4
  import { spacing } from "../constants/spacing.js";
5
5
  import { useTheme } from "../hooks/useTheme.js";
6
6
  import { useScalePress } from "../hooks/useScalePress.js";
7
+ import { useFocusVisible } from "../hooks/useFocusVisible.js";
7
8
  import * as CollapsiblePrimitive from "@rn-primitives/collapsible";
8
9
  function Collapsible({ children, ...props }) {
9
10
  return (_jsx(CollapsiblePrimitive.Root, { ...props, asChild: Platform.OS !== "web", children: _jsx(View, { children: children }) }));
10
11
  }
11
12
  function CollapsibleTrigger({ style: styleOverride, disabled, ...props }) {
12
- const { theme } = useTheme();
13
+ const { theme, getFocusRingStyle } = useTheme();
13
14
  const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
14
15
  disabled: !!disabled,
15
16
  scaleTo: 0.97,
16
17
  haptic: false,
17
18
  });
18
- return (_jsx(TextClassContext.Provider, { value: "", children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(Animated.View, { style: scaleStyle, children: _jsx(CollapsiblePrimitive.Trigger, { disabled: disabled, ...props, onPressIn: pressHandlers.onPressIn, onPressOut: pressHandlers.onPressOut, style: {
19
+ const focus = useFocusVisible();
20
+ return (_jsx(TextClassContext.Provider, { value: "", children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(Animated.View, { style: scaleStyle, children: _jsx(CollapsiblePrimitive.Trigger, { disabled: disabled, ...props, onPressIn: pressHandlers.onPressIn, onPressOut: pressHandlers.onPressOut, onFocus: focus.onFocus, onBlur: focus.onBlur, style: {
19
21
  flexDirection: "row",
20
22
  alignItems: "center",
21
23
  justifyContent: "space-between",
22
24
  paddingVertical: spacing.sm,
25
+ borderRadius: spacing.radiusSm,
23
26
  ...(Platform.OS === "web" && {
24
27
  cursor: "pointer",
25
28
  outlineStyle: "none",
26
29
  userSelect: "none",
27
30
  }),
31
+ ...(focus.focused && !disabled ? getFocusRingStyle() : null),
28
32
  ...(styleOverride && typeof styleOverride !== "function"
29
33
  ? StyleSheet.flatten(styleOverride)
30
34
  : {}),
@@ -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
  }