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.
@@ -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
- const side = el('aside', 'twm-settings__sidebar');
170
- const head = el('div', 'twm-settings__sidebar-title');
171
- head.append(icon('settings'), el('span', '', this._title));
172
-
173
- const search = el('label', 'twm-settings__search');
174
- const input = el('input', 'twm-settings__search-input');
175
- input.type = 'search';
176
- input.placeholder = 'Filter settings…';
177
- input.autocomplete = 'off';
178
- input.spellcheck = false;
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(side, content);
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
- return;
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
- // The slice's defaults may have moved values on screen; an edit still
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
- const active = this.#searching() ? null : this.getCategory();
348
- const buttons = this.store.getCategories().map((cat) => {
349
- const b = el('button', 'twm-settings__cat');
350
- b.type = 'button';
351
- b.dataset.category = cat.id;
352
- if (cat.id === active) {
353
- b.classList.add('twm-settings__cat--active');
354
- b.setAttribute('aria-current', 'page');
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.#recordHistory({ ...removed, dismissed: true, reason: 'overflow' });
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.#recordHistory({ ...gone, dismissed: true, reason });
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.#recordHistory({ ...removed, dismissed: true, reason });
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);