flexdesk 0.11.0 → 0.12.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.
@@ -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() {