flexdesk 0.2.0 → 0.3.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 (56) hide show
  1. package/css/base.css +1484 -181
  2. package/css/flexdesk.css +1311 -18
  3. package/css/overrides.css +44 -0
  4. package/css/tokens.css +45 -0
  5. package/dist/charts.js +5 -3
  6. package/dist/charts.js.map +1 -1
  7. package/dist/{chunk-DVU44T77.js → chunk-ELXVW542.js} +196 -75
  8. package/dist/chunk-ELXVW542.js.map +7 -0
  9. package/dist/chunk-LH5TSOZW.js +1237 -0
  10. package/dist/chunk-LH5TSOZW.js.map +7 -0
  11. package/dist/{chunk-TLZUUFOE.js → chunk-O5OHMWBB.js} +10 -2
  12. package/dist/chunk-O5OHMWBB.js.map +7 -0
  13. package/dist/{chunk-CT4YXXLP.js → chunk-QIU5S2RU.js} +371 -73
  14. package/dist/chunk-QIU5S2RU.js.map +7 -0
  15. package/dist/chunk-QNQHQ24V.js +408 -0
  16. package/dist/chunk-QNQHQ24V.js.map +7 -0
  17. package/dist/{chunk-DRYCDMEG.js → chunk-XKDTIT4Q.js} +168 -12
  18. package/dist/chunk-XKDTIT4Q.js.map +7 -0
  19. package/dist/editor.js +3 -380
  20. package/dist/editor.js.map +3 -3
  21. package/dist/flexdesk.css +1311 -18
  22. package/dist/tiles.js +168 -41
  23. package/dist/tiles.js.map +2 -2
  24. package/dist/tokens.css +45 -0
  25. package/dist/widgets.js +44 -14
  26. package/dist/widgets.js.map +2 -2
  27. package/dist/wm.js +2983 -142
  28. package/dist/wm.js.map +4 -4
  29. package/package.json +3 -2
  30. package/src/charts/chart_types.js +167 -0
  31. package/src/charts/plotly_wrapper.js +178 -10
  32. package/src/editor/notebook_tab_bar.js +39 -3
  33. package/src/tiles/tile_base.js +143 -35
  34. package/src/tiles/tile_grid.js +52 -1
  35. package/src/tiling/command_palette.js +71 -18
  36. package/src/tiling/desktops.js +36 -12
  37. package/src/tiling/keymap.js +24 -4
  38. package/src/tiling/shell.js +135 -24
  39. package/src/tiling/tab_strip.js +184 -0
  40. package/src/tiling/tile_breadcrumb.js +34 -2
  41. package/src/tiling/tile_renderer.js +1386 -21
  42. package/src/tiling/tile_tab_menu.js +101 -0
  43. package/src/tiling/tile_tree.js +82 -0
  44. package/src/tiling/wm.js +2352 -74
  45. package/src/ui/components/action_dropdown.js +34 -3
  46. package/src/ui/components/autocomplete_field.js +65 -13
  47. package/src/ui/components/context_menu.js +79 -8
  48. package/src/ui/components/data_table.js +508 -84
  49. package/src/ui/components/managed_window.js +928 -36
  50. package/src/ui/components/modal.js +214 -8
  51. package/dist/chunk-CT4YXXLP.js.map +0 -7
  52. package/dist/chunk-DRYCDMEG.js.map +0 -7
  53. package/dist/chunk-DVU44T77.js.map +0 -7
  54. package/dist/chunk-TLZUUFOE.js.map +0 -7
  55. package/dist/chunk-UCJ2WD4D.js +0 -625
  56. package/dist/chunk-UCJ2WD4D.js.map +0 -7
@@ -174,13 +174,27 @@ export class ActionDropdown {
174
174
  // Position against trigger
175
175
  ActionDropdown.position(this.trigger, this.menuEl);
176
176
 
177
- // Animate in
177
+ // Animate in.
178
+ //
179
+ // OPTIONAL CHAINING, AND IT IS LOAD-BEARING. This callback runs a frame
180
+ // after `open()` returned, and `destroy()` sets `this.menuEl = null`
181
+ // (see below) — so a dropdown that is opened and then destroyed inside
182
+ // one frame threw an uncaught `TypeError: Cannot read properties of
183
+ // null` out of an animation-frame callback, where no caller has a stack
184
+ // to catch it. That is not a hypothetical: it is what a user does every
185
+ // time they open a picker and then click something that unmounts the
186
+ // pane around it, and it was reproduced eighteen times in one run of a
187
+ // consumer's settings suite. Nothing is lost by skipping the class — the
188
+ // element it would have been added to no longer exists.
178
189
  requestAnimationFrame(() => {
179
- this.menuEl.classList.add('visible');
190
+ this.menuEl?.classList.add('visible');
180
191
  });
181
192
 
182
193
  // Update trigger state
183
194
  this.trigger?.classList.add('twm-is-open');
195
+ if (this.trigger?.hasAttribute('aria-expanded')) {
196
+ this.trigger.setAttribute('aria-expanded', 'true');
197
+ }
184
198
 
185
199
  // Add document listeners
186
200
  document.addEventListener('click', this._boundHandleDocumentClick, true);
@@ -204,8 +218,16 @@ export class ActionDropdown {
204
218
  this.menuEl.hidden = true;
205
219
  }
206
220
 
207
- // Update trigger state
221
+ // Update trigger state. `aria-expanded` belongs on the TRIGGER and has
222
+ // to be written on every close, not only on the ones a click caused —
223
+ // an embedder that synced it from its own click handler was announcing
224
+ // an expanded menu to a screen reader every time Escape or an outside
225
+ // click dismissed one. The component knows when it closed; nothing else
226
+ // reliably does.
208
227
  this.trigger?.classList.remove('twm-is-open');
228
+ if (this.trigger?.hasAttribute('aria-expanded')) {
229
+ this.trigger.setAttribute('aria-expanded', 'false');
230
+ }
209
231
 
210
232
  // Remove document listeners
211
233
  document.removeEventListener('click', this._boundHandleDocumentClick, true);
@@ -235,6 +257,15 @@ export class ActionDropdown {
235
257
  */
236
258
  _handleKeydown(e) {
237
259
  if (e.key === 'Escape') {
260
+ // AND NOBODY ELSE GETS IT. An open dropdown is the innermost thing
261
+ // on screen, so Escape means "close this" and nothing further —
262
+ // but the event was left to bubble, and inside a `ManagedWindow`
263
+ // (which binds its own Escape to dismiss) that meant one keystroke
264
+ // closed the dropdown AND the dialog around it. The user loses a
265
+ // form they were filling in because they changed their mind about
266
+ // one field.
267
+ e.preventDefault();
268
+ e.stopPropagation();
238
269
  this.close();
239
270
  this.trigger?.focus();
240
271
  return;
@@ -80,6 +80,10 @@ export class AutocompleteField {
80
80
  this._activeIndex = -1;
81
81
  this._isOpen = false;
82
82
  this._disposers = [];
83
+ // Every call to the provider is numbered. See `#updateSuggestions`: a
84
+ // provider that answers over the network answers out of order, and the
85
+ // reply to "No" must not be allowed to overwrite the reply to "North".
86
+ this._suggestSeq = 0;
83
87
  }
84
88
 
85
89
  getValue() {
@@ -431,24 +435,68 @@ export class AutocompleteField {
431
435
  #updateSuggestions() {
432
436
  // Pass the fragment (variable part) for filtering, not the full composed value
433
437
  const fragment = this.#getInputFragment();
438
+
439
+ // A PROVIDER MAY ANSWER LATER THAN IT WAS ASKED.
440
+ //
441
+ // This used to require the answer synchronously — `provider(...) || []`
442
+ // straight into `Array.isArray`, so a provider that returned a promise
443
+ // produced an empty list and a dropdown that closed on every keystroke.
444
+ // That ruled out the entire class of completion source that lives on a
445
+ // server, which is most of them outside a single-page simulation: a
446
+ // table's rows, a query's result set, an index's matches.
447
+ //
448
+ // A provider that answers synchronously still takes the synchronous
449
+ // path, unchanged and untouched, so nothing that works today changes.
450
+ // A provider that hands back a thenable is awaited, and the reply is
451
+ // used only if it is the reply to the most recent question — typing
452
+ // "North" fires five requests and the network is free to answer them in
453
+ // any order, so without the sequence number the list can settle on the
454
+ // matches for "Nor".
455
+ const request = ++this._suggestSeq;
456
+ let produced;
434
457
  try {
435
- const items = this.provider({
436
- value: fragment,
437
- scope: this.scope,
438
- namespace: this.selectedNamespace
439
- }) || [];
440
- this._items = Array.isArray(items) ? items : [];
441
- this._activeIndex = items.length > 0 ? 0 : -1;
442
- this.#renderDropdown();
443
- if (items.length > 0) {
444
- this.#open();
445
- } else {
446
- this.#close();
447
- }
458
+ produced = this.provider({
459
+ value: fragment,
460
+ scope: this.scope,
461
+ namespace: this.selectedNamespace
462
+ });
448
463
  } catch (err) {
449
464
  this.logger?.warn?.('autocomplete', 'Provider error', { err });
450
465
  this._items = [];
451
466
  this.#close();
467
+ return;
468
+ }
469
+
470
+ if (produced && typeof produced.then === 'function') {
471
+ produced.then(
472
+ (items) => {
473
+ if (request !== this._suggestSeq) return;
474
+ this.#applySuggestions(items);
475
+ },
476
+ (err) => {
477
+ this.logger?.warn?.('autocomplete', 'Provider error', { err });
478
+ if (request !== this._suggestSeq) return;
479
+ this._items = [];
480
+ this.#close();
481
+ }
482
+ );
483
+ return;
484
+ }
485
+
486
+ this.#applySuggestions(produced);
487
+ }
488
+
489
+ /** Draw whatever the provider produced. Split out of `#updateSuggestions`
490
+ * so the synchronous and the awaited paths cannot drift apart. */
491
+ #applySuggestions(produced) {
492
+ const items = Array.isArray(produced) ? produced : [];
493
+ this._items = items;
494
+ this._activeIndex = items.length > 0 ? 0 : -1;
495
+ this.#renderDropdown();
496
+ if (items.length > 0) {
497
+ this.#open();
498
+ } else {
499
+ this.#close();
452
500
  }
453
501
  }
454
502
 
@@ -714,6 +762,10 @@ export class AutocompleteField {
714
762
  }
715
763
 
716
764
  dispose() {
765
+ // Retire the outstanding question first. An awaited provider can answer
766
+ // after the field is gone, and `#applySuggestions` would then paint into
767
+ // a dropdown that has been removed and nulled.
768
+ this._suggestSeq += 1;
717
769
  // Close dropdown before cleanup
718
770
  this.#close();
719
771
  this._disposers.forEach((fn) => {
@@ -10,28 +10,65 @@
10
10
  * ], (action) => { ... });
11
11
  *
12
12
  * The menu auto-closes on outside click / scroll / Escape.
13
+ *
14
+ * ── It is operable by keyboard ────────────────────────────────────────
15
+ * Items were bare `<div>`s with a click listener: no role, no tab stop, no
16
+ * arrow-key movement, and `showContextMenu` never moved focus into the menu.
17
+ * A consumer that opened this from Shift+F10 — the rail in Tables does — put a
18
+ * menu on screen that the keyboard could only dismiss. Every command in it was
19
+ * unreachable without a pointer, and nothing said so.
20
+ *
21
+ * So an item is a `<button role="menuitem">`, the first enabled one takes focus
22
+ * when the menu opens, Up/Down/Home/End move between them and wrap, Enter and
23
+ * Space activate, Escape closes, and focus returns to whatever had it before.
24
+ * Disabled items are skipped by the arrows rather than focusable-but-inert.
25
+ * `<button>` rather than a div with `tabindex`: the browser then gives Enter and
26
+ * Space for free and screen readers announce it without further help.
13
27
  */
14
28
 
29
+ import { modalHost } from './modal.js';
30
+
15
31
  let _activeMenu = null;
32
+ let _returnFocusTo = null;
16
33
 
17
34
  export function showContextMenu(x, y, items, onAction) {
18
35
  hideContextMenu();
19
36
 
37
+ // Whatever had focus when the menu opened gets it back when the menu
38
+ // closes. Without this a keyboard user who presses Escape is returned to
39
+ // `document.body` and has to tab back to where they were.
40
+ _returnFocusTo = document.activeElement;
41
+
20
42
  const menu = document.createElement('div');
21
43
  menu.className = 'twm-context-menu ea-context-menu';
44
+ menu.setAttribute('role', 'menu');
22
45
 
23
46
  for (const it of items) {
24
47
  if (it.separator) {
25
48
  const sep = document.createElement('div');
26
49
  sep.className = 'twm-context-menu__separator';
50
+ sep.setAttribute('role', 'separator');
27
51
  menu.appendChild(sep);
28
52
  continue;
29
53
  }
30
- const row = document.createElement('div');
54
+ const row = document.createElement('button');
55
+ row.type = 'button';
56
+ row.setAttribute('role', 'menuitem');
31
57
  let cls = 'twm-context-menu-item';
32
58
  if (it.danger) cls += ' twm-delete-node';
33
59
  if (it.disabled) cls += ' disabled';
34
60
  row.className = cls;
61
+ if (it.disabled) {
62
+ row.disabled = true;
63
+ row.setAttribute('aria-disabled', 'true');
64
+ }
65
+ // A DISABLED ROW WITH NO EXPLANATION IS A DEAD CONTROL. `title` was
66
+ // accepted by callers and rendered by nothing — the item was built with
67
+ // one, the row silently dropped it, and the user got a greyed line with
68
+ // no way to learn why. It is the same shape as the classes this
69
+ // repository keeps finding: no error, no throw, and invisible to every
70
+ // test that checks the row is disabled.
71
+ if (it.title) row.title = it.title;
35
72
  row.innerHTML = `
36
73
  <span class="material-symbols-outlined">${it.icon || ''}</span>
37
74
  <span>${escapeHtml(it.label)}</span>
@@ -46,7 +83,12 @@ export function showContextMenu(x, y, items, onAction) {
46
83
  menu.appendChild(row);
47
84
  }
48
85
 
49
- document.body.appendChild(menu);
86
+ // C25. THE SAME QUESTION A MODAL ASKS: which window is the user in? A
87
+ // consumer spanning two browser windows sets the host when its focus moves;
88
+ // null — every consumer today — is `document.body`, exactly as before. A
89
+ // menu in the wrong window is worse than a modal in the wrong window,
90
+ // because it is positioned at coordinates from the OTHER one.
91
+ (modalHost() || document.body).appendChild(menu);
50
92
  menu.style.display = 'block';
51
93
  _activeMenu = menu;
52
94
 
@@ -61,23 +103,52 @@ export function showContextMenu(x, y, items, onAction) {
61
103
  setTimeout(() => {
62
104
  document.addEventListener('mousedown', _outsideHandler, { once: true, capture: true });
63
105
  }, 0);
64
- document.addEventListener('keydown', _escHandler);
106
+ document.addEventListener('keydown', _keyHandler);
65
107
  window.addEventListener('scroll', hideContextMenu, { once: true, capture: true });
108
+
109
+ // Focus the first item the keyboard can actually use. `preventScroll` so a
110
+ // menu opened near the bottom of a long page does not jump it.
111
+ _enabledItems(menu)[0]?.focus({ preventScroll: true });
66
112
  }
67
113
 
68
114
  export function hideContextMenu() {
69
115
  if (!_activeMenu) return;
116
+ const returnTo = _returnFocusTo;
117
+ const held = _activeMenu.contains(document.activeElement);
70
118
  _activeMenu.remove();
71
119
  _activeMenu = null;
72
- document.removeEventListener('keydown', _escHandler);
120
+ _returnFocusTo = null;
121
+ document.removeEventListener('keydown', _keyHandler);
122
+ // Only take focus back if the menu still had it. A click elsewhere has
123
+ // already moved focus deliberately and must not be undone.
124
+ if (held && returnTo?.isConnected) returnTo.focus?.({ preventScroll: true });
73
125
  }
74
126
 
75
- function _outsideHandler(e) {
76
- if (_activeMenu && !_activeMenu.contains(e.target)) hideContextMenu();
127
+ function _enabledItems(menu) {
128
+ return [...menu.querySelectorAll('.twm-context-menu-item:not(.disabled)')];
129
+ }
130
+
131
+ /** Up/Down/Home/End move; the list WRAPS, which is what a menu of four items
132
+ * wants and what every desktop menu does. Enter and Space are the button's
133
+ * own, so they are not bound here. */
134
+ function _keyHandler(e) {
135
+ if (!_activeMenu) return;
136
+ if (e.key === 'Escape') { e.preventDefault(); hideContextMenu(); return; }
137
+ const items = _enabledItems(_activeMenu);
138
+ if (items.length === 0) return;
139
+ const at = items.indexOf(document.activeElement);
140
+ let next = null;
141
+ if (e.key === 'ArrowDown') next = items[(at + 1 + items.length) % items.length];
142
+ else if (e.key === 'ArrowUp') next = items[(at - 1 + items.length) % items.length];
143
+ else if (e.key === 'Home') next = items[0];
144
+ else if (e.key === 'End') next = items[items.length - 1];
145
+ if (!next) return;
146
+ e.preventDefault();
147
+ next.focus({ preventScroll: true });
77
148
  }
78
149
 
79
- function _escHandler(e) {
80
- if (e.key === 'Escape') hideContextMenu();
150
+ function _outsideHandler(e) {
151
+ if (_activeMenu && !_activeMenu.contains(e.target)) hideContextMenu();
81
152
  }
82
153
 
83
154
  function escapeHtml(s) {