@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jungherz-de/notionkit-elements",
3
- "version": "1.18.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.18.0"
66
+ "@jungherz-de/notionkit": ">=1.20.0"
67
67
  },
68
68
  "devDependencies": {
69
- "@jungherz-de/notionkit": "^1.18.0",
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",
@@ -2,6 +2,7 @@ import { NkFormElement } from '../../base.js';
2
2
  import { isoOf, parseDay, addDays, isoWeek, resolveLocale, firstWeekday, weekdayNames, monthGrid, stepMonth, weekLabel, CHEVRONS } from '../../util/dates.js';
3
3
  import { placeUnder } from '../../util/floating.js';
4
4
  import { deepActiveElement } from '../../util/focus.js';
5
+ import { openLayer, closeLayer } from '../../util/layers.js';
5
6
 
6
7
  // <nk-calendar name="due" value="2026-06-02" weeks weekend="6,0"></nk-calendar>
7
8
  // <nk-calendar range value="2026-06-08/2026-06-12"></nk-calendar>
@@ -271,14 +272,13 @@ class NkCalendar extends NkFormElement {
271
272
  this._draw();
272
273
  this.emit('nk-change', { value: '', start: null, end: null, time: null });
273
274
  };
274
- // Floating: a tap outside closes it and reaches nothing else; Escape closes (captured, before a modal around it).
275
+ // Floating: a tap outside closes it and reaches nothing else; Escape closes it, before a dialog around it (util/layers.js).
275
276
  this._onOutside = (e) => {
276
277
  const path = e.composedPath();
277
278
  if (path.includes(this) || (this._anchor && path.includes(this._anchor))) return;
278
279
  e.preventDefault(); e.stopPropagation();
279
280
  this.close();
280
281
  };
281
- this._onEscape = (e) => { if (e.key === 'Escape' && this.getBoolAttr('floating') && this.getBoolAttr('open')) { e.stopPropagation(); this.close(); } };
282
282
  this._grid.addEventListener('click', this._onGrid);
283
283
  this._grid.addEventListener('keydown', this._onKey);
284
284
  this._todayBtn.addEventListener('click', this._onToday);
@@ -286,7 +286,6 @@ class NkCalendar extends NkFormElement {
286
286
  this._next.addEventListener('click', this._onNext);
287
287
  this._timeInput.addEventListener('change', this._onTime);
288
288
  this._clear.addEventListener('click', this._onClear);
289
- document.addEventListener('keydown', this._onEscape, true);
290
289
  if (this.getBoolAttr('open')) document.addEventListener('click', this._onOutside, true);
291
290
  }
292
291
 
@@ -298,7 +297,7 @@ class NkCalendar extends NkFormElement {
298
297
  this._next?.removeEventListener('click', this._onNext);
299
298
  this._timeInput?.removeEventListener('change', this._onTime);
300
299
  this._clear?.removeEventListener('click', this._onClear);
301
- document.removeEventListener('keydown', this._onEscape, true);
300
+ closeLayer(this);
302
301
  document.removeEventListener('click', this._onOutside, true);
303
302
  }
304
303
 
@@ -322,7 +321,9 @@ class NkCalendar extends NkFormElement {
322
321
  this._returnFocus = deepActiveElement();
323
322
  if (this._returnFocus?.matches(':focus-visible')) requestAnimationFrame(() => this.focusDay());
324
323
  document.addEventListener('click', this._onOutside, true);
324
+ if (this.getBoolAttr('floating')) openLayer(this, () => this.close());
325
325
  } else {
326
+ closeLayer(this);
326
327
  document.removeEventListener('click', this._onOutside, true);
327
328
  const back = this._returnFocus;
328
329
  this._returnFocus = null;
@@ -1,5 +1,6 @@
1
1
  import { NkElement } from '../../base.js';
2
- import { lockScroll, unlockScroll, inertOutside } from '../../util/focus.js';
2
+ import { lockScroll, unlockScroll, inertOutside, deepActiveElement } from '../../util/focus.js';
3
+ import { openLayer, closeLayer } from '../../util/layers.js';
3
4
  import { fuzzyScore } from '../../util/fuzzy.js';
4
5
 
5
6
  // <nk-cmdk id="palette" placeholder="Search or type a command …"></nk-cmdk>
@@ -133,14 +134,16 @@ class NkCmdk extends NkElement {
133
134
  if (open === this._wasOpen) return;
134
135
  this._wasOpen = open;
135
136
  if (open) {
136
- this._returnFocus = document.activeElement;
137
+ this._returnFocus = deepActiveElement();
137
138
  this._input.value = '';
138
139
  this._index = 0;
139
140
  this._renderList();
140
141
  lockScroll();
141
142
  this._undoInert = inertOutside(this);
143
+ openLayer(this, () => this.close());
142
144
  requestAnimationFrame(() => this._input.focus({ preventScroll: true }));
143
145
  } else {
146
+ closeLayer(this);
144
147
  unlockScroll();
145
148
  this._undoInert?.(); this._undoInert = null;
146
149
  const back = this._returnFocus;
@@ -151,8 +154,7 @@ class NkCmdk extends NkElement {
151
154
 
152
155
  setupEvents() {
153
156
  this._onDocKey = (e) => {
154
- if (this._matchesHotkey(e)) { e.preventDefault(); this.toggle(); return; }
155
- if (e.key === 'Escape' && this.getBoolAttr('open')) { e.stopPropagation(); this.close(); }
157
+ if (this._matchesHotkey(e)) { e.preventDefault(); this.toggle(); }
156
158
  };
157
159
  this._onInput = () => { this._index = 0; this._renderList(); };
158
160
  this._onInputKey = (e) => {
@@ -181,7 +183,7 @@ class NkCmdk extends NkElement {
181
183
  this._list?.removeEventListener('mousemove', this._onListMove);
182
184
  this._list?.removeEventListener('mousedown', this._onListDown);
183
185
  this._backdrop?.removeEventListener('click', this._onBackdrop);
184
- if (this._wasOpen) { unlockScroll(); this._undoInert?.(); this._undoInert = null; this._wasOpen = false; }
186
+ if (this._wasOpen) { closeLayer(this); unlockScroll(); this._undoInert?.(); this._undoInert = null; this._wasOpen = false; }
185
187
  }
186
188
 
187
189
  onAttributeChanged(name) {
@@ -1,5 +1,6 @@
1
1
  import { NkElement } from '../../base.js';
2
2
  import { lockScroll, unlockScroll, inertOutside, firstFocusable, deepActiveElement } from '../../util/focus.js';
3
+ import { openLayer, closeLayer } from '../../util/layers.js';
3
4
 
4
5
  // <nk-dialog id="trash" title="Move “Roadmap” to trash?" alert>
5
6
  // The page and its sub-pages can be restored from Trash for 30 days.
@@ -79,8 +80,10 @@ class NkDialog extends NkElement {
79
80
  this.inert = false;
80
81
  lockScroll();
81
82
  this._undoInert = inertOutside(this);
83
+ openLayer(this, () => this.close(''));
82
84
  requestAnimationFrame(() => (this.querySelector('[autofocus]') || firstFocusable(this) || this._box).focus({ preventScroll: true }));
83
85
  } else {
86
+ closeLayer(this);
84
87
  unlockScroll();
85
88
  this._undoInert?.(); this._undoInert = null;
86
89
  this.inert = this._wasInert;
@@ -92,7 +95,6 @@ class NkDialog extends NkElement {
92
95
 
93
96
  setupEvents() {
94
97
  this._onBackdrop = (e) => { if (e.target === this._backdrop) this.close(''); };
95
- this._onKey = (e) => { if (e.key === 'Escape' && this.getBoolAttr('open')) { e.stopPropagation(); this.close(''); } };
96
98
  // A button in the actions with a value closes it, and one with data-close
97
99
  // anywhere inside; a submit button is the form's.
98
100
  this._onClick = (e) => {
@@ -112,7 +114,6 @@ class NkDialog extends NkElement {
112
114
  this.addEventListener('click', this._onClick);
113
115
  this.addEventListener('submit', this._onSubmit);
114
116
  this._actionsSlot.addEventListener('slotchange', this._onSlot);
115
- document.addEventListener('keydown', this._onKey, true);
116
117
  this._onSlot();
117
118
  }
118
119
 
@@ -121,8 +122,7 @@ class NkDialog extends NkElement {
121
122
  this.removeEventListener('click', this._onClick);
122
123
  this.removeEventListener('submit', this._onSubmit);
123
124
  this._actionsSlot?.removeEventListener('slotchange', this._onSlot);
124
- document.removeEventListener('keydown', this._onKey, true);
125
- if (this._wasOpen) { unlockScroll(); this._undoInert?.(); this._undoInert = null; this._wasOpen = false; }
125
+ if (this._wasOpen) { closeLayer(this); unlockScroll(); this._undoInert?.(); this._undoInert = null; this._wasOpen = false; }
126
126
  }
127
127
 
128
128
  onAttributeChanged(name, oldValue, value) {
@@ -1,5 +1,6 @@
1
1
  import { NkElement } from '../../base.js';
2
- import { deepActiveElement } from '../../util/focus.js';
2
+ import { deepActiveElement, containsDeep } from '../../util/focus.js';
3
+ import { openLayer, closeLayer } from '../../util/layers.js';
3
4
  import { placeUnder } from '../../util/floating.js';
4
5
 
5
6
  // <nk-menu>
@@ -48,15 +49,30 @@ class NkMenu extends NkElement {
48
49
  this._returnFocus = deepActiveElement();
49
50
  if (this._returnFocus?.matches(':focus-visible')) requestAnimationFrame(() => this.focusFirst());
50
51
  document.addEventListener('click', this._onOutside, true);
52
+ if (this.getBoolAttr('floating')) { openLayer(this, () => this.close()); this._scrollToStart(); }
51
53
  } else {
54
+ closeLayer(this);
52
55
  document.removeEventListener('click', this._onOutside, true);
53
56
  const back = this._returnFocus;
54
57
  this._returnFocus = null;
55
- if (back?.isConnected && this.contains(document.activeElement)) back.focus({ preventScroll: true });
58
+ // The focus behind shadow hosts: document.activeElement stops at the
59
+ // host of a view that holds the menu (1.19.0).
60
+ const active = deepActiveElement();
61
+ if (back?.isConnected && active && (containsDeep(this, active) || active === document.body)) back.focus({ preventScroll: true });
56
62
  }
57
63
  this.emit('nk-toggle', { open });
58
64
  }
59
65
 
66
+ // A long menu opens at its top again, or at its first checked item (1.19.0):
67
+ // it kept where it had been scrolled to, its heading out of sight.
68
+ _scrollToStart() {
69
+ this._box.scrollTop = 0;
70
+ const item = this.items.find(i => i.hasAttribute('checked'))?.shadowRoot?.querySelector('.nk-menu-item');
71
+ if (!item) return;
72
+ const box = this._box.getBoundingClientRect(), r = item.getBoundingClientRect();
73
+ if (r.bottom > box.bottom) this._box.scrollTop += r.top - box.top - (box.height - r.height) / 2;
74
+ }
75
+
60
76
  /** Opens under `anchor` (any element, display: contents hosts included). */
61
77
  show(anchor) {
62
78
  if (anchor) {
@@ -94,17 +110,14 @@ class NkMenu extends NkElement {
94
110
  e.preventDefault(); e.stopPropagation();
95
111
  this.close();
96
112
  };
97
- // Captured, so a menu inside a modal or sheet closes before they do.
98
- this._onEscape = (e) => { if (e.key === 'Escape' && this.getBoolAttr('floating') && this.getBoolAttr('open')) { e.stopPropagation(); this.close(); } };
99
113
  this._onSelect = () => { if (this.getBoolAttr('floating')) this.close(); };
100
- document.addEventListener('keydown', this._onEscape, true);
101
114
  this.addEventListener('nk-select', this._onSelect);
102
115
  if (this.getBoolAttr('open')) document.addEventListener('click', this._onOutside, true);
103
116
  }
104
117
 
105
118
  teardownEvents() {
106
119
  this.removeEventListener('keydown', this._onKey);
107
- document.removeEventListener('keydown', this._onEscape, true);
120
+ closeLayer(this);
108
121
  this.removeEventListener('nk-select', this._onSelect);
109
122
  document.removeEventListener('click', this._onOutside, true);
110
123
  }
@@ -1,5 +1,6 @@
1
1
  import { NkElement } from '../../base.js';
2
- import { lockScroll, unlockScroll, inertOutside, firstFocusable } from '../../util/focus.js';
2
+ import { lockScroll, unlockScroll, inertOutside, firstFocusable, deepActiveElement } from '../../util/focus.js';
3
+ import { openLayer, closeLayer } from '../../util/layers.js';
3
4
 
4
5
  // <nk-modal id="settings">
5
6
  // <nk-settings-user slot="user" name="…" mail="…"></nk-settings-user>
@@ -93,14 +94,17 @@ class NkModal extends NkElement {
93
94
  if (open === this._wasOpen) return;
94
95
  this._wasOpen = open;
95
96
  if (open) {
96
- this._returnFocus = document.activeElement;
97
+ // Behind shadow hosts too: a trigger in a view's shadow root gets the focus back.
98
+ this._returnFocus = deepActiveElement();
97
99
  lockScroll();
98
100
  this._undoInert = inertOutside(this);
101
+ openLayer(this, () => this.close());
99
102
  requestAnimationFrame(() => {
100
103
  const target = this._rows.querySelector('.nk-tree-item.active') || firstFocusable(this) || this._box;
101
104
  target.focus({ preventScroll: true });
102
105
  });
103
106
  } else {
107
+ closeLayer(this);
104
108
  unlockScroll();
105
109
  this._undoInert?.(); this._undoInert = null;
106
110
  const back = this._returnFocus;
@@ -111,7 +115,6 @@ class NkModal extends NkElement {
111
115
 
112
116
  setupEvents() {
113
117
  this._onBackdrop = (e) => { if (e.target === this._backdrop) this.close(); };
114
- this._onKey = (e) => { if (e.key === 'Escape' && this.getBoolAttr('open')) { e.stopPropagation(); this.close(); } };
115
118
  this._onNavClick = (e) => { const row = e.target.closest('[data-pane]'); if (row) { row.focus(); this.pane = row.dataset.pane; } };
116
119
  this._onNavKey = (e) => {
117
120
  const row = e.target.closest?.('[data-pane]');
@@ -125,7 +128,6 @@ class NkModal extends NkElement {
125
128
  };
126
129
  this._onSlot = () => this.requestNav();
127
130
  this._backdrop.addEventListener('click', this._onBackdrop);
128
- document.addEventListener('keydown', this._onKey);
129
131
  this._rows.addEventListener('click', this._onNavClick);
130
132
  this._rows.addEventListener('keydown', this._onNavKey);
131
133
  this._slot.addEventListener('slotchange', this._onSlot);
@@ -133,11 +135,10 @@ class NkModal extends NkElement {
133
135
 
134
136
  teardownEvents() {
135
137
  this._backdrop?.removeEventListener('click', this._onBackdrop);
136
- document.removeEventListener('keydown', this._onKey);
137
138
  this._rows?.removeEventListener('click', this._onNavClick);
138
139
  this._rows?.removeEventListener('keydown', this._onNavKey);
139
140
  this._slot?.removeEventListener('slotchange', this._onSlot);
140
- if (this._wasOpen) { unlockScroll(); this._undoInert?.(); this._undoInert = null; this._wasOpen = false; }
141
+ if (this._wasOpen) { closeLayer(this); unlockScroll(); this._undoInert?.(); this._undoInert = null; this._wasOpen = false; }
141
142
  }
142
143
 
143
144
  onStringsChanged() { this._nav.setAttribute('aria-label', this.str('settings')); }
@@ -1,5 +1,6 @@
1
1
  import { NkElement } from '../../base.js';
2
2
  import { lockScroll, unlockScroll, inertOutside, deepActiveElement } from '../../util/focus.js';
3
+ import { openLayer, closeLayer } from '../../util/layers.js';
3
4
 
4
5
  // <nk-peek id="peek" label="Database Table-View">
5
6
  // <nk-btn slot="actions" variant="topbar" aria-label="Open as page"><svg …></svg></nk-btn>
@@ -108,8 +109,10 @@ class NkPeek extends NkElement {
108
109
  lockScroll();
109
110
  this._undoInert = inertOutside(this);
110
111
  }
112
+ openLayer(this, () => this.close());
111
113
  requestAnimationFrame(() => this._box.focus({ preventScroll: true }));
112
114
  } else {
115
+ closeLayer(this);
113
116
  if (this._modal) { this._modal = false; this._box.removeAttribute('aria-modal'); unlockScroll(); this._undoInert?.(); this._undoInert = null; }
114
117
  const active = deepActiveElement();
115
118
  const inside = active && (this.contains(active) || this._shadow.contains(active) || active === document.body);
@@ -122,7 +125,6 @@ class NkPeek extends NkElement {
122
125
  setupEvents() {
123
126
  this._onClose = () => this.close();
124
127
  this._onBackdrop = (e) => { if (e.target === this._backdrop) this.close(); };
125
- this._onKey = (e) => { if (e.key === 'Escape' && this.getBoolAttr('open')) this.close(); };
126
128
  // Bubble phase, after the page's own handlers: a click that showed the
127
129
  // peek again (another row) must not close it.
128
130
  this._onOutside = (e) => {
@@ -156,7 +158,6 @@ class NkPeek extends NkElement {
156
158
  this._handle.addEventListener('pointermove', this._onResizeMove);
157
159
  this._handle.addEventListener('lostpointercapture', this._onResizeEnd);
158
160
  this._handle.addEventListener('keydown', this._onResizeKey);
159
- document.addEventListener('keydown', this._onKey);
160
161
  document.addEventListener('click', this._onOutside);
161
162
  addEventListener('resize', this._onViewport);
162
163
  }
@@ -168,7 +169,7 @@ class NkPeek extends NkElement {
168
169
  this._handle?.removeEventListener('pointermove', this._onResizeMove);
169
170
  this._handle?.removeEventListener('lostpointercapture', this._onResizeEnd);
170
171
  this._handle?.removeEventListener('keydown', this._onResizeKey);
171
- document.removeEventListener('keydown', this._onKey);
172
+ closeLayer(this);
172
173
  document.removeEventListener('click', this._onOutside);
173
174
  removeEventListener('resize', this._onViewport);
174
175
  if (this._insetApp) { this._app()?.removeAttribute('peek-inset'); this._insetApp = false; }
@@ -1,4 +1,5 @@
1
1
  import { NkElement } from '../../base.js';
2
+ import { openLayer, closeLayer } from '../../util/layers.js';
2
3
 
3
4
  // <nk-pop placement="bottom-start">
4
5
  // <nk-btn slot="trigger" variant="secondary">Options</nk-btn>
@@ -48,12 +49,10 @@ class NkPop extends NkElement {
48
49
  this.toggle();
49
50
  };
50
51
  this._onDocClick = (e) => { if (this.getBoolAttr('open') && !e.composedPath().includes(this)) this.close(); };
51
- this._onKey = (e) => { if (e.key === 'Escape' && this.getBoolAttr('open')) this.close(); };
52
52
  this._onSelect = (e) => { if (e.target !== this) this.close(); };
53
53
  this._onSlot = () => this._syncSurface();
54
54
  this.addEventListener('click', this._onTrigger);
55
55
  document.addEventListener('click', this._onDocClick);
56
- document.addEventListener('keydown', this._onKey);
57
56
  this._float.addEventListener('nk-select', this._onSelect);
58
57
  this._slot.addEventListener('slotchange', this._onSlot);
59
58
  }
@@ -61,7 +60,7 @@ class NkPop extends NkElement {
61
60
  teardownEvents() {
62
61
  this.removeEventListener('click', this._onTrigger);
63
62
  document.removeEventListener('click', this._onDocClick);
64
- document.removeEventListener('keydown', this._onKey);
63
+ closeLayer(this);
65
64
  this._float?.removeEventListener('nk-select', this._onSelect);
66
65
  this._slot?.removeEventListener('slotchange', this._onSlot);
67
66
  }
@@ -70,6 +69,7 @@ class NkPop extends NkElement {
70
69
  if (name === 'bare') this._syncSurface();
71
70
  if (name === 'open') {
72
71
  const open = this.getBoolAttr('open');
72
+ if (open) openLayer(this, () => this.close()); else closeLayer(this);
73
73
  for (const el of this._triggerSlot.assignedElements()) el.setAttribute('aria-expanded', open ? 'true' : 'false');
74
74
  this.emit('nk-toggle', { open });
75
75
  }
@@ -1,5 +1,6 @@
1
1
  import { NkElement } from '../../base.js';
2
2
  import { lockScroll, unlockScroll, inertOutside, firstFocusable, deepActiveElement } from '../../util/focus.js';
3
+ import { openLayer, closeLayer } from '../../util/layers.js';
3
4
 
4
5
  // <nk-sheet id="more" title="More">
5
6
  // <nk-tree manual>
@@ -52,8 +53,10 @@ class NkSheet extends NkElement {
52
53
  this._returnFocus = deepActiveElement();
53
54
  lockScroll();
54
55
  this._undoInert = inertOutside(this);
56
+ openLayer(this, () => this.close());
55
57
  requestAnimationFrame(() => (firstFocusable(this) || this._box).focus({ preventScroll: true }));
56
58
  } else {
59
+ closeLayer(this);
57
60
  unlockScroll();
58
61
  this._undoInert?.(); this._undoInert = null;
59
62
  const back = this._returnFocus;
@@ -64,15 +67,12 @@ class NkSheet extends NkElement {
64
67
 
65
68
  setupEvents() {
66
69
  this._onBackdrop = (e) => { if (e.target === this._backdrop) this.close(); };
67
- this._onKey = (e) => { if (e.key === 'Escape' && this.getBoolAttr('open')) { e.stopPropagation(); this.close(); } };
68
70
  this._backdrop.addEventListener('click', this._onBackdrop);
69
- document.addEventListener('keydown', this._onKey);
70
71
  }
71
72
 
72
73
  teardownEvents() {
73
74
  this._backdrop?.removeEventListener('click', this._onBackdrop);
74
- document.removeEventListener('keydown', this._onKey);
75
- if (this._wasOpen) { unlockScroll(); this._undoInert?.(); this._undoInert = null; this._wasOpen = false; }
75
+ if (this._wasOpen) { closeLayer(this); unlockScroll(); this._undoInert?.(); this._undoInert = null; this._wasOpen = false; }
76
76
  }
77
77
 
78
78
  onAttributeChanged(name, oldValue, value) {
@@ -1,11 +1,21 @@
1
1
  import { NkElement } from '../../base.js';
2
+ import { deepActiveElement, containsDeep } from '../../util/focus.js';
2
3
 
3
4
  // <nk-toast id="toast"></nk-toast> … toast.show('Saved');
4
- // → <div class="nk-toast show">✓ Saved</div>
5
+ // toast.show('Moved to trash', { action: { label: 'Undo', value: 'undo' } });
6
+ // → <div class="nk-toast show">✓ Moved to trash<button class="t-action">Undo</button><button class="t-close">×</button></div>
5
7
  // One node, bottom-centred, auto-hides after `duration` ms (default 2200).
6
8
  // Above a tab bar at the bottom of the screen it rises 12px over the bar:
7
9
  // notionkit.css does that for class markup with :has(), which cannot see
8
10
  // into shadow roots, so the element measures the bar when it opens.
11
+ // An action (1.19.0) – from show() or a <button slot="action"> – makes it
12
+ // Notion's "Moved to trash · Undo": a button, and an × that closes. Such a
13
+ // toast stays until one of them is used, unless show() names a duration,
14
+ // and waits while the pointer or the focus is on it. The action fires
15
+ // nk-action { action, value, label } and, unless cancelled, closes it;
16
+ // Escape closes it while the focus is inside.
17
+ const CLOSE_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>';
18
+
9
19
  class NkToast extends NkElement {
10
20
  static get observedAttributes() { return ['open', 'duration', 'icon']; }
11
21
 
@@ -13,19 +23,37 @@ class NkToast extends NkElement {
13
23
  this._box = this.createElement('div', ['nk-toast'], { role: 'status', 'aria-live': 'polite' });
14
24
  this._icon = document.createElement('span');
15
25
  this._msg = document.createElement('span');
16
- this._box.append(this._icon, this._msg, document.createElement('slot'));
26
+ this._action = this.createElement('button', ['t-action'], { type: 'button' });
27
+ this._actionSlot = this.createElement('slot', [], { name: 'action' });
28
+ this._close = this.createElement('button', ['t-close'], { type: 'button' });
29
+ this._close.innerHTML = CLOSE_ICON;
30
+ this._box.append(this._icon, this._msg, document.createElement('slot'), this._actionSlot);
17
31
  this._wrapper.appendChild(this._box);
18
32
  this._sync();
19
33
  }
20
34
 
21
35
  _sync() {
22
- if (this.getBoolAttr('open')) this._lift();
23
- this._box.classList.toggle('show', this.getBoolAttr('open'));
36
+ const open = this.getBoolAttr('open');
37
+ if (open) this._lift();
38
+ this._box.classList.toggle('show', open);
24
39
  const icon = this.getAttribute('icon') ?? '✓';
25
40
  this._icon.textContent = icon;
26
41
  this._icon.style.display = icon ? '' : 'none';
42
+ // An empty message takes no place – with slotted text it added a second gap.
43
+ this._msg.style.display = this._msg.textContent ? '' : 'none';
44
+ // The action from show() and the × sit in the tree only when there is an
45
+ // action; a closed toast keeps them for its fade, out of the tab order.
46
+ const own = this._act, slotted = this._slotted();
47
+ if (own) { this._action.textContent = own.label; if (!this._action.isConnected) this._actionSlot.before(this._action); }
48
+ else this._action.remove();
49
+ if (own || slotted) { if (!this._close.isConnected) this._box.appendChild(this._close); }
50
+ else this._close.remove();
51
+ this._close.setAttribute('aria-label', this.str('close'));
52
+ for (const b of [this._action, this._close]) open ? b.removeAttribute('tabindex') : b.setAttribute('tabindex', '-1');
27
53
  }
28
54
 
55
+ _slotted() { return this._actionSlot?.assignedElements()[0] ?? null; }
56
+
29
57
  // A bar counts when it is rendered and ends near the bottom of the viewport –
30
58
  // the app's own bar, fixed, sticky or floating. A tab bar shown as a preview
31
59
  // somewhere in a page does not lift the toast.
@@ -40,16 +68,83 @@ class NkToast extends NkElement {
40
68
  this._box.style.bottom = rect ? `${Math.round(innerHeight - rect.top + 12)}px` : '';
41
69
  }
42
70
 
43
- /** Shows `message` (or the slotted content when omitted) and hides it again after `duration`. */
44
- show(message, { duration } = {}) {
71
+ /**
72
+ * Shows `message` (or the slotted content when omitted) and hides it again
73
+ * after `duration` ms – the attribute's, else 2200; with an `action`
74
+ * ({ label, value }) it stays until the action or the × is used.
75
+ */
76
+ show(message, { duration, action } = {}) {
45
77
  if (message !== undefined) this._msg.textContent = message;
78
+ this._shown = (this._shown ?? 0) + 1;
79
+ this._act = action ? { label: String(action.label ?? action.value ?? ''), value: action.value ?? action.label } : null;
80
+ const attr = this.getAttribute('duration'), set = attr !== null && attr.trim() !== '' && Number.isFinite(Number(attr));
81
+ // Number(null) is 0: without the attribute a toast used to stay for good.
82
+ this._ms = duration ?? (this._act || this._slotted() ? 0 : set ? Number(attr) : 2200);
83
+ if (this.getBoolAttr('open')) this._sync(); else this.setBoolAttr('open', true);
84
+ this._arm();
85
+ }
86
+
87
+ close() {
46
88
  clearTimeout(this._timer);
47
- this.setBoolAttr('open', true);
48
- const ms = duration ?? Number(this.getAttribute('duration')) ?? 2200;
49
- if (ms > 0) this._timer = setTimeout(() => this.close(), ms || 2200);
89
+ // The focus leaves with the toast, back to where it came from.
90
+ const active = deepActiveElement();
91
+ if (active && containsDeep(this, active)) {
92
+ this._from?.isConnected && this._from.focus({ preventScroll: true });
93
+ if (containsDeep(this, deepActiveElement())) active.blur();
94
+ }
95
+ this._from = null;
96
+ this.setBoolAttr('open', false);
50
97
  }
51
98
 
52
- close() { clearTimeout(this._timer); this.setBoolAttr('open', false); }
99
+ _arm() {
100
+ clearTimeout(this._timer);
101
+ if (this._ms > 0 && !this._held && this.getBoolAttr('open')) this._timer = setTimeout(() => this.close(), this._ms);
102
+ }
103
+
104
+ _take(act) {
105
+ if (!act) return;
106
+ // A handler that shows the next message – "Restored" – keeps it open.
107
+ const shown = this._shown;
108
+ if (this.emit('nk-action', { action: act.value, value: act.value, label: act.label }) && this._shown === shown) this.close();
109
+ }
110
+
111
+ setupEvents() {
112
+ this._onClick = (e) => {
113
+ const path = e.composedPath();
114
+ if (path.includes(this._close)) { this.close(); return; }
115
+ if (path.includes(this._action)) { this._take(this._act); return; }
116
+ const slotted = this._slotted();
117
+ if (slotted && path.includes(slotted)) this._take({ value: slotted.value || slotted.getAttribute('value') || slotted.textContent.trim(), label: slotted.textContent.trim() });
118
+ };
119
+ // While the pointer or the focus is on it, a toast with a duration waits.
120
+ this._onHold = (e) => {
121
+ if (e.type === 'focusin' && !this._from) this._from = e.relatedTarget ?? null;
122
+ this._held = true; clearTimeout(this._timer);
123
+ };
124
+ this._onRelease = (e) => {
125
+ if (e.relatedTarget && containsDeep(this, e.relatedTarget)) return;
126
+ this._held = false; this._arm();
127
+ };
128
+ this._onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); this.close(); } };
129
+ this._onSlot = () => this._sync();
130
+ this._box.addEventListener('click', this._onClick);
131
+ this._box.addEventListener('pointerover', this._onHold);
132
+ this._box.addEventListener('pointerout', this._onRelease);
133
+ this._box.addEventListener('focusin', this._onHold);
134
+ this._box.addEventListener('focusout', this._onRelease);
135
+ this._box.addEventListener('keydown', this._onKey);
136
+ this._actionSlot.addEventListener('slotchange', this._onSlot);
137
+ }
138
+
139
+ teardownEvents() {
140
+ this._box?.removeEventListener('click', this._onClick);
141
+ this._box?.removeEventListener('pointerover', this._onHold);
142
+ this._box?.removeEventListener('pointerout', this._onRelease);
143
+ this._box?.removeEventListener('focusin', this._onHold);
144
+ this._box?.removeEventListener('focusout', this._onRelease);
145
+ this._box?.removeEventListener('keydown', this._onKey);
146
+ this._actionSlot?.removeEventListener('slotchange', this._onSlot);
147
+ }
53
148
 
54
149
  onAttributeChanged(name) {
55
150
  this._sync();
@@ -61,7 +156,8 @@ class NkToast extends NkElement {
61
156
  get open() { return this.getBoolAttr('open'); }
62
157
  set open(v) { this.setBoolAttr('open', v); }
63
158
  get message() { return this._msg?.textContent ?? ''; }
64
- set message(v) { if (this._msg) this._msg.textContent = v; }
159
+ set message(v) { if (this._msg) { this._msg.textContent = v; this._sync(); } }
160
+ onStringsChanged() { if (this._box) this._sync(); }
65
161
  }
66
162
 
67
163
  customElements.define('nk-toast', NkToast);