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
@@ -13,11 +13,30 @@
13
13
  * Source Material
14
14
  * - html/js/status_view.js and ui_manager.js toast helpers.
15
15
  * - html/js/ui_utilities.js notification utilities.
16
+ *
17
+ * Progress toasts (0.17.0)
18
+ * ------------------------
19
+ * `show({ progress })` makes a toast that tracks a job: `progress` is a 0..1
20
+ * fraction, `{ value, total }`, or `null` for indeterminate; `status` is a line
21
+ * under the message and `counts` a label beside the bar. Such a toast is
22
+ * persistent and collapsible, and it RUNS until `update()` gives it an outcome
23
+ * severity (success, error or fatal), which ends its bar (done or error) for
24
+ * good and lets a `durationMs` in the same patch dismiss it. Every toast is
25
+ * drawn once and then patched: `update()` touches only its own nodes, and a
26
+ * toast arriving or leaving moves no other toast's element, so a bar, a
27
+ * collapsed state and a hovered button survive. A running progress toast is
28
+ * never held behind "+N more" (and never dropped by 'drop'); it counts toward
29
+ * `maxVisible`, so it takes the place of the newest other toast.
16
30
  */
17
31
  import { ComponentBase } from './base/component_base.js';
18
32
  import { getSetting } from '../core/settings.js';
33
+ import { setToastBus, getToastBus } from './components/toast.js';
34
+ import { ProgressBar } from './components/progress.js';
19
35
 
20
36
  const SEVERITIES = ['info', 'success', 'warn', 'error', 'fatal'];
37
+
38
+ /** The severity a progress toast finishes with, and the state its bar ends in. */
39
+ const OUTCOME_BAR_STATE = Object.freeze({ success: 'done', error: 'error', fatal: 'error' });
21
40
  const LOG_SEVERITY_MAP = {
22
41
  trace: 'info',
23
42
  debug: 'info',
@@ -34,6 +53,9 @@ const DEFAULT_LOG_LEVELS = new Set(['warn', 'error', 'fatal']);
34
53
  const SUPPRESSED_NAMESPACE_TITLES = new Set(['bootstrap', 'app']);
35
54
 
36
55
  export class NotificationCenter extends ComponentBase {
56
+ #items;
57
+ #summaryEl;
58
+
37
59
  constructor({
38
60
  eventBus,
39
61
  logger,
@@ -65,6 +87,9 @@ export class NotificationCenter extends ComponentBase {
65
87
  this.sequence = 0;
66
88
  this.activeTimers = new Map();
67
89
  this.eventDisposers = [];
90
+ /** id -> the drawn toast's nodes, for the toasts on screen. */
91
+ this.#items = new Map();
92
+ this.#summaryEl = null;
68
93
 
69
94
  this.container = null;
70
95
  this.rootEl = null;
@@ -83,6 +108,7 @@ export class NotificationCenter extends ComponentBase {
83
108
  this.liveRegionEl = existingContainer.querySelector('.notification-center__live-region');
84
109
  this._mounted = true;
85
110
  this.#subscribeEvents();
111
+ this.#claimToastBus();
86
112
  return this;
87
113
  }
88
114
 
@@ -114,6 +140,7 @@ export class NotificationCenter extends ComponentBase {
114
140
 
115
141
  this._mounted = true;
116
142
  this.#subscribeEvents();
143
+ this.#claimToastBus();
117
144
 
118
145
  if (Array.isArray(props.initialNotifications)) {
119
146
  props.initialNotifications.forEach((notification) => this.show(notification));
@@ -124,6 +151,12 @@ export class NotificationCenter extends ComponentBase {
124
151
  dispose() {
125
152
  this.#clearTimers();
126
153
  this.#unsubscribeEvents();
154
+ // Give the toast helpers back to the console — unless someone has
155
+ // pointed them at another bus since this centre mounted.
156
+ if (this.eventBus && getToastBus() === this.eventBus) setToastBus(null);
157
+ this.#items.forEach((entry) => entry.bar?.dispose());
158
+ this.#items.clear();
159
+ this.#summaryEl = null;
127
160
  if (this.rootEl?.parentNode) {
128
161
  this.rootEl.parentNode.removeChild(this.rootEl);
129
162
  }
@@ -137,7 +170,8 @@ export class NotificationCenter extends ComponentBase {
137
170
  show(notification) {
138
171
  const normalized = this.#normalizeNotification(notification);
139
172
  this.#consoleLog(normalized);
140
- if (this.overflow === 'collapse' && this.notifications.length >= this.maxVisible) {
173
+ const running = NotificationCenter.#isRunning(normalized);
174
+ if (this.overflow === 'collapse' && !running && this.notifications.length >= this.maxVisible) {
141
175
  // Held, not shown: its timer starts when it comes on screen.
142
176
  this.held.push(normalized);
143
177
  this.#recordHistory(normalized);
@@ -146,10 +180,18 @@ export class NotificationCenter extends ComponentBase {
146
180
  return normalized.id;
147
181
  }
148
182
  this.notifications.push(normalized);
149
- if (this.notifications.length > this.maxVisible) {
150
- const removed = this.notifications.shift();
151
- this.#stopTimer(removed.id);
152
- this.#recordHistory({ ...removed, dismissed: true, reason: 'overflow' });
183
+ if (this.overflow === 'collapse') {
184
+ // Only a running progress toast gets here past the limit: the
185
+ // newest other toast steps back behind "+N more" to make room.
186
+ this.#rebalance();
187
+ } else if (this.notifications.length > this.maxVisible) {
188
+ // The oldest leaves — but never a running progress toast.
189
+ const idx = this.notifications.findIndex((item) => !NotificationCenter.#isRunning(item));
190
+ if (idx !== -1) {
191
+ const [removed] = this.notifications.splice(idx, 1);
192
+ this.#stopTimer(removed.id);
193
+ this.#recordHistory({ ...removed, dismissed: true, reason: 'overflow' });
194
+ }
153
195
  }
154
196
 
155
197
  this.#recordHistory(normalized);
@@ -173,50 +215,93 @@ export class NotificationCenter extends ComponentBase {
173
215
  this.#stopTimer(notificationId);
174
216
  this.#recordHistory({ ...removed, dismissed: true, reason });
175
217
  // A place on screen came free: the oldest held-back toast takes it.
176
- if (this.held.length && this.notifications.length < this.maxVisible) {
177
- const next = this.held.shift();
178
- this.notifications.push(next);
179
- this.#scheduleAutoDismiss(next);
180
- }
218
+ this.#rebalance();
181
219
  this.#render();
182
220
  return true;
183
221
  }
184
222
 
185
223
  /**
186
- * Update an existing notification's message in place without re-rendering.
187
- * This prevents the flicker caused by dismiss+show.
224
+ * Change a notification in place: only its own nodes are touched, and no
225
+ * other toast is redrawn. A held-back toast is changed too and shows the
226
+ * change when it comes on screen.
227
+ *
228
+ * Fields: `message`, `title`, `description`, `severity`, `meta`, and for
229
+ * progress toasts `progress` (fraction, `{ value, total }`, or `null` for
230
+ * indeterminate), `status`, `counts` and `collapsed`; `progress` on a
231
+ * plain toast turns it into a running progress toast (on screen, as with
232
+ * show()). Giving a running progress toast an outcome severity (success,
233
+ * error, fatal) finishes it, and its bar keeps the state it ended in: a
234
+ * later severity only restyles the toast. A `durationMs` on a toast that
235
+ * is not running (re)starts its auto-dismiss (0 keeps it until closed).
236
+ *
188
237
  * @param {string} notificationId - The ID of the notification to update
189
- * @param {Object} updates - Object with fields to update (message, title, etc.)
238
+ * @param {Object} updates - The fields to change
190
239
  * @returns {boolean} True if the notification was found and updated
191
240
  */
192
241
  update(notificationId, updates = {}) {
193
- const notification = this.notifications.find((item) => item.id === notificationId);
242
+ const notification = this.notifications.find((item) => item.id === notificationId)
243
+ ?? this.held.find((item) => item.id === notificationId);
194
244
  if (!notification) {
195
245
  return false;
196
246
  }
247
+ const hadTitle = Boolean(notification.title);
248
+ const hadDescription = Boolean(notification.description);
249
+ const wasProgress = notification.progress !== undefined;
250
+ const wasRunning = NotificationCenter.#isRunning(notification);
197
251
 
198
- // Update allowed fields
199
252
  if (updates.message !== undefined) notification.message = updates.message;
200
253
  if (updates.title !== undefined) notification.title = updates.title;
201
- if (updates.severity !== undefined) notification.severity = this.#normalizeSeverity(updates.severity);
254
+ if (updates.description !== undefined) notification.description = updates.description;
202
255
  if (updates.meta !== undefined) notification.meta = updates.meta;
203
-
204
- // Update the DOM directly without full re-render
205
- const notificationEl = this.listEl?.querySelector(`[data-notification-id="${notificationId}"]`);
206
- if (notificationEl) {
207
- const bodyEl = notificationEl.querySelector('.twm-notification__body');
208
- if (bodyEl && updates.message !== undefined) {
209
- bodyEl.textContent = updates.message;
256
+ if (updates.status !== undefined) notification.status = updates.status ?? '';
257
+ if (updates.counts !== undefined) notification.counts = updates.counts ?? '';
258
+ if (updates.collapsed !== undefined) notification.collapsed = Boolean(updates.collapsed);
259
+ if (updates.progress !== undefined) {
260
+ if (wasProgress) {
261
+ notification.progress = NotificationCenter.#normalizeProgress(updates.progress);
262
+ } else {
263
+ // A plain toast becomes a running progress toast, set up as show() would.
264
+ Object.assign(notification, NotificationCenter.#progressFields({ ...notification, ...updates }));
265
+ notification.persistent = true;
266
+ this.#stopTimer(notification.id);
210
267
  }
211
- const titleEl = notificationEl.querySelector('.twm-notification__title');
212
- if (titleEl && updates.title !== undefined) {
213
- titleEl.textContent = updates.title;
268
+ }
269
+ if (updates.severity !== undefined) {
270
+ notification.severity = this.#normalizeSeverity(updates.severity);
271
+ const outcome = OUTCOME_BAR_STATE[notification.severity];
272
+ if (NotificationCenter.#isRunning(notification) && outcome) notification.outcome = outcome;
273
+ }
274
+ const running = NotificationCenter.#isRunning(notification);
275
+ const finished = wasRunning && !running;
276
+ if (updates.durationMs !== undefined && !NotificationCenter.#isRunning(notification)) {
277
+ notification.durationMs = this.#coerceDuration(updates.durationMs);
278
+ notification.persistent = notification.durationMs === 0;
279
+ if (this.notifications.includes(notification)) {
280
+ this.#stopTimer(notification.id);
281
+ this.#scheduleAutoDismiss(notification);
214
282
  }
215
- } else {
216
- // Fallback to full re-render if DOM element not found
217
- this.#render();
218
283
  }
219
284
 
285
+ const entry = this.#items.get(notification.id);
286
+ if (entry) {
287
+ const structural = hadTitle !== Boolean(notification.title)
288
+ || hadDescription !== Boolean(notification.description)
289
+ || wasProgress !== (notification.progress !== undefined);
290
+ if (structural) {
291
+ // A part appears or goes: rebuild this one toast, in its place.
292
+ const next = this.#buildItem(notification);
293
+ entry.el.replaceWith(next.el);
294
+ entry.bar?.dispose();
295
+ this.#items.set(notification.id, next);
296
+ } else {
297
+ this.#patchItem(entry, notification);
298
+ }
299
+ }
300
+ if (finished) this.#announce(notification);
301
+ // Started, it comes on screen whatever is held; finished, it folds
302
+ // like any other toast.
303
+ if (running !== wasRunning) this.#rebalance();
304
+ this.#render();
220
305
  return true;
221
306
  }
222
307
 
@@ -268,7 +353,9 @@ export class NotificationCenter extends ComponentBase {
268
353
  }
269
354
  const id = notification.id ?? `notification-${++this.sequence}`;
270
355
  const severity = this.#normalizeSeverity(notification.severity ?? 'info');
356
+ const isProgress = notification.progress !== undefined;
271
357
  return {
358
+ ...(isProgress ? NotificationCenter.#progressFields(notification) : {}),
272
359
  id,
273
360
  title: notification.title ?? null,
274
361
  message: notification.message ?? '',
@@ -277,7 +364,9 @@ export class NotificationCenter extends ComponentBase {
277
364
  severity,
278
365
  namespace: notification.namespace ?? null,
279
366
  durationMs: this.#coerceDuration(notification.durationMs),
280
- persistent: Boolean(notification.persistent),
367
+ // A progress toast stays until it finishes (and then until its
368
+ // finishing durationMs, or its ×).
369
+ persistent: isProgress || Boolean(notification.persistent),
281
370
  skipHistory: Boolean(notification.skipHistory),
282
371
  actions: this.#normalizeActions(notification.actions),
283
372
  meta: notification.meta ?? null,
@@ -394,111 +483,273 @@ export class NotificationCenter extends ComponentBase {
394
483
  }
395
484
  }
396
485
 
397
- #render() {
398
- if (!this._mounted || !this.listEl) {
399
- return;
486
+ /** A progress toast that has not been given an outcome yet. */
487
+ static #isRunning(notification) {
488
+ return notification.progress !== undefined && !notification.outcome;
489
+ }
490
+
491
+ /**
492
+ * The fields a progress toast carries, from a show() payload or an
493
+ * update() that turns a plain toast into one. `outcome` is null while it
494
+ * runs, then the state its bar ended in (done or error).
495
+ */
496
+ static #progressFields(source) {
497
+ return {
498
+ progress: NotificationCenter.#normalizeProgress(source.progress),
499
+ status: source.status ?? '',
500
+ counts: source.counts ?? '',
501
+ collapsed: Boolean(source.collapsed),
502
+ outcome: null,
503
+ };
504
+ }
505
+
506
+ /** `null` (indeterminate), a 0..1 fraction, or `{ value, total }` -> ProgressBar input. */
507
+ static #normalizeProgress(progress) {
508
+ if (progress === null) return { indeterminate: true };
509
+ if (typeof progress === 'number') {
510
+ return { value: Number.isFinite(progress) ? progress : 0, max: null, indeterminate: false };
400
511
  }
401
- this.listEl.innerHTML = '';
402
- const fragment = document.createDocumentFragment();
403
-
404
- this.notifications.forEach((notification) => {
405
- const item = document.createElement('div');
406
- item.className = `notification notification--${notification.severity}`;
407
- // Use setAttribute to avoid StateGuard blocking dataset mutations
408
- item.setAttribute('data-notification-id', notification.id);
409
-
410
- const header = document.createElement('div');
411
- header.className = 'twm-notification__header';
412
- if (notification.title) {
413
- const titleEl = document.createElement('span');
414
- titleEl.className = 'twm-notification__title';
415
- titleEl.textContent = notification.title;
416
- header.appendChild(titleEl);
417
- }
512
+ if (typeof progress === 'object') {
513
+ const value = Number(progress.value);
514
+ const total = Number(progress.total);
515
+ return {
516
+ value: Number.isFinite(value) ? value : 0,
517
+ max: Number.isFinite(total) && total > 0 ? total : null,
518
+ indeterminate: false,
519
+ };
520
+ }
521
+ return { indeterminate: true };
522
+ }
418
523
 
419
- const timeEl = document.createElement('span');
420
- timeEl.className = 'twm-notification__timestamp';
421
- timeEl.textContent = this.#formatTimestamp(notification.timestamp);
422
- header.appendChild(timeEl);
423
-
424
- const closeBtn = document.createElement('button');
425
- closeBtn.className = 'twm-notification__close';
426
- closeBtn.type = 'button';
427
- closeBtn.setAttribute('aria-label', 'Dismiss notification');
428
- closeBtn.innerHTML = '<span class="material-symbols-outlined">close</span>';
429
- closeBtn.addEventListener('click', () => this.dismiss(notification.id, 'manual'));
430
- header.appendChild(closeBtn);
431
- item.appendChild(header);
432
-
433
- const body = document.createElement('div');
434
- body.className = 'twm-notification__body';
435
- body.textContent = notification.message;
436
- item.appendChild(body);
437
-
438
- if (notification.description) {
439
- const descriptionEl = document.createElement('div');
440
- descriptionEl.className = 'twm-notification__description';
441
- descriptionEl.textContent = notification.description;
442
- item.appendChild(descriptionEl);
524
+ /**
525
+ * 'collapse' only: keep at most `maxVisible` on screen, counting running
526
+ * progress toasts, which never leave and are never held (one that started
527
+ * running while held comes on screen first). Past the limit the newest
528
+ * other toast steps back to the front of the held queue (its timer
529
+ * restarts when it returns); under it the oldest held toast comes in.
530
+ */
531
+ #rebalance() {
532
+ if (this.overflow !== 'collapse') return;
533
+ for (let i = 0; i < this.held.length;) {
534
+ if (NotificationCenter.#isRunning(this.held[i])) this.notifications.push(...this.held.splice(i, 1));
535
+ else i++;
536
+ }
537
+ while (this.notifications.length > this.maxVisible) {
538
+ let idx = -1;
539
+ for (let i = this.notifications.length - 1; i >= 0; i--) {
540
+ if (!NotificationCenter.#isRunning(this.notifications[i])) { idx = i; break; }
443
541
  }
542
+ if (idx === -1) break;
543
+ const [back] = this.notifications.splice(idx, 1);
544
+ this.#stopTimer(back.id);
545
+ this.held.unshift(back);
546
+ }
547
+ while (this.held.length && this.notifications.length < this.maxVisible) {
548
+ const next = this.held.shift();
549
+ this.notifications.push(next);
550
+ this.#scheduleAutoDismiss(next);
551
+ }
552
+ }
444
553
 
445
- if (notification.actions.length > 0) {
446
- const actionsEl = document.createElement('div');
447
- actionsEl.className = 'twm-notification__actions';
448
- notification.actions.forEach((action) => {
449
- const button = document.createElement('button');
450
- button.type = 'button';
451
- button.className = 'twm-notification__action-btn';
452
- button.textContent = action.label;
453
- button.addEventListener('click', () => {
454
- try {
455
- if (action.handler) {
456
- action.handler(notification, action);
457
- } else if (action.eventName && this.eventBus) {
458
- this.eventBus.emit(action.eventName, action.payload ?? { notificationId: notification.id });
459
- }
460
- } catch (err) {
461
- this.logger?.error?.('twm-notification-center', 'Notification action failed', { err });
462
- } finally {
463
- // Don't auto-dismiss error/fatal notifications after action clicks
464
- if (notification.severity !== 'error' && notification.severity !== 'fatal') {
465
- this.dismiss(notification.id, 'action');
466
- }
467
- }
468
- });
469
- actionsEl.appendChild(button);
470
- });
471
- item.appendChild(actionsEl);
554
+ /**
555
+ * Draw the toasts on screen. Each toast's element is built once and kept:
556
+ * a toast arriving or leaving only inserts or removes its own element, so
557
+ * every other toast keeps its nodes (and its bar, collapsed state, hover).
558
+ */
559
+ #render() {
560
+ if (!this._mounted || !this.listEl) {
561
+ return;
562
+ }
563
+ const visible = new Set(this.notifications.map((n) => n.id));
564
+ for (const [id, entry] of this.#items) {
565
+ if (visible.has(id)) continue;
566
+ entry.bar?.dispose();
567
+ entry.el.remove();
568
+ this.#items.delete(id);
569
+ }
570
+ const wanted = this.notifications.map((notification) => {
571
+ let entry = this.#items.get(notification.id);
572
+ if (!entry) {
573
+ entry = this.#buildItem(notification);
574
+ this.#items.set(notification.id, entry);
472
575
  }
473
-
474
- item.addEventListener('click', (event) => {
475
- if (event.target.closest('button')) {
476
- return;
477
- }
478
- this.dismiss(notification.id, 'click');
479
- });
480
-
481
- fragment.appendChild(item);
576
+ return entry.el;
482
577
  });
578
+ const summary = this.#syncSummary();
579
+ if (summary) wanted.push(summary);
580
+ wanted.forEach((el, i) => {
581
+ const at = this.listEl.children[i];
582
+ if (at !== el) this.listEl.insertBefore(el, at ?? null);
583
+ });
584
+ // Anything after them is stale (another instance's toasts on a reused root).
585
+ while (this.listEl.children.length > wanted.length) {
586
+ this.listEl.lastElementChild.remove();
587
+ }
588
+ }
483
589
 
484
- // The held-back toasts, as one: "+N more". It is a button so the count is
485
- // reachable by keyboard; what it opens is the consumer's (summaryAction),
486
- // and it is also announced on the bus for a history panel to answer.
487
- if (this.held.length) {
590
+ // The held-back toasts, as one: "+N more". It is a button so the count is
591
+ // reachable by keyboard; what it opens is the consumer's (summaryAction),
592
+ // and it is also announced on the bus for a history panel to answer.
593
+ #syncSummary() {
594
+ if (!this.held.length) {
595
+ this.#summaryEl?.remove();
596
+ this.#summaryEl = null;
597
+ return null;
598
+ }
599
+ if (!this.#summaryEl) {
488
600
  const more = document.createElement('button');
489
601
  more.type = 'button';
490
602
  more.className = 'notification twm-notification--summary';
491
- more.setAttribute('data-notification-summary', String(this.held.length));
492
- more.textContent = `+${this.held.length} more`;
493
- more.title = this.held.map((h) => h.title || h.message).join('\n');
494
603
  more.addEventListener('click', () => {
495
604
  const held = [...this.held];
496
605
  try { this.summaryAction?.(held); } catch (err) { this.logger?.warn?.('summaryAction failed', err); }
497
606
  this.eventBus?.emit?.('notification:summary', { held });
498
607
  });
499
- fragment.appendChild(more);
608
+ this.#summaryEl = more;
609
+ }
610
+ const more = this.#summaryEl;
611
+ const count = String(this.held.length);
612
+ if (more.getAttribute('data-notification-summary') !== count) more.setAttribute('data-notification-summary', count);
613
+ NotificationCenter.#setText(more, `+${count} more`);
614
+ const tip = this.held.map((h) => h.title || h.message).join('\n');
615
+ if (more.title !== tip) more.title = tip;
616
+ return more;
617
+ }
618
+
619
+ static #setText(node, text) {
620
+ const t = text == null ? '' : String(text);
621
+ if (node.textContent !== t) node.textContent = t;
622
+ }
623
+
624
+ /** Build one toast's element; `#patchItem` then fills what can change. */
625
+ #buildItem(notification) {
626
+ const isProgress = notification.progress !== undefined;
627
+ const item = document.createElement('div');
628
+ // Use setAttribute to avoid StateGuard blocking dataset mutations
629
+ item.setAttribute('data-notification-id', notification.id);
630
+ const entry = { el: item, severity: null, titleEl: null, bodyEl: null, descriptionEl: null,
631
+ statusEl: null, bar: null, collapseBtn: null };
632
+
633
+ const header = document.createElement('div');
634
+ header.className = 'twm-notification__header';
635
+ if (notification.title) {
636
+ entry.titleEl = document.createElement('span');
637
+ entry.titleEl.className = 'twm-notification__title';
638
+ header.appendChild(entry.titleEl);
639
+ }
640
+
641
+ const timeEl = document.createElement('span');
642
+ timeEl.className = 'twm-notification__timestamp';
643
+ timeEl.textContent = this.#formatTimestamp(notification.timestamp);
644
+ header.appendChild(timeEl);
645
+
646
+ if (isProgress) {
647
+ const toggle = document.createElement('button');
648
+ toggle.className = 'twm-notification__collapse';
649
+ toggle.type = 'button';
650
+ toggle.innerHTML = '<span class="material-symbols-outlined">expand_more</span>';
651
+ toggle.addEventListener('click', () => {
652
+ this.update(notification.id, { collapsed: !notification.collapsed });
653
+ });
654
+ entry.collapseBtn = toggle;
655
+ header.appendChild(toggle);
500
656
  }
501
- this.listEl.appendChild(fragment);
657
+
658
+ const closeBtn = document.createElement('button');
659
+ closeBtn.className = 'twm-notification__close';
660
+ closeBtn.type = 'button';
661
+ closeBtn.setAttribute('aria-label', 'Dismiss notification');
662
+ closeBtn.innerHTML = '<span class="material-symbols-outlined">close</span>';
663
+ closeBtn.addEventListener('click', () => this.dismiss(notification.id, 'manual'));
664
+ header.appendChild(closeBtn);
665
+ item.appendChild(header);
666
+
667
+ entry.bodyEl = document.createElement('div');
668
+ entry.bodyEl.className = 'twm-notification__body';
669
+ item.appendChild(entry.bodyEl);
670
+
671
+ if (isProgress) {
672
+ entry.bar = new ProgressBar(item, {
673
+ ...notification.progress, showCount: false, className: 'twm-notification__progress',
674
+ });
675
+ entry.statusEl = document.createElement('div');
676
+ entry.statusEl.className = 'twm-notification__status';
677
+ item.appendChild(entry.statusEl);
678
+ }
679
+
680
+ if (notification.description) {
681
+ entry.descriptionEl = document.createElement('div');
682
+ entry.descriptionEl.className = 'twm-notification__description';
683
+ item.appendChild(entry.descriptionEl);
684
+ }
685
+
686
+ if (notification.actions.length > 0) {
687
+ const actionsEl = document.createElement('div');
688
+ actionsEl.className = 'twm-notification__actions';
689
+ notification.actions.forEach((action) => {
690
+ const button = document.createElement('button');
691
+ button.type = 'button';
692
+ button.className = 'twm-notification__action-btn';
693
+ button.textContent = action.label;
694
+ button.addEventListener('click', () => {
695
+ try {
696
+ if (action.handler) {
697
+ action.handler(notification, action);
698
+ } else if (action.eventName && this.eventBus) {
699
+ this.eventBus.emit(action.eventName, action.payload ?? { notificationId: notification.id });
700
+ }
701
+ } catch (err) {
702
+ this.logger?.error?.('twm-notification-center', 'Notification action failed', { err });
703
+ } finally {
704
+ // Don't auto-dismiss error/fatal notifications, or a job
705
+ // still running, after action clicks
706
+ if (notification.severity !== 'error' && notification.severity !== 'fatal'
707
+ && !NotificationCenter.#isRunning(notification)) {
708
+ this.dismiss(notification.id, 'action');
709
+ }
710
+ }
711
+ });
712
+ actionsEl.appendChild(button);
713
+ });
714
+ item.appendChild(actionsEl);
715
+ }
716
+
717
+ item.addEventListener('click', (event) => {
718
+ if (event.target.closest('button') || NotificationCenter.#isRunning(notification)) {
719
+ return;
720
+ }
721
+ this.dismiss(notification.id, 'click');
722
+ });
723
+
724
+ this.#patchItem(entry, notification);
725
+ return entry;
726
+ }
727
+
728
+ /** Bring a drawn toast's nodes in line with its data, touching only what changed. */
729
+ #patchItem(entry, notification) {
730
+ const item = entry.el;
731
+ if (entry.severity !== notification.severity) {
732
+ if (entry.severity) item.classList.remove(`notification--${entry.severity}`);
733
+ item.classList.add('notification', `notification--${notification.severity}`);
734
+ entry.severity = notification.severity;
735
+ }
736
+ if (entry.titleEl) NotificationCenter.#setText(entry.titleEl, notification.title);
737
+ NotificationCenter.#setText(entry.bodyEl, notification.message);
738
+ if (entry.descriptionEl) NotificationCenter.#setText(entry.descriptionEl, notification.description);
739
+ if (!entry.bar) return;
740
+
741
+ item.classList.add('twm-notification--progress');
742
+ item.classList.toggle('twm-notification--collapsed', Boolean(notification.collapsed));
743
+ const expanded = String(!notification.collapsed);
744
+ if (entry.collapseBtn.getAttribute('aria-expanded') !== expanded) {
745
+ entry.collapseBtn.setAttribute('aria-expanded', expanded);
746
+ entry.collapseBtn.setAttribute('aria-label', notification.collapsed ? 'Expand notification' : 'Collapse notification');
747
+ }
748
+ NotificationCenter.#setText(entry.statusEl, notification.status);
749
+ entry.statusEl.hidden = !notification.status;
750
+ entry.bar.update({ ...notification.progress, label: notification.counts });
751
+ const end = notification.outcome ?? 'running';
752
+ if (entry.bar.state !== end) entry.bar.setState(end);
502
753
  }
503
754
 
504
755
  #formatTimestamp(timestamp) {
@@ -600,6 +851,11 @@ export class NotificationCenter extends ComponentBase {
600
851
  );
601
852
  }
602
853
 
854
+ /** The toast helpers (toast.js) emit on the bus of the centre mounted last. */
855
+ #claimToastBus() {
856
+ if (this.eventBus) setToastBus(this.eventBus);
857
+ }
858
+
603
859
  #unsubscribeEvents() {
604
860
  this.eventDisposers.forEach((disposer) => {
605
861
  try {
File without changes