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
@@ -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
- btn.textContent = String(a.label || '');
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
- canMaximize: false,
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
- onClose: () => { if (!_resolved) { _resolved = true; resolve(null); } },
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
- // Focus the primary button (or the last action) so Enter
366
- // resolves the most-likely intended outcome.
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();