@codenhub/theme 0.0.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 +355 -0
- package/dist/index.d.ts +84 -0
- package/dist/index.js +198 -0
- package/package.json +41 -0
package/README.md
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
# @codenhub/theme
|
|
2
|
+
|
|
3
|
+
Small zero-dependency theme preference helper for browser apps. It applies a theme name to the document, updates `document.documentElement.style.colorScheme`, and leaves tokens, variables, and visual styles to your CSS.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pnpm add @codenhub/theme
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
By default, `init()` uses a valid stored preference first. If there is no valid stored preference, it maps the OS color scheme to `light` or `dark`.
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { Theme } from "@codenhub/theme";
|
|
17
|
+
|
|
18
|
+
const theme = new Theme({ tailwindcss: false, applyClass: true });
|
|
19
|
+
|
|
20
|
+
theme.init();
|
|
21
|
+
theme.set("dark");
|
|
22
|
+
theme.toggle();
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Call `destroy()` during app or test cleanup when the instance is no longer used.
|
|
26
|
+
|
|
27
|
+
## Reference
|
|
28
|
+
|
|
29
|
+
### `@codenhub/theme`
|
|
30
|
+
|
|
31
|
+
Primary entrypoint for the theme preference API.
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { Theme, darkTheme, lightTheme, THEME_CHANGE_EVENT } from "@codenhub/theme";
|
|
35
|
+
import type {
|
|
36
|
+
SystemThemeMap,
|
|
37
|
+
ThemeChangeDetail,
|
|
38
|
+
ThemeChangeListener,
|
|
39
|
+
ThemeChangeSource,
|
|
40
|
+
ThemeClassResolver,
|
|
41
|
+
ThemeDefinition,
|
|
42
|
+
ThemeOptions,
|
|
43
|
+
} from "@codenhub/theme";
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Supported import paths:
|
|
47
|
+
|
|
48
|
+
| Path | Description |
|
|
49
|
+
| ----------------- | ----------------------------------- |
|
|
50
|
+
| `@codenhub/theme` | Main JavaScript and TypeScript API. |
|
|
51
|
+
|
|
52
|
+
#### `Theme`
|
|
53
|
+
|
|
54
|
+
Manages the active theme, storage preference, DOM attribute, `colorScheme` style, classes, system preference listener, and change notifications.
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
class Theme {
|
|
58
|
+
constructor(options?: ThemeOptions);
|
|
59
|
+
init(): this;
|
|
60
|
+
get(): ThemeDefinition;
|
|
61
|
+
set(name: string): ThemeDefinition;
|
|
62
|
+
toggle(): ThemeDefinition;
|
|
63
|
+
clearPreference(): ThemeDefinition;
|
|
64
|
+
getStored(): string | null;
|
|
65
|
+
getSystem(): ThemeDefinition;
|
|
66
|
+
subscribe(listener: ThemeChangeListener): () => void;
|
|
67
|
+
destroy(): void;
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Import from `@codenhub/theme`.
|
|
72
|
+
|
|
73
|
+
The constructor throws `Error` when configured theme names are empty, duplicated, invalid for CSS class application, or referenced by `defaultTheme` or `systemTheme` without being configured.
|
|
74
|
+
|
|
75
|
+
##### `init()`
|
|
76
|
+
|
|
77
|
+
Registers the system preference listener, resolves the initial theme, applies it, and emits a change with source `"init"`.
|
|
78
|
+
|
|
79
|
+
Repeated calls do not register duplicate system preference listeners.
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
function init(): this;
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
##### `get()`
|
|
86
|
+
|
|
87
|
+
Returns the active theme definition.
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
function get(): ThemeDefinition;
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
##### `set()`
|
|
94
|
+
|
|
95
|
+
Activates a configured theme by name and stores the explicit preference when browser storage is available.
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
function set(name: string): ThemeDefinition;
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Throws `Error` when `name` is not configured.
|
|
102
|
+
|
|
103
|
+
##### `toggle()`
|
|
104
|
+
|
|
105
|
+
Toggles between the configured system light and dark theme names, then stores the explicit preference when browser storage is available.
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
function toggle(): ThemeDefinition;
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
##### `clearPreference()`
|
|
112
|
+
|
|
113
|
+
Removes the stored preference and activates the current system theme.
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
function clearPreference(): ThemeDefinition;
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
##### `getStored()`
|
|
120
|
+
|
|
121
|
+
Returns the stored theme name when it exists and is configured.
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
function getStored(): string | null;
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Returns `null` during SSR, when storage is unavailable, when storage access throws, or when the stored name is not configured.
|
|
128
|
+
|
|
129
|
+
##### `getSystem()`
|
|
130
|
+
|
|
131
|
+
Returns the configured theme for the current `prefers-color-scheme` value.
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
function getSystem(): ThemeDefinition;
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Returns the default theme during SSR or when `matchMedia` is unavailable.
|
|
138
|
+
|
|
139
|
+
##### `subscribe()`
|
|
140
|
+
|
|
141
|
+
Registers an in-process listener for theme changes.
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
function subscribe(listener: ThemeChangeListener): () => void;
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Returns an unsubscribe function.
|
|
148
|
+
|
|
149
|
+
##### `destroy()`
|
|
150
|
+
|
|
151
|
+
Removes the system preference listener and clears in-process subscribers.
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
function destroy(): void;
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Call this during app or test cleanup when the instance is no longer used.
|
|
158
|
+
|
|
159
|
+
#### `ThemeOptions`
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
interface ThemeOptions {
|
|
163
|
+
themes?: readonly ThemeDefinition[];
|
|
164
|
+
defaultTheme?: string;
|
|
165
|
+
systemTheme?: SystemThemeMap;
|
|
166
|
+
storageKey?: string;
|
|
167
|
+
attribute?: string;
|
|
168
|
+
tailwindcss?: boolean;
|
|
169
|
+
applyClass?: boolean | ThemeClassResolver;
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
| Option | Type | Default | Description |
|
|
174
|
+
| -------------- | ------------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
175
|
+
| `themes` | `readonly ThemeDefinition[]` | `[lightTheme, darkTheme]` | Defines available themes. |
|
|
176
|
+
| `defaultTheme` | `string` | `"light"` | Theme used before init and when browser APIs are unavailable. |
|
|
177
|
+
| `systemTheme` | `SystemThemeMap` | `{ light: "light", dark: "dark" }` | Maps OS light and dark preferences to configured theme names. |
|
|
178
|
+
| `storageKey` | `string` | `"app-theme-preference"` | Key used for `localStorage`. |
|
|
179
|
+
| `attribute` | `string` | `"data-theme"` | Attribute set on `document.documentElement`. |
|
|
180
|
+
| `tailwindcss` | `boolean` | `false` | Toggles the `dark` class when the active theme has `colorScheme: "dark"`. |
|
|
181
|
+
| `applyClass` | `boolean` or `(theme: ThemeDefinition) => string` | `true` | Adds `theme-${name}`, no class, or a resolver-provided class to `document.documentElement`. |
|
|
182
|
+
|
|
183
|
+
When class application is enabled, each theme application removes classes for all configured themes, then adds the class for the active theme.
|
|
184
|
+
|
|
185
|
+
#### `ThemeDefinition`
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
interface ThemeDefinition {
|
|
189
|
+
name: string;
|
|
190
|
+
colorScheme: "light" | "dark";
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
| Field | Type | Description |
|
|
195
|
+
| ------------- | ------------------- | ---------------------------------------------------------------- |
|
|
196
|
+
| `name` | `string` | Unique theme name used for storage, attributes, and class names. |
|
|
197
|
+
| `colorScheme` | `"light" \| "dark"` | Browser color scheme applied through `style.colorScheme`. |
|
|
198
|
+
|
|
199
|
+
#### `SystemThemeMap`
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
interface SystemThemeMap {
|
|
203
|
+
light: string;
|
|
204
|
+
dark: string;
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
| Field | Type | Description |
|
|
209
|
+
| ------- | -------- | ----------------------------------------------------------- |
|
|
210
|
+
| `light` | `string` | Configured theme name used when the OS preference is light. |
|
|
211
|
+
| `dark` | `string` | Configured theme name used when the OS preference is dark. |
|
|
212
|
+
|
|
213
|
+
#### `ThemeClassResolver`
|
|
214
|
+
|
|
215
|
+
Returns the class name applied to `document.documentElement` for a theme.
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
type ThemeClassResolver = (theme: ThemeDefinition) => string;
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
The returned class name must be a single non-empty class token without whitespace.
|
|
222
|
+
|
|
223
|
+
`init()`, `set()`, `toggle()`, `clearPreference()`, or system preference changes throw `Error` if the resolver returns an empty class name or a class name containing whitespace.
|
|
224
|
+
|
|
225
|
+
#### `ThemeChangeListener`
|
|
226
|
+
|
|
227
|
+
Listener passed to `theme.subscribe()`.
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
type ThemeChangeListener = (detail: ThemeChangeDetail) => void;
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
#### `THEME_CHANGE_EVENT`
|
|
234
|
+
|
|
235
|
+
Window event name dispatched after theme changes in browser environments.
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
const THEME_CHANGE_EVENT = "themechange";
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
#### `ThemeChangeDetail`
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
interface ThemeChangeDetail {
|
|
245
|
+
name: string;
|
|
246
|
+
theme: ThemeDefinition;
|
|
247
|
+
source: "init" | "set" | "toggle" | "clearPreference" | "system";
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
| Field | Type | Description |
|
|
252
|
+
| -------- | ------------------- | ----------------------------------------- |
|
|
253
|
+
| `name` | `string` | Active theme name after the change. |
|
|
254
|
+
| `theme` | `ThemeDefinition` | Active theme definition after the change. |
|
|
255
|
+
| `source` | `ThemeChangeSource` | Reason the theme change was emitted. |
|
|
256
|
+
|
|
257
|
+
#### `ThemeChangeSource`
|
|
258
|
+
|
|
259
|
+
Reason a theme change was emitted.
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
type ThemeChangeSource = "init" | "set" | "toggle" | "clearPreference" | "system";
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
| Value | Emitted when |
|
|
266
|
+
| ------------------- | -------------------------------------------------- |
|
|
267
|
+
| `"init"` | `init()` resolves and applies the initial theme. |
|
|
268
|
+
| `"set"` | `set()` applies an explicit theme preference. |
|
|
269
|
+
| `"toggle"` | `toggle()` switches between system light and dark. |
|
|
270
|
+
| `"clearPreference"` | `clearPreference()` removes stored preference. |
|
|
271
|
+
| `"system"` | OS color scheme changes with no stored preference. |
|
|
272
|
+
|
|
273
|
+
#### Built-In Themes
|
|
274
|
+
|
|
275
|
+
Built-in theme definitions.
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
const lightTheme: ThemeDefinition = { name: "light", colorScheme: "light" };
|
|
279
|
+
const darkTheme: ThemeDefinition = { name: "dark", colorScheme: "dark" };
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
## Examples
|
|
283
|
+
|
|
284
|
+
### Define CSS Tokens
|
|
285
|
+
|
|
286
|
+
```css
|
|
287
|
+
:root,
|
|
288
|
+
[data-theme="light"] {
|
|
289
|
+
--color-background: white;
|
|
290
|
+
--color-foreground: black;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
[data-theme="dark"] {
|
|
294
|
+
--color-background: black;
|
|
295
|
+
--color-foreground: white;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
body {
|
|
299
|
+
background: var(--color-background);
|
|
300
|
+
color: var(--color-foreground);
|
|
301
|
+
}
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### Add More Themes
|
|
305
|
+
|
|
306
|
+
```ts
|
|
307
|
+
import { Theme, darkTheme, lightTheme } from "@codenhub/theme";
|
|
308
|
+
|
|
309
|
+
const theme = new Theme({
|
|
310
|
+
themes: [lightTheme, darkTheme, { name: "high-contrast", colorScheme: "dark" }],
|
|
311
|
+
systemTheme: { light: "light", dark: "high-contrast" },
|
|
312
|
+
applyClass: (definition) => `mode-${definition.name}`,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
theme.init();
|
|
316
|
+
theme.set("high-contrast");
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
### Listen For Changes
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
import { Theme, THEME_CHANGE_EVENT, type ThemeChangeDetail } from "@codenhub/theme";
|
|
323
|
+
|
|
324
|
+
const theme = new Theme().init();
|
|
325
|
+
|
|
326
|
+
const unsubscribe = theme.subscribe((detail) => {
|
|
327
|
+
console.log(detail.name, detail.theme, detail.source);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
window.addEventListener(THEME_CHANGE_EVENT, (event) => {
|
|
331
|
+
const detail = (event as CustomEvent<ThemeChangeDetail>).detail;
|
|
332
|
+
|
|
333
|
+
console.log(detail.name);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
unsubscribe();
|
|
337
|
+
theme.destroy();
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
## Requirements
|
|
341
|
+
|
|
342
|
+
- Browser integration uses `document.documentElement`, `document.documentElement.style.colorScheme`, `window.matchMedia`, `localStorage`, and `CustomEvent`.
|
|
343
|
+
- SSR is supported; DOM, storage, media query, and event work is skipped when browser APIs are unavailable.
|
|
344
|
+
- System preference changes update the active theme only when there is no valid stored preference.
|
|
345
|
+
- `localStorage` read, write, and remove errors are ignored and treated as unavailable storage.
|
|
346
|
+
- Consumers own CSS variables, selectors, visual tokens, and persistence consent requirements.
|
|
347
|
+
- No CSS file, design tokens, framework adapter, or peer dependency is provided.
|
|
348
|
+
|
|
349
|
+
## Notes
|
|
350
|
+
|
|
351
|
+
- Does not provide design tokens or generated CSS.
|
|
352
|
+
- Does not provide React, Vue, or other framework bindings.
|
|
353
|
+
- Does not provide server-side persistence.
|
|
354
|
+
- Does not synchronize theme changes across tabs.
|
|
355
|
+
- Does not manage user consent requirements for storage.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
//#region src/index.d.ts
|
|
2
|
+
/** Window event name dispatched with `ThemeChangeDetail` after a theme change is applied in browser environments. */
|
|
3
|
+
declare const THEME_CHANGE_EVENT = "themechange";
|
|
4
|
+
/** Theme option stored, applied to the configured DOM attribute, and mapped to the browser color scheme. */
|
|
5
|
+
interface ThemeDefinition {
|
|
6
|
+
/** Unique configured theme name used for storage, DOM attributes, and generated default classes. */
|
|
7
|
+
name: string;
|
|
8
|
+
/** Browser color scheme applied to `document.documentElement.style.colorScheme`. */
|
|
9
|
+
colorScheme: "light" | "dark";
|
|
10
|
+
}
|
|
11
|
+
/** Mapping from OS color-scheme preferences to configured theme names. */
|
|
12
|
+
interface SystemThemeMap {
|
|
13
|
+
/** Configured theme name used when the OS preference is light or no dark preference is detected. */
|
|
14
|
+
light: string;
|
|
15
|
+
/** Configured theme name used when the OS preference is dark. */
|
|
16
|
+
dark: string;
|
|
17
|
+
}
|
|
18
|
+
/** Resolves the single DOM class token applied for a theme when custom class application is enabled. */
|
|
19
|
+
type ThemeClassResolver = (theme: ThemeDefinition) => string;
|
|
20
|
+
/** Reason a theme change notification was emitted. */
|
|
21
|
+
type ThemeChangeSource = "init" | "set" | "toggle" | "clearPreference" | "system";
|
|
22
|
+
/** Payload passed to subscribers and the browser `themechange` event after a theme change. */
|
|
23
|
+
interface ThemeChangeDetail {
|
|
24
|
+
/** Active theme name after the change. */
|
|
25
|
+
name: string;
|
|
26
|
+
/** Active theme definition after the change. */
|
|
27
|
+
theme: ThemeDefinition;
|
|
28
|
+
/** Operation or browser signal that caused the change notification. */
|
|
29
|
+
source: ThemeChangeSource;
|
|
30
|
+
}
|
|
31
|
+
/** In-process callback registered with `Theme.subscribe()` for applied theme changes. */
|
|
32
|
+
type ThemeChangeListener = (detail: ThemeChangeDetail) => void;
|
|
33
|
+
/** Configuration for theme definitions, persistence, DOM application, and system preference mapping. */
|
|
34
|
+
interface ThemeOptions {
|
|
35
|
+
/** Available themes. Names must be unique, non-empty, and valid default class tokens when `applyClass` is `true`. */
|
|
36
|
+
themes?: readonly ThemeDefinition[];
|
|
37
|
+
/** Configured theme name used before initialization and when browser APIs are unavailable. */
|
|
38
|
+
defaultTheme?: string;
|
|
39
|
+
/** Configured theme names selected for OS light and dark color-scheme preferences. */
|
|
40
|
+
systemTheme?: SystemThemeMap;
|
|
41
|
+
/** `localStorage` key used for explicit user preferences. */
|
|
42
|
+
storageKey?: string;
|
|
43
|
+
/** Attribute set on `document.documentElement` with the active theme name. */
|
|
44
|
+
attribute?: string;
|
|
45
|
+
/** Whether to toggle Tailwind CSS's `dark` class for themes with `colorScheme: "dark"`. */
|
|
46
|
+
tailwindcss?: boolean;
|
|
47
|
+
/** Whether and how to apply a theme-specific class to `document.documentElement`. */
|
|
48
|
+
applyClass?: boolean | ThemeClassResolver;
|
|
49
|
+
}
|
|
50
|
+
/** Built-in light theme used by default and available for custom theme lists. */
|
|
51
|
+
declare const lightTheme: ThemeDefinition;
|
|
52
|
+
/** Built-in dark theme used by default and available for custom theme lists. */
|
|
53
|
+
declare const darkTheme: ThemeDefinition;
|
|
54
|
+
/**
|
|
55
|
+
* Manages theme preference, DOM application, system preference changes, and change notifications.
|
|
56
|
+
*
|
|
57
|
+
* The constructor validates configured theme names, default and system mappings, and default class tokens.
|
|
58
|
+
* Theme application throws `Error` when a requested theme is missing or a class resolver returns an invalid class token.
|
|
59
|
+
*/
|
|
60
|
+
declare class Theme {
|
|
61
|
+
#private;
|
|
62
|
+
/** Creates a theme manager with default light/dark themes unless overridden. */
|
|
63
|
+
constructor(options?: ThemeOptions);
|
|
64
|
+
/** Registers system preference handling, applies the initial theme, emits an `init` change, and returns this instance. */
|
|
65
|
+
init(): this;
|
|
66
|
+
/** Returns the currently active theme definition. */
|
|
67
|
+
get(): ThemeDefinition;
|
|
68
|
+
/** Applies a configured theme by name, stores it when possible, emits a `set` change, and throws `Error` for unknown names. */
|
|
69
|
+
set(name: string): ThemeDefinition;
|
|
70
|
+
/** Toggles between the configured system light and dark themes, stores the preference when possible, and emits a `toggle` change. */
|
|
71
|
+
toggle(): ThemeDefinition;
|
|
72
|
+
/** Removes the stored preference when possible, applies the current system theme, and emits a `clearPreference` change. */
|
|
73
|
+
clearPreference(): ThemeDefinition;
|
|
74
|
+
/** Returns the stored configured theme name, or `null` during SSR, storage failures, or invalid stored preferences. */
|
|
75
|
+
getStored(): string | null;
|
|
76
|
+
/** Returns the configured theme for the current OS color-scheme preference, or the default theme without browser support. */
|
|
77
|
+
getSystem(): ThemeDefinition;
|
|
78
|
+
/** Registers a listener for in-process theme changes and returns an unsubscribe function. */
|
|
79
|
+
subscribe(listener: ThemeChangeListener): () => void;
|
|
80
|
+
/** Removes the system preference listener and clears in-process subscribers. */
|
|
81
|
+
destroy(): void;
|
|
82
|
+
}
|
|
83
|
+
//#endregion
|
|
84
|
+
export { SystemThemeMap, THEME_CHANGE_EVENT, Theme, ThemeChangeDetail, ThemeChangeListener, ThemeChangeSource, ThemeClassResolver, ThemeDefinition, ThemeOptions, darkTheme, lightTheme };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
//#region src/index.ts
|
|
2
|
+
/** Window event name dispatched with `ThemeChangeDetail` after a theme change is applied in browser environments. */
|
|
3
|
+
const THEME_CHANGE_EVENT = "themechange";
|
|
4
|
+
const DEFAULT_STORAGE_KEY = "app-theme-preference";
|
|
5
|
+
const DEFAULT_ATTRIBUTE = "data-theme";
|
|
6
|
+
const DARK_CLASS = "dark";
|
|
7
|
+
const PREFERS_DARK_QUERY = "(prefers-color-scheme: dark)";
|
|
8
|
+
const CLASS_TOKEN_WHITESPACE = /\s/;
|
|
9
|
+
/** Built-in light theme used by default and available for custom theme lists. */
|
|
10
|
+
const lightTheme = {
|
|
11
|
+
name: "light",
|
|
12
|
+
colorScheme: "light"
|
|
13
|
+
};
|
|
14
|
+
/** Built-in dark theme used by default and available for custom theme lists. */
|
|
15
|
+
const darkTheme = {
|
|
16
|
+
name: "dark",
|
|
17
|
+
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
|
+
},
|
|
26
|
+
storageKey: DEFAULT_STORAGE_KEY,
|
|
27
|
+
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);
|
|
38
|
+
assertClassToken(className, `Theme class resolver returned an invalid class for theme: ${theme.name}.`);
|
|
39
|
+
return className;
|
|
40
|
+
}
|
|
41
|
+
return `theme-${theme.name}`;
|
|
42
|
+
};
|
|
43
|
+
const assertClassToken = (className, message) => {
|
|
44
|
+
if (className.length === 0 || CLASS_TOKEN_WHITESPACE.test(className)) throw new Error(message);
|
|
45
|
+
};
|
|
46
|
+
const assertThemeConfig = (options) => {
|
|
47
|
+
const names = /* @__PURE__ */ new Set();
|
|
48
|
+
for (const theme of options.themes) {
|
|
49
|
+
if (theme.name.trim().length === 0) throw new Error("Theme names must be non-empty.");
|
|
50
|
+
if (names.has(theme.name)) throw new Error(`Duplicate theme name: ${theme.name}.`);
|
|
51
|
+
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}.`);
|
|
53
|
+
}
|
|
54
|
+
if (!names.has(options.defaultTheme)) throw new Error(`Default theme is not configured: ${options.defaultTheme}.`);
|
|
55
|
+
if (!names.has(options.systemTheme.light)) throw new Error(`System light theme is not configured: ${options.systemTheme.light}.`);
|
|
56
|
+
if (!names.has(options.systemTheme.dark)) throw new Error(`System dark theme is not configured: ${options.systemTheme.dark}.`);
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Manages theme preference, DOM application, system preference changes, and change notifications.
|
|
60
|
+
*
|
|
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.
|
|
63
|
+
*/
|
|
64
|
+
var Theme = class {
|
|
65
|
+
#options;
|
|
66
|
+
#activeName;
|
|
67
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
68
|
+
#mediaQueryList = null;
|
|
69
|
+
#handleSystemChange = (event) => {
|
|
70
|
+
if (this.getStored() !== null) return;
|
|
71
|
+
const name = event.matches ? this.#options.systemTheme.dark : this.#options.systemTheme.light;
|
|
72
|
+
this.#activate(name, "system", { shouldStore: false });
|
|
73
|
+
};
|
|
74
|
+
/** Creates a theme manager with default light/dark themes unless overridden. */
|
|
75
|
+
constructor(options = {}) {
|
|
76
|
+
this.#options = {
|
|
77
|
+
...defaultOptions,
|
|
78
|
+
...options,
|
|
79
|
+
systemTheme: {
|
|
80
|
+
...defaultOptions.systemTheme,
|
|
81
|
+
...options.systemTheme
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
assertThemeConfig(this.#options);
|
|
85
|
+
this.#activeName = this.#options.defaultTheme;
|
|
86
|
+
}
|
|
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 });
|
|
91
|
+
return this;
|
|
92
|
+
}
|
|
93
|
+
/** Returns the currently active theme definition. */
|
|
94
|
+
get() {
|
|
95
|
+
return this.#getTheme(this.#activeName) ?? this.#getTheme(this.#options.defaultTheme);
|
|
96
|
+
}
|
|
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 });
|
|
100
|
+
}
|
|
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 });
|
|
105
|
+
}
|
|
106
|
+
/** Removes the stored preference when possible, applies the current system theme, and emits a `clearPreference` change. */
|
|
107
|
+
clearPreference() {
|
|
108
|
+
this.#removeStored();
|
|
109
|
+
return this.#activate(this.getSystem().name, "clearPreference", { shouldStore: false });
|
|
110
|
+
}
|
|
111
|
+
/** Returns the stored configured theme name, or `null` during SSR, storage failures, or invalid stored preferences. */
|
|
112
|
+
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
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** Returns the configured theme for the current OS color-scheme preference, or the default theme without browser support. */
|
|
122
|
+
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);
|
|
126
|
+
}
|
|
127
|
+
/** Registers a listener for in-process theme changes and returns an unsubscribe function. */
|
|
128
|
+
subscribe(listener) {
|
|
129
|
+
this.#listeners.add(listener);
|
|
130
|
+
return () => {
|
|
131
|
+
this.#listeners.delete(listener);
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/** Removes the system preference listener and clears in-process subscribers. */
|
|
135
|
+
destroy() {
|
|
136
|
+
if (this.#mediaQueryList !== null) {
|
|
137
|
+
this.#mediaQueryList.removeEventListener("change", this.#handleSystemChange);
|
|
138
|
+
this.#mediaQueryList = null;
|
|
139
|
+
}
|
|
140
|
+
this.#listeners.clear();
|
|
141
|
+
}
|
|
142
|
+
#activate(name, source, options) {
|
|
143
|
+
const theme = this.#getTheme(name);
|
|
144
|
+
this.#activeName = theme.name;
|
|
145
|
+
if (options.shouldStore) this.#store(theme.name);
|
|
146
|
+
this.#apply(theme);
|
|
147
|
+
this.#emit({
|
|
148
|
+
name: theme.name,
|
|
149
|
+
theme,
|
|
150
|
+
source
|
|
151
|
+
});
|
|
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");
|
|
164
|
+
}
|
|
165
|
+
#emit(detail) {
|
|
166
|
+
for (const listener of this.#listeners) listener(detail);
|
|
167
|
+
if (!isBrowser()) return;
|
|
168
|
+
window.dispatchEvent(new CustomEvent(THEME_CHANGE_EVENT, { detail }));
|
|
169
|
+
}
|
|
170
|
+
#getTheme(name) {
|
|
171
|
+
const theme = this.#options.themes.find((candidate) => candidate.name === name);
|
|
172
|
+
if (theme === void 0) throw new Error(`Theme is not configured: ${name}.`);
|
|
173
|
+
return theme;
|
|
174
|
+
}
|
|
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
|
+
};
|
|
197
|
+
//#endregion
|
|
198
|
+
export { THEME_CHANGE_EVENT, Theme, darkTheme, lightTheme };
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@codenhub/theme",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Zero-dependency browser theme preference helper for TypeScript apps.",
|
|
6
|
+
"homepage": "https://github.com/codenhub/codenhub/tree/main/packages/theme",
|
|
7
|
+
"license": "Apache-2.0",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/codenhub/codenhub.git",
|
|
11
|
+
"directory": "packages/theme"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "./dist/index.js",
|
|
18
|
+
"module": "./dist/index.js",
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"import": "./dist/index.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"jsdom": "^29.1.1",
|
|
31
|
+
"typescript": "^6.0.3",
|
|
32
|
+
"vitest": "^4.0.17"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsdown src/index.ts --format esm --dts --clean --no-fixed-extension",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"test:coverage": "vitest run --coverage",
|
|
38
|
+
"test:watch": "vitest",
|
|
39
|
+
"typecheck": "tsc --noEmit"
|
|
40
|
+
}
|
|
41
|
+
}
|