flexdesk 0.17.18 → 0.18.1
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/CHANGELOG.md +91 -0
- package/README.md +1 -0
- package/css/flexdesk.css +7547 -5891
- package/css/reset.css +1 -1
- package/css/tokens.css +30 -1
- package/dist/charts.js +83 -13
- package/dist/charts.js.map +2 -2
- package/dist/chunk-3BLIVOFD.js +778 -0
- package/dist/chunk-3BLIVOFD.js.map +7 -0
- package/dist/{chunk-7OL6F2AM.js → chunk-4X4SY3CP.js} +7 -5
- package/dist/chunk-4X4SY3CP.js.map +7 -0
- package/dist/chunk-DNJ3N4T4.js +82 -0
- package/dist/chunk-DNJ3N4T4.js.map +7 -0
- package/dist/{chunk-PGZ4EHSE.js → chunk-HL3IIHU3.js} +501 -359
- package/dist/chunk-HL3IIHU3.js.map +7 -0
- package/dist/{chunk-REJ5HLIM.js → chunk-HYJYYEPK.js} +2 -2
- package/dist/{chunk-7U4IB65H.js → chunk-LLBJ2SJH.js} +5 -3
- package/dist/{chunk-7U4IB65H.js.map → chunk-LLBJ2SJH.js.map} +1 -1
- package/dist/{chunk-XJNADRGR.js → chunk-NFZJ6P42.js} +188 -96
- package/dist/chunk-NFZJ6P42.js.map +7 -0
- package/dist/chunk-PAI2ZUWV.js +260 -0
- package/dist/chunk-PAI2ZUWV.js.map +7 -0
- package/dist/chunk-Q7BALEPX.js +527 -0
- package/dist/chunk-Q7BALEPX.js.map +7 -0
- package/dist/{chunk-2Z2ENHR3.js → chunk-QRBOIUU3.js} +7 -8
- package/dist/chunk-QRBOIUU3.js.map +7 -0
- package/dist/chunk-QXAOTGCC.js +40 -0
- package/dist/chunk-QXAOTGCC.js.map +7 -0
- package/dist/chunk-UKP22RUG.js +219 -0
- package/dist/chunk-UKP22RUG.js.map +7 -0
- package/dist/chunk-VLOAI3HS.js +43 -0
- package/dist/chunk-VLOAI3HS.js.map +7 -0
- package/dist/{chunk-WUFLGETN.js → chunk-WVFGV5FT.js} +2 -39
- package/dist/{chunk-WUFLGETN.js.map → chunk-WVFGV5FT.js.map} +3 -3
- package/dist/editor.js +3 -2
- package/dist/editor.js.map +1 -1
- package/dist/flexdesk.css +7547 -5891
- package/dist/flow.js +29 -97
- package/dist/flow.js.map +3 -3
- package/dist/git.js +1356 -0
- package/dist/git.js.map +7 -0
- package/dist/reset.css +1 -1
- package/dist/tiles.js +20 -15
- package/dist/tiles.js.map +2 -2
- package/dist/tokens.css +30 -1
- package/dist/widgets.js +4146 -1199
- package/dist/widgets.js.map +4 -4
- package/dist/wm.js +120 -32
- package/dist/wm.js.map +2 -2
- package/docs/README.md +9 -4
- package/docs/best-practices.md +2 -2
- package/docs/components/charts.md +40 -0
- package/docs/components/chat-shell.md +204 -0
- package/docs/components/conversation.md +10 -0
- package/docs/components/filter-builder.md +292 -0
- package/docs/components/forms-and-dialogs.md +30 -0
- package/docs/components/git.md +355 -0
- package/docs/components/input-fields.md +191 -2
- package/docs/components/keymap.md +8 -0
- package/docs/components/menus.md +86 -2
- package/docs/components/panels-and-desktops.md +16 -5
- package/docs/components/shell.md +108 -1
- package/docs/components/workspace-picker.md +193 -0
- package/docs/concepts/building-and-packaging.md +2 -2
- package/docs/concepts/packages-and-entry-points.md +14 -13
- package/docs/concepts/state-guard.md +3 -3
- package/docs/quickstart.md +1 -1
- package/git.js +13 -0
- package/package.json +6 -1
- package/src/charts/plot_config_editor.js +92 -6
- package/src/charts/plot_grid.js +6 -1
- package/src/flow/kit/dom.js +28 -119
- package/src/flow/kit/step_picker.js +1 -1
- package/src/flow/kit/value_picker.js +1 -1
- package/src/flow/outline/menu.js +1 -1
- package/src/git/diff_viewer.js +212 -0
- package/src/git/diff_window.js +51 -0
- package/src/git/git_graph.js +401 -0
- package/src/git/git_ops.js +61 -0
- package/src/git/git_panel.js +755 -0
- package/src/git/index.js +9 -0
- package/src/git/line_diff.js +202 -0
- package/src/tiles/tile_base.js +5 -4
- package/src/tiling/desktops.js +10 -8
- package/src/tiling/keymap.js +6 -5
- package/src/tiling/kind_taxonomy.js +15 -2
- package/src/tiling/shell.js +124 -17
- package/src/tiling/tile_renderer.js +2 -3
- package/src/ui/components/action_dropdown.js +4 -78
- package/src/ui/components/anchored_layer.js +255 -0
- package/src/ui/components/chat_shell.js +926 -0
- package/src/ui/components/chat_state_machine.js +223 -0
- package/src/ui/components/choice_controls.js +45 -2
- package/src/ui/components/color_field.js +3 -2
- package/src/ui/components/color_list_field.js +3 -2
- package/src/ui/components/combobox.js +422 -0
- package/src/ui/components/context_menu.js +8 -2
- package/src/ui/components/conversation_view.js +17 -2
- package/src/ui/components/data_table.js +2 -2
- package/src/ui/components/filter_builder.js +791 -0
- package/src/ui/components/filter_model.js +682 -0
- package/src/ui/components/inline_renamer.js +10 -5
- package/src/ui/components/managed_window.js +24 -47
- package/src/ui/components/modal.js +104 -117
- package/src/ui/components/option_list.js +160 -0
- package/src/ui/components/popover.js +94 -0
- package/src/ui/components/recent_items.js +169 -0
- package/src/ui/components/select.js +322 -0
- package/src/ui/components/tag_input.js +77 -8
- package/src/ui/components/workspace_picker.js +1253 -0
- package/src/ui/utils/fire_event.js +17 -0
- package/src/ui/utils/focusable.js +25 -0
- package/src/ui/utils/modal_host.js +27 -0
- package/src/ui/utils/tooltip_service.js +81 -14
- package/widgets.js +12 -2
- package/css/base.css +0 -10547
- package/css/overrides.css +0 -2596
- package/dist/chunk-2Z2ENHR3.js.map +0 -7
- package/dist/chunk-7JI7HHSY.js +0 -107
- package/dist/chunk-7JI7HHSY.js.map +0 -7
- package/dist/chunk-7OL6F2AM.js.map +0 -7
- package/dist/chunk-MIW7D6WC.js +0 -526
- package/dist/chunk-MIW7D6WC.js.map +0 -7
- package/dist/chunk-PGZ4EHSE.js.map +0 -7
- package/dist/chunk-XJNADRGR.js.map +0 -7
- /package/dist/{chunk-REJ5HLIM.js.map → chunk-HYJYYEPK.js.map} +0 -0
|
@@ -32,11 +32,12 @@ export function attachInlineRenamer(options = {}) {
|
|
|
32
32
|
let originalText = label.textContent || '';
|
|
33
33
|
|
|
34
34
|
const cleanup = () => {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
// `input` goes first: taking a focused field off the page fires its blur,
|
|
36
|
+
// and a blur with the field still held would commit an edit that just ended.
|
|
37
|
+
const gone = input;
|
|
38
38
|
input = null;
|
|
39
39
|
editing = false;
|
|
40
|
+
if (gone && gone.parentNode === label) gone.remove();
|
|
40
41
|
};
|
|
41
42
|
|
|
42
43
|
const cancel = () => {
|
|
@@ -68,8 +69,8 @@ export function attachInlineRenamer(options = {}) {
|
|
|
68
69
|
return;
|
|
69
70
|
}
|
|
70
71
|
const finalValue = result.newValue ?? next ?? '';
|
|
71
|
-
label.textContent = finalValue;
|
|
72
72
|
cleanup();
|
|
73
|
+
label.textContent = finalValue;
|
|
73
74
|
options.onFinish?.({ value: finalValue });
|
|
74
75
|
} catch (err) {
|
|
75
76
|
window.logger?.warn('inline-renamer', 'onCommit failed', err);
|
|
@@ -89,6 +90,11 @@ export function attachInlineRenamer(options = {}) {
|
|
|
89
90
|
input.addEventListener('mousedown', stopEvents);
|
|
90
91
|
input.addEventListener('click', stopEvents);
|
|
91
92
|
|
|
93
|
+
// Before the renamer's own keys, so that what it attaches to the input
|
|
94
|
+
// hears them first: `attachInputOptions` takes Enter on a highlighted row
|
|
95
|
+
// and Esc with its list open, and the renamer never sees either.
|
|
96
|
+
options.onStart?.({ value: originalText, triggerEvent, input });
|
|
97
|
+
|
|
92
98
|
input.addEventListener('keydown', (ev) => {
|
|
93
99
|
if (ev.key === 'Enter') {
|
|
94
100
|
ev.preventDefault();
|
|
@@ -101,7 +107,6 @@ export function attachInlineRenamer(options = {}) {
|
|
|
101
107
|
|
|
102
108
|
input.addEventListener('blur', () => commit('blur'));
|
|
103
109
|
|
|
104
|
-
options.onStart?.({ value: originalText, triggerEvent });
|
|
105
110
|
setTimeout(() => {
|
|
106
111
|
input?.focus();
|
|
107
112
|
input?.select?.();
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { getSetting } from '../../core/settings.js';
|
|
21
|
+
import { isInAnchoredLayer } from './anchored_layer.js';
|
|
22
|
+
import { collectFocusable } from '../utils/focusable.js';
|
|
21
23
|
|
|
22
24
|
/** Where window geometry lives when the embedder names no key. An embedder
|
|
23
25
|
* that saved geometry under another key keeps it by passing that key to
|
|
@@ -70,29 +72,6 @@ let _activeWindows = new Map(); // id -> ManagedWindow instance
|
|
|
70
72
|
* the same event reaches it — one Esc must close one window. */
|
|
71
73
|
const _escAnswered = new WeakSet();
|
|
72
74
|
|
|
73
|
-
/** Collect the focusable elements inside `root`, in DOM order. Used
|
|
74
|
-
* by the modal focus trap so Tab cycles within the modal frame and
|
|
75
|
-
* can't escape into the underlying tiles. */
|
|
76
|
-
function _collectFocusable(root) {
|
|
77
|
-
if (!root) return [];
|
|
78
|
-
const sel = [
|
|
79
|
-
'a[href]',
|
|
80
|
-
'button:not([disabled])',
|
|
81
|
-
'input:not([disabled]):not([type="hidden"])',
|
|
82
|
-
'select:not([disabled])',
|
|
83
|
-
'textarea:not([disabled])',
|
|
84
|
-
'[tabindex]:not([tabindex="-1"])',
|
|
85
|
-
].join(',');
|
|
86
|
-
return Array.from(root.querySelectorAll(sel)).filter((el) => {
|
|
87
|
-
// Skip hidden / zero-size elements — Tab visits only what the
|
|
88
|
-
// user can actually see.
|
|
89
|
-
if (el.hidden) return false;
|
|
90
|
-
if (el.closest('[hidden]')) return false;
|
|
91
|
-
const r = el.getBoundingClientRect();
|
|
92
|
-
return r.width > 0 || r.height > 0;
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
|
|
96
75
|
// Global state cache
|
|
97
76
|
let _stateCache = null;
|
|
98
77
|
|
|
@@ -548,12 +527,17 @@ export class ManagedWindow {
|
|
|
548
527
|
// taskbar can filter on receipt: an in-panel taskbar shows only the
|
|
549
528
|
// windows mounted in its own panel, and the viewport taskbar shows only
|
|
550
529
|
// the ones with no container.
|
|
551
|
-
|
|
552
|
-
detail: { id: this.id, container: this.container }
|
|
553
|
-
}));
|
|
530
|
+
this._announce('managed-window-closed', { id: this.id, container: this.container });
|
|
554
531
|
this._release();
|
|
555
532
|
}
|
|
556
533
|
|
|
534
|
+
/** Every window event goes out on the page's window, and a window takes only
|
|
535
|
+
* an event built by its own `CustomEvent`: Node's global one is refused
|
|
536
|
+
* under jsdom, in the middle of the close that fired it. */
|
|
537
|
+
_announce(name, detail) {
|
|
538
|
+
window.dispatchEvent(new window.CustomEvent(name, { detail }));
|
|
539
|
+
}
|
|
540
|
+
|
|
557
541
|
/** BUG-0053. A closed window leaves the registry and lets go of what it
|
|
558
542
|
* held: its content, its DOM and every consumer callback (an `onClose`
|
|
559
543
|
* typically captures its owner). Until now the registry kept all of it
|
|
@@ -607,12 +591,10 @@ export class ManagedWindow {
|
|
|
607
591
|
}
|
|
608
592
|
|
|
609
593
|
// Dispatch event first so the taskbar button is created synchronously
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
}
|
|
615
|
-
}));
|
|
594
|
+
this._announce('managed-window-minimized', {
|
|
595
|
+
id: this.id, title: this.title, icon: this.icon,
|
|
596
|
+
container: this.container,
|
|
597
|
+
});
|
|
616
598
|
|
|
617
599
|
if (!this.element) return;
|
|
618
600
|
|
|
@@ -744,9 +726,7 @@ export class ManagedWindow {
|
|
|
744
726
|
this.bringToFront();
|
|
745
727
|
|
|
746
728
|
// Notify taskbar (removes the taskbar button)
|
|
747
|
-
|
|
748
|
-
detail: { id: this.id, container: this.container }
|
|
749
|
-
}));
|
|
729
|
+
this._announce('managed-window-restored', { id: this.id, container: this.container });
|
|
750
730
|
}
|
|
751
731
|
|
|
752
732
|
/**
|
|
@@ -825,7 +805,7 @@ export class ManagedWindow {
|
|
|
825
805
|
|
|
826
806
|
// A CLASS THE STYLESHEET HAS ALWAYS KNOWN AND NOTHING EVER SET.
|
|
827
807
|
// `.twm-managed-window--maximized .twm-managed-window__resize
|
|
828
|
-
// { display: none }` is in `css/
|
|
808
|
+
// { display: none }` is in `css/flexdesk.css` — "a maximised window is not
|
|
829
809
|
// resized by dragging its edges" — and it has never matched anything,
|
|
830
810
|
// because no code path adds the modifier. So a maximised window keeps
|
|
831
811
|
// eight live resize handles hanging 3px outside its edges. Exactly the
|
|
@@ -841,9 +821,7 @@ export class ManagedWindow {
|
|
|
841
821
|
// tiles — had no edge to hang the rule on and would have had to poll.
|
|
842
822
|
// Same detail shape as the other three window events, `container`
|
|
843
823
|
// included, so a per-region consumer can filter on receipt.
|
|
844
|
-
|
|
845
|
-
detail: { id: this.id, maximized: this.isMaximized, container: this.container },
|
|
846
|
-
}));
|
|
824
|
+
this._announce('managed-window-maximized', { id: this.id, maximized: this.isMaximized, container: this.container });
|
|
847
825
|
}
|
|
848
826
|
|
|
849
827
|
/**
|
|
@@ -965,7 +943,7 @@ export class ManagedWindow {
|
|
|
965
943
|
}
|
|
966
944
|
this.element.setAttribute('data-window-id', this.id);
|
|
967
945
|
// BUG-0053. Focusable by script and by a press, never by Tab (the
|
|
968
|
-
// modal trap's `
|
|
946
|
+
// modal trap's `collectFocusable` skips tabindex -1): a press on the
|
|
969
947
|
// title bar or on empty content keeps focus in the window, where a
|
|
970
948
|
// non-modal window's Esc looks for it.
|
|
971
949
|
this.element.tabIndex = -1;
|
|
@@ -1274,9 +1252,7 @@ export class ManagedWindow {
|
|
|
1274
1252
|
this.width = bounds.width; this.height = bounds.height;
|
|
1275
1253
|
}
|
|
1276
1254
|
this._applyPosition();
|
|
1277
|
-
|
|
1278
|
-
detail: { id: this.id, container },
|
|
1279
|
-
}));
|
|
1255
|
+
this._announce('managed-window-moved', { id: this.id, container });
|
|
1280
1256
|
return true;
|
|
1281
1257
|
}
|
|
1282
1258
|
|
|
@@ -1736,9 +1712,7 @@ export class ManagedWindow {
|
|
|
1736
1712
|
this.height = rect.height;
|
|
1737
1713
|
this._applyPosition();
|
|
1738
1714
|
this.element?.classList.add('twm-managed-window--snapped');
|
|
1739
|
-
|
|
1740
|
-
detail: { id: this.id, zone, container: this.container },
|
|
1741
|
-
}));
|
|
1715
|
+
this._announce('managed-window-snapped', { id: this.id, zone, container: this.container });
|
|
1742
1716
|
}
|
|
1743
1717
|
|
|
1744
1718
|
/** Restore the geometry a snap replaced. Called when a snapped window is
|
|
@@ -1850,7 +1824,10 @@ export class ManagedWindow {
|
|
|
1850
1824
|
// active element into the background — bad UX, and a
|
|
1851
1825
|
// genuine WCAG focus-trap violation for accessibility tools.
|
|
1852
1826
|
if (e.key === 'Tab' && this.modal && this.element) {
|
|
1853
|
-
|
|
1827
|
+
// A popover opened from this modal is over it but outside its frame,
|
|
1828
|
+
// and sees to its own Tab.
|
|
1829
|
+
if (isInAnchoredLayer(document.activeElement)) return;
|
|
1830
|
+
const focusables = collectFocusable(this.element);
|
|
1854
1831
|
if (focusables.length === 0) {
|
|
1855
1832
|
e.preventDefault();
|
|
1856
1833
|
return;
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
* }
|
|
78
78
|
*
|
|
79
79
|
* Dependent-property pick-or-create — a `select` field that carries a
|
|
80
|
-
* `create` spec renders as a
|
|
80
|
+
* `create` spec renders as a `createCombobox` instead of a
|
|
81
81
|
* dropdown: the user picks an existing option OR types a new value.
|
|
82
82
|
* On submit, a value that isn't an existing option is created via
|
|
83
83
|
* `create.onCreate(value)` before the form resolves:
|
|
@@ -115,29 +115,11 @@ import { ToggleField } from '../../ui/components/toggle_field.js';
|
|
|
115
115
|
import { ColorField } from './color_field.js';
|
|
116
116
|
import { ColorListField } from './color_list_field.js';
|
|
117
117
|
import { choiceList, findChoice, flatChoices, selectBox, toggleChips } from './choice_controls.js';
|
|
118
|
+
import { createCombobox } from './combobox.js';
|
|
119
|
+
import { modalHost, setModalHost } from '../utils/modal_host.js';
|
|
120
|
+
import { fireEvent } from '../utils/fire_event.js';
|
|
118
121
|
|
|
119
|
-
|
|
120
|
-
* C25. WHERE A DIALOG OPENS, when the application spans more than one window.
|
|
121
|
-
*
|
|
122
|
-
* Every modal here is a `ManagedWindow` with no `container`, so it mounts into
|
|
123
|
-
* `document.body` — and "document" is the document this MODULE was loaded in.
|
|
124
|
-
* A consumer that opens a second browser window with `window.open` and builds
|
|
125
|
-
* its DOM from the opener's realm (which is how a pop-out grid works: same
|
|
126
|
-
* modules, same JS context, a different document) therefore gets its dialogs in
|
|
127
|
-
* the window it popped OUT of. The user presses "Add column" on their second
|
|
128
|
-
* monitor and a dialog appears on the first, behind whatever is there.
|
|
129
|
-
*
|
|
130
|
-
* Passing a container at each call site was the other option and it is worse:
|
|
131
|
-
* twenty-one of them across eleven files, every one of which would have to be
|
|
132
|
-
* given a document it has no other reason to know about, and any one missed is
|
|
133
|
-
* this bug again with no way to see it from here.
|
|
134
|
-
*
|
|
135
|
-
* So the HOST is ambient and the consumer sets it when its focus moves. Null —
|
|
136
|
-
* every consumer today — means `document.body`, exactly as before.
|
|
137
|
-
*/
|
|
138
|
-
let _modalHost = null;
|
|
139
|
-
export function setModalHost(el) { _modalHost = el || null; }
|
|
140
|
-
export function modalHost() { return _modalHost; }
|
|
122
|
+
export { setModalHost, modalHost };
|
|
141
123
|
|
|
142
124
|
let _modalSeq = 1;
|
|
143
125
|
|
|
@@ -198,7 +180,7 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
|
|
|
198
180
|
`;
|
|
199
181
|
|
|
200
182
|
const win = new ManagedWindow({
|
|
201
|
-
container:
|
|
183
|
+
container: modalHost(),
|
|
202
184
|
id: `twm-modal-${_modalSeq++}`,
|
|
203
185
|
title: title || 'Dialog',
|
|
204
186
|
icon: 'edit_note',
|
|
@@ -237,12 +219,7 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
|
|
|
237
219
|
// The options each field offers NOW — `setOptions` replaces them, the
|
|
238
220
|
// caller's field object is never written to.
|
|
239
221
|
const optionsOf = new Map(formFields.map((f) => [f.name, f.options]));
|
|
240
|
-
const comboHints = new Map();
|
|
241
222
|
for (const f of formFields) {
|
|
242
|
-
if (f.type === 'select' && f.create) {
|
|
243
|
-
const update = _wireComboboxHint(body, f, () => optionsOf.get(f.name));
|
|
244
|
-
if (update) comboHints.set(f.name, update);
|
|
245
|
-
}
|
|
246
223
|
if (f.type === 'picker') _wirePicker(body, f);
|
|
247
224
|
}
|
|
248
225
|
const rowHidden = (name) => !!body.querySelector(`[data-field-row="${name}"]`)?.hidden;
|
|
@@ -281,13 +258,7 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
|
|
|
281
258
|
* read a combobox through this, so they agree (`'2'` is option `2`). */
|
|
282
259
|
const comboOption = (f, text) => findChoice(flatChoices(optionsOf.get(f.name)), text, true);
|
|
283
260
|
/** A disabled option's reason ('' with none) when one is the value, else null. */
|
|
284
|
-
const unavailable = (f
|
|
285
|
-
const c = controls.get(f.name);
|
|
286
|
-
if (c) return c.unavailable();
|
|
287
|
-
if (f.type !== 'select' || !f.create || value == null || value === '') return null;
|
|
288
|
-
const o = comboOption(f, value);
|
|
289
|
-
return o?.disabled ? (o.reason || '') : null;
|
|
290
|
-
};
|
|
261
|
+
const unavailable = (f) => controls.get(f.name)?.unavailable() ?? null;
|
|
291
262
|
|
|
292
263
|
// Clear per-field errors on input so the user sees the row recover.
|
|
293
264
|
for (const f of formFields) {
|
|
@@ -337,11 +308,6 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
|
|
|
337
308
|
const el = body.querySelector(`[name="${name}"]`);
|
|
338
309
|
if (!el) return undefined;
|
|
339
310
|
if (el.type === 'checkbox') return !!el.checked;
|
|
340
|
-
const f = fieldOf(name);
|
|
341
|
-
if (f?.type === 'select' && f.create) {
|
|
342
|
-
const match = comboOption(f, el.value);
|
|
343
|
-
return match ? match.value : el.value;
|
|
344
|
-
}
|
|
345
311
|
return el.value;
|
|
346
312
|
},
|
|
347
313
|
set(name, value) {
|
|
@@ -350,29 +316,17 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
|
|
|
350
316
|
if (!el) return;
|
|
351
317
|
if (el.type === 'checkbox') { el.checked = !!value; return; }
|
|
352
318
|
const f = fieldOf(name);
|
|
353
|
-
|
|
354
|
-
const shut = f?.type === 'select' && f.create && comboOption(f, value)?.disabled;
|
|
355
|
-
el.value = value == null || shut ? '' : String(value);
|
|
356
|
-
comboHints.get(name)?.();
|
|
319
|
+
el.value = value == null ? '' : String(value);
|
|
357
320
|
if (f?.type === 'picker') {
|
|
358
321
|
const display = body.querySelector(`[data-picker-display="${name}"]`);
|
|
359
322
|
if (display) display.value = _pickerLabel(f, el.value);
|
|
360
323
|
}
|
|
361
324
|
},
|
|
362
325
|
setOptions(name, options) {
|
|
363
|
-
const f = fieldOf(name);
|
|
364
|
-
if (!f) return;
|
|
365
326
|
const c = controls.get(name);
|
|
366
|
-
if (c?.setOptions)
|
|
367
|
-
optionsOf.set(name, options);
|
|
368
|
-
c.setOptions(options);
|
|
369
|
-
return;
|
|
370
|
-
}
|
|
371
|
-
if (f.type !== 'select' || !f.create) return;
|
|
327
|
+
if (!c?.setOptions) return;
|
|
372
328
|
optionsOf.set(name, options);
|
|
373
|
-
|
|
374
|
-
if (list) list.innerHTML = _datalistOptions(options);
|
|
375
|
-
comboHints.get(name)?.();
|
|
329
|
+
c.setOptions(options);
|
|
376
330
|
},
|
|
377
331
|
};
|
|
378
332
|
const fire = (name) => {
|
|
@@ -444,7 +398,7 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
|
|
|
444
398
|
if (rowHidden(f.name)) {
|
|
445
399
|
// Not asked about — but a disabled option is still never
|
|
446
400
|
// the value: a hidden row holding one submits no value.
|
|
447
|
-
if (unavailable(f
|
|
401
|
+
if (unavailable(f) != null) data[f.name] = f.type === 'radio' ? null : '';
|
|
448
402
|
continue;
|
|
449
403
|
}
|
|
450
404
|
const v = data[f.name];
|
|
@@ -454,7 +408,7 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
|
|
|
454
408
|
let msg = f.required && _missing(f, v) ? 'Required.' : null;
|
|
455
409
|
if (!msg && _outOfRange(f, v)) msg = _rangeMessage(f);
|
|
456
410
|
if (!msg) {
|
|
457
|
-
const reason = unavailable(f
|
|
411
|
+
const reason = unavailable(f);
|
|
458
412
|
if (reason != null) msg = reason ? `Not available: ${reason}` : 'Not available.';
|
|
459
413
|
}
|
|
460
414
|
if (!msg) msg = controls.get(f.name)?.error?.() ?? null;
|
|
@@ -490,7 +444,7 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
|
|
|
490
444
|
|
|
491
445
|
/**
|
|
492
446
|
* `openConfirm` — a yes/no modal. Resolves `true` on confirm, `false` on
|
|
493
|
-
* cancel, Esc
|
|
447
|
+
* cancel, Esc or X.
|
|
494
448
|
*
|
|
495
449
|
* `message`, `confirmLabel` and `cancelLabel` are TEXT, never markup: callers
|
|
496
450
|
* put user-named things in them ("Delete flow "<name>"?"), so they are set
|
|
@@ -527,7 +481,7 @@ export function openConfirm({
|
|
|
527
481
|
|
|
528
482
|
let _resolved = false;
|
|
529
483
|
const win = new ManagedWindow({
|
|
530
|
-
container:
|
|
484
|
+
container: modalHost(),
|
|
531
485
|
id: `twm-confirm-${_modalSeq++}`,
|
|
532
486
|
title: title || 'Confirm',
|
|
533
487
|
icon: icon || (danger ? 'warning' : 'help'),
|
|
@@ -577,7 +531,8 @@ export function openConfirm({
|
|
|
577
531
|
* omitted, defaults to a single `[Close]` action that
|
|
578
532
|
* resolves with `null`. The first action with
|
|
579
533
|
* `primary: true` is the rightmost; otherwise the
|
|
580
|
-
* last action wins. Esc / X
|
|
534
|
+
* last action wins. Esc / X (and, with
|
|
535
|
+
* `dismissOnBackdrop`, a click on the backdrop) resolve with
|
|
581
536
|
* `null` regardless of what's in `actions`. `icon` is
|
|
582
537
|
* an optional material-symbols-outlined ligature drawn
|
|
583
538
|
* BEFORE the label; when it is given the label lives in
|
|
@@ -601,6 +556,12 @@ export function openConfirm({
|
|
|
601
556
|
* existing caller has always got.
|
|
602
557
|
* - `onMaximizeChange` `(maximized, contentEl) => void`, called on each
|
|
603
558
|
* flip. Only useful with `maximizable`.
|
|
559
|
+
* - `beforeClose` `() => boolean | Promise<boolean>`: asked when Esc or
|
|
560
|
+
* the X would dismiss; answering false keeps the modal open. An
|
|
561
|
+
* action button does not ask it.
|
|
562
|
+
* - `dismissOnBackdrop` a click on the dimmed page beside the modal dismisses
|
|
563
|
+
* it, as Esc does, so `beforeClose` is asked. Default `false`:
|
|
564
|
+
* the backdrop only blocks.
|
|
604
565
|
*
|
|
605
566
|
* ══ C29. WHY A DIALOG'S MAXIMISE IS THE GEOMETRIC ONE ═══════════════════
|
|
606
567
|
*
|
|
@@ -642,6 +603,8 @@ export function openModal({
|
|
|
642
603
|
backdropOpacity = undefined,
|
|
643
604
|
maximizable = false,
|
|
644
605
|
onMaximizeChange = null,
|
|
606
|
+
beforeClose = null,
|
|
607
|
+
dismissOnBackdrop = false,
|
|
645
608
|
} = {}) {
|
|
646
609
|
return new Promise((resolve) => {
|
|
647
610
|
const body = document.createElement('div');
|
|
@@ -652,8 +615,8 @@ export function openModal({
|
|
|
652
615
|
if (content instanceof HTMLElement) bodyInner.appendChild(content);
|
|
653
616
|
body.appendChild(bodyInner);
|
|
654
617
|
|
|
655
|
-
// Actions row — default = single Close button. Esc
|
|
656
|
-
//
|
|
618
|
+
// Actions row — default = single Close button. Esc and X (and an
|
|
619
|
+
// opted-in backdrop) bypass this and resolve with null.
|
|
657
620
|
const acts = Array.isArray(actions) && actions.length > 0
|
|
658
621
|
? actions
|
|
659
622
|
: [{ label: 'Close', value: null }];
|
|
@@ -726,7 +689,7 @@ export function openModal({
|
|
|
726
689
|
_onMax = null;
|
|
727
690
|
};
|
|
728
691
|
const win = new ManagedWindow({
|
|
729
|
-
container:
|
|
692
|
+
container: modalHost(),
|
|
730
693
|
id: `twm-modal-${_modalSeq++}`,
|
|
731
694
|
title,
|
|
732
695
|
icon,
|
|
@@ -747,16 +710,25 @@ export function openModal({
|
|
|
747
710
|
canDrag: true,
|
|
748
711
|
defaultWidth: width,
|
|
749
712
|
defaultHeight: height,
|
|
750
|
-
|
|
751
|
-
//
|
|
752
|
-
//
|
|
753
|
-
//
|
|
713
|
+
beforeClose,
|
|
714
|
+
// Esc, the X and an opted-in backdrop all land here without passing
|
|
715
|
+
// through `close`, so the listener is dropped here as well as there —
|
|
716
|
+
// the dismissals a user reaches for most are exactly the ones that
|
|
717
|
+
// would otherwise leak it.
|
|
754
718
|
onClose: () => {
|
|
755
719
|
_dropMaxListener();
|
|
756
720
|
if (!_resolved) { _resolved = true; resolve(null); }
|
|
757
721
|
},
|
|
758
722
|
});
|
|
759
723
|
win.show();
|
|
724
|
+
// A click, not a press: a drag that starts in the window and ends on the
|
|
725
|
+
// page (selecting text, resizing) has no click on the backdrop.
|
|
726
|
+
// The press takes no focus from the window: `beforeClose` may refuse, and
|
|
727
|
+
// the person goes on typing where they were.
|
|
728
|
+
if (dismissOnBackdrop) {
|
|
729
|
+
win.backdropElement.addEventListener('mousedown', (e) => e.preventDefault());
|
|
730
|
+
win.backdropElement.addEventListener('click', () => win.close());
|
|
731
|
+
}
|
|
760
732
|
|
|
761
733
|
// C29. THE STATE, FOR A CALLER WHOSE LAYOUT DEPENDS ON IT.
|
|
762
734
|
//
|
|
@@ -859,7 +831,11 @@ export function openModal({
|
|
|
859
831
|
// whose first control is a read-only statement panel (C27's disabled
|
|
860
832
|
// primary is the sibling case) would swallow the caret into a box that
|
|
861
833
|
// cannot take it.
|
|
834
|
+
//
|
|
835
|
+
// AN `onMount` THAT PLACED FOCUS KEEPS IT, with its selection: it ran in
|
|
836
|
+
// the frame before this one, and it knows the field better than "the first".
|
|
862
837
|
requestAnimationFrame(() => {
|
|
838
|
+
if (bodyInner.contains(document.activeElement)) return;
|
|
863
839
|
const field = bodyInner.querySelector(
|
|
864
840
|
'input:not([type="hidden"]):not([disabled]):not([readonly]),'
|
|
865
841
|
+ ' textarea:not([disabled]):not([readonly]),'
|
|
@@ -953,14 +929,68 @@ function _offered(options) {
|
|
|
953
929
|
return flatChoices(options).filter((o) => !o.disabled);
|
|
954
930
|
}
|
|
955
931
|
|
|
956
|
-
|
|
957
|
-
|
|
932
|
+
/**
|
|
933
|
+
* A `select` with `create`: `createCombobox` over the enabled options. The box holds
|
|
934
|
+
* the text; `read` is the value of the option that text names, else the text. A
|
|
935
|
+
* disabled option is not offered, and a default or `write` naming one leaves the box
|
|
936
|
+
* empty; typed all the same, it is `unavailable`.
|
|
937
|
+
*/
|
|
938
|
+
function _mountCombobox(slot, f, value) {
|
|
939
|
+
let options = f.options;
|
|
940
|
+
const named = (text) => findChoice(flatChoices(options), text, true);
|
|
941
|
+
const shown = (v) => (v == null || named(v)?.disabled ? '' : String(v));
|
|
942
|
+
|
|
943
|
+
const hint = document.createElement('span');
|
|
944
|
+
hint.className = 'twm-combobox__hint';
|
|
945
|
+
hint.hidden = true;
|
|
946
|
+
// With `create: true` the typed value simply IS the value — there is no
|
|
947
|
+
// entity being made, so promising to "create" one is a lie that reads as
|
|
948
|
+
// a warning.
|
|
949
|
+
const noun = f.create === true ? null : _createSpec(f).hint || 'new entry';
|
|
950
|
+
const updateHint = () => {
|
|
951
|
+
const v = c.value.trim();
|
|
952
|
+
const isNew = noun && v && !flatChoices(options).some((o) => String(o.value) === v);
|
|
953
|
+
if (isNew) hint.textContent = `↳ will create ${noun} “${v}”`;
|
|
954
|
+
hint.hidden = !isNew;
|
|
955
|
+
};
|
|
956
|
+
|
|
957
|
+
const c = createCombobox({
|
|
958
|
+
options: _offered(options),
|
|
959
|
+
value: shown(value),
|
|
960
|
+
name: f.name,
|
|
961
|
+
placeholder: f.placeholder ?? 'type to pick or create…',
|
|
962
|
+
// Enter keeps what was typed: this field exists to accept a value nobody listed.
|
|
963
|
+
autoHighlight: false,
|
|
964
|
+
onChange: () => updateHint(),
|
|
965
|
+
});
|
|
966
|
+
if (f.required) c.input.required = true;
|
|
967
|
+
c.el.appendChild(hint);
|
|
968
|
+
slot.replaceWith(c.el);
|
|
969
|
+
updateHint();
|
|
970
|
+
return {
|
|
971
|
+
read() {
|
|
972
|
+
const o = named(c.value);
|
|
973
|
+
return o ? o.value : c.value;
|
|
974
|
+
},
|
|
975
|
+
write(v) { c.value = shown(v); updateHint(); },
|
|
976
|
+
focus: c.focus,
|
|
977
|
+
unavailable() {
|
|
978
|
+
const o = c.value === '' ? undefined : named(c.value);
|
|
979
|
+
return o?.disabled ? (o.reason || '') : null;
|
|
980
|
+
},
|
|
981
|
+
setOptions(next) {
|
|
982
|
+
options = next;
|
|
983
|
+
c.setOptions(_offered(next));
|
|
984
|
+
updateHint();
|
|
985
|
+
},
|
|
986
|
+
dispose: c.destroy,
|
|
987
|
+
};
|
|
958
988
|
}
|
|
959
989
|
|
|
960
990
|
/**
|
|
961
991
|
* Mount a field that is a component rather than markup — a `toggle`, a
|
|
962
|
-
* `select
|
|
963
|
-
* `
|
|
992
|
+
* `select`, `checklist` or `radio` drawn by `choice_controls.js`, a `select`
|
|
993
|
+
* with `create` drawn by `createCombobox`, or a `color` / `colorList` — into the
|
|
964
994
|
* `data-control-slot` its row left, and hand back what the form needs of it:
|
|
965
995
|
* `{ read(), write(v), focus(), unavailable(), submit?(), error?(),
|
|
966
996
|
* setOptions?(options), dispose?() }`. `unavailable()` is the reason (or '')
|
|
@@ -1018,6 +1048,7 @@ function _mountControl(host, f, value, onChange) {
|
|
|
1018
1048
|
dispose: () => c.dispose(),
|
|
1019
1049
|
};
|
|
1020
1050
|
}
|
|
1051
|
+
if (f.type === 'select' && f.create) return _mountCombobox(slot, f, value);
|
|
1021
1052
|
const common = { keepMissing: false, loose: true };
|
|
1022
1053
|
const disabledReason = (entry) => (entry?.disabled ? (entry.reason || '') : null);
|
|
1023
1054
|
if (f.type === 'select') {
|
|
@@ -1113,7 +1144,7 @@ function _wirePicker(host, f) {
|
|
|
1113
1144
|
? String(picked.label) : _pickerLabel(f, hidden.value);
|
|
1114
1145
|
// A hidden input fires no `change` of its own; this is the one the
|
|
1115
1146
|
// field-error clear and `onFieldChange` listen for.
|
|
1116
|
-
hidden
|
|
1147
|
+
fireEvent(hidden, 'change', { bubbles: true });
|
|
1117
1148
|
};
|
|
1118
1149
|
btn.addEventListener('click', open);
|
|
1119
1150
|
// The read-only box is the obvious thing to click; make it work too.
|
|
@@ -1121,33 +1152,6 @@ function _wirePicker(host, f) {
|
|
|
1121
1152
|
}
|
|
1122
1153
|
|
|
1123
1154
|
|
|
1124
|
-
/** Show / hide the "↳ will create …" hint as the user types into a
|
|
1125
|
-
* create-capable combobox. */
|
|
1126
|
-
function _wireComboboxHint(host, f, optionsOf) {
|
|
1127
|
-
const input = host.querySelector(`input[name="${f.name}"]`);
|
|
1128
|
-
const hintEl = host.querySelector(`.twm-combobox__hint[data-for="${f.name}"]`);
|
|
1129
|
-
if (!input || !hintEl) return;
|
|
1130
|
-
// With `create: true` the typed value simply IS the value — there is no
|
|
1131
|
-
// entity being made, so promising to "create" one is a lie that reads as
|
|
1132
|
-
// a warning.
|
|
1133
|
-
if (f.create === true) return;
|
|
1134
|
-
const label = _createSpec(f).hint || 'new entry';
|
|
1135
|
-
const update = () => {
|
|
1136
|
-
const v = input.value.trim();
|
|
1137
|
-
const known = new Set(flatChoices(optionsOf()).map((o) => String(o.value)));
|
|
1138
|
-
if (v && !known.has(v)) {
|
|
1139
|
-
hintEl.textContent = `↳ will create ${label} “${v}”`;
|
|
1140
|
-
hintEl.hidden = false;
|
|
1141
|
-
} else {
|
|
1142
|
-
hintEl.hidden = true;
|
|
1143
|
-
}
|
|
1144
|
-
};
|
|
1145
|
-
input.addEventListener('input', update);
|
|
1146
|
-
update();
|
|
1147
|
-
return update;
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
1155
|
function _renderEntry(f, defaults) {
|
|
1152
1156
|
if (f && f.section != null) {
|
|
1153
1157
|
// A named section is addressable by `setVisible`, so a heading can be
|
|
@@ -1247,23 +1251,6 @@ function _renderField(f, value) {
|
|
|
1247
1251
|
</div>
|
|
1248
1252
|
`);
|
|
1249
1253
|
}
|
|
1250
|
-
if (f.type === 'select' && f.create) {
|
|
1251
|
-
// A default naming a disabled option is not taken, as in a select.
|
|
1252
|
-
const shown = findChoice(flatChoices(f.options), value, true)?.disabled ? '' : v;
|
|
1253
|
-
const dlId = `ea-dl-${f.name}-${_modalSeq}`;
|
|
1254
|
-
const opts = _datalistOptions(f.options);
|
|
1255
|
-
const placeholder = f.placeholder
|
|
1256
|
-
? `placeholder="${_esc(f.placeholder)}"` : 'placeholder="type to pick or create…"';
|
|
1257
|
-
return wrap(`
|
|
1258
|
-
<div class="twm-combobox">
|
|
1259
|
-
<input name="${f.name}" type="text" list="${dlId}"
|
|
1260
|
-
value="${_esc(shown)}" ${placeholder} ${required}
|
|
1261
|
-
autocomplete="off">
|
|
1262
|
-
<datalist id="${dlId}" data-datalist-for="${f.name}">${opts}</datalist>
|
|
1263
|
-
<span class="twm-combobox__hint" data-for="${f.name}" hidden></span>
|
|
1264
|
-
</div>
|
|
1265
|
-
`);
|
|
1266
|
-
}
|
|
1267
1254
|
if (f.type === 'select') return wrap(slot);
|
|
1268
1255
|
const step = f.step != null ? `step="${f.step}"` : '';
|
|
1269
1256
|
const placeholder = f.placeholder ? `placeholder="${_esc(f.placeholder)}"` : '';
|