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,182 @@
1
+ /**
2
+ * table_structure.js — STORY-0066. DataTable's STRUCTURAL EDITS: the
3
+ * consumer's action buttons on the column headers (`columnActions`), renaming
4
+ * a column in place (`onColumnRename`, through `attachInlineRenamer`), and the
5
+ * menu items that add and delete columns and rows (`onColumnDelete`,
6
+ * `onRowAdd`, `onRowDelete`). INTERNAL to the table: imported by
7
+ * `data_table.js` only, and not part of the widgets barrel.
8
+ *
9
+ * THE CONSUMER OWNS THE DATA. Nothing here changes `headers` or `rows`: each
10
+ * edit is a call, and the consumer answers it with `setData`. A rename shows
11
+ * the new name in the header until then.
12
+ *
13
+ * Where the items go: the header's right-click menu (the STORY-0003
14
+ * `headerMenu` path) gets "Rename column", "Delete column" and "Add row" after
15
+ * the consumer's `headerMenu` items, and the body's right-click menu (the
16
+ * STORY-0010 `contextMenuItems` path) "Insert row below" and "Delete row"
17
+ * after the consumer's `contextMenuItems`. "Add row" is in the header's menu
18
+ * so a table with no rows can still get its first.
19
+ *
20
+ * A table without any of these options never builds this.
21
+ */
22
+
23
+ import { callOption, headerActionButton } from './header_actions.js';
24
+ import { attachInlineRenamer } from './inline_renamer.js';
25
+
26
+ /** The options that build this module. */
27
+ export const STRUCTURE_OPTIONS = ['columnActions', 'onColumnRename', 'onColumnDelete', 'onRowAdd', 'onRowDelete'];
28
+
29
+ export class TableStructure {
30
+ /** @param {import('./data_table.js').DataTable} table */
31
+ constructor(table) {
32
+ this.table = table;
33
+ this._renamer = null;
34
+ }
35
+
36
+ _cfg() {
37
+ return this.table.config;
38
+ }
39
+
40
+ _has(name) {
41
+ return typeof this._cfg()[name] === 'function';
42
+ }
43
+
44
+ _call(name, ...args) {
45
+ return callOption(this._cfg(), name, ...args);
46
+ }
47
+
48
+ /** Whether the header's right-click has items of this module's. */
49
+ hasHeaderItems() {
50
+ return this._has('onColumnRename') || this._has('onColumnDelete') || this._has('onRowAdd');
51
+ }
52
+
53
+ /** Whether a row's right-click has items of this module's. */
54
+ hasRowItems() {
55
+ return this._has('onRowAdd') || this._has('onRowDelete');
56
+ }
57
+
58
+ /** The header menu's items for the data column `colIdx` (null off one). */
59
+ headerItems(colIdx) {
60
+ const items = [];
61
+ if (colIdx !== null && this._has('onColumnRename')) {
62
+ items.push({ label: 'Rename column', icon: 'edit', onChoose: () => this.rename(colIdx) });
63
+ }
64
+ if (colIdx !== null && this._has('onColumnDelete')) {
65
+ items.push({ label: 'Delete column', icon: 'delete', danger: true,
66
+ onChoose: () => this._call('onColumnDelete', colIdx) });
67
+ }
68
+ if (this._has('onRowAdd')) {
69
+ items.push({ label: 'Add row', icon: 'add', onChoose: () => this._call('onRowAdd', null) });
70
+ }
71
+ return items;
72
+ }
73
+
74
+ /** The body menu's items for the row `rowIdx` (its original index). */
75
+ rowItems(rowIdx) {
76
+ const items = [];
77
+ if (this._has('onRowAdd')) {
78
+ items.push({ label: 'Insert row below', icon: 'add', onChoose: () => this._call('onRowAdd', rowIdx) });
79
+ }
80
+ if (this._has('onRowDelete')) {
81
+ items.push({ label: 'Delete row', icon: 'delete', danger: true,
82
+ onChoose: () => this._call('onRowDelete', rowIdx) });
83
+ }
84
+ return items;
85
+ }
86
+
87
+ /**
88
+ * After the header is built: each data column's label in a span the
89
+ * renamer can take over, and the consumer's action buttons.
90
+ */
91
+ decorate(headerTable) {
92
+ this._renamer?.destroy();
93
+ this._renamer = null;
94
+ const labelRow = headerTable?.querySelector('thead > tr');
95
+ if (!labelRow) return;
96
+ const headers = this._cfg().headers || [];
97
+ const rename = this._has('onColumnRename');
98
+ const actions = this._has('columnActions');
99
+ if (!rename && !actions) return;
100
+ for (let colIdx = 0; colIdx < headers.length; colIdx++) {
101
+ const th = labelRow.children[this.table._domIdxOf(colIdx)];
102
+ if (!th) continue;
103
+ if (rename) this._wrapLabel(th, colIdx);
104
+ if (actions) this._addActions(th, colIdx, headers[colIdx]);
105
+ }
106
+ }
107
+
108
+ _wrapLabel(th, colIdx) {
109
+ const text = [...th.childNodes].find((n) => n.nodeType === 3);
110
+ if (!text) return;
111
+ const label = document.createElement('span');
112
+ label.className = 'twm-dt-col-label';
113
+ th.insertBefore(label, text);
114
+ label.appendChild(text);
115
+ // The renamer's keys stay its own: no Escape or Enter it handles
116
+ // reaches the table or the page. (On the label, not added per rename:
117
+ // the renamer's Escape removes its input before the key bubbles here.)
118
+ label.addEventListener('keydown', (ev) => ev.stopPropagation());
119
+ // A double-click on the label renames — on a table whose header click
120
+ // does not sort (there, the first click redraws the header).
121
+ if (!this._cfg().sortable) {
122
+ label.addEventListener('dblclick', (ev) => {
123
+ ev.preventDefault();
124
+ ev.stopPropagation();
125
+ this.rename(colIdx);
126
+ });
127
+ }
128
+ }
129
+
130
+ _addActions(th, colIdx, header) {
131
+ const items = this._call('columnActions', colIdx, { colIdx, header, table: this.table });
132
+ const list = Array.isArray(items) ? items.filter(Boolean) : [];
133
+ if (!list.length) return;
134
+ const box = document.createElement('span');
135
+ box.className = 'twm-dt-col-actions';
136
+ for (const item of list) {
137
+ box.appendChild(headerActionButton('twm-dt-col-action', item,
138
+ () => this._call('onColumnAction', item.action, colIdx)));
139
+ }
140
+ th.appendChild(box);
141
+ }
142
+
143
+ /**
144
+ * Open the renamer on the data column's header. Enter (or the focus
145
+ * leaving) with a different, non-empty name calls
146
+ * `onColumnRename(colIdx, name)`; Escape, or the name left as it was,
147
+ * restores the header and calls nothing. Returns whether it opened.
148
+ */
149
+ rename(colIdx) {
150
+ if (!this._has('onColumnRename')) return false;
151
+ const labelRow = this.table._headerTableEl?.querySelector('thead > tr');
152
+ const dom = this.table._domIdxOf(colIdx);
153
+ const label = dom < 0 ? null : labelRow?.children[dom]?.querySelector('.twm-dt-col-label');
154
+ if (!label) return false;
155
+ this._renamer?.cancel();
156
+ const before = label.textContent || '';
157
+ const done = () => {
158
+ if (this._renamer === renamer) this._renamer = null;
159
+ };
160
+ const renamer = attachInlineRenamer({
161
+ label,
162
+ inputClass: 'twm-dt-col-rename',
163
+ onCommit: async (raw) => {
164
+ const name = String(raw ?? '').trim();
165
+ if (!name || name === before) return { success: false };
166
+ const answer = await this._call('onColumnRename', colIdx, name);
167
+ if (answer === false || (answer && answer.ok === false)) return { success: false };
168
+ return { newValue: name };
169
+ },
170
+ onCancel: done,
171
+ onFinish: done,
172
+ });
173
+ this._renamer = renamer;
174
+ renamer.start();
175
+ return true;
176
+ }
177
+
178
+ dispose() {
179
+ this._renamer?.destroy();
180
+ this._renamer = null;
181
+ }
182
+ }
File without changes
@@ -0,0 +1,497 @@
1
+ /**
2
+ * ══ ONE TAB STRIP, WITH AN OPTIONAL PANE HOST ════════════════════════════
3
+ *
4
+ * Three strips grew apart: the window manager's (`../../tiling/tab_strip.js`,
5
+ * internal, over `NotebookTabBar`), the editor's (`NotebookTabBar`, keyed by
6
+ * FILE PATH) and the bottom panel's (`createBottomPanelTabs`, tied to its bus
7
+ * event). This one is keyed by whatever id the caller gives, knows no event
8
+ * name and no file, and the bottom panel is built on it (FlexDesk STORY-0048).
9
+ *
10
+ * const tabs = createTabs(el, {
11
+ * items: [{ id: 'a', label: 'Inputs', icon: 'tune' }, { id: 'b', label: 'Results', badge: 3 }],
12
+ * active: 'a',
13
+ * onSelect: (id, item) => …,
14
+ * mountPane: (item, el, tabs) => ({ onShow() {…}, destroy() {…} }), // omit: strip only
15
+ * });
16
+ * tabs.update(nextItems, 'b');
17
+ * tabs.setBadge('b', 7, 'warning');
18
+ *
19
+ * AN ITEM is `{ id, label, icon?, title?, badge?, indicator?, closable?, dirty?, eager? }`:
20
+ * badge a count (`n`, or `{ count, level }`); none at zero, `99+` above 99
21
+ * indicator a dot: `true`, or a level (`info` | `warning` | `alert`)
22
+ * closable draws a close mark (and `{block}__tab--closable`) and lets Delete
23
+ * close — both call `onClose(id)`; without an `onClose` there is
24
+ * nothing to close and none is drawn
25
+ * dirty the unsaved dot
26
+ * eager mount this item's pane at once instead of on first selection —
27
+ * at creation, or when an `update` brings the item in
28
+ *
29
+ * PANES are optional. Without `mountPane` this is a strip and nothing else.
30
+ * With it, an item's pane is mounted the FIRST time it is selected — the tab in
31
+ * front at creation counts — and never again; `eager` items mount at once, in
32
+ * order, before it, and an eager item that an `update` adds mounts in that
33
+ * `update`, before the tab in front is settled. `mountPane(item, el, tabs)`
34
+ * answers an instance or nothing; the strip calls its `onShow()` each time its
35
+ * tab comes to the front and its `destroy()` when the item goes (an `update`
36
+ * without it, or `destroy`). Panes mount INSIDE `createTabs` before it has
37
+ * answered, so a pane reaches the strip through its third argument, not the
38
+ * variable being assigned.
39
+ *
40
+ * `onSelect(id, item)` hears every change of the tab in front AFTER creation —
41
+ * a click, a key, `select`, the bus, an `update` that had to move it — but not
42
+ * the tab chosen at creation, which the caller already knows. An `update` that
43
+ * leaves no items empties the front, and that is a change too: it is heard as
44
+ * `onSelect(null, null)`. A click on the
45
+ * tab ALREADY in front changes nothing, so it is not a select; a caller that
46
+ * acts on it anyway (the bottom panel re-shows its pane) passes
47
+ * `onReselect(id, item)`, which hears exactly that click.
48
+ *
49
+ * THE BUS IS OPT-IN. Pass `eventBus` AND `selectEvent` (a name) and that event
50
+ * selects a tab: the id is `selectFrom(payload)`, by default `payload.id`. No
51
+ * event name, no subscription — whatever bus is passed.
52
+ *
53
+ * KEYS are the ARIA tabs pattern with automatic activation: ArrowLeft/Right move
54
+ * the selection (wrapping), Home and End go to the ends, Delete closes a
55
+ * closable tab. One tab is in the tab order — the one in front. When an
56
+ * `update` removes the tab that has focus (Delete, then the caller drops the
57
+ * item), focus moves to the tab now in front, so the keyboard keeps its place;
58
+ * focus that the update put somewhere else (a pane focusing itself) stays.
59
+ *
60
+ * OVERFLOW scrolls, never wraps: a vertical wheel moves the strip sideways and a
61
+ * tab that comes to the front is scrolled into it (`../utils/strip_scroll.js`).
62
+ *
63
+ * CLASSES are BEM under `block` (default `twm-tabs`): `{block}` root,
64
+ * `{block}--compact`, `{block}__tabs`, `{block}__tab`, `{block}__tab--on`,
65
+ * `{block}__tab--dirty`, `{block}__tab--closable`, `{block}__icon`, `{block}__label`, `{block}__badge`
66
+ * (`--{level}`), `{block}__indicator` (`--{level}`), `{block}__dirty`,
67
+ * `{block}__close`, `{block}__body`, `{block}-pane`. The bottom panel passes
68
+ * `twm-bp` and gets its existing markup. Tabs and panes carry the item id in
69
+ * `data-{dataKey}` (default `tab-id`), as a string; the strip never reads it
70
+ * back, so every callback and `ids()` hand out the id as given (`1` stays `1`).
71
+ *
72
+ * The strip is never rebuilt: a select toggles classes on the same buttons and
73
+ * an `update` keeps the button of every id it keeps, so a click that lands
74
+ * while a tab is being selected keeps its target.
75
+ */
76
+
77
+ import { DragReorder } from './drag_reorder.js';
78
+ import { revealInStrip, wheelStripSideways } from '../utils/strip_scroll.js';
79
+
80
+ /** Badge and indicator levels, worst last. Any other value draws the neutral one. */
81
+ export const TAB_LEVELS = Object.freeze(['info', 'warning', 'alert']);
82
+
83
+ let stripCount = 0;
84
+
85
+ /**
86
+ * @param {HTMLElement} container
87
+ * @param {object} opts
88
+ * @param {Array} opts.items see AN ITEM above
89
+ * @param {string} [opts.active] the tab in front at creation; the first by default
90
+ * @param {Function} [opts.onSelect] `(id, item)`; `(null, null)` when an `update` empties the strip
91
+ * @param {Function} [opts.onReselect] `(id, item)` on a click on the tab already in front
92
+ * @param {Function} [opts.onClose] `(id, item)` — the caller removes it with `update`
93
+ * @param {Function} [opts.onReorder] `(ids)` after a drag; omit and tabs do not drag
94
+ * @param {Function} [opts.onContextMenu] `(id, event, item)` on right-click
95
+ * @param {Function} [opts.mountPane] `(item, el, tabs)`; omit for a strip only
96
+ * @param {object} [opts.eventBus]
97
+ * @param {string} [opts.selectEvent] the bus event that selects a tab
98
+ * @param {Function} [opts.selectFrom] `(payload) => id`; `payload.id` by default
99
+ * @param {boolean} [opts.compact] the dense variant
100
+ * @param {string} [opts.label] the tablist's accessible name
101
+ * @param {string} [opts.block] the BEM block
102
+ * @param {string} [opts.dataKey] the `data-*` key carrying an item's id
103
+ */
104
+ export function createTabs(container, {
105
+ items = [], active = null, onSelect = null, onReselect = null, onClose = null, onReorder = null,
106
+ onContextMenu = null, mountPane = null, eventBus = null, selectEvent = null,
107
+ selectFrom = (payload) => payload?.id, compact = false, label = null,
108
+ block = 'twm-tabs', dataKey = 'tab-id',
109
+ } = {}) {
110
+ const doc = container.ownerDocument;
111
+ const uid = `${block}-${++stripCount}`;
112
+ const datasetKey = dataKey.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
113
+ const cls = (part) => `${block}__${part}`;
114
+
115
+ const root = doc.createElement('div');
116
+ root.className = compact ? `${block} ${block}--compact` : block;
117
+ const strip = doc.createElement('div');
118
+ strip.className = cls('tabs');
119
+ strip.setAttribute('role', 'tablist');
120
+ strip.setAttribute('aria-orientation', 'horizontal');
121
+ if (label) strip.setAttribute('aria-label', label);
122
+ root.appendChild(strip);
123
+ let body = null;
124
+ if (mountPane) {
125
+ body = doc.createElement('div');
126
+ body.className = cls('body');
127
+ root.appendChild(body);
128
+ }
129
+ container.appendChild(root);
130
+
131
+ /** id → the caller's item, as last given (setBadge/setIndicator write here). */
132
+ let order = [];
133
+ const byId = new Map();
134
+ /** id → { el } for every tab drawn. */
135
+ const tabs = new Map();
136
+ /** A tab's button → its id, unchanged: `data-*` holds only a string. */
137
+ const idByEl = new WeakMap();
138
+ /** id → { el, instance } for every pane mounted. */
139
+ const panes = new Map();
140
+ let current = null;
141
+ let serial = 0;
142
+ let destroyed = false;
143
+ /** The bus subscription, made last; a pane may destroy the strip first. */
144
+ let subscription = null;
145
+
146
+ // ── a tab ───────────────────────────────────────────────────────────
147
+
148
+ function tabFor(id) {
149
+ let entry = tabs.get(id);
150
+ if (entry) return entry;
151
+ const el = doc.createElement('button');
152
+ el.type = 'button';
153
+ el.className = cls('tab');
154
+ el.dataset[datasetKey] = id;
155
+ el.id = `${uid}-tab-${++serial}`;
156
+ el.setAttribute('role', 'tab');
157
+ el.setAttribute('aria-selected', 'false');
158
+ el.tabIndex = -1;
159
+ idByEl.set(el, id);
160
+ entry = { el };
161
+ tabs.set(id, entry);
162
+ return entry;
163
+ }
164
+
165
+ function badgeOf(item) {
166
+ const b = item.badge;
167
+ if (b && typeof b === 'object') return { count: Number(b.count) || 0, level: b.level ?? null };
168
+ return { count: Number(b) || 0, level: null };
169
+ }
170
+
171
+ function drawBadge(span, { count, level }) {
172
+ span.hidden = count <= 0;
173
+ span.textContent = count > 99 ? '99+' : String(Math.max(count, 0));
174
+ span.className = cls('badge') + (TAB_LEVELS.includes(level) ? ` ${cls('badge')}--${level}` : '');
175
+ }
176
+
177
+ function drawIndicator(span, value) {
178
+ span.hidden = !value;
179
+ span.className = cls('indicator')
180
+ + (TAB_LEVELS.includes(value) ? ` ${cls('indicator')}--${value}` : '');
181
+ }
182
+
183
+ function draw(id) {
184
+ const item = byId.get(id);
185
+ const { el } = tabFor(id);
186
+ const closable = !!(item.closable && onClose);
187
+ el.classList.toggle(`${cls('tab')}--dirty`, !!item.dirty);
188
+ el.classList.toggle(`${cls('tab')}--closable`, closable);
189
+ if (item.title) el.title = item.title; else el.removeAttribute('title');
190
+ const parts = [];
191
+ if (item.icon) {
192
+ const icon = doc.createElement('span');
193
+ icon.className = `material-symbols-outlined ${cls('icon')}`;
194
+ icon.setAttribute('aria-hidden', 'true');
195
+ icon.textContent = item.icon;
196
+ parts.push(icon);
197
+ }
198
+ const text = doc.createElement('span');
199
+ text.className = cls('label');
200
+ text.textContent = item.label ?? String(id);
201
+ parts.push(text);
202
+ const badge = doc.createElement('span');
203
+ drawBadge(badge, badgeOf(item));
204
+ parts.push(badge);
205
+ const dot = doc.createElement('span');
206
+ dot.setAttribute('aria-hidden', 'true');
207
+ drawIndicator(dot, item.indicator);
208
+ parts.push(dot);
209
+ if (item.dirty) {
210
+ const dirty = doc.createElement('span');
211
+ dirty.className = cls('dirty');
212
+ dirty.setAttribute('aria-label', 'unsaved');
213
+ dirty.textContent = '●';
214
+ parts.push(dirty);
215
+ }
216
+ if (closable) {
217
+ // A mark, not a button: a button inside the tab's button is not
218
+ // HTML. The keyboard closes with Delete.
219
+ const close = doc.createElement('span');
220
+ close.className = cls('close');
221
+ close.setAttribute('aria-hidden', 'true');
222
+ close.title = 'Close';
223
+ close.textContent = '×';
224
+ parts.push(close);
225
+ }
226
+ el.replaceChildren(...parts);
227
+ }
228
+
229
+ // ── panes ───────────────────────────────────────────────────────────
230
+
231
+ function mount(id) {
232
+ if (destroyed || !mountPane || panes.has(id)) return panes.get(id) ?? null;
233
+ const el = doc.createElement('div');
234
+ el.className = `${block}-pane`;
235
+ el.dataset[datasetKey] = id;
236
+ el.id = `${tabFor(id).el.id}-pane`;
237
+ el.setAttribute('role', 'tabpanel');
238
+ el.setAttribute('aria-labelledby', tabFor(id).el.id);
239
+ el.hidden = id !== current;
240
+ body.appendChild(el);
241
+ tabFor(id).el.setAttribute('aria-controls', el.id);
242
+ const entry = { el, instance: null };
243
+ panes.set(id, entry);
244
+ // ONE PANE THAT THROWS IS ONE EMPTY TAB, not a dead strip: the strip
245
+ // and every other pane go on working.
246
+ let instance = null;
247
+ try {
248
+ instance = mountPane(byId.get(id), el, api) ?? null;
249
+ } catch (err) {
250
+ console.error(`[tabs] pane "${id}" failed to mount`, err);
251
+ }
252
+ // The pane let go of its own entry while it mounted (it destroyed the
253
+ // strip, or an update dropped its item): it was never anyone's, so it
254
+ // is destroyed now and never shown.
255
+ if (panes.get(id) !== entry) {
256
+ try { instance?.destroy?.(); } catch (err) {
257
+ console.warn(`[tabs] pane "${id}" destroy failed`, err);
258
+ }
259
+ return null;
260
+ }
261
+ entry.instance = instance;
262
+ return entry;
263
+ }
264
+
265
+ /** Every eager item not mounted yet, in strip order. */
266
+ function mountEager() {
267
+ for (const id of order) if (byId.get(id).eager && !panes.has(id)) mount(id);
268
+ }
269
+
270
+ function unmount(id) {
271
+ const entry = panes.get(id);
272
+ if (!entry) return;
273
+ panes.delete(id);
274
+ try { entry.instance?.destroy?.(); } catch (err) {
275
+ console.warn(`[tabs] pane "${id}" destroy failed`, err);
276
+ }
277
+ entry.el.remove();
278
+ }
279
+
280
+ // ── the tab in front ────────────────────────────────────────────────
281
+
282
+ /** Classes, ARIA and pane visibility, for the tab in front. */
283
+ function paint() {
284
+ for (const [key, { el }] of tabs) {
285
+ const on = key === current;
286
+ el.classList.toggle(`${cls('tab')}--on`, on);
287
+ el.setAttribute('aria-selected', String(on));
288
+ el.tabIndex = on ? 0 : -1;
289
+ }
290
+ for (const [key, entry] of panes) entry.el.hidden = key !== current;
291
+ }
292
+
293
+ /** Make `id` the tab in front. `notify` is false only at creation. */
294
+ function show(id, notify) {
295
+ current = id;
296
+ paint();
297
+ if (id != null) {
298
+ mount(id)?.instance?.onShow?.();
299
+ if (destroyed) return; // a pane destroyed the strip as it mounted
300
+ if (tabs.has(id)) revealInStrip(strip, tabs.get(id).el);
301
+ }
302
+ if (notify) onSelect?.(id, id == null ? null : byId.get(id));
303
+ }
304
+
305
+ function select(id, { focus = false } = {}) {
306
+ if (destroyed || !byId.has(id)) return false;
307
+ if (id !== current) show(id, true);
308
+ if (focus && !destroyed) tabs.get(id)?.el.focus();
309
+ return true;
310
+ }
311
+
312
+ // ── items ───────────────────────────────────────────────────────────
313
+
314
+ function setItems(next) {
315
+ const seen = new Set();
316
+ order = [];
317
+ byId.clear();
318
+ for (const item of next) {
319
+ if (item?.id == null || seen.has(item.id)) continue;
320
+ seen.add(item.id);
321
+ order.push(item.id);
322
+ byId.set(item.id, { ...item });
323
+ }
324
+ for (const id of [...tabs.keys()]) {
325
+ if (byId.has(id)) continue;
326
+ tabs.get(id).el.remove();
327
+ tabs.delete(id);
328
+ unmount(id);
329
+ }
330
+ for (const id of order) {
331
+ draw(id);
332
+ strip.appendChild(tabs.get(id).el); // moves a kept one into its place
333
+ }
334
+ dragReorder?.attach();
335
+ }
336
+
337
+ // ── events ──────────────────────────────────────────────────────────
338
+
339
+ const idOf = (target) => {
340
+ const el = target?.closest?.(`.${cls('tab')}`);
341
+ return el && strip.contains(el) && idByEl.has(el) ? idByEl.get(el) : null;
342
+ };
343
+
344
+ function onClick(e) {
345
+ const id = idOf(e.target);
346
+ if (id == null) return;
347
+ if (e.target.closest(`.${cls('close')}`)) { onClose?.(id, byId.get(id)); return; }
348
+ if (id === current && !destroyed) { onReselect?.(id, byId.get(id)); return; }
349
+ select(id);
350
+ }
351
+
352
+ function onAuxClick(e) {
353
+ const id = idOf(e.target);
354
+ if (e.button !== 1 || id == null || !byId.get(id)?.closable || !onClose) return;
355
+ e.preventDefault();
356
+ onClose(id, byId.get(id));
357
+ }
358
+
359
+ function onKeyDown(e) {
360
+ const id = idOf(e.target);
361
+ if (id == null || e.altKey || e.ctrlKey || e.metaKey) return;
362
+ const at = order.indexOf(id);
363
+ let to = null;
364
+ if (e.key === 'ArrowRight') to = order[(at + 1) % order.length];
365
+ else if (e.key === 'ArrowLeft') to = order[(at - 1 + order.length) % order.length];
366
+ else if (e.key === 'Home') to = order[0];
367
+ else if (e.key === 'End') to = order[order.length - 1];
368
+ else if (e.key === 'Delete' && byId.get(id)?.closable && onClose) {
369
+ e.preventDefault();
370
+ onClose(id, byId.get(id));
371
+ return;
372
+ }
373
+ if (to == null) return;
374
+ e.preventDefault();
375
+ select(to, { focus: true });
376
+ }
377
+
378
+ function onMenu(e) {
379
+ const id = idOf(e.target);
380
+ if (id == null || !onContextMenu) return;
381
+ e.preventDefault();
382
+ onContextMenu(id, e, byId.get(id));
383
+ }
384
+
385
+ const onWheel = (e) => wheelStripSideways(strip, e);
386
+
387
+ strip.addEventListener('click', onClick);
388
+ strip.addEventListener('auxclick', onAuxClick);
389
+ strip.addEventListener('keydown', onKeyDown);
390
+ strip.addEventListener('contextmenu', onMenu);
391
+ strip.addEventListener('wheel', onWheel, { passive: false });
392
+
393
+ const dragReorder = onReorder ? new DragReorder({
394
+ container: strip,
395
+ itemSelector: `.${cls('tab')}`,
396
+ keyAttr: datasetKey,
397
+ axis: 'x',
398
+ draggableGuard: (e) => !e.target.closest?.(`.${cls('close')}`),
399
+ // DragReorder answers the `data-*` strings; the buttons, now in their
400
+ // new order, give the caller's ids back unchanged.
401
+ onReorder: () => {
402
+ order = [...strip.querySelectorAll(`.${cls('tab')}`)]
403
+ .filter((el) => idByEl.has(el)).map((el) => idByEl.get(el))
404
+ .filter((id) => byId.has(id));
405
+ onReorder(order.slice());
406
+ },
407
+ }) : null;
408
+
409
+ // ── the api ─────────────────────────────────────────────────────────
410
+
411
+ const api = {
412
+ root,
413
+ /** Bring a tab to the front. False for an id it does not have. */
414
+ select: (id) => select(id),
415
+ /** The tab in front, or null. */
416
+ active: () => current,
417
+ /** Whether a tab with this id is in the strip. */
418
+ has: (id) => byId.has(id),
419
+ /** The ids, in strip order. */
420
+ ids: () => order.slice(),
421
+ /**
422
+ * Replace the items. The tab in front is `activeId` when given and
423
+ * present, else the one in front now if it is still there, else the
424
+ * first. Panes of items that went are destroyed; eager items that
425
+ * came are mounted, in order, before the tab in front is settled.
426
+ * No items left: nothing is in front, heard as `onSelect(null, null)`.
427
+ * Focus on a tab that went moves to the tab in front, unless the
428
+ * update put it somewhere else (a pane that focuses itself on show).
429
+ */
430
+ update(next = [], activeId = undefined) {
431
+ if (destroyed) return;
432
+ const focused = doc.activeElement;
433
+ const focusedTab = idByEl.has(focused) && strip.contains(focused);
434
+ setItems(next);
435
+ mountEager();
436
+ if (destroyed) return;
437
+ const want = activeId !== undefined && byId.has(activeId) ? activeId
438
+ : byId.has(current) ? current
439
+ : order[0] ?? null;
440
+ if (want !== current) show(want, true);
441
+ else paint(); // a new button is drawn off; the kept ones keep their state
442
+ if (destroyed) return;
443
+ // The focused tab went with its item and focus fell to <body>:
444
+ // it goes to the tab in front. Focus that landed anywhere else stays.
445
+ const fell = doc.activeElement == null || doc.activeElement === doc.body;
446
+ if (focusedTab && !strip.contains(focused) && fell && current != null) {
447
+ tabs.get(current).el.focus();
448
+ }
449
+ },
450
+ /** `count` on a tab, coloured by `level`; nothing at zero. */
451
+ setBadge(id, count, level = null) {
452
+ const item = byId.get(id);
453
+ if (!item) return;
454
+ item.badge = { count: Number(count) || 0, level };
455
+ const span = tabs.get(id).el.querySelector(`.${cls('badge')}`);
456
+ if (span) drawBadge(span, badgeOf(item));
457
+ },
458
+ /** The dot on a tab: a level, `true` for the neutral one, falsy for none. */
459
+ setIndicator(id, value) {
460
+ const item = byId.get(id);
461
+ if (!item) return;
462
+ item.indicator = value;
463
+ const span = tabs.get(id).el.querySelector(`.${cls('indicator')}`);
464
+ if (span) drawIndicator(span, value);
465
+ },
466
+ /** A mounted pane's instance, or null. */
467
+ instance: (id) => panes.get(id)?.instance ?? null,
468
+ /** Every mounted pane's instance, in mount order. */
469
+ instances: () => [...panes.values()].map((p) => p.instance).filter(Boolean),
470
+ /** Gone for good: panes are destroyed, the bus is let go, the markup removed. */
471
+ destroy() {
472
+ if (destroyed) return;
473
+ destroyed = true;
474
+ try { subscription?.dispose?.(); } catch { /* bus gone */ }
475
+ strip.removeEventListener('click', onClick);
476
+ strip.removeEventListener('auxclick', onAuxClick);
477
+ strip.removeEventListener('keydown', onKeyDown);
478
+ strip.removeEventListener('contextmenu', onMenu);
479
+ strip.removeEventListener('wheel', onWheel);
480
+ dragReorder?.destroy();
481
+ for (const id of [...panes.keys()]) unmount(id);
482
+ root.remove();
483
+ },
484
+ };
485
+
486
+ setItems(items);
487
+ current = byId.has(active) ? active : order[0] ?? null;
488
+ mountEager();
489
+ show(current, false);
490
+
491
+ // A pane that destroyed the strip while it mounted leaves nothing to subscribe.
492
+ if (eventBus && selectEvent && !destroyed) {
493
+ subscription = eventBus.on?.(selectEvent, (payload) => select(selectFrom(payload))) ?? null;
494
+ }
495
+
496
+ return api;
497
+ }