flexdesk 0.16.1 → 0.17.1

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 (262) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/LICENSE +0 -0
  3. package/README.md +2435 -42
  4. package/THIRD_PARTY_LICENSES.md +0 -0
  5. package/canvas.js +18 -0
  6. package/charts.js +21 -0
  7. package/core.js +14 -0
  8. package/css/base.css +1834 -75
  9. package/css/flexdesk.css +3165 -159
  10. package/css/overrides.css +16 -0
  11. package/css/reset.css +0 -0
  12. package/css/tokens.css +0 -0
  13. package/dist/canvas.js +6 -239
  14. package/dist/canvas.js.map +3 -3
  15. package/dist/charts.js +707 -18
  16. package/dist/charts.js.map +4 -4
  17. package/dist/chunk-3PHCPZHT.js +0 -0
  18. package/dist/chunk-3PHCPZHT.js.map +0 -0
  19. package/dist/chunk-4A7SJC3T.js +503 -0
  20. package/dist/chunk-4A7SJC3T.js.map +7 -0
  21. package/dist/{chunk-6JSOVNID.js → chunk-CVMCZZX2.js} +635 -10
  22. package/dist/chunk-CVMCZZX2.js.map +7 -0
  23. package/dist/chunk-D3COLZZ2.js +1311 -0
  24. package/dist/chunk-D3COLZZ2.js.map +7 -0
  25. package/dist/chunk-FOOS3T5L.js +0 -0
  26. package/dist/chunk-FOOS3T5L.js.map +0 -0
  27. package/dist/{chunk-SN4LSOAR.js → chunk-GOQLR5WR.js} +452 -2
  28. package/dist/chunk-GOQLR5WR.js.map +7 -0
  29. package/dist/{chunk-FL5KFNQH.js → chunk-HNU6MM2B.js} +52 -17
  30. package/dist/chunk-HNU6MM2B.js.map +7 -0
  31. package/dist/chunk-HYJYYEPK.js +375 -0
  32. package/dist/chunk-HYJYYEPK.js.map +7 -0
  33. package/dist/chunk-JKZLNKG6.js +2041 -0
  34. package/dist/chunk-JKZLNKG6.js.map +7 -0
  35. package/dist/chunk-JYWURG5T.js +0 -0
  36. package/dist/chunk-JYWURG5T.js.map +0 -0
  37. package/dist/{chunk-FBGP5KN5.js → chunk-LWV23YVE.js} +5073 -532
  38. package/dist/chunk-LWV23YVE.js.map +7 -0
  39. package/dist/chunk-N7HC3ADV.js +30 -0
  40. package/dist/chunk-N7HC3ADV.js.map +7 -0
  41. package/dist/{chunk-RQM5Z2MU.js → chunk-RMTRWWEO.js} +633 -311
  42. package/dist/chunk-RMTRWWEO.js.map +7 -0
  43. package/dist/{chunk-NLTOY57M.js → chunk-UURZ37SD.js} +28 -48
  44. package/dist/chunk-UURZ37SD.js.map +7 -0
  45. package/dist/{chunk-B5Q53LAJ.js → chunk-UWO43BZU.js} +2 -2
  46. package/dist/chunk-UWO43BZU.js.map +7 -0
  47. package/dist/chunk-WVFGV5FT.js +0 -0
  48. package/dist/chunk-WVFGV5FT.js.map +0 -0
  49. package/dist/core.js +170 -118
  50. package/dist/core.js.map +2 -2
  51. package/dist/editor.js +156 -70
  52. package/dist/editor.js.map +2 -2
  53. package/dist/flexdesk.css +3165 -159
  54. package/dist/flow.js +2789 -306
  55. package/dist/flow.js.map +4 -4
  56. package/dist/host.js +109 -3
  57. package/dist/host.js.map +3 -3
  58. package/dist/reset.css +0 -0
  59. package/dist/tile_registry-6WZPTUZV.js +0 -0
  60. package/dist/tile_registry-6WZPTUZV.js.map +0 -0
  61. package/dist/tiles.js +657 -234
  62. package/dist/tiles.js.map +4 -4
  63. package/dist/tokens.css +0 -0
  64. package/dist/widgets.js +3290 -2124
  65. package/dist/widgets.js.map +4 -4
  66. package/dist/wm.js +1227 -347
  67. package/dist/wm.js.map +4 -4
  68. package/editor.js +20 -0
  69. package/flow.js +21 -0
  70. package/host.js +14 -0
  71. package/package.json +12 -1
  72. package/src/canvas/canvas_adapter.js +5 -0
  73. package/src/canvas/edge_router.js +0 -0
  74. package/src/canvas/graph/connector_router.js +0 -0
  75. package/src/canvas/graph/history_service.js +0 -0
  76. package/src/canvas/graph/node_platform.js +0 -0
  77. package/src/canvas/index.js +1 -0
  78. package/src/canvas/layered_layout.js +244 -0
  79. package/src/charts/chart_types.js +0 -0
  80. package/src/charts/data_series_plot_window.js +0 -0
  81. package/src/charts/downsample.js +2 -2
  82. package/src/charts/plot_config.js +53 -29
  83. package/src/charts/plot_config_editor.js +627 -0
  84. package/src/charts/plot_grid.js +449 -0
  85. package/src/charts/plot_popout_window.js +103 -317
  86. package/src/charts/plotly_wrapper.js +22 -2
  87. package/src/charts/series_transforms.js +206 -0
  88. package/src/core/event_bus.js +0 -0
  89. package/src/core/logging.js +1 -1
  90. package/src/core/settings.js +105 -19
  91. package/src/core/state_guard.js +93 -51
  92. package/src/core/state_guard_config.js +85 -74
  93. package/src/core/state_machine.js +0 -0
  94. package/src/editor/editor_pane.js +3 -3
  95. package/src/editor/monaco_editor_factory.js +124 -60
  96. package/src/editor/monaco_loader.js +103 -28
  97. package/src/editor/notebook_search.js +0 -0
  98. package/src/editor/notebook_tab_bar.js +38 -52
  99. package/src/editor/notebook_undo_manager.js +0 -0
  100. package/src/editor/split_pane_container.js +6 -3
  101. package/src/flow/canvas_editor/connections.js +90 -0
  102. package/src/flow/canvas_editor/editor.js +2351 -153
  103. package/src/flow/canvas_editor/index.js +2 -1
  104. package/src/flow/canvas_editor/layout.js +119 -12
  105. package/src/flow/canvas_editor/strings.js +80 -0
  106. package/src/flow/kit/always_before.js +0 -0
  107. package/src/flow/kit/catalogue.js +97 -3
  108. package/src/flow/kit/chip_input.js +0 -0
  109. package/src/flow/kit/dom.js +0 -0
  110. package/src/flow/kit/findings.js +0 -0
  111. package/src/flow/kit/history.js +0 -0
  112. package/src/flow/kit/index.js +3 -3
  113. package/src/flow/kit/keys.js +7 -1
  114. package/src/flow/kit/logic_graph.js +97 -15
  115. package/src/flow/kit/references.js +0 -0
  116. package/src/flow/kit/settings_panel.js +0 -0
  117. package/src/flow/kit/settings_schema.js +2 -1
  118. package/src/flow/kit/step_picker.js +0 -0
  119. package/src/flow/kit/strings.js +3 -1
  120. package/src/flow/kit/value_picker.js +0 -0
  121. package/src/flow/kit/widgets.js +86 -109
  122. package/src/flow/lanes/editor.js +136 -14
  123. package/src/flow/lanes/index.js +3 -1
  124. package/src/flow/lanes/layout.js +0 -0
  125. package/src/flow/lanes/pipeline.js +44 -2
  126. package/src/flow/lanes/preview.js +0 -0
  127. package/src/flow/lanes/strings.js +1 -0
  128. package/src/flow/outline/editor.js +0 -0
  129. package/src/flow/outline/index.js +0 -0
  130. package/src/flow/outline/mapping.js +0 -0
  131. package/src/flow/outline/menu.js +0 -0
  132. package/src/flow/outline/operations.js +0 -0
  133. package/src/flow/outline/recognise.js +0 -0
  134. package/src/flow/outline/strings.js +0 -0
  135. package/src/help/help_modal.js +168 -42
  136. package/src/help/help_registry.js +12 -1
  137. package/src/host/external_links.js +0 -0
  138. package/src/host/file_picker.js +117 -0
  139. package/src/host/host.js +10 -2
  140. package/src/host/photino_host.js +9 -0
  141. package/src/host/photino_host.md +21 -3
  142. package/src/host/pywebview_host.js +10 -0
  143. package/src/tiles/chart_tiles.js +53 -245
  144. package/src/tiles/config_schema.js +8 -0
  145. package/src/tiles/figure_tile.js +322 -0
  146. package/src/tiles/layout_persistence.js +3 -3
  147. package/src/tiles/list_tiles.js +7 -7
  148. package/src/tiles/scope_picker.js +0 -0
  149. package/src/tiles/tile_base.js +1 -1
  150. package/src/tiles/tile_config_form.js +442 -0
  151. package/src/tiles/tile_grid.js +2 -0
  152. package/src/tiles/tile_registry.js +0 -0
  153. package/src/tiling/bottom_panel_tabs.js +67 -99
  154. package/src/tiling/command_palette.js +64 -19
  155. package/src/tiling/content_registry.js +4 -1
  156. package/src/tiling/desktops.js +11 -1
  157. package/src/tiling/entity_sources.js +0 -0
  158. package/src/tiling/keymap.js +403 -128
  159. package/src/tiling/kind_taxonomy.js +78 -0
  160. package/src/tiling/landing_table.js +0 -0
  161. package/src/tiling/loading_overlay.js +0 -0
  162. package/src/tiling/nav_panel.js +59 -10
  163. package/src/tiling/page_factory.js +5 -5
  164. package/src/tiling/panel_availability.js +49 -0
  165. package/src/tiling/panel_keys.js +0 -0
  166. package/src/tiling/shell.js +114 -12
  167. package/src/tiling/tab_strip.js +0 -0
  168. package/src/tiling/tile_breadcrumb.js +0 -0
  169. package/src/tiling/tile_renderer.js +105 -9
  170. package/src/tiling/tile_tab_menu.js +0 -0
  171. package/src/tiling/tile_tree.js +0 -0
  172. package/src/tiling/wm.js +562 -30
  173. package/src/tiling/zoom.js +0 -0
  174. package/src/ui/base/component_base.js +0 -0
  175. package/src/ui/base/controller_base.js +0 -0
  176. package/src/ui/base/page_base.js +0 -0
  177. package/src/ui/components/about_dialog.js +0 -0
  178. package/src/ui/components/action_dropdown.js +30 -2
  179. package/src/ui/components/alert_log.js +0 -0
  180. package/src/ui/components/alerts_pane.js +0 -0
  181. package/src/ui/components/attribute_list_editor.js +0 -0
  182. package/src/ui/components/autocomplete_field.js +0 -0
  183. package/src/ui/components/cell_editor_host.js +293 -0
  184. package/src/ui/components/cell_editors.js +480 -0
  185. package/src/ui/components/cell_grid.js +761 -0
  186. package/src/ui/components/cell_paste.js +243 -0
  187. package/src/ui/components/cell_selection.js +220 -0
  188. package/src/ui/components/choice_controls.js +351 -0
  189. package/src/ui/components/color_field.js +211 -0
  190. package/src/ui/components/color_list_field.js +210 -0
  191. package/src/ui/components/column_fit.js +0 -0
  192. package/src/ui/components/column_groups.js +314 -0
  193. package/src/ui/components/column_order.js +142 -0
  194. package/src/ui/components/computing_status_window.js +98 -25
  195. package/src/ui/components/confirm_dialog.js +81 -38
  196. package/src/ui/components/context_menu.js +2 -2
  197. package/src/ui/components/conversation_view.js +440 -0
  198. package/src/ui/components/crosstab.js +289 -0
  199. package/src/ui/components/data_table.js +696 -71
  200. package/src/ui/components/detail_header.js +22 -11
  201. package/src/ui/components/document_outline.js +397 -0
  202. package/src/ui/components/drag_reorder.js +0 -0
  203. package/src/ui/components/fact_table.js +0 -0
  204. package/src/ui/components/fill_handle.js +322 -0
  205. package/src/ui/components/gallery_picker.js +19 -8
  206. package/src/ui/components/header_actions.js +55 -0
  207. package/src/ui/components/inline_renamer.js +0 -0
  208. package/src/ui/components/job_progress.js +51 -0
  209. package/src/ui/components/managed_window.js +27 -4
  210. package/src/ui/components/menu_bar.js +745 -0
  211. package/src/ui/components/modal.js +449 -75
  212. package/src/ui/components/notification_history.js +3 -3
  213. package/src/ui/components/progress.js +483 -0
  214. package/src/ui/components/record_list.js +0 -0
  215. package/src/ui/components/row_activation.js +0 -0
  216. package/src/ui/components/section_stack.js +298 -0
  217. package/src/ui/components/series.js +173 -0
  218. package/src/ui/components/settings_view.js +634 -0
  219. package/src/ui/components/slide_out_panel.js +43 -6
  220. package/src/ui/components/slider_field.js +275 -18
  221. package/src/ui/components/sortable_list.js +0 -0
  222. package/src/ui/components/staged_edits.js +361 -0
  223. package/src/ui/components/staged_history.js +172 -0
  224. package/src/ui/components/table_filter_chips.js +0 -0
  225. package/src/ui/components/table_state_store.js +0 -0
  226. package/src/ui/components/table_structure.js +182 -0
  227. package/src/ui/components/table_values.js +0 -0
  228. package/src/ui/components/tabs.js +497 -0
  229. package/src/ui/components/tag_input.js +215 -0
  230. package/src/ui/components/toast.js +25 -8
  231. package/src/ui/components/toggle_field.js +175 -0
  232. package/src/ui/components/tree_view.js +55 -34
  233. package/src/ui/components/tsv.js +74 -0
  234. package/src/ui/components/window_taskbar.js +0 -0
  235. package/src/ui/components/wizard.js +563 -0
  236. package/src/ui/controllers/window_chrome_controller.js +2 -2
  237. package/src/ui/notification_center.js +375 -119
  238. package/src/ui/utils/auto_scrollbars.js +0 -0
  239. package/src/ui/utils/markdown.js +709 -0
  240. package/src/ui/utils/native_context_menu.js +0 -0
  241. package/src/ui/utils/overlay_scrollbar.js +16 -12
  242. package/src/ui/utils/raf_resize_observer.js +0 -0
  243. package/src/ui/utils/scroll_lock.js +0 -0
  244. package/src/ui/utils/strip_scroll.js +55 -0
  245. package/src/ui/utils/tooltip_service.js +0 -8
  246. package/tiles.js +27 -0
  247. package/widgets.js +69 -0
  248. package/wm.js +28 -0
  249. package/dist/chunk-6JSOVNID.js.map +0 -7
  250. package/dist/chunk-B5Q53LAJ.js.map +0 -7
  251. package/dist/chunk-FBGP5KN5.js.map +0 -7
  252. package/dist/chunk-FL5KFNQH.js.map +0 -7
  253. package/dist/chunk-NLTOY57M.js.map +0 -7
  254. package/dist/chunk-NNFTETK3.js +0 -633
  255. package/dist/chunk-NNFTETK3.js.map +0 -7
  256. package/dist/chunk-O5OHMWBB.js +0 -284
  257. package/dist/chunk-O5OHMWBB.js.map +0 -7
  258. package/dist/chunk-RQM5Z2MU.js.map +0 -7
  259. package/dist/chunk-SN4LSOAR.js.map +0 -7
  260. package/dist/chunk-W6FBJLPW.js +0 -367
  261. package/dist/chunk-W6FBJLPW.js.map +0 -7
  262. package/src/ui/controllers/panel_state_machine.js +0 -634
@@ -4,10 +4,11 @@
4
4
  * Unified, reusable data table component with support for:
5
5
  * - Pagination (first/prev/next/last/jump to row)
6
6
  * - Row selection (single, range with Shift, toggle with Ctrl/Cmd)
7
- * - Keyboard shortcuts (Ctrl+A, Ctrl+C, Shift+Ctrl+C, Esc)
7
+ * - Keyboard shortcuts (Ctrl+A, Ctrl+C, Shift+Ctrl+C outside cell mode, Esc)
8
8
  * - Context menu (copy as TSV/CSV)
9
9
  * - Column sorting (internal or external via callback)
10
10
  * - Column drag-to-resize, and double-click-to-auto-size on the same grip
11
+ * - Column drag-to-reorder (`reorderable`, `columnOrder`; column_order.js)
11
12
  * - Per-column filtering (numeric operators, text substring)
12
13
  * - Column type detection (numeric/text alignment)
13
14
  * - Header icons
@@ -25,6 +26,12 @@ import {
25
26
  } from './table_values.js';
26
27
  import { isRowActivation, isRowControl } from './row_activation.js';
27
28
  import { createChipsFilter } from './table_filter_chips.js';
29
+ import { CellGrid } from './cell_grid.js';
30
+ import { toTSV } from './tsv.js';
31
+ import { ColumnReorder, cellOrder, isColumnOrder, moveInOrder, naturalOrder } from './column_order.js';
32
+ import { ColumnGroups } from './column_groups.js';
33
+ import { Crosstab } from './crosstab.js';
34
+ import { STRUCTURE_OPTIONS, TableStructure } from './table_structure.js';
28
35
 
29
36
  // 0.7.0: the built-in chips filter control, for a consumer that wants to build
30
37
  // one outside a table (the same control `filterKinds: {kind: 'chips'}` draws).
@@ -401,8 +408,48 @@ const AUTOSIZE_MAX_PX = 520;
401
408
  * @property {boolean} [selectToggle=false] - STORY-0016. The table offers a SELECT MODE: a narrow leading column whose header holds a toggle button. While the mode is on, every row shows a checkbox there and the header a select-all box; a plain click (or Space) ticks or unticks one row, Shift-click adds the range from the last row ticked, and no gesture opens a row (`onRowActivate` is not called; `onRowClick` still is — a consumer that opens rows from it asks `isSelectMode()`). The arrows move a cursor without changing what is ticked. A right-click keeps the ticks. Escape clears the ticks, then leaves the mode. Needs a multi-row `selectable` (ignored, with a warning, on `false` or 'single'). The column is not resizable, is no data column (not in a copy, a CSV, `ctx.colIdx` or `hiddenColumns`), and remembered widths stay on their columns.
402
409
  * @property {boolean} [selectMode=false] - STORY-0016. Start in select mode. Draws the select column even without `selectToggle` (then there is no toggle: the consumer turns the mode off with `setSelectMode(false)`).
403
410
  * @property {Function} [onSelectModeChange] - STORY-0016. `(on) => void`, after every change of the mode (the toggle, Escape, `setSelectMode`).
404
- * @property {Function} [onColumnsChange] - STORY-0012. `(hidden) => void` with the hidden columns' indices, ascending, after every change of the set (the chooser, `setColumnVisible`, `setData({hiddenColumns})`).
411
+ * @property {Function} [onColumnsChange] - STORY-0012. `(hidden, {hidden, order}) => void` with the hidden columns' indices, ascending, after every change of the set (the chooser, `setColumnVisible`, `setData({hiddenColumns})`) — and (STORY-0086) of the order (a drag, `moveColumn`, `setData({columnOrder})`); `order` is `getColumnOrder()`.
412
+ * @property {boolean} [reorderable=false] - STORY-0086. Data columns can be dragged into a new order by their headers (a press on a resize grip resizes instead), and the header's right-click offers Move left / Move right. The row-number and select columns stay first: they do not move and nothing is dropped in front of them. The order is kept by data index, so `hiddenColumns`, the sort and the filters stay on their columns; copies and the CSV follow the order shown. With `persistKey` it is saved, and a saved order wins over `columnOrder` — unless it is no order of today's columns (another column count), when it is ignored and `columnOrder` is drawn, or the natural order when there is none. The same holds when the saved order arrives before the headers do.
413
+ * @property {number[]} [columnOrder] - STORY-0086. Every data index, in the order to draw them (`[0, 3, 1, 2]`). Not a permutation of the columns: ignored with a console warning, natural order (with no headers yet, checked and warned once when `setData` brings them). Works without `reorderable` (then only code moves columns). See `moveColumn`, `getColumnOrder`.
405
414
  * @property {boolean} [persistPage=false] - With `persistKey`: the client page is saved with the sort and the filters, and opened again on mount (on the last page, if the rows have shrunk since). OFF by default: a key shared by DIFFERENT row sets (ad-hoc filters, search results) must not reopen on a page of the last list. A sort under `resetPageOnSort` and a new filter save the first page, as they show it. A server's page (`onPageChange`, `dataSource`) is the consumer's and is not kept.
415
+ *
416
+ * ── STORY-0003, every one of them off unless asked for ─────────────────────
417
+ * @property {'rows'|'cells'} [selectionMode='rows'] - 'cells': CELL MODE (cell_grid.js, over cell_selection.js). A click selects one cell and puts the CURSOR on it, Shift+click selects the range from the anchor to it; every cell of the range is drawn `twm-dt-cell--selected` and the cursor `twm-dt-cell--cursor` (rows are not lit). On the focused table the arrows move the cursor (Shift extends), Ctrl+arrow jumps to the edge of the block of filled cells, Home/End go to the row's ends (Ctrl: the page's corners), Tab moves right and wraps to the next row (Shift+Tab back), Ctrl+A selects every cell of the page and Escape collapses the range onto the cursor. The cursor stays on the page drawn and is scrolled into view (under `virtualize` its row is drawn). `getSelection()` still answers ROWS — the original indices the range touches — and Ctrl+C copies those rows as before. A cell is `{row, col}`: `row` the position in the order shown (`getDisplayedRows()`), `col` the data column. Fixed for the table's life; `selectToggle`, `selectMode` and `keyboardNav` beside it are ignored with a console warning. See `getCellSelection`, `setCellSelection`.
418
+ * @property {boolean|number[]|Function} [editable=false] - Cell mode only (a warning, and ignored, without it). Which cells open an EDITOR: `true` every one, an array of data column indices, or `(colIdx, row, rowIdx) => boolean`. F2, Enter, a double-click or a printable key (which the editor starts with) opens it on the cursor cell; in it Enter commits and moves down, Tab commits and moves right, Escape restores the cell and calls nothing, and the focus leaving commits. While it is open a `render()` (a `setData` included) waits, and runs when it closes; its row leaving the virtual window commits it. An editor with no `getValue` (a factory's `{el}` around an element that is not an input, select or textarea) has no value to commit: Enter and Tab do nothing, the focus leaving does nothing, and its row leaving the window CANCELS it — it calls `commit(value)` itself.
419
+ * @property {Object} [editors] - `{[colIdx|type]: factory}`, the editor for a column (by index first, then by its type: `editTypes`', else `columnTypes`', else the detected 'num'/'text' — as named, then as the built-in editors name it, 'num' being 'number' and 'date' 'datetime'). A factory is `({value, row, rowIdx, colIdx, column: {index, header, type, ...editTypes' entry}, locale, utc, messages, initialText, commit(value, move?), cancel()}) => Element | {el, getValue?, parse?, multiline?, focus?, destroy?}`; an input, select or textarea returned as it is commits its `value`. Absent: the built-in editor for the column's type (cell_editors.js `CELL_EDITORS`: text, multitext, number, bool, choice, datetime), else a text box (cell_editor_host.js `createTextEditor`). Every editor commits the value unchanged when left as it was. An injected editor replaces the built-in one for its type (or column) only.
420
+ * @property {Array|Object} [editTypes] - STORY-0087. Each column's EDITOR type, by position (array) or by data column index (object): a name ('text', 'multitext', 'number', 'bool', 'choice', 'datetime', or any name `editors` is keyed by) or `{type, options?, allowOther?, rows?}`. Unset, a column's editor type is its `columnTypes` type, else the detected one. 'choice' offers `options` (values, or `{value, label}`) through an AutocompleteField and refuses other text unless `allowOther`; 'multitext' is a text area of `rows` lines (3). Only editing reads it: drawing, sorting and filtering are as without it.
421
+ * @property {string} [locale] - STORY-0087. The locale the built-in number and datetime editors read and show their text in (Intl's; unset: the runtime's): '1.234,56' is 1234.56 under 'de-DE', '1,234.56' under 'en-US'; '31/12/2026' is a date under 'en-GB'. A datetime also takes ISO 8601 in any locale.
422
+ * @property {Object} [editorMessages] - STORY-0087. Words for the built-in editors' refusals, over `EDITOR_MESSAGES`: `{number, datetime, bool, choice}`.
423
+ * @property {Function} [onCellCommit] - `({rowIdx, colIdx, value, previous}) => void | {ok, error?} | Promise<…>`, once per commit (not for a value left as the cell showed it; `previous` is the value the row holds). Resolving `{ok: false, error}`, rejecting or throwing leaves the typed value in the cell drawn `twm-dt-cell--error` with the message as its `title` and `data-error`; until it settles the cell is `twm-dt-cell--pending`. Otherwise the value is written into the row the table holds for `rowIdx` when it settles (`rows[rowIdx][colIdx]`) and drawn. Committing a refused cell back to the row's value clears the mark without a call. New rows (`setData({rows})`) drop the marks.
424
+ * @property {Function} [onCellSelectionChange] - `(getCellSelection()) => void`, after every change of the cells selected.
425
+ * @property {Function} [headerMenu] - `(colIdx, {colIdx, header, hiddenColumns, table}) => items`, asked on a right-click on a data column's header (or its filter cell). The items (`{label, action, icon?, disabled?, danger?, title?}` or `{separator: true}`) open the table's menu; with `columnChooser` the chooser's items follow behind a separator. `[]` or `null` shows what would show without the hook: the chooser, or the page's own menu.
426
+ * @property {Function} [onHeaderMenuAction] - `(action, colIdx) => void`, when one of `headerMenu`'s items is chosen; the menu is hidden first.
427
+ *
428
+ * ── STORY-0088, off unless asked for ──────────────────────────────────────
429
+ * @property {boolean} [staged=false] - With `editable` (a warning, and ignored, without it): an editor's commit is STAGED instead of sent to `onCellCommit` (staged_edits.js). A staged cell shows its value drawn `twm-dt-cell--staged`; staging the value its row holds is no change. On the focused table Ctrl+Z takes the last staged edit back and Ctrl+Y (or Ctrl+Shift+Z) puts it back — a cell staged as `null` undoes to no change, not to a staged null. `commit()` sends every staged cell to `onCommit` at once; `discard()` drops them. While any cell is staged the sort, the filters and the pages are FROZEN: their controls are disabled with the reason on them (`data-refusal`, `title`), and `sortBy`, `setFilter`, `clearFilters`, `goToPage`, `goToRow` and the paging calls refuse with a console warning. New rows (`setData({rows})`) drop the staged cells and the ring.
430
+ * @property {number} [historyLimit=100] - With `staged`: the undo ring keeps this many entries — the limit counts entries, not cells; an entry is one cell's before/after change or one fill (a group of cell changes, undone together); the oldest fall off.
431
+ * @property {Function} [onCommit] - With `staged`: `({edits}) => void | {ok?, error?, errors?} | Promise<…>`, called once by `commit()` with every staged cell as `{rowIdx, colIdx, value, previous}` (by row, then column; `previous` is the value the row holds). Resolving `{errors: [{rowIdx, colIdx, error}]}` (non-empty) marks each of those cells `twm-dt-cell--error` with the message as its `title` and `data-error`, and every staged value stays; `{ok: false, error}`, a rejection or a throw marks every staged cell with the message. Anything else is success: the values are written into the rows the table holds, and the staged cells and the ring are cleared. While it is out no editor opens and Ctrl+Z, Ctrl+Y and `discard()` do nothing.
432
+ * @property {Function} [onStagedChange] - With `staged`: `({count, canUndo, canRedo, committing}) => void` after every change of the staged cells.
433
+ * @property {boolean} [fillHandle=false] - STORY-0089. With `staged` (a warning, and ignored, without it): a FILL HANDLE (fill_handle.js) on the bottom-right corner of the cell range (`.twm-dt-fill-handle`, in the corner cell). Dragging it up, down, left or right over the cells beyond the range previews them `twm-dt-cell--fill-preview` and, on release, stages their values as ONE undo entry: each column of the range (each row, filling sideways) is continued by `projectSeries` (series.js) — one cell repeats, numbers, dates and "Item 1"-style texts a constant step apart continue, anything else repeats in a cycle — and Ctrl (Cmd) held at release repeats the block instead. A target cell `editable` refuses is skipped and drawn `twm-dt-cell--fill-skipped` until the selection moves. The range then covers what was filled. Escape during the drag cancels it. See `fill`.
434
+ * @property {boolean} [pasteable=false] - STORY-0090. With `staged` (a warning, and ignored, without it): Ctrl+V on the focused table (the browser's `paste` event; `paste(text)` from code) pastes a tab-separated block — what Excel and Google Sheets copy, read by `fromTSV` (tsv.js) — at the CURSOR cell, filling right over the columns shown and down over the rows in the order shown (cell_paste.js). Each value is read by its column's editor's parse (the built-in editor's for its type, or an injected `editors` factory's own `parse` property), and every cell that reads is staged as ONE undo entry; the range then covers the block, the cursor where it began. Columns past the last shown one are dropped with a warning naming how many (a console warning and a 'warn' toast). A value that does not parse marks its cell `twm-dt-cell--error` (message as `title` and `data-error`, the text kept) and the rest still stages; a cell `editable` refuses is skipped and drawn `twm-dt-cell--paste-skipped` until the selection moves. Rows past the last loaded row ask `onPasteOverrun`.
435
+ * @property {Function} [onPasteOverrun] - With `pasteable`: `({rows}) => boolean | Promise<boolean>`, asked when a paste runs `rows` rows past the last loaded row (the last row in the order shown; for a server's page, the last row it sent). Anything but `true` stages NOTHING; `true` stages the rows that fit and hands the extra ones to `onRowsAppend`. Without it, such a paste stages nothing, with a console warning.
436
+ * @property {Function} [onRowsAppend] - With `pasteable`: `({rows, errors}) => void | Promise`, after `onPasteOverrun` said `true`: `rows` the extra rows as data rows (one array per row, as long as `headers`, every pasted column read by its parse, the others null) and `errors` the values that did not read (`{row, colIdx, text, error}`, `row` an index into `rows`; that cell is null). Adding them is the consumer's; the rows that fit are staged once it settles. Without it, the extra rows are dropped with a warning.
437
+ * @property {string|Function} [stagedRefusal] - With `staged`: the reason a frozen control carries, or `(count) => string`. Absent: "Sorting, filtering and paging are held while N changes are not committed. Commit or discard them first."
438
+ *
439
+ * ── STORY-0066, every one of them off unless asked for. THE CONSUMER OWNS THE DATA: each edit is a call, answered with `setData` ──
440
+ * @property {Array<{key: string|number, label?: string, columns: number[]}>} [columnGroups] - COLUMN GROUPS (column_groups.js). A second header row above the column headers: one cell per run of shown columns of a group, spanning them, and a blank cell over the row number / select column and over columns in no group. It is a table of its own in the header wrap (`table.twm-dt-groups`, cells `th.twm-dt-group`) that takes the header's column widths after every fit, auto-size and drag, and scrolls sideways with it. A column is in the first group that lists it. A group with NO columns keeps one PLACEHOLDER column (`th.twm-dt-col--placeholder`, `td.twm-dt-cell--placeholder` in every row), after the columns of the groups listed before it, so its add control is reachable; it is no data column (not in a copy, the CSV, `ctx.colIdx` or cell mode). `setData({columnGroups})` changes them.
441
+ * @property {Function} [groupActions] - `(group, {key, columns, table}) => items`, buttons in a group's header cell (`{action, icon?, label?, title?, disabled?}`); a click calls `onGroupAction(action, groupKey)`.
442
+ * @property {Function} [onGroupAction] - `(action, groupKey) => void`.
443
+ * @property {Function} [onColumnAdd] - `(groupKey) => void`. Puts an add button (`.twm-dt-group__add`) in every group's header cell and in a placeholder column's header.
444
+ * @property {Function} [columnActions] - `(colIdx, {colIdx, header, table}) => items`, buttons in a data column's header (`.twm-dt-col-action`, same items as `groupActions`); a click calls `onColumnAction(action, colIdx)` and does not sort.
445
+ * @property {Function} [onColumnAction] - `(action, colIdx) => void`.
446
+ * @property {Function} [onColumnRename] - `(colIdx, name) => void | false | {ok: false} | Promise<…>`. RENAMING IN PLACE through `attachInlineRenamer`: "Rename column" in the header's right-click menu, `renameColumn(colIdx)`, or (on a table that is not `sortable`) a double-click on the header's label opens it. Enter, or the focus leaving, with a different non-empty name calls it once; Escape, or the name unchanged, restores the header and calls nothing. The new name shows until the next render; `false` or `{ok: false}` restores the old one.
447
+ * @property {Function} [onColumnDelete] - `(colIdx) => void`, from "Delete column" in the header's right-click menu.
448
+ * @property {Function} [onRowAdd] - `(afterRowIdx|null) => void`, from "Insert row below" in a row's right-click menu (its original index) and "Add row" in the header's (null), so a table with no rows can get its first.
449
+ * @property {Function} [onRowDelete] - `(rowIdx) => void`, from "Delete row" in a row's right-click menu.
450
+ *
451
+ * ── STORY-0091, off unless asked for ──────────────────────────────────────
452
+ * @property {{labelColumns?: number, headerRows?: Array<Array<{label: string, columns: number[], className?: string|string[], title?: string}>>, emptyLabel?: string}} [crosstab] - A CROSSTAB (crosstab.js): one table in one scroller, the `thead` kept in the body table. `headerRows` draws rows of spans above the column headers (top first; a cell spans the run of drawn body columns it lists, a run in no cell is blank), the leading `labelColumns` drawn data columns are row labels, and header rows stick to the top and label columns (behind the select and row-number columns) to the left at offsets MEASURED after layout and again when a header row or pinned header cell changes size; the corner sticks to both. With no body column, one column headed `emptyLabel` ('(none)'). No header table is built, so the column fit, resize grips, header menu (and the header items of `table_structure`), `columnActions`, `reorderable` and `columnGroups` (ignored, with a warning) do not apply. A row's background is `--twm-dt-row-bg` on its `tr`, which the pinned cells lay over their surface, with every cell tint as a layer above it.
406
453
  */
407
454
 
408
455
  /** C10. When the scroll box has no layout — mounted hidden, or a DOM with no
@@ -410,9 +457,16 @@ const AUTOSIZE_MAX_PX = 520;
410
457
  * it were this tall. A ResizeObserver corrects it the moment a real size exists. */
411
458
  const VIRTUAL_FALLBACK_VIEWPORT_PX = 1000;
412
459
 
413
- /** The copy menu's two items — the whole right-click menu of a table without
414
- * `contextMenuItems`, and its tail with them (STORY-0010). */
415
- const COPY_MENU_HTML = `
460
+ /** The copy menu — the whole right-click menu of a table without
461
+ * `contextMenuItems`, and its tail with them (STORY-0010). Row mode has two
462
+ * items, "Copy Row(s)" and "Copy as CSV"; cell mode (STORY-0085) has one,
463
+ * "Copy Cells", and no CSV copy. */
464
+ const COPY_CELLS_MENU_HTML = `
465
+ <div class="twm-context-menu-item" data-action="copy-tsv">
466
+ <span class="material-symbols-outlined">content_copy</span>
467
+ <span class="label">Copy Cells</span>
468
+ </div>`;
469
+ const copyMenuHtml = (cells) => cells ? COPY_CELLS_MENU_HTML : `
416
470
  <div class="twm-context-menu-item" data-action="copy-tsv">
417
471
  <span class="material-symbols-outlined">content_copy</span>
418
472
  <span class="label">Copy Row(s)</span>
@@ -548,13 +602,85 @@ export class DataTable {
548
602
  hiddenColumns: null,
549
603
  columnChooser: false,
550
604
  onColumnsChange: null,
605
+ // STORY-0086. Off — columns in their natural order, as in 0.16.
606
+ reorderable: false,
607
+ columnOrder: null,
551
608
  // STORY-0016. Off — no select column, as in 0.11 — until set.
552
609
  selectToggle: false,
553
610
  selectMode: false,
554
611
  onSelectModeChange: null,
612
+ // STORY-0003. Off — rows selected as in 0.16, no editing — until
613
+ // set. The typedef says what each does.
614
+ selectionMode: 'rows',
615
+ editable: false,
616
+ editors: null,
617
+ editTypes: null,
618
+ locale: null,
619
+ editorMessages: null,
620
+ onCellCommit: null,
621
+ onCellSelectionChange: null,
622
+ headerMenu: null,
623
+ onHeaderMenuAction: null,
624
+ // STORY-0088. Off — commits go to onCellCommit — until set.
625
+ staged: false,
626
+ historyLimit: 100,
627
+ onCommit: null,
628
+ onStagedChange: null,
629
+ stagedRefusal: null,
630
+ // STORY-0066. Off — one header row, no structural edits — until
631
+ // set. The typedef says what each does.
632
+ columnGroups: null,
633
+ groupActions: null,
634
+ onGroupAction: null,
635
+ onColumnAdd: null,
636
+ columnActions: null,
637
+ onColumnAction: null,
638
+ onColumnRename: null,
639
+ onColumnDelete: null,
640
+ onRowAdd: null,
641
+ onRowDelete: null,
642
+ // STORY-0089. Off — no fill handle — until set.
643
+ fillHandle: false,
644
+ // STORY-0090. Off — Ctrl+V is not the table's — until set.
645
+ pasteable: false,
646
+ onPasteOverrun: null,
647
+ onRowsAppend: null,
555
648
  ...config,
556
649
  };
557
650
 
651
+ // STORY-0003. Cell mode is the table's for its whole life. The row
652
+ // modes it replaces are ignored beside it, out loud.
653
+ this._cellGrid = null;
654
+ // STORY-0066. Built the first time their options are set (see
655
+ // `_colGroups`, `_structure`); a table without them never builds either.
656
+ this._columnGroups = null;
657
+ this._tableStructure = null;
658
+ // STORY-0091. Built the first time `crosstab` is read (`_crosstab`).
659
+ this._crosstabLayout = null;
660
+ if (this.config.selectionMode === 'cells') {
661
+ const clash = ['selectToggle', 'selectMode', 'keyboardNav'].filter((k) => !!this.config[k]);
662
+ if (clash.length) {
663
+ console.warn(`[DataTable] ${clash.join('/')} cannot be combined with selectionMode: 'cells'; ignored.`);
664
+ for (const k of clash) this.config[k] = false;
665
+ }
666
+ this._cellGrid = new CellGrid(this);
667
+ } else if (this.config.editable) {
668
+ console.warn("[DataTable] `editable` needs selectionMode: 'cells'; ignored.");
669
+ }
670
+ if (this.config.staged && !this._cellGrid) {
671
+ console.warn('[DataTable] `staged` needs `editable`; ignored.');
672
+ }
673
+ if (this.config.fillHandle && !this._cellGrid) {
674
+ console.warn('[DataTable] `fillHandle` needs `staged`; ignored.');
675
+ }
676
+ if (this.config.pasteable && !this._cellGrid) {
677
+ console.warn('[DataTable] `pasteable` needs `staged`; ignored.');
678
+ }
679
+ // STORY-0091. A crosstab has no group row: its spans are `headerRows`.
680
+ if (Array.isArray(this.config.columnGroups) && this.config.columnGroups.length && this._crosstab()) {
681
+ console.warn('[DataTable] `columnGroups` cannot be combined with `crosstab` (use crosstab.headerRows); ignored.');
682
+ }
683
+
558
684
  // STORY-0016. The select column is drawn for the table's whole life or
559
685
  // not at all, so the drawn column indices (and the widths kept by them)
560
686
  // never move when the mode flips.
@@ -584,6 +710,15 @@ export class DataTable {
584
710
  // below, which may replace them with the remembered set.
585
711
  this._hidden = new Set();
586
712
  this._setHidden(this.config.hiddenColumns);
713
+ // STORY-0086. The order asked for (null: natural), before the restore
714
+ // below, which may replace it with the remembered one.
715
+ this._order = null;
716
+ this._askedOrder = null;
717
+ this._askedUnchecked = false;
718
+ if (!this._setOrder(this.config.columnOrder, true)) {
719
+ console.warn('[DataTable] columnOrder is not an order of the columns; ignored.');
720
+ }
721
+ this._reorder = this.config.reorderable ? new ColumnReorder(this) : null;
587
722
 
588
723
  // 0.5.0. The open row's key, and the failure on show (`setError`).
589
724
  this._activeKey = this.config.activeRow == null ? null : String(this.config.activeRow);
@@ -723,6 +858,11 @@ export class DataTable {
723
858
  this._setHidden(blob.hidden);
724
859
  applied = true;
725
860
  }
861
+ // STORY-0086. The order, before the widths for the same reason. One
862
+ // saved for a different number of columns is no order of these.
863
+ if (Array.isArray(blob.order) && this._setOrder(blob.order)) {
864
+ applied = true;
865
+ }
726
866
  if (blob.colWidths && typeof blob.colWidths === 'object') {
727
867
  // Stored under string keys (JSON) → coerce back to ints.
728
868
  this._colWidths = {};
@@ -756,7 +896,11 @@ export class DataTable {
756
896
  + (this.config.showRowNumbers ? '|#' : '')
757
897
  // STORY-0012. Widths are by drawn column, so a different set drawn
758
898
  // is a different schema — unless `setColumnVisible` carried them.
759
- + (this._hidden?.size ? `|-${this.getHiddenColumns().join(',')}` : '');
899
+ + (this._hidden?.size ? `|-${this.getHiddenColumns().join(',')}` : '')
900
+ // STORY-0086. And a different order — unless `moveColumn` carried them.
901
+ + (this._columnOrder() ? `|>${this._columnOrder().join(',')}` : '')
902
+ // STORY-0066. A placeholder column moves the drawn indices too.
903
+ + (this._colGroups()?.sig() || '');
760
904
  }
761
905
 
762
906
  /** Snapshot the persistable slice of state to the project store.
@@ -777,6 +921,8 @@ export class DataTable {
777
921
  // STORY-0012. Only for a table that hides columns or lets them be
778
922
  // hidden.
779
923
  ...(this._columnsOn() ? { hidden: this.getHiddenColumns() } : {}),
924
+ // STORY-0086. Only for a table that orders its columns.
925
+ ...(this._orderOn() ? { order: this.getColumnOrder() } : {}),
780
926
  });
781
927
  }
782
928
 
@@ -807,7 +953,36 @@ export class DataTable {
807
953
  /** The cells a drawn row has: the shown columns, and the row number. */
808
954
  _domColCount() {
809
955
  const n = Math.max(0, (this.config.headers?.length || 0) - this._hidden.size);
810
- return n + this._lead().all;
956
+ // STORY-0066: an empty group's placeholder column is drawn too.
957
+ return n + this._lead().all + (this._colGroups()?.placeholderCount() || 0)
958
+ // STORY-0091: an empty axis's one column.
959
+ + (this._crosstab()?.extraColumns() || 0);
960
+ }
961
+
962
+ /** STORY-0066. The column groups (column_groups.js) while `columnGroups`
963
+ * holds any; null otherwise. */
964
+ _colGroups() {
965
+ const spec = this.config.columnGroups;
966
+ if (!Array.isArray(spec) || !spec.length) return null;
967
+ // STORY-0091. A crosstab's spanned rows are its `headerRows`; the
968
+ // split layout's group row has no header table to stand in.
969
+ if (this._crosstab()) return null;
970
+ return (this._columnGroups ||= new ColumnGroups(this));
971
+ }
972
+
973
+ /** STORY-0091. The crosstab layout (crosstab.js) while `crosstab` is an
974
+ * object; null otherwise. */
975
+ _crosstab() {
976
+ const spec = this.config.crosstab;
977
+ if (!spec || typeof spec !== 'object') return null;
978
+ return (this._crosstabLayout ||= new Crosstab(this));
979
+ }
980
+
981
+ /** STORY-0066. The structural edits (table_structure.js) while any of
982
+ * their options is set; null otherwise. */
983
+ _structure() {
984
+ if (!STRUCTURE_OPTIONS.some((k) => typeof this.config[k] === 'function')) return null;
985
+ return (this._tableStructure ||= new TableStructure(this));
811
986
  }
812
987
 
813
988
  /** STORY-0016. The drawn columns in front of the data: the select column
@@ -817,25 +992,36 @@ export class DataTable {
817
992
  return { select, all: select + (this.config.showRowNumbers ? 1 : 0) };
818
993
  }
819
994
 
995
+ /** The data columns drawn, in the order drawn (STORY-0086): the order
996
+ * without the hidden ones. */
997
+ _shownColumns() {
998
+ return this._rowColumns(this.config.headers?.length || 0);
999
+ }
1000
+
1001
+ /** The data indices of a row of `count` cells to draw, in order: the
1002
+ * shown columns, and any cell past the headers after them. */
1003
+ _rowColumns(count) {
1004
+ return cellOrder(this._columnOrder(), count).filter((c) => !this._isHidden(c));
1005
+ }
1006
+
820
1007
  /** A data column's drawn index (row number included), or -1 when hidden. */
821
1008
  _domIdxOf(colIdx) {
822
- if (this._isHidden(colIdx)) return -1;
823
- let dom = this._lead().all;
824
- for (const h of this._hidden) if (h < colIdx) dom--;
825
- return dom + colIdx;
1009
+ // STORY-0066. With a placeholder drawn, the groups' layout says.
1010
+ const slot = this._colGroups()?.slotOf(colIdx) ?? -1;
1011
+ if (slot >= 0) return this._lead().all + slot;
1012
+ const at = this._shownColumns().indexOf(colIdx);
1013
+ return at < 0 ? -1 : this._lead().all + at;
826
1014
  }
827
1015
 
828
1016
  /** A drawn cell's data column, or null for the row number. */
829
1017
  _dataIdxOfDom(domIdx) {
830
- let i = domIdx - this._lead().all;
1018
+ const i = domIdx - this._lead().all;
831
1019
  if (i < 0) return null;
832
- const n = this.config.headers?.length || 0;
833
- for (let c = 0; c < n; c++) {
834
- if (this._isHidden(c)) continue;
835
- if (i === 0) return c;
836
- i--;
837
- }
838
- return null;
1020
+ // STORY-0066. With a placeholder drawn, the groups' layout says (and
1021
+ // a placeholder is no data column).
1022
+ const slot = this._colGroups()?.slotAt(i);
1023
+ if (slot !== undefined) return slot;
1024
+ return this._shownColumns()[i] ?? null;
839
1025
  }
840
1026
 
841
1027
  /** The columns not drawn, by data index, ascending. */
@@ -868,12 +1054,24 @@ export class DataTable {
868
1054
  * that stay drawn to their new places, and tell whoever listens. */
869
1055
  _applyHidden(next) {
870
1056
  const before = this.getHiddenColumns();
1057
+ this._carryWidths(() => this._setHidden([...next]));
1058
+ const after = this.getHiddenColumns();
1059
+ if (before.join() === after.join()) return;
1060
+ this._savePersisted();
1061
+ if (this._wrapperEl) this.render();
1062
+ this._columnsChanged();
1063
+ }
1064
+
1065
+ /** STORY-0012/0086. Run `change` (a new hidden set or order), carrying
1066
+ * the drawn widths of the columns drawn before and after to their new
1067
+ * places. */
1068
+ _carryWidths(change) {
871
1069
  const n = this.config.headers?.length || 0;
872
1070
  const old = this._colWidths;
873
1071
  const kept = this._colWidthsSig === this._colSig();
874
1072
  const domBefore = [];
875
1073
  for (let c = 0; c < n; c++) domBefore[c] = this._domIdxOf(c);
876
- this._setHidden([...next]);
1074
+ change();
877
1075
  if (kept) {
878
1076
  const widths = {};
879
1077
  for (let i = 0; i < this._lead().all; i++) if (old[i] != null) widths[i] = old[i];
@@ -885,21 +1083,109 @@ export class DataTable {
885
1083
  this._colWidths = widths;
886
1084
  this._colWidthsSig = this._colSig();
887
1085
  }
888
- const after = this.getHiddenColumns();
889
- if (before.join() === after.join()) return;
890
- this._savePersisted();
891
- if (this._wrapperEl) this.render();
892
- try { this.config.onColumnsChange?.(after); } catch (err) {
1086
+ }
1087
+
1088
+ /** `onColumnsChange(hidden, {hidden, order})`, after a change of either. */
1089
+ _columnsChanged() {
1090
+ const hidden = this.getHiddenColumns();
1091
+ try { this.config.onColumnsChange?.(hidden, { hidden, order: this.getColumnOrder() }); } catch (err) {
893
1092
  console.error('[DataTable] onColumnsChange threw', err);
894
1093
  }
895
1094
  }
896
1095
 
897
- /** `columnChooser`: the header's right-click menu, one checkable item a
898
- * column. */
899
- _showColumnChooser(clientX, clientY) {
1096
+ // ─────────────────────────────────────────────────────────────────
1097
+ // STORY-0086 — the order the columns are drawn in (column_order.js)
1098
+ // ─────────────────────────────────────────────────────────────────
1099
+
1100
+ /** Whether this table orders its columns at all (and so saves the order). */
1101
+ _orderOn() {
1102
+ return !!this.config.reorderable || Array.isArray(this.config.columnOrder) || !!this._orderTouched;
1103
+ }
1104
+
1105
+ /** Take `list` as the order (`asked`: it is `columnOrder`, kept apart as
1106
+ * the fallback). Refused (false, nothing changed) when it is no
1107
+ * permutation of the columns; a table with no headers yet keeps it for
1108
+ * the headers `setData` brings, and `_checkAskedOrder` checks it then. */
1109
+ _setOrder(list, asked = false) {
1110
+ const n = this.config.headers?.length || 0;
1111
+ if (list != null && (!Array.isArray(list) || (n && !isColumnOrder(list, n)))) return false;
1112
+ this._order = list == null ? null : list.map(Number);
1113
+ if (asked) {
1114
+ this._askedOrder = this._order;
1115
+ this._askedUnchecked = !!this._order && !n;
1116
+ }
1117
+ return true;
1118
+ }
1119
+
1120
+ /** The first headers have come: a `columnOrder` taken before them, unchecked,
1121
+ * that is no order of them is dropped with the same warning a table with
1122
+ * headers gives at once — once, as the check is made once. */
1123
+ _checkAskedOrder() {
1124
+ const n = this.config.headers?.length || 0;
1125
+ if (!this._askedUnchecked || !n) return;
1126
+ this._askedUnchecked = false;
1127
+ if (isColumnOrder(this._askedOrder, n)) return;
1128
+ console.warn('[DataTable] columnOrder is not an order of the columns; ignored.');
1129
+ if (this._order === this._askedOrder) this._order = null;
1130
+ this._askedOrder = null;
1131
+ }
1132
+
1133
+ /** The order in force, or null for the natural one. One rule, whether the
1134
+ * headers were known when an order came or not: the first of the live
1135
+ * order (dragged, moved or restored) and `columnOrder` that is an order
1136
+ * of today's columns — so a saved order of the wrong length never
1137
+ * displaces `columnOrder`. */
1138
+ _columnOrder() {
1139
+ const n = this.config.headers?.length || 0;
1140
+ for (const o of [this._order, this._askedOrder]) {
1141
+ if (o && isColumnOrder(o, n)) return o;
1142
+ }
1143
+ return null;
1144
+ }
1145
+
1146
+ /**
1147
+ * Every data column's index, hidden ones included, in the order drawn.
1148
+ * @returns {number[]}
1149
+ */
1150
+ getColumnOrder() {
1151
+ return (this._columnOrder() || naturalOrder(this.config.headers?.length || 0)).slice();
1152
+ }
1153
+
1154
+ /**
1155
+ * Move the column at position `from` of `getColumnOrder()` to position
1156
+ * `to`, the others closing up behind it: from the natural order,
1157
+ * `moveColumn(3, 1)` draws 0, 3, 1, 2. The widths stay with their columns.
1158
+ * Saved under `persistKey`, and `onColumnsChange` is told.
1159
+ *
1160
+ * @param {number} from
1161
+ * @param {number} to
1162
+ * @returns {boolean} whether the order changed
1163
+ */
1164
+ moveColumn(from, to) {
1165
+ const order = this.getColumnOrder();
1166
+ const n = order.length;
1167
+ if (![from, to].every((i) => Number.isInteger(i) && i >= 0 && i < n) || from === to) return false;
1168
+ this._applyOrder(moveInOrder(order, from, to));
1169
+ return true;
1170
+ }
1171
+
1172
+ /** Swap in a new order (a permutation, or null), widths carried, and
1173
+ * tell whoever listens. */
1174
+ _applyOrder(next) {
1175
+ const before = this.getColumnOrder().join();
1176
+ this._orderTouched = true;
1177
+ this._carryWidths(() => this._setOrder(next));
1178
+ if (this.getColumnOrder().join() === before) return;
1179
+ this._savePersisted();
1180
+ if (this._wrapperEl) this.render();
1181
+ this._columnsChanged();
1182
+ }
1183
+
1184
+ /** `columnChooser`'s items: one checkable item a column. */
1185
+ _columnChooserItems() {
900
1186
  const headers = this.config.headers || [];
901
1187
  const lastShown = headers.length - this._hidden.size <= 1;
902
- const items = headers.map((h, c) => {
1188
+ return headers.map((h, c) => {
903
1189
  const shown = !this._isHidden(c);
904
1190
  return {
905
1191
  label: String(h ?? ''),
@@ -910,7 +1196,69 @@ export class DataTable {
910
1196
  onChoose: () => this.setColumnVisible(c, !shown),
911
1197
  };
912
1198
  });
913
- this._showContextMenu(clientX, clientY, { ctx: null, items, copy: false });
1199
+ }
1200
+
1201
+ /**
1202
+ * STORY-0003. The header's right-click menu for a press on `target`: the
1203
+ * consumer's `headerMenu` items for the data column under it, then — with
1204
+ * `columnChooser` — the chooser's items behind a separator. Empty when
1205
+ * there is nothing to show.
1206
+ */
1207
+ _headerMenuItems(target) {
1208
+ const items = [];
1209
+ const hook = this.config.headerMenu;
1210
+ const colIdx = this._headerColumnAt(target);
1211
+ if (typeof hook === 'function' && colIdx !== null) {
1212
+ let asked = null;
1213
+ try {
1214
+ asked = hook(colIdx, {
1215
+ colIdx,
1216
+ header: this.config.headers?.[colIdx],
1217
+ hiddenColumns: this.getHiddenColumns(),
1218
+ table: this,
1219
+ });
1220
+ } catch (err) {
1221
+ console.error('[DataTable] headerMenu threw', err);
1222
+ }
1223
+ for (const item of Array.isArray(asked) ? asked.filter(Boolean) : []) {
1224
+ if (item.separator) { items.push({ separator: true }); continue; }
1225
+ items.push({
1226
+ ...item,
1227
+ onChoose: () => {
1228
+ try { this.config.onHeaderMenuAction?.(item.action, colIdx); } catch (err) {
1229
+ console.error('[DataTable] onHeaderMenuAction threw', err);
1230
+ }
1231
+ },
1232
+ });
1233
+ }
1234
+ }
1235
+ // STORY-0086. Move left / Move right, for the data column under it.
1236
+ if (this._reorder && colIdx !== null) {
1237
+ const moves = this._reorder.menuItems(colIdx);
1238
+ if (moves.length && items.length) items.push({ separator: true });
1239
+ items.push(...moves);
1240
+ }
1241
+ // STORY-0066. Rename, delete, add a row.
1242
+ const structural = this._structure()?.headerItems(colIdx) || [];
1243
+ if (structural.length) {
1244
+ if (items.length) items.push({ separator: true });
1245
+ items.push(...structural);
1246
+ }
1247
+ if (this.config.columnChooser) {
1248
+ if (items.length) items.push({ separator: true });
1249
+ items.push(...this._columnChooserItems());
1250
+ }
1251
+ return items;
1252
+ }
1253
+
1254
+ /** The data column of a header cell (the header row, or the filter row
1255
+ * under it), or null off one — the row number, the select column. */
1256
+ _headerColumnAt(target) {
1257
+ const cell = target?.closest?.('th, td');
1258
+ const tr = cell?.parentNode;
1259
+ if (!tr || tr.parentNode?.tagName !== 'THEAD') return null;
1260
+ if (tr.children.length !== this._domColCount()) return null;
1261
+ return this._dataIdxOfDom(Array.prototype.indexOf.call(tr.children, cell));
914
1262
  }
915
1263
 
916
1264
  /** STORY-0010 `persistPage`: the client page changed, so save it. */
@@ -964,18 +1312,31 @@ export class DataTable {
964
1312
  this._setHidden(updates.hiddenColumns);
965
1313
  if (this.getHiddenColumns().join() !== before) {
966
1314
  this._savePersisted();
967
- try { this.config.onColumnsChange?.(this.getHiddenColumns()); } catch (err) {
968
- console.error('[DataTable] onColumnsChange threw', err);
969
- }
1315
+ this._columnsChanged();
970
1316
  }
971
1317
  } else if (updates.headers && this._hidden.size) {
972
1318
  this._setHidden([...this._hidden]);
973
1319
  }
1320
+ // STORY-0086. A new order asked for applies at once (refused, with a
1321
+ // warning, when it is no order of the columns); new headers keep the
1322
+ // order while it still fits them.
1323
+ if (Object.prototype.hasOwnProperty.call(updates, 'columnOrder')) {
1324
+ const before = this.getColumnOrder().join();
1325
+ if (!this._setOrder(updates.columnOrder, true)) {
1326
+ console.warn('[DataTable] columnOrder is not an order of the columns; ignored.');
1327
+ } else if (this.getColumnOrder().join() !== before) {
1328
+ this._savePersisted();
1329
+ this._columnsChanged();
1330
+ }
1331
+ } else if (updates.headers) {
1332
+ this._checkAskedOrder();
1333
+ }
974
1334
 
975
1335
  // Reset state if rows changed
976
1336
  if (updates.rows) {
977
1337
  this._state.selected.clear();
978
1338
  this._state.anchorIndex = null;
1339
+ this._cellGrid?.newRows();
979
1340
  if (this._state.offset >= updates.rows.length) {
980
1341
  this._state.offset = 0;
981
1342
  }
@@ -1061,6 +1422,12 @@ export class DataTable {
1061
1422
  * @param {number[]} indices - Row indices to select
1062
1423
  */
1063
1424
  setSelection(indices) {
1425
+ // STORY-0003. In cell mode, the cells of those rows: one range from the
1426
+ // first of them to the last, as shown, across every column shown.
1427
+ if (this._cellGrid) {
1428
+ this._cellGrid.selectRows(indices);
1429
+ return;
1430
+ }
1064
1431
  this._state.selected.clear();
1065
1432
  for (const idx of indices) {
1066
1433
  if (idx >= 0 && idx < this.config.rows.length) {
@@ -1151,17 +1518,146 @@ export class DataTable {
1151
1518
  * Clear selection
1152
1519
  */
1153
1520
  clearSelection() {
1521
+ if (this._cellGrid) {
1522
+ this._cellGrid.clear();
1523
+ return;
1524
+ }
1154
1525
  this._state.selected.clear();
1155
1526
  this._state.anchorIndex = null;
1156
1527
  this._updateRowSelection();
1157
1528
  this._notifySelectionChange();
1158
1529
  }
1159
1530
 
1531
+ // ─────────────────────────────────────────────────────────────────
1532
+ // STORY-0003 — cell mode (`selectionMode: 'cells'`), see cell_grid.js
1533
+ // ─────────────────────────────────────────────────────────────────
1534
+
1535
+ /**
1536
+ * The cell selection: `{anchor, focus, range}`. `anchor` and `focus` (the
1537
+ * cursor) are `{row, col, rowIdx}` — `row` the position in the order
1538
+ * shown, `col` the data column, `rowIdx` the original row index — and
1539
+ * `range` is `{top, bottom, left, right, rows, columns}`: positions,
1540
+ * data columns at its edges, and the original row indices and data
1541
+ * columns it covers. All null with nothing selected. Null outside cell
1542
+ * mode.
1543
+ */
1544
+ getCellSelection() {
1545
+ return this._cellGrid ? this._cellGrid.get() : null;
1546
+ }
1547
+
1548
+ /**
1549
+ * Put the cursor on a cell (`row` a position in the order shown, `col` a
1550
+ * data column) — with `anchor`, select the range from there. `null`
1551
+ * clears. The row is brought into view. A no-op outside cell mode.
1552
+ *
1553
+ * @param {{row:number, col:number}|null} focus
1554
+ * @param {{row:number, col:number}} [anchor]
1555
+ */
1556
+ setCellSelection(focus, anchor = null) {
1557
+ this._cellGrid?.set(focus, anchor);
1558
+ }
1559
+
1560
+ /** Open the editor on a cell (position, data column), as F2 would.
1561
+ * Returns whether one opened. */
1562
+ editCell(row, col) {
1563
+ return !!this._cellGrid?.open(row, col);
1564
+ }
1565
+
1566
+ /** Whether a cell editor is open. */
1567
+ isEditing() {
1568
+ return !!this._cellGrid?.isEditing();
1569
+ }
1570
+
1571
+ // ── STORY-0088 `staged` ──────────────────────────────────────────────
1572
+
1573
+ /**
1574
+ * Send every staged cell to `onCommit({edits})`, in one call. Resolves
1575
+ * `{ok: true, committed}` (the staged cells and the undo ring are
1576
+ * cleared), or `{ok: false, errors}` (the cells refused are marked and
1577
+ * every staged value stays). An open editor's value is staged first.
1578
+ * Without `staged`, resolves `{ok: false, errors: []}`.
1579
+ *
1580
+ * @returns {Promise<{ok: boolean, committed?: number, errors?: Array<{rowIdx:number, colIdx:number, error:string}>}>}
1581
+ */
1582
+ commit() {
1583
+ const st = this._cellGrid?.staged;
1584
+ return st ? st.commit() : Promise.resolve({ ok: false, errors: [] });
1585
+ }
1586
+
1587
+ /** Drop every staged cell, its marks and the undo ring. Returns whether
1588
+ * there was anything to drop. */
1589
+ discard() {
1590
+ return !!this._cellGrid?.staged?.discard();
1591
+ }
1592
+
1593
+ /** Take the last staged edit back (Ctrl+Z). Returns whether it moved. */
1594
+ undo() {
1595
+ return !!this._cellGrid?.staged?.undo();
1596
+ }
1597
+
1598
+ /** Put the last edit taken back back again (Ctrl+Y). */
1599
+ redo() {
1600
+ return !!this._cellGrid?.staged?.redo();
1601
+ }
1602
+
1603
+ /** Every staged cell, `{rowIdx, colIdx, value, previous}`, by row then
1604
+ * column. Empty without `staged`. */
1605
+ getStagedEdits() {
1606
+ return this._cellGrid?.staged ? this._cellGrid.staged.edits() : [];
1607
+ }
1608
+
1609
+ /**
1610
+ * STORY-0089. Fill from the cell range to the cell `to` (`{row, col}`:
1611
+ * position in the order shown, data column), as dragging the fill handle
1612
+ * there does: staged as ONE undo entry, Ctrl's repeat as `forceCopy`.
1613
+ * Returns `{direction, count, staged, skipped}` —
1614
+ * `staged` the NUMBER of cells that changed, `skipped` the cells `editable` refused
1615
+ * (`{row, col, rowIdx}`) — or null when nothing was filled (no
1616
+ * `fillHandle`, no range, `to` inside it, an editor open, a commit out).
1617
+ */
1618
+ fill(to, { forceCopy = false } = {}) {
1619
+ return this._cellGrid?.fill ? this._cellGrid.fill.fill(to, { forceCopy }) : null;
1620
+ }
1621
+
1622
+ /**
1623
+ * STORY-0090. Paste tab-separated `text` at the cursor cell, as Ctrl+V
1624
+ * does: each value read by its column's parse, staged as ONE undo entry.
1625
+ * Resolves `{rows, cols, staged, skipped, refused, droppedColumns,
1626
+ * appended}` — `staged` the NUMBER of cells that changed, `skipped` the
1627
+ * cells `editable` refused (`{row, col, rowIdx}`), `refused` the values
1628
+ * that did not parse (`{row, col, rowIdx, text, error}`),
1629
+ * `droppedColumns` the columns past the last one, `appended` the rows
1630
+ * handed to `onRowsAppend` — or null when nothing was pasted (no
1631
+ * `pasteable`, empty text, no cursor, an editor open, a commit or another
1632
+ * paste out, an overrun `onPasteOverrun` did not accept).
1633
+ *
1634
+ * @param {string} text
1635
+ * @returns {Promise<object|null>}
1636
+ */
1637
+ paste(text) {
1638
+ return this._cellGrid?.paste ? this._cellGrid.paste.paste(text) : Promise.resolve(null);
1639
+ }
1640
+
1641
+ /** Why sort, filters and pages are held — a staged edit — or null; with
1642
+ * a reason, `what` is refused out loud. */
1643
+ _stagedRefusal(what) {
1644
+ const why = this._cellGrid?.staged?.freezeReason() || null;
1645
+ if (why) console.warn(`[DataTable] ${what} refused: ${why}`);
1646
+ return why;
1647
+ }
1648
+
1649
+ /** STORY-0066. Open the inline renamer on a data column's header (needs
1650
+ * `onColumnRename`). Returns whether it opened. */
1651
+ renameColumn(colIdx) {
1652
+ return !!this._structure()?.rename(colIdx);
1653
+ }
1654
+
1160
1655
  /**
1161
1656
  * Go to specific page
1162
1657
  * @param {number} page - Page number (0-indexed)
1163
1658
  */
1164
1659
  goToPage(page) {
1660
+ if (this._stagedRefusal('goToPage')) return;
1165
1661
  // 0.7.0 `dataSource`: the page is the SOURCE's — asked for, and drawn
1166
1662
  // when it answers (the promise). Past a known total, the last page.
1167
1663
  // (The release review: this set an offset nothing under a data source
@@ -1185,6 +1681,7 @@ export class DataTable {
1185
1681
  * @param {number} rowIndex - Row index
1186
1682
  */
1187
1683
  goToRow(rowIndex) {
1684
+ if (this._stagedRefusal('goToRow')) return;
1188
1685
  // 0.7.0 `dataSource`: the page that holds the row, from the source.
1189
1686
  if (this.config.dataSource) {
1190
1687
  const size = this._sourcePageSize();
@@ -1203,6 +1700,7 @@ export class DataTable {
1203
1700
  * @param {boolean} [ascending] - Sort direction (toggles if same column)
1204
1701
  */
1205
1702
  sortBy(colIndex, ascending) {
1703
+ if (this._stagedRefusal('sortBy')) return;
1206
1704
  if (this._state.sortColumn === colIndex && ascending === undefined) {
1207
1705
  this._state.sortAscending = !this._state.sortAscending;
1208
1706
  } else {
@@ -1245,6 +1743,7 @@ export class DataTable {
1245
1743
  * Clear all column filters
1246
1744
  */
1247
1745
  clearFilters() {
1746
+ if (this._stagedRefusal('clearFilters')) return;
1248
1747
  this._state.filters.clear();
1249
1748
  this._processedRows = null;
1250
1749
  this._processedIndexMap = null;
@@ -1310,6 +1809,12 @@ export class DataTable {
1310
1809
  * Render the table
1311
1810
  */
1312
1811
  render() {
1812
+ // STORY-0003. An open cell editor is not torn down by a redraw: the
1813
+ // render waits for its commit or cancel, and runs then.
1814
+ if (this._cellGrid?.isEditing()) {
1815
+ this._cellGrid.renderWaiting = true;
1816
+ return;
1817
+ }
1313
1818
  // Capture active filter input before re-render
1314
1819
  const activeFilterColIdx = this._getActiveFilterColIdx();
1315
1820
  // KEYBOARD FOCUS SURVIVES A RE-RENDER. Rendering replaces the table
@@ -1433,7 +1938,9 @@ export class DataTable {
1433
1938
  // empty one must keep the filter row — or a filter that matched nothing
1434
1939
  // could never be cleared. It goes through `_createTable` and gets its
1435
1940
  // empty row there.
1436
- const keepFilterRow = rows.length === 0 && !!this.config.dataSource && this.config.filterable;
1941
+ // STORY-0091: a crosstab keeps its header rows and its corner too.
1942
+ const keepFilterRow = rows.length === 0
1943
+ && ((!!this.config.dataSource && this.config.filterable) || !!this._crosstab());
1437
1944
  if (rows.length === 0 && !keepFilterRow) {
1438
1945
  // Empty state — render the same chrome as the data table
1439
1946
  // (header row in its own non-scrolling wrap) followed by a
@@ -1456,13 +1963,12 @@ export class DataTable {
1456
1963
  th.textContent = '#';
1457
1964
  tr.appendChild(th);
1458
1965
  }
1459
- headers.forEach((h, colIdx) => {
1460
- if (this._isHidden(colIdx)) return;
1966
+ for (const colIdx of this._shownColumns()) {
1461
1967
  const th = document.createElement('th');
1462
1968
  th.className = this._columnTypes[colIdx] || 'text';
1463
- th.textContent = h;
1969
+ th.textContent = headers[colIdx];
1464
1970
  tr.appendChild(th);
1465
- });
1971
+ }
1466
1972
  thead.appendChild(tr);
1467
1973
  headerTable.appendChild(thead);
1468
1974
  headerWrap.appendChild(headerTable);
@@ -1489,7 +1995,15 @@ export class DataTable {
1489
1995
  this._tableEl = fullTable;
1490
1996
  // Extract thead → header table in its own non-scrolling
1491
1997
  // container. The body table keeps tbody only.
1492
- const thead = fullTable.querySelector('thead');
1998
+ // STORY-0091. A crosstab keeps its `thead` in the body table: one
1999
+ // table in one scroller, pinned by `position: sticky`.
2000
+ const crosstab = this._crosstab();
2001
+ const thead = crosstab ? null : fullTable.querySelector('thead');
2002
+ if (crosstab) {
2003
+ crosstab.decorate(fullTable);
2004
+ this._headerTableEl = null;
2005
+ this._headerWrapEl = null;
2006
+ }
1493
2007
  if (thead) {
1494
2008
  const headerWrap = document.createElement('div');
1495
2009
  headerWrap.className = 'twm-preview-table-header-wrap';
@@ -1515,17 +2029,30 @@ export class DataTable {
1515
2029
  }
1516
2030
 
1517
2031
  this._tableWrapEl = tableWrap;
1518
- // STORY-0012 `columnChooser`: the header's right-click.
1519
- if (this.config.columnChooser && this._headerTableEl) {
2032
+ // STORY-0012 `columnChooser` and STORY-0003 `headerMenu`: the header's
2033
+ // right-click. With neither item to show, the page's own menu.
2034
+ // STORY-0066: the groups' row and placeholder columns, the renamer's
2035
+ // labels and the consumer's column actions, on the header just built.
2036
+ this._colGroups()?.decorate(this._headerWrapEl, this._headerTableEl);
2037
+ this._structure()?.decorate(this._headerTableEl);
2038
+ if ((this.config.columnChooser || typeof this.config.headerMenu === 'function' || this._reorder
2039
+ || this._structure()?.hasHeaderItems()) && this._headerTableEl) {
1520
2040
  const head = this._headerTableEl;
1521
2041
  const onHeadMenu = (ev) => {
2042
+ const items = this._headerMenuItems(ev.target);
2043
+ if (!items.length) return;
1522
2044
  ev.preventDefault();
1523
2045
  ev.stopPropagation();
1524
- setTimeout(() => this._showColumnChooser(ev.clientX, ev.clientY), 0);
2046
+ setTimeout(() => this._showContextMenu(ev.clientX, ev.clientY,
2047
+ { ctx: null, items, copy: false }), 0);
1525
2048
  };
1526
2049
  head.addEventListener('contextmenu', onHeadMenu);
1527
2050
  this._disposers.push(() => head.removeEventListener('contextmenu', onHeadMenu));
1528
2051
  }
2052
+ // STORY-0086 `reorderable`: the header's drag.
2053
+ if (this._reorder && this._headerTableEl) {
2054
+ this._reorder.install(this._headerTableEl.querySelector('thead > tr'));
2055
+ }
1529
2056
  // 0.7.0 `pagerPosition: 'bottom'`: under the rows.
1530
2057
  if (this._paginationEl && this.config.pagerPosition === 'bottom') {
1531
2058
  this._paginationEl.classList.add('twm-pagination-controls--bottom');
@@ -1541,6 +2068,8 @@ export class DataTable {
1541
2068
  if (this._pagingEl) this._wrapperEl.appendChild(this._pagingEl);
1542
2069
  }
1543
2070
  this.container.appendChild(this._wrapperEl);
2071
+ // STORY-0091. The crosstab's offsets, measured now the table is in.
2072
+ this._crosstab()?.install();
1544
2073
  // 0.6.1 `scrollLock`: THIS render's body box joins the group, and leaves
1545
2074
  // it with the next render's cleanup or the dispose.
1546
2075
  if (this.config.scrollLock && typeof this.config.scrollLock.attach === 'function') {
@@ -1618,7 +2147,8 @@ export class DataTable {
1618
2147
  if (rows.length > 0 && (this.config.selectable || this.config.copyable || !this.config.readonly
1619
2148
  || typeof this.config.onRowActivate === 'function'
1620
2149
  || typeof this.config.rowDetail === 'function'
1621
- || typeof this.config.onRowKeyDown === 'function')) {
2150
+ || typeof this.config.onRowKeyDown === 'function'
2151
+ || this._cellGrid)) {
1622
2152
  this._installInteractions();
1623
2153
  }
1624
2154
 
@@ -1658,10 +2188,21 @@ export class DataTable {
1658
2188
  }
1659
2189
 
1660
2190
  /**
1661
- * Copy selected rows to clipboard
2191
+ * Copy selected rows to clipboard — in cell mode (STORY-0085) the cells
2192
+ * selected, or the cursor cell alone, as tab-separated raw values with no
2193
+ * header line; cell mode has no CSV copy
1662
2194
  * @param {'tsv'|'csv'} [format='tsv'] - Output format
1663
2195
  */
1664
2196
  async copyToClipboard(format = 'tsv') {
2197
+ // STORY-0085. Cell mode copies the cells selected, not their rows,
2198
+ // and only as TSV.
2199
+ if (this._cellGrid) {
2200
+ if (format === 'csv') {
2201
+ console.warn('[DataTable] copyToClipboard: cell mode has no CSV copy; nothing copied');
2202
+ return;
2203
+ }
2204
+ return this._copyCells();
2205
+ }
1665
2206
  const indices = this.getSelection();
1666
2207
  if (indices.length === 0) {
1667
2208
  this._notify('Copy', 'No rows selected.', 'warn');
@@ -1669,14 +2210,14 @@ export class DataTable {
1669
2210
  }
1670
2211
 
1671
2212
  const { headers, rows } = this.config;
1672
- // STORY-0012. The columns shown — all of them, unless some are hidden.
1673
- const shown = (_, colIdx) => !this._isHidden(colIdx);
1674
- const matrix = [headers.filter(shown)];
2213
+ // STORY-0012. The columns shown — all of them, unless some are hidden —
2214
+ // and (STORY-0086) in the order shown.
2215
+ const matrix = [this._rowColumns(headers.length).map((c) => headers[c])];
1675
2216
 
1676
2217
  for (const idx of indices) {
1677
2218
  const row = rows[idx];
1678
2219
  if (row) {
1679
- matrix.push(row.map((val, colIdx) => this._formatValue(val, colIdx)).filter(shown));
2220
+ matrix.push(this._rowColumns(row.length).map((c) => this._formatValue(row[c], c)));
1680
2221
  }
1681
2222
  }
1682
2223
 
@@ -1704,6 +2245,36 @@ export class DataTable {
1704
2245
  this._hideContextMenu();
1705
2246
  }
1706
2247
 
2248
+ /**
2249
+ * STORY-0085. Cell mode's copy: the range (or the cursor cell alone), its
2250
+ * raw values as TSV — Excel's quoting, `tsv.js` — with no header line.
2251
+ */
2252
+ async _copyCells() {
2253
+ const matrix = this._cellGrid.copyMatrix();
2254
+ if (!matrix || !matrix.length) {
2255
+ this._notify('Copy', 'No cells selected.', 'warn');
2256
+ this._hideContextMenu();
2257
+ return;
2258
+ }
2259
+ const text = toTSV(matrix);
2260
+ let ok = false;
2261
+ try {
2262
+ await navigator.clipboard.writeText(text);
2263
+ ok = true;
2264
+ } catch (_) {
2265
+ ok = this._fallbackCopy(text);
2266
+ }
2267
+ if (ok) {
2268
+ const n = matrix.length;
2269
+ const m = matrix[0].length;
2270
+ const what = n * m === 1 ? '1 cell' : `${n} × ${m} cells`;
2271
+ this._notify('Copy', `Copied ${what}.`, 'info');
2272
+ } else {
2273
+ this._notify('Copy', 'Clipboard unavailable.', 'warn');
2274
+ }
2275
+ this._hideContextMenu();
2276
+ }
2277
+
1707
2278
  /**
1708
2279
  * Download all data as CSV
1709
2280
  * @param {string} [filename='data.csv'] - Filename for download
@@ -1715,12 +2286,11 @@ export class DataTable {
1715
2286
  return;
1716
2287
  }
1717
2288
 
1718
- const shown = (_, colIdx) => !this._isHidden(colIdx);
1719
- const lines = [headers.filter(shown).join(',')];
2289
+ const lines = [this._rowColumns(headers.length).map((c) => headers[c]).join(',')];
1720
2290
  for (const row of rows) {
1721
- lines.push(row.map((val, colIdx) =>
1722
- this._csvEscape(this._formatValue(val, colIdx))
1723
- ).filter(shown).join(','));
2291
+ lines.push(this._rowColumns(row.length).map((c) =>
2292
+ this._csvEscape(this._formatValue(row[c], c))
2293
+ ).join(','));
1724
2294
  }
1725
2295
 
1726
2296
  const csv = lines.join('\n');
@@ -1783,6 +2353,10 @@ export class DataTable {
1783
2353
  }
1784
2354
 
1785
2355
  _teardown(emptyHost) {
2356
+ // STORY-0003. An open editor goes, committing nothing.
2357
+ this._cellGrid?.dispose();
2358
+ // STORY-0066. And an open column renamer.
2359
+ this._tableStructure?.dispose();
1786
2360
  autoDisposeUnwatch(this);
1787
2361
  this._autoSeen = false;
1788
2362
  // 0.7.0. A grace still running, the follow timer, a request still out
@@ -2073,8 +2647,8 @@ export class DataTable {
2073
2647
  tr.appendChild(th);
2074
2648
  }
2075
2649
 
2076
- headers.forEach((header, colIdx) => {
2077
- if (this._isHidden(colIdx)) return;
2650
+ this._shownColumns().forEach((colIdx) => {
2651
+ const header = headers[colIdx];
2078
2652
  const th = document.createElement('th');
2079
2653
  th.className = 'data-table__filter-cell';
2080
2654
 
@@ -2450,6 +3024,7 @@ export class DataTable {
2450
3024
  }
2451
3025
 
2452
3026
  _onFilterInput(colIdx, value) {
3027
+ if (this._stagedRefusal('A filter')) return;
2453
3028
  // 0.7.0. A filter value need not be text (a control's array, a range),
2454
3029
  // and an empty one of those is no filter. Text is judged as in 0.4.
2455
3030
  const empty = typeof value === 'string' ? !value : this._isEmptyFilterValue(value);
@@ -2653,6 +3228,7 @@ export class DataTable {
2653
3228
 
2654
3229
  // Page change handler - supports both client-side and server-side pagination
2655
3230
  const handlePageChange = (newOffset) => {
3231
+ if (this._stagedRefusal('Turning the page')) return;
2656
3232
  if (isServerSide) {
2657
3233
  // Auto-spinner if the consumer returns a Promise.
2658
3234
  this._awaitWithSpinner(onPageChange(newOffset, pageSize));
@@ -2730,8 +3306,11 @@ export class DataTable {
2730
3306
  _createExportBtn() {
2731
3307
  const name = this.config.exportFilename;
2732
3308
  const filename = (typeof name === 'function' ? name(this) : name) || 'data.csv';
2733
- return this._createPaginationBtn('download', 'Download visible rows as CSV', false,
2734
- () => this.downloadCSV(filename));
3309
+ const btn = this._createPaginationBtn('download', 'Download visible rows as CSV', false,
3310
+ () => this.downloadCSV(filename));
3311
+ // STORY-0088. Moves no rows, so a staged edit's freeze leaves it be.
3312
+ btn.classList.add('twm-pagination-btn--export');
3313
+ return btn;
2735
3314
  }
2736
3315
 
2737
3316
  /**
@@ -2871,6 +3450,7 @@ export class DataTable {
2871
3450
  this._wrapperEl.appendChild(next);
2872
3451
  }
2873
3452
  this._pagingEl = next;
3453
+ this._cellGrid?.staged?.paintFreeze(); // STORY-0088
2874
3454
  }
2875
3455
 
2876
3456
  /**
@@ -2881,6 +3461,7 @@ export class DataTable {
2881
3461
  */
2882
3462
  _pagingCall(kind, fn, ...args) {
2883
3463
  if (typeof fn !== 'function' || this._pagingBusy) return;
3464
+ if (this._stagedRefusal(`paging ${kind}`)) return;
2884
3465
  let result;
2885
3466
  try {
2886
3467
  result = fn(...args);
@@ -2912,6 +3493,9 @@ export class DataTable {
2912
3493
  _pagingMaybeLoadMore() {
2913
3494
  const p = this._pagingConfig();
2914
3495
  if (!p || !p.hasMore || this._pagingBusy) return;
3496
+ // STORY-0088. Paging is held while cells are staged — quietly here:
3497
+ // a scroll is not a click on a control that could say why.
3498
+ if (this._cellGrid?.staged?.freezeReason()) return;
2915
3499
  // 0.7.0: not while the source is answering another question (a sort, a
2916
3500
  // filter, a refresh) — those rows are about to be replaced.
2917
3501
  if (this.config.dataSource && this._source?.inFlight) return;
@@ -3081,6 +3665,11 @@ export class DataTable {
3081
3665
  // hundred of a thousand.
3082
3666
  const pageRows = isServerSide || !this.config.pagination || this._pagingConfig()
3083
3667
  ? processedRows : processedRows.slice(offset, offset + pageSize);
3668
+ // STORY-0003. Where the rows drawn stand in the order shown.
3669
+ this._pageSpan = {
3670
+ start: pageRows === processedRows ? 0 : offset,
3671
+ count: pageRows.length,
3672
+ };
3084
3673
 
3085
3674
  const table = document.createElement('table');
3086
3675
  table.className = this._tableClassName();
@@ -3098,8 +3687,8 @@ export class DataTable {
3098
3687
  headerRow.appendChild(th);
3099
3688
  }
3100
3689
 
3101
- headers.forEach((header, colIdx) => {
3102
- if (this._isHidden(colIdx)) return;
3690
+ this._shownColumns().forEach((colIdx) => {
3691
+ const header = headers[colIdx];
3103
3692
  const th = document.createElement('th');
3104
3693
  th.className = this._columnTypes[colIdx] || 'text';
3105
3694
 
@@ -3217,8 +3806,11 @@ export class DataTable {
3217
3806
  // 0.5.0. The row's place in the order shown (for 'position' numbers).
3218
3807
  tr.__position = (this._positionBase || 0) + localIdx;
3219
3808
  tr.className = 'data-preview-row';
3809
+ // STORY-0003. The row's position in the order shown, for cell mode.
3810
+ if (this._cellGrid) tr.__cellPos = (this._pageSpan?.start || 0) + localIdx;
3220
3811
 
3221
- if (this._state.selected.has(globalIdx)) {
3812
+ // STORY-0003. In cell mode the CELLS are lit, not the rows.
3813
+ if (this._state.selected.has(globalIdx) && !this._cellGrid) {
3222
3814
  tr.classList.add('selected');
3223
3815
  }
3224
3816
  // 0.5.0. The consumer's row classes and attributes, and the open mark.
@@ -3532,6 +4124,7 @@ export class DataTable {
3532
4124
  const wanted = new Set(want);
3533
4125
  for (const [i, tr] of v.map) {
3534
4126
  if (!wanted.has(i)) {
4127
+ this._cellGrid?.rowLeaving(tr); // STORY-0003: an open editor commits
3535
4128
  tr.remove();
3536
4129
  this._removeDetailPieces(tr); // 0.7.0: its detail goes with it
3537
4130
  v.map.delete(i);
@@ -3759,10 +4352,10 @@ export class DataTable {
3759
4352
  const changedCells = diff?.cells
3760
4353
  ? (diff.cells instanceof Set ? diff.cells : new Set(diff.cells))
3761
4354
  : null;
3762
- for (let colIdx = 0; colIdx < row.length; colIdx++) {
3763
- if (this._isHidden(colIdx)) continue;
4355
+ for (const colIdx of this._rowColumns(row.length)) {
3764
4356
  const td = document.createElement('td');
3765
4357
  td.className = this._columnTypes[colIdx] || 'text';
4358
+ if (this._cellGrid) td.__colIdx = colIdx; // STORY-0003
3766
4359
 
3767
4360
  // Use custom cell renderer if provided
3768
4361
  const value = row[colIdx];
@@ -3813,6 +4406,12 @@ export class DataTable {
3813
4406
 
3814
4407
  tr.appendChild(td);
3815
4408
  }
4409
+ // STORY-0066. An empty group's placeholder column.
4410
+ this._colGroups()?.fillRow(tr);
4411
+ // STORY-0091. The crosstab's pinned cells, and an empty axis's cell.
4412
+ this._crosstab()?.fillRow(tr);
4413
+ // STORY-0003. The selection, and a commit's pending or refused mark.
4414
+ this._cellGrid?.decorateRow(tr);
3816
4415
  }
3817
4416
 
3818
4417
  /** Re-render ONE row in place, preserving everything around it.
@@ -3835,6 +4434,9 @@ export class DataTable {
3835
4434
  * - keyboard focus, because the focused element's position is recorded
3836
4435
  * before the replace and restored after.
3837
4436
  *
4437
+ * STORY-0003: on the row an open cell editor is in, the data is stored
4438
+ * now and the row is drawn when the editor closes (true is returned).
4439
+ *
3838
4440
  * @param {number} index row index as tracked by `tr.__rowIndex`
3839
4441
  * @param {any[]} row the new cell values
3840
4442
  * @returns {boolean} false when the row is not currently rendered
@@ -3867,6 +4469,9 @@ export class DataTable {
3867
4469
  this.config.rows[index] = row;
3868
4470
  }
3869
4471
  if (this._virt) this._virtSetRow(index, row);
4472
+ // STORY-0003. The row holds an open cell editor: the rebuild would take
4473
+ // it out of the document, so the row is drawn when the editor closes.
4474
+ if (this._cellGrid?.holdRow(tr, index)) return true;
3870
4475
 
3871
4476
  // Record focus BEFORE the replace: `replaceChildren` detaches the
3872
4477
  // focused cell and the browser moves focus to <body>.
@@ -3913,7 +4518,7 @@ export class DataTable {
3913
4518
  });
3914
4519
  }
3915
4520
 
3916
- tr.classList.toggle('selected', this._state?.selected?.has(index) === true);
4521
+ tr.classList.toggle('selected', this._state?.selected?.has(index) === true && !this._cellGrid);
3917
4522
  // 0.5.0. The row's data changed, so its classes, attributes, key and
3918
4523
  // open mark are asked again (a key paused, a delivery that died).
3919
4524
  this._decorateRow(tr, row, index);
@@ -4473,6 +5078,7 @@ export class DataTable {
4473
5078
 
4474
5079
  /** Go to the page at `offset` (paging back stops following). */
4475
5080
  _sourceGoTo(offset) {
5081
+ if (this._stagedRefusal('Turning the page')) return undefined;
4476
5082
  const next = Math.max(0, offset | 0);
4477
5083
  // PAGING AWAY TURNS FOLLOWING OFF, and coming back does not turn it on
4478
5084
  // again by itself: a reader who left the tail to read an old page is
@@ -4764,6 +5370,7 @@ export class DataTable {
4764
5370
  const maxJump = Number.isFinite(c.maxJumpOffset) ? c.maxJumpOffset : null;
4765
5371
 
4766
5372
  const go = (newOffset) => {
5373
+ if (this._stagedRefusal('Turning the page')) return;
4767
5374
  if (source) { this._sourceGoTo(newOffset); return; }
4768
5375
  if (server) { this._awaitWithSpinner(c.onPageChange(newOffset, pageSize)); return; }
4769
5376
  this._state.offset = newOffset;
@@ -4953,6 +5560,7 @@ export class DataTable {
4953
5560
  * value is whatever the column's control works in — text for the 0.4 box.
4954
5561
  */
4955
5562
  setFilter(colIdx, value) {
5563
+ if (this._stagedRefusal('setFilter')) return;
4956
5564
  const textBox = !this._filterKindFor(colIdx, (this.config.headers || [])[colIdx]);
4957
5565
  const v = textBox && value != null && typeof value !== 'string' ? String(value) : value;
4958
5566
  if (textBox) {
@@ -5101,6 +5709,8 @@ export class DataTable {
5101
5709
  // STORY-0016. The select column, and whether the mode is on.
5102
5710
  if (this._gutter) names.push('twm-dt--select');
5103
5711
  if (this._selecting) names.push('twm-dt--selecting');
5712
+ // STORY-0003.
5713
+ if (this._cellGrid) names.push('twm-dt--cells');
5104
5714
  return names.join(' ');
5105
5715
  }
5106
5716
 
@@ -6056,6 +6666,9 @@ export class DataTable {
6056
6666
  headerWrap.scrollLeft = x;
6057
6667
  const residual = x - headerWrap.scrollLeft;
6058
6668
  headerTable.style.transform = residual ? `translateX(${-residual}px)` : '';
6669
+ // STORY-0066. The group row follows the header: every width pass and
6670
+ // drag ends here, and so does every sideways scroll.
6671
+ this._colGroups()?.align();
6059
6672
  }
6060
6673
 
6061
6674
  /** Drag-to-resize: hang a thin grab handle off the right edge of
@@ -6261,11 +6874,17 @@ export class DataTable {
6261
6874
 
6262
6875
  const { selectable, copyable } = this.config;
6263
6876
  // STORY-0010. The consumer's own items install the menu by themselves.
6264
- const customMenu = this.config.contextMenuItems != null;
6877
+ // STORY-0066: the structural row items make a menu of their own.
6878
+ const customMenu = this.config.contextMenuItems != null || !!this._structure()?.hasRowItems();
6265
6879
  const tbody = this._tbodyEl;
6266
6880
  const table = this._tableEl;
6267
6881
 
6268
- if (selectable) {
6882
+ // STORY-0003. Cell mode: the cells' own pointer and keys, bound before
6883
+ // the activation, detail and copy listeners so a key it takes stops.
6884
+ // The row click below is not cell mode's.
6885
+ if (this._cellGrid) this._cellGrid.install(tbody, table);
6886
+
6887
+ if (selectable && !this._cellGrid) {
6269
6888
  const handleRowClick = (event) => {
6270
6889
  const rowEl = event.target.closest('tr');
6271
6890
  if (!rowEl || rowEl.__rowIndex === undefined) return;
@@ -6512,7 +7131,9 @@ export class DataTable {
6512
7131
  } else if (key === 'a' && selectable) {
6513
7132
  event.preventDefault();
6514
7133
  this._selectAllShown();
6515
- } else if (key === 'c') {
7134
+ } else if (key === 'c' && !(event.shiftKey && this._cellGrid)) {
7135
+ // STORY-0085. Cell mode binds Ctrl+C only: it has no
7136
+ // CSV copy, so Ctrl+Shift+C is left to the page.
6516
7137
  event.preventDefault();
6517
7138
  this.copyToClipboard(event.shiftKey ? 'csv' : 'tsv');
6518
7139
  }
@@ -6537,7 +7158,7 @@ export class DataTable {
6537
7158
  const idx = tr.__rowIndex;
6538
7159
  if (idx !== undefined) {
6539
7160
  const on = this._state.selected.has(idx);
6540
- tr.classList.toggle('selected', on);
7161
+ tr.classList.toggle('selected', on && !this._cellGrid);
6541
7162
  // STORY-0016.
6542
7163
  const box = this._gutter ? tr.querySelector(':scope > td.twm-dt-select-cell > input') : null;
6543
7164
  if (box) box.checked = on;
@@ -6663,7 +7284,7 @@ export class DataTable {
6663
7284
  menu.style.display = 'none';
6664
7285
  menu.style.position = 'fixed';
6665
7286
  menu.style.zIndex = '10001';
6666
- menu.innerHTML = COPY_MENU_HTML;
7287
+ menu.innerHTML = copyMenuHtml(!!this._cellGrid);
6667
7288
  document.body.appendChild(menu);
6668
7289
 
6669
7290
  menu.addEventListener('click', (event) => {
@@ -6818,7 +7439,11 @@ export class DataTable {
6818
7439
  items = null;
6819
7440
  }
6820
7441
  }
6821
- return Array.isArray(items) ? items.filter(Boolean) : [];
7442
+ const list = Array.isArray(items) ? items.filter(Boolean) : [];
7443
+ // STORY-0066. Insert and delete a row, after the consumer's items.
7444
+ const structural = this._structure()?.rowItems(ctx.rowIdx) || [];
7445
+ if (structural.length && list.length) list.push({ separator: true });
7446
+ return list.concat(structural);
6822
7447
  }
6823
7448
 
6824
7449
  /**
@@ -6832,7 +7457,7 @@ export class DataTable {
6832
7457
  this._contextMenuCtx = custom ? custom.ctx : null;
6833
7458
  if (!custom) {
6834
7459
  if (this._contextMenuCustom) {
6835
- menu.innerHTML = COPY_MENU_HTML;
7460
+ menu.innerHTML = copyMenuHtml(!!this._cellGrid);
6836
7461
  this._contextMenuCustom = false;
6837
7462
  }
6838
7463
  return;
@@ -6877,7 +7502,7 @@ export class DataTable {
6877
7502
  sep.className = 'twm-context-menu__separator';
6878
7503
  menu.appendChild(sep);
6879
7504
  }
6880
- menu.insertAdjacentHTML('beforeend', COPY_MENU_HTML);
7505
+ menu.insertAdjacentHTML('beforeend', copyMenuHtml(!!this._cellGrid));
6881
7506
  }
6882
7507
  }
6883
7508