@codenhub/theme 0.0.2 → 0.0.3

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.
Files changed (5) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +171 -62
  3. package/dist/index.d.ts +116 -42
  4. package/dist/index.js +397 -112
  5. package/package.json +10 -3
package/dist/index.js CHANGED
@@ -1,198 +1,483 @@
1
- //#region src/index.ts
1
+ //#region src/constants.ts
2
2
  /** Window event name dispatched with `ThemeChangeDetail` after a theme change is applied in browser environments. */
3
3
  const THEME_CHANGE_EVENT = "themechange";
4
+ /**
5
+ * Default `localStorage` key used to store the user's explicit theme preference.
6
+ *
7
+ * @internal
8
+ */
4
9
  const DEFAULT_STORAGE_KEY = "app-theme-preference";
10
+ /**
11
+ * Default HTML attribute set on `document.documentElement` to reflect the active theme.
12
+ *
13
+ * @internal
14
+ */
5
15
  const DEFAULT_ATTRIBUTE = "data-theme";
16
+ /**
17
+ * Default CSS class name applied to `document.documentElement` when a dark color scheme theme is active.
18
+ *
19
+ * @internal
20
+ */
6
21
  const DARK_CLASS = "dark";
22
+ /**
23
+ * Media query used to detect if the user's OS preference is set to a dark color scheme.
24
+ *
25
+ * @internal
26
+ */
7
27
  const PREFERS_DARK_QUERY = "(prefers-color-scheme: dark)";
28
+ /**
29
+ * Regular expression used to match and validate whitespace characters in CSS class names.
30
+ *
31
+ * @internal
32
+ */
8
33
  const CLASS_TOKEN_WHITESPACE = /\s/;
9
34
  /** Built-in light theme used by default and available for custom theme lists. */
10
- const lightTheme = {
35
+ const LIGHT_THEME = Object.freeze({
11
36
  name: "light",
12
37
  colorScheme: "light"
13
- };
38
+ });
14
39
  /** Built-in dark theme used by default and available for custom theme lists. */
15
- const darkTheme = {
40
+ const DARK_THEME = Object.freeze({
16
41
  name: "dark",
17
42
  colorScheme: "dark"
18
- };
19
- const defaultOptions = {
20
- themes: [lightTheme, darkTheme],
21
- defaultTheme: lightTheme.name,
22
- systemTheme: {
23
- light: lightTheme.name,
24
- dark: darkTheme.name
25
- },
43
+ });
44
+ /**
45
+ * Default resolved options used to initialize theme management when custom options are not provided.
46
+ *
47
+ * @internal
48
+ */
49
+ const DEFAULT_OPTIONS = Object.freeze({
50
+ themes: Object.freeze([LIGHT_THEME, DARK_THEME]),
51
+ defaultTheme: LIGHT_THEME.name,
52
+ systemTheme: Object.freeze({
53
+ light: LIGHT_THEME.name,
54
+ dark: DARK_THEME.name
55
+ }),
26
56
  storageKey: DEFAULT_STORAGE_KEY,
27
57
  attribute: DEFAULT_ATTRIBUTE,
28
- tailwindcss: false,
29
- applyClass: true
30
- };
31
- const isBrowser = () => {
32
- return typeof window !== "undefined" && typeof document !== "undefined";
33
- };
34
- const getThemeClass = (theme, applyClass) => {
35
- if (applyClass === false) return null;
36
- if (typeof applyClass === "function") {
37
- const className = applyClass(theme);
58
+ isTailwindCss: false,
59
+ shouldApplyClass: true
60
+ });
61
+ //#endregion
62
+ //#region src/class-resolver.ts
63
+ /**
64
+ * Resolves the single DOM class token applied for a theme.
65
+ *
66
+ * @returns The class string, or `null` when class application is disabled.
67
+ * @internal
68
+ */
69
+ const getThemeClass = (theme, shouldApplyClass) => {
70
+ if (shouldApplyClass === false) return null;
71
+ if (typeof shouldApplyClass === "function") {
72
+ const className = shouldApplyClass(theme);
38
73
  assertClassToken(className, `Theme class resolver returned an invalid class for theme: ${theme.name}.`);
39
74
  return className;
40
75
  }
41
- return `theme-${theme.name}`;
76
+ const className = `theme-${theme.name}`;
77
+ assertClassToken(className, `Theme name cannot be used as a default theme class: ${theme.name}.`);
78
+ return className;
42
79
  };
80
+ /**
81
+ * Asserts that a value is a valid single DOM class token (non-empty string, no whitespace).
82
+ *
83
+ * @internal
84
+ */
43
85
  const assertClassToken = (className, message) => {
44
- if (className.length === 0 || CLASS_TOKEN_WHITESPACE.test(className)) throw new Error(message);
86
+ if (typeof className !== "string" || className.length === 0 || CLASS_TOKEN_WHITESPACE.test(className)) throw new Error(message);
87
+ };
88
+ //#endregion
89
+ //#region src/dom.ts
90
+ /**
91
+ * Applies the active theme configuration, class/attribute updates, and CSS Custom Properties to the DOM.
92
+ *
93
+ * @param resolvedClasses - Pre-computed class strings for all configured themes. Callers must supply
94
+ * this value; computing it on each activation is the caller's responsibility (see `ThemeImpl`).
95
+ * @param nextClass - Pre-computed class string for the active theme, or `null` when class application is disabled.
96
+ * @internal
97
+ */
98
+ const applyTheme = (args) => {
99
+ const { theme, options, activeTokens, resolvedClasses, nextClass } = args;
100
+ if (typeof document === "undefined") return;
101
+ const root = document.documentElement;
102
+ root.setAttribute(options.attribute, theme.name);
103
+ root.style.colorScheme = theme.colorScheme;
104
+ for (const configuredClass of resolvedClasses) if (configuredClass !== nextClass) root.classList.remove(configuredClass);
105
+ if (nextClass !== null) root.classList.add(nextClass);
106
+ if (options.isTailwindCss) root.classList.toggle(DARK_CLASS, theme.colorScheme === "dark");
107
+ if (options.tokenSchema) {
108
+ const schema = options.tokenSchema;
109
+ const mergedTokens = {
110
+ ...theme.tokens,
111
+ ...activeTokens
112
+ };
113
+ for (const key of Object.keys(schema)) {
114
+ const cssVarName = schema[key];
115
+ const tokenValue = mergedTokens[key];
116
+ if (tokenValue !== void 0 && tokenValue !== null) root.style.setProperty(cssVarName, tokenValue);
117
+ else root.style.removeProperty(cssVarName);
118
+ }
119
+ }
120
+ };
121
+ /**
122
+ * Resolves any missing CSS variables from the computed style of the root DOM element.
123
+ *
124
+ * @internal
125
+ */
126
+ const readComputedTokens = (args) => {
127
+ const { theme, options, activeTokens } = args;
128
+ const computedTokens = {};
129
+ if (typeof window === "undefined" || typeof window.getComputedStyle !== "function" || typeof document === "undefined" || !options.tokenSchema) return computedTokens;
130
+ const root = document.documentElement;
131
+ try {
132
+ const schema = options.tokenSchema;
133
+ const mergedTokens = {
134
+ ...theme.tokens,
135
+ ...activeTokens
136
+ };
137
+ if (!Object.keys(schema).some((key) => mergedTokens[key] === void 0)) return computedTokens;
138
+ const style = window.getComputedStyle(root);
139
+ for (const key of Object.keys(schema)) if (mergedTokens[key] === void 0) {
140
+ const val = style.getPropertyValue(schema[key]).trim();
141
+ if (val) computedTokens[key] = val;
142
+ }
143
+ } catch (error) {
144
+ console.error("[theme] Failed to read computed token styles:", error);
145
+ }
146
+ return computedTokens;
147
+ };
148
+ /**
149
+ * Dispatches a custom `themechange` event on the window.
150
+ *
151
+ * @internal
152
+ */
153
+ const emitThemeEvent = (detail) => {
154
+ if (typeof window === "undefined" || typeof window.dispatchEvent !== "function" || typeof window.CustomEvent !== "function") return;
155
+ window.dispatchEvent(new CustomEvent(THEME_CHANGE_EVENT, { detail }));
156
+ };
157
+ //#endregion
158
+ //#region src/storage.ts
159
+ /**
160
+ * Reads and validates the user's stored theme preference from `localStorage`.
161
+ * Returns the theme name if it exists and matches a configured theme; otherwise `null`.
162
+ *
163
+ * @internal
164
+ */
165
+ const readStorage = (storageKey, themes) => {
166
+ if (typeof window === "undefined") return null;
167
+ try {
168
+ const storedName = window.localStorage.getItem(storageKey);
169
+ if (storedName === null) return null;
170
+ return themes.some((t) => t.name === storedName) ? storedName : null;
171
+ } catch (error) {
172
+ console.error("[theme] Failed to read from localStorage:", error);
173
+ return null;
174
+ }
175
+ };
176
+ /**
177
+ * Writes the explicit theme preference to `localStorage`.
178
+ *
179
+ * @internal
180
+ */
181
+ const writeStorage = (storageKey, themeName) => {
182
+ if (typeof window === "undefined") return;
183
+ try {
184
+ window.localStorage.setItem(storageKey, themeName);
185
+ } catch (error) {
186
+ console.error("[theme] Failed to write to localStorage:", error);
187
+ }
188
+ };
189
+ /**
190
+ * Removes the explicit theme preference from `localStorage`.
191
+ *
192
+ * @internal
193
+ */
194
+ const removeStorage = (storageKey) => {
195
+ if (typeof window === "undefined") return;
196
+ try {
197
+ window.localStorage.removeItem(storageKey);
198
+ } catch (error) {
199
+ console.error("[theme] Failed to remove from localStorage:", error);
200
+ }
201
+ };
202
+ //#endregion
203
+ //#region src/system.ts
204
+ /**
205
+ * Resolves the configured theme that matches the active OS color-scheme preference.
206
+ *
207
+ * @internal
208
+ */
209
+ const readSystemTheme = (options) => {
210
+ const { defaultTheme, systemTheme, themes } = options;
211
+ const getTheme = (name) => {
212
+ const theme = themes.find((candidate) => candidate.name === name);
213
+ if (theme === void 0)
214
+ // c8 ignore next
215
+ throw new Error(`Theme is not configured: ${name}.`);
216
+ return theme;
217
+ };
218
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return getTheme(defaultTheme);
219
+ try {
220
+ return getTheme(window.matchMedia("(prefers-color-scheme: dark)").matches ? systemTheme.dark : systemTheme.light);
221
+ } catch {
222
+ return getTheme(defaultTheme);
223
+ }
45
224
  };
225
+ /**
226
+ * Registers a media query listener for system color-scheme changes and returns a cleanup function.
227
+ *
228
+ * @internal
229
+ */
230
+ const registerSystemListener = (handler) => {
231
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return () => {};
232
+ const mql = (() => {
233
+ try {
234
+ return window.matchMedia(PREFERS_DARK_QUERY);
235
+ } catch {
236
+ return null;
237
+ }
238
+ })();
239
+ if (mql) try {
240
+ mql.addEventListener("change", handler);
241
+ } catch {}
242
+ return () => {
243
+ if (mql === null) return;
244
+ try {
245
+ mql.removeEventListener("change", handler);
246
+ } catch {}
247
+ };
248
+ };
249
+ /**
250
+ * Registers a listener for local storage changes across tabs and returns a cleanup function.
251
+ *
252
+ * @internal
253
+ */
254
+ const registerStorageListener = (handler) => {
255
+ if (typeof window === "undefined" || typeof window.addEventListener !== "function") return () => {};
256
+ window.addEventListener("storage", handler);
257
+ return () => {
258
+ window.removeEventListener("storage", handler);
259
+ };
260
+ };
261
+ //#endregion
262
+ //#region src/validation.ts
263
+ /**
264
+ * Validates the resolved theme configuration, throwing descriptive errors on misconfiguration.
265
+ *
266
+ * @internal
267
+ */
46
268
  const assertThemeConfig = (options) => {
269
+ if (typeof options.attribute !== "string" || options.attribute.trim().length === 0) throw new Error("Theme attribute option must be a non-empty string.");
270
+ if (/[\s"'/>=]/.test(options.attribute)) throw new Error("Theme attribute option must be a valid HTML attribute name.");
271
+ if (typeof options.storageKey !== "string" || options.storageKey.trim().length === 0) throw new Error("Theme storageKey option must be a non-empty string.");
272
+ if (!Array.isArray(options.themes)) throw new Error("Theme options.themes must be an array.");
47
273
  const names = /* @__PURE__ */ new Set();
274
+ if (options.tokenSchema) {
275
+ for (const [key, value] of Object.entries(options.tokenSchema)) if (typeof value !== "string" || !value.startsWith("--")) throw new Error(`Token schema key "${key}" must map to a CSS custom property starting with "--". Received: "${value}".`);
276
+ }
48
277
  for (const theme of options.themes) {
49
- if (theme.name.trim().length === 0) throw new Error("Theme names must be non-empty.");
278
+ if (typeof theme !== "object" || theme === null) throw new Error("Theme definitions must be objects.");
279
+ if (typeof theme.name !== "string" || theme.name.trim().length === 0) throw new Error("Theme names must be non-empty strings.");
280
+ if (theme.colorScheme !== "light" && theme.colorScheme !== "dark") throw new Error(`Theme "${theme.name}" has an invalid colorScheme: ${theme.colorScheme}. Must be "light" or "dark".`);
50
281
  if (names.has(theme.name)) throw new Error(`Duplicate theme name: ${theme.name}.`);
51
282
  names.add(theme.name);
52
- if (options.applyClass === true) assertClassToken(`theme-${theme.name}`, `Theme name cannot be used as a default theme class: ${theme.name}.`);
283
+ if (typeof options.shouldApplyClass !== "function") getThemeClass(theme, options.shouldApplyClass);
284
+ if (theme.tokens) {
285
+ if (!options.tokenSchema) throw new Error(`Theme "${theme.name}" defines tokens but no tokenSchema is configured.`);
286
+ for (const tokenKey of Object.keys(theme.tokens)) if (!Object.hasOwn(options.tokenSchema, tokenKey)) throw new Error(`Theme "${theme.name}" defines token "${tokenKey}" which is not present in tokenSchema.`);
287
+ }
53
288
  }
54
289
  if (!names.has(options.defaultTheme)) throw new Error(`Default theme is not configured: ${options.defaultTheme}.`);
55
290
  if (!names.has(options.systemTheme.light)) throw new Error(`System light theme is not configured: ${options.systemTheme.light}.`);
56
291
  if (!names.has(options.systemTheme.dark)) throw new Error(`System dark theme is not configured: ${options.systemTheme.dark}.`);
57
292
  };
58
293
  /**
59
- * Manages theme preference, DOM application, system preference changes, and change notifications.
294
+ * Validates runtime token overrides against the configured token schema.
60
295
  *
61
- * The constructor validates configured theme names, default and system mappings, and default class tokens.
62
- * Theme application throws `Error` when a requested theme is missing or a class resolver returns an invalid class token.
296
+ * @internal
63
297
  */
64
- var Theme = class {
298
+ const assertRuntimeTokens = (tokens, tokenSchema) => {
299
+ if (tokens === void 0 || tokens === null) return;
300
+ if (typeof tokens !== "object" || Array.isArray(tokens)) throw new Error("Runtime tokens must be an object.");
301
+ if (tokenSchema === void 0) throw new Error("Runtime tokens provided but no tokenSchema is configured.");
302
+ for (const tokenKey of Object.keys(tokens)) if (!Object.hasOwn(tokenSchema, tokenKey)) throw new Error(`Runtime token override "${tokenKey}" is not present in tokenSchema.`);
303
+ };
304
+ //#endregion
305
+ //#region src/theme.ts
306
+ var ThemeImpl = class {
65
307
  #options;
66
308
  #activeName;
309
+ #activeTokens = {};
67
310
  #listeners = /* @__PURE__ */ new Set();
68
- #mediaQueryList = null;
311
+ #systemListenerCleanup = null;
312
+ #storageListenerCleanup = null;
313
+ #isInitialized = false;
314
+ #resolvedClasses = null;
315
+ #getResolvedClasses() {
316
+ if (this.#resolvedClasses === null) {
317
+ this.#resolvedClasses = /* @__PURE__ */ new Map();
318
+ for (const t of this.#options.themes) this.#resolvedClasses.set(t.name, getThemeClass(t, this.#options.shouldApplyClass));
319
+ }
320
+ return this.#resolvedClasses;
321
+ }
69
322
  #handleSystemChange = (event) => {
70
- if (this.getStored() !== null) return;
323
+ if (readStorage(this.#options.storageKey, this.#options.themes) !== null) return;
71
324
  const name = event.matches ? this.#options.systemTheme.dark : this.#options.systemTheme.light;
72
- this.#activate(name, "system", { shouldStore: false });
325
+ if (this.#activeName !== name) this.#activate(name, {
326
+ source: "system",
327
+ shouldStore: false
328
+ });
329
+ };
330
+ #handleStorageChange = (event) => {
331
+ if (event.key !== this.#options.storageKey) return;
332
+ if (event.newValue === null) {
333
+ const systemTheme = this.getSystem().name;
334
+ this.#activate(systemTheme, {
335
+ source: "clearPreference",
336
+ shouldStore: false
337
+ });
338
+ } else if (this.#options.themes.some((t) => t.name === event.newValue) && this.#activeName !== event.newValue) this.#activate(event.newValue, {
339
+ source: "set",
340
+ shouldStore: false
341
+ });
73
342
  };
74
- /** Creates a theme manager with default light/dark themes unless overridden. */
75
343
  constructor(options = {}) {
76
344
  this.#options = {
77
- ...defaultOptions,
345
+ ...DEFAULT_OPTIONS,
78
346
  ...options,
79
347
  systemTheme: {
80
- ...defaultOptions.systemTheme,
348
+ ...DEFAULT_OPTIONS.systemTheme,
81
349
  ...options.systemTheme
82
350
  }
83
351
  };
84
352
  assertThemeConfig(this.#options);
85
353
  this.#activeName = this.#options.defaultTheme;
86
354
  }
87
- /** Registers system preference handling, applies the initial theme, emits an `init` change, and returns this instance. */
88
- init() {
89
- this.#registerSystemListener();
90
- this.#activate(this.getStored() ?? this.getSystem().name, "init", { shouldStore: false });
355
+ init(tokens) {
356
+ if (this.#isInitialized) return this;
357
+ this.#systemListenerCleanup = registerSystemListener(this.#handleSystemChange);
358
+ this.#storageListenerCleanup = registerStorageListener(this.#handleStorageChange);
359
+ this.#activate(readStorage(this.#options.storageKey, this.#options.themes) ?? this.getSystem().name, {
360
+ source: "init",
361
+ shouldStore: false,
362
+ tokens
363
+ });
364
+ this.#isInitialized = true;
91
365
  return this;
92
366
  }
93
- /** Returns the currently active theme definition. */
94
367
  get() {
95
- return this.#getTheme(this.#activeName) ?? this.#getTheme(this.#options.defaultTheme);
368
+ const baseTheme = this.#getTheme(this.#activeName);
369
+ const computedTokens = readComputedTokens({
370
+ theme: baseTheme,
371
+ options: this.#options,
372
+ activeTokens: this.#activeTokens
373
+ });
374
+ return {
375
+ ...baseTheme,
376
+ tokens: {
377
+ ...computedTokens,
378
+ ...baseTheme.tokens,
379
+ ...this.#activeTokens
380
+ }
381
+ };
96
382
  }
97
- /** Applies a configured theme by name, stores it when possible, emits a `set` change, and throws `Error` for unknown names. */
98
- set(name) {
99
- return this.#activate(name, "set", { shouldStore: true });
383
+ set(name, tokens) {
384
+ return this.#activate(name, {
385
+ source: "set",
386
+ shouldStore: true,
387
+ tokens
388
+ });
100
389
  }
101
- /** Toggles between the configured system light and dark themes, stores the preference when possible, and emits a `toggle` change. */
102
- toggle() {
103
- const nextName = this.get().name === this.#options.systemTheme.dark ? this.#options.systemTheme.light : this.#options.systemTheme.dark;
104
- return this.#activate(nextName, "toggle", { shouldStore: true });
390
+ toggle(tokens) {
391
+ const nextName = this.#getTheme(this.#activeName).colorScheme === "dark" ? this.#options.systemTheme.light : this.#options.systemTheme.dark;
392
+ return this.#activate(nextName, {
393
+ source: "toggle",
394
+ shouldStore: true,
395
+ tokens
396
+ });
105
397
  }
106
- /** Removes the stored preference when possible, applies the current system theme, and emits a `clearPreference` change. */
107
398
  clearPreference() {
108
- this.#removeStored();
109
- return this.#activate(this.getSystem().name, "clearPreference", { shouldStore: false });
399
+ removeStorage(this.#options.storageKey);
400
+ return this.#activate(this.getSystem().name, {
401
+ source: "clearPreference",
402
+ shouldStore: false
403
+ });
110
404
  }
111
- /** Returns the stored configured theme name, or `null` during SSR, storage failures, or invalid stored preferences. */
112
405
  getStored() {
113
- if (!isBrowser()) return null;
114
- try {
115
- const storedName = window.localStorage.getItem(this.#options.storageKey);
116
- return storedName !== null && this.#getTheme(storedName) !== null ? storedName : null;
117
- } catch {
118
- return null;
119
- }
406
+ return readStorage(this.#options.storageKey, this.#options.themes);
120
407
  }
121
- /** Returns the configured theme for the current OS color-scheme preference, or the default theme without browser support. */
122
408
  getSystem() {
123
- if (!isBrowser() || typeof window.matchMedia !== "function") return this.#getTheme(this.#options.defaultTheme);
124
- const name = window.matchMedia(PREFERS_DARK_QUERY).matches ? this.#options.systemTheme.dark : this.#options.systemTheme.light;
125
- return this.#getTheme(name);
409
+ return readSystemTheme({
410
+ defaultTheme: this.#options.defaultTheme,
411
+ systemTheme: this.#options.systemTheme,
412
+ themes: this.#options.themes
413
+ });
126
414
  }
127
- /** Registers a listener for in-process theme changes and returns an unsubscribe function. */
128
415
  subscribe(listener) {
129
416
  this.#listeners.add(listener);
130
417
  return () => {
131
418
  this.#listeners.delete(listener);
132
419
  };
133
420
  }
134
- /** Removes the system preference listener and clears in-process subscribers. */
135
421
  destroy() {
136
- if (this.#mediaQueryList !== null) {
137
- this.#mediaQueryList.removeEventListener("change", this.#handleSystemChange);
138
- this.#mediaQueryList = null;
422
+ if (this.#systemListenerCleanup) {
423
+ this.#systemListenerCleanup();
424
+ this.#systemListenerCleanup = null;
425
+ }
426
+ if (this.#storageListenerCleanup) {
427
+ this.#storageListenerCleanup();
428
+ this.#storageListenerCleanup = null;
139
429
  }
140
430
  this.#listeners.clear();
431
+ this.#activeTokens = {};
432
+ this.#activeName = this.#options.defaultTheme;
433
+ this.#resolvedClasses = null;
434
+ this.#isInitialized = false;
141
435
  }
142
- #activate(name, source, options) {
436
+ #activate(name, options) {
437
+ assertRuntimeTokens(options.tokens, this.#options.tokenSchema);
143
438
  const theme = this.#getTheme(name);
144
439
  this.#activeName = theme.name;
145
- if (options.shouldStore) this.#store(theme.name);
146
- this.#apply(theme);
147
- this.#emit({
148
- name: theme.name,
440
+ if (options.tokens !== void 0) this.#activeTokens = options.tokens;
441
+ if (options.shouldStore) writeStorage(this.#options.storageKey, theme.name);
442
+ const resolved = this.#getResolvedClasses();
443
+ applyTheme({
149
444
  theme,
150
- source
445
+ options: this.#options,
446
+ activeTokens: this.#activeTokens,
447
+ resolvedClasses: Array.from(resolved.values()).filter((c) => c !== null),
448
+ nextClass: resolved.get(theme.name) ?? null
151
449
  });
152
- return theme;
153
- }
154
- #apply(theme) {
155
- if (!isBrowser()) return;
156
- const root = document.documentElement;
157
- const nextClass = getThemeClass(theme, this.#options.applyClass);
158
- const configuredClasses = this.#options.themes.map((configuredTheme) => getThemeClass(configuredTheme, this.#options.applyClass)).filter((configuredClass) => configuredClass !== null);
159
- root.setAttribute(this.#options.attribute, theme.name);
160
- root.style.colorScheme = theme.colorScheme;
161
- for (const configuredClass of configuredClasses) root.classList.remove(configuredClass);
162
- if (nextClass !== null) root.classList.add(nextClass);
163
- if (this.#options.tailwindcss) root.classList.toggle(DARK_CLASS, theme.colorScheme === "dark");
450
+ const activeTheme = this.get();
451
+ this.#emit({
452
+ name: activeTheme.name,
453
+ theme: activeTheme,
454
+ source: options.source
455
+ });
456
+ return activeTheme;
164
457
  }
165
458
  #emit(detail) {
166
- for (const listener of this.#listeners) listener(detail);
167
- if (!isBrowser()) return;
168
- window.dispatchEvent(new CustomEvent(THEME_CHANGE_EVENT, { detail }));
459
+ for (const listener of this.#listeners) try {
460
+ listener(detail);
461
+ } catch (error) {
462
+ console.error("Error in theme change listener:", error);
463
+ }
464
+ emitThemeEvent(detail);
169
465
  }
170
466
  #getTheme(name) {
171
467
  const theme = this.#options.themes.find((candidate) => candidate.name === name);
172
468
  if (theme === void 0) throw new Error(`Theme is not configured: ${name}.`);
173
469
  return theme;
174
470
  }
175
- #registerSystemListener() {
176
- if (!isBrowser() || typeof window.matchMedia !== "function" || this.#mediaQueryList !== null) return;
177
- this.#mediaQueryList = window.matchMedia(PREFERS_DARK_QUERY);
178
- this.#mediaQueryList.addEventListener("change", this.#handleSystemChange);
179
- }
180
- #store(name) {
181
- if (!isBrowser()) return;
182
- try {
183
- window.localStorage.setItem(this.#options.storageKey, name);
184
- } catch {
185
- return;
186
- }
187
- }
188
- #removeStored() {
189
- if (!isBrowser()) return;
190
- try {
191
- window.localStorage.removeItem(this.#options.storageKey);
192
- } catch {
193
- return;
194
- }
195
- }
196
471
  };
472
+ /**
473
+ * Factory function that creates and returns a `Theme` instance.
474
+ *
475
+ * @param options - Configuration options for theme definitions, persistence keys, DOM attributes, custom class resolvers, and dynamic token schemas.
476
+ * @returns A `Theme` instance.
477
+ * @throws {Error} If configured theme names are empty, duplicated, invalid for CSS class application, or if the default/system themes are not present in the configured list.
478
+ */
479
+ function createTheme(options = {}) {
480
+ return new ThemeImpl(options);
481
+ }
197
482
  //#endregion
198
- export { THEME_CHANGE_EVENT, Theme, darkTheme, lightTheme };
483
+ export { DARK_THEME, LIGHT_THEME, THEME_CHANGE_EVENT, createTheme };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codenhub/theme",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "private": false,
5
5
  "description": "Zero-dependency browser theme preference helper for TypeScript apps.",
6
6
  "homepage": "https://github.com/codenhub/codenhub/tree/main/packages/theme",
@@ -27,17 +27,24 @@
27
27
  "access": "public"
28
28
  },
29
29
  "devDependencies": {
30
+ "@playwright/test": "^1.57.0",
30
31
  "jsdom": "^29.1.1",
32
+ "tsdown": "^0.22.2",
31
33
  "typescript": "^6.0.3",
32
- "vitest": "^4.0.17"
34
+ "vitest": "^4.0.17",
35
+ "@codenhub/styles": "0.0.3"
33
36
  },
34
37
  "scripts": {
38
+ "dev": "pnpm --filter=@codenhub/theme-dev dev",
39
+ "debug": "pnpm build && pnpm --filter=@codenhub/theme-debug dev",
35
40
  "build": "tsdown src/index.ts --format esm --dts --clean --no-fixed-extension",
36
41
  "status:npm": "npm view @codenhub/theme version dist-tags time --json && npm dist-tag ls @codenhub/theme && npm access get status @codenhub/theme",
37
42
  "status:pack": "npm pack --dry-run",
38
- "test": "vitest run",
43
+ "test": "vitest run && pnpm build && pnpm test:visual",
39
44
  "test:coverage": "vitest run --coverage",
45
+ "test:visual": "playwright test",
40
46
  "test:watch": "vitest",
47
+ "test:visual:watch": "playwright test --ui",
41
48
  "typecheck": "tsc --noEmit"
42
49
  }
43
50
  }