@mrmeg/expo-ui 0.1.7 → 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
@@ -139,6 +139,11 @@ const { styles } = useStyles(({ theme, spacing, withAlpha }) => ({
139
139
  }));
140
140
  ```
141
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`.
146
+
142
147
  ## Component Use-Case Index
143
148
 
144
149
  Use this table before creating a new app-local primitive.
package/README.md CHANGED
@@ -97,6 +97,11 @@ const styles = StyleSheet.create({
97
97
 
98
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
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
+
100
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.
101
106
 
102
107
  ```tsx
@@ -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(() => {
@@ -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.7",
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": [