ngx-theme-stack 1.0.1 → 2.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 +117 -33
- package/fesm2022/ngx-theme-stack.mjs +228 -50
- package/fesm2022/ngx-theme-stack.mjs.map +1 -1
- package/package.json +1 -1
- package/schematics/collection.json +6 -1
- package/schematics/ng-add/anti-flash.d.ts +26 -0
- package/schematics/ng-add/anti-flash.js +135 -0
- package/schematics/ng-add/anti-flash.js.map +1 -0
- package/schematics/ng-add/app-config.d.ts +3 -3
- package/schematics/ng-add/app-config.js +9 -7
- package/schematics/ng-add/app-config.js.map +1 -1
- package/schematics/ng-add/constants.d.ts +5 -5
- package/schematics/ng-add/constants.js +5 -5
- package/schematics/ng-add/constants.js.map +1 -1
- package/schematics/ng-add/index.js +115 -15
- package/schematics/ng-add/index.js.map +1 -1
- package/schematics/ng-add/schema.d.ts +2 -0
- package/schematics/ng-add/schema.json +7 -2
- package/schematics/ng-add/utils.d.ts +1 -6
- package/schematics/ng-add/utils.js +10 -27
- package/schematics/ng-add/utils.js.map +1 -1
- package/schematics/sync/index.d.ts +3 -0
- package/schematics/sync/index.js +314 -0
- package/schematics/sync/index.js.map +1 -0
- package/schematics/sync/schema.d.ts +6 -0
- package/schematics/sync/schema.js +3 -0
- package/schematics/sync/schema.js.map +1 -0
- package/schematics/sync/schema.json +21 -0
- package/types/ngx-theme-stack.d.ts +217 -43
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# ngx-theme-stack 🎨
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
A simple and powerful headless theme manager for **Angular**. Built for performance and SSR support.
|
|
4
6
|
|
|
5
7
|
## 🚀 Features
|
|
6
8
|
|
|
@@ -10,6 +12,7 @@ A complete and lightweight solution for managing themes (light, dark, system, an
|
|
|
10
12
|
- 🛠️ **Highly Customizable**: Support for custom themes, class prefixes, and configurable storage.
|
|
11
13
|
- 🧱 **Modern Architecture**: Powered by Angular Signals for maximum reactivity and performance.
|
|
12
14
|
- 🌍 **SSR Ready**: Safe to use in Server-Side Rendering environments.
|
|
15
|
+
- 🚫 **Zero Flicker**: Includes an optimized anti-flash script and the **Critters Trick** strategy to prevent theme jumps and network requests on load.
|
|
13
16
|
|
|
14
17
|
## 📦 Installation
|
|
15
18
|
|
|
@@ -23,26 +26,29 @@ ng add ngx-theme-stack
|
|
|
23
26
|
|
|
24
27
|
When running `ng add`, you will be presented with two configuration options:
|
|
25
28
|
|
|
26
|
-
1. **Quick Mode**:
|
|
29
|
+
1. **Quick Mode**:
|
|
27
30
|
- Applies default configuration instantly.
|
|
28
31
|
- Initial theme: `system`.
|
|
29
32
|
- Apply mode: `class` (adds the theme class to the `<html>` element).
|
|
30
33
|
- Available themes: `['light', 'dark', 'system']`.
|
|
34
|
+
- **Strategy**: `critters` (Zero-flash via CSS inlining).
|
|
31
35
|
|
|
32
36
|
2. **Custom Mode**:
|
|
33
37
|
- Choose which themes to include (e.g., if you have a `blue` or `high-contrast` theme).
|
|
34
38
|
- Configure the default theme upon app startup.
|
|
35
39
|
- Change the `localStorage` key where the theme choice is saved.
|
|
36
40
|
- Decide how to apply themes: via classes (`class`), attributes (`data-theme`), or both.
|
|
41
|
+
- **Pick your strategy**: `critters` for modern SSR/SSG apps or `blocking` for standard CSS loading.
|
|
37
42
|
|
|
38
43
|
## 🏗️ Architecture & Extensibility
|
|
39
44
|
|
|
40
45
|
The library is designed to be flexible. The **`CoreThemeService`** is the foundation:
|
|
41
46
|
|
|
42
|
-
-
|
|
43
|
-
-
|
|
47
|
+
- **Solid Base:** Manages state (`Signal`), persistence (`localStorage`), system detection (`matchMedia`), and safe DOM manipulation (SSR compatible).
|
|
48
|
+
- **Extensibility:** You can inject `CoreThemeService` to build your own custom services or components with specific business logic.
|
|
44
49
|
|
|
45
50
|
### Utility Services (Ready to Use)
|
|
51
|
+
|
|
46
52
|
For common use cases, we include three services with predefined logic:
|
|
47
53
|
|
|
48
54
|
1. **`ThemeToggleService`**: A simple binary switch between `light` and `dark`.
|
|
@@ -53,18 +59,18 @@ For common use cases, we include three services with predefined logic:
|
|
|
53
59
|
|
|
54
60
|
## ⚙️ Supported Versions
|
|
55
61
|
|
|
56
|
-
| Angular Version | Support
|
|
57
|
-
|
|
|
58
|
-
| **Angular 21**
|
|
59
|
-
| **Angular 20**
|
|
60
|
-
| **Angular 19**
|
|
61
|
-
| **Angular 18**
|
|
62
|
+
| Angular Version | Support |
|
|
63
|
+
| :-------------- | :-------- |
|
|
64
|
+
| **Angular 21** | ✅ Stable |
|
|
65
|
+
| **Angular 20** | ✅ Stable |
|
|
66
|
+
| **Angular 19** | ✅ Stable |
|
|
67
|
+
| **Angular 18** | ✅ Stable |
|
|
62
68
|
|
|
63
69
|
## 🛠️ Basic Usage
|
|
64
70
|
|
|
65
|
-
### CoreThemeService
|
|
71
|
+
### CoreThemeService API
|
|
66
72
|
|
|
67
|
-
|
|
73
|
+
The foundational service managing the theme state. It exposes pure Angular Signals and a solid minimal API.
|
|
68
74
|
|
|
69
75
|
```typescript
|
|
70
76
|
import { inject } from '@angular/core';
|
|
@@ -72,14 +78,29 @@ import { CoreThemeService } from 'ngx-theme-stack';
|
|
|
72
78
|
|
|
73
79
|
@Component({ ... })
|
|
74
80
|
export class MyComponent {
|
|
75
|
-
|
|
81
|
+
themeService = inject(CoreThemeService);
|
|
82
|
+
|
|
83
|
+
/* --- 📊 Reactive Signals --- */
|
|
84
|
+
|
|
85
|
+
// The exact theme chosen by the user ('dark', 'light', 'system', etc.)
|
|
86
|
+
selectedTheme = this.themeService.selectedTheme;
|
|
87
|
+
|
|
88
|
+
// The theme finally applied to the DOM (resolves 'system' to 'dark' or 'light')
|
|
89
|
+
resolvedTheme = this.themeService.resolvedTheme;
|
|
76
90
|
|
|
77
|
-
//
|
|
78
|
-
isDark = this.themeService.isDark;
|
|
79
|
-
|
|
91
|
+
// Helper boolean signals evaluating the applied theme
|
|
92
|
+
isDark = this.themeService.isDark;
|
|
93
|
+
isLight = this.themeService.isLight;
|
|
94
|
+
isSystem = this.themeService.isSystem;
|
|
80
95
|
|
|
81
|
-
|
|
82
|
-
|
|
96
|
+
// True after the first browser render. Great for preventing SSR flickering!
|
|
97
|
+
isHydrated = this.themeService.isHydrated;
|
|
98
|
+
|
|
99
|
+
/* --- 🛠️ Methods --- */
|
|
100
|
+
|
|
101
|
+
changeTheme(newTheme: string) {
|
|
102
|
+
// Validates, applies to the DOM, and saves to localStorage
|
|
103
|
+
this.themeService.setTheme(newTheme);
|
|
83
104
|
}
|
|
84
105
|
}
|
|
85
106
|
```
|
|
@@ -94,10 +115,8 @@ import { ThemeToggleService } from 'ngx-theme-stack';
|
|
|
94
115
|
@Component({
|
|
95
116
|
selector: 'app-theme-toggle',
|
|
96
117
|
template: `
|
|
97
|
-
<button (click)="toggle.toggle()">
|
|
98
|
-
|
|
99
|
-
</button>
|
|
100
|
-
`
|
|
118
|
+
<button (click)="toggle.toggle()">Switch to {{ toggle.isDark() ? 'Light' : 'Dark' }}</button>
|
|
119
|
+
`,
|
|
101
120
|
})
|
|
102
121
|
export class ThemeToggleComponent {
|
|
103
122
|
protected toggle = inject(ThemeToggleService);
|
|
@@ -106,26 +125,91 @@ export class ThemeToggleComponent {
|
|
|
106
125
|
|
|
107
126
|
## 🎨 Styling
|
|
108
127
|
|
|
109
|
-
|
|
128
|
+
The `ng add` command automatically creates a **`src/themes.css`** file in your project. This is where you should define your theme-specific CSS variables.
|
|
129
|
+
|
|
130
|
+
The library targets the `<html>` element. Based on your configured `mode`, you should define your variables like this:
|
|
110
131
|
|
|
111
132
|
```css
|
|
112
|
-
/*
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
133
|
+
/* src/themes.css */
|
|
134
|
+
|
|
135
|
+
/* Using Classes (Default Mode) */
|
|
136
|
+
:root,
|
|
137
|
+
.light {
|
|
138
|
+
--bg-color: #ffffff;
|
|
139
|
+
--text-color: #333333;
|
|
116
140
|
}
|
|
117
141
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
color: #
|
|
142
|
+
.dark {
|
|
143
|
+
--bg-color: #121212;
|
|
144
|
+
--text-color: #ffffff;
|
|
121
145
|
}
|
|
122
146
|
|
|
123
|
-
/* Using Attributes */
|
|
124
|
-
[data-theme='
|
|
125
|
-
--
|
|
147
|
+
/* Using Attributes (Attribute Mode) */
|
|
148
|
+
[data-theme='sunset'] {
|
|
149
|
+
--bg-color: #ff5f6d;
|
|
150
|
+
--text-color: #ffffff;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* Base styles using the variables */
|
|
154
|
+
body {
|
|
155
|
+
background-color: var(--bg-color);
|
|
156
|
+
color: var(--text-color);
|
|
157
|
+
transition: background-color 0.3s ease;
|
|
126
158
|
}
|
|
127
159
|
```
|
|
128
160
|
|
|
161
|
+
## 🌪️ Tailwind CSS v4 Integration
|
|
162
|
+
|
|
163
|
+
If you are using **Tailwind CSS v4**, you can achieve a much cleaner HTML by mapping your `themes.css` variables to your Tailwind theme. This avoids cluttering your components with `dark:` variants.
|
|
164
|
+
|
|
165
|
+
### 1. Configure Custom Variants
|
|
166
|
+
|
|
167
|
+
In your main `styles.css`, define how Tailwind should detect your themes:
|
|
168
|
+
|
|
169
|
+
```css
|
|
170
|
+
/* src/styles.css */
|
|
171
|
+
@import 'tailwindcss';
|
|
172
|
+
|
|
173
|
+
/* If using Class mode */
|
|
174
|
+
@custom-variant dark (&:where(.dark, .dark *));
|
|
175
|
+
|
|
176
|
+
/* If using Attribute mode */
|
|
177
|
+
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### 2. Map Semantic Variables
|
|
181
|
+
|
|
182
|
+
Extend your Tailwind theme using the variables defined in `themes.css`:
|
|
183
|
+
|
|
184
|
+
```css
|
|
185
|
+
@theme {
|
|
186
|
+
--color-main-bg: var(--bg-color);
|
|
187
|
+
--color-main-text: var(--text-color);
|
|
188
|
+
--color-card-bg: var(--card-bg);
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### 3. Usage in Components
|
|
193
|
+
|
|
194
|
+
Now, instead of writing `<div class="bg-white dark:bg-black">`, you simply write:
|
|
195
|
+
|
|
196
|
+
```html
|
|
197
|
+
<div class="bg-main-bg text-main-text shadow-xl">
|
|
198
|
+
<!-- This automatically changes colors based on the active theme -->
|
|
199
|
+
</div>
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
This approach keeps your UI code clean, semantic, and fully synchronized with `ngx-theme-stack`.
|
|
203
|
+
|
|
204
|
+
## ⚡ Performance Strategies
|
|
205
|
+
|
|
206
|
+
`ngx-theme-stack` offers two ways to handle the initial theme application to prevent that annoying white flash:
|
|
207
|
+
|
|
208
|
+
1. **Critters (Default)**: Best for SSR/Static sites. It uses hidden markers to trick the Angular builder into inlining all your theme CSS variables directly in the HTML `<head>`. Result: **Zero network requests for CSS variables.**
|
|
209
|
+
2. **Blocking**: Best for standard SPAs. It loads the `themes.css` file as a traditional blocking resource.
|
|
210
|
+
|
|
211
|
+
The `ng-add` schematic helps you configure the right one automatically.
|
|
212
|
+
|
|
129
213
|
## 📄 License
|
|
130
214
|
|
|
131
215
|
[MIT](./LICENSE)
|
|
@@ -1,35 +1,139 @@
|
|
|
1
1
|
import { isPlatformBrowser } from '@angular/common';
|
|
2
2
|
import * as i0 from '@angular/core';
|
|
3
|
-
import { InjectionToken, inject, DestroyRef, DOCUMENT, PLATFORM_ID, signal, computed, effect, Injectable } from '@angular/core';
|
|
3
|
+
import { InjectionToken, inject, DestroyRef, DOCUMENT, PLATFORM_ID, signal, computed, effect, afterNextRender, Injectable } from '@angular/core';
|
|
4
4
|
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* Base error class for `ngx-theme-stack`.
|
|
7
|
+
*
|
|
8
|
+
* Thrown when the library configuration is invalid.
|
|
9
|
+
* Consumers can use `instanceof NgxThemeStackError` to catch only
|
|
10
|
+
* errors originating from this library.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* try {
|
|
14
|
+
* bootstrapApplication(AppComponent, appConfig);
|
|
15
|
+
* } catch (e) {
|
|
16
|
+
* if (e instanceof NgxThemeStackError) {
|
|
17
|
+
* console.error('Bad ngx-theme-stack config:', e.message);
|
|
18
|
+
* }
|
|
19
|
+
* }
|
|
20
|
+
*/
|
|
21
|
+
class NgxThemeStackError extends Error {
|
|
22
|
+
name = 'NgxThemeStackError';
|
|
23
|
+
constructor(message) {
|
|
24
|
+
super(`[ngx-theme-stack] ${message}`);
|
|
25
|
+
// Restore prototype chain (required when targeting ES5 or older)
|
|
26
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Runtime list of built-in themes.
|
|
32
|
+
*
|
|
33
|
+
* Lives here (and not in config/index.ts) because it defines a type:
|
|
34
|
+
* config/index.ts already imports from types.ts, so placing DEFAULT_THEMES
|
|
35
|
+
* here avoids any circular dependency.
|
|
36
|
+
*
|
|
37
|
+
* ⚠ KEEP IN SYNC with the duplicate in:
|
|
38
|
+
* projects/ngx-theme-stack/schematics/ng-add/constants.ts → DEFAULT_THEMES
|
|
39
|
+
*
|
|
40
|
+
* Schematics compile to CommonJS and cannot import from this ESM file,
|
|
41
|
+
* so the values are intentionally duplicated. Change both at the same time.
|
|
42
|
+
*/
|
|
6
43
|
const DEFAULT_THEMES = ['system', 'light', 'dark'];
|
|
7
44
|
|
|
8
45
|
/**
|
|
9
46
|
* ⚠ ATTENTION: SHARED CONFIGURATION VALUES
|
|
10
47
|
*
|
|
11
|
-
* These
|
|
12
|
-
* projects/ngx-theme-stack/schematics/ng-add/
|
|
48
|
+
* These defaults MUST match the schematic defaults in:
|
|
49
|
+
* projects/ngx-theme-stack/schematics/ng-add/constants.ts → DEFAULTS
|
|
13
50
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
51
|
+
* Schematics compile to CommonJS and cannot import from this ESM file,
|
|
52
|
+
* so the values are intentionally duplicated. Change both at the same time.
|
|
53
|
+
*
|
|
54
|
+
* If you change defaults here, also update:
|
|
55
|
+
* schematics/ng-add/constants.ts → DEFAULTS + DEFAULT_THEMES
|
|
16
56
|
*/
|
|
17
57
|
const DEFAULT_NG_CONFIG = {
|
|
18
|
-
|
|
58
|
+
defaultTheme: 'system',
|
|
19
59
|
storageKey: 'ngx-theme-stack-theme',
|
|
20
60
|
mode: 'class',
|
|
61
|
+
strategy: 'critters',
|
|
21
62
|
themes: [...DEFAULT_THEMES],
|
|
22
63
|
};
|
|
64
|
+
// The token uses NgConfig<string> because Angular DI resolves types at runtime
|
|
65
|
+
// and cannot carry generic parameters. Type-safety is enforced at the
|
|
66
|
+
// provideThemeStack() call site instead.
|
|
23
67
|
const NGX_THEME_STACK_CONFIG = new InjectionToken('NGX_THEME_STACK_CONFIG', {
|
|
24
68
|
factory: () => DEFAULT_NG_CONFIG,
|
|
25
69
|
});
|
|
26
70
|
/**
|
|
27
|
-
*
|
|
71
|
+
* Provides Theme Stack configuration to Angular's DI system.
|
|
72
|
+
*
|
|
73
|
+
* Custom `themes` are **merged** with the built-in defaults
|
|
74
|
+
* (`'light'`, `'dark'`, `'system'`), so you never lose the base themes.
|
|
75
|
+
*
|
|
76
|
+
* **Defaults:**
|
|
77
|
+
* - `themes`: `['light', 'dark', 'system']`
|
|
78
|
+
* - `defaultTheme`: `'system'`
|
|
79
|
+
* - `storageKey`: `'ngx-theme-stack-theme'`
|
|
80
|
+
* - `mode`: `'class'`
|
|
81
|
+
* - `strategy`: `'critters'`
|
|
82
|
+
|
|
83
|
+
*
|
|
84
|
+
* The type parameter `T` is **inferred automatically** from the `themes` array
|
|
85
|
+
* when passed as a `const` — no need to specify it manually.
|
|
86
|
+
*
|
|
87
|
+
* @typeParam T - Custom theme string literals, inferred from the `themes` option.
|
|
88
|
+
*
|
|
89
|
+
* @param config - Optional partial configuration. Omitted fields fall back to
|
|
90
|
+
* {@link DEFAULT_NG_CONFIG}.
|
|
91
|
+
*
|
|
92
|
+
* @throws {@link NgxThemeStackError}
|
|
93
|
+
* - If any entry in `themes` is an empty or whitespace-only string.
|
|
94
|
+
* - If `defaultTheme` is not present in the resolved (merged) themes array.
|
|
95
|
+
* - If `storageKey` is an empty or whitespace-only string.
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* // Default — uses built-in themes and sensible defaults
|
|
99
|
+
* provideThemeStack()
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* // SSR/SSG Optimization — uses Critters inlining strategy
|
|
103
|
+
* provideThemeStack({
|
|
104
|
+
* strategy: 'critters',
|
|
105
|
+
* mode: 'class',
|
|
106
|
+
* })
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* // Closed union: TypeScript infers 'sepia' | 'ocean' from the array
|
|
110
|
+
* provideThemeStack({
|
|
111
|
+
* themes: ['sepia', 'ocean'] as const,
|
|
112
|
+
* defaultTheme: 'sepia', // ✅ in resolved themes
|
|
113
|
+
* // defaultTheme: 'nope', // ❌ throws NgxThemeStackError at runtime
|
|
114
|
+
* })
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* // Custom storage key and mode
|
|
118
|
+
* provideThemeStack({
|
|
119
|
+
* storageKey: 'my-app-theme',
|
|
120
|
+
* mode: 'attribute',
|
|
121
|
+
* })
|
|
28
122
|
*/
|
|
29
123
|
function provideThemeStack(config = {}) {
|
|
124
|
+
config.themes?.forEach((t) => {
|
|
125
|
+
if (t.trim() === '')
|
|
126
|
+
throw new NgxThemeStackError('Theme cannot be empty or whitespace.');
|
|
127
|
+
});
|
|
30
128
|
const themes = config.themes
|
|
31
129
|
? Array.from(new Set([...DEFAULT_NG_CONFIG.themes, ...config.themes]))
|
|
32
130
|
: DEFAULT_NG_CONFIG.themes;
|
|
131
|
+
if (config.defaultTheme && !themes.includes(config.defaultTheme)) {
|
|
132
|
+
throw new NgxThemeStackError(`"defaultTheme" must be one of the resolved themes: [${themes.join(', ')}].`);
|
|
133
|
+
}
|
|
134
|
+
if (config.storageKey !== undefined && config.storageKey.trim() === '') {
|
|
135
|
+
throw new NgxThemeStackError('"storageKey" cannot be empty or whitespace.');
|
|
136
|
+
}
|
|
33
137
|
return {
|
|
34
138
|
provide: NGX_THEME_STACK_CONFIG,
|
|
35
139
|
useValue: {
|
|
@@ -53,10 +157,21 @@ class CoreThemeService {
|
|
|
53
157
|
#document = inject(DOCUMENT);
|
|
54
158
|
#isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
55
159
|
// ── Theme configuration ───────────────────────────────────────────────────
|
|
56
|
-
/**
|
|
160
|
+
/**
|
|
161
|
+
* The initial stored theme read from localStorage.
|
|
162
|
+
* This is used to determine the initial theme of the application.
|
|
163
|
+
*/
|
|
164
|
+
#initialStoredTheme = this.readStoredTheme();
|
|
165
|
+
/** List of available themes for Select/Cycle services. Defaults to ['system', 'light', 'dark']. */
|
|
57
166
|
availableThemes = this.#config.themes;
|
|
58
167
|
/** Internal Set for O(1) existence checks. */
|
|
59
168
|
#validThemes = new Set(this.availableThemes);
|
|
169
|
+
/**
|
|
170
|
+
* The anti-flash class to remove from the host element.
|
|
171
|
+
* Internal mechanism to bridge the gap between the blocking script's
|
|
172
|
+
* initial DOM state and Angular's first effect run.
|
|
173
|
+
*/
|
|
174
|
+
#antiFlashClass = null;
|
|
60
175
|
// ── System preference ─────────────────────────────────────────────────────
|
|
61
176
|
/** MediaQueryList for OS color scheme, created once and reused. Null in SSR. */
|
|
62
177
|
#mediaQuery = this.#isBrowser
|
|
@@ -68,22 +183,43 @@ class CoreThemeService {
|
|
|
68
183
|
/** The theme explicitly selected by the user. May be `'system'`. */
|
|
69
184
|
selectedTheme = this.#selectedTheme.asReadonly();
|
|
70
185
|
/** Resolved theme applied to the DOM. Always `'dark'` or `'light'` (or custom) — never `'system'`. */
|
|
71
|
-
|
|
186
|
+
resolvedTheme = computed(() => {
|
|
72
187
|
const theme = this.#selectedTheme();
|
|
73
188
|
return theme === 'system' ? this.#systemPreference() : theme;
|
|
74
|
-
}, ...(ngDevMode ? [{ debugName: "
|
|
189
|
+
}, ...(ngDevMode ? [{ debugName: "resolvedTheme" }] : /* istanbul ignore next */ []));
|
|
75
190
|
/** Whether the currently applied theme is dark. */
|
|
76
|
-
isDark = computed(() => this.
|
|
191
|
+
isDark = computed(() => this.resolvedTheme() === 'dark', ...(ngDevMode ? [{ debugName: "isDark" }] : /* istanbul ignore next */ []));
|
|
77
192
|
/** Whether the currently applied theme is light. */
|
|
78
|
-
isLight = computed(() => this.
|
|
193
|
+
isLight = computed(() => this.resolvedTheme() === 'light', ...(ngDevMode ? [{ debugName: "isLight" }] : /* istanbul ignore next */ []));
|
|
194
|
+
/** Whether the currently applied theme is system. */
|
|
195
|
+
isSystem = computed(() => this.selectedTheme() === 'system', ...(ngDevMode ? [{ debugName: "isSystem" }] : /* istanbul ignore next */ []));
|
|
196
|
+
/**
|
|
197
|
+
* Whether the service has completed client-side initialization.
|
|
198
|
+
*
|
|
199
|
+
* `false` during SSR and on the very first render pass before the initial theme
|
|
200
|
+
* is resolved from `localStorage`. Becomes `true` immediately after the
|
|
201
|
+
* first browser render pass.
|
|
202
|
+
*
|
|
203
|
+
* **Important:** Guard template elements that display `selectedTheme()` or
|
|
204
|
+
* `resolvedTheme()` behind this signal to prevent hydration-mismatch flashes
|
|
205
|
+
* (e.g. if the server renders the default 'system' but the user has 'dark' stored).
|
|
206
|
+
*
|
|
207
|
+
* @example
|
|
208
|
+
* ```html
|
|
209
|
+
* {{ theme.isHydrated() ? theme.selectedTheme() : '...' }}
|
|
210
|
+
* ```
|
|
211
|
+
*/
|
|
212
|
+
isHydrated = signal(false, ...(ngDevMode ? [{ debugName: "isHydrated" }] : /* istanbul ignore next */ []));
|
|
79
213
|
// ── Event handler ─────────────────────────────────────────────────────────
|
|
80
214
|
#onSystemPreferenceChange = () => this.#systemPreference.set(this.resolveSystemPreference());
|
|
81
215
|
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
82
216
|
constructor() {
|
|
217
|
+
this.captureAntiFlashClass();
|
|
83
218
|
if (this.#isBrowser && this.#selectedTheme() === 'system') {
|
|
84
219
|
this.startSystemThemeListener();
|
|
85
220
|
}
|
|
86
|
-
effect(() => this.applyThemeToDOM(this.
|
|
221
|
+
effect(() => this.applyThemeToDOM(this.resolvedTheme()));
|
|
222
|
+
afterNextRender(() => this.isHydrated.set(true));
|
|
87
223
|
this.#destroyRef.onDestroy(() => this.stopSystemThemeListener());
|
|
88
224
|
}
|
|
89
225
|
// ── Public API ────────────────────────────────────────────────────────────
|
|
@@ -91,18 +227,20 @@ class CoreThemeService {
|
|
|
91
227
|
* Changes the active theme.
|
|
92
228
|
*
|
|
93
229
|
* Persists the choice explicitly so that switching e.g. from `'system'` to
|
|
94
|
-
* `'light'` is saved even when the resolved
|
|
230
|
+
* `'light'` is saved even when the resolved theme did not change
|
|
95
231
|
* (system preference was already `'light'`).
|
|
96
232
|
*
|
|
97
233
|
* @param theme - The theme to apply: `'dark'`, `'light'`, `'system'`, or a custom theme name.
|
|
98
234
|
* @throws If `theme` is not a valid theme according to library configuration.
|
|
99
235
|
*/
|
|
100
236
|
setTheme(theme) {
|
|
101
|
-
if (!this.#isBrowser)
|
|
102
|
-
return;
|
|
103
237
|
if (!this.#validThemes.has(theme)) {
|
|
104
|
-
throw new
|
|
238
|
+
throw new NgxThemeStackError(`Invalid theme: "${theme}". Valid values are: ${[...this.#validThemes].join(', ')}.`);
|
|
105
239
|
}
|
|
240
|
+
if (!this.#isBrowser)
|
|
241
|
+
return;
|
|
242
|
+
if (theme === this.#selectedTheme())
|
|
243
|
+
return;
|
|
106
244
|
if (theme === 'system') {
|
|
107
245
|
this.#systemPreference.set(this.resolveSystemPreference());
|
|
108
246
|
this.startSystemThemeListener();
|
|
@@ -118,9 +256,11 @@ class CoreThemeService {
|
|
|
118
256
|
return this.#mediaQuery?.matches ? 'dark' : 'light';
|
|
119
257
|
}
|
|
120
258
|
resolveInitialTheme() {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
259
|
+
const theme = this.#initialStoredTheme;
|
|
260
|
+
if (theme && this.#validThemes.has(theme)) {
|
|
261
|
+
return theme;
|
|
262
|
+
}
|
|
263
|
+
return this.#config.defaultTheme;
|
|
124
264
|
}
|
|
125
265
|
startSystemThemeListener() {
|
|
126
266
|
if (!this.#mediaQuery)
|
|
@@ -131,26 +271,28 @@ class CoreThemeService {
|
|
|
131
271
|
stopSystemThemeListener() {
|
|
132
272
|
this.#mediaQuery?.removeEventListener('change', this.#onSystemPreferenceChange);
|
|
133
273
|
}
|
|
134
|
-
applyThemeToDOM(
|
|
274
|
+
applyThemeToDOM(theme) {
|
|
135
275
|
if (!this.#isBrowser)
|
|
136
276
|
return;
|
|
137
277
|
const host = this.#document.documentElement;
|
|
278
|
+
if (this.#antiFlashClass) {
|
|
279
|
+
host.classList.remove(this.#antiFlashClass);
|
|
280
|
+
this.#antiFlashClass = null;
|
|
281
|
+
}
|
|
138
282
|
const { mode } = this.#config;
|
|
139
283
|
if (mode === 'attribute' || mode === 'both') {
|
|
140
|
-
this.applyThemeAttribute(host,
|
|
284
|
+
this.applyThemeAttribute(host, theme);
|
|
141
285
|
}
|
|
142
286
|
if (mode === 'class' || mode === 'both') {
|
|
143
|
-
this.applyThemeClasses(host,
|
|
287
|
+
this.applyThemeClasses(host, theme);
|
|
144
288
|
}
|
|
145
|
-
this.applyColorSchemeHint(host,
|
|
289
|
+
this.applyColorSchemeHint(host, theme);
|
|
146
290
|
}
|
|
147
291
|
applyThemeAttribute(host, theme) {
|
|
148
292
|
host.setAttribute('data-theme', theme);
|
|
149
293
|
}
|
|
150
294
|
applyThemeClasses(host, theme) {
|
|
151
|
-
|
|
152
|
-
host.classList.remove(t);
|
|
153
|
-
}
|
|
295
|
+
host.classList.remove(...this.availableThemes);
|
|
154
296
|
host.classList.add(theme);
|
|
155
297
|
}
|
|
156
298
|
applyColorSchemeHint(host, theme) {
|
|
@@ -160,13 +302,24 @@ class CoreThemeService {
|
|
|
160
302
|
}
|
|
161
303
|
host.style.removeProperty('color-scheme');
|
|
162
304
|
}
|
|
305
|
+
captureAntiFlashClass() {
|
|
306
|
+
if (!this.#isBrowser || !this.#initialStoredTheme)
|
|
307
|
+
return;
|
|
308
|
+
if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(this.#initialStoredTheme)) {
|
|
309
|
+
this.#antiFlashClass = null;
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (this.#initialStoredTheme === 'system') {
|
|
313
|
+
this.#antiFlashClass = this.resolveSystemPreference();
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
this.#antiFlashClass = this.#initialStoredTheme;
|
|
317
|
+
}
|
|
163
318
|
readStoredTheme() {
|
|
164
|
-
|
|
165
|
-
const stored = localStorage.getItem(this.#config.storageKey);
|
|
166
|
-
if (stored && this.#validThemes.has(stored)) {
|
|
167
|
-
return stored;
|
|
168
|
-
}
|
|
319
|
+
if (!this.#isBrowser)
|
|
169
320
|
return null;
|
|
321
|
+
try {
|
|
322
|
+
return localStorage.getItem(this.#config.storageKey);
|
|
170
323
|
}
|
|
171
324
|
catch (e) {
|
|
172
325
|
console.warn('[ngx-theme-stack] Could not read theme from localStorage.', e);
|
|
@@ -174,6 +327,8 @@ class CoreThemeService {
|
|
|
174
327
|
}
|
|
175
328
|
}
|
|
176
329
|
saveTheme(theme) {
|
|
330
|
+
if (!this.#isBrowser)
|
|
331
|
+
return;
|
|
177
332
|
try {
|
|
178
333
|
localStorage.setItem(this.#config.storageKey, theme);
|
|
179
334
|
}
|
|
@@ -192,23 +347,30 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.7", ngImpor
|
|
|
192
347
|
/**
|
|
193
348
|
* Convenience service for cycling through themes in a fixed order.
|
|
194
349
|
*
|
|
195
|
-
* Default cycle: `'
|
|
350
|
+
* Default cycle: `'system'` → `'light'` → `'dark'` → `'system'` → ...
|
|
196
351
|
*
|
|
197
352
|
* Use this when you want to offer users a single button that rotates
|
|
198
353
|
* through all available theme options.
|
|
199
354
|
*/
|
|
200
355
|
class ThemeCycleService {
|
|
201
356
|
#core = inject(CoreThemeService);
|
|
202
|
-
/** List of all configured themes for cycling. Defaults to ['light', 'dark', 'system']
|
|
357
|
+
/** List of all configured themes for cycling. Defaults to `['light', 'dark', 'system']`. */
|
|
203
358
|
#cycle = this.#core.availableThemes;
|
|
204
359
|
/** The theme explicitly selected by the user. May be `'system'`. */
|
|
205
360
|
selectedTheme = this.#core.selectedTheme;
|
|
206
|
-
/** Resolved theme applied to the DOM. Always concrete — never `'system'`. */
|
|
207
|
-
|
|
208
|
-
/** Whether the currently applied theme is dark
|
|
361
|
+
/** Resolved theme currently applied to the DOM. Always concrete — never `'system'`. */
|
|
362
|
+
resolvedTheme = this.#core.resolvedTheme;
|
|
363
|
+
/** Whether the currently applied theme is `'dark'`. */
|
|
209
364
|
isDark = this.#core.isDark;
|
|
210
|
-
/** Whether the currently applied theme is light
|
|
365
|
+
/** Whether the currently applied theme is `'light'`. */
|
|
211
366
|
isLight = this.#core.isLight;
|
|
367
|
+
/** Whether the user has explicitly selected `'system'` preference. */
|
|
368
|
+
isSystem = this.#core.isSystem;
|
|
369
|
+
/**
|
|
370
|
+
* Whether the service has completed client-side initialization and
|
|
371
|
+
* resolved the real persisted theme. Use to prevent hydration flashes.
|
|
372
|
+
*/
|
|
373
|
+
isHydrated = this.#core.isHydrated.asReadonly();
|
|
212
374
|
/**
|
|
213
375
|
* Advances to the next theme in the cycle.
|
|
214
376
|
*
|
|
@@ -239,16 +401,23 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.7", ngImpor
|
|
|
239
401
|
*/
|
|
240
402
|
class ThemeSelectService {
|
|
241
403
|
#core = inject(CoreThemeService);
|
|
242
|
-
/** List of all configured themes. Defaults to ['light', 'dark', 'system']
|
|
404
|
+
/** List of all configured themes. Defaults to `['light', 'dark', 'system']`. */
|
|
243
405
|
availableThemes = this.#core.availableThemes;
|
|
244
406
|
/** The theme explicitly selected by the user. May be `'system'`. */
|
|
245
407
|
selectedTheme = this.#core.selectedTheme;
|
|
246
|
-
/** Resolved theme applied to the DOM. Always concrete — never `'system'`. */
|
|
247
|
-
|
|
248
|
-
/** Whether the currently applied theme is dark
|
|
408
|
+
/** Resolved theme currently applied to the DOM. Always concrete — never `'system'`. */
|
|
409
|
+
resolvedTheme = this.#core.resolvedTheme;
|
|
410
|
+
/** Whether the currently applied theme is `'dark'`. */
|
|
249
411
|
isDark = this.#core.isDark;
|
|
250
|
-
/** Whether the currently applied theme is light
|
|
412
|
+
/** Whether the currently applied theme is `'light'`. */
|
|
251
413
|
isLight = this.#core.isLight;
|
|
414
|
+
/** Whether the user has explicitly selected `'system'` preference. */
|
|
415
|
+
isSystem = this.#core.isSystem;
|
|
416
|
+
/**
|
|
417
|
+
* Whether the service has completed client-side initialization and
|
|
418
|
+
* resolved the real persisted theme. Use to prevent hydration flashes.
|
|
419
|
+
*/
|
|
420
|
+
isHydrated = this.#core.isHydrated.asReadonly();
|
|
252
421
|
/**
|
|
253
422
|
* Applies the given theme.
|
|
254
423
|
*
|
|
@@ -274,12 +443,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.7", ngImpor
|
|
|
274
443
|
*/
|
|
275
444
|
class ThemeToggleService {
|
|
276
445
|
#core = inject(CoreThemeService);
|
|
277
|
-
/** Resolved theme applied to the DOM. Always concrete — never `'system'`. */
|
|
278
|
-
|
|
279
|
-
/**
|
|
446
|
+
/** Resolved theme currently applied to the DOM. Always concrete — never `'system'`. */
|
|
447
|
+
resolvedTheme = this.#core.resolvedTheme;
|
|
448
|
+
/** The theme explicitly selected by the user. May be `'system'`. */
|
|
449
|
+
selectedTheme = this.#core.selectedTheme;
|
|
450
|
+
/** Whether the currently applied theme is `'dark'`. */
|
|
280
451
|
isDark = this.#core.isDark;
|
|
281
|
-
/** Whether the currently applied theme is light
|
|
452
|
+
/** Whether the currently applied theme is `'light'`. */
|
|
282
453
|
isLight = this.#core.isLight;
|
|
454
|
+
/** Whether the user has explicitly selected `'system'` preference. */
|
|
455
|
+
isSystem = this.#core.isSystem;
|
|
456
|
+
/**
|
|
457
|
+
* Whether the service has completed client-side initialization and
|
|
458
|
+
* resolved the real persisted theme. Use to prevent hydration flashes.
|
|
459
|
+
*/
|
|
460
|
+
isHydrated = this.#core.isHydrated.asReadonly();
|
|
283
461
|
/**
|
|
284
462
|
* Toggles between `'dark'` and `'light'`.
|
|
285
463
|
*
|
|
@@ -287,7 +465,7 @@ class ThemeToggleService {
|
|
|
287
465
|
* Otherwise (including `'system'`), switches to `'dark'`.
|
|
288
466
|
*/
|
|
289
467
|
toggle() {
|
|
290
|
-
const next = this.#core.
|
|
468
|
+
const next = this.#core.resolvedTheme() === 'dark' ? 'light' : 'dark';
|
|
291
469
|
this.#core.setTheme(next);
|
|
292
470
|
}
|
|
293
471
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.7", ngImport: i0, type: ThemeToggleService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
@@ -306,5 +484,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.7", ngImpor
|
|
|
306
484
|
* Generated bundle index. Do not edit.
|
|
307
485
|
*/
|
|
308
486
|
|
|
309
|
-
export { CoreThemeService, DEFAULT_NG_CONFIG, DEFAULT_THEMES, NGX_THEME_STACK_CONFIG, ThemeCycleService, ThemeSelectService, ThemeToggleService, provideThemeStack };
|
|
487
|
+
export { CoreThemeService, DEFAULT_NG_CONFIG, DEFAULT_THEMES, NGX_THEME_STACK_CONFIG, NgxThemeStackError, ThemeCycleService, ThemeSelectService, ThemeToggleService, provideThemeStack };
|
|
310
488
|
//# sourceMappingURL=ngx-theme-stack.mjs.map
|