flexdesk 0.11.1 → 0.12.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flexdesk",
3
- "version": "0.11.1",
3
+ "version": "0.12.1",
4
4
  "description": "A tiling window manager and widget set for desktop-class web applications. Zero runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -9,6 +9,11 @@
9
9
  * The tile renderer calls the factory once when the leaf is first
10
10
  * mounted with a given (kind, props) pair, and `destroy()` on unmount.
11
11
  *
12
+ * `ctx` is the shell's ctx plus, per leaf, `leafId` and `setTitle(text)` —
13
+ * rename the pane's chrome title from inside the content, at mount or any
14
+ * time after; it survives repaints, and `setTitle(null)` gives the title back
15
+ * to the tree (tile_renderer.js, C41).
16
+ *
12
17
  * ── ONE REGISTRY PER SHELL ────────────────────────────────────────────
13
18
  * This module used to hold a module-level `Map` plus a `register(kind, fn)`
14
19
  * export. Two problems, both structural:
@@ -75,6 +75,22 @@
75
75
  * no content factory is unmounted and nothing mounted in a tile — a grid with
76
76
  * staged edits, an editor with an undo stack — notices that it happened.
77
77
  *
78
+ * ── C41. A CONTENT CAN RENAME ITS OWN PANE ───────────────────────────────
79
+ *
80
+ * The per-leaf ctx carries `setTitle(text)`. A factory's returned `title` is
81
+ * read once, after the mount, and the next repaint puts `leaf.title` back — and
82
+ * for a `panel:*` leaf that is PANEL_TITLES, which `wm._canonicalize` rewrites
83
+ * on every pass. So a bottom panel with tabs of its own could never say which
84
+ * one it was showing: it read "Console" whatever it held.
85
+ *
86
+ * `ctx.setTitle(text)` paints the chrome's title now and keeps it on the cache
87
+ * entry, where every cached repaint prefers it to `leaf.title`. Called DURING
88
+ * the mount it wins over the returned `title`. `setTitle(null)` or `''` hands
89
+ * the title back to the tree. It changes what the chrome SHOWS only: the tree,
90
+ * the tab labels and what is persisted are untouched, so a reload shows the
91
+ * tree's title until the content mounts and says otherwise. A call from a
92
+ * content whose leaf has since been rebuilt does nothing.
93
+ *
78
94
  * ── C33. A TAB DRAGGED INTO A TILE ───────────────────────────────────────
79
95
  *
80
96
  * *"I would like to be able to drag & drop tabs into tiles, too."* A tab could
@@ -526,7 +542,10 @@ export class TileRenderer {
526
542
 
527
543
  let entry = this._leafCache.get(leaf.id);
528
544
  if (entry && entry.kindKey === kindKey) {
529
- entry.titleEl.textContent = leaf.title || (leaf.content ? leaf.content.kind : 'empty');
545
+ // C41. A title the content set through `ctx.setTitle` wins over
546
+ // the tree's: a panel leaf's `title` is rewritten to PANEL_TITLES
547
+ // by every canonicalize, and this repaint follows it.
548
+ entry.titleEl.textContent = entry.liveTitle || leaf.title || (leaf.content ? leaf.content.kind : 'empty');
530
549
  // C19. Re-read the glyph beside the title, and for the same reason:
531
550
  // a cached leaf is NOT re-mounted, so anything the chrome shows
532
551
  // that can change while the content stays put has to be refreshed
@@ -797,8 +816,27 @@ export class TileRenderer {
797
816
  // `wm.openFromContext(ctx, ...)` instead of always hitting
798
817
  // the primary tile.
799
818
  let content = {};
819
+ // C41. The title the content set through `ctx.setTitle`, held here
820
+ // until the cache entry exists (a call made DURING the mount) and on
821
+ // the entry after that.
822
+ let liveTitle = null;
823
+ let liveEntry = null;
800
824
  if (leaf.content) {
801
- const leafCtx = { ...this.ctx, leafId: leaf.id };
825
+ const setTitle = (text) => {
826
+ const next = text == null || text === '' ? null : String(text);
827
+ if (!liveEntry) {
828
+ liveTitle = next;
829
+ title.textContent = next || leaf.title || leaf.content.kind;
830
+ return;
831
+ }
832
+ // A leaf rebuilt since — its old content still holding this
833
+ // ctx — must not write into the new one's chrome.
834
+ if (this._leafCache.get(leaf.id) !== liveEntry) return;
835
+ liveEntry.liveTitle = next;
836
+ const now = this.tree?.get?.(leaf.id) || leaf;
837
+ liveEntry.titleEl.textContent = next || now.title || (now.content ? now.content.kind : 'empty');
838
+ };
839
+ const leafCtx = { ...this.ctx, leafId: leaf.id, setTitle };
802
840
  // Mount with the ACTIVE TAB's props, not `content.props`.
803
841
  // `wm.updateActiveTabProps` (the canonical per-tile view-state
804
842
  // store — survives reload + desktop switches via desktops.json)
@@ -810,6 +848,7 @@ export class TileRenderer {
810
848
  const activeProps = activeTab?.props ?? leaf.content.props;
811
849
  content = this.content.mount(leaf.content.kind, body, activeProps, leafCtx);
812
850
  if (content.title) title.textContent = content.title;
851
+ if (liveTitle) title.textContent = liveTitle;
813
852
  // C19. `icon` is the natural sibling of `title` and is read the
814
853
  // same way: AFTER the mount, because that is the first moment the
815
854
  // content knows what it is — a canvas pane you emptied is the same
@@ -848,8 +887,11 @@ export class TileRenderer {
848
887
  // C22. The `top` layout's NotebookTabBar, and the child it
849
888
  // mounts into. Null under the `bottom` layout, which is plain
850
889
  // markup this file writes itself.
851
- tabStrip: null, tabStripHostEl: null };
890
+ tabStrip: null, tabStripHostEl: null,
891
+ // C41. Null until the content calls `ctx.setTitle`.
892
+ liveTitle };
852
893
  this._leafCache.set(leaf.id, entry);
894
+ liveEntry = entry;
853
895
 
854
896
  // Render tab strip last so it has access to the cached entry.
855
897
  this._renderTabBar(leaf, entry);
@@ -397,6 +397,9 @@ const AUTOSIZE_MAX_PX = 520;
397
397
  * @property {Function} [onRowKeyDown] - `(rowIdx, row, ev) => boolean|void`, for every key pressed on the focused table (not in a control inside it) BEFORE the table's own keys: `rowIdx`/`row` are the row Enter would open (the one row selected, the selection's anchor, else the active row — see `activateOn`), or null when none is drawn. Return `true` and the key is the consumer's: the table does nothing more with it (default prevented, propagation stopped — the page's keymap does not see it either). For the keys `activateOn` does not take: Ctrl+Enter, Alt+T, a "delete this row".
398
398
  * @property {number[]} [hiddenColumns] - STORY-0012. Columns (data indices) NOT DRAWN: no header, no filter box, no cell, no width in the fit, not in a copy or the CSV. They stay in the row data — `renderCell`, `contextMenuItems`' `ctx.row`, sorting and filtering (`sortBy`, `setFilter`) all still see them. A column sorted on while hidden keeps the order and has no arrow to show it. The last column shown cannot be hidden. With `persistKey` the set shown is saved, and a saved set wins over this one. `setData({hiddenColumns})` applies a new set.
399
399
  * @property {boolean} [columnChooser=false] - STORY-0012. A right-click on the header lists every column with a check mark on those shown; choosing one shows or hides it (`setColumnVisible`).
400
+ * @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.
401
+ * @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)`).
402
+ * @property {Function} [onSelectModeChange] - STORY-0016. `(on) => void`, after every change of the mode (the toggle, Escape, `setSelectMode`).
400
403
  * @property {Function} [onColumnsChange] - STORY-0012. `(hidden) => void` with the hidden columns' indices, ascending, after every change of the set (the chooser, `setColumnVisible`, `setData({hiddenColumns})`).
401
404
  * @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.
402
405
  */
@@ -543,9 +546,26 @@ export class DataTable {
543
546
  hiddenColumns: null,
544
547
  columnChooser: false,
545
548
  onColumnsChange: null,
549
+ // STORY-0016. Off — no select column, as in 0.11 — until set.
550
+ selectToggle: false,
551
+ selectMode: false,
552
+ onSelectModeChange: null,
546
553
  ...config,
547
554
  };
548
555
 
556
+ // STORY-0016. The select column is drawn for the table's whole life or
557
+ // not at all, so the drawn column indices (and the widths kept by them)
558
+ // never move when the mode flips.
559
+ {
560
+ const sel = this.config.selectable;
561
+ const asked = !!this.config.selectToggle || !!this.config.selectMode;
562
+ this._gutter = asked && !!sel && sel !== 'single';
563
+ if (asked && !this._gutter) {
564
+ console.warn('[DataTable] selectToggle/selectMode need a multi-row `selectable`; ignored.');
565
+ }
566
+ this._selecting = this._gutter && !!this.config.selectMode;
567
+ }
568
+
549
569
  // 0.7.0. Open details (by key), the server's half, follow, and the
550
570
  // filter controls that are not text boxes.
551
571
  this._expanded = new Set((this.config.expandedRows || []).map((k) => String(k)));
@@ -704,8 +724,12 @@ export class DataTable {
704
724
  if (blob.colWidths && typeof blob.colWidths === 'object') {
705
725
  // Stored under string keys (JSON) → coerce back to ints.
706
726
  this._colWidths = {};
727
+ // STORY-0016. Widths are by drawn column; a blob saved before this
728
+ // table drew its select column (or a consumer's seed) has none.
729
+ const shift = this._lead().select - (Number(blob.colWidthsLead) || 0);
707
730
  for (const [k, v] of Object.entries(blob.colWidths)) {
708
- this._colWidths[Number(k)] = v;
731
+ const i = Number(k) + shift;
732
+ if (i >= this._lead().select) this._colWidths[i] = v;
709
733
  }
710
734
  // Stamp the matching signature so the next render keeps these
711
735
  // widths instead of treating them as stale.
@@ -742,6 +766,9 @@ export class DataTable {
742
766
  sortAscending: this._state.sortAscending,
743
767
  filters: [...this._state.filters.entries()],
744
768
  colWidths: { ...this._colWidths },
769
+ // STORY-0016. Where the widths start, so a blob read by a table
770
+ // with or without the select column lands on the same columns.
771
+ ...(this._gutter ? { colWidthsLead: 1 } : {}),
745
772
  // STORY-0010. Only for a table that asked: one that did not writes
746
773
  // the 0.9.1 blob, key for key.
747
774
  ...(this.config.persistPage ? { offset: this._state.offset } : {}),
@@ -778,20 +805,27 @@ export class DataTable {
778
805
  /** The cells a drawn row has: the shown columns, and the row number. */
779
806
  _domColCount() {
780
807
  const n = Math.max(0, (this.config.headers?.length || 0) - this._hidden.size);
781
- return n + (this.config.showRowNumbers ? 1 : 0);
808
+ return n + this._lead().all;
809
+ }
810
+
811
+ /** STORY-0016. The drawn columns in front of the data: the select column
812
+ * (`select`, 0 or 1, always first) and the row number. */
813
+ _lead() {
814
+ const select = this._gutter ? 1 : 0;
815
+ return { select, all: select + (this.config.showRowNumbers ? 1 : 0) };
782
816
  }
783
817
 
784
818
  /** A data column's drawn index (row number included), or -1 when hidden. */
785
819
  _domIdxOf(colIdx) {
786
820
  if (this._isHidden(colIdx)) return -1;
787
- let dom = this.config.showRowNumbers ? 1 : 0;
821
+ let dom = this._lead().all;
788
822
  for (const h of this._hidden) if (h < colIdx) dom--;
789
823
  return dom + colIdx;
790
824
  }
791
825
 
792
826
  /** A drawn cell's data column, or null for the row number. */
793
827
  _dataIdxOfDom(domIdx) {
794
- let i = domIdx - (this.config.showRowNumbers ? 1 : 0);
828
+ let i = domIdx - this._lead().all;
795
829
  if (i < 0) return null;
796
830
  const n = this.config.headers?.length || 0;
797
831
  for (let c = 0; c < n; c++) {
@@ -840,7 +874,7 @@ export class DataTable {
840
874
  this._setHidden([...next]);
841
875
  if (kept) {
842
876
  const widths = {};
843
- if (this.config.showRowNumbers && old[0] != null) widths[0] = old[0];
877
+ for (let i = 0; i < this._lead().all; i++) if (old[i] != null) widths[i] = old[i];
844
878
  for (let c = 0; c < n; c++) {
845
879
  const from = domBefore[c];
846
880
  const to = this._domIdxOf(c);
@@ -887,6 +921,14 @@ export class DataTable {
887
921
  * @param {Object} updates - Partial config updates (headers, rows, etc.)
888
922
  */
889
923
  setData(updates) {
924
+ // STORY-0016. In select mode, ticks are kept by `getRowKey` across new
925
+ // rows (read before they are replaced): a list refreshed under somebody halfway through ticking twenty
926
+ // rows must not throw them away. Only in the mode; elsewhere new rows
927
+ // are an answer, as before.
928
+ const keepTicks = updates.rows && this._selecting && typeof this.config.getRowKey === 'function'
929
+ && this._state.selected.size
930
+ ? new Set(this.getSelection().map((i) => this._rowKey(this.config.rows[i], i)).filter((k) => k !== null))
931
+ : null;
890
932
  // Clear filters if headers changed
891
933
  if (updates.headers && this._state.filters.size > 0) {
892
934
  const oldHeaders = this.config.headers;
@@ -951,11 +993,19 @@ export class DataTable {
951
993
  this._columnTypes = this._detectColumnTypes();
952
994
  }
953
995
 
996
+ // STORY-0016. The ticks, found again in the new rows by key.
997
+ if (keepTicks) {
998
+ (this.config.rows || []).forEach((r, i) => {
999
+ if (keepTicks.has(this._rowKey(r, i))) this._state.selected.add(i);
1000
+ });
1001
+ }
1002
+
954
1003
  // Invalidate processed cache
955
1004
  this._processedRows = null;
956
1005
  this._processedIndexMap = null;
957
1006
 
958
1007
  this.render();
1008
+ if (keepTicks) this._notifySelectionChange();
959
1009
  }
960
1010
 
961
1011
  /**
@@ -1397,6 +1447,7 @@ export class DataTable {
1397
1447
  headerTable.className = this._tableClassName();
1398
1448
  const thead = document.createElement('thead');
1399
1449
  const tr = document.createElement('tr');
1450
+ if (this._gutter) tr.appendChild(this._buildSelectHead());
1400
1451
  if (showRowNumbers) {
1401
1452
  const th = document.createElement('th');
1402
1453
  th.className = 'num';
@@ -2009,6 +2060,11 @@ export class DataTable {
2009
2060
  const tr = document.createElement('tr');
2010
2061
  tr.className = 'twm-data-table__filter-row';
2011
2062
 
2063
+ if (this._gutter) {
2064
+ const th = document.createElement('th');
2065
+ th.className = 'data-table__filter-cell data-table__filter-cell--empty twm-dt-select-cell';
2066
+ tr.appendChild(th);
2067
+ }
2012
2068
  if (showRowNumbers) {
2013
2069
  const th = document.createElement('th');
2014
2070
  th.className = 'data-table__filter-cell data-table__filter-cell--empty';
@@ -2451,7 +2507,7 @@ export class DataTable {
2451
2507
  if (!row) return null;
2452
2508
  const cells = Array.from(row.children);
2453
2509
  const idx = cells.indexOf(cell);
2454
- return this._hidden.size ? this._dataIdxOfDom(idx) : (this.config.showRowNumbers ? idx - 1 : idx);
2510
+ return this._dataIdxOfDom(idx);
2455
2511
  }
2456
2512
 
2457
2513
  _restoreFilterFocus(colIdx) {
@@ -3013,6 +3069,7 @@ export class DataTable {
3013
3069
  const thead = document.createElement('thead');
3014
3070
  const headerRow = document.createElement('tr');
3015
3071
 
3072
+ if (this._gutter) headerRow.appendChild(this._buildSelectHead());
3016
3073
  if (showRowNumbers) {
3017
3074
  const th = document.createElement('th');
3018
3075
  th.className = 'num';
@@ -3225,7 +3282,7 @@ export class DataTable {
3225
3282
  const colgroup = document.createElement('colgroup');
3226
3283
  for (let i = 0; i < n; i++) {
3227
3284
  const col = document.createElement('col');
3228
- const w = this._colWidths?.[i];
3285
+ const w = i < this._lead().select ? this._selectColWidth() : this._colWidths?.[i];
3229
3286
  if (w != null && this._colWidthsSig === this._colSig()) col.style.width = `${w}px`;
3230
3287
  colgroup.appendChild(col);
3231
3288
  }
@@ -3645,6 +3702,24 @@ export class DataTable {
3645
3702
  _fillRowCells(tr, row, globalIdx, showRowNumbers) {
3646
3703
  tr.replaceChildren();
3647
3704
 
3705
+ // STORY-0016. The select column: a checkbox, shown in select mode. It
3706
+ // takes no focus and no click of its own — the row's click ticks it.
3707
+ if (this._gutter) {
3708
+ const td = document.createElement('td');
3709
+ td.className = 'twm-dt-select-cell';
3710
+ const box = document.createElement('input');
3711
+ box.type = 'checkbox';
3712
+ box.className = 'twm-dt-select-box';
3713
+ box.tabIndex = -1;
3714
+ box.checked = this._state.selected.has(globalIdx);
3715
+ box.setAttribute('aria-label', 'Select row');
3716
+ // Its click is the row's: the box flips, the row's handler ticks
3717
+ // and sets it to the tick. (Prevented, the browser would flip it
3718
+ // back AFTER the handler.)
3719
+ td.appendChild(box);
3720
+ tr.appendChild(td);
3721
+ }
3722
+
3648
3723
  if (showRowNumbers) {
3649
3724
  const td = document.createElement('td');
3650
3725
  td.className = 'num';
@@ -3800,7 +3875,7 @@ export class DataTable {
3800
3875
  : null;
3801
3876
 
3802
3877
  const showRowNumbers = this.config.showRowNumbers !== false
3803
- && tr.firstElementChild?.classList.contains('num');
3878
+ && !!tr.querySelector(':scope > td.num');
3804
3879
  this._fillRowCells(tr, row, index, showRowNumbers);
3805
3880
 
3806
3881
  // `_syncHeaderWidths` derives column widths from the FIRST body row.
@@ -5005,6 +5080,9 @@ export class DataTable {
5005
5080
  : ['twm-preview-table'];
5006
5081
  if (this.config.firstColumn === 'plain') names.push('twm-dt--first-plain');
5007
5082
  if (this._isClickable()) names.push('twm-dt--clickable');
5083
+ // STORY-0016. The select column, and whether the mode is on.
5084
+ if (this._gutter) names.push('twm-dt--select');
5085
+ if (this._selecting) names.push('twm-dt--selecting');
5008
5086
  return names.join(' ');
5009
5087
  }
5010
5088
 
@@ -5101,6 +5179,8 @@ export class DataTable {
5101
5179
  // A table disposed by the press (its selection callback threw the
5102
5180
  // list away) opens nothing.
5103
5181
  if (!this._wrapperEl || typeof this.config.onRowActivate !== 'function') return;
5182
+ // STORY-0016. In select mode a gesture ticks; nothing opens.
5183
+ if (this._selecting) return;
5104
5184
  try { this.config.onRowActivate(tr.__rowIndex, tr.__row, ev); } catch (err) {
5105
5185
  console.error('[DataTable] onRowActivate threw', err);
5106
5186
  }
@@ -5527,7 +5607,7 @@ export class DataTable {
5527
5607
  */
5528
5608
  autoSizeColumn(domIdx, opts = {}) {
5529
5609
  const natural = this._measureNaturalWidths({ sample: true });
5530
- if (!natural || domIdx < 0 || domIdx >= natural.length) return false;
5610
+ if (!natural || domIdx < this._lead().select || domIdx >= natural.length) return false;
5531
5611
  this._colWidths[domIdx] = this._autoWidth(natural[domIdx], opts);
5532
5612
  this._colWidthsSig = this._colSig();
5533
5613
  this._savePersisted();
@@ -5554,7 +5634,7 @@ export class DataTable {
5554
5634
  const natural = this._measureNaturalWidths({ sample: true });
5555
5635
  if (!natural) return false;
5556
5636
  this._colWidths = {};
5557
- for (let i = 0; i < natural.length; i++) {
5637
+ for (let i = this._lead().select; i < natural.length; i++) {
5558
5638
  this._colWidths[i] = this._autoWidth(natural[i], opts);
5559
5639
  }
5560
5640
  this._colWidthsSig = this._colSig();
@@ -5588,7 +5668,7 @@ export class DataTable {
5588
5668
  */
5589
5669
  _allColumnsPinned(n) {
5590
5670
  if (!n || this._colWidthsSig !== this._colSig()) return false;
5591
- for (let i = 0; i < n; i++) {
5671
+ for (let i = this._lead().select; i < n; i++) {
5592
5672
  if (this._colWidths[i] == null) return false;
5593
5673
  }
5594
5674
  return true;
@@ -5640,10 +5720,12 @@ export class DataTable {
5640
5720
  const wrap = this._tableWrapEl;
5641
5721
  const headerWrap = this._headerWrapEl;
5642
5722
  const avail = wrap ? wrap.clientWidth : 0;
5643
- const firstIdx = this.config.showRowNumbers && n > 1 ? 1 : 0;
5723
+ const lead = this._lead().all;
5724
+ const firstIdx = lead && n > lead ? lead : 0;
5644
5725
  const widths = this._fitColumnWidths(natural, avail, {
5645
5726
  firstIdx,
5646
- overrides: this._colWidths,
5727
+ // STORY-0016. The select column is as wide as its controls, always.
5728
+ overrides: this._gutter ? { ...this._colWidths, 0: this._selectColWidth() } : this._colWidths,
5647
5729
  fit: this.config.columnFit,
5648
5730
  floors: this._filterFloors,
5649
5731
  });
@@ -5968,6 +6050,7 @@ export class DataTable {
5968
6050
  if (!headRow) return;
5969
6051
  [...headRow.children].forEach((th, domIdx) => {
5970
6052
  if (th.querySelector('.twm-dt-col-resizer')) return;
6053
+ if (th.classList.contains('twm-dt-select-cell')) return; // STORY-0016: fixed
5971
6054
  th.classList.add('twm-dt-col');
5972
6055
  const grip = document.createElement('div');
5973
6056
  grip.className = 'twm-dt-col-resizer';
@@ -6180,6 +6263,21 @@ export class DataTable {
6180
6263
  if (!(was && (event.metaKey || event.ctrlKey))) selected.add(idx);
6181
6264
  else DESELECTING_CLICKS.add(event); // so it does not ALSO open it
6182
6265
  this._state.anchorIndex = selected.size ? idx : null;
6266
+ } else if (this._selecting) {
6267
+ // STORY-0016. A plain click ticks one row; Shift adds the
6268
+ // range from the last row ticked, as SHOWN.
6269
+ if (event.shiftKey && this._state.anchorIndex != null) {
6270
+ const order = this._processedIndexMap || this.config.rows.map((_, i) => i);
6271
+ const a = order.indexOf(this._state.anchorIndex);
6272
+ const b = order.indexOf(idx);
6273
+ if (a >= 0 && b >= 0) {
6274
+ for (let i = Math.min(a, b); i <= Math.max(a, b); i++) selected.add(order[i]);
6275
+ } else selected.add(idx);
6276
+ } else if (selected.has(idx)) selected.delete(idx);
6277
+ else selected.add(idx);
6278
+ this._state.anchorIndex = idx;
6279
+ this._navCursor = idx;
6280
+ this._markCursor();
6183
6281
  } else if (event.shiftKey && this._state.anchorIndex != null) {
6184
6282
  const start = Math.min(this._state.anchorIndex, idx);
6185
6283
  const end = Math.max(this._state.anchorIndex, idx);
@@ -6239,7 +6337,7 @@ export class DataTable {
6239
6337
  const selected = this._state.selected;
6240
6338
  // Where the arrows left off, while that row is still selected;
6241
6339
  // else the anchor (a click since moved it).
6242
- const cur = this._navCursor != null && selected.has(this._navCursor)
6340
+ const cur = this._navCursor != null && (this._selecting || selected.has(this._navCursor))
6243
6341
  ? this._navCursor : this._state.anchorIndex;
6244
6342
  let pos = order.indexOf(cur);
6245
6343
  if (ev.key === 'Home') pos = 0;
@@ -6249,6 +6347,22 @@ export class DataTable {
6249
6347
  const idx = order[pos];
6250
6348
  this._navCursor = idx;
6251
6349
  const anchorPos = order.indexOf(this._state.anchorIndex);
6350
+ if (this._selecting) {
6351
+ // STORY-0016. The arrows move the cursor; the ticks stay.
6352
+ // Shift ticks the rows passed over.
6353
+ if (ev.shiftKey) {
6354
+ const from = order.indexOf(cur);
6355
+ if (from >= 0) {
6356
+ for (let i = Math.min(from, pos); i <= Math.max(from, pos); i++) selected.add(order[i]);
6357
+ } else selected.add(idx);
6358
+ this._updateRowSelection();
6359
+ this._notifySelectionChange();
6360
+ }
6361
+ this._state.anchorIndex = idx;
6362
+ this._markCursor();
6363
+ this.scrollToRow(idx);
6364
+ return;
6365
+ }
6252
6366
  if (ev.shiftKey && selectable !== 'single' && anchorPos !== -1) {
6253
6367
  // Shift: the range between the fixed anchor and the moving
6254
6368
  // row, as Shift-click selects it.
@@ -6268,6 +6382,26 @@ export class DataTable {
6268
6382
  this._disposers.push(() => table.removeEventListener('keydown', onNavKey));
6269
6383
  }
6270
6384
 
6385
+ // STORY-0016. Space ticks the keyboard's row in select mode.
6386
+ if (this._gutter) {
6387
+ const onSpace = (ev) => {
6388
+ if (!this._selecting || ev.key !== ' ' || ev.ctrlKey || ev.metaKey || ev.altKey) return;
6389
+ if (isRowControl(ev.target, table)) return;
6390
+ const idx = this._navCursor ?? this._state.anchorIndex;
6391
+ if (idx == null || !this.config.rows[idx]) return;
6392
+ ev.preventDefault();
6393
+ const selected = this._state.selected;
6394
+ if (selected.has(idx)) selected.delete(idx); else selected.add(idx);
6395
+ this._state.anchorIndex = idx;
6396
+ this._updateRowSelection();
6397
+ this._notifySelectionChange();
6398
+ };
6399
+ table.addEventListener('keydown', onSpace);
6400
+ this._disposers.push(() => table.removeEventListener('keydown', onSpace));
6401
+ this._paintSelectHead();
6402
+ this._markCursor();
6403
+ }
6404
+
6271
6405
  // 0.5.0 `onRowActivate`. Bound AFTER the selection handler on the same
6272
6406
  // element, so a row is selected before it is opened — the listener
6273
6407
  // order is the guarantee. `onRowClick` stays exactly where it was (on
@@ -6315,6 +6449,9 @@ export class DataTable {
6315
6449
  const idx = rowEl.__rowIndex;
6316
6450
  let custom = null;
6317
6451
  if (customMenu) {
6452
+ // STORY-0016. In select mode the menu is about the ticked
6453
+ // rows when the row is one of them, else about that row
6454
+ // alone — and the ticks stay as they were.
6318
6455
  const selection = idx !== undefined && !this._state.selected.has(idx)
6319
6456
  ? [idx] : this.getSelection();
6320
6457
  const ctx = this._contextMenuCtxFor(rowEl, event.target, selection);
@@ -6328,7 +6465,7 @@ export class DataTable {
6328
6465
  event.preventDefault();
6329
6466
  event.stopPropagation();
6330
6467
 
6331
- if (idx !== undefined && !this._state.selected.has(idx)) {
6468
+ if (idx !== undefined && !this._state.selected.has(idx) && !this._selecting) {
6332
6469
  this._state.selected.clear();
6333
6470
  this._state.selected.add(idx);
6334
6471
  this._state.anchorIndex = idx;
@@ -6356,21 +6493,7 @@ export class DataTable {
6356
6493
  event.preventDefault();
6357
6494
  } else if (key === 'a' && selectable) {
6358
6495
  event.preventDefault();
6359
- this._state.selected.clear();
6360
- // Select only visible (filtered) rows via index map
6361
- const indexMap = this._processedIndexMap;
6362
- if (indexMap) {
6363
- for (const originalIdx of indexMap) {
6364
- this._state.selected.add(originalIdx);
6365
- }
6366
- } else {
6367
- for (let i = 0; i < this.config.rows.length; i++) {
6368
- this._state.selected.add(i);
6369
- }
6370
- }
6371
- this._state.anchorIndex = this.config.rows.length - 1;
6372
- this._updateRowSelection();
6373
- this._notifySelectionChange();
6496
+ this._selectAllShown();
6374
6497
  } else if (key === 'c') {
6375
6498
  event.preventDefault();
6376
6499
  this.copyToClipboard(event.shiftKey ? 'csv' : 'tsv');
@@ -6378,6 +6501,8 @@ export class DataTable {
6378
6501
  } else if (event.key === 'Escape') {
6379
6502
  if (this._state.selected.size) {
6380
6503
  this.clearSelection();
6504
+ } else if (this._selecting && this.config.selectToggle) {
6505
+ this.setSelectMode(false); // STORY-0016
6381
6506
  }
6382
6507
  this._hideContextMenu();
6383
6508
  }
@@ -6393,9 +6518,119 @@ export class DataTable {
6393
6518
  this._tbodyEl.querySelectorAll('tr').forEach(tr => {
6394
6519
  const idx = tr.__rowIndex;
6395
6520
  if (idx !== undefined) {
6396
- tr.classList.toggle('selected', this._state.selected.has(idx));
6521
+ const on = this._state.selected.has(idx);
6522
+ tr.classList.toggle('selected', on);
6523
+ // STORY-0016.
6524
+ const box = this._gutter ? tr.querySelector(':scope > td.twm-dt-select-cell > input') : null;
6525
+ if (box) box.checked = on;
6397
6526
  }
6398
6527
  });
6528
+ this._paintSelectHead();
6529
+ }
6530
+
6531
+ /* ── STORY-0016: select mode ─────────────────────────────────────── */
6532
+
6533
+ /** Whether the table is in select mode. */
6534
+ isSelectMode() {
6535
+ return !!this._selecting;
6536
+ }
6537
+
6538
+ /**
6539
+ * Turn select mode on or off. The ticks are kept either way (a selection
6540
+ * is a selection); the consumer clears it if leaving means "never mind".
6541
+ * Does nothing on a table without the select column.
6542
+ *
6543
+ * @param {boolean} on
6544
+ * @returns {boolean} whether the mode changed
6545
+ */
6546
+ setSelectMode(on) {
6547
+ if (!this._gutter || !!on === this._selecting) return false;
6548
+ this._selecting = !!on;
6549
+ this._navCursor = this._selecting ? this._state.anchorIndex : null;
6550
+ if (this._wrapperEl) this.render();
6551
+ try { this.config.onSelectModeChange?.(this._selecting); } catch (err) {
6552
+ console.error('[DataTable] onSelectModeChange threw', err);
6553
+ }
6554
+ return true;
6555
+ }
6556
+
6557
+ /** The rows the filters let through, on every page, by original index. */
6558
+ _shownIndices() {
6559
+ if (this._isServerSide()) return this.config.rows.map((_, i) => i);
6560
+ this._getProcessedRows();
6561
+ return this._processedIndexMap ? [...this._processedIndexMap] : this.config.rows.map((_, i) => i);
6562
+ }
6563
+
6564
+ /** Ctrl+A and the select-all box: every row the filters let through. */
6565
+ _selectAllShown() {
6566
+ this._state.selected.clear();
6567
+ for (const i of this._shownIndices()) this._state.selected.add(i);
6568
+ this._state.anchorIndex = this.config.rows.length - 1;
6569
+ this._updateRowSelection();
6570
+ this._notifySelectionChange();
6571
+ }
6572
+
6573
+ /** The select column's width: its controls, never a drag. */
6574
+ _selectColWidth() {
6575
+ return this._selecting ? (this.config.selectToggle ? 56 : 32) : 32;
6576
+ }
6577
+
6578
+ /** The select column's header: the toggle and, in the mode, select-all. */
6579
+ _buildSelectHead() {
6580
+ const th = document.createElement('th');
6581
+ th.className = 'twm-dt-select-cell twm-dt-select-head';
6582
+ if (this._selecting) {
6583
+ const all = document.createElement('input');
6584
+ all.type = 'checkbox';
6585
+ all.className = 'twm-dt-select-all';
6586
+ all.setAttribute('aria-label', 'Select all rows');
6587
+ all.title = 'Select all rows';
6588
+ all.addEventListener('click', (ev) => {
6589
+ // Not prevented: the browser would undo the state set here.
6590
+ ev.stopPropagation();
6591
+ const shown = this._shownIndices();
6592
+ const every = shown.length > 0 && shown.every((i) => this._state.selected.has(i));
6593
+ if (every) this.clearSelection(); else this._selectAllShown();
6594
+ });
6595
+ th.appendChild(all);
6596
+ }
6597
+ if (this.config.selectToggle) {
6598
+ const btn = document.createElement('button');
6599
+ btn.type = 'button';
6600
+ btn.className = 'material-symbols-outlined twm-dt-select-toggle';
6601
+ btn.textContent = this._selecting ? 'close' : 'checklist';
6602
+ btn.setAttribute('aria-pressed', this._selecting ? 'true' : 'false');
6603
+ const label = this._selecting ? 'Stop selecting' : 'Select multiple';
6604
+ btn.setAttribute('aria-label', label);
6605
+ btn.title = label;
6606
+ btn.addEventListener('click', (ev) => {
6607
+ ev.stopPropagation();
6608
+ this.setSelectMode(!this._selecting);
6609
+ });
6610
+ th.appendChild(btn);
6611
+ }
6612
+ th.classList.toggle('twm-dt-select-head--on', this._selecting);
6613
+ return th;
6614
+ }
6615
+
6616
+ /** The select-all box: checked, mixed or clear, as the ticks are. */
6617
+ _paintSelectHead() {
6618
+ if (!this._gutter || !this._wrapperEl) return;
6619
+ const all = this._wrapperEl.querySelector('.twm-dt-select-all');
6620
+ if (!all) return;
6621
+ const shown = this._shownIndices();
6622
+ const n = shown.filter((i) => this._state.selected.has(i)).length;
6623
+ all.checked = n > 0 && n === shown.length;
6624
+ all.indeterminate = n > 0 && n < shown.length;
6625
+ }
6626
+
6627
+ /** The keyboard's row in select mode, marked so it can be seen. */
6628
+ _markCursor() {
6629
+ if (!this._tbodyEl) return;
6630
+ for (const tr of this._tbodyEl.children) {
6631
+ if (tr.__rowIndex === undefined) continue;
6632
+ tr.classList.toggle('twm-dt-row--cursor', this._selecting && tr.__rowIndex === this._navCursor);
6633
+ }
6399
6634
  }
6400
6635
 
6401
6636
  _notifySelectionChange() {
@@ -116,7 +116,9 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
116
116
  body.className = 'twm-modal__body-host';
117
117
  body.innerHTML = `
118
118
  <form class="twm-modal__form">
119
- ${fields.map((f) => _renderEntry(f, defaults[f.name] ?? f.default)).join('')}
119
+ <div class="twm-modal__fields">
120
+ ${fields.map((f) => _renderEntry(f, defaults[f.name] ?? f.default)).join('')}
121
+ </div>
120
122
  <div class="twm-modal__error" data-role="form-error" hidden></div>
121
123
  <div class="twm-modal__actions">
122
124
  <button type="button" class="twm-btn" data-action="cancel">Cancel</button>
@@ -700,7 +702,7 @@ function _estimateHeight(fields) {
700
702
  body += 32 + rows * 18;
701
703
  if (f.hint) body += 16;
702
704
  }
703
- else if (f.type === 'checkbox') body += 32;
705
+ else if (f.type === 'checkbox') { body += 32; if (f.hint) body += 16; }
704
706
  else { body += 46; if (f.hint) body += 16; }
705
707
  }
706
708
  const base = 36 + 46 + 28 + body;