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.
Files changed (262) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/LICENSE +0 -0
  3. package/README.md +2435 -42
  4. package/THIRD_PARTY_LICENSES.md +0 -0
  5. package/canvas.js +18 -0
  6. package/charts.js +21 -0
  7. package/core.js +14 -0
  8. package/css/base.css +1834 -75
  9. package/css/flexdesk.css +3165 -159
  10. package/css/overrides.css +16 -0
  11. package/css/reset.css +0 -0
  12. package/css/tokens.css +0 -0
  13. package/dist/canvas.js +6 -239
  14. package/dist/canvas.js.map +3 -3
  15. package/dist/charts.js +707 -18
  16. package/dist/charts.js.map +4 -4
  17. package/dist/chunk-3PHCPZHT.js +0 -0
  18. package/dist/chunk-3PHCPZHT.js.map +0 -0
  19. package/dist/chunk-4A7SJC3T.js +503 -0
  20. package/dist/chunk-4A7SJC3T.js.map +7 -0
  21. package/dist/{chunk-6JSOVNID.js → chunk-CVMCZZX2.js} +635 -10
  22. package/dist/chunk-CVMCZZX2.js.map +7 -0
  23. package/dist/chunk-D3COLZZ2.js +1311 -0
  24. package/dist/chunk-D3COLZZ2.js.map +7 -0
  25. package/dist/chunk-FOOS3T5L.js +0 -0
  26. package/dist/chunk-FOOS3T5L.js.map +0 -0
  27. package/dist/{chunk-SN4LSOAR.js → chunk-GOQLR5WR.js} +452 -2
  28. package/dist/chunk-GOQLR5WR.js.map +7 -0
  29. package/dist/{chunk-FL5KFNQH.js → chunk-HNU6MM2B.js} +52 -17
  30. package/dist/chunk-HNU6MM2B.js.map +7 -0
  31. package/dist/chunk-HYJYYEPK.js +375 -0
  32. package/dist/chunk-HYJYYEPK.js.map +7 -0
  33. package/dist/chunk-JKZLNKG6.js +2041 -0
  34. package/dist/chunk-JKZLNKG6.js.map +7 -0
  35. package/dist/chunk-JYWURG5T.js +0 -0
  36. package/dist/chunk-JYWURG5T.js.map +0 -0
  37. package/dist/{chunk-FBGP5KN5.js → chunk-LWV23YVE.js} +5073 -532
  38. package/dist/chunk-LWV23YVE.js.map +7 -0
  39. package/dist/chunk-N7HC3ADV.js +30 -0
  40. package/dist/chunk-N7HC3ADV.js.map +7 -0
  41. package/dist/{chunk-RQM5Z2MU.js → chunk-RMTRWWEO.js} +633 -311
  42. package/dist/chunk-RMTRWWEO.js.map +7 -0
  43. package/dist/{chunk-NLTOY57M.js → chunk-UURZ37SD.js} +28 -48
  44. package/dist/chunk-UURZ37SD.js.map +7 -0
  45. package/dist/{chunk-B5Q53LAJ.js → chunk-UWO43BZU.js} +2 -2
  46. package/dist/chunk-UWO43BZU.js.map +7 -0
  47. package/dist/chunk-WVFGV5FT.js +0 -0
  48. package/dist/chunk-WVFGV5FT.js.map +0 -0
  49. package/dist/core.js +170 -118
  50. package/dist/core.js.map +2 -2
  51. package/dist/editor.js +156 -70
  52. package/dist/editor.js.map +2 -2
  53. package/dist/flexdesk.css +3165 -159
  54. package/dist/flow.js +2789 -306
  55. package/dist/flow.js.map +4 -4
  56. package/dist/host.js +109 -3
  57. package/dist/host.js.map +3 -3
  58. package/dist/reset.css +0 -0
  59. package/dist/tile_registry-6WZPTUZV.js +0 -0
  60. package/dist/tile_registry-6WZPTUZV.js.map +0 -0
  61. package/dist/tiles.js +657 -234
  62. package/dist/tiles.js.map +4 -4
  63. package/dist/tokens.css +0 -0
  64. package/dist/widgets.js +3290 -2124
  65. package/dist/widgets.js.map +4 -4
  66. package/dist/wm.js +1227 -347
  67. package/dist/wm.js.map +4 -4
  68. package/editor.js +20 -0
  69. package/flow.js +21 -0
  70. package/host.js +14 -0
  71. package/package.json +12 -1
  72. package/src/canvas/canvas_adapter.js +5 -0
  73. package/src/canvas/edge_router.js +0 -0
  74. package/src/canvas/graph/connector_router.js +0 -0
  75. package/src/canvas/graph/history_service.js +0 -0
  76. package/src/canvas/graph/node_platform.js +0 -0
  77. package/src/canvas/index.js +1 -0
  78. package/src/canvas/layered_layout.js +244 -0
  79. package/src/charts/chart_types.js +0 -0
  80. package/src/charts/data_series_plot_window.js +0 -0
  81. package/src/charts/downsample.js +2 -2
  82. package/src/charts/plot_config.js +53 -29
  83. package/src/charts/plot_config_editor.js +627 -0
  84. package/src/charts/plot_grid.js +449 -0
  85. package/src/charts/plot_popout_window.js +103 -317
  86. package/src/charts/plotly_wrapper.js +22 -2
  87. package/src/charts/series_transforms.js +206 -0
  88. package/src/core/event_bus.js +0 -0
  89. package/src/core/logging.js +1 -1
  90. package/src/core/settings.js +105 -19
  91. package/src/core/state_guard.js +93 -51
  92. package/src/core/state_guard_config.js +85 -74
  93. package/src/core/state_machine.js +0 -0
  94. package/src/editor/editor_pane.js +3 -3
  95. package/src/editor/monaco_editor_factory.js +124 -60
  96. package/src/editor/monaco_loader.js +103 -28
  97. package/src/editor/notebook_search.js +0 -0
  98. package/src/editor/notebook_tab_bar.js +38 -52
  99. package/src/editor/notebook_undo_manager.js +0 -0
  100. package/src/editor/split_pane_container.js +6 -3
  101. package/src/flow/canvas_editor/connections.js +90 -0
  102. package/src/flow/canvas_editor/editor.js +2351 -153
  103. package/src/flow/canvas_editor/index.js +2 -1
  104. package/src/flow/canvas_editor/layout.js +119 -12
  105. package/src/flow/canvas_editor/strings.js +80 -0
  106. package/src/flow/kit/always_before.js +0 -0
  107. package/src/flow/kit/catalogue.js +97 -3
  108. package/src/flow/kit/chip_input.js +0 -0
  109. package/src/flow/kit/dom.js +0 -0
  110. package/src/flow/kit/findings.js +0 -0
  111. package/src/flow/kit/history.js +0 -0
  112. package/src/flow/kit/index.js +3 -3
  113. package/src/flow/kit/keys.js +7 -1
  114. package/src/flow/kit/logic_graph.js +97 -15
  115. package/src/flow/kit/references.js +0 -0
  116. package/src/flow/kit/settings_panel.js +0 -0
  117. package/src/flow/kit/settings_schema.js +2 -1
  118. package/src/flow/kit/step_picker.js +0 -0
  119. package/src/flow/kit/strings.js +3 -1
  120. package/src/flow/kit/value_picker.js +0 -0
  121. package/src/flow/kit/widgets.js +86 -109
  122. package/src/flow/lanes/editor.js +136 -14
  123. package/src/flow/lanes/index.js +3 -1
  124. package/src/flow/lanes/layout.js +0 -0
  125. package/src/flow/lanes/pipeline.js +44 -2
  126. package/src/flow/lanes/preview.js +0 -0
  127. package/src/flow/lanes/strings.js +1 -0
  128. package/src/flow/outline/editor.js +0 -0
  129. package/src/flow/outline/index.js +0 -0
  130. package/src/flow/outline/mapping.js +0 -0
  131. package/src/flow/outline/menu.js +0 -0
  132. package/src/flow/outline/operations.js +0 -0
  133. package/src/flow/outline/recognise.js +0 -0
  134. package/src/flow/outline/strings.js +0 -0
  135. package/src/help/help_modal.js +168 -42
  136. package/src/help/help_registry.js +12 -1
  137. package/src/host/external_links.js +0 -0
  138. package/src/host/file_picker.js +117 -0
  139. package/src/host/host.js +10 -2
  140. package/src/host/photino_host.js +9 -0
  141. package/src/host/photino_host.md +21 -3
  142. package/src/host/pywebview_host.js +10 -0
  143. package/src/tiles/chart_tiles.js +53 -245
  144. package/src/tiles/config_schema.js +8 -0
  145. package/src/tiles/figure_tile.js +322 -0
  146. package/src/tiles/layout_persistence.js +3 -3
  147. package/src/tiles/list_tiles.js +7 -7
  148. package/src/tiles/scope_picker.js +0 -0
  149. package/src/tiles/tile_base.js +1 -1
  150. package/src/tiles/tile_config_form.js +442 -0
  151. package/src/tiles/tile_grid.js +2 -0
  152. package/src/tiles/tile_registry.js +0 -0
  153. package/src/tiling/bottom_panel_tabs.js +67 -99
  154. package/src/tiling/command_palette.js +64 -19
  155. package/src/tiling/content_registry.js +4 -1
  156. package/src/tiling/desktops.js +11 -1
  157. package/src/tiling/entity_sources.js +0 -0
  158. package/src/tiling/keymap.js +403 -128
  159. package/src/tiling/kind_taxonomy.js +78 -0
  160. package/src/tiling/landing_table.js +0 -0
  161. package/src/tiling/loading_overlay.js +0 -0
  162. package/src/tiling/nav_panel.js +59 -10
  163. package/src/tiling/page_factory.js +5 -5
  164. package/src/tiling/panel_availability.js +49 -0
  165. package/src/tiling/panel_keys.js +0 -0
  166. package/src/tiling/shell.js +114 -12
  167. package/src/tiling/tab_strip.js +0 -0
  168. package/src/tiling/tile_breadcrumb.js +0 -0
  169. package/src/tiling/tile_renderer.js +105 -9
  170. package/src/tiling/tile_tab_menu.js +0 -0
  171. package/src/tiling/tile_tree.js +0 -0
  172. package/src/tiling/wm.js +562 -30
  173. package/src/tiling/zoom.js +0 -0
  174. package/src/ui/base/component_base.js +0 -0
  175. package/src/ui/base/controller_base.js +0 -0
  176. package/src/ui/base/page_base.js +0 -0
  177. package/src/ui/components/about_dialog.js +0 -0
  178. package/src/ui/components/action_dropdown.js +30 -2
  179. package/src/ui/components/alert_log.js +0 -0
  180. package/src/ui/components/alerts_pane.js +0 -0
  181. package/src/ui/components/attribute_list_editor.js +0 -0
  182. package/src/ui/components/autocomplete_field.js +0 -0
  183. package/src/ui/components/cell_editor_host.js +293 -0
  184. package/src/ui/components/cell_editors.js +480 -0
  185. package/src/ui/components/cell_grid.js +761 -0
  186. package/src/ui/components/cell_paste.js +243 -0
  187. package/src/ui/components/cell_selection.js +220 -0
  188. package/src/ui/components/choice_controls.js +351 -0
  189. package/src/ui/components/color_field.js +211 -0
  190. package/src/ui/components/color_list_field.js +210 -0
  191. package/src/ui/components/column_fit.js +0 -0
  192. package/src/ui/components/column_groups.js +314 -0
  193. package/src/ui/components/column_order.js +142 -0
  194. package/src/ui/components/computing_status_window.js +98 -25
  195. package/src/ui/components/confirm_dialog.js +81 -38
  196. package/src/ui/components/context_menu.js +2 -2
  197. package/src/ui/components/conversation_view.js +440 -0
  198. package/src/ui/components/crosstab.js +289 -0
  199. package/src/ui/components/data_table.js +696 -71
  200. package/src/ui/components/detail_header.js +22 -11
  201. package/src/ui/components/document_outline.js +397 -0
  202. package/src/ui/components/drag_reorder.js +0 -0
  203. package/src/ui/components/fact_table.js +0 -0
  204. package/src/ui/components/fill_handle.js +322 -0
  205. package/src/ui/components/gallery_picker.js +19 -8
  206. package/src/ui/components/header_actions.js +55 -0
  207. package/src/ui/components/inline_renamer.js +0 -0
  208. package/src/ui/components/job_progress.js +51 -0
  209. package/src/ui/components/managed_window.js +27 -4
  210. package/src/ui/components/menu_bar.js +745 -0
  211. package/src/ui/components/modal.js +449 -75
  212. package/src/ui/components/notification_history.js +3 -3
  213. package/src/ui/components/progress.js +483 -0
  214. package/src/ui/components/record_list.js +0 -0
  215. package/src/ui/components/row_activation.js +0 -0
  216. package/src/ui/components/section_stack.js +298 -0
  217. package/src/ui/components/series.js +173 -0
  218. package/src/ui/components/settings_view.js +634 -0
  219. package/src/ui/components/slide_out_panel.js +43 -6
  220. package/src/ui/components/slider_field.js +275 -18
  221. package/src/ui/components/sortable_list.js +0 -0
  222. package/src/ui/components/staged_edits.js +361 -0
  223. package/src/ui/components/staged_history.js +172 -0
  224. package/src/ui/components/table_filter_chips.js +0 -0
  225. package/src/ui/components/table_state_store.js +0 -0
  226. package/src/ui/components/table_structure.js +182 -0
  227. package/src/ui/components/table_values.js +0 -0
  228. package/src/ui/components/tabs.js +497 -0
  229. package/src/ui/components/tag_input.js +215 -0
  230. package/src/ui/components/toast.js +25 -8
  231. package/src/ui/components/toggle_field.js +175 -0
  232. package/src/ui/components/tree_view.js +55 -34
  233. package/src/ui/components/tsv.js +74 -0
  234. package/src/ui/components/window_taskbar.js +0 -0
  235. package/src/ui/components/wizard.js +563 -0
  236. package/src/ui/controllers/window_chrome_controller.js +2 -2
  237. package/src/ui/notification_center.js +375 -119
  238. package/src/ui/utils/auto_scrollbars.js +0 -0
  239. package/src/ui/utils/markdown.js +709 -0
  240. package/src/ui/utils/native_context_menu.js +0 -0
  241. package/src/ui/utils/overlay_scrollbar.js +16 -12
  242. package/src/ui/utils/raf_resize_observer.js +0 -0
  243. package/src/ui/utils/scroll_lock.js +0 -0
  244. package/src/ui/utils/strip_scroll.js +55 -0
  245. package/src/ui/utils/tooltip_service.js +0 -8
  246. package/tiles.js +27 -0
  247. package/widgets.js +69 -0
  248. package/wm.js +28 -0
  249. package/dist/chunk-6JSOVNID.js.map +0 -7
  250. package/dist/chunk-B5Q53LAJ.js.map +0 -7
  251. package/dist/chunk-FBGP5KN5.js.map +0 -7
  252. package/dist/chunk-FL5KFNQH.js.map +0 -7
  253. package/dist/chunk-NLTOY57M.js.map +0 -7
  254. package/dist/chunk-NNFTETK3.js +0 -633
  255. package/dist/chunk-NNFTETK3.js.map +0 -7
  256. package/dist/chunk-O5OHMWBB.js +0 -284
  257. package/dist/chunk-O5OHMWBB.js.map +0 -7
  258. package/dist/chunk-RQM5Z2MU.js.map +0 -7
  259. package/dist/chunk-SN4LSOAR.js.map +0 -7
  260. package/dist/chunk-W6FBJLPW.js +0 -367
  261. package/dist/chunk-W6FBJLPW.js.map +0 -7
  262. package/src/ui/controllers/panel_state_machine.js +0 -634
@@ -9,20 +9,56 @@
9
9
  * / a boolean (confirm).
10
10
  *
11
11
  * Field schema:
12
- * { name, label, type='text', default, step?, options?, required?,
13
- * placeholder?, hint?, validator?, rows?, create?, pick?, displayFor? }
12
+ * { name, label, type='text', default, step?, min?, max?, options?, required?,
13
+ * placeholder?, hint?, validator?, rows?, create?, pick?, displayFor?,
14
+ * onText?, offText?, disabled?, compact? }
14
15
  *
15
16
  * - type: 'text' | 'number' | 'password' | 'select' | 'textarea' |
16
- * 'checkbox' | 'picker'
17
+ * 'checkbox' | 'toggle' | 'picker' | 'checklist' | 'radio' |
18
+ * 'color' | 'colorList'
17
19
  * - hint: small dim help line rendered under the field
18
20
  * - validator: fn(value, allValues) => string | null (inline error)
21
+ * - min / max: a `number` field's bounds — the input's own `min` / `max`
22
+ * attributes, and on submit a value outside them is refused with a field
23
+ * error under that field ("Must be between 2 and 200."), not the browser's
24
+ * bubble. An empty, non-required field is not out of range.
19
25
  * - rows: number of textarea rows (only used for type='textarea')
26
+ * - toggle: a `ToggleField` switch, submitted as a boolean like a checkbox;
27
+ * `onText` / `offText` label its state, `disabled` and `compact` pass
28
+ * through to it
29
+ *
30
+ * Options — `select`, `checklist` and `radio` take `options`: bare values, or
31
+ * `{ value, label?, disabled?, reason?, description? }`. A value comes back AS
32
+ * ITSELF — an option whose value is the number 2 submits 2, not '2' — and a
33
+ * default matches an option of the same text (`'2'` selects 2). A `disabled`
34
+ * option is shown with its `reason` and is never the submitted value: it is not
35
+ * selected by a default or by `api.set`, the reader cannot pick it, and a
36
+ * submit that finds one selected anyway is refused with a field error.
37
+ * `{ group: 'Heading', options: [...] }` in a select's list is an `<optgroup>`;
38
+ * in a checklist or radio it is a `.twm-choice__group` heading over its
39
+ * options. A combobox (a `select` with `create`) offers a group's enabled
40
+ * options, flattened, and an entry naming a disabled one — a default or an
41
+ * `api.set` — leaves the box empty.
42
+ *
43
+ * - `checklist`: toggle chips; submits the ARRAY of chosen values in the
44
+ * list's order (`[]` for none). `required` means at least one.
45
+ * - `radio`: one radio row per option, each with its `description` under it;
46
+ * submits the chosen value, or null. `required` means one is chosen.
47
+ *
48
+ * These three are drawn by `choice_controls.js` — the same select, chips and
49
+ * radio list as the flow kit's settings widgets, so there is one of each.
20
50
  *
21
51
  * Section dividers — entries of the form `{ section: 'Label' }` are
22
52
  * rendered as a small uppercase heading that groups the fields below
23
53
  * them. Useful for longer forms. A section that also carries a `name`
24
54
  * can be hidden and shown by `onFieldChange`'s `setVisible`.
25
55
  *
56
+ * Rows — an entry of the form `{ row: [field, field, ...] }` puts its fields
57
+ * side by side (a min and a max, say). Each is an ordinary field: its own
58
+ * label, hint, error, `setVisible`, value and submitted key — the row is
59
+ * layout only and has no name. The row stacks its fields when the form is
60
+ * narrower than 360px (a container query on `.twm-modal__form`).
61
+ *
26
62
  * Picker — a value the user LOOKS UP rather than types (an id chosen in a
27
63
  * search dialog, say). The row shows a read-only box and a search button;
28
64
  * the button (or a click on the box) awaits `pick(currentValue)`, which
@@ -56,9 +92,25 @@
56
92
  * create, so there is no "will create" hint either. A bare `true` used to
57
93
  * reach `f.create.onCreate(v)` and fail the submit with "onCreate is not a
58
94
  * function", blaming the user for typing their own name.
95
+ *
96
+ * Colours — `type: 'color'` is a `ColorField` (swatch + native picker, a hex
97
+ * box kept in step, `presets?` as swatch buttons) and submits lowercase
98
+ * `#rrggbb`, or `''` when left empty. `type: 'colorList'` is a
99
+ * `ColorListField` (add, remove, reorder; `presets?` on every row) and submits
100
+ * an ordered array of `#rrggbb`. An `rgb()` default is shown as its hex. A box
101
+ * that holds no hex colour is a field error and the form does not resolve;
102
+ * `required` on either is checked by the form, not the browser:
103
+ *
104
+ * { name: 'accent', label: 'Accent', type: 'color', default: 'rgb(31,119,180)',
105
+ * presets: ['#1f77b4', '#ff7f0e'] }
106
+ * { name: 'palette', label: 'Palette', type: 'colorList', default: ['#1f77b4'] }
59
107
  */
60
108
 
61
109
  import { ManagedWindow } from '../../ui/components/managed_window.js';
110
+ import { ToggleField } from '../../ui/components/toggle_field.js';
111
+ import { ColorField } from './color_field.js';
112
+ import { ColorListField } from './color_list_field.js';
113
+ import { choiceList, findChoice, flatChoices, selectBox, toggleChips } from './choice_controls.js';
62
114
 
63
115
  /**
64
116
  * C25. WHERE A DIALOG OPENS, when the application spans more than one window.
@@ -90,18 +142,32 @@ let _modalSeq = 1;
90
142
  * @param {Function?} o.onFieldChange `(name, value, api)` — called after any
91
143
  * field's `change`, and ONCE on open with `name === null` so the form starts
92
144
  * consistent with its defaults. `api` is
93
- * `{ setVisible(name, on), get(name), set(name, value) }`, addressing fields
94
- * and named sections by name. `get` reads the control as it stands (a
95
- * checkbox as a boolean, everything else as its string); `set` writes it and
96
- * does NOT call `onFieldChange` back, so a hook that fills one field from
97
- * another cannot loop.
145
+ * `{ setVisible(name, on), get(name), set(name, value), setOptions(name,
146
+ * options) }`, addressing fields and named sections by name. `get` reads the
147
+ * control as it stands (a checkbox or toggle as a boolean, a select or radio as its
148
+ * option's own value — a select with no option selected as '', a radio
149
+ * with none as null — a combobox as the value of the option its text names,
150
+ * exactly as it submits, or as its text when it names none — a checklist as
151
+ * the array of chosen values, a colorList as the array of its rows' text,
152
+ * everything else — a colour too — as its string). An option
153
+ * whose value IS the given one wins over one that only has its text, here,
154
+ * in `set` and in defaults. `set` writes it (a value a select or radio does not
155
+ * offer, or offers only disabled, clears it; so does a disabled option's
156
+ * value in a combobox) and does NOT call
157
+ * `onFieldChange` back, so a hook that fills one field from another cannot
158
+ * loop. `setOptions` replaces the options of a `select` (a combobox's too),
159
+ * `checklist` or `radio` while the form is open, keeping the current value
160
+ * when the new list still offers it enabled — otherwise a select falls to its
161
+ * first enabled option, a radio to none, and a checklist drops what is gone.
162
+ * It does not call `onFieldChange` back either.
98
163
  *
99
164
  * It exists for the form whose Type select spans two record kinds and must
100
165
  * show the fields of the kind actually chosen. Rebuilding the form on every
101
166
  * change would lose what the user had already typed, so every field is
102
167
  * built once and the irrelevant rows are hidden. A hidden row keeps its
103
168
  * value and is still submitted — the caller drops what its chosen kind does
104
- * not use — but it is not ASKED about: its `required` and its `validator`
169
+ * not use; a disabled option it holds submits as no value ('' for a select
170
+ * or combobox, null for a radio) — but it is not ASKED about: its `required` and its `validator`
105
171
  * stand down until it is shown again, because a constraint on a field the
106
172
  * user cannot see is a submit button that silently does nothing.
107
173
  *
@@ -117,7 +183,7 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
117
183
  body.innerHTML = `
118
184
  <form class="twm-modal__form">
119
185
  <div class="twm-modal__fields">
120
- ${fields.map((f) => _renderEntry(f, defaults[f.name] ?? f.default)).join('')}
186
+ ${fields.map((f) => _renderEntry(f, defaults)).join('')}
121
187
  </div>
122
188
  <div class="twm-modal__error" data-role="form-error" hidden></div>
123
189
  <div class="twm-modal__actions">
@@ -140,9 +206,11 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
140
206
  canDrag: true,
141
207
  defaultWidth: _estimateWidth(fields),
142
208
  defaultHeight: _estimateHeight(fields),
143
- onClose: () => { if (!_resolved) { _resolved = true; resolve(null); } },
209
+ onClose: () => {
210
+ disposeControls();
211
+ if (!_resolved) { _resolved = true; resolve(null); }
212
+ },
144
213
  });
145
- win.show();
146
214
 
147
215
  let _resolved = false;
148
216
  const close = (result) => {
@@ -150,6 +218,7 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
150
218
  _resolved = true;
151
219
  resolve(result);
152
220
  try { win.close({ force: true }); } catch {}
221
+ disposeControls();
153
222
  };
154
223
 
155
224
  const errEl = body.querySelector('[data-role="form-error"]');
@@ -160,9 +229,16 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
160
229
  };
161
230
  body.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(null));
162
231
 
163
- const formFields = fields.filter((f) => !f.section);
232
+ const formFields = _formFields(fields);
233
+ // The options each field offers NOW — `setOptions` replaces them, the
234
+ // caller's field object is never written to.
235
+ const optionsOf = new Map(formFields.map((f) => [f.name, f.options]));
236
+ const comboHints = new Map();
164
237
  for (const f of formFields) {
165
- if (f.type === 'select' && f.create) _wireComboboxHint(body, f);
238
+ if (f.type === 'select' && f.create) {
239
+ const update = _wireComboboxHint(body, f, () => optionsOf.get(f.name));
240
+ if (update) comboHints.set(f.name, update);
241
+ }
166
242
  if (f.type === 'picker') _wirePicker(body, f);
167
243
  }
168
244
  const rowHidden = (name) => !!body.querySelector(`[data-field-row="${name}"]`)?.hidden;
@@ -179,11 +255,51 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
179
255
  if (row) row.classList.add('twm-is-invalid');
180
256
  if (errEl) { errEl.textContent = msg; errEl.hidden = false; }
181
257
  };
258
+ // Fields that are components, not markup — toggles, selects,
259
+ // checklists, radios and colours — are mounted into the slot their
260
+ // row left (`_mountControl`) and read back through it: a toggle as a
261
+ // boolean, a choice as its option's own value, a colour as its hex. `onChoice` is what a checklist or
262
+ // radio calls when the reader changes it (a toggle or select is a
263
+ // named control and is heard by the loops below).
264
+ let onChoice = (name) => clearFieldError(name);
265
+ const controls = new Map();
266
+ for (const f of formFields) {
267
+ const c = _mountControl(body, f, defaults[f.name] ?? f.default, (name) => onChoice(name));
268
+ if (c) controls.set(f.name, c);
269
+ }
270
+ function disposeControls() {
271
+ for (const c of controls.values()) c.dispose?.();
272
+ controls.clear();
273
+ }
274
+ win.show();
275
+ /** The option a combobox entry names — the one whose value IS it, else the
276
+ * one whose value has its text — or undefined. `get` and the submit both
277
+ * read a combobox through this, so they agree (`'2'` is option `2`). */
278
+ const comboOption = (f, text) => findChoice(flatChoices(optionsOf.get(f.name)), text, true);
279
+ /** A disabled option's reason ('' with none) when one is the value, else null. */
280
+ const unavailable = (f, value) => {
281
+ const c = controls.get(f.name);
282
+ if (c) return c.unavailable();
283
+ if (f.type !== 'select' || !f.create || value == null || value === '') return null;
284
+ const o = comboOption(f, value);
285
+ return o?.disabled ? (o.reason || '') : null;
286
+ };
287
+
182
288
  // Clear per-field errors on input so the user sees the row recover.
183
289
  for (const f of formFields) {
184
290
  const el = body.querySelector(`[name="${f.name}"]`);
185
291
  el?.addEventListener('input', () => clearFieldError(f.name));
186
292
  el?.addEventListener('change', () => clearFieldError(f.name));
293
+ // The browser refuses a number outside its min / max before the
294
+ // submit handler runs; its bubble is replaced by the field error
295
+ // the submit would have shown.
296
+ if (f.type === 'number' && _bounded(f)) {
297
+ el?.addEventListener('invalid', (e) => {
298
+ if (!el.validity.rangeUnderflow && !el.validity.rangeOverflow) return;
299
+ e.preventDefault();
300
+ setFieldError(f.name, _rangeMessage(f));
301
+ });
302
+ }
187
303
  }
188
304
 
189
305
  // Adaptive fields — see `onFieldChange` above.
@@ -200,29 +316,60 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
200
316
  row.style.display = on ? '' : 'none';
201
317
  clearFieldError(name);
202
318
  // A required control nobody can see blocks the submit with
203
- // no message the user can find. Remembered on the control,
204
- // so showing the row gives back exactly what it had.
319
+ // no message the user can find, and so does a number out
320
+ // of its min or max. Remembered on the control, so showing
321
+ // the row gives back exactly what it had.
205
322
  for (const el of row.querySelectorAll('input, select, textarea')) {
206
323
  if (!on && el.required) { el.required = false; el.dataset.twmRequired = '1'; }
207
324
  else if (on && el.dataset.twmRequired) { el.required = true; delete el.dataset.twmRequired; }
325
+ for (const [attr, key] of [['min', 'twmMin'], ['max', 'twmMax']]) {
326
+ if (!on && el.hasAttribute(attr)) { el.dataset[key] = el.getAttribute(attr); el.removeAttribute(attr); }
327
+ else if (on && el.dataset[key] != null) { el.setAttribute(attr, el.dataset[key]); delete el.dataset[key]; }
328
+ }
208
329
  }
209
330
  },
210
331
  get(name) {
332
+ if (controls.has(name)) return controls.get(name).read();
211
333
  const el = body.querySelector(`[name="${name}"]`);
212
334
  if (!el) return undefined;
213
- return el.type === 'checkbox' ? !!el.checked : el.value;
335
+ if (el.type === 'checkbox') return !!el.checked;
336
+ const f = fieldOf(name);
337
+ if (f?.type === 'select' && f.create) {
338
+ const match = comboOption(f, el.value);
339
+ return match ? match.value : el.value;
340
+ }
341
+ return el.value;
214
342
  },
215
343
  set(name, value) {
344
+ if (controls.has(name)) { controls.get(name).write(value); return; }
216
345
  const el = body.querySelector(`[name="${name}"]`);
217
346
  if (!el) return;
218
347
  if (el.type === 'checkbox') { el.checked = !!value; return; }
219
- el.value = value == null ? '' : String(value);
220
348
  const f = fieldOf(name);
349
+ // A combobox does not take a disabled option's text either.
350
+ const shut = f?.type === 'select' && f.create && comboOption(f, value)?.disabled;
351
+ el.value = value == null || shut ? '' : String(value);
352
+ comboHints.get(name)?.();
221
353
  if (f?.type === 'picker') {
222
354
  const display = body.querySelector(`[data-picker-display="${name}"]`);
223
355
  if (display) display.value = _pickerLabel(f, el.value);
224
356
  }
225
357
  },
358
+ setOptions(name, options) {
359
+ const f = fieldOf(name);
360
+ if (!f) return;
361
+ const c = controls.get(name);
362
+ if (c?.setOptions) {
363
+ optionsOf.set(name, options);
364
+ c.setOptions(options);
365
+ return;
366
+ }
367
+ if (f.type !== 'select' || !f.create) return;
368
+ optionsOf.set(name, options);
369
+ const list = body.querySelector(`[data-datalist-for="${name}"]`);
370
+ if (list) list.innerHTML = _datalistOptions(options);
371
+ comboHints.get(name)?.();
372
+ },
226
373
  };
227
374
  const fire = (name) => {
228
375
  try { onFieldChange(name, name == null ? undefined : api.get(name), api); }
@@ -232,6 +379,7 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
232
379
  const el = body.querySelector(`[name="${f.name}"]`);
233
380
  el?.addEventListener('change', () => fire(f.name));
234
381
  }
382
+ onChoice = (name) => { clearFieldError(name); fire(name); };
235
383
  // Once on open, so a preselected value is reflected immediately
236
384
  // rather than only after the user touches something.
237
385
  fire(null);
@@ -243,10 +391,16 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
243
391
  showError('');
244
392
  const data = {};
245
393
  for (const f of formFields) {
246
- const el = body.querySelector(`[name="${f.name}"]`);
247
- if (!el) continue;
394
+ const control = controls.get(f.name);
395
+ const el = control ? null : body.querySelector(`[name="${f.name}"]`);
396
+ if (!control && !el) continue;
248
397
  let v;
249
- if (f.type === 'checkbox') {
398
+ if (control) {
399
+ // A colour reads as its text but submits as its hex.
400
+ v = control.submit ? control.submit() : control.read();
401
+ // An option with no value submits '', as an empty select does.
402
+ if (v === undefined) v = f.type === 'select' ? '' : null;
403
+ } else if (f.type === 'checkbox') {
250
404
  v = !!el.checked;
251
405
  } else if (f.type === 'number') {
252
406
  v = (el.value === '' ? null : Number(el.value));
@@ -255,8 +409,11 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
255
409
  }
256
410
  if (f.type === 'select' && f.create) {
257
411
  const spec = _createSpec(f);
258
- const known = new Set(_optionValues(f.options));
259
- if (v && !known.has(v)) {
412
+ // A typed value that is an option's text IS that option,
413
+ // with the option's own value (a number stays a number).
414
+ const match = comboOption(f, v);
415
+ if (match) v = match.value;
416
+ else if (v) {
260
417
  if (submitBtn) submitBtn.disabled = true;
261
418
  let created;
262
419
  let failMsg = null;
@@ -280,14 +437,25 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
280
437
  let firstBadName = null;
281
438
  for (const f of formFields) {
282
439
  clearFieldError(f.name);
283
- if (rowHidden(f.name)) continue;
284
- let msg = null;
285
- // A picker's value lives in a hidden input, which the browser's
286
- // own `required` check skips — so it is checked here.
287
- if (f.type === 'picker' && f.required && !data[f.name]) msg = 'Required.';
288
- else if (typeof f.validator !== 'function') continue;
289
- else {
290
- try { msg = f.validator(data[f.name], data); } catch (err) { msg = err?.message || String(err); }
440
+ if (rowHidden(f.name)) {
441
+ // Not asked about — but a disabled option is still never
442
+ // the value: a hidden row holding one submits no value.
443
+ if (unavailable(f, data[f.name]) != null) data[f.name] = f.type === 'radio' ? null : '';
444
+ continue;
445
+ }
446
+ const v = data[f.name];
447
+ // A picker's value lives in a hidden input, and a checklist or
448
+ // radio is no single control, so the browser's own `required`
449
+ // check never sees them — they are checked here.
450
+ let msg = f.required && _missing(f, v) ? 'Required.' : null;
451
+ if (!msg && _outOfRange(f, v)) msg = _rangeMessage(f);
452
+ if (!msg) {
453
+ const reason = unavailable(f, v);
454
+ if (reason != null) msg = reason ? `Not available: ${reason}` : 'Not available.';
455
+ }
456
+ if (!msg) msg = controls.get(f.name)?.error?.() ?? null;
457
+ if (!msg && typeof f.validator === 'function') {
458
+ try { msg = f.validator(v, data); } catch (err) { msg = err?.message || String(err); }
291
459
  }
292
460
  if (msg) {
293
461
  setFieldError(f.name, msg);
@@ -295,6 +463,7 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
295
463
  }
296
464
  }
297
465
  if (firstBadName) {
466
+ controls.get(firstBadName)?.focus();
298
467
  body.querySelector(`[data-picker="${firstBadName}"]`)?.focus();
299
468
  body.querySelector(`[name="${firstBadName}"]:not([type="hidden"])`)?.focus();
300
469
  return;
@@ -315,6 +484,15 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
315
484
  }
316
485
 
317
486
 
487
+ /**
488
+ * `openConfirm` — a yes/no modal. Resolves `true` on confirm, `false` on
489
+ * cancel, Esc, X or backdrop.
490
+ *
491
+ * `message`, `confirmLabel` and `cancelLabel` are TEXT, never markup: callers
492
+ * put user-named things in them ("Delete flow "<name>"?"), so they are set
493
+ * with `textContent` and a `<` in a name shows as a `<`. A `\n` in `message`
494
+ * breaks the line (`white-space: pre-line` on `.twm-modal__body--text`).
495
+ */
318
496
  export function openConfirm({
319
497
  title, message, confirmLabel = 'OK',
320
498
  cancelLabel = 'Cancel',
@@ -323,14 +501,25 @@ export function openConfirm({
323
501
  return new Promise((resolve) => {
324
502
  const body = document.createElement('div');
325
503
  body.className = 'twm-modal__body-host';
326
- const cls = danger ? 'twm-btn twm-btn--danger' : 'twm-btn twm-btn--primary';
327
- body.innerHTML = `
328
- <div class="twm-modal__body">${message}</div>
329
- <div class="twm-modal__actions">
330
- <button type="button" class="twm-btn" data-action="cancel">${cancelLabel}</button>
331
- <button type="button" class="${cls}" data-action="confirm">${confirmLabel}</button>
332
- </div>
333
- `;
504
+
505
+ const messageEl = document.createElement('div');
506
+ messageEl.className = 'twm-modal__body twm-modal__body--text';
507
+ messageEl.textContent = message == null ? '' : String(message);
508
+
509
+ const actionsEl = document.createElement('div');
510
+ actionsEl.className = 'twm-modal__actions';
511
+ const button = (action, className, label) => {
512
+ const btn = document.createElement('button');
513
+ btn.type = 'button';
514
+ btn.className = className;
515
+ btn.dataset.action = action;
516
+ btn.textContent = label == null ? '' : String(label);
517
+ actionsEl.appendChild(btn);
518
+ };
519
+ button('cancel', 'twm-btn', cancelLabel);
520
+ button('confirm', danger ? 'twm-btn twm-btn--danger' : 'twm-btn twm-btn--primary', confirmLabel);
521
+
522
+ body.append(messageEl, actionsEl);
334
523
 
335
524
  let _resolved = false;
336
525
  const win = new ManagedWindow({
@@ -693,17 +882,27 @@ export function openModal({
693
882
  function _estimateHeight(fields) {
694
883
  // Header (~36) + actions row (~46) + section dividers (~32 each)
695
884
  // + per-field rows (~46 text, ~140 textarea, ~32 checkbox)
696
- // + body padding. Clamped to 200..80vh.
885
+ // + body padding. Clamped to 200..80vh. A `{ row }` is as tall as its
886
+ // tallest field.
887
+ const height = (f) => {
888
+ if (f.section) return 32;
889
+ const hint = f.hint ? 16 : 0;
890
+ if (f.type === 'textarea') return 32 + (Number(f.rows) || 4) * 18 + hint;
891
+ if (f.type === 'checkbox' || f.type === 'toggle') return 32 + hint;
892
+ if (f.type === 'colorList') {
893
+ const n = Array.isArray(f.default) ? f.default.length : 0;
894
+ return 46 + Math.max(n, 1) * 40 + 40 + hint;
895
+ }
896
+ if (f.type === 'color') return 46 + (Array.isArray(f.presets) && f.presets.length ? 28 : 0) + hint;
897
+ if (f.type === 'radio') {
898
+ const opts = flatChoices(f.options);
899
+ return 12 + opts.reduce((h, o) => h + 22 + (o.description || o.reason ? 16 : 0), 0) + hint;
900
+ }
901
+ return 46 + hint;
902
+ };
697
903
  let body = 0;
698
904
  for (const f of fields) {
699
- if (f.section) body += 32;
700
- else if (f.type === 'textarea') {
701
- const rows = Number(f.rows) || 4;
702
- body += 32 + rows * 18;
703
- if (f.hint) body += 16;
704
- }
705
- else if (f.type === 'checkbox') { body += 32; if (f.hint) body += 16; }
706
- else { body += 46; if (f.hint) body += 16; }
905
+ body += Array.isArray(f?.row) ? Math.max(0, ..._formFields(f.row).map(height)) : height(f);
707
906
  }
708
907
  const base = 36 + 46 + 28 + body;
709
908
  const max = Math.floor(window.innerHeight * 0.8);
@@ -711,15 +910,158 @@ function _estimateHeight(fields) {
711
910
  }
712
911
 
713
912
  function _estimateWidth(fields) {
714
- // Forms with a textarea or section divider get the wider layout so
715
- // multi-line content + grouped fields breathe; otherwise 480 px is
716
- // enough for label + single-line input pairs.
717
- const wide = fields.some((f) => f && (f.type === 'textarea' || f.section));
913
+ // Forms with a textarea, a colour list, a section divider or a row of
914
+ // fields get the wider layout so multi-line content + grouped fields
915
+ // breathe; otherwise 480 px is enough for label + single-line input pairs.
916
+ const wide = fields.some((f) => f && (f.type === 'textarea' || f.type === 'colorList' || f.section || Array.isArray(f.row)));
718
917
  return wide ? 640 : 480;
719
918
  }
720
919
 
721
- function _optionValues(options) {
722
- return (options || []).map((o) => (typeof o === 'object') ? o.value : o);
920
+ /** The fields of a form's entries, rows opened up, headings left out. */
921
+ function _formFields(entries) {
922
+ return entries.flatMap((e) => (Array.isArray(e?.row) ? e.row : [e])).filter((f) => f && f.section == null);
923
+ }
924
+
925
+ /** Does a field carry a `min` or a `max`? */
926
+ function _bounded(f) {
927
+ return f.min != null || f.max != null;
928
+ }
929
+
930
+ /** Is a `number` field's submitted value outside its `min` / `max`? */
931
+ function _outOfRange(f, v) {
932
+ if (f.type !== 'number' || !_bounded(f) || v == null || Number.isNaN(v)) return false;
933
+ return (f.min != null && v < Number(f.min)) || (f.max != null && v > Number(f.max));
934
+ }
935
+
936
+ /** The field error for a number outside its bounds. */
937
+ function _rangeMessage(f) {
938
+ if (f.min != null && f.max != null) return `Must be between ${f.min} and ${f.max}.`;
939
+ return f.min != null ? `Must be at least ${f.min}.` : `Must be at most ${f.max}.`;
940
+ }
941
+
942
+ /** A combobox's offered options: enabled, groups flattened. */
943
+ function _offered(options) {
944
+ return flatChoices(options).filter((o) => !o.disabled);
945
+ }
946
+
947
+ function _datalistOptions(options) {
948
+ return _offered(options).map((o) => `<option value="${_esc(o.value)}"></option>`).join('');
949
+ }
950
+
951
+ /**
952
+ * Mount a field that is a component rather than markup — a `toggle`, a
953
+ * `select` (not a combobox), `checklist` or `radio` drawn by
954
+ * `choice_controls.js`, or a `color` / `colorList` — into the
955
+ * `data-control-slot` its row left, and hand back what the form needs of it:
956
+ * `{ read(), write(v), focus(), unavailable(), submit?(), error?(),
957
+ * setOptions?(options), dispose?() }`. `unavailable()` is the reason (or '')
958
+ * when the selected option is disabled, else null. `submit()`, where given,
959
+ * is the value submitted when it differs from what `read()` shows (a colour's
960
+ * hex, null while its box holds none), and `error()` is the field's own
961
+ * message for a value it cannot submit, else null. `onChange(name)` hears the
962
+ * reader's choice on a checklist or radio; a toggle or select is a named
963
+ * control, heard as one. Null for any other field.
964
+ */
965
+ function _mountControl(host, f, value, onChange) {
966
+ const slot = host.querySelector(`[data-control-slot="${f.name}"]`);
967
+ if (!slot) return null;
968
+ if (f.type === 'toggle') {
969
+ // Mounted INTO the slot: the slot is the row's `.twm-modal__toggle`
970
+ // cell, and the switch's own root is the <label>.
971
+ const toggle = new ToggleField({
972
+ name: f.name,
973
+ checked: value === true || value === 'true',
974
+ ariaLabel: f.label || f.name,
975
+ onText: f.onText ?? null,
976
+ offText: f.offText ?? null,
977
+ disabled: !!f.disabled,
978
+ compact: !!f.compact,
979
+ });
980
+ slot.appendChild(toggle.render());
981
+ return {
982
+ read: () => toggle.get(),
983
+ write: (v) => toggle.set(v),
984
+ focus: () => toggle.input?.focus(),
985
+ unavailable: () => null,
986
+ dispose: () => toggle.dispose(),
987
+ };
988
+ }
989
+ if (f.type === 'color' || f.type === 'colorList') {
990
+ // Read as their text as it stands (a list as its rows' text), submitted
991
+ // as hex: `getValue()` is null while a box holds no hex colour.
992
+ const c = f.type === 'color'
993
+ ? new ColorField({ name: f.name, value: value ?? '', presets: f.presets, label: f.label })
994
+ : new ColorListField({ name: f.name, value: value ?? [], presets: f.presets });
995
+ slot.replaceWith(c.render());
996
+ const list = f.type === 'colorList';
997
+ return {
998
+ read: () => (list ? c.getTexts() : c.getText()),
999
+ submit: () => c.getValue(),
1000
+ write: (v) => c.setValue(v),
1001
+ focus: () => c.focus(),
1002
+ unavailable: () => null,
1003
+ error: () => {
1004
+ if (c.getValue() !== null) return null;
1005
+ if (!list) return 'Not a hex colour: use #rgb or #rrggbb.';
1006
+ const bad = c.invalidIndexes().map((i) => i + 1).join(', ');
1007
+ return `Not a hex colour (row ${bad}): use #rgb or #rrggbb.`;
1008
+ },
1009
+ dispose: () => c.dispose(),
1010
+ };
1011
+ }
1012
+ const common = { keepMissing: false, loose: true };
1013
+ const disabledReason = (entry) => (entry?.disabled ? (entry.reason || '') : null);
1014
+ if (f.type === 'select') {
1015
+ const c = selectBox(f.options, value, { ...common, valueAttr: 'value', className: '' });
1016
+ c.box.name = f.name;
1017
+ if (f.required) c.box.required = true;
1018
+ slot.replaceWith(c.box);
1019
+ return {
1020
+ // No option selected reads '', as an empty select always has (and
1021
+ // as it submits); a selected option reads as its own value.
1022
+ read: () => (c.entry() ? c.entry().value : ''),
1023
+ // A value the list does not offer enabled clears the select, as
1024
+ // writing an unknown `.value` always has (it then submits '').
1025
+ write: (v) => { if (!c.select(v)) c.box.selectedIndex = -1; },
1026
+ focus: () => c.box.focus(),
1027
+ unavailable: () => disabledReason(c.entry()),
1028
+ setOptions: c.setOptions,
1029
+ };
1030
+ }
1031
+ const list = (v) => (Array.isArray(v) ? v : (v == null || v === '' ? [] : [v]));
1032
+ if (f.type === 'checklist') {
1033
+ const c = toggleChips({ ...common, options: f.options, value: list(value), label: f.label || f.name,
1034
+ onChange: () => onChange(f.name) });
1035
+ slot.replaceWith(c.el);
1036
+ return {
1037
+ read: c.valueOf,
1038
+ write: (v) => c.setValue(list(v)),
1039
+ focus: () => c.el.querySelector('button:not(:disabled)')?.focus(),
1040
+ unavailable: () => null, // a disabled chip cannot be pressed, by hand or by set
1041
+ setOptions: c.setOptions,
1042
+ };
1043
+ }
1044
+ const c = choiceList({ ...common, options: f.options, value, label: f.label || f.name,
1045
+ name: `twm-form-${_modalSeq}-${f.name}`, onChange: () => onChange(f.name) });
1046
+ slot.replaceWith(c.el);
1047
+ return {
1048
+ read: () => c.valueOf() ?? null,
1049
+ write: (v) => { c.select(v); },
1050
+ focus: c.focus,
1051
+ unavailable: () => disabledReason(c.entry()),
1052
+ setOptions: c.setOptions,
1053
+ };
1054
+ }
1055
+
1056
+ /** Is a `required` field without a value the browser cannot check for itself? */
1057
+ function _missing(f, v) {
1058
+ if (f.type === 'picker') return !v;
1059
+ if (f.type === 'checklist') return !Array.isArray(v) || v.length === 0;
1060
+ if (f.type === 'radio') return v == null;
1061
+ // A colour that is not a hex (null) is its own error, not a missing one.
1062
+ if (f.type === 'color') return v === '';
1063
+ if (f.type === 'colorList') return Array.isArray(v) && v.length === 0;
1064
+ return false;
723
1065
  }
724
1066
 
725
1067
  /** Normalise a `create` spec. `true` means the typed value is accepted as-is. */
@@ -772,7 +1114,7 @@ function _wirePicker(host, f) {
772
1114
 
773
1115
  /** Show / hide the "↳ will create …" hint as the user types into a
774
1116
  * create-capable combobox. */
775
- function _wireComboboxHint(host, f) {
1117
+ function _wireComboboxHint(host, f, optionsOf) {
776
1118
  const input = host.querySelector(`input[name="${f.name}"]`);
777
1119
  const hintEl = host.querySelector(`.twm-combobox__hint[data-for="${f.name}"]`);
778
1120
  if (!input || !hintEl) return;
@@ -780,10 +1122,10 @@ function _wireComboboxHint(host, f) {
780
1122
  // entity being made, so promising to "create" one is a lie that reads as
781
1123
  // a warning.
782
1124
  if (f.create === true) return;
783
- const known = new Set(_optionValues(f.options));
784
1125
  const label = _createSpec(f).hint || 'new entry';
785
1126
  const update = () => {
786
1127
  const v = input.value.trim();
1128
+ const known = new Set(flatChoices(optionsOf()).map((o) => String(o.value)));
787
1129
  if (v && !known.has(v)) {
788
1130
  hintEl.textContent = `↳ will create ${label} “${v}”`;
789
1131
  hintEl.hidden = false;
@@ -793,33 +1135,43 @@ function _wireComboboxHint(host, f) {
793
1135
  };
794
1136
  input.addEventListener('input', update);
795
1137
  update();
1138
+ return update;
796
1139
  }
797
1140
 
798
1141
 
799
- function _renderEntry(f, value) {
1142
+ function _renderEntry(f, defaults) {
800
1143
  if (f && f.section != null) {
801
1144
  // A named section is addressable by `setVisible`, so a heading can be
802
1145
  // hidden along with the group it introduces.
803
1146
  const attr = f.name ? ` data-field-row="${f.name}"` : '';
804
1147
  return `<div class="twm-modal__section"${attr}>${_esc(f.section)}</div>`;
805
1148
  }
806
- return _renderField(f, value);
1149
+ // A row is layout only: each field in it is rendered exactly as it would
1150
+ // be on its own line, with its own `data-field-row`.
1151
+ if (Array.isArray(f?.row)) {
1152
+ const cells = _formFields(f.row).map((g) => _renderField(g, defaults[g.name] ?? g.default));
1153
+ return `<div class="twm-modal__field-group">${cells.join('')}</div>`;
1154
+ }
1155
+ return _renderField(f, defaults[f.name] ?? f.default);
807
1156
  }
808
1157
 
809
1158
  function _renderField(f, value) {
810
1159
  const required = f.required ? 'required' : '';
811
- const rowMod = (f.type === 'textarea') ? ' twm-modal__row--multiline' : '';
1160
+ const rowMod = (f.type === 'textarea' || f.type === 'checklist' || f.type === 'radio')
1161
+ ? ' twm-modal__row--multiline' : '';
812
1162
  const hintHtml = f.hint
813
1163
  ? `<div class="twm-modal__hint--field">${_esc(f.hint)}</div>` : '';
814
1164
  const errHtml = `<div class="twm-modal__field-error" data-field-error="${f.name}" hidden></div>`;
815
- const wrap = (inner) => `
816
- <label class="twm-modal__row${rowMod}" data-field-row="${f.name}">
1165
+ const wrap = (inner, tag = 'label') => `
1166
+ <${tag} class="twm-modal__row${rowMod}" data-field-row="${f.name}">
817
1167
  <span>${_esc(f.label || '')}</span>
818
1168
  ${inner}
819
1169
  ${hintHtml}
820
1170
  ${errHtml}
821
- </label>
1171
+ </${tag}>
822
1172
  `;
1173
+ // A component's place, filled once the markup is in (`_mountControl`).
1174
+ const slot = `<span data-control-slot="${f.name}"></span>`;
823
1175
 
824
1176
  if (f.type === 'checkbox') {
825
1177
  const checked = (value === true || value === 'true') ? 'checked' : '';
@@ -836,6 +1188,16 @@ function _renderField(f, value) {
836
1188
  `;
837
1189
  }
838
1190
 
1191
+ // A group of controls is not one labelable control: a `<label>` round it
1192
+ // would hand every click on the row to its first chip or radio.
1193
+ if (f.type === 'checklist' || f.type === 'radio') return wrap(slot, 'div');
1194
+
1195
+ // A div, not a label: the switch's own root is the <label>, and a label
1196
+ // may not hold another.
1197
+ if (f.type === 'toggle') {
1198
+ return wrap(`<span class="twm-modal__toggle" data-control-slot="${f.name}"></span>`, 'div');
1199
+ }
1200
+
839
1201
  if (f.type === 'textarea') {
840
1202
  const v = value == null ? '' : String(value);
841
1203
  const rows = f.rows != null ? `rows="${Number(f.rows) || 4}"` : 'rows="4"';
@@ -843,6 +1205,20 @@ function _renderField(f, value) {
843
1205
  return wrap(`<textarea name="${f.name}" ${rows} ${placeholder} ${required}>${_esc(v)}</textarea>`);
844
1206
  }
845
1207
 
1208
+ if (f.type === 'color' || f.type === 'colorList') {
1209
+ // The component is mounted into the slot by `_mountControl`. A list
1210
+ // is a group of controls, so its row is not a <label>: a label would
1211
+ // hand every click on it to the first row's swatch.
1212
+ if (f.type === 'color') return wrap(slot);
1213
+ return `
1214
+ <div class="twm-modal__row twm-modal__row--multiline" data-field-row="${f.name}" role="group" aria-label="${_esc(f.label || '')}">
1215
+ <span>${_esc(f.label || '')}</span>
1216
+ ${slot}
1217
+ ${hintHtml}
1218
+ ${errHtml}
1219
+ </div>
1220
+ `;
1221
+ }
846
1222
  const v = value == null ? '' : String(value);
847
1223
  if (f.type === 'picker') {
848
1224
  // The display text and the stored value are different things (an id
@@ -863,33 +1239,31 @@ function _renderField(f, value) {
863
1239
  `);
864
1240
  }
865
1241
  if (f.type === 'select' && f.create) {
1242
+ // A default naming a disabled option is not taken, as in a select.
1243
+ const shown = findChoice(flatChoices(f.options), value, true)?.disabled ? '' : v;
866
1244
  const dlId = `ea-dl-${f.name}-${_modalSeq}`;
867
- const opts = _optionValues(f.options)
868
- .map((ov) => `<option value="${_esc(ov)}"></option>`).join('');
1245
+ const opts = _datalistOptions(f.options);
869
1246
  const placeholder = f.placeholder
870
1247
  ? `placeholder="${_esc(f.placeholder)}"` : 'placeholder="type to pick or create…"';
871
1248
  return wrap(`
872
1249
  <div class="twm-combobox">
873
1250
  <input name="${f.name}" type="text" list="${dlId}"
874
- value="${_esc(v)}" ${placeholder} ${required}
1251
+ value="${_esc(shown)}" ${placeholder} ${required}
875
1252
  autocomplete="off">
876
- <datalist id="${dlId}">${opts}</datalist>
1253
+ <datalist id="${dlId}" data-datalist-for="${f.name}">${opts}</datalist>
877
1254
  <span class="twm-combobox__hint" data-for="${f.name}" hidden></span>
878
1255
  </div>
879
1256
  `);
880
1257
  }
881
- if (f.type === 'select') {
882
- const opts = (f.options || []).map((o) => {
883
- const ov = (typeof o === 'object') ? o.value : o;
884
- const ol = (typeof o === 'object') ? o.label : o;
885
- return `<option value="${_esc(ov)}" ${ov === v ? 'selected' : ''}>${_esc(ol)}</option>`;
886
- }).join('');
887
- return wrap(`<select name="${f.name}" ${required}>${opts}</select>`);
888
- }
1258
+ if (f.type === 'select') return wrap(slot);
889
1259
  const step = f.step != null ? `step="${f.step}"` : '';
890
1260
  const placeholder = f.placeholder ? `placeholder="${_esc(f.placeholder)}"` : '';
891
1261
  const type = (f.type === 'number' || f.type === 'password') ? f.type : 'text';
892
- return wrap(`<input name="${f.name}" type="${type}" value="${_esc(v)}" ${step} ${placeholder} ${required}>`);
1262
+ // Attributes only a bounded number has, so no other input's markup moves.
1263
+ let bounds = '';
1264
+ if (f.type === 'number' && f.min != null) bounds += ` min="${_esc(f.min)}"`;
1265
+ if (f.type === 'number' && f.max != null) bounds += ` max="${_esc(f.max)}"`;
1266
+ return wrap(`<input name="${f.name}" type="${type}" value="${_esc(v)}" ${step}${bounds} ${placeholder} ${required}>`);
893
1267
  }
894
1268
 
895
1269
  function _esc(s) {