flexdesk 0.16.1 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/LICENSE +0 -0
  3. package/README.md +2217 -42
  4. package/THIRD_PARTY_LICENSES.md +0 -0
  5. package/css/base.css +1834 -75
  6. package/css/flexdesk.css +3146 -164
  7. package/css/overrides.css +16 -0
  8. package/css/reset.css +0 -0
  9. package/css/tokens.css +0 -0
  10. package/dist/canvas.js +6 -239
  11. package/dist/canvas.js.map +3 -3
  12. package/dist/charts.js +709 -18
  13. package/dist/charts.js.map +4 -4
  14. package/dist/chunk-3PHCPZHT.js +0 -0
  15. package/dist/chunk-3PHCPZHT.js.map +0 -0
  16. package/dist/chunk-4A7SJC3T.js +503 -0
  17. package/dist/chunk-4A7SJC3T.js.map +7 -0
  18. package/dist/{chunk-6JSOVNID.js → chunk-CVMCZZX2.js} +635 -10
  19. package/dist/chunk-CVMCZZX2.js.map +7 -0
  20. package/dist/chunk-D3COLZZ2.js +1311 -0
  21. package/dist/chunk-D3COLZZ2.js.map +7 -0
  22. package/dist/chunk-FOOS3T5L.js +0 -0
  23. package/dist/chunk-FOOS3T5L.js.map +0 -0
  24. package/dist/{chunk-SN4LSOAR.js → chunk-GOQLR5WR.js} +452 -2
  25. package/dist/chunk-GOQLR5WR.js.map +7 -0
  26. package/dist/{chunk-FL5KFNQH.js → chunk-HNU6MM2B.js} +52 -17
  27. package/dist/chunk-HNU6MM2B.js.map +7 -0
  28. package/dist/chunk-HYJYYEPK.js +375 -0
  29. package/dist/chunk-HYJYYEPK.js.map +7 -0
  30. package/dist/chunk-JKZLNKG6.js +2041 -0
  31. package/dist/chunk-JKZLNKG6.js.map +7 -0
  32. package/dist/chunk-JYWURG5T.js +0 -0
  33. package/dist/chunk-JYWURG5T.js.map +0 -0
  34. package/dist/{chunk-RQM5Z2MU.js → chunk-MCIXFNVF.js} +633 -311
  35. package/dist/chunk-MCIXFNVF.js.map +7 -0
  36. package/dist/chunk-N7HC3ADV.js +30 -0
  37. package/dist/chunk-N7HC3ADV.js.map +7 -0
  38. package/dist/{chunk-FBGP5KN5.js → chunk-Q36EPT2R.js} +3268 -203
  39. package/dist/chunk-Q36EPT2R.js.map +7 -0
  40. package/dist/chunk-QWQJUDPD.js +610 -0
  41. package/dist/chunk-QWQJUDPD.js.map +7 -0
  42. package/dist/{chunk-NLTOY57M.js → chunk-UURZ37SD.js} +28 -48
  43. package/dist/chunk-UURZ37SD.js.map +7 -0
  44. package/dist/{chunk-B5Q53LAJ.js → chunk-UWO43BZU.js} +2 -2
  45. package/dist/chunk-UWO43BZU.js.map +7 -0
  46. package/dist/chunk-WVFGV5FT.js +0 -0
  47. package/dist/chunk-WVFGV5FT.js.map +0 -0
  48. package/dist/core.js +170 -118
  49. package/dist/core.js.map +2 -2
  50. package/dist/editor.js +156 -70
  51. package/dist/editor.js.map +2 -2
  52. package/dist/flexdesk.css +3146 -164
  53. package/dist/flow.js +2789 -306
  54. package/dist/flow.js.map +4 -4
  55. package/dist/host.js +109 -3
  56. package/dist/host.js.map +3 -3
  57. package/dist/reset.css +0 -0
  58. package/dist/tile_registry-6WZPTUZV.js +0 -0
  59. package/dist/tile_registry-6WZPTUZV.js.map +0 -0
  60. package/dist/tiles.js +657 -234
  61. package/dist/tiles.js.map +4 -4
  62. package/dist/tokens.css +0 -0
  63. package/dist/widgets.js +3252 -2124
  64. package/dist/widgets.js.map +4 -4
  65. package/dist/wm.js +1227 -347
  66. package/dist/wm.js.map +4 -4
  67. package/package.json +3 -1
  68. package/src/canvas/canvas_adapter.js +5 -0
  69. package/src/canvas/edge_router.js +0 -0
  70. package/src/canvas/graph/connector_router.js +0 -0
  71. package/src/canvas/graph/history_service.js +0 -0
  72. package/src/canvas/graph/node_platform.js +0 -0
  73. package/src/canvas/index.js +1 -0
  74. package/src/canvas/layered_layout.js +244 -0
  75. package/src/charts/chart_types.js +0 -0
  76. package/src/charts/data_series_plot_window.js +0 -0
  77. package/src/charts/downsample.js +2 -2
  78. package/src/charts/plot_config.js +53 -29
  79. package/src/charts/plot_config_editor.js +627 -0
  80. package/src/charts/plot_grid.js +449 -0
  81. package/src/charts/plot_popout_window.js +103 -317
  82. package/src/charts/plotly_wrapper.js +22 -2
  83. package/src/charts/series_transforms.js +206 -0
  84. package/src/core/event_bus.js +0 -0
  85. package/src/core/logging.js +1 -1
  86. package/src/core/settings.js +105 -19
  87. package/src/core/state_guard.js +93 -51
  88. package/src/core/state_guard_config.js +85 -74
  89. package/src/core/state_machine.js +0 -0
  90. package/src/editor/editor_pane.js +3 -3
  91. package/src/editor/monaco_editor_factory.js +124 -60
  92. package/src/editor/monaco_loader.js +103 -28
  93. package/src/editor/notebook_search.js +0 -0
  94. package/src/editor/notebook_tab_bar.js +38 -52
  95. package/src/editor/notebook_undo_manager.js +0 -0
  96. package/src/editor/split_pane_container.js +6 -3
  97. package/src/flow/canvas_editor/connections.js +90 -0
  98. package/src/flow/canvas_editor/editor.js +2351 -153
  99. package/src/flow/canvas_editor/index.js +2 -1
  100. package/src/flow/canvas_editor/layout.js +119 -12
  101. package/src/flow/canvas_editor/strings.js +80 -0
  102. package/src/flow/kit/always_before.js +0 -0
  103. package/src/flow/kit/catalogue.js +97 -3
  104. package/src/flow/kit/chip_input.js +0 -0
  105. package/src/flow/kit/dom.js +0 -0
  106. package/src/flow/kit/findings.js +0 -0
  107. package/src/flow/kit/history.js +0 -0
  108. package/src/flow/kit/index.js +3 -3
  109. package/src/flow/kit/keys.js +7 -1
  110. package/src/flow/kit/logic_graph.js +97 -15
  111. package/src/flow/kit/references.js +0 -0
  112. package/src/flow/kit/settings_panel.js +0 -0
  113. package/src/flow/kit/settings_schema.js +2 -1
  114. package/src/flow/kit/step_picker.js +0 -0
  115. package/src/flow/kit/strings.js +3 -1
  116. package/src/flow/kit/value_picker.js +0 -0
  117. package/src/flow/kit/widgets.js +86 -109
  118. package/src/flow/lanes/editor.js +136 -14
  119. package/src/flow/lanes/index.js +3 -1
  120. package/src/flow/lanes/layout.js +0 -0
  121. package/src/flow/lanes/pipeline.js +44 -2
  122. package/src/flow/lanes/preview.js +0 -0
  123. package/src/flow/lanes/strings.js +1 -0
  124. package/src/flow/outline/editor.js +0 -0
  125. package/src/flow/outline/index.js +0 -0
  126. package/src/flow/outline/mapping.js +0 -0
  127. package/src/flow/outline/menu.js +0 -0
  128. package/src/flow/outline/operations.js +0 -0
  129. package/src/flow/outline/recognise.js +0 -0
  130. package/src/flow/outline/strings.js +0 -0
  131. package/src/help/help_modal.js +168 -42
  132. package/src/help/help_registry.js +12 -1
  133. package/src/host/external_links.js +0 -0
  134. package/src/host/file_picker.js +117 -0
  135. package/src/host/host.js +10 -2
  136. package/src/host/photino_host.js +9 -0
  137. package/src/host/photino_host.md +21 -3
  138. package/src/host/pywebview_host.js +10 -0
  139. package/src/tiles/chart_tiles.js +53 -245
  140. package/src/tiles/config_schema.js +8 -0
  141. package/src/tiles/figure_tile.js +322 -0
  142. package/src/tiles/layout_persistence.js +3 -3
  143. package/src/tiles/list_tiles.js +7 -7
  144. package/src/tiles/scope_picker.js +0 -0
  145. package/src/tiles/tile_base.js +1 -1
  146. package/src/tiles/tile_config_form.js +442 -0
  147. package/src/tiles/tile_grid.js +2 -0
  148. package/src/tiles/tile_registry.js +0 -0
  149. package/src/tiling/bottom_panel_tabs.js +67 -99
  150. package/src/tiling/command_palette.js +64 -19
  151. package/src/tiling/content_registry.js +4 -1
  152. package/src/tiling/desktops.js +11 -1
  153. package/src/tiling/entity_sources.js +0 -0
  154. package/src/tiling/keymap.js +403 -128
  155. package/src/tiling/kind_taxonomy.js +78 -0
  156. package/src/tiling/landing_table.js +0 -0
  157. package/src/tiling/loading_overlay.js +0 -0
  158. package/src/tiling/nav_panel.js +59 -10
  159. package/src/tiling/page_factory.js +5 -5
  160. package/src/tiling/panel_availability.js +49 -0
  161. package/src/tiling/panel_keys.js +0 -0
  162. package/src/tiling/shell.js +114 -12
  163. package/src/tiling/tab_strip.js +0 -0
  164. package/src/tiling/tile_breadcrumb.js +0 -0
  165. package/src/tiling/tile_renderer.js +105 -9
  166. package/src/tiling/tile_tab_menu.js +0 -0
  167. package/src/tiling/tile_tree.js +0 -0
  168. package/src/tiling/wm.js +562 -30
  169. package/src/tiling/zoom.js +0 -0
  170. package/src/ui/base/component_base.js +0 -0
  171. package/src/ui/base/controller_base.js +0 -0
  172. package/src/ui/base/page_base.js +0 -0
  173. package/src/ui/components/about_dialog.js +0 -0
  174. package/src/ui/components/action_dropdown.js +30 -2
  175. package/src/ui/components/alert_log.js +0 -0
  176. package/src/ui/components/alerts_pane.js +0 -0
  177. package/src/ui/components/attribute_list_editor.js +0 -0
  178. package/src/ui/components/autocomplete_field.js +0 -0
  179. package/src/ui/components/cell_editor_host.js +211 -0
  180. package/src/ui/components/cell_grid.js +648 -0
  181. package/src/ui/components/cell_selection.js +220 -0
  182. package/src/ui/components/choice_controls.js +351 -0
  183. package/src/ui/components/color_field.js +211 -0
  184. package/src/ui/components/color_list_field.js +210 -0
  185. package/src/ui/components/column_fit.js +0 -0
  186. package/src/ui/components/column_groups.js +314 -0
  187. package/src/ui/components/column_order.js +142 -0
  188. package/src/ui/components/computing_status_window.js +98 -25
  189. package/src/ui/components/confirm_dialog.js +81 -38
  190. package/src/ui/components/context_menu.js +2 -2
  191. package/src/ui/components/conversation_view.js +440 -0
  192. package/src/ui/components/crosstab.js +289 -0
  193. package/src/ui/components/data_table.js +601 -62
  194. package/src/ui/components/detail_header.js +22 -11
  195. package/src/ui/components/document_outline.js +397 -0
  196. package/src/ui/components/drag_reorder.js +0 -0
  197. package/src/ui/components/fact_table.js +0 -0
  198. package/src/ui/components/fill_handle.js +322 -0
  199. package/src/ui/components/gallery_picker.js +19 -8
  200. package/src/ui/components/header_actions.js +55 -0
  201. package/src/ui/components/inline_renamer.js +0 -0
  202. package/src/ui/components/job_progress.js +51 -0
  203. package/src/ui/components/managed_window.js +27 -4
  204. package/src/ui/components/menu_bar.js +745 -0
  205. package/src/ui/components/modal.js +449 -75
  206. package/src/ui/components/notification_history.js +3 -3
  207. package/src/ui/components/progress.js +483 -0
  208. package/src/ui/components/record_list.js +0 -0
  209. package/src/ui/components/row_activation.js +0 -0
  210. package/src/ui/components/section_stack.js +298 -0
  211. package/src/ui/components/series.js +173 -0
  212. package/src/ui/components/settings_view.js +634 -0
  213. package/src/ui/components/slide_out_panel.js +43 -6
  214. package/src/ui/components/slider_field.js +275 -18
  215. package/src/ui/components/sortable_list.js +0 -0
  216. package/src/ui/components/staged_edits.js +343 -0
  217. package/src/ui/components/staged_history.js +172 -0
  218. package/src/ui/components/table_filter_chips.js +0 -0
  219. package/src/ui/components/table_state_store.js +0 -0
  220. package/src/ui/components/table_structure.js +182 -0
  221. package/src/ui/components/table_values.js +0 -0
  222. package/src/ui/components/tabs.js +497 -0
  223. package/src/ui/components/tag_input.js +215 -0
  224. package/src/ui/components/toast.js +25 -8
  225. package/src/ui/components/toggle_field.js +175 -0
  226. package/src/ui/components/tree_view.js +55 -34
  227. package/src/ui/components/window_taskbar.js +0 -0
  228. package/src/ui/components/wizard.js +563 -0
  229. package/src/ui/controllers/window_chrome_controller.js +2 -2
  230. package/src/ui/notification_center.js +375 -119
  231. package/src/ui/utils/auto_scrollbars.js +0 -0
  232. package/src/ui/utils/markdown.js +709 -0
  233. package/src/ui/utils/native_context_menu.js +0 -0
  234. package/src/ui/utils/overlay_scrollbar.js +16 -12
  235. package/src/ui/utils/raf_resize_observer.js +0 -0
  236. package/src/ui/utils/scroll_lock.js +0 -0
  237. package/src/ui/utils/strip_scroll.js +55 -0
  238. package/src/ui/utils/tooltip_service.js +0 -8
  239. package/dist/chunk-6JSOVNID.js.map +0 -7
  240. package/dist/chunk-B5Q53LAJ.js.map +0 -7
  241. package/dist/chunk-FBGP5KN5.js.map +0 -7
  242. package/dist/chunk-FL5KFNQH.js.map +0 -7
  243. package/dist/chunk-NLTOY57M.js.map +0 -7
  244. package/dist/chunk-NNFTETK3.js +0 -633
  245. package/dist/chunk-NNFTETK3.js.map +0 -7
  246. package/dist/chunk-O5OHMWBB.js +0 -284
  247. package/dist/chunk-O5OHMWBB.js.map +0 -7
  248. package/dist/chunk-RQM5Z2MU.js.map +0 -7
  249. package/dist/chunk-SN4LSOAR.js.map +0 -7
  250. package/dist/chunk-W6FBJLPW.js +0 -367
  251. package/dist/chunk-W6FBJLPW.js.map +0 -7
  252. package/src/ui/controllers/panel_state_machine.js +0 -634
@@ -0,0 +1,442 @@
1
+ /**
2
+ * tile_config_form.js — a tile's config schema drawn as a form (0.17.0).
3
+ *
4
+ * `TileBase.getConfigSchema()` answers `{fields: [...]}` and `config_schema.js`
5
+ * knows how to validate, default and list options for those fields. This draws
6
+ * them, so an embedder no longer hand-builds a control per field:
7
+ *
8
+ * import { mountTileConfigForm } from '@flexdesk/tiles';
9
+ * const form = mountTileConfigForm(host, {
10
+ * schema: tile.getConfigSchema(),
11
+ * config: tile.config,
12
+ * data, // for `options: 'variables'` and the like
13
+ * onChange: (key, value, config) => tile.update(undefined, config),
14
+ * renderers: { variable: (ctx) => myPicker(ctx) },
15
+ * });
16
+ * form.getValues(); form.setValues(next); form.isValid();
17
+ * form.focusFirstInvalid(); form.flush(); form.dispose();
18
+ *
19
+ * ══ A FIELD ═════════════════════════════════════════════════════════════
20
+ *
21
+ * { key, type, label?, description?, default?, required?, readonly?,
22
+ * min?, max?, step?, options?, visibleWhen? }
23
+ *
24
+ * `type` is a `FieldTypes` entry (text, number, checkbox, select, multiselect,
25
+ * color, range) or a type a consumer renders through `renderers`. `options` is
26
+ * what `getFieldOptions(field, data)` reads. `visibleWhen` is
27
+ * `{field, in: [...]}` or `{field, equals}`: the row is hidden while the other
28
+ * field's value does not match, and shown again when it does — by toggling the
29
+ * row, never by rebuilding the form, so the control being typed into keeps its
30
+ * focus and its caret.
31
+ *
32
+ * A field with no `key` throws: a form that wrote every edit to
33
+ * `config['undefined']` was BUG-0021, and a loud mount is the cheaper failure.
34
+ * So does a `type` with neither a built-in control nor a renderer. `label` is
35
+ * optional: without one the field is called by its `key`, on its label and in
36
+ * every message (`count must be at least 1`).
37
+ *
38
+ * ══ AN EDIT ═════════════════════════════════════════════════════════════
39
+ *
40
+ * Every edit is checked by `validateField`. A valid one is applied and calls
41
+ * `onChange(key, value, config)` with a copy of the whole config. An invalid one
42
+ * shows its error under the field and is NOT applied: the config keeps the last
43
+ * valid value until the field is corrected (or `setValues` replaces it).
44
+ * `setValues` does not call `onChange`. `isValid()` answers false while a
45
+ * shown row has an error; `focusFirstInvalid()` focuses the first such row's
46
+ * control (a renderer's element, or the first focusable element inside it) and
47
+ * answers whether there was one.
48
+ *
49
+ * `commitOn: 'input'` (the default) checks text, number, range and colour
50
+ * controls on every keystroke. `commitOn: 'change'` checks them when the edit is
51
+ * finished (the control's `change`: blur, Enter, a released slider), for a
52
+ * consumer whose `onChange` is expensive — a save, a reload. A range still shows
53
+ * its value as it moves. `flush()` commits an edit typed but not yet finished,
54
+ * for a consumer about to take the form away; it is a no-op under `'input'`.
55
+ *
56
+ * ══ A CONSUMER'S CONTROL ════════════════════════════════════════════════
57
+ *
58
+ * `renderers[type](ctx)` — consulted before the built-ins, so it may also
59
+ * replace one — gets `{field, key, value, data, readonly, commit}` and returns an
60
+ * element, or `{element, setValue?(value), dispose?()}`. `commit(value)` runs
61
+ * the same check as a built-in edit and answers `{valid, error?}`. On a
62
+ * `readonly` field it applies nothing and answers `{valid: false, error}`, as a
63
+ * built-in control on a readonly field is disabled and never commits.
64
+ *
65
+ * ══ THE GRID'S COGWHEEL ═════════════════════════════════════════════════
66
+ *
67
+ * `TileGrid` emits `grid:config-modal-show` and nothing listens by default.
68
+ * `openTileConfig(eventBus, opts)` is the opt-in listener: it shows the form in
69
+ * a `SlideOutPanel` over `opts.host` (every finished, valid edit is saved — the
70
+ * form runs `commitOn: 'change'`, so a typed title is one save, not one per
71
+ * keystroke; closing the panel saves an edit still being typed) or in a modal
72
+ * (saved on Save, which is refused while a field shows an error: the modal
73
+ * stays open and the first invalid field gets the focus). A request that arrives while a modal is open is
74
+ * ignored: the open modal is the one being answered. It answers a function that
75
+ * unsubscribes; it also closes the slide-out, but leaves an open modal alone —
76
+ * that modal stays working (its Save still saves) until the user dismisses it.
77
+ */
78
+
79
+ import {
80
+ FieldTypes, validateField, mergeWithDefaults, getFieldOptions,
81
+ } from './config_schema.js';
82
+ import { SlideOutPanel } from '../ui/components/slide_out_panel.js';
83
+ import { openModal } from '../ui/components/modal.js';
84
+
85
+ let _seq = 0;
86
+ // What focusFirstInvalid may focus inside a renderer's element.
87
+ const FOCUSABLE = 'input:not([disabled]), select:not([disabled]), textarea:not([disabled]), '
88
+ + 'button:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable="true"]';
89
+
90
+ function el(tag, className, text) {
91
+ const n = document.createElement(tag);
92
+ if (className) n.className = className;
93
+ if (text != null) n.textContent = String(text);
94
+ return n;
95
+ }
96
+
97
+ function isVisible(field, values) {
98
+ const rule = field.visibleWhen;
99
+ if (!rule) return true;
100
+ const v = values[rule.field];
101
+ if (Array.isArray(rule.in)) return rule.in.includes(v);
102
+ if ('equals' in rule) return v === rule.equals;
103
+ return true;
104
+ }
105
+
106
+ /** Built-in controls: each answers `{element, read(), write(value)}`. */
107
+ const BUILT_IN = {
108
+ [FieldTypes.TEXT]: (field, id) => {
109
+ const input = el('input', 'fd-tile-config__input');
110
+ input.type = 'text';
111
+ input.id = id;
112
+ return {
113
+ element: input, events: ['input'],
114
+ read: () => input.value,
115
+ write: (v) => { input.value = v == null ? '' : String(v); },
116
+ };
117
+ },
118
+ [FieldTypes.NUMBER]: (field, id) => numeric(field, id, 'number'),
119
+ [FieldTypes.RANGE]: (field, id) => numeric(field, id, 'range'),
120
+ [FieldTypes.CHECKBOX]: (field, id) => {
121
+ const input = el('input', 'fd-tile-config__checkbox');
122
+ input.type = 'checkbox';
123
+ input.id = id;
124
+ return {
125
+ element: input, events: ['change'],
126
+ read: () => input.checked,
127
+ write: (v) => { input.checked = !!v; },
128
+ };
129
+ },
130
+ [FieldTypes.COLOR]: (field, id) => {
131
+ const input = el('input', 'fd-tile-config__color');
132
+ input.type = 'color';
133
+ input.id = id;
134
+ return {
135
+ element: input, events: ['input'],
136
+ read: () => input.value,
137
+ write: (v) => { if (v) input.value = String(v); },
138
+ };
139
+ },
140
+ [FieldTypes.SELECT]: (field, id, data) => choice(field, id, data, false),
141
+ [FieldTypes.MULTISELECT]: (field, id, data) => choice(field, id, data, true),
142
+ };
143
+
144
+ function numeric(field, id, kind) {
145
+ const input = el('input', `fd-tile-config__${kind === 'range' ? 'range' : 'input'}`);
146
+ input.type = kind;
147
+ input.id = id;
148
+ if (field.min != null) input.min = String(field.min);
149
+ if (field.max != null) input.max = String(field.max);
150
+ if (field.step != null) input.step = String(field.step);
151
+ const out = kind === 'range' ? el('output', 'fd-tile-config__range-value') : null;
152
+ const element = out ? el('span', 'fd-tile-config__range-wrap') : input;
153
+ if (out) element.append(input, out);
154
+ return {
155
+ element, events: ['input'],
156
+ // The raw text goes to `validateField`; the form turns it into a number.
157
+ read: () => input.value,
158
+ write: (v) => {
159
+ input.value = v == null ? '' : String(v);
160
+ if (out) out.textContent = input.value;
161
+ },
162
+ shown: (v) => { if (out) out.textContent = v == null ? '' : String(v); },
163
+ };
164
+ }
165
+
166
+ function choice(field, id, data, multiple) {
167
+ const select = el('select', 'fd-tile-config__select');
168
+ select.id = id;
169
+ select.multiple = multiple;
170
+ // Option values keep their type (a number stays a number) — they are read
171
+ // back from this array by index, never from the element's string value.
172
+ const options = getFieldOptions(field, data);
173
+ options.forEach((o) => select.append(el('option', null, o.label ?? o.value)));
174
+ return {
175
+ element: select, events: ['change'],
176
+ read: () => (multiple
177
+ ? options.filter((_, i) => select.options[i].selected).map((o) => o.value)
178
+ : (options[select.selectedIndex]?.value ?? null)),
179
+ write: (v) => {
180
+ if (multiple) {
181
+ const want = Array.isArray(v) ? v : [];
182
+ options.forEach((o, i) => { select.options[i].selected = want.includes(o.value); });
183
+ } else {
184
+ select.selectedIndex = options.findIndex((o) => o.value === v);
185
+ }
186
+ },
187
+ };
188
+ }
189
+
190
+ /** Number and range read as text; an empty one is null. */
191
+ function coerce(field, raw) {
192
+ if (field.type === FieldTypes.NUMBER || field.type === FieldTypes.RANGE) {
193
+ return raw === '' || raw == null ? null : Number(raw);
194
+ }
195
+ return raw;
196
+ }
197
+
198
+ /**
199
+ * Draw `schema.fields` into `host`.
200
+ * @param {'input'|'change'} [opts.commitOn='input'] When a text, number, range
201
+ * or colour control is checked and committed: on every keystroke, or once the
202
+ * edit is finished.
203
+ * @returns {{getValues(): Object, setValues(config: Object): void,
204
+ * isValid(): boolean, focusFirstInvalid(): boolean, flush(): void,
205
+ * dispose(): void}}
206
+ */
207
+ export function mountTileConfigForm(host, {
208
+ schema, config = {}, data = null, onChange = null, renderers = {}, commitOn = 'input',
209
+ } = {}) {
210
+ if (!host) throw new TypeError('mountTileConfigForm: a host element is required');
211
+ if (commitOn !== 'input' && commitOn !== 'change') {
212
+ throw new TypeError(`mountTileConfigForm: unknown commitOn "${commitOn}"`);
213
+ }
214
+ const fields = Array.isArray(schema?.fields) ? schema.fields : [];
215
+ fields.forEach((f, i) => {
216
+ if (typeof f?.key !== 'string' || !f.key) {
217
+ const called = typeof f?.label === 'string' && f.label ? ` "${f.label}"` : '';
218
+ throw new TypeError(`mountTileConfigForm: field #${i + 1}${called} has no key`);
219
+ }
220
+ });
221
+
222
+ let values = mergeWithDefaults(config || {}, schema);
223
+ const unlisten = []; // listeners removed by dispose
224
+ const root = el('div', 'fd-tile-config');
225
+ const rows = []; // {field, named, row, error, control, invalid, pending}
226
+
227
+ const showError = (r, message) => {
228
+ r.invalid = !!message;
229
+ r.error.textContent = message || '';
230
+ r.error.hidden = !message;
231
+ r.row.classList.toggle('fd-tile-config__row--invalid', !!message);
232
+ };
233
+
234
+ const applyVisibility = () => {
235
+ for (const r of rows) r.row.hidden = !isVisible(r.field, values);
236
+ };
237
+
238
+ const commit = (r, raw) => {
239
+ if (r.field.readonly) return { valid: false, error: `${r.named.label} is read-only` };
240
+ const result = validateField(raw, r.named);
241
+ if (!result.valid) {
242
+ showError(r, result.error);
243
+ return { valid: false, error: result.error };
244
+ }
245
+ showError(r, null);
246
+ const value = coerce(r.field, raw);
247
+ values = { ...values, [r.field.key]: value };
248
+ applyVisibility();
249
+ onChange?.(r.field.key, value, { ...values });
250
+ return { valid: true };
251
+ };
252
+
253
+ for (const field of fields) {
254
+ const id = `fd-tile-config-${++_seq}`;
255
+ const row = el('div', `fd-tile-config__row fd-tile-config__row--${field.type}`);
256
+ row.setAttribute('data-field', field.key);
257
+ // What validateField and the messages read: the label, or the key.
258
+ const named = { ...field, label: field.label ?? field.key };
259
+ const label = el('label', 'fd-tile-config__label', named.label);
260
+ const error = el('div', 'fd-tile-config__error');
261
+ error.hidden = true;
262
+ const r = { field, named, row, error, control: null, invalid: false, pending: null };
263
+
264
+ const custom = renderers?.[field.type];
265
+ if (typeof custom === 'function') {
266
+ const made = custom({
267
+ field, key: field.key, value: values[field.key], data,
268
+ readonly: !!field.readonly, commit: (v) => commit(r, v),
269
+ });
270
+ const element = made instanceof HTMLElement ? made : made?.element;
271
+ if (!(element instanceof HTMLElement)) {
272
+ throw new TypeError(`mountTileConfigForm: the renderer for "${field.type}" returned no element`);
273
+ }
274
+ element.id ||= id;
275
+ // The id the control carries: a renderer may have set its own.
276
+ label.htmlFor = element.id;
277
+ r.control = {
278
+ element,
279
+ write: made instanceof HTMLElement ? () => {} : (v) => made.setValue?.(v),
280
+ dispose: made instanceof HTMLElement ? null : () => made.dispose?.(),
281
+ };
282
+ } else {
283
+ const build = BUILT_IN[field.type];
284
+ if (!build) {
285
+ throw new TypeError(`mountTileConfigForm: no control for type "${field.type}" (field "${field.key}"); pass a renderer`);
286
+ }
287
+ const c = build(field, id, data);
288
+ label.htmlFor = id;
289
+ c.write(values[field.key]);
290
+ const input = c.element.matches('input, select') ? c.element : c.element.querySelector('input, select');
291
+ if (field.readonly) {
292
+ input.disabled = true;
293
+ } else {
294
+ const listen = (type, fn) => {
295
+ input.addEventListener(type, fn);
296
+ unlisten.push(() => input.removeEventListener(type, fn));
297
+ };
298
+ const onEdit = () => {
299
+ r.pending = null;
300
+ const raw = c.read();
301
+ c.shown?.(raw);
302
+ commit(r, raw);
303
+ };
304
+ if (commitOn === 'change') {
305
+ // Checked once the edit is finished; until then it is
306
+ // pending, so flush() can still commit it.
307
+ listen('change', onEdit);
308
+ if (c.events.includes('input')) {
309
+ listen('input', () => { r.pending = onEdit; c.shown?.(c.read()); });
310
+ }
311
+ } else {
312
+ for (const type of c.events) listen(type, onEdit);
313
+ }
314
+ }
315
+ r.control = c;
316
+ }
317
+
318
+ if (field.type === FieldTypes.CHECKBOX) row.append(r.control.element, label);
319
+ else row.append(label, r.control.element);
320
+ if (field.description) row.append(el('div', 'fd-tile-config__help', field.description));
321
+ row.append(error);
322
+ rows.push(r);
323
+ root.append(row);
324
+ }
325
+
326
+ applyVisibility();
327
+ host.append(root);
328
+
329
+ return {
330
+ getValues: () => ({ ...values }),
331
+ setValues(next) {
332
+ values = mergeWithDefaults(next || {}, schema);
333
+ for (const r of rows) {
334
+ r.pending = null;
335
+ r.control.write(values[r.field.key]);
336
+ showError(r, null);
337
+ }
338
+ applyVisibility();
339
+ },
340
+ isValid: () => rows.every((r) => r.row.hidden || !r.invalid),
341
+ focusFirstInvalid() {
342
+ const r = rows.find((x) => !x.row.hidden && x.invalid);
343
+ if (!r) return false;
344
+ const { element } = r.control;
345
+ const target = element.matches(FOCUSABLE) ? element : element.querySelector(FOCUSABLE);
346
+ (target || element).focus();
347
+ return true;
348
+ },
349
+ flush() {
350
+ for (const r of rows) r.pending?.();
351
+ },
352
+ dispose() {
353
+ unlisten.splice(0).forEach((off) => off());
354
+ for (const r of rows) {
355
+ try { r.control.dispose?.(); } catch (err) { console.warn('[tile-config] a renderer dispose threw', err); }
356
+ }
357
+ rows.length = 0;
358
+ root.remove();
359
+ },
360
+ };
361
+ }
362
+
363
+ /**
364
+ * Show `grid:config-modal-show` requests as a config form.
365
+ *
366
+ * @param {{on(name: string, handler: Function): {dispose(): void}}} eventBus
367
+ * The `EventBus` the `TileGrid` emits on.
368
+ * @param {object} [opts]
369
+ * @param {'modal'|'slide-out'} [opts.mode='modal'] A modal saves on Save, and
370
+ * refuses Save while a field shows an error; a slide-out saves every
371
+ * finished, valid edit (`commitOn: 'change'`).
372
+ * @param {HTMLElement} [opts.host] The slide-out's host (required for it).
373
+ * @param {number} [opts.width] The slide-out's or the modal's width.
374
+ * @param {Object} [opts.renderers] Passed to `mountTileConfigForm`.
375
+ * @param {string|Function} [opts.title='Tile settings'] Or `(detail) => string`.
376
+ * @returns {() => void} Unsubscribes and closes the slide-out. An open modal is
377
+ * left to the user: it keeps its form, and its Save still calls `onSave`.
378
+ */
379
+ export function openTileConfig(eventBus, { mode = 'modal', host = null, width, renderers = {}, title = 'Tile settings' } = {}) {
380
+ if (typeof eventBus?.on !== 'function') throw new TypeError('openTileConfig: an event bus with on() is required');
381
+ if (mode !== 'modal' && mode !== 'slide-out') throw new TypeError(`openTileConfig: unknown mode "${mode}"`);
382
+ if (mode === 'slide-out' && !(host instanceof HTMLElement)) {
383
+ throw new TypeError('openTileConfig: a slide-out needs a host element');
384
+ }
385
+
386
+ let form = null; // the slide-out's form
387
+ let panel = null;
388
+ let modalOpen = false;
389
+ // An edit still being typed when the panel goes is saved, not lost.
390
+ const drop = () => { form?.flush(); form?.dispose(); form = null; };
391
+
392
+ const show = (detail = {}) => {
393
+ if (modalOpen) return;
394
+ const heading = typeof title === 'function' ? title(detail) : title;
395
+ const base = { schema: detail.schema, config: detail.config, data: detail.data ?? null, renderers };
396
+
397
+ if (mode === 'slide-out') {
398
+ drop();
399
+ if (!panel) {
400
+ panel = new SlideOutPanel({ ...(width ? { width } : {}), onClose: drop });
401
+ panel.mount(host);
402
+ }
403
+ const body = panel.open(heading, 'tune');
404
+ form = mountTileConfigForm(body, {
405
+ ...base, commitOn: 'change', onChange: (_k, _v, cfg) => detail.onSave?.(cfg),
406
+ });
407
+ return;
408
+ }
409
+
410
+ // The modal's form lives until the user dismisses the modal, whatever
411
+ // else happens meanwhile: requests are ignored while it is open, and
412
+ // the returned function leaves it alone.
413
+ const content = el('div', 'fd-tile-config-host');
414
+ const mine = mountTileConfigForm(content, base);
415
+ modalOpen = true;
416
+ openModal({
417
+ title: heading, icon: 'tune', content, ...(width ? { width } : {}),
418
+ actions: [
419
+ { label: 'Cancel', value: null },
420
+ // Refused while a field shows an error: its last valid value is
421
+ // not what the user is looking at, so saving it would be a lie.
422
+ { label: 'Save', value: 'save', primary: true, keepOpen: true, onClick: (close) => {
423
+ if (mine.isValid()) { close('save'); return; }
424
+ mine.focusFirstInvalid();
425
+ } },
426
+ ],
427
+ }).then((v) => {
428
+ modalOpen = false;
429
+ if (v === 'save') detail.onSave?.(mine.getValues());
430
+ mine.dispose();
431
+ });
432
+ };
433
+
434
+ const sub = eventBus.on('grid:config-modal-show', show);
435
+
436
+ return () => {
437
+ sub.dispose();
438
+ drop();
439
+ panel?.dispose();
440
+ panel = null;
441
+ };
442
+ }
@@ -848,6 +848,8 @@ export class TileGrid {
848
848
  tileType,
849
849
  config,
850
850
  schema,
851
+ // What `getFieldOptions` reads for a field with dynamic options.
852
+ data: this.data,
851
853
  variables: this.data ? this._getVariableList() : [],
852
854
  onSave: (newConfig) => {
853
855
  const tile = this.tiles.get(tileId);
File without changes
@@ -41,12 +41,15 @@
41
41
  * the panel itself is open is the embedder's (a tile it toggles), so the bus
42
42
  * event is the embedder's to hear too.
43
43
  *
44
- * The strip is never rebuilt: a select toggles classes on the same buttons, so
45
- * a click that lands while a tab is being selected keeps its target.
44
+ * The strip is `createTabs` (`../ui/components/tabs.js`, STORY-0048) under the
45
+ * `twm-bp` block: this file adds only what a bottom panel has and a strip does
46
+ * not — the pane's own store, the remembered tab, `persist`, and its event.
46
47
  */
47
48
 
49
+ import { createTabs, TAB_LEVELS } from '../ui/components/tabs.js';
50
+
48
51
  /** Badge levels, worst last. Any other value draws the neutral badge. */
49
- export const BADGE_LEVELS = Object.freeze(['info', 'warning', 'alert']);
52
+ export const BADGE_LEVELS = TAB_LEVELS;
50
53
 
51
54
  /**
52
55
  * @param {HTMLElement} container
@@ -61,108 +64,44 @@ export const BADGE_LEVELS = Object.freeze(['info', 'warning', 'alert']);
61
64
  export function createBottomPanelTabs(container, {
62
65
  panes = [], remember = null, store = null, eventBus = null, onSelect = null, initial = null,
63
66
  } = {}) {
64
- const doc = container.ownerDocument;
65
- const root = doc.createElement('div');
66
- root.className = 'twm-bp';
67
- const strip = doc.createElement('div');
68
- strip.className = 'twm-bp__tabs';
69
- strip.setAttribute('role', 'tablist');
70
- const body = doc.createElement('div');
71
- body.className = 'twm-bp__body';
72
- root.append(strip, body);
73
- container.appendChild(root);
74
-
75
67
  const byId = new Map(panes.map((pane) => [pane.id, pane]));
76
- const tabs = new Map();
77
- const mounted = new Map();
78
- let active = null;
68
+ // A tab put in front by THIS FILE (the remembered one) is not a choice: it
69
+ // is neither written back nor allowed to beat a choice made meanwhile.
70
+ let restoring = false;
79
71
  let chosen = false;
80
72
  let destroyed = false;
81
-
82
- for (const pane of panes) {
83
- const tab = doc.createElement('button');
84
- tab.type = 'button';
85
- tab.className = 'twm-bp__tab';
86
- tab.dataset.pane = pane.id;
87
- tab.setAttribute('role', 'tab');
88
- tab.setAttribute('aria-selected', 'false');
89
- const label = doc.createElement('span');
90
- label.className = 'twm-bp__label';
91
- label.textContent = pane.label;
92
- const badge = doc.createElement('span');
93
- badge.className = 'twm-bp__badge';
94
- badge.hidden = true;
95
- tab.append(label, badge);
96
- tab.addEventListener('click', () => api.select(pane.id));
97
- strip.appendChild(tab);
98
- tabs.set(pane.id, tab);
99
- }
100
-
101
- function mount(id) {
102
- if (mounted.has(id)) return mounted.get(id);
103
- const pane = byId.get(id);
104
- const el = doc.createElement('div');
105
- el.className = 'twm-bp-pane';
106
- el.dataset.pane = id;
107
- el.setAttribute('role', 'tabpanel');
108
- el.hidden = id !== active;
109
- body.appendChild(el);
110
- const entry = { el, instance: null };
111
- mounted.set(id, entry);
112
- // ONE PANE THAT THROWS IS ONE EMPTY TAB, not a dead panel: the strip
113
- // and every other pane go on working.
114
- try {
115
- entry.instance = pane.mount?.(el, store ? store(pane) : null, api) ?? null;
116
- } catch (err) {
117
- console.error(`[bottom-panel] pane "${id}" failed to mount`, err);
118
- }
119
- return entry;
120
- }
121
-
122
- function show(id) {
123
- if (destroyed || !byId.has(id)) return false;
124
- active = id;
125
- for (const [key, tab] of tabs) {
126
- const on = key === id;
127
- tab.classList.toggle('twm-bp__tab--on', on);
128
- tab.setAttribute('aria-selected', String(on));
129
- }
130
- for (const [key, entry] of mounted) entry.el.hidden = key !== id;
131
- mount(id).instance?.onShow?.();
132
- onSelect?.(id, byId.get(id));
133
- return true;
134
- }
73
+ // Eager panes and the first one shown mount inside `createTabs`, before it
74
+ // has answered; `mountPane` hands the strip (and `api.root`) over then, so
75
+ // a pane sees the whole api inside its `mount`.
76
+ let strip = null;
135
77
 
136
78
  const api = {
137
- root,
79
+ root: null,
138
80
  /** Bring a tab to the front, and remember it. */
139
81
  select(id) {
140
- if (!show(id)) return;
82
+ if (destroyed || !byId.has(id)) return;
141
83
  chosen = true;
142
- try { remember?.write?.(id); } catch { /* storage refused: not ours to fix */ }
84
+ if (strip.active() !== id) { strip.select(id); return; }
85
+ // Already in front: asked for again (a click on it, a toast's
86
+ // "+N more"), so the pane is told again and the choice is
87
+ // remembered, as any select.
88
+ strip.instance(id)?.onShow?.();
89
+ remembered(id);
143
90
  },
144
91
  /** The tab in front, or null. */
145
- active: () => active,
92
+ active: () => strip.active(),
146
93
  /** `count` on a tab, coloured by `level`; nothing at zero. */
147
- setBadge(id, count, level = null) {
148
- const badge = tabs.get(id)?.querySelector('.twm-bp__badge');
149
- if (!badge) return;
150
- const n = Number(count) || 0;
151
- badge.hidden = n <= 0;
152
- badge.textContent = n > 99 ? '99+' : String(Math.max(n, 0));
153
- badge.className = 'twm-bp__badge'
154
- + (BADGE_LEVELS.includes(level) ? ` twm-bp__badge--${level}` : '');
155
- },
94
+ setBadge: (id, count, level = null) => strip.setBadge(id, count, level),
156
95
  /** A mounted pane's live instance, or null — how one tab hands work to
157
96
  * another. `select` it first, which mounts it. */
158
- instance: (id) => mounted.get(id)?.instance ?? null,
97
+ instance: (id) => strip.instance(id),
159
98
  /** The panel is on screen again (its tile re-mounted): tell the pane in front. */
160
99
  reshow() {
161
- if (active) mounted.get(active)?.instance?.onShow?.();
100
+ strip.instance(strip.active())?.onShow?.();
162
101
  },
163
102
  /** Before the panel is torn down: every mounted pane saves. */
164
103
  async persist() {
165
- for (const entry of mounted.values()) await entry.instance?.persist?.();
104
+ for (const instance of strip.instances()) await instance.persist?.();
166
105
  },
167
106
  /** Gone for good: panes save, then are destroyed; the bus is let go. */
168
107
  async destroy() {
@@ -170,29 +109,58 @@ export function createBottomPanelTabs(container, {
170
109
  await api.persist();
171
110
  destroyed = true;
172
111
  try { subscription?.dispose?.(); } catch { /* bus gone */ }
173
- for (const entry of mounted.values()) {
174
- try { entry.instance?.destroy?.(); } catch (err) {
175
- console.warn('[bottom-panel] pane destroy failed', err);
176
- }
177
- }
178
- mounted.clear();
179
- root.remove();
112
+ strip.destroy();
180
113
  },
181
114
  };
182
115
 
183
- for (const pane of panes) if (pane.eager) mount(pane.id);
116
+ function remembered(id) {
117
+ onSelect?.(id, byId.get(id));
118
+ try { remember?.write?.(id); } catch { /* storage refused: not ours to fix */ }
119
+ }
184
120
 
121
+ strip = createTabs(container, {
122
+ block: 'twm-bp',
123
+ dataKey: 'pane',
124
+ items: panes.map(({ id, label, eager }) => ({ id, label, eager: !!eager })),
125
+ active: byId.has(initial) ? initial : panes[0]?.id,
126
+ mountPane: (item, el, tabs) => {
127
+ strip ??= tabs;
128
+ api.root ??= tabs.root;
129
+ const pane = byId.get(item.id);
130
+ return pane.mount?.(el, store ? store(pane) : null, api) ?? null;
131
+ },
132
+ onSelect: (id) => {
133
+ if (restoring) { onSelect?.(id, byId.get(id)); return; }
134
+ chosen = true;
135
+ remembered(id);
136
+ },
137
+ // A click on the tab already in front is a select here, as it always
138
+ // was: the pane hears onShow again and the choice is remembered.
139
+ onReselect: (id) => api.select(id),
140
+ });
141
+ api.root ??= strip.root;
142
+ if (strip.active() != null) onSelect?.(strip.active(), byId.get(strip.active()));
143
+
144
+ // The bus is the bottom panel's, not the strip's: a `bottompanel:show` for
145
+ // the tab already in front still tells its pane, which a strip does not.
185
146
  const subscription = eventBus?.on?.('bottompanel:show',
186
147
  (payload) => api.select(payload?.tab ?? panes[0]?.id)) ?? null;
187
148
 
188
- show(byId.has(initial) ? initial : panes[0]?.id);
189
-
190
149
  if (remember?.read) {
191
150
  Promise.resolve()
192
151
  .then(() => remember.read())
193
152
  .then((saved) => {
194
- if (chosen || destroyed) return;
195
- if (byId.has(saved)) show(saved);
153
+ if (chosen || destroyed || !byId.has(saved)) return;
154
+ if (strip.active() === saved) {
155
+ // Already in front: shown again, as it always was — the
156
+ // pane hears onShow and the caller onSelect, nothing is
157
+ // written back.
158
+ strip.instance(saved)?.onShow?.();
159
+ onSelect?.(saved, byId.get(saved));
160
+ return;
161
+ }
162
+ restoring = true;
163
+ try { strip.select(saved); } finally { restoring = false; }
196
164
  })
197
165
  .catch(() => { /* nothing remembered */ });
198
166
  }