@wrksz/themes 0.2.1 → 0.3.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 CHANGED
@@ -66,6 +66,7 @@ export function ThemeToggle() {
66
66
  | `storageKey` | `string` | `"theme"` | Key used for storage |
67
67
  | `storage` | `"localStorage" \| "sessionStorage" \| "none"` | `"localStorage"` | Where to persist the theme |
68
68
  | `disableTransitionOnChange` | `boolean` | `false` | Disable CSS transitions when switching themes |
69
+ | `followSystem` | `boolean` | `false` | Always follow system preference changes, even after `setTheme` was called |
69
70
  | `themeColor` | `string \| Record<string, string>` | - | Update `<meta name="theme-color">` on theme change |
70
71
  | `nonce` | `string` | - | CSP nonce for the inline script |
71
72
  | `onThemeChange` | `(theme: string) => void` | - | Called whenever the resolved theme changes |
package/dist/index.d.ts CHANGED
@@ -35,6 +35,8 @@ type ThemeProviderProps<Themes extends string = DefaultTheme> = {
35
35
  onThemeChange?: (theme: Themes) => void;
36
36
  /** Colors for meta theme-color tag, per theme or a single value */
37
37
  themeColor?: ThemeColor;
38
+ /** Always follow system preference changes, even after setTheme was called */
39
+ followSystem?: boolean;
38
40
  };
39
41
  type ThemeContextValue<Themes extends string = DefaultTheme> = {
40
42
  /** Current theme (may be "system") */
@@ -52,5 +54,5 @@ type ThemeContextValue<Themes extends string = DefaultTheme> = {
52
54
  };
53
55
  declare function useTheme<Themes extends string = DefaultTheme>(): ThemeContextValue<Themes>;
54
56
  import { ReactElement } from "react";
55
- declare function ThemeProvider<Themes extends string = DefaultTheme>({ children, themes, forcedTheme, enableSystem, defaultTheme, attribute, value: valueMap, target, disableTransitionOnChange, storage, storageKey, enableColorScheme, nonce, onThemeChange, themeColor }: ThemeProviderProps<Themes>): ReactElement;
57
+ declare function ThemeProvider<Themes extends string = DefaultTheme>({ children, themes, forcedTheme, enableSystem, defaultTheme, attribute, value: valueMap, target, disableTransitionOnChange, storage, storageKey, enableColorScheme, nonce, onThemeChange, themeColor, followSystem }: ThemeProviderProps<Themes>): ReactElement;
56
58
  export { useTheme, ValueObject, ThemeProviderProps, ThemeProvider, ThemeContextValue, ThemeColor, StorageType, DefaultTheme, Attribute };
package/dist/index.js CHANGED
@@ -13,39 +13,41 @@ function useTheme() {
13
13
  import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
14
14
 
15
15
  // src/store.ts
16
- var state = { theme: undefined, systemTheme: undefined };
17
- var listeners = new Set;
18
- function emit() {
19
- for (const listener of listeners)
20
- listener();
21
- }
22
16
  var SERVER_SNAPSHOT = { theme: undefined, systemTheme: undefined };
23
- var themeStore = {
24
- subscribe(listener) {
25
- listeners.add(listener);
26
- return () => {
27
- listeners.delete(listener);
28
- };
29
- },
30
- getSnapshot() {
31
- return state;
32
- },
33
- getServerSnapshot() {
34
- return SERVER_SNAPSHOT;
35
- },
36
- setTheme(theme) {
37
- if (state.theme === theme)
38
- return;
39
- state = { ...state, theme };
40
- emit();
41
- },
42
- setSystemTheme(systemTheme) {
43
- if (state.systemTheme === systemTheme)
44
- return;
45
- state = { ...state, systemTheme };
46
- emit();
17
+ function createThemeStore() {
18
+ let state = { theme: undefined, systemTheme: undefined };
19
+ const listeners = new Set;
20
+ function emit() {
21
+ for (const listener of listeners)
22
+ listener();
47
23
  }
48
- };
24
+ return {
25
+ subscribe(listener) {
26
+ listeners.add(listener);
27
+ return () => {
28
+ listeners.delete(listener);
29
+ };
30
+ },
31
+ getSnapshot() {
32
+ return state;
33
+ },
34
+ getServerSnapshot() {
35
+ return SERVER_SNAPSHOT;
36
+ },
37
+ setTheme(theme) {
38
+ if (state.theme === theme)
39
+ return;
40
+ state = { ...state, theme };
41
+ emit();
42
+ },
43
+ setSystemTheme(systemTheme) {
44
+ if (state.systemTheme === systemTheme)
45
+ return;
46
+ state = { ...state, systemTheme };
47
+ emit();
48
+ }
49
+ };
50
+ }
49
51
 
50
52
  // src/client-provider.tsx
51
53
  import { jsxDEV } from "react/jsx-dev-runtime";
@@ -81,10 +83,14 @@ function ClientThemeProvider({
81
83
  storageKey = "theme",
82
84
  enableColorScheme = true,
83
85
  themeColor,
86
+ followSystem = false,
84
87
  onThemeChange
85
88
  }) {
86
89
  const resolvedDefault = defaultTheme ?? (enableSystem ? "system" : "light");
87
- const { theme, systemTheme } = useSyncExternalStore(themeStore.subscribe, themeStore.getSnapshot, themeStore.getServerSnapshot);
90
+ const storeRef = useRef(createThemeStore());
91
+ const store = storeRef.current;
92
+ const { getSnapshot, setTheme: setStoreTheme, setSystemTheme: setStoreSystemTheme } = store;
93
+ const { theme, systemTheme } = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getServerSnapshot);
88
94
  const resolvedTheme = forcedTheme ?? (theme === "system" || theme === undefined ? systemTheme : theme);
89
95
  const onThemeChangeRef = useRef(onThemeChange);
90
96
  useEffect(() => {
@@ -134,37 +140,68 @@ function ClientThemeProvider({
134
140
  themeColor
135
141
  ]);
136
142
  useEffect(() => {
143
+ const mq = enableSystem ? window.matchMedia("(prefers-color-scheme: dark)") : null;
144
+ const sys = mq ? mq.matches ? "dark" : "light" : undefined;
145
+ if (sys)
146
+ setStoreSystemTheme(sys);
137
147
  if (forcedTheme) {
138
- themeStore.setTheme(forcedTheme);
139
- return;
148
+ setStoreTheme(forcedTheme);
149
+ applyToDom(forcedTheme);
150
+ } else {
151
+ let stored = null;
152
+ try {
153
+ if (storage !== "none") {
154
+ const s = storage === "localStorage" ? localStorage : sessionStorage;
155
+ stored = s.getItem(storageKey);
156
+ }
157
+ } catch {}
158
+ const initial = stored && themes.includes(stored) ? stored : resolvedDefault;
159
+ setStoreTheme(initial);
160
+ applyToDom(initial === "system" ? sys ?? "light" : initial);
140
161
  }
141
- let stored = null;
142
- try {
143
- if (storage !== "none") {
144
- const store = storage === "localStorage" ? localStorage : sessionStorage;
145
- stored = store.getItem(storageKey);
146
- }
147
- } catch {}
148
- const initial = stored && themes.includes(stored) ? stored : resolvedDefault;
149
- themeStore.setTheme(initial);
150
- }, [forcedTheme, resolvedDefault, storage, storageKey, themes]);
151
- useEffect(() => {
152
- if (!enableSystem)
162
+ if (!mq)
153
163
  return;
154
- const mq = window.matchMedia("(prefers-color-scheme: dark)");
155
- themeStore.setSystemTheme(mq.matches ? "dark" : "light");
156
164
  const handler = (e) => {
157
165
  const next = e.matches ? "dark" : "light";
158
- themeStore.setSystemTheme(next);
159
- const current = themeStore.getSnapshot().theme;
160
- if (current === "system" || current === undefined) {
166
+ setStoreSystemTheme(next);
167
+ const current = getSnapshot().theme;
168
+ if (current === "system" || current === undefined || followSystem) {
169
+ if (followSystem) {
170
+ setStoreTheme("system");
171
+ }
161
172
  applyToDom(next);
162
173
  onThemeChangeRef.current?.(next);
163
174
  }
164
175
  };
165
176
  mq.addEventListener("change", handler);
166
177
  return () => mq.removeEventListener("change", handler);
167
- }, [enableSystem, applyToDom]);
178
+ }, [
179
+ forcedTheme,
180
+ resolvedDefault,
181
+ storage,
182
+ storageKey,
183
+ themes,
184
+ enableSystem,
185
+ followSystem,
186
+ applyToDom,
187
+ getSnapshot,
188
+ setStoreTheme,
189
+ setStoreSystemTheme
190
+ ]);
191
+ useEffect(() => {
192
+ const handler = () => {
193
+ const { theme: theme2, systemTheme: systemTheme2 } = getSnapshot();
194
+ const resolved = forcedTheme ?? (theme2 === "system" || theme2 === undefined ? systemTheme2 : theme2);
195
+ if (resolved)
196
+ applyToDom(resolved);
197
+ };
198
+ window.addEventListener("pageshow", handler);
199
+ window.addEventListener("popstate", handler);
200
+ return () => {
201
+ window.removeEventListener("pageshow", handler);
202
+ window.removeEventListener("popstate", handler);
203
+ };
204
+ }, [applyToDom, forcedTheme, getSnapshot]);
168
205
  useEffect(() => {
169
206
  if (storage === "none")
170
207
  return;
@@ -173,30 +210,30 @@ function ClientThemeProvider({
173
210
  return;
174
211
  if (themes.includes(e.newValue)) {
175
212
  const newTheme = e.newValue;
176
- const resolved = newTheme === "system" ? themeStore.getSnapshot().systemTheme ?? "light" : newTheme;
177
- themeStore.setTheme(newTheme);
213
+ const resolved = newTheme === "system" ? getSnapshot().systemTheme ?? "light" : newTheme;
214
+ setStoreTheme(newTheme);
178
215
  applyToDom(resolved);
179
216
  }
180
217
  };
181
218
  window.addEventListener("storage", handler);
182
219
  return () => window.removeEventListener("storage", handler);
183
- }, [storage, storageKey, themes, applyToDom]);
220
+ }, [storage, storageKey, themes, applyToDom, getSnapshot, setStoreTheme]);
184
221
  const setTheme = useCallback((next) => {
185
222
  if (forcedTheme)
186
223
  return;
187
- const current = themeStore.getSnapshot().theme;
224
+ const current = getSnapshot().theme;
188
225
  const newTheme = typeof next === "function" ? next(current) : next;
189
- const resolved = newTheme === "system" ? themeStore.getSnapshot().systemTheme ?? "light" : newTheme;
190
- themeStore.setTheme(newTheme);
226
+ const resolved = newTheme === "system" ? getSnapshot().systemTheme ?? "light" : newTheme;
227
+ setStoreTheme(newTheme);
191
228
  applyToDom(resolved);
192
229
  onThemeChangeRef.current?.(resolved);
193
230
  try {
194
231
  if (storage !== "none") {
195
- const store = storage === "localStorage" ? localStorage : sessionStorage;
196
- store.setItem(storageKey, newTheme);
232
+ const store2 = storage === "localStorage" ? localStorage : sessionStorage;
233
+ store2.setItem(storageKey, newTheme);
197
234
  }
198
235
  } catch {}
199
- }, [applyToDom, forcedTheme, storage, storageKey]);
236
+ }, [applyToDom, forcedTheme, storage, storageKey, getSnapshot, setStoreTheme]);
200
237
  const contextValue = {
201
238
  theme: forcedTheme ?? theme,
202
239
  resolvedTheme,
@@ -295,7 +332,8 @@ function ThemeProvider({
295
332
  enableColorScheme = true,
296
333
  nonce,
297
334
  onThemeChange,
298
- themeColor
335
+ themeColor,
336
+ followSystem = false
299
337
  }) {
300
338
  const resolvedDefault = defaultTheme ?? (enableSystem ? "system" : "light");
301
339
  return /* @__PURE__ */ jsxDEV2(Fragment, {
@@ -331,6 +369,7 @@ function ThemeProvider({
331
369
  storageKey,
332
370
  enableColorScheme,
333
371
  themeColor,
372
+ followSystem,
334
373
  onThemeChange,
335
374
  children
336
375
  }, undefined, false, undefined, this)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrksz/themes",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "A modern, fully-featured theme management library for Next.js",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,6 +17,7 @@
17
17
  "lint": "biome check src",
18
18
  "lint:fix": "biome check --write src",
19
19
  "format": "biome format --write src",
20
+ "test": "bun test",
20
21
  "prepare": "[ \"$CI\" = \"true\" ] || lefthook install"
21
22
  },
22
23
  "devDependencies": {
@@ -25,6 +26,7 @@
25
26
  "@types/react": "^19.2.14",
26
27
  "@types/react-dom": "^19.2.3",
27
28
  "bunup": "^0.16.31",
29
+ "happy-dom": "^20.8.4",
28
30
  "lefthook": "^2.1.4",
29
31
  "react": "^19.2.4",
30
32
  "react-dom": "^19.2.4",