@wrksz/themes 0.2.1 → 0.3.1

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
@@ -58,6 +58,7 @@ export function ThemeToggle() {
58
58
  | `themes` | `string[]` | `["light", "dark"]` | Available themes |
59
59
  | `defaultTheme` | `string` | `"system"` | Theme used when no preference is stored |
60
60
  | `forcedTheme` | `string` | - | Force a specific theme, ignoring user preference |
61
+ | `initialTheme` | `string` | - | Server-provided theme that overrides storage on mount. User can still call `setTheme` to change it |
61
62
  | `enableSystem` | `boolean` | `true` | Detect system preference via `prefers-color-scheme` |
62
63
  | `enableColorScheme` | `boolean` | `true` | Set native `color-scheme` CSS property |
63
64
  | `attribute` | `string \| string[]` | `"class"` | HTML attribute(s) to set on target element (`"class"`, `"data-theme"`, etc.) |
@@ -66,6 +67,7 @@ export function ThemeToggle() {
66
67
  | `storageKey` | `string` | `"theme"` | Key used for storage |
67
68
  | `storage` | `"localStorage" \| "sessionStorage" \| "none"` | `"localStorage"` | Where to persist the theme |
68
69
  | `disableTransitionOnChange` | `boolean` | `false` | Disable CSS transitions when switching themes |
70
+ | `followSystem` | `boolean` | `false` | Always follow system preference changes, even after `setTheme` was called |
69
71
  | `themeColor` | `string \| Record<string, string>` | - | Update `<meta name="theme-color">` on theme change |
70
72
  | `nonce` | `string` | - | CSP nonce for the inline script |
71
73
  | `onThemeChange` | `(theme: string) => void` | - | Called whenever the resolved theme changes |
@@ -131,16 +133,27 @@ const { theme, setTheme } = useTheme<AppTheme>();
131
133
  </ThemeProvider>
132
134
  ```
133
135
 
134
- ### Meta theme-color (Safari / PWA)
136
+ ### Multiple classes per theme
137
+
138
+ Map a theme to multiple CSS classes by using a space-separated value:
135
139
 
136
140
  ```tsx
137
141
  <ThemeProvider
138
- themeColor={{ light: "#ffffff", dark: "#0a0a0a" }}
142
+ themes={["light", "dark", "dim"]}
143
+ value={{ light: "light", dark: "dark high-contrast", dim: "dark dim" }}
139
144
  >
140
145
  {children}
141
146
  </ThemeProvider>
142
147
  ```
143
148
 
149
+ ### Meta theme-color (Safari / PWA)
150
+
151
+ ```tsx
152
+ <ThemeProvider themeColor={{ light: "#ffffff", dark: "#0a0a0a" }}>
153
+ {children}
154
+ </ThemeProvider>
155
+ ```
156
+
144
157
  Works with CSS variables too:
145
158
 
146
159
  ```tsx
@@ -167,6 +180,48 @@ Works with CSS variables too:
167
180
  </ThemeProvider>
168
181
  ```
169
182
 
183
+ ### Server-provided theme
184
+
185
+ Use `initialTheme` to initialize from a server-side source (database, session, cookie) on every mount, overriding any locally stored value. The user can still call `setTheme` to change it - use `onThemeChange` to persist the change back.
186
+
187
+ ```tsx
188
+ // app/layout.tsx (server component)
189
+ export default async function RootLayout({ children }) {
190
+ const userTheme = await getUserTheme(); // "light" | "dark" | null
191
+
192
+ return (
193
+ <html lang="en" suppressHydrationWarning>
194
+ <body>
195
+ <ThemeProvider
196
+ initialTheme={userTheme ?? undefined}
197
+ onThemeChange={saveUserTheme}
198
+ >
199
+ {children}
200
+ </ThemeProvider>
201
+ </body>
202
+ </html>
203
+ );
204
+ }
205
+ ```
206
+
207
+ ### Nested provider in a Client Component
208
+
209
+ `ThemeProvider` renders an inline `<script>` and must be used in a Server Component. For nested providers inside Client Components, use `ClientThemeProvider` instead:
210
+
211
+ ```tsx
212
+ "use client";
213
+
214
+ import { ClientThemeProvider } from "@wrksz/themes";
215
+
216
+ export function AdminShell({ children }: { children: React.ReactNode }) {
217
+ return (
218
+ <ClientThemeProvider forcedTheme="dark">
219
+ {children}
220
+ </ClientThemeProvider>
221
+ );
222
+ }
223
+ ```
224
+
170
225
  ### Class on body instead of html
171
226
 
172
227
  ```tsx
@@ -182,9 +237,12 @@ Works with CSS variables too:
182
237
  | React 19 script warning | Yes | Fixed (RSC split) |
183
238
  | `__name` minification bug | Yes | Fixed |
184
239
  | React 19 Activity/cacheComponents stale theme | Yes | Fixed (`useSyncExternalStore`) |
240
+ | Multiple classes per theme | No | Yes (`value` map with spaces) |
241
+ | Nested providers | No | Yes (per-instance store) |
185
242
  | `sessionStorage` support | No | Yes |
186
243
  | Disable storage | No | Yes (`storage: "none"`) |
187
244
  | `meta theme-color` support | No | Yes (`themeColor` prop) |
245
+ | Server-provided theme | No | Yes (`initialTheme` prop) |
188
246
  | Generic types | No | Yes (`useTheme<AppTheme>()`) |
189
247
  | Zero runtime dependencies | Yes | Yes |
190
248
 
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { ReactElement } from "react";
1
2
  import { ReactNode } from "react";
2
3
  type DefaultTheme = "light" | "dark" | "system";
3
4
  type Attribute = "class" | `data-${string}`;
@@ -35,6 +36,10 @@ type ThemeProviderProps<Themes extends string = DefaultTheme> = {
35
36
  onThemeChange?: (theme: Themes) => void;
36
37
  /** Colors for meta theme-color tag, per theme or a single value */
37
38
  themeColor?: ThemeColor;
39
+ /** Always follow system preference changes, even after setTheme was called */
40
+ followSystem?: boolean;
41
+ /** Server-provided theme that overrides storage on mount (e.g. from a database). User can still call setTheme to change it. */
42
+ initialTheme?: Themes | "system";
38
43
  };
39
44
  type ThemeContextValue<Themes extends string = DefaultTheme> = {
40
45
  /** Current theme (may be "system") */
@@ -50,7 +55,8 @@ type ThemeContextValue<Themes extends string = DefaultTheme> = {
50
55
  /** Set theme */
51
56
  setTheme: (theme: Themes | "system" | ((current: Themes | "system" | undefined) => Themes | "system")) => void;
52
57
  };
58
+ declare function ClientThemeProvider<Themes extends string = DefaultTheme>({ children, themes, forcedTheme, enableSystem, defaultTheme, attribute, value: valueMap, target, disableTransitionOnChange, storage, storageKey, enableColorScheme, themeColor, followSystem, onThemeChange, initialTheme }: ThemeProviderProps<Themes>): ReactElement;
53
59
  declare function useTheme<Themes extends string = DefaultTheme>(): ThemeContextValue<Themes>;
54
- 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;
56
- export { useTheme, ValueObject, ThemeProviderProps, ThemeProvider, ThemeContextValue, ThemeColor, StorageType, DefaultTheme, Attribute };
60
+ import { ReactElement as ReactElement2 } from "react";
61
+ declare function ThemeProvider<Themes extends string = DefaultTheme>({ children, themes, forcedTheme, enableSystem, defaultTheme, attribute, value: valueMap, target, disableTransitionOnChange, storage, storageKey, enableColorScheme, nonce, onThemeChange, themeColor, followSystem, initialTheme }: ThemeProviderProps<Themes>): ReactElement2;
62
+ export { useTheme, ValueObject, ThemeProviderProps, ThemeProvider, ThemeContextValue, ThemeColor, StorageType, DefaultTheme, ClientThemeProvider, Attribute };
package/dist/index.js CHANGED
@@ -1,4 +1,7 @@
1
1
  "use client";
2
+ // src/client-provider.tsx
3
+ import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
4
+
2
5
  // src/context.ts
3
6
  import { createContext, useContext } from "react";
4
7
  var ThemeContext = createContext(undefined);
@@ -9,43 +12,43 @@ function useTheme() {
9
12
  }
10
13
  return ctx;
11
14
  }
12
- // src/client-provider.tsx
13
- import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
14
15
 
15
16
  // 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
17
  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();
18
+ function createThemeStore() {
19
+ let state = { theme: undefined, systemTheme: undefined };
20
+ const listeners = new Set;
21
+ function emit() {
22
+ for (const listener of listeners)
23
+ listener();
47
24
  }
48
- };
25
+ return {
26
+ subscribe(listener) {
27
+ listeners.add(listener);
28
+ return () => {
29
+ listeners.delete(listener);
30
+ };
31
+ },
32
+ getSnapshot() {
33
+ return state;
34
+ },
35
+ getServerSnapshot() {
36
+ return SERVER_SNAPSHOT;
37
+ },
38
+ setTheme(theme) {
39
+ if (state.theme === theme)
40
+ return;
41
+ state = { ...state, theme };
42
+ emit();
43
+ },
44
+ setSystemTheme(systemTheme) {
45
+ if (state.systemTheme === systemTheme)
46
+ return;
47
+ state = { ...state, systemTheme };
48
+ emit();
49
+ }
50
+ };
51
+ }
49
52
 
50
53
  // src/client-provider.tsx
51
54
  import { jsxDEV } from "react/jsx-dev-runtime";
@@ -81,10 +84,15 @@ function ClientThemeProvider({
81
84
  storageKey = "theme",
82
85
  enableColorScheme = true,
83
86
  themeColor,
84
- onThemeChange
87
+ followSystem = false,
88
+ onThemeChange,
89
+ initialTheme
85
90
  }) {
86
91
  const resolvedDefault = defaultTheme ?? (enableSystem ? "system" : "light");
87
- const { theme, systemTheme } = useSyncExternalStore(themeStore.subscribe, themeStore.getSnapshot, themeStore.getServerSnapshot);
92
+ const storeRef = useRef(createThemeStore());
93
+ const store = storeRef.current;
94
+ const { getSnapshot, setTheme: setStoreTheme, setSystemTheme: setStoreSystemTheme } = store;
95
+ const { theme, systemTheme } = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getServerSnapshot);
88
96
  const resolvedTheme = forcedTheme ?? (theme === "system" || theme === undefined ? systemTheme : theme);
89
97
  const onThemeChangeRef = useRef(onThemeChange);
90
98
  useEffect(() => {
@@ -111,9 +119,9 @@ function ClientThemeProvider({
111
119
  }
112
120
  for (const attr of attrs) {
113
121
  if (attr === "class") {
114
- const toRemove = themes.map((t) => valueMap?.[t] ?? t);
122
+ const toRemove = themes.flatMap((t) => (valueMap?.[t] ?? t).split(" "));
115
123
  el.classList.remove(...toRemove);
116
- el.classList.add(attrValue);
124
+ el.classList.add(...attrValue.split(" "));
117
125
  } else {
118
126
  el.setAttribute(attr, attrValue);
119
127
  }
@@ -134,37 +142,78 @@ function ClientThemeProvider({
134
142
  themeColor
135
143
  ]);
136
144
  useEffect(() => {
145
+ const mq = enableSystem ? window.matchMedia("(prefers-color-scheme: dark)") : null;
146
+ const sys = mq ? mq.matches ? "dark" : "light" : undefined;
147
+ if (sys)
148
+ setStoreSystemTheme(sys);
137
149
  if (forcedTheme) {
138
- themeStore.setTheme(forcedTheme);
139
- return;
150
+ setStoreTheme(forcedTheme);
151
+ applyToDom(forcedTheme);
152
+ } else if (initialTheme) {
153
+ setStoreTheme(initialTheme);
154
+ applyToDom(initialTheme === "system" ? sys ?? "light" : initialTheme);
155
+ try {
156
+ if (storage !== "none") {
157
+ const s = storage === "localStorage" ? localStorage : sessionStorage;
158
+ s.setItem(storageKey, initialTheme);
159
+ }
160
+ } catch {}
161
+ } else {
162
+ let stored = null;
163
+ try {
164
+ if (storage !== "none") {
165
+ const s = storage === "localStorage" ? localStorage : sessionStorage;
166
+ stored = s.getItem(storageKey);
167
+ }
168
+ } catch {}
169
+ const initial = stored && themes.includes(stored) ? stored : resolvedDefault;
170
+ setStoreTheme(initial);
171
+ applyToDom(initial === "system" ? sys ?? "light" : initial);
140
172
  }
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)
173
+ if (!mq)
153
174
  return;
154
- const mq = window.matchMedia("(prefers-color-scheme: dark)");
155
- themeStore.setSystemTheme(mq.matches ? "dark" : "light");
156
175
  const handler = (e) => {
157
176
  const next = e.matches ? "dark" : "light";
158
- themeStore.setSystemTheme(next);
159
- const current = themeStore.getSnapshot().theme;
160
- if (current === "system" || current === undefined) {
177
+ setStoreSystemTheme(next);
178
+ const current = getSnapshot().theme;
179
+ if (current === "system" || current === undefined || followSystem) {
180
+ if (followSystem) {
181
+ setStoreTheme("system");
182
+ }
161
183
  applyToDom(next);
162
184
  onThemeChangeRef.current?.(next);
163
185
  }
164
186
  };
165
187
  mq.addEventListener("change", handler);
166
188
  return () => mq.removeEventListener("change", handler);
167
- }, [enableSystem, applyToDom]);
189
+ }, [
190
+ forcedTheme,
191
+ initialTheme,
192
+ resolvedDefault,
193
+ storage,
194
+ storageKey,
195
+ themes,
196
+ enableSystem,
197
+ followSystem,
198
+ applyToDom,
199
+ getSnapshot,
200
+ setStoreTheme,
201
+ setStoreSystemTheme
202
+ ]);
203
+ useEffect(() => {
204
+ const handler = () => {
205
+ const { theme: theme2, systemTheme: systemTheme2 } = getSnapshot();
206
+ const resolved = forcedTheme ?? (theme2 === "system" || theme2 === undefined ? systemTheme2 : theme2);
207
+ if (resolved)
208
+ applyToDom(resolved);
209
+ };
210
+ window.addEventListener("pageshow", handler);
211
+ window.addEventListener("popstate", handler);
212
+ return () => {
213
+ window.removeEventListener("pageshow", handler);
214
+ window.removeEventListener("popstate", handler);
215
+ };
216
+ }, [applyToDom, forcedTheme, getSnapshot]);
168
217
  useEffect(() => {
169
218
  if (storage === "none")
170
219
  return;
@@ -173,30 +222,30 @@ function ClientThemeProvider({
173
222
  return;
174
223
  if (themes.includes(e.newValue)) {
175
224
  const newTheme = e.newValue;
176
- const resolved = newTheme === "system" ? themeStore.getSnapshot().systemTheme ?? "light" : newTheme;
177
- themeStore.setTheme(newTheme);
225
+ const resolved = newTheme === "system" ? getSnapshot().systemTheme ?? "light" : newTheme;
226
+ setStoreTheme(newTheme);
178
227
  applyToDom(resolved);
179
228
  }
180
229
  };
181
230
  window.addEventListener("storage", handler);
182
231
  return () => window.removeEventListener("storage", handler);
183
- }, [storage, storageKey, themes, applyToDom]);
232
+ }, [storage, storageKey, themes, applyToDom, getSnapshot, setStoreTheme]);
184
233
  const setTheme = useCallback((next) => {
185
234
  if (forcedTheme)
186
235
  return;
187
- const current = themeStore.getSnapshot().theme;
236
+ const current = getSnapshot().theme;
188
237
  const newTheme = typeof next === "function" ? next(current) : next;
189
- const resolved = newTheme === "system" ? themeStore.getSnapshot().systemTheme ?? "light" : newTheme;
190
- themeStore.setTheme(newTheme);
238
+ const resolved = newTheme === "system" ? getSnapshot().systemTheme ?? "light" : newTheme;
239
+ setStoreTheme(newTheme);
191
240
  applyToDom(resolved);
192
241
  onThemeChangeRef.current?.(resolved);
193
242
  try {
194
243
  if (storage !== "none") {
195
- const store = storage === "localStorage" ? localStorage : sessionStorage;
196
- store.setItem(storageKey, newTheme);
244
+ const store2 = storage === "localStorage" ? localStorage : sessionStorage;
245
+ store2.setItem(storageKey, newTheme);
197
246
  }
198
247
  } catch {}
199
- }, [applyToDom, forcedTheme, storage, storageKey]);
248
+ }, [applyToDom, forcedTheme, storage, storageKey, getSnapshot, setStoreTheme]);
200
249
  const contextValue = {
201
250
  theme: forcedTheme ?? theme,
202
251
  resolvedTheme,
@@ -210,12 +259,13 @@ function ClientThemeProvider({
210
259
  children
211
260
  }, undefined, false, undefined, this);
212
261
  }
213
-
214
262
  // src/script.ts
215
- function themeScript(storageKey, attribute, defaultTheme, enableSystem, enableColorScheme, forcedTheme, themes, value, target, storage, themeColors) {
263
+ function themeScript(storageKey, attribute, defaultTheme, enableSystem, enableColorScheme, forcedTheme, themes, value, target, storage, themeColors, initialTheme) {
216
264
  let theme;
217
265
  if (forcedTheme) {
218
266
  theme = forcedTheme;
267
+ } else if (initialTheme && themes.includes(initialTheme)) {
268
+ theme = initialTheme;
219
269
  } else {
220
270
  let stored = null;
221
271
  try {
@@ -236,9 +286,9 @@ function themeScript(storageKey, attribute, defaultTheme, enableSystem, enableCo
236
286
  const attrs = Array.isArray(attribute) ? attribute : [attribute];
237
287
  for (const attr of attrs) {
238
288
  if (attr === "class") {
239
- const toRemove = themes.map((t) => value?.[t] || t);
289
+ const toRemove = themes.flatMap((t) => (value?.[t] || t).split(" "));
240
290
  el.classList.remove(...toRemove);
241
- el.classList.add(attrValue);
291
+ el.classList.add(...attrValue.split(" "));
242
292
  } else {
243
293
  el.setAttribute(attr, attrValue);
244
294
  }
@@ -272,7 +322,8 @@ function getScript(config) {
272
322
  JSON.stringify(config.value ?? null),
273
323
  JSON.stringify(config.target),
274
324
  JSON.stringify(config.storage),
275
- JSON.stringify(config.themeColors ?? null)
325
+ JSON.stringify(config.themeColors ?? null),
326
+ JSON.stringify(config.initialTheme ?? null)
276
327
  ].join(",");
277
328
  return `(${fn})(${args})`;
278
329
  }
@@ -295,7 +346,9 @@ function ThemeProvider({
295
346
  enableColorScheme = true,
296
347
  nonce,
297
348
  onThemeChange,
298
- themeColor
349
+ themeColor,
350
+ followSystem = false,
351
+ initialTheme
299
352
  }) {
300
353
  const resolvedDefault = defaultTheme ?? (enableSystem ? "system" : "light");
301
354
  return /* @__PURE__ */ jsxDEV2(Fragment, {
@@ -313,7 +366,8 @@ function ThemeProvider({
313
366
  value: valueMap,
314
367
  target,
315
368
  storage,
316
- themeColors: themeColor
369
+ themeColors: themeColor,
370
+ initialTheme
317
371
  })
318
372
  },
319
373
  nonce
@@ -331,7 +385,9 @@ function ThemeProvider({
331
385
  storageKey,
332
386
  enableColorScheme,
333
387
  themeColor,
388
+ followSystem,
334
389
  onThemeChange,
390
+ initialTheme,
335
391
  children
336
392
  }, undefined, false, undefined, this)
337
393
  ]
@@ -339,5 +395,6 @@ function ThemeProvider({
339
395
  }
340
396
  export {
341
397
  useTheme,
342
- ThemeProvider
398
+ ThemeProvider,
399
+ ClientThemeProvider
343
400
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrksz/themes",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
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",