@mrmeg/expo-ui 0.6.1 → 0.7.1

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 (55) hide show
  1. package/LLM_USAGE.md +9 -6
  2. package/README.md +11 -7
  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 +106 -27
  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/globalUIStore.d.ts +23 -16
  52. package/dist/state/themeColorScope.js +3 -3
  53. package/llms-full.md +5 -6
  54. package/llms.txt +2 -2
  55. package/package.json +8 -6
@@ -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,72 @@ 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 opacityRef = useRef(null);
50
+ if (opacityRef.current === null) {
51
+ opacityRef.current = new Animated.Value(0);
52
+ }
53
+ const opacity = opacityRef.current;
54
+ const translateYRef = useRef(null);
55
+ if (translateYRef.current === null) {
56
+ translateYRef.current = new Animated.Value(0);
57
+ }
58
+ const translateY = translateYRef.current;
49
59
  const wasVisibleRef = useRef(false);
50
60
  const timerRef = useRef(null);
51
61
  const hideNotification = useCallback(() => {
52
62
  hide();
53
63
  }, [hide]);
54
- const timingIn = { duration: 150, easing: Easing.out(Easing.quad) };
55
- const timingOut = { duration: 100, easing: Easing.in(Easing.quad) };
64
+ const clearAutoDismissTimer = useCallback(() => {
65
+ if (timerRef.current) {
66
+ clearTimeout(timerRef.current);
67
+ timerRef.current = null;
68
+ }
69
+ }, []);
56
70
  const animateOut = useCallback(() => {
71
+ clearAutoDismissTimer();
57
72
  if (reduceMotion) {
58
- opacity.value = withTiming(0, { duration: 0 });
73
+ opacity.setValue(0);
59
74
  hideNotification();
60
75
  return;
61
76
  }
62
77
  const slideTarget = isBottom ? 8 : -8;
63
- opacity.value = withTiming(0, timingOut);
64
- translateY.value = withTiming(slideTarget, timingOut, (finished) => {
78
+ Animated.parallel([
79
+ Animated.timing(opacity, {
80
+ toValue: 0,
81
+ ...timingOut,
82
+ }),
83
+ Animated.timing(translateY, {
84
+ toValue: slideTarget,
85
+ ...timingOut,
86
+ }),
87
+ ]).start(({ finished }) => {
65
88
  if (finished)
66
- runOnJS(hideNotification)();
89
+ hideNotification();
67
90
  });
68
- }, [reduceMotion, isBottom, opacity, translateY, hideNotification]);
91
+ }, [clearAutoDismissTimer, reduceMotion, isBottom, opacity, translateY, hideNotification]);
92
+ const handleActionPress = useCallback(() => {
93
+ const action = alert?.action;
94
+ if (!action)
95
+ return;
96
+ try {
97
+ action.onPress();
98
+ }
99
+ finally {
100
+ animateOut();
101
+ }
102
+ }, [alert?.action, animateOut]);
103
+ // The auto-dismiss timer only needs the latest animateOut; wrapping it in an
104
+ // Effect Event keeps it out of the deps so the effect doesn't re-run (and
105
+ // restart the timer) every time animateOut's identity changes.
106
+ const onAutoDismiss = useEffectEvent(() => {
107
+ animateOut();
108
+ });
69
109
  useEffect(() => {
70
110
  const isNowVisible = alert?.show ?? false;
71
111
  const wasVisible = wasVisibleRef.current;
@@ -76,20 +116,28 @@ export const Notification = () => {
76
116
  }
77
117
  const slideFrom = isBottom ? 8 : -8;
78
118
  if (reduceMotion) {
79
- opacity.value = withTiming(1, { duration: 0 });
80
- translateY.value = withTiming(0, { duration: 0 });
119
+ opacity.setValue(1);
120
+ translateY.setValue(0);
81
121
  }
82
122
  else {
83
- opacity.value = 0;
84
- translateY.value = slideFrom;
85
- opacity.value = withTiming(1, timingIn);
86
- translateY.value = withTiming(0, timingIn);
123
+ opacity.setValue(0);
124
+ translateY.setValue(slideFrom);
125
+ Animated.parallel([
126
+ Animated.timing(opacity, {
127
+ toValue: 1,
128
+ ...timingIn,
129
+ }),
130
+ Animated.timing(translateY, {
131
+ toValue: 0,
132
+ ...timingIn,
133
+ }),
134
+ ]).start();
87
135
  }
88
136
  }
89
137
  wasVisibleRef.current = isNowVisible;
90
138
  if (isNowVisible && !wasVisible && alert?.duration) {
91
139
  timerRef.current = setTimeout(() => {
92
- animateOut();
140
+ onAutoDismiss();
93
141
  }, alert.duration);
94
142
  return () => {
95
143
  if (timerRef.current) {
@@ -98,11 +146,12 @@ export const Notification = () => {
98
146
  }
99
147
  };
100
148
  }
101
- }, [alert, reduceMotion, isBottom, opacity, translateY, animateOut]);
102
- const animatedContainerStyle = useAnimatedStyle(() => ({
103
- opacity: opacity.value,
104
- transform: [{ translateY: translateY.value }],
105
- }));
149
+ // onAutoDismiss is an Effect Event — intentionally omitted from deps.
150
+ }, [alert, reduceMotion, isBottom, opacity, translateY]);
151
+ const animatedContainerStyle = {
152
+ opacity,
153
+ transform: [{ translateY }],
154
+ };
106
155
  const topPosition = insets?.top ? insets.top : 20;
107
156
  const bottomPosition = insets?.bottom ? insets.bottom : 20;
108
157
  const getIconProps = () => {
@@ -157,6 +206,7 @@ export const Notification = () => {
157
206
  const message = alert?.messages?.find((item) => item.trim().length > 0);
158
207
  const title = getTitle(message);
159
208
  const hasMessage = !!message;
209
+ const action = alert?.action;
160
210
  if (!alert?.show) {
161
211
  return null;
162
212
  }
@@ -172,14 +222,23 @@ export const Notification = () => {
172
222
  styles.alert,
173
223
  isBottom && styles.alertBottom,
174
224
  getShadowStyle("base"),
175
- ], children: [_jsx(View, { style: [styles.iconBadge, { backgroundColor: iconBgColor }], children: alert?.loading ? (_jsx(ActivityIndicator, { size: "small", color: iconColor })) : (_jsx(Icon, { name: icon, size: 18, color: iconColor })) }), _jsxs(View, { style: styles.alertContent, children: [!!title && (_jsx(StyledText, { selectable: false, style: [styles.alertTitle, { color: theme.colors.foreground }], numberOfLines: 1, children: title })), hasMessage && (_jsx(StyledText, { selectable: false, style: [styles.alertDescription, { color: theme.colors.mutedForeground }], numberOfLines: 2, children: message }))] }), _jsx(Pressable, { style: styles.closeButton, hitSlop: spacing.sm, onPress: animateOut, accessibilityLabel: "Dismiss notification", accessibilityRole: "button", children: _jsx(Icon, { name: "x", size: 16, color: theme.colors.mutedForeground }) })] }) }));
225
+ ], children: [_jsx(View, { style: [styles.iconBadge, { backgroundColor: iconBgColor }], children: alert?.loading ? (_jsx(ActivityIndicator, { size: "small", color: iconColor })) : (_jsx(Icon, { name: icon, size: 18, color: iconColor })) }), _jsxs(View, { style: styles.alertContent, children: [!!title && (_jsx(StyledText, { selectable: false, style: [styles.alertTitle, { color: theme.colors.foreground }], numberOfLines: 1, children: title })), hasMessage && (_jsx(StyledText, { selectable: false, style: [styles.alertDescription, { color: theme.colors.mutedForeground }], numberOfLines: 2, children: message }))] }), action && (_jsx(Pressable, { style: ({ pressed }) => [
226
+ styles.actionButton,
227
+ {
228
+ borderColor: theme.colors.primary + "30",
229
+ backgroundColor: theme.colors.primary + "10",
230
+ },
231
+ pressed && styles.actionButtonPressed,
232
+ ], hitSlop: spacing.xs, onPress: handleActionPress, accessibilityLabel: action.label, accessibilityRole: "button", children: _jsx(StyledText, { selectable: false, style: [styles.actionLabel, { color: theme.colors.primary }], numberOfLines: 1, children: action.label }) })), _jsx(Pressable, { style: styles.closeButton, hitSlop: spacing.sm, onPress: animateOut, accessibilityLabel: "Dismiss notification", accessibilityRole: "button", children: _jsx(Icon, { name: "x", size: 16, color: theme.colors.mutedForeground }) })] }) }));
176
233
  };
177
234
  const createStyles = (theme) => StyleSheet.create({
178
235
  container: {
179
236
  position: "absolute",
180
237
  left: spacing.md,
181
238
  right: spacing.md,
182
- zIndex: 1000,
239
+ // Toast sits above the overlay layer (dialogs/drawers/dropdowns top out
240
+ // around 52); no need to escalate into the hundreds.
241
+ zIndex: 60,
183
242
  alignItems: "center",
184
243
  },
185
244
  alert: {
@@ -223,6 +282,26 @@ const createStyles = (theme) => StyleSheet.create({
223
282
  fontSize: 13,
224
283
  lineHeight: 18,
225
284
  },
285
+ actionButton: {
286
+ minHeight: 28,
287
+ maxWidth: 140,
288
+ paddingHorizontal: spacing.sm,
289
+ borderRadius: spacing.radiusSm,
290
+ borderWidth: 1,
291
+ justifyContent: "center",
292
+ alignItems: "center",
293
+ flexShrink: 0,
294
+ ...(Platform.OS === "web" && { cursor: "pointer" }),
295
+ },
296
+ actionButtonPressed: {
297
+ opacity: 0.75,
298
+ },
299
+ actionLabel: {
300
+ fontFamily: fontFamilies.sansSerif.regular,
301
+ fontWeight: "600",
302
+ fontSize: 13,
303
+ lineHeight: 18,
304
+ },
226
305
  closeButton: {
227
306
  position: "absolute",
228
307
  top: spacing.sm,
@@ -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";
@@ -15,8 +15,8 @@ export interface ProgressProps {
15
15
  * Progress Component
16
16
  *
17
17
  * A linear progress bar supporting determinate and indeterminate modes.
18
- * Determinate mode animates the fill width via Reanimated.
19
- * Indeterminate mode pulses opacity via RN core Animated.loop.
18
+ * Determinate mode animates the fill with React Native Animated.
19
+ * Indeterminate mode pulses opacity via Animated.loop.
20
20
  *
21
21
  * @example
22
22
  * ```tsx
@@ -1,9 +1,8 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useEffect, useRef, useState } from "react";
2
+ import { useEffect, useRef } from "react";
3
3
  import { Animated, StyleSheet, View } from "react-native";
4
- import Reanimated, { useSharedValue, useAnimatedStyle, withTiming, useReducedMotion, } from "react-native-reanimated";
5
4
  import { useTheme } from "../hooks/useTheme.js";
6
- import { shouldUseNativeDriver } from "../lib/animations.js";
5
+ import { useReducedMotion } from "../hooks/useReduceMotion.js";
7
6
  // ============================================================================
8
7
  // Constants
9
8
  // ============================================================================
@@ -19,8 +18,8 @@ const SIZE_MAP = {
19
18
  * Progress Component
20
19
  *
21
20
  * A linear progress bar supporting determinate and indeterminate modes.
22
- * Determinate mode animates the fill width via Reanimated.
23
- * Indeterminate mode pulses opacity via RN core Animated.loop.
21
+ * Determinate mode animates the fill with React Native Animated.
22
+ * Indeterminate mode pulses opacity via Animated.loop.
24
23
  *
25
24
  * @example
26
25
  * ```tsx
@@ -52,28 +51,28 @@ export function Progress({ value, variant = "default", size = "md", style, }) {
52
51
  ]), children: isDeterminate ? (_jsx(DeterminateFill, { value: value, fillColor: fillColor, height: height, borderRadius: borderRadius, reduceMotion: reduceMotion })) : (_jsx(IndeterminateFill, { fillColor: fillColor, height: height, borderRadius: borderRadius, reduceMotion: reduceMotion })) }));
53
52
  }
54
53
  function DeterminateFill({ value, fillColor, height, borderRadius, reduceMotion, }) {
55
- const [containerWidth, setContainerWidth] = useState(0);
56
54
  const clamped = Math.min(100, Math.max(0, value));
57
- const animatedWidth = useSharedValue(0);
55
+ // Animate scaleX (GPU compositor) instead of width (JS-thread layout each
56
+ // frame). transformOrigin "left" grows the fill from the left edge, so a
57
+ // full-width bar scaled by clamped/100 needs no container measurement.
58
+ const scaleX = useRef(new Animated.Value(0)).current;
58
59
  useEffect(() => {
59
- if (containerWidth === 0)
60
- return;
61
- const target = (clamped / 100) * containerWidth;
62
- animatedWidth.value = withTiming(target, {
60
+ Animated.timing(scaleX, {
61
+ toValue: clamped / 100,
63
62
  duration: reduceMotion ? 0 : 300,
64
- });
65
- }, [clamped, containerWidth, reduceMotion]);
66
- const animatedStyle = useAnimatedStyle(() => ({
67
- width: animatedWidth.value,
68
- }));
69
- return (_jsx(View, { style: { flex: 1 }, onLayout: (e) => setContainerWidth(e.nativeEvent.layout.width), children: _jsx(Reanimated.View, { style: [
70
- {
71
- height,
72
- borderRadius,
73
- backgroundColor: fillColor,
74
- },
75
- animatedStyle,
76
- ] }) }));
63
+ useNativeDriver: true,
64
+ }).start();
65
+ }, [clamped, reduceMotion, scaleX]);
66
+ return (_jsx(Animated.View, { style: [
67
+ {
68
+ width: "100%",
69
+ height,
70
+ borderRadius,
71
+ backgroundColor: fillColor,
72
+ transformOrigin: "left",
73
+ },
74
+ { transform: [{ scaleX }] },
75
+ ] }));
77
76
  }
78
77
  function IndeterminateFill({ fillColor, height, borderRadius, reduceMotion, }) {
79
78
  const opacity = useRef(new Animated.Value(reduceMotion ? 0.7 : 0.4)).current;
@@ -82,26 +81,29 @@ function IndeterminateFill({ fillColor, height, borderRadius, reduceMotion, }) {
82
81
  opacity.setValue(0.7);
83
82
  return;
84
83
  }
84
+ opacity.setValue(0.4);
85
85
  const animation = Animated.loop(Animated.sequence([
86
86
  Animated.timing(opacity, {
87
- toValue: 1.0,
87
+ toValue: 1,
88
88
  duration: 800,
89
- useNativeDriver: shouldUseNativeDriver,
89
+ useNativeDriver: true,
90
90
  }),
91
91
  Animated.timing(opacity, {
92
92
  toValue: 0.4,
93
93
  duration: 800,
94
- useNativeDriver: shouldUseNativeDriver,
94
+ useNativeDriver: true,
95
95
  }),
96
96
  ]));
97
97
  animation.start();
98
98
  return () => animation.stop();
99
99
  }, [opacity, reduceMotion]);
100
- return (_jsx(Animated.View, { style: {
101
- width: "40%",
102
- height,
103
- borderRadius,
104
- backgroundColor: fillColor,
105
- opacity,
106
- } }));
100
+ return (_jsx(Animated.View, { style: [
101
+ {
102
+ width: "40%",
103
+ height,
104
+ borderRadius,
105
+ backgroundColor: fillColor,
106
+ },
107
+ { opacity },
108
+ ] }));
107
109
  }
@@ -1,11 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { createContext, useContext, 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 React, { createContext, use, useEffect, useRef } from "react";
3
+ import { View, StyleSheet, Pressable, Platform, Animated } from "react-native";
5
4
  import { StyledText } from "./StyledText.js";
6
5
  import { useTheme } from "../hooks/useTheme.js";
7
6
  import { spacing } from "../constants/spacing.js";
8
7
  import { hapticLight } from "../lib/haptics.js";
8
+ import { useReducedMotion } from "../hooks/useReduceMotion.js";
9
9
  import * as RadioGroupPrimitive from "@rn-primitives/radio-group";
10
10
  const DEFAULT_HIT_SLOP = 8;
11
11
  const SIZE_CONFIGS = {
@@ -14,8 +14,11 @@ const SIZE_CONFIGS = {
14
14
  lg: { outer: 24, inner: 12, borderWidth: 1 },
15
15
  };
16
16
  const RadioGroupContext = createContext(null);
17
+ function handleRadioPress() {
18
+ hapticLight();
19
+ }
17
20
  function useRadioGroupContext() {
18
- const context = useContext(RadioGroupContext);
21
+ const context = use(RadioGroupContext);
19
22
  if (context === null) {
20
23
  throw new Error("RadioGroup compound components cannot be rendered outside the RadioGroup component");
21
24
  }
@@ -38,7 +41,8 @@ function useRadioGroupContext() {
38
41
  */
39
42
  function RadioGroupRoot({ size = "md", error = false, value, onValueChange, style: styleOverride, children, ...props }) {
40
43
  const flattenedStyle = styleOverride ? StyleSheet.flatten(styleOverride) : undefined;
41
- return (_jsx(RadioGroupContext.Provider, { value: { size, error, value, onValueChange }, children: _jsx(RadioGroupPrimitive.Root, { ...props, value: value, onValueChange: onValueChange, style: {
44
+ const contextValue = React.useMemo(() => ({ size, error, value, onValueChange }), [size, error, value, onValueChange]);
45
+ return (_jsx(RadioGroupContext.Provider, { value: contextValue, children: _jsx(RadioGroupPrimitive.Root, { ...props, value: value, onValueChange: onValueChange, style: {
42
46
  flexDirection: "column",
43
47
  gap: spacing.listItemSpacing,
44
48
  ...(flattenedStyle || {}),
@@ -56,19 +60,14 @@ function RadioGroupItem({ label, required = false, style: styleOverride, labelSt
56
60
  const sizeConfig = SIZE_CONFIGS[size];
57
61
  const isChecked = groupValue === itemValue;
58
62
  // Animated dot scale — follows Checkbox opacity pattern
59
- const dotScale = useSharedValue(isChecked ? 1 : 0);
63
+ const dotScale = useRef(new Animated.Value(isChecked ? 1 : 0)).current;
60
64
  useEffect(() => {
61
- dotScale.value = reduceMotion
62
- ? (isChecked ? 1 : 0)
63
- : withTiming(isChecked ? 1 : 0, { duration: 60 });
65
+ Animated.timing(dotScale, {
66
+ toValue: isChecked ? 1 : 0,
67
+ duration: reduceMotion ? 0 : 60,
68
+ useNativeDriver: true,
69
+ }).start();
64
70
  }, [isChecked, reduceMotion, dotScale]);
65
- const dotStyle = useAnimatedStyle(() => ({
66
- transform: [{ scale: dotScale.value }],
67
- }));
68
- // Wrap onPress to add haptic feedback
69
- const handlePress = () => {
70
- hapticLight();
71
- };
72
71
  // Border color follows Checkbox pattern
73
72
  const borderColor = error
74
73
  ? theme.colors.destructive
@@ -76,20 +75,19 @@ function RadioGroupItem({ label, required = false, style: styleOverride, labelSt
76
75
  ? theme.colors.primary
77
76
  : getContrastingColor(theme.colors.background, theme.colors.text, theme.colors.textDim);
78
77
  const flattenedStyle = styleOverride ? StyleSheet.flatten(styleOverride) : undefined;
79
- const radioElement = (_jsx(RadioGroupPrimitive.Item, { ...props, value: itemValue, disabled: disabled, onPress: handlePress, style: {
78
+ const radioElement = (_jsx(RadioGroupPrimitive.Item, { ...props, value: itemValue, disabled: disabled, onPress: handleRadioPress, style: {
79
+ ...styles.radio,
80
80
  borderColor,
81
81
  backgroundColor: theme.colors.background,
82
82
  borderRadius: sizeConfig.outer / 2,
83
83
  borderWidth: sizeConfig.borderWidth,
84
84
  width: sizeConfig.outer,
85
85
  height: sizeConfig.outer,
86
- justifyContent: "center",
87
- alignItems: "center",
88
86
  opacity: disabled ? 0.5 : 1,
89
87
  ...(Platform.OS === "web" && { cursor: disabled ? "not-allowed" : "pointer" }),
90
88
  ...(flattenedStyle || {}),
91
89
  }, hitSlop: DEFAULT_HIT_SLOP, accessibilityLabel: label, children: _jsx(Animated.View, { style: [
92
- dotStyle,
90
+ { transform: [{ scale: dotScale }] },
93
91
  {
94
92
  width: sizeConfig.inner,
95
93
  height: sizeConfig.inner,
@@ -118,6 +116,10 @@ function RadioGroupItem({ label, required = false, style: styleOverride, labelSt
118
116
  ], children: [label, required && (_jsx(StyledText, { selectable: false, style: [styles.required, { color: theme.colors.destructive }], children: " *" }))] }) })] }));
119
117
  }
120
118
  const styles = StyleSheet.create({
119
+ radio: {
120
+ justifyContent: "center",
121
+ alignItems: "center",
122
+ },
121
123
  container: {
122
124
  flexDirection: "row",
123
125
  alignItems: "center",
@@ -3,7 +3,7 @@ import * as React from "react";
3
3
  import { Platform, StyleSheet, View } from "react-native";
4
4
  import { Icon } from "./Icon.js";
5
5
  import { AnimatedView } from "./AnimatedView.js";
6
- import { TextClassContext, TextColorContext, TextSelectabilityContext } from "./StyledText.js";
6
+ import { TextClassContext, TextColorContext, TextSelectabilityContext } from "./StyledText.context";
7
7
  import { useTheme } from "../hooks/useTheme.js";
8
8
  import { spacing } from "../constants/spacing.js";
9
9
  import * as SelectPrimitive from "@rn-primitives/select";
@@ -37,14 +37,10 @@ function SelectTrigger({ size = "md", error = false, children, style: styleOverr
37
37
  const { theme } = useTheme();
38
38
  const sizeConfig = SIZE_CONFIGS[size];
39
39
  return (_jsx(SelectPrimitive.Trigger, { disabled: disabled, ...props, style: {
40
- flexDirection: "row",
41
- justifyContent: "space-between",
42
- alignItems: "center",
40
+ ...styles.trigger,
43
41
  height: sizeConfig.height,
44
42
  paddingHorizontal: sizeConfig.paddingHorizontal,
45
- borderWidth: 1,
46
43
  borderColor: error ? theme.colors.destructive : theme.colors.border,
47
- borderRadius: spacing.radiusMd,
48
44
  backgroundColor: theme.colors.background,
49
45
  ...(Platform.OS === "web" && {
50
46
  cursor: disabled ? "not-allowed" : "pointer",
@@ -98,19 +94,13 @@ function SelectContent({ side, align = "start", sideOffset = 4, portalHost, styl
98
94
  }
99
95
  function SelectItem({ children, style: styleOverride, ...props }) {
100
96
  const { theme } = useTheme();
101
- const shouldRenderDefaultText = children == null ||
102
- typeof children === "string" ||
103
- typeof children === "number";
97
+ // Render custom element/array children when provided; otherwise fall back to
98
+ // the default ItemText driven by the required `label` prop. Discriminating on
99
+ // "is this a renderable node?" keeps the API explicit without switching on the
100
+ // primitive type of children.
101
+ const hasCustomChildren = React.isValidElement(children) || Array.isArray(children);
104
102
  return (_jsx(TextClassContext.Provider, { value: "", children: _jsxs(SelectPrimitive.Item, { ...props, style: {
105
- position: "relative",
106
- flexDirection: "row",
107
- alignItems: "center",
108
- gap: spacing.sm,
109
- borderRadius: spacing.radiusSm,
110
- paddingVertical: Platform.select({ web: spacing.xs, default: spacing.sm }),
111
- paddingLeft: spacing.xl,
112
- paddingRight: spacing.sm,
113
- backgroundColor: "transparent",
103
+ ...styles.item,
114
104
  ...(Platform.OS === "web" && {
115
105
  cursor: props.disabled ? "not-allowed" : "pointer",
116
106
  outlineStyle: "none",
@@ -127,11 +117,11 @@ function SelectItem({ children, style: styleOverride, ...props }) {
127
117
  width: 14,
128
118
  alignItems: "center",
129
119
  justifyContent: "center",
130
- }, children: _jsx(SelectPrimitive.ItemIndicator, { children: _jsx(Icon, { name: "check", size: 16, color: theme.colors.accent, ...(Platform.OS === "web" && { style: { pointerEvents: "none" } }) }) }) }), _jsx(TextSelectabilityContext.Provider, { value: false, children: shouldRenderDefaultText ? (_jsx(SelectPrimitive.ItemText, { style: {
120
+ }, children: _jsx(SelectPrimitive.ItemIndicator, { children: _jsx(Icon, { name: "check", size: 16, color: theme.colors.accent, ...(Platform.OS === "web" && { style: { pointerEvents: "none" } }) }) }) }), _jsx(TextSelectabilityContext.Provider, { value: false, children: hasCustomChildren ? (children) : (_jsx(SelectPrimitive.ItemText, { style: {
131
121
  color: theme.colors.popoverForeground,
132
122
  fontSize: 14,
133
123
  lineHeight: 20,
134
- } })) : typeof children === "function" ? null : (children) })] }) }));
124
+ } })) })] }) }));
135
125
  }
136
126
  function SelectGroup({ style: styleOverride, ...props }) {
137
127
  return (_jsx(SelectPrimitive.Group, { ...props, style: {
@@ -166,6 +156,26 @@ function SelectSeparator({ style: styleOverride, ...props }) {
166
156
  : {}),
167
157
  } }));
168
158
  }
159
+ const styles = StyleSheet.create({
160
+ trigger: {
161
+ flexDirection: "row",
162
+ justifyContent: "space-between",
163
+ alignItems: "center",
164
+ borderWidth: 1,
165
+ borderRadius: spacing.radiusMd,
166
+ },
167
+ item: {
168
+ position: "relative",
169
+ flexDirection: "row",
170
+ alignItems: "center",
171
+ gap: spacing.sm,
172
+ borderRadius: spacing.radiusSm,
173
+ paddingVertical: Platform.select({ web: spacing.xs, default: spacing.sm }),
174
+ paddingLeft: spacing.xl,
175
+ paddingRight: spacing.sm,
176
+ backgroundColor: "transparent",
177
+ },
178
+ });
169
179
  /**
170
180
  * Select Component with Sub-components
171
181
  * Properly typed interface for dot notation access (e.g., Select.Trigger)
@@ -1,9 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useRef } from "react";
3
- import { View, Animated, StyleSheet } from "react-native";
4
- import { useReducedMotion } from "react-native-reanimated";
3
+ import { View, StyleSheet, Animated } from "react-native";
5
4
  import { useTheme } from "../hooks/useTheme.js";
6
- import { shouldUseNativeDriver } from "../lib/animations.js";
5
+ import { useReducedMotion } from "../hooks/useReduceMotion.js";
7
6
  import { spacing } from "../constants/spacing.js";
8
7
  /**
9
8
  * Skeleton Component
@@ -26,16 +25,17 @@ export function Skeleton({ width = "100%", height = 20, borderRadius = spacing.r
26
25
  opacity.setValue(0.6);
27
26
  return;
28
27
  }
28
+ opacity.setValue(0.3);
29
29
  const animation = Animated.loop(Animated.sequence([
30
30
  Animated.timing(opacity, {
31
31
  toValue: 1,
32
32
  duration: 800,
33
- useNativeDriver: shouldUseNativeDriver,
33
+ useNativeDriver: true,
34
34
  }),
35
35
  Animated.timing(opacity, {
36
36
  toValue: 0.3,
37
37
  duration: 800,
38
- useNativeDriver: shouldUseNativeDriver,
38
+ useNativeDriver: true,
39
39
  }),
40
40
  ]));
41
41
  animation.start();
@@ -48,8 +48,8 @@ export function Skeleton({ width = "100%", height = 20, borderRadius = spacing.r
48
48
  height: circle ? resolvedSize : height,
49
49
  borderRadius: circle ? (resolvedSize / 2) : borderRadius,
50
50
  backgroundColor: theme.colors.muted,
51
- opacity,
52
51
  },
52
+ { opacity },
53
53
  style,
54
54
  ] }));
55
55
  }