flexdesk 0.16.1 → 0.17.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 +112 -0
- package/LICENSE +0 -0
- package/README.md +2435 -42
- package/THIRD_PARTY_LICENSES.md +0 -0
- package/canvas.js +18 -0
- package/charts.js +21 -0
- package/core.js +14 -0
- package/css/base.css +1834 -75
- package/css/flexdesk.css +3165 -159
- package/css/overrides.css +16 -0
- package/css/reset.css +0 -0
- package/css/tokens.css +0 -0
- package/dist/canvas.js +6 -239
- package/dist/canvas.js.map +3 -3
- package/dist/charts.js +707 -18
- package/dist/charts.js.map +4 -4
- package/dist/chunk-3PHCPZHT.js +0 -0
- package/dist/chunk-3PHCPZHT.js.map +0 -0
- package/dist/chunk-4A7SJC3T.js +503 -0
- package/dist/chunk-4A7SJC3T.js.map +7 -0
- package/dist/{chunk-6JSOVNID.js → chunk-CVMCZZX2.js} +635 -10
- package/dist/chunk-CVMCZZX2.js.map +7 -0
- package/dist/chunk-D3COLZZ2.js +1311 -0
- package/dist/chunk-D3COLZZ2.js.map +7 -0
- package/dist/chunk-FOOS3T5L.js +0 -0
- package/dist/chunk-FOOS3T5L.js.map +0 -0
- package/dist/{chunk-SN4LSOAR.js → chunk-GOQLR5WR.js} +452 -2
- package/dist/chunk-GOQLR5WR.js.map +7 -0
- package/dist/{chunk-FL5KFNQH.js → chunk-HNU6MM2B.js} +52 -17
- package/dist/chunk-HNU6MM2B.js.map +7 -0
- package/dist/chunk-HYJYYEPK.js +375 -0
- package/dist/chunk-HYJYYEPK.js.map +7 -0
- package/dist/chunk-JKZLNKG6.js +2041 -0
- package/dist/chunk-JKZLNKG6.js.map +7 -0
- package/dist/chunk-JYWURG5T.js +0 -0
- package/dist/chunk-JYWURG5T.js.map +0 -0
- package/dist/{chunk-FBGP5KN5.js → chunk-LWV23YVE.js} +5073 -532
- package/dist/chunk-LWV23YVE.js.map +7 -0
- package/dist/chunk-N7HC3ADV.js +30 -0
- package/dist/chunk-N7HC3ADV.js.map +7 -0
- package/dist/{chunk-RQM5Z2MU.js → chunk-RMTRWWEO.js} +633 -311
- package/dist/chunk-RMTRWWEO.js.map +7 -0
- package/dist/{chunk-NLTOY57M.js → chunk-UURZ37SD.js} +28 -48
- package/dist/chunk-UURZ37SD.js.map +7 -0
- package/dist/{chunk-B5Q53LAJ.js → chunk-UWO43BZU.js} +2 -2
- package/dist/chunk-UWO43BZU.js.map +7 -0
- package/dist/chunk-WVFGV5FT.js +0 -0
- package/dist/chunk-WVFGV5FT.js.map +0 -0
- package/dist/core.js +170 -118
- package/dist/core.js.map +2 -2
- package/dist/editor.js +156 -70
- package/dist/editor.js.map +2 -2
- package/dist/flexdesk.css +3165 -159
- package/dist/flow.js +2789 -306
- package/dist/flow.js.map +4 -4
- package/dist/host.js +109 -3
- package/dist/host.js.map +3 -3
- package/dist/reset.css +0 -0
- package/dist/tile_registry-6WZPTUZV.js +0 -0
- package/dist/tile_registry-6WZPTUZV.js.map +0 -0
- package/dist/tiles.js +657 -234
- package/dist/tiles.js.map +4 -4
- package/dist/tokens.css +0 -0
- package/dist/widgets.js +3290 -2124
- package/dist/widgets.js.map +4 -4
- package/dist/wm.js +1227 -347
- package/dist/wm.js.map +4 -4
- package/editor.js +20 -0
- package/flow.js +21 -0
- package/host.js +14 -0
- package/package.json +12 -1
- package/src/canvas/canvas_adapter.js +5 -0
- package/src/canvas/edge_router.js +0 -0
- package/src/canvas/graph/connector_router.js +0 -0
- package/src/canvas/graph/history_service.js +0 -0
- package/src/canvas/graph/node_platform.js +0 -0
- package/src/canvas/index.js +1 -0
- package/src/canvas/layered_layout.js +244 -0
- package/src/charts/chart_types.js +0 -0
- package/src/charts/data_series_plot_window.js +0 -0
- package/src/charts/downsample.js +2 -2
- package/src/charts/plot_config.js +53 -29
- package/src/charts/plot_config_editor.js +627 -0
- package/src/charts/plot_grid.js +449 -0
- package/src/charts/plot_popout_window.js +103 -317
- package/src/charts/plotly_wrapper.js +22 -2
- package/src/charts/series_transforms.js +206 -0
- package/src/core/event_bus.js +0 -0
- package/src/core/logging.js +1 -1
- package/src/core/settings.js +105 -19
- package/src/core/state_guard.js +93 -51
- package/src/core/state_guard_config.js +85 -74
- package/src/core/state_machine.js +0 -0
- package/src/editor/editor_pane.js +3 -3
- package/src/editor/monaco_editor_factory.js +124 -60
- package/src/editor/monaco_loader.js +103 -28
- package/src/editor/notebook_search.js +0 -0
- package/src/editor/notebook_tab_bar.js +38 -52
- package/src/editor/notebook_undo_manager.js +0 -0
- package/src/editor/split_pane_container.js +6 -3
- package/src/flow/canvas_editor/connections.js +90 -0
- package/src/flow/canvas_editor/editor.js +2351 -153
- package/src/flow/canvas_editor/index.js +2 -1
- package/src/flow/canvas_editor/layout.js +119 -12
- package/src/flow/canvas_editor/strings.js +80 -0
- package/src/flow/kit/always_before.js +0 -0
- package/src/flow/kit/catalogue.js +97 -3
- package/src/flow/kit/chip_input.js +0 -0
- package/src/flow/kit/dom.js +0 -0
- package/src/flow/kit/findings.js +0 -0
- package/src/flow/kit/history.js +0 -0
- package/src/flow/kit/index.js +3 -3
- package/src/flow/kit/keys.js +7 -1
- package/src/flow/kit/logic_graph.js +97 -15
- package/src/flow/kit/references.js +0 -0
- package/src/flow/kit/settings_panel.js +0 -0
- package/src/flow/kit/settings_schema.js +2 -1
- package/src/flow/kit/step_picker.js +0 -0
- package/src/flow/kit/strings.js +3 -1
- package/src/flow/kit/value_picker.js +0 -0
- package/src/flow/kit/widgets.js +86 -109
- package/src/flow/lanes/editor.js +136 -14
- package/src/flow/lanes/index.js +3 -1
- package/src/flow/lanes/layout.js +0 -0
- package/src/flow/lanes/pipeline.js +44 -2
- package/src/flow/lanes/preview.js +0 -0
- package/src/flow/lanes/strings.js +1 -0
- package/src/flow/outline/editor.js +0 -0
- package/src/flow/outline/index.js +0 -0
- package/src/flow/outline/mapping.js +0 -0
- package/src/flow/outline/menu.js +0 -0
- package/src/flow/outline/operations.js +0 -0
- package/src/flow/outline/recognise.js +0 -0
- package/src/flow/outline/strings.js +0 -0
- package/src/help/help_modal.js +168 -42
- package/src/help/help_registry.js +12 -1
- package/src/host/external_links.js +0 -0
- package/src/host/file_picker.js +117 -0
- package/src/host/host.js +10 -2
- package/src/host/photino_host.js +9 -0
- package/src/host/photino_host.md +21 -3
- package/src/host/pywebview_host.js +10 -0
- package/src/tiles/chart_tiles.js +53 -245
- package/src/tiles/config_schema.js +8 -0
- package/src/tiles/figure_tile.js +322 -0
- package/src/tiles/layout_persistence.js +3 -3
- package/src/tiles/list_tiles.js +7 -7
- package/src/tiles/scope_picker.js +0 -0
- package/src/tiles/tile_base.js +1 -1
- package/src/tiles/tile_config_form.js +442 -0
- package/src/tiles/tile_grid.js +2 -0
- package/src/tiles/tile_registry.js +0 -0
- package/src/tiling/bottom_panel_tabs.js +67 -99
- package/src/tiling/command_palette.js +64 -19
- package/src/tiling/content_registry.js +4 -1
- package/src/tiling/desktops.js +11 -1
- package/src/tiling/entity_sources.js +0 -0
- package/src/tiling/keymap.js +403 -128
- package/src/tiling/kind_taxonomy.js +78 -0
- package/src/tiling/landing_table.js +0 -0
- package/src/tiling/loading_overlay.js +0 -0
- package/src/tiling/nav_panel.js +59 -10
- package/src/tiling/page_factory.js +5 -5
- package/src/tiling/panel_availability.js +49 -0
- package/src/tiling/panel_keys.js +0 -0
- package/src/tiling/shell.js +114 -12
- package/src/tiling/tab_strip.js +0 -0
- package/src/tiling/tile_breadcrumb.js +0 -0
- package/src/tiling/tile_renderer.js +105 -9
- package/src/tiling/tile_tab_menu.js +0 -0
- package/src/tiling/tile_tree.js +0 -0
- package/src/tiling/wm.js +562 -30
- package/src/tiling/zoom.js +0 -0
- package/src/ui/base/component_base.js +0 -0
- package/src/ui/base/controller_base.js +0 -0
- package/src/ui/base/page_base.js +0 -0
- package/src/ui/components/about_dialog.js +0 -0
- package/src/ui/components/action_dropdown.js +30 -2
- package/src/ui/components/alert_log.js +0 -0
- package/src/ui/components/alerts_pane.js +0 -0
- package/src/ui/components/attribute_list_editor.js +0 -0
- package/src/ui/components/autocomplete_field.js +0 -0
- package/src/ui/components/cell_editor_host.js +293 -0
- package/src/ui/components/cell_editors.js +480 -0
- package/src/ui/components/cell_grid.js +761 -0
- package/src/ui/components/cell_paste.js +243 -0
- package/src/ui/components/cell_selection.js +220 -0
- package/src/ui/components/choice_controls.js +351 -0
- package/src/ui/components/color_field.js +211 -0
- package/src/ui/components/color_list_field.js +210 -0
- package/src/ui/components/column_fit.js +0 -0
- package/src/ui/components/column_groups.js +314 -0
- package/src/ui/components/column_order.js +142 -0
- package/src/ui/components/computing_status_window.js +98 -25
- package/src/ui/components/confirm_dialog.js +81 -38
- package/src/ui/components/context_menu.js +2 -2
- package/src/ui/components/conversation_view.js +440 -0
- package/src/ui/components/crosstab.js +289 -0
- package/src/ui/components/data_table.js +696 -71
- package/src/ui/components/detail_header.js +22 -11
- package/src/ui/components/document_outline.js +397 -0
- package/src/ui/components/drag_reorder.js +0 -0
- package/src/ui/components/fact_table.js +0 -0
- package/src/ui/components/fill_handle.js +322 -0
- package/src/ui/components/gallery_picker.js +19 -8
- package/src/ui/components/header_actions.js +55 -0
- package/src/ui/components/inline_renamer.js +0 -0
- package/src/ui/components/job_progress.js +51 -0
- package/src/ui/components/managed_window.js +27 -4
- package/src/ui/components/menu_bar.js +745 -0
- package/src/ui/components/modal.js +449 -75
- package/src/ui/components/notification_history.js +3 -3
- package/src/ui/components/progress.js +483 -0
- package/src/ui/components/record_list.js +0 -0
- package/src/ui/components/row_activation.js +0 -0
- package/src/ui/components/section_stack.js +298 -0
- package/src/ui/components/series.js +173 -0
- package/src/ui/components/settings_view.js +634 -0
- package/src/ui/components/slide_out_panel.js +43 -6
- package/src/ui/components/slider_field.js +275 -18
- package/src/ui/components/sortable_list.js +0 -0
- package/src/ui/components/staged_edits.js +361 -0
- package/src/ui/components/staged_history.js +172 -0
- package/src/ui/components/table_filter_chips.js +0 -0
- package/src/ui/components/table_state_store.js +0 -0
- package/src/ui/components/table_structure.js +182 -0
- package/src/ui/components/table_values.js +0 -0
- package/src/ui/components/tabs.js +497 -0
- package/src/ui/components/tag_input.js +215 -0
- package/src/ui/components/toast.js +25 -8
- package/src/ui/components/toggle_field.js +175 -0
- package/src/ui/components/tree_view.js +55 -34
- package/src/ui/components/tsv.js +74 -0
- package/src/ui/components/window_taskbar.js +0 -0
- package/src/ui/components/wizard.js +563 -0
- package/src/ui/controllers/window_chrome_controller.js +2 -2
- package/src/ui/notification_center.js +375 -119
- package/src/ui/utils/auto_scrollbars.js +0 -0
- package/src/ui/utils/markdown.js +709 -0
- package/src/ui/utils/native_context_menu.js +0 -0
- package/src/ui/utils/overlay_scrollbar.js +16 -12
- package/src/ui/utils/raf_resize_observer.js +0 -0
- package/src/ui/utils/scroll_lock.js +0 -0
- package/src/ui/utils/strip_scroll.js +55 -0
- package/src/ui/utils/tooltip_service.js +0 -8
- package/tiles.js +27 -0
- package/widgets.js +69 -0
- package/wm.js +28 -0
- package/dist/chunk-6JSOVNID.js.map +0 -7
- package/dist/chunk-B5Q53LAJ.js.map +0 -7
- package/dist/chunk-FBGP5KN5.js.map +0 -7
- package/dist/chunk-FL5KFNQH.js.map +0 -7
- package/dist/chunk-NLTOY57M.js.map +0 -7
- package/dist/chunk-NNFTETK3.js +0 -633
- package/dist/chunk-NNFTETK3.js.map +0 -7
- package/dist/chunk-O5OHMWBB.js +0 -284
- package/dist/chunk-O5OHMWBB.js.map +0 -7
- package/dist/chunk-RQM5Z2MU.js.map +0 -7
- package/dist/chunk-SN4LSOAR.js.map +0 -7
- package/dist/chunk-W6FBJLPW.js +0 -367
- package/dist/chunk-W6FBJLPW.js.map +0 -7
- package/src/ui/controllers/panel_state_machine.js +0 -634
|
@@ -0,0 +1,1311 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ColorField,
|
|
3
|
+
ManagedWindow,
|
|
4
|
+
ToggleField,
|
|
5
|
+
choiceList,
|
|
6
|
+
findChoice,
|
|
7
|
+
flatChoices,
|
|
8
|
+
isHexColor,
|
|
9
|
+
normalizeHex,
|
|
10
|
+
selectBox,
|
|
11
|
+
toggleChips
|
|
12
|
+
} from "./chunk-CVMCZZX2.js";
|
|
13
|
+
import {
|
|
14
|
+
DragReorder
|
|
15
|
+
} from "./chunk-WVFGV5FT.js";
|
|
16
|
+
|
|
17
|
+
// src/ui/components/sortable_list.js
|
|
18
|
+
var ROW_CLASS = "twm-sortable-list__row";
|
|
19
|
+
var ROW_SELECTOR = `.${ROW_CLASS}`;
|
|
20
|
+
var HANDLE_CLASS = "twm-sortable-list__handle";
|
|
21
|
+
var HANDLE_SELECTOR = `.${HANDLE_CLASS}`;
|
|
22
|
+
var SortableList = class {
|
|
23
|
+
/**
|
|
24
|
+
* Create a sortable list.
|
|
25
|
+
* @param {Object} options - Configuration options
|
|
26
|
+
* @param {string} options.containerId - Unique ID for the container
|
|
27
|
+
* @param {Array} options.items - Initial items array
|
|
28
|
+
* @param {Function} options.onChange - Called with updated items array when list changes
|
|
29
|
+
* @param {Function} options.renderItem - Custom renderer for item content (receives item, index, callbacks)
|
|
30
|
+
* @param {Function} options.createItem - Factory function to create new items
|
|
31
|
+
* @param {string} options.emptyMessage - Message to show when list is empty
|
|
32
|
+
* @param {string} options.addButtonText - Text for add button
|
|
33
|
+
* @param {boolean} options.allowReorder - Whether to allow drag-and-drop reordering
|
|
34
|
+
* @param {boolean} options.allowRemove - Whether to allow removing items
|
|
35
|
+
* @param {number} options.minItems - Minimum number of items (blocks removal if at min)
|
|
36
|
+
*/
|
|
37
|
+
constructor({
|
|
38
|
+
containerId,
|
|
39
|
+
items = [],
|
|
40
|
+
onChange = null,
|
|
41
|
+
renderItem = null,
|
|
42
|
+
createItem = null,
|
|
43
|
+
emptyMessage = "No items",
|
|
44
|
+
addButtonText = "Add Item",
|
|
45
|
+
allowReorder = true,
|
|
46
|
+
allowRemove = true,
|
|
47
|
+
minItems = 0
|
|
48
|
+
} = {}) {
|
|
49
|
+
this.containerId = containerId || `sortable-list-${Date.now()}`;
|
|
50
|
+
this.items = Array.isArray(items) ? [...items] : [];
|
|
51
|
+
this.onChange = typeof onChange === "function" ? onChange : null;
|
|
52
|
+
this.renderItem = typeof renderItem === "function" ? renderItem : this.#defaultRenderItem.bind(this);
|
|
53
|
+
this.createItem = typeof createItem === "function" ? createItem : this.#defaultCreateItem.bind(this);
|
|
54
|
+
this.emptyMessage = emptyMessage;
|
|
55
|
+
this.addButtonText = addButtonText;
|
|
56
|
+
this.allowReorder = allowReorder;
|
|
57
|
+
this.allowRemove = allowRemove;
|
|
58
|
+
this.minItems = minItems;
|
|
59
|
+
this.root = null;
|
|
60
|
+
this.listEl = null;
|
|
61
|
+
this._dragReorder = null;
|
|
62
|
+
this._pressedHandle = false;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Render the sortable list.
|
|
66
|
+
* @returns {HTMLElement} The root container element
|
|
67
|
+
*/
|
|
68
|
+
render() {
|
|
69
|
+
if (this.root) {
|
|
70
|
+
this.#refresh();
|
|
71
|
+
return this.root;
|
|
72
|
+
}
|
|
73
|
+
this.root = document.createElement("div");
|
|
74
|
+
this.root.className = "twm-sortable-list";
|
|
75
|
+
this.root.id = this.containerId;
|
|
76
|
+
this.listEl = document.createElement("div");
|
|
77
|
+
this.listEl.className = "twm-sortable-list__items";
|
|
78
|
+
this.root.appendChild(this.listEl);
|
|
79
|
+
if (this.addButtonText) {
|
|
80
|
+
const addBtn = document.createElement("button");
|
|
81
|
+
addBtn.type = "button";
|
|
82
|
+
addBtn.className = "twm-sortable-list__add";
|
|
83
|
+
addBtn.innerHTML = `<span class="material-symbols-outlined">add</span> ${this.#escapeHtml(this.addButtonText)}`;
|
|
84
|
+
addBtn.addEventListener("click", () => this.addItem());
|
|
85
|
+
this.root.appendChild(addBtn);
|
|
86
|
+
}
|
|
87
|
+
this.#refresh();
|
|
88
|
+
return this.root;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Get current items.
|
|
92
|
+
* @returns {Array} Copy of items array
|
|
93
|
+
*/
|
|
94
|
+
getItems() {
|
|
95
|
+
return [...this.items];
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Set items and re-render.
|
|
99
|
+
* @param {Array} items - New items array
|
|
100
|
+
*/
|
|
101
|
+
setItems(items) {
|
|
102
|
+
this.items = Array.isArray(items) ? [...items] : [];
|
|
103
|
+
this.#refresh();
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Add a new item.
|
|
107
|
+
*/
|
|
108
|
+
addItem() {
|
|
109
|
+
const newItem = this.createItem(this.items.length);
|
|
110
|
+
this.items.push(newItem);
|
|
111
|
+
this.#notifyChange();
|
|
112
|
+
this.#refresh();
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Remove an item by index.
|
|
116
|
+
* @param {number} index - Index of item to remove
|
|
117
|
+
*/
|
|
118
|
+
removeItem(index) {
|
|
119
|
+
if (index < 0 || index >= this.items.length) return;
|
|
120
|
+
if (this.items.length <= this.minItems) return;
|
|
121
|
+
this.items.splice(index, 1);
|
|
122
|
+
this.#notifyChange();
|
|
123
|
+
this.#refresh();
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Update an item at index.
|
|
127
|
+
* @param {number} index - Index of item to update
|
|
128
|
+
* @param {Object} updates - Properties to merge into item
|
|
129
|
+
*/
|
|
130
|
+
updateItem(index, updates) {
|
|
131
|
+
if (index < 0 || index >= this.items.length) return;
|
|
132
|
+
this.items[index] = { ...this.items[index], ...updates };
|
|
133
|
+
this.#notifyChange();
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Move an item from one index to another.
|
|
137
|
+
* @param {number} fromIndex - Source index
|
|
138
|
+
* @param {number} toIndex - Destination index
|
|
139
|
+
*/
|
|
140
|
+
moveItem(fromIndex, toIndex) {
|
|
141
|
+
if (fromIndex < 0 || fromIndex >= this.items.length) return;
|
|
142
|
+
if (toIndex < 0 || toIndex >= this.items.length) return;
|
|
143
|
+
if (fromIndex === toIndex) return;
|
|
144
|
+
const [item] = this.items.splice(fromIndex, 1);
|
|
145
|
+
this.items.splice(toIndex, 0, item);
|
|
146
|
+
this.#notifyChange();
|
|
147
|
+
this.#refresh();
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Dispose the component.
|
|
151
|
+
*/
|
|
152
|
+
dispose() {
|
|
153
|
+
this._dragReorder?.destroy();
|
|
154
|
+
this._dragReorder = null;
|
|
155
|
+
this.root = null;
|
|
156
|
+
this.listEl = null;
|
|
157
|
+
this.items = [];
|
|
158
|
+
}
|
|
159
|
+
// ─── Private Methods ───────────────────────────────────────────────────
|
|
160
|
+
#refresh() {
|
|
161
|
+
if (!this.listEl) return;
|
|
162
|
+
this.listEl.innerHTML = "";
|
|
163
|
+
if (this.items.length === 0) {
|
|
164
|
+
const empty = document.createElement("div");
|
|
165
|
+
empty.className = "twm-sortable-list__empty";
|
|
166
|
+
empty.textContent = this.emptyMessage;
|
|
167
|
+
this.listEl.appendChild(empty);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
this.items.forEach((item, index) => {
|
|
171
|
+
const row = this.#createRow(item, index);
|
|
172
|
+
this.listEl.appendChild(row);
|
|
173
|
+
});
|
|
174
|
+
if (this.allowReorder) {
|
|
175
|
+
if (!this._dragReorder) {
|
|
176
|
+
this.listEl.addEventListener("mousedown", (e) => {
|
|
177
|
+
this._pressedHandle = !!e.target.closest?.(HANDLE_SELECTOR);
|
|
178
|
+
});
|
|
179
|
+
this._dragReorder = new DragReorder({
|
|
180
|
+
container: this.listEl,
|
|
181
|
+
itemSelector: ROW_SELECTOR,
|
|
182
|
+
keyAttr: "index",
|
|
183
|
+
axis: "y",
|
|
184
|
+
indicatorClass: "twm-sortable-list__drop",
|
|
185
|
+
draggableGuard: () => {
|
|
186
|
+
const fromHandle = this._pressedHandle;
|
|
187
|
+
this._pressedHandle = false;
|
|
188
|
+
return fromHandle;
|
|
189
|
+
},
|
|
190
|
+
onReorder: (order) => this.#applyReorder(order)
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
this._dragReorder.attach();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** Reorder `items` to match the new DOM order produced by a drag,
|
|
197
|
+
* then notify + re-render. `order` holds the rows' pre-drag
|
|
198
|
+
* `data-index` values in their new arrangement. */
|
|
199
|
+
#applyReorder(order) {
|
|
200
|
+
const next = order.map((i) => this.items[Number(i)]).filter((it) => it !== void 0);
|
|
201
|
+
if (next.length !== this.items.length) return;
|
|
202
|
+
this.items = next;
|
|
203
|
+
this.#notifyChange();
|
|
204
|
+
this.#refresh();
|
|
205
|
+
}
|
|
206
|
+
#createRow(item, index) {
|
|
207
|
+
const row = document.createElement("div");
|
|
208
|
+
row.className = ROW_CLASS;
|
|
209
|
+
row.dataset.index = index;
|
|
210
|
+
if (this.allowReorder) {
|
|
211
|
+
const handle = document.createElement("div");
|
|
212
|
+
handle.className = HANDLE_CLASS;
|
|
213
|
+
handle.innerHTML = '<span class="material-symbols-outlined">drag_indicator</span>';
|
|
214
|
+
row.appendChild(handle);
|
|
215
|
+
}
|
|
216
|
+
const content = document.createElement("div");
|
|
217
|
+
content.className = "twm-sortable-list__content";
|
|
218
|
+
const callbacks = {
|
|
219
|
+
update: (updates) => this.updateItem(index, updates),
|
|
220
|
+
remove: () => this.removeItem(index),
|
|
221
|
+
moveUp: () => this.moveItem(index, index - 1),
|
|
222
|
+
moveDown: () => this.moveItem(index, index + 1)
|
|
223
|
+
};
|
|
224
|
+
this.renderItem(content, item, index, callbacks);
|
|
225
|
+
row.appendChild(content);
|
|
226
|
+
if (this.allowRemove) {
|
|
227
|
+
const canRemove = this.items.length > this.minItems;
|
|
228
|
+
const removeBtn = document.createElement("button");
|
|
229
|
+
removeBtn.type = "button";
|
|
230
|
+
removeBtn.className = "twm-sortable-list__remove";
|
|
231
|
+
removeBtn.disabled = !canRemove;
|
|
232
|
+
removeBtn.title = canRemove ? "Remove" : `Minimum ${this.minItems} items required`;
|
|
233
|
+
removeBtn.innerHTML = '<span class="material-symbols-outlined">close</span>';
|
|
234
|
+
removeBtn.addEventListener("click", () => {
|
|
235
|
+
if (canRemove) this.removeItem(index);
|
|
236
|
+
});
|
|
237
|
+
row.appendChild(removeBtn);
|
|
238
|
+
}
|
|
239
|
+
return row;
|
|
240
|
+
}
|
|
241
|
+
#defaultRenderItem(container, item, index, callbacks) {
|
|
242
|
+
const input = document.createElement("input");
|
|
243
|
+
input.type = "text";
|
|
244
|
+
input.className = "twm-sortable-list__input";
|
|
245
|
+
input.value = item.name ?? item.label ?? "";
|
|
246
|
+
input.placeholder = `Item ${index + 1}`;
|
|
247
|
+
input.addEventListener("change", () => {
|
|
248
|
+
callbacks.update({ name: input.value });
|
|
249
|
+
});
|
|
250
|
+
container.appendChild(input);
|
|
251
|
+
}
|
|
252
|
+
#defaultCreateItem(index) {
|
|
253
|
+
return {
|
|
254
|
+
id: `item_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`,
|
|
255
|
+
name: `item${index + 1}`
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
#notifyChange() {
|
|
259
|
+
if (this.onChange) {
|
|
260
|
+
this.onChange([...this.items]);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
#escapeHtml(str) {
|
|
264
|
+
return String(str ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// src/ui/components/color_list_field.js
|
|
269
|
+
var _seq = 0;
|
|
270
|
+
var hexOf = (text) => {
|
|
271
|
+
const t = String(text ?? "").trim();
|
|
272
|
+
return isHexColor(t) ? normalizeHex(t) : null;
|
|
273
|
+
};
|
|
274
|
+
var ColorListField = class {
|
|
275
|
+
/**
|
|
276
|
+
* @param {Object} [o]
|
|
277
|
+
* @param {string[]} [o.value] initial colours (hex or rgb())
|
|
278
|
+
* @param {string[]} [o.presets] preset colours offered on every row
|
|
279
|
+
* @param {string} [o.newColor] colour a newly added row starts on
|
|
280
|
+
* @param {string} [o.addLabel] text of the add button
|
|
281
|
+
* @param {string} [o.name] `name` of the hidden input, for a host form
|
|
282
|
+
* @param {Function} [o.onChange] `(colors, { commit })`, colors as `getValue()`
|
|
283
|
+
*/
|
|
284
|
+
constructor({
|
|
285
|
+
value = [],
|
|
286
|
+
presets = [],
|
|
287
|
+
newColor = "#000000",
|
|
288
|
+
addLabel = "Add colour",
|
|
289
|
+
name = null,
|
|
290
|
+
onChange = null
|
|
291
|
+
} = {}) {
|
|
292
|
+
this.presets = Array.isArray(presets) ? presets : [];
|
|
293
|
+
this.newColor = normalizeHex(String(newColor ?? "")) ?? String(newColor ?? "");
|
|
294
|
+
this.addLabel = addLabel;
|
|
295
|
+
this.name = name;
|
|
296
|
+
this.onChange = typeof onChange === "function" ? onChange : null;
|
|
297
|
+
this._fields = [];
|
|
298
|
+
this._commit = true;
|
|
299
|
+
this._rowEdit = false;
|
|
300
|
+
this.list = new SortableList({
|
|
301
|
+
items: this.#toItems(value),
|
|
302
|
+
addButtonText: addLabel,
|
|
303
|
+
emptyMessage: "No colours",
|
|
304
|
+
createItem: () => ({ id: ++_seq, text: this.newColor }),
|
|
305
|
+
renderItem: (container, item, index, callbacks) => this.#renderRow(container, item, index, callbacks),
|
|
306
|
+
// Every SortableList change but a row's own edit (add, remove,
|
|
307
|
+
// move, drag) is followed by a refresh that rebuilds all rows —
|
|
308
|
+
// down to none — so the old fields go first.
|
|
309
|
+
onChange: () => {
|
|
310
|
+
if (!this._rowEdit) this.#dropFields();
|
|
311
|
+
this.#emit(this._commit);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
this.root = null;
|
|
315
|
+
this.input = null;
|
|
316
|
+
}
|
|
317
|
+
render() {
|
|
318
|
+
if (this.root) return this.root;
|
|
319
|
+
const root = document.createElement("div");
|
|
320
|
+
root.className = "twm-color-list";
|
|
321
|
+
if (this.name) {
|
|
322
|
+
const input = document.createElement("input");
|
|
323
|
+
input.type = "hidden";
|
|
324
|
+
input.name = this.name;
|
|
325
|
+
root.appendChild(input);
|
|
326
|
+
this.input = input;
|
|
327
|
+
}
|
|
328
|
+
root.appendChild(this.list.render());
|
|
329
|
+
this.root = root;
|
|
330
|
+
this.#writeInput();
|
|
331
|
+
return root;
|
|
332
|
+
}
|
|
333
|
+
/** The colours in order as `#rrggbb`, or null while any row is not one. */
|
|
334
|
+
getValue() {
|
|
335
|
+
const out = this.getTexts().map(hexOf);
|
|
336
|
+
return out.includes(null) ? null : out;
|
|
337
|
+
}
|
|
338
|
+
/** The rows' text as it stands, valid or not. */
|
|
339
|
+
getTexts() {
|
|
340
|
+
return this.list.getItems().map((item) => String(item.text ?? ""));
|
|
341
|
+
}
|
|
342
|
+
/** Indexes of the rows that do not hold a hex colour. */
|
|
343
|
+
invalidIndexes() {
|
|
344
|
+
const bad = [];
|
|
345
|
+
this.getTexts().forEach((text, i) => {
|
|
346
|
+
if (!hexOf(text)) bad.push(i);
|
|
347
|
+
});
|
|
348
|
+
return bad;
|
|
349
|
+
}
|
|
350
|
+
/** Replace the colours without firing `onChange`. */
|
|
351
|
+
setValue(colors) {
|
|
352
|
+
this.#dropFields();
|
|
353
|
+
this.list.setItems(this.#toItems(colors));
|
|
354
|
+
this.#writeInput();
|
|
355
|
+
}
|
|
356
|
+
/** Focus the first invalid row, else the first row, else the add button. */
|
|
357
|
+
focus() {
|
|
358
|
+
const bad = this.invalidIndexes()[0];
|
|
359
|
+
const field = this._fields[bad ?? 0];
|
|
360
|
+
if (field) {
|
|
361
|
+
field.focus();
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
this.root?.querySelector(".twm-sortable-list__add")?.focus();
|
|
365
|
+
}
|
|
366
|
+
dispose() {
|
|
367
|
+
this.#dropFields();
|
|
368
|
+
this.list.dispose();
|
|
369
|
+
this.root = null;
|
|
370
|
+
this.input = null;
|
|
371
|
+
}
|
|
372
|
+
#toItems(colors) {
|
|
373
|
+
return (Array.isArray(colors) ? colors : []).map((c) => ({
|
|
374
|
+
id: ++_seq,
|
|
375
|
+
text: normalizeHex(String(c ?? "")) ?? String(c ?? "")
|
|
376
|
+
}));
|
|
377
|
+
}
|
|
378
|
+
/** Dispose every row's field: the rows are about to be rebuilt. */
|
|
379
|
+
#dropFields() {
|
|
380
|
+
for (const f of this._fields) f?.dispose();
|
|
381
|
+
this._fields = [];
|
|
382
|
+
}
|
|
383
|
+
#renderRow(container, item, index, callbacks) {
|
|
384
|
+
const count = this.list.getItems().length;
|
|
385
|
+
const row = document.createElement("div");
|
|
386
|
+
row.className = "twm-color-list__row";
|
|
387
|
+
const field = new ColorField({
|
|
388
|
+
value: item.text,
|
|
389
|
+
presets: this.presets,
|
|
390
|
+
label: `Colour ${index + 1}`,
|
|
391
|
+
// Every edit goes into the list's items, so the list never holds
|
|
392
|
+
// a stale row; `_commit` tells its onChange which kind it was.
|
|
393
|
+
onChange: (_v, { commit }) => {
|
|
394
|
+
this._commit = commit;
|
|
395
|
+
this._rowEdit = true;
|
|
396
|
+
try {
|
|
397
|
+
callbacks.update({ text: field.getText() });
|
|
398
|
+
} finally {
|
|
399
|
+
this._commit = true;
|
|
400
|
+
this._rowEdit = false;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
this._fields[index] = field;
|
|
405
|
+
row.appendChild(field.render());
|
|
406
|
+
const move = (icon, label, disabled, fn) => {
|
|
407
|
+
const btn = document.createElement("button");
|
|
408
|
+
btn.type = "button";
|
|
409
|
+
btn.className = "twm-color-list__move";
|
|
410
|
+
btn.title = label;
|
|
411
|
+
btn.setAttribute("aria-label", label);
|
|
412
|
+
btn.disabled = disabled;
|
|
413
|
+
const glyph = document.createElement("span");
|
|
414
|
+
glyph.className = "material-symbols-outlined";
|
|
415
|
+
glyph.setAttribute("aria-hidden", "true");
|
|
416
|
+
glyph.textContent = icon;
|
|
417
|
+
btn.appendChild(glyph);
|
|
418
|
+
btn.addEventListener("click", fn);
|
|
419
|
+
row.appendChild(btn);
|
|
420
|
+
};
|
|
421
|
+
move("arrow_upward", "Move up", index === 0, callbacks.moveUp);
|
|
422
|
+
move("arrow_downward", "Move down", index === count - 1, callbacks.moveDown);
|
|
423
|
+
container.appendChild(row);
|
|
424
|
+
}
|
|
425
|
+
#writeInput() {
|
|
426
|
+
if (this.input) this.input.value = JSON.stringify(this.getTexts());
|
|
427
|
+
}
|
|
428
|
+
#emit(commit) {
|
|
429
|
+
this.#writeInput();
|
|
430
|
+
if (this.input) {
|
|
431
|
+
this.input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
432
|
+
if (commit) this.input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
433
|
+
}
|
|
434
|
+
this.onChange?.(this.getValue(), { commit });
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
// src/ui/components/modal.js
|
|
439
|
+
var _modalHost = null;
|
|
440
|
+
function setModalHost(el) {
|
|
441
|
+
_modalHost = el || null;
|
|
442
|
+
}
|
|
443
|
+
function modalHost() {
|
|
444
|
+
return _modalHost;
|
|
445
|
+
}
|
|
446
|
+
var _modalSeq = 1;
|
|
447
|
+
function openForm({ title, fields = [], defaults = {}, submitLabel = "OK", onFieldChange = null } = {}) {
|
|
448
|
+
return new Promise((resolve) => {
|
|
449
|
+
const body = document.createElement("div");
|
|
450
|
+
body.className = "twm-modal__body-host";
|
|
451
|
+
body.innerHTML = `
|
|
452
|
+
<form class="twm-modal__form">
|
|
453
|
+
<div class="twm-modal__fields">
|
|
454
|
+
${fields.map((f) => _renderEntry(f, defaults)).join("")}
|
|
455
|
+
</div>
|
|
456
|
+
<div class="twm-modal__error" data-role="form-error" hidden></div>
|
|
457
|
+
<div class="twm-modal__actions">
|
|
458
|
+
<button type="button" class="twm-btn" data-action="cancel">Cancel</button>
|
|
459
|
+
<button type="submit" class="twm-btn twm-btn--primary">${submitLabel}</button>
|
|
460
|
+
</div>
|
|
461
|
+
</form>
|
|
462
|
+
`;
|
|
463
|
+
const win = new ManagedWindow({
|
|
464
|
+
container: _modalHost,
|
|
465
|
+
id: `twm-modal-${_modalSeq++}`,
|
|
466
|
+
title: title || "Dialog",
|
|
467
|
+
icon: "edit_note",
|
|
468
|
+
content: body,
|
|
469
|
+
modal: true,
|
|
470
|
+
canMinimize: false,
|
|
471
|
+
canMaximize: false,
|
|
472
|
+
canResize: false,
|
|
473
|
+
canDrag: true,
|
|
474
|
+
defaultWidth: _estimateWidth(fields),
|
|
475
|
+
defaultHeight: _estimateHeight(fields),
|
|
476
|
+
onClose: () => {
|
|
477
|
+
disposeControls();
|
|
478
|
+
if (!_resolved) {
|
|
479
|
+
_resolved = true;
|
|
480
|
+
resolve(null);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
let _resolved = false;
|
|
485
|
+
const close = (result) => {
|
|
486
|
+
if (_resolved) return;
|
|
487
|
+
_resolved = true;
|
|
488
|
+
resolve(result);
|
|
489
|
+
try {
|
|
490
|
+
win.close({ force: true });
|
|
491
|
+
} catch {
|
|
492
|
+
}
|
|
493
|
+
disposeControls();
|
|
494
|
+
};
|
|
495
|
+
const errEl = body.querySelector('[data-role="form-error"]');
|
|
496
|
+
const showError = (msg) => {
|
|
497
|
+
if (!errEl) return;
|
|
498
|
+
errEl.textContent = msg;
|
|
499
|
+
errEl.hidden = !msg;
|
|
500
|
+
};
|
|
501
|
+
body.querySelector('[data-action="cancel"]')?.addEventListener("click", () => close(null));
|
|
502
|
+
const formFields = _formFields(fields);
|
|
503
|
+
const optionsOf = new Map(formFields.map((f) => [f.name, f.options]));
|
|
504
|
+
const comboHints = /* @__PURE__ */ new Map();
|
|
505
|
+
for (const f of formFields) {
|
|
506
|
+
if (f.type === "select" && f.create) {
|
|
507
|
+
const update = _wireComboboxHint(body, f, () => optionsOf.get(f.name));
|
|
508
|
+
if (update) comboHints.set(f.name, update);
|
|
509
|
+
}
|
|
510
|
+
if (f.type === "picker") _wirePicker(body, f);
|
|
511
|
+
}
|
|
512
|
+
const rowHidden = (name) => !!body.querySelector(`[data-field-row="${name}"]`)?.hidden;
|
|
513
|
+
const clearFieldError = (name) => {
|
|
514
|
+
const row = body.querySelector(`[data-field-row="${name}"]`);
|
|
515
|
+
const errEl2 = body.querySelector(`[data-field-error="${name}"]`);
|
|
516
|
+
if (row) row.classList.remove("twm-is-invalid");
|
|
517
|
+
if (errEl2) {
|
|
518
|
+
errEl2.textContent = "";
|
|
519
|
+
errEl2.hidden = true;
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
const setFieldError = (name, msg) => {
|
|
523
|
+
const row = body.querySelector(`[data-field-row="${name}"]`);
|
|
524
|
+
const errEl2 = body.querySelector(`[data-field-error="${name}"]`);
|
|
525
|
+
if (row) row.classList.add("twm-is-invalid");
|
|
526
|
+
if (errEl2) {
|
|
527
|
+
errEl2.textContent = msg;
|
|
528
|
+
errEl2.hidden = false;
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
let onChoice = (name) => clearFieldError(name);
|
|
532
|
+
const controls = /* @__PURE__ */ new Map();
|
|
533
|
+
for (const f of formFields) {
|
|
534
|
+
const c = _mountControl(body, f, defaults[f.name] ?? f.default, (name) => onChoice(name));
|
|
535
|
+
if (c) controls.set(f.name, c);
|
|
536
|
+
}
|
|
537
|
+
function disposeControls() {
|
|
538
|
+
for (const c of controls.values()) c.dispose?.();
|
|
539
|
+
controls.clear();
|
|
540
|
+
}
|
|
541
|
+
win.show();
|
|
542
|
+
const comboOption = (f, text) => findChoice(flatChoices(optionsOf.get(f.name)), text, true);
|
|
543
|
+
const unavailable = (f, value) => {
|
|
544
|
+
const c = controls.get(f.name);
|
|
545
|
+
if (c) return c.unavailable();
|
|
546
|
+
if (f.type !== "select" || !f.create || value == null || value === "") return null;
|
|
547
|
+
const o = comboOption(f, value);
|
|
548
|
+
return o?.disabled ? o.reason || "" : null;
|
|
549
|
+
};
|
|
550
|
+
for (const f of formFields) {
|
|
551
|
+
const el = body.querySelector(`[name="${f.name}"]`);
|
|
552
|
+
el?.addEventListener("input", () => clearFieldError(f.name));
|
|
553
|
+
el?.addEventListener("change", () => clearFieldError(f.name));
|
|
554
|
+
if (f.type === "number" && _bounded(f)) {
|
|
555
|
+
el?.addEventListener("invalid", (e) => {
|
|
556
|
+
if (!el.validity.rangeUnderflow && !el.validity.rangeOverflow) return;
|
|
557
|
+
e.preventDefault();
|
|
558
|
+
setFieldError(f.name, _rangeMessage(f));
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if (typeof onFieldChange === "function") {
|
|
563
|
+
const fieldOf = (name) => formFields.find((f) => f.name === name) || null;
|
|
564
|
+
const api = {
|
|
565
|
+
setVisible(name, on) {
|
|
566
|
+
const row = body.querySelector(`[data-field-row="${name}"]`);
|
|
567
|
+
if (!row) return;
|
|
568
|
+
row.hidden = !on;
|
|
569
|
+
row.style.display = on ? "" : "none";
|
|
570
|
+
clearFieldError(name);
|
|
571
|
+
for (const el of row.querySelectorAll("input, select, textarea")) {
|
|
572
|
+
if (!on && el.required) {
|
|
573
|
+
el.required = false;
|
|
574
|
+
el.dataset.twmRequired = "1";
|
|
575
|
+
} else if (on && el.dataset.twmRequired) {
|
|
576
|
+
el.required = true;
|
|
577
|
+
delete el.dataset.twmRequired;
|
|
578
|
+
}
|
|
579
|
+
for (const [attr, key] of [["min", "twmMin"], ["max", "twmMax"]]) {
|
|
580
|
+
if (!on && el.hasAttribute(attr)) {
|
|
581
|
+
el.dataset[key] = el.getAttribute(attr);
|
|
582
|
+
el.removeAttribute(attr);
|
|
583
|
+
} else if (on && el.dataset[key] != null) {
|
|
584
|
+
el.setAttribute(attr, el.dataset[key]);
|
|
585
|
+
delete el.dataset[key];
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
},
|
|
590
|
+
get(name) {
|
|
591
|
+
if (controls.has(name)) return controls.get(name).read();
|
|
592
|
+
const el = body.querySelector(`[name="${name}"]`);
|
|
593
|
+
if (!el) return void 0;
|
|
594
|
+
if (el.type === "checkbox") return !!el.checked;
|
|
595
|
+
const f = fieldOf(name);
|
|
596
|
+
if (f?.type === "select" && f.create) {
|
|
597
|
+
const match = comboOption(f, el.value);
|
|
598
|
+
return match ? match.value : el.value;
|
|
599
|
+
}
|
|
600
|
+
return el.value;
|
|
601
|
+
},
|
|
602
|
+
set(name, value) {
|
|
603
|
+
if (controls.has(name)) {
|
|
604
|
+
controls.get(name).write(value);
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const el = body.querySelector(`[name="${name}"]`);
|
|
608
|
+
if (!el) return;
|
|
609
|
+
if (el.type === "checkbox") {
|
|
610
|
+
el.checked = !!value;
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
const f = fieldOf(name);
|
|
614
|
+
const shut = f?.type === "select" && f.create && comboOption(f, value)?.disabled;
|
|
615
|
+
el.value = value == null || shut ? "" : String(value);
|
|
616
|
+
comboHints.get(name)?.();
|
|
617
|
+
if (f?.type === "picker") {
|
|
618
|
+
const display = body.querySelector(`[data-picker-display="${name}"]`);
|
|
619
|
+
if (display) display.value = _pickerLabel(f, el.value);
|
|
620
|
+
}
|
|
621
|
+
},
|
|
622
|
+
setOptions(name, options) {
|
|
623
|
+
const f = fieldOf(name);
|
|
624
|
+
if (!f) return;
|
|
625
|
+
const c = controls.get(name);
|
|
626
|
+
if (c?.setOptions) {
|
|
627
|
+
optionsOf.set(name, options);
|
|
628
|
+
c.setOptions(options);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
if (f.type !== "select" || !f.create) return;
|
|
632
|
+
optionsOf.set(name, options);
|
|
633
|
+
const list = body.querySelector(`[data-datalist-for="${name}"]`);
|
|
634
|
+
if (list) list.innerHTML = _datalistOptions(options);
|
|
635
|
+
comboHints.get(name)?.();
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
const fire = (name) => {
|
|
639
|
+
try {
|
|
640
|
+
onFieldChange(name, name == null ? void 0 : api.get(name), api);
|
|
641
|
+
} catch (err) {
|
|
642
|
+
console.error("[modal] onFieldChange threw", err);
|
|
643
|
+
}
|
|
644
|
+
};
|
|
645
|
+
for (const f of formFields) {
|
|
646
|
+
const el = body.querySelector(`[name="${f.name}"]`);
|
|
647
|
+
el?.addEventListener("change", () => fire(f.name));
|
|
648
|
+
}
|
|
649
|
+
onChoice = (name) => {
|
|
650
|
+
clearFieldError(name);
|
|
651
|
+
fire(name);
|
|
652
|
+
};
|
|
653
|
+
fire(null);
|
|
654
|
+
}
|
|
655
|
+
body.querySelector("form").addEventListener("submit", async (e) => {
|
|
656
|
+
e.preventDefault();
|
|
657
|
+
const submitBtn = body.querySelector('button[type="submit"]');
|
|
658
|
+
showError("");
|
|
659
|
+
const data = {};
|
|
660
|
+
for (const f of formFields) {
|
|
661
|
+
const control = controls.get(f.name);
|
|
662
|
+
const el = control ? null : body.querySelector(`[name="${f.name}"]`);
|
|
663
|
+
if (!control && !el) continue;
|
|
664
|
+
let v;
|
|
665
|
+
if (control) {
|
|
666
|
+
v = control.submit ? control.submit() : control.read();
|
|
667
|
+
if (v === void 0) v = f.type === "select" ? "" : null;
|
|
668
|
+
} else if (f.type === "checkbox") {
|
|
669
|
+
v = !!el.checked;
|
|
670
|
+
} else if (f.type === "number") {
|
|
671
|
+
v = el.value === "" ? null : Number(el.value);
|
|
672
|
+
} else {
|
|
673
|
+
v = el.value;
|
|
674
|
+
}
|
|
675
|
+
if (f.type === "select" && f.create) {
|
|
676
|
+
const spec = _createSpec(f);
|
|
677
|
+
const match = comboOption(f, v);
|
|
678
|
+
if (match) v = match.value;
|
|
679
|
+
else if (v) {
|
|
680
|
+
if (submitBtn) submitBtn.disabled = true;
|
|
681
|
+
let created;
|
|
682
|
+
let failMsg = null;
|
|
683
|
+
try {
|
|
684
|
+
created = await spec.onCreate(v);
|
|
685
|
+
} catch (err) {
|
|
686
|
+
created = null;
|
|
687
|
+
failMsg = err?.message || String(err);
|
|
688
|
+
}
|
|
689
|
+
if (submitBtn) submitBtn.disabled = false;
|
|
690
|
+
if (created == null) {
|
|
691
|
+
const hint = spec.hint || f.label.toLowerCase();
|
|
692
|
+
showError(failMsg ? `Couldn't create ${hint} "${v}": ${failMsg}` : `Couldn't create ${hint} "${v}". See the toast for details.`);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
v = created;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
data[f.name] = v;
|
|
699
|
+
}
|
|
700
|
+
let firstBadName = null;
|
|
701
|
+
for (const f of formFields) {
|
|
702
|
+
clearFieldError(f.name);
|
|
703
|
+
if (rowHidden(f.name)) {
|
|
704
|
+
if (unavailable(f, data[f.name]) != null) data[f.name] = f.type === "radio" ? null : "";
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
const v = data[f.name];
|
|
708
|
+
let msg = f.required && _missing(f, v) ? "Required." : null;
|
|
709
|
+
if (!msg && _outOfRange(f, v)) msg = _rangeMessage(f);
|
|
710
|
+
if (!msg) {
|
|
711
|
+
const reason = unavailable(f, v);
|
|
712
|
+
if (reason != null) msg = reason ? `Not available: ${reason}` : "Not available.";
|
|
713
|
+
}
|
|
714
|
+
if (!msg) msg = controls.get(f.name)?.error?.() ?? null;
|
|
715
|
+
if (!msg && typeof f.validator === "function") {
|
|
716
|
+
try {
|
|
717
|
+
msg = f.validator(v, data);
|
|
718
|
+
} catch (err) {
|
|
719
|
+
msg = err?.message || String(err);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
if (msg) {
|
|
723
|
+
setFieldError(f.name, msg);
|
|
724
|
+
if (!firstBadName) firstBadName = f.name;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
if (firstBadName) {
|
|
728
|
+
controls.get(firstBadName)?.focus();
|
|
729
|
+
body.querySelector(`[data-picker="${firstBadName}"]`)?.focus();
|
|
730
|
+
body.querySelector(`[name="${firstBadName}"]:not([type="hidden"])`)?.focus();
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
close(data);
|
|
734
|
+
});
|
|
735
|
+
requestAnimationFrame(() => {
|
|
736
|
+
const first = [...body.querySelectorAll('input:not([type="hidden"]), select, textarea')].find((el) => !el.closest("[data-field-row]")?.hidden);
|
|
737
|
+
first?.focus();
|
|
738
|
+
});
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
function openConfirm({
|
|
742
|
+
title,
|
|
743
|
+
message,
|
|
744
|
+
confirmLabel = "OK",
|
|
745
|
+
cancelLabel = "Cancel",
|
|
746
|
+
danger = false,
|
|
747
|
+
icon = null
|
|
748
|
+
} = {}) {
|
|
749
|
+
return new Promise((resolve) => {
|
|
750
|
+
const body = document.createElement("div");
|
|
751
|
+
body.className = "twm-modal__body-host";
|
|
752
|
+
const messageEl = document.createElement("div");
|
|
753
|
+
messageEl.className = "twm-modal__body twm-modal__body--text";
|
|
754
|
+
messageEl.textContent = message == null ? "" : String(message);
|
|
755
|
+
const actionsEl = document.createElement("div");
|
|
756
|
+
actionsEl.className = "twm-modal__actions";
|
|
757
|
+
const button = (action, className, label) => {
|
|
758
|
+
const btn = document.createElement("button");
|
|
759
|
+
btn.type = "button";
|
|
760
|
+
btn.className = className;
|
|
761
|
+
btn.dataset.action = action;
|
|
762
|
+
btn.textContent = label == null ? "" : String(label);
|
|
763
|
+
actionsEl.appendChild(btn);
|
|
764
|
+
};
|
|
765
|
+
button("cancel", "twm-btn", cancelLabel);
|
|
766
|
+
button("confirm", danger ? "twm-btn twm-btn--danger" : "twm-btn twm-btn--primary", confirmLabel);
|
|
767
|
+
body.append(messageEl, actionsEl);
|
|
768
|
+
let _resolved = false;
|
|
769
|
+
const win = new ManagedWindow({
|
|
770
|
+
container: _modalHost,
|
|
771
|
+
id: `twm-confirm-${_modalSeq++}`,
|
|
772
|
+
title: title || "Confirm",
|
|
773
|
+
icon: icon || (danger ? "warning" : "help"),
|
|
774
|
+
content: body,
|
|
775
|
+
modal: true,
|
|
776
|
+
canMinimize: false,
|
|
777
|
+
canMaximize: false,
|
|
778
|
+
canResize: false,
|
|
779
|
+
canDrag: true,
|
|
780
|
+
defaultWidth: 380,
|
|
781
|
+
defaultHeight: 180,
|
|
782
|
+
onClose: () => {
|
|
783
|
+
if (!_resolved) {
|
|
784
|
+
_resolved = true;
|
|
785
|
+
resolve(false);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
});
|
|
789
|
+
win.show();
|
|
790
|
+
const close = (v) => {
|
|
791
|
+
if (_resolved) return;
|
|
792
|
+
_resolved = true;
|
|
793
|
+
resolve(v);
|
|
794
|
+
try {
|
|
795
|
+
win.close({ force: true });
|
|
796
|
+
} catch {
|
|
797
|
+
}
|
|
798
|
+
};
|
|
799
|
+
body.querySelector('[data-action="cancel"]').addEventListener("click", () => close(false));
|
|
800
|
+
body.querySelector('[data-action="confirm"]').addEventListener("click", () => close(true));
|
|
801
|
+
requestAnimationFrame(() => {
|
|
802
|
+
body.querySelector(`[data-action="${danger ? "cancel" : "confirm"}"]`)?.focus();
|
|
803
|
+
});
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
function openModal({
|
|
807
|
+
title = "Dialog",
|
|
808
|
+
icon = "info",
|
|
809
|
+
content = null,
|
|
810
|
+
actions = null,
|
|
811
|
+
width = 640,
|
|
812
|
+
height = 480,
|
|
813
|
+
onMount = null,
|
|
814
|
+
backdropBlur = void 0,
|
|
815
|
+
backdropOpacity = void 0,
|
|
816
|
+
maximizable = false,
|
|
817
|
+
onMaximizeChange = null
|
|
818
|
+
} = {}) {
|
|
819
|
+
return new Promise((resolve) => {
|
|
820
|
+
const body = document.createElement("div");
|
|
821
|
+
body.className = "twm-modal__body-host";
|
|
822
|
+
const bodyInner = document.createElement("div");
|
|
823
|
+
bodyInner.className = "twm-modal__body twm-modal__body--rich";
|
|
824
|
+
if (content instanceof HTMLElement) bodyInner.appendChild(content);
|
|
825
|
+
body.appendChild(bodyInner);
|
|
826
|
+
const acts = Array.isArray(actions) && actions.length > 0 ? actions : [{ label: "Close", value: null }];
|
|
827
|
+
const actionsEl = document.createElement("div");
|
|
828
|
+
actionsEl.className = "twm-modal__actions";
|
|
829
|
+
acts.forEach((a, i) => {
|
|
830
|
+
const btn = document.createElement("button");
|
|
831
|
+
btn.type = "button";
|
|
832
|
+
let cls = "twm-btn";
|
|
833
|
+
if (a.primary) cls += " twm-btn--primary";
|
|
834
|
+
if (a.danger) cls += " twm-btn--danger";
|
|
835
|
+
btn.className = cls;
|
|
836
|
+
if (a.icon) {
|
|
837
|
+
const glyph = document.createElement("span");
|
|
838
|
+
glyph.className = "material-symbols-outlined twm-btn__glyph";
|
|
839
|
+
glyph.textContent = String(a.icon);
|
|
840
|
+
glyph.setAttribute("aria-hidden", "true");
|
|
841
|
+
const text = document.createElement("span");
|
|
842
|
+
text.className = "twm-btn__label";
|
|
843
|
+
text.textContent = String(a.label || "");
|
|
844
|
+
btn.append(glyph, text);
|
|
845
|
+
} else {
|
|
846
|
+
btn.textContent = String(a.label || "");
|
|
847
|
+
}
|
|
848
|
+
btn.dataset.actionIdx = String(i);
|
|
849
|
+
if (a.disabled) btn.disabled = true;
|
|
850
|
+
if (a.value !== void 0 && a.value !== null) {
|
|
851
|
+
btn.dataset.actionValue = String(a.value);
|
|
852
|
+
}
|
|
853
|
+
actionsEl.appendChild(btn);
|
|
854
|
+
});
|
|
855
|
+
body.appendChild(actionsEl);
|
|
856
|
+
let _resolved = false;
|
|
857
|
+
let _onMax = null;
|
|
858
|
+
const _dropMaxListener = () => {
|
|
859
|
+
if (!_onMax) return;
|
|
860
|
+
window.removeEventListener("managed-window-maximized", _onMax);
|
|
861
|
+
_onMax = null;
|
|
862
|
+
};
|
|
863
|
+
const win = new ManagedWindow({
|
|
864
|
+
container: _modalHost,
|
|
865
|
+
id: `twm-modal-${_modalSeq++}`,
|
|
866
|
+
title,
|
|
867
|
+
icon,
|
|
868
|
+
content: body,
|
|
869
|
+
modal: true,
|
|
870
|
+
backdropBlur,
|
|
871
|
+
backdropOpacity,
|
|
872
|
+
canMinimize: false,
|
|
873
|
+
// C29. OPT-IN, and `false` is still the default — a confirmation
|
|
874
|
+
// and a two-field form have nothing to do with the extra room, and
|
|
875
|
+
// a button that grows a dialog nobody wanted grown is noise in the
|
|
876
|
+
// one place a user looks for the X. `onMaximize` is deliberately
|
|
877
|
+
// NOT set: see the C29 note in the header — with nothing claiming
|
|
878
|
+
// the gesture, `toggleMaximize` runs the rectangle, which is the
|
|
879
|
+
// only thing maximise can mean for a window with no tile.
|
|
880
|
+
canMaximize: !!maximizable,
|
|
881
|
+
canResize: true,
|
|
882
|
+
canDrag: true,
|
|
883
|
+
defaultWidth: width,
|
|
884
|
+
defaultHeight: height,
|
|
885
|
+
// Esc, the X and the backdrop all land here without passing through
|
|
886
|
+
// `close`, so the listener is dropped here as well as there — the
|
|
887
|
+
// three dismissals a user reaches for most are exactly the ones
|
|
888
|
+
// that would otherwise leak it.
|
|
889
|
+
onClose: () => {
|
|
890
|
+
_dropMaxListener();
|
|
891
|
+
if (!_resolved) {
|
|
892
|
+
_resolved = true;
|
|
893
|
+
resolve(null);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
});
|
|
897
|
+
win.show();
|
|
898
|
+
if (maximizable && typeof onMaximizeChange === "function") {
|
|
899
|
+
_onMax = (e) => {
|
|
900
|
+
if (e.detail?.id !== win.id) return;
|
|
901
|
+
try {
|
|
902
|
+
onMaximizeChange(!!e.detail.maximized, bodyInner);
|
|
903
|
+
} catch (err) {
|
|
904
|
+
console.warn("[modal] onMaximizeChange threw", err);
|
|
905
|
+
}
|
|
906
|
+
};
|
|
907
|
+
window.addEventListener("managed-window-maximized", _onMax);
|
|
908
|
+
}
|
|
909
|
+
const close = (value) => {
|
|
910
|
+
if (_resolved) return;
|
|
911
|
+
_resolved = true;
|
|
912
|
+
_dropMaxListener();
|
|
913
|
+
resolve(value);
|
|
914
|
+
try {
|
|
915
|
+
win.close({ force: true });
|
|
916
|
+
} catch {
|
|
917
|
+
}
|
|
918
|
+
};
|
|
919
|
+
actionsEl.addEventListener("click", (e) => {
|
|
920
|
+
const btn = e.target.closest("[data-action-idx]");
|
|
921
|
+
if (!btn) return;
|
|
922
|
+
const idx = Number(btn.dataset.actionIdx);
|
|
923
|
+
const a = acts[idx];
|
|
924
|
+
if (!a) return;
|
|
925
|
+
if (a.keepOpen) {
|
|
926
|
+
try {
|
|
927
|
+
a.onClick?.(close);
|
|
928
|
+
} catch (err) {
|
|
929
|
+
console.warn(err);
|
|
930
|
+
}
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
close(a.value);
|
|
934
|
+
});
|
|
935
|
+
const controls = {
|
|
936
|
+
// NOT `CSS.escape`. It is a browser global that jsdom does not
|
|
937
|
+
// provide, and this file is mounted under jsdom by four render
|
|
938
|
+
// tests — so reaching for it turns "the dialog is valid" into a
|
|
939
|
+
// ReferenceError in every one of them, and into nothing at all in a
|
|
940
|
+
// headless consumer. An attribute selector needs `"` and `\`
|
|
941
|
+
// escaped and nothing else.
|
|
942
|
+
actionButton: (value) => actionsEl.querySelector(
|
|
943
|
+
`[data-action-value="${String(value).replace(/["\\]/g, "\\$&")}"]`
|
|
944
|
+
),
|
|
945
|
+
setActionEnabled(value, enabled) {
|
|
946
|
+
const btn = controls.actionButton(value);
|
|
947
|
+
if (btn) btn.disabled = !enabled;
|
|
948
|
+
return !!btn;
|
|
949
|
+
}
|
|
950
|
+
};
|
|
951
|
+
if (typeof onMount === "function") {
|
|
952
|
+
requestAnimationFrame(() => {
|
|
953
|
+
try {
|
|
954
|
+
onMount(bodyInner, controls);
|
|
955
|
+
} catch (err) {
|
|
956
|
+
console.warn(err);
|
|
957
|
+
}
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
requestAnimationFrame(() => {
|
|
961
|
+
const field = bodyInner.querySelector(
|
|
962
|
+
'input:not([type="hidden"]):not([disabled]):not([readonly]), textarea:not([disabled]):not([readonly]), select:not([disabled])'
|
|
963
|
+
);
|
|
964
|
+
if (field) {
|
|
965
|
+
field.focus();
|
|
966
|
+
try {
|
|
967
|
+
field.setSelectionRange?.(field.value.length, field.value.length);
|
|
968
|
+
} catch {
|
|
969
|
+
}
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
const idx = acts.findIndex((a) => a.primary);
|
|
973
|
+
const which = idx >= 0 ? idx : acts.length - 1;
|
|
974
|
+
actionsEl.querySelector(`[data-action-idx="${which}"]`)?.focus();
|
|
975
|
+
});
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
function _estimateHeight(fields) {
|
|
979
|
+
const height = (f) => {
|
|
980
|
+
if (f.section) return 32;
|
|
981
|
+
const hint = f.hint ? 16 : 0;
|
|
982
|
+
if (f.type === "textarea") return 32 + (Number(f.rows) || 4) * 18 + hint;
|
|
983
|
+
if (f.type === "checkbox" || f.type === "toggle") return 32 + hint;
|
|
984
|
+
if (f.type === "colorList") {
|
|
985
|
+
const n = Array.isArray(f.default) ? f.default.length : 0;
|
|
986
|
+
return 46 + Math.max(n, 1) * 40 + 40 + hint;
|
|
987
|
+
}
|
|
988
|
+
if (f.type === "color") return 46 + (Array.isArray(f.presets) && f.presets.length ? 28 : 0) + hint;
|
|
989
|
+
if (f.type === "radio") {
|
|
990
|
+
const opts = flatChoices(f.options);
|
|
991
|
+
return 12 + opts.reduce((h, o) => h + 22 + (o.description || o.reason ? 16 : 0), 0) + hint;
|
|
992
|
+
}
|
|
993
|
+
return 46 + hint;
|
|
994
|
+
};
|
|
995
|
+
let body = 0;
|
|
996
|
+
for (const f of fields) {
|
|
997
|
+
body += Array.isArray(f?.row) ? Math.max(0, ..._formFields(f.row).map(height)) : height(f);
|
|
998
|
+
}
|
|
999
|
+
const base = 36 + 46 + 28 + body;
|
|
1000
|
+
const max = Math.floor(window.innerHeight * 0.8);
|
|
1001
|
+
return Math.max(200, Math.min(base, max));
|
|
1002
|
+
}
|
|
1003
|
+
function _estimateWidth(fields) {
|
|
1004
|
+
const wide = fields.some((f) => f && (f.type === "textarea" || f.type === "colorList" || f.section || Array.isArray(f.row)));
|
|
1005
|
+
return wide ? 640 : 480;
|
|
1006
|
+
}
|
|
1007
|
+
function _formFields(entries) {
|
|
1008
|
+
return entries.flatMap((e) => Array.isArray(e?.row) ? e.row : [e]).filter((f) => f && f.section == null);
|
|
1009
|
+
}
|
|
1010
|
+
function _bounded(f) {
|
|
1011
|
+
return f.min != null || f.max != null;
|
|
1012
|
+
}
|
|
1013
|
+
function _outOfRange(f, v) {
|
|
1014
|
+
if (f.type !== "number" || !_bounded(f) || v == null || Number.isNaN(v)) return false;
|
|
1015
|
+
return f.min != null && v < Number(f.min) || f.max != null && v > Number(f.max);
|
|
1016
|
+
}
|
|
1017
|
+
function _rangeMessage(f) {
|
|
1018
|
+
if (f.min != null && f.max != null) return `Must be between ${f.min} and ${f.max}.`;
|
|
1019
|
+
return f.min != null ? `Must be at least ${f.min}.` : `Must be at most ${f.max}.`;
|
|
1020
|
+
}
|
|
1021
|
+
function _offered(options) {
|
|
1022
|
+
return flatChoices(options).filter((o) => !o.disabled);
|
|
1023
|
+
}
|
|
1024
|
+
function _datalistOptions(options) {
|
|
1025
|
+
return _offered(options).map((o) => `<option value="${_esc(o.value)}"></option>`).join("");
|
|
1026
|
+
}
|
|
1027
|
+
function _mountControl(host, f, value, onChange) {
|
|
1028
|
+
const slot = host.querySelector(`[data-control-slot="${f.name}"]`);
|
|
1029
|
+
if (!slot) return null;
|
|
1030
|
+
if (f.type === "toggle") {
|
|
1031
|
+
const toggle = new ToggleField({
|
|
1032
|
+
name: f.name,
|
|
1033
|
+
checked: value === true || value === "true",
|
|
1034
|
+
ariaLabel: f.label || f.name,
|
|
1035
|
+
onText: f.onText ?? null,
|
|
1036
|
+
offText: f.offText ?? null,
|
|
1037
|
+
disabled: !!f.disabled,
|
|
1038
|
+
compact: !!f.compact
|
|
1039
|
+
});
|
|
1040
|
+
slot.appendChild(toggle.render());
|
|
1041
|
+
return {
|
|
1042
|
+
read: () => toggle.get(),
|
|
1043
|
+
write: (v) => toggle.set(v),
|
|
1044
|
+
focus: () => toggle.input?.focus(),
|
|
1045
|
+
unavailable: () => null,
|
|
1046
|
+
dispose: () => toggle.dispose()
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
if (f.type === "color" || f.type === "colorList") {
|
|
1050
|
+
const c2 = f.type === "color" ? new ColorField({ name: f.name, value: value ?? "", presets: f.presets, label: f.label }) : new ColorListField({ name: f.name, value: value ?? [], presets: f.presets });
|
|
1051
|
+
slot.replaceWith(c2.render());
|
|
1052
|
+
const list2 = f.type === "colorList";
|
|
1053
|
+
return {
|
|
1054
|
+
read: () => list2 ? c2.getTexts() : c2.getText(),
|
|
1055
|
+
submit: () => c2.getValue(),
|
|
1056
|
+
write: (v) => c2.setValue(v),
|
|
1057
|
+
focus: () => c2.focus(),
|
|
1058
|
+
unavailable: () => null,
|
|
1059
|
+
error: () => {
|
|
1060
|
+
if (c2.getValue() !== null) return null;
|
|
1061
|
+
if (!list2) return "Not a hex colour: use #rgb or #rrggbb.";
|
|
1062
|
+
const bad = c2.invalidIndexes().map((i) => i + 1).join(", ");
|
|
1063
|
+
return `Not a hex colour (row ${bad}): use #rgb or #rrggbb.`;
|
|
1064
|
+
},
|
|
1065
|
+
dispose: () => c2.dispose()
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
const common = { keepMissing: false, loose: true };
|
|
1069
|
+
const disabledReason = (entry) => entry?.disabled ? entry.reason || "" : null;
|
|
1070
|
+
if (f.type === "select") {
|
|
1071
|
+
const c2 = selectBox(f.options, value, { ...common, valueAttr: "value", className: "" });
|
|
1072
|
+
c2.box.name = f.name;
|
|
1073
|
+
if (f.required) c2.box.required = true;
|
|
1074
|
+
slot.replaceWith(c2.box);
|
|
1075
|
+
return {
|
|
1076
|
+
// No option selected reads '', as an empty select always has (and
|
|
1077
|
+
// as it submits); a selected option reads as its own value.
|
|
1078
|
+
read: () => c2.entry() ? c2.entry().value : "",
|
|
1079
|
+
// A value the list does not offer enabled clears the select, as
|
|
1080
|
+
// writing an unknown `.value` always has (it then submits '').
|
|
1081
|
+
write: (v) => {
|
|
1082
|
+
if (!c2.select(v)) c2.box.selectedIndex = -1;
|
|
1083
|
+
},
|
|
1084
|
+
focus: () => c2.box.focus(),
|
|
1085
|
+
unavailable: () => disabledReason(c2.entry()),
|
|
1086
|
+
setOptions: c2.setOptions
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
const list = (v) => Array.isArray(v) ? v : v == null || v === "" ? [] : [v];
|
|
1090
|
+
if (f.type === "checklist") {
|
|
1091
|
+
const c2 = toggleChips({
|
|
1092
|
+
...common,
|
|
1093
|
+
options: f.options,
|
|
1094
|
+
value: list(value),
|
|
1095
|
+
label: f.label || f.name,
|
|
1096
|
+
onChange: () => onChange(f.name)
|
|
1097
|
+
});
|
|
1098
|
+
slot.replaceWith(c2.el);
|
|
1099
|
+
return {
|
|
1100
|
+
read: c2.valueOf,
|
|
1101
|
+
write: (v) => c2.setValue(list(v)),
|
|
1102
|
+
focus: () => c2.el.querySelector("button:not(:disabled)")?.focus(),
|
|
1103
|
+
unavailable: () => null,
|
|
1104
|
+
// a disabled chip cannot be pressed, by hand or by set
|
|
1105
|
+
setOptions: c2.setOptions
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
const c = choiceList({
|
|
1109
|
+
...common,
|
|
1110
|
+
options: f.options,
|
|
1111
|
+
value,
|
|
1112
|
+
label: f.label || f.name,
|
|
1113
|
+
name: `twm-form-${_modalSeq}-${f.name}`,
|
|
1114
|
+
onChange: () => onChange(f.name)
|
|
1115
|
+
});
|
|
1116
|
+
slot.replaceWith(c.el);
|
|
1117
|
+
return {
|
|
1118
|
+
read: () => c.valueOf() ?? null,
|
|
1119
|
+
write: (v) => {
|
|
1120
|
+
c.select(v);
|
|
1121
|
+
},
|
|
1122
|
+
focus: c.focus,
|
|
1123
|
+
unavailable: () => disabledReason(c.entry()),
|
|
1124
|
+
setOptions: c.setOptions
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
function _missing(f, v) {
|
|
1128
|
+
if (f.type === "picker") return !v;
|
|
1129
|
+
if (f.type === "checklist") return !Array.isArray(v) || v.length === 0;
|
|
1130
|
+
if (f.type === "radio") return v == null;
|
|
1131
|
+
if (f.type === "color") return v === "";
|
|
1132
|
+
if (f.type === "colorList") return Array.isArray(v) && v.length === 0;
|
|
1133
|
+
return false;
|
|
1134
|
+
}
|
|
1135
|
+
function _createSpec(f) {
|
|
1136
|
+
if (!f || !f.create) return null;
|
|
1137
|
+
if (f.create === true) return { onCreate: async (v) => v, hint: null };
|
|
1138
|
+
return {
|
|
1139
|
+
onCreate: typeof f.create.onCreate === "function" ? f.create.onCreate : async (v) => v,
|
|
1140
|
+
hint: f.create.hint ?? null
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
function _pickerLabel(f, value) {
|
|
1144
|
+
if (typeof f.displayFor !== "function") return value == null ? "" : String(value);
|
|
1145
|
+
try {
|
|
1146
|
+
return String(f.displayFor(value) ?? "");
|
|
1147
|
+
} catch (err) {
|
|
1148
|
+
console.warn("[modal] displayFor threw", err);
|
|
1149
|
+
return String(value ?? "");
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
function _wirePicker(host, f) {
|
|
1153
|
+
const hidden = host.querySelector(`input[type="hidden"][name="${f.name}"]`);
|
|
1154
|
+
const display = host.querySelector(`[data-picker-display="${f.name}"]`);
|
|
1155
|
+
const btn = host.querySelector(`[data-picker="${f.name}"]`);
|
|
1156
|
+
if (!hidden || !display || !btn || typeof f.pick !== "function") return;
|
|
1157
|
+
let busy = false;
|
|
1158
|
+
const open = async () => {
|
|
1159
|
+
if (busy) return;
|
|
1160
|
+
busy = true;
|
|
1161
|
+
let picked;
|
|
1162
|
+
try {
|
|
1163
|
+
picked = await f.pick(hidden.value);
|
|
1164
|
+
} catch (err) {
|
|
1165
|
+
console.warn("[modal] pick threw", err);
|
|
1166
|
+
picked = null;
|
|
1167
|
+
} finally {
|
|
1168
|
+
busy = false;
|
|
1169
|
+
}
|
|
1170
|
+
if (picked == null) return;
|
|
1171
|
+
const isPair = typeof picked === "object";
|
|
1172
|
+
hidden.value = String((isPair ? picked.value : picked) ?? "");
|
|
1173
|
+
display.value = isPair && picked.label != null ? String(picked.label) : _pickerLabel(f, hidden.value);
|
|
1174
|
+
hidden.dispatchEvent(new Event("change", { bubbles: true }));
|
|
1175
|
+
};
|
|
1176
|
+
btn.addEventListener("click", open);
|
|
1177
|
+
display.addEventListener("click", open);
|
|
1178
|
+
}
|
|
1179
|
+
function _wireComboboxHint(host, f, optionsOf) {
|
|
1180
|
+
const input = host.querySelector(`input[name="${f.name}"]`);
|
|
1181
|
+
const hintEl = host.querySelector(`.twm-combobox__hint[data-for="${f.name}"]`);
|
|
1182
|
+
if (!input || !hintEl) return;
|
|
1183
|
+
if (f.create === true) return;
|
|
1184
|
+
const label = _createSpec(f).hint || "new entry";
|
|
1185
|
+
const update = () => {
|
|
1186
|
+
const v = input.value.trim();
|
|
1187
|
+
const known = new Set(flatChoices(optionsOf()).map((o) => String(o.value)));
|
|
1188
|
+
if (v && !known.has(v)) {
|
|
1189
|
+
hintEl.textContent = `\u21B3 will create ${label} \u201C${v}\u201D`;
|
|
1190
|
+
hintEl.hidden = false;
|
|
1191
|
+
} else {
|
|
1192
|
+
hintEl.hidden = true;
|
|
1193
|
+
}
|
|
1194
|
+
};
|
|
1195
|
+
input.addEventListener("input", update);
|
|
1196
|
+
update();
|
|
1197
|
+
return update;
|
|
1198
|
+
}
|
|
1199
|
+
function _renderEntry(f, defaults) {
|
|
1200
|
+
if (f && f.section != null) {
|
|
1201
|
+
const attr = f.name ? ` data-field-row="${f.name}"` : "";
|
|
1202
|
+
return `<div class="twm-modal__section"${attr}>${_esc(f.section)}</div>`;
|
|
1203
|
+
}
|
|
1204
|
+
if (Array.isArray(f?.row)) {
|
|
1205
|
+
const cells = _formFields(f.row).map((g) => _renderField(g, defaults[g.name] ?? g.default));
|
|
1206
|
+
return `<div class="twm-modal__field-group">${cells.join("")}</div>`;
|
|
1207
|
+
}
|
|
1208
|
+
return _renderField(f, defaults[f.name] ?? f.default);
|
|
1209
|
+
}
|
|
1210
|
+
function _renderField(f, value) {
|
|
1211
|
+
const required = f.required ? "required" : "";
|
|
1212
|
+
const rowMod = f.type === "textarea" || f.type === "checklist" || f.type === "radio" ? " twm-modal__row--multiline" : "";
|
|
1213
|
+
const hintHtml = f.hint ? `<div class="twm-modal__hint--field">${_esc(f.hint)}</div>` : "";
|
|
1214
|
+
const errHtml = `<div class="twm-modal__field-error" data-field-error="${f.name}" hidden></div>`;
|
|
1215
|
+
const wrap = (inner, tag = "label") => `
|
|
1216
|
+
<${tag} class="twm-modal__row${rowMod}" data-field-row="${f.name}">
|
|
1217
|
+
<span>${_esc(f.label || "")}</span>
|
|
1218
|
+
${inner}
|
|
1219
|
+
${hintHtml}
|
|
1220
|
+
${errHtml}
|
|
1221
|
+
</${tag}>
|
|
1222
|
+
`;
|
|
1223
|
+
const slot = `<span data-control-slot="${f.name}"></span>`;
|
|
1224
|
+
if (f.type === "checkbox") {
|
|
1225
|
+
const checked = value === true || value === "true" ? "checked" : "";
|
|
1226
|
+
return `
|
|
1227
|
+
<label class="twm-modal__row" data-field-row="${f.name}">
|
|
1228
|
+
<span></span>
|
|
1229
|
+
<span class="twm-modal__check">
|
|
1230
|
+
<input name="${f.name}" type="checkbox" ${checked}>
|
|
1231
|
+
<span>${_esc(f.label || "")}</span>
|
|
1232
|
+
</span>
|
|
1233
|
+
${hintHtml}
|
|
1234
|
+
${errHtml}
|
|
1235
|
+
</label>
|
|
1236
|
+
`;
|
|
1237
|
+
}
|
|
1238
|
+
if (f.type === "checklist" || f.type === "radio") return wrap(slot, "div");
|
|
1239
|
+
if (f.type === "toggle") {
|
|
1240
|
+
return wrap(`<span class="twm-modal__toggle" data-control-slot="${f.name}"></span>`, "div");
|
|
1241
|
+
}
|
|
1242
|
+
if (f.type === "textarea") {
|
|
1243
|
+
const v2 = value == null ? "" : String(value);
|
|
1244
|
+
const rows = f.rows != null ? `rows="${Number(f.rows) || 4}"` : 'rows="4"';
|
|
1245
|
+
const placeholder2 = f.placeholder ? `placeholder="${_esc(f.placeholder)}"` : "";
|
|
1246
|
+
return wrap(`<textarea name="${f.name}" ${rows} ${placeholder2} ${required}>${_esc(v2)}</textarea>`);
|
|
1247
|
+
}
|
|
1248
|
+
if (f.type === "color" || f.type === "colorList") {
|
|
1249
|
+
if (f.type === "color") return wrap(slot);
|
|
1250
|
+
return `
|
|
1251
|
+
<div class="twm-modal__row twm-modal__row--multiline" data-field-row="${f.name}" role="group" aria-label="${_esc(f.label || "")}">
|
|
1252
|
+
<span>${_esc(f.label || "")}</span>
|
|
1253
|
+
${slot}
|
|
1254
|
+
${hintHtml}
|
|
1255
|
+
${errHtml}
|
|
1256
|
+
</div>
|
|
1257
|
+
`;
|
|
1258
|
+
}
|
|
1259
|
+
const v = value == null ? "" : String(value);
|
|
1260
|
+
if (f.type === "picker") {
|
|
1261
|
+
const placeholder2 = f.placeholder ? `placeholder="${_esc(f.placeholder)}"` : "";
|
|
1262
|
+
return wrap(`
|
|
1263
|
+
<div class="twm-picker">
|
|
1264
|
+
<input type="hidden" name="${f.name}" value="${_esc(v)}">
|
|
1265
|
+
<input class="twm-picker__display" data-picker-display="${f.name}" type="text"
|
|
1266
|
+
value="${_esc(_pickerLabel(f, v))}" ${placeholder2} readonly>
|
|
1267
|
+
<button type="button" class="twm-btn twm-picker__btn" data-picker="${f.name}"
|
|
1268
|
+
aria-label="Search" title="Search">
|
|
1269
|
+
<span class="material-symbols-outlined" aria-hidden="true">search</span>
|
|
1270
|
+
</button>
|
|
1271
|
+
</div>
|
|
1272
|
+
`);
|
|
1273
|
+
}
|
|
1274
|
+
if (f.type === "select" && f.create) {
|
|
1275
|
+
const shown = findChoice(flatChoices(f.options), value, true)?.disabled ? "" : v;
|
|
1276
|
+
const dlId = `ea-dl-${f.name}-${_modalSeq}`;
|
|
1277
|
+
const opts = _datalistOptions(f.options);
|
|
1278
|
+
const placeholder2 = f.placeholder ? `placeholder="${_esc(f.placeholder)}"` : 'placeholder="type to pick or create\u2026"';
|
|
1279
|
+
return wrap(`
|
|
1280
|
+
<div class="twm-combobox">
|
|
1281
|
+
<input name="${f.name}" type="text" list="${dlId}"
|
|
1282
|
+
value="${_esc(shown)}" ${placeholder2} ${required}
|
|
1283
|
+
autocomplete="off">
|
|
1284
|
+
<datalist id="${dlId}" data-datalist-for="${f.name}">${opts}</datalist>
|
|
1285
|
+
<span class="twm-combobox__hint" data-for="${f.name}" hidden></span>
|
|
1286
|
+
</div>
|
|
1287
|
+
`);
|
|
1288
|
+
}
|
|
1289
|
+
if (f.type === "select") return wrap(slot);
|
|
1290
|
+
const step = f.step != null ? `step="${f.step}"` : "";
|
|
1291
|
+
const placeholder = f.placeholder ? `placeholder="${_esc(f.placeholder)}"` : "";
|
|
1292
|
+
const type = f.type === "number" || f.type === "password" ? f.type : "text";
|
|
1293
|
+
let bounds = "";
|
|
1294
|
+
if (f.type === "number" && f.min != null) bounds += ` min="${_esc(f.min)}"`;
|
|
1295
|
+
if (f.type === "number" && f.max != null) bounds += ` max="${_esc(f.max)}"`;
|
|
1296
|
+
return wrap(`<input name="${f.name}" type="${type}" value="${_esc(v)}" ${step}${bounds} ${placeholder} ${required}>`);
|
|
1297
|
+
}
|
|
1298
|
+
function _esc(s) {
|
|
1299
|
+
return String(s == null ? "" : s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
export {
|
|
1303
|
+
SortableList,
|
|
1304
|
+
ColorListField,
|
|
1305
|
+
setModalHost,
|
|
1306
|
+
modalHost,
|
|
1307
|
+
openForm,
|
|
1308
|
+
openConfirm,
|
|
1309
|
+
openModal
|
|
1310
|
+
};
|
|
1311
|
+
//# sourceMappingURL=chunk-D3COLZZ2.js.map
|