flexdesk 0.16.0 → 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 (251) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/LICENSE +0 -0
  3. package/README.md +2261 -42
  4. package/THIRD_PARTY_LICENSES.md +0 -0
  5. package/css/base.css +1834 -75
  6. package/css/flexdesk.css +3373 -238
  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-UWO43BZU.js +111 -0
  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 +3373 -238
  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 +1131 -253
  61. package/dist/tiles.js.map +4 -4
  62. package/dist/tokens.css +0 -0
  63. package/dist/widgets.js +3254 -2112
  64. package/dist/widgets.js.map +4 -4
  65. package/dist/wm.js +1227 -345
  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 +422 -0
  144. package/src/tiles/scope_picker.js +96 -0
  145. package/src/tiles/tile_base.js +12 -1
  146. package/src/tiles/tile_config_form.js +442 -0
  147. package/src/tiles/tile_grid.js +38 -1
  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 +26 -4
  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-CWPMLX67.js +0 -475
  241. package/dist/chunk-CWPMLX67.js.map +0 -7
  242. package/dist/chunk-FBGP5KN5.js.map +0 -7
  243. package/dist/chunk-FL5KFNQH.js.map +0 -7
  244. package/dist/chunk-NLTOY57M.js.map +0 -7
  245. package/dist/chunk-NNFTETK3.js +0 -633
  246. package/dist/chunk-NNFTETK3.js.map +0 -7
  247. package/dist/chunk-O5OHMWBB.js +0 -284
  248. package/dist/chunk-O5OHMWBB.js.map +0 -7
  249. package/dist/chunk-RQM5Z2MU.js.map +0 -7
  250. package/dist/chunk-SN4LSOAR.js.map +0 -7
  251. package/src/ui/controllers/panel_state_machine.js +0 -634
package/dist/tiles.js CHANGED
@@ -12,8 +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";
18
+ import {
19
+ showContextMenu
20
+ } from "./chunk-UWO43BZU.js";
21
+ import {
22
+ openModal
23
+ } from "./chunk-D3COLZZ2.js";
17
24
  import {
18
25
  buildHistogramTrace,
19
26
  buildLegendLayout,
@@ -28,10 +35,13 @@ import {
28
35
  purge,
29
36
  resize,
30
37
  strideDownsampleShared
31
- } from "./chunk-RQM5Z2MU.js";
32
- import "./chunk-FBGP5KN5.js";
33
- import "./chunk-6JSOVNID.js";
34
- 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";
35
45
  import {
36
46
  __toCommonJS
37
47
  } from "./chunk-JYWURG5T.js";
@@ -71,6 +81,11 @@ function validateField(value, field) {
71
81
  return { valid: false, error: `${field.label} must be selected` };
72
82
  }
73
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;
74
89
  case FieldTypes.MULTISELECT:
75
90
  if (field.required && (!Array.isArray(value) || value.length === 0)) {
76
91
  return { valid: false, error: `${field.label} must have at least one selection` };
@@ -234,6 +249,16 @@ var TileBase = class {
234
249
  this._lastLoadedAt = null;
235
250
  this._loadSeq = 0;
236
251
  this.comparisonData = null;
252
+ this.scope = null;
253
+ }
254
+ /**
255
+ * Called when the board's scope changes (`TileGrid.setScope`), after
256
+ * `this.scope` is set. A no-op here: a tile whose rows the grid's data
257
+ * source loads is reloaded by the grid, and one fed through `setData` is
258
+ * fed again by whoever owns its data.
259
+ * @param {*} _scope
260
+ */
261
+ onScopeChange(_scope) {
237
262
  }
238
263
  /**
239
264
  * Set comparison data for overlay rendering.
@@ -696,7 +721,7 @@ var TileBase = class {
696
721
  * @private
697
722
  */
698
723
  _onConfigClick() {
699
- this.element?.closest(".tile-grid")?.querySelectorAll(".tile.tile--selected").forEach((el) => el.classList.remove("tile--selected"));
724
+ this.element?.closest(".tile-grid")?.querySelectorAll(".tile.tile--selected").forEach((el3) => el3.classList.remove("tile--selected"));
700
725
  this.element?.classList.add("tile--selected");
701
726
  this.eventBus?.emit?.("tile:config-request", {
702
727
  tileId: this.id,
@@ -908,64 +933,19 @@ var TileBase = class {
908
933
  }
909
934
  };
910
935
 
911
- // src/tiles/chart_tiles.js
912
- init_tile_registry();
913
- var CATEGORICAL_ONLY = /* @__PURE__ */ new Set(["pie", "donut", "funnel"]);
914
- var CHART_TILE_TYPES = Object.freeze([
915
- { value: "line", label: "Line" },
916
- { value: "bar", label: "Bar" },
917
- { value: "bar-stacked", label: "Bar (stacked)" },
918
- { value: "bar-horizontal", label: "Bar (horizontal)" },
919
- { value: "area", label: "Area" },
920
- { value: "area-stacked", label: "Area (stacked %)" },
921
- { value: "scatter", label: "Scatter" },
922
- { value: "pie", label: "Pie" },
923
- { value: "donut", label: "Donut" },
924
- { value: "funnel", label: "Funnel" },
925
- { value: "histogram", label: "Histogram" }
926
- ]);
927
- var AXIS_CHARTS = ["bar", "bar-stacked", "bar-horizontal", "line", "area", "area-stacked", "scatter"];
928
- var READS = Object.freeze({
929
- legend: /* @__PURE__ */ new Set([...AXIS_CHARTS, "pie", "donut", "histogram"]),
930
- axis_titles: /* @__PURE__ */ new Set([...AXIS_CHARTS, "histogram"]),
931
- y_scale: /* @__PURE__ */ new Set(["bar", "bar-horizontal", "line", "area", "scatter"]),
932
- y_range: /* @__PURE__ */ new Set(["bar", "bar-stacked", "bar-horizontal", "line", "area", "scatter"]),
933
- show_markers: /* @__PURE__ */ new Set(["line", "area", "area-stacked"])
936
+ // src/tiles/figure_tile.js
937
+ var FIGURE_CONFIG = Object.freeze({
938
+ scrollZoom: false,
939
+ displayModeBar: false,
940
+ responsive: true
934
941
  });
935
- var TILE_MARGIN = { l: 48, r: 16, t: 8, b: 36 };
936
- var MAX_POINTS = 2e3;
937
- function normalizeChartData(data) {
938
- if (data == null) return null;
939
- let d = data;
940
- if (Array.isArray(d)) d = { series: d };
941
- else if (typeof d !== "object") return null;
942
- else if (!Array.isArray(d.series)) {
943
- const single = ["x", "y", "labels", "values"].some((k) => Array.isArray(d[k]));
944
- d = { ...d, series: single ? [d] : [] };
945
- }
946
- const series = d.series.filter((s) => s && pointCount(s) > 0);
947
- if (!series.length) return null;
948
- return {
949
- series,
950
- markers: Array.isArray(d.markers) ? d.markers : [],
951
- referenceLines: Array.isArray(d.referenceLines) ? d.referenceLines : [],
952
- xKind: d.xKind || null,
953
- note: typeof d.note === "string" ? d.note : ""
954
- };
955
- }
956
- function pointCount(s) {
957
- for (const k of ["y", "values", "x", "labels"]) {
958
- if (Array.isArray(s[k])) return s[k].length;
959
- }
960
- return 0;
961
- }
962
- var ChartTile = class extends TileBase {
963
- static TYPE = "flexdesk.chart";
964
- static TITLE = "Chart";
965
- static ICON = "insert_chart";
966
- static DESCRIPTION = "Line, bar, area, pie, donut, funnel or histogram over series you supply.";
967
- static DEFAULT_SIZE = { w: 6, h: 4 };
968
- static SIZE_CONSTRAINTS = { minW: 3, minH: 3, maxW: 12, maxH: 10 };
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.";
969
949
  /**
970
950
  * @param {Object} options - TileBase's, plus:
971
951
  * @param {Function} [options.onPointClick] - `({customdata, point, tile})`
@@ -977,109 +957,18 @@ var ChartTile = class extends TileBase {
977
957
  this._plotEl = null;
978
958
  this._drawn = false;
979
959
  this._drawSeq = 0;
980
- }
981
- /** Pure and static-only — it runs inside `TileBase`'s constructor. */
982
- getDefaultConfig() {
983
- return {
984
- title: "",
985
- chart_type: "line",
986
- unit: "",
987
- legend: "bottom",
988
- x_title: "",
989
- y_title: "",
990
- y_scale: "linear",
991
- y_min: null,
992
- y_max: null,
993
- show_markers: false,
994
- max_points: MAX_POINTS
995
- };
996
- }
997
- getConfigSchema() {
998
- return {
999
- fields: [
1000
- { name: "title", type: "text", label: "Tile title" },
1001
- {
1002
- name: "chart_type",
1003
- type: "select",
1004
- label: "Chart type",
1005
- options: CHART_TILE_TYPES.map((o) => ({ ...o }))
1006
- },
1007
- {
1008
- name: "unit",
1009
- type: "text",
1010
- label: "Unit",
1011
- help: 'Shown with the value axis title, e.g. "pts" or "h".'
1012
- },
1013
- {
1014
- name: "legend",
1015
- type: "select",
1016
- label: "Legend",
1017
- visibleWhen: { field: "chart_type", in: [...READS.legend] },
1018
- options: [
1019
- { value: "bottom", label: "Below" },
1020
- { value: "top", label: "Above" },
1021
- { value: "right", label: "Right" },
1022
- { value: "left", label: "Left" },
1023
- { value: "hidden", label: "None" }
1024
- ]
1025
- },
1026
- {
1027
- name: "x_title",
1028
- type: "text",
1029
- label: "X axis title",
1030
- visibleWhen: { field: "chart_type", in: [...READS.axis_titles] }
1031
- },
1032
- {
1033
- name: "y_title",
1034
- type: "text",
1035
- label: "Y axis title",
1036
- visibleWhen: { field: "chart_type", in: [...READS.axis_titles] }
1037
- },
1038
- {
1039
- name: "y_scale",
1040
- type: "select",
1041
- label: "Y scale",
1042
- visibleWhen: { field: "chart_type", in: [...READS.y_scale] },
1043
- options: [{ value: "linear", label: "Linear" }, { value: "log", label: "Logarithmic" }]
1044
- },
1045
- {
1046
- name: "y_min",
1047
- type: "number",
1048
- label: "Y minimum",
1049
- visibleWhen: { field: "chart_type", in: [...READS.y_range] }
1050
- },
1051
- {
1052
- name: "y_max",
1053
- type: "number",
1054
- label: "Y maximum",
1055
- visibleWhen: { field: "chart_type", in: [...READS.y_range] }
1056
- },
1057
- {
1058
- name: "show_markers",
1059
- type: "checkbox",
1060
- label: "Show point markers",
1061
- visibleWhen: { field: "chart_type", in: [...READS.show_markers] }
1062
- }
1063
- ]
1064
- };
1065
- }
1066
- mount(container) {
1067
- super.mount(container);
1068
- applyTitle(this);
960
+ this._figure = null;
1069
961
  }
1070
962
  /**
1071
- * `null` here is "the grid has no data", not "clear this tile": TileGrid's
1072
- * config save calls `update(grid.data, newConfig)`, and a grid whose tiles
1073
- * are fed one by one through `setData` has `grid.data === null` — passed on,
1074
- * it would drop the tile's series and skip the redraw in the new chart type.
1075
- * `setData(null)` is how a tile is cleared.
963
+ * The one override: what to draw for `data`.
964
+ * @param {*} _data
965
+ * @returns {{traces: Array, layout?: Object, config?: Object, frames?: Array}|null}
1076
966
  */
1077
- update(data, config) {
1078
- super.update(data === null ? void 0 : data, config);
1079
- if (config) applyTitle(this);
967
+ figure(_data) {
968
+ throw new Error(`${this.constructor.name}.figure() must be implemented by the subclass`);
1080
969
  }
1081
970
  /**
1082
- * Hand this tile its series. Unlike `update()`, an empty or null value is
971
+ * Hand this tile its data. Unlike `update()`, an empty or null value is
1083
972
  * drawn — as the empty state — rather than ignored.
1084
973
  */
1085
974
  setData(data) {
@@ -1101,10 +990,28 @@ var ChartTile = class extends TileBase {
1101
990
  });
1102
991
  }
1103
992
  menuItems() {
1104
- return [{ action: "download-png", label: "Download PNG", icon: "image" }];
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 }];
1105
999
  }
1106
1000
  _onMenuAction(action) {
1107
- if (action === "download-png") this._downloadPng();
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
+ };
1108
1015
  }
1109
1016
  // ── the three states: each one invalidates the figure ───────────────
1110
1017
  showLoading() {
@@ -1133,63 +1040,70 @@ var ChartTile = class extends TileBase {
1133
1040
  * not awaited: a rejected purge (Plotly absent, already purged) must not
1134
1041
  * surface as an unhandled rejection. */
1135
1042
  _releasePlot() {
1136
- const el = this._plotEl;
1043
+ const el3 = this._plotEl;
1137
1044
  this._plotEl = null;
1138
1045
  this._drawn = false;
1139
- if (el) purge(el).catch(() => {
1046
+ this._figure = null;
1047
+ if (el3) purge(el3).catch(() => {
1140
1048
  });
1141
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
+ }
1142
1061
  // ── the draw ────────────────────────────────────────────────────────
1143
1062
  async _draw(data) {
1144
1063
  this._drawSeq += 1;
1145
1064
  const seq = this._drawSeq;
1146
1065
  const content = this.contentElement;
1147
1066
  if (!content) return;
1067
+ const stale = () => this._disposed || seq !== this._drawSeq;
1148
1068
  this._releasePlot();
1149
- const norm = normalizeChartData(data);
1150
- if (!norm) {
1151
- this.showEmpty("No data to show.");
1152
- return;
1153
- }
1154
- let plan;
1069
+ let fig;
1155
1070
  try {
1156
- plan = this._plan(norm);
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
+ }
1157
1075
  } catch (err) {
1158
1076
  this._failed(err);
1159
1077
  return;
1160
1078
  }
1161
- if (!plan) return;
1162
- const host = document.createElement("div");
1163
- host.className = "fd-chart-tile";
1164
- const parts = [host];
1165
- if (norm.note) {
1166
- const note = document.createElement("div");
1167
- note.className = "fd-chart-tile__note";
1168
- note.setAttribute("role", "note");
1169
- note.textContent = norm.note;
1170
- note.title = norm.note;
1171
- parts.push(note);
1079
+ if (!fig) {
1080
+ if (!stale()) this.showEmpty(this.constructor.EMPTY_MESSAGE);
1081
+ return;
1172
1082
  }
1173
- content.replaceChildren(...parts);
1083
+ const host = document.createElement("div");
1084
+ host.className = "fd-figure-tile";
1085
+ content.replaceChildren(...this._figureContent(host, fig));
1174
1086
  this._plotEl = host;
1175
1087
  this._drawn = false;
1176
1088
  try {
1177
- await createChart(host, plan.traces, plan.layout, {
1178
- scrollZoom: false,
1179
- displayModeBar: false,
1180
- responsive: true
1181
- });
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
+ }
1182
1095
  } catch (err) {
1183
- if (this._disposed || seq !== this._drawSeq) return;
1096
+ if (stale()) return;
1184
1097
  this._failed(err);
1185
1098
  return;
1186
1099
  }
1187
- if (this._disposed || seq !== this._drawSeq) {
1100
+ if (stale()) {
1188
1101
  purge(host).catch(() => {
1189
1102
  });
1190
1103
  return;
1191
1104
  }
1192
1105
  this._drawn = true;
1106
+ this._figure = fig;
1193
1107
  this._wireClick(host);
1194
1108
  }
1195
1109
  /** Plotly's click, reduced to the point's own `customdata`. A pie's point
@@ -1209,14 +1123,224 @@ var ChartTile = class extends TileBase {
1209
1123
  try {
1210
1124
  this.onPointClick({ customdata, point, tile: this });
1211
1125
  } catch (err) {
1212
- console.error("[ChartTile] onPointClick threw:", 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;
1213
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)
1214
1157
  });
1215
1158
  }
1159
+ };
1160
+ function slug(text) {
1161
+ return String(text).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "chart";
1162
+ }
1163
+
1164
+ // src/tiles/chart_tiles.js
1165
+ init_tile_registry();
1166
+ var CATEGORICAL_ONLY = /* @__PURE__ */ new Set(["pie", "donut", "funnel"]);
1167
+ var CHART_TILE_TYPES = Object.freeze([
1168
+ { value: "line", label: "Line" },
1169
+ { value: "bar", label: "Bar" },
1170
+ { value: "bar-stacked", label: "Bar (stacked)" },
1171
+ { value: "bar-horizontal", label: "Bar (horizontal)" },
1172
+ { value: "area", label: "Area" },
1173
+ { value: "area-stacked", label: "Area (stacked %)" },
1174
+ { value: "scatter", label: "Scatter" },
1175
+ { value: "pie", label: "Pie" },
1176
+ { value: "donut", label: "Donut" },
1177
+ { value: "funnel", label: "Funnel" },
1178
+ { value: "histogram", label: "Histogram" }
1179
+ ]);
1180
+ var AXIS_CHARTS = ["bar", "bar-stacked", "bar-horizontal", "line", "area", "area-stacked", "scatter"];
1181
+ var READS = Object.freeze({
1182
+ legend: /* @__PURE__ */ new Set([...AXIS_CHARTS, "pie", "donut", "histogram"]),
1183
+ axis_titles: /* @__PURE__ */ new Set([...AXIS_CHARTS, "histogram"]),
1184
+ y_scale: /* @__PURE__ */ new Set(["bar", "bar-horizontal", "line", "area", "scatter"]),
1185
+ y_range: /* @__PURE__ */ new Set(["bar", "bar-stacked", "bar-horizontal", "line", "area", "scatter"]),
1186
+ show_markers: /* @__PURE__ */ new Set(["line", "area", "area-stacked"])
1187
+ });
1188
+ var TILE_MARGIN = { l: 48, r: 16, t: 8, b: 36 };
1189
+ var MAX_POINTS = 2e3;
1190
+ function normalizeChartData(data) {
1191
+ if (data == null) return null;
1192
+ let d = data;
1193
+ if (Array.isArray(d)) d = { series: d };
1194
+ else if (typeof d !== "object") return null;
1195
+ else if (!Array.isArray(d.series)) {
1196
+ const single = ["x", "y", "labels", "values"].some((k) => Array.isArray(d[k]));
1197
+ d = { ...d, series: single ? [d] : [] };
1198
+ }
1199
+ const series = d.series.filter((s) => s && pointCount(s) > 0);
1200
+ if (!series.length) return null;
1201
+ return {
1202
+ series,
1203
+ markers: Array.isArray(d.markers) ? d.markers : [],
1204
+ referenceLines: Array.isArray(d.referenceLines) ? d.referenceLines : [],
1205
+ xKind: d.xKind || null,
1206
+ note: typeof d.note === "string" ? d.note : ""
1207
+ };
1208
+ }
1209
+ function pointCount(s) {
1210
+ for (const k of ["y", "values", "x", "labels"]) {
1211
+ if (Array.isArray(s[k])) return s[k].length;
1212
+ }
1213
+ return 0;
1214
+ }
1215
+ var ChartTile = class extends FigureTile {
1216
+ static TYPE = "flexdesk.chart";
1217
+ static TITLE = "Chart";
1218
+ static ICON = "insert_chart";
1219
+ static DESCRIPTION = "Line, bar, area, pie, donut, funnel or histogram over series you supply.";
1220
+ static DEFAULT_SIZE = { w: 6, h: 4 };
1221
+ static SIZE_CONSTRAINTS = { minW: 3, minH: 3, maxW: 12, maxH: 10 };
1222
+ /** Pure and static-only — it runs inside `TileBase`'s constructor. */
1223
+ getDefaultConfig() {
1224
+ return {
1225
+ title: "",
1226
+ chart_type: "line",
1227
+ unit: "",
1228
+ legend: "bottom",
1229
+ x_title: "",
1230
+ y_title: "",
1231
+ y_scale: "linear",
1232
+ y_min: null,
1233
+ y_max: null,
1234
+ show_markers: false,
1235
+ max_points: MAX_POINTS
1236
+ };
1237
+ }
1238
+ getConfigSchema() {
1239
+ return {
1240
+ fields: [
1241
+ { key: "title", type: "text", label: "Tile title" },
1242
+ {
1243
+ key: "chart_type",
1244
+ type: "select",
1245
+ label: "Chart type",
1246
+ options: CHART_TILE_TYPES.map((o) => ({ ...o }))
1247
+ },
1248
+ {
1249
+ key: "unit",
1250
+ type: "text",
1251
+ label: "Unit",
1252
+ description: 'Shown with the value axis title, e.g. "pts" or "h".'
1253
+ },
1254
+ {
1255
+ key: "legend",
1256
+ type: "select",
1257
+ label: "Legend",
1258
+ visibleWhen: { field: "chart_type", in: [...READS.legend] },
1259
+ options: [
1260
+ { value: "bottom", label: "Below" },
1261
+ { value: "top", label: "Above" },
1262
+ { value: "right", label: "Right" },
1263
+ { value: "left", label: "Left" },
1264
+ { value: "hidden", label: "None" }
1265
+ ]
1266
+ },
1267
+ {
1268
+ key: "x_title",
1269
+ type: "text",
1270
+ label: "X axis title",
1271
+ visibleWhen: { field: "chart_type", in: [...READS.axis_titles] }
1272
+ },
1273
+ {
1274
+ key: "y_title",
1275
+ type: "text",
1276
+ label: "Y axis title",
1277
+ visibleWhen: { field: "chart_type", in: [...READS.axis_titles] }
1278
+ },
1279
+ {
1280
+ key: "y_scale",
1281
+ type: "select",
1282
+ label: "Y scale",
1283
+ visibleWhen: { field: "chart_type", in: [...READS.y_scale] },
1284
+ options: [{ value: "linear", label: "Linear" }, { value: "log", label: "Logarithmic" }]
1285
+ },
1286
+ {
1287
+ key: "y_min",
1288
+ type: "number",
1289
+ label: "Y minimum",
1290
+ visibleWhen: { field: "chart_type", in: [...READS.y_range] }
1291
+ },
1292
+ {
1293
+ key: "y_max",
1294
+ type: "number",
1295
+ label: "Y maximum",
1296
+ visibleWhen: { field: "chart_type", in: [...READS.y_range] }
1297
+ },
1298
+ {
1299
+ key: "show_markers",
1300
+ type: "checkbox",
1301
+ label: "Show point markers",
1302
+ visibleWhen: { field: "chart_type", in: [...READS.show_markers] }
1303
+ }
1304
+ ]
1305
+ };
1306
+ }
1307
+ mount(container) {
1308
+ super.mount(container);
1309
+ applyTitle(this);
1310
+ }
1216
1311
  /**
1217
- * Turn normalised data into `{traces, layout}`, or put the tile in the
1218
- * empty state and answer `null`.
1312
+ * `null` here is "the grid has no data", not "clear this tile": TileGrid's
1313
+ * config save calls `update(grid.data, newConfig)`, and a grid whose tiles
1314
+ * are fed one by one through `setData` has `grid.data === null` — passed on,
1315
+ * it would drop the tile's series and skip the redraw in the new chart type.
1316
+ * `setData(null)` is how a tile is cleared.
1219
1317
  */
1318
+ update(data, config) {
1319
+ super.update(data === null ? void 0 : data, config);
1320
+ if (config) applyTitle(this);
1321
+ }
1322
+ /** `null` (the empty state) when there is nothing to draw. */
1323
+ figure(data) {
1324
+ const norm = normalizeChartData(data);
1325
+ if (!norm) return null;
1326
+ return { ...this._plan(norm), note: norm.note };
1327
+ }
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}`. */
1220
1344
  _plan(norm) {
1221
1345
  const type = this.config.chart_type || "line";
1222
1346
  const { series } = norm;
@@ -1350,35 +1474,6 @@ var ChartTile = class extends TileBase {
1350
1474
  }
1351
1475
  return out;
1352
1476
  }
1353
- async _downloadPng() {
1354
- if (!this._plotEl || !this._drawn) return;
1355
- try {
1356
- const P = await ensurePlotly();
1357
- const uri = await P.toImage(this._plotEl, { format: "png", scale: 2 });
1358
- const filename = `${slug(this.config.title || this.constructor.TITLE)}.png`;
1359
- const dialogs = this.host?.dialogs;
1360
- const result = dialogs ? await dialogs.saveFile({ data: uri.split(",")[1], filename, kind: "png" }) : null;
1361
- if (result) {
1362
- if (result.ok) this._notify("Download", `Chart saved to ${result.path}`, "success");
1363
- else if (!result.cancelled) this._notify("Download failed", result.error || "The image could not be saved", "error");
1364
- return;
1365
- }
1366
- const a = document.createElement("a");
1367
- a.href = uri;
1368
- a.download = filename;
1369
- a.click();
1370
- } catch (err) {
1371
- this._notify("Download failed", `The image could not be made: ${err?.message || err}`, "error");
1372
- }
1373
- }
1374
- _failed(err) {
1375
- if (this._disposed) return;
1376
- this.showError(`This chart could not be drawn: ${err?.message || err}`);
1377
- this.eventBus?.emit?.("tile:render-failed", {
1378
- tileId: this.id,
1379
- message: err?.message || String(err)
1380
- });
1381
- }
1382
1477
  };
1383
1478
  var KpiTile = class extends TileBase {
1384
1479
  static TYPE = "flexdesk.kpi";
@@ -1405,11 +1500,11 @@ var KpiTile = class extends TileBase {
1405
1500
  getConfigSchema() {
1406
1501
  return {
1407
1502
  fields: [
1408
- { name: "title", type: "text", label: "Tile title" },
1409
- { name: "label", type: "text", label: "Label" },
1410
- { 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".' },
1411
1506
  {
1412
- name: "format",
1507
+ key: "format",
1413
1508
  type: "select",
1414
1509
  label: "Format",
1415
1510
  options: [
@@ -1420,23 +1515,23 @@ var KpiTile = class extends TileBase {
1420
1515
  { value: "text", label: "Text" }
1421
1516
  ]
1422
1517
  },
1423
- { name: "decimals", type: "number", label: "Decimals", min: 0, max: 6 },
1518
+ { key: "decimals", type: "number", label: "Decimals", min: 0, max: 6 },
1424
1519
  {
1425
- name: "currency",
1520
+ key: "currency",
1426
1521
  type: "text",
1427
1522
  label: "Currency code",
1428
- 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."
1429
1524
  },
1430
- { name: "prefix", type: "text", label: "Prefix" },
1431
- { name: "suffix", type: "text", label: "Suffix" },
1432
- { 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" },
1433
1528
  {
1434
- name: "invert_delta",
1529
+ key: "invert_delta",
1435
1530
  type: "checkbox",
1436
1531
  label: "A fall is good",
1437
- 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."
1438
1533
  },
1439
- { name: "foot", type: "text", label: "Caption" }
1534
+ { key: "foot", type: "text", label: "Caption" }
1440
1535
  ]
1441
1536
  };
1442
1537
  }
@@ -1487,11 +1582,11 @@ var KpiTile = class extends TileBase {
1487
1582
  if (delta) box.append(delta);
1488
1583
  const foot = [this.config.foot, d.note].filter(Boolean).join(" \xB7 ");
1489
1584
  if (foot) {
1490
- const el = document.createElement("div");
1491
- el.className = "fd-kpi__foot";
1492
- el.textContent = foot;
1493
- el.title = foot;
1494
- box.append(el);
1585
+ const el3 = document.createElement("div");
1586
+ el3.className = "fd-kpi__foot";
1587
+ el3.textContent = foot;
1588
+ el3.title = foot;
1589
+ box.append(el3);
1495
1590
  }
1496
1591
  content.append(box);
1497
1592
  }
@@ -1503,19 +1598,19 @@ var KpiTile = class extends TileBase {
1503
1598
  const reference = Number(rawReference);
1504
1599
  if (!Number.isFinite(value) || !Number.isFinite(reference)) return null;
1505
1600
  const change = value - reference;
1506
- const el = document.createElement("div");
1507
- el.className = "fd-kpi__delta";
1508
- if (change > 0) el.classList.add(this.config.invert_delta ? "fd-kpi__delta--down" : "fd-kpi__delta--up");
1509
- else if (change < 0) el.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");
1510
1605
  const arrow = change > 0 ? "\u25B2" : change < 0 ? "\u25BC" : "\u2013";
1511
1606
  const magnitude = this.config.delta_relative ? reference === 0 ? null : formatValue(Math.abs(change / reference), {
1512
1607
  format: "percent",
1513
1608
  decimals: this.config.decimals ?? 1
1514
1609
  }) : this._format(Math.abs(change), false);
1515
1610
  const sign = change > 0 ? "+" : change < 0 ? "-" : "";
1516
- el.textContent = magnitude === null ? `${arrow} \u2014` : `${arrow} ${sign}${magnitude}`;
1517
- el.title = `Compared with ${this._format(reference, false)}`;
1518
- return el;
1611
+ el3.textContent = magnitude === null ? `${arrow} \u2014` : `${arrow} ${sign}${magnitude}`;
1612
+ el3.title = `Compared with ${this._format(reference, false)}`;
1613
+ return el3;
1519
1614
  }
1520
1615
  _format(value, withAffixes) {
1521
1616
  const format = this.config.format || "number";
@@ -1616,9 +1711,6 @@ function formatValue(value, {
1616
1711
  })();
1617
1712
  return `${prefix || ""}${body}${suffix || ""}`;
1618
1713
  }
1619
- function slug(text) {
1620
- return String(text).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "chart";
1621
- }
1622
1714
 
1623
1715
  // src/tiles/layout_persistence.js
1624
1716
  var LAYOUT_VERSION = 3;
@@ -2139,6 +2231,739 @@ function getLayoutPersistence() {
2139
2231
  return instance;
2140
2232
  }
2141
2233
 
2234
+ // src/tiles/list_tiles.js
2235
+ init_tile_registry();
2236
+ var TONES = /* @__PURE__ */ new Set(["danger", "warning", "ok", "muted"]);
2237
+ function normalize(data, key) {
2238
+ if (data == null) return null;
2239
+ const d = Array.isArray(data) ? { [key]: data } : data;
2240
+ if (typeof d !== "object" || !Array.isArray(d[key])) return null;
2241
+ const items = d[key].filter((r) => r && typeof r === "object" && r.key != null);
2242
+ const total = Number.isFinite(Number(d.total)) && d.total !== null && d.total !== "" ? Number(d.total) : items.length;
2243
+ return {
2244
+ items,
2245
+ total: Math.max(total, items.length),
2246
+ note: typeof d.note === "string" ? d.note : "",
2247
+ empty: typeof d.empty === "string" ? d.empty : "",
2248
+ action: d.action && typeof d.action === "object" && d.action.label ? d.action : null
2249
+ };
2250
+ }
2251
+ var normalizeListData = (data) => normalize(data, "rows");
2252
+ var normalizePeopleData = (data) => normalize(data, "people");
2253
+ function el(tag, cls, text) {
2254
+ const e = document.createElement(tag);
2255
+ if (cls) e.className = cls;
2256
+ if (text != null) e.textContent = String(text);
2257
+ return e;
2258
+ }
2259
+ function glyph(name, cls = "") {
2260
+ const g = el("span", `material-symbols-outlined${cls ? ` ${cls}` : ""}`, name);
2261
+ g.setAttribute("aria-hidden", "true");
2262
+ return g;
2263
+ }
2264
+ var toneClass = (base, tone) => TONES.has(tone) ? ` ${base}--${tone}` : "";
2265
+ var RowsTile = class extends TileBase {
2266
+ static _KEY = "rows";
2267
+ constructor(options = {}) {
2268
+ super(options);
2269
+ this.onRowActivate = typeof options.onRowActivate === "function" ? options.onRowActivate : null;
2270
+ this.rowMenu = typeof options.rowMenu === "function" ? options.rowMenu : null;
2271
+ this.onRowMenuAction = typeof options.onRowMenuAction === "function" ? options.onRowMenuAction : null;
2272
+ this.onAction = typeof options.onAction === "function" ? options.onAction : null;
2273
+ this.rowsDraggable = options.rowsDraggable === true;
2274
+ this._items = /* @__PURE__ */ new Map();
2275
+ this._onClick = (e) => this._click(e);
2276
+ this._onKey = (e) => this._key(e);
2277
+ this._onMenu = (e) => this._menu(e);
2278
+ }
2279
+ /** Pure and static-only — it runs inside `TileBase`'s constructor. */
2280
+ getDefaultConfig() {
2281
+ return { title: "", empty_text: "", max_rows: 0 };
2282
+ }
2283
+ getConfigSchema() {
2284
+ return {
2285
+ fields: [
2286
+ { key: "title", type: "text", label: "Tile title" },
2287
+ {
2288
+ key: "empty_text",
2289
+ type: "text",
2290
+ label: "When there is nothing",
2291
+ description: 'What the tile says when the list is empty, e.g. "Nothing is late."'
2292
+ },
2293
+ {
2294
+ key: "max_rows",
2295
+ type: "number",
2296
+ label: "Rows at most",
2297
+ min: 0,
2298
+ description: "0 shows them all; the tile scrolls."
2299
+ }
2300
+ ]
2301
+ };
2302
+ }
2303
+ mount(container) {
2304
+ super.mount(container);
2305
+ applyTitle2(this);
2306
+ const c = this.contentElement;
2307
+ if (!c) return;
2308
+ c.addEventListener("click", this._onClick);
2309
+ c.addEventListener("keydown", this._onKey);
2310
+ c.addEventListener("contextmenu", this._onMenu);
2311
+ }
2312
+ /** `null` is "the grid has no data" — see `ChartTile.update`. */
2313
+ update(data, config) {
2314
+ if (this._disposed) return;
2315
+ super.update(data === null ? void 0 : data, config);
2316
+ if (config) applyTitle2(this);
2317
+ }
2318
+ /** Hand this tile its rows; null is the empty state. */
2319
+ setData(data) {
2320
+ if (this._disposed) return;
2321
+ this.data = data ?? null;
2322
+ if (this.contentElement) this.render(this.data);
2323
+ }
2324
+ /** The item drawn under `key`, or null. */
2325
+ itemFor(key) {
2326
+ return this._items.get(String(key)) ?? null;
2327
+ }
2328
+ /** The element of the item drawn under `key`, or null. */
2329
+ rowElement(key) {
2330
+ const want = String(key);
2331
+ return [...this.contentElement?.querySelectorAll("[data-row-key]") || []].find((r) => r.dataset.rowKey === want) || null;
2332
+ }
2333
+ render(data) {
2334
+ const content = this.contentElement;
2335
+ if (!content) return;
2336
+ const norm = normalize(data, this.constructor._KEY);
2337
+ this._items.clear();
2338
+ if (!norm) {
2339
+ this.showEmpty("Nothing to show.");
2340
+ return;
2341
+ }
2342
+ const max = Math.max(0, Math.floor(Number(this.config.max_rows) || 0));
2343
+ const shown = max ? norm.items.slice(0, max) : norm.items;
2344
+ const box = el("div", `fd-list-tile fd-list-tile--${this.constructor._KEY}`);
2345
+ const bar = el("div", "fd-list-tile__bar");
2346
+ const count = el(
2347
+ "span",
2348
+ "fd-list-tile__count",
2349
+ shown.length < norm.total ? `${shown.length} of ${norm.total}` : String(norm.total)
2350
+ );
2351
+ count.title = `${norm.total} in all`;
2352
+ bar.append(count, el("span", "fd-list-tile__spacer"));
2353
+ if (norm.action) {
2354
+ const b = el("button", "fd-list-tile__action");
2355
+ b.type = "button";
2356
+ b.dataset.listAction = "action";
2357
+ if (norm.action.icon) b.append(glyph(norm.action.icon));
2358
+ b.append(el("span", null, norm.action.label));
2359
+ bar.append(b);
2360
+ }
2361
+ box.append(bar);
2362
+ const list = el("div", "fd-list-tile__rows");
2363
+ list.setAttribute("role", "list");
2364
+ if (!shown.length) {
2365
+ list.append(el("div", "fd-list-tile__empty", norm.empty || this.config.empty_text || "Nothing here."));
2366
+ }
2367
+ for (const item of shown) {
2368
+ const key = String(item.key);
2369
+ if (this._items.has(key)) continue;
2370
+ this._items.set(key, item);
2371
+ const row = this._renderItem(item);
2372
+ row.classList.add("fd-list-tile__row");
2373
+ row.dataset.rowKey = key;
2374
+ row.setAttribute("role", "button");
2375
+ row.tabIndex = 0;
2376
+ if (this.rowsDraggable) row.draggable = true;
2377
+ const wrap = el("div", "fd-list-tile__item");
2378
+ wrap.setAttribute("role", "listitem");
2379
+ wrap.append(row);
2380
+ list.append(wrap);
2381
+ }
2382
+ box.append(list);
2383
+ if (norm.note) {
2384
+ const note = el("div", "fd-list-tile__note", norm.note);
2385
+ note.setAttribute("role", "note");
2386
+ note.title = norm.note;
2387
+ box.append(note);
2388
+ }
2389
+ content.replaceChildren(box);
2390
+ }
2391
+ /** One item's row element. @abstract */
2392
+ _renderItem(_item) {
2393
+ return el("div");
2394
+ }
2395
+ // ── input ───────────────────────────────────────────────────────────
2396
+ _rowOf(target) {
2397
+ const row = target?.closest?.("[data-row-key]");
2398
+ return row && this.contentElement?.contains(row) ? row : null;
2399
+ }
2400
+ _click(e) {
2401
+ if (e.target?.closest?.("[data-list-action]")) {
2402
+ this._call(this.onAction, { tile: this, event: e });
2403
+ return;
2404
+ }
2405
+ const row = this._rowOf(e.target);
2406
+ if (row) this._activate(row, e);
2407
+ }
2408
+ _activate(rowEl, event) {
2409
+ const row = this.itemFor(rowEl.dataset.rowKey);
2410
+ if (row) this._call(this.onRowActivate, { row, event, tile: this });
2411
+ }
2412
+ _key(e) {
2413
+ const rowEl = this._rowOf(e.target);
2414
+ if (!rowEl) return;
2415
+ if (e.key === "ContextMenu" || e.key === "F10" && e.shiftKey) {
2416
+ const r = rowEl.getBoundingClientRect();
2417
+ if (this._openMenu(rowEl, e, r.left + 16, r.bottom)) e.preventDefault();
2418
+ return;
2419
+ }
2420
+ if ((e.key === "Enter" || e.key === " ") && !e.altKey) {
2421
+ e.preventDefault();
2422
+ this._activate(rowEl, e);
2423
+ return;
2424
+ }
2425
+ const rows = [...this.contentElement.querySelectorAll("[data-row-key]")];
2426
+ const i = rows.indexOf(rowEl);
2427
+ const to = { ArrowDown: i + 1, ArrowUp: i - 1, Home: 0, End: rows.length - 1 }[e.key];
2428
+ if (to === void 0 || !rows[to]) return;
2429
+ e.preventDefault();
2430
+ rows[to].focus();
2431
+ }
2432
+ _menu(e) {
2433
+ const rowEl = this._rowOf(e.target);
2434
+ if (rowEl && this._openMenu(rowEl, e, e.clientX, e.clientY)) e.preventDefault();
2435
+ }
2436
+ /** Show the row's menu, if it has one. → whether it did. */
2437
+ _openMenu(rowEl, event, x, y) {
2438
+ const row = this.itemFor(rowEl.dataset.rowKey);
2439
+ if (!row || typeof this.rowMenu !== "function") return false;
2440
+ let items = null;
2441
+ try {
2442
+ items = this.rowMenu({ row, event, tile: this });
2443
+ } catch (err) {
2444
+ console.error(`[${this.constructor.name}] rowMenu threw:`, err);
2445
+ return false;
2446
+ }
2447
+ if (!Array.isArray(items) || !items.length) return false;
2448
+ showContextMenu(x, y, items, (action) => this._call(this.onRowMenuAction, { action, row, tile: this }));
2449
+ return true;
2450
+ }
2451
+ /** A hook, called so that one that throws is reported and the tile goes on. */
2452
+ _call(fn, arg) {
2453
+ if (typeof fn !== "function") return;
2454
+ try {
2455
+ fn(arg);
2456
+ } catch (err) {
2457
+ console.error(`[${this.constructor.name}] a callback threw:`, err);
2458
+ }
2459
+ }
2460
+ dispose() {
2461
+ const c = this.contentElement;
2462
+ if (c) {
2463
+ c.removeEventListener("click", this._onClick);
2464
+ c.removeEventListener("keydown", this._onKey);
2465
+ c.removeEventListener("contextmenu", this._onMenu);
2466
+ }
2467
+ this._items.clear();
2468
+ super.dispose();
2469
+ }
2470
+ };
2471
+ var ListTile = class extends RowsTile {
2472
+ static TYPE = "flexdesk.list";
2473
+ static TITLE = "List";
2474
+ static ICON = "list";
2475
+ static DESCRIPTION = "Rows you supply, with a count, a click and a right-click menu per row.";
2476
+ static DEFAULT_SIZE = { w: 6, h: 4 };
2477
+ static SIZE_CONSTRAINTS = { minW: 3, minH: 2, maxW: 12, maxH: 12 };
2478
+ static _KEY = "rows";
2479
+ _renderItem(r) {
2480
+ const row = el("div", r.className ? String(r.className) : null);
2481
+ if (r.icon) {
2482
+ const g = glyph(r.icon, "fd-list-tile__icon");
2483
+ if (r.iconTitle) {
2484
+ g.title = r.iconTitle;
2485
+ g.removeAttribute("aria-hidden");
2486
+ }
2487
+ row.append(g);
2488
+ }
2489
+ if (r.lead != null && r.lead !== "") row.append(el("span", "fd-list-tile__lead", r.lead));
2490
+ const title = el("span", "fd-list-tile__title", r.title ?? "");
2491
+ title.title = r.tip || String(r.title ?? "");
2492
+ row.append(title);
2493
+ for (const c of Array.isArray(r.cells) ? r.cells : []) {
2494
+ if (!c) continue;
2495
+ const cell = el("span", `fd-list-tile__cell${toneClass("fd-list-tile__cell", c.tone)}${c.className ? ` ${c.className}` : ""}`);
2496
+ if (c.icon) cell.append(glyph(c.icon));
2497
+ if (c.text != null && c.text !== "") cell.append(document.createTextNode(String(c.text)));
2498
+ if (c.title) cell.title = c.title;
2499
+ row.append(cell);
2500
+ }
2501
+ return row;
2502
+ }
2503
+ };
2504
+ var PeopleTile = class extends RowsTile {
2505
+ static TYPE = "flexdesk.people";
2506
+ static TITLE = "People";
2507
+ static ICON = "groups";
2508
+ static DESCRIPTION = "One row per person, with how much is on them.";
2509
+ static DEFAULT_SIZE = { w: 6, h: 4 };
2510
+ static SIZE_CONSTRAINTS = { minW: 3, minH: 2, maxW: 12, maxH: 12 };
2511
+ static _KEY = "people";
2512
+ getDefaultConfig() {
2513
+ return { ...super.getDefaultConfig(), count_label: "open" };
2514
+ }
2515
+ getConfigSchema() {
2516
+ const { fields } = super.getConfigSchema();
2517
+ return { fields: [...fields, {
2518
+ key: "count_label",
2519
+ type: "text",
2520
+ label: "Count reads",
2521
+ description: `After each person's number, e.g. "open" for "3 open".`
2522
+ }] };
2523
+ }
2524
+ _renderItem(p) {
2525
+ const row = el("div", "fd-people-tile__row");
2526
+ row.append(glyph(p.icon || (p.muted ? "person_off" : "person"), "fd-list-tile__icon"));
2527
+ row.append(el("span", `fd-people-tile__name${p.muted ? " fd-people-tile__name--muted" : ""}`, p.name ?? p.key));
2528
+ const pills = el("span", "fd-people-tile__pills");
2529
+ for (const pill of Array.isArray(p.pills) ? p.pills : []) {
2530
+ if (!pill?.text) continue;
2531
+ const s = el("span", `fd-people-tile__pill${toneClass("fd-people-tile__pill", pill.tone)}`, pill.text);
2532
+ if (pill.title) s.title = pill.title;
2533
+ pills.append(s);
2534
+ }
2535
+ row.append(pills);
2536
+ const label = p.countLabel ?? this.config.count_label ?? "";
2537
+ row.append(el("span", "fd-people-tile__count", `${Number(p.count) || 0}${label ? ` ${label}` : ""}`));
2538
+ if (p.title) row.title = p.title;
2539
+ return row;
2540
+ }
2541
+ };
2542
+ function groupByPerson(items, personOf) {
2543
+ const by = /* @__PURE__ */ new Map();
2544
+ for (const item of Array.isArray(items) ? items : []) {
2545
+ const who = String(personOf(item) || "");
2546
+ if (!by.has(who)) by.set(who, { key: who, items: [] });
2547
+ by.get(who).items.push(item);
2548
+ }
2549
+ return [...by.values()].sort((a, b) => b.items.length - a.items.length || (a.key === "") - (b.key === "") || a.key.localeCompare(b.key));
2550
+ }
2551
+ function registerListTiles() {
2552
+ for (const Tile of [ListTile, PeopleTile]) {
2553
+ if (getWidget(Tile.TYPE) !== Tile) registerWidget(Tile);
2554
+ }
2555
+ }
2556
+ function applyTitle2(tile) {
2557
+ tile.setTitle(tile.config.title || tile.constructor.TITLE);
2558
+ }
2559
+
2560
+ // src/tiles/scope_picker.js
2561
+ function createScopePicker({ options = [], value, label = "Scope", onChange } = {}) {
2562
+ const opts = options.filter((o) => o && o.value != null).map((o) => ({ ...o, value: String(o.value) }));
2563
+ const known = (v) => opts.some((o) => o.value === String(v));
2564
+ let current = known(value) ? String(value) : opts[0]?.value ?? "";
2565
+ const element = document.createElement("span");
2566
+ element.className = "fd-scope-picker";
2567
+ element.setAttribute("role", "group");
2568
+ element.setAttribute("aria-label", label);
2569
+ const buttons = opts.map((o) => {
2570
+ const b = document.createElement("button");
2571
+ b.type = "button";
2572
+ b.className = "fd-scope-picker__btn";
2573
+ b.dataset.scope = o.value;
2574
+ b.textContent = o.label ?? o.value;
2575
+ if (o.hint) b.title = o.hint;
2576
+ element.append(b);
2577
+ return b;
2578
+ });
2579
+ const paint = () => {
2580
+ for (const b of buttons) {
2581
+ const on = b.dataset.scope === current;
2582
+ b.classList.toggle("fd-scope-picker__btn--on", on);
2583
+ b.setAttribute("aria-pressed", String(on));
2584
+ }
2585
+ };
2586
+ paint();
2587
+ const onClick = (e) => {
2588
+ const b = e.target?.closest?.("[data-scope]");
2589
+ if (!b || !element.contains(b) || b.dataset.scope === current) return;
2590
+ current = b.dataset.scope;
2591
+ paint();
2592
+ try {
2593
+ onChange?.(current);
2594
+ } catch (err) {
2595
+ console.error("[ScopePicker] onChange threw:", err);
2596
+ }
2597
+ };
2598
+ const onKey = (e) => {
2599
+ const i = buttons.indexOf(e.target);
2600
+ if (i < 0) return;
2601
+ const to = { ArrowRight: i + 1, ArrowLeft: i - 1, Home: 0, End: buttons.length - 1 }[e.key];
2602
+ if (to === void 0 || !buttons[to]) return;
2603
+ e.preventDefault();
2604
+ buttons[to].focus();
2605
+ };
2606
+ element.addEventListener("click", onClick);
2607
+ element.addEventListener("keydown", onKey);
2608
+ return {
2609
+ element,
2610
+ get value() {
2611
+ return current;
2612
+ },
2613
+ /** Press `v` without calling `onChange` (a value restored from elsewhere). */
2614
+ setValue(v) {
2615
+ if (!known(v)) return;
2616
+ current = String(v);
2617
+ paint();
2618
+ },
2619
+ dispose() {
2620
+ element.removeEventListener("click", onClick);
2621
+ element.removeEventListener("keydown", onKey);
2622
+ element.remove();
2623
+ }
2624
+ };
2625
+ }
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
+
2142
2967
  // src/tiles/tile_grid.js
2143
2968
  init_tile_registry();
2144
2969
  var TileGrid = class {
@@ -2157,6 +2982,9 @@ var TileGrid = class {
2157
2982
  * @param {{resolve: Function}} [options.dataSource] - Per-tile data provider,
2158
2983
  * handed to every tile. With none, the grid is the broadcast-only grid
2159
2984
  * it has always been and no tile is ever asked to load anything.
2985
+ * @param {*} [options.scope=null] - The board's scope, a filter every tile
2986
+ * follows (0.16.1); see `setScope`.
2987
+ * @param {Function} [options.onScopeChange] - `(scope)` after `setScope` changed it.
2160
2988
  */
2161
2989
  constructor({
2162
2990
  container,
@@ -2179,9 +3007,13 @@ var TileGrid = class {
2179
3007
  stateGuard = null,
2180
3008
  // Injected, never constructed here: the grid does not know what
2181
3009
  // a binding is, only that a tile may have somewhere to ask.
2182
- dataSource = null
3010
+ dataSource = null,
3011
+ scope = null,
3012
+ onScopeChange = null
2183
3013
  }) {
2184
3014
  this.host = host;
3015
+ this.scope = scope;
3016
+ this._onScopeChangeCallback = onScopeChange;
2185
3017
  this.stateGuard = stateGuard;
2186
3018
  this.dataSource = dataSource;
2187
3019
  this.container = container;
@@ -2304,6 +3136,7 @@ var TileGrid = class {
2304
3136
  dataSource: this.dataSource
2305
3137
  });
2306
3138
  if (!tile) return null;
3139
+ tile.scope = this.scope;
2307
3140
  const layoutEntry = {
2308
3141
  id: tileId,
2309
3142
  type,
@@ -2374,6 +3207,39 @@ var TileGrid = class {
2374
3207
  tile.loadData?.({ force: true });
2375
3208
  });
2376
3209
  }
3210
+ /**
3211
+ * THE BOARD'S SCOPE: one filter every tile follows ("Everyone", "Assigned
3212
+ * by me", …), set from a `createScopePicker` or from anything else.
3213
+ *
3214
+ * The grid only carries the value; what it means is the embedder's. Every
3215
+ * tile gets it as `tile.scope` and through `tile.onScopeChange(scope)`, the
3216
+ * grid's `onScopeChange(scope)` is called, and a grid with a data source
3217
+ * reloads its tiles, since their rows depend on it. The scope is not part
3218
+ * of the layout: it is how the board is being looked at, not what is on it.
3219
+ *
3220
+ * @param {*} scope - Compared by JSON: setting the same scope again does nothing.
3221
+ * @returns {boolean} whether it changed
3222
+ */
3223
+ setScope(scope) {
3224
+ if (JSON.stringify(scope ?? null) === JSON.stringify(this.scope ?? null)) return false;
3225
+ this.scope = scope ?? null;
3226
+ this.tiles.forEach((tile) => {
3227
+ tile.scope = this.scope;
3228
+ try {
3229
+ tile.onScopeChange?.(this.scope);
3230
+ } catch (err) {
3231
+ console.error("[TileGrid] a tile's onScopeChange threw:", err);
3232
+ }
3233
+ });
3234
+ try {
3235
+ this._onScopeChangeCallback?.(this.scope);
3236
+ } catch (err) {
3237
+ console.error("[TileGrid] onScopeChange threw:", err);
3238
+ }
3239
+ this.eventBus?.emit?.("grid:scope-changed", { scope: this.scope });
3240
+ if (this.dataSource) this.reloadAll();
3241
+ return true;
3242
+ }
2377
3243
  /**
2378
3244
  * Get current layout.
2379
3245
  * @returns {Array} Layout array
@@ -2770,6 +3636,8 @@ var TileGrid = class {
2770
3636
  tileType,
2771
3637
  config,
2772
3638
  schema,
3639
+ // What `getFieldOptions` reads for a field with dynamic options.
3640
+ data: this.data,
2773
3641
  variables: this.data ? this._getVariableList() : [],
2774
3642
  onSave: (newConfig) => {
2775
3643
  const tile = this.tiles.get(tileId);
@@ -2896,12 +3764,16 @@ export {
2896
3764
  CHART_TILE_TYPES,
2897
3765
  ChartTile,
2898
3766
  FieldTypes,
3767
+ FigureTile,
2899
3768
  KpiTile,
2900
3769
  LayoutPersistence,
3770
+ ListTile,
3771
+ PeopleTile,
2901
3772
  TileBase,
2902
3773
  TileGrid,
2903
3774
  TileRegistry,
2904
3775
  configureLayoutPersistence,
3776
+ createScopePicker,
2905
3777
  createWidget,
2906
3778
  formatVariableLabel,
2907
3779
  getAllWidgetTypes,
@@ -2912,10 +3784,16 @@ export {
2912
3784
  getWidget,
2913
3785
  getWidgetCatalog,
2914
3786
  getWidgetMetadata,
3787
+ groupByPerson,
2915
3788
  hasWidget,
2916
3789
  mergeWithDefaults,
3790
+ mountTileConfigForm,
2917
3791
  normalizeChartData,
3792
+ normalizeListData,
3793
+ normalizePeopleData,
3794
+ openTileConfig,
2918
3795
  registerChartTiles,
3796
+ registerListTiles,
2919
3797
  registerWidget,
2920
3798
  setTileLogger,
2921
3799
  validateConfig,