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.
- package/css/base.css +1484 -181
- package/css/flexdesk.css +1311 -18
- package/css/overrides.css +44 -0
- package/css/tokens.css +45 -0
- package/dist/charts.js +5 -3
- package/dist/charts.js.map +1 -1
- package/dist/{chunk-DVU44T77.js → chunk-ELXVW542.js} +196 -75
- package/dist/chunk-ELXVW542.js.map +7 -0
- package/dist/chunk-LH5TSOZW.js +1237 -0
- package/dist/chunk-LH5TSOZW.js.map +7 -0
- package/dist/{chunk-TLZUUFOE.js → chunk-O5OHMWBB.js} +10 -2
- package/dist/chunk-O5OHMWBB.js.map +7 -0
- package/dist/{chunk-CT4YXXLP.js → chunk-QIU5S2RU.js} +371 -73
- package/dist/chunk-QIU5S2RU.js.map +7 -0
- package/dist/chunk-QNQHQ24V.js +408 -0
- package/dist/chunk-QNQHQ24V.js.map +7 -0
- package/dist/{chunk-DRYCDMEG.js → chunk-XKDTIT4Q.js} +168 -12
- package/dist/chunk-XKDTIT4Q.js.map +7 -0
- package/dist/editor.js +3 -380
- package/dist/editor.js.map +3 -3
- package/dist/flexdesk.css +1311 -18
- package/dist/tiles.js +168 -41
- package/dist/tiles.js.map +2 -2
- package/dist/tokens.css +45 -0
- package/dist/widgets.js +44 -14
- package/dist/widgets.js.map +2 -2
- package/dist/wm.js +2983 -142
- package/dist/wm.js.map +4 -4
- package/package.json +3 -2
- package/src/charts/chart_types.js +167 -0
- package/src/charts/plotly_wrapper.js +178 -10
- package/src/editor/notebook_tab_bar.js +39 -3
- package/src/tiles/tile_base.js +143 -35
- package/src/tiles/tile_grid.js +52 -1
- package/src/tiling/command_palette.js +71 -18
- package/src/tiling/desktops.js +36 -12
- package/src/tiling/keymap.js +24 -4
- package/src/tiling/shell.js +135 -24
- package/src/tiling/tab_strip.js +184 -0
- package/src/tiling/tile_breadcrumb.js +34 -2
- package/src/tiling/tile_renderer.js +1386 -21
- package/src/tiling/tile_tab_menu.js +101 -0
- package/src/tiling/tile_tree.js +82 -0
- package/src/tiling/wm.js +2352 -74
- package/src/ui/components/action_dropdown.js +34 -3
- package/src/ui/components/autocomplete_field.js +65 -13
- package/src/ui/components/context_menu.js +79 -8
- package/src/ui/components/data_table.js +508 -84
- package/src/ui/components/managed_window.js +928 -36
- package/src/ui/components/modal.js +214 -8
- package/dist/chunk-CT4YXXLP.js.map +0 -7
- package/dist/chunk-DRYCDMEG.js.map +0 -7
- package/dist/chunk-DVU44T77.js.map +0 -7
- package/dist/chunk-TLZUUFOE.js.map +0 -7
- package/dist/chunk-UCJ2WD4D.js +0 -625
- 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
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
|
76
|
-
|
|
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
|
|
80
|
-
if (e.
|
|
150
|
+
function _outsideHandler(e) {
|
|
151
|
+
if (_activeMenu && !_activeMenu.contains(e.target)) hideContextMenu();
|
|
81
152
|
}
|
|
82
153
|
|
|
83
154
|
function escapeHtml(s) {
|