@mrmeg/expo-ui 0.1.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/README.md +96 -0
- package/dist/components/Accordion.d.ts +54 -0
- package/dist/components/Accordion.js +149 -0
- package/dist/components/Alert.d.ts +30 -0
- package/dist/components/Alert.js +25 -0
- package/dist/components/AnimatedView.d.ts +55 -0
- package/dist/components/AnimatedView.js +39 -0
- package/dist/components/Badge.d.ts +23 -0
- package/dist/components/Badge.js +74 -0
- package/dist/components/BottomSheet.d.ts +74 -0
- package/dist/components/BottomSheet.js +513 -0
- package/dist/components/Button.d.ts +129 -0
- package/dist/components/Button.js +216 -0
- package/dist/components/Card.d.ts +42 -0
- package/dist/components/Card.js +126 -0
- package/dist/components/Checkbox.d.ts +39 -0
- package/dist/components/Checkbox.js +96 -0
- package/dist/components/Collapsible.d.ts +67 -0
- package/dist/components/Collapsible.js +38 -0
- package/dist/components/Dialog.d.ts +140 -0
- package/dist/components/Dialog.js +167 -0
- package/dist/components/DismissKeyboard.d.ts +15 -0
- package/dist/components/DismissKeyboard.js +13 -0
- package/dist/components/Drawer.d.ts +74 -0
- package/dist/components/Drawer.js +423 -0
- package/dist/components/DropdownMenu.d.ts +120 -0
- package/dist/components/DropdownMenu.js +211 -0
- package/dist/components/EmptyState.d.ts +42 -0
- package/dist/components/EmptyState.js +58 -0
- package/dist/components/ErrorBoundary.d.ts +53 -0
- package/dist/components/ErrorBoundary.js +75 -0
- package/dist/components/Icon.d.ts +46 -0
- package/dist/components/Icon.js +40 -0
- package/dist/components/InputOTP.d.ts +72 -0
- package/dist/components/InputOTP.js +155 -0
- package/dist/components/Label.d.ts +61 -0
- package/dist/components/Label.js +72 -0
- package/dist/components/MaxWidthContainer.d.ts +58 -0
- package/dist/components/MaxWidthContainer.js +64 -0
- package/dist/components/Notification.d.ts +26 -0
- package/dist/components/Notification.js +230 -0
- package/dist/components/Popover.d.ts +79 -0
- package/dist/components/Popover.js +91 -0
- package/dist/components/Progress.d.ts +28 -0
- package/dist/components/Progress.js +107 -0
- package/dist/components/RadioGroup.d.ts +65 -0
- package/dist/components/RadioGroup.js +142 -0
- package/dist/components/Select.d.ts +88 -0
- package/dist/components/Select.js +172 -0
- package/dist/components/Separator.d.ts +83 -0
- package/dist/components/Separator.js +85 -0
- package/dist/components/Skeleton.d.ts +68 -0
- package/dist/components/Skeleton.js +99 -0
- package/dist/components/Slider.d.ts +24 -0
- package/dist/components/Slider.js +162 -0
- package/dist/components/StatusBar.d.ts +1 -0
- package/dist/components/StatusBar.js +19 -0
- package/dist/components/StyledText.d.ts +161 -0
- package/dist/components/StyledText.js +193 -0
- package/dist/components/Switch.d.ts +44 -0
- package/dist/components/Switch.js +129 -0
- package/dist/components/Tabs.d.ts +31 -0
- package/dist/components/Tabs.js +127 -0
- package/dist/components/TextInput.d.ts +120 -0
- package/dist/components/TextInput.js +263 -0
- package/dist/components/Toggle.d.ts +106 -0
- package/dist/components/Toggle.js +150 -0
- package/dist/components/ToggleGroup.d.ts +80 -0
- package/dist/components/ToggleGroup.js +189 -0
- package/dist/components/Tooltip.d.ts +121 -0
- package/dist/components/Tooltip.js +132 -0
- package/dist/components/index.d.ts +35 -0
- package/dist/components/index.js +35 -0
- package/dist/constants/colors.d.ts +82 -0
- package/dist/constants/colors.js +116 -0
- package/dist/constants/fonts.d.ts +32 -0
- package/dist/constants/fonts.js +91 -0
- package/dist/constants/index.d.ts +3 -0
- package/dist/constants/index.js +3 -0
- package/dist/constants/spacing.d.ts +40 -0
- package/dist/constants/spacing.js +48 -0
- package/dist/hooks/index.d.ts +6 -0
- package/dist/hooks/index.js +6 -0
- package/dist/hooks/useDimensions.d.ts +19 -0
- package/dist/hooks/useDimensions.js +55 -0
- package/dist/hooks/useReduceMotion.d.ts +5 -0
- package/dist/hooks/useReduceMotion.js +64 -0
- package/dist/hooks/useResources.d.ts +12 -0
- package/dist/hooks/useResources.js +56 -0
- package/dist/hooks/useScalePress.d.ts +57 -0
- package/dist/hooks/useScalePress.js +55 -0
- package/dist/hooks/useStaggeredEntrance.d.ts +67 -0
- package/dist/hooks/useStaggeredEntrance.js +74 -0
- package/dist/hooks/useTheme.d.ts +88 -0
- package/dist/hooks/useTheme.js +328 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/lib/animations.d.ts +1 -0
- package/dist/lib/animations.js +3 -0
- package/dist/lib/haptics.d.ts +3 -0
- package/dist/lib/haptics.js +29 -0
- package/dist/lib/index.d.ts +3 -0
- package/dist/lib/index.js +3 -0
- package/dist/lib/sentry.d.ts +16 -0
- package/dist/lib/sentry.js +55 -0
- package/dist/state/globalUIStore.d.ts +30 -0
- package/dist/state/globalUIStore.js +8 -0
- package/dist/state/index.d.ts +2 -0
- package/dist/state/index.js +2 -0
- package/dist/state/themeStore.d.ts +6 -0
- package/dist/state/themeStore.js +38 -0
- package/package.json +92 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { useRef, useState, useCallback } from "react";
|
|
3
|
+
import { View, TextInput as RNTextInput, Pressable, StyleSheet, Platform, } from "react-native";
|
|
4
|
+
import Animated, { useSharedValue, useAnimatedStyle, withTiming, useReducedMotion, } from "react-native-reanimated";
|
|
5
|
+
import { useTheme } from "../hooks/useTheme";
|
|
6
|
+
import { spacing } from "../constants/spacing";
|
|
7
|
+
import { fontFamilies } from "../constants/fonts";
|
|
8
|
+
import { StyledText } from "./StyledText";
|
|
9
|
+
const CELL_WIDTH = 36;
|
|
10
|
+
const CELL_HEIGHT = 40;
|
|
11
|
+
const CELL_FONT_SIZE = 20;
|
|
12
|
+
const CELL_FONT_WEIGHT = "600";
|
|
13
|
+
const BULLET = "\u2022";
|
|
14
|
+
const ANIM_DURATION = 60;
|
|
15
|
+
/**
|
|
16
|
+
* OTP/verification code input with individual character cells.
|
|
17
|
+
*
|
|
18
|
+
* A single hidden TextInput captures keyboard input. Visible cells are
|
|
19
|
+
* Pressable views that focus the hidden input on tap.
|
|
20
|
+
*
|
|
21
|
+
* Usage:
|
|
22
|
+
* ```tsx
|
|
23
|
+
* <InputOTP
|
|
24
|
+
* length={6}
|
|
25
|
+
* value={code}
|
|
26
|
+
* onChangeText={setCode}
|
|
27
|
+
* onComplete={(code) => verify(code)}
|
|
28
|
+
* />
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
function InputOTP({ length = 6, value = "", onChangeText, onComplete, error = false, errorText, disabled = false, autoFocus = false, secureTextEntry = false, inputMode = "numeric", style: styleOverride, }) {
|
|
32
|
+
const { theme } = useTheme();
|
|
33
|
+
const reduceMotion = useReducedMotion();
|
|
34
|
+
const inputRef = useRef(null);
|
|
35
|
+
const [focused, setFocused] = useState(false);
|
|
36
|
+
const styles = createStyles(theme);
|
|
37
|
+
const hasError = error || !!errorText;
|
|
38
|
+
// Active cell index: next empty cell, or last cell when full
|
|
39
|
+
const activeIndex = Math.min(value.length, length - 1);
|
|
40
|
+
const focusInput = useCallback(() => {
|
|
41
|
+
if (!disabled) {
|
|
42
|
+
inputRef.current?.focus();
|
|
43
|
+
}
|
|
44
|
+
}, [disabled]);
|
|
45
|
+
const handleChangeText = useCallback((text) => {
|
|
46
|
+
// Filter non-digits when numeric mode
|
|
47
|
+
let filtered = text;
|
|
48
|
+
if (inputMode === "numeric") {
|
|
49
|
+
filtered = text.replace(/[^0-9]/g, "");
|
|
50
|
+
}
|
|
51
|
+
// Truncate to length
|
|
52
|
+
const truncated = filtered.slice(0, length);
|
|
53
|
+
onChangeText?.(truncated);
|
|
54
|
+
if (truncated.length === length) {
|
|
55
|
+
onComplete?.(truncated);
|
|
56
|
+
}
|
|
57
|
+
}, [inputMode, length, onChangeText, onComplete]);
|
|
58
|
+
const handleKeyPress = useCallback((e) => {
|
|
59
|
+
if (e.nativeEvent.key === "Backspace" && value.length === 0) {
|
|
60
|
+
// Already empty, nothing to do
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
}, [value]);
|
|
64
|
+
const handleFocus = useCallback(() => {
|
|
65
|
+
setFocused(true);
|
|
66
|
+
}, []);
|
|
67
|
+
const handleBlur = useCallback(() => {
|
|
68
|
+
setFocused(false);
|
|
69
|
+
}, []);
|
|
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) => {
|
|
71
|
+
const char = value[index] ?? "";
|
|
72
|
+
const isActive = focused && index === activeIndex;
|
|
73
|
+
const displayChar = char
|
|
74
|
+
? secureTextEntry
|
|
75
|
+
? BULLET
|
|
76
|
+
: char
|
|
77
|
+
: "";
|
|
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 }))] }));
|
|
80
|
+
}
|
|
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
|
|
84
|
+
? theme.colors.destructive
|
|
85
|
+
: isActive
|
|
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: [
|
|
114
|
+
{
|
|
115
|
+
width: CELL_WIDTH,
|
|
116
|
+
height: CELL_HEIGHT,
|
|
117
|
+
borderRadius: spacing.radiusMd,
|
|
118
|
+
justifyContent: "center",
|
|
119
|
+
alignItems: "center",
|
|
120
|
+
backgroundColor: "transparent",
|
|
121
|
+
opacity: disabled ? 0.5 : 1,
|
|
122
|
+
},
|
|
123
|
+
animatedStyle,
|
|
124
|
+
], children: _jsx(StyledText, { style: {
|
|
125
|
+
fontSize: CELL_FONT_SIZE,
|
|
126
|
+
fontWeight: CELL_FONT_WEIGHT,
|
|
127
|
+
fontFamily: fontFamilies.sansSerif.regular,
|
|
128
|
+
color: theme.colors.text,
|
|
129
|
+
textAlign: "center",
|
|
130
|
+
lineHeight: CELL_FONT_SIZE * 1.2,
|
|
131
|
+
}, children: char }) }) }));
|
|
132
|
+
}
|
|
133
|
+
const createStyles = (theme) => StyleSheet.create({
|
|
134
|
+
container: {
|
|
135
|
+
alignItems: "center",
|
|
136
|
+
},
|
|
137
|
+
hiddenInput: {
|
|
138
|
+
position: "absolute",
|
|
139
|
+
width: 1,
|
|
140
|
+
height: 1,
|
|
141
|
+
opacity: 0,
|
|
142
|
+
...(Platform.OS === "web" && { caretColor: "transparent" }),
|
|
143
|
+
},
|
|
144
|
+
cellRow: {
|
|
145
|
+
flexDirection: "row",
|
|
146
|
+
gap: spacing.sm,
|
|
147
|
+
},
|
|
148
|
+
errorText: {
|
|
149
|
+
fontSize: 12,
|
|
150
|
+
fontFamily: fontFamilies.sansSerif.regular,
|
|
151
|
+
color: theme.colors.destructive,
|
|
152
|
+
marginTop: spacing.xs,
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
export { InputOTP };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { StyleProp, TextStyle } from "react-native";
|
|
2
|
+
export interface LabelProps {
|
|
3
|
+
/**
|
|
4
|
+
* The label text
|
|
5
|
+
*/
|
|
6
|
+
children: string;
|
|
7
|
+
/**
|
|
8
|
+
* Native ID to associate with a form control
|
|
9
|
+
*/
|
|
10
|
+
nativeID?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Whether the field is required (shows asterisk)
|
|
13
|
+
*/
|
|
14
|
+
required?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Size variant
|
|
17
|
+
* @default "md"
|
|
18
|
+
*/
|
|
19
|
+
size?: "sm" | "md" | "lg";
|
|
20
|
+
/**
|
|
21
|
+
* Whether the label is in an error state
|
|
22
|
+
*/
|
|
23
|
+
error?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Whether the label is disabled
|
|
26
|
+
*/
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Optional style override for the text
|
|
30
|
+
*/
|
|
31
|
+
style?: StyleProp<TextStyle>;
|
|
32
|
+
/**
|
|
33
|
+
* Press handler (useful for focusing associated input)
|
|
34
|
+
*/
|
|
35
|
+
onPress?: () => void;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Label component for form fields using @rn-primitives/label
|
|
39
|
+
*
|
|
40
|
+
* Provides accessible labeling for form controls with automatic
|
|
41
|
+
* association via nativeID.
|
|
42
|
+
*
|
|
43
|
+
* Usage:
|
|
44
|
+
* ```tsx
|
|
45
|
+
* // Basic label
|
|
46
|
+
* <Label nativeID="email-input">Email</Label>
|
|
47
|
+
* <TextInput nativeID="email-input" />
|
|
48
|
+
*
|
|
49
|
+
* // Required field
|
|
50
|
+
* <Label nativeID="password" required>Password</Label>
|
|
51
|
+
*
|
|
52
|
+
* // With error state
|
|
53
|
+
* <Label nativeID="username" error>Username</Label>
|
|
54
|
+
*
|
|
55
|
+
* // With press handler to focus input
|
|
56
|
+
* <Label nativeID="search" onPress={() => inputRef.current?.focus()}>
|
|
57
|
+
* Search
|
|
58
|
+
* </Label>
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
export declare function Label({ children, nativeID, required, size, error, disabled, style, onPress, }: LabelProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { StyleSheet, Platform } from "react-native";
|
|
3
|
+
import * as LabelPrimitive from "@rn-primitives/label";
|
|
4
|
+
import { useTheme } from "../hooks/useTheme";
|
|
5
|
+
import { spacing } from "../constants/spacing";
|
|
6
|
+
import { fontFamilies } from "../constants/fonts";
|
|
7
|
+
import { StyledText } from "./StyledText";
|
|
8
|
+
const SIZE_CONFIGS = {
|
|
9
|
+
sm: { fontSize: 12 },
|
|
10
|
+
md: { fontSize: 14 },
|
|
11
|
+
lg: { fontSize: 16 },
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Label component for form fields using @rn-primitives/label
|
|
15
|
+
*
|
|
16
|
+
* Provides accessible labeling for form controls with automatic
|
|
17
|
+
* association via nativeID.
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* ```tsx
|
|
21
|
+
* // Basic label
|
|
22
|
+
* <Label nativeID="email-input">Email</Label>
|
|
23
|
+
* <TextInput nativeID="email-input" />
|
|
24
|
+
*
|
|
25
|
+
* // Required field
|
|
26
|
+
* <Label nativeID="password" required>Password</Label>
|
|
27
|
+
*
|
|
28
|
+
* // With error state
|
|
29
|
+
* <Label nativeID="username" error>Username</Label>
|
|
30
|
+
*
|
|
31
|
+
* // With press handler to focus input
|
|
32
|
+
* <Label nativeID="search" onPress={() => inputRef.current?.focus()}>
|
|
33
|
+
* Search
|
|
34
|
+
* </Label>
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export function Label({ children, nativeID, required, size = "md", error, disabled, style, onPress, }) {
|
|
38
|
+
const { theme } = useTheme();
|
|
39
|
+
const styles = createStyles(theme);
|
|
40
|
+
const sizeConfig = SIZE_CONFIGS[size];
|
|
41
|
+
const textStyle = StyleSheet.flatten([
|
|
42
|
+
styles.label,
|
|
43
|
+
{ fontSize: sizeConfig.fontSize },
|
|
44
|
+
error && styles.errorLabel,
|
|
45
|
+
disabled && styles.disabledLabel,
|
|
46
|
+
style,
|
|
47
|
+
]);
|
|
48
|
+
return (_jsx(LabelPrimitive.Root, { nativeID: nativeID, onPress: onPress, style: {
|
|
49
|
+
...styles.root,
|
|
50
|
+
...(Platform.OS === "web" && onPress && { cursor: "pointer" }),
|
|
51
|
+
}, children: _jsxs(LabelPrimitive.Text, { style: textStyle, children: [children, required && _jsx(StyledText, { style: styles.required, children: " *" })] }) }));
|
|
52
|
+
}
|
|
53
|
+
const createStyles = (theme) => StyleSheet.create({
|
|
54
|
+
root: {
|
|
55
|
+
marginBottom: spacing.xs,
|
|
56
|
+
},
|
|
57
|
+
label: {
|
|
58
|
+
fontFamily: fontFamilies.sansSerif.regular,
|
|
59
|
+
fontWeight: "500",
|
|
60
|
+
color: theme.colors.text,
|
|
61
|
+
},
|
|
62
|
+
required: {
|
|
63
|
+
color: theme.colors.destructive,
|
|
64
|
+
fontFamily: fontFamilies.sansSerif.bold,
|
|
65
|
+
},
|
|
66
|
+
errorLabel: {
|
|
67
|
+
color: theme.colors.destructive,
|
|
68
|
+
},
|
|
69
|
+
disabledLabel: {
|
|
70
|
+
opacity: 0.6,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { ViewStyle } from "react-native";
|
|
3
|
+
/**
|
|
4
|
+
* Preset width options for common responsive breakpoints
|
|
5
|
+
*/
|
|
6
|
+
export type MaxWidthPreset = "sm" | "md" | "lg" | "xl" | "2xl" | "full";
|
|
7
|
+
interface MaxWidthContainerProps {
|
|
8
|
+
children: React.ReactNode;
|
|
9
|
+
/**
|
|
10
|
+
* Maximum width in pixels (custom number)
|
|
11
|
+
* If preset is provided, this is ignored
|
|
12
|
+
*/
|
|
13
|
+
maxWidth?: number;
|
|
14
|
+
/**
|
|
15
|
+
* Preset width option (sm, md, lg, xl, 2xl, full)
|
|
16
|
+
* Overrides maxWidth if provided
|
|
17
|
+
*/
|
|
18
|
+
preset?: MaxWidthPreset;
|
|
19
|
+
/**
|
|
20
|
+
* Additional styles
|
|
21
|
+
*/
|
|
22
|
+
style?: ViewStyle;
|
|
23
|
+
/**
|
|
24
|
+
* Whether to center the container horizontally
|
|
25
|
+
* @default true
|
|
26
|
+
*/
|
|
27
|
+
centered?: boolean;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Max Width Container Component
|
|
31
|
+
* Constrains content width on large screens (web only by default)
|
|
32
|
+
*
|
|
33
|
+
* Features:
|
|
34
|
+
* - Preset width options (sm, md, lg, xl, 2xl, full)
|
|
35
|
+
* - Custom max width
|
|
36
|
+
* - Automatic centering
|
|
37
|
+
* - Platform-aware (only applies on web by default)
|
|
38
|
+
*
|
|
39
|
+
* Usage:
|
|
40
|
+
* ```tsx
|
|
41
|
+
* // With preset
|
|
42
|
+
* <MaxWidthContainer preset="lg">
|
|
43
|
+
* {children}
|
|
44
|
+
* </MaxWidthContainer>
|
|
45
|
+
*
|
|
46
|
+
* // With custom width
|
|
47
|
+
* <MaxWidthContainer maxWidth={1200}>
|
|
48
|
+
* {children}
|
|
49
|
+
* </MaxWidthContainer>
|
|
50
|
+
*
|
|
51
|
+
* // Full width (no constraint)
|
|
52
|
+
* <MaxWidthContainer preset="full">
|
|
53
|
+
* {children}
|
|
54
|
+
* </MaxWidthContainer>
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export declare function MaxWidthContainer({ children, maxWidth, preset, style, centered, }: MaxWidthContainerProps): import("react/jsx-runtime").JSX.Element;
|
|
58
|
+
export {};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { View, StyleSheet, Platform } from "react-native";
|
|
3
|
+
import { useDimensions } from "../hooks/useDimensions";
|
|
4
|
+
const MAX_WIDTH_PRESETS = {
|
|
5
|
+
sm: 640,
|
|
6
|
+
md: 768,
|
|
7
|
+
lg: 1024,
|
|
8
|
+
xl: 1280,
|
|
9
|
+
"2xl": 1536,
|
|
10
|
+
full: "100%",
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Max Width Container Component
|
|
14
|
+
* Constrains content width on large screens (web only by default)
|
|
15
|
+
*
|
|
16
|
+
* Features:
|
|
17
|
+
* - Preset width options (sm, md, lg, xl, 2xl, full)
|
|
18
|
+
* - Custom max width
|
|
19
|
+
* - Automatic centering
|
|
20
|
+
* - Platform-aware (only applies on web by default)
|
|
21
|
+
*
|
|
22
|
+
* Usage:
|
|
23
|
+
* ```tsx
|
|
24
|
+
* // With preset
|
|
25
|
+
* <MaxWidthContainer preset="lg">
|
|
26
|
+
* {children}
|
|
27
|
+
* </MaxWidthContainer>
|
|
28
|
+
*
|
|
29
|
+
* // With custom width
|
|
30
|
+
* <MaxWidthContainer maxWidth={1200}>
|
|
31
|
+
* {children}
|
|
32
|
+
* </MaxWidthContainer>
|
|
33
|
+
*
|
|
34
|
+
* // Full width (no constraint)
|
|
35
|
+
* <MaxWidthContainer preset="full">
|
|
36
|
+
* {children}
|
|
37
|
+
* </MaxWidthContainer>
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export function MaxWidthContainer({ children, maxWidth, preset = "2xl", style, centered = true, }) {
|
|
41
|
+
const { width } = useDimensions();
|
|
42
|
+
// Determine final max width
|
|
43
|
+
const finalMaxWidth = preset ? MAX_WIDTH_PRESETS[preset] : maxWidth;
|
|
44
|
+
// Only apply max-width on web and large screens
|
|
45
|
+
const shouldApplyMaxWidth = Platform.OS === "web" &&
|
|
46
|
+
finalMaxWidth !== "100%" &&
|
|
47
|
+
width > finalMaxWidth;
|
|
48
|
+
return (_jsx(View, { style: [
|
|
49
|
+
styles.container,
|
|
50
|
+
shouldApplyMaxWidth && {
|
|
51
|
+
maxWidth: finalMaxWidth,
|
|
52
|
+
...(centered && {
|
|
53
|
+
alignSelf: "center",
|
|
54
|
+
width: "100%",
|
|
55
|
+
}),
|
|
56
|
+
},
|
|
57
|
+
style,
|
|
58
|
+
], children: children }));
|
|
59
|
+
}
|
|
60
|
+
const styles = StyleSheet.create({
|
|
61
|
+
container: {
|
|
62
|
+
flex: 1,
|
|
63
|
+
},
|
|
64
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification
|
|
3
|
+
*
|
|
4
|
+
* Global animated notification component rendered in the root `_layout`.
|
|
5
|
+
* Supports top (banner) and bottom (toast) positions.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ```ts
|
|
9
|
+
* // Top notification (default)
|
|
10
|
+
* globalUIStore.getState().show({
|
|
11
|
+
* type: "success",
|
|
12
|
+
* title: "Saved",
|
|
13
|
+
* messages: ["Your changes have been saved."],
|
|
14
|
+
* duration: 3000,
|
|
15
|
+
* });
|
|
16
|
+
*
|
|
17
|
+
* // Bottom toast
|
|
18
|
+
* globalUIStore.getState().show({
|
|
19
|
+
* type: "info",
|
|
20
|
+
* title: "Copied to clipboard",
|
|
21
|
+
* duration: 2000,
|
|
22
|
+
* position: "bottom",
|
|
23
|
+
* });
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export declare const Notification: () => import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { 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";
|
|
5
|
+
import { useTranslation } from "react-i18next";
|
|
6
|
+
import { SafeAreaInsetsContext } from "react-native-safe-area-context";
|
|
7
|
+
import { fontFamilies } from "../constants/fonts";
|
|
8
|
+
import { Icon } from "./Icon";
|
|
9
|
+
import { useTheme } from "../hooks/useTheme";
|
|
10
|
+
import { spacing } from "../constants/spacing";
|
|
11
|
+
import { StyledText } from "./StyledText";
|
|
12
|
+
import { globalUIStore } from "../state/globalUIStore";
|
|
13
|
+
/**
|
|
14
|
+
* Notification
|
|
15
|
+
*
|
|
16
|
+
* Global animated notification component rendered in the root `_layout`.
|
|
17
|
+
* Supports top (banner) and bottom (toast) positions.
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* ```ts
|
|
21
|
+
* // Top notification (default)
|
|
22
|
+
* globalUIStore.getState().show({
|
|
23
|
+
* type: "success",
|
|
24
|
+
* title: "Saved",
|
|
25
|
+
* messages: ["Your changes have been saved."],
|
|
26
|
+
* duration: 3000,
|
|
27
|
+
* });
|
|
28
|
+
*
|
|
29
|
+
* // Bottom toast
|
|
30
|
+
* globalUIStore.getState().show({
|
|
31
|
+
* type: "info",
|
|
32
|
+
* title: "Copied to clipboard",
|
|
33
|
+
* duration: 2000,
|
|
34
|
+
* position: "bottom",
|
|
35
|
+
* });
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export const Notification = () => {
|
|
39
|
+
const { t } = useTranslation();
|
|
40
|
+
const { theme, getShadowStyle } = useTheme();
|
|
41
|
+
const reduceMotion = useReducedMotion();
|
|
42
|
+
const insets = useContext(SafeAreaInsetsContext);
|
|
43
|
+
const { alert, hide } = globalUIStore();
|
|
44
|
+
const styles = createStyles(theme);
|
|
45
|
+
const position = alert?.position ?? "top";
|
|
46
|
+
const isBottom = position === "bottom";
|
|
47
|
+
// Just opacity + translateY — no scale (scale = bouncy feel)
|
|
48
|
+
const opacity = useSharedValue(0);
|
|
49
|
+
const translateY = useSharedValue(0);
|
|
50
|
+
const wasVisibleRef = useRef(false);
|
|
51
|
+
const timerRef = useRef(null);
|
|
52
|
+
const hideNotification = useCallback(() => {
|
|
53
|
+
hide();
|
|
54
|
+
}, [hide]);
|
|
55
|
+
const timingIn = { duration: 150, easing: Easing.out(Easing.quad) };
|
|
56
|
+
const timingOut = { duration: 100, easing: Easing.in(Easing.quad) };
|
|
57
|
+
const animateOut = useCallback(() => {
|
|
58
|
+
if (reduceMotion) {
|
|
59
|
+
opacity.value = withTiming(0, { duration: 0 });
|
|
60
|
+
hideNotification();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const slideTarget = isBottom ? 8 : -8;
|
|
64
|
+
opacity.value = withTiming(0, timingOut);
|
|
65
|
+
translateY.value = withTiming(slideTarget, timingOut, (finished) => {
|
|
66
|
+
if (finished)
|
|
67
|
+
runOnJS(hideNotification)();
|
|
68
|
+
});
|
|
69
|
+
}, [reduceMotion, isBottom, opacity, translateY, hideNotification]);
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const isNowVisible = alert?.show ?? false;
|
|
72
|
+
const wasVisible = wasVisibleRef.current;
|
|
73
|
+
if (isNowVisible && !wasVisible) {
|
|
74
|
+
if (timerRef.current) {
|
|
75
|
+
clearTimeout(timerRef.current);
|
|
76
|
+
timerRef.current = null;
|
|
77
|
+
}
|
|
78
|
+
const slideFrom = isBottom ? 8 : -8;
|
|
79
|
+
if (reduceMotion) {
|
|
80
|
+
opacity.value = withTiming(1, { duration: 0 });
|
|
81
|
+
translateY.value = withTiming(0, { duration: 0 });
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
opacity.value = 0;
|
|
85
|
+
translateY.value = slideFrom;
|
|
86
|
+
opacity.value = withTiming(1, timingIn);
|
|
87
|
+
translateY.value = withTiming(0, timingIn);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
wasVisibleRef.current = isNowVisible;
|
|
91
|
+
if (isNowVisible && !wasVisible && alert?.duration) {
|
|
92
|
+
timerRef.current = setTimeout(() => {
|
|
93
|
+
animateOut();
|
|
94
|
+
}, alert.duration);
|
|
95
|
+
return () => {
|
|
96
|
+
if (timerRef.current) {
|
|
97
|
+
clearTimeout(timerRef.current);
|
|
98
|
+
timerRef.current = null;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}, [alert, reduceMotion, isBottom, opacity, translateY, animateOut]);
|
|
103
|
+
const animatedContainerStyle = useAnimatedStyle(() => ({
|
|
104
|
+
opacity: opacity.value,
|
|
105
|
+
transform: [{ translateY: translateY.value }],
|
|
106
|
+
}));
|
|
107
|
+
const topPosition = insets?.top ? insets.top : 20;
|
|
108
|
+
const bottomPosition = insets?.bottom ? insets.bottom : 20;
|
|
109
|
+
const getIconProps = () => {
|
|
110
|
+
switch (alert?.type) {
|
|
111
|
+
case "error":
|
|
112
|
+
return {
|
|
113
|
+
icon: "alert-circle",
|
|
114
|
+
color: theme.colors.destructive,
|
|
115
|
+
bgColor: theme.colors.destructive + "15",
|
|
116
|
+
};
|
|
117
|
+
case "success":
|
|
118
|
+
return {
|
|
119
|
+
icon: "check-circle",
|
|
120
|
+
color: theme.colors.success,
|
|
121
|
+
bgColor: theme.colors.success + "15",
|
|
122
|
+
};
|
|
123
|
+
case "warning":
|
|
124
|
+
return {
|
|
125
|
+
icon: "alert-triangle",
|
|
126
|
+
color: theme.colors.warning,
|
|
127
|
+
bgColor: theme.colors.warning + "15",
|
|
128
|
+
};
|
|
129
|
+
case "info":
|
|
130
|
+
default:
|
|
131
|
+
return {
|
|
132
|
+
icon: "info",
|
|
133
|
+
color: theme.colors.accent,
|
|
134
|
+
bgColor: theme.colors.accent + "15",
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
const getTitle = () => {
|
|
139
|
+
if (alert?.title)
|
|
140
|
+
return alert.title;
|
|
141
|
+
switch (alert?.type) {
|
|
142
|
+
case "error":
|
|
143
|
+
return t("notification.error");
|
|
144
|
+
case "success":
|
|
145
|
+
return t("notification.success");
|
|
146
|
+
case "warning":
|
|
147
|
+
return t("notification.warning");
|
|
148
|
+
case "info":
|
|
149
|
+
return "";
|
|
150
|
+
default:
|
|
151
|
+
return "";
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
const { icon, color: iconColor, bgColor: iconBgColor } = getIconProps();
|
|
155
|
+
const title = getTitle();
|
|
156
|
+
const hasMessage = !!alert?.messages?.[0];
|
|
157
|
+
return (_jsx(Animated.View, { accessibilityLiveRegion: "polite", accessibilityRole: "alert", pointerEvents: alert?.show ? "auto" : "none", style: [
|
|
158
|
+
styles.container,
|
|
159
|
+
isBottom
|
|
160
|
+
? { bottom: bottomPosition }
|
|
161
|
+
: { top: topPosition },
|
|
162
|
+
animatedContainerStyle,
|
|
163
|
+
!alert?.show && { opacity: 0 },
|
|
164
|
+
], children: _jsxs(View, { style: [
|
|
165
|
+
styles.alert,
|
|
166
|
+
isBottom && styles.alertBottom,
|
|
167
|
+
getShadowStyle("base"),
|
|
168
|
+
], 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, { style: [styles.alertTitle, { color: theme.colors.foreground }], numberOfLines: 1, children: title })), hasMessage && (_jsx(StyledText, { style: [styles.alertDescription, { color: theme.colors.mutedForeground }], numberOfLines: 2, children: alert.messages[0] }))] }), _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 }) })] }) }));
|
|
169
|
+
};
|
|
170
|
+
const createStyles = (theme) => StyleSheet.create({
|
|
171
|
+
container: {
|
|
172
|
+
position: "absolute",
|
|
173
|
+
left: spacing.md,
|
|
174
|
+
right: spacing.md,
|
|
175
|
+
zIndex: 1000,
|
|
176
|
+
alignItems: "center",
|
|
177
|
+
},
|
|
178
|
+
alert: {
|
|
179
|
+
width: "100%",
|
|
180
|
+
maxWidth: 420,
|
|
181
|
+
paddingVertical: spacing.md,
|
|
182
|
+
paddingLeft: spacing.md,
|
|
183
|
+
paddingRight: spacing.xl + spacing.sm,
|
|
184
|
+
borderRadius: spacing.radiusLg,
|
|
185
|
+
borderWidth: 1,
|
|
186
|
+
borderColor: theme.colors.border,
|
|
187
|
+
backgroundColor: theme.colors.card,
|
|
188
|
+
flexDirection: "row",
|
|
189
|
+
alignItems: "center",
|
|
190
|
+
gap: spacing.md,
|
|
191
|
+
},
|
|
192
|
+
alertBottom: {
|
|
193
|
+
borderRadius: spacing.radiusXl,
|
|
194
|
+
},
|
|
195
|
+
iconBadge: {
|
|
196
|
+
width: 36,
|
|
197
|
+
height: 36,
|
|
198
|
+
borderRadius: spacing.radiusMd,
|
|
199
|
+
justifyContent: "center",
|
|
200
|
+
alignItems: "center",
|
|
201
|
+
flexShrink: 0,
|
|
202
|
+
},
|
|
203
|
+
alertContent: {
|
|
204
|
+
flex: 1,
|
|
205
|
+
justifyContent: "center",
|
|
206
|
+
gap: spacing.xxs,
|
|
207
|
+
},
|
|
208
|
+
alertTitle: {
|
|
209
|
+
fontFamily: fontFamilies.sansSerif.regular,
|
|
210
|
+
fontWeight: "600",
|
|
211
|
+
fontSize: 14,
|
|
212
|
+
lineHeight: 20,
|
|
213
|
+
},
|
|
214
|
+
alertDescription: {
|
|
215
|
+
fontFamily: fontFamilies.sansSerif.regular,
|
|
216
|
+
fontSize: 13,
|
|
217
|
+
lineHeight: 18,
|
|
218
|
+
},
|
|
219
|
+
closeButton: {
|
|
220
|
+
position: "absolute",
|
|
221
|
+
top: spacing.sm,
|
|
222
|
+
right: spacing.sm,
|
|
223
|
+
width: 28,
|
|
224
|
+
height: 28,
|
|
225
|
+
borderRadius: spacing.radiusSm,
|
|
226
|
+
justifyContent: "center",
|
|
227
|
+
alignItems: "center",
|
|
228
|
+
...(Platform.OS === "web" && { cursor: "pointer" }),
|
|
229
|
+
},
|
|
230
|
+
});
|