flexdesk 0.17.0 → 0.17.2

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 (45) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +399 -3
  3. package/canvas.js +18 -0
  4. package/charts.js +21 -0
  5. package/core.js +14 -0
  6. package/css/base.css +23 -1
  7. package/css/flexdesk.css +61 -1
  8. package/dist/charts.js +3 -5
  9. package/dist/charts.js.map +1 -1
  10. package/dist/{chunk-Q36EPT2R.js → chunk-LWV23YVE.js} +1614 -138
  11. package/dist/chunk-LWV23YVE.js.map +7 -0
  12. package/dist/{chunk-MCIXFNVF.js → chunk-RMTRWWEO.js} +2 -2
  13. package/dist/flexdesk.css +61 -1
  14. package/dist/flow.js +2 -2
  15. package/dist/flow.js.map +2 -2
  16. package/dist/tiles.js +2 -2
  17. package/dist/widgets.js +186 -55
  18. package/dist/widgets.js.map +2 -2
  19. package/dist/wm.js +90 -10
  20. package/dist/wm.js.map +2 -2
  21. package/editor.js +20 -0
  22. package/flow.js +21 -0
  23. package/host.js +14 -0
  24. package/package.json +10 -1
  25. package/src/flow/canvas_editor/editor.js +1 -1
  26. package/src/tiling/command_palette.js +46 -7
  27. package/src/tiling/content_registry.js +3 -0
  28. package/src/tiling/entity_sources.js +10 -0
  29. package/src/tiling/wm.js +65 -3
  30. package/src/ui/components/cell_editor_host.js +100 -18
  31. package/src/ui/components/cell_editors.js +480 -0
  32. package/src/ui/components/cell_grid.js +121 -8
  33. package/src/ui/components/cell_paste.js +243 -0
  34. package/src/ui/components/data_table.js +96 -10
  35. package/src/ui/components/settings_view.js +192 -51
  36. package/src/ui/components/staged_edits.js +19 -1
  37. package/src/ui/components/tsv.js +74 -0
  38. package/src/ui/notification_center.js +5 -5
  39. package/tiles.js +27 -0
  40. package/widgets.js +69 -0
  41. package/wm.js +28 -0
  42. package/dist/chunk-Q36EPT2R.js.map +0 -7
  43. package/dist/chunk-QWQJUDPD.js +0 -610
  44. package/dist/chunk-QWQJUDPD.js.map +0 -7
  45. /package/dist/{chunk-MCIXFNVF.js.map → chunk-RMTRWWEO.js.map} +0 -0
package/editor.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @flexdesk/editor — A Monaco-backed multi-file editor: the loader, the editor factory, the
3
+ * tab bar, search, a split-pane container and an undo manager.
4
+ *
5
+ * Monaco is a PEER dependency — 14 MB this bundle deliberately does NOT
6
+ * contain, and whose location it refuses to guess. Call setMonacoBasePath()
7
+ * and setDefaultLanguage().
8
+ *
9
+ * GENERATED by scripts/gen_barrels.py. Do not hand-edit.
10
+ *
11
+ * 7 modules.
12
+ */
13
+
14
+ export * from './src/editor/editor_pane.js';
15
+ export * from './src/editor/monaco_editor_factory.js';
16
+ export * from './src/editor/monaco_loader.js';
17
+ export * from './src/editor/notebook_search.js';
18
+ export * from './src/editor/notebook_tab_bar.js';
19
+ export * from './src/editor/notebook_undo_manager.js';
20
+ export * from './src/editor/split_pane_container.js';
package/flow.js ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @flexdesk/flow — flow editors: the shared kit (a step-type catalogue, a
3
+ * settings panel generated from a JSON-Schema subset, a widget registry,
4
+ * reference chips, the step and value pickers, findings, undo over whole
5
+ * snapshots, keys) and the three editors built on it — the canvas, the outline
6
+ * and the lanes.
7
+ *
8
+ * Every editor is domain-neutral: it never fetches, never saves and never
9
+ * names a consumer's vocabulary. The step types, the reference grammar, every
10
+ * finding and every byte of I/O are the consumer's, passed in.
11
+ *
12
+ * HAND-WRITTEN, not generated. The other barrels say "GENERATED by
13
+ * scripts/gen_barrels.py", and that script is not in this repository, so this
14
+ * one is kept by hand. Each line below is one owner's module (36 §2): the kit
15
+ * is laid down first, and each editor fills its own index.js.
16
+ */
17
+
18
+ export * from './src/flow/kit/index.js';
19
+ export * from './src/flow/canvas_editor/index.js';
20
+ export * from './src/flow/outline/index.js';
21
+ export * from './src/flow/lanes/index.js';
package/host.js ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @flexdesk/host — The host port. The library never touches pywebview, fetch or localStorage
3
+ * directly — it asks a Host, and the application supplies one.
4
+ *
5
+ * GENERATED by scripts/gen_barrels.py. Do not hand-edit.
6
+ *
7
+ * 5 modules.
8
+ */
9
+
10
+ export * from './src/host/host.js';
11
+ export * from './src/host/pywebview_host.js';
12
+ export * from './src/host/photino_host.js';
13
+ export * from './src/host/external_links.js';
14
+ export * from './src/host/file_picker.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flexdesk",
3
- "version": "0.17.0",
3
+ "version": "0.17.2",
4
4
  "description": "A tiling window manager and widget set for desktop-class web applications. Zero runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -18,6 +18,15 @@
18
18
  "files": [
19
19
  "dist",
20
20
  "src",
21
+ "core.js",
22
+ "host.js",
23
+ "wm.js",
24
+ "widgets.js",
25
+ "charts.js",
26
+ "tiles.js",
27
+ "editor.js",
28
+ "canvas.js",
29
+ "flow.js",
21
30
  "css",
22
31
  "README.md",
23
32
  "CHANGELOG.md",
@@ -2888,7 +2888,7 @@ export function createCanvasEditor(host, options = {}) {
2888
2888
  for (const c of g.connections) {
2889
2889
  const source = idOf.get(c.source);
2890
2890
  const target = idOf.get(c.target);
2891
- if (source && target) connect(graph, catalogue, { ...c, source, target }, { strings: S });
2891
+ if (source && target) connect(graph, catalogue, { ...c, source, target }, { strings: S, dataPorts: dataConnect });
2892
2892
  }
2893
2893
  const noteIds = [];
2894
2894
  for (const from of noting ? g.notes || [] : []) {
@@ -72,6 +72,17 @@
72
72
  * drillLabel the Tab hint ("search rows"); Tab is offered only with it
73
73
  * shortcut a key binding drawn on the right
74
74
  * verb the Enter hint ('open' by default, 'run' for a command)
75
+ * enabled `() => boolean` (0.17.2). False: the row is still listed and
76
+ * the arrows still reach it, but it is drawn greyed
77
+ * (`twm-cmdpal__item--disabled`, aria-disabled="true") and
78
+ * picking it — click, Enter, Ctrl/Shift+Enter — does nothing and
79
+ * leaves the palette open. Asked on every draw and every pick.
80
+ * disabledReason the disabled row's tooltip (its `title`)
81
+ *
82
+ * A catalog source with `scopedOnly: true` (0.17.2) contributes no rows to a
83
+ * plain typed query, in either layout: its rows are listed only once its
84
+ * prefix has made the type chip (`help: solver`).
85
+ *
75
86
  * `hint` keeps its old meaning — drawn AND searched — for a row that sets
76
87
  * neither `context` nor `keywords`. A group's header is the taxonomy's
77
88
  * `plural` for the kind, else its `label`.
@@ -281,8 +292,7 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
281
292
  const render = () => {
282
293
  if (grouped) { renderGrouped(); return; }
283
294
  list.innerHTML = filtered.slice(0, MAX_ROWS).map((e, i) => `
284
- <div class="twm-cmdpal__item${i === active ? ' twm-cmdpal__item--active' : ''}"
285
- data-idx="${i}">
295
+ ${_itemOpen(e, i === active ? ' twm-cmdpal__item--active' : '', `data-idx="${i}"`)}
286
296
  <span class="material-symbols-outlined twm-cmdpal__icon">${e.icon || 'arrow_right'}</span>
287
297
  <span class="twm-cmdpal__label">${_esc(e.label || e.id)}</span>
288
298
  <span class="twm-cmdpal__kind">${e.kind}</span>
@@ -343,7 +353,7 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
343
353
  const side = r.shortcut
344
354
  ? `<kbd class="twm-cmdpal__shortcut">${_esc(r.shortcut)}</kbd>`
345
355
  : `<span class="twm-cmdpal__kind">${_esc(kindLabel(r))}</span>`;
346
- return `<div class="twm-cmdpal__item${on}" data-idx="${i}" role="option">
356
+ return `${_itemOpen(r, on, `data-idx="${i}" role="option"`)}
347
357
  <span class="material-symbols-outlined twm-cmdpal__icon">${_esc(r.icon || 'arrow_right')}</span>
348
358
  <span class="twm-cmdpal__main"><span class="twm-cmdpal__label">${_mark(r.label || r.id, r._hits)}</span>${ctx}</span>
349
359
  <span class="twm-cmdpal__side">${side}<span class="twm-cmdpal__acts">${acts.join('')}</span></span>
@@ -453,6 +463,9 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
453
463
  const picked = filtered[i];
454
464
  if (!picked) return;
455
465
  if (picked.more) { showAll(picked.more); return; }
466
+ // A disabled row is listed so it can say why; picking it runs
467
+ // nothing and the palette stays open on it.
468
+ if (_isDisabled(picked)) return;
456
469
  // Split and window open a COPY; a single kind refuses both, and the
457
470
  // palette stays open on the row whose hints say why.
458
471
  if (mode !== 'open' && isSingle(picked)) return;
@@ -585,6 +598,7 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
585
598
  return;
586
599
  }
587
600
  let pool = entities;
601
+ if (!filterSet) pool = pool.filter((e) => !e._scopedOnly);
588
602
  if (commandsOnly) pool = pool.filter((e) => e.kind === 'command');
589
603
  else if (filterSet) pool = pool.filter((e) => filterSet.has(_groupOf(e)) || filterSet.has(e.kind));
590
604
  else if (q.length < commandMinChars) pool = pool.filter((e) => e.kind !== 'command');
@@ -649,7 +663,7 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
649
663
  const filterSet = typeFilter ? new Set(typeFilter) : null;
650
664
  const pool = filterSet
651
665
  ? entities.filter((e) => filterSet.has(e.kind))
652
- : entities;
666
+ : entities.filter((e) => !e._scopedOnly);
653
667
  if (!q) {
654
668
  // Scoped + empty query → list every entity of that
655
669
  // kind so the user sees what's available. Unscoped +
@@ -715,10 +729,18 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
715
729
  }
716
730
  return [...staticCommands, ...extra];
717
731
  };
732
+ // A `scopedOnly` source's rows are marked here, from the catalog's
733
+ // own source list, so only the rows the catalog produced are held
734
+ // back — a command or a Recent row of the same kind is not.
735
+ const scopedKinds = new Set((catalog?.sources || [])
736
+ .filter((s) => s?.scopedOnly === true).map((s) => s.navKind));
737
+ const tagScoped = (rows) => (scopedKinds.size
738
+ ? rows.map((r) => (scopedKinds.has(r.kind) ? { ...r, _scopedOnly: true } : r))
739
+ : rows);
718
740
  // Objects before commands in the grouped layout, so that a tie that
719
741
  // survives every rule goes to the object (first seen wins).
720
742
  const withCommands = (rows) => (grouped ? [...rows, ...commandRows()] : [...commandRows(), ...rows]);
721
- entities = _decorateIcons(withCommands(cache && cached ? cached : []), taxonomy);
743
+ entities = _decorateIcons(withCommands(tagScoped(cache && cached ? cached : [])), taxonomy);
722
744
  if (grouped) {
723
745
  refilter();
724
746
  if (typeof empty === 'function') {
@@ -735,7 +757,7 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
735
757
  const loaded = await catalog.loadAll(api);
736
758
  if (cache) cached = loaded;
737
759
  if (overlay !== mine) return;
738
- entities = _decorateIcons(withCommands(loaded), taxonomy);
760
+ entities = _decorateIcons(withCommands(tagScoped(loaded)), taxonomy);
739
761
  if (grouped ? (!scope && (input.value || typeFilter)) : input.value) refilter();
740
762
  } catch (err) {
741
763
  console.warn('[cmdpal] entity load failed', err);
@@ -859,9 +881,26 @@ function _searched(r) {
859
881
  }
860
882
  function _groupOf(r) { return r.group || r.kind; }
861
883
 
884
+ /** 0.17.2. `enabled` is asked each time; one that throws counts as no. */
885
+ function _isDisabled(r) {
886
+ if (!r || typeof r.enabled !== 'function') return false;
887
+ try { return !r.enabled(); }
888
+ catch (err) { console.warn('[cmdpal] enabled() threw', err); return true; }
889
+ }
890
+ /** A row's opening `<div>` with its classes (`twm-cmdpal__item` plus `extra`)
891
+ * and attributes. A disabled row adds its class, `aria-disabled` and, given a
892
+ * `disabledReason`, the reason as its `title`. */
893
+ function _itemOpen(r, extra, attrs) {
894
+ const off = _isDisabled(r);
895
+ const cls = 'twm-cmdpal__item' + extra + (off ? ' twm-cmdpal__item--disabled' : '');
896
+ const refused = !off ? ''
897
+ : ' aria-disabled="true"' + (r.disabledReason ? ` title="${_esc(r.disabledReason)}"` : '');
898
+ return `<div class="${cls}"${refused} ${attrs}>`;
899
+ }
900
+
862
901
  /** Strip the palette's private fields before a row leaves it. */
863
902
  function _public(r) {
864
- const { _hits, _score, ...rest } = r;
903
+ const { _hits, _score, _scopedOnly, ...rest } = r;
865
904
  return rest;
866
905
  }
867
906
 
@@ -8,6 +8,9 @@
8
8
  *
9
9
  * The tile renderer calls the factory once when the leaf is first
10
10
  * mounted with a given (kind, props) pair, and `destroy()` on unmount.
11
+ * A content of a `single` kind may return `navigate(props)`: an open path
12
+ * that reveals the instance instead of mounting it hands it the new props
13
+ * (`wm.revealInstance`, 0.17.2).
11
14
  *
12
15
  * `ctx` is the shell's ctx plus, per leaf, `leafId` and `setTitle(text)` —
13
16
  * rename the pane's chrome title from inside the content, at mount or any
@@ -27,6 +27,12 @@
27
27
  * row to the record shape every search surface consumes.
28
28
  * Return `null` to skip the row.
29
29
  *
30
+ * scopedOnly? `true` keeps the source's rows out of a plain typed query:
31
+ * the command palette lists them only behind the source's
32
+ * prefix (`<navKind>:` or an alias that resolves to it). For a
33
+ * source too broad to compete with everything else — help
34
+ * topics, say. Absent or `false`: searched as always (0.17.2).
35
+ *
30
36
  * ## Aggregation
31
37
  *
32
38
  * An embedder whose backend can answer for every source in ONE call
@@ -48,6 +54,10 @@ export function createEntityCatalog({ sources, aliases = {}, aggregate = null }
48
54
  throw new TypeError(
49
55
  `createEntityCatalog: source '${s?.navKind ?? '?'}' needs navKind + list() + shape()`);
50
56
  }
57
+ if (s.scopedOnly != null && typeof s.scopedOnly !== 'boolean') {
58
+ throw new TypeError(
59
+ `createEntityCatalog: source '${s.navKind}' \`scopedOnly\` must be a boolean when present`);
60
+ }
51
61
  }
52
62
  if (aggregate != null && typeof aggregate !== 'function') {
53
63
  throw new TypeError('createEntityCatalog: `aggregate` must be a function when present');
package/src/tiling/wm.js CHANGED
@@ -348,10 +348,57 @@ export class WindowManager {
348
348
  }
349
349
 
350
350
  /** Reveal the instance of a single kind that is open: answers whether
351
- * there was one. Every open path asks this first. */
351
+ * there was one. Every open path asks this first.
352
+ *
353
+ * An open path names props too, and revealing alone would drop them: the
354
+ * instance keeps showing what it was opened with. So a revealed content
355
+ * that returned a `navigate(props)` hook from its mount is handed the new
356
+ * props — once per open, the same props twice are two calls — and they
357
+ * are recorded on its tab, so a page restore or a reload mounts it with
358
+ * the latest. A content without the hook is revealed as it is: its tab
359
+ * keeps its props and nothing is called. So is every content when the
360
+ * open names no props at all — a top-nav button, `openInPrimary(kind)` —
361
+ * which asks to SEE the instance, not to empty it. */
352
362
  revealInstance(kind, props = {}) {
353
363
  const loc = this.findInstance(kind, props);
354
- return loc ? this._revealAt(loc) : false;
364
+ if (!loc || !this._revealAt(loc)) return false;
365
+ if (props && Object.keys(props).length) this._deliverProps(kind, props);
366
+ return true;
367
+ }
368
+
369
+ /** Hand a revealed instance the props it was opened with, and record them
370
+ * on its tab (and the tile's `content` mirror of it, re-baselining the
371
+ * tile so the write does not rebuild it). Looks
372
+ * the instance up again: a reveal from a page archive moved it into the
373
+ * tile's tabs. A throwing hook is a warning, never a crash. */
374
+ _deliverProps(kind, props) {
375
+ const loc = this.findInstance(kind, props);
376
+ if (!loc || loc.where === 'archive') return;
377
+ const scope = loc.where === 'window' ? { windowId: loc.windowId } : { leafId: loc.leafId };
378
+ const content = this._scopeContent(scope);
379
+ if (typeof content?.navigate !== 'function') return;
380
+ const next = { ...(props || {}) };
381
+ if (loc.where === 'window') {
382
+ const rec = this._windowToLeaf.get(loc.windowId);
383
+ const tab = Array.isArray(rec.tabs) ? rec.tabs[loc.idx] : null;
384
+ if (tab) tab.props = { ...next };
385
+ if (rec.original) rec.original = { ...rec.original, props: { ...next } };
386
+ } else {
387
+ const leaf = this._tree().get(loc.leafId);
388
+ const expected = this.renderer?.leafKey?.(loc.leafId) ?? undefined;
389
+ leaf.tabs[loc.idx].props = { ...next };
390
+ // The tab is the active one now, and `content` mirrors it: the
391
+ // next tab activation re-mirrors it anyway, and a mirror left
392
+ // behind would change the tile's key then and rebuild it.
393
+ if (leaf.content?.kind === kind) leaf.content = { ...leaf.content, props: { ...next } };
394
+ this.renderer?.rebaselineLeaf?.(loc.leafId, expected);
395
+ }
396
+ this._persist();
397
+ try {
398
+ content.navigate({ ...next });
399
+ } catch (err) {
400
+ console.warn('[wm] content navigate threw', err);
401
+ }
355
402
  }
356
403
 
357
404
  /** A leaf's archived pages that can come back — every `pageTabs` entry
@@ -4106,8 +4153,23 @@ export class WindowManager {
4106
4153
  *
4107
4154
  * The lower-level primitives (`openFromContext`,
4108
4155
  * `openInTabFromContext`, `openInTabInPrimary`, `openInPrimary`,
4109
- * `openInWindow`) stay internal; callers prefer `wm.navigate(...)`. */
4156
+ * `openInWindow`) stay internal; callers prefer `wm.navigate(...)`.
4157
+ *
4158
+ * Returns `true` once the navigation is routed. A kind the shell's
4159
+ * content registry has no factory for is refused instead: one
4160
+ * `console.warn` naming it, no tile, tab, window or history touched, and
4161
+ * `false` — never a placeholder tile, never a throw. */
4110
4162
  navigate(kind, props = {}, opts = {}) {
4163
+ if (!this.content.has(kind)) {
4164
+ console.warn(`[wm] navigate: no content registered for kind "${String(kind)}"`);
4165
+ return false;
4166
+ }
4167
+ this._route(kind, props, opts);
4168
+ return true;
4169
+ }
4170
+
4171
+ /** The routing behind `navigate`, for a kind known to be registered. */
4172
+ _route(kind, props, opts) {
4111
4173
  const { ctx = null, transient = false } = opts;
4112
4174
  // Append the tab but stay where you are. Only meaningful with `newTab`.
4113
4175
  const background = !!opts.background;
@@ -6,10 +6,11 @@
6
6
  * it likes (DataTable's `editors` option); the plain text box below is the
7
7
  * default. A factory is called with
8
8
  *
9
- * { value, row, rowIdx, colIdx, column: {index, header, type},
10
- * initialText, commit(value), cancel() }
9
+ * { ...args, value, row, rowIdx, colIdx, column: {index, header, type},
10
+ * initialText, commit(value, move?), cancel() }
11
11
  *
12
- * and returns either an element, or `{ el, getValue?, focus?, destroy? }`.
12
+ * and returns either an element, or
13
+ * `{ el, getValue?, parse?, multiline?, focus?, destroy? }`.
13
14
  * `initialText` is the character typed to open the editor (null when it was
14
15
  * opened by F2, Enter or a double-click): the editor starts with it in place
15
16
  * of the value, as a spreadsheet does.
@@ -29,6 +30,20 @@
29
30
  * pressed in the editor stops there: the table's own keys (the arrows, Ctrl+A,
30
31
  * Ctrl+C) are not the editor's.
31
32
  *
33
+ * ══ A VALUE THAT DOES NOT PARSE (STORY-0087) ═══════════════════════════
34
+ *
35
+ * An editor with a `parse()` — `() => {ok: true, value} | {ok: false, error}`
36
+ * — commits what `parse()` reads, not `getValue()`. A refusal on Enter or Tab
37
+ * keeps the editor open, and its cell carries the message
38
+ * (`twm-dt-cell--error`, `title`, `data-error`) until the next keystroke in
39
+ * it; nothing is committed. A refusal when the focus leaves, or on `commit()`
40
+ * from outside, closes the editor and hands `onRefuse` the message and the
41
+ * text (`getValue()`); still nothing is committed. A `multiline` editor
42
+ * keeps Enter for a new line: Ctrl/Cmd+Enter commits it.
43
+ *
44
+ * `commit(value, move?)` takes an optional move ('down', 'up', 'right',
45
+ * 'left'), for an editor that commits on a key of its own.
46
+ *
32
47
  * ══ EXACTLY ONCE ═════════════════════════════════════════════════════════
33
48
  *
34
49
  * A session closes once. Escape's own repaint takes the focused input out of
@@ -73,14 +88,29 @@ function normalise(made) {
73
88
  let getValue = typeof own.getValue === 'function' ? own.getValue : null;
74
89
  // A form field's value is the editor's; any other element says for itself.
75
90
  if (!getValue && el.matches?.('input, select, textarea')) getValue = () => el.value;
91
+ const parse = typeof own.parse === 'function' ? own.parse : null;
92
+ // What a commit reads: the parse, else the value as it is.
93
+ let read = null;
94
+ if (parse) {
95
+ read = () => {
96
+ let r;
97
+ try { r = parse(); } catch (err) { r = { ok: false, error: err?.message || String(err) }; }
98
+ return r && r.ok === false ? { ok: false, error: String(r.error ?? 'Not valid') }
99
+ : { ok: true, value: r ? r.value : undefined };
100
+ };
101
+ } else if (getValue) read = () => ({ ok: true, value: getValue() });
76
102
  return {
77
103
  el,
78
104
  getValue,
105
+ read,
106
+ multiline: !!own.multiline,
79
107
  focus: typeof own.focus === 'function' ? own.focus : null,
80
108
  destroy: typeof own.destroy === 'function' ? own.destroy : null,
81
109
  };
82
110
  }
83
111
 
112
+ const MOVES = new Set(['down', 'up', 'right', 'left']);
113
+
84
114
  export class CellEditorHost {
85
115
  /**
86
116
  * @param {object} o
@@ -88,19 +118,24 @@ export class CellEditorHost {
88
118
  * cell is drawn as it was again; `move` is 'down', 'up', 'right', 'left'
89
119
  * or null; `reason` is 'key', 'blur', 'editor' or 'scroll'.
90
120
  * @param {(session, reason) => void} o.onCancel
121
+ * @param {(session, error, text, reason) => void} [o.onRefuse] a value
122
+ * that did not parse, when the editor closed with it (the focus left,
123
+ * `commit()` from outside); without it such a close is a cancel.
91
124
  */
92
- constructor({ onCommit, onCancel }) {
125
+ constructor({ onCommit, onCancel, onRefuse = null }) {
93
126
  this._onCommit = onCommit;
94
127
  this._onCancel = onCancel;
128
+ this._onRefuse = onRefuse;
95
129
  /** The open session, or null. */
96
130
  this.session = null;
97
131
  }
98
132
 
99
133
  /**
100
- * Open an editor in `td`. Returns whether one opened (a factory that
134
+ * Open an editor in `td`; `args` go to the factory beside the cell's own
135
+ * (DataTable's `locale`, `utc`, `messages`). Returns whether one opened (a factory that
101
136
  * throws or returns nothing opens none, and the cell is untouched).
102
137
  */
103
- open({ td, factory, value, row, rowIdx, colIdx, column, initialText = null, ...extra }) {
138
+ open({ td, factory, value, row, rowIdx, colIdx, column, initialText = null, args = {}, ...extra }) {
104
139
  if (this.session) return false;
105
140
  const s = {
106
141
  ...extra, td, value, row, rowIdx, colIdx, column,
@@ -109,8 +144,8 @@ export class CellEditorHost {
109
144
  let made = null;
110
145
  try {
111
146
  made = factory({
112
- value, row, rowIdx, colIdx, column, initialText,
113
- commit: (v) => this._finish(s, v, null, 'editor'),
147
+ ...args, value, row, rowIdx, colIdx, column, initialText,
148
+ commit: (v, move = null) => this._finish(s, v, MOVES.has(move) ? move : null, 'editor'),
114
149
  cancel: () => this._cancel(s, 'editor'),
115
150
  });
116
151
  } catch (err) {
@@ -122,6 +157,10 @@ export class CellEditorHost {
122
157
  s.editor = editor;
123
158
  s.saved = [...td.childNodes];
124
159
  s.savedTitle = td.getAttribute('title');
160
+ s.savedError = {
161
+ on: td.classList.contains('twm-dt-cell--error'),
162
+ message: td.getAttribute('data-error'),
163
+ };
125
164
  this.session = s;
126
165
  td.classList.add('twm-dt-cell--editing');
127
166
  td.replaceChildren(editor.el);
@@ -136,26 +175,29 @@ export class CellEditorHost {
136
175
  if (ev.key === 'Escape') {
137
176
  ev.preventDefault();
138
177
  this._cancel(s, 'key');
139
- } else if (ev.key === 'Enter' && !ev.altKey && !ev.ctrlKey && !ev.metaKey && editor.getValue) {
178
+ } else if (ev.key === 'Enter' && !ev.altKey && editor.read
179
+ && (editor.multiline ? (ev.ctrlKey || ev.metaKey) : !(ev.ctrlKey || ev.metaKey))) {
140
180
  ev.preventDefault();
141
- this._finish(s, editor.getValue(), ev.shiftKey ? 'up' : 'down', 'key');
142
- } else if (ev.key === 'Tab' && !ev.altKey && !ev.ctrlKey && !ev.metaKey && editor.getValue) {
181
+ this._tryKey(s, ev.shiftKey ? 'up' : 'down');
182
+ } else if (ev.key === 'Tab' && !ev.altKey && !ev.ctrlKey && !ev.metaKey && editor.read) {
143
183
  ev.preventDefault();
144
- this._finish(s, editor.getValue(), ev.shiftKey ? 'left' : 'right', 'key');
184
+ this._tryKey(s, ev.shiftKey ? 'left' : 'right');
145
185
  }
146
186
  });
187
+ // A refusal's message stands until the text changes.
188
+ on('input', () => this._unmark(s));
147
189
  // A press inside the editor is the editor's, not a click on the cell.
148
190
  for (const type of ['click', 'dblclick', 'mousedown', 'contextmenu']) {
149
191
  on(type, (ev) => ev.stopPropagation());
150
192
  }
151
193
  on('focusout', (ev) => {
152
- if (s.closed || !editor.getValue) return;
194
+ if (s.closed || !editor.read) return;
153
195
  const to = ev.relatedTarget;
154
196
  if (to && editor.el.contains(to)) return;
155
197
  // The window losing focus (another app) is not a click elsewhere.
156
198
  const doc = editor.el.ownerDocument;
157
199
  if (!to && doc && typeof doc.hasFocus === 'function' && !doc.hasFocus()) return;
158
- this._finish(s, editor.getValue(), null, 'blur');
200
+ this._settle(s, null, 'blur');
159
201
  });
160
202
 
161
203
  try {
@@ -169,8 +211,49 @@ export class CellEditorHost {
169
211
  commit(move = null, reason = 'editor') {
170
212
  const s = this.session;
171
213
  if (!s) return false;
172
- if (!s.editor.getValue) { this._cancel(s, reason); return false; }
173
- return this._finish(s, s.editor.getValue(), move, reason);
214
+ if (!s.editor.read) { this._cancel(s, reason); return false; }
215
+ return this._settle(s, move, reason);
216
+ }
217
+
218
+ /** Enter or Tab: commit what parses; a refusal keeps the editor open,
219
+ * the message on its cell. */
220
+ _tryKey(s, move) {
221
+ const r = s.editor.read();
222
+ if (r.ok) return this._finish(s, r.value, move, 'key');
223
+ s.td.classList.add('twm-dt-cell--error');
224
+ s.td.setAttribute('data-error', r.error);
225
+ s.td.setAttribute('title', r.error);
226
+ s.marked = true;
227
+ return false;
228
+ }
229
+
230
+ _unmark(s) {
231
+ if (!s.marked) return;
232
+ s.marked = false;
233
+ this._restoreMark(s);
234
+ }
235
+
236
+ _restoreMark(s) {
237
+ const { on, message } = s.savedError;
238
+ s.td.classList.toggle('twm-dt-cell--error', on);
239
+ if (message == null) s.td.removeAttribute('data-error');
240
+ else s.td.setAttribute('data-error', message);
241
+ if (s.savedTitle == null) s.td.removeAttribute('title');
242
+ else s.td.setAttribute('title', s.savedTitle);
243
+ }
244
+
245
+ /** The editor closing with what it holds (the focus left, `commit()`):
246
+ * committed when it parses, else handed to `onRefuse`. */
247
+ _settle(s, move, reason) {
248
+ const r = s.editor.read();
249
+ if (r.ok) return this._finish(s, r.value, move, reason);
250
+ if (s.closed) return false;
251
+ let text = '';
252
+ try { text = s.editor.getValue ? s.editor.getValue() : ''; } catch (_) { /* gone */ }
253
+ this._close(s);
254
+ if (this._onRefuse) this._onRefuse(s, r.error, text, reason);
255
+ else this._onCancel?.(s, reason);
256
+ return true;
174
257
  }
175
258
 
176
259
  /** Cancel the open session: the cell is drawn as it was. */
@@ -191,8 +274,7 @@ export class CellEditorHost {
191
274
  }
192
275
  s.td.classList.remove('twm-dt-cell--editing');
193
276
  s.td.replaceChildren(...s.saved);
194
- if (s.savedTitle == null) s.td.removeAttribute('title');
195
- else s.td.setAttribute('title', s.savedTitle);
277
+ this._restoreMark(s);
196
278
  }
197
279
 
198
280
  _finish(s, value, move, reason) {