@mrmeg/expo-ui 0.6.0 → 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.
- package/LLM_USAGE.md +4 -5
- package/README.md +6 -6
- package/dist/components/Accordion.js +21 -16
- package/dist/components/AnimatedView.d.ts +1 -1
- package/dist/components/AnimatedView.js +2 -2
- package/dist/components/Badge.d.ts +3 -2
- package/dist/components/Badge.js +4 -3
- package/dist/components/BottomSheet.js +31 -29
- package/dist/components/BottomSheetKeyboard.d.ts +7 -0
- package/dist/components/BottomSheetKeyboard.js +35 -0
- package/dist/components/Button.d.ts +55 -13
- package/dist/components/Button.js +72 -28
- package/dist/components/Card.js +8 -10
- package/dist/components/Checkbox.js +22 -25
- package/dist/components/Collapsible.js +3 -7
- package/dist/components/Dialog.js +1 -1
- package/dist/components/DismissKeyboard.js +3 -3
- package/dist/components/Drawer.js +21 -10
- package/dist/components/DropdownMenu.d.ts +16 -12
- package/dist/components/DropdownMenu.js +32 -30
- package/dist/components/EmptyState.js +1 -1
- package/dist/components/InputOTP.js +16 -40
- package/dist/components/Notification.js +50 -25
- package/dist/components/Popover.js +1 -1
- package/dist/components/Progress.d.ts +2 -2
- package/dist/components/Progress.js +36 -34
- package/dist/components/RadioGroup.js +22 -20
- package/dist/components/Select.js +30 -20
- package/dist/components/Skeleton.js +6 -6
- package/dist/components/Slider.js +90 -97
- package/dist/components/StyledText.context.d.ts +6 -0
- package/dist/components/StyledText.context.js +5 -0
- package/dist/components/StyledText.d.ts +7 -58
- package/dist/components/StyledText.js +8 -28
- package/dist/components/Switch.js +30 -26
- package/dist/components/Tabs.d.ts +23 -3
- package/dist/components/Tabs.js +39 -17
- package/dist/components/TextInput.d.ts +6 -2
- package/dist/components/TextInput.js +6 -7
- package/dist/components/Toggle.js +12 -7
- package/dist/components/ToggleGroup.js +17 -11
- package/dist/components/Tooltip.js +1 -1
- package/dist/hooks/useDimensions.js +25 -26
- package/dist/hooks/useReduceMotion.d.ts +5 -1
- package/dist/hooks/useReduceMotion.js +46 -41
- package/dist/hooks/useResources.js +6 -1
- package/dist/hooks/useScalePress.d.ts +6 -5
- package/dist/hooks/useScalePress.js +25 -21
- package/dist/hooks/useStaggeredEntrance.d.ts +9 -8
- package/dist/hooks/useStaggeredEntrance.js +48 -21
- package/dist/state/themeColorScope.js +3 -3
- package/llms-full.md +4 -5
- package/llms.txt +2 -2
- package/package.json +1 -4
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
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
|
|
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
|
|
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
|
|
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
|
|
119
|
+
const showInputFocusRing = (e) => {
|
|
120
120
|
setFocused(true);
|
|
121
121
|
onFocus?.(e);
|
|
122
122
|
};
|
|
123
|
-
const
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 {
|
|
2
|
-
import {
|
|
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 =
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
*
|
|
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 {
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
2
|
import { AccessibilityInfo, Platform } from "react-native";
|
|
3
3
|
let sharedValue = false;
|
|
4
|
-
|
|
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
|
-
|
|
12
|
-
const handler = (e) =>
|
|
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(
|
|
19
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
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:
|
|
47
|
+
animatedStyle: {
|
|
47
48
|
transform: {
|
|
48
|
-
scale:
|
|
49
|
+
scale: Animated.Value;
|
|
49
50
|
}[];
|
|
50
|
-
}
|
|
51
|
+
};
|
|
51
52
|
pressHandlers: {
|
|
52
53
|
onPressIn: () => void;
|
|
53
54
|
onPressOut: () => void;
|
|
54
55
|
};
|
|
55
|
-
scale:
|
|
56
|
+
scale: Animated.Value;
|
|
56
57
|
};
|
|
57
58
|
export {};
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { useCallback } from "react";
|
|
2
|
-
import {
|
|
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
|
|
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 =
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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):
|
|
50
|
-
opacity:
|
|
50
|
+
export declare function useStaggeredEntrance(options?: StaggeredEntranceOptions): {
|
|
51
|
+
opacity: Animated.Value;
|
|
51
52
|
transform?: undefined;
|
|
52
53
|
} | {
|
|
53
|
-
opacity:
|
|
54
|
+
opacity: Animated.Value;
|
|
54
55
|
transform: {
|
|
55
|
-
translateY:
|
|
56
|
+
translateY: Animated.Value;
|
|
56
57
|
}[];
|
|
57
58
|
} | {
|
|
58
|
-
opacity:
|
|
59
|
+
opacity: Animated.Value;
|
|
59
60
|
transform: {
|
|
60
|
-
scale:
|
|
61
|
+
scale: Animated.Value;
|
|
61
62
|
}[];
|
|
62
|
-
}
|
|
63
|
+
};
|
|
63
64
|
/**
|
|
64
65
|
* Convenience constant: default stagger delay between items (ms)
|
|
65
66
|
*/
|