@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,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
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const shouldUseNativeDriver: boolean;
|
|
@@ -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,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,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
|
+
}
|