tosijs-ui 1.5.0 → 1.5.3

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.
@@ -125,6 +125,7 @@ export declare class TosiTable extends WebComponent {
125
125
  localized: boolean;
126
126
  };
127
127
  selectionChanged: SelectCallback;
128
+ rowRendered: ((item: any, cells: HTMLElement[]) => void) | null;
128
129
  private selectedKey;
129
130
  private selectBinding;
130
131
  maxVisibleRows: number;
@@ -174,6 +175,8 @@ export declare class TosiTable extends WebComponent {
174
175
  get visibleRows(): any[];
175
176
  get visibleSelectedRows(): any[];
176
177
  get selectedRows(): any[];
178
+ getCells(itemOrCell: any): HTMLElement[] | undefined;
179
+ getItem(cell: Element): any;
177
180
  private draggedColumn?;
178
181
  private dropColumn;
179
182
  render(): void;
@@ -45,13 +45,24 @@ const columns = [
45
45
  },
46
46
  ]
47
47
 
48
- preview.append(tosiTable({
48
+ const table = tosiTable({
49
49
  multiple: true,
50
50
  array: emojiData,
51
51
  localized: true,
52
52
  columns,
53
53
  rowHeight: 40,
54
- }))
54
+ })
55
+
56
+ table.addEventListener('mouseover', (e) => {
57
+ for (const el of table.querySelectorAll('.row-hover')) {
58
+ el.classList.remove('row-hover')
59
+ }
60
+ const item = table.getItem(e.target)
61
+ if (!item) return
62
+ table.getCells(item)?.forEach(c => c.classList.add('row-hover'))
63
+ })
64
+
65
+ preview.append(table)
55
66
  ```
56
67
  ```css
57
68
  .preview input.td {
@@ -64,6 +75,10 @@ preview.append(tosiTable({
64
75
  .preview tosi-table {
65
76
  height: 100%;
66
77
  }
78
+
79
+ .preview .row-hover {
80
+ background: #08835810;
81
+ }
67
82
  ```
68
83
  ```test
69
84
  const table = await waitFor('tosi-table')
@@ -98,6 +113,30 @@ test('row selection via data model', () => {
98
113
  expect(items[0][table.selectedKey]).not.toBe(true)
99
114
  expect(items[1][table.selectedKey]).not.toBe(true)
100
115
  })
116
+
117
+ test('getCells and getItem', async () => {
118
+ // Wait for list binding to stamp DOM elements
119
+ const items = table.visibleRows
120
+ let cells
121
+ await new Promise(resolve => {
122
+ const check = () => {
123
+ cells = table.getCells(items[0])
124
+ if (cells) return resolve()
125
+ setTimeout(check, 100)
126
+ }
127
+ check()
128
+ })
129
+
130
+ expect(cells.length).toBe(table.visibleColumns.length)
131
+
132
+ // getItem round-trips back to the same item
133
+ const item = table.getItem(cells[0])
134
+ expect(item).toBe(items[0])
135
+
136
+ // getCells from a cell element
137
+ const cellsFromCell = table.getCells(cells[1])
138
+ expect(cellsFromCell).toBe(cells)
139
+ })
101
140
  ```
102
141
 
103
142
  > In the preceding example, the `name` column is *editable* (and *bound*, try editing something and scrolling
@@ -143,15 +182,18 @@ rendering with no jitter.
143
182
  import { elements } from 'tosijs'
144
183
  import { tosiTable, icons } from 'tosijs-ui'
145
184
 
146
- const { button } = elements
185
+ const { button, span } = elements
147
186
 
148
187
  const count = 100
149
188
  const cols = ['Q1', 'Q2', 'Q3', 'Q4']
189
+ const numKeys = []
150
190
  const rows = Array.from({ length: count }, (_, i) => {
151
191
  const row = { id: i + 1, name: 'Item ' + (i + 1) }
152
192
  for (const year of [2024, 2025, 2026]) {
153
193
  for (const q of cols) {
154
- row[q + ' ' + year] = Math.round(Math.random() * 10000) / 100
194
+ const key = q + ' ' + year
195
+ row[key] = Math.round((Math.random() * 200 - 100) * 100) / 100
196
+ if (i === 0) numKeys.push(key)
155
197
  }
156
198
  }
157
199
  return row
@@ -159,25 +201,44 @@ const rows = Array.from({ length: count }, (_, i) => {
159
201
 
160
202
  // totals row
161
203
  const totals = { id: '', name: 'Total' }
162
- for (const key of Object.keys(rows[0])) {
163
- if (key === 'id' || key === 'name') continue
204
+ for (const key of numKeys) {
164
205
  totals[key] = Math.round(rows.reduce((sum, r) => sum + r[key], 0) * 100) / 100
165
206
  }
166
207
  rows.push(totals)
167
208
 
168
- const dataColumns = []
169
- for (const year of [2024, 2025, 2026]) {
170
- for (const q of cols) {
171
- dataColumns.push({ prop: q + ' ' + year, width: 100, align: 'right' })
172
- }
209
+ // custom cell that colors negative numbers red
210
+ function numCell(options) {
211
+ return span({
212
+ class: 'td num-cell',
213
+ bindText: '^.' + options.prop,
214
+ bind: {
215
+ value: '^.' + options.prop,
216
+ binding: {
217
+ toDOM(el, val) {
218
+ el.style.color = val < 0 ? '#c00' : ''
219
+ }
220
+ }
221
+ }
222
+ })
173
223
  }
174
224
 
175
- preview.append(tosiTable({
225
+ const dataColumns = numKeys.map(key => ({
226
+ prop: key, width: 100, align: 'right', dataCell: numCell,
227
+ }))
228
+
229
+ const table = tosiTable({
176
230
  array: rows,
177
231
  rowHeight: 32,
178
232
  pinnedBottom: 1,
179
233
  pinnedLeft: 2,
180
234
  pinnedRight: 1,
235
+ rowRendered(item, cells) {
236
+ const total = numKeys.reduce((sum, key) => sum + (item[key] || 0), 0)
237
+ const cls = total < 0 ? 'row-negative' : ''
238
+ for (const c of cells) {
239
+ c.classList.toggle('row-negative', total < 0)
240
+ }
241
+ },
181
242
  columns: [
182
243
  { prop: 'id', name: '#', width: 50, align: 'right' },
183
244
  { prop: 'name', width: 120 },
@@ -199,7 +260,9 @@ preview.append(tosiTable({
199
260
  },
200
261
  },
201
262
  ],
202
- }))
263
+ })
264
+
265
+ preview.append(table)
203
266
  ```
204
267
  ```css
205
268
  .preview tosi-table {
@@ -217,6 +280,12 @@ preview.append(tosiTable({
217
280
  background: #eee;
218
281
  font-weight: bold;
219
282
  }
283
+ .preview .row-negative {
284
+ background: #fdd;
285
+ }
286
+ .preview .num-cell {
287
+ font-variant-numeric: tabular-nums;
288
+ }
220
289
  ```
221
290
 
222
291
  ## Selection
@@ -237,6 +306,43 @@ The following methods are also provided:
237
306
 
238
307
  These are rather fine-grained but they're used internally by the selection code so they may as well be documented.
239
308
 
309
+ ## Row Access
310
+
311
+ Because the table uses a flat CSS grid (no `.tr` row elements), two methods
312
+ provide O(1) access between items and their cells:
313
+
314
+ - `<tosi-table>.getCells(itemOrCell)` — returns the `HTMLElement[]` of cells for a
315
+ given data item or any cell in the row, or `undefined` if the row isn't
316
+ currently rendered (virtual scroll)
317
+ - `<tosi-table>.getItem(cell)` — returns the data item bound to a cell element
318
+
319
+ These are useful for row-level hover effects, styling, and event handling:
320
+
321
+ ```typescript
322
+ table.addEventListener('mouseover', (e) => {
323
+ for (const el of table.querySelectorAll('.row-hover')) {
324
+ el.classList.remove('row-hover')
325
+ }
326
+ const item = table.getItem(e.target)
327
+ if (!item) return
328
+ table.getCells(item)?.forEach(c => c.classList.add('row-hover'))
329
+ })
330
+ ```
331
+
332
+ ### `rowRendered` callback
333
+
334
+ For virtual tables, cells are created and destroyed as you scroll. The
335
+ `rowRendered` callback fires whenever a row's cells are rendered, letting
336
+ you apply styling that survives virtualisation:
337
+
338
+ ```typescript
339
+ table.rowRendered = (item, cells) => {
340
+ if (item.overdue) {
341
+ cells.forEach(c => c.classList.add('overdue'))
342
+ }
343
+ }
344
+ ```
345
+
240
346
  ## Sorting
241
347
 
242
348
  By default, the user can sort the table by any column which doesn't have a `sort === false`.
@@ -300,7 +406,7 @@ You'll need to make sure your localized strings include:
300
406
 
301
407
  As well as any column names you want localized.
302
408
  */
303
- import { Component as WebComponent, elements, vars, varDefault, tosiValue, getListItem, tosi, } from 'tosijs';
409
+ import { Component as WebComponent, elements, vars, varDefault, tosiValue, getListItem, getListBinding, bind, tosi, } from 'tosijs';
304
410
  import { trackDrag } from './track-drag';
305
411
  import { icons } from './icons';
306
412
  import { popMenu } from './menu';
@@ -445,6 +551,7 @@ export class TosiTable extends WebComponent {
445
551
  selectionChanged = () => {
446
552
  /* do not care */
447
553
  };
554
+ rowRendered = null;
448
555
  selectedKey = Symbol('selected');
449
556
  selectBinding = (elt, obj) => {
450
557
  if (obj == null)
@@ -590,12 +697,12 @@ export class TosiTable extends WebComponent {
590
697
  return style;
591
698
  }
592
699
  applyPinnedToCustomCell(cell, colIndex, si, style) {
593
- cell.dataset.col = String(colIndex);
700
+ cell.setAttribute('aria-colindex', String(colIndex + 1));
594
701
  cell.tabIndex = -1;
595
702
  cell.classList.add(...this.cellClasses('td', si).split(' '));
596
703
  Object.assign(cell.style, style);
597
704
  }
598
- buildPinnedCells(rows, cols, stickyInfo, pin, rowHeight) {
705
+ buildPinnedCells(rows, cols, stickyInfo, pin, rowHeight, startRowIndex) {
599
706
  const cells = [];
600
707
  for (let r = 0; r < rows.length; r++) {
601
708
  const rowItem = rows[r];
@@ -607,13 +714,14 @@ export class TosiTable extends WebComponent {
607
714
  const si = stickyInfo[c];
608
715
  cells.push(span({
609
716
  class: this.cellClasses(`td pinned-${pin}`, si),
610
- role: 'cell',
717
+ role: 'gridcell',
611
718
  tabindex: -1,
719
+ ariaRowindex: String(startRowIndex + r + 1),
720
+ ariaColindex: String(c + 1),
612
721
  style: this.cellStyle(col, si, {
613
722
  position: 'sticky',
614
723
  [pin]: offset,
615
724
  }),
616
- dataCol: String(c),
617
725
  }, String(rowItem[col.prop] ?? '')));
618
726
  }
619
727
  }
@@ -777,44 +885,20 @@ export class TosiTable extends WebComponent {
777
885
  if (!this._grid)
778
886
  return null;
779
887
  const cols = this.visibleColumns.length;
780
- // Header cells
888
+ // Header cells (row -1)
781
889
  if (rowIndex === -1) {
782
- return this._grid.querySelector(`.th[data-col="${colIndex}"]`);
783
- }
784
- // Pinned top cells
785
- if (rowIndex < this.pinnedTop) {
786
- let count = 0;
787
- for (const child of this._grid.children) {
788
- const el = child;
789
- if (el.classList.contains('pinned-top') &&
790
- el.dataset.col === String(colIndex)) {
791
- if (count === rowIndex)
792
- return el;
793
- count++;
794
- }
795
- }
796
- return null;
890
+ return this._grid.querySelector(`.th[aria-colindex="${colIndex + 1}"]`);
797
891
  }
798
- // Pinned bottom cells
799
- const totalRows = this._array.length;
800
- if (rowIndex >= totalRows - this.pinnedBottom) {
801
- const bottomIdx = rowIndex - (totalRows - this.pinnedBottom);
802
- let count = 0;
803
- for (const child of this._grid.children) {
804
- const el = child;
805
- if (el.classList.contains('pinned-bottom') &&
806
- el.dataset.col === String(colIndex)) {
807
- if (count === bottomIdx)
808
- return el;
809
- count++;
810
- }
811
- }
812
- return null;
813
- }
814
- // Virtual data cells — find by aria-rowindex and aria-colindex
892
+ // Pinned cells use full-array-relative aria-rowindex
893
+ // Virtual data cells use visible-data-relative aria-rowindex (set by bindList)
894
+ // Try pinned first, then virtual
895
+ const cell = this._grid.querySelector(`.pinned-top[aria-rowindex="${rowIndex + 1}"][aria-colindex="${colIndex + 1}"],` +
896
+ `.pinned-bottom[aria-rowindex="${rowIndex + 1}"][aria-colindex="${colIndex + 1}"]`);
897
+ if (cell)
898
+ return cell;
899
+ // Virtual data cell: convert full-array index to visible-data index
815
900
  const dataRowIndex = rowIndex - this.pinnedTop;
816
- const cell = this._grid.querySelector(`[aria-rowindex="${dataRowIndex + 1}"][aria-colindex="${colIndex + 1}"]`);
817
- return cell;
901
+ return this._grid.querySelector(`[aria-rowindex="${dataRowIndex + 1}"][aria-colindex="${colIndex + 1}"]:not(.pinned-top):not(.pinned-bottom)`);
818
902
  }
819
903
  _pendingFocus = null;
820
904
  onScrollEnd = () => {
@@ -850,45 +934,29 @@ export class TosiTable extends WebComponent {
850
934
  const target = el.closest('.td') || el.closest('.th');
851
935
  if (!target)
852
936
  return;
853
- const colIndex = parseInt(target.dataset.col, 10);
854
- if (isNaN(colIndex))
937
+ const ariaCol = parseInt(target.getAttribute('aria-colindex') || '', 10);
938
+ if (isNaN(ariaCol))
855
939
  return;
940
+ const colIndex = ariaCol - 1;
856
941
  const cols = this.visibleColumns.length;
857
942
  const totalRows = this._array.length;
858
943
  const meta = event.metaKey || event.ctrlKey;
859
944
  const isHeader = target.classList.contains('th');
860
- // Determine current logical row index (-1 for header)
945
+ // Determine current logical row index (-1 for header, 0-based full-array for data)
861
946
  let rowIndex;
862
947
  if (isHeader) {
863
948
  rowIndex = -1;
864
949
  }
865
- else if (target.classList.contains('pinned-top')) {
866
- let count = 0;
867
- for (const child of this._grid.children) {
868
- if (child === target)
869
- break;
870
- const c = child;
871
- if (c.classList.contains('pinned-top') &&
872
- c.dataset.col === String(colIndex)) {
873
- count++;
874
- }
875
- }
876
- rowIndex = count;
877
- }
878
- else if (target.classList.contains('pinned-bottom')) {
879
- let count = 0;
880
- for (const child of this._grid.children) {
881
- if (child === target)
882
- break;
883
- const c = child;
884
- if (c.classList.contains('pinned-bottom') &&
885
- c.dataset.col === String(colIndex)) {
886
- count++;
887
- }
888
- }
889
- rowIndex = totalRows - this.pinnedBottom + count;
950
+ else if (target.classList.contains('pinned-top') ||
951
+ target.classList.contains('pinned-bottom')) {
952
+ // Pinned cells: aria-rowindex is full-array-relative (1-based)
953
+ const ariaRow = parseInt(target.getAttribute('aria-rowindex') || '', 10);
954
+ if (isNaN(ariaRow))
955
+ return;
956
+ rowIndex = ariaRow - 1;
890
957
  }
891
958
  else {
959
+ // Virtual data cells: aria-rowindex is visible-data-relative (1-based)
892
960
  const ariaRow = parseInt(target.getAttribute('aria-rowindex') || '', 10);
893
961
  if (isNaN(ariaRow))
894
962
  return;
@@ -986,9 +1054,9 @@ export class TosiTable extends WebComponent {
986
1054
  if (this._grid) {
987
1055
  const stickyInfo = this.computeStickyInfo(cols);
988
1056
  for (const cell of this._grid.querySelectorAll('.col-pinned')) {
989
- const colIndex = parseInt(cell.dataset.col, 10);
990
- if (!isNaN(colIndex) && stickyInfo[colIndex]) {
991
- const si = stickyInfo[colIndex];
1057
+ const ci = parseInt(cell.getAttribute('aria-colindex') || '', 10) - 1;
1058
+ if (!isNaN(ci) && stickyInfo[ci]) {
1059
+ const si = stickyInfo[ci];
992
1060
  if (si.left != null)
993
1061
  cell.style.left = si.left;
994
1062
  if (si.right != null)
@@ -1086,10 +1154,24 @@ export class TosiTable extends WebComponent {
1086
1154
  get selectedRows() {
1087
1155
  return this.array.filter((obj) => obj[this.selectedKey]);
1088
1156
  }
1157
+ getCells(itemOrCell) {
1158
+ if (!this._grid)
1159
+ return undefined;
1160
+ const binding = getListBinding(this._grid);
1161
+ if (!binding)
1162
+ return undefined;
1163
+ const item = itemOrCell instanceof Element ? getListItem(itemOrCell) : itemOrCell;
1164
+ if (item == null)
1165
+ return undefined;
1166
+ return binding.itemToElement.get(tosiValue(item));
1167
+ }
1168
+ getItem(cell) {
1169
+ return getListItem(cell);
1170
+ }
1089
1171
  draggedColumn;
1090
1172
  dropColumn = (event) => {
1091
1173
  const target = event.target.closest('.drag-over');
1092
- const colIndex = parseInt(target.dataset.col, 10);
1174
+ const colIndex = parseInt(target.getAttribute('aria-colindex') || '', 10) - 1;
1093
1175
  const dropped = this.visibleColumns[colIndex];
1094
1176
  const draggedIndex = this.columns.indexOf(this.draggedColumn);
1095
1177
  const droppedIndex = this.columns.indexOf(dropped);
@@ -1151,8 +1233,8 @@ export class TosiTable extends WebComponent {
1151
1233
  role: 'columnheader',
1152
1234
  tabindex: -1,
1153
1235
  ariaSort,
1236
+ ariaColindex: String(i + 1),
1154
1237
  style: this.cellStyle(col, si),
1155
- dataCol: String(i),
1156
1238
  }, this.captionSpan({ style: { flex: '1' } }, typeof col.name === 'string' ? col.name : col.prop), menuButton);
1157
1239
  // Apply sticky to custom headerCell
1158
1240
  if (col.headerCell !== undefined) {
@@ -1174,11 +1256,23 @@ export class TosiTable extends WebComponent {
1174
1256
  }
1175
1257
  return cell;
1176
1258
  });
1177
- const pinnedTopCells = this.buildPinnedCells(pinnedTopData, cols, stickyInfo, 'top', rowHeight);
1178
- const pinnedBottomCells = this.buildPinnedCells(pinnedBottomData, cols, stickyInfo, 'bottom', rowHeight);
1259
+ const pinnedTopCells = this.buildPinnedCells(pinnedTopData, cols, stickyInfo, 'top', rowHeight, 0);
1260
+ const pinnedBottomCells = this.buildPinnedCells(pinnedBottomData, cols, stickyInfo, 'bottom', rowHeight, this._array.length - this.pinnedBottom);
1179
1261
  // Data cells via listBinding with itemsPerRow
1180
1262
  const selectEnabled = this.select || this.multiple;
1181
1263
  const selectBindingFn = this.selectBinding;
1264
+ const { rowRendered } = this;
1265
+ const lastCol = cols.length - 1;
1266
+ const rowRenderedBinding = rowRendered
1267
+ ? (cell) => {
1268
+ const item = getListItem(cell);
1269
+ if (item != null) {
1270
+ const cells = this.getCells(item);
1271
+ if (cells)
1272
+ rowRendered(item, cells);
1273
+ }
1274
+ }
1275
+ : null;
1182
1276
  const binding = this.rowData.visible.listBinding(({ span: s }, item, colIndex) => {
1183
1277
  const col = cols[colIndex];
1184
1278
  const si = stickyInfo[colIndex];
@@ -1186,14 +1280,16 @@ export class TosiTable extends WebComponent {
1186
1280
  if (col.dataCell != null) {
1187
1281
  const customCell = col.dataCell(col);
1188
1282
  this.applyPinnedToCustomCell(customCell, colIndex, si, style);
1283
+ if (rowRenderedBinding && colIndex === lastCol) {
1284
+ bind(customCell, item, { toDOM: rowRenderedBinding });
1285
+ }
1189
1286
  return customCell;
1190
1287
  }
1191
1288
  const props = {
1192
1289
  class: this.cellClasses('td', si),
1193
- role: 'cell',
1290
+ role: 'gridcell',
1194
1291
  tabindex: -1,
1195
1292
  style,
1196
- dataCol: String(colIndex),
1197
1293
  bindText: item[col.prop],
1198
1294
  };
1199
1295
  if (selectEnabled) {
@@ -1202,6 +1298,19 @@ export class TosiTable extends WebComponent {
1202
1298
  binding: { toDOM: selectBindingFn },
1203
1299
  };
1204
1300
  }
1301
+ // Fire rowRendered on the last cell of each row
1302
+ if (rowRenderedBinding && colIndex === lastCol) {
1303
+ props.bind = {
1304
+ value: item,
1305
+ binding: {
1306
+ toDOM(cell) {
1307
+ if (selectEnabled)
1308
+ selectBindingFn(cell, getListItem(cell));
1309
+ rowRenderedBinding(cell);
1310
+ },
1311
+ },
1312
+ };
1313
+ }
1205
1314
  return s(props);
1206
1315
  }, {
1207
1316
  virtual: {