@codenhub/theme 0.0.3 → 0.1.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 +0 -7
- package/dist/index.js +1 -483
- package/package.json +7 -6
package/README.md
CHANGED
|
@@ -2,13 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
Small zero-dependency theme preference helper for browser apps. It applies a theme name to the document, updates `document.documentElement.style.colorScheme`, and supports managing dynamic CSS tokens.
|
|
4
4
|
|
|
5
|
-
## Features
|
|
6
|
-
|
|
7
|
-
- **Factory API**: Instantiate with `createTheme()`.
|
|
8
|
-
- **Dynamic CSS Tokens**: Define a `tokenSchema` at initialization for type-safe, dynamic inline CSS custom property styling.
|
|
9
|
-
- **Zero Dependencies**: Tiny footprint and pure TypeScript.
|
|
10
|
-
- **Flexible Styling**: Works with standard CSS variables, class toggles, or Tailwind CSS.
|
|
11
|
-
|
|
12
5
|
## Installation
|
|
13
6
|
|
|
14
7
|
```sh
|
package/dist/index.js
CHANGED
|
@@ -1,483 +1 @@
|
|
|
1
|
-
|
|
2
|
-
/** Window event name dispatched with `ThemeChangeDetail` after a theme change is applied in browser environments. */
|
|
3
|
-
const THEME_CHANGE_EVENT = "themechange";
|
|
4
|
-
/**
|
|
5
|
-
* Default `localStorage` key used to store the user's explicit theme preference.
|
|
6
|
-
*
|
|
7
|
-
* @internal
|
|
8
|
-
*/
|
|
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
|
-
*/
|
|
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
|
-
*/
|
|
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
|
-
*/
|
|
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
|
-
*/
|
|
33
|
-
const CLASS_TOKEN_WHITESPACE = /\s/;
|
|
34
|
-
/** Built-in light theme used by default and available for custom theme lists. */
|
|
35
|
-
const LIGHT_THEME = Object.freeze({
|
|
36
|
-
name: "light",
|
|
37
|
-
colorScheme: "light"
|
|
38
|
-
});
|
|
39
|
-
/** Built-in dark theme used by default and available for custom theme lists. */
|
|
40
|
-
const DARK_THEME = Object.freeze({
|
|
41
|
-
name: "dark",
|
|
42
|
-
colorScheme: "dark"
|
|
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
|
-
}),
|
|
56
|
-
storageKey: DEFAULT_STORAGE_KEY,
|
|
57
|
-
attribute: DEFAULT_ATTRIBUTE,
|
|
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);
|
|
73
|
-
assertClassToken(className, `Theme class resolver returned an invalid class for theme: ${theme.name}.`);
|
|
74
|
-
return className;
|
|
75
|
-
}
|
|
76
|
-
const className = `theme-${theme.name}`;
|
|
77
|
-
assertClassToken(className, `Theme name cannot be used as a default theme class: ${theme.name}.`);
|
|
78
|
-
return className;
|
|
79
|
-
};
|
|
80
|
-
/**
|
|
81
|
-
* Asserts that a value is a valid single DOM class token (non-empty string, no whitespace).
|
|
82
|
-
*
|
|
83
|
-
* @internal
|
|
84
|
-
*/
|
|
85
|
-
const assertClassToken = (className, 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
|
-
}
|
|
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
|
-
*/
|
|
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.");
|
|
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
|
-
}
|
|
277
|
-
for (const theme of options.themes) {
|
|
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".`);
|
|
281
|
-
if (names.has(theme.name)) throw new Error(`Duplicate theme name: ${theme.name}.`);
|
|
282
|
-
names.add(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
|
-
}
|
|
288
|
-
}
|
|
289
|
-
if (!names.has(options.defaultTheme)) throw new Error(`Default theme is not configured: ${options.defaultTheme}.`);
|
|
290
|
-
if (!names.has(options.systemTheme.light)) throw new Error(`System light theme is not configured: ${options.systemTheme.light}.`);
|
|
291
|
-
if (!names.has(options.systemTheme.dark)) throw new Error(`System dark theme is not configured: ${options.systemTheme.dark}.`);
|
|
292
|
-
};
|
|
293
|
-
/**
|
|
294
|
-
* Validates runtime token overrides against the configured token schema.
|
|
295
|
-
*
|
|
296
|
-
* @internal
|
|
297
|
-
*/
|
|
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 {
|
|
307
|
-
#options;
|
|
308
|
-
#activeName;
|
|
309
|
-
#activeTokens = {};
|
|
310
|
-
#listeners = /* @__PURE__ */ new Set();
|
|
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
|
-
}
|
|
322
|
-
#handleSystemChange = (event) => {
|
|
323
|
-
if (readStorage(this.#options.storageKey, this.#options.themes) !== null) return;
|
|
324
|
-
const name = event.matches ? this.#options.systemTheme.dark : this.#options.systemTheme.light;
|
|
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
|
-
});
|
|
342
|
-
};
|
|
343
|
-
constructor(options = {}) {
|
|
344
|
-
this.#options = {
|
|
345
|
-
...DEFAULT_OPTIONS,
|
|
346
|
-
...options,
|
|
347
|
-
systemTheme: {
|
|
348
|
-
...DEFAULT_OPTIONS.systemTheme,
|
|
349
|
-
...options.systemTheme
|
|
350
|
-
}
|
|
351
|
-
};
|
|
352
|
-
assertThemeConfig(this.#options);
|
|
353
|
-
this.#activeName = this.#options.defaultTheme;
|
|
354
|
-
}
|
|
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;
|
|
365
|
-
return this;
|
|
366
|
-
}
|
|
367
|
-
get() {
|
|
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
|
-
};
|
|
382
|
-
}
|
|
383
|
-
set(name, tokens) {
|
|
384
|
-
return this.#activate(name, {
|
|
385
|
-
source: "set",
|
|
386
|
-
shouldStore: true,
|
|
387
|
-
tokens
|
|
388
|
-
});
|
|
389
|
-
}
|
|
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
|
-
});
|
|
397
|
-
}
|
|
398
|
-
clearPreference() {
|
|
399
|
-
removeStorage(this.#options.storageKey);
|
|
400
|
-
return this.#activate(this.getSystem().name, {
|
|
401
|
-
source: "clearPreference",
|
|
402
|
-
shouldStore: false
|
|
403
|
-
});
|
|
404
|
-
}
|
|
405
|
-
getStored() {
|
|
406
|
-
return readStorage(this.#options.storageKey, this.#options.themes);
|
|
407
|
-
}
|
|
408
|
-
getSystem() {
|
|
409
|
-
return readSystemTheme({
|
|
410
|
-
defaultTheme: this.#options.defaultTheme,
|
|
411
|
-
systemTheme: this.#options.systemTheme,
|
|
412
|
-
themes: this.#options.themes
|
|
413
|
-
});
|
|
414
|
-
}
|
|
415
|
-
subscribe(listener) {
|
|
416
|
-
this.#listeners.add(listener);
|
|
417
|
-
return () => {
|
|
418
|
-
this.#listeners.delete(listener);
|
|
419
|
-
};
|
|
420
|
-
}
|
|
421
|
-
destroy() {
|
|
422
|
-
if (this.#systemListenerCleanup) {
|
|
423
|
-
this.#systemListenerCleanup();
|
|
424
|
-
this.#systemListenerCleanup = null;
|
|
425
|
-
}
|
|
426
|
-
if (this.#storageListenerCleanup) {
|
|
427
|
-
this.#storageListenerCleanup();
|
|
428
|
-
this.#storageListenerCleanup = null;
|
|
429
|
-
}
|
|
430
|
-
this.#listeners.clear();
|
|
431
|
-
this.#activeTokens = {};
|
|
432
|
-
this.#activeName = this.#options.defaultTheme;
|
|
433
|
-
this.#resolvedClasses = null;
|
|
434
|
-
this.#isInitialized = false;
|
|
435
|
-
}
|
|
436
|
-
#activate(name, options) {
|
|
437
|
-
assertRuntimeTokens(options.tokens, this.#options.tokenSchema);
|
|
438
|
-
const theme = this.#getTheme(name);
|
|
439
|
-
this.#activeName = 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({
|
|
444
|
-
theme,
|
|
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
|
|
449
|
-
});
|
|
450
|
-
const activeTheme = this.get();
|
|
451
|
-
this.#emit({
|
|
452
|
-
name: activeTheme.name,
|
|
453
|
-
theme: activeTheme,
|
|
454
|
-
source: options.source
|
|
455
|
-
});
|
|
456
|
-
return activeTheme;
|
|
457
|
-
}
|
|
458
|
-
#emit(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);
|
|
465
|
-
}
|
|
466
|
-
#getTheme(name) {
|
|
467
|
-
const theme = this.#options.themes.find((candidate) => candidate.name === name);
|
|
468
|
-
if (theme === void 0) throw new Error(`Theme is not configured: ${name}.`);
|
|
469
|
-
return theme;
|
|
470
|
-
}
|
|
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
|
-
}
|
|
482
|
-
//#endregion
|
|
483
|
-
export { DARK_THEME, LIGHT_THEME, THEME_CHANGE_EVENT, createTheme };
|
|
1
|
+
const e=`themechange`,t=/\s/,n=Object.freeze({name:`light`,colorScheme:`light`}),r=Object.freeze({name:`dark`,colorScheme:`dark`}),i=Object.freeze({themes:Object.freeze([n,r]),defaultTheme:n.name,systemTheme:Object.freeze({light:n.name,dark:r.name}),storageKey:`app-theme-preference`,attribute:`data-theme`,isTailwindCss:!1,shouldApplyClass:!0}),a=(e,t)=>{if(t===!1)return null;if(typeof t==`function`){let n=t(e);return o(n,`Theme class resolver returned an invalid class for theme: ${e.name}.`),n}let n=`theme-${e.name}`;return o(n,`Theme name cannot be used as a default theme class: ${e.name}.`),n},o=(e,n)=>{if(typeof e!=`string`||e.length===0||t.test(e))throw Error(n)},s=e=>{let{theme:t,options:n,activeTokens:r,resolvedClasses:i,nextClass:a}=e;if(typeof document>`u`)return;let o=document.documentElement;o.setAttribute(n.attribute,t.name),o.style.colorScheme=t.colorScheme;for(let e of i)e!==a&&o.classList.remove(e);if(a!==null&&o.classList.add(a),n.isTailwindCss&&o.classList.toggle(`dark`,t.colorScheme===`dark`),n.tokenSchema){let e=n.tokenSchema,i={...t.tokens,...r};for(let t of Object.keys(e)){let n=e[t],r=i[t];r==null?o.style.removeProperty(n):o.style.setProperty(n,r)}}},c=e=>{let{theme:t,options:n,activeTokens:r}=e,i={};if(typeof window>`u`||typeof window.getComputedStyle!=`function`||typeof document>`u`||!n.tokenSchema)return i;let a=document.documentElement;try{let e=n.tokenSchema,o={...t.tokens,...r};if(!Object.keys(e).some(e=>o[e]===void 0))return i;let s=window.getComputedStyle(a);if(!s)return i;for(let t of Object.keys(e))if(o[t]===void 0){let n=s.getPropertyValue(e[t]).trim();n&&(i[t]=n)}}catch(e){console.error(`[theme] Failed to read computed token styles:`,e)}return i},l=t=>{typeof window>`u`||typeof window.dispatchEvent!=`function`||typeof window.CustomEvent!=`function`||window.dispatchEvent(new CustomEvent(e,{detail:t}))},u=(e,t)=>{if(typeof window>`u`)return null;try{let n=window.localStorage.getItem(e);return n===null?null:t.some(e=>e.name===n)?n:null}catch(e){return console.error(`[theme] Failed to read from localStorage:`,e),null}},d=(e,t)=>{if(!(typeof window>`u`))try{window.localStorage.setItem(e,t)}catch(e){console.error(`[theme] Failed to write to localStorage:`,e)}},f=e=>{if(!(typeof window>`u`))try{window.localStorage.removeItem(e)}catch(e){console.error(`[theme] Failed to remove from localStorage:`,e)}},p=e=>{let{defaultTheme:t,systemTheme:n,themes:r}=e,i=e=>{let t=r.find(t=>t.name===e);if(t===void 0)throw Error(`Theme is not configured: ${e}.`);return t};if(typeof window>`u`||typeof window.matchMedia!=`function`)return i(t);try{return i(window.matchMedia(`(prefers-color-scheme: dark)`).matches?n.dark:n.light)}catch{return i(t)}},m=e=>{if(typeof window>`u`||typeof window.matchMedia!=`function`)return()=>{};let t=(()=>{try{return window.matchMedia(`(prefers-color-scheme: dark)`)}catch{return null}})(),n=!1;if(t)try{typeof t.addEventListener==`function`?t.addEventListener(`change`,e):typeof t.addListener==`function`&&(n=!0,t.addListener(e))}catch{}return()=>{if(t!==null)try{n&&typeof t.removeListener==`function`?t.removeListener(e):typeof t.removeEventListener==`function`&&t.removeEventListener(`change`,e)}catch{}}},h=e=>typeof window>`u`||typeof window.addEventListener!=`function`?()=>{}:(window.addEventListener(`storage`,e),()=>{window.removeEventListener(`storage`,e)}),g=e=>{if(typeof e.attribute!=`string`||e.attribute.trim().length===0)throw Error(`Theme attribute option must be a non-empty string.`);if(/[\s"'/>=]/.test(e.attribute))throw Error(`Theme attribute option must be a valid HTML attribute name.`);if(typeof e.storageKey!=`string`||e.storageKey.trim().length===0)throw Error(`Theme storageKey option must be a non-empty string.`);if(!Array.isArray(e.themes))throw Error(`Theme options.themes must be an array.`);let t=new Set;if(e.tokenSchema){for(let[t,n]of Object.entries(e.tokenSchema))if(typeof n!=`string`||!n.startsWith(`--`))throw Error(`Token schema key "${t}" must map to a CSS custom property starting with "--". Received: "${n}".`)}for(let n of e.themes){if(typeof n!=`object`||!n)throw Error(`Theme definitions must be objects.`);if(typeof n.name!=`string`||n.name.trim().length===0)throw Error(`Theme names must be non-empty strings.`);if(n.colorScheme!==`light`&&n.colorScheme!==`dark`)throw Error(`Theme "${n.name}" has an invalid colorScheme: ${n.colorScheme}. Must be "light" or "dark".`);if(t.has(n.name))throw Error(`Duplicate theme name: ${n.name}.`);if(t.add(n.name),typeof e.shouldApplyClass!=`function`&&a(n,e.shouldApplyClass),n.tokens){if(!e.tokenSchema)throw Error(`Theme "${n.name}" defines tokens but no tokenSchema is configured.`);for(let t of Object.keys(n.tokens))if(!Object.hasOwn(e.tokenSchema,t))throw Error(`Theme "${n.name}" defines token "${t}" which is not present in tokenSchema.`)}}if(!t.has(e.defaultTheme))throw Error(`Default theme is not configured: ${e.defaultTheme}.`);if(!t.has(e.systemTheme.light))throw Error(`System light theme is not configured: ${e.systemTheme.light}.`);if(!t.has(e.systemTheme.dark))throw Error(`System dark theme is not configured: ${e.systemTheme.dark}.`)},_=(e,t)=>{if(e!=null){if(typeof e!=`object`||Array.isArray(e))throw Error(`Runtime tokens must be an object.`);if(t===void 0)throw Error(`Runtime tokens provided but no tokenSchema is configured.`);for(let n of Object.keys(e))if(!Object.hasOwn(t,n))throw Error(`Runtime token override "${n}" is not present in tokenSchema.`)}};var v=class{#e;#t;#n={};#r=new Set;#i=null;#a=null;#o=!1;#s=null;#c=null;#l(){if(this.#s===null){this.#s=new Map;for(let e of this.#e.themes)this.#s.set(e.name,a(e,this.#e.shouldApplyClass))}return this.#s}#u(){if(this.#c===null){let e=this.#l();this.#c=Array.from(e.values()).filter(e=>e!==null)}return this.#c}#d=e=>{if(u(this.#e.storageKey,this.#e.themes)!==null)return;let t=e.matches?this.#e.systemTheme.dark:this.#e.systemTheme.light;this.#t!==t&&this.#p(t,{source:`system`,shouldStore:!1})};#f=e=>{if(e.key===this.#e.storageKey)if(e.newValue===null){let e=this.getSystem().name;this.#p(e,{source:`clearPreference`,shouldStore:!1})}else this.#e.themes.some(t=>t.name===e.newValue)&&this.#t!==e.newValue&&this.#p(e.newValue,{source:`set`,shouldStore:!1})};constructor(e={}){this.#e={...i,...e,systemTheme:{...i.systemTheme,...e.systemTheme}},g(this.#e),this.#t=this.#e.defaultTheme}init(e){return this.#o?this:(this.#i=m(this.#d),this.#a=h(this.#f),this.#p(u(this.#e.storageKey,this.#e.themes)??this.getSystem().name,{source:`init`,shouldStore:!1,tokens:e}),this.#o=!0,this)}get(){let e=this.#h(this.#t),t=this.#e,n=this.#n,r=null;return{...e,get tokens(){return r===null&&(r={...c({theme:e,options:t,activeTokens:n}),...e.tokens,...n}),r}}}set(e,t){return this.#p(e,{source:`set`,shouldStore:!0,tokens:t})}toggle(e){let t=this.#h(this.#t).colorScheme===`dark`?this.#e.systemTheme.light:this.#e.systemTheme.dark;return this.#p(t,{source:`toggle`,shouldStore:!0,tokens:e})}clearPreference(){return f(this.#e.storageKey),this.#p(this.getSystem().name,{source:`clearPreference`,shouldStore:!1})}getStored(){return u(this.#e.storageKey,this.#e.themes)}getSystem(){return p({defaultTheme:this.#e.defaultTheme,systemTheme:this.#e.systemTheme,themes:this.#e.themes})}subscribe(e){return this.#r.add(e),()=>{this.#r.delete(e)}}destroy(){this.#i&&=(this.#i(),null),this.#a&&=(this.#a(),null),this.#r.clear(),this.#n={},this.#t=this.#e.defaultTheme,this.#s=null,this.#c=null,this.#o=!1}#p(e,t){_(t.tokens,this.#e.tokenSchema);let n=this.#h(e);this.#t=n.name,t.tokens!==void 0&&(this.#n=t.tokens),t.shouldStore&&d(this.#e.storageKey,n.name);let r=this.#l();s({theme:n,options:this.#e,activeTokens:this.#n,resolvedClasses:this.#u(),nextClass:r.get(n.name)??null});let i=this.get();return this.#m({name:i.name,theme:i,source:t.source}),i}#m(e){for(let t of this.#r)try{t(e)}catch(e){console.error(`Error in theme change listener:`,e)}l(e)}#h(e){let t=this.#e.themes.find(t=>t.name===e);if(t===void 0)throw Error(`Theme is not configured: ${e}.`);return t}};function y(e={}){return new v(e)}export{r as DARK_THEME,n as LIGHT_THEME,e as THEME_CHANGE_EVENT,y as createTheme};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@codenhub/theme",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
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,18 @@
|
|
|
27
27
|
"access": "public"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@playwright/test": "^1.
|
|
30
|
+
"@playwright/test": "^1.61.1",
|
|
31
31
|
"jsdom": "^29.1.1",
|
|
32
|
-
"tsdown": "^0.22.
|
|
32
|
+
"tsdown": "^0.22.3",
|
|
33
33
|
"typescript": "^6.0.3",
|
|
34
|
-
"vitest": "^4.
|
|
35
|
-
"@codenhub/styles": "0.0.
|
|
34
|
+
"vitest": "^4.1.10",
|
|
35
|
+
"@codenhub/styles": "0.0.4",
|
|
36
|
+
"@codenhub/vite-plugin-icons": "0.0.1"
|
|
36
37
|
},
|
|
37
38
|
"scripts": {
|
|
38
39
|
"dev": "pnpm --filter=@codenhub/theme-dev dev",
|
|
39
40
|
"debug": "pnpm build && pnpm --filter=@codenhub/theme-debug dev",
|
|
40
|
-
"build": "tsdown src/index.ts --format esm --dts --clean --no-fixed-extension",
|
|
41
|
+
"build": "tsdown src/index.ts --format esm --dts --clean --no-fixed-extension --minify",
|
|
41
42
|
"status:npm": "npm view @codenhub/theme version dist-tags time --json && npm dist-tag ls @codenhub/theme && npm access get status @codenhub/theme",
|
|
42
43
|
"status:pack": "npm pack --dry-run",
|
|
43
44
|
"test": "vitest run && pnpm build && pnpm test:visual",
|