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
@@ -0,0 +1,442 @@
1
+ /**
2
+ * tile_config_form.js — a tile's config schema drawn as a form (0.17.0).
3
+ *
4
+ * `TileBase.getConfigSchema()` answers `{fields: [...]}` and `config_schema.js`
5
+ * knows how to validate, default and list options for those fields. This draws
6
+ * them, so an embedder no longer hand-builds a control per field:
7
+ *
8
+ * import { mountTileConfigForm } from '@flexdesk/tiles';
9
+ * const form = mountTileConfigForm(host, {
10
+ * schema: tile.getConfigSchema(),
11
+ * config: tile.config,
12
+ * data, // for `options: 'variables'` and the like
13
+ * onChange: (key, value, config) => tile.update(undefined, config),
14
+ * renderers: { variable: (ctx) => myPicker(ctx) },
15
+ * });
16
+ * form.getValues(); form.setValues(next); form.isValid();
17
+ * form.focusFirstInvalid(); form.flush(); form.dispose();
18
+ *
19
+ * ══ A FIELD ═════════════════════════════════════════════════════════════
20
+ *
21
+ * { key, type, label?, description?, default?, required?, readonly?,
22
+ * min?, max?, step?, options?, visibleWhen? }
23
+ *
24
+ * `type` is a `FieldTypes` entry (text, number, checkbox, select, multiselect,
25
+ * color, range) or a type a consumer renders through `renderers`. `options` is
26
+ * what `getFieldOptions(field, data)` reads. `visibleWhen` is
27
+ * `{field, in: [...]}` or `{field, equals}`: the row is hidden while the other
28
+ * field's value does not match, and shown again when it does — by toggling the
29
+ * row, never by rebuilding the form, so the control being typed into keeps its
30
+ * focus and its caret.
31
+ *
32
+ * A field with no `key` throws: a form that wrote every edit to
33
+ * `config['undefined']` was BUG-0021, and a loud mount is the cheaper failure.
34
+ * So does a `type` with neither a built-in control nor a renderer. `label` is
35
+ * optional: without one the field is called by its `key`, on its label and in
36
+ * every message (`count must be at least 1`).
37
+ *
38
+ * ══ AN EDIT ═════════════════════════════════════════════════════════════
39
+ *
40
+ * Every edit is checked by `validateField`. A valid one is applied and calls
41
+ * `onChange(key, value, config)` with a copy of the whole config. An invalid one
42
+ * shows its error under the field and is NOT applied: the config keeps the last
43
+ * valid value until the field is corrected (or `setValues` replaces it).
44
+ * `setValues` does not call `onChange`. `isValid()` answers false while a
45
+ * shown row has an error; `focusFirstInvalid()` focuses the first such row's
46
+ * control (a renderer's element, or the first focusable element inside it) and
47
+ * answers whether there was one.
48
+ *
49
+ * `commitOn: 'input'` (the default) checks text, number, range and colour
50
+ * controls on every keystroke. `commitOn: 'change'` checks them when the edit is
51
+ * finished (the control's `change`: blur, Enter, a released slider), for a
52
+ * consumer whose `onChange` is expensive — a save, a reload. A range still shows
53
+ * its value as it moves. `flush()` commits an edit typed but not yet finished,
54
+ * for a consumer about to take the form away; it is a no-op under `'input'`.
55
+ *
56
+ * ══ A CONSUMER'S CONTROL ════════════════════════════════════════════════
57
+ *
58
+ * `renderers[type](ctx)` — consulted before the built-ins, so it may also
59
+ * replace one — gets `{field, key, value, data, readonly, commit}` and returns an
60
+ * element, or `{element, setValue?(value), dispose?()}`. `commit(value)` runs
61
+ * the same check as a built-in edit and answers `{valid, error?}`. On a
62
+ * `readonly` field it applies nothing and answers `{valid: false, error}`, as a
63
+ * built-in control on a readonly field is disabled and never commits.
64
+ *
65
+ * ══ THE GRID'S COGWHEEL ═════════════════════════════════════════════════
66
+ *
67
+ * `TileGrid` emits `grid:config-modal-show` and nothing listens by default.
68
+ * `openTileConfig(eventBus, opts)` is the opt-in listener: it shows the form in
69
+ * a `SlideOutPanel` over `opts.host` (every finished, valid edit is saved — the
70
+ * form runs `commitOn: 'change'`, so a typed title is one save, not one per
71
+ * keystroke; closing the panel saves an edit still being typed) or in a modal
72
+ * (saved on Save, which is refused while a field shows an error: the modal
73
+ * stays open and the first invalid field gets the focus). A request that arrives while a modal is open is
74
+ * ignored: the open modal is the one being answered. It answers a function that
75
+ * unsubscribes; it also closes the slide-out, but leaves an open modal alone —
76
+ * that modal stays working (its Save still saves) until the user dismisses it.
77
+ */
78
+
79
+ import {
80
+ FieldTypes, validateField, mergeWithDefaults, getFieldOptions,
81
+ } from './config_schema.js';
82
+ import { SlideOutPanel } from '../ui/components/slide_out_panel.js';
83
+ import { openModal } from '../ui/components/modal.js';
84
+
85
+ let _seq = 0;
86
+ // What focusFirstInvalid may focus inside a renderer's element.
87
+ const FOCUSABLE = 'input:not([disabled]), select:not([disabled]), textarea:not([disabled]), '
88
+ + 'button:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable="true"]';
89
+
90
+ function el(tag, className, text) {
91
+ const n = document.createElement(tag);
92
+ if (className) n.className = className;
93
+ if (text != null) n.textContent = String(text);
94
+ return n;
95
+ }
96
+
97
+ function isVisible(field, values) {
98
+ const rule = field.visibleWhen;
99
+ if (!rule) return true;
100
+ const v = values[rule.field];
101
+ if (Array.isArray(rule.in)) return rule.in.includes(v);
102
+ if ('equals' in rule) return v === rule.equals;
103
+ return true;
104
+ }
105
+
106
+ /** Built-in controls: each answers `{element, read(), write(value)}`. */
107
+ const BUILT_IN = {
108
+ [FieldTypes.TEXT]: (field, id) => {
109
+ const input = el('input', 'fd-tile-config__input');
110
+ input.type = 'text';
111
+ input.id = id;
112
+ return {
113
+ element: input, events: ['input'],
114
+ read: () => input.value,
115
+ write: (v) => { input.value = v == null ? '' : String(v); },
116
+ };
117
+ },
118
+ [FieldTypes.NUMBER]: (field, id) => numeric(field, id, 'number'),
119
+ [FieldTypes.RANGE]: (field, id) => numeric(field, id, 'range'),
120
+ [FieldTypes.CHECKBOX]: (field, id) => {
121
+ const input = el('input', 'fd-tile-config__checkbox');
122
+ input.type = 'checkbox';
123
+ input.id = id;
124
+ return {
125
+ element: input, events: ['change'],
126
+ read: () => input.checked,
127
+ write: (v) => { input.checked = !!v; },
128
+ };
129
+ },
130
+ [FieldTypes.COLOR]: (field, id) => {
131
+ const input = el('input', 'fd-tile-config__color');
132
+ input.type = 'color';
133
+ input.id = id;
134
+ return {
135
+ element: input, events: ['input'],
136
+ read: () => input.value,
137
+ write: (v) => { if (v) input.value = String(v); },
138
+ };
139
+ },
140
+ [FieldTypes.SELECT]: (field, id, data) => choice(field, id, data, false),
141
+ [FieldTypes.MULTISELECT]: (field, id, data) => choice(field, id, data, true),
142
+ };
143
+
144
+ function numeric(field, id, kind) {
145
+ const input = el('input', `fd-tile-config__${kind === 'range' ? 'range' : 'input'}`);
146
+ input.type = kind;
147
+ input.id = id;
148
+ if (field.min != null) input.min = String(field.min);
149
+ if (field.max != null) input.max = String(field.max);
150
+ if (field.step != null) input.step = String(field.step);
151
+ const out = kind === 'range' ? el('output', 'fd-tile-config__range-value') : null;
152
+ const element = out ? el('span', 'fd-tile-config__range-wrap') : input;
153
+ if (out) element.append(input, out);
154
+ return {
155
+ element, events: ['input'],
156
+ // The raw text goes to `validateField`; the form turns it into a number.
157
+ read: () => input.value,
158
+ write: (v) => {
159
+ input.value = v == null ? '' : String(v);
160
+ if (out) out.textContent = input.value;
161
+ },
162
+ shown: (v) => { if (out) out.textContent = v == null ? '' : String(v); },
163
+ };
164
+ }
165
+
166
+ function choice(field, id, data, multiple) {
167
+ const select = el('select', 'fd-tile-config__select');
168
+ select.id = id;
169
+ select.multiple = multiple;
170
+ // Option values keep their type (a number stays a number) — they are read
171
+ // back from this array by index, never from the element's string value.
172
+ const options = getFieldOptions(field, data);
173
+ options.forEach((o) => select.append(el('option', null, o.label ?? o.value)));
174
+ return {
175
+ element: select, events: ['change'],
176
+ read: () => (multiple
177
+ ? options.filter((_, i) => select.options[i].selected).map((o) => o.value)
178
+ : (options[select.selectedIndex]?.value ?? null)),
179
+ write: (v) => {
180
+ if (multiple) {
181
+ const want = Array.isArray(v) ? v : [];
182
+ options.forEach((o, i) => { select.options[i].selected = want.includes(o.value); });
183
+ } else {
184
+ select.selectedIndex = options.findIndex((o) => o.value === v);
185
+ }
186
+ },
187
+ };
188
+ }
189
+
190
+ /** Number and range read as text; an empty one is null. */
191
+ function coerce(field, raw) {
192
+ if (field.type === FieldTypes.NUMBER || field.type === FieldTypes.RANGE) {
193
+ return raw === '' || raw == null ? null : Number(raw);
194
+ }
195
+ return raw;
196
+ }
197
+
198
+ /**
199
+ * Draw `schema.fields` into `host`.
200
+ * @param {'input'|'change'} [opts.commitOn='input'] When a text, number, range
201
+ * or colour control is checked and committed: on every keystroke, or once the
202
+ * edit is finished.
203
+ * @returns {{getValues(): Object, setValues(config: Object): void,
204
+ * isValid(): boolean, focusFirstInvalid(): boolean, flush(): void,
205
+ * dispose(): void}}
206
+ */
207
+ export function mountTileConfigForm(host, {
208
+ schema, config = {}, data = null, onChange = null, renderers = {}, commitOn = 'input',
209
+ } = {}) {
210
+ if (!host) throw new TypeError('mountTileConfigForm: a host element is required');
211
+ if (commitOn !== 'input' && commitOn !== 'change') {
212
+ throw new TypeError(`mountTileConfigForm: unknown commitOn "${commitOn}"`);
213
+ }
214
+ const fields = Array.isArray(schema?.fields) ? schema.fields : [];
215
+ fields.forEach((f, i) => {
216
+ if (typeof f?.key !== 'string' || !f.key) {
217
+ const called = typeof f?.label === 'string' && f.label ? ` "${f.label}"` : '';
218
+ throw new TypeError(`mountTileConfigForm: field #${i + 1}${called} has no key`);
219
+ }
220
+ });
221
+
222
+ let values = mergeWithDefaults(config || {}, schema);
223
+ const unlisten = []; // listeners removed by dispose
224
+ const root = el('div', 'fd-tile-config');
225
+ const rows = []; // {field, named, row, error, control, invalid, pending}
226
+
227
+ const showError = (r, message) => {
228
+ r.invalid = !!message;
229
+ r.error.textContent = message || '';
230
+ r.error.hidden = !message;
231
+ r.row.classList.toggle('fd-tile-config__row--invalid', !!message);
232
+ };
233
+
234
+ const applyVisibility = () => {
235
+ for (const r of rows) r.row.hidden = !isVisible(r.field, values);
236
+ };
237
+
238
+ const commit = (r, raw) => {
239
+ if (r.field.readonly) return { valid: false, error: `${r.named.label} is read-only` };
240
+ const result = validateField(raw, r.named);
241
+ if (!result.valid) {
242
+ showError(r, result.error);
243
+ return { valid: false, error: result.error };
244
+ }
245
+ showError(r, null);
246
+ const value = coerce(r.field, raw);
247
+ values = { ...values, [r.field.key]: value };
248
+ applyVisibility();
249
+ onChange?.(r.field.key, value, { ...values });
250
+ return { valid: true };
251
+ };
252
+
253
+ for (const field of fields) {
254
+ const id = `fd-tile-config-${++_seq}`;
255
+ const row = el('div', `fd-tile-config__row fd-tile-config__row--${field.type}`);
256
+ row.setAttribute('data-field', field.key);
257
+ // What validateField and the messages read: the label, or the key.
258
+ const named = { ...field, label: field.label ?? field.key };
259
+ const label = el('label', 'fd-tile-config__label', named.label);
260
+ const error = el('div', 'fd-tile-config__error');
261
+ error.hidden = true;
262
+ const r = { field, named, row, error, control: null, invalid: false, pending: null };
263
+
264
+ const custom = renderers?.[field.type];
265
+ if (typeof custom === 'function') {
266
+ const made = custom({
267
+ field, key: field.key, value: values[field.key], data,
268
+ readonly: !!field.readonly, commit: (v) => commit(r, v),
269
+ });
270
+ const element = made instanceof HTMLElement ? made : made?.element;
271
+ if (!(element instanceof HTMLElement)) {
272
+ throw new TypeError(`mountTileConfigForm: the renderer for "${field.type}" returned no element`);
273
+ }
274
+ element.id ||= id;
275
+ // The id the control carries: a renderer may have set its own.
276
+ label.htmlFor = element.id;
277
+ r.control = {
278
+ element,
279
+ write: made instanceof HTMLElement ? () => {} : (v) => made.setValue?.(v),
280
+ dispose: made instanceof HTMLElement ? null : () => made.dispose?.(),
281
+ };
282
+ } else {
283
+ const build = BUILT_IN[field.type];
284
+ if (!build) {
285
+ throw new TypeError(`mountTileConfigForm: no control for type "${field.type}" (field "${field.key}"); pass a renderer`);
286
+ }
287
+ const c = build(field, id, data);
288
+ label.htmlFor = id;
289
+ c.write(values[field.key]);
290
+ const input = c.element.matches('input, select') ? c.element : c.element.querySelector('input, select');
291
+ if (field.readonly) {
292
+ input.disabled = true;
293
+ } else {
294
+ const listen = (type, fn) => {
295
+ input.addEventListener(type, fn);
296
+ unlisten.push(() => input.removeEventListener(type, fn));
297
+ };
298
+ const onEdit = () => {
299
+ r.pending = null;
300
+ const raw = c.read();
301
+ c.shown?.(raw);
302
+ commit(r, raw);
303
+ };
304
+ if (commitOn === 'change') {
305
+ // Checked once the edit is finished; until then it is
306
+ // pending, so flush() can still commit it.
307
+ listen('change', onEdit);
308
+ if (c.events.includes('input')) {
309
+ listen('input', () => { r.pending = onEdit; c.shown?.(c.read()); });
310
+ }
311
+ } else {
312
+ for (const type of c.events) listen(type, onEdit);
313
+ }
314
+ }
315
+ r.control = c;
316
+ }
317
+
318
+ if (field.type === FieldTypes.CHECKBOX) row.append(r.control.element, label);
319
+ else row.append(label, r.control.element);
320
+ if (field.description) row.append(el('div', 'fd-tile-config__help', field.description));
321
+ row.append(error);
322
+ rows.push(r);
323
+ root.append(row);
324
+ }
325
+
326
+ applyVisibility();
327
+ host.append(root);
328
+
329
+ return {
330
+ getValues: () => ({ ...values }),
331
+ setValues(next) {
332
+ values = mergeWithDefaults(next || {}, schema);
333
+ for (const r of rows) {
334
+ r.pending = null;
335
+ r.control.write(values[r.field.key]);
336
+ showError(r, null);
337
+ }
338
+ applyVisibility();
339
+ },
340
+ isValid: () => rows.every((r) => r.row.hidden || !r.invalid),
341
+ focusFirstInvalid() {
342
+ const r = rows.find((x) => !x.row.hidden && x.invalid);
343
+ if (!r) return false;
344
+ const { element } = r.control;
345
+ const target = element.matches(FOCUSABLE) ? element : element.querySelector(FOCUSABLE);
346
+ (target || element).focus();
347
+ return true;
348
+ },
349
+ flush() {
350
+ for (const r of rows) r.pending?.();
351
+ },
352
+ dispose() {
353
+ unlisten.splice(0).forEach((off) => off());
354
+ for (const r of rows) {
355
+ try { r.control.dispose?.(); } catch (err) { console.warn('[tile-config] a renderer dispose threw', err); }
356
+ }
357
+ rows.length = 0;
358
+ root.remove();
359
+ },
360
+ };
361
+ }
362
+
363
+ /**
364
+ * Show `grid:config-modal-show` requests as a config form.
365
+ *
366
+ * @param {{on(name: string, handler: Function): {dispose(): void}}} eventBus
367
+ * The `EventBus` the `TileGrid` emits on.
368
+ * @param {object} [opts]
369
+ * @param {'modal'|'slide-out'} [opts.mode='modal'] A modal saves on Save, and
370
+ * refuses Save while a field shows an error; a slide-out saves every
371
+ * finished, valid edit (`commitOn: 'change'`).
372
+ * @param {HTMLElement} [opts.host] The slide-out's host (required for it).
373
+ * @param {number} [opts.width] The slide-out's or the modal's width.
374
+ * @param {Object} [opts.renderers] Passed to `mountTileConfigForm`.
375
+ * @param {string|Function} [opts.title='Tile settings'] Or `(detail) => string`.
376
+ * @returns {() => void} Unsubscribes and closes the slide-out. An open modal is
377
+ * left to the user: it keeps its form, and its Save still calls `onSave`.
378
+ */
379
+ export function openTileConfig(eventBus, { mode = 'modal', host = null, width, renderers = {}, title = 'Tile settings' } = {}) {
380
+ if (typeof eventBus?.on !== 'function') throw new TypeError('openTileConfig: an event bus with on() is required');
381
+ if (mode !== 'modal' && mode !== 'slide-out') throw new TypeError(`openTileConfig: unknown mode "${mode}"`);
382
+ if (mode === 'slide-out' && !(host instanceof HTMLElement)) {
383
+ throw new TypeError('openTileConfig: a slide-out needs a host element');
384
+ }
385
+
386
+ let form = null; // the slide-out's form
387
+ let panel = null;
388
+ let modalOpen = false;
389
+ // An edit still being typed when the panel goes is saved, not lost.
390
+ const drop = () => { form?.flush(); form?.dispose(); form = null; };
391
+
392
+ const show = (detail = {}) => {
393
+ if (modalOpen) return;
394
+ const heading = typeof title === 'function' ? title(detail) : title;
395
+ const base = { schema: detail.schema, config: detail.config, data: detail.data ?? null, renderers };
396
+
397
+ if (mode === 'slide-out') {
398
+ drop();
399
+ if (!panel) {
400
+ panel = new SlideOutPanel({ ...(width ? { width } : {}), onClose: drop });
401
+ panel.mount(host);
402
+ }
403
+ const body = panel.open(heading, 'tune');
404
+ form = mountTileConfigForm(body, {
405
+ ...base, commitOn: 'change', onChange: (_k, _v, cfg) => detail.onSave?.(cfg),
406
+ });
407
+ return;
408
+ }
409
+
410
+ // The modal's form lives until the user dismisses the modal, whatever
411
+ // else happens meanwhile: requests are ignored while it is open, and
412
+ // the returned function leaves it alone.
413
+ const content = el('div', 'fd-tile-config-host');
414
+ const mine = mountTileConfigForm(content, base);
415
+ modalOpen = true;
416
+ openModal({
417
+ title: heading, icon: 'tune', content, ...(width ? { width } : {}),
418
+ actions: [
419
+ { label: 'Cancel', value: null },
420
+ // Refused while a field shows an error: its last valid value is
421
+ // not what the user is looking at, so saving it would be a lie.
422
+ { label: 'Save', value: 'save', primary: true, keepOpen: true, onClick: (close) => {
423
+ if (mine.isValid()) { close('save'); return; }
424
+ mine.focusFirstInvalid();
425
+ } },
426
+ ],
427
+ }).then((v) => {
428
+ modalOpen = false;
429
+ if (v === 'save') detail.onSave?.(mine.getValues());
430
+ mine.dispose();
431
+ });
432
+ };
433
+
434
+ const sub = eventBus.on('grid:config-modal-show', show);
435
+
436
+ return () => {
437
+ sub.dispose();
438
+ drop();
439
+ panel?.dispose();
440
+ panel = null;
441
+ };
442
+ }
@@ -24,6 +24,9 @@ export class TileGrid {
24
24
  * @param {{resolve: Function}} [options.dataSource] - Per-tile data provider,
25
25
  * handed to every tile. With none, the grid is the broadcast-only grid
26
26
  * it has always been and no tile is ever asked to load anything.
27
+ * @param {*} [options.scope=null] - The board's scope, a filter every tile
28
+ * follows (0.16.1); see `setScope`.
29
+ * @param {Function} [options.onScopeChange] - `(scope)` after `setScope` changed it.
27
30
  */
28
31
  constructor({ container, columns = 12, rowHeight = 80, gap = 16, eventBus = null,
29
32
  showToolbar = true, readonly = false, onLayoutChange, onBeforeLayoutChange,
@@ -35,8 +38,10 @@ export class TileGrid {
35
38
  host = null, stateGuard = null,
36
39
  // Injected, never constructed here: the grid does not know what
37
40
  // a binding is, only that a tile may have somewhere to ask.
38
- dataSource = null }) {
41
+ dataSource = null, scope = null, onScopeChange = null }) {
39
42
  this.host = host;
43
+ this.scope = scope;
44
+ this._onScopeChangeCallback = onScopeChange;
40
45
  this.stateGuard = stateGuard;
41
46
  this.dataSource = dataSource;
42
47
  this.container = container;
@@ -192,6 +197,8 @@ export class TileGrid {
192
197
  });
193
198
 
194
199
  if (!tile) return null;
200
+ // Before mount, so a tile's first render and first load already follow it.
201
+ tile.scope = this.scope;
195
202
 
196
203
  // Add to layout
197
204
  const layoutEntry = {
@@ -289,6 +296,34 @@ export class TileGrid {
289
296
  });
290
297
  }
291
298
 
299
+ /**
300
+ * THE BOARD'S SCOPE: one filter every tile follows ("Everyone", "Assigned
301
+ * by me", …), set from a `createScopePicker` or from anything else.
302
+ *
303
+ * The grid only carries the value; what it means is the embedder's. Every
304
+ * tile gets it as `tile.scope` and through `tile.onScopeChange(scope)`, the
305
+ * grid's `onScopeChange(scope)` is called, and a grid with a data source
306
+ * reloads its tiles, since their rows depend on it. The scope is not part
307
+ * of the layout: it is how the board is being looked at, not what is on it.
308
+ *
309
+ * @param {*} scope - Compared by JSON: setting the same scope again does nothing.
310
+ * @returns {boolean} whether it changed
311
+ */
312
+ setScope(scope) {
313
+ if (JSON.stringify(scope ?? null) === JSON.stringify(this.scope ?? null)) return false;
314
+ this.scope = scope ?? null;
315
+ this.tiles.forEach((tile) => {
316
+ tile.scope = this.scope;
317
+ try { tile.onScopeChange?.(this.scope); }
318
+ catch (err) { console.error('[TileGrid] a tile\'s onScopeChange threw:', err); }
319
+ });
320
+ try { this._onScopeChangeCallback?.(this.scope); }
321
+ catch (err) { console.error('[TileGrid] onScopeChange threw:', err); }
322
+ this.eventBus?.emit?.('grid:scope-changed', { scope: this.scope });
323
+ if (this.dataSource) this.reloadAll();
324
+ return true;
325
+ }
326
+
292
327
  /**
293
328
  * Get current layout.
294
329
  * @returns {Array} Layout array
@@ -813,6 +848,8 @@ export class TileGrid {
813
848
  tileType,
814
849
  config,
815
850
  schema,
851
+ // What `getFieldOptions` reads for a field with dynamic options.
852
+ data: this.data,
816
853
  variables: this.data ? this._getVariableList() : [],
817
854
  onSave: (newConfig) => {
818
855
  const tile = this.tiles.get(tileId);
File without changes