flexdesk 0.17.14 → 0.17.16

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 (47) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/css/base.css +237 -8
  3. package/css/flexdesk.css +344 -17
  4. package/css/overrides.css +6 -0
  5. package/dist/charts.js +104 -34
  6. package/dist/charts.js.map +3 -3
  7. package/dist/{chunk-2PM6ZMNS.js → chunk-2Z2ENHR3.js} +2 -2
  8. package/dist/{chunk-IG74S3ZH.js → chunk-7JI7HHSY.js} +2 -2
  9. package/dist/{chunk-TLIV6QSU.js → chunk-7OL6F2AM.js} +3 -3
  10. package/dist/{chunk-MADMGRX2.js → chunk-PGZ4EHSE.js} +156 -9
  11. package/dist/chunk-PGZ4EHSE.js.map +7 -0
  12. package/dist/{chunk-ZMH3OC7H.js → chunk-XJNADRGR.js} +4 -2
  13. package/dist/chunk-XJNADRGR.js.map +7 -0
  14. package/dist/flexdesk.css +344 -17
  15. package/dist/flow.js +3 -3
  16. package/dist/tiles.js +84 -9
  17. package/dist/tiles.js.map +2 -2
  18. package/dist/widgets.js +448 -5
  19. package/dist/widgets.js.map +4 -4
  20. package/dist/wm.js +110 -19
  21. package/dist/wm.js.map +2 -2
  22. package/docs/README.md +1 -1
  23. package/docs/components/charts.md +70 -3
  24. package/docs/components/forms-and-dialogs.md +80 -2
  25. package/docs/components/menus.md +85 -1
  26. package/docs/components/shell.md +52 -0
  27. package/docs/components/slide-out-panel.md +15 -0
  28. package/docs/components/tiles.md +37 -0
  29. package/package.json +1 -1
  30. package/src/charts/plot_config_editor.js +125 -29
  31. package/src/tiles/tile_base.js +48 -2
  32. package/src/tiles/tile_grid.js +42 -1
  33. package/src/tiling/kind_taxonomy.js +45 -0
  34. package/src/tiling/tile_breadcrumb.js +12 -4
  35. package/src/tiling/wm.js +86 -11
  36. package/src/ui/components/category_picker.js +556 -0
  37. package/src/ui/components/computing_status_window.js +3 -0
  38. package/src/ui/components/confirm_dialog.js +3 -0
  39. package/src/ui/components/managed_window.js +204 -9
  40. package/src/ui/components/modal.js +11 -2
  41. package/src/ui/components/wizard.js +4 -1
  42. package/widgets.js +2 -1
  43. package/dist/chunk-MADMGRX2.js.map +0 -7
  44. package/dist/chunk-ZMH3OC7H.js.map +0 -7
  45. /package/dist/{chunk-2PM6ZMNS.js.map → chunk-2Z2ENHR3.js.map} +0 -0
  46. /package/dist/{chunk-IG74S3ZH.js.map → chunk-7JI7HHSY.js.map} +0 -0
  47. /package/dist/{chunk-TLIV6QSU.js.map → chunk-7OL6F2AM.js.map} +0 -0
@@ -24,6 +24,16 @@ import { getSetting } from '../../core/settings.js';
24
24
  * `ManagedWindow.configure({ storageKey })` before the first window opens. */
25
25
  export const DEFAULT_WINDOW_STORAGE_KEY = 'flexdesk.managedWindows.v1';
26
26
  let _storageKey = DEFAULT_WINDOW_STORAGE_KEY;
27
+ /** BUG-0053. How many window ids keep saved geometry. Every save stamps its
28
+ * entry with a use counter; past the cap the least recently saved ids are
29
+ * dropped, so ids minted per window (the window manager's `twm-mw-<time>-…`)
30
+ * no longer grow the entry for ever. */
31
+ export const DEFAULT_MAX_SAVED_WINDOWS = 50;
32
+ /** The prefix of the ids the window manager mints per window
33
+ * (`twm-mw-<time>-<rand>`). Such an id never opens again, so geometry saved
34
+ * under it can never be restored: entries under it are purged on load. */
35
+ const MINTED_ID_PREFIX = 'twm-mw-';
36
+ let _maxSavedWindows = DEFAULT_MAX_SAVED_WINDOWS;
27
37
  const BASE_Z_INDEX = 6000;
28
38
  const MAX_Z_INDEX = 6999;
29
39
 
@@ -55,6 +65,10 @@ const DEFAULT_ICON = 'web_asset';
55
65
  // Global state for z-order management
56
66
  let _zIndexCounter = 0;
57
67
  let _activeWindows = new Map(); // id -> ManagedWindow instance
68
+ /** BUG-0053. Esc events a window has already answered. Every visible window
69
+ * listens on the document, and closing one can hand focus to another before
70
+ * the same event reaches it — one Esc must close one window. */
71
+ const _escAnswered = new WeakSet();
58
72
 
59
73
  /** Collect the focusable elements inside `root`, in DOM order. Used
60
74
  * by the modal focus trap so Tab cycles within the modal frame and
@@ -86,7 +100,14 @@ function _loadState() {
86
100
  if (_stateCache) return _stateCache;
87
101
  try {
88
102
  const raw = localStorage.getItem(_storageKey);
89
- _stateCache = raw ? JSON.parse(raw) : {};
103
+ const parsed = raw ? JSON.parse(raw) : {};
104
+ _stateCache = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
105
+ // BUG-0053. Before 0.17.16 every window-manager window saved an entry
106
+ // under its minted id; they are junk, and leaving them in would let
107
+ // them crowd stable ids out of the cap.
108
+ for (const k of Object.keys(_stateCache)) {
109
+ if (k.startsWith(MINTED_ID_PREFIX)) delete _stateCache[k];
110
+ }
90
111
  } catch {
91
112
  _stateCache = {};
92
113
  }
@@ -109,10 +130,68 @@ function _getWindowState(id) {
109
130
 
110
131
  function _setWindowState(id, windowState) {
111
132
  const state = _loadState();
112
- state[id] = windowState;
133
+ // `u` orders the entries by last save. A plain object's key order cannot
134
+ // carry that (integer-like ids sort first). An entry written before the
135
+ // stamp existed is a stable id the user has used (minted ids are purged
136
+ // on load), so it is stamped here, in the order it was stored, AHEAD of
137
+ // nothing newer: it ranks as older than any entry saved since, and the
138
+ // cap reaches it only after the minted junk is gone and real ids crowd it.
139
+ let latest = 0;
140
+ for (const k of Object.keys(state)) latest = Math.max(latest, Number(state[k]?.u) || 0);
141
+ for (const k of Object.keys(state)) {
142
+ if (!(Number(state[k]?.u) > 0) && state[k] && typeof state[k] === 'object') {
143
+ state[k] = { ...state[k], u: ++latest };
144
+ }
145
+ }
146
+ state[id] = { ...windowState, u: latest + 1 };
147
+ const ids = Object.keys(state);
148
+ if (ids.length > _maxSavedWindows) {
149
+ ids.sort((a, b) => (Number(state[a]?.u) || 0) - (Number(state[b]?.u) || 0));
150
+ for (const k of ids.slice(0, ids.length - _maxSavedWindows)) delete state[k];
151
+ }
113
152
  _saveState(state);
114
153
  }
115
154
 
155
+ /** BUG-0053. The first element of `root` that actually takes focus. Tried in
156
+ * DOM order rather than filtered by layout, because an element that is
157
+ * `display: none` or `visibility: hidden` refuses `focus()` in a browser —
158
+ * so asking the element is exact where measuring it would guess. */
159
+ function _focusFirstIn(root) {
160
+ if (!root) return false;
161
+ const sel = [
162
+ 'a[href]',
163
+ 'button:not([disabled])',
164
+ 'input:not([disabled]):not([type="hidden"])',
165
+ 'select:not([disabled])',
166
+ 'textarea:not([disabled])',
167
+ '[contenteditable="true"]',
168
+ '[tabindex]:not([tabindex="-1"])',
169
+ ].join(',');
170
+ for (const el of root.querySelectorAll(sel)) {
171
+ if (el.hidden || el.closest('[hidden]')) continue;
172
+ try { el.focus({ preventScroll: true }); } catch { continue; }
173
+ if (el.ownerDocument.activeElement === el) return true;
174
+ }
175
+ return false;
176
+ }
177
+
178
+ /** BUG-0053. Which window an Esc closes, or null.
179
+ *
180
+ * - A modal on top answers wherever focus is, as it always has.
181
+ * - Otherwise the window holding focus answers, modal or not.
182
+ * - Otherwise (focus in no window) the topmost modal answers.
183
+ * A non-modal window that does not hold focus never answers: an Esc meant
184
+ * for an editor beside it is not its. */
185
+ function _escAnswerer() {
186
+ const visible = Array.from(_activeWindows.values())
187
+ .filter((w) => w.isVisible && !w.isMinimized && w.element)
188
+ .sort((a, b) => b.zIndex - a.zIndex);
189
+ if (visible[0]?.modal) return visible[0];
190
+ const active = document.activeElement;
191
+ const holder = active ? visible.find((w) => w.element.contains(active)) : null;
192
+ return holder || visible.find((w) => w.modal) || null;
193
+ }
194
+
116
195
  export class ManagedWindow {
117
196
  /**
118
197
  * @param {Object} options
@@ -150,6 +229,15 @@ export class ManagedWindow {
150
229
  * away.
151
230
  * @param {Array} [options.titlebarButtons] - Extra buttons left of
152
231
  * minimize: `{icon, title, onClick}`.
232
+ * @param {boolean} [options.persistGeometry=true] - Save and restore this
233
+ * window's position and size under its id. Pass false for a window whose
234
+ * id is minted per opening, so it never writes an entry.
235
+ * @param {boolean|'window'} [options.focusOnOpen=true] - When the window
236
+ * opens, move focus to the first focusable element of its content (the
237
+ * window itself when it has none; always the window itself with
238
+ * `'window'`), and on close give focus back to the element that had it
239
+ * before, if that element is still in the document and focus is still in
240
+ * this window or nowhere.
153
241
  */
154
242
  constructor(options) {
155
243
  this.id = options.id;
@@ -274,6 +362,13 @@ export class ManagedWindow {
274
362
  this._restoreTimer = null;
275
363
  this.titlebarButtons = Array.isArray(options.titlebarButtons)
276
364
  ? options.titlebarButtons : [];
365
+ /** BUG-0053. See the constructor docs. */
366
+ this.persistGeometry = options.persistGeometry ?? true;
367
+ this.focusOnOpen = options.focusOnOpen ?? true;
368
+ /** The element that had focus when the window opened. */
369
+ this._returnFocusTo = null;
370
+ /** True once the window has closed: a closed window is spent. */
371
+ this._closed = false;
277
372
 
278
373
  this.element = null;
279
374
  this.backdropElement = null;
@@ -310,6 +405,12 @@ export class ManagedWindow {
310
405
  * Show the window.
311
406
  */
312
407
  show() {
408
+ if (this._closed) {
409
+ // BUG-0053. Close releases the content and every callback, so
410
+ // there is nothing left to show. Build a new window for the id.
411
+ console.warn(`[ManagedWindow] show() on closed window "${this.id}"; build a new ManagedWindow to reopen it`);
412
+ return;
413
+ }
313
414
  if (this.isVisible && !this.isMinimized) {
314
415
  // C27. `show()` IS THE REPAIR PATH, so it repairs. A window that is
315
416
  // visible by its own flags and hidden by an inline `display: none`
@@ -325,6 +426,7 @@ export class ManagedWindow {
325
426
  return;
326
427
  }
327
428
 
429
+ let opened = false;
328
430
  if (!this.element) {
329
431
  this._build();
330
432
  this._restoreState();
@@ -352,10 +454,38 @@ export class ManagedWindow {
352
454
  }
353
455
  this._installContainerResizeObserver();
354
456
  this.isVisible = true;
457
+ opened = true;
355
458
  }
356
459
 
357
460
  this.bringToFront();
358
461
  document.addEventListener('keydown', this._boundOnKeyDown);
462
+ if (opened && this.focusOnOpen) this._moveFocusIn();
463
+ }
464
+
465
+ /** BUG-0053. Focus into the content on open, remembering where it was. */
466
+ _moveFocusIn() {
467
+ const active = document.activeElement;
468
+ this._returnFocusTo = active && active !== document.body
469
+ && !this.element.contains(active) ? active : null;
470
+ if (this.focusOnOpen !== 'window' && _focusFirstIn(this.contentContainer)) return;
471
+ // Nothing focusable in the content (or the caller asked for the
472
+ // window): the window takes focus itself, so Esc (which a non-modal
473
+ // window answers only with focus inside) and Tab both start from here.
474
+ try { this.element.focus({ preventScroll: true }); } catch { /* detached */ }
475
+ }
476
+
477
+ /** BUG-0053. Give focus back on close — only when it is still in this
478
+ * window (or nowhere): a window closed while the user works elsewhere
479
+ * must not pull focus away from where they are. */
480
+ _returnFocus() {
481
+ const back = this._returnFocusTo;
482
+ this._returnFocusTo = null;
483
+ if (!back || !back.isConnected) return;
484
+ const active = document.activeElement;
485
+ const held = !active || active === document.body
486
+ || (this.element && this.element.contains(active));
487
+ if (!held) return;
488
+ try { back.focus({ preventScroll: true }); } catch { /* gone */ }
359
489
  }
360
490
 
361
491
  /**
@@ -389,6 +519,10 @@ export class ManagedWindow {
389
519
  this.onClose();
390
520
  }
391
521
 
522
+ // BUG-0053. Before the element leaves the document, while focus can
523
+ // still be seen to be inside it.
524
+ if (this.focusOnOpen) this._returnFocus();
525
+
392
526
  if (this.element && this.element.parentNode) {
393
527
  this.element.parentNode.removeChild(this.element);
394
528
  }
@@ -417,8 +551,44 @@ export class ManagedWindow {
417
551
  window.dispatchEvent(new CustomEvent('managed-window-closed', {
418
552
  detail: { id: this.id, container: this.container }
419
553
  }));
554
+ this._release();
555
+ }
556
+
557
+ /** BUG-0053. A closed window leaves the registry and lets go of what it
558
+ * held: its content, its DOM and every consumer callback (an `onClose`
559
+ * typically captures its owner). Until now the registry kept all of it
560
+ * alive until the same id opened again, and `get(id)` answered with the
561
+ * dead window. */
562
+ _release() {
563
+ if (_activeWindows.get(this.id) === this) _activeWindows.delete(this.id);
564
+ this._closed = true;
565
+ // A close in the middle of a drag (a snap controller docking the
566
+ // window from its own `commit`) still finishes in `_onPointerUp`,
567
+ // which removes these; a programmatic close mid-drag has no pointer-up
568
+ // coming that is its business.
569
+ if (!this._dragState && !this._resizeState) {
570
+ document.removeEventListener('pointermove', this._boundOnPointerMove);
571
+ document.removeEventListener('pointerup', this._boundOnPointerUp);
572
+ document.removeEventListener('pointercancel', this._boundOnPointerUp);
573
+ }
574
+ this.content = null;
575
+ this.element = null;
576
+ this.contentContainer = null;
577
+ this.backdropElement = null;
578
+ this.onClose = null;
579
+ this.beforeClose = null;
580
+ this.onMinimize = null;
581
+ this.onMaximize = null;
582
+ this.snapController = null;
583
+ this.dragHost = null;
584
+ this.dragBounds = null;
585
+ this.titlebarButtons = [];
420
586
  }
421
587
 
588
+ /** True once the window has closed. A closed window cannot be shown
589
+ * again; `ManagedWindow.get(id)` no longer returns it. */
590
+ get isClosed() { return this._closed; }
591
+
422
592
  /**
423
593
  * Minimize to taskbar with animation.
424
594
  */
@@ -794,6 +964,11 @@ export class ManagedWindow {
794
964
  this.element.classList.add('twm-managed-window--modal');
795
965
  }
796
966
  this.element.setAttribute('data-window-id', this.id);
967
+ // BUG-0053. Focusable by script and by a press, never by Tab (the
968
+ // modal trap's `_collectFocusable` skips tabindex -1): a press on the
969
+ // title bar or on empty content keeps focus in the window, where a
970
+ // non-modal window's Esc looks for it.
971
+ this.element.tabIndex = -1;
797
972
 
798
973
  // Top bar
799
974
  const topbar = document.createElement('div');
@@ -933,7 +1108,16 @@ export class ManagedWindow {
933
1108
  // something, which is the whole complaint it was reported under.
934
1109
  topbar.addEventListener('dblclick', () => this.toggleMaximize());
935
1110
  }
936
- this.element.addEventListener('pointerdown', () => this.bringToFront());
1111
+ this.element.addEventListener('pointerdown', () => {
1112
+ this.bringToFront();
1113
+ // The title bar's pointerdown is default-prevented (it starts a
1114
+ // drag), which also stops the browser moving focus. A press inside
1115
+ // a window that does not hold focus gives it focus; a press on a
1116
+ // field then moves it on to the field as usual.
1117
+ if (this.element && !this.element.contains(document.activeElement)) {
1118
+ try { this.element.focus({ preventScroll: true }); } catch { /* detached */ }
1119
+ }
1120
+ });
937
1121
  }
938
1122
 
939
1123
  _addResizeHandles() {
@@ -1263,7 +1447,7 @@ export class ManagedWindow {
1263
1447
  const maxHeight = bounds.height;
1264
1448
 
1265
1449
  // Modal windows always center on screen - never restore saved position
1266
- const saved = this.modal ? null : _getWindowState(this.id);
1450
+ const saved = this.modal || !this.persistGeometry ? null : _getWindowState(this.id);
1267
1451
  if (saved) {
1268
1452
  this.x = saved.x ?? this.x;
1269
1453
  this.y = saved.y ?? this.y;
@@ -1289,7 +1473,7 @@ export class ManagedWindow {
1289
1473
 
1290
1474
  _saveCurrentState() {
1291
1475
  // Modal windows always center - no need to persist position
1292
- if (this.modal) return;
1476
+ if (this.modal || !this.persistGeometry || this._closed) return;
1293
1477
 
1294
1478
  _setWindowState(this.id, {
1295
1479
  x: this._preMaximizeState?.x ?? this.x,
@@ -1647,12 +1831,19 @@ export class ManagedWindow {
1647
1831
  const topWindow = Array.from(_activeWindows.values())
1648
1832
  .filter(w => w.isVisible && !w.isMinimized)
1649
1833
  .sort((a, b) => b.zIndex - a.zIndex)[0];
1650
- if (topWindow !== this) return;
1651
-
1652
1834
  if (e.key === 'Escape') {
1653
- this.close();
1835
+ // BUG-0053. An Esc something inside already answered (an
1836
+ // autocomplete closing its list) or one that ends an IME
1837
+ // composition is not the window's.
1838
+ if (e.defaultPrevented || e.isComposing || _escAnswered.has(e)) return;
1839
+ if (_escAnswerer() === this) {
1840
+ _escAnswered.add(e);
1841
+ this.close();
1842
+ }
1654
1843
  return;
1655
1844
  }
1845
+ if (topWindow !== this) return;
1846
+
1656
1847
  // Focus trap for modals: Tab and Shift+Tab cycle through the
1657
1848
  // focusable elements inside the modal's frame instead of
1658
1849
  // escaping to underlying tiles. Without this, Tab moves the
@@ -1691,9 +1882,13 @@ export class ManagedWindow {
1691
1882
  *
1692
1883
  * @param {object} [opts]
1693
1884
  * @param {string} [opts.storageKey=DEFAULT_WINDOW_STORAGE_KEY]
1885
+ * @param {number} [opts.maxSavedWindows=DEFAULT_MAX_SAVED_WINDOWS] How many
1886
+ * ids keep saved geometry; the least recently saved go first.
1694
1887
  */
1695
- static configure({ storageKey = DEFAULT_WINDOW_STORAGE_KEY } = {}) {
1888
+ static configure({ storageKey = DEFAULT_WINDOW_STORAGE_KEY,
1889
+ maxSavedWindows = DEFAULT_MAX_SAVED_WINDOWS } = {}) {
1696
1890
  _storageKey = storageKey;
1891
+ _maxSavedWindows = Math.max(1, Math.floor(maxSavedWindows) || DEFAULT_MAX_SAVED_WINDOWS);
1697
1892
  _stateCache = null;
1698
1893
  }
1699
1894
 
@@ -56,8 +56,12 @@
56
56
  * Rows — an entry of the form `{ row: [field, field, ...] }` puts its fields
57
57
  * side by side (a min and a max, say). Each is an ordinary field: its own
58
58
  * label, hint, error, `setVisible`, value and submitted key — the row is
59
- * layout only and has no name. The row stacks its fields when the form is
60
- * narrower than 360px (a container query on `.twm-modal__form`).
59
+ * layout only and has no name. Its inputs are equally wide (up to each
60
+ * control's own padding): the space left after the labels is shared equally.
61
+ * A form whose widest row has three or more fields opens wider (about 230px
62
+ * per field; at least 640px, otherwise at most 90% of the window).
63
+ * The row stacks its fields when the form is narrower than 360px (a
64
+ * container query on `.twm-modal__form`).
61
65
  *
62
66
  * Picker — a value the user LOOKS UP rather than types (an id chosen in a
63
67
  * search dialog, say). The row shows a read-only box and a search button;
@@ -913,6 +917,11 @@ function _estimateWidth(fields) {
913
917
  // Forms with a textarea, a colour list, a section divider or a row of
914
918
  // fields get the wider layout so multi-line content + grouped fields
915
919
  // breathe; otherwise 480 px is enough for label + single-line input pairs.
920
+ // A row of three or more fields needs more than 640 px for inputs of a
921
+ // usable width (BUG-0050): ~230 px per field (its label and input) past the
922
+ // label column, clamped to 90% of the window.
923
+ const widest = Math.max(0, ...fields.filter((f) => Array.isArray(f?.row)).map((f) => _formFields(f.row).length));
924
+ if (widest >= 3) return Math.max(640, Math.min(170 + widest * 230, Math.floor(window.innerWidth * 0.9)));
916
925
  const wide = fields.some((f) => f && (f.type === 'textarea' || f.type === 'colorList' || f.section || Array.isArray(f.row)));
917
926
  return wide ? 640 : 480;
918
927
  }
@@ -233,9 +233,12 @@ export class Wizard extends ComponentBase {
233
233
  return false;
234
234
  },
235
235
  });
236
- this.#window.show();
236
+ // Rendered and painted BEFORE show, so the window's focus-on-open
237
+ // (BUG-0053) lands on the step's first field — the stepper's buttons
238
+ // are disabled by then — and not on the first step button.
237
239
  this.#renderBody();
238
240
  this.#paint();
241
+ this.#window.show();
239
242
  return this.#window;
240
243
  }
241
244
 
package/widgets.js CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * GENERATED by scripts/gen_barrels.py. Do not hand-edit.
6
6
  *
7
- * 59 modules.
7
+ * 60 modules.
8
8
  */
9
9
 
10
10
  export * from './src/ui/base/component_base.js';
@@ -16,6 +16,7 @@ export * from './src/ui/components/alerts_pane.js';
16
16
  export * from './src/ui/components/action_dropdown.js';
17
17
  export * from './src/ui/components/attribute_list_editor.js';
18
18
  export * from './src/ui/components/autocomplete_field.js';
19
+ export * from './src/ui/components/category_picker.js';
19
20
  export * from './src/ui/components/cell_editor_host.js';
20
21
  export * from './src/ui/components/cell_editors.js';
21
22
  export * from './src/ui/components/cell_selection.js';