flexdesk 0.17.1 → 0.17.3
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/CHANGELOG.md +38 -0
- package/README.md +206 -1
- package/css/base.css +23 -1
- package/css/flexdesk.css +37 -1
- package/dist/flexdesk.css +37 -1
- package/dist/widgets.js +188 -74
- package/dist/widgets.js.map +2 -2
- package/dist/wm.js +90 -10
- package/dist/wm.js.map +2 -2
- package/package.json +1 -1
- package/src/tiling/command_palette.js +46 -7
- package/src/tiling/content_registry.js +3 -0
- package/src/tiling/entity_sources.js +10 -0
- package/src/tiling/wm.js +65 -3
- package/src/ui/components/notification_history.js +17 -24
- package/src/ui/components/settings_view.js +192 -51
- package/src/ui/notification_center.js +46 -8
|
@@ -50,6 +50,20 @@
|
|
|
50
50
|
* *Reset all* ask `confirm(…)` first (default `openConfirm`) and change
|
|
51
51
|
* nothing unless it resolves true.
|
|
52
52
|
*
|
|
53
|
+
* THE SIDEBAR ELSEWHERE (0.17.2). `sidebar: false` leaves the sidebar out:
|
|
54
|
+
* the view is the content alone. A `SettingsCategories` bound to it, mounted
|
|
55
|
+
* anywhere, draws the filter box, the categories and *Reset all* instead:
|
|
56
|
+
*
|
|
57
|
+
* const view = new SettingsView({ store, eventBus, sidebar: false });
|
|
58
|
+
* view.mount(pageHost);
|
|
59
|
+
* const cats = new SettingsCategories({ view });
|
|
60
|
+
* cats.mount(navigatorHost);
|
|
61
|
+
*
|
|
62
|
+
* Both draw from the view's state, so the filter text and the selected
|
|
63
|
+
* category are the same in both, whichever moved them; `view.subscribe(fn)`
|
|
64
|
+
* tells any other listener. *Reset all* there is `view.resetAll()`, so it asks
|
|
65
|
+
* to confirm as before.
|
|
66
|
+
*
|
|
53
67
|
* Every word on screen that comes from the schema, the store or the user is
|
|
54
68
|
* set as text, so a label or a query holding markup is shown as typed.
|
|
55
69
|
*
|
|
@@ -58,7 +72,9 @@
|
|
|
58
72
|
* + `.twm-settings__content` (`__header` > `__title`, `__description`,
|
|
59
73
|
* `__reset-category`; `section.__group` > `__group-title` + `.__row`
|
|
60
74
|
* [`--modified`] > `__info` (`__label`, `__marker`, `__description`,
|
|
61
|
-
* `__path`) + `__control` + `__reset`; `__empty`).
|
|
75
|
+
* `__path`) + `__control` + `__reset`; `__empty`). A `SettingsCategories` is
|
|
76
|
+
* `.twm-settings-categories` > `.twm-settings__search`, `.twm-settings__nav`
|
|
77
|
+
* and `.twm-settings__reset-all`, the same parts as the sidebar's.
|
|
62
78
|
*/
|
|
63
79
|
|
|
64
80
|
import { ComponentBase } from '../base/component_base.js';
|
|
@@ -121,6 +137,59 @@ function fitNumber(n, { min, max, step }) {
|
|
|
121
137
|
return at(k);
|
|
122
138
|
}
|
|
123
139
|
|
|
140
|
+
// ── the sidebar's parts, drawn alike by SettingsView and SettingsCategories ──
|
|
141
|
+
|
|
142
|
+
/** The filter box: `{ label, input }`; `onInput(text)` on every keystroke. */
|
|
143
|
+
function filterBox(onInput) {
|
|
144
|
+
const label = el('label', 'twm-settings__search');
|
|
145
|
+
const input = el('input', 'twm-settings__search-input');
|
|
146
|
+
input.type = 'search';
|
|
147
|
+
input.placeholder = 'Filter settings…';
|
|
148
|
+
input.autocomplete = 'off';
|
|
149
|
+
input.spellcheck = false;
|
|
150
|
+
input.setAttribute('aria-label', 'Filter settings');
|
|
151
|
+
input.addEventListener('input', () => onInput(input.value));
|
|
152
|
+
label.append(icon('search'), input);
|
|
153
|
+
return { label, input };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The category list; `onSelect(id)` when a category is clicked. */
|
|
157
|
+
function categoryNav(onSelect) {
|
|
158
|
+
const nav = el('nav', 'twm-settings__nav');
|
|
159
|
+
nav.setAttribute('aria-label', 'Settings categories');
|
|
160
|
+
nav.addEventListener('click', (e) => {
|
|
161
|
+
const btn = e.target.closest?.('[data-category]');
|
|
162
|
+
if (btn && nav.contains(btn)) onSelect(btn.dataset.category);
|
|
163
|
+
});
|
|
164
|
+
return nav;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Draw `categories` into `nav`, `active` (an id, or null) marked. */
|
|
168
|
+
function paintCategories(nav, categories, active) {
|
|
169
|
+
const buttons = categories.map((cat) => {
|
|
170
|
+
const b = el('button', 'twm-settings__cat');
|
|
171
|
+
b.type = 'button';
|
|
172
|
+
b.dataset.category = cat.id;
|
|
173
|
+
if (cat.id === active) {
|
|
174
|
+
b.classList.add('twm-settings__cat--active');
|
|
175
|
+
b.setAttribute('aria-current', 'page');
|
|
176
|
+
}
|
|
177
|
+
b.append(icon(cat.icon || 'tune'), el('span', 'twm-settings__cat-label', cat.label ?? cat.id));
|
|
178
|
+
if (cat.description) b.title = String(cat.description);
|
|
179
|
+
return b;
|
|
180
|
+
});
|
|
181
|
+
nav.replaceChildren(...buttons);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The *Reset all* button; `onClick()` when pressed. */
|
|
185
|
+
function resetAllButton(onClick) {
|
|
186
|
+
const b = el('button', 'twm-btn twm-settings__reset-all');
|
|
187
|
+
b.type = 'button';
|
|
188
|
+
b.append(icon('restart_alt'), el('span', '', 'Reset all'));
|
|
189
|
+
b.addEventListener('click', () => { onClick(); });
|
|
190
|
+
return b;
|
|
191
|
+
}
|
|
192
|
+
|
|
124
193
|
export class SettingsView extends ComponentBase {
|
|
125
194
|
/**
|
|
126
195
|
* @param {object} o
|
|
@@ -130,10 +199,12 @@ export class SettingsView extends ComponentBase {
|
|
|
130
199
|
* @param {string} [o.category] the category shown first (default: the first)
|
|
131
200
|
* @param {number} [o.textDebounceMs]
|
|
132
201
|
* @param {string} [o.title='Settings'] the sidebar heading
|
|
202
|
+
* @param {boolean} [o.sidebar=true] false leaves the sidebar (filter,
|
|
203
|
+
* categories, *Reset all*) out, for a `SettingsCategories` elsewhere
|
|
133
204
|
*/
|
|
134
205
|
constructor({
|
|
135
206
|
store, eventBus, confirm = openConfirm, category = null,
|
|
136
|
-
textDebounceMs = SETTINGS_TEXT_DEBOUNCE_MS, title = 'Settings', logger,
|
|
207
|
+
textDebounceMs = SETTINGS_TEXT_DEBOUNCE_MS, title = 'Settings', sidebar = true, logger,
|
|
137
208
|
} = {}) {
|
|
138
209
|
super({ eventBus, logger });
|
|
139
210
|
if (!store || typeof store.getSchema !== 'function') {
|
|
@@ -148,6 +219,9 @@ export class SettingsView extends ComponentBase {
|
|
|
148
219
|
this._query = '';
|
|
149
220
|
this._debounceMs = textDebounceMs;
|
|
150
221
|
this._title = title;
|
|
222
|
+
this._sidebar = sidebar !== false;
|
|
223
|
+
/** `fn({ category, query })` after the category, the filter or the categories change. */
|
|
224
|
+
this._listeners = new Set();
|
|
151
225
|
|
|
152
226
|
this.root = null;
|
|
153
227
|
this._nav = null;
|
|
@@ -166,39 +240,20 @@ export class SettingsView extends ComponentBase {
|
|
|
166
240
|
mount(container) {
|
|
167
241
|
if (this.root) return this.root;
|
|
168
242
|
const root = el('div', 'twm-settings');
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
input.setAttribute('aria-label', 'Filter settings');
|
|
180
|
-
input.addEventListener('input', () => this.setQuery(input.value));
|
|
181
|
-
search.append(icon('search'), input);
|
|
182
|
-
this._search = input;
|
|
183
|
-
|
|
184
|
-
const nav = el('nav', 'twm-settings__nav');
|
|
185
|
-
nav.setAttribute('aria-label', 'Settings categories');
|
|
186
|
-
nav.addEventListener('click', (e) => {
|
|
187
|
-
const btn = e.target.closest?.('[data-category]');
|
|
188
|
-
if (btn && nav.contains(btn)) this.selectCategory(btn.dataset.category);
|
|
189
|
-
});
|
|
190
|
-
this._nav = nav;
|
|
191
|
-
|
|
192
|
-
const resetAll = el('button', 'twm-btn twm-settings__reset-all');
|
|
193
|
-
resetAll.type = 'button';
|
|
194
|
-
resetAll.append(icon('restart_alt'), el('span', '', 'Reset all'));
|
|
195
|
-
resetAll.addEventListener('click', () => { this.resetAll(); });
|
|
196
|
-
|
|
197
|
-
side.append(head, search, nav, resetAll);
|
|
243
|
+
if (this._sidebar) {
|
|
244
|
+
const side = el('aside', 'twm-settings__sidebar');
|
|
245
|
+
const head = el('div', 'twm-settings__sidebar-title');
|
|
246
|
+
head.append(icon('settings'), el('span', '', this._title));
|
|
247
|
+
const search = filterBox((text) => this.setQuery(text));
|
|
248
|
+
this._search = search.input;
|
|
249
|
+
this._nav = categoryNav((id) => this.selectCategory(id));
|
|
250
|
+
side.append(head, search.label, this._nav, resetAllButton(() => this.resetAll()));
|
|
251
|
+
root.append(side);
|
|
252
|
+
}
|
|
198
253
|
|
|
199
254
|
const content = el('div', 'twm-settings__content');
|
|
200
255
|
this._content = content;
|
|
201
|
-
root.append(
|
|
256
|
+
root.append(content);
|
|
202
257
|
this.root = root;
|
|
203
258
|
container.appendChild(root);
|
|
204
259
|
this._mounted = true;
|
|
@@ -211,6 +266,7 @@ export class SettingsView extends ComponentBase {
|
|
|
211
266
|
|
|
212
267
|
this.#paintNav();
|
|
213
268
|
this.#paintContent();
|
|
269
|
+
this.#notify();
|
|
214
270
|
return root;
|
|
215
271
|
}
|
|
216
272
|
|
|
@@ -220,6 +276,19 @@ export class SettingsView extends ComponentBase {
|
|
|
220
276
|
/** The filter text. */
|
|
221
277
|
getQuery() { return this._query; }
|
|
222
278
|
|
|
279
|
+
/** True while the filter is in effect (its trimmed text is not empty). */
|
|
280
|
+
isFiltering() { return this.#searching(); }
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* `fn({ category, query })` after every change of the category, the filter
|
|
284
|
+
* or the registered categories, until the view is disposed. Answers the
|
|
285
|
+
* unsubscribe.
|
|
286
|
+
*/
|
|
287
|
+
subscribe(fn) {
|
|
288
|
+
this._listeners.add(fn);
|
|
289
|
+
return () => this._listeners.delete(fn);
|
|
290
|
+
}
|
|
291
|
+
|
|
223
292
|
/** Show one category; clears the filter. */
|
|
224
293
|
selectCategory(id) {
|
|
225
294
|
if (!this.root) return;
|
|
@@ -229,6 +298,7 @@ export class SettingsView extends ComponentBase {
|
|
|
229
298
|
if (this._search) this._search.value = '';
|
|
230
299
|
this.#paintNav();
|
|
231
300
|
this.#paintContent();
|
|
301
|
+
this.#notify();
|
|
232
302
|
}
|
|
233
303
|
|
|
234
304
|
/** Search every category; an empty query goes back to the category. */
|
|
@@ -239,6 +309,7 @@ export class SettingsView extends ComponentBase {
|
|
|
239
309
|
if (this._search && this._search.value !== q) this._search.value = q;
|
|
240
310
|
this.#paintNav();
|
|
241
311
|
this.#paintContent();
|
|
312
|
+
this.#notify();
|
|
242
313
|
}
|
|
243
314
|
|
|
244
315
|
/** Reset one category after `confirm`; resolves true when it was reset. */
|
|
@@ -287,6 +358,7 @@ export class SettingsView extends ComponentBase {
|
|
|
287
358
|
this._nav = null;
|
|
288
359
|
this._content = null;
|
|
289
360
|
this._search = null;
|
|
361
|
+
this._listeners.clear();
|
|
290
362
|
super.dispose();
|
|
291
363
|
}
|
|
292
364
|
|
|
@@ -310,12 +382,13 @@ export class SettingsView extends ComponentBase {
|
|
|
310
382
|
|| paths.some((p) => schema[p]?.category === active);
|
|
311
383
|
if (touches) {
|
|
312
384
|
this.#paintContent();
|
|
313
|
-
|
|
385
|
+
} else {
|
|
386
|
+
// The slice's defaults may have moved values on screen; an edit still
|
|
387
|
+
// being typed is written first, so the sync shows it rather than drop it.
|
|
388
|
+
this.#flushAll();
|
|
389
|
+
this.#syncAll();
|
|
314
390
|
}
|
|
315
|
-
|
|
316
|
-
// being typed is written first, so the sync shows it rather than drop it.
|
|
317
|
-
this.#flushAll();
|
|
318
|
-
this.#syncAll();
|
|
391
|
+
this.#notify();
|
|
319
392
|
}
|
|
320
393
|
|
|
321
394
|
/** True while a filter is in effect: the trimmed query is not empty. */
|
|
@@ -344,20 +417,14 @@ export class SettingsView extends ComponentBase {
|
|
|
344
417
|
}
|
|
345
418
|
|
|
346
419
|
#paintNav() {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
}
|
|
356
|
-
b.append(icon(cat.icon || 'tune'), el('span', 'twm-settings__cat-label', cat.label ?? cat.id));
|
|
357
|
-
if (cat.description) b.title = String(cat.description);
|
|
358
|
-
return b;
|
|
359
|
-
});
|
|
360
|
-
this._nav.replaceChildren(...buttons);
|
|
420
|
+
if (!this._nav) return;
|
|
421
|
+
paintCategories(this._nav, this.store.getCategories(), this.#searching() ? null : this.getCategory());
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Tell the listeners, after the content is drawn (it settles the category). */
|
|
425
|
+
#notify() {
|
|
426
|
+
const state = { category: this.getCategory(), query: this._query };
|
|
427
|
+
for (const fn of [...this._listeners]) fn(state);
|
|
361
428
|
}
|
|
362
429
|
|
|
363
430
|
#paintContent() {
|
|
@@ -614,6 +681,80 @@ export class SettingsView extends ComponentBase {
|
|
|
614
681
|
}
|
|
615
682
|
}
|
|
616
683
|
|
|
684
|
+
/**
|
|
685
|
+
* SettingsCategories — a SettingsView's filter box, category list and
|
|
686
|
+
* *Reset all*, mounted wherever the app wants them (0.17.2).
|
|
687
|
+
*
|
|
688
|
+
* const view = new SettingsView({ store, eventBus, sidebar: false });
|
|
689
|
+
* view.mount(pageHost);
|
|
690
|
+
* const cats = new SettingsCategories({ view });
|
|
691
|
+
* cats.mount(navigatorHost);
|
|
692
|
+
* cats.dispose(); // before or with the view
|
|
693
|
+
*
|
|
694
|
+
* It holds no state of its own: typing in its filter is `view.setQuery()`,
|
|
695
|
+
* clicking a category `view.selectCategory()`, *Reset all* `view.resetAll()`
|
|
696
|
+
* (which asks the view's `confirm` first), and it redraws from the view on
|
|
697
|
+
* every `view.subscribe()` call. So the filter text and the selected category
|
|
698
|
+
* are always the view's, however either side moved them, and a category
|
|
699
|
+
* registered later joins the list. Its clicks and keystrokes act once the
|
|
700
|
+
* view is mounted.
|
|
701
|
+
*
|
|
702
|
+
* Markup: `.twm-settings-categories` > `.twm-settings__search` (>
|
|
703
|
+
* `__search-input`), `.twm-settings__nav` > `button.twm-settings__cat`
|
|
704
|
+
* [`--active`], `.twm-settings__reset-all`.
|
|
705
|
+
*/
|
|
706
|
+
export class SettingsCategories extends ComponentBase {
|
|
707
|
+
/**
|
|
708
|
+
* @param {object} o
|
|
709
|
+
* @param {SettingsView} o.view the view it lists the categories of
|
|
710
|
+
*/
|
|
711
|
+
constructor({ view, logger } = {}) {
|
|
712
|
+
if (!(view instanceof SettingsView)) {
|
|
713
|
+
throw new TypeError('SettingsCategories: `view` must be a SettingsView');
|
|
714
|
+
}
|
|
715
|
+
super({ eventBus: view.eventBus, logger });
|
|
716
|
+
this.view = view;
|
|
717
|
+
this.root = null;
|
|
718
|
+
this._search = null;
|
|
719
|
+
this._nav = null;
|
|
720
|
+
this._unsubscribe = null;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** Build the list into `container` (once). */
|
|
724
|
+
mount(container) {
|
|
725
|
+
if (this.root) return this.root;
|
|
726
|
+
const view = this.view;
|
|
727
|
+
const root = el('div', 'twm-settings-categories');
|
|
728
|
+
const search = filterBox((text) => view.setQuery(text));
|
|
729
|
+
this._search = search.input;
|
|
730
|
+
this._nav = categoryNav((id) => view.selectCategory(id));
|
|
731
|
+
root.append(search.label, this._nav, resetAllButton(() => view.resetAll()));
|
|
732
|
+
this.root = root;
|
|
733
|
+
container.appendChild(root);
|
|
734
|
+
this._mounted = true;
|
|
735
|
+
this._unsubscribe = view.subscribe(() => this.#paint());
|
|
736
|
+
this.#paint();
|
|
737
|
+
return root;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
dispose() {
|
|
741
|
+
this._unsubscribe?.();
|
|
742
|
+
this._unsubscribe = null;
|
|
743
|
+
this.root?.remove();
|
|
744
|
+
this.root = null;
|
|
745
|
+
this._search = null;
|
|
746
|
+
this._nav = null;
|
|
747
|
+
super.dispose();
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
#paint() {
|
|
751
|
+
const view = this.view;
|
|
752
|
+
const q = view.getQuery();
|
|
753
|
+
if (this._search.value !== q) this._search.value = q;
|
|
754
|
+
paintCategories(this._nav, view.store.getCategories(), view.isFiltering() ? null : view.getCategory());
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
617
758
|
/**
|
|
618
759
|
* A content factory for the shell: `(hostEl, props, ctx) -> { title, destroy }`.
|
|
619
760
|
* `props.category` picks the category shown first.
|
|
@@ -27,6 +27,14 @@
|
|
|
27
27
|
* collapsed state and a hovered button survive. A running progress toast is
|
|
28
28
|
* never held behind "+N more" (and never dropped by 'drop'); it counts toward
|
|
29
29
|
* `maxVisible`, so it takes the place of the newest other toast.
|
|
30
|
+
*
|
|
31
|
+
* History subscribers (0.17.3)
|
|
32
|
+
* ----------------------------
|
|
33
|
+
* `history` holds one entry per toast, whichever way it came (`show()`, the
|
|
34
|
+
* `warn()`/`error()`/... helpers, `toast:show`, `notification:show`,
|
|
35
|
+
* `log:entry`). A toast that leaves marks its own entry `dismissed` with a
|
|
36
|
+
* `reason`; it adds none. `subscribe(fn)` calls `fn(entry, center)` for each
|
|
37
|
+
* entry added, so a history view follows the centre rather than the bus.
|
|
30
38
|
*/
|
|
31
39
|
import { ComponentBase } from './base/component_base.js';
|
|
32
40
|
import { getSetting } from '../core/settings.js';
|
|
@@ -55,6 +63,7 @@ const SUPPRESSED_NAMESPACE_TITLES = new Set(['bootstrap', 'app']);
|
|
|
55
63
|
export class NotificationCenter extends ComponentBase {
|
|
56
64
|
#items;
|
|
57
65
|
#summaryEl;
|
|
66
|
+
#historyListeners;
|
|
58
67
|
|
|
59
68
|
constructor({
|
|
60
69
|
eventBus,
|
|
@@ -90,6 +99,7 @@ export class NotificationCenter extends ComponentBase {
|
|
|
90
99
|
/** id -> the drawn toast's nodes, for the toasts on screen. */
|
|
91
100
|
this.#items = new Map();
|
|
92
101
|
this.#summaryEl = null;
|
|
102
|
+
this.#historyListeners = new Set();
|
|
93
103
|
|
|
94
104
|
this.container = null;
|
|
95
105
|
this.rootEl = null;
|
|
@@ -105,7 +115,7 @@ export class NotificationCenter extends ComponentBase {
|
|
|
105
115
|
this.container = existingContainer;
|
|
106
116
|
this.rootEl = existingContainer.querySelector('.twm-notification-center');
|
|
107
117
|
this.listEl = existingContainer.querySelector('.twm-notification-center__list');
|
|
108
|
-
this.liveRegionEl = existingContainer.querySelector('.notification-center__live-region');
|
|
118
|
+
this.liveRegionEl = existingContainer.querySelector('.twm-notification-center__live-region');
|
|
109
119
|
this._mounted = true;
|
|
110
120
|
this.#subscribeEvents();
|
|
111
121
|
this.#claimToastBus();
|
|
@@ -124,7 +134,7 @@ export class NotificationCenter extends ComponentBase {
|
|
|
124
134
|
this.listEl.className = 'twm-notification-center__list';
|
|
125
135
|
|
|
126
136
|
this.liveRegionEl = document.createElement('div');
|
|
127
|
-
this.liveRegionEl.className = 'notification-center__live-region';
|
|
137
|
+
this.liveRegionEl.className = 'twm-notification-center__live-region';
|
|
128
138
|
this.liveRegionEl.setAttribute('aria-live', 'polite');
|
|
129
139
|
this.liveRegionEl.setAttribute('aria-atomic', 'true');
|
|
130
140
|
this.liveRegionEl.style.position = 'absolute';
|
|
@@ -151,6 +161,7 @@ export class NotificationCenter extends ComponentBase {
|
|
|
151
161
|
dispose() {
|
|
152
162
|
this.#clearTimers();
|
|
153
163
|
this.#unsubscribeEvents();
|
|
164
|
+
this.#historyListeners.clear();
|
|
154
165
|
// Give the toast helpers back to the console — unless someone has
|
|
155
166
|
// pointed them at another bus since this centre mounted.
|
|
156
167
|
if (this.eventBus && getToastBus() === this.eventBus) setToastBus(null);
|
|
@@ -190,7 +201,7 @@ export class NotificationCenter extends ComponentBase {
|
|
|
190
201
|
if (idx !== -1) {
|
|
191
202
|
const [removed] = this.notifications.splice(idx, 1);
|
|
192
203
|
this.#stopTimer(removed.id);
|
|
193
|
-
this.#
|
|
204
|
+
this.#recordDismissal(removed, 'overflow');
|
|
194
205
|
}
|
|
195
206
|
}
|
|
196
207
|
|
|
@@ -207,13 +218,13 @@ export class NotificationCenter extends ComponentBase {
|
|
|
207
218
|
const h = this.held.findIndex((item) => item.id === notificationId);
|
|
208
219
|
if (h === -1) return false;
|
|
209
220
|
const [gone] = this.held.splice(h, 1);
|
|
210
|
-
this.#
|
|
221
|
+
this.#recordDismissal(gone, reason);
|
|
211
222
|
this.#render();
|
|
212
223
|
return true;
|
|
213
224
|
}
|
|
214
225
|
const [removed] = this.notifications.splice(idx, 1);
|
|
215
226
|
this.#stopTimer(notificationId);
|
|
216
|
-
this.#
|
|
227
|
+
this.#recordDismissal(removed, reason);
|
|
217
228
|
// A place on screen came free: the oldest held-back toast takes it.
|
|
218
229
|
this.#rebalance();
|
|
219
230
|
this.#render();
|
|
@@ -475,12 +486,39 @@ export class NotificationCenter extends ComponentBase {
|
|
|
475
486
|
this.activeTimers.clear();
|
|
476
487
|
}
|
|
477
488
|
|
|
489
|
+
/**
|
|
490
|
+
* Hear of every entry added to `history`, by `show()`, `warn()`/`error()`/
|
|
491
|
+
* `info()`/`success()` and the bus alike: `listener(entry, center)`, once
|
|
492
|
+
* per toast, after the entry is in `history`. A dismissal changes the
|
|
493
|
+
* toast's own entry and is not an entry of its own. Answers the unsubscribe.
|
|
494
|
+
*/
|
|
495
|
+
subscribe(listener) {
|
|
496
|
+
if (typeof listener !== 'function') return () => {};
|
|
497
|
+
this.#historyListeners.add(listener);
|
|
498
|
+
return () => { this.#historyListeners.delete(listener); };
|
|
499
|
+
}
|
|
500
|
+
|
|
478
501
|
#recordHistory(entry) {
|
|
479
502
|
if (entry.skipHistory) return;
|
|
480
503
|
this.history.push(entry);
|
|
481
504
|
if (this.history.length > this.historyLimit) {
|
|
482
505
|
this.history.shift();
|
|
483
506
|
}
|
|
507
|
+
[...this.#historyListeners].forEach((listener) => {
|
|
508
|
+
try {
|
|
509
|
+
listener(entry, this);
|
|
510
|
+
} catch (err) {
|
|
511
|
+
this.logger?.warn?.('twm-notification-center', 'History listener failed', { err });
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** A toast that left marks its own entry: one toast, one entry. */
|
|
517
|
+
#recordDismissal(notification, reason) {
|
|
518
|
+
if (notification.skipHistory) return;
|
|
519
|
+
const at = this.history.findLastIndex((entry) => entry.id === notification.id);
|
|
520
|
+
if (at === -1) return;
|
|
521
|
+
this.history[at] = { ...notification, dismissed: true, reason };
|
|
484
522
|
}
|
|
485
523
|
|
|
486
524
|
/** A progress toast that has not been given an outcome yet. */
|
|
@@ -599,7 +637,7 @@ export class NotificationCenter extends ComponentBase {
|
|
|
599
637
|
if (!this.#summaryEl) {
|
|
600
638
|
const more = document.createElement('button');
|
|
601
639
|
more.type = 'button';
|
|
602
|
-
more.className = 'notification twm-notification--summary';
|
|
640
|
+
more.className = 'twm-notification twm-notification--summary';
|
|
603
641
|
more.addEventListener('click', () => {
|
|
604
642
|
const held = [...this.held];
|
|
605
643
|
try { this.summaryAction?.(held); } catch (err) { this.logger?.warn?.('summaryAction failed', err); }
|
|
@@ -729,8 +767,8 @@ export class NotificationCenter extends ComponentBase {
|
|
|
729
767
|
#patchItem(entry, notification) {
|
|
730
768
|
const item = entry.el;
|
|
731
769
|
if (entry.severity !== notification.severity) {
|
|
732
|
-
if (entry.severity) item.classList.remove(`notification--${entry.severity}`);
|
|
733
|
-
item.classList.add('notification', `notification--${notification.severity}`);
|
|
770
|
+
if (entry.severity) item.classList.remove(`twm-notification--${entry.severity}`);
|
|
771
|
+
item.classList.add('twm-notification', `twm-notification--${notification.severity}`);
|
|
734
772
|
entry.severity = notification.severity;
|
|
735
773
|
}
|
|
736
774
|
if (entry.titleEl) NotificationCenter.#setText(entry.titleEl, notification.title);
|