@mrmeg/expo-ui 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LLM_USAGE.md CHANGED
@@ -119,10 +119,30 @@ Token intent:
119
119
  - `ring`: focus outline color
120
120
  - `popover`: elevated overlay surface
121
121
 
122
- Use `getShadowStyle()` for package surfaces that need elevation and
123
- `getFocusRingStyle()` for web focus styling. Keep web controls compact, but
124
- preserve mobile tap comfort with package controls that already provide native
125
- hit slop or 44px touch rows.
122
+ Use `getShadowStyle()` for package surfaces that need elevation. It supports
123
+ `base`, `soft`, `sharp`, `subtle`, `elevated`, `glow`, `glass`, `card`,
124
+ `cardHover`, and `cardSubtle`, returning native shadow/elevation off web and
125
+ CSS `boxShadow` on web. Use `getFocusRingStyle()` for web focus styling. Keep
126
+ web controls compact, but preserve mobile tap comfort with package controls
127
+ that already provide native hit slop or 44px touch rows.
128
+
129
+ Use `useStyles()` for memoized theme-aware local styles. Its factory receives
130
+ `{ theme, spacing, withAlpha }`, so components can derive alpha-adjusted
131
+ semantic colors without destructuring `withAlpha` outside the factory:
132
+
133
+ ```tsx
134
+ const { styles } = useStyles(({ theme, spacing, withAlpha }) => ({
135
+ card: {
136
+ backgroundColor: withAlpha(theme.colors.primary, 0.08),
137
+ padding: spacing.md,
138
+ },
139
+ }));
140
+ ```
141
+
142
+ When the saved theme preference is `system`, the package theme store owns the
143
+ OS color-scheme subscription, including web `prefers-color-scheme`. Do not add
144
+ app-local Appearance or `matchMedia` listeners for package components; import
145
+ `useTheme()`, `useStyles()`, and `useThemeStore` from `@mrmeg/expo-ui`.
126
146
 
127
147
  ## Component Use-Case Index
128
148
 
package/README.md CHANGED
@@ -95,7 +95,26 @@ const styles = StyleSheet.create({
95
95
  });
96
96
  ```
97
97
 
98
- `useTheme()` returns the active `theme`, resolved `scheme`, persisted `currentTheme`, `setTheme`, `toggleTheme`, cross-platform shadow helpers, a web focus-ring helper, contrast helpers, and `withAlpha`. Use semantic tokens such as `theme.colors.background`, `foreground`, `card`, `popover`, `border`, `input`, `ring`, `primary`, `secondary`, `accent`, `mutedForeground`, `destructive`, `success`, and `warning`. `primary` is the neutral action color, `secondary` is a neutral secondary surface, `accent` is the teal highlight color, `input` is the default form-control border, and `ring` is the focus outline color.
98
+ `useTheme()` returns the active `theme`, resolved `scheme`, persisted `currentTheme`, `setTheme`, `toggleTheme`, cross-platform shadow helpers, a web focus-ring helper, contrast helpers, and `withAlpha`. `getShadowStyle(type)` supports `base`, `soft`, `sharp`, `subtle`, `elevated`, `glow`, `glass`, `card`, `cardHover`, and `cardSubtle`. Use semantic tokens such as `theme.colors.background`, `foreground`, `card`, `popover`, `border`, `input`, `ring`, `primary`, `secondary`, `accent`, `mutedForeground`, `destructive`, `success`, and `warning`. `primary` is the neutral action color, `secondary` is a neutral secondary surface, `accent` is the teal highlight color, `input` is the default form-control border, and `ring` is the focus outline color.
99
+
100
+ When `currentTheme` is `"system"`, the package tracks the OS color scheme and
101
+ updates all `useTheme()` and `useStyles()` consumers through the package theme
102
+ store. Apps should not add their own Appearance or `matchMedia` listeners just
103
+ to make package components follow system light/dark changes.
104
+
105
+ Use `useStyles()` when a component needs memoized theme-aware styles. The style factory receives `{ theme, spacing, withAlpha }`, and the returned hook value also includes the normal `useTheme()` helpers.
106
+
107
+ ```tsx
108
+ import { useStyles } from "@mrmeg/expo-ui/hooks";
109
+
110
+ const { styles } = useStyles(({ theme, spacing, withAlpha }) => ({
111
+ card: {
112
+ backgroundColor: withAlpha(theme.colors.primary, 0.08),
113
+ borderRadius: spacing.radiusMd,
114
+ padding: spacing.md,
115
+ },
116
+ }));
117
+ ```
99
118
 
100
119
  Use `StyledText` for theme-aware text:
101
120
 
@@ -1,7 +1,7 @@
1
1
  import { Colors } from "../constants/colors";
2
2
  import { ViewStyle, StyleSheet } from "react-native";
3
3
  import { spacing as spacingConstants } from "../constants/spacing";
4
- type ShadowType = "base" | "soft" | "sharp" | "subtle";
4
+ type ShadowType = "base" | "soft" | "sharp" | "subtle" | "elevated" | "glow" | "glass" | "card" | "cardHover" | "cardSubtle";
5
5
  interface ExtendedColorScheme {
6
6
  theme: Colors["light" | "dark"];
7
7
  scheme: "light" | "dark";
@@ -45,6 +45,7 @@ export declare function useTheme(): ExtendedColorScheme & {
45
45
  interface StyleContext {
46
46
  theme: Colors["light" | "dark"];
47
47
  spacing: typeof spacingConstants;
48
+ withAlpha: (color: string, alpha: number) => string;
48
49
  }
49
50
  /**
50
51
  * Return type for useStyles hook
@@ -58,17 +59,17 @@ type UseStylesReturn<T extends StyleSheet.NamedStyles<T>> = {
58
59
  * useStyles
59
60
  *
60
61
  * A hook that combines useTheme with StyleSheet.create for theme-aware styling.
61
- * Provides access to theme colors and spacing constants within the style factory.
62
+ * Provides access to theme colors, spacing constants, and color helpers within the style factory.
62
63
  *
63
- * @param factory - A function that receives { theme, spacing } and returns style definitions
64
+ * @param factory - A function that receives { theme, spacing, withAlpha } and returns style definitions
64
65
  * @returns { styles, theme, spacing, ...themeUtilities }
65
66
  *
66
67
  * @example
67
68
  * ```tsx
68
69
  * function MyComponent() {
69
- * const { styles, theme } = useStyles(({ theme, spacing }) => ({
70
+ * const { styles, theme } = useStyles(({ theme, spacing, withAlpha }) => ({
70
71
  * container: {
71
- * backgroundColor: theme.colors.background,
72
+ * backgroundColor: withAlpha(theme.colors.primary, 0.08),
72
73
  * padding: spacing.md,
73
74
  * borderRadius: spacing.radiusMd,
74
75
  * },
@@ -1,7 +1,7 @@
1
1
  import { useCallback, useEffect, useMemo } from "react";
2
2
  import { colors } from "../constants/colors.js";
3
- import { useColorScheme as useColorSchemeDefault, Platform, StyleSheet } from "react-native";
4
- import { useThemeStore } from "../state/themeStore.js";
3
+ import { Platform, StyleSheet } from "react-native";
4
+ import { resolveThemePreference, useThemeStore } from "../state/themeStore.js";
5
5
  import { spacing as spacingConstants } from "../constants/spacing.js";
6
6
  // Module-level cache for contrast calculations to avoid memory leak
7
7
  // and share across components
@@ -45,14 +45,10 @@ function getCachedOrCompute(key, compute) {
45
45
  */
46
46
  export function useTheme() {
47
47
  const userTheme = useThemeStore((s) => s.userTheme);
48
+ const systemTheme = useThemeStore((s) => s.systemTheme);
48
49
  const setTheme = useThemeStore((s) => s.setTheme);
49
- let defaultScheme = useColorSchemeDefault();
50
- // Ensure a scheme is selected, even if we fail to get one
51
- if (!defaultScheme) {
52
- defaultScheme = "light";
53
- }
54
50
  // Determine which theme to use (user preference or system)
55
- const effectiveScheme = userTheme === "system" ? defaultScheme : userTheme;
51
+ const effectiveScheme = resolveThemePreference(userTheme, systemTheme);
56
52
  const theme = colors[effectiveScheme];
57
53
  // Sync theme to DOM so CSS in +html.tsx follows the app's runtime theme
58
54
  useEffect(() => {
@@ -109,6 +105,48 @@ export function useTheme() {
109
105
  shadowRadius: 2,
110
106
  elevation: 1,
111
107
  },
108
+ elevated: {
109
+ shadowColor: theme.colors.overlay,
110
+ shadowOffset: { width: 0, height: 20 },
111
+ shadowOpacity: 0.15,
112
+ shadowRadius: 40,
113
+ elevation: 16,
114
+ },
115
+ glow: {
116
+ shadowColor: theme.colors.primary,
117
+ shadowOffset: { width: 0, height: 4 },
118
+ shadowOpacity: 0.4,
119
+ shadowRadius: 20,
120
+ elevation: 10,
121
+ },
122
+ glass: {
123
+ shadowColor: theme.colors.overlay,
124
+ shadowOffset: { width: 0, height: 4 },
125
+ shadowOpacity: 0.05,
126
+ shadowRadius: 30,
127
+ elevation: 4,
128
+ },
129
+ card: {
130
+ shadowColor: theme.colors.overlay,
131
+ shadowOffset: { width: 0, height: 2 },
132
+ shadowOpacity: 0.08,
133
+ shadowRadius: 8,
134
+ elevation: 4,
135
+ },
136
+ cardHover: {
137
+ shadowColor: theme.colors.overlay,
138
+ shadowOffset: { width: 0, height: 8 },
139
+ shadowOpacity: 0.12,
140
+ shadowRadius: 24,
141
+ elevation: 8,
142
+ },
143
+ cardSubtle: {
144
+ shadowColor: theme.colors.overlay,
145
+ shadowOffset: { width: 0, height: 1 },
146
+ shadowOpacity: 0.08,
147
+ shadowRadius: 3,
148
+ elevation: 2,
149
+ },
112
150
  };
113
151
  const config = shadowConfigs[type];
114
152
  if (Platform.OS === "web") {
@@ -117,6 +155,12 @@ export function useTheme() {
117
155
  soft: { boxShadow: theme.dark ? "0 8px 24px rgba(0, 0, 0, 0.36)" : "0 8px 24px rgba(0, 0, 0, 0.10)" },
118
156
  sharp: { boxShadow: theme.dark ? "0 1px 1px rgba(0, 0, 0, 0.55)" : "0 1px 1px rgba(0, 0, 0, 0.12)" },
119
157
  subtle: { boxShadow: theme.dark ? "0 1px 2px rgba(0, 0, 0, 0.32)" : "0 1px 2px rgba(0, 0, 0, 0.05)" },
158
+ elevated: { boxShadow: theme.dark ? "0 20px 40px rgba(0, 0, 0, 0.38)" : "0 20px 40px rgba(0, 0, 0, 0.15)" },
159
+ glow: { boxShadow: `0 0 20px ${theme.colors.primary}` },
160
+ glass: { boxShadow: theme.dark ? "0 4px 30px rgba(0, 0, 0, 0.32)" : "0 4px 30px rgba(0, 0, 0, 0.05)" },
161
+ card: { boxShadow: theme.dark ? "0 1px 2px rgba(0, 0, 0, 0.32)" : "0 1px 3px rgba(0, 0, 0, 0.08)" },
162
+ cardHover: { boxShadow: theme.dark ? "0 8px 24px rgba(0, 0, 0, 0.36)" : "0 8px 24px rgba(0, 0, 0, 0.12)" },
163
+ cardSubtle: { boxShadow: theme.dark ? "0 1px 2px rgba(0, 0, 0, 0.32)" : "0 1px 3px rgba(0, 0, 0, 0.05)" },
120
164
  };
121
165
  return webShadows[type];
122
166
  }
@@ -304,17 +348,17 @@ function withAlpha(color, alpha) {
304
348
  * useStyles
305
349
  *
306
350
  * A hook that combines useTheme with StyleSheet.create for theme-aware styling.
307
- * Provides access to theme colors and spacing constants within the style factory.
351
+ * Provides access to theme colors, spacing constants, and color helpers within the style factory.
308
352
  *
309
- * @param factory - A function that receives { theme, spacing } and returns style definitions
353
+ * @param factory - A function that receives { theme, spacing, withAlpha } and returns style definitions
310
354
  * @returns { styles, theme, spacing, ...themeUtilities }
311
355
  *
312
356
  * @example
313
357
  * ```tsx
314
358
  * function MyComponent() {
315
- * const { styles, theme } = useStyles(({ theme, spacing }) => ({
359
+ * const { styles, theme } = useStyles(({ theme, spacing, withAlpha }) => ({
316
360
  * container: {
317
- * backgroundColor: theme.colors.background,
361
+ * backgroundColor: withAlpha(theme.colors.primary, 0.08),
318
362
  * padding: spacing.md,
319
363
  * borderRadius: spacing.radiusMd,
320
364
  * },
@@ -337,7 +381,8 @@ export function useStyles(factory) {
337
381
  const styles = useMemo(() => StyleSheet.create(factory({
338
382
  theme: themeContext.theme,
339
383
  spacing: spacingConstants,
340
- })), [factory, themeContext.theme]);
384
+ withAlpha: themeContext.withAlpha,
385
+ })), [factory, themeContext.theme, themeContext.withAlpha]);
341
386
  return useMemo(() => ({
342
387
  styles,
343
388
  theme: themeContext.theme,
@@ -1,6 +1,13 @@
1
+ export type ThemePreference = "system" | "light" | "dark";
2
+ export type ResolvedTheme = "light" | "dark";
1
3
  export type ThemeStore = {
2
- userTheme: "system" | "light" | "dark";
3
- setTheme: (theme: "system" | "light" | "dark") => void;
4
+ userTheme: ThemePreference;
5
+ systemTheme: ResolvedTheme;
6
+ setTheme: (theme: ThemePreference) => void;
7
+ setSystemTheme: (theme: ResolvedTheme) => void;
4
8
  loadTheme: () => void;
5
9
  };
10
+ export declare function resolveThemePreference(userTheme: ThemePreference, systemTheme: ResolvedTheme): ResolvedTheme;
6
11
  export declare const useThemeStore: import("zustand").UseBoundStore<import("zustand").StoreApi<ThemeStore>>;
12
+ export declare function syncSystemTheme(): void;
13
+ export declare function startSystemThemeListener(): () => void;
@@ -1,11 +1,24 @@
1
- import { Platform } from "react-native";
1
+ import { Appearance, Platform } from "react-native";
2
2
  import { create } from "zustand";
3
3
  import AsyncStorage from "@react-native-async-storage/async-storage";
4
4
  const THEME_KEY = "user-theme-preference";
5
+ export function resolveThemePreference(userTheme, systemTheme) {
6
+ return userTheme === "system" ? systemTheme : userTheme;
7
+ }
8
+ function getSystemTheme() {
9
+ if (Platform.OS === "web" && typeof window !== "undefined" && typeof window.matchMedia === "function") {
10
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
11
+ }
12
+ return Appearance.getColorScheme() === "dark" ? "dark" : "light";
13
+ }
5
14
  export const useThemeStore = create((set) => ({
6
15
  userTheme: "system",
16
+ systemTheme: getSystemTheme(),
7
17
  setTheme: (theme) => {
8
- set({ userTheme: theme });
18
+ set({
19
+ userTheme: theme,
20
+ ...(theme === "system" ? { systemTheme: getSystemTheme() } : {}),
21
+ });
9
22
  // Save directly when setting theme
10
23
  if (Platform.OS !== "web") {
11
24
  AsyncStorage.setItem(THEME_KEY, theme).catch(() => {
@@ -16,6 +29,9 @@ export const useThemeStore = create((set) => ({
16
29
  localStorage.setItem(THEME_KEY, theme);
17
30
  }
18
31
  },
32
+ setSystemTheme: (theme) => {
33
+ set({ systemTheme: theme });
34
+ },
19
35
  loadTheme: () => {
20
36
  if (Platform.OS !== "web") {
21
37
  AsyncStorage.getItem(THEME_KEY).then((saved) => {
@@ -34,5 +50,45 @@ export const useThemeStore = create((set) => ({
34
50
  }
35
51
  }
36
52
  }));
53
+ let stopSystemThemeListener = null;
54
+ export function syncSystemTheme() {
55
+ useThemeStore.getState().setSystemTheme(getSystemTheme());
56
+ }
57
+ export function startSystemThemeListener() {
58
+ if (stopSystemThemeListener) {
59
+ return stopSystemThemeListener;
60
+ }
61
+ syncSystemTheme();
62
+ if (Platform.OS === "web" && typeof window !== "undefined" && typeof window.matchMedia === "function") {
63
+ const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
64
+ const onChange = () => {
65
+ useThemeStore.getState().setSystemTheme(mediaQuery.matches ? "dark" : "light");
66
+ };
67
+ if (typeof mediaQuery.addEventListener === "function") {
68
+ mediaQuery.addEventListener("change", onChange);
69
+ stopSystemThemeListener = () => {
70
+ mediaQuery.removeEventListener("change", onChange);
71
+ stopSystemThemeListener = null;
72
+ };
73
+ }
74
+ else {
75
+ mediaQuery.addListener(onChange);
76
+ stopSystemThemeListener = () => {
77
+ mediaQuery.removeListener(onChange);
78
+ stopSystemThemeListener = null;
79
+ };
80
+ }
81
+ return stopSystemThemeListener;
82
+ }
83
+ const subscription = Appearance.addChangeListener(({ colorScheme }) => {
84
+ useThemeStore.getState().setSystemTheme(colorScheme === "dark" ? "dark" : "light");
85
+ });
86
+ stopSystemThemeListener = () => {
87
+ subscription.remove();
88
+ stopSystemThemeListener = null;
89
+ };
90
+ return stopSystemThemeListener;
91
+ }
37
92
  // Load saved theme on store creation
38
93
  useThemeStore.getState().loadTheme();
94
+ startSystemThemeListener();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrmeg/expo-ui",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "private": false,
5
5
  "description": "Reusable Expo and React Native UI primitives for MrMeg projects.",
6
6
  "keywords": [