flexdesk 0.16.1 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/LICENSE +0 -0
  3. package/README.md +2217 -42
  4. package/THIRD_PARTY_LICENSES.md +0 -0
  5. package/css/base.css +1834 -75
  6. package/css/flexdesk.css +3146 -164
  7. package/css/overrides.css +16 -0
  8. package/css/reset.css +0 -0
  9. package/css/tokens.css +0 -0
  10. package/dist/canvas.js +6 -239
  11. package/dist/canvas.js.map +3 -3
  12. package/dist/charts.js +709 -18
  13. package/dist/charts.js.map +4 -4
  14. package/dist/chunk-3PHCPZHT.js +0 -0
  15. package/dist/chunk-3PHCPZHT.js.map +0 -0
  16. package/dist/chunk-4A7SJC3T.js +503 -0
  17. package/dist/chunk-4A7SJC3T.js.map +7 -0
  18. package/dist/{chunk-6JSOVNID.js → chunk-CVMCZZX2.js} +635 -10
  19. package/dist/chunk-CVMCZZX2.js.map +7 -0
  20. package/dist/chunk-D3COLZZ2.js +1311 -0
  21. package/dist/chunk-D3COLZZ2.js.map +7 -0
  22. package/dist/chunk-FOOS3T5L.js +0 -0
  23. package/dist/chunk-FOOS3T5L.js.map +0 -0
  24. package/dist/{chunk-SN4LSOAR.js → chunk-GOQLR5WR.js} +452 -2
  25. package/dist/chunk-GOQLR5WR.js.map +7 -0
  26. package/dist/{chunk-FL5KFNQH.js → chunk-HNU6MM2B.js} +52 -17
  27. package/dist/chunk-HNU6MM2B.js.map +7 -0
  28. package/dist/chunk-HYJYYEPK.js +375 -0
  29. package/dist/chunk-HYJYYEPK.js.map +7 -0
  30. package/dist/chunk-JKZLNKG6.js +2041 -0
  31. package/dist/chunk-JKZLNKG6.js.map +7 -0
  32. package/dist/chunk-JYWURG5T.js +0 -0
  33. package/dist/chunk-JYWURG5T.js.map +0 -0
  34. package/dist/{chunk-RQM5Z2MU.js → chunk-MCIXFNVF.js} +633 -311
  35. package/dist/chunk-MCIXFNVF.js.map +7 -0
  36. package/dist/chunk-N7HC3ADV.js +30 -0
  37. package/dist/chunk-N7HC3ADV.js.map +7 -0
  38. package/dist/{chunk-FBGP5KN5.js → chunk-Q36EPT2R.js} +3268 -203
  39. package/dist/chunk-Q36EPT2R.js.map +7 -0
  40. package/dist/chunk-QWQJUDPD.js +610 -0
  41. package/dist/chunk-QWQJUDPD.js.map +7 -0
  42. package/dist/{chunk-NLTOY57M.js → chunk-UURZ37SD.js} +28 -48
  43. package/dist/chunk-UURZ37SD.js.map +7 -0
  44. package/dist/{chunk-B5Q53LAJ.js → chunk-UWO43BZU.js} +2 -2
  45. package/dist/chunk-UWO43BZU.js.map +7 -0
  46. package/dist/chunk-WVFGV5FT.js +0 -0
  47. package/dist/chunk-WVFGV5FT.js.map +0 -0
  48. package/dist/core.js +170 -118
  49. package/dist/core.js.map +2 -2
  50. package/dist/editor.js +156 -70
  51. package/dist/editor.js.map +2 -2
  52. package/dist/flexdesk.css +3146 -164
  53. package/dist/flow.js +2789 -306
  54. package/dist/flow.js.map +4 -4
  55. package/dist/host.js +109 -3
  56. package/dist/host.js.map +3 -3
  57. package/dist/reset.css +0 -0
  58. package/dist/tile_registry-6WZPTUZV.js +0 -0
  59. package/dist/tile_registry-6WZPTUZV.js.map +0 -0
  60. package/dist/tiles.js +657 -234
  61. package/dist/tiles.js.map +4 -4
  62. package/dist/tokens.css +0 -0
  63. package/dist/widgets.js +3252 -2124
  64. package/dist/widgets.js.map +4 -4
  65. package/dist/wm.js +1227 -347
  66. package/dist/wm.js.map +4 -4
  67. package/package.json +3 -1
  68. package/src/canvas/canvas_adapter.js +5 -0
  69. package/src/canvas/edge_router.js +0 -0
  70. package/src/canvas/graph/connector_router.js +0 -0
  71. package/src/canvas/graph/history_service.js +0 -0
  72. package/src/canvas/graph/node_platform.js +0 -0
  73. package/src/canvas/index.js +1 -0
  74. package/src/canvas/layered_layout.js +244 -0
  75. package/src/charts/chart_types.js +0 -0
  76. package/src/charts/data_series_plot_window.js +0 -0
  77. package/src/charts/downsample.js +2 -2
  78. package/src/charts/plot_config.js +53 -29
  79. package/src/charts/plot_config_editor.js +627 -0
  80. package/src/charts/plot_grid.js +449 -0
  81. package/src/charts/plot_popout_window.js +103 -317
  82. package/src/charts/plotly_wrapper.js +22 -2
  83. package/src/charts/series_transforms.js +206 -0
  84. package/src/core/event_bus.js +0 -0
  85. package/src/core/logging.js +1 -1
  86. package/src/core/settings.js +105 -19
  87. package/src/core/state_guard.js +93 -51
  88. package/src/core/state_guard_config.js +85 -74
  89. package/src/core/state_machine.js +0 -0
  90. package/src/editor/editor_pane.js +3 -3
  91. package/src/editor/monaco_editor_factory.js +124 -60
  92. package/src/editor/monaco_loader.js +103 -28
  93. package/src/editor/notebook_search.js +0 -0
  94. package/src/editor/notebook_tab_bar.js +38 -52
  95. package/src/editor/notebook_undo_manager.js +0 -0
  96. package/src/editor/split_pane_container.js +6 -3
  97. package/src/flow/canvas_editor/connections.js +90 -0
  98. package/src/flow/canvas_editor/editor.js +2351 -153
  99. package/src/flow/canvas_editor/index.js +2 -1
  100. package/src/flow/canvas_editor/layout.js +119 -12
  101. package/src/flow/canvas_editor/strings.js +80 -0
  102. package/src/flow/kit/always_before.js +0 -0
  103. package/src/flow/kit/catalogue.js +97 -3
  104. package/src/flow/kit/chip_input.js +0 -0
  105. package/src/flow/kit/dom.js +0 -0
  106. package/src/flow/kit/findings.js +0 -0
  107. package/src/flow/kit/history.js +0 -0
  108. package/src/flow/kit/index.js +3 -3
  109. package/src/flow/kit/keys.js +7 -1
  110. package/src/flow/kit/logic_graph.js +97 -15
  111. package/src/flow/kit/references.js +0 -0
  112. package/src/flow/kit/settings_panel.js +0 -0
  113. package/src/flow/kit/settings_schema.js +2 -1
  114. package/src/flow/kit/step_picker.js +0 -0
  115. package/src/flow/kit/strings.js +3 -1
  116. package/src/flow/kit/value_picker.js +0 -0
  117. package/src/flow/kit/widgets.js +86 -109
  118. package/src/flow/lanes/editor.js +136 -14
  119. package/src/flow/lanes/index.js +3 -1
  120. package/src/flow/lanes/layout.js +0 -0
  121. package/src/flow/lanes/pipeline.js +44 -2
  122. package/src/flow/lanes/preview.js +0 -0
  123. package/src/flow/lanes/strings.js +1 -0
  124. package/src/flow/outline/editor.js +0 -0
  125. package/src/flow/outline/index.js +0 -0
  126. package/src/flow/outline/mapping.js +0 -0
  127. package/src/flow/outline/menu.js +0 -0
  128. package/src/flow/outline/operations.js +0 -0
  129. package/src/flow/outline/recognise.js +0 -0
  130. package/src/flow/outline/strings.js +0 -0
  131. package/src/help/help_modal.js +168 -42
  132. package/src/help/help_registry.js +12 -1
  133. package/src/host/external_links.js +0 -0
  134. package/src/host/file_picker.js +117 -0
  135. package/src/host/host.js +10 -2
  136. package/src/host/photino_host.js +9 -0
  137. package/src/host/photino_host.md +21 -3
  138. package/src/host/pywebview_host.js +10 -0
  139. package/src/tiles/chart_tiles.js +53 -245
  140. package/src/tiles/config_schema.js +8 -0
  141. package/src/tiles/figure_tile.js +322 -0
  142. package/src/tiles/layout_persistence.js +3 -3
  143. package/src/tiles/list_tiles.js +7 -7
  144. package/src/tiles/scope_picker.js +0 -0
  145. package/src/tiles/tile_base.js +1 -1
  146. package/src/tiles/tile_config_form.js +442 -0
  147. package/src/tiles/tile_grid.js +2 -0
  148. package/src/tiles/tile_registry.js +0 -0
  149. package/src/tiling/bottom_panel_tabs.js +67 -99
  150. package/src/tiling/command_palette.js +64 -19
  151. package/src/tiling/content_registry.js +4 -1
  152. package/src/tiling/desktops.js +11 -1
  153. package/src/tiling/entity_sources.js +0 -0
  154. package/src/tiling/keymap.js +403 -128
  155. package/src/tiling/kind_taxonomy.js +78 -0
  156. package/src/tiling/landing_table.js +0 -0
  157. package/src/tiling/loading_overlay.js +0 -0
  158. package/src/tiling/nav_panel.js +59 -10
  159. package/src/tiling/page_factory.js +5 -5
  160. package/src/tiling/panel_availability.js +49 -0
  161. package/src/tiling/panel_keys.js +0 -0
  162. package/src/tiling/shell.js +114 -12
  163. package/src/tiling/tab_strip.js +0 -0
  164. package/src/tiling/tile_breadcrumb.js +0 -0
  165. package/src/tiling/tile_renderer.js +105 -9
  166. package/src/tiling/tile_tab_menu.js +0 -0
  167. package/src/tiling/tile_tree.js +0 -0
  168. package/src/tiling/wm.js +562 -30
  169. package/src/tiling/zoom.js +0 -0
  170. package/src/ui/base/component_base.js +0 -0
  171. package/src/ui/base/controller_base.js +0 -0
  172. package/src/ui/base/page_base.js +0 -0
  173. package/src/ui/components/about_dialog.js +0 -0
  174. package/src/ui/components/action_dropdown.js +30 -2
  175. package/src/ui/components/alert_log.js +0 -0
  176. package/src/ui/components/alerts_pane.js +0 -0
  177. package/src/ui/components/attribute_list_editor.js +0 -0
  178. package/src/ui/components/autocomplete_field.js +0 -0
  179. package/src/ui/components/cell_editor_host.js +211 -0
  180. package/src/ui/components/cell_grid.js +648 -0
  181. package/src/ui/components/cell_selection.js +220 -0
  182. package/src/ui/components/choice_controls.js +351 -0
  183. package/src/ui/components/color_field.js +211 -0
  184. package/src/ui/components/color_list_field.js +210 -0
  185. package/src/ui/components/column_fit.js +0 -0
  186. package/src/ui/components/column_groups.js +314 -0
  187. package/src/ui/components/column_order.js +142 -0
  188. package/src/ui/components/computing_status_window.js +98 -25
  189. package/src/ui/components/confirm_dialog.js +81 -38
  190. package/src/ui/components/context_menu.js +2 -2
  191. package/src/ui/components/conversation_view.js +440 -0
  192. package/src/ui/components/crosstab.js +289 -0
  193. package/src/ui/components/data_table.js +601 -62
  194. package/src/ui/components/detail_header.js +22 -11
  195. package/src/ui/components/document_outline.js +397 -0
  196. package/src/ui/components/drag_reorder.js +0 -0
  197. package/src/ui/components/fact_table.js +0 -0
  198. package/src/ui/components/fill_handle.js +322 -0
  199. package/src/ui/components/gallery_picker.js +19 -8
  200. package/src/ui/components/header_actions.js +55 -0
  201. package/src/ui/components/inline_renamer.js +0 -0
  202. package/src/ui/components/job_progress.js +51 -0
  203. package/src/ui/components/managed_window.js +27 -4
  204. package/src/ui/components/menu_bar.js +745 -0
  205. package/src/ui/components/modal.js +449 -75
  206. package/src/ui/components/notification_history.js +3 -3
  207. package/src/ui/components/progress.js +483 -0
  208. package/src/ui/components/record_list.js +0 -0
  209. package/src/ui/components/row_activation.js +0 -0
  210. package/src/ui/components/section_stack.js +298 -0
  211. package/src/ui/components/series.js +173 -0
  212. package/src/ui/components/settings_view.js +634 -0
  213. package/src/ui/components/slide_out_panel.js +43 -6
  214. package/src/ui/components/slider_field.js +275 -18
  215. package/src/ui/components/sortable_list.js +0 -0
  216. package/src/ui/components/staged_edits.js +343 -0
  217. package/src/ui/components/staged_history.js +172 -0
  218. package/src/ui/components/table_filter_chips.js +0 -0
  219. package/src/ui/components/table_state_store.js +0 -0
  220. package/src/ui/components/table_structure.js +182 -0
  221. package/src/ui/components/table_values.js +0 -0
  222. package/src/ui/components/tabs.js +497 -0
  223. package/src/ui/components/tag_input.js +215 -0
  224. package/src/ui/components/toast.js +25 -8
  225. package/src/ui/components/toggle_field.js +175 -0
  226. package/src/ui/components/tree_view.js +55 -34
  227. package/src/ui/components/window_taskbar.js +0 -0
  228. package/src/ui/components/wizard.js +563 -0
  229. package/src/ui/controllers/window_chrome_controller.js +2 -2
  230. package/src/ui/notification_center.js +375 -119
  231. package/src/ui/utils/auto_scrollbars.js +0 -0
  232. package/src/ui/utils/markdown.js +709 -0
  233. package/src/ui/utils/native_context_menu.js +0 -0
  234. package/src/ui/utils/overlay_scrollbar.js +16 -12
  235. package/src/ui/utils/raf_resize_observer.js +0 -0
  236. package/src/ui/utils/scroll_lock.js +0 -0
  237. package/src/ui/utils/strip_scroll.js +55 -0
  238. package/src/ui/utils/tooltip_service.js +0 -8
  239. package/dist/chunk-6JSOVNID.js.map +0 -7
  240. package/dist/chunk-B5Q53LAJ.js.map +0 -7
  241. package/dist/chunk-FBGP5KN5.js.map +0 -7
  242. package/dist/chunk-FL5KFNQH.js.map +0 -7
  243. package/dist/chunk-NLTOY57M.js.map +0 -7
  244. package/dist/chunk-NNFTETK3.js +0 -633
  245. package/dist/chunk-NNFTETK3.js.map +0 -7
  246. package/dist/chunk-O5OHMWBB.js +0 -284
  247. package/dist/chunk-O5OHMWBB.js.map +0 -7
  248. package/dist/chunk-RQM5Z2MU.js.map +0 -7
  249. package/dist/chunk-SN4LSOAR.js.map +0 -7
  250. package/dist/chunk-W6FBJLPW.js +0 -367
  251. package/dist/chunk-W6FBJLPW.js.map +0 -7
  252. package/src/ui/controllers/panel_state_machine.js +0 -634
@@ -0,0 +1,215 @@
1
+ /**
2
+ * tag_input.js — A LIST OF SHORT STRINGS, drawn as removable chips followed by
3
+ * a text box. (0.17.0)
4
+ *
5
+ * ┌──────────────────────────────────────────────────┐
6
+ * │ [baseline ×] [stress ×] [q3 ×] Add a tag… │
7
+ * └──────────────────────────────────────────────────┘
8
+ *
9
+ * The text box commits what was typed — trimmed, split at commas — on Enter,
10
+ * on a comma and when it loses focus. Empty pieces are dropped, and so is a
11
+ * piece already in the list unless `allowDuplicates`. Backspace in an empty
12
+ * box removes the last chip. A paste that carries a comma is split into
13
+ * chips at once; a paste without one is ordinary text in the box.
14
+ *
15
+ * Every chip has a remove button with an `aria-label` (`removeLabel(tag)`,
16
+ * "Remove <tag>" by default). `readOnly` draws the chips alone: no remove
17
+ * buttons and no text box.
18
+ *
19
+ * `onChange(list)` is called with a fresh copy of the list after every change
20
+ * the reader makes — never for `setValue`, and never for a commit that added
21
+ * nothing.
22
+ *
23
+ * const tags = createTagInput(host, {
24
+ * value: ['baseline'],
25
+ * placeholder: 'Add a tag…',
26
+ * onChange: (list) => save(list),
27
+ * });
28
+ * tags.getValue(); // ['baseline']
29
+ * tags.setValue(['a', 'b']);
30
+ * tags.dispose();
31
+ */
32
+
33
+ /** The list as strings, the empty ones left out. */
34
+ const normalise = (list) => (Array.isArray(list) ? list : [])
35
+ .map((v) => (v == null ? '' : String(v).trim()))
36
+ .filter((v) => v !== '');
37
+
38
+ /**
39
+ * @typedef {Object} TagInputOptions
40
+ * @property {string[]} [value] - The tags it opens with.
41
+ * @property {string} [placeholder] - The empty text box's placeholder.
42
+ * @property {boolean} [readOnly] - Chips only: no remove buttons, no text box.
43
+ * @property {boolean} [allowDuplicates] - Keep a tag that is already in the list.
44
+ * @property {(list: string[]) => void} [onChange] - After each change the reader makes.
45
+ * @property {string} [label] - The text box's accessible name.
46
+ * @property {(tag: string) => string} [removeLabel] - A remove button's `aria-label`.
47
+ * @property {string} [className] - Extra classes on the root.
48
+ */
49
+
50
+ /**
51
+ * @param {HTMLElement} host
52
+ * @param {TagInputOptions} [options]
53
+ */
54
+ export function createTagInput(host, options = {}) {
55
+ if (!host || typeof host.appendChild !== 'function') {
56
+ throw new Error('[TagInput] host must be an HTMLElement');
57
+ }
58
+ const readOnly = !!options.readOnly;
59
+ const allowDuplicates = !!options.allowDuplicates;
60
+ const removeLabel = typeof options.removeLabel === 'function'
61
+ ? options.removeLabel : (tag) => `Remove ${tag}`;
62
+
63
+ let tags = normalise(options.value);
64
+ let disposed = false;
65
+
66
+ const root = document.createElement('div');
67
+ root.className = ['twm-tag-input', readOnly ? 'twm-tag-input--readonly' : '', options.className || '']
68
+ .join(' ').replace(/\s+/g, ' ').trim();
69
+ root.setAttribute('role', 'group');
70
+ if (options.label) root.setAttribute('aria-label', options.label);
71
+
72
+ const chips = document.createElement('ul');
73
+ chips.className = 'twm-tag-input__chips';
74
+ root.appendChild(chips);
75
+
76
+ let input = null;
77
+ if (!readOnly) {
78
+ input = document.createElement('input');
79
+ input.type = 'text';
80
+ input.className = 'twm-tag-input__input';
81
+ input.spellcheck = false;
82
+ input.autocomplete = 'off';
83
+ if (options.placeholder) input.placeholder = options.placeholder;
84
+ if (options.label) input.setAttribute('aria-label', options.label);
85
+ root.appendChild(input);
86
+ }
87
+
88
+ const changed = () => {
89
+ if (typeof options.onChange === 'function') options.onChange([...tags]);
90
+ };
91
+
92
+ const chipFor = (tag, idx) => {
93
+ const chip = document.createElement('li');
94
+ chip.className = 'twm-tag-input__chip';
95
+ const text = document.createElement('span');
96
+ text.className = 'twm-tag-input__text';
97
+ text.textContent = tag;
98
+ text.title = tag;
99
+ chip.appendChild(text);
100
+ if (!readOnly) {
101
+ const remove = document.createElement('button');
102
+ remove.type = 'button';
103
+ remove.className = 'twm-tag-input__remove';
104
+ remove.setAttribute('aria-label', removeLabel(tag));
105
+ remove.textContent = '×';
106
+ // The text box keeps the focus: a blur would commit its text
107
+ // and redraw the chips under the pointer before the click.
108
+ remove.addEventListener('mousedown', (ev) => ev.preventDefault());
109
+ remove.addEventListener('click', (ev) => {
110
+ ev.preventDefault();
111
+ ev.stopPropagation();
112
+ removeAt(idx);
113
+ input?.focus();
114
+ });
115
+ chip.appendChild(remove);
116
+ }
117
+ return chip;
118
+ };
119
+
120
+ /** Redraws every chip. */
121
+ const paint = () => {
122
+ chips.replaceChildren(...tags.map(chipFor));
123
+ };
124
+
125
+ function removeAt(idx) {
126
+ if (disposed || readOnly || idx < 0 || idx >= tags.length) return;
127
+ tags = tags.filter((_, i) => i !== idx);
128
+ paint();
129
+ changed();
130
+ }
131
+
132
+ /** Adds the comma-separated pieces of `text`; true when any was added. */
133
+ const commitText = (text) => {
134
+ if (disposed || readOnly) return false;
135
+ const next = [...tags];
136
+ for (const piece of String(text).split(',')) {
137
+ const tag = piece.trim();
138
+ if (tag === '') continue;
139
+ if (!allowDuplicates && next.includes(tag)) continue;
140
+ next.push(tag);
141
+ }
142
+ if (next.length === tags.length) return false;
143
+ // Only the new chips are drawn: the ones already there stay in place,
144
+ // so a remove button that the focus is moving to (Tab out of the box
145
+ // commits its text) is still in the document when the focus lands.
146
+ const added = next.slice(tags.length).map((tag, i) => chipFor(tag, tags.length + i));
147
+ tags = next;
148
+ chips.append(...added);
149
+ changed();
150
+ return true;
151
+ };
152
+
153
+ const commitInput = () => {
154
+ if (!input) return;
155
+ const text = input.value;
156
+ input.value = '';
157
+ commitText(text);
158
+ };
159
+
160
+ if (input) {
161
+ input.addEventListener('keydown', (ev) => {
162
+ if (ev.isComposing) return;
163
+ if (ev.key === 'Enter' || ev.key === ',') {
164
+ ev.preventDefault();
165
+ commitInput();
166
+ } else if (ev.key === 'Backspace' && input.value === '' && tags.length) {
167
+ ev.preventDefault();
168
+ removeAt(tags.length - 1);
169
+ }
170
+ });
171
+ // A comma that arrives without its keydown (a soft keyboard, an IME,
172
+ // a drop) commits all the same.
173
+ input.addEventListener('input', () => {
174
+ if (input.value.includes(',')) commitInput();
175
+ });
176
+ input.addEventListener('blur', commitInput);
177
+ input.addEventListener('paste', (ev) => {
178
+ const pasted = ev.clipboardData?.getData('text/plain') ?? '';
179
+ if (!pasted.includes(',')) return;
180
+ ev.preventDefault();
181
+ const start = input.selectionStart ?? input.value.length;
182
+ const end = input.selectionEnd ?? start;
183
+ const text = input.value.slice(0, start) + pasted + input.value.slice(end);
184
+ input.value = '';
185
+ commitText(text);
186
+ });
187
+ root.addEventListener('mousedown', (ev) => {
188
+ if (ev.target === root || ev.target === chips) {
189
+ ev.preventDefault();
190
+ input.focus();
191
+ }
192
+ });
193
+ }
194
+
195
+ paint();
196
+ host.appendChild(root);
197
+
198
+ return {
199
+ element: root,
200
+ input,
201
+ getValue: () => [...tags],
202
+ /** Replaces the list. Does not call `onChange`. */
203
+ setValue(list) {
204
+ if (disposed) return;
205
+ tags = normalise(list);
206
+ paint();
207
+ },
208
+ focus() { input?.focus(); },
209
+ dispose() {
210
+ if (disposed) return;
211
+ disposed = true;
212
+ root.remove();
213
+ },
214
+ };
215
+ }
@@ -1,25 +1,42 @@
1
1
  /**
2
- * toast.js — thin wrapper around the global NotificationCenter for
2
+ * toast.js — thin wrapper around the mounted NotificationCenter for
3
3
  * callers that don't already hold an eventBus reference.
4
4
  *
5
5
  * The NotificationCenter (../notification_center.js) subscribes to
6
- * `toast:show` on the global event bus; we re-export tiny helpers
7
- * that emit the same payload shape.
6
+ * `toast:show` on its event bus; these helpers emit the same payload shape
7
+ * on the bus they were given. They read no global: `NotificationCenter.mount()`
8
+ * hands its bus to `setToastBus()`, and `dispose()` takes it back. An embedder
9
+ * that wants toasts on some other bus calls `setToastBus(bus)` itself, and
10
+ * `setToastBus(null)` to stop.
8
11
  *
9
12
  * Usage:
10
13
  * import { toastError, toastSuccess, toastWarn, toastInfo } from './toast.js';
11
- * toastError('Save failed', 'sector_update refused: …');
14
+ * toastError('Save failed', 'the server refused the change');
12
15
  *
13
16
  * The 1-arg form treats the string as the message (no title); the
14
- * 2-arg form is title + message.
17
+ * 2-arg form is title + message. With no bus (no NotificationCenter mounted
18
+ * yet, isolated test pages) the message goes to the console and nothing throws.
15
19
  */
16
20
 
17
- function _bus() {
18
- return window.__ecoagent?.eventBus ?? null;
21
+ let toastBus = null;
22
+
23
+ /**
24
+ * Route the toast helpers to `bus` (anything with `emit(name, payload)`), or
25
+ * to the console with `null`. Returns the bus that was set before.
26
+ */
27
+ export function setToastBus(bus) {
28
+ const prev = toastBus;
29
+ toastBus = bus && typeof bus.emit === 'function' ? bus : null;
30
+ return prev;
31
+ }
32
+
33
+ /** The bus the toast helpers currently emit on, or null. */
34
+ export function getToastBus() {
35
+ return toastBus;
19
36
  }
20
37
 
21
38
  function _emit(severity, persistent, titleOrMsg, msg) {
22
- const bus = _bus();
39
+ const bus = toastBus;
23
40
  const title = (msg !== undefined) ? titleOrMsg : null;
24
41
  const message = (msg !== undefined) ? msg : titleOrMsg;
25
42
  if (!bus) {
@@ -0,0 +1,175 @@
1
+ /**
2
+ * ToggleField — an on/off switch: a track and a knob drawn over a REAL
3
+ * checkbox, so the form, the label and assistive technology all see a native
4
+ * control.
5
+ *
6
+ * const t = new ToggleField({ label: 'Autosave', onText: 'On', offText: 'Off',
7
+ * checked: true, onChange: (on) => save(on) });
8
+ * host.appendChild(t.render());
9
+ * t.get(); // true
10
+ * t.set(false); // no onChange: the caller already knows
11
+ * t.setDisabled(true);
12
+ * t.dispose();
13
+ *
14
+ * The input carries `role="switch"` and an `aria-checked` that follows it.
15
+ * Space toggles it (as it does any checkbox) and so does Enter, which a
16
+ * checkbox ignores but a switch is expected to honour. Both go through the
17
+ * input's own `click()`, so a disabled switch ignores them exactly as it
18
+ * ignores the mouse, and a toggle fires the input's `input` and `change` like
19
+ * any other control. The root is a `<label>`, so a click on the track, the
20
+ * on/off text or the label toggles it too.
21
+ *
22
+ * Markup — every class is styled by `flexdesk.css` from tokens alone:
23
+ *
24
+ * label.twm-toggle[.twm-toggle--compact][.twm-toggle--disabled]
25
+ * span.twm-toggle__control
26
+ * input.twm-toggle__input[type=checkbox][role=switch]
27
+ * span.twm-toggle__track > span.twm-toggle__knob
28
+ * span.twm-toggle__state (onText / offText, when either is given)
29
+ * span.twm-toggle__label (label, when given)
30
+ */
31
+ export class ToggleField {
32
+ /**
33
+ * @param {object} o
34
+ * @param {boolean} [o.checked=false]
35
+ * @param {string?} [o.label] text beside the switch, and its accessible name
36
+ * @param {string?} [o.ariaLabel] accessible name when there is no visible label
37
+ * @param {string?} [o.onText] state text shown while on
38
+ * @param {string?} [o.offText] state text shown while off
39
+ * @param {boolean} [o.disabled=false]
40
+ * @param {boolean} [o.compact=false] a smaller track, for dense rows
41
+ * @param {string?} [o.name] the input's name, for a surrounding form
42
+ * @param {string?} [o.id] the input's id
43
+ * @param {Function?}[o.onChange] `(checked: boolean)` after a user toggle
44
+ */
45
+ constructor({
46
+ checked = false, label = null, ariaLabel = null, onText = null, offText = null,
47
+ disabled = false, compact = false, name = null, id = null, onChange = null,
48
+ } = {}) {
49
+ this._initial = !!checked;
50
+ this._label = label == null ? null : String(label);
51
+ this._ariaLabel = ariaLabel == null ? null : String(ariaLabel);
52
+ this._onText = onText == null ? null : String(onText);
53
+ this._offText = offText == null ? null : String(offText);
54
+ this._disabled = !!disabled;
55
+ this._compact = !!compact;
56
+ this._name = name == null ? null : String(name);
57
+ this._id = id == null ? null : String(id);
58
+ this._onChange = typeof onChange === 'function' ? onChange : null;
59
+
60
+ this.root = null;
61
+ this.input = null;
62
+ this._stateEl = null;
63
+ this._disposers = [];
64
+ }
65
+
66
+ /** Build the switch once and hand back its root; later calls return it. */
67
+ render() {
68
+ if (this.root) return this.root;
69
+
70
+ const root = document.createElement('label');
71
+ root.className = 'twm-toggle';
72
+ if (this._compact) root.classList.add('twm-toggle--compact');
73
+
74
+ const control = document.createElement('span');
75
+ control.className = 'twm-toggle__control';
76
+
77
+ const input = document.createElement('input');
78
+ input.type = 'checkbox';
79
+ input.className = 'twm-toggle__input';
80
+ input.setAttribute('role', 'switch');
81
+ if (this._name) input.name = this._name;
82
+ if (this._id) input.id = this._id;
83
+ if (this._ariaLabel && !this._label) input.setAttribute('aria-label', this._ariaLabel);
84
+ input.checked = this._initial;
85
+
86
+ const track = document.createElement('span');
87
+ track.className = 'twm-toggle__track';
88
+ track.setAttribute('aria-hidden', 'true');
89
+ const knob = document.createElement('span');
90
+ knob.className = 'twm-toggle__knob';
91
+ track.appendChild(knob);
92
+
93
+ control.append(input, track);
94
+ root.appendChild(control);
95
+
96
+ if (this._onText != null || this._offText != null) {
97
+ this._stateEl = document.createElement('span');
98
+ this._stateEl.className = 'twm-toggle__state';
99
+ // The switch already says on/off to a screen reader.
100
+ this._stateEl.setAttribute('aria-hidden', 'true');
101
+ root.appendChild(this._stateEl);
102
+ }
103
+ if (this._label != null) {
104
+ const labelEl = document.createElement('span');
105
+ labelEl.className = 'twm-toggle__label';
106
+ labelEl.textContent = this._label;
107
+ root.appendChild(labelEl);
108
+ }
109
+
110
+ this.root = root;
111
+ this.input = input;
112
+ this.setDisabled(this._disabled);
113
+ this._sync();
114
+
115
+ const onKeyDown = (e) => {
116
+ if (e.key !== ' ' && e.key !== 'Enter') return;
117
+ // Ours, not the browser's: Space would otherwise activate on keyup
118
+ // as well, and Enter would submit the surrounding form.
119
+ e.preventDefault();
120
+ if (e.repeat) return;
121
+ input.click();
122
+ };
123
+ const onKeyUp = (e) => { if (e.key === ' ') e.preventDefault(); };
124
+ const onChange = () => {
125
+ this._sync();
126
+ this._onChange?.(input.checked);
127
+ };
128
+ input.addEventListener('keydown', onKeyDown);
129
+ input.addEventListener('keyup', onKeyUp);
130
+ input.addEventListener('change', onChange);
131
+ this._disposers.push(
132
+ () => input.removeEventListener('keydown', onKeyDown),
133
+ () => input.removeEventListener('keyup', onKeyUp),
134
+ () => input.removeEventListener('change', onChange),
135
+ );
136
+ return root;
137
+ }
138
+
139
+ /** Whether the switch is on. */
140
+ get() {
141
+ return this.input ? this.input.checked : this._initial;
142
+ }
143
+
144
+ /** Turn it on or off. Does NOT call `onChange`. */
145
+ set(checked) {
146
+ if (!this.input) { this._initial = !!checked; return; }
147
+ this.input.checked = !!checked;
148
+ this._sync();
149
+ }
150
+
151
+ /** A disabled switch ignores clicks and keys alike. */
152
+ setDisabled(disabled) {
153
+ this._disabled = !!disabled;
154
+ if (!this.input) return;
155
+ this.input.disabled = this._disabled;
156
+ this.root.classList.toggle('twm-toggle--disabled', this._disabled);
157
+ }
158
+
159
+ dispose() {
160
+ for (const d of this._disposers) {
161
+ try { d(); } catch { /* the element is already gone */ }
162
+ }
163
+ this._disposers = [];
164
+ this.root?.remove();
165
+ this.root = null;
166
+ this.input = null;
167
+ this._stateEl = null;
168
+ }
169
+
170
+ _sync() {
171
+ const on = this.input.checked;
172
+ this.input.setAttribute('aria-checked', on ? 'true' : 'false');
173
+ if (this._stateEl) this._stateEl.textContent = (on ? this._onText : this._offText) ?? '';
174
+ }
175
+ }
@@ -11,6 +11,28 @@
11
11
  * - TreeItem: Leaf item (e.g., function, series)
12
12
  */
13
13
 
14
+ /**
15
+ * Labels, subtitles and badge text are TEXT, set with `textContent` after the
16
+ * skeleton is built (BUG-0013): a dataset called 'A <b>B</b>' is shown as
17
+ * those characters, and a name carrying `<img onerror>` runs nothing.
18
+ */
19
+ function setText(root, selector, value) {
20
+ const el = root.querySelector(selector);
21
+ if (el) el.textContent = value == null ? '' : String(value);
22
+ }
23
+
24
+ /**
25
+ * An id as the value of a double-quoted attribute selector (BUG-0013).
26
+ * NOT `CSS.escape`: jsdom does not provide it (see modal.js). Inside a quoted
27
+ * attribute value `"` and `\` are escaped, and a line break, which would end
28
+ * the string, becomes its hex escape.
29
+ */
30
+ function attrValue(value) {
31
+ return String(value)
32
+ .replace(/["\\]/g, '\\$&')
33
+ .replace(/[\n\r\f]/g, (c) => `\\${c.charCodeAt(0).toString(16)} `);
34
+ }
35
+
14
36
  /**
15
37
  * Create a tree category (top-level collapsible section)
16
38
  * @param {Object} options - Category options
@@ -49,11 +71,12 @@ export function createTreeCategory(options) {
49
71
  <span class="twm-tree-category__icon ${iconClass}">
50
72
  <span class="material-symbols-outlined">${icon}</span>
51
73
  </span>
52
- <span class="twm-tree-category__label">${label}</span>
74
+ <span class="twm-tree-category__label"></span>
53
75
  <span class="twm-tree-category__count">(${count})</span>
54
76
  </div>
55
77
  <div class="twm-collapsible-content${expanded ? ' visible' : ''}" data-collapsible-content="true"></div>
56
78
  `;
79
+ setText(node, '.twm-tree-category__label', label);
57
80
 
58
81
  return node;
59
82
  }
@@ -97,12 +120,13 @@ export function createTreeNode(options) {
97
120
  <span class="material-symbols-outlined">${expanded ? 'expand_more' : 'chevron_right'}</span>
98
121
  </span>
99
122
  ${iconHtml}
100
- <span class="twm-tree-node__label">${label}</span>
123
+ <span class="twm-tree-node__label"></span>
101
124
  <span class="twm-tree-node__count">(${count})</span>
102
125
  ${actionsHtml}
103
126
  </div>
104
127
  <div class="twm-tree-node__children" style="display: ${expanded ? 'block' : 'none'};"></div>
105
128
  `;
129
+ setText(node, '.twm-tree-node__label', label);
106
130
 
107
131
  // Add action buttons if specified
108
132
  if (actions) {
@@ -173,25 +197,28 @@ export function createTreeItem(options) {
173
197
  : '';
174
198
 
175
199
  const subtitleHtml = subtitle
176
- ? `<span class="twm-tree-item__subtitle">${subtitle}</span>`
200
+ ? '<span class="twm-tree-item__subtitle"></span>'
177
201
  : '';
178
202
 
179
203
  let badgeHtml = '';
180
204
  if (badge) {
181
205
  const variant = badge.variant || 'muted';
182
206
  if (badge.text) {
183
- badgeHtml = `<span class="twm-tree-item__badge tree-item__badge--${variant}">${badge.text}</span>`;
207
+ badgeHtml = `<span class="twm-tree-item__badge twm-tree-item__badge--${variant}"></span>`;
184
208
  } else {
185
- badgeHtml = `<span class="twm-tree-item__status-dot tree-item__status-dot--${variant}"></span>`;
209
+ badgeHtml = `<span class="twm-tree-item__status-dot twm-tree-item__status-dot--${variant}"></span>`;
186
210
  }
187
211
  }
188
212
 
189
213
  item.innerHTML = `
190
214
  ${iconHtml}
191
- <span class="twm-tree-item__label">${label}</span>
215
+ <span class="twm-tree-item__label"></span>
192
216
  ${subtitleHtml}
193
217
  ${badgeHtml}
194
218
  `;
219
+ setText(item, '.twm-tree-item__label', label);
220
+ if (subtitle) setText(item, '.twm-tree-item__subtitle', subtitle);
221
+ if (badge?.text) setText(item, '.twm-tree-item__badge', badge.text);
195
222
 
196
223
  return item;
197
224
  }
@@ -257,6 +284,12 @@ export function toggleNode(nodeEl, force) {
257
284
 
258
285
  /**
259
286
  * Filter tree items by text match
287
+ *
288
+ * An item is shown when its label contains the filter. A node or a category is
289
+ * shown when any tree item inside it is shown, whether the item sits directly
290
+ * in it or deeper inside nodes. An empty filter shows everything, so clearing
291
+ * the filter restores the whole tree, empty nodes and categories included.
292
+ *
260
293
  * @param {HTMLElement} container - Tree container
261
294
  * @param {string} filter - Filter text (case-insensitive)
262
295
  * @param {Object} [options] - Filter options
@@ -266,40 +299,28 @@ export function filterTree(container, filter, options = {}) {
266
299
  const { expandMatches = true } = options;
267
300
  const normalizedFilter = (filter || '').toLowerCase().trim();
268
301
 
269
- const items = container.querySelectorAll('.twm-tree-item');
270
- const nodes = container.querySelectorAll('.twm-tree-node');
271
- const categories = container.querySelectorAll('.twm-tree-category');
272
-
273
- // Reset visibility
274
- items.forEach((item) => {
302
+ const visibleItems = new Set();
303
+ container.querySelectorAll('.twm-tree-item').forEach((item) => {
275
304
  const label = item.querySelector('.twm-tree-item__label')?.textContent || '';
276
305
  const matches = !normalizedFilter || label.toLowerCase().includes(normalizedFilter);
277
306
  item.style.display = matches ? '' : 'none';
278
307
  item.classList.toggle('twm-filter-match', matches && !!normalizedFilter);
308
+ if (matches) visibleItems.add(item);
279
309
  });
280
310
 
281
- // Show/hide nodes based on visible children
282
- nodes.forEach((node) => {
283
- const visibleChildren = node.querySelectorAll('.twm-tree-item:not([style*="display: none"])');
284
- const hasVisibleChildren = visibleChildren.length > 0;
285
- node.style.display = hasVisibleChildren ? '' : 'none';
311
+ const hasVisibleItem = (el) => !normalizedFilter
312
+ || [...el.querySelectorAll('.twm-tree-item')].some((item) => visibleItems.has(item));
286
313
 
287
- // Auto-expand nodes with matches
288
- if (expandMatches && hasVisibleChildren && normalizedFilter) {
289
- toggleNode(node, true);
290
- }
314
+ container.querySelectorAll('.twm-tree-node').forEach((node) => {
315
+ const visible = hasVisibleItem(node);
316
+ node.style.display = visible ? '' : 'none';
317
+ if (expandMatches && visible && normalizedFilter) toggleNode(node, true);
291
318
  });
292
319
 
293
- // Show/hide categories based on visible nodes
294
- categories.forEach((category) => {
295
- const visibleNodes = category.querySelectorAll('.twm-tree-node:not([style*="display: none"])');
296
- const hasVisibleNodes = visibleNodes.length > 0;
297
- category.style.display = hasVisibleNodes ? '' : 'none';
298
-
299
- // Auto-expand categories with matches
300
- if (expandMatches && hasVisibleNodes && normalizedFilter) {
301
- toggleCategory(category, true);
302
- }
320
+ container.querySelectorAll('.twm-tree-category').forEach((category) => {
321
+ const visible = hasVisibleItem(category);
322
+ category.style.display = visible ? '' : 'none';
323
+ if (expandMatches && visible && normalizedFilter) toggleCategory(category, true);
303
324
  });
304
325
  }
305
326
 
@@ -320,7 +341,7 @@ export function selectItem(container, itemId, options = {}) {
320
341
  });
321
342
 
322
343
  // Find and select item
323
- const item = container.querySelector(`.twm-tree-item[data-item-id="${itemId}"]`);
344
+ const item = container.querySelector(`.twm-tree-item[data-item-id="${attrValue(itemId)}"]`);
324
345
  if (!item) return null;
325
346
 
326
347
  item.classList.add('selected');
@@ -418,7 +439,7 @@ export class TreeView {
418
439
  }
419
440
 
420
441
  addNode(categoryId, options) {
421
- const category = this.container.querySelector(`.twm-tree-category[data-category-id="${categoryId}"]`);
442
+ const category = this.container.querySelector(`.twm-tree-category[data-category-id="${attrValue(categoryId)}"]`);
422
443
  const content = category?.querySelector('.twm-collapsible-content');
423
444
  if (!content) return null;
424
445
 
@@ -428,7 +449,7 @@ export class TreeView {
428
449
  }
429
450
 
430
451
  addItem(nodeId, options) {
431
- const node = this.container.querySelector(`.twm-tree-node[data-node-id="${nodeId}"]`);
452
+ const node = this.container.querySelector(`.twm-tree-node[data-node-id="${attrValue(nodeId)}"]`);
432
453
  const children = node?.querySelector('.twm-tree-node__children');
433
454
  if (!children) return null;
434
455
 
File without changes