@mrmeg/expo-ui 0.6.1 → 0.7.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 (54) hide show
  1. package/LLM_USAGE.md +4 -5
  2. package/README.md +6 -6
  3. package/dist/components/Accordion.js +21 -16
  4. package/dist/components/AnimatedView.d.ts +1 -1
  5. package/dist/components/AnimatedView.js +2 -2
  6. package/dist/components/Badge.d.ts +3 -2
  7. package/dist/components/Badge.js +4 -3
  8. package/dist/components/BottomSheet.js +31 -29
  9. package/dist/components/BottomSheetKeyboard.d.ts +7 -0
  10. package/dist/components/BottomSheetKeyboard.js +35 -0
  11. package/dist/components/Button.d.ts +55 -13
  12. package/dist/components/Button.js +72 -28
  13. package/dist/components/Card.js +8 -10
  14. package/dist/components/Checkbox.js +22 -25
  15. package/dist/components/Collapsible.js +3 -7
  16. package/dist/components/Dialog.js +1 -1
  17. package/dist/components/DismissKeyboard.js +3 -3
  18. package/dist/components/Drawer.js +21 -10
  19. package/dist/components/DropdownMenu.d.ts +3 -2
  20. package/dist/components/DropdownMenu.js +29 -29
  21. package/dist/components/EmptyState.js +1 -1
  22. package/dist/components/InputOTP.js +16 -40
  23. package/dist/components/Notification.js +50 -25
  24. package/dist/components/Popover.js +1 -1
  25. package/dist/components/Progress.d.ts +2 -2
  26. package/dist/components/Progress.js +36 -34
  27. package/dist/components/RadioGroup.js +22 -20
  28. package/dist/components/Select.js +30 -20
  29. package/dist/components/Skeleton.js +6 -6
  30. package/dist/components/Slider.js +90 -97
  31. package/dist/components/StyledText.context.d.ts +6 -0
  32. package/dist/components/StyledText.context.js +5 -0
  33. package/dist/components/StyledText.d.ts +7 -58
  34. package/dist/components/StyledText.js +8 -28
  35. package/dist/components/Switch.js +30 -26
  36. package/dist/components/Tabs.d.ts +23 -3
  37. package/dist/components/Tabs.js +39 -17
  38. package/dist/components/TextInput.d.ts +6 -2
  39. package/dist/components/TextInput.js +6 -7
  40. package/dist/components/Toggle.js +12 -7
  41. package/dist/components/ToggleGroup.js +17 -11
  42. package/dist/components/Tooltip.js +1 -1
  43. package/dist/hooks/useDimensions.js +25 -26
  44. package/dist/hooks/useReduceMotion.d.ts +5 -1
  45. package/dist/hooks/useReduceMotion.js +46 -41
  46. package/dist/hooks/useResources.js +6 -1
  47. package/dist/hooks/useScalePress.d.ts +6 -5
  48. package/dist/hooks/useScalePress.js +25 -21
  49. package/dist/hooks/useStaggeredEntrance.d.ts +9 -8
  50. package/dist/hooks/useStaggeredEntrance.js +48 -21
  51. package/dist/state/themeColorScope.js +3 -3
  52. package/llms-full.md +4 -5
  53. package/llms.txt +2 -2
  54. package/package.json +1 -4
@@ -1,7 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { createContext, useContext } from "react";
3
- import { View, Pressable, StyleSheet, Platform } from "react-native";
4
- import Animated from "react-native-reanimated";
2
+ import { createContext, use } from "react";
3
+ import { View, Pressable, StyleSheet, Platform, Animated } from "react-native";
5
4
  import { StyledText } from "./StyledText.js";
6
5
  import { useTheme } from "../hooks/useTheme.js";
7
6
  import { useScalePress } from "../hooks/useScalePress.js";
@@ -30,13 +29,12 @@ import { spacing } from "../constants/spacing.js";
30
29
  */
31
30
  const CardContext = createContext(null);
32
31
  function useCardContext() {
33
- const ctx = useContext(CardContext);
34
- if (!ctx) {
35
- // Fallback for standalone usage without Card parent
36
- const { theme } = useTheme();
37
- return { theme, styles: createCardStyles(theme) };
38
- }
39
- return ctx;
32
+ const ctx = use(CardContext);
33
+ // useTheme must run unconditionally (Rules of Hooks); createCardStyles is a
34
+ // plain function, so the `??` keeps the fallback styles lazy.
35
+ const { theme } = useTheme();
36
+ // Fallback for standalone usage without a Card parent.
37
+ return ctx ?? { theme, styles: createCardStyles(theme) };
40
38
  }
41
39
  function Card({ children, style: styleOverride, variant = "default", onPress, disabled }) {
42
40
  const { theme, getShadowStyle } = useTheme();
@@ -1,12 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect } from "react";
3
- import { View, StyleSheet, Pressable, Platform } from "react-native";
4
- import Animated, { useSharedValue, useAnimatedStyle, withTiming, useReducedMotion, } from "react-native-reanimated";
2
+ import { useCallback, useEffect, useRef } from "react";
3
+ import { View, StyleSheet, Pressable, Platform, Animated } from "react-native";
5
4
  import { Icon } from "./Icon.js";
6
5
  import { StyledText } from "./StyledText.js";
7
6
  import { useTheme } from "../hooks/useTheme.js";
8
7
  import { spacing } from "../constants/spacing.js";
9
8
  import { hapticLight } from "../lib/haptics.js";
9
+ import { useReducedMotion } from "../hooks/useReduceMotion.js";
10
10
  import * as CheckboxPrimitive from "@rn-primitives/checkbox";
11
11
  const DEFAULT_HIT_SLOP = 8;
12
12
  const SIZE_CONFIGS = {
@@ -19,30 +19,24 @@ function Checkbox({ size = "md", label, indeterminate = false, error = false, st
19
19
  const reduceMotion = useReducedMotion();
20
20
  const sizeConfig = SIZE_CONFIGS[size];
21
21
  // Simple fast opacity for the checkmark icon
22
- const checkOpacity = useSharedValue(checked || indeterminate ? 1 : 0);
22
+ const checkOpacity = useRef(new Animated.Value(checked || indeterminate ? 1 : 0)).current;
23
23
  const isVisuallyChecked = !!checked || indeterminate;
24
+ const animateCheckOpacity = useCallback((nextVisible) => {
25
+ Animated.timing(checkOpacity, {
26
+ toValue: nextVisible ? 1 : 0,
27
+ duration: reduceMotion ? 0 : 60,
28
+ useNativeDriver: true,
29
+ }).start();
30
+ }, [checkOpacity, reduceMotion]);
24
31
  useEffect(() => {
25
- if (reduceMotion) {
26
- checkOpacity.value = withTiming(isVisuallyChecked ? 1 : 0, { duration: 0 });
27
- }
28
- else {
29
- checkOpacity.value = withTiming(isVisuallyChecked ? 1 : 0, { duration: 60 });
30
- }
31
- }, [checkOpacity, isVisuallyChecked, reduceMotion]);
32
+ animateCheckOpacity(isVisuallyChecked);
33
+ }, [animateCheckOpacity, isVisuallyChecked]);
32
34
  const wrappedOnCheckedChange = (next) => {
33
35
  if (next)
34
36
  hapticLight();
35
- if (reduceMotion) {
36
- checkOpacity.value = withTiming(next ? 1 : 0, { duration: 0 });
37
- }
38
- else {
39
- checkOpacity.value = withTiming(next ? 1 : 0, { duration: 60 });
40
- }
37
+ animateCheckOpacity(next);
41
38
  onCheckedChange?.(next);
42
39
  };
43
- const checkAnimatedStyle = useAnimatedStyle(() => ({
44
- opacity: checkOpacity.value,
45
- }));
46
40
  // Dynamic border color with sufficient contrast against background
47
41
  const borderColor = error
48
42
  ? theme.colors.destructive
@@ -52,14 +46,11 @@ function Checkbox({ size = "md", label, indeterminate = false, error = false, st
52
46
  // Flatten style override for web compatibility
53
47
  const flattenedStyle = styleOverride ? StyleSheet.flatten(styleOverride) : undefined;
54
48
  const checkboxElement = (_jsx(CheckboxPrimitive.Root, { ...props, checked: checked, onCheckedChange: wrappedOnCheckedChange, disabled: disabled, style: {
49
+ ...styles.box,
55
50
  borderColor,
56
51
  backgroundColor: isVisuallyChecked ? theme.colors.primary : theme.colors.background,
57
- borderRadius: spacing.radiusSm,
58
- borderWidth: 1,
59
52
  width: sizeConfig.size,
60
53
  height: sizeConfig.size,
61
- justifyContent: "center",
62
- alignItems: "center",
63
54
  opacity: disabled ? 0.5 : 1,
64
55
  ...(Platform.OS === "web" && { cursor: disabled ? "not-allowed" : "pointer" }),
65
56
  ...(flattenedStyle || {}),
@@ -69,7 +60,7 @@ function Checkbox({ size = "md", label, indeterminate = false, error = false, st
69
60
  }, accessibilityLabel: label, children: _jsx(CheckboxPrimitive.Indicator, { style: {
70
61
  justifyContent: "center",
71
62
  alignItems: "center",
72
- }, children: _jsx(Animated.View, { style: checkAnimatedStyle, children: indeterminate ? (_jsx(Icon, { name: "minus", size: sizeConfig.iconSize, color: theme.colors.primaryForeground })) : (_jsx(Icon, { name: "check", size: sizeConfig.iconSize, color: theme.colors.primaryForeground })) }) }) }));
63
+ }, 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 })) }) }) }));
73
64
  // If no label, return just the checkbox
74
65
  if (!label) {
75
66
  return checkboxElement;
@@ -86,6 +77,12 @@ function Checkbox({ size = "md", label, indeterminate = false, error = false, st
86
77
  ], children: [label, required && (_jsx(StyledText, { selectable: false, style: [styles.required, { color: theme.colors.destructive }], children: " *" }))] }) })] }));
87
78
  }
88
79
  const styles = StyleSheet.create({
80
+ box: {
81
+ borderRadius: spacing.radiusSm,
82
+ borderWidth: 1,
83
+ justifyContent: "center",
84
+ alignItems: "center",
85
+ },
89
86
  container: {
90
87
  flexDirection: "row",
91
88
  alignItems: "center",
@@ -1,7 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import * as React from "react";
3
- import { Animated, Platform, StyleSheet, View } from "react-native";
4
- import { TextClassContext, TextSelectabilityContext } from "./StyledText.js";
2
+ import { Platform, StyleSheet, View } from "react-native";
3
+ import { TextClassContext, TextSelectabilityContext } from "./StyledText.context";
5
4
  import { spacing } from "../constants/spacing.js";
6
5
  import { useTheme } from "../hooks/useTheme.js";
7
6
  import * as CollapsiblePrimitive from "@rn-primitives/collapsible";
@@ -26,11 +25,8 @@ function CollapsibleTrigger({ style: styleOverride, ...props }) {
26
25
  } }) }) }));
27
26
  }
28
27
  function CollapsibleContent({ forceMount, style: styleOverride, children, ...props }) {
29
- const { theme } = useTheme();
30
- const fadeAnim = React.useRef(new Animated.Value(1)).current;
31
- return (_jsx(TextClassContext.Provider, { value: "", children: _jsx(CollapsiblePrimitive.Content, { ...props, forceMount: forceMount, children: _jsx(Animated.View, { style: {
28
+ return (_jsx(TextClassContext.Provider, { value: "", children: _jsx(CollapsiblePrimitive.Content, { ...props, forceMount: forceMount, children: _jsx(View, { style: {
32
29
  overflow: "hidden",
33
- opacity: fadeAnim,
34
30
  ...(styleOverride && typeof styleOverride !== "function"
35
31
  ? StyleSheet.flatten(styleOverride)
36
32
  : {}),
@@ -5,7 +5,7 @@ import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens";
5
5
  import * as DialogPrimitive from "@rn-primitives/dialog";
6
6
  import * as AlertDialogPrimitive from "@rn-primitives/alert-dialog";
7
7
  import { AnimatedView } from "./AnimatedView.js";
8
- import { TextClassContext, TextColorContext } from "./StyledText.js";
8
+ import { TextClassContext, TextColorContext } from "./StyledText.context";
9
9
  import { StyledText } from "./StyledText.js";
10
10
  import { useTheme } from "../hooks/useTheme.js";
11
11
  import { spacing } from "../constants/spacing.js";
@@ -1,13 +1,13 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Pressable, Keyboard, Platform, KeyboardAvoidingView, ScrollView } from "react-native";
3
+ const handleDismissKeyboard = () => Platform.OS !== "web" && Keyboard.dismiss();
3
4
  /**
4
5
  * @returns Wrapper for a view that dismisses the keyboard when tapped outside of a text input
5
6
  */
6
7
  export const DismissKeyboard = ({ children, style, avoidKeyboard = true, scrollable = true }) => {
7
- const handlePress = () => Platform.OS !== "web" && Keyboard.dismiss();
8
8
  const content = scrollable ? (_jsx(ScrollView, { style: { flex: 1 }, contentContainerStyle: { flexGrow: 1, justifyContent: "center" }, keyboardShouldPersistTaps: "handled", showsVerticalScrollIndicator: false, children: children })) : (children);
9
9
  if (!avoidKeyboard) {
10
- return (_jsx(Pressable, { onPress: handlePress, accessible: false, style: { flex: 1 }, children: content }));
10
+ return (_jsx(Pressable, { onPress: handleDismissKeyboard, accessible: false, style: { flex: 1 }, children: content }));
11
11
  }
12
- return (_jsx(KeyboardAvoidingView, { style: [{ flex: 1, width: "100%" }, style], behavior: Platform.OS === "ios" ? "padding" : "height", keyboardVerticalOffset: Platform.OS === "ios" ? 0 : 0, children: _jsx(Pressable, { onPress: handlePress, accessible: false, style: { flex: 1 }, children: content }) }));
12
+ return (_jsx(KeyboardAvoidingView, { style: [{ flex: 1, width: "100%" }, style], behavior: Platform.OS === "ios" ? "padding" : "height", keyboardVerticalOffset: Platform.OS === "ios" ? 0 : 0, children: _jsx(Pressable, { onPress: handleDismissKeyboard, accessible: false, style: { flex: 1 }, children: content }) }));
13
13
  };
@@ -1,13 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React, { createContext, useContext, useState, useReducer, useRef } from "react";
3
- import { View, Pressable, Animated, StyleSheet, Platform, Dimensions, PanResponder, } from "react-native";
2
+ import React, { createContext, use, useState, useReducer, useRef } from "react";
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";
6
6
  import { Pressable as SlotPressable } from "@rn-primitives/slot";
7
7
  import { useTheme } from "../hooks/useTheme.js";
8
+ import { useDimensions } from "../hooks/useDimensions.js";
8
9
  import { shouldUseNativeDriver } from "../lib/animations.js";
9
10
  import { spacing } from "../constants/spacing.js";
10
- import { TextColorContext, TextClassContext } from "./StyledText.js";
11
+ import { TextColorContext, TextClassContext } from "./StyledText.context";
11
12
  import { useSafeAreaInsets } from "react-native-safe-area-context";
12
13
  /**
13
14
  * Drawer Component with Sub-components
@@ -50,7 +51,7 @@ const FullWindowOverlay = Platform.OS === "ios" ? RNFullWindowOverlay : React.Fr
50
51
  // ============================================================================
51
52
  const DrawerContext = createContext(null);
52
53
  function useDrawerContext() {
53
- const context = useContext(DrawerContext);
54
+ const context = use(DrawerContext);
54
55
  if (!context) {
55
56
  throw new Error("Drawer components must be used within a Drawer");
56
57
  }
@@ -59,13 +60,12 @@ function useDrawerContext() {
59
60
  // ============================================================================
60
61
  // Utility Functions
61
62
  // ============================================================================
62
- function parseWidth(width) {
63
+ function parseWidth(width, screenWidth) {
63
64
  if (typeof width === "number") {
64
65
  return width;
65
66
  }
66
67
  // Parse percentage string
67
68
  const percentage = parseFloat(width) / 100;
68
- const screenWidth = Dimensions.get("window").width;
69
69
  return screenWidth * percentage;
70
70
  }
71
71
  function drawerReducer(state, action) {
@@ -103,7 +103,9 @@ function DrawerRoot({ open: controlledOpen, onOpenChange: controlledOnOpenChange
103
103
  dispatch({ type: newOpen ? "OPEN" : "CLOSE" });
104
104
  }
105
105
  };
106
- const parsedWidth = parseWidth(width);
106
+ // useDimensions reacts to rotation / split-screen, unlike Dimensions.get.
107
+ const { width: screenWidth } = useDimensions();
108
+ const parsedWidth = parseWidth(width, screenWidth);
107
109
  const contextValue = {
108
110
  open,
109
111
  onOpenChange,
@@ -148,10 +150,19 @@ function DrawerContent({ swipeEnabled = true, swipeThreshold = 0.3, velocityThre
148
150
  const { open, onOpenChange, side, width, closeOnBackdropPress } = drawerContext;
149
151
  const { theme, getShadowStyle } = useTheme();
150
152
  const insets = useSafeAreaInsets();
151
- // Animation values - initialize based on initial open state
153
+ // Animation values - initialize lazily so the Animated.Value is allocated
154
+ // once on first render instead of being rebuilt and discarded every render.
152
155
  const closedPosition = side === "left" ? -width : width;
153
- const translateX = useRef(new Animated.Value(open ? 0 : closedPosition)).current;
154
- const backdropOpacity = useRef(new Animated.Value(open ? 1 : 0)).current;
156
+ const translateXRef = useRef(null);
157
+ if (translateXRef.current === null) {
158
+ translateXRef.current = new Animated.Value(open ? 0 : closedPosition);
159
+ }
160
+ const translateX = translateXRef.current;
161
+ const backdropOpacityRef = useRef(null);
162
+ if (backdropOpacityRef.current === null) {
163
+ backdropOpacityRef.current = new Animated.Value(open ? 1 : 0);
164
+ }
165
+ const backdropOpacity = backdropOpacityRef.current;
155
166
  // Track if drawer is actually visible (for unmounting after close animation)
156
167
  const [isVisible, setIsVisible] = useState(open);
157
168
  // Track what we last animated to - persists across renders
@@ -115,9 +115,10 @@ declare function DropdownMenuSeparator({ style: styleOverride, ...props }: Dropd
115
115
  * Text component for displaying keyboard shortcuts
116
116
  */
117
117
  interface DropdownMenuShortcutProps {
118
- children: React.ReactNode;
118
+ children?: React.ReactNode;
119
+ text?: string;
119
120
  style?: TextStyle;
120
121
  }
121
- declare function DropdownMenuShortcut({ style: styleOverride, ...props }: DropdownMenuShortcutProps): import("react/jsx-runtime").JSX.Element;
122
+ declare function DropdownMenuShortcut({ children, text, style: styleOverride, ...props }: DropdownMenuShortcutProps): import("react/jsx-runtime").JSX.Element;
122
123
  export { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, };
123
124
  export type { DropdownMenuTriggerProps, DropdownMenuSubTriggerProps, DropdownMenuSubContentProps, DropdownMenuContentProps, DropdownMenuItemProps, DropdownMenuCheckboxItemProps, DropdownMenuRadioItemProps, DropdownMenuLabelProps, DropdownMenuSeparatorProps, DropdownMenuShortcutProps, };
@@ -3,7 +3,7 @@ import * as React from "react";
3
3
  import { 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.js";
6
+ import { TextClassContext, TextSelectabilityContext } from "./StyledText.context";
7
7
  import { useTheme } from "../hooks/useTheme.js";
8
8
  import { spacing } from "../constants/spacing.js";
9
9
  import * as DropdownMenuPrimitive from "@rn-primitives/dropdown-menu";
@@ -91,14 +91,7 @@ function DropdownMenuContent({ side, align = "start", sideOffset = 4, portalHost
91
91
  function DropdownMenuItem({ inset = false, variant = "default", style: styleOverride, ...props }) {
92
92
  const { theme } = useTheme();
93
93
  return (_jsx(TextClassContext.Provider, { value: "", children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(DropdownMenuPrimitive.Item, { ...props, style: {
94
- position: "relative",
95
- flexDirection: "row",
96
- alignItems: "center",
97
- gap: spacing.sm,
98
- borderRadius: spacing.radiusSm,
99
- paddingHorizontal: spacing.sm,
100
- paddingVertical: Platform.select({ web: spacing.xs, default: spacing.sm }),
101
- backgroundColor: "transparent",
94
+ ...styles.item,
102
95
  ...(Platform.OS === "web" && {
103
96
  cursor: props.disabled ? "not-allowed" : "pointer",
104
97
  outlineStyle: "none",
@@ -114,15 +107,7 @@ function DropdownMenuItem({ inset = false, variant = "default", style: styleOver
114
107
  function DropdownMenuCheckboxItem({ children, style: styleOverride, ...props }) {
115
108
  const { theme } = useTheme();
116
109
  return (_jsx(TextClassContext.Provider, { value: "", children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsxs(DropdownMenuPrimitive.CheckboxItem, { ...props, style: {
117
- position: "relative",
118
- flexDirection: "row",
119
- alignItems: "center",
120
- gap: spacing.sm,
121
- borderRadius: spacing.radiusSm,
122
- paddingVertical: Platform.select({ web: spacing.xs, default: spacing.sm }),
123
- paddingLeft: spacing.xl,
124
- paddingRight: spacing.sm,
125
- backgroundColor: "transparent",
110
+ ...styles.indicatorItem,
126
111
  ...(Platform.OS === "web" && {
127
112
  cursor: props.disabled ? "not-allowed" : "pointer",
128
113
  outlineStyle: "none",
@@ -144,15 +129,7 @@ function DropdownMenuCheckboxItem({ children, style: styleOverride, ...props })
144
129
  function DropdownMenuRadioItem({ children, style: styleOverride, ...props }) {
145
130
  const { theme } = useTheme();
146
131
  return (_jsx(TextClassContext.Provider, { value: "", children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsxs(DropdownMenuPrimitive.RadioItem, { ...props, style: {
147
- position: "relative",
148
- flexDirection: "row",
149
- alignItems: "center",
150
- gap: spacing.sm,
151
- borderRadius: spacing.radiusSm,
152
- paddingVertical: Platform.select({ web: spacing.xs, default: spacing.sm }),
153
- paddingLeft: spacing.xl,
154
- paddingRight: spacing.sm,
155
- backgroundColor: "transparent",
132
+ ...styles.indicatorItem,
156
133
  ...(Platform.OS === "web" && {
157
134
  cursor: props.disabled ? "not-allowed" : "pointer",
158
135
  outlineStyle: "none",
@@ -203,7 +180,7 @@ function DropdownMenuSeparator({ style: styleOverride, ...props }) {
203
180
  : {}),
204
181
  } }));
205
182
  }
206
- function DropdownMenuShortcut({ style: styleOverride, ...props }) {
183
+ function DropdownMenuShortcut({ children, text, style: styleOverride, ...props }) {
207
184
  const { theme } = useTheme();
208
185
  return (_jsx(Text, { ...props, style: [
209
186
  {
@@ -215,6 +192,29 @@ function DropdownMenuShortcut({ style: styleOverride, ...props }) {
215
192
  userSelect: "none",
216
193
  },
217
194
  styleOverride,
218
- ] }));
195
+ ], children: text ?? children }));
219
196
  }
197
+ const styles = StyleSheet.create({
198
+ item: {
199
+ position: "relative",
200
+ flexDirection: "row",
201
+ alignItems: "center",
202
+ gap: spacing.sm,
203
+ borderRadius: spacing.radiusSm,
204
+ paddingHorizontal: spacing.sm,
205
+ paddingVertical: Platform.select({ web: spacing.xs, default: spacing.sm }),
206
+ backgroundColor: "transparent",
207
+ },
208
+ indicatorItem: {
209
+ position: "relative",
210
+ flexDirection: "row",
211
+ alignItems: "center",
212
+ gap: spacing.sm,
213
+ borderRadius: spacing.radiusSm,
214
+ paddingVertical: Platform.select({ web: spacing.xs, default: spacing.sm }),
215
+ paddingLeft: spacing.xl,
216
+ paddingRight: spacing.sm,
217
+ backgroundColor: "transparent",
218
+ },
219
+ });
220
220
  export { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, };
@@ -26,7 +26,7 @@ import { spacing } from "../constants/spacing.js";
26
26
  export function EmptyState({ icon, iconSize = 48, title, description, actionLabel, onAction, actionPreset = "default", style, children, }) {
27
27
  const { theme } = useTheme();
28
28
  const styles = useMemo(() => createStyles(theme), [theme]);
29
- return (_jsxs(View, { style: [styles.container, style], children: [!!icon && (_jsx(View, { style: styles.iconWrapper, children: _jsx(Icon, { name: icon, size: iconSize, color: theme.colors.mutedForeground }) })), _jsx(SansSerifBoldText, { selectable: false, style: styles.title, children: title }), !!description && (_jsx(SansSerifText, { selectable: false, style: styles.description, children: description })), children, !!actionLabel && onAction && (_jsx(Button, { preset: actionPreset, onPress: onAction, style: styles.action, children: actionLabel }))] }));
29
+ return (_jsxs(View, { style: [styles.container, style], children: [!!icon && (_jsx(View, { style: styles.iconWrapper, children: _jsx(Icon, { name: icon, size: iconSize, color: theme.colors.mutedForeground }) })), _jsx(SansSerifBoldText, { selectable: false, style: styles.title, children: title }), !!description && (_jsx(SansSerifText, { selectable: false, style: styles.description, children: description })), children, !!actionLabel && onAction && (_jsx(Button, { preset: actionPreset, onPress: onAction, text: actionLabel, style: styles.action }))] }));
30
30
  }
31
31
  const createStyles = (theme) => StyleSheet.create({
32
32
  container: {
@@ -1,7 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React, { useMemo, useRef, useState, useCallback } from "react";
2
+ import { useMemo, useRef, useState, useCallback } from "react";
3
3
  import { View, TextInput as RNTextInput, Pressable, StyleSheet, Platform, } from "react-native";
4
- import Animated, { useSharedValue, useAnimatedStyle, withTiming, useReducedMotion, } from "react-native-reanimated";
5
4
  import { useTheme } from "../hooks/useTheme.js";
6
5
  import { spacing } from "../constants/spacing.js";
7
6
  import { fontFamilies } from "../constants/fonts.js";
@@ -11,7 +10,6 @@ const CELL_HEIGHT = 40;
11
10
  const CELL_FONT_SIZE = 20;
12
11
  const CELL_FONT_WEIGHT = "600";
13
12
  const BULLET = "\u2022";
14
- const ANIM_DURATION = 60;
15
13
  /**
16
14
  * OTP/verification code input with individual character cells.
17
15
  *
@@ -30,7 +28,6 @@ const ANIM_DURATION = 60;
30
28
  */
31
29
  function InputOTP({ length = 6, value = "", onChangeText, onComplete, error = false, errorText, disabled = false, autoFocus = false, secureTextEntry = false, inputMode = "numeric", style: styleOverride, }) {
32
30
  const { theme } = useTheme();
33
- const reduceMotion = useReducedMotion();
34
31
  const inputRef = useRef(null);
35
32
  const [focused, setFocused] = useState(false);
36
33
  const styles = useMemo(() => createStyles(theme), [theme]);
@@ -61,13 +58,13 @@ function InputOTP({ length = 6, value = "", onChangeText, onComplete, error = fa
61
58
  return;
62
59
  }
63
60
  }, [value]);
64
- const handleFocus = useCallback(() => {
61
+ const markOtpFocused = useCallback(() => {
65
62
  setFocused(true);
66
63
  }, []);
67
- const handleBlur = useCallback(() => {
64
+ const markOtpBlurred = useCallback(() => {
68
65
  setFocused(false);
69
66
  }, []);
70
- return (_jsxs(View, { style: StyleSheet.flatten([styles.container, styleOverride]), children: [_jsx(RNTextInput, { ref: inputRef, value: value, onChangeText: handleChangeText, onKeyPress: handleKeyPress, onFocus: handleFocus, onBlur: handleBlur, maxLength: length, autoFocus: autoFocus, editable: !disabled, inputMode: inputMode, autoComplete: "one-time-code", textContentType: "oneTimeCode", caretHidden: true, style: styles.hiddenInput, accessibilityLabel: "Verification code input", importantForAccessibility: "yes" }), _jsx(View, { style: styles.cellRow, children: Array.from({ length }, (_, index) => {
67
+ return (_jsxs(View, { style: StyleSheet.flatten([styles.container, styleOverride]), children: [_jsx(RNTextInput, { ref: inputRef, value: value, onChangeText: handleChangeText, onKeyPress: handleKeyPress, onFocus: markOtpFocused, onBlur: markOtpBlurred, maxLength: length, autoFocus: autoFocus, editable: !disabled, inputMode: inputMode, autoComplete: "one-time-code", textContentType: "oneTimeCode", caretHidden: true, style: styles.hiddenInput, accessibilityLabel: "Verification code input", importantForAccessibility: "yes" }), _jsx(View, { style: styles.cellRow, children: Array.from({ length }, (_, index) => {
71
68
  const char = value[index] ?? "";
72
69
  const isActive = focused && index === activeIndex;
73
70
  const displayChar = char
@@ -75,52 +72,31 @@ function InputOTP({ length = 6, value = "", onChangeText, onComplete, error = fa
75
72
  ? BULLET
76
73
  : char
77
74
  : "";
78
- return (_jsx(OTPCell, { index: index, total: length, char: displayChar, isActive: isActive, hasError: hasError, disabled: disabled, theme: theme, reduceMotion: reduceMotion, onPress: focusInput }, index));
79
- }) }), !!errorText && (_jsx(StyledText, { style: [styles.errorText], children: errorText }))] }));
75
+ return (_jsx(OTPCell, { index: index, total: length, char: displayChar, isActive: isActive, hasError: hasError, disabled: disabled, theme: theme, onPress: focusInput }, index));
76
+ }) }), !!errorText && (_jsx(StyledText, { style: styles.errorText, children: errorText }))] }));
80
77
  }
81
- function OTPCell({ index, total, char, isActive, hasError, disabled, theme, reduceMotion, onPress, }) {
82
- const borderWidth = useSharedValue(isActive && !hasError ? 2 : 1);
83
- const borderColor = useSharedValue(hasError
78
+ function OTPCell({ index, total, char, isActive, hasError, disabled, theme, onPress, }) {
79
+ // borderWidth is a layout property animating it forces a JS-thread layout
80
+ // pass every frame. The 1↔2px and color changes read as instant for OTP
81
+ // cells, so compute both during render.
82
+ const borderWidth = isActive && !hasError ? 2 : 1;
83
+ const borderColor = hasError
84
84
  ? theme.colors.destructive
85
85
  : isActive
86
86
  ? theme.colors.primary
87
- : theme.colors.border);
88
- // Update animated values when state changes
89
- React.useEffect(() => {
90
- const duration = reduceMotion ? 0 : ANIM_DURATION;
91
- const targetWidth = isActive && !hasError ? 2 : 1;
92
- const targetColor = hasError
93
- ? theme.colors.destructive
94
- : isActive
95
- ? theme.colors.primary
96
- : theme.colors.border;
97
- borderWidth.value = withTiming(targetWidth, { duration });
98
- borderColor.value = withTiming(targetColor, { duration });
99
- }, [
100
- isActive,
101
- hasError,
102
- theme.colors.destructive,
103
- theme.colors.primary,
104
- theme.colors.border,
105
- reduceMotion,
106
- borderWidth,
107
- borderColor,
108
- ]);
109
- const animatedStyle = useAnimatedStyle(() => ({
110
- borderWidth: borderWidth.value,
111
- borderColor: borderColor.value,
112
- }));
113
- return (_jsx(Pressable, { onPress: onPress, disabled: disabled, accessibilityRole: "button", accessibilityLabel: `Digit ${index + 1} of ${total}`, accessibilityState: { disabled }, children: _jsx(Animated.View, { style: [
87
+ : theme.colors.border;
88
+ return (_jsx(Pressable, { onPress: onPress, disabled: disabled, accessibilityRole: "button", accessibilityLabel: `Digit ${index + 1} of ${total}`, accessibilityState: { disabled }, children: _jsx(View, { style: [
114
89
  {
115
90
  width: CELL_WIDTH,
116
91
  height: CELL_HEIGHT,
117
92
  borderRadius: spacing.radiusMd,
93
+ borderWidth,
118
94
  justifyContent: "center",
119
95
  alignItems: "center",
120
96
  backgroundColor: "transparent",
121
97
  opacity: disabled ? 0.5 : 1,
98
+ borderColor,
122
99
  },
123
- animatedStyle,
124
100
  ], children: _jsx(StyledText, { selectable: false, style: {
125
101
  fontSize: CELL_FONT_SIZE,
126
102
  fontWeight: CELL_FONT_WEIGHT,
@@ -1,15 +1,17 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useMemo, useCallback, useContext, useEffect, useRef } from "react";
3
- import { StyleSheet, View, ActivityIndicator, Pressable, Platform } from "react-native";
4
- import Animated, { useSharedValue, useAnimatedStyle, withTiming, runOnJS, useReducedMotion, Easing, } from "react-native-reanimated";
2
+ import { useMemo, useCallback, use, useEffect, useEffectEvent, useRef } from "react";
3
+ import { Animated, Easing, StyleSheet, View, ActivityIndicator, Pressable, Platform } from "react-native";
5
4
  import { SafeAreaInsetsContext } from "react-native-safe-area-context";
6
5
  import { fontFamilies } from "../constants/fonts.js";
7
6
  import { Icon } from "./Icon.js";
8
7
  import { useTheme } from "../hooks/useTheme.js";
8
+ import { useReducedMotion } from "../hooks/useReduceMotion.js";
9
9
  import { spacing } from "../constants/spacing.js";
10
10
  import { StyledText } from "./StyledText.js";
11
11
  import { translateText } from "../lib/i18n.js";
12
12
  import { globalUIStore } from "../state/globalUIStore.js";
13
+ const timingIn = { duration: 150, easing: Easing.out(Easing.quad), useNativeDriver: true };
14
+ const timingOut = { duration: 100, easing: Easing.in(Easing.quad), useNativeDriver: true };
13
15
  /**
14
16
  * Notification
15
17
  *
@@ -38,34 +40,46 @@ import { globalUIStore } from "../state/globalUIStore.js";
38
40
  export const Notification = () => {
39
41
  const { theme, getShadowStyle } = useTheme();
40
42
  const reduceMotion = useReducedMotion();
41
- const insets = useContext(SafeAreaInsetsContext);
43
+ const insets = use(SafeAreaInsetsContext);
42
44
  const { alert, hide } = globalUIStore();
43
45
  const styles = useMemo(() => createStyles(theme), [theme]);
44
46
  const position = alert?.position ?? "top";
45
47
  const isBottom = position === "bottom";
46
48
  // Just opacity + translateY — no scale (scale = bouncy feel)
47
- const opacity = useSharedValue(0);
48
- const translateY = useSharedValue(0);
49
+ const opacity = useRef(new Animated.Value(0)).current;
50
+ const translateY = useRef(new Animated.Value(0)).current;
49
51
  const wasVisibleRef = useRef(false);
50
52
  const timerRef = useRef(null);
51
53
  const hideNotification = useCallback(() => {
52
54
  hide();
53
55
  }, [hide]);
54
- const timingIn = { duration: 150, easing: Easing.out(Easing.quad) };
55
- const timingOut = { duration: 100, easing: Easing.in(Easing.quad) };
56
56
  const animateOut = useCallback(() => {
57
57
  if (reduceMotion) {
58
- opacity.value = withTiming(0, { duration: 0 });
58
+ opacity.setValue(0);
59
59
  hideNotification();
60
60
  return;
61
61
  }
62
62
  const slideTarget = isBottom ? 8 : -8;
63
- opacity.value = withTiming(0, timingOut);
64
- translateY.value = withTiming(slideTarget, timingOut, (finished) => {
63
+ Animated.parallel([
64
+ Animated.timing(opacity, {
65
+ toValue: 0,
66
+ ...timingOut,
67
+ }),
68
+ Animated.timing(translateY, {
69
+ toValue: slideTarget,
70
+ ...timingOut,
71
+ }),
72
+ ]).start(({ finished }) => {
65
73
  if (finished)
66
- runOnJS(hideNotification)();
74
+ hideNotification();
67
75
  });
68
76
  }, [reduceMotion, isBottom, opacity, translateY, hideNotification]);
77
+ // The auto-dismiss timer only needs the latest animateOut; wrapping it in an
78
+ // Effect Event keeps it out of the deps so the effect doesn't re-run (and
79
+ // restart the timer) every time animateOut's identity changes.
80
+ const onAutoDismiss = useEffectEvent(() => {
81
+ animateOut();
82
+ });
69
83
  useEffect(() => {
70
84
  const isNowVisible = alert?.show ?? false;
71
85
  const wasVisible = wasVisibleRef.current;
@@ -76,20 +90,28 @@ export const Notification = () => {
76
90
  }
77
91
  const slideFrom = isBottom ? 8 : -8;
78
92
  if (reduceMotion) {
79
- opacity.value = withTiming(1, { duration: 0 });
80
- translateY.value = withTiming(0, { duration: 0 });
93
+ opacity.setValue(1);
94
+ translateY.setValue(0);
81
95
  }
82
96
  else {
83
- opacity.value = 0;
84
- translateY.value = slideFrom;
85
- opacity.value = withTiming(1, timingIn);
86
- translateY.value = withTiming(0, timingIn);
97
+ opacity.setValue(0);
98
+ translateY.setValue(slideFrom);
99
+ Animated.parallel([
100
+ Animated.timing(opacity, {
101
+ toValue: 1,
102
+ ...timingIn,
103
+ }),
104
+ Animated.timing(translateY, {
105
+ toValue: 0,
106
+ ...timingIn,
107
+ }),
108
+ ]).start();
87
109
  }
88
110
  }
89
111
  wasVisibleRef.current = isNowVisible;
90
112
  if (isNowVisible && !wasVisible && alert?.duration) {
91
113
  timerRef.current = setTimeout(() => {
92
- animateOut();
114
+ onAutoDismiss();
93
115
  }, alert.duration);
94
116
  return () => {
95
117
  if (timerRef.current) {
@@ -98,11 +120,12 @@ export const Notification = () => {
98
120
  }
99
121
  };
100
122
  }
101
- }, [alert, reduceMotion, isBottom, opacity, translateY, animateOut]);
102
- const animatedContainerStyle = useAnimatedStyle(() => ({
103
- opacity: opacity.value,
104
- transform: [{ translateY: translateY.value }],
105
- }));
123
+ // onAutoDismiss is an Effect Event — intentionally omitted from deps.
124
+ }, [alert, reduceMotion, isBottom, opacity, translateY]);
125
+ const animatedContainerStyle = {
126
+ opacity,
127
+ transform: [{ translateY }],
128
+ };
106
129
  const topPosition = insets?.top ? insets.top : 20;
107
130
  const bottomPosition = insets?.bottom ? insets.bottom : 20;
108
131
  const getIconProps = () => {
@@ -179,7 +202,9 @@ const createStyles = (theme) => StyleSheet.create({
179
202
  position: "absolute",
180
203
  left: spacing.md,
181
204
  right: spacing.md,
182
- zIndex: 1000,
205
+ // Toast sits above the overlay layer (dialogs/drawers/dropdowns top out
206
+ // around 52); no need to escalate into the hundreds.
207
+ zIndex: 60,
183
208
  alignItems: "center",
184
209
  },
185
210
  alert: {
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { AnimatedView } from "./AnimatedView.js";
3
- import { TextClassContext, TextColorContext } from "./StyledText.js";
3
+ import { TextClassContext, TextColorContext } from "./StyledText.context";
4
4
  import { useTheme } from "../hooks/useTheme.js";
5
5
  import { spacing } from "../constants/spacing.js";
6
6
  import * as PopoverPrimitive from "@rn-primitives/popover";