@jungherz-de/notionkit-elements 1.18.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +11 -7
  3. package/SKILL.md +42 -37
  4. package/dist/components/base.js +2 -0
  5. package/dist/components/nk-calendar.js +6 -5
  6. package/dist/components/nk-cmdk.js +7 -5
  7. package/dist/components/nk-dialog.js +5 -5
  8. package/dist/components/nk-menu.js +19 -6
  9. package/dist/components/nk-modal.js +7 -6
  10. package/dist/components/nk-peek.js +5 -4
  11. package/dist/components/nk-pop.js +3 -3
  12. package/dist/components/nk-sheet.js +5 -5
  13. package/dist/components/nk-sidebar.js +1 -1
  14. package/dist/components/nk-theme-toggle.js +163 -22
  15. package/dist/components/nk-toast.js +107 -11
  16. package/dist/components/nk-tooltip.js +1 -1
  17. package/dist/components/shared/{focus-C4tbSNND.js → focus-D55JGjRA.js} +7 -1
  18. package/dist/components/shared/layers-D9YYYA5g.js +35 -0
  19. package/dist/notionkit-elements.esm.js +357 -69
  20. package/dist/notionkit-elements.esm.min.js +1 -1
  21. package/dist/notionkit-elements.esm.min.js.map +1 -1
  22. package/dist/notionkit-elements.js +357 -69
  23. package/dist/notionkit-elements.min.js +1 -1
  24. package/package.json +3 -3
  25. package/src/components/forms/nk-calendar.js +5 -4
  26. package/src/components/overlays/nk-cmdk.js +7 -5
  27. package/src/components/overlays/nk-dialog.js +4 -4
  28. package/src/components/overlays/nk-menu.js +19 -6
  29. package/src/components/overlays/nk-modal.js +7 -6
  30. package/src/components/overlays/nk-peek.js +4 -3
  31. package/src/components/overlays/nk-pop.js +3 -3
  32. package/src/components/overlays/nk-sheet.js +4 -4
  33. package/src/components/overlays/nk-toast.js +107 -11
  34. package/src/components/shell/nk-theme-toggle.js +163 -22
  35. package/src/util/focus.js +6 -0
  36. package/src/util/layers.js +33 -0
  37. package/src/util/strings.js +2 -0
@@ -1,5 +1,6 @@
1
1
  import { NkElement } from './base.js';
2
- import { l as lockScroll, i as inertOutside, f as firstFocusable, u as unlockScroll } from './shared/focus-C4tbSNND.js';
2
+ import { d as deepActiveElement, l as lockScroll, i as inertOutside, f as firstFocusable, u as unlockScroll } from './shared/focus-D55JGjRA.js';
3
+ import { o as openLayer, c as closeLayer } from './shared/layers-D9YYYA5g.js';
3
4
  import '@jungherz-de/notionkit/notionkit-styles.js';
4
5
 
5
6
  // <nk-modal id="settings">
@@ -94,14 +95,17 @@ class NkModal extends NkElement {
94
95
  if (open === this._wasOpen) return;
95
96
  this._wasOpen = open;
96
97
  if (open) {
97
- this._returnFocus = document.activeElement;
98
+ // Behind shadow hosts too: a trigger in a view's shadow root gets the focus back.
99
+ this._returnFocus = deepActiveElement();
98
100
  lockScroll();
99
101
  this._undoInert = inertOutside(this);
102
+ openLayer(this, () => this.close());
100
103
  requestAnimationFrame(() => {
101
104
  const target = this._rows.querySelector('.nk-tree-item.active') || firstFocusable(this) || this._box;
102
105
  target.focus({ preventScroll: true });
103
106
  });
104
107
  } else {
108
+ closeLayer(this);
105
109
  unlockScroll();
106
110
  this._undoInert?.(); this._undoInert = null;
107
111
  const back = this._returnFocus;
@@ -112,7 +116,6 @@ class NkModal extends NkElement {
112
116
 
113
117
  setupEvents() {
114
118
  this._onBackdrop = (e) => { if (e.target === this._backdrop) this.close(); };
115
- this._onKey = (e) => { if (e.key === 'Escape' && this.getBoolAttr('open')) { e.stopPropagation(); this.close(); } };
116
119
  this._onNavClick = (e) => { const row = e.target.closest('[data-pane]'); if (row) { row.focus(); this.pane = row.dataset.pane; } };
117
120
  this._onNavKey = (e) => {
118
121
  const row = e.target.closest?.('[data-pane]');
@@ -126,7 +129,6 @@ class NkModal extends NkElement {
126
129
  };
127
130
  this._onSlot = () => this.requestNav();
128
131
  this._backdrop.addEventListener('click', this._onBackdrop);
129
- document.addEventListener('keydown', this._onKey);
130
132
  this._rows.addEventListener('click', this._onNavClick);
131
133
  this._rows.addEventListener('keydown', this._onNavKey);
132
134
  this._slot.addEventListener('slotchange', this._onSlot);
@@ -134,11 +136,10 @@ class NkModal extends NkElement {
134
136
 
135
137
  teardownEvents() {
136
138
  this._backdrop?.removeEventListener('click', this._onBackdrop);
137
- document.removeEventListener('keydown', this._onKey);
138
139
  this._rows?.removeEventListener('click', this._onNavClick);
139
140
  this._rows?.removeEventListener('keydown', this._onNavKey);
140
141
  this._slot?.removeEventListener('slotchange', this._onSlot);
141
- if (this._wasOpen) { unlockScroll(); this._undoInert?.(); this._undoInert = null; this._wasOpen = false; }
142
+ if (this._wasOpen) { closeLayer(this); unlockScroll(); this._undoInert?.(); this._undoInert = null; this._wasOpen = false; }
142
143
  }
143
144
 
144
145
  onStringsChanged() { this._nav.setAttribute('aria-label', this.str('settings')); }
@@ -1,5 +1,6 @@
1
1
  import { NkElement } from './base.js';
2
- import { d as deepActiveElement, l as lockScroll, i as inertOutside, u as unlockScroll } from './shared/focus-C4tbSNND.js';
2
+ import { d as deepActiveElement, l as lockScroll, i as inertOutside, u as unlockScroll } from './shared/focus-D55JGjRA.js';
3
+ import { o as openLayer, c as closeLayer } from './shared/layers-D9YYYA5g.js';
3
4
  import '@jungherz-de/notionkit/notionkit-styles.js';
4
5
 
5
6
  // <nk-peek id="peek" label="Database Table-View">
@@ -109,8 +110,10 @@ class NkPeek extends NkElement {
109
110
  lockScroll();
110
111
  this._undoInert = inertOutside(this);
111
112
  }
113
+ openLayer(this, () => this.close());
112
114
  requestAnimationFrame(() => this._box.focus({ preventScroll: true }));
113
115
  } else {
116
+ closeLayer(this);
114
117
  if (this._modal) { this._modal = false; this._box.removeAttribute('aria-modal'); unlockScroll(); this._undoInert?.(); this._undoInert = null; }
115
118
  const active = deepActiveElement();
116
119
  const inside = active && (this.contains(active) || this._shadow.contains(active) || active === document.body);
@@ -123,7 +126,6 @@ class NkPeek extends NkElement {
123
126
  setupEvents() {
124
127
  this._onClose = () => this.close();
125
128
  this._onBackdrop = (e) => { if (e.target === this._backdrop) this.close(); };
126
- this._onKey = (e) => { if (e.key === 'Escape' && this.getBoolAttr('open')) this.close(); };
127
129
  // Bubble phase, after the page's own handlers: a click that showed the
128
130
  // peek again (another row) must not close it.
129
131
  this._onOutside = (e) => {
@@ -157,7 +159,6 @@ class NkPeek extends NkElement {
157
159
  this._handle.addEventListener('pointermove', this._onResizeMove);
158
160
  this._handle.addEventListener('lostpointercapture', this._onResizeEnd);
159
161
  this._handle.addEventListener('keydown', this._onResizeKey);
160
- document.addEventListener('keydown', this._onKey);
161
162
  document.addEventListener('click', this._onOutside);
162
163
  addEventListener('resize', this._onViewport);
163
164
  }
@@ -169,7 +170,7 @@ class NkPeek extends NkElement {
169
170
  this._handle?.removeEventListener('pointermove', this._onResizeMove);
170
171
  this._handle?.removeEventListener('lostpointercapture', this._onResizeEnd);
171
172
  this._handle?.removeEventListener('keydown', this._onResizeKey);
172
- document.removeEventListener('keydown', this._onKey);
173
+ closeLayer(this);
173
174
  document.removeEventListener('click', this._onOutside);
174
175
  removeEventListener('resize', this._onViewport);
175
176
  if (this._insetApp) { this._app()?.removeAttribute('peek-inset'); this._insetApp = false; }
@@ -1,4 +1,5 @@
1
1
  import { NkElement } from './base.js';
2
+ import { c as closeLayer, o as openLayer } from './shared/layers-D9YYYA5g.js';
2
3
  import '@jungherz-de/notionkit/notionkit-styles.js';
3
4
 
4
5
  // <nk-pop placement="bottom-start">
@@ -49,12 +50,10 @@ class NkPop extends NkElement {
49
50
  this.toggle();
50
51
  };
51
52
  this._onDocClick = (e) => { if (this.getBoolAttr('open') && !e.composedPath().includes(this)) this.close(); };
52
- this._onKey = (e) => { if (e.key === 'Escape' && this.getBoolAttr('open')) this.close(); };
53
53
  this._onSelect = (e) => { if (e.target !== this) this.close(); };
54
54
  this._onSlot = () => this._syncSurface();
55
55
  this.addEventListener('click', this._onTrigger);
56
56
  document.addEventListener('click', this._onDocClick);
57
- document.addEventListener('keydown', this._onKey);
58
57
  this._float.addEventListener('nk-select', this._onSelect);
59
58
  this._slot.addEventListener('slotchange', this._onSlot);
60
59
  }
@@ -62,7 +61,7 @@ class NkPop extends NkElement {
62
61
  teardownEvents() {
63
62
  this.removeEventListener('click', this._onTrigger);
64
63
  document.removeEventListener('click', this._onDocClick);
65
- document.removeEventListener('keydown', this._onKey);
64
+ closeLayer(this);
66
65
  this._float?.removeEventListener('nk-select', this._onSelect);
67
66
  this._slot?.removeEventListener('slotchange', this._onSlot);
68
67
  }
@@ -71,6 +70,7 @@ class NkPop extends NkElement {
71
70
  if (name === 'bare') this._syncSurface();
72
71
  if (name === 'open') {
73
72
  const open = this.getBoolAttr('open');
73
+ if (open) openLayer(this, () => this.close()); else closeLayer(this);
74
74
  for (const el of this._triggerSlot.assignedElements()) el.setAttribute('aria-expanded', open ? 'true' : 'false');
75
75
  this.emit('nk-toggle', { open });
76
76
  }
@@ -1,5 +1,6 @@
1
1
  import { NkElement } from './base.js';
2
- import { d as deepActiveElement, l as lockScroll, i as inertOutside, f as firstFocusable, u as unlockScroll } from './shared/focus-C4tbSNND.js';
2
+ import { d as deepActiveElement, l as lockScroll, i as inertOutside, f as firstFocusable, u as unlockScroll } from './shared/focus-D55JGjRA.js';
3
+ import { o as openLayer, c as closeLayer } from './shared/layers-D9YYYA5g.js';
3
4
  import '@jungherz-de/notionkit/notionkit-styles.js';
4
5
 
5
6
  // <nk-sheet id="more" title="More">
@@ -53,8 +54,10 @@ class NkSheet extends NkElement {
53
54
  this._returnFocus = deepActiveElement();
54
55
  lockScroll();
55
56
  this._undoInert = inertOutside(this);
57
+ openLayer(this, () => this.close());
56
58
  requestAnimationFrame(() => (firstFocusable(this) || this._box).focus({ preventScroll: true }));
57
59
  } else {
60
+ closeLayer(this);
58
61
  unlockScroll();
59
62
  this._undoInert?.(); this._undoInert = null;
60
63
  const back = this._returnFocus;
@@ -65,15 +68,12 @@ class NkSheet extends NkElement {
65
68
 
66
69
  setupEvents() {
67
70
  this._onBackdrop = (e) => { if (e.target === this._backdrop) this.close(); };
68
- this._onKey = (e) => { if (e.key === 'Escape' && this.getBoolAttr('open')) { e.stopPropagation(); this.close(); } };
69
71
  this._backdrop.addEventListener('click', this._onBackdrop);
70
- document.addEventListener('keydown', this._onKey);
71
72
  }
72
73
 
73
74
  teardownEvents() {
74
75
  this._backdrop?.removeEventListener('click', this._onBackdrop);
75
- document.removeEventListener('keydown', this._onKey);
76
- if (this._wasOpen) { unlockScroll(); this._undoInert?.(); this._undoInert = null; this._wasOpen = false; }
76
+ if (this._wasOpen) { closeLayer(this); unlockScroll(); this._undoInert?.(); this._undoInert = null; this._wasOpen = false; }
77
77
  }
78
78
 
79
79
  onAttributeChanged(name, oldValue, value) {
@@ -1,5 +1,5 @@
1
1
  import { NkElement } from './base.js';
2
- import { d as deepActiveElement } from './shared/focus-C4tbSNND.js';
2
+ import { d as deepActiveElement } from './shared/focus-D55JGjRA.js';
3
3
  import '@jungherz-de/notionkit/notionkit-styles.js';
4
4
 
5
5
  // <nk-sidebar slot="sidebar">
@@ -8,62 +8,203 @@ import '@jungherz-de/notionkit/notionkit-styles.js';
8
8
  // system preference on first connect when <html> carries no theme yet, and
9
9
  // accepts postMessage({ nkTheme }) from a parent page (the landing page's
10
10
  // embedded demo uses that).
11
- class NkThemeToggle extends NkElement {
12
- static get observedAttributes() { return ['storage-key', 'title'];
11
+ //
12
+ // <nk-theme-toggle modes="auto light dark"> adds a third state (1.20.0):
13
+ // `auto` follows prefers-color-scheme, live, and is what no stored value
14
+ // means – so the documented inline script before first paint keeps working
15
+ // and a choice made without `modes` still counts. The glyph then shows the
16
+ // mode (☀️, 🌙, both for auto) instead of the next theme, the title names
17
+ // the mode and the next one, other tabs follow through the storage event,
18
+ // and `theme-color` keeps <meta name="theme-color"> in step. Two states stay
19
+ // the default, where nothing about this element changes.
20
+ const GLYPH = { light: '☀️', dark: '🌙' };
13
21
 
14
- }
22
+ class NkThemeToggle extends NkElement {
23
+ static get observedAttributes() { return ['storage-key', 'title', 'modes', 'theme-color']; }
15
24
 
16
25
  render() {
17
26
  this._btn = this.createElement('button', ['nk-topbar-btn', 'nk-theme-toggle'], { type: 'button' });
18
- this._syncTitle();
19
27
  this._wrapper.appendChild(this._btn);
20
- const root = document.documentElement;
28
+ this._readThemeColors();
29
+ if (this.auto) {
30
+ // With `auto` this element owns the theme: a stored choice, else the
31
+ // system. The inline script before first paint has set the same value.
32
+ this._applyMode();
33
+ } else {
34
+ const root = document.documentElement;
35
+ let stored = null;
36
+ try { stored = localStorage.getItem(this.storageKey); } catch {}
37
+ if (stored === 'light' || stored === 'dark') root.setAttribute('data-theme', stored);
38
+ else if (!root.hasAttribute('data-theme')) root.setAttribute('data-theme', this.systemTheme);
39
+ this._syncThemeColor();
40
+ this._syncGlyph();
41
+ this._syncTitle();
42
+ }
43
+ }
44
+
45
+ // ── Modes ──
46
+
47
+ /** The states offered, in the author's order; without a theme among them the default two. */
48
+ get modes() {
49
+ const list = [...new Set((this.getAttribute('modes') || '').toLowerCase().split(/\s+/))].filter(m => m === 'auto' || m === 'light' || m === 'dark');
50
+ return list.some(m => m !== 'auto') ? list : ['light', 'dark'];
51
+ }
52
+
53
+ /** Whether `auto` is offered – the only thing that changes this element's behaviour. */
54
+ get auto() { return this.modes.includes('auto'); }
55
+
56
+ /** One query object per element: a listener on a throwaway one is neither removable nor safe from collection. */
57
+ get systemQuery() { return (this._query ??= matchMedia('(prefers-color-scheme: dark)')); }
58
+
59
+ get systemTheme() { return this.systemQuery.matches ? 'dark' : 'light'; }
60
+
61
+ /** The mode in force: 'auto' (nothing stored), 'light' or 'dark'. Without `auto` the theme on screen. */
62
+ get mode() {
63
+ if (!this.auto) return getCurrentTheme();
21
64
  let stored = null;
22
- try { stored = localStorage.getItem(this.storageKey); } catch {}
23
- if (stored === 'light' || stored === 'dark') root.setAttribute('data-theme', stored);
24
- else if (!root.hasAttribute('data-theme')) root.setAttribute('data-theme', matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
65
+ try { stored = localStorage.getItem(this.storageKey); } catch { return this._unsaved || 'auto'; }
66
+ if (stored === 'light' || stored === 'dark') return stored;
67
+ return this._unsaved || 'auto';
68
+ }
69
+
70
+ /** The theme on screen for a mode: `auto` is whatever the system says. */
71
+ themeFor(mode = this.mode) { return mode === 'auto' ? this.systemTheme : mode; }
72
+
73
+ /**
74
+ * The mode a click moves to. From `auto` to the theme that is *not* on
75
+ * screen, then to the other one, then back to `auto` – so every click but
76
+ * the last changes something visibly. Without `auto` it cycles the themes.
77
+ */
78
+ nextMode(mode = this.mode) {
79
+ const modes = this.modes, themes = modes.filter(m => m !== 'auto'), system = this.systemTheme;
80
+ if (!modes.includes('auto')) return themes[(themes.indexOf(mode) + 1) % themes.length];
81
+ if (mode === 'auto') return themes.find(m => m !== system) ?? themes[0];
82
+ if (mode !== system) return themes.includes(system) ? system : 'auto';
83
+ return 'auto';
84
+ }
85
+
86
+ // ── Applying ──
87
+
88
+ _applyMode(mode = this.mode) {
89
+ const theme = this.themeFor(mode);
90
+ const root = document.documentElement;
91
+ // Only on a change: the observer in base.js mirrors data-theme into every
92
+ // element, and auto re-applies the same value on every system change.
93
+ if (root.getAttribute('data-theme') !== theme) root.setAttribute('data-theme', theme);
94
+ this._syncThemeColor(mode);
25
95
  this._syncGlyph();
96
+ this._syncTitle();
97
+ }
98
+
99
+ /** The page's own theme-color values, per medium, before this element touched them. */
100
+ _readThemeColors() {
101
+ this._bars = [...document.querySelectorAll('meta[name="theme-color"]')].map(meta => ({
102
+ meta, dark: (meta.getAttribute('media') || '').includes('dark'), content: meta.getAttribute('content'),
103
+ }));
104
+ }
105
+
106
+ /**
107
+ * `theme-color`: while a theme is fixed every meta carries the colour of
108
+ * that theme, so the browser bar follows the choice; back on `auto` each
109
+ * one carries its own again. The colours are the page's – never invented.
110
+ */
111
+ _syncThemeColor(mode = this.mode) {
112
+ if (!this.hasAttribute('theme-color') || !this._bars?.length) return;
113
+ const auto = mode === 'auto', dark = this.themeFor(mode) === 'dark';
114
+ const fixed = (this._bars.find(bar => bar.dark === dark) ?? this._bars[0]).content;
115
+ for (const bar of this._bars) if (bar.content !== null) bar.meta.setAttribute('content', auto ? bar.content : fixed);
26
116
  }
27
117
 
28
118
  _syncGlyph() {
29
- if (this._btn) this._btn.textContent = getCurrentTheme() === 'dark' ? '☀️' : '🌙';
119
+ if (!this._btn) return;
120
+ if (!this.auto) {
121
+ // Two states: the glyph is the next theme, as it has always been.
122
+ this._btn.replaceChildren(getCurrentTheme() === 'dark' ? GLYPH.light : GLYPH.dark);
123
+ this._btn.removeAttribute('aria-label');
124
+ return;
125
+ }
126
+ const mode = this.mode;
127
+ const glyph = this.createElement('span', ['nk-theme-glyph'], { 'aria-hidden': 'true' });
128
+ if (mode === 'auto') glyph.classList.add('auto');
129
+ for (const text of mode === 'auto' ? [GLYPH.light, GLYPH.dark] : [GLYPH[mode]]) {
130
+ glyph.appendChild(Object.assign(document.createElement('span'), { textContent: text }));
131
+ }
132
+ this._btn.replaceChildren(glyph);
133
+ // Two emojis are no accessible name; the title's text is.
134
+ this._btn.setAttribute('aria-label', this._titleText());
30
135
  }
31
136
 
32
137
  _syncTheme(theme) {
33
138
  super._syncTheme(theme);
34
139
  this._syncGlyph();
140
+ this._syncTitle();
141
+ }
142
+
143
+ _titleText() {
144
+ const own = this.getAttribute('title');
145
+ if (own) return own;
146
+ if (!this.auto) return this.str('toggleTheme');
147
+ const name = (mode) => this.str(mode === 'auto' ? 'themeAuto' : mode === 'dark' ? 'themeDark' : 'themeLight');
148
+ return this.str('appearance').replace('{mode}', name(this.mode)).replace('{next}', name(this.nextMode()));
35
149
  }
36
150
 
151
+ _syncTitle() { if (this._btn) this._btn.title = this._titleText(); }
152
+ onStringsChanged() { this._syncTitle(); if (this.auto) this._syncGlyph(); }
153
+
37
154
  setupEvents() {
38
- this._onClick = () => this.apply(getCurrentTheme() === 'dark' ? 'light' : 'dark');
39
- this._onMessage = (e) => { const t = e.data?.nkTheme; if (t === 'light' || t === 'dark') this.apply(t); };
155
+ this._onClick = () => this.apply(this.nextMode());
156
+ this._onMessage = (e) => { const t = e.data?.nkTheme; if (t === 'light' || t === 'dark' || (t === 'auto' && this.auto)) this.apply(t); };
157
+ // `auto` has to notice a system switch while the page is open, and a
158
+ // choice made in another tab. Both only matter where `auto` is offered.
159
+ this._onSystem = () => { if (this.mode === 'auto') this._applyMode(); };
160
+ this._onStorage = (e) => { if (e.key === this.storageKey || e.key === null) this._applyMode(); };
40
161
  this._btn.addEventListener('click', this._onClick);
41
162
  window.addEventListener('message', this._onMessage);
163
+ if (this.auto) {
164
+ this.systemQuery.addEventListener('change', this._onSystem);
165
+ window.addEventListener('storage', this._onStorage);
166
+ }
42
167
  }
43
168
 
44
169
  teardownEvents() {
45
170
  this._btn?.removeEventListener('click', this._onClick);
46
171
  window.removeEventListener('message', this._onMessage);
172
+ if (this._onSystem) this.systemQuery.removeEventListener('change', this._onSystem);
173
+ window.removeEventListener('storage', this._onStorage);
47
174
  }
48
175
 
49
- _syncTitle() { this._btn.title = this.getAttribute('title') || this.str('toggleTheme'); }
50
- onStringsChanged() { this._syncTitle(); }
51
-
52
176
  onAttributeChanged(name) {
53
- if (name === 'title' && this._btn) this._syncTitle();
177
+ if (!this._btn) return;
178
+ if (name === 'modes') {
179
+ // Re-arm: the listeners above hang on whether `auto` is offered.
180
+ if (this.isConnected) { this.teardownEvents(); this.setupEvents(); }
181
+ this.auto ? this._applyMode() : this._syncGlyph();
182
+ }
183
+ this._syncTitle();
54
184
  }
55
185
 
56
- /** Sets the theme on <html>, persists it and reports it. */
57
- apply(theme) {
58
- document.documentElement.setAttribute('data-theme', theme);
59
- try { localStorage.setItem(this.storageKey, theme); } catch {}
60
- this._syncGlyph();
61
- this.emit('nk-change', { value: theme });
186
+ /**
187
+ * Sets the mode on <html>, persists it and reports it. `light` / `dark`
188
+ * are stored, `auto` (only where it is offered) clears the entry – that is
189
+ * what the inline script before first paint reads.
190
+ */
191
+ apply(mode) {
192
+ const wanted = mode === 'auto' && this.auto ? 'auto' : mode === 'light' || mode === 'dark' ? mode : this.themeFor(mode);
193
+ try {
194
+ if (wanted === 'auto') localStorage.removeItem(this.storageKey);
195
+ else localStorage.setItem(this.storageKey, wanted);
196
+ this._unsaved = null;
197
+ } catch { this._unsaved = wanted; }
198
+ this._applyMode(wanted);
199
+ this.emit('nk-change', { value: this.auto ? wanted : this.themeFor(wanted), theme: this.themeFor(wanted) });
62
200
  }
63
201
 
64
202
  get storageKey() { return this.getAttribute('storage-key') || 'nk-theme'; }
65
- get value() { return getCurrentTheme(); }
203
+ /** The mode: 'auto' | 'light' | 'dark'. Without `auto` the theme, as before. */
204
+ get value() { return this.auto ? this.mode : getCurrentTheme(); }
66
205
  set value(v) { this.apply(v); }
206
+ /** The theme on screen – 'light' or 'dark', whatever the mode. */
207
+ get theme() { return this.themeFor(); }
67
208
  }
68
209
 
69
210
  customElements.define('nk-theme-toggle', NkThemeToggle);
@@ -1,12 +1,22 @@
1
1
  import { NkElement } from './base.js';
2
+ import { d as deepActiveElement, c as containsDeep } from './shared/focus-D55JGjRA.js';
2
3
  import '@jungherz-de/notionkit/notionkit-styles.js';
3
4
 
4
5
  // <nk-toast id="toast"></nk-toast> … toast.show('Saved');
5
- // → <div class="nk-toast show">✓ Saved</div>
6
+ // toast.show('Moved to trash', { action: { label: 'Undo', value: 'undo' } });
7
+ // → <div class="nk-toast show">✓ Moved to trash<button class="t-action">Undo</button><button class="t-close">×</button></div>
6
8
  // One node, bottom-centred, auto-hides after `duration` ms (default 2200).
7
9
  // Above a tab bar at the bottom of the screen it rises 12px over the bar:
8
10
  // notionkit.css does that for class markup with :has(), which cannot see
9
11
  // into shadow roots, so the element measures the bar when it opens.
12
+ // An action (1.19.0) – from show() or a <button slot="action"> – makes it
13
+ // Notion's "Moved to trash · Undo": a button, and an × that closes. Such a
14
+ // toast stays until one of them is used, unless show() names a duration,
15
+ // and waits while the pointer or the focus is on it. The action fires
16
+ // nk-action { action, value, label } and, unless cancelled, closes it;
17
+ // Escape closes it while the focus is inside.
18
+ const CLOSE_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>';
19
+
10
20
  class NkToast extends NkElement {
11
21
  static get observedAttributes() { return ['open', 'duration', 'icon']; }
12
22
 
@@ -14,19 +24,37 @@ class NkToast extends NkElement {
14
24
  this._box = this.createElement('div', ['nk-toast'], { role: 'status', 'aria-live': 'polite' });
15
25
  this._icon = document.createElement('span');
16
26
  this._msg = document.createElement('span');
17
- this._box.append(this._icon, this._msg, document.createElement('slot'));
27
+ this._action = this.createElement('button', ['t-action'], { type: 'button' });
28
+ this._actionSlot = this.createElement('slot', [], { name: 'action' });
29
+ this._close = this.createElement('button', ['t-close'], { type: 'button' });
30
+ this._close.innerHTML = CLOSE_ICON;
31
+ this._box.append(this._icon, this._msg, document.createElement('slot'), this._actionSlot);
18
32
  this._wrapper.appendChild(this._box);
19
33
  this._sync();
20
34
  }
21
35
 
22
36
  _sync() {
23
- if (this.getBoolAttr('open')) this._lift();
24
- this._box.classList.toggle('show', this.getBoolAttr('open'));
37
+ const open = this.getBoolAttr('open');
38
+ if (open) this._lift();
39
+ this._box.classList.toggle('show', open);
25
40
  const icon = this.getAttribute('icon') ?? '✓';
26
41
  this._icon.textContent = icon;
27
42
  this._icon.style.display = icon ? '' : 'none';
43
+ // An empty message takes no place – with slotted text it added a second gap.
44
+ this._msg.style.display = this._msg.textContent ? '' : 'none';
45
+ // The action from show() and the × sit in the tree only when there is an
46
+ // action; a closed toast keeps them for its fade, out of the tab order.
47
+ const own = this._act, slotted = this._slotted();
48
+ if (own) { this._action.textContent = own.label; if (!this._action.isConnected) this._actionSlot.before(this._action); }
49
+ else this._action.remove();
50
+ if (own || slotted) { if (!this._close.isConnected) this._box.appendChild(this._close); }
51
+ else this._close.remove();
52
+ this._close.setAttribute('aria-label', this.str('close'));
53
+ for (const b of [this._action, this._close]) open ? b.removeAttribute('tabindex') : b.setAttribute('tabindex', '-1');
28
54
  }
29
55
 
56
+ _slotted() { return this._actionSlot?.assignedElements()[0] ?? null; }
57
+
30
58
  // A bar counts when it is rendered and ends near the bottom of the viewport –
31
59
  // the app's own bar, fixed, sticky or floating. A tab bar shown as a preview
32
60
  // somewhere in a page does not lift the toast.
@@ -41,16 +69,83 @@ class NkToast extends NkElement {
41
69
  this._box.style.bottom = rect ? `${Math.round(innerHeight - rect.top + 12)}px` : '';
42
70
  }
43
71
 
44
- /** Shows `message` (or the slotted content when omitted) and hides it again after `duration`. */
45
- show(message, { duration } = {}) {
72
+ /**
73
+ * Shows `message` (or the slotted content when omitted) and hides it again
74
+ * after `duration` ms – the attribute's, else 2200; with an `action`
75
+ * ({ label, value }) it stays until the action or the × is used.
76
+ */
77
+ show(message, { duration, action } = {}) {
46
78
  if (message !== undefined) this._msg.textContent = message;
79
+ this._shown = (this._shown ?? 0) + 1;
80
+ this._act = action ? { label: String(action.label ?? action.value ?? ''), value: action.value ?? action.label } : null;
81
+ const attr = this.getAttribute('duration'), set = attr !== null && attr.trim() !== '' && Number.isFinite(Number(attr));
82
+ // Number(null) is 0: without the attribute a toast used to stay for good.
83
+ this._ms = duration ?? (this._act || this._slotted() ? 0 : set ? Number(attr) : 2200);
84
+ if (this.getBoolAttr('open')) this._sync(); else this.setBoolAttr('open', true);
85
+ this._arm();
86
+ }
87
+
88
+ close() {
47
89
  clearTimeout(this._timer);
48
- this.setBoolAttr('open', true);
49
- const ms = duration ?? Number(this.getAttribute('duration')) ?? 2200;
50
- if (ms > 0) this._timer = setTimeout(() => this.close(), ms || 2200);
90
+ // The focus leaves with the toast, back to where it came from.
91
+ const active = deepActiveElement();
92
+ if (active && containsDeep(this, active)) {
93
+ this._from?.isConnected && this._from.focus({ preventScroll: true });
94
+ if (containsDeep(this, deepActiveElement())) active.blur();
95
+ }
96
+ this._from = null;
97
+ this.setBoolAttr('open', false);
51
98
  }
52
99
 
53
- close() { clearTimeout(this._timer); this.setBoolAttr('open', false); }
100
+ _arm() {
101
+ clearTimeout(this._timer);
102
+ if (this._ms > 0 && !this._held && this.getBoolAttr('open')) this._timer = setTimeout(() => this.close(), this._ms);
103
+ }
104
+
105
+ _take(act) {
106
+ if (!act) return;
107
+ // A handler that shows the next message – "Restored" – keeps it open.
108
+ const shown = this._shown;
109
+ if (this.emit('nk-action', { action: act.value, value: act.value, label: act.label }) && this._shown === shown) this.close();
110
+ }
111
+
112
+ setupEvents() {
113
+ this._onClick = (e) => {
114
+ const path = e.composedPath();
115
+ if (path.includes(this._close)) { this.close(); return; }
116
+ if (path.includes(this._action)) { this._take(this._act); return; }
117
+ const slotted = this._slotted();
118
+ if (slotted && path.includes(slotted)) this._take({ value: slotted.value || slotted.getAttribute('value') || slotted.textContent.trim(), label: slotted.textContent.trim() });
119
+ };
120
+ // While the pointer or the focus is on it, a toast with a duration waits.
121
+ this._onHold = (e) => {
122
+ if (e.type === 'focusin' && !this._from) this._from = e.relatedTarget ?? null;
123
+ this._held = true; clearTimeout(this._timer);
124
+ };
125
+ this._onRelease = (e) => {
126
+ if (e.relatedTarget && containsDeep(this, e.relatedTarget)) return;
127
+ this._held = false; this._arm();
128
+ };
129
+ this._onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); this.close(); } };
130
+ this._onSlot = () => this._sync();
131
+ this._box.addEventListener('click', this._onClick);
132
+ this._box.addEventListener('pointerover', this._onHold);
133
+ this._box.addEventListener('pointerout', this._onRelease);
134
+ this._box.addEventListener('focusin', this._onHold);
135
+ this._box.addEventListener('focusout', this._onRelease);
136
+ this._box.addEventListener('keydown', this._onKey);
137
+ this._actionSlot.addEventListener('slotchange', this._onSlot);
138
+ }
139
+
140
+ teardownEvents() {
141
+ this._box?.removeEventListener('click', this._onClick);
142
+ this._box?.removeEventListener('pointerover', this._onHold);
143
+ this._box?.removeEventListener('pointerout', this._onRelease);
144
+ this._box?.removeEventListener('focusin', this._onHold);
145
+ this._box?.removeEventListener('focusout', this._onRelease);
146
+ this._box?.removeEventListener('keydown', this._onKey);
147
+ this._actionSlot?.removeEventListener('slotchange', this._onSlot);
148
+ }
54
149
 
55
150
  onAttributeChanged(name) {
56
151
  this._sync();
@@ -62,7 +157,8 @@ class NkToast extends NkElement {
62
157
  get open() { return this.getBoolAttr('open'); }
63
158
  set open(v) { this.setBoolAttr('open', v); }
64
159
  get message() { return this._msg?.textContent ?? ''; }
65
- set message(v) { if (this._msg) this._msg.textContent = v; }
160
+ set message(v) { if (this._msg) { this._msg.textContent = v; this._sync(); } }
161
+ onStringsChanged() { if (this._box) this._sync(); }
66
162
  }
67
163
 
68
164
  customElements.define('nk-toast', NkToast);
@@ -1,6 +1,6 @@
1
1
  import { NkElement } from './base.js';
2
2
  import { a as placeNear } from './shared/floating-DOK73b_D.js';
3
- import { d as deepActiveElement } from './shared/focus-C4tbSNND.js';
3
+ import { d as deepActiveElement } from './shared/focus-D55JGjRA.js';
4
4
  import '@jungherz-de/notionkit/notionkit-styles.js';
5
5
 
6
6
  // <nk-tooltip></nk-tooltip> one for the page: every [data-tooltip]
@@ -44,6 +44,12 @@ function deepActiveElement() {
44
44
  return el;
45
45
  }
46
46
 
47
+ /** Whether `node` lies inside `root`, across shadow roots – Node.contains() stops at them. */
48
+ function containsDeep(root, node) {
49
+ for (let n = node; n; n = n.parentNode ?? n.host) if (n === root) return true;
50
+ return false;
51
+ }
52
+
47
53
  /** The first tabbable element inside a light-DOM subtree, looking into shadow roots. */
48
54
  function firstFocusable(root) {
49
55
  const SEL = 'a[href], button:not([disabled]), input:not([disabled]):not([type=hidden]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable="true"], [contenteditable="plaintext-only"]';
@@ -57,4 +63,4 @@ function firstFocusable(root) {
57
63
  return walk(root);
58
64
  }
59
65
 
60
- export { deepActiveElement as d, firstFocusable as f, inertOutside as i, lockScroll as l, unlockScroll as u };
66
+ export { containsDeep as c, deepActiveElement as d, firstFocusable as f, inertOutside as i, lockScroll as l, unlockScroll as u };
@@ -0,0 +1,35 @@
1
+ // Escape closes the layer that opened last (NotionKit Elements 1.19.0). The
2
+ // overlays – modal, sheet, dialog, side peek, palette, popover, a floating
3
+ // menu or date picker – join this stack while they are open, and one
4
+ // listener on the document, in the capture phase, hands Escape to the top
5
+ // of it only. Each used to listen for itself: a date picker opened from a
6
+ // dialog and the dialog both listened on the document in the same phase,
7
+ // where stopPropagation() does not part them, so one Escape closed both.
8
+ const stack = [];
9
+ let listening = false;
10
+
11
+ function onKey(e) {
12
+ if (e.key !== 'Escape') return;
13
+ // Escape with the focus in a toast is the toast's, not the dialog's below.
14
+ if (e.composedPath().some(n => n.localName === 'nk-toast')) return;
15
+ for (let i = stack.length - 1; i >= 0; i--) if (!stack[i].el.isConnected) stack.splice(i, 1);
16
+ const top = stack.at(-1);
17
+ if (!top) return;
18
+ e.stopPropagation();
19
+ top.close();
20
+ }
21
+
22
+ /** `el` is open on top of the others; Escape calls `close` until closeLayer(el). */
23
+ function openLayer(el, close) {
24
+ closeLayer(el);
25
+ stack.push({ el, close });
26
+ if (!listening && typeof document !== 'undefined') { document.addEventListener('keydown', onKey, true); listening = true; }
27
+ }
28
+
29
+ /** `el` is closed or gone. */
30
+ function closeLayer(el) {
31
+ const i = stack.findIndex(layer => layer.el === el);
32
+ if (i >= 0) stack.splice(i, 1);
33
+ }
34
+
35
+ export { closeLayer as c, openLayer as o };