@design.estate/dees-domtools 5.0.1 → 5.2.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 CHANGED
@@ -17,6 +17,7 @@ pnpm add @design.estate/dees-domtools
17
17
  ## What It Includes
18
18
 
19
19
  - `DomTools` for one-time app bootstrap and shared browser services
20
+ - `ThemeManager` (the class behind `domtools.themeManager`) and its `TThemePreference` type, for a bright/dark theme that follows the system until the reader picks one
20
21
  - `breakpoints` helpers for viewport and container-query driven Lit CSS
21
22
  - `css.cssGridColumns()` for simple grid-template generation
22
23
  - `elementBasic` helpers for Lit base styles and one-time global CSS setup
@@ -144,6 +145,29 @@ await themeManager.enableAutomaticGlobalThemeChange();
144
145
 
145
146
  The theme manager starts from `prefers-color-scheme` and publishes updates through an RxJS `ReplaySubject<boolean>`.
146
147
 
148
+ ### Theme preference
149
+
150
+ A theme toggle sets the reader's preference rather than the theme of the moment:
151
+
152
+ ```ts
153
+ themeManager.preference; // 'system' | 'bright' | 'dark' (TThemePreference)
154
+ themeManager.setPreference(themeManager.goBrightBoolean ? 'dark' : 'bright'); // true when kept
155
+ themeManager.setPreference('system'); // follow prefers-color-scheme again
156
+ ```
157
+
158
+ `'system'` follows `prefers-color-scheme`; `'bright'` and `'dark'` hold that theme while the system's scheme changes. A picked preference is kept in `localStorage` under `ThemeManager.preferenceStorageKey` (`dees-domtools:theme-preference`, per browser and origin; `'system'` removes the key) and is read before the theme manager's first emission, so a reload starts in it without showing the system theme first. Another tab's pick reaches every open page of the origin through the `storage` event. `setPreference()` returns `false` when the browser refuses the page its storage: the pick then holds for this page only, and it throws on a value that is no `TThemePreference`. An unknown stored value counts as no pick. `goBright()`, `goDark()` and `toggleDarkBright()` set the theme for now and leave the preference alone.
159
+
160
+ A toggle that shows the preference, not only the theme, subscribes to `stateObservable`, a `ReplaySubject<IThemeState>` that replays the current state and emits on every change of the preference, the system's colour scheme or the theme, also when the theme stays (a pick of the theme the system already shows, the system changing while a pick holds, another tab's pick):
161
+
162
+ ```ts
163
+ themeManager.stateObservable.subscribe(({ preference, systemBright, goBright }) => {
164
+ // e.g. offer "follow the system" while a pick holds the theme the system shows
165
+ });
166
+ themeManager.systemBright; // the system's colour scheme is light, as prefers-color-scheme last reported it
167
+ ```
168
+
169
+ The page's `DomTools` is one instance on `globalThis` shared by every copy of this package, so it is the copy that creates it whose `ThemeManager` serves the page: every copy in a bundle must be a version with `setPreference()` for a toggle to rely on it.
170
+
147
171
  `enableAutomaticGlobalThemeChange()` waits for `domReady`, so it is safe to call before `document.body` exists.
148
172
 
149
173
  ## Keyboard Shortcuts
@@ -305,9 +329,10 @@ Repeated callers receive the first result, and repeated failures re-throw the st
305
329
  ## Extra Exports
306
330
 
307
331
  ```ts
308
- import { TypedRequest, plugins } from '@design.estate/dees-domtools';
332
+ import { TypedRequest, ThemeManager, type IThemeState, type TThemePreference, plugins } from '@design.estate/dees-domtools';
309
333
  ```
310
334
 
335
+ - `ThemeManager` is the class of `domtools.themeManager`, exported for `ThemeManager.preferenceStorageKey` and for typing; use the page's instance rather than constructing one, `TThemePreference` (`'system' | 'bright' | 'dark'`) for a preference, and `IThemeState` for what `stateObservable` emits (see Theme preference)
311
336
  - `TypedRequest` is re-exported from `@api.global/typedrequest` 9 for typed request flows
312
337
  - `plugins` exposes the underlying modules used by the package, including `smartrouter`, `smartstate`, `smartrx`, `smartpromise`, and `typedrequest`
313
338
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@design.estate/dees-domtools',
6
- version: '5.0.1',
6
+ version: '5.2.0',
7
7
  description: 'A package providing tools to simplify complex CSS structures and web development tasks, featuring TypeScript support and integration with various web technologies.'
8
8
  }
@@ -1,28 +1,93 @@
1
1
  import { DomTools } from './domtools.classes.domtools.js';
2
2
  import * as plugins from './domtools.plugins.js';
3
3
 
4
+ /**
5
+ * Which theme the reader asked for: `'system'` follows `prefers-color-scheme`, `'bright'` and
6
+ * `'dark'` hold that theme whatever the system does.
7
+ */
8
+ export type TThemePreference = 'system' | 'bright' | 'dark';
9
+
10
+ const themePreferences: readonly TThemePreference[] = ['system', 'bright', 'dark'];
11
+
12
+ const isThemePreference = (valueArg: unknown): valueArg is TThemePreference => (
13
+ themePreferences.includes(valueArg as TThemePreference)
14
+ );
15
+
16
+ /** What a theme toggle shows: the reader's preference, the system's colour scheme and the theme they resolve to. */
17
+ export interface IThemeState {
18
+ preference: TThemePreference;
19
+ /** The system's colour scheme is light. */
20
+ systemBright: boolean;
21
+ /** The page shows the bright theme. */
22
+ goBright: boolean;
23
+ }
24
+
4
25
  export class ThemeManager {
26
+ /**
27
+ * The `localStorage` key a picked preference is kept under, per browser and origin. `'system'` is
28
+ * the absence of the key.
29
+ */
30
+ public static readonly preferenceStorageKey = 'dees-domtools:theme-preference';
31
+
5
32
  public domtoolsRef: DomTools;
6
33
 
7
34
  public goBrightBoolean = false;
35
+ /** The reader's preference; the theme follows the system only while it is `'system'`. */
36
+ public preference: TThemePreference = 'system';
8
37
  public preferredColorSchemeMediaMatch = window.matchMedia('(prefers-color-scheme: light)');
38
+ /** The system's colour scheme is light, as `prefers-color-scheme` last reported it. */
39
+ public systemBright = this.preferredColorSchemeMediaMatch.matches;
9
40
 
10
41
  public themeObservable = new plugins.smartrx.rxjs.ReplaySubject<boolean>(1);
42
+ /**
43
+ * The preference, the system's colour scheme and the theme, on every change of any of them, also
44
+ * when the theme stays; replays the current state to a new subscriber.
45
+ */
46
+ public stateObservable = new plugins.smartrx.rxjs.ReplaySubject<IThemeState>(1);
11
47
  private automaticGlobalThemeChangeSubscription: plugins.smartrx.rxjs.Subscription | null = null;
12
48
  private readonly preferredColorSchemeChangeHandler = (eventArg: MediaQueryListEvent) => {
49
+ this.systemBright = eventArg.matches;
50
+ if (this.preference !== 'system') {
51
+ this.publishState();
52
+ return;
53
+ }
13
54
  this.goBrightBoolean = eventArg.matches;
14
55
  this.updateAllConnectedElements();
15
56
  };
57
+ /** Another tab of this origin picked a preference; this page follows it. */
58
+ private readonly storageHandler = (eventArg: StorageEvent) => {
59
+ if (eventArg.storageArea !== window.localStorage) return;
60
+ if (eventArg.key !== ThemeManager.preferenceStorageKey && eventArg.key !== null) return;
61
+ // A cleared storage (`key` null) forgets the pick as well.
62
+ const value = eventArg.key === null ? null : eventArg.newValue;
63
+ this.applyPreference(isThemePreference(value) ? value : 'system');
64
+ };
16
65
 
17
66
  constructor(domtoolsRefArg: DomTools) {
18
67
  this.domtoolsRef = domtoolsRefArg;
19
68
 
20
- // lets care
21
- this.goBrightBoolean = this.preferredColorSchemeMediaMatch.matches;
69
+ // The stored preference is read before the first emission, so a page never flashes the system theme.
70
+ this.preference = ThemeManager.storedPreference();
71
+ this.goBrightBoolean = this.resolveBright();
22
72
  this.preferredColorSchemeMediaMatch.addEventListener('change', this.preferredColorSchemeChangeHandler);
73
+ window.addEventListener('storage', this.storageHandler);
23
74
  this.updateAllConnectedElements();
24
75
  }
25
76
 
77
+ /**
78
+ * The preference kept in this browser. A browser that does not let the page read its storage, or a
79
+ * value this version does not know, keeps no preference: the theme follows the system.
80
+ */
81
+ private static storedPreference(): TThemePreference {
82
+ let stored: string | null;
83
+ try {
84
+ stored = window.localStorage.getItem(ThemeManager.preferenceStorageKey);
85
+ } catch {
86
+ return 'system';
87
+ }
88
+ return isThemePreference(stored) ? stored : 'system';
89
+ }
90
+
26
91
  public async enableAutomaticGlobalThemeChange() {
27
92
  await this.domtoolsRef.domReady.promise;
28
93
  if (!this.automaticGlobalThemeChangeSubscription) {
@@ -39,10 +104,55 @@ export class ThemeManager {
39
104
  return;
40
105
  }
41
106
  this.themeObservable.next(this.goBrightBoolean);
107
+ this.publishState();
108
+ }
109
+
110
+ private publishState(): void {
111
+ if (this.domtoolsRef.disposed) {
112
+ return;
113
+ }
114
+ this.stateObservable.next({ preference: this.preference, systemBright: this.systemBright, goBright: this.goBrightBoolean });
115
+ }
116
+
117
+ private resolveBright(): boolean {
118
+ return this.preference === 'system' ? this.systemBright : this.preference === 'bright';
119
+ }
120
+
121
+ private applyPreference(preferenceArg: TThemePreference): void {
122
+ const preferenceChanged = preferenceArg !== this.preference;
123
+ this.preference = preferenceArg;
124
+ const goBright = this.resolveBright();
125
+ if (goBright !== this.goBrightBoolean) {
126
+ this.goBrightBoolean = goBright;
127
+ this.updateAllConnectedElements();
128
+ } else if (preferenceChanged) {
129
+ this.publishState();
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Picks the theme and keeps the pick in this browser (`'system'` forgets it), so the next page
135
+ * load starts in it. While the preference is `'bright'` or `'dark'` the system's colour scheme no
136
+ * longer moves the theme. Returns whether the pick was kept: false when the browser refuses the
137
+ * page its storage, in which case it holds for this page only. Throws on a value that is no
138
+ * `TThemePreference`.
139
+ */
140
+ public setPreference(preferenceArg: TThemePreference): boolean {
141
+ if (!isThemePreference(preferenceArg)) {
142
+ throw new Error(`Unknown theme preference "${String(preferenceArg)}".`);
143
+ }
144
+ this.applyPreference(preferenceArg);
145
+ try {
146
+ if (preferenceArg === 'system') window.localStorage.removeItem(ThemeManager.preferenceStorageKey);
147
+ else window.localStorage.setItem(ThemeManager.preferenceStorageKey, preferenceArg);
148
+ return true;
149
+ } catch {
150
+ return false;
151
+ }
42
152
  }
43
153
 
44
154
  /**
45
- * set the theme of the website to bright
155
+ * set the theme of the website to bright, for now: the preference is unchanged
46
156
  */
47
157
  public goBright() {
48
158
  this.goBrightBoolean = true;
@@ -50,7 +160,7 @@ export class ThemeManager {
50
160
  }
51
161
 
52
162
  /**
53
- * set the theme of the website to dark
163
+ * set the theme of the website to dark, for now: the preference is unchanged
54
164
  */
55
165
  public goDark() {
56
166
  this.goBrightBoolean = false;
@@ -58,7 +168,7 @@ export class ThemeManager {
58
168
  }
59
169
 
60
170
  /**
61
- * simply toggle between bright and dark
171
+ * simply toggle between bright and dark, for now: the preference is unchanged
62
172
  */
63
173
  public toggleDarkBright() {
64
174
  this.goBrightBoolean = !this.goBrightBoolean;
@@ -67,8 +177,10 @@ export class ThemeManager {
67
177
 
68
178
  public dispose() {
69
179
  this.preferredColorSchemeMediaMatch.removeEventListener('change', this.preferredColorSchemeChangeHandler);
180
+ window.removeEventListener('storage', this.storageHandler);
70
181
  this.automaticGlobalThemeChangeSubscription?.unsubscribe();
71
182
  this.automaticGlobalThemeChangeSubscription = null;
72
183
  this.themeObservable.complete();
184
+ this.stateObservable.complete();
73
185
  }
74
186
  }
package/ts/index.ts CHANGED
@@ -6,6 +6,7 @@ import * as css from './domtools.css.js';
6
6
 
7
7
  export { css, breakpoints, elementBasic };
8
8
  export { DomTools, type IDomToolsConstructorOptions } from './domtools.classes.domtools.js';
9
+ export { ThemeManager, type IThemeState, type TThemePreference } from './domtools.classes.thememanager.js';
9
10
  export { TypedRequest } from '@api.global/typedrequest';
10
11
  export { type IWebSetupConstructorOptions } from '@push.rocks/websetup';
11
12