@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.
Files changed (112) hide show
  1. package/README.md +96 -0
  2. package/dist/components/Accordion.d.ts +54 -0
  3. package/dist/components/Accordion.js +149 -0
  4. package/dist/components/Alert.d.ts +30 -0
  5. package/dist/components/Alert.js +25 -0
  6. package/dist/components/AnimatedView.d.ts +55 -0
  7. package/dist/components/AnimatedView.js +39 -0
  8. package/dist/components/Badge.d.ts +23 -0
  9. package/dist/components/Badge.js +74 -0
  10. package/dist/components/BottomSheet.d.ts +74 -0
  11. package/dist/components/BottomSheet.js +513 -0
  12. package/dist/components/Button.d.ts +129 -0
  13. package/dist/components/Button.js +216 -0
  14. package/dist/components/Card.d.ts +42 -0
  15. package/dist/components/Card.js +126 -0
  16. package/dist/components/Checkbox.d.ts +39 -0
  17. package/dist/components/Checkbox.js +96 -0
  18. package/dist/components/Collapsible.d.ts +67 -0
  19. package/dist/components/Collapsible.js +38 -0
  20. package/dist/components/Dialog.d.ts +140 -0
  21. package/dist/components/Dialog.js +167 -0
  22. package/dist/components/DismissKeyboard.d.ts +15 -0
  23. package/dist/components/DismissKeyboard.js +13 -0
  24. package/dist/components/Drawer.d.ts +74 -0
  25. package/dist/components/Drawer.js +423 -0
  26. package/dist/components/DropdownMenu.d.ts +120 -0
  27. package/dist/components/DropdownMenu.js +211 -0
  28. package/dist/components/EmptyState.d.ts +42 -0
  29. package/dist/components/EmptyState.js +58 -0
  30. package/dist/components/ErrorBoundary.d.ts +53 -0
  31. package/dist/components/ErrorBoundary.js +75 -0
  32. package/dist/components/Icon.d.ts +46 -0
  33. package/dist/components/Icon.js +40 -0
  34. package/dist/components/InputOTP.d.ts +72 -0
  35. package/dist/components/InputOTP.js +155 -0
  36. package/dist/components/Label.d.ts +61 -0
  37. package/dist/components/Label.js +72 -0
  38. package/dist/components/MaxWidthContainer.d.ts +58 -0
  39. package/dist/components/MaxWidthContainer.js +64 -0
  40. package/dist/components/Notification.d.ts +26 -0
  41. package/dist/components/Notification.js +230 -0
  42. package/dist/components/Popover.d.ts +79 -0
  43. package/dist/components/Popover.js +91 -0
  44. package/dist/components/Progress.d.ts +28 -0
  45. package/dist/components/Progress.js +107 -0
  46. package/dist/components/RadioGroup.d.ts +65 -0
  47. package/dist/components/RadioGroup.js +142 -0
  48. package/dist/components/Select.d.ts +88 -0
  49. package/dist/components/Select.js +172 -0
  50. package/dist/components/Separator.d.ts +83 -0
  51. package/dist/components/Separator.js +85 -0
  52. package/dist/components/Skeleton.d.ts +68 -0
  53. package/dist/components/Skeleton.js +99 -0
  54. package/dist/components/Slider.d.ts +24 -0
  55. package/dist/components/Slider.js +162 -0
  56. package/dist/components/StatusBar.d.ts +1 -0
  57. package/dist/components/StatusBar.js +19 -0
  58. package/dist/components/StyledText.d.ts +161 -0
  59. package/dist/components/StyledText.js +193 -0
  60. package/dist/components/Switch.d.ts +44 -0
  61. package/dist/components/Switch.js +129 -0
  62. package/dist/components/Tabs.d.ts +31 -0
  63. package/dist/components/Tabs.js +127 -0
  64. package/dist/components/TextInput.d.ts +120 -0
  65. package/dist/components/TextInput.js +263 -0
  66. package/dist/components/Toggle.d.ts +106 -0
  67. package/dist/components/Toggle.js +150 -0
  68. package/dist/components/ToggleGroup.d.ts +80 -0
  69. package/dist/components/ToggleGroup.js +189 -0
  70. package/dist/components/Tooltip.d.ts +121 -0
  71. package/dist/components/Tooltip.js +132 -0
  72. package/dist/components/index.d.ts +35 -0
  73. package/dist/components/index.js +35 -0
  74. package/dist/constants/colors.d.ts +82 -0
  75. package/dist/constants/colors.js +116 -0
  76. package/dist/constants/fonts.d.ts +32 -0
  77. package/dist/constants/fonts.js +91 -0
  78. package/dist/constants/index.d.ts +3 -0
  79. package/dist/constants/index.js +3 -0
  80. package/dist/constants/spacing.d.ts +40 -0
  81. package/dist/constants/spacing.js +48 -0
  82. package/dist/hooks/index.d.ts +6 -0
  83. package/dist/hooks/index.js +6 -0
  84. package/dist/hooks/useDimensions.d.ts +19 -0
  85. package/dist/hooks/useDimensions.js +55 -0
  86. package/dist/hooks/useReduceMotion.d.ts +5 -0
  87. package/dist/hooks/useReduceMotion.js +64 -0
  88. package/dist/hooks/useResources.d.ts +12 -0
  89. package/dist/hooks/useResources.js +56 -0
  90. package/dist/hooks/useScalePress.d.ts +57 -0
  91. package/dist/hooks/useScalePress.js +55 -0
  92. package/dist/hooks/useStaggeredEntrance.d.ts +67 -0
  93. package/dist/hooks/useStaggeredEntrance.js +74 -0
  94. package/dist/hooks/useTheme.d.ts +88 -0
  95. package/dist/hooks/useTheme.js +328 -0
  96. package/dist/index.d.ts +5 -0
  97. package/dist/index.js +5 -0
  98. package/dist/lib/animations.d.ts +1 -0
  99. package/dist/lib/animations.js +3 -0
  100. package/dist/lib/haptics.d.ts +3 -0
  101. package/dist/lib/haptics.js +29 -0
  102. package/dist/lib/index.d.ts +3 -0
  103. package/dist/lib/index.js +3 -0
  104. package/dist/lib/sentry.d.ts +16 -0
  105. package/dist/lib/sentry.js +55 -0
  106. package/dist/state/globalUIStore.d.ts +30 -0
  107. package/dist/state/globalUIStore.js +8 -0
  108. package/dist/state/index.d.ts +2 -0
  109. package/dist/state/index.js +2 -0
  110. package/dist/state/themeStore.d.ts +6 -0
  111. package/dist/state/themeStore.js +38 -0
  112. 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
+ });