@jungherz-de/notionkit-elements 1.19.0 → 1.20.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jungherz-de/notionkit-elements",
3
- "version": "1.19.0",
3
+ "version": "1.20.0",
4
4
  "description": "Vanilla JavaScript Web Components wrapping the NotionKit CSS foundation – <nk-*> custom elements with Shadow DOM, automatic theme sync and native form participation.",
5
5
  "license": "MIT",
6
6
  "author": "Jungherz GmbH <info@jungherz.com> (https://www.jungherz.com)",
@@ -63,10 +63,10 @@
63
63
  "dark-mode"
64
64
  ],
65
65
  "peerDependencies": {
66
- "@jungherz-de/notionkit": ">=1.19.1"
66
+ "@jungherz-de/notionkit": ">=1.20.0"
67
67
  },
68
68
  "devDependencies": {
69
- "@jungherz-de/notionkit": "^1.19.1",
69
+ "@jungherz-de/notionkit": "^1.20.0",
70
70
  "@playwright/test": "^1.50.0",
71
71
  "@rollup/plugin-node-resolve": "^15.0.0",
72
72
  "@rollup/plugin-terser": "^0.4.0",
@@ -7,62 +7,203 @@ import { NkElement, getCurrentTheme } from '../../base.js';
7
7
  // system preference on first connect when <html> carries no theme yet, and
8
8
  // accepts postMessage({ nkTheme }) from a parent page (the landing page's
9
9
  // embedded demo uses that).
10
- class NkThemeToggle extends NkElement {
11
- static get observedAttributes() { return ['storage-key', 'title'];
10
+ //
11
+ // <nk-theme-toggle modes="auto light dark"> adds a third state (1.20.0):
12
+ // `auto` follows prefers-color-scheme, live, and is what no stored value
13
+ // means – so the documented inline script before first paint keeps working
14
+ // and a choice made without `modes` still counts. The glyph then shows the
15
+ // mode (☀️, 🌙, both for auto) instead of the next theme, the title names
16
+ // the mode and the next one, other tabs follow through the storage event,
17
+ // and `theme-color` keeps <meta name="theme-color"> in step. Two states stay
18
+ // the default, where nothing about this element changes.
19
+ const GLYPH = { light: '☀️', dark: '🌙' };
12
20
 
13
- }
21
+ class NkThemeToggle extends NkElement {
22
+ static get observedAttributes() { return ['storage-key', 'title', 'modes', 'theme-color']; }
14
23
 
15
24
  render() {
16
25
  this._btn = this.createElement('button', ['nk-topbar-btn', 'nk-theme-toggle'], { type: 'button' });
17
- this._syncTitle();
18
26
  this._wrapper.appendChild(this._btn);
19
- const root = document.documentElement;
27
+ this._readThemeColors();
28
+ if (this.auto) {
29
+ // With `auto` this element owns the theme: a stored choice, else the
30
+ // system. The inline script before first paint has set the same value.
31
+ this._applyMode();
32
+ } else {
33
+ const root = document.documentElement;
34
+ let stored = null;
35
+ try { stored = localStorage.getItem(this.storageKey); } catch {}
36
+ if (stored === 'light' || stored === 'dark') root.setAttribute('data-theme', stored);
37
+ else if (!root.hasAttribute('data-theme')) root.setAttribute('data-theme', this.systemTheme);
38
+ this._syncThemeColor();
39
+ this._syncGlyph();
40
+ this._syncTitle();
41
+ }
42
+ }
43
+
44
+ // ── Modes ──
45
+
46
+ /** The states offered, in the author's order; without a theme among them the default two. */
47
+ get modes() {
48
+ const list = [...new Set((this.getAttribute('modes') || '').toLowerCase().split(/\s+/))].filter(m => m === 'auto' || m === 'light' || m === 'dark');
49
+ return list.some(m => m !== 'auto') ? list : ['light', 'dark'];
50
+ }
51
+
52
+ /** Whether `auto` is offered – the only thing that changes this element's behaviour. */
53
+ get auto() { return this.modes.includes('auto'); }
54
+
55
+ /** One query object per element: a listener on a throwaway one is neither removable nor safe from collection. */
56
+ get systemQuery() { return (this._query ??= matchMedia('(prefers-color-scheme: dark)')); }
57
+
58
+ get systemTheme() { return this.systemQuery.matches ? 'dark' : 'light'; }
59
+
60
+ /** The mode in force: 'auto' (nothing stored), 'light' or 'dark'. Without `auto` the theme on screen. */
61
+ get mode() {
62
+ if (!this.auto) return getCurrentTheme();
20
63
  let stored = null;
21
- try { stored = localStorage.getItem(this.storageKey); } catch {}
22
- if (stored === 'light' || stored === 'dark') root.setAttribute('data-theme', stored);
23
- else if (!root.hasAttribute('data-theme')) root.setAttribute('data-theme', matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
64
+ try { stored = localStorage.getItem(this.storageKey); } catch { return this._unsaved || 'auto'; }
65
+ if (stored === 'light' || stored === 'dark') return stored;
66
+ return this._unsaved || 'auto';
67
+ }
68
+
69
+ /** The theme on screen for a mode: `auto` is whatever the system says. */
70
+ themeFor(mode = this.mode) { return mode === 'auto' ? this.systemTheme : mode; }
71
+
72
+ /**
73
+ * The mode a click moves to. From `auto` to the theme that is *not* on
74
+ * screen, then to the other one, then back to `auto` – so every click but
75
+ * the last changes something visibly. Without `auto` it cycles the themes.
76
+ */
77
+ nextMode(mode = this.mode) {
78
+ const modes = this.modes, themes = modes.filter(m => m !== 'auto'), system = this.systemTheme;
79
+ if (!modes.includes('auto')) return themes[(themes.indexOf(mode) + 1) % themes.length];
80
+ if (mode === 'auto') return themes.find(m => m !== system) ?? themes[0];
81
+ if (mode !== system) return themes.includes(system) ? system : 'auto';
82
+ return 'auto';
83
+ }
84
+
85
+ // ── Applying ──
86
+
87
+ _applyMode(mode = this.mode) {
88
+ const theme = this.themeFor(mode);
89
+ const root = document.documentElement;
90
+ // Only on a change: the observer in base.js mirrors data-theme into every
91
+ // element, and auto re-applies the same value on every system change.
92
+ if (root.getAttribute('data-theme') !== theme) root.setAttribute('data-theme', theme);
93
+ this._syncThemeColor(mode);
24
94
  this._syncGlyph();
95
+ this._syncTitle();
96
+ }
97
+
98
+ /** The page's own theme-color values, per medium, before this element touched them. */
99
+ _readThemeColors() {
100
+ this._bars = [...document.querySelectorAll('meta[name="theme-color"]')].map(meta => ({
101
+ meta, dark: (meta.getAttribute('media') || '').includes('dark'), content: meta.getAttribute('content'),
102
+ }));
103
+ }
104
+
105
+ /**
106
+ * `theme-color`: while a theme is fixed every meta carries the colour of
107
+ * that theme, so the browser bar follows the choice; back on `auto` each
108
+ * one carries its own again. The colours are the page's – never invented.
109
+ */
110
+ _syncThemeColor(mode = this.mode) {
111
+ if (!this.hasAttribute('theme-color') || !this._bars?.length) return;
112
+ const auto = mode === 'auto', dark = this.themeFor(mode) === 'dark';
113
+ const fixed = (this._bars.find(bar => bar.dark === dark) ?? this._bars[0]).content;
114
+ for (const bar of this._bars) if (bar.content !== null) bar.meta.setAttribute('content', auto ? bar.content : fixed);
25
115
  }
26
116
 
27
117
  _syncGlyph() {
28
- if (this._btn) this._btn.textContent = getCurrentTheme() === 'dark' ? '☀️' : '🌙';
118
+ if (!this._btn) return;
119
+ if (!this.auto) {
120
+ // Two states: the glyph is the next theme, as it has always been.
121
+ this._btn.replaceChildren(getCurrentTheme() === 'dark' ? GLYPH.light : GLYPH.dark);
122
+ this._btn.removeAttribute('aria-label');
123
+ return;
124
+ }
125
+ const mode = this.mode;
126
+ const glyph = this.createElement('span', ['nk-theme-glyph'], { 'aria-hidden': 'true' });
127
+ if (mode === 'auto') glyph.classList.add('auto');
128
+ for (const text of mode === 'auto' ? [GLYPH.light, GLYPH.dark] : [GLYPH[mode]]) {
129
+ glyph.appendChild(Object.assign(document.createElement('span'), { textContent: text }));
130
+ }
131
+ this._btn.replaceChildren(glyph);
132
+ // Two emojis are no accessible name; the title's text is.
133
+ this._btn.setAttribute('aria-label', this._titleText());
29
134
  }
30
135
 
31
136
  _syncTheme(theme) {
32
137
  super._syncTheme(theme);
33
138
  this._syncGlyph();
139
+ this._syncTitle();
140
+ }
141
+
142
+ _titleText() {
143
+ const own = this.getAttribute('title');
144
+ if (own) return own;
145
+ if (!this.auto) return this.str('toggleTheme');
146
+ const name = (mode) => this.str(mode === 'auto' ? 'themeAuto' : mode === 'dark' ? 'themeDark' : 'themeLight');
147
+ return this.str('appearance').replace('{mode}', name(this.mode)).replace('{next}', name(this.nextMode()));
34
148
  }
35
149
 
150
+ _syncTitle() { if (this._btn) this._btn.title = this._titleText(); }
151
+ onStringsChanged() { this._syncTitle(); if (this.auto) this._syncGlyph(); }
152
+
36
153
  setupEvents() {
37
- this._onClick = () => this.apply(getCurrentTheme() === 'dark' ? 'light' : 'dark');
38
- this._onMessage = (e) => { const t = e.data?.nkTheme; if (t === 'light' || t === 'dark') this.apply(t); };
154
+ this._onClick = () => this.apply(this.nextMode());
155
+ this._onMessage = (e) => { const t = e.data?.nkTheme; if (t === 'light' || t === 'dark' || (t === 'auto' && this.auto)) this.apply(t); };
156
+ // `auto` has to notice a system switch while the page is open, and a
157
+ // choice made in another tab. Both only matter where `auto` is offered.
158
+ this._onSystem = () => { if (this.mode === 'auto') this._applyMode(); };
159
+ this._onStorage = (e) => { if (e.key === this.storageKey || e.key === null) this._applyMode(); };
39
160
  this._btn.addEventListener('click', this._onClick);
40
161
  window.addEventListener('message', this._onMessage);
162
+ if (this.auto) {
163
+ this.systemQuery.addEventListener('change', this._onSystem);
164
+ window.addEventListener('storage', this._onStorage);
165
+ }
41
166
  }
42
167
 
43
168
  teardownEvents() {
44
169
  this._btn?.removeEventListener('click', this._onClick);
45
170
  window.removeEventListener('message', this._onMessage);
171
+ if (this._onSystem) this.systemQuery.removeEventListener('change', this._onSystem);
172
+ window.removeEventListener('storage', this._onStorage);
46
173
  }
47
174
 
48
- _syncTitle() { this._btn.title = this.getAttribute('title') || this.str('toggleTheme'); }
49
- onStringsChanged() { this._syncTitle(); }
50
-
51
175
  onAttributeChanged(name) {
52
- if (name === 'title' && this._btn) this._syncTitle();
176
+ if (!this._btn) return;
177
+ if (name === 'modes') {
178
+ // Re-arm: the listeners above hang on whether `auto` is offered.
179
+ if (this.isConnected) { this.teardownEvents(); this.setupEvents(); }
180
+ this.auto ? this._applyMode() : this._syncGlyph();
181
+ }
182
+ this._syncTitle();
53
183
  }
54
184
 
55
- /** Sets the theme on <html>, persists it and reports it. */
56
- apply(theme) {
57
- document.documentElement.setAttribute('data-theme', theme);
58
- try { localStorage.setItem(this.storageKey, theme); } catch {}
59
- this._syncGlyph();
60
- this.emit('nk-change', { value: theme });
185
+ /**
186
+ * Sets the mode on <html>, persists it and reports it. `light` / `dark`
187
+ * are stored, `auto` (only where it is offered) clears the entry – that is
188
+ * what the inline script before first paint reads.
189
+ */
190
+ apply(mode) {
191
+ const wanted = mode === 'auto' && this.auto ? 'auto' : mode === 'light' || mode === 'dark' ? mode : this.themeFor(mode);
192
+ try {
193
+ if (wanted === 'auto') localStorage.removeItem(this.storageKey);
194
+ else localStorage.setItem(this.storageKey, wanted);
195
+ this._unsaved = null;
196
+ } catch { this._unsaved = wanted; }
197
+ this._applyMode(wanted);
198
+ this.emit('nk-change', { value: this.auto ? wanted : this.themeFor(wanted), theme: this.themeFor(wanted) });
61
199
  }
62
200
 
63
201
  get storageKey() { return this.getAttribute('storage-key') || 'nk-theme'; }
64
- get value() { return getCurrentTheme(); }
202
+ /** The mode: 'auto' | 'light' | 'dark'. Without `auto` the theme, as before. */
203
+ get value() { return this.auto ? this.mode : getCurrentTheme(); }
65
204
  set value(v) { this.apply(v); }
205
+ /** The theme on screen – 'light' or 'dark', whatever the mode. */
206
+ get theme() { return this.themeFor(); }
66
207
  }
67
208
 
68
209
  customElements.define('nk-theme-toggle', NkThemeToggle);
@@ -16,6 +16,7 @@ const BUILT_IN = {
16
16
  commentPlaceholder: 'Comment …', send: 'Send', askPlaceholder: 'Ask something …',
17
17
  commandPlaceholder: 'Search or type a command …', emojiPlaceholder: 'Search…',
18
18
  toggleTheme: 'Toggle light / dark', synced: '⟳ synced',
19
+ appearance: 'Appearance: {mode} – click: {next}', themeAuto: 'automatic (follows the system)', themeLight: 'light', themeDark: 'dark',
19
20
  message: 'Message', breadcrumb: 'Breadcrumb', commandPalette: 'Command palette', comment: 'Comment',
20
21
  searchEmoji: 'Search emoji', settings: 'Settings', title: 'Title', changeIcon: 'Change icon',
21
22
  add: 'Add', more: 'More',
@@ -30,6 +31,7 @@ const BUILT_IN = {
30
31
  commentPlaceholder: 'Kommentieren …', send: 'Senden', askPlaceholder: 'Frag etwas …',
31
32
  commandPlaceholder: 'Suchen oder Befehl eingeben …', emojiPlaceholder: 'Suchen …',
32
33
  toggleTheme: 'Hell / Dunkel', synced: '⟳ synchronisiert',
34
+ appearance: 'Darstellung: {mode} – Klick: {next}', themeAuto: 'automatisch (wie das System)', themeLight: 'hell', themeDark: 'dunkel',
33
35
  message: 'Nachricht', breadcrumb: 'Pfad', commandPalette: 'Befehlspalette', comment: 'Kommentar',
34
36
  searchEmoji: 'Emoji suchen', settings: 'Einstellungen', title: 'Titel', changeIcon: 'Symbol ändern',
35
37
  add: 'Hinzufügen', more: 'Mehr',