flexdesk 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (251) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/LICENSE +0 -0
  3. package/README.md +2261 -42
  4. package/THIRD_PARTY_LICENSES.md +0 -0
  5. package/css/base.css +1834 -75
  6. package/css/flexdesk.css +3373 -238
  7. package/css/overrides.css +16 -0
  8. package/css/reset.css +0 -0
  9. package/css/tokens.css +0 -0
  10. package/dist/canvas.js +6 -239
  11. package/dist/canvas.js.map +3 -3
  12. package/dist/charts.js +709 -18
  13. package/dist/charts.js.map +4 -4
  14. package/dist/chunk-3PHCPZHT.js +0 -0
  15. package/dist/chunk-3PHCPZHT.js.map +0 -0
  16. package/dist/chunk-4A7SJC3T.js +503 -0
  17. package/dist/chunk-4A7SJC3T.js.map +7 -0
  18. package/dist/{chunk-6JSOVNID.js → chunk-CVMCZZX2.js} +635 -10
  19. package/dist/chunk-CVMCZZX2.js.map +7 -0
  20. package/dist/chunk-D3COLZZ2.js +1311 -0
  21. package/dist/chunk-D3COLZZ2.js.map +7 -0
  22. package/dist/chunk-FOOS3T5L.js +0 -0
  23. package/dist/chunk-FOOS3T5L.js.map +0 -0
  24. package/dist/{chunk-SN4LSOAR.js → chunk-GOQLR5WR.js} +452 -2
  25. package/dist/chunk-GOQLR5WR.js.map +7 -0
  26. package/dist/{chunk-FL5KFNQH.js → chunk-HNU6MM2B.js} +52 -17
  27. package/dist/chunk-HNU6MM2B.js.map +7 -0
  28. package/dist/chunk-HYJYYEPK.js +375 -0
  29. package/dist/chunk-HYJYYEPK.js.map +7 -0
  30. package/dist/chunk-JKZLNKG6.js +2041 -0
  31. package/dist/chunk-JKZLNKG6.js.map +7 -0
  32. package/dist/chunk-JYWURG5T.js +0 -0
  33. package/dist/chunk-JYWURG5T.js.map +0 -0
  34. package/dist/{chunk-RQM5Z2MU.js → chunk-MCIXFNVF.js} +633 -311
  35. package/dist/chunk-MCIXFNVF.js.map +7 -0
  36. package/dist/chunk-N7HC3ADV.js +30 -0
  37. package/dist/chunk-N7HC3ADV.js.map +7 -0
  38. package/dist/{chunk-FBGP5KN5.js → chunk-Q36EPT2R.js} +3268 -203
  39. package/dist/chunk-Q36EPT2R.js.map +7 -0
  40. package/dist/chunk-QWQJUDPD.js +610 -0
  41. package/dist/chunk-QWQJUDPD.js.map +7 -0
  42. package/dist/{chunk-NLTOY57M.js → chunk-UURZ37SD.js} +28 -48
  43. package/dist/chunk-UURZ37SD.js.map +7 -0
  44. package/dist/chunk-UWO43BZU.js +111 -0
  45. package/dist/chunk-UWO43BZU.js.map +7 -0
  46. package/dist/chunk-WVFGV5FT.js +0 -0
  47. package/dist/chunk-WVFGV5FT.js.map +0 -0
  48. package/dist/core.js +170 -118
  49. package/dist/core.js.map +2 -2
  50. package/dist/editor.js +156 -70
  51. package/dist/editor.js.map +2 -2
  52. package/dist/flexdesk.css +3373 -238
  53. package/dist/flow.js +2789 -306
  54. package/dist/flow.js.map +4 -4
  55. package/dist/host.js +109 -3
  56. package/dist/host.js.map +3 -3
  57. package/dist/reset.css +0 -0
  58. package/dist/tile_registry-6WZPTUZV.js +0 -0
  59. package/dist/tile_registry-6WZPTUZV.js.map +0 -0
  60. package/dist/tiles.js +1131 -253
  61. package/dist/tiles.js.map +4 -4
  62. package/dist/tokens.css +0 -0
  63. package/dist/widgets.js +3254 -2112
  64. package/dist/widgets.js.map +4 -4
  65. package/dist/wm.js +1227 -345
  66. package/dist/wm.js.map +4 -4
  67. package/package.json +3 -1
  68. package/src/canvas/canvas_adapter.js +5 -0
  69. package/src/canvas/edge_router.js +0 -0
  70. package/src/canvas/graph/connector_router.js +0 -0
  71. package/src/canvas/graph/history_service.js +0 -0
  72. package/src/canvas/graph/node_platform.js +0 -0
  73. package/src/canvas/index.js +1 -0
  74. package/src/canvas/layered_layout.js +244 -0
  75. package/src/charts/chart_types.js +0 -0
  76. package/src/charts/data_series_plot_window.js +0 -0
  77. package/src/charts/downsample.js +2 -2
  78. package/src/charts/plot_config.js +53 -29
  79. package/src/charts/plot_config_editor.js +627 -0
  80. package/src/charts/plot_grid.js +449 -0
  81. package/src/charts/plot_popout_window.js +103 -317
  82. package/src/charts/plotly_wrapper.js +22 -2
  83. package/src/charts/series_transforms.js +206 -0
  84. package/src/core/event_bus.js +0 -0
  85. package/src/core/logging.js +1 -1
  86. package/src/core/settings.js +105 -19
  87. package/src/core/state_guard.js +93 -51
  88. package/src/core/state_guard_config.js +85 -74
  89. package/src/core/state_machine.js +0 -0
  90. package/src/editor/editor_pane.js +3 -3
  91. package/src/editor/monaco_editor_factory.js +124 -60
  92. package/src/editor/monaco_loader.js +103 -28
  93. package/src/editor/notebook_search.js +0 -0
  94. package/src/editor/notebook_tab_bar.js +38 -52
  95. package/src/editor/notebook_undo_manager.js +0 -0
  96. package/src/editor/split_pane_container.js +6 -3
  97. package/src/flow/canvas_editor/connections.js +90 -0
  98. package/src/flow/canvas_editor/editor.js +2351 -153
  99. package/src/flow/canvas_editor/index.js +2 -1
  100. package/src/flow/canvas_editor/layout.js +119 -12
  101. package/src/flow/canvas_editor/strings.js +80 -0
  102. package/src/flow/kit/always_before.js +0 -0
  103. package/src/flow/kit/catalogue.js +97 -3
  104. package/src/flow/kit/chip_input.js +0 -0
  105. package/src/flow/kit/dom.js +0 -0
  106. package/src/flow/kit/findings.js +0 -0
  107. package/src/flow/kit/history.js +0 -0
  108. package/src/flow/kit/index.js +3 -3
  109. package/src/flow/kit/keys.js +7 -1
  110. package/src/flow/kit/logic_graph.js +97 -15
  111. package/src/flow/kit/references.js +0 -0
  112. package/src/flow/kit/settings_panel.js +0 -0
  113. package/src/flow/kit/settings_schema.js +2 -1
  114. package/src/flow/kit/step_picker.js +0 -0
  115. package/src/flow/kit/strings.js +3 -1
  116. package/src/flow/kit/value_picker.js +0 -0
  117. package/src/flow/kit/widgets.js +86 -109
  118. package/src/flow/lanes/editor.js +136 -14
  119. package/src/flow/lanes/index.js +3 -1
  120. package/src/flow/lanes/layout.js +0 -0
  121. package/src/flow/lanes/pipeline.js +44 -2
  122. package/src/flow/lanes/preview.js +0 -0
  123. package/src/flow/lanes/strings.js +1 -0
  124. package/src/flow/outline/editor.js +0 -0
  125. package/src/flow/outline/index.js +0 -0
  126. package/src/flow/outline/mapping.js +0 -0
  127. package/src/flow/outline/menu.js +0 -0
  128. package/src/flow/outline/operations.js +0 -0
  129. package/src/flow/outline/recognise.js +0 -0
  130. package/src/flow/outline/strings.js +0 -0
  131. package/src/help/help_modal.js +168 -42
  132. package/src/help/help_registry.js +12 -1
  133. package/src/host/external_links.js +0 -0
  134. package/src/host/file_picker.js +117 -0
  135. package/src/host/host.js +10 -2
  136. package/src/host/photino_host.js +9 -0
  137. package/src/host/photino_host.md +21 -3
  138. package/src/host/pywebview_host.js +10 -0
  139. package/src/tiles/chart_tiles.js +53 -245
  140. package/src/tiles/config_schema.js +8 -0
  141. package/src/tiles/figure_tile.js +322 -0
  142. package/src/tiles/layout_persistence.js +3 -3
  143. package/src/tiles/list_tiles.js +422 -0
  144. package/src/tiles/scope_picker.js +96 -0
  145. package/src/tiles/tile_base.js +12 -1
  146. package/src/tiles/tile_config_form.js +442 -0
  147. package/src/tiles/tile_grid.js +38 -1
  148. package/src/tiles/tile_registry.js +0 -0
  149. package/src/tiling/bottom_panel_tabs.js +67 -99
  150. package/src/tiling/command_palette.js +64 -19
  151. package/src/tiling/content_registry.js +4 -1
  152. package/src/tiling/desktops.js +11 -1
  153. package/src/tiling/entity_sources.js +0 -0
  154. package/src/tiling/keymap.js +403 -128
  155. package/src/tiling/kind_taxonomy.js +78 -0
  156. package/src/tiling/landing_table.js +0 -0
  157. package/src/tiling/loading_overlay.js +0 -0
  158. package/src/tiling/nav_panel.js +59 -10
  159. package/src/tiling/page_factory.js +5 -5
  160. package/src/tiling/panel_availability.js +49 -0
  161. package/src/tiling/panel_keys.js +0 -0
  162. package/src/tiling/shell.js +114 -12
  163. package/src/tiling/tab_strip.js +0 -0
  164. package/src/tiling/tile_breadcrumb.js +0 -0
  165. package/src/tiling/tile_renderer.js +105 -9
  166. package/src/tiling/tile_tab_menu.js +0 -0
  167. package/src/tiling/tile_tree.js +0 -0
  168. package/src/tiling/wm.js +562 -30
  169. package/src/tiling/zoom.js +0 -0
  170. package/src/ui/base/component_base.js +0 -0
  171. package/src/ui/base/controller_base.js +0 -0
  172. package/src/ui/base/page_base.js +0 -0
  173. package/src/ui/components/about_dialog.js +0 -0
  174. package/src/ui/components/action_dropdown.js +30 -2
  175. package/src/ui/components/alert_log.js +0 -0
  176. package/src/ui/components/alerts_pane.js +0 -0
  177. package/src/ui/components/attribute_list_editor.js +0 -0
  178. package/src/ui/components/autocomplete_field.js +0 -0
  179. package/src/ui/components/cell_editor_host.js +211 -0
  180. package/src/ui/components/cell_grid.js +648 -0
  181. package/src/ui/components/cell_selection.js +220 -0
  182. package/src/ui/components/choice_controls.js +351 -0
  183. package/src/ui/components/color_field.js +211 -0
  184. package/src/ui/components/color_list_field.js +210 -0
  185. package/src/ui/components/column_fit.js +0 -0
  186. package/src/ui/components/column_groups.js +314 -0
  187. package/src/ui/components/column_order.js +142 -0
  188. package/src/ui/components/computing_status_window.js +98 -25
  189. package/src/ui/components/confirm_dialog.js +81 -38
  190. package/src/ui/components/context_menu.js +2 -2
  191. package/src/ui/components/conversation_view.js +440 -0
  192. package/src/ui/components/crosstab.js +289 -0
  193. package/src/ui/components/data_table.js +601 -62
  194. package/src/ui/components/detail_header.js +22 -11
  195. package/src/ui/components/document_outline.js +397 -0
  196. package/src/ui/components/drag_reorder.js +0 -0
  197. package/src/ui/components/fact_table.js +0 -0
  198. package/src/ui/components/fill_handle.js +322 -0
  199. package/src/ui/components/gallery_picker.js +19 -8
  200. package/src/ui/components/header_actions.js +55 -0
  201. package/src/ui/components/inline_renamer.js +0 -0
  202. package/src/ui/components/job_progress.js +51 -0
  203. package/src/ui/components/managed_window.js +27 -4
  204. package/src/ui/components/menu_bar.js +745 -0
  205. package/src/ui/components/modal.js +449 -75
  206. package/src/ui/components/notification_history.js +3 -3
  207. package/src/ui/components/progress.js +483 -0
  208. package/src/ui/components/record_list.js +0 -0
  209. package/src/ui/components/row_activation.js +0 -0
  210. package/src/ui/components/section_stack.js +298 -0
  211. package/src/ui/components/series.js +173 -0
  212. package/src/ui/components/settings_view.js +634 -0
  213. package/src/ui/components/slide_out_panel.js +43 -6
  214. package/src/ui/components/slider_field.js +275 -18
  215. package/src/ui/components/sortable_list.js +26 -4
  216. package/src/ui/components/staged_edits.js +343 -0
  217. package/src/ui/components/staged_history.js +172 -0
  218. package/src/ui/components/table_filter_chips.js +0 -0
  219. package/src/ui/components/table_state_store.js +0 -0
  220. package/src/ui/components/table_structure.js +182 -0
  221. package/src/ui/components/table_values.js +0 -0
  222. package/src/ui/components/tabs.js +497 -0
  223. package/src/ui/components/tag_input.js +215 -0
  224. package/src/ui/components/toast.js +25 -8
  225. package/src/ui/components/toggle_field.js +175 -0
  226. package/src/ui/components/tree_view.js +55 -34
  227. package/src/ui/components/window_taskbar.js +0 -0
  228. package/src/ui/components/wizard.js +563 -0
  229. package/src/ui/controllers/window_chrome_controller.js +2 -2
  230. package/src/ui/notification_center.js +375 -119
  231. package/src/ui/utils/auto_scrollbars.js +0 -0
  232. package/src/ui/utils/markdown.js +709 -0
  233. package/src/ui/utils/native_context_menu.js +0 -0
  234. package/src/ui/utils/overlay_scrollbar.js +16 -12
  235. package/src/ui/utils/raf_resize_observer.js +0 -0
  236. package/src/ui/utils/scroll_lock.js +0 -0
  237. package/src/ui/utils/strip_scroll.js +55 -0
  238. package/src/ui/utils/tooltip_service.js +0 -8
  239. package/dist/chunk-6JSOVNID.js.map +0 -7
  240. package/dist/chunk-CWPMLX67.js +0 -475
  241. package/dist/chunk-CWPMLX67.js.map +0 -7
  242. package/dist/chunk-FBGP5KN5.js.map +0 -7
  243. package/dist/chunk-FL5KFNQH.js.map +0 -7
  244. package/dist/chunk-NLTOY57M.js.map +0 -7
  245. package/dist/chunk-NNFTETK3.js +0 -633
  246. package/dist/chunk-NNFTETK3.js.map +0 -7
  247. package/dist/chunk-O5OHMWBB.js +0 -284
  248. package/dist/chunk-O5OHMWBB.js.map +0 -7
  249. package/dist/chunk-RQM5Z2MU.js.map +0 -7
  250. package/dist/chunk-SN4LSOAR.js.map +0 -7
  251. package/src/ui/controllers/panel_state_machine.js +0 -634
@@ -8,6 +8,7 @@
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,11 @@ 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 { ColumnReorder, cellOrder, isColumnOrder, moveInOrder, naturalOrder } from './column_order.js';
31
+ import { ColumnGroups } from './column_groups.js';
32
+ import { Crosstab } from './crosstab.js';
33
+ import { STRUCTURE_OPTIONS, TableStructure } from './table_structure.js';
28
34
 
29
35
  // 0.7.0: the built-in chips filter control, for a consumer that wants to build
30
36
  // one outside a table (the same control `filterKinds: {kind: 'chips'}` draws).
@@ -401,8 +407,42 @@ const AUTOSIZE_MAX_PX = 520;
401
407
  * @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
408
  * @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
409
  * @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})`).
410
+ * @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()`.
411
+ * @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.
412
+ * @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
413
  * @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.
414
+ *
415
+ * ── STORY-0003, every one of them off unless asked for ─────────────────────
416
+ * @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`.
417
+ * @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.
418
+ * @property {Object} [editors] - `{[colIdx|type]: factory}`, the editor for a column (by index first, then by its type: `columnTypes`' or the detected 'num'/'text'). A factory is `({value, row, rowIdx, colIdx, column: {index, header, type}, initialText, commit(value), cancel()}) => Element | {el, getValue?, focus?, destroy?}`; an input, select or textarea returned as it is commits its `value`. Absent: a text box (cell_editor_host.js `createTextEditor`), which commits the value unchanged when left as it was.
419
+ * @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.
420
+ * @property {Function} [onCellSelectionChange] - `(getCellSelection()) => void`, after every change of the cells selected.
421
+ * @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.
422
+ * @property {Function} [onHeaderMenuAction] - `(action, colIdx) => void`, when one of `headerMenu`'s items is chosen; the menu is hidden first.
423
+ *
424
+ * ── STORY-0088, off unless asked for ──────────────────────────────────────
425
+ * @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.
426
+ * @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.
427
+ * @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.
428
+ * @property {Function} [onStagedChange] - With `staged`: `({count, canUndo, canRedo, committing}) => void` after every change of the staged cells.
429
+ * @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`.
430
+ * @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."
431
+ *
432
+ * ── STORY-0066, every one of them off unless asked for. THE CONSUMER OWNS THE DATA: each edit is a call, answered with `setData` ──
433
+ * @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.
434
+ * @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)`.
435
+ * @property {Function} [onGroupAction] - `(action, groupKey) => void`.
436
+ * @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.
437
+ * @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.
438
+ * @property {Function} [onColumnAction] - `(action, colIdx) => void`.
439
+ * @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.
440
+ * @property {Function} [onColumnDelete] - `(colIdx) => void`, from "Delete column" in the header's right-click menu.
441
+ * @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.
442
+ * @property {Function} [onRowDelete] - `(rowIdx) => void`, from "Delete row" in a row's right-click menu.
443
+ *
444
+ * ── STORY-0091, off unless asked for ──────────────────────────────────────
445
+ * @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
446
  */
407
447
 
408
448
  /** C10. When the scroll box has no layout — mounted hidden, or a DOM with no
@@ -548,13 +588,75 @@ export class DataTable {
548
588
  hiddenColumns: null,
549
589
  columnChooser: false,
550
590
  onColumnsChange: null,
591
+ // STORY-0086. Off — columns in their natural order, as in 0.16.
592
+ reorderable: false,
593
+ columnOrder: null,
551
594
  // STORY-0016. Off — no select column, as in 0.11 — until set.
552
595
  selectToggle: false,
553
596
  selectMode: false,
554
597
  onSelectModeChange: null,
598
+ // STORY-0003. Off — rows selected as in 0.16, no editing — until
599
+ // set. The typedef says what each does.
600
+ selectionMode: 'rows',
601
+ editable: false,
602
+ editors: null,
603
+ onCellCommit: null,
604
+ onCellSelectionChange: null,
605
+ headerMenu: null,
606
+ onHeaderMenuAction: null,
607
+ // STORY-0088. Off — commits go to onCellCommit — until set.
608
+ staged: false,
609
+ historyLimit: 100,
610
+ onCommit: null,
611
+ onStagedChange: null,
612
+ stagedRefusal: null,
613
+ // STORY-0066. Off — one header row, no structural edits — until
614
+ // set. The typedef says what each does.
615
+ columnGroups: null,
616
+ groupActions: null,
617
+ onGroupAction: null,
618
+ onColumnAdd: null,
619
+ columnActions: null,
620
+ onColumnAction: null,
621
+ onColumnRename: null,
622
+ onColumnDelete: null,
623
+ onRowAdd: null,
624
+ onRowDelete: null,
625
+ // STORY-0089. Off — no fill handle — until set.
626
+ fillHandle: false,
555
627
  ...config,
556
628
  };
557
629
 
630
+ // STORY-0003. Cell mode is the table's for its whole life. The row
631
+ // modes it replaces are ignored beside it, out loud.
632
+ this._cellGrid = null;
633
+ // STORY-0066. Built the first time their options are set (see
634
+ // `_colGroups`, `_structure`); a table without them never builds either.
635
+ this._columnGroups = null;
636
+ this._tableStructure = null;
637
+ // STORY-0091. Built the first time `crosstab` is read (`_crosstab`).
638
+ this._crosstabLayout = null;
639
+ if (this.config.selectionMode === 'cells') {
640
+ const clash = ['selectToggle', 'selectMode', 'keyboardNav'].filter((k) => !!this.config[k]);
641
+ if (clash.length) {
642
+ console.warn(`[DataTable] ${clash.join('/')} cannot be combined with selectionMode: 'cells'; ignored.`);
643
+ for (const k of clash) this.config[k] = false;
644
+ }
645
+ this._cellGrid = new CellGrid(this);
646
+ } else if (this.config.editable) {
647
+ console.warn("[DataTable] `editable` needs selectionMode: 'cells'; ignored.");
648
+ }
649
+ if (this.config.staged && !this._cellGrid) {
650
+ console.warn('[DataTable] `staged` needs `editable`; ignored.');
651
+ }
652
+ if (this.config.fillHandle && !this._cellGrid) {
653
+ console.warn('[DataTable] `fillHandle` needs `staged`; ignored.');
654
+ }
655
+ // STORY-0091. A crosstab has no group row: its spans are `headerRows`.
656
+ if (Array.isArray(this.config.columnGroups) && this.config.columnGroups.length && this._crosstab()) {
657
+ console.warn('[DataTable] `columnGroups` cannot be combined with `crosstab` (use crosstab.headerRows); ignored.');
658
+ }
659
+
558
660
  // STORY-0016. The select column is drawn for the table's whole life or
559
661
  // not at all, so the drawn column indices (and the widths kept by them)
560
662
  // never move when the mode flips.
@@ -584,6 +686,15 @@ export class DataTable {
584
686
  // below, which may replace them with the remembered set.
585
687
  this._hidden = new Set();
586
688
  this._setHidden(this.config.hiddenColumns);
689
+ // STORY-0086. The order asked for (null: natural), before the restore
690
+ // below, which may replace it with the remembered one.
691
+ this._order = null;
692
+ this._askedOrder = null;
693
+ this._askedUnchecked = false;
694
+ if (!this._setOrder(this.config.columnOrder, true)) {
695
+ console.warn('[DataTable] columnOrder is not an order of the columns; ignored.');
696
+ }
697
+ this._reorder = this.config.reorderable ? new ColumnReorder(this) : null;
587
698
 
588
699
  // 0.5.0. The open row's key, and the failure on show (`setError`).
589
700
  this._activeKey = this.config.activeRow == null ? null : String(this.config.activeRow);
@@ -723,6 +834,11 @@ export class DataTable {
723
834
  this._setHidden(blob.hidden);
724
835
  applied = true;
725
836
  }
837
+ // STORY-0086. The order, before the widths for the same reason. One
838
+ // saved for a different number of columns is no order of these.
839
+ if (Array.isArray(blob.order) && this._setOrder(blob.order)) {
840
+ applied = true;
841
+ }
726
842
  if (blob.colWidths && typeof blob.colWidths === 'object') {
727
843
  // Stored under string keys (JSON) → coerce back to ints.
728
844
  this._colWidths = {};
@@ -756,7 +872,11 @@ export class DataTable {
756
872
  + (this.config.showRowNumbers ? '|#' : '')
757
873
  // STORY-0012. Widths are by drawn column, so a different set drawn
758
874
  // is a different schema — unless `setColumnVisible` carried them.
759
- + (this._hidden?.size ? `|-${this.getHiddenColumns().join(',')}` : '');
875
+ + (this._hidden?.size ? `|-${this.getHiddenColumns().join(',')}` : '')
876
+ // STORY-0086. And a different order — unless `moveColumn` carried them.
877
+ + (this._columnOrder() ? `|>${this._columnOrder().join(',')}` : '')
878
+ // STORY-0066. A placeholder column moves the drawn indices too.
879
+ + (this._colGroups()?.sig() || '');
760
880
  }
761
881
 
762
882
  /** Snapshot the persistable slice of state to the project store.
@@ -777,6 +897,8 @@ export class DataTable {
777
897
  // STORY-0012. Only for a table that hides columns or lets them be
778
898
  // hidden.
779
899
  ...(this._columnsOn() ? { hidden: this.getHiddenColumns() } : {}),
900
+ // STORY-0086. Only for a table that orders its columns.
901
+ ...(this._orderOn() ? { order: this.getColumnOrder() } : {}),
780
902
  });
781
903
  }
782
904
 
@@ -807,7 +929,36 @@ export class DataTable {
807
929
  /** The cells a drawn row has: the shown columns, and the row number. */
808
930
  _domColCount() {
809
931
  const n = Math.max(0, (this.config.headers?.length || 0) - this._hidden.size);
810
- return n + this._lead().all;
932
+ // STORY-0066: an empty group's placeholder column is drawn too.
933
+ return n + this._lead().all + (this._colGroups()?.placeholderCount() || 0)
934
+ // STORY-0091: an empty axis's one column.
935
+ + (this._crosstab()?.extraColumns() || 0);
936
+ }
937
+
938
+ /** STORY-0066. The column groups (column_groups.js) while `columnGroups`
939
+ * holds any; null otherwise. */
940
+ _colGroups() {
941
+ const spec = this.config.columnGroups;
942
+ if (!Array.isArray(spec) || !spec.length) return null;
943
+ // STORY-0091. A crosstab's spanned rows are its `headerRows`; the
944
+ // split layout's group row has no header table to stand in.
945
+ if (this._crosstab()) return null;
946
+ return (this._columnGroups ||= new ColumnGroups(this));
947
+ }
948
+
949
+ /** STORY-0091. The crosstab layout (crosstab.js) while `crosstab` is an
950
+ * object; null otherwise. */
951
+ _crosstab() {
952
+ const spec = this.config.crosstab;
953
+ if (!spec || typeof spec !== 'object') return null;
954
+ return (this._crosstabLayout ||= new Crosstab(this));
955
+ }
956
+
957
+ /** STORY-0066. The structural edits (table_structure.js) while any of
958
+ * their options is set; null otherwise. */
959
+ _structure() {
960
+ if (!STRUCTURE_OPTIONS.some((k) => typeof this.config[k] === 'function')) return null;
961
+ return (this._tableStructure ||= new TableStructure(this));
811
962
  }
812
963
 
813
964
  /** STORY-0016. The drawn columns in front of the data: the select column
@@ -817,25 +968,36 @@ export class DataTable {
817
968
  return { select, all: select + (this.config.showRowNumbers ? 1 : 0) };
818
969
  }
819
970
 
971
+ /** The data columns drawn, in the order drawn (STORY-0086): the order
972
+ * without the hidden ones. */
973
+ _shownColumns() {
974
+ return this._rowColumns(this.config.headers?.length || 0);
975
+ }
976
+
977
+ /** The data indices of a row of `count` cells to draw, in order: the
978
+ * shown columns, and any cell past the headers after them. */
979
+ _rowColumns(count) {
980
+ return cellOrder(this._columnOrder(), count).filter((c) => !this._isHidden(c));
981
+ }
982
+
820
983
  /** A data column's drawn index (row number included), or -1 when hidden. */
821
984
  _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;
985
+ // STORY-0066. With a placeholder drawn, the groups' layout says.
986
+ const slot = this._colGroups()?.slotOf(colIdx) ?? -1;
987
+ if (slot >= 0) return this._lead().all + slot;
988
+ const at = this._shownColumns().indexOf(colIdx);
989
+ return at < 0 ? -1 : this._lead().all + at;
826
990
  }
827
991
 
828
992
  /** A drawn cell's data column, or null for the row number. */
829
993
  _dataIdxOfDom(domIdx) {
830
- let i = domIdx - this._lead().all;
994
+ const i = domIdx - this._lead().all;
831
995
  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;
996
+ // STORY-0066. With a placeholder drawn, the groups' layout says (and
997
+ // a placeholder is no data column).
998
+ const slot = this._colGroups()?.slotAt(i);
999
+ if (slot !== undefined) return slot;
1000
+ return this._shownColumns()[i] ?? null;
839
1001
  }
840
1002
 
841
1003
  /** The columns not drawn, by data index, ascending. */
@@ -868,12 +1030,24 @@ export class DataTable {
868
1030
  * that stay drawn to their new places, and tell whoever listens. */
869
1031
  _applyHidden(next) {
870
1032
  const before = this.getHiddenColumns();
1033
+ this._carryWidths(() => this._setHidden([...next]));
1034
+ const after = this.getHiddenColumns();
1035
+ if (before.join() === after.join()) return;
1036
+ this._savePersisted();
1037
+ if (this._wrapperEl) this.render();
1038
+ this._columnsChanged();
1039
+ }
1040
+
1041
+ /** STORY-0012/0086. Run `change` (a new hidden set or order), carrying
1042
+ * the drawn widths of the columns drawn before and after to their new
1043
+ * places. */
1044
+ _carryWidths(change) {
871
1045
  const n = this.config.headers?.length || 0;
872
1046
  const old = this._colWidths;
873
1047
  const kept = this._colWidthsSig === this._colSig();
874
1048
  const domBefore = [];
875
1049
  for (let c = 0; c < n; c++) domBefore[c] = this._domIdxOf(c);
876
- this._setHidden([...next]);
1050
+ change();
877
1051
  if (kept) {
878
1052
  const widths = {};
879
1053
  for (let i = 0; i < this._lead().all; i++) if (old[i] != null) widths[i] = old[i];
@@ -885,21 +1059,109 @@ export class DataTable {
885
1059
  this._colWidths = widths;
886
1060
  this._colWidthsSig = this._colSig();
887
1061
  }
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) {
1062
+ }
1063
+
1064
+ /** `onColumnsChange(hidden, {hidden, order})`, after a change of either. */
1065
+ _columnsChanged() {
1066
+ const hidden = this.getHiddenColumns();
1067
+ try { this.config.onColumnsChange?.(hidden, { hidden, order: this.getColumnOrder() }); } catch (err) {
893
1068
  console.error('[DataTable] onColumnsChange threw', err);
894
1069
  }
895
1070
  }
896
1071
 
897
- /** `columnChooser`: the header's right-click menu, one checkable item a
898
- * column. */
899
- _showColumnChooser(clientX, clientY) {
1072
+ // ─────────────────────────────────────────────────────────────────
1073
+ // STORY-0086 — the order the columns are drawn in (column_order.js)
1074
+ // ─────────────────────────────────────────────────────────────────
1075
+
1076
+ /** Whether this table orders its columns at all (and so saves the order). */
1077
+ _orderOn() {
1078
+ return !!this.config.reorderable || Array.isArray(this.config.columnOrder) || !!this._orderTouched;
1079
+ }
1080
+
1081
+ /** Take `list` as the order (`asked`: it is `columnOrder`, kept apart as
1082
+ * the fallback). Refused (false, nothing changed) when it is no
1083
+ * permutation of the columns; a table with no headers yet keeps it for
1084
+ * the headers `setData` brings, and `_checkAskedOrder` checks it then. */
1085
+ _setOrder(list, asked = false) {
1086
+ const n = this.config.headers?.length || 0;
1087
+ if (list != null && (!Array.isArray(list) || (n && !isColumnOrder(list, n)))) return false;
1088
+ this._order = list == null ? null : list.map(Number);
1089
+ if (asked) {
1090
+ this._askedOrder = this._order;
1091
+ this._askedUnchecked = !!this._order && !n;
1092
+ }
1093
+ return true;
1094
+ }
1095
+
1096
+ /** The first headers have come: a `columnOrder` taken before them, unchecked,
1097
+ * that is no order of them is dropped with the same warning a table with
1098
+ * headers gives at once — once, as the check is made once. */
1099
+ _checkAskedOrder() {
1100
+ const n = this.config.headers?.length || 0;
1101
+ if (!this._askedUnchecked || !n) return;
1102
+ this._askedUnchecked = false;
1103
+ if (isColumnOrder(this._askedOrder, n)) return;
1104
+ console.warn('[DataTable] columnOrder is not an order of the columns; ignored.');
1105
+ if (this._order === this._askedOrder) this._order = null;
1106
+ this._askedOrder = null;
1107
+ }
1108
+
1109
+ /** The order in force, or null for the natural one. One rule, whether the
1110
+ * headers were known when an order came or not: the first of the live
1111
+ * order (dragged, moved or restored) and `columnOrder` that is an order
1112
+ * of today's columns — so a saved order of the wrong length never
1113
+ * displaces `columnOrder`. */
1114
+ _columnOrder() {
1115
+ const n = this.config.headers?.length || 0;
1116
+ for (const o of [this._order, this._askedOrder]) {
1117
+ if (o && isColumnOrder(o, n)) return o;
1118
+ }
1119
+ return null;
1120
+ }
1121
+
1122
+ /**
1123
+ * Every data column's index, hidden ones included, in the order drawn.
1124
+ * @returns {number[]}
1125
+ */
1126
+ getColumnOrder() {
1127
+ return (this._columnOrder() || naturalOrder(this.config.headers?.length || 0)).slice();
1128
+ }
1129
+
1130
+ /**
1131
+ * Move the column at position `from` of `getColumnOrder()` to position
1132
+ * `to`, the others closing up behind it: from the natural order,
1133
+ * `moveColumn(3, 1)` draws 0, 3, 1, 2. The widths stay with their columns.
1134
+ * Saved under `persistKey`, and `onColumnsChange` is told.
1135
+ *
1136
+ * @param {number} from
1137
+ * @param {number} to
1138
+ * @returns {boolean} whether the order changed
1139
+ */
1140
+ moveColumn(from, to) {
1141
+ const order = this.getColumnOrder();
1142
+ const n = order.length;
1143
+ if (![from, to].every((i) => Number.isInteger(i) && i >= 0 && i < n) || from === to) return false;
1144
+ this._applyOrder(moveInOrder(order, from, to));
1145
+ return true;
1146
+ }
1147
+
1148
+ /** Swap in a new order (a permutation, or null), widths carried, and
1149
+ * tell whoever listens. */
1150
+ _applyOrder(next) {
1151
+ const before = this.getColumnOrder().join();
1152
+ this._orderTouched = true;
1153
+ this._carryWidths(() => this._setOrder(next));
1154
+ if (this.getColumnOrder().join() === before) return;
1155
+ this._savePersisted();
1156
+ if (this._wrapperEl) this.render();
1157
+ this._columnsChanged();
1158
+ }
1159
+
1160
+ /** `columnChooser`'s items: one checkable item a column. */
1161
+ _columnChooserItems() {
900
1162
  const headers = this.config.headers || [];
901
1163
  const lastShown = headers.length - this._hidden.size <= 1;
902
- const items = headers.map((h, c) => {
1164
+ return headers.map((h, c) => {
903
1165
  const shown = !this._isHidden(c);
904
1166
  return {
905
1167
  label: String(h ?? ''),
@@ -910,7 +1172,69 @@ export class DataTable {
910
1172
  onChoose: () => this.setColumnVisible(c, !shown),
911
1173
  };
912
1174
  });
913
- this._showContextMenu(clientX, clientY, { ctx: null, items, copy: false });
1175
+ }
1176
+
1177
+ /**
1178
+ * STORY-0003. The header's right-click menu for a press on `target`: the
1179
+ * consumer's `headerMenu` items for the data column under it, then — with
1180
+ * `columnChooser` — the chooser's items behind a separator. Empty when
1181
+ * there is nothing to show.
1182
+ */
1183
+ _headerMenuItems(target) {
1184
+ const items = [];
1185
+ const hook = this.config.headerMenu;
1186
+ const colIdx = this._headerColumnAt(target);
1187
+ if (typeof hook === 'function' && colIdx !== null) {
1188
+ let asked = null;
1189
+ try {
1190
+ asked = hook(colIdx, {
1191
+ colIdx,
1192
+ header: this.config.headers?.[colIdx],
1193
+ hiddenColumns: this.getHiddenColumns(),
1194
+ table: this,
1195
+ });
1196
+ } catch (err) {
1197
+ console.error('[DataTable] headerMenu threw', err);
1198
+ }
1199
+ for (const item of Array.isArray(asked) ? asked.filter(Boolean) : []) {
1200
+ if (item.separator) { items.push({ separator: true }); continue; }
1201
+ items.push({
1202
+ ...item,
1203
+ onChoose: () => {
1204
+ try { this.config.onHeaderMenuAction?.(item.action, colIdx); } catch (err) {
1205
+ console.error('[DataTable] onHeaderMenuAction threw', err);
1206
+ }
1207
+ },
1208
+ });
1209
+ }
1210
+ }
1211
+ // STORY-0086. Move left / Move right, for the data column under it.
1212
+ if (this._reorder && colIdx !== null) {
1213
+ const moves = this._reorder.menuItems(colIdx);
1214
+ if (moves.length && items.length) items.push({ separator: true });
1215
+ items.push(...moves);
1216
+ }
1217
+ // STORY-0066. Rename, delete, add a row.
1218
+ const structural = this._structure()?.headerItems(colIdx) || [];
1219
+ if (structural.length) {
1220
+ if (items.length) items.push({ separator: true });
1221
+ items.push(...structural);
1222
+ }
1223
+ if (this.config.columnChooser) {
1224
+ if (items.length) items.push({ separator: true });
1225
+ items.push(...this._columnChooserItems());
1226
+ }
1227
+ return items;
1228
+ }
1229
+
1230
+ /** The data column of a header cell (the header row, or the filter row
1231
+ * under it), or null off one — the row number, the select column. */
1232
+ _headerColumnAt(target) {
1233
+ const cell = target?.closest?.('th, td');
1234
+ const tr = cell?.parentNode;
1235
+ if (!tr || tr.parentNode?.tagName !== 'THEAD') return null;
1236
+ if (tr.children.length !== this._domColCount()) return null;
1237
+ return this._dataIdxOfDom(Array.prototype.indexOf.call(tr.children, cell));
914
1238
  }
915
1239
 
916
1240
  /** STORY-0010 `persistPage`: the client page changed, so save it. */
@@ -964,18 +1288,31 @@ export class DataTable {
964
1288
  this._setHidden(updates.hiddenColumns);
965
1289
  if (this.getHiddenColumns().join() !== before) {
966
1290
  this._savePersisted();
967
- try { this.config.onColumnsChange?.(this.getHiddenColumns()); } catch (err) {
968
- console.error('[DataTable] onColumnsChange threw', err);
969
- }
1291
+ this._columnsChanged();
970
1292
  }
971
1293
  } else if (updates.headers && this._hidden.size) {
972
1294
  this._setHidden([...this._hidden]);
973
1295
  }
1296
+ // STORY-0086. A new order asked for applies at once (refused, with a
1297
+ // warning, when it is no order of the columns); new headers keep the
1298
+ // order while it still fits them.
1299
+ if (Object.prototype.hasOwnProperty.call(updates, 'columnOrder')) {
1300
+ const before = this.getColumnOrder().join();
1301
+ if (!this._setOrder(updates.columnOrder, true)) {
1302
+ console.warn('[DataTable] columnOrder is not an order of the columns; ignored.');
1303
+ } else if (this.getColumnOrder().join() !== before) {
1304
+ this._savePersisted();
1305
+ this._columnsChanged();
1306
+ }
1307
+ } else if (updates.headers) {
1308
+ this._checkAskedOrder();
1309
+ }
974
1310
 
975
1311
  // Reset state if rows changed
976
1312
  if (updates.rows) {
977
1313
  this._state.selected.clear();
978
1314
  this._state.anchorIndex = null;
1315
+ this._cellGrid?.newRows();
979
1316
  if (this._state.offset >= updates.rows.length) {
980
1317
  this._state.offset = 0;
981
1318
  }
@@ -1061,6 +1398,12 @@ export class DataTable {
1061
1398
  * @param {number[]} indices - Row indices to select
1062
1399
  */
1063
1400
  setSelection(indices) {
1401
+ // STORY-0003. In cell mode, the cells of those rows: one range from the
1402
+ // first of them to the last, as shown, across every column shown.
1403
+ if (this._cellGrid) {
1404
+ this._cellGrid.selectRows(indices);
1405
+ return;
1406
+ }
1064
1407
  this._state.selected.clear();
1065
1408
  for (const idx of indices) {
1066
1409
  if (idx >= 0 && idx < this.config.rows.length) {
@@ -1151,17 +1494,127 @@ export class DataTable {
1151
1494
  * Clear selection
1152
1495
  */
1153
1496
  clearSelection() {
1497
+ if (this._cellGrid) {
1498
+ this._cellGrid.clear();
1499
+ return;
1500
+ }
1154
1501
  this._state.selected.clear();
1155
1502
  this._state.anchorIndex = null;
1156
1503
  this._updateRowSelection();
1157
1504
  this._notifySelectionChange();
1158
1505
  }
1159
1506
 
1507
+ // ─────────────────────────────────────────────────────────────────
1508
+ // STORY-0003 — cell mode (`selectionMode: 'cells'`), see cell_grid.js
1509
+ // ─────────────────────────────────────────────────────────────────
1510
+
1511
+ /**
1512
+ * The cell selection: `{anchor, focus, range}`. `anchor` and `focus` (the
1513
+ * cursor) are `{row, col, rowIdx}` — `row` the position in the order
1514
+ * shown, `col` the data column, `rowIdx` the original row index — and
1515
+ * `range` is `{top, bottom, left, right, rows, columns}`: positions,
1516
+ * data columns at its edges, and the original row indices and data
1517
+ * columns it covers. All null with nothing selected. Null outside cell
1518
+ * mode.
1519
+ */
1520
+ getCellSelection() {
1521
+ return this._cellGrid ? this._cellGrid.get() : null;
1522
+ }
1523
+
1524
+ /**
1525
+ * Put the cursor on a cell (`row` a position in the order shown, `col` a
1526
+ * data column) — with `anchor`, select the range from there. `null`
1527
+ * clears. The row is brought into view. A no-op outside cell mode.
1528
+ *
1529
+ * @param {{row:number, col:number}|null} focus
1530
+ * @param {{row:number, col:number}} [anchor]
1531
+ */
1532
+ setCellSelection(focus, anchor = null) {
1533
+ this._cellGrid?.set(focus, anchor);
1534
+ }
1535
+
1536
+ /** Open the editor on a cell (position, data column), as F2 would.
1537
+ * Returns whether one opened. */
1538
+ editCell(row, col) {
1539
+ return !!this._cellGrid?.open(row, col);
1540
+ }
1541
+
1542
+ /** Whether a cell editor is open. */
1543
+ isEditing() {
1544
+ return !!this._cellGrid?.isEditing();
1545
+ }
1546
+
1547
+ // ── STORY-0088 `staged` ──────────────────────────────────────────────
1548
+
1549
+ /**
1550
+ * Send every staged cell to `onCommit({edits})`, in one call. Resolves
1551
+ * `{ok: true, committed}` (the staged cells and the undo ring are
1552
+ * cleared), or `{ok: false, errors}` (the cells refused are marked and
1553
+ * every staged value stays). An open editor's value is staged first.
1554
+ * Without `staged`, resolves `{ok: false, errors: []}`.
1555
+ *
1556
+ * @returns {Promise<{ok: boolean, committed?: number, errors?: Array<{rowIdx:number, colIdx:number, error:string}>}>}
1557
+ */
1558
+ commit() {
1559
+ const st = this._cellGrid?.staged;
1560
+ return st ? st.commit() : Promise.resolve({ ok: false, errors: [] });
1561
+ }
1562
+
1563
+ /** Drop every staged cell, its marks and the undo ring. Returns whether
1564
+ * there was anything to drop. */
1565
+ discard() {
1566
+ return !!this._cellGrid?.staged?.discard();
1567
+ }
1568
+
1569
+ /** Take the last staged edit back (Ctrl+Z). Returns whether it moved. */
1570
+ undo() {
1571
+ return !!this._cellGrid?.staged?.undo();
1572
+ }
1573
+
1574
+ /** Put the last edit taken back back again (Ctrl+Y). */
1575
+ redo() {
1576
+ return !!this._cellGrid?.staged?.redo();
1577
+ }
1578
+
1579
+ /** Every staged cell, `{rowIdx, colIdx, value, previous}`, by row then
1580
+ * column. Empty without `staged`. */
1581
+ getStagedEdits() {
1582
+ return this._cellGrid?.staged ? this._cellGrid.staged.edits() : [];
1583
+ }
1584
+
1585
+ /**
1586
+ * STORY-0089. Fill from the cell range to the cell `to` (`{row, col}`:
1587
+ * position in the order shown, data column), as dragging the fill handle
1588
+ * there does: staged as ONE undo entry, Ctrl's repeat as `forceCopy`.
1589
+ * Returns `{direction, count, staged, skipped}` —
1590
+ * `staged` the NUMBER of cells that changed, `skipped` the cells `editable` refused
1591
+ * (`{row, col, rowIdx}`) — or null when nothing was filled (no
1592
+ * `fillHandle`, no range, `to` inside it, an editor open, a commit out).
1593
+ */
1594
+ fill(to, { forceCopy = false } = {}) {
1595
+ return this._cellGrid?.fill ? this._cellGrid.fill.fill(to, { forceCopy }) : null;
1596
+ }
1597
+
1598
+ /** Why sort, filters and pages are held — a staged edit — or null; with
1599
+ * a reason, `what` is refused out loud. */
1600
+ _stagedRefusal(what) {
1601
+ const why = this._cellGrid?.staged?.freezeReason() || null;
1602
+ if (why) console.warn(`[DataTable] ${what} refused: ${why}`);
1603
+ return why;
1604
+ }
1605
+
1606
+ /** STORY-0066. Open the inline renamer on a data column's header (needs
1607
+ * `onColumnRename`). Returns whether it opened. */
1608
+ renameColumn(colIdx) {
1609
+ return !!this._structure()?.rename(colIdx);
1610
+ }
1611
+
1160
1612
  /**
1161
1613
  * Go to specific page
1162
1614
  * @param {number} page - Page number (0-indexed)
1163
1615
  */
1164
1616
  goToPage(page) {
1617
+ if (this._stagedRefusal('goToPage')) return;
1165
1618
  // 0.7.0 `dataSource`: the page is the SOURCE's — asked for, and drawn
1166
1619
  // when it answers (the promise). Past a known total, the last page.
1167
1620
  // (The release review: this set an offset nothing under a data source
@@ -1185,6 +1638,7 @@ export class DataTable {
1185
1638
  * @param {number} rowIndex - Row index
1186
1639
  */
1187
1640
  goToRow(rowIndex) {
1641
+ if (this._stagedRefusal('goToRow')) return;
1188
1642
  // 0.7.0 `dataSource`: the page that holds the row, from the source.
1189
1643
  if (this.config.dataSource) {
1190
1644
  const size = this._sourcePageSize();
@@ -1203,6 +1657,7 @@ export class DataTable {
1203
1657
  * @param {boolean} [ascending] - Sort direction (toggles if same column)
1204
1658
  */
1205
1659
  sortBy(colIndex, ascending) {
1660
+ if (this._stagedRefusal('sortBy')) return;
1206
1661
  if (this._state.sortColumn === colIndex && ascending === undefined) {
1207
1662
  this._state.sortAscending = !this._state.sortAscending;
1208
1663
  } else {
@@ -1245,6 +1700,7 @@ export class DataTable {
1245
1700
  * Clear all column filters
1246
1701
  */
1247
1702
  clearFilters() {
1703
+ if (this._stagedRefusal('clearFilters')) return;
1248
1704
  this._state.filters.clear();
1249
1705
  this._processedRows = null;
1250
1706
  this._processedIndexMap = null;
@@ -1310,6 +1766,12 @@ export class DataTable {
1310
1766
  * Render the table
1311
1767
  */
1312
1768
  render() {
1769
+ // STORY-0003. An open cell editor is not torn down by a redraw: the
1770
+ // render waits for its commit or cancel, and runs then.
1771
+ if (this._cellGrid?.isEditing()) {
1772
+ this._cellGrid.renderWaiting = true;
1773
+ return;
1774
+ }
1313
1775
  // Capture active filter input before re-render
1314
1776
  const activeFilterColIdx = this._getActiveFilterColIdx();
1315
1777
  // KEYBOARD FOCUS SURVIVES A RE-RENDER. Rendering replaces the table
@@ -1433,7 +1895,9 @@ export class DataTable {
1433
1895
  // empty one must keep the filter row — or a filter that matched nothing
1434
1896
  // could never be cleared. It goes through `_createTable` and gets its
1435
1897
  // empty row there.
1436
- const keepFilterRow = rows.length === 0 && !!this.config.dataSource && this.config.filterable;
1898
+ // STORY-0091: a crosstab keeps its header rows and its corner too.
1899
+ const keepFilterRow = rows.length === 0
1900
+ && ((!!this.config.dataSource && this.config.filterable) || !!this._crosstab());
1437
1901
  if (rows.length === 0 && !keepFilterRow) {
1438
1902
  // Empty state — render the same chrome as the data table
1439
1903
  // (header row in its own non-scrolling wrap) followed by a
@@ -1456,13 +1920,12 @@ export class DataTable {
1456
1920
  th.textContent = '#';
1457
1921
  tr.appendChild(th);
1458
1922
  }
1459
- headers.forEach((h, colIdx) => {
1460
- if (this._isHidden(colIdx)) return;
1923
+ for (const colIdx of this._shownColumns()) {
1461
1924
  const th = document.createElement('th');
1462
1925
  th.className = this._columnTypes[colIdx] || 'text';
1463
- th.textContent = h;
1926
+ th.textContent = headers[colIdx];
1464
1927
  tr.appendChild(th);
1465
- });
1928
+ }
1466
1929
  thead.appendChild(tr);
1467
1930
  headerTable.appendChild(thead);
1468
1931
  headerWrap.appendChild(headerTable);
@@ -1489,7 +1952,15 @@ export class DataTable {
1489
1952
  this._tableEl = fullTable;
1490
1953
  // Extract thead → header table in its own non-scrolling
1491
1954
  // container. The body table keeps tbody only.
1492
- const thead = fullTable.querySelector('thead');
1955
+ // STORY-0091. A crosstab keeps its `thead` in the body table: one
1956
+ // table in one scroller, pinned by `position: sticky`.
1957
+ const crosstab = this._crosstab();
1958
+ const thead = crosstab ? null : fullTable.querySelector('thead');
1959
+ if (crosstab) {
1960
+ crosstab.decorate(fullTable);
1961
+ this._headerTableEl = null;
1962
+ this._headerWrapEl = null;
1963
+ }
1493
1964
  if (thead) {
1494
1965
  const headerWrap = document.createElement('div');
1495
1966
  headerWrap.className = 'twm-preview-table-header-wrap';
@@ -1515,17 +1986,30 @@ export class DataTable {
1515
1986
  }
1516
1987
 
1517
1988
  this._tableWrapEl = tableWrap;
1518
- // STORY-0012 `columnChooser`: the header's right-click.
1519
- if (this.config.columnChooser && this._headerTableEl) {
1989
+ // STORY-0012 `columnChooser` and STORY-0003 `headerMenu`: the header's
1990
+ // right-click. With neither item to show, the page's own menu.
1991
+ // STORY-0066: the groups' row and placeholder columns, the renamer's
1992
+ // labels and the consumer's column actions, on the header just built.
1993
+ this._colGroups()?.decorate(this._headerWrapEl, this._headerTableEl);
1994
+ this._structure()?.decorate(this._headerTableEl);
1995
+ if ((this.config.columnChooser || typeof this.config.headerMenu === 'function' || this._reorder
1996
+ || this._structure()?.hasHeaderItems()) && this._headerTableEl) {
1520
1997
  const head = this._headerTableEl;
1521
1998
  const onHeadMenu = (ev) => {
1999
+ const items = this._headerMenuItems(ev.target);
2000
+ if (!items.length) return;
1522
2001
  ev.preventDefault();
1523
2002
  ev.stopPropagation();
1524
- setTimeout(() => this._showColumnChooser(ev.clientX, ev.clientY), 0);
2003
+ setTimeout(() => this._showContextMenu(ev.clientX, ev.clientY,
2004
+ { ctx: null, items, copy: false }), 0);
1525
2005
  };
1526
2006
  head.addEventListener('contextmenu', onHeadMenu);
1527
2007
  this._disposers.push(() => head.removeEventListener('contextmenu', onHeadMenu));
1528
2008
  }
2009
+ // STORY-0086 `reorderable`: the header's drag.
2010
+ if (this._reorder && this._headerTableEl) {
2011
+ this._reorder.install(this._headerTableEl.querySelector('thead > tr'));
2012
+ }
1529
2013
  // 0.7.0 `pagerPosition: 'bottom'`: under the rows.
1530
2014
  if (this._paginationEl && this.config.pagerPosition === 'bottom') {
1531
2015
  this._paginationEl.classList.add('twm-pagination-controls--bottom');
@@ -1541,6 +2025,8 @@ export class DataTable {
1541
2025
  if (this._pagingEl) this._wrapperEl.appendChild(this._pagingEl);
1542
2026
  }
1543
2027
  this.container.appendChild(this._wrapperEl);
2028
+ // STORY-0091. The crosstab's offsets, measured now the table is in.
2029
+ this._crosstab()?.install();
1544
2030
  // 0.6.1 `scrollLock`: THIS render's body box joins the group, and leaves
1545
2031
  // it with the next render's cleanup or the dispose.
1546
2032
  if (this.config.scrollLock && typeof this.config.scrollLock.attach === 'function') {
@@ -1618,7 +2104,8 @@ export class DataTable {
1618
2104
  if (rows.length > 0 && (this.config.selectable || this.config.copyable || !this.config.readonly
1619
2105
  || typeof this.config.onRowActivate === 'function'
1620
2106
  || typeof this.config.rowDetail === 'function'
1621
- || typeof this.config.onRowKeyDown === 'function')) {
2107
+ || typeof this.config.onRowKeyDown === 'function'
2108
+ || this._cellGrid)) {
1622
2109
  this._installInteractions();
1623
2110
  }
1624
2111
 
@@ -1669,14 +2156,14 @@ export class DataTable {
1669
2156
  }
1670
2157
 
1671
2158
  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)];
2159
+ // STORY-0012. The columns shown — all of them, unless some are hidden —
2160
+ // and (STORY-0086) in the order shown.
2161
+ const matrix = [this._rowColumns(headers.length).map((c) => headers[c])];
1675
2162
 
1676
2163
  for (const idx of indices) {
1677
2164
  const row = rows[idx];
1678
2165
  if (row) {
1679
- matrix.push(row.map((val, colIdx) => this._formatValue(val, colIdx)).filter(shown));
2166
+ matrix.push(this._rowColumns(row.length).map((c) => this._formatValue(row[c], c)));
1680
2167
  }
1681
2168
  }
1682
2169
 
@@ -1715,12 +2202,11 @@ export class DataTable {
1715
2202
  return;
1716
2203
  }
1717
2204
 
1718
- const shown = (_, colIdx) => !this._isHidden(colIdx);
1719
- const lines = [headers.filter(shown).join(',')];
2205
+ const lines = [this._rowColumns(headers.length).map((c) => headers[c]).join(',')];
1720
2206
  for (const row of rows) {
1721
- lines.push(row.map((val, colIdx) =>
1722
- this._csvEscape(this._formatValue(val, colIdx))
1723
- ).filter(shown).join(','));
2207
+ lines.push(this._rowColumns(row.length).map((c) =>
2208
+ this._csvEscape(this._formatValue(row[c], c))
2209
+ ).join(','));
1724
2210
  }
1725
2211
 
1726
2212
  const csv = lines.join('\n');
@@ -1783,6 +2269,10 @@ export class DataTable {
1783
2269
  }
1784
2270
 
1785
2271
  _teardown(emptyHost) {
2272
+ // STORY-0003. An open editor goes, committing nothing.
2273
+ this._cellGrid?.dispose();
2274
+ // STORY-0066. And an open column renamer.
2275
+ this._tableStructure?.dispose();
1786
2276
  autoDisposeUnwatch(this);
1787
2277
  this._autoSeen = false;
1788
2278
  // 0.7.0. A grace still running, the follow timer, a request still out
@@ -2073,8 +2563,8 @@ export class DataTable {
2073
2563
  tr.appendChild(th);
2074
2564
  }
2075
2565
 
2076
- headers.forEach((header, colIdx) => {
2077
- if (this._isHidden(colIdx)) return;
2566
+ this._shownColumns().forEach((colIdx) => {
2567
+ const header = headers[colIdx];
2078
2568
  const th = document.createElement('th');
2079
2569
  th.className = 'data-table__filter-cell';
2080
2570
 
@@ -2450,6 +2940,7 @@ export class DataTable {
2450
2940
  }
2451
2941
 
2452
2942
  _onFilterInput(colIdx, value) {
2943
+ if (this._stagedRefusal('A filter')) return;
2453
2944
  // 0.7.0. A filter value need not be text (a control's array, a range),
2454
2945
  // and an empty one of those is no filter. Text is judged as in 0.4.
2455
2946
  const empty = typeof value === 'string' ? !value : this._isEmptyFilterValue(value);
@@ -2653,6 +3144,7 @@ export class DataTable {
2653
3144
 
2654
3145
  // Page change handler - supports both client-side and server-side pagination
2655
3146
  const handlePageChange = (newOffset) => {
3147
+ if (this._stagedRefusal('Turning the page')) return;
2656
3148
  if (isServerSide) {
2657
3149
  // Auto-spinner if the consumer returns a Promise.
2658
3150
  this._awaitWithSpinner(onPageChange(newOffset, pageSize));
@@ -2730,8 +3222,11 @@ export class DataTable {
2730
3222
  _createExportBtn() {
2731
3223
  const name = this.config.exportFilename;
2732
3224
  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));
3225
+ const btn = this._createPaginationBtn('download', 'Download visible rows as CSV', false,
3226
+ () => this.downloadCSV(filename));
3227
+ // STORY-0088. Moves no rows, so a staged edit's freeze leaves it be.
3228
+ btn.classList.add('twm-pagination-btn--export');
3229
+ return btn;
2735
3230
  }
2736
3231
 
2737
3232
  /**
@@ -2871,6 +3366,7 @@ export class DataTable {
2871
3366
  this._wrapperEl.appendChild(next);
2872
3367
  }
2873
3368
  this._pagingEl = next;
3369
+ this._cellGrid?.staged?.paintFreeze(); // STORY-0088
2874
3370
  }
2875
3371
 
2876
3372
  /**
@@ -2881,6 +3377,7 @@ export class DataTable {
2881
3377
  */
2882
3378
  _pagingCall(kind, fn, ...args) {
2883
3379
  if (typeof fn !== 'function' || this._pagingBusy) return;
3380
+ if (this._stagedRefusal(`paging ${kind}`)) return;
2884
3381
  let result;
2885
3382
  try {
2886
3383
  result = fn(...args);
@@ -2912,6 +3409,9 @@ export class DataTable {
2912
3409
  _pagingMaybeLoadMore() {
2913
3410
  const p = this._pagingConfig();
2914
3411
  if (!p || !p.hasMore || this._pagingBusy) return;
3412
+ // STORY-0088. Paging is held while cells are staged — quietly here:
3413
+ // a scroll is not a click on a control that could say why.
3414
+ if (this._cellGrid?.staged?.freezeReason()) return;
2915
3415
  // 0.7.0: not while the source is answering another question (a sort, a
2916
3416
  // filter, a refresh) — those rows are about to be replaced.
2917
3417
  if (this.config.dataSource && this._source?.inFlight) return;
@@ -3081,6 +3581,11 @@ export class DataTable {
3081
3581
  // hundred of a thousand.
3082
3582
  const pageRows = isServerSide || !this.config.pagination || this._pagingConfig()
3083
3583
  ? processedRows : processedRows.slice(offset, offset + pageSize);
3584
+ // STORY-0003. Where the rows drawn stand in the order shown.
3585
+ this._pageSpan = {
3586
+ start: pageRows === processedRows ? 0 : offset,
3587
+ count: pageRows.length,
3588
+ };
3084
3589
 
3085
3590
  const table = document.createElement('table');
3086
3591
  table.className = this._tableClassName();
@@ -3098,8 +3603,8 @@ export class DataTable {
3098
3603
  headerRow.appendChild(th);
3099
3604
  }
3100
3605
 
3101
- headers.forEach((header, colIdx) => {
3102
- if (this._isHidden(colIdx)) return;
3606
+ this._shownColumns().forEach((colIdx) => {
3607
+ const header = headers[colIdx];
3103
3608
  const th = document.createElement('th');
3104
3609
  th.className = this._columnTypes[colIdx] || 'text';
3105
3610
 
@@ -3217,8 +3722,11 @@ export class DataTable {
3217
3722
  // 0.5.0. The row's place in the order shown (for 'position' numbers).
3218
3723
  tr.__position = (this._positionBase || 0) + localIdx;
3219
3724
  tr.className = 'data-preview-row';
3725
+ // STORY-0003. The row's position in the order shown, for cell mode.
3726
+ if (this._cellGrid) tr.__cellPos = (this._pageSpan?.start || 0) + localIdx;
3220
3727
 
3221
- if (this._state.selected.has(globalIdx)) {
3728
+ // STORY-0003. In cell mode the CELLS are lit, not the rows.
3729
+ if (this._state.selected.has(globalIdx) && !this._cellGrid) {
3222
3730
  tr.classList.add('selected');
3223
3731
  }
3224
3732
  // 0.5.0. The consumer's row classes and attributes, and the open mark.
@@ -3532,6 +4040,7 @@ export class DataTable {
3532
4040
  const wanted = new Set(want);
3533
4041
  for (const [i, tr] of v.map) {
3534
4042
  if (!wanted.has(i)) {
4043
+ this._cellGrid?.rowLeaving(tr); // STORY-0003: an open editor commits
3535
4044
  tr.remove();
3536
4045
  this._removeDetailPieces(tr); // 0.7.0: its detail goes with it
3537
4046
  v.map.delete(i);
@@ -3759,10 +4268,10 @@ export class DataTable {
3759
4268
  const changedCells = diff?.cells
3760
4269
  ? (diff.cells instanceof Set ? diff.cells : new Set(diff.cells))
3761
4270
  : null;
3762
- for (let colIdx = 0; colIdx < row.length; colIdx++) {
3763
- if (this._isHidden(colIdx)) continue;
4271
+ for (const colIdx of this._rowColumns(row.length)) {
3764
4272
  const td = document.createElement('td');
3765
4273
  td.className = this._columnTypes[colIdx] || 'text';
4274
+ if (this._cellGrid) td.__colIdx = colIdx; // STORY-0003
3766
4275
 
3767
4276
  // Use custom cell renderer if provided
3768
4277
  const value = row[colIdx];
@@ -3813,6 +4322,12 @@ export class DataTable {
3813
4322
 
3814
4323
  tr.appendChild(td);
3815
4324
  }
4325
+ // STORY-0066. An empty group's placeholder column.
4326
+ this._colGroups()?.fillRow(tr);
4327
+ // STORY-0091. The crosstab's pinned cells, and an empty axis's cell.
4328
+ this._crosstab()?.fillRow(tr);
4329
+ // STORY-0003. The selection, and a commit's pending or refused mark.
4330
+ this._cellGrid?.decorateRow(tr);
3816
4331
  }
3817
4332
 
3818
4333
  /** Re-render ONE row in place, preserving everything around it.
@@ -3835,6 +4350,9 @@ export class DataTable {
3835
4350
  * - keyboard focus, because the focused element's position is recorded
3836
4351
  * before the replace and restored after.
3837
4352
  *
4353
+ * STORY-0003: on the row an open cell editor is in, the data is stored
4354
+ * now and the row is drawn when the editor closes (true is returned).
4355
+ *
3838
4356
  * @param {number} index row index as tracked by `tr.__rowIndex`
3839
4357
  * @param {any[]} row the new cell values
3840
4358
  * @returns {boolean} false when the row is not currently rendered
@@ -3867,6 +4385,9 @@ export class DataTable {
3867
4385
  this.config.rows[index] = row;
3868
4386
  }
3869
4387
  if (this._virt) this._virtSetRow(index, row);
4388
+ // STORY-0003. The row holds an open cell editor: the rebuild would take
4389
+ // it out of the document, so the row is drawn when the editor closes.
4390
+ if (this._cellGrid?.holdRow(tr, index)) return true;
3870
4391
 
3871
4392
  // Record focus BEFORE the replace: `replaceChildren` detaches the
3872
4393
  // focused cell and the browser moves focus to <body>.
@@ -3913,7 +4434,7 @@ export class DataTable {
3913
4434
  });
3914
4435
  }
3915
4436
 
3916
- tr.classList.toggle('selected', this._state?.selected?.has(index) === true);
4437
+ tr.classList.toggle('selected', this._state?.selected?.has(index) === true && !this._cellGrid);
3917
4438
  // 0.5.0. The row's data changed, so its classes, attributes, key and
3918
4439
  // open mark are asked again (a key paused, a delivery that died).
3919
4440
  this._decorateRow(tr, row, index);
@@ -4473,6 +4994,7 @@ export class DataTable {
4473
4994
 
4474
4995
  /** Go to the page at `offset` (paging back stops following). */
4475
4996
  _sourceGoTo(offset) {
4997
+ if (this._stagedRefusal('Turning the page')) return undefined;
4476
4998
  const next = Math.max(0, offset | 0);
4477
4999
  // PAGING AWAY TURNS FOLLOWING OFF, and coming back does not turn it on
4478
5000
  // again by itself: a reader who left the tail to read an old page is
@@ -4764,6 +5286,7 @@ export class DataTable {
4764
5286
  const maxJump = Number.isFinite(c.maxJumpOffset) ? c.maxJumpOffset : null;
4765
5287
 
4766
5288
  const go = (newOffset) => {
5289
+ if (this._stagedRefusal('Turning the page')) return;
4767
5290
  if (source) { this._sourceGoTo(newOffset); return; }
4768
5291
  if (server) { this._awaitWithSpinner(c.onPageChange(newOffset, pageSize)); return; }
4769
5292
  this._state.offset = newOffset;
@@ -4953,6 +5476,7 @@ export class DataTable {
4953
5476
  * value is whatever the column's control works in — text for the 0.4 box.
4954
5477
  */
4955
5478
  setFilter(colIdx, value) {
5479
+ if (this._stagedRefusal('setFilter')) return;
4956
5480
  const textBox = !this._filterKindFor(colIdx, (this.config.headers || [])[colIdx]);
4957
5481
  const v = textBox && value != null && typeof value !== 'string' ? String(value) : value;
4958
5482
  if (textBox) {
@@ -5101,6 +5625,8 @@ export class DataTable {
5101
5625
  // STORY-0016. The select column, and whether the mode is on.
5102
5626
  if (this._gutter) names.push('twm-dt--select');
5103
5627
  if (this._selecting) names.push('twm-dt--selecting');
5628
+ // STORY-0003.
5629
+ if (this._cellGrid) names.push('twm-dt--cells');
5104
5630
  return names.join(' ');
5105
5631
  }
5106
5632
 
@@ -6056,6 +6582,9 @@ export class DataTable {
6056
6582
  headerWrap.scrollLeft = x;
6057
6583
  const residual = x - headerWrap.scrollLeft;
6058
6584
  headerTable.style.transform = residual ? `translateX(${-residual}px)` : '';
6585
+ // STORY-0066. The group row follows the header: every width pass and
6586
+ // drag ends here, and so does every sideways scroll.
6587
+ this._colGroups()?.align();
6059
6588
  }
6060
6589
 
6061
6590
  /** Drag-to-resize: hang a thin grab handle off the right edge of
@@ -6261,11 +6790,17 @@ export class DataTable {
6261
6790
 
6262
6791
  const { selectable, copyable } = this.config;
6263
6792
  // STORY-0010. The consumer's own items install the menu by themselves.
6264
- const customMenu = this.config.contextMenuItems != null;
6793
+ // STORY-0066: the structural row items make a menu of their own.
6794
+ const customMenu = this.config.contextMenuItems != null || !!this._structure()?.hasRowItems();
6265
6795
  const tbody = this._tbodyEl;
6266
6796
  const table = this._tableEl;
6267
6797
 
6268
- if (selectable) {
6798
+ // STORY-0003. Cell mode: the cells' own pointer and keys, bound before
6799
+ // the activation, detail and copy listeners so a key it takes stops.
6800
+ // The row click below is not cell mode's.
6801
+ if (this._cellGrid) this._cellGrid.install(tbody, table);
6802
+
6803
+ if (selectable && !this._cellGrid) {
6269
6804
  const handleRowClick = (event) => {
6270
6805
  const rowEl = event.target.closest('tr');
6271
6806
  if (!rowEl || rowEl.__rowIndex === undefined) return;
@@ -6537,7 +7072,7 @@ export class DataTable {
6537
7072
  const idx = tr.__rowIndex;
6538
7073
  if (idx !== undefined) {
6539
7074
  const on = this._state.selected.has(idx);
6540
- tr.classList.toggle('selected', on);
7075
+ tr.classList.toggle('selected', on && !this._cellGrid);
6541
7076
  // STORY-0016.
6542
7077
  const box = this._gutter ? tr.querySelector(':scope > td.twm-dt-select-cell > input') : null;
6543
7078
  if (box) box.checked = on;
@@ -6818,7 +7353,11 @@ export class DataTable {
6818
7353
  items = null;
6819
7354
  }
6820
7355
  }
6821
- return Array.isArray(items) ? items.filter(Boolean) : [];
7356
+ const list = Array.isArray(items) ? items.filter(Boolean) : [];
7357
+ // STORY-0066. Insert and delete a row, after the consumer's items.
7358
+ const structural = this._structure()?.rowItems(ctx.rowIdx) || [];
7359
+ if (structural.length && list.length) list.push({ separator: true });
7360
+ return list.concat(structural);
6822
7361
  }
6823
7362
 
6824
7363
  /**