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
package/dist/tiles.js CHANGED
@@ -12,12 +12,15 @@ import {
12
12
  tile_registry_exports
13
13
  } from "./chunk-FOOS3T5L.js";
14
14
  import {
15
- ActionDropdown
16
- } from "./chunk-O5OHMWBB.js";
15
+ ActionDropdown,
16
+ SlideOutPanel
17
+ } from "./chunk-4A7SJC3T.js";
17
18
  import {
18
19
  showContextMenu
19
- } from "./chunk-B5Q53LAJ.js";
20
- import "./chunk-NNFTETK3.js";
20
+ } from "./chunk-UWO43BZU.js";
21
+ import {
22
+ openModal
23
+ } from "./chunk-D3COLZZ2.js";
21
24
  import {
22
25
  buildHistogramTrace,
23
26
  buildLegendLayout,
@@ -32,10 +35,13 @@ import {
32
35
  purge,
33
36
  resize,
34
37
  strideDownsampleShared
35
- } from "./chunk-RQM5Z2MU.js";
36
- import "./chunk-FBGP5KN5.js";
37
- import "./chunk-6JSOVNID.js";
38
- import "./chunk-FL5KFNQH.js";
38
+ } from "./chunk-MCIXFNVF.js";
39
+ import "./chunk-Q36EPT2R.js";
40
+ import {
41
+ isHexColor
42
+ } from "./chunk-CVMCZZX2.js";
43
+ import "./chunk-HNU6MM2B.js";
44
+ import "./chunk-WVFGV5FT.js";
39
45
  import {
40
46
  __toCommonJS
41
47
  } from "./chunk-JYWURG5T.js";
@@ -75,6 +81,11 @@ function validateField(value, field) {
75
81
  return { valid: false, error: `${field.label} must be selected` };
76
82
  }
77
83
  break;
84
+ case FieldTypes.COLOR:
85
+ if (value != null && value !== "" && !isHexColor(value)) {
86
+ return { valid: false, error: `${field.label} must be a hex colour (#rgb or #rrggbb)` };
87
+ }
88
+ break;
78
89
  case FieldTypes.MULTISELECT:
79
90
  if (field.required && (!Array.isArray(value) || value.length === 0)) {
80
91
  return { valid: false, error: `${field.label} must have at least one selection` };
@@ -710,7 +721,7 @@ var TileBase = class {
710
721
  * @private
711
722
  */
712
723
  _onConfigClick() {
713
- this.element?.closest(".tile-grid")?.querySelectorAll(".tile.tile--selected").forEach((el2) => el2.classList.remove("tile--selected"));
724
+ this.element?.closest(".tile-grid")?.querySelectorAll(".tile.tile--selected").forEach((el3) => el3.classList.remove("tile--selected"));
714
725
  this.element?.classList.add("tile--selected");
715
726
  this.eventBus?.emit?.("tile:config-request", {
716
727
  tileId: this.id,
@@ -922,6 +933,234 @@ var TileBase = class {
922
933
  }
923
934
  };
924
935
 
936
+ // src/tiles/figure_tile.js
937
+ var FIGURE_CONFIG = Object.freeze({
938
+ scrollZoom: false,
939
+ displayModeBar: false,
940
+ responsive: true
941
+ });
942
+ var DOWNLOAD_PNG = Object.freeze({ action: "download-png", label: "Download PNG", icon: "image" });
943
+ var FigureTile = class extends TileBase {
944
+ static TYPE = "flexdesk.figure";
945
+ static TITLE = "Figure";
946
+ static EXPANDABLE = true;
947
+ /** The empty state's text when `figure()` answers `null`. */
948
+ static EMPTY_MESSAGE = "No data to show.";
949
+ /**
950
+ * @param {Object} options - TileBase's, plus:
951
+ * @param {Function} [options.onPointClick] - `({customdata, point, tile})`
952
+ * on a click on a mark. Also assignable later as `tile.onPointClick`.
953
+ */
954
+ constructor(options = {}) {
955
+ super(options);
956
+ this.onPointClick = typeof options.onPointClick === "function" ? options.onPointClick : null;
957
+ this._plotEl = null;
958
+ this._drawn = false;
959
+ this._drawSeq = 0;
960
+ this._figure = null;
961
+ }
962
+ /**
963
+ * The one override: what to draw for `data`.
964
+ * @param {*} _data
965
+ * @returns {{traces: Array, layout?: Object, config?: Object, frames?: Array}|null}
966
+ */
967
+ figure(_data) {
968
+ throw new Error(`${this.constructor.name}.figure() must be implemented by the subclass`);
969
+ }
970
+ /**
971
+ * Hand this tile its data. Unlike `update()`, an empty or null value is
972
+ * drawn — as the empty state — rather than ignored.
973
+ */
974
+ setData(data) {
975
+ if (this._disposed) return;
976
+ this.data = data ?? null;
977
+ if (this.contentElement) this.render(this.data);
978
+ }
979
+ /**
980
+ * Kick off the draw and return. Deliberately not `async`: nothing awaits
981
+ * `render`, so a returned promise would be a rejection nobody catches.
982
+ */
983
+ render(data) {
984
+ this._draw(data);
985
+ }
986
+ /** One relayout when a resize settles; nothing while a draw is in flight. */
987
+ onResize() {
988
+ if (!this._plotEl || !this._drawn) return;
989
+ resize(this._plotEl).catch(() => {
990
+ });
991
+ }
992
+ menuItems() {
993
+ return [...super.menuItems(), ...this._figureMenuItems()];
994
+ }
995
+ /** The entries this class adds to the menu: Download PNG. For a subclass
996
+ * that wants them WITHOUT `TileBase`'s own. @protected */
997
+ _figureMenuItems() {
998
+ return [{ ...DOWNLOAD_PNG }];
999
+ }
1000
+ _onMenuAction(action) {
1001
+ if (action === DOWNLOAD_PNG.action) this._downloadPng();
1002
+ else super._onMenuAction(action);
1003
+ }
1004
+ /** The last figure drawn, for the expand window; `null` while there is none. */
1005
+ getExpandData() {
1006
+ const fig = this._figure;
1007
+ if (!fig) return null;
1008
+ return {
1009
+ title: this.config.title || this.constructor.TITLE,
1010
+ // Copies: Plotly writes onto the trace and layout objects it draws.
1011
+ traces: fig.traces.map((t) => ({ ...t })),
1012
+ layout: { ...fig.layout || {} },
1013
+ frames: Array.isArray(fig.frames) ? fig.frames : void 0
1014
+ };
1015
+ }
1016
+ // ── the three states: each one invalidates the figure ───────────────
1017
+ showLoading() {
1018
+ this._invalidate();
1019
+ super.showLoading();
1020
+ }
1021
+ showEmpty(message) {
1022
+ this._invalidate();
1023
+ super.showEmpty(message);
1024
+ }
1025
+ showError(message) {
1026
+ this._invalidate();
1027
+ super.showError(message);
1028
+ }
1029
+ /** Release the figure BEFORE the element goes — `TileBase.dispose` nulls it. */
1030
+ dispose() {
1031
+ this._invalidate();
1032
+ super.dispose();
1033
+ }
1034
+ /** Any draw in flight is now stale, and the figure on screen is released. */
1035
+ _invalidate() {
1036
+ this._drawSeq += 1;
1037
+ this._releasePlot();
1038
+ }
1039
+ /** Hand the current figure back to Plotly and forget it. Idempotent, and
1040
+ * not awaited: a rejected purge (Plotly absent, already purged) must not
1041
+ * surface as an unhandled rejection. */
1042
+ _releasePlot() {
1043
+ const el3 = this._plotEl;
1044
+ this._plotEl = null;
1045
+ this._drawn = false;
1046
+ this._figure = null;
1047
+ if (el3) purge(el3).catch(() => {
1048
+ });
1049
+ }
1050
+ /**
1051
+ * The nodes the content holds while `plotEl` draws `fig`: the plot alone
1052
+ * here. A subclass adds what sits around the figure (a note, a caption).
1053
+ * @param {HTMLElement} plotEl
1054
+ * @param {Object} _fig - what `figure()` answered
1055
+ * @returns {HTMLElement[]}
1056
+ * @protected
1057
+ */
1058
+ _figureContent(plotEl, _fig) {
1059
+ return [plotEl];
1060
+ }
1061
+ // ── the draw ────────────────────────────────────────────────────────
1062
+ async _draw(data) {
1063
+ this._drawSeq += 1;
1064
+ const seq = this._drawSeq;
1065
+ const content = this.contentElement;
1066
+ if (!content) return;
1067
+ const stale = () => this._disposed || seq !== this._drawSeq;
1068
+ this._releasePlot();
1069
+ let fig;
1070
+ try {
1071
+ fig = this.figure(data);
1072
+ if (fig != null && (typeof fig !== "object" || !Array.isArray(fig.traces))) {
1073
+ throw new Error("figure() must answer {traces, layout} or null");
1074
+ }
1075
+ } catch (err) {
1076
+ this._failed(err);
1077
+ return;
1078
+ }
1079
+ if (!fig) {
1080
+ if (!stale()) this.showEmpty(this.constructor.EMPTY_MESSAGE);
1081
+ return;
1082
+ }
1083
+ const host = document.createElement("div");
1084
+ host.className = "fd-figure-tile";
1085
+ content.replaceChildren(...this._figureContent(host, fig));
1086
+ this._plotEl = host;
1087
+ this._drawn = false;
1088
+ try {
1089
+ const P = await ensurePlotly();
1090
+ if (stale()) return;
1091
+ await createChart(host, fig.traces, fig.layout || {}, { ...FIGURE_CONFIG, ...fig.config || {} });
1092
+ if (Array.isArray(fig.frames) && fig.frames.length && !stale()) {
1093
+ await P.addFrames(host, fig.frames);
1094
+ }
1095
+ } catch (err) {
1096
+ if (stale()) return;
1097
+ this._failed(err);
1098
+ return;
1099
+ }
1100
+ if (stale()) {
1101
+ purge(host).catch(() => {
1102
+ });
1103
+ return;
1104
+ }
1105
+ this._drawn = true;
1106
+ this._figure = fig;
1107
+ this._wireClick(host);
1108
+ }
1109
+ /** Plotly's click, reduced to the point's own `customdata`. A pie's point
1110
+ * carries its slice index as `pointNumber` (older builds: `pointIndex`),
1111
+ * and its `customdata` is read off the trace by that index. */
1112
+ _wireClick(host) {
1113
+ if (typeof host.on !== "function") return;
1114
+ host.on("plotly_click", (ev) => {
1115
+ const point = ev?.points?.[0];
1116
+ if (!point || typeof this.onPointClick !== "function") return;
1117
+ const trace = point.data || {};
1118
+ let customdata = point.customdata;
1119
+ if (trace.type === "pie" || customdata === void 0) {
1120
+ const i = point.pointNumber ?? point.pointIndex;
1121
+ if (Array.isArray(trace.customdata) && i != null) customdata = trace.customdata[i];
1122
+ }
1123
+ try {
1124
+ this.onPointClick({ customdata, point, tile: this });
1125
+ } catch (err) {
1126
+ console.error(`[${this.constructor.name}] onPointClick threw:`, err);
1127
+ }
1128
+ });
1129
+ }
1130
+ async _downloadPng() {
1131
+ if (!this._plotEl || !this._drawn) return;
1132
+ try {
1133
+ const P = await ensurePlotly();
1134
+ const uri = await P.toImage(this._plotEl, { format: "png", scale: 2 });
1135
+ const filename = `${slug(this.config.title || this.constructor.TITLE)}.png`;
1136
+ const dialogs = this.host?.dialogs;
1137
+ const result = dialogs ? await dialogs.saveFile({ data: uri.split(",")[1], filename, kind: "png" }) : null;
1138
+ if (result) {
1139
+ if (result.ok) this._notify("Download", `Chart saved to ${result.path}`, "success");
1140
+ else if (!result.cancelled) this._notify("Download failed", result.error || "The image could not be saved", "error");
1141
+ return;
1142
+ }
1143
+ const a = document.createElement("a");
1144
+ a.href = uri;
1145
+ a.download = filename;
1146
+ a.click();
1147
+ } catch (err) {
1148
+ this._notify("Download failed", `The image could not be made: ${err?.message || err}`, "error");
1149
+ }
1150
+ }
1151
+ _failed(err) {
1152
+ if (this._disposed) return;
1153
+ this.showError(`This chart could not be drawn: ${err?.message || err}`);
1154
+ this.eventBus?.emit?.("tile:render-failed", {
1155
+ tileId: this.id,
1156
+ message: err?.message || String(err)
1157
+ });
1158
+ }
1159
+ };
1160
+ function slug(text) {
1161
+ return String(text).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "chart";
1162
+ }
1163
+
925
1164
  // src/tiles/chart_tiles.js
926
1165
  init_tile_registry();
927
1166
  var CATEGORICAL_ONLY = /* @__PURE__ */ new Set(["pie", "donut", "funnel"]);
@@ -973,25 +1212,13 @@ function pointCount(s) {
973
1212
  }
974
1213
  return 0;
975
1214
  }
976
- var ChartTile = class extends TileBase {
1215
+ var ChartTile = class extends FigureTile {
977
1216
  static TYPE = "flexdesk.chart";
978
1217
  static TITLE = "Chart";
979
1218
  static ICON = "insert_chart";
980
1219
  static DESCRIPTION = "Line, bar, area, pie, donut, funnel or histogram over series you supply.";
981
1220
  static DEFAULT_SIZE = { w: 6, h: 4 };
982
1221
  static SIZE_CONSTRAINTS = { minW: 3, minH: 3, maxW: 12, maxH: 10 };
983
- /**
984
- * @param {Object} options - TileBase's, plus:
985
- * @param {Function} [options.onPointClick] - `({customdata, point, tile})`
986
- * on a click on a mark. Also assignable later as `tile.onPointClick`.
987
- */
988
- constructor(options = {}) {
989
- super(options);
990
- this.onPointClick = typeof options.onPointClick === "function" ? options.onPointClick : null;
991
- this._plotEl = null;
992
- this._drawn = false;
993
- this._drawSeq = 0;
994
- }
995
1222
  /** Pure and static-only — it runs inside `TileBase`'s constructor. */
996
1223
  getDefaultConfig() {
997
1224
  return {
@@ -1011,21 +1238,21 @@ var ChartTile = class extends TileBase {
1011
1238
  getConfigSchema() {
1012
1239
  return {
1013
1240
  fields: [
1014
- { name: "title", type: "text", label: "Tile title" },
1241
+ { key: "title", type: "text", label: "Tile title" },
1015
1242
  {
1016
- name: "chart_type",
1243
+ key: "chart_type",
1017
1244
  type: "select",
1018
1245
  label: "Chart type",
1019
1246
  options: CHART_TILE_TYPES.map((o) => ({ ...o }))
1020
1247
  },
1021
1248
  {
1022
- name: "unit",
1249
+ key: "unit",
1023
1250
  type: "text",
1024
1251
  label: "Unit",
1025
- help: 'Shown with the value axis title, e.g. "pts" or "h".'
1252
+ description: 'Shown with the value axis title, e.g. "pts" or "h".'
1026
1253
  },
1027
1254
  {
1028
- name: "legend",
1255
+ key: "legend",
1029
1256
  type: "select",
1030
1257
  label: "Legend",
1031
1258
  visibleWhen: { field: "chart_type", in: [...READS.legend] },
@@ -1038,38 +1265,38 @@ var ChartTile = class extends TileBase {
1038
1265
  ]
1039
1266
  },
1040
1267
  {
1041
- name: "x_title",
1268
+ key: "x_title",
1042
1269
  type: "text",
1043
1270
  label: "X axis title",
1044
1271
  visibleWhen: { field: "chart_type", in: [...READS.axis_titles] }
1045
1272
  },
1046
1273
  {
1047
- name: "y_title",
1274
+ key: "y_title",
1048
1275
  type: "text",
1049
1276
  label: "Y axis title",
1050
1277
  visibleWhen: { field: "chart_type", in: [...READS.axis_titles] }
1051
1278
  },
1052
1279
  {
1053
- name: "y_scale",
1280
+ key: "y_scale",
1054
1281
  type: "select",
1055
1282
  label: "Y scale",
1056
1283
  visibleWhen: { field: "chart_type", in: [...READS.y_scale] },
1057
1284
  options: [{ value: "linear", label: "Linear" }, { value: "log", label: "Logarithmic" }]
1058
1285
  },
1059
1286
  {
1060
- name: "y_min",
1287
+ key: "y_min",
1061
1288
  type: "number",
1062
1289
  label: "Y minimum",
1063
1290
  visibleWhen: { field: "chart_type", in: [...READS.y_range] }
1064
1291
  },
1065
1292
  {
1066
- name: "y_max",
1293
+ key: "y_max",
1067
1294
  type: "number",
1068
1295
  label: "Y maximum",
1069
1296
  visibleWhen: { field: "chart_type", in: [...READS.y_range] }
1070
1297
  },
1071
1298
  {
1072
- name: "show_markers",
1299
+ key: "show_markers",
1073
1300
  type: "checkbox",
1074
1301
  label: "Show point markers",
1075
1302
  visibleWhen: { field: "chart_type", in: [...READS.show_markers] }
@@ -1092,145 +1319,28 @@ var ChartTile = class extends TileBase {
1092
1319
  super.update(data === null ? void 0 : data, config);
1093
1320
  if (config) applyTitle(this);
1094
1321
  }
1095
- /**
1096
- * Hand this tile its series. Unlike `update()`, an empty or null value is
1097
- * drawn — as the empty state — rather than ignored.
1098
- */
1099
- setData(data) {
1100
- if (this._disposed) return;
1101
- this.data = data ?? null;
1102
- if (this.contentElement) this.render(this.data);
1103
- }
1104
- /**
1105
- * Kick off the draw and return. Deliberately not `async`: nothing awaits
1106
- * `render`, so a returned promise would be a rejection nobody catches.
1107
- */
1108
- render(data) {
1109
- this._draw(data);
1110
- }
1111
- /** One relayout when a resize settles; nothing while a draw is in flight. */
1112
- onResize() {
1113
- if (!this._plotEl || !this._drawn) return;
1114
- resize(this._plotEl).catch(() => {
1115
- });
1116
- }
1117
- menuItems() {
1118
- return [{ action: "download-png", label: "Download PNG", icon: "image" }];
1119
- }
1120
- _onMenuAction(action) {
1121
- if (action === "download-png") this._downloadPng();
1122
- }
1123
- // ── the three states: each one invalidates the figure ───────────────
1124
- showLoading() {
1125
- this._invalidate();
1126
- super.showLoading();
1127
- }
1128
- showEmpty(message) {
1129
- this._invalidate();
1130
- super.showEmpty(message);
1131
- }
1132
- showError(message) {
1133
- this._invalidate();
1134
- super.showError(message);
1135
- }
1136
- /** Release the figure BEFORE the element goes — `TileBase.dispose` nulls it. */
1137
- dispose() {
1138
- this._invalidate();
1139
- super.dispose();
1140
- }
1141
- /** Any draw in flight is now stale, and the figure on screen is released. */
1142
- _invalidate() {
1143
- this._drawSeq += 1;
1144
- this._releasePlot();
1145
- }
1146
- /** Hand the current figure back to Plotly and forget it. Idempotent, and
1147
- * not awaited: a rejected purge (Plotly absent, already purged) must not
1148
- * surface as an unhandled rejection. */
1149
- _releasePlot() {
1150
- const el2 = this._plotEl;
1151
- this._plotEl = null;
1152
- this._drawn = false;
1153
- if (el2) purge(el2).catch(() => {
1154
- });
1155
- }
1156
- // ── the draw ────────────────────────────────────────────────────────
1157
- async _draw(data) {
1158
- this._drawSeq += 1;
1159
- const seq = this._drawSeq;
1160
- const content = this.contentElement;
1161
- if (!content) return;
1162
- this._releasePlot();
1322
+ /** `null` (the empty state) when there is nothing to draw. */
1323
+ figure(data) {
1163
1324
  const norm = normalizeChartData(data);
1164
- if (!norm) {
1165
- this.showEmpty("No data to show.");
1166
- return;
1167
- }
1168
- let plan;
1169
- try {
1170
- plan = this._plan(norm);
1171
- } catch (err) {
1172
- this._failed(err);
1173
- return;
1174
- }
1175
- if (!plan) return;
1176
- const host = document.createElement("div");
1177
- host.className = "fd-chart-tile";
1178
- const parts = [host];
1179
- if (norm.note) {
1180
- const note = document.createElement("div");
1181
- note.className = "fd-chart-tile__note";
1182
- note.setAttribute("role", "note");
1183
- note.textContent = norm.note;
1184
- note.title = norm.note;
1185
- parts.push(note);
1186
- }
1187
- content.replaceChildren(...parts);
1188
- this._plotEl = host;
1189
- this._drawn = false;
1190
- try {
1191
- await createChart(host, plan.traces, plan.layout, {
1192
- scrollZoom: false,
1193
- displayModeBar: false,
1194
- responsive: true
1195
- });
1196
- } catch (err) {
1197
- if (this._disposed || seq !== this._drawSeq) return;
1198
- this._failed(err);
1199
- return;
1200
- }
1201
- if (this._disposed || seq !== this._drawSeq) {
1202
- purge(host).catch(() => {
1203
- });
1204
- return;
1205
- }
1206
- this._drawn = true;
1207
- this._wireClick(host);
1208
- }
1209
- /** Plotly's click, reduced to the point's own `customdata`. A pie's point
1210
- * carries its slice index as `pointNumber` (older builds: `pointIndex`),
1211
- * and its `customdata` is read off the trace by that index. */
1212
- _wireClick(host) {
1213
- if (typeof host.on !== "function") return;
1214
- host.on("plotly_click", (ev) => {
1215
- const point = ev?.points?.[0];
1216
- if (!point || typeof this.onPointClick !== "function") return;
1217
- const trace = point.data || {};
1218
- let customdata = point.customdata;
1219
- if (trace.type === "pie" || customdata === void 0) {
1220
- const i = point.pointNumber ?? point.pointIndex;
1221
- if (Array.isArray(trace.customdata) && i != null) customdata = trace.customdata[i];
1222
- }
1223
- try {
1224
- this.onPointClick({ customdata, point, tile: this });
1225
- } catch (err) {
1226
- console.error("[ChartTile] onPointClick threw:", err);
1227
- }
1228
- });
1325
+ if (!norm) return null;
1326
+ return { ...this._plan(norm), note: norm.note };
1229
1327
  }
1230
- /**
1231
- * Turn normalised data into `{traces, layout}`, or put the tile in the
1232
- * empty state and answer `null`.
1233
- */
1328
+ /** Download PNG alone — see the header. */
1329
+ menuItems() {
1330
+ return this._figureMenuItems();
1331
+ }
1332
+ /** The figure, and the note on its own line under it. */
1333
+ _figureContent(plotEl, fig) {
1334
+ plotEl.classList.add("fd-chart-tile");
1335
+ if (!fig.note) return [plotEl];
1336
+ const note = document.createElement("div");
1337
+ note.className = "fd-chart-tile__note";
1338
+ note.setAttribute("role", "note");
1339
+ note.textContent = fig.note;
1340
+ note.title = fig.note;
1341
+ return [plotEl, note];
1342
+ }
1343
+ /** Turn normalised data into `{traces, layout}`. */
1234
1344
  _plan(norm) {
1235
1345
  const type = this.config.chart_type || "line";
1236
1346
  const { series } = norm;
@@ -1364,35 +1474,6 @@ var ChartTile = class extends TileBase {
1364
1474
  }
1365
1475
  return out;
1366
1476
  }
1367
- async _downloadPng() {
1368
- if (!this._plotEl || !this._drawn) return;
1369
- try {
1370
- const P = await ensurePlotly();
1371
- const uri = await P.toImage(this._plotEl, { format: "png", scale: 2 });
1372
- const filename = `${slug(this.config.title || this.constructor.TITLE)}.png`;
1373
- const dialogs = this.host?.dialogs;
1374
- const result = dialogs ? await dialogs.saveFile({ data: uri.split(",")[1], filename, kind: "png" }) : null;
1375
- if (result) {
1376
- if (result.ok) this._notify("Download", `Chart saved to ${result.path}`, "success");
1377
- else if (!result.cancelled) this._notify("Download failed", result.error || "The image could not be saved", "error");
1378
- return;
1379
- }
1380
- const a = document.createElement("a");
1381
- a.href = uri;
1382
- a.download = filename;
1383
- a.click();
1384
- } catch (err) {
1385
- this._notify("Download failed", `The image could not be made: ${err?.message || err}`, "error");
1386
- }
1387
- }
1388
- _failed(err) {
1389
- if (this._disposed) return;
1390
- this.showError(`This chart could not be drawn: ${err?.message || err}`);
1391
- this.eventBus?.emit?.("tile:render-failed", {
1392
- tileId: this.id,
1393
- message: err?.message || String(err)
1394
- });
1395
- }
1396
1477
  };
1397
1478
  var KpiTile = class extends TileBase {
1398
1479
  static TYPE = "flexdesk.kpi";
@@ -1419,11 +1500,11 @@ var KpiTile = class extends TileBase {
1419
1500
  getConfigSchema() {
1420
1501
  return {
1421
1502
  fields: [
1422
- { name: "title", type: "text", label: "Tile title" },
1423
- { name: "label", type: "text", label: "Label" },
1424
- { name: "unit", type: "text", label: "Unit", help: 'Shown after the number, e.g. "pts".' },
1503
+ { key: "title", type: "text", label: "Tile title" },
1504
+ { key: "label", type: "text", label: "Label" },
1505
+ { key: "unit", type: "text", label: "Unit", description: 'Shown after the number, e.g. "pts".' },
1425
1506
  {
1426
- name: "format",
1507
+ key: "format",
1427
1508
  type: "select",
1428
1509
  label: "Format",
1429
1510
  options: [
@@ -1434,23 +1515,23 @@ var KpiTile = class extends TileBase {
1434
1515
  { value: "text", label: "Text" }
1435
1516
  ]
1436
1517
  },
1437
- { name: "decimals", type: "number", label: "Decimals", min: 0, max: 6 },
1518
+ { key: "decimals", type: "number", label: "Decimals", min: 0, max: 6 },
1438
1519
  {
1439
- name: "currency",
1520
+ key: "currency",
1440
1521
  type: "text",
1441
1522
  label: "Currency code",
1442
- help: "ISO 4217, e.g. EUR. Left empty, the number is shown without a symbol."
1523
+ description: "ISO 4217, e.g. EUR. Left empty, the number is shown without a symbol."
1443
1524
  },
1444
- { name: "prefix", type: "text", label: "Prefix" },
1445
- { name: "suffix", type: "text", label: "Suffix" },
1446
- { name: "delta_relative", type: "checkbox", label: "Show the change as a percentage" },
1525
+ { key: "prefix", type: "text", label: "Prefix" },
1526
+ { key: "suffix", type: "text", label: "Suffix" },
1527
+ { key: "delta_relative", type: "checkbox", label: "Show the change as a percentage" },
1447
1528
  {
1448
- name: "invert_delta",
1529
+ key: "invert_delta",
1449
1530
  type: "checkbox",
1450
1531
  label: "A fall is good",
1451
- help: "For a cost, a queue length or a latency: a smaller number is the better one."
1532
+ description: "For a cost, a queue length or a latency: a smaller number is the better one."
1452
1533
  },
1453
- { name: "foot", type: "text", label: "Caption" }
1534
+ { key: "foot", type: "text", label: "Caption" }
1454
1535
  ]
1455
1536
  };
1456
1537
  }
@@ -1501,11 +1582,11 @@ var KpiTile = class extends TileBase {
1501
1582
  if (delta) box.append(delta);
1502
1583
  const foot = [this.config.foot, d.note].filter(Boolean).join(" \xB7 ");
1503
1584
  if (foot) {
1504
- const el2 = document.createElement("div");
1505
- el2.className = "fd-kpi__foot";
1506
- el2.textContent = foot;
1507
- el2.title = foot;
1508
- box.append(el2);
1585
+ const el3 = document.createElement("div");
1586
+ el3.className = "fd-kpi__foot";
1587
+ el3.textContent = foot;
1588
+ el3.title = foot;
1589
+ box.append(el3);
1509
1590
  }
1510
1591
  content.append(box);
1511
1592
  }
@@ -1517,19 +1598,19 @@ var KpiTile = class extends TileBase {
1517
1598
  const reference = Number(rawReference);
1518
1599
  if (!Number.isFinite(value) || !Number.isFinite(reference)) return null;
1519
1600
  const change = value - reference;
1520
- const el2 = document.createElement("div");
1521
- el2.className = "fd-kpi__delta";
1522
- if (change > 0) el2.classList.add(this.config.invert_delta ? "fd-kpi__delta--down" : "fd-kpi__delta--up");
1523
- else if (change < 0) el2.classList.add(this.config.invert_delta ? "fd-kpi__delta--up" : "fd-kpi__delta--down");
1601
+ const el3 = document.createElement("div");
1602
+ el3.className = "fd-kpi__delta";
1603
+ if (change > 0) el3.classList.add(this.config.invert_delta ? "fd-kpi__delta--down" : "fd-kpi__delta--up");
1604
+ else if (change < 0) el3.classList.add(this.config.invert_delta ? "fd-kpi__delta--up" : "fd-kpi__delta--down");
1524
1605
  const arrow = change > 0 ? "\u25B2" : change < 0 ? "\u25BC" : "\u2013";
1525
1606
  const magnitude = this.config.delta_relative ? reference === 0 ? null : formatValue(Math.abs(change / reference), {
1526
1607
  format: "percent",
1527
1608
  decimals: this.config.decimals ?? 1
1528
1609
  }) : this._format(Math.abs(change), false);
1529
1610
  const sign = change > 0 ? "+" : change < 0 ? "-" : "";
1530
- el2.textContent = magnitude === null ? `${arrow} \u2014` : `${arrow} ${sign}${magnitude}`;
1531
- el2.title = `Compared with ${this._format(reference, false)}`;
1532
- return el2;
1611
+ el3.textContent = magnitude === null ? `${arrow} \u2014` : `${arrow} ${sign}${magnitude}`;
1612
+ el3.title = `Compared with ${this._format(reference, false)}`;
1613
+ return el3;
1533
1614
  }
1534
1615
  _format(value, withAffixes) {
1535
1616
  const format = this.config.format || "number";
@@ -1630,9 +1711,6 @@ function formatValue(value, {
1630
1711
  })();
1631
1712
  return `${prefix || ""}${body}${suffix || ""}`;
1632
1713
  }
1633
- function slug(text) {
1634
- return String(text).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "chart";
1635
- }
1636
1714
 
1637
1715
  // src/tiles/layout_persistence.js
1638
1716
  var LAYOUT_VERSION = 3;
@@ -2205,19 +2283,19 @@ var RowsTile = class extends TileBase {
2205
2283
  getConfigSchema() {
2206
2284
  return {
2207
2285
  fields: [
2208
- { name: "title", type: "text", label: "Tile title" },
2286
+ { key: "title", type: "text", label: "Tile title" },
2209
2287
  {
2210
- name: "empty_text",
2288
+ key: "empty_text",
2211
2289
  type: "text",
2212
2290
  label: "When there is nothing",
2213
- help: 'What the tile says when the list is empty, e.g. "Nothing is late."'
2291
+ description: 'What the tile says when the list is empty, e.g. "Nothing is late."'
2214
2292
  },
2215
2293
  {
2216
- name: "max_rows",
2294
+ key: "max_rows",
2217
2295
  type: "number",
2218
2296
  label: "Rows at most",
2219
2297
  min: 0,
2220
- help: "0 shows them all; the tile scrolls."
2298
+ description: "0 shows them all; the tile scrolls."
2221
2299
  }
2222
2300
  ]
2223
2301
  };
@@ -2437,10 +2515,10 @@ var PeopleTile = class extends RowsTile {
2437
2515
  getConfigSchema() {
2438
2516
  const { fields } = super.getConfigSchema();
2439
2517
  return { fields: [...fields, {
2440
- name: "count_label",
2518
+ key: "count_label",
2441
2519
  type: "text",
2442
2520
  label: "Count reads",
2443
- help: `After each person's number, e.g. "open" for "3 open".`
2521
+ description: `After each person's number, e.g. "open" for "3 open".`
2444
2522
  }] };
2445
2523
  }
2446
2524
  _renderItem(p) {
@@ -2546,6 +2624,346 @@ function createScopePicker({ options = [], value, label = "Scope", onChange } =
2546
2624
  };
2547
2625
  }
2548
2626
 
2627
+ // src/tiles/tile_config_form.js
2628
+ var _seq = 0;
2629
+ var FOCUSABLE = 'input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable="true"]';
2630
+ function el2(tag, className, text) {
2631
+ const n = document.createElement(tag);
2632
+ if (className) n.className = className;
2633
+ if (text != null) n.textContent = String(text);
2634
+ return n;
2635
+ }
2636
+ function isVisible(field, values) {
2637
+ const rule = field.visibleWhen;
2638
+ if (!rule) return true;
2639
+ const v = values[rule.field];
2640
+ if (Array.isArray(rule.in)) return rule.in.includes(v);
2641
+ if ("equals" in rule) return v === rule.equals;
2642
+ return true;
2643
+ }
2644
+ var BUILT_IN = {
2645
+ [FieldTypes.TEXT]: (field, id) => {
2646
+ const input = el2("input", "fd-tile-config__input");
2647
+ input.type = "text";
2648
+ input.id = id;
2649
+ return {
2650
+ element: input,
2651
+ events: ["input"],
2652
+ read: () => input.value,
2653
+ write: (v) => {
2654
+ input.value = v == null ? "" : String(v);
2655
+ }
2656
+ };
2657
+ },
2658
+ [FieldTypes.NUMBER]: (field, id) => numeric(field, id, "number"),
2659
+ [FieldTypes.RANGE]: (field, id) => numeric(field, id, "range"),
2660
+ [FieldTypes.CHECKBOX]: (field, id) => {
2661
+ const input = el2("input", "fd-tile-config__checkbox");
2662
+ input.type = "checkbox";
2663
+ input.id = id;
2664
+ return {
2665
+ element: input,
2666
+ events: ["change"],
2667
+ read: () => input.checked,
2668
+ write: (v) => {
2669
+ input.checked = !!v;
2670
+ }
2671
+ };
2672
+ },
2673
+ [FieldTypes.COLOR]: (field, id) => {
2674
+ const input = el2("input", "fd-tile-config__color");
2675
+ input.type = "color";
2676
+ input.id = id;
2677
+ return {
2678
+ element: input,
2679
+ events: ["input"],
2680
+ read: () => input.value,
2681
+ write: (v) => {
2682
+ if (v) input.value = String(v);
2683
+ }
2684
+ };
2685
+ },
2686
+ [FieldTypes.SELECT]: (field, id, data) => choice(field, id, data, false),
2687
+ [FieldTypes.MULTISELECT]: (field, id, data) => choice(field, id, data, true)
2688
+ };
2689
+ function numeric(field, id, kind) {
2690
+ const input = el2("input", `fd-tile-config__${kind === "range" ? "range" : "input"}`);
2691
+ input.type = kind;
2692
+ input.id = id;
2693
+ if (field.min != null) input.min = String(field.min);
2694
+ if (field.max != null) input.max = String(field.max);
2695
+ if (field.step != null) input.step = String(field.step);
2696
+ const out = kind === "range" ? el2("output", "fd-tile-config__range-value") : null;
2697
+ const element = out ? el2("span", "fd-tile-config__range-wrap") : input;
2698
+ if (out) element.append(input, out);
2699
+ return {
2700
+ element,
2701
+ events: ["input"],
2702
+ // The raw text goes to `validateField`; the form turns it into a number.
2703
+ read: () => input.value,
2704
+ write: (v) => {
2705
+ input.value = v == null ? "" : String(v);
2706
+ if (out) out.textContent = input.value;
2707
+ },
2708
+ shown: (v) => {
2709
+ if (out) out.textContent = v == null ? "" : String(v);
2710
+ }
2711
+ };
2712
+ }
2713
+ function choice(field, id, data, multiple) {
2714
+ const select = el2("select", "fd-tile-config__select");
2715
+ select.id = id;
2716
+ select.multiple = multiple;
2717
+ const options = getFieldOptions(field, data);
2718
+ options.forEach((o) => select.append(el2("option", null, o.label ?? o.value)));
2719
+ return {
2720
+ element: select,
2721
+ events: ["change"],
2722
+ read: () => multiple ? options.filter((_, i) => select.options[i].selected).map((o) => o.value) : options[select.selectedIndex]?.value ?? null,
2723
+ write: (v) => {
2724
+ if (multiple) {
2725
+ const want = Array.isArray(v) ? v : [];
2726
+ options.forEach((o, i) => {
2727
+ select.options[i].selected = want.includes(o.value);
2728
+ });
2729
+ } else {
2730
+ select.selectedIndex = options.findIndex((o) => o.value === v);
2731
+ }
2732
+ }
2733
+ };
2734
+ }
2735
+ function coerce(field, raw) {
2736
+ if (field.type === FieldTypes.NUMBER || field.type === FieldTypes.RANGE) {
2737
+ return raw === "" || raw == null ? null : Number(raw);
2738
+ }
2739
+ return raw;
2740
+ }
2741
+ function mountTileConfigForm(host, {
2742
+ schema,
2743
+ config = {},
2744
+ data = null,
2745
+ onChange = null,
2746
+ renderers = {},
2747
+ commitOn = "input"
2748
+ } = {}) {
2749
+ if (!host) throw new TypeError("mountTileConfigForm: a host element is required");
2750
+ if (commitOn !== "input" && commitOn !== "change") {
2751
+ throw new TypeError(`mountTileConfigForm: unknown commitOn "${commitOn}"`);
2752
+ }
2753
+ const fields = Array.isArray(schema?.fields) ? schema.fields : [];
2754
+ fields.forEach((f, i) => {
2755
+ if (typeof f?.key !== "string" || !f.key) {
2756
+ const called = typeof f?.label === "string" && f.label ? ` "${f.label}"` : "";
2757
+ throw new TypeError(`mountTileConfigForm: field #${i + 1}${called} has no key`);
2758
+ }
2759
+ });
2760
+ let values = mergeWithDefaults(config || {}, schema);
2761
+ const unlisten = [];
2762
+ const root = el2("div", "fd-tile-config");
2763
+ const rows = [];
2764
+ const showError = (r, message) => {
2765
+ r.invalid = !!message;
2766
+ r.error.textContent = message || "";
2767
+ r.error.hidden = !message;
2768
+ r.row.classList.toggle("fd-tile-config__row--invalid", !!message);
2769
+ };
2770
+ const applyVisibility = () => {
2771
+ for (const r of rows) r.row.hidden = !isVisible(r.field, values);
2772
+ };
2773
+ const commit = (r, raw) => {
2774
+ if (r.field.readonly) return { valid: false, error: `${r.named.label} is read-only` };
2775
+ const result = validateField(raw, r.named);
2776
+ if (!result.valid) {
2777
+ showError(r, result.error);
2778
+ return { valid: false, error: result.error };
2779
+ }
2780
+ showError(r, null);
2781
+ const value = coerce(r.field, raw);
2782
+ values = { ...values, [r.field.key]: value };
2783
+ applyVisibility();
2784
+ onChange?.(r.field.key, value, { ...values });
2785
+ return { valid: true };
2786
+ };
2787
+ for (const field of fields) {
2788
+ const id = `fd-tile-config-${++_seq}`;
2789
+ const row = el2("div", `fd-tile-config__row fd-tile-config__row--${field.type}`);
2790
+ row.setAttribute("data-field", field.key);
2791
+ const named = { ...field, label: field.label ?? field.key };
2792
+ const label = el2("label", "fd-tile-config__label", named.label);
2793
+ const error = el2("div", "fd-tile-config__error");
2794
+ error.hidden = true;
2795
+ const r = { field, named, row, error, control: null, invalid: false, pending: null };
2796
+ const custom = renderers?.[field.type];
2797
+ if (typeof custom === "function") {
2798
+ const made = custom({
2799
+ field,
2800
+ key: field.key,
2801
+ value: values[field.key],
2802
+ data,
2803
+ readonly: !!field.readonly,
2804
+ commit: (v) => commit(r, v)
2805
+ });
2806
+ const element = made instanceof HTMLElement ? made : made?.element;
2807
+ if (!(element instanceof HTMLElement)) {
2808
+ throw new TypeError(`mountTileConfigForm: the renderer for "${field.type}" returned no element`);
2809
+ }
2810
+ element.id ||= id;
2811
+ label.htmlFor = element.id;
2812
+ r.control = {
2813
+ element,
2814
+ write: made instanceof HTMLElement ? () => {
2815
+ } : (v) => made.setValue?.(v),
2816
+ dispose: made instanceof HTMLElement ? null : () => made.dispose?.()
2817
+ };
2818
+ } else {
2819
+ const build = BUILT_IN[field.type];
2820
+ if (!build) {
2821
+ throw new TypeError(`mountTileConfigForm: no control for type "${field.type}" (field "${field.key}"); pass a renderer`);
2822
+ }
2823
+ const c = build(field, id, data);
2824
+ label.htmlFor = id;
2825
+ c.write(values[field.key]);
2826
+ const input = c.element.matches("input, select") ? c.element : c.element.querySelector("input, select");
2827
+ if (field.readonly) {
2828
+ input.disabled = true;
2829
+ } else {
2830
+ const listen = (type, fn) => {
2831
+ input.addEventListener(type, fn);
2832
+ unlisten.push(() => input.removeEventListener(type, fn));
2833
+ };
2834
+ const onEdit = () => {
2835
+ r.pending = null;
2836
+ const raw = c.read();
2837
+ c.shown?.(raw);
2838
+ commit(r, raw);
2839
+ };
2840
+ if (commitOn === "change") {
2841
+ listen("change", onEdit);
2842
+ if (c.events.includes("input")) {
2843
+ listen("input", () => {
2844
+ r.pending = onEdit;
2845
+ c.shown?.(c.read());
2846
+ });
2847
+ }
2848
+ } else {
2849
+ for (const type of c.events) listen(type, onEdit);
2850
+ }
2851
+ }
2852
+ r.control = c;
2853
+ }
2854
+ if (field.type === FieldTypes.CHECKBOX) row.append(r.control.element, label);
2855
+ else row.append(label, r.control.element);
2856
+ if (field.description) row.append(el2("div", "fd-tile-config__help", field.description));
2857
+ row.append(error);
2858
+ rows.push(r);
2859
+ root.append(row);
2860
+ }
2861
+ applyVisibility();
2862
+ host.append(root);
2863
+ return {
2864
+ getValues: () => ({ ...values }),
2865
+ setValues(next) {
2866
+ values = mergeWithDefaults(next || {}, schema);
2867
+ for (const r of rows) {
2868
+ r.pending = null;
2869
+ r.control.write(values[r.field.key]);
2870
+ showError(r, null);
2871
+ }
2872
+ applyVisibility();
2873
+ },
2874
+ isValid: () => rows.every((r) => r.row.hidden || !r.invalid),
2875
+ focusFirstInvalid() {
2876
+ const r = rows.find((x) => !x.row.hidden && x.invalid);
2877
+ if (!r) return false;
2878
+ const { element } = r.control;
2879
+ const target = element.matches(FOCUSABLE) ? element : element.querySelector(FOCUSABLE);
2880
+ (target || element).focus();
2881
+ return true;
2882
+ },
2883
+ flush() {
2884
+ for (const r of rows) r.pending?.();
2885
+ },
2886
+ dispose() {
2887
+ unlisten.splice(0).forEach((off) => off());
2888
+ for (const r of rows) {
2889
+ try {
2890
+ r.control.dispose?.();
2891
+ } catch (err) {
2892
+ console.warn("[tile-config] a renderer dispose threw", err);
2893
+ }
2894
+ }
2895
+ rows.length = 0;
2896
+ root.remove();
2897
+ }
2898
+ };
2899
+ }
2900
+ function openTileConfig(eventBus, { mode = "modal", host = null, width, renderers = {}, title = "Tile settings" } = {}) {
2901
+ if (typeof eventBus?.on !== "function") throw new TypeError("openTileConfig: an event bus with on() is required");
2902
+ if (mode !== "modal" && mode !== "slide-out") throw new TypeError(`openTileConfig: unknown mode "${mode}"`);
2903
+ if (mode === "slide-out" && !(host instanceof HTMLElement)) {
2904
+ throw new TypeError("openTileConfig: a slide-out needs a host element");
2905
+ }
2906
+ let form = null;
2907
+ let panel = null;
2908
+ let modalOpen = false;
2909
+ const drop = () => {
2910
+ form?.flush();
2911
+ form?.dispose();
2912
+ form = null;
2913
+ };
2914
+ const show = (detail = {}) => {
2915
+ if (modalOpen) return;
2916
+ const heading = typeof title === "function" ? title(detail) : title;
2917
+ const base = { schema: detail.schema, config: detail.config, data: detail.data ?? null, renderers };
2918
+ if (mode === "slide-out") {
2919
+ drop();
2920
+ if (!panel) {
2921
+ panel = new SlideOutPanel({ ...width ? { width } : {}, onClose: drop });
2922
+ panel.mount(host);
2923
+ }
2924
+ const body = panel.open(heading, "tune");
2925
+ form = mountTileConfigForm(body, {
2926
+ ...base,
2927
+ commitOn: "change",
2928
+ onChange: (_k, _v, cfg) => detail.onSave?.(cfg)
2929
+ });
2930
+ return;
2931
+ }
2932
+ const content = el2("div", "fd-tile-config-host");
2933
+ const mine = mountTileConfigForm(content, base);
2934
+ modalOpen = true;
2935
+ openModal({
2936
+ title: heading,
2937
+ icon: "tune",
2938
+ content,
2939
+ ...width ? { width } : {},
2940
+ actions: [
2941
+ { label: "Cancel", value: null },
2942
+ // Refused while a field shows an error: its last valid value is
2943
+ // not what the user is looking at, so saving it would be a lie.
2944
+ { label: "Save", value: "save", primary: true, keepOpen: true, onClick: (close) => {
2945
+ if (mine.isValid()) {
2946
+ close("save");
2947
+ return;
2948
+ }
2949
+ mine.focusFirstInvalid();
2950
+ } }
2951
+ ]
2952
+ }).then((v) => {
2953
+ modalOpen = false;
2954
+ if (v === "save") detail.onSave?.(mine.getValues());
2955
+ mine.dispose();
2956
+ });
2957
+ };
2958
+ const sub = eventBus.on("grid:config-modal-show", show);
2959
+ return () => {
2960
+ sub.dispose();
2961
+ drop();
2962
+ panel?.dispose();
2963
+ panel = null;
2964
+ };
2965
+ }
2966
+
2549
2967
  // src/tiles/tile_grid.js
2550
2968
  init_tile_registry();
2551
2969
  var TileGrid = class {
@@ -3218,6 +3636,8 @@ var TileGrid = class {
3218
3636
  tileType,
3219
3637
  config,
3220
3638
  schema,
3639
+ // What `getFieldOptions` reads for a field with dynamic options.
3640
+ data: this.data,
3221
3641
  variables: this.data ? this._getVariableList() : [],
3222
3642
  onSave: (newConfig) => {
3223
3643
  const tile = this.tiles.get(tileId);
@@ -3344,6 +3764,7 @@ export {
3344
3764
  CHART_TILE_TYPES,
3345
3765
  ChartTile,
3346
3766
  FieldTypes,
3767
+ FigureTile,
3347
3768
  KpiTile,
3348
3769
  LayoutPersistence,
3349
3770
  ListTile,
@@ -3366,9 +3787,11 @@ export {
3366
3787
  groupByPerson,
3367
3788
  hasWidget,
3368
3789
  mergeWithDefaults,
3790
+ mountTileConfigForm,
3369
3791
  normalizeChartData,
3370
3792
  normalizeListData,
3371
3793
  normalizePeopleData,
3794
+ openTileConfig,
3372
3795
  registerChartTiles,
3373
3796
  registerListTiles,
3374
3797
  registerWidget,