@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,4 +1,4 @@
1
- import React, { ReactNode } from "react";
1
+ import { type ReactNode, type Ref } from "react";
2
2
  import { TextInput as RNTextInput, ViewStyle, TextStyle, TextInputProps, StyleProp } from "react-native";
3
3
  /**
4
4
  * Size variants for TextInput
@@ -9,6 +9,10 @@ export type TextInputSize = "sm" | "md" | "lg";
9
9
  */
10
10
  export type TextInputVariant = "outline" | "filled" | "underlined";
11
11
  interface TextInputCustomProps extends TextInputProps {
12
+ /**
13
+ * Forwarded ref to the underlying RNTextInput element.
14
+ */
15
+ ref?: Ref<RNTextInput>;
12
16
  /**
13
17
  * Visual variant
14
18
  * @default "outline"
@@ -116,5 +120,5 @@ interface TextInputCustomProps extends TextInputProps {
116
120
  * />
117
121
  * ```
118
122
  */
119
- export declare const TextInput: React.ForwardRefExoticComponent<TextInputCustomProps & React.RefAttributes<RNTextInput>>;
123
+ export declare function TextInput({ variant, size, label, helperText, errorText, error, required, rows, showSecureEntryToggle, leftElement, rightElement, clearable, wrapperStyle, focusedStyle, forceLight, secureTextEntry, inputMode, style, onChangeText, onFocus, onBlur, value, multiline, editable, ref, ...rest }: TextInputCustomProps): import("react/jsx-runtime").JSX.Element;
120
124
  export {};
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React, { useMemo, useState } from "react";
2
+ import { useMemo, useState } from "react";
3
3
  import { StyleSheet, TextInput as RNTextInput, Platform, View, Pressable, } from "react-native";
4
4
  import { useTheme } from "../hooks/useTheme.js";
5
5
  import { spacing } from "../constants/spacing.js";
@@ -70,7 +70,7 @@ const SIZE_CONFIGS = {
70
70
  * />
71
71
  * ```
72
72
  */
73
- export const TextInput = React.forwardRef(({ variant = "outline", size = "md", label, helperText, errorText, error, required, rows, showSecureEntryToggle, leftElement, rightElement, clearable = false, wrapperStyle, focusedStyle, forceLight, secureTextEntry, inputMode, style, onChangeText, onFocus, onBlur, value, multiline, editable = true, ...rest }, ref) => {
73
+ export function TextInput({ variant = "outline", size = "md", label, helperText, errorText, error, required, rows, showSecureEntryToggle, leftElement, rightElement, clearable = false, wrapperStyle, focusedStyle, forceLight, secureTextEntry, inputMode, style, onChangeText, onFocus, onBlur, value, multiline, editable = true, ref, ...rest }) {
74
74
  const { theme, getContrastingColor, getFocusRingStyle } = useTheme();
75
75
  const styles = useMemo(() => createStyles(theme, variant, size), [theme, variant, size]);
76
76
  const [focused, setFocused] = useState(false);
@@ -116,18 +116,18 @@ export const TextInput = React.forwardRef(({ variant = "outline", size = "md", l
116
116
  ? "#1f2937"
117
117
  : getContrastingColor(backgroundColor === "transparent" ? theme.colors.background : backgroundColor, theme.colors.text, palette.white);
118
118
  const shouldScroll = multiline && rest.scrollEnabled !== false && contentHeight > 100;
119
- const handleFocus = (e) => {
119
+ const showInputFocusRing = (e) => {
120
120
  setFocused(true);
121
121
  onFocus?.(e);
122
122
  };
123
- const handleBlur = (e) => {
123
+ const hideInputFocusRing = (e) => {
124
124
  setFocused(false);
125
125
  onBlur?.(e);
126
126
  };
127
127
  const togglePasswordVisible = () => {
128
128
  setPasswordVisible(v => !v);
129
129
  };
130
- return (_jsxs(View, { style: wrapperStyle, children: [!!label && (_jsx(View, { style: styles.labelContainer, children: _jsxs(StyledText, { selectable: false, style: styles.label, children: [label, required && _jsx(StyledText, { selectable: false, style: styles.required, children: " *" })] }) })), _jsxs(View, { style: [styles.wrapper, focused && getFocusRingStyle()], children: [leftElement && _jsx(View, { style: styles.leftElement, children: leftElement }), _jsx(RNTextInput, { ref: ref, ...rest, editable: editable, inputMode: inputMode || "text", multiline: multiline, numberOfLines: rows, secureTextEntry: secureTextEntry && !passwordVisible, onChangeText: inputMode === "numeric" ? handleNumericChange : handleTextChange, onFocus: handleFocus, onBlur: handleBlur, onContentSizeChange: (e) => setContentHeight(e.nativeEvent.contentSize.height), scrollEnabled: shouldScroll, placeholderTextColor: theme.colors.textDim, style: [
130
+ return (_jsxs(View, { style: wrapperStyle, children: [!!label && (_jsx(View, { style: styles.labelContainer, children: _jsxs(StyledText, { selectable: false, style: styles.label, children: [label, required && _jsx(StyledText, { selectable: false, style: styles.required, children: " *" })] }) })), _jsxs(View, { style: [styles.wrapper, focused && getFocusRingStyle()], children: [leftElement && _jsx(View, { style: styles.leftElement, children: leftElement }), _jsx(RNTextInput, { ref: ref, ...rest, editable: editable, inputMode: inputMode || "text", multiline: multiline, numberOfLines: rows, secureTextEntry: secureTextEntry && !passwordVisible, onChangeText: inputMode === "numeric" ? handleNumericChange : handleTextChange, onFocus: showInputFocusRing, onBlur: hideInputFocusRing, onContentSizeChange: (e) => setContentHeight(e.nativeEvent.contentSize.height), scrollEnabled: shouldScroll, placeholderTextColor: theme.colors.textDim, style: [
131
131
  styles.input,
132
132
  {
133
133
  backgroundColor,
@@ -156,8 +156,7 @@ export const TextInput = React.forwardRef(({ variant = "outline", size = "md", l
156
156
  styles.helperText,
157
157
  hasError && styles.errorText,
158
158
  ], children: errorText || helperText }))] }));
159
- });
160
- TextInput.displayName = "TextInput";
159
+ }
161
160
  const createStyles = (theme, variant, size) => StyleSheet.create({
162
161
  wrapper: {
163
162
  width: "100%",
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import React from "react";
3
3
  import { Icon } from "./Icon.js";
4
- import { TextClassContext, TextColorContext, TextSelectabilityContext } from "./StyledText.js";
4
+ import { TextClassContext, TextColorContext, TextSelectabilityContext } from "./StyledText.context";
5
5
  import { useTheme } from "../hooks/useTheme.js";
6
6
  import { spacing } from "../constants/spacing.js";
7
7
  import * as TogglePrimitive from "@rn-primitives/toggle";
@@ -102,15 +102,11 @@ function Toggle({ variant = "default", size = "default", shape = "default", load
102
102
  const isDisabled = props.disabled || loading;
103
103
  const children = props.children;
104
104
  return (_jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(TogglePrimitive.Root, { ...props, disabled: isDisabled, style: {
105
- flexDirection: "row",
106
- alignItems: "center",
107
- justifyContent: "center",
108
- gap: spacing.sm,
105
+ ...styles.root,
109
106
  height: sizeConfig.height,
110
107
  minWidth: sizeConfig.minWidth,
111
108
  paddingHorizontal: iconOnly ? sizeConfig.height / 2 - sizeConfig.iconSize / 2 : sizeConfig.paddingHorizontal,
112
109
  borderRadius: getBorderRadius(),
113
- borderWidth: 1,
114
110
  // Base variant styles
115
111
  ...(variant === "default" && !props.pressed && {
116
112
  backgroundColor: "transparent",
@@ -146,7 +142,16 @@ function Toggle({ variant = "default", size = "default", shape = "default", load
146
142
  }, children: loading ? (_jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(ActivityIndicator, { size: "small", color: textColor }) })) : typeof children === "function" ? ((state) => (_jsx(TextSelectabilityContext.Provider, { value: false, children: children(state) }))) : (_jsx(TextSelectabilityContext.Provider, { value: false, children: children })) }) }) }));
147
143
  }
148
144
  function ToggleIcon({ name, size, color }) {
149
- const contextColor = React.useContext(TextColorContext);
145
+ const contextColor = React.use(TextColorContext);
150
146
  return _jsx(Icon, { name: name, size: size || spacing.iconMd, color: color || contextColor });
151
147
  }
148
+ const styles = StyleSheet.create({
149
+ root: {
150
+ flexDirection: "row",
151
+ alignItems: "center",
152
+ justifyContent: "center",
153
+ gap: spacing.sm,
154
+ borderWidth: 1,
155
+ },
156
+ });
152
157
  export { Toggle, ToggleIcon };
@@ -1,11 +1,11 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Icon } from "./Icon.js";
3
- import { TextClassContext, TextColorContext, TextSelectabilityContext } from "./StyledText.js";
3
+ import { TextClassContext, TextColorContext, TextSelectabilityContext } from "./StyledText.context";
4
4
  import { spacing } from "../constants/spacing.js";
5
5
  import { useTheme } from "../hooks/useTheme.js";
6
6
  import * as ToggleGroupPrimitive from "@rn-primitives/toggle-group";
7
7
  import * as React from "react";
8
- import { Platform } from "react-native";
8
+ import { Platform, StyleSheet } from "react-native";
9
9
  const DEFAULT_HIT_SLOP = 8;
10
10
  // Size configurations (same as Toggle)
11
11
  const TOGGLE_GROUP_SIZES = {
@@ -33,7 +33,7 @@ const TOGGLE_GROUP_SIZES = {
33
33
  };
34
34
  const ToggleGroupContext = React.createContext(null);
35
35
  function useToggleGroupContext() {
36
- const context = React.useContext(ToggleGroupContext);
36
+ const context = React.use(ToggleGroupContext);
37
37
  if (context === null) {
38
38
  throw new Error("ToggleGroup compound components cannot be rendered outside the ToggleGroup component");
39
39
  }
@@ -65,6 +65,7 @@ function useToggleGroupContext() {
65
65
  */
66
66
  function ToggleGroup({ variant = "default", size = "default", children, ...props }) {
67
67
  const { theme } = useTheme();
68
+ const contextValue = React.useMemo(() => ({ variant, size }), [variant, size]);
68
69
  // Count valid children for first/last detection
69
70
  const childrenArray = React.Children.toArray(children);
70
71
  const validChildren = childrenArray.filter((child) => React.isValidElement(child) && child.type === ToggleGroupItem);
@@ -97,7 +98,7 @@ function ToggleGroup({ variant = "default", size = "default", children, ...props
97
98
  ...(Platform.OS === "web" && {
98
99
  width: "fit-content",
99
100
  }),
100
- }, children: _jsx(ToggleGroupContext.Provider, { value: { variant, size }, children: enhancedChildren }) }));
101
+ }, children: _jsx(ToggleGroupContext.Provider, { value: contextValue, children: enhancedChildren }) }));
101
102
  }
102
103
  /**
103
104
  * ToggleGroupItem Component
@@ -124,15 +125,10 @@ function ToggleGroupItem({ isFirst = false, isLast = false, children, ...props }
124
125
  ? getContrastingColor(itemBgColor, theme.colors.foreground, theme.colors.background)
125
126
  : theme.colors.foreground;
126
127
  return (_jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(ToggleGroupPrimitive.Item, { ...props, style: {
127
- flexDirection: "row",
128
- alignItems: "center",
129
- justifyContent: "center",
130
- gap: spacing.sm,
128
+ ...styles.item,
131
129
  height: sizeConfig.height,
132
130
  minWidth: sizeConfig.minWidth,
133
131
  paddingHorizontal: sizeConfig.paddingHorizontal,
134
- borderWidth: 1,
135
- flexShrink: 0,
136
132
  // Base variant styles
137
133
  ...(context.variant === "default" && !isSelected && {
138
134
  backgroundColor: "transparent",
@@ -184,7 +180,17 @@ function ToggleGroupItem({ isFirst = false, isLast = false, children, ...props }
184
180
  }, hitSlop: DEFAULT_HIT_SLOP, children: typeof children === "function" ? ((state) => (_jsx(TextSelectabilityContext.Provider, { value: false, children: children(state) }))) : (_jsx(TextSelectabilityContext.Provider, { value: false, children: children })) }) }) }));
185
181
  }
186
182
  function ToggleGroupIcon({ name, size, color }) {
187
- const contextColor = React.useContext(TextColorContext);
183
+ const contextColor = React.use(TextColorContext);
188
184
  return _jsx(Icon, { name: name, size: size || spacing.iconMd, color: color || contextColor });
189
185
  }
186
+ const styles = StyleSheet.create({
187
+ item: {
188
+ flexDirection: "row",
189
+ alignItems: "center",
190
+ justifyContent: "center",
191
+ gap: spacing.sm,
192
+ borderWidth: 1,
193
+ flexShrink: 0,
194
+ },
195
+ });
190
196
  export { ToggleGroup, ToggleGroupIcon, ToggleGroupItem };
@@ -2,7 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
3
  import { Platform, StyleSheet, View } from "react-native";
4
4
  import { AnimatedView } from "./AnimatedView.js";
5
- import { TextClassContext, TextColorContext, TextSelectabilityContext } from "./StyledText.js";
5
+ import { TextClassContext, TextColorContext, TextSelectabilityContext } from "./StyledText.context";
6
6
  import { useTheme } from "../hooks/useTheme.js";
7
7
  import { spacing } from "../constants/spacing.js";
8
8
  import * as TooltipPrimitive from "@rn-primitives/tooltip";
@@ -1,5 +1,5 @@
1
- import { useContext, useEffect, useState } from "react";
2
- import { Dimensions, Platform } from "react-native";
1
+ import { use, useEffect, useState } from "react";
2
+ import { Platform, useWindowDimensions } from "react-native";
3
3
  import { SsrViewportContext, SSR_VIEWPORT_DEFAULT_HEIGHT, } from "../state/SsrViewportContext.js";
4
4
  export const SCREEN_SIZES = {
5
5
  SMALL: 768,
@@ -50,35 +50,34 @@ function writeViewportCookie(width) {
50
50
  */
51
51
  export const useDimensions = () => {
52
52
  const isWeb = Platform.OS === "web";
53
- const ssrWidth = useContext(SsrViewportContext);
53
+ const ssrWidth = use(SsrViewportContext);
54
+ // Native reads come from useWindowDimensions, which subscribes to rotation /
55
+ // split-screen / resize and tears the listener down for us — no manual
56
+ // Dimensions.addEventListener to leak. On web we ignore it and drive layout
57
+ // from the SSR context + the resize listener below so hydration stays exact.
58
+ const native = useWindowDimensions();
54
59
  // Lazy initializer: both server and client first render compute identical
55
60
  // flags from the context value, so hydration matches.
56
61
  const [dimensions, setDimensions] = useState(() => calculateDimensionFlags(ssrWidth, SSR_VIEWPORT_DEFAULT_HEIGHT));
62
+ // Web: read the real viewport after mount and follow resize events. Keeping
63
+ // this in an effect (not render) preserves the SSR-matched first paint.
57
64
  useEffect(() => {
58
- const initialDimensions = isWeb
59
- ? { width: window.innerWidth, height: window.innerHeight }
60
- : Dimensions.get("window");
61
- const updateDimensions = (width, height) => {
62
- setDimensions(calculateDimensionFlags(width, height));
65
+ if (!isWeb)
66
+ return;
67
+ const syncFromWindow = () => {
68
+ setDimensions(calculateDimensionFlags(window.innerWidth, window.innerHeight));
69
+ writeViewportCookie(window.innerWidth);
70
+ };
71
+ syncFromWindow();
72
+ window.addEventListener("resize", syncFromWindow);
73
+ return () => {
74
+ window.removeEventListener("resize", syncFromWindow);
63
75
  };
64
- updateDimensions(initialDimensions.width, initialDimensions.height);
65
- if (isWeb) {
66
- writeViewportCookie(initialDimensions.width);
67
- const handleResize = () => {
68
- updateDimensions(window.innerWidth, window.innerHeight);
69
- writeViewportCookie(window.innerWidth);
70
- };
71
- window.addEventListener("resize", handleResize);
72
- return () => {
73
- window.removeEventListener("resize", handleResize);
74
- };
75
- }
76
- else {
77
- const onChange = ({ window }) => {
78
- updateDimensions(window.width, window.height);
79
- };
80
- Dimensions.addEventListener("change", onChange);
81
- }
82
76
  }, [isWeb]);
77
+ // Native: useWindowDimensions already reacts to changes; mirror it into our
78
+ // enriched flags. (On web `native` is unused — the effect above wins.)
79
+ if (!isWeb) {
80
+ return calculateDimensionFlags(native.width, native.height);
81
+ }
83
82
  return dimensions;
84
83
  };
@@ -1,5 +1,9 @@
1
1
  /**
2
2
  * Hook that returns whether the user prefers reduced motion.
3
- * Uses a shared singleton listener so multiple consumers don't create duplicate subscriptions.
3
+ *
4
+ * Backed by useSyncExternalStore so the value is read straight from a shared
5
+ * OS subscription — no mount-time setState, no polling, and a clean SSR
6
+ * snapshot that hydrates without a mismatch. A single singleton listener is
7
+ * shared across all consumers.
4
8
  */
5
9
  export declare function useReducedMotion(): boolean;
@@ -1,64 +1,69 @@
1
- import { useEffect, useState } from "react";
1
+ import { useSyncExternalStore } from "react";
2
2
  import { AccessibilityInfo, Platform } from "react-native";
3
3
  let sharedValue = false;
4
- let listenerCount = 0;
4
+ const listeners = new Set();
5
5
  let subscription = null;
6
+ function notify() {
7
+ for (const listener of listeners)
8
+ listener();
9
+ }
10
+ function setSharedValue(next) {
11
+ if (next === sharedValue)
12
+ return;
13
+ sharedValue = next;
14
+ notify();
15
+ }
6
16
  function startListening() {
7
17
  if (Platform.OS === "web") {
8
18
  // On web, use the media query
9
19
  if (typeof window !== "undefined" && window.matchMedia) {
10
20
  const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
11
- sharedValue = mq.matches;
12
- const handler = (e) => { sharedValue = e.matches; };
21
+ setSharedValue(mq.matches);
22
+ const handler = (e) => setSharedValue(e.matches);
13
23
  mq.addEventListener("change", handler);
14
24
  subscription = { remove: () => mq.removeEventListener("change", handler) };
15
25
  }
16
26
  return;
17
27
  }
18
- AccessibilityInfo.isReduceMotionEnabled().then((enabled) => {
19
- sharedValue = enabled;
20
- });
21
- const sub = AccessibilityInfo.addEventListener("reduceMotionChanged", (enabled) => {
22
- sharedValue = enabled;
23
- });
24
- subscription = sub;
28
+ AccessibilityInfo.isReduceMotionEnabled().then(setSharedValue);
29
+ subscription = AccessibilityInfo.addEventListener("reduceMotionChanged", setSharedValue);
25
30
  }
26
31
  function stopListening() {
27
32
  subscription?.remove();
28
33
  subscription = null;
29
34
  }
35
+ // useSyncExternalStore contract: register the consumer, lazily starting the
36
+ // shared OS subscription on the first listener and tearing it down after the
37
+ // last unmounts.
38
+ function subscribe(listener) {
39
+ listeners.add(listener);
40
+ if (listeners.size === 1) {
41
+ startListening();
42
+ }
43
+ return () => {
44
+ listeners.delete(listener);
45
+ if (listeners.size === 0) {
46
+ stopListening();
47
+ }
48
+ };
49
+ }
50
+ function getSnapshot() {
51
+ return sharedValue;
52
+ }
53
+ // The server can't know the user's accessibility preference; default to
54
+ // "motion allowed" so SSR and the first client render agree (the real value
55
+ // arrives via subscribe() after hydration).
56
+ function getServerSnapshot() {
57
+ return false;
58
+ }
30
59
  /**
31
60
  * Hook that returns whether the user prefers reduced motion.
32
- * Uses a shared singleton listener so multiple consumers don't create duplicate subscriptions.
61
+ *
62
+ * Backed by useSyncExternalStore so the value is read straight from a shared
63
+ * OS subscription — no mount-time setState, no polling, and a clean SSR
64
+ * snapshot that hydrates without a mismatch. A single singleton listener is
65
+ * shared across all consumers.
33
66
  */
34
67
  export function useReducedMotion() {
35
- const [reduceMotion, setReduceMotion] = useState(sharedValue);
36
- useEffect(() => {
37
- listenerCount++;
38
- if (listenerCount === 1) {
39
- startListening();
40
- }
41
- // Re-read current value on mount (may have changed since last render)
42
- if (Platform.OS === "web") {
43
- if (typeof window !== "undefined" && window.matchMedia) {
44
- const current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
45
- setReduceMotion(current);
46
- }
47
- }
48
- else {
49
- AccessibilityInfo.isReduceMotionEnabled().then(setReduceMotion);
50
- }
51
- // Poll the shared value to pick up changes (lightweight — only boolean comparison)
52
- const interval = setInterval(() => {
53
- setReduceMotion((prev) => (prev !== sharedValue ? sharedValue : prev));
54
- }, 500);
55
- return () => {
56
- clearInterval(interval);
57
- listenerCount--;
58
- if (listenerCount === 0) {
59
- stopListening();
60
- }
61
- };
62
- }, []);
63
- return reduceMotion;
68
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
64
69
  }
@@ -40,6 +40,7 @@ export const useResources = () => {
40
40
  const [loaded, setLoaded] = useState(false);
41
41
  const [error, setError] = useState(null);
42
42
  useEffect(() => {
43
+ let timeoutId;
43
44
  async function loadResourcesAndDataAsync() {
44
45
  try {
45
46
  const fontPromise = Promise.all([
@@ -47,7 +48,9 @@ export const useResources = () => {
47
48
  ensureWebFontStylesheet(),
48
49
  ]);
49
50
  // Timeout after 5 seconds — proceed with system fallback fonts
50
- const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("Font loading timed out after 5s")), 5000));
51
+ const timeoutPromise = new Promise((_, reject) => {
52
+ timeoutId = setTimeout(() => reject(new Error("Font loading timed out after 5s")), 5000);
53
+ });
51
54
  await Promise.race([fontPromise, timeoutPromise]);
52
55
  }
53
56
  catch (e) {
@@ -56,10 +59,12 @@ export const useResources = () => {
56
59
  setError(error);
57
60
  }
58
61
  finally {
62
+ clearTimeout(timeoutId);
59
63
  setLoaded(true);
60
64
  }
61
65
  }
62
66
  loadResourcesAndDataAsync();
67
+ return () => clearTimeout(timeoutId);
63
68
  }, []);
64
69
  return { loaded, error };
65
70
  };
@@ -1,3 +1,4 @@
1
+ import { Animated } from "react-native";
1
2
  interface ScalePressOptions {
2
3
  /**
3
4
  * Scale value when pressed (1 = no change, 0.97 = subtle, 0.93 = more pronounced)
@@ -26,7 +27,7 @@ interface ScalePressOptions {
26
27
  disabled?: boolean;
27
28
  }
28
29
  /**
29
- * Hook for press-feedback scale animation using Reanimated.
30
+ * Hook for press-feedback scale animation using React Native Animated.
30
31
  *
31
32
  * Returns an animated style and onPressIn/onPressOut handlers to spread onto a Pressable.
32
33
  * Respects reduced motion preferences.
@@ -43,15 +44,15 @@ interface ScalePressOptions {
43
44
  * ```
44
45
  */
45
46
  export declare function useScalePress(options?: ScalePressOptions): {
46
- animatedStyle: import("react-native-reanimated/lib/typescript/hook/commonTypes").AnimatedStyleHandle<{
47
+ animatedStyle: {
47
48
  transform: {
48
- scale: number;
49
+ scale: Animated.Value;
49
50
  }[];
50
- }>;
51
+ };
51
52
  pressHandlers: {
52
53
  onPressIn: () => void;
53
54
  onPressOut: () => void;
54
55
  };
55
- scale: import("react-native-reanimated").SharedValue<number>;
56
+ scale: Animated.Value;
56
57
  };
57
58
  export {};
@@ -1,8 +1,9 @@
1
- import { useCallback } from "react";
2
- import { useSharedValue, useAnimatedStyle, withSpring, withTiming, useReducedMotion, } from "react-native-reanimated";
1
+ import { useCallback, useMemo, useRef } from "react";
2
+ import { Animated } from "react-native";
3
3
  import { hapticLight } from "../lib/haptics.js";
4
+ import { useReducedMotion } from "./useReduceMotion.js";
4
5
  /**
5
- * Hook for press-feedback scale animation using Reanimated.
6
+ * Hook for press-feedback scale animation using React Native Animated.
6
7
  *
7
8
  * Returns an animated style and onPressIn/onPressOut handlers to spread onto a Pressable.
8
9
  * Respects reduced motion preferences.
@@ -21,32 +22,35 @@ import { hapticLight } from "../lib/haptics.js";
21
22
  export function useScalePress(options = {}) {
22
23
  const { scaleTo = 0.97, haptic = true, damping = 20, stiffness = 300, disabled = false, } = options;
23
24
  const reduceMotion = useReducedMotion();
24
- const scale = useSharedValue(1);
25
+ const scale = useRef(new Animated.Value(1)).current;
26
+ const animateTo = useCallback((toValue) => {
27
+ scale.stopAnimation();
28
+ if (reduceMotion) {
29
+ scale.setValue(toValue);
30
+ return;
31
+ }
32
+ Animated.spring(scale, {
33
+ toValue,
34
+ damping,
35
+ stiffness,
36
+ useNativeDriver: true,
37
+ }).start();
38
+ }, [damping, reduceMotion, scale, stiffness]);
25
39
  const onPressIn = useCallback(() => {
26
40
  if (disabled)
27
41
  return;
28
42
  if (haptic)
29
43
  hapticLight();
30
- if (reduceMotion) {
31
- scale.value = withTiming(scaleTo, { duration: 0 });
32
- }
33
- else {
34
- scale.value = withSpring(scaleTo, { damping, stiffness });
35
- }
36
- }, [disabled, haptic, reduceMotion, scale, scaleTo, damping, stiffness]);
44
+ animateTo(scaleTo);
45
+ }, [animateTo, disabled, haptic, scaleTo]);
37
46
  const onPressOut = useCallback(() => {
38
47
  if (disabled)
39
48
  return;
40
- if (reduceMotion) {
41
- scale.value = withTiming(1, { duration: 0 });
42
- }
43
- else {
44
- scale.value = withSpring(1, { damping, stiffness });
45
- }
46
- }, [disabled, reduceMotion, scale, damping, stiffness]);
47
- const animatedStyle = useAnimatedStyle(() => ({
48
- transform: [{ scale: scale.value }],
49
- }));
49
+ animateTo(1);
50
+ }, [animateTo, disabled]);
51
+ const animatedStyle = useMemo(() => ({
52
+ transform: [{ scale }],
53
+ }), [scale]);
50
54
  return {
51
55
  animatedStyle,
52
56
  pressHandlers: { onPressIn, onPressOut },
@@ -1,3 +1,4 @@
1
+ import { Animated } from "react-native";
1
2
  type EntranceType = "fade" | "fadeSlideUp" | "fadeSlideDown" | "scale";
2
3
  interface StaggeredEntranceOptions {
3
4
  /**
@@ -28,7 +29,7 @@ interface StaggeredEntranceOptions {
28
29
  initialScale?: number;
29
30
  }
30
31
  /**
31
- * Hook for entrance animations with stagger support using Reanimated.
32
+ * Hook for entrance animations with stagger support using React Native Animated.
32
33
  *
33
34
  * Returns an animated style to apply to an Animated.View.
34
35
  * Respects reduced motion preferences.
@@ -46,20 +47,20 @@ interface StaggeredEntranceOptions {
46
47
  * })}
47
48
  * ```
48
49
  */
49
- export declare function useStaggeredEntrance(options?: StaggeredEntranceOptions): import("react-native-reanimated/lib/typescript/hook/commonTypes").AnimatedStyleHandle<{
50
- opacity: number;
50
+ export declare function useStaggeredEntrance(options?: StaggeredEntranceOptions): {
51
+ opacity: Animated.Value;
51
52
  transform?: undefined;
52
53
  } | {
53
- opacity: number;
54
+ opacity: Animated.Value;
54
55
  transform: {
55
- translateY: number;
56
+ translateY: Animated.Value;
56
57
  }[];
57
58
  } | {
58
- opacity: number;
59
+ opacity: Animated.Value;
59
60
  transform: {
60
- scale: number;
61
+ scale: Animated.Value;
61
62
  }[];
62
- }>;
63
+ };
63
64
  /**
64
65
  * Convenience constant: default stagger delay between items (ms)
65
66
  */