@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,328 @@
1
+ import { useEffect } from "react";
2
+ import { colors } from "../constants/colors";
3
+ import { useColorScheme as useColorSchemeDefault, Platform, StyleSheet } from "react-native";
4
+ import { useThemeStore } from "../state/themeStore";
5
+ import { spacing as spacingConstants } from "../constants/spacing";
6
+ // Module-level cache for contrast calculations to avoid memory leak
7
+ // and share across components
8
+ const contrastCache = new Map();
9
+ const MAX_CACHE_SIZE = 500;
10
+ function getCachedOrCompute(key, compute) {
11
+ if (contrastCache.has(key)) {
12
+ return contrastCache.get(key);
13
+ }
14
+ // Prevent unbounded growth
15
+ if (contrastCache.size >= MAX_CACHE_SIZE) {
16
+ const firstKey = contrastCache.keys().next().value;
17
+ if (firstKey)
18
+ contrastCache.delete(firstKey);
19
+ }
20
+ const result = compute();
21
+ contrastCache.set(key, result);
22
+ return result;
23
+ }
24
+ /**
25
+ * useTheme
26
+ *
27
+ * Provides access to app colors, theme styles, and utilities for color contrast.
28
+ * Includes helpers to determine readable text color for any background.
29
+ *
30
+ * Returns:
31
+ * - theme: active theme colors (light or dark)
32
+ * - scheme: "light" | "dark"
33
+ * - getShadowStyle(type): returns cross-platform shadow style object
34
+ * - getContrastingColor(bg, color1?, color2?): pick best contrast of two options
35
+ * - getTextColorForBackground(bg): returns "light" or "dark"
36
+ * - withAlpha(color, alpha): adds transparency
37
+ * - getContrastRatio(color1, color2): returns numeric WCAG contrast ratio
38
+ *
39
+ * Examples:
40
+ * - getTextColorForBackground("#000") → "light"
41
+ * - getContrastingColor("#f4f4f4", "#222", "#fff") → "#222"
42
+ * - withAlpha("#336699", 0.6) → "rgba(51,102,153,0.6)"
43
+ * - getShadowStyle('base') → { shadowColor, shadowOffset, ... }
44
+ */
45
+ export function useTheme() {
46
+ const userTheme = useThemeStore((s) => s.userTheme);
47
+ const setTheme = useThemeStore((s) => s.setTheme);
48
+ let defaultScheme = useColorSchemeDefault();
49
+ // Ensure a scheme is selected, even if we fail to get one
50
+ if (!defaultScheme) {
51
+ defaultScheme = "light";
52
+ }
53
+ // Determine which theme to use (user preference or system)
54
+ const effectiveScheme = userTheme === "system" ? defaultScheme : userTheme;
55
+ const theme = colors[effectiveScheme];
56
+ // Sync theme to DOM so CSS in +html.tsx follows the app's runtime theme
57
+ useEffect(() => {
58
+ if (Platform.OS === "web" && typeof document !== "undefined") {
59
+ document.documentElement.dataset.theme = effectiveScheme;
60
+ document.documentElement.style.colorScheme = effectiveScheme;
61
+ }
62
+ }, [effectiveScheme]);
63
+ // Toggle between light, dark, and system themes
64
+ const toggleTheme = () => {
65
+ if (userTheme === "light") {
66
+ setTheme("dark");
67
+ }
68
+ else if (userTheme === "dark") {
69
+ setTheme("system");
70
+ }
71
+ else {
72
+ setTheme("light");
73
+ }
74
+ };
75
+ /**
76
+ * getShadowStyle
77
+ * Returns platform-appropriate shadow styles
78
+ * - Web: returns empty object (boxShadow not supported by RN Web)
79
+ * - Native: uses shadowColor, shadowOffset, shadowOpacity, shadowRadius, elevation
80
+ */
81
+ const getShadowStyle = (type) => {
82
+ const shadowConfigs = {
83
+ base: {
84
+ shadowColor: theme.colors.overlay,
85
+ shadowOffset: { width: 0, height: 1 },
86
+ shadowOpacity: 0.1,
87
+ shadowRadius: 3,
88
+ elevation: 3,
89
+ },
90
+ soft: {
91
+ shadowColor: theme.colors.overlay,
92
+ shadowOffset: { width: 0, height: 4 },
93
+ shadowOpacity: 0.1,
94
+ shadowRadius: 6,
95
+ elevation: 4,
96
+ },
97
+ sharp: {
98
+ shadowColor: theme.colors.overlay,
99
+ shadowOffset: { width: 0, height: 1 },
100
+ shadowOpacity: 0.15,
101
+ shadowRadius: 1,
102
+ elevation: 2,
103
+ },
104
+ subtle: {
105
+ shadowColor: theme.colors.overlay,
106
+ shadowOffset: { width: 0, height: 1 },
107
+ shadowOpacity: 0.05,
108
+ shadowRadius: 2,
109
+ elevation: 1,
110
+ },
111
+ };
112
+ const config = shadowConfigs[type];
113
+ return Platform.select({
114
+ web: {}, // Empty on web - boxShadow causes crashes
115
+ default: config,
116
+ });
117
+ };
118
+ // Helper to calculate contrast ratio between two colors
119
+ const getContrastRatio = (color1, color2) => {
120
+ const rgb1 = parseColor(color1);
121
+ const rgb2 = parseColor(color2);
122
+ if (!rgb1 || !rgb2) {
123
+ console.error("Invalid colors for contrast ratio:", color1, color2);
124
+ return 1; // Default to minimum contrast
125
+ }
126
+ const lum1 = calculateLuminance(rgb1[0], rgb1[1], rgb1[2]);
127
+ const lum2 = calculateLuminance(rgb2[0], rgb2[1], rgb2[2]);
128
+ return calculateContrastRatio(lum1, lum2);
129
+ };
130
+ // Cached version of getContrastingColor for performance
131
+ // Uses module-level cache to prevent memory leak
132
+ const getCachedContrastingColor = (backgroundColor, color1, color2) => {
133
+ const cacheKey = `${backgroundColor}-${color1}-${color2}`;
134
+ return getCachedOrCompute(cacheKey, () => getBetterContrast(backgroundColor, color1, color2));
135
+ };
136
+ return {
137
+ theme,
138
+ scheme: theme.dark ? "dark" : "light",
139
+ getShadowStyle,
140
+ toggleTheme,
141
+ setTheme,
142
+ currentTheme: userTheme,
143
+ getContrastingColor: getCachedContrastingColor,
144
+ getTextColorForBackground,
145
+ withAlpha,
146
+ getContrastRatio,
147
+ };
148
+ }
149
+ /**
150
+ * Parses a color string (hex or rgba) and returns RGB values.
151
+ * @param color - The color string (hex or rgba format)
152
+ * @returns An array of [red, green, blue, alpha] values (RGB 0-255, alpha 0-1) or null if invalid.
153
+ */
154
+ function parseColor(color) {
155
+ // Check if it's an rgba string
156
+ const rgbaMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([0-9.]+))?\)/);
157
+ if (rgbaMatch) {
158
+ return [
159
+ parseInt(rgbaMatch[1], 10),
160
+ parseInt(rgbaMatch[2], 10),
161
+ parseInt(rgbaMatch[3], 10),
162
+ rgbaMatch[4] ? parseFloat(rgbaMatch[4]) : 1
163
+ ];
164
+ }
165
+ // Otherwise, try to parse as hex
166
+ const rgb = hexToRgb(color);
167
+ return rgb ? [...rgb, 1] : null; // Add alpha=1 for hex colors
168
+ }
169
+ /**
170
+ * Converts a hexadecimal color code to RGB values.
171
+ * @param hex - The hexadecimal color code (e.g., "#RRGGBB" or "#RGB").
172
+ * @returns An array of [red, green, blue] values (0-255) or null if invalid.
173
+ */
174
+ function hexToRgb(hex) {
175
+ // Check if already in rgba format
176
+ if (hex.startsWith("rgba") || hex.startsWith("rgb")) {
177
+ const rgbValues = parseColor(hex);
178
+ return rgbValues ? [rgbValues[0], rgbValues[1], rgbValues[2]] : null;
179
+ }
180
+ // Remove the hash if it exists
181
+ hex = hex.replace(/^#/, "");
182
+ // Handle shorthand hex (#RGB)
183
+ if (hex.length === 3) {
184
+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
185
+ }
186
+ // Handle standard hex (#RRGGBB)
187
+ const result = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
188
+ return result
189
+ ? [
190
+ parseInt(result[1], 16),
191
+ parseInt(result[2], 16),
192
+ parseInt(result[3], 16)
193
+ ]
194
+ : null;
195
+ }
196
+ /**
197
+ * Calculates the relative luminance of a color according to WCAG guidelines.
198
+ * @param r - Red component (0-255).
199
+ * @param g - Green component (0-255).
200
+ * @param b - Blue component (0-255).
201
+ * @returns The relative luminance value (0-1).
202
+ */
203
+ function calculateLuminance(r, g, b) {
204
+ // Convert RGB to sRGB
205
+ const sRGB = [r, g, b].map(v => {
206
+ v /= 255;
207
+ return v <= 0.03928
208
+ ? v / 12.92
209
+ : Math.pow((v + 0.055) / 1.055, 2.4);
210
+ });
211
+ // Calculate luminance using WCAG formula
212
+ return sRGB[0] * 0.2126 + sRGB[1] * 0.7152 + sRGB[2] * 0.0722;
213
+ }
214
+ /**
215
+ * Calculates the contrast ratio between two colors according to WCAG guidelines.
216
+ * @param lum1 - First color's luminance.
217
+ * @param lum2 - Second color's luminance.
218
+ * @returns The contrast ratio (1-21).
219
+ */
220
+ function calculateContrastRatio(lum1, lum2) {
221
+ const lighterLum = Math.max(lum1, lum2);
222
+ const darkerLum = Math.min(lum1, lum2);
223
+ return (lighterLum + 0.05) / (darkerLum + 0.05);
224
+ }
225
+ /**
226
+ * Determines the color with better contrast against the background.
227
+ * @param backgroundColor - The background color in hex or rgba format.
228
+ * @param color1 - First color option in hex format (default: "#FFFFFF").
229
+ * @param color2 - Second color option in hex format (default: "#000000").
230
+ * @returns The hex color code of the color with better contrast.
231
+ */
232
+ function getBetterContrast(backgroundColor, color1 = "#FFFFFF", color2 = "#000000") {
233
+ const bgColor = parseColor(backgroundColor);
234
+ const color1Rgb = parseColor(color1);
235
+ const color2Rgb = parseColor(color2);
236
+ if (!bgColor || !color1Rgb || !color2Rgb) {
237
+ return "#000000"; // Default to black if any color is invalid
238
+ }
239
+ // For semi-transparent backgrounds, we'll consider their luminance directly
240
+ const bgLuminance = calculateLuminance(bgColor[0], bgColor[1], bgColor[2]);
241
+ const color1Luminance = calculateLuminance(color1Rgb[0], color1Rgb[1], color1Rgb[2]);
242
+ const color2Luminance = calculateLuminance(color2Rgb[0], color2Rgb[1], color2Rgb[2]);
243
+ const contrast1 = calculateContrastRatio(bgLuminance, color1Luminance);
244
+ const contrast2 = calculateContrastRatio(bgLuminance, color2Luminance);
245
+ return contrast1 > contrast2 ? color1 : color2;
246
+ }
247
+ /**
248
+ * Determines whether to use light or dark text based on background color.
249
+ * @param backgroundColor - The background color in hex or rgba format.
250
+ * @returns "light" or "dark" indicating the recommended text color.
251
+ */
252
+ function getTextColorForBackground(backgroundColor) {
253
+ const rgb = parseColor(backgroundColor);
254
+ if (!rgb) {
255
+ console.error("Invalid color:", backgroundColor);
256
+ return "light"; // default to light if invalid color
257
+ }
258
+ // Using threshold of 0.5 for better light/dark text discrimination
259
+ const luminance = calculateLuminance(rgb[0], rgb[1], rgb[2]);
260
+ return luminance > 0.5 ? "dark" : "light";
261
+ }
262
+ /**
263
+ * Generates an alpha-modified version of a color
264
+ * @param color - The color to modify (hex or rgba)
265
+ * @param alpha - Alpha value between 0 and 1
266
+ * @returns rgba color string with the specified alpha
267
+ */
268
+ function withAlpha(color, alpha) {
269
+ const rgb = parseColor(color);
270
+ if (!rgb) {
271
+ console.error("Invalid color for alpha:", color);
272
+ return `rgba(0, 0, 0, ${alpha})`;
273
+ }
274
+ return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha})`;
275
+ }
276
+ /**
277
+ * useStyles
278
+ *
279
+ * A hook that combines useTheme with StyleSheet.create for theme-aware styling.
280
+ * Provides access to theme colors and spacing constants within the style factory.
281
+ *
282
+ * @param factory - A function that receives { theme, spacing } and returns style definitions
283
+ * @returns { styles, theme, spacing, ...themeUtilities }
284
+ *
285
+ * @example
286
+ * ```tsx
287
+ * function MyComponent() {
288
+ * const { styles, theme } = useStyles(({ theme, spacing }) => ({
289
+ * container: {
290
+ * backgroundColor: theme.colors.background,
291
+ * padding: spacing.md,
292
+ * borderRadius: spacing.radiusMd,
293
+ * },
294
+ * text: {
295
+ * color: theme.colors.textPrimary,
296
+ * fontSize: 16,
297
+ * },
298
+ * }));
299
+ *
300
+ * return (
301
+ * <View style={styles.container}>
302
+ * <Text style={styles.text}>Hello</Text>
303
+ * </View>
304
+ * );
305
+ * }
306
+ * ```
307
+ */
308
+ export function useStyles(factory) {
309
+ const themeContext = useTheme();
310
+ const styles = StyleSheet.create(factory({
311
+ theme: themeContext.theme,
312
+ spacing: spacingConstants,
313
+ }));
314
+ return {
315
+ styles,
316
+ theme: themeContext.theme,
317
+ spacing: spacingConstants,
318
+ scheme: themeContext.scheme,
319
+ getShadowStyle: themeContext.getShadowStyle,
320
+ getContrastingColor: themeContext.getContrastingColor,
321
+ getTextColorForBackground: themeContext.getTextColorForBackground,
322
+ withAlpha: themeContext.withAlpha,
323
+ getContrastRatio: themeContext.getContrastRatio,
324
+ toggleTheme: themeContext.toggleTheme,
325
+ setTheme: themeContext.setTheme,
326
+ currentTheme: themeContext.currentTheme,
327
+ };
328
+ }
@@ -0,0 +1,5 @@
1
+ export * from "./components";
2
+ export * from "./constants";
3
+ export * from "./hooks";
4
+ export * from "./state";
5
+ export * from "./lib";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export * from "./components";
2
+ export * from "./constants";
3
+ export * from "./hooks";
4
+ export * from "./state";
5
+ export * from "./lib";
@@ -0,0 +1 @@
1
+ export declare const shouldUseNativeDriver: boolean;
@@ -0,0 +1,3 @@
1
+ import { Platform } from "react-native";
2
+ // RN core Animated's native driver is not available on web.
3
+ export const shouldUseNativeDriver = Platform.OS !== "web";
@@ -0,0 +1,3 @@
1
+ export declare function hapticLight(): void;
2
+ export declare function hapticMedium(): void;
3
+ export declare function hapticSuccess(): void;
@@ -0,0 +1,29 @@
1
+ import { Platform } from "react-native";
2
+ function runHaptic(fn) {
3
+ if (Platform.OS === "web")
4
+ return;
5
+ try {
6
+ fn();
7
+ }
8
+ catch {
9
+ // Haptics not available
10
+ }
11
+ }
12
+ export function hapticLight() {
13
+ runHaptic(() => {
14
+ const H = require("expo-haptics");
15
+ H.impactAsync(H.ImpactFeedbackStyle.Light);
16
+ });
17
+ }
18
+ export function hapticMedium() {
19
+ runHaptic(() => {
20
+ const H = require("expo-haptics");
21
+ H.impactAsync(H.ImpactFeedbackStyle.Medium);
22
+ });
23
+ }
24
+ export function hapticSuccess() {
25
+ runHaptic(() => {
26
+ const H = require("expo-haptics");
27
+ H.notificationAsync(H.NotificationFeedbackType.Success);
28
+ });
29
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./animations";
2
+ export * from "./haptics";
3
+ export * from "./sentry";
@@ -0,0 +1,3 @@
1
+ export * from "./animations";
2
+ export * from "./haptics";
3
+ export * from "./sentry";
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Sentry error tracking wrapper.
3
+ *
4
+ * Zero-impact when EXPO_PUBLIC_SENTRY_DSN is not set — no network requests,
5
+ * no global handlers, and no Sentry code in the entry bundle.
6
+ */
7
+ /**
8
+ * Initialize Sentry. Call once at app startup (module scope in _layout.tsx).
9
+ * No-op if sentryDsn is empty.
10
+ */
11
+ export declare function setupSentry(): void;
12
+ /**
13
+ * Capture an exception after the optional Sentry bundle has loaded.
14
+ * No-op when Sentry is disabled or failed to initialize.
15
+ */
16
+ export declare function captureException(error: unknown, context?: Parameters<typeof import("@sentry/react-native")["captureException"]>[1]): void;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Sentry error tracking wrapper.
3
+ *
4
+ * Zero-impact when EXPO_PUBLIC_SENTRY_DSN is not set — no network requests,
5
+ * no global handlers, and no Sentry code in the entry bundle.
6
+ */
7
+ let initialized = false;
8
+ let sentryModulePromise = null;
9
+ function loadSentry() {
10
+ const dsn = process.env.EXPO_PUBLIC_SENTRY_DSN;
11
+ if (!dsn) {
12
+ if (__DEV__) {
13
+ console.log("Sentry disabled — no EXPO_PUBLIC_SENTRY_DSN set");
14
+ }
15
+ return Promise.resolve(null);
16
+ }
17
+ sentryModulePromise ??= import("@sentry/react-native").then((Sentry) => {
18
+ Sentry.init({
19
+ dsn,
20
+ debug: __DEV__,
21
+ enabled: true,
22
+ environment: __DEV__ ? "development" : "production",
23
+ tracesSampleRate: __DEV__ ? 1.0 : 0.2,
24
+ });
25
+ return Sentry;
26
+ });
27
+ return sentryModulePromise;
28
+ }
29
+ /**
30
+ * Initialize Sentry. Call once at app startup (module scope in _layout.tsx).
31
+ * No-op if sentryDsn is empty.
32
+ */
33
+ export function setupSentry() {
34
+ if (initialized)
35
+ return;
36
+ initialized = true;
37
+ loadSentry().catch((error) => {
38
+ if (__DEV__) {
39
+ console.warn("Sentry failed to initialize:", error);
40
+ }
41
+ });
42
+ }
43
+ /**
44
+ * Capture an exception after the optional Sentry bundle has loaded.
45
+ * No-op when Sentry is disabled or failed to initialize.
46
+ */
47
+ export function captureException(error, context) {
48
+ loadSentry().then((Sentry) => {
49
+ Sentry?.captureException(error, context);
50
+ }).catch((loadError) => {
51
+ if (__DEV__) {
52
+ console.warn("Sentry capture skipped:", loadError);
53
+ }
54
+ });
55
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * globalUIStore
3
+ *
4
+ * Global UI state store for alerts, modals, and transient UI elements.
5
+ * Primarily used to trigger and dismiss the `Notification` component globally.
6
+ *
7
+ * Methods:
8
+ * - show({ type, title, messages, duration, loading }): displays a notification
9
+ * - hide(): hides the current notification
10
+ *
11
+ * Recommended: wrap in hooks or utility functions for cleaner usage across components.
12
+ */
13
+ type State = {
14
+ alert: {
15
+ show: boolean;
16
+ type: "error" | "success" | "info" | "warning";
17
+ messages?: string[];
18
+ title?: string;
19
+ duration?: number;
20
+ loading?: boolean;
21
+ /** Where to display the notification */
22
+ position?: "top" | "bottom";
23
+ } | null;
24
+ };
25
+ type Actions = {
26
+ show: (alert: Omit<NonNullable<State["alert"]>, "show">) => void;
27
+ hide: () => void;
28
+ };
29
+ export declare const globalUIStore: import("zustand").UseBoundStore<import("zustand").StoreApi<State & Actions>>;
30
+ export {};
@@ -0,0 +1,8 @@
1
+ import { create } from "zustand";
2
+ export const globalUIStore = create((set) => ({
3
+ alert: null,
4
+ show: (alert) => set({
5
+ alert: { ...alert, show: true }
6
+ }),
7
+ hide: () => set({ alert: null }),
8
+ }));
@@ -0,0 +1,2 @@
1
+ export * from "./globalUIStore";
2
+ export * from "./themeStore";
@@ -0,0 +1,2 @@
1
+ export * from "./globalUIStore";
2
+ export * from "./themeStore";
@@ -0,0 +1,6 @@
1
+ export type ThemeStore = {
2
+ userTheme: "system" | "light" | "dark";
3
+ setTheme: (theme: "system" | "light" | "dark") => void;
4
+ loadTheme: () => void;
5
+ };
6
+ export declare const useThemeStore: import("zustand").UseBoundStore<import("zustand").StoreApi<ThemeStore>>;
@@ -0,0 +1,38 @@
1
+ import { Platform } from "react-native";
2
+ import { create } from "zustand";
3
+ import AsyncStorage from "@react-native-async-storage/async-storage";
4
+ const THEME_KEY = "user-theme-preference";
5
+ export const useThemeStore = create((set) => ({
6
+ userTheme: "system",
7
+ setTheme: (theme) => {
8
+ set({ userTheme: theme });
9
+ // Save directly when setting theme
10
+ if (Platform.OS !== "web") {
11
+ AsyncStorage.setItem(THEME_KEY, theme).catch(() => {
12
+ // Silently fail if storage is not available
13
+ });
14
+ }
15
+ else if (typeof window !== "undefined" && window.localStorage) {
16
+ localStorage.setItem(THEME_KEY, theme);
17
+ }
18
+ },
19
+ loadTheme: () => {
20
+ if (Platform.OS !== "web") {
21
+ AsyncStorage.getItem(THEME_KEY).then((saved) => {
22
+ if (saved && (saved === "system" || saved === "light" || saved === "dark")) {
23
+ set({ userTheme: saved });
24
+ }
25
+ }).catch(() => {
26
+ // Use default if loading fails
27
+ });
28
+ }
29
+ else if (typeof window !== "undefined" && window.localStorage) {
30
+ const saved = localStorage.getItem(THEME_KEY);
31
+ if (saved && (saved === "system" || saved === "light" || saved === "dark")) {
32
+ set({ userTheme: saved });
33
+ }
34
+ }
35
+ }
36
+ }));
37
+ // Load saved theme on store creation
38
+ useThemeStore.getState().loadTheme();
package/package.json ADDED
@@ -0,0 +1,92 @@
1
+ {
2
+ "name": "@mrmeg/expo-ui",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "Reusable Expo and React Native UI primitives for MrMeg projects.",
6
+ "license": "UNLICENSED",
7
+ "type": "module",
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "main": "./dist/index.js",
12
+ "types": "./dist/index.d.ts",
13
+ "files": [
14
+ "dist",
15
+ "package.json",
16
+ "README.md"
17
+ ],
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "default": "./dist/index.js"
22
+ },
23
+ "./components": {
24
+ "types": "./dist/components/index.d.ts",
25
+ "default": "./dist/components/index.js"
26
+ },
27
+ "./components/*": {
28
+ "types": "./dist/components/*.d.ts",
29
+ "default": "./dist/components/*.js"
30
+ },
31
+ "./constants": {
32
+ "types": "./dist/constants/index.d.ts",
33
+ "default": "./dist/constants/index.js"
34
+ },
35
+ "./hooks": {
36
+ "types": "./dist/hooks/index.d.ts",
37
+ "default": "./dist/hooks/index.js"
38
+ },
39
+ "./state": {
40
+ "types": "./dist/state/index.d.ts",
41
+ "default": "./dist/state/index.js"
42
+ },
43
+ "./lib": {
44
+ "types": "./dist/lib/index.d.ts",
45
+ "default": "./dist/lib/index.js"
46
+ }
47
+ },
48
+ "scripts": {
49
+ "typecheck": "tsc --noEmit -p tsconfig.json",
50
+ "test": "jest --config ../../jest.config.js packages/ui/src --runInBand --watchman=false",
51
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
52
+ "publish:dry-run": "bun pm pack --dry-run"
53
+ },
54
+ "peerDependencies": {
55
+ "@expo/vector-icons": "*",
56
+ "@react-native-async-storage/async-storage": "*",
57
+ "@rn-primitives/accordion": "*",
58
+ "@rn-primitives/alert-dialog": "*",
59
+ "@rn-primitives/checkbox": "*",
60
+ "@rn-primitives/collapsible": "*",
61
+ "@rn-primitives/dialog": "*",
62
+ "@rn-primitives/dropdown-menu": "*",
63
+ "@rn-primitives/label": "*",
64
+ "@rn-primitives/popover": "*",
65
+ "@rn-primitives/portal": "*",
66
+ "@rn-primitives/radio-group": "*",
67
+ "@rn-primitives/select": "*",
68
+ "@rn-primitives/separator": "*",
69
+ "@rn-primitives/slot": "*",
70
+ "@rn-primitives/switch": "*",
71
+ "@rn-primitives/tabs": "*",
72
+ "@rn-primitives/toggle": "*",
73
+ "@rn-primitives/toggle-group": "*",
74
+ "@rn-primitives/tooltip": "*",
75
+ "@rn-primitives/types": "*",
76
+ "@sentry/react-native": "*",
77
+ "expo": "*",
78
+ "expo-font": "*",
79
+ "expo-haptics": "*",
80
+ "react": "*",
81
+ "react-native": "*",
82
+ "react-native-gesture-handler": "*",
83
+ "react-native-reanimated": "*",
84
+ "react-native-safe-area-context": "*",
85
+ "react-native-web": "*",
86
+ "zustand": "*"
87
+ },
88
+ "devDependencies": {
89
+ "@types/react": "~19.2.14",
90
+ "typescript": "~5.9.2"
91
+ }
92
+ }