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
|
@@ -40,6 +40,29 @@
|
|
|
40
40
|
|
|
41
41
|
import { ManagedWindow } from '../../ui/components/managed_window.js';
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* C25. WHERE A DIALOG OPENS, when the application spans more than one window.
|
|
45
|
+
*
|
|
46
|
+
* Every modal here is a `ManagedWindow` with no `container`, so it mounts into
|
|
47
|
+
* `document.body` — and "document" is the document this MODULE was loaded in.
|
|
48
|
+
* A consumer that opens a second browser window with `window.open` and builds
|
|
49
|
+
* its DOM from the opener's realm (which is how a pop-out grid works: same
|
|
50
|
+
* modules, same JS context, a different document) therefore gets its dialogs in
|
|
51
|
+
* the window it popped OUT of. The user presses "Add column" on their second
|
|
52
|
+
* monitor and a dialog appears on the first, behind whatever is there.
|
|
53
|
+
*
|
|
54
|
+
* Passing a container at each call site was the other option and it is worse:
|
|
55
|
+
* twenty-one of them across eleven files, every one of which would have to be
|
|
56
|
+
* given a document it has no other reason to know about, and any one missed is
|
|
57
|
+
* this bug again with no way to see it from here.
|
|
58
|
+
*
|
|
59
|
+
* So the HOST is ambient and the consumer sets it when its focus moves. Null —
|
|
60
|
+
* every consumer today — means `document.body`, exactly as before.
|
|
61
|
+
*/
|
|
62
|
+
let _modalHost = null;
|
|
63
|
+
export function setModalHost(el) { _modalHost = el || null; }
|
|
64
|
+
export function modalHost() { return _modalHost; }
|
|
65
|
+
|
|
43
66
|
let _modalSeq = 1;
|
|
44
67
|
|
|
45
68
|
export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK' } = {}) {
|
|
@@ -61,6 +84,7 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
|
|
|
61
84
|
`;
|
|
62
85
|
|
|
63
86
|
const win = new ManagedWindow({
|
|
87
|
+
container: _modalHost,
|
|
64
88
|
id: `twm-modal-${_modalSeq++}`,
|
|
65
89
|
title: title || 'Dialog',
|
|
66
90
|
icon: 'edit_note',
|
|
@@ -201,6 +225,7 @@ export function openConfirm({
|
|
|
201
225
|
|
|
202
226
|
let _resolved = false;
|
|
203
227
|
const win = new ManagedWindow({
|
|
228
|
+
container: _modalHost,
|
|
204
229
|
id: `twm-confirm-${_modalSeq++}`,
|
|
205
230
|
title: title || 'Confirm',
|
|
206
231
|
icon: icon || (danger ? 'warning' : 'help'),
|
|
@@ -246,12 +271,17 @@ export function openConfirm({
|
|
|
246
271
|
* - `content` HTMLElement appended into the body. Caller owns
|
|
247
272
|
* rendering — `openModal` doesn't size or style the
|
|
248
273
|
* internal content beyond making it scrollable.
|
|
249
|
-
* - `actions` `[{ label, value, primary?, danger? }, …]`. If
|
|
274
|
+
* - `actions` `[{ label, value, primary?, danger?, icon? }, …]`. If
|
|
250
275
|
* omitted, defaults to a single `[Close]` action that
|
|
251
276
|
* resolves with `null`. The first action with
|
|
252
277
|
* `primary: true` is the rightmost; otherwise the
|
|
253
278
|
* last action wins. Esc / X / backdrop resolve with
|
|
254
|
-
* `null` regardless of what's in `actions`.
|
|
279
|
+
* `null` regardless of what's in `actions`. `icon` is
|
|
280
|
+
* an optional material-symbols-outlined ligature drawn
|
|
281
|
+
* BEFORE the label; when it is given the label lives in
|
|
282
|
+
* a `.twm-btn__label` span, because an icon font's
|
|
283
|
+
* ligature name is itself text and would otherwise be
|
|
284
|
+
* read back as part of the label.
|
|
255
285
|
* - `width`,
|
|
256
286
|
* `height` initial size in px. Defaults: 640 × 480.
|
|
257
287
|
* - `onMount` optional `(contentEl) => void` invoked once after
|
|
@@ -264,6 +294,36 @@ export function openConfirm({
|
|
|
264
294
|
* legible — e.g. a live-applied editor where you want to
|
|
265
295
|
* watch the content behind update. Omit both to inherit
|
|
266
296
|
* the default look (blur 2px over a 50% dim).
|
|
297
|
+
* - `maximizable` C29. Draw a maximise button in the title bar.
|
|
298
|
+
* Default `false`, which is byte-for-byte what every
|
|
299
|
+
* existing caller has always got.
|
|
300
|
+
* - `onMaximizeChange` `(maximized, contentEl) => void`, called on each
|
|
301
|
+
* flip. Only useful with `maximizable`.
|
|
302
|
+
*
|
|
303
|
+
* ══ C29. WHY A DIALOG'S MAXIMISE IS THE GEOMETRIC ONE ═══════════════════
|
|
304
|
+
*
|
|
305
|
+
* Everywhere else in this library maximise means BACK TO TILE: the window
|
|
306
|
+
* stops being a window and its content returns to the tile it came out of.
|
|
307
|
+
* That is R14, it is the product owner's ruling — *"maximize here means back
|
|
308
|
+
* to tile"* — and it holds for the chrome button, the title bar's
|
|
309
|
+
* double-click, an embedder's window menu and the aero-snap top edge alike.
|
|
310
|
+
*
|
|
311
|
+
* A DIALOG HAS NO TILE. It was never lifted out of one; it is modal, it owns
|
|
312
|
+
* the screen until it resolves, and "back to tile" names a destination that
|
|
313
|
+
* does not exist for it. So here maximise can only mean the rectangle, and
|
|
314
|
+
* that is not a contradiction of the ruling but its boundary: THE RULING IS
|
|
315
|
+
* ABOUT WINDOWS THAT CAME FROM A TILE. Do not "fix" this into a dock — there
|
|
316
|
+
* is nothing to dock into, and `bringBackWindow` would refuse it anyway.
|
|
317
|
+
*
|
|
318
|
+
* Mechanically that means setting no `onMaximize` at all rather than reaching
|
|
319
|
+
* for `toggleMaximize({claimable: false})`: with nothing claiming the gesture,
|
|
320
|
+
* the ordinary path IS the rectangle, and the escape hatch is for consumers
|
|
321
|
+
* who have claimed it.
|
|
322
|
+
*
|
|
323
|
+
* The dialog stays a dialog while maximised — `modal: true` is untouched, so
|
|
324
|
+
* the backdrop, the Esc handler and the Tab focus trap (all of which hang off
|
|
325
|
+
* `this.element` and the top-most-window test, not off geometry) go on working
|
|
326
|
+
* exactly as they did.
|
|
267
327
|
*
|
|
268
328
|
* Returns a Promise resolving with the chosen action's `value`, or
|
|
269
329
|
* `null` if the user dismissed the modal.
|
|
@@ -278,6 +338,8 @@ export function openModal({
|
|
|
278
338
|
onMount = null,
|
|
279
339
|
backdropBlur = undefined,
|
|
280
340
|
backdropOpacity = undefined,
|
|
341
|
+
maximizable = false,
|
|
342
|
+
onMaximizeChange = null,
|
|
281
343
|
} = {}) {
|
|
282
344
|
return new Promise((resolve) => {
|
|
283
345
|
const body = document.createElement('div');
|
|
@@ -302,14 +364,67 @@ export function openModal({
|
|
|
302
364
|
if (a.primary) cls += ' twm-btn--primary';
|
|
303
365
|
if (a.danger) cls += ' twm-btn--danger';
|
|
304
366
|
btn.className = cls;
|
|
305
|
-
|
|
367
|
+
// C26. AN ACTION MAY CARRY A GLYPH — additively, and only when it
|
|
368
|
+
// asks for one. Without `icon` the button is byte-identical to what
|
|
369
|
+
// every existing caller gets today: one text node, so a consumer
|
|
370
|
+
// reading `btn.textContent` to find its own button keeps working.
|
|
371
|
+
//
|
|
372
|
+
// With one, the label moves into its own span so that reading it
|
|
373
|
+
// back is still possible: an icon font's ligature name IS text
|
|
374
|
+
// content ("save" renders as a glyph but reads as the word), so a
|
|
375
|
+
// bare `textContent` on a button with an icon would answer
|
|
376
|
+
// "saveSave". `.twm-btn__label` is the answer to "what does this
|
|
377
|
+
// button say", and `aria-hidden` on the glyph is what keeps a screen
|
|
378
|
+
// reader from saying the ligature name out loud beside the label.
|
|
379
|
+
if (a.icon) {
|
|
380
|
+
const glyph = document.createElement('span');
|
|
381
|
+
glyph.className = 'material-symbols-outlined twm-btn__glyph';
|
|
382
|
+
glyph.textContent = String(a.icon);
|
|
383
|
+
glyph.setAttribute('aria-hidden', 'true');
|
|
384
|
+
const text = document.createElement('span');
|
|
385
|
+
text.className = 'twm-btn__label';
|
|
386
|
+
text.textContent = String(a.label || '');
|
|
387
|
+
btn.append(glyph, text);
|
|
388
|
+
} else {
|
|
389
|
+
btn.textContent = String(a.label || '');
|
|
390
|
+
}
|
|
306
391
|
btn.dataset.actionIdx = String(i);
|
|
392
|
+
// C27. AN ACTION MAY START DISABLED, and be enabled later.
|
|
393
|
+
//
|
|
394
|
+
// Every action here dismisses the dialog and resolves the promise —
|
|
395
|
+
// that is the documented contract, and it is why a caller cannot
|
|
396
|
+
// "refuse" a submit: by the time it sees the value, the form and
|
|
397
|
+
// everything typed into it are gone. So a dialog whose primary
|
|
398
|
+
// action is not yet valid has had exactly two options: let the user
|
|
399
|
+
// press it and lose their work to a toast, or reach into this markup
|
|
400
|
+
// from outside, which is the coupling C6 exists to remove.
|
|
401
|
+
//
|
|
402
|
+
// `disabled` plus the controller handed to `onMount` is the third.
|
|
403
|
+
// Absent — every existing caller — the button is enabled exactly as
|
|
404
|
+
// before.
|
|
405
|
+
if (a.disabled) btn.disabled = true;
|
|
406
|
+
if (a.value !== undefined && a.value !== null) {
|
|
407
|
+
btn.dataset.actionValue = String(a.value);
|
|
408
|
+
}
|
|
307
409
|
actionsEl.appendChild(btn);
|
|
308
410
|
});
|
|
309
411
|
body.appendChild(actionsEl);
|
|
310
412
|
|
|
311
413
|
let _resolved = false;
|
|
414
|
+
// DECLARED BEFORE THE WINDOW, INSTALLED AFTER IT. `onClose` below closes
|
|
415
|
+
// over `_dropMaxListener`, and `show()` runs between the two — so
|
|
416
|
+
// leaving the declaration down where the listener is installed would
|
|
417
|
+
// stretch a temporal dead zone across a call that could come back
|
|
418
|
+
// through `onClose`. The cost of getting that wrong is a ReferenceError
|
|
419
|
+
// inside a dismissal, which is the least debuggable place in this file.
|
|
420
|
+
let _onMax = null;
|
|
421
|
+
const _dropMaxListener = () => {
|
|
422
|
+
if (!_onMax) return;
|
|
423
|
+
window.removeEventListener('managed-window-maximized', _onMax);
|
|
424
|
+
_onMax = null;
|
|
425
|
+
};
|
|
312
426
|
const win = new ManagedWindow({
|
|
427
|
+
container: _modalHost,
|
|
313
428
|
id: `twm-modal-${_modalSeq++}`,
|
|
314
429
|
title,
|
|
315
430
|
icon,
|
|
@@ -318,18 +433,57 @@ export function openModal({
|
|
|
318
433
|
backdropBlur,
|
|
319
434
|
backdropOpacity,
|
|
320
435
|
canMinimize: false,
|
|
321
|
-
|
|
436
|
+
// C29. OPT-IN, and `false` is still the default — a confirmation
|
|
437
|
+
// and a two-field form have nothing to do with the extra room, and
|
|
438
|
+
// a button that grows a dialog nobody wanted grown is noise in the
|
|
439
|
+
// one place a user looks for the X. `onMaximize` is deliberately
|
|
440
|
+
// NOT set: see the C29 note in the header — with nothing claiming
|
|
441
|
+
// the gesture, `toggleMaximize` runs the rectangle, which is the
|
|
442
|
+
// only thing maximise can mean for a window with no tile.
|
|
443
|
+
canMaximize: !!maximizable,
|
|
322
444
|
canResize: true,
|
|
323
445
|
canDrag: true,
|
|
324
446
|
defaultWidth: width,
|
|
325
447
|
defaultHeight: height,
|
|
326
|
-
|
|
448
|
+
// Esc, the X and the backdrop all land here without passing through
|
|
449
|
+
// `close`, so the listener is dropped here as well as there — the
|
|
450
|
+
// three dismissals a user reaches for most are exactly the ones
|
|
451
|
+
// that would otherwise leak it.
|
|
452
|
+
onClose: () => {
|
|
453
|
+
_dropMaxListener();
|
|
454
|
+
if (!_resolved) { _resolved = true; resolve(null); }
|
|
455
|
+
},
|
|
327
456
|
});
|
|
328
457
|
win.show();
|
|
329
458
|
|
|
459
|
+
// C29. THE STATE, FOR A CALLER WHOSE LAYOUT DEPENDS ON IT.
|
|
460
|
+
//
|
|
461
|
+
// `managed-window-maximized` (C16) is the framework's own edge and it
|
|
462
|
+
// is global, so it is filtered on the id — the alternative, wrapping
|
|
463
|
+
// `win.toggleMaximize`, would miss `_restoreState`'s replay of a
|
|
464
|
+
// persisted maximised window and any consumer calling the method
|
|
465
|
+
// directly. Removed on close: a dialog is short-lived and twenty of
|
|
466
|
+
// them over a session leaving listeners behind is a leak with no
|
|
467
|
+
// symptom until the twenty-first.
|
|
468
|
+
//
|
|
469
|
+
// The CSS half needs no listener at all — `ManagedWindow` toggles
|
|
470
|
+
// `.twm-managed-window--maximized` on its own element, which is an
|
|
471
|
+
// ancestor of everything in here, so a stylesheet can respond without
|
|
472
|
+
// any of this. The callback is for the layout a stylesheet cannot
|
|
473
|
+
// reach: content whose height was fixed by the CALLER's own rules.
|
|
474
|
+
if (maximizable && typeof onMaximizeChange === 'function') {
|
|
475
|
+
_onMax = (e) => {
|
|
476
|
+
if (e.detail?.id !== win.id) return;
|
|
477
|
+
try { onMaximizeChange(!!e.detail.maximized, bodyInner); }
|
|
478
|
+
catch (err) { console.warn('[modal] onMaximizeChange threw', err); }
|
|
479
|
+
};
|
|
480
|
+
window.addEventListener('managed-window-maximized', _onMax);
|
|
481
|
+
}
|
|
482
|
+
|
|
330
483
|
const close = (value) => {
|
|
331
484
|
if (_resolved) return;
|
|
332
485
|
_resolved = true;
|
|
486
|
+
_dropMaxListener();
|
|
333
487
|
resolve(value);
|
|
334
488
|
try { win.close({ force: true }); } catch {}
|
|
335
489
|
};
|
|
@@ -355,16 +509,68 @@ export function openModal({
|
|
|
355
509
|
close(a.value);
|
|
356
510
|
});
|
|
357
511
|
|
|
512
|
+
/**
|
|
513
|
+
* C27. What `onMount` is handed alongside the body, so a form can keep
|
|
514
|
+
* its own submit honest without knowing this file's markup.
|
|
515
|
+
*
|
|
516
|
+
* Addressed by an action's VALUE rather than its index: an index is a
|
|
517
|
+
* fact about the order the caller happened to list them in, and a caller
|
|
518
|
+
* that inserted a "Save and add another" in the middle would silently
|
|
519
|
+
* start disabling Cancel.
|
|
520
|
+
*/
|
|
521
|
+
const controls = {
|
|
522
|
+
// NOT `CSS.escape`. It is a browser global that jsdom does not
|
|
523
|
+
// provide, and this file is mounted under jsdom by four render
|
|
524
|
+
// tests — so reaching for it turns "the dialog is valid" into a
|
|
525
|
+
// ReferenceError in every one of them, and into nothing at all in a
|
|
526
|
+
// headless consumer. An attribute selector needs `"` and `\`
|
|
527
|
+
// escaped and nothing else.
|
|
528
|
+
actionButton: (value) => actionsEl.querySelector(
|
|
529
|
+
`[data-action-value="${String(value).replace(/["\\]/g, '\\$&')}"]`),
|
|
530
|
+
setActionEnabled(value, enabled) {
|
|
531
|
+
const btn = controls.actionButton(value);
|
|
532
|
+
if (btn) btn.disabled = !enabled;
|
|
533
|
+
return !!btn;
|
|
534
|
+
},
|
|
535
|
+
};
|
|
536
|
+
|
|
358
537
|
// onMount runs after the next frame so layout has settled and
|
|
359
538
|
// the inner content has a real bounding box for sizing.
|
|
360
539
|
if (typeof onMount === 'function') {
|
|
361
540
|
requestAnimationFrame(() => {
|
|
362
|
-
try { onMount(bodyInner); } catch (err) { console.warn(err); }
|
|
541
|
+
try { onMount(bodyInner, controls); } catch (err) { console.warn(err); }
|
|
363
542
|
});
|
|
364
543
|
}
|
|
365
|
-
//
|
|
366
|
-
//
|
|
544
|
+
// C28. THE FIRST FIELD, IF THERE IS ONE. Otherwise the primary button.
|
|
545
|
+
//
|
|
546
|
+
// A dialog that asks for a name should let you type it: *"on any 'new'
|
|
547
|
+
// dialog I would like to have the first input focused by default, so
|
|
548
|
+
// that I can directly start typing."* Focusing the action button first
|
|
549
|
+
// means every new table, project and column begins with a click into a
|
|
550
|
+
// field that was the only place the caret could sensibly have been.
|
|
551
|
+
//
|
|
552
|
+
// The button remains the fallback, which is what keeps Enter useful on
|
|
553
|
+
// a dialog that asks nothing — a confirmation has no field, and there
|
|
554
|
+
// the primary action IS the answer.
|
|
555
|
+
//
|
|
556
|
+
// DISABLED CONTROLS ARE SKIPPED, and so is anything `readonly`: a form
|
|
557
|
+
// whose first control is a read-only statement panel (C27's disabled
|
|
558
|
+
// primary is the sibling case) would swallow the caret into a box that
|
|
559
|
+
// cannot take it.
|
|
367
560
|
requestAnimationFrame(() => {
|
|
561
|
+
const field = bodyInner.querySelector(
|
|
562
|
+
'input:not([type="hidden"]):not([disabled]):not([readonly]),'
|
|
563
|
+
+ ' textarea:not([disabled]):not([readonly]),'
|
|
564
|
+
+ ' select:not([disabled])');
|
|
565
|
+
if (field) {
|
|
566
|
+
field.focus();
|
|
567
|
+
// The caret at the END of whatever is already there, not
|
|
568
|
+
// selecting it: an edit dialog opens on a value the user means
|
|
569
|
+
// to amend, and a selected value is one keystroke from gone.
|
|
570
|
+
try { field.setSelectionRange?.(field.value.length, field.value.length); }
|
|
571
|
+
catch { /* a `select`, or an input type with no selection */ }
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
368
574
|
const idx = acts.findIndex((a) => a.primary);
|
|
369
575
|
const which = idx >= 0 ? idx : acts.length - 1;
|
|
370
576
|
actionsEl.querySelector(`[data-action-idx="${which}"]`)?.focus();
|