@tipolox/litgrid-web 1.0.2 → 1.1.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.
@@ -1,5 +1,5 @@
1
1
  // src/components/DataGrid.ts
2
- import { LitElement, css, html, render as litRender } from 'lit';
2
+ import { LitElement, css, html, nothing, render as litRender } from 'lit';
3
3
  import { cache } from 'lit/directives/cache.js';
4
4
  import { createGridEngine } from '@tipolox/litgrid-core';
5
5
  import { calculateDisplayLayout, createVariableVirtualizer, createVirtualizer, mapDisplayScrollOffset } from '@tipolox/litgrid-renderer';
@@ -11,6 +11,7 @@ import { normalizeRowHeight } from '../sizing/rowSizing.js';
11
11
  import { serializeSelectedCells } from '../clipboard/serializeSelectedCells.js';
12
12
  import { serializeSelectedRows } from '../clipboard/serializeSelectedRows.js';
13
13
  import { createColumnState, parseColumnState, resolveColumnState } from '../columnState/columnState.js';
14
+ const CELL_CONTROL_SELECTOR = 'a[href], area[href], button, input, select, textarea, summary, audio[controls], video[controls], [tabindex], [contenteditable]:not([contenteditable="false"])';
14
15
  const FILTER_OPERATORS = [
15
16
  { value: 'contains', label: 'Contains' },
16
17
  { value: 'equals', label: 'Equals' },
@@ -60,6 +61,7 @@ export class DataGrid extends LitElement {
60
61
  this.scrollbarWidth = 0;
61
62
  this.columnDefs = [];
62
63
  this.sourceColumnDefs = [];
64
+ this._inferColumns = true;
63
65
  this.columnWidths = new Map();
64
66
  this.hiddenColumnKeys = new Set();
65
67
  this._columnStateStorageKey = null;
@@ -88,6 +90,11 @@ export class DataGrid extends LitElement {
88
90
  this.headerRowElement = null;
89
91
  this.activeCell = null;
90
92
  this.shouldFocusActiveCell = false;
93
+ this.gridCellHasFocus = false;
94
+ this.shouldFocusViewport = false;
95
+ this.renderedActiveCellId = null;
96
+ this.renderedRowStart = 0;
97
+ this.renderedColumnStart = 0;
91
98
  this.headerMenuTrigger = null;
92
99
  this.shouldFocusHeaderMenu = false;
93
100
  this.shouldRestoreHeaderMenuFocus = false;
@@ -143,22 +150,7 @@ export class DataGrid extends LitElement {
143
150
  this.requestUpdate();
144
151
  };
145
152
  this.stopColumnReorder = () => {
146
- const state = this.columnReorderState;
147
- if (!state)
148
- return;
149
- this.columnReorderState = null;
150
- window.removeEventListener('pointermove', this.handleColumnReorder);
151
- window.removeEventListener('pointerup', this.stopColumnReorder);
152
- window.removeEventListener('pointercancel', this.stopColumnReorder);
153
- this.removeColumnDragPreview();
154
- if (state.hasMoved)
155
- this.stopBodyResize();
156
- if (state.hasMoved && state.sourceIndex !== state.targetIndex) {
157
- this.moveColumn(state.columnKey, state.targetIndex);
158
- }
159
- else {
160
- this.requestUpdate();
161
- }
153
+ this.finishColumnReorder();
162
154
  };
163
155
  this.handleColumnResize = (event) => {
164
156
  if (!this.columnResizeState) {
@@ -169,14 +161,7 @@ export class DataGrid extends LitElement {
169
161
  this.requestUpdate();
170
162
  };
171
163
  this.stopColumnResize = () => {
172
- if (!this.columnResizeState) {
173
- return;
174
- }
175
- this.columnResizeState = null;
176
- this.commitColumnState('resize');
177
- this.stopBodyResize();
178
- window.removeEventListener('pointermove', this.handleColumnResize);
179
- window.removeEventListener('pointerup', this.stopColumnResize);
164
+ this.finishColumnResize();
180
165
  };
181
166
  this.handleRowResize = (event) => {
182
167
  if (!this.rowResizeState) {
@@ -241,6 +226,8 @@ export class DataGrid extends LitElement {
241
226
  static { this.styles = [lightThemeStyles, darkThemeStyles, css `
242
227
  :host {
243
228
  display: block;
229
+ min-width: 0;
230
+ max-width: 100%;
244
231
  color: var(--litgrid-color-text);
245
232
  font: 14px/1.4 system-ui, sans-serif;
246
233
  }
@@ -368,6 +355,8 @@ export class DataGrid extends LitElement {
368
355
 
369
356
  .header-row-header {
370
357
  position: relative;
358
+ /* Cover translated headers without clipping menus below the header. */
359
+ z-index: 4;
371
360
  height: 36px;
372
361
  border-right: 1px solid var(--litgrid-color-border);
373
362
  }
@@ -706,6 +695,7 @@ export class DataGrid extends LitElement {
706
695
 
707
696
  .pagination {
708
697
  display: flex;
698
+ flex-wrap: wrap;
709
699
  align-items: center;
710
700
  justify-content: flex-end;
711
701
  gap: 10px;
@@ -716,8 +706,29 @@ export class DataGrid extends LitElement {
716
706
  font-size: 13px;
717
707
  }
718
708
 
709
+ .pagination-summary,
710
+ .pagination-navigation {
711
+ display: flex;
712
+ flex-wrap: wrap;
713
+ align-items: center;
714
+ gap: 10px;
715
+ min-width: 0;
716
+ }
717
+
718
+ .pagination span,
719
+ .pagination label {
720
+ white-space: nowrap;
721
+ }
722
+
723
+ .pagination label {
724
+ display: inline-flex;
725
+ align-items: center;
726
+ gap: 4px;
727
+ }
728
+
719
729
  .pagination button,
720
730
  .pagination select {
731
+ flex-shrink: 0;
721
732
  border: 1px solid var(--litgrid-color-border);
722
733
  border-radius: 6px;
723
734
  background: var(--litgrid-color-surface);
@@ -740,6 +751,10 @@ export class DataGrid extends LitElement {
740
751
  }
741
752
  updated() {
742
753
  this.updateScrollbarWidth();
754
+ if (this.shouldFocusViewport) {
755
+ this.shouldFocusViewport = false;
756
+ this.viewportElement?.focus({ preventScroll: true });
757
+ }
743
758
  this.focusActiveCell();
744
759
  this.focusHeaderMenu();
745
760
  // Detect actual DOM spacer height after render. Some browsers clamp
@@ -775,6 +790,7 @@ export class DataGrid extends LitElement {
775
790
  viewportElement.removeEventListener('scroll', this.handleScroll);
776
791
  }
777
792
  this.headerRowElement = null;
793
+ this.viewportElement = null;
778
794
  if (this.measureContainer && this.measureContainer.parentNode) {
779
795
  this.measureContainer.parentNode.removeChild(this.measureContainer);
780
796
  }
@@ -805,6 +821,7 @@ export class DataGrid extends LitElement {
805
821
  if (!(viewport instanceof HTMLElement)) {
806
822
  return;
807
823
  }
824
+ this.viewportElement = viewport;
808
825
  const nextWidth = viewport.offsetWidth - viewport.clientWidth;
809
826
  const nextViewportWidth = viewport.clientWidth;
810
827
  if (nextWidth !== this.scrollbarWidth || nextViewportWidth !== this.viewportWidth) {
@@ -814,7 +831,7 @@ export class DataGrid extends LitElement {
814
831
  }
815
832
  }
816
833
  getResolvedColumns() {
817
- if (this.columnDefs.length > 0) {
834
+ if (this.columnDefs.length > 0 || !this._inferColumns) {
818
835
  return this.columnDefs;
819
836
  }
820
837
  const firstRow = this.engine.getRows()[0];
@@ -1046,7 +1063,7 @@ export class DataGrid extends LitElement {
1046
1063
  return null;
1047
1064
  }
1048
1065
  }
1049
- restorePersistedColumnState() {
1066
+ restorePersistedColumnState(notify = true) {
1050
1067
  if (!this._columnStateStorageKey || this.sourceColumnDefs.length === 0)
1051
1068
  return;
1052
1069
  const storage = this.getColumnStateStorage();
@@ -1062,7 +1079,8 @@ export class DataGrid extends LitElement {
1062
1079
  this.hiddenColumnKeys = resolvedState.hiddenKeys;
1063
1080
  this.activeCell = null;
1064
1081
  this.requestUpdate();
1065
- this.dispatchColumnStateChange('restore');
1082
+ if (notify)
1083
+ this.dispatchColumnStateChange('restore');
1066
1084
  }
1067
1085
  catch {
1068
1086
  // Storage may be unavailable or blocked by browser privacy settings.
@@ -1138,7 +1156,7 @@ export class DataGrid extends LitElement {
1138
1156
  this.columnChooserOpen = false;
1139
1157
  this.columnChooserPosition = null;
1140
1158
  this.shouldFocusHeaderMenu = false;
1141
- this.shouldRestoreHeaderMenuFocus = restoreFocus && this.headerMenuTrigger !== null;
1159
+ this.shouldRestoreHeaderMenuFocus = restoreFocus;
1142
1160
  this.requestUpdate();
1143
1161
  }
1144
1162
  toggleColumnChooser(event) {
@@ -1147,6 +1165,9 @@ export class DataGrid extends LitElement {
1147
1165
  return;
1148
1166
  }
1149
1167
  if (event?.currentTarget instanceof HTMLElement) {
1168
+ if (!this.activeHeaderMenu && event.currentTarget instanceof HTMLButtonElement) {
1169
+ this.headerMenuTrigger = event.currentTarget;
1170
+ }
1150
1171
  const shell = this.renderRoot.querySelector('.shell');
1151
1172
  const menu = event.currentTarget.closest('.header-menu');
1152
1173
  const anchorBounds = (menu ?? event.currentTarget).getBoundingClientRect();
@@ -1172,13 +1193,17 @@ export class DataGrid extends LitElement {
1172
1193
  this.columnChooserOpen = false;
1173
1194
  this.columnChooserPosition = null;
1174
1195
  this.shouldFocusHeaderMenu = false;
1175
- this.shouldRestoreHeaderMenuFocus = restoreFocus && this.headerMenuTrigger !== null;
1196
+ this.shouldRestoreHeaderMenuFocus = restoreFocus;
1176
1197
  this.requestUpdate();
1177
1198
  }
1178
1199
  focusHeaderMenu() {
1179
1200
  if (this.shouldRestoreHeaderMenuFocus) {
1180
1201
  this.shouldRestoreHeaderMenuFocus = false;
1181
- this.headerMenuTrigger?.focus();
1202
+ const target = this.headerMenuTrigger?.isConnected
1203
+ ? this.headerMenuTrigger
1204
+ : this.renderRoot.querySelector('.header-action-button, .columns-button')
1205
+ ?? this.viewportElement;
1206
+ target?.focus();
1182
1207
  this.headerMenuTrigger = null;
1183
1208
  return;
1184
1209
  }
@@ -1199,12 +1224,16 @@ export class DataGrid extends LitElement {
1199
1224
  this.closeHeaderMenu(true);
1200
1225
  }
1201
1226
  handleViewportFocus(event) {
1202
- if (event.target !== event.currentTarget || this.activeCell) {
1227
+ if (event.target !== event.currentTarget || this.activeCell || this.engine.getRowCount() === 0 || this.getVisibleColumns().length === 0) {
1203
1228
  return;
1204
1229
  }
1205
1230
  this.setActiveCell(0, 0);
1206
1231
  this.requestUpdate();
1207
1232
  }
1233
+ handleGridFocusIn(event) {
1234
+ const target = event.composedPath()[0];
1235
+ this.gridCellHasFocus = target instanceof HTMLElement && target.classList.contains('cell');
1236
+ }
1208
1237
  announceScreenReader(message) {
1209
1238
  if (!this.screenReaderAnnouncements || !message || message === this.lastScreenReaderAnnouncement) {
1210
1239
  return;
@@ -1214,7 +1243,18 @@ export class DataGrid extends LitElement {
1214
1243
  this.requestUpdate();
1215
1244
  }
1216
1245
  getScreenReaderDescription() {
1217
- return this.ariaDescription || 'Use arrow keys to move between cells. Press Enter or Space to select. Press Control or Command C to copy selected cells or rows.';
1246
+ if (this.ariaDescription)
1247
+ return this.ariaDescription;
1248
+ const mode = this.engine.getConfig().selection.mode;
1249
+ const navigation = 'Use arrow keys to move between cells. Press F2 to interact with controls in a cell and Escape to return to cell navigation.';
1250
+ if (mode === 'none')
1251
+ return navigation;
1252
+ const selection = mode === 'multi-row'
1253
+ ? 'Press Enter or Space to select a row. Hold Control or Command to toggle a row, or Shift to select a range of rows.'
1254
+ : mode === 'multi-cell'
1255
+ ? 'Press Enter or Space to toggle a cell.'
1256
+ : `Press Enter or Space to select a ${mode === 'row' ? 'row' : 'cell'}.`;
1257
+ return `${navigation} ${selection} Press Control or Command C to copy selected cells or rows.`;
1218
1258
  }
1219
1259
  getColumnLabel(columnKey) {
1220
1260
  const column = this.getVisibleColumns().find((item) => item.key === columnKey)
@@ -1238,8 +1278,11 @@ export class DataGrid extends LitElement {
1238
1278
  announceActiveCell() {
1239
1279
  this.announceScreenReader(this.getActiveCellAnnouncement());
1240
1280
  }
1241
- announceRowSelection(rowIndex) {
1242
- this.announceScreenReader(`Row ${rowIndex + 1} ${this.engine.isRowSelected(rowIndex) ? 'selected' : 'deselected'}.`);
1281
+ announceRowSelection(rowIndex, intent = 'replace') {
1282
+ const anchor = this.engine.getSelection().anchorRowIndex ?? rowIndex;
1283
+ this.announceScreenReader(intent === 'range' && anchor !== rowIndex
1284
+ ? `Rows ${Math.min(anchor, rowIndex) + 1} through ${Math.max(anchor, rowIndex) + 1} selected.`
1285
+ : `Row ${rowIndex + 1} ${this.engine.isRowSelected(rowIndex) ? 'selected' : 'deselected'}.`);
1243
1286
  }
1244
1287
  announceCellSelection(rowIndex, columnKey) {
1245
1288
  this.announceScreenReader(`${this.getColumnLabel(columnKey)}, row ${rowIndex + 1} ${this.engine.isCellSelected(rowIndex, columnKey) ? 'selected' : 'deselected'}.`);
@@ -1252,27 +1295,27 @@ export class DataGrid extends LitElement {
1252
1295
  this.engine.sortBy(columnKey, 'asc');
1253
1296
  this.announceScreenReader(`${this.getColumnLabel(columnKey)} sorted ascending.`);
1254
1297
  this.requestUpdate();
1255
- this.closeHeaderMenu();
1298
+ this.closeHeaderMenu(true);
1256
1299
  }
1257
1300
  handleSortDescending(columnKey) {
1258
1301
  this.engine.sortBy(columnKey, 'desc');
1259
1302
  this.announceScreenReader(`${this.getColumnLabel(columnKey)} sorted descending.`);
1260
1303
  this.requestUpdate();
1261
- this.closeHeaderMenu();
1304
+ this.closeHeaderMenu(true);
1262
1305
  }
1263
1306
  handleClearSort() {
1264
1307
  this.engine.clearSort();
1265
1308
  this.announceScreenReader('Sorting cleared.');
1266
1309
  this.requestUpdate();
1267
- this.closeHeaderMenu();
1310
+ this.closeHeaderMenu(true);
1268
1311
  }
1269
1312
  handleBestFit(columnKey) {
1270
1313
  this.bestFitColumn(columnKey);
1271
- this.closeHeaderMenu();
1314
+ this.closeHeaderMenu(true);
1272
1315
  }
1273
1316
  handleBestFitAllColumns() {
1274
1317
  this.bestFitAllColumns();
1275
- this.closeHeaderMenu();
1318
+ this.closeHeaderMenu(true);
1276
1319
  }
1277
1320
  getTextMeasureContext() {
1278
1321
  if (this.textMeasureContext) {
@@ -1428,7 +1471,7 @@ export class DataGrid extends LitElement {
1428
1471
  }
1429
1472
  toggleQuickSearch() {
1430
1473
  this.quickSearchVisible = !this.quickSearchVisible;
1431
- this.closeHeaderMenu();
1474
+ this.closeHeaderMenu(true);
1432
1475
  }
1433
1476
  handleQuickSearchInput(query) {
1434
1477
  this.quickSearchDraft = query;
@@ -1488,18 +1531,35 @@ export class DataGrid extends LitElement {
1488
1531
  return this.engine.getTotalRowCount();
1489
1532
  }
1490
1533
  selectRow(rowIndex, intent) {
1491
- this.engine.selectRow(rowIndex, intent);
1492
- this.announceRowSelection(rowIndex);
1534
+ if (!this.isRowSelectionMode() || !Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= this.engine.getRowCount())
1535
+ return;
1536
+ const effectiveIntent = this.engine.getConfig().selection.mode === 'row' ? 'replace' : intent ?? 'replace';
1537
+ const previous = this.engine.getSelection();
1538
+ // The engine's range operation always produces a contiguous anchored range.
1539
+ // Its endpoint and size identify a repeated operation without scanning rows.
1540
+ const unchanged = effectiveIntent === 'range'
1541
+ ? previous.rowIndex === rowIndex && previous.rowIndexes.size === Math.abs((previous.anchorRowIndex ?? rowIndex) - rowIndex) + 1
1542
+ : effectiveIntent === 'replace' && previous.rowIndexes.size === 1 && previous.rowIndexes.has(rowIndex);
1543
+ this.engine.selectRow(rowIndex, effectiveIntent);
1544
+ if (!unchanged)
1545
+ this.announceRowSelection(rowIndex, effectiveIntent);
1493
1546
  this.requestUpdate();
1494
1547
  }
1495
1548
  selectAllRows() {
1549
+ if (this.engine.getConfig().selection.mode !== 'multi-row' || this.engine.getRowCount() === 0)
1550
+ return;
1551
+ const unchanged = this.engine.getSelection().rowIndexes.size === this.engine.getRowCount();
1496
1552
  this.engine.selectAllRows();
1497
- this.announceScreenReader(`All ${this.engine.getRowCount()} displayed rows selected.`);
1553
+ if (!unchanged)
1554
+ this.announceScreenReader(`All ${this.engine.getRowCount()} displayed rows selected.`);
1498
1555
  this.requestUpdate();
1499
1556
  }
1500
1557
  clearSelection() {
1558
+ const previous = this.engine.getSelection();
1559
+ const hadSelection = previous.rowIndexes.size > 0 || previous.cells.size > 0;
1501
1560
  this.engine.clearSelection();
1502
- this.announceScreenReader('Selection cleared.');
1561
+ if (hadSelection)
1562
+ this.announceScreenReader('Selection cleared.');
1503
1563
  this.requestUpdate();
1504
1564
  }
1505
1565
  getSelection() {
@@ -1561,11 +1621,11 @@ export class DataGrid extends LitElement {
1561
1621
  ...filter,
1562
1622
  value: this.isValueFreeFilter(filter.operator) ? undefined : filter.value
1563
1623
  });
1564
- this.closeHeaderMenu();
1624
+ this.closeHeaderMenu(true);
1565
1625
  }
1566
1626
  clearHeaderFilter(columnKey) {
1567
1627
  this.clearFilter(columnKey);
1568
- this.closeHeaderMenu();
1628
+ this.closeHeaderMenu(true);
1569
1629
  }
1570
1630
  isColumnFiltered(columnKey) {
1571
1631
  return this.engine.getFilters().some((filter) => filter.columnKey === columnKey);
@@ -1663,6 +1723,24 @@ export class DataGrid extends LitElement {
1663
1723
  this.columnDragPreview?.remove();
1664
1724
  this.columnDragPreview = null;
1665
1725
  }
1726
+ finishColumnReorder(commit = true) {
1727
+ const state = this.columnReorderState;
1728
+ if (!state)
1729
+ return;
1730
+ this.columnReorderState = null;
1731
+ window.removeEventListener('pointermove', this.handleColumnReorder);
1732
+ window.removeEventListener('pointerup', this.stopColumnReorder);
1733
+ window.removeEventListener('pointercancel', this.stopColumnReorder);
1734
+ this.removeColumnDragPreview();
1735
+ if (state.hasMoved)
1736
+ this.stopBodyResize();
1737
+ if (commit && state.hasMoved && state.sourceIndex !== state.targetIndex) {
1738
+ this.moveColumn(state.columnKey, state.targetIndex);
1739
+ }
1740
+ else {
1741
+ this.requestUpdate();
1742
+ }
1743
+ }
1666
1744
  getGridCellId(rowIndex, columnIndex) {
1667
1745
  return `${this.gridId}-cell-${rowIndex}-${columnIndex}`;
1668
1746
  }
@@ -1709,6 +1787,17 @@ export class DataGrid extends LitElement {
1709
1787
  window.addEventListener('pointermove', this.handleColumnResize);
1710
1788
  window.addEventListener('pointerup', this.stopColumnResize);
1711
1789
  }
1790
+ finishColumnResize(commit = true) {
1791
+ if (!this.columnResizeState) {
1792
+ return;
1793
+ }
1794
+ this.columnResizeState = null;
1795
+ if (commit)
1796
+ this.commitColumnState('resize');
1797
+ this.stopBodyResize();
1798
+ window.removeEventListener('pointermove', this.handleColumnResize);
1799
+ window.removeEventListener('pointerup', this.stopColumnResize);
1800
+ }
1712
1801
  startRowResize(event, rowIndex) {
1713
1802
  event.preventDefault();
1714
1803
  event.stopPropagation();
@@ -1787,8 +1876,23 @@ export class DataGrid extends LitElement {
1787
1876
  this.shouldFocusActiveCell = shouldFocus;
1788
1877
  this.announceActiveCell();
1789
1878
  }
1879
+ reconcileActiveCell(rowCount, columnCount) {
1880
+ if (!this.activeCell)
1881
+ return;
1882
+ if (rowCount === 0 || columnCount === 0) {
1883
+ this.activeCell = null;
1884
+ this.shouldFocusActiveCell = false;
1885
+ return;
1886
+ }
1887
+ const rowIndex = Math.min(this.activeCell.rowIndex, rowCount - 1);
1888
+ const columnIndex = Math.min(this.activeCell.columnIndex, columnCount - 1);
1889
+ if (rowIndex !== this.activeCell.rowIndex || columnIndex !== this.activeCell.columnIndex) {
1890
+ this.activeCell = { rowIndex, columnIndex };
1891
+ this.shouldFocusActiveCell = this.gridCellHasFocus;
1892
+ }
1893
+ }
1790
1894
  focusActiveCell() {
1791
- if (!this.shouldFocusActiveCell || !this.activeCell) {
1895
+ if (!this.shouldFocusActiveCell || !this.activeCell || !this.renderedActiveCellId) {
1792
1896
  return;
1793
1897
  }
1794
1898
  const cell = this.renderRoot.querySelector(`.cell[data-row-index="${this.activeCell.rowIndex}"][data-column-index="${this.activeCell.columnIndex}"]`);
@@ -1892,32 +1996,115 @@ export class DataGrid extends LitElement {
1892
1996
  viewport.scrollLeft = Math.max(0, columnRight - bodyViewportWidth);
1893
1997
  }
1894
1998
  }
1999
+ getCellControls(cell) {
2000
+ // Only inspect the cell explicitly entered by the user, never a render or
2001
+ // virtualization path. Custom focusable hosts can own shadow controls.
2002
+ return Array.from(cell.querySelectorAll(CELL_CONTROL_SELECTOR))
2003
+ .filter((control) => !control.matches(':disabled, input[type="hidden"]')
2004
+ && !control.closest('[hidden], [inert]'));
2005
+ }
2006
+ focusCellControl(control) {
2007
+ control.focus({ preventScroll: true });
2008
+ return control.getRootNode().activeElement === control;
2009
+ }
2010
+ syncInteractionCell(cell) {
2011
+ const rowIndex = Number(cell.dataset.rowIndex);
2012
+ const columnIndex = Number(cell.dataset.columnIndex);
2013
+ if (this.activeCell?.rowIndex !== rowIndex || this.activeCell.columnIndex !== columnIndex) {
2014
+ this.setActiveCell(rowIndex, columnIndex);
2015
+ this.requestUpdate();
2016
+ }
2017
+ }
1895
2018
  handleKeyDown(event) {
1896
- const target = event.target;
1897
- if (target instanceof HTMLInputElement || target instanceof HTMLSelectElement || target instanceof HTMLButtonElement) {
2019
+ if (event.defaultPrevented)
1898
2020
  return;
2021
+ let owningCell;
2022
+ let insideControl = false;
2023
+ // The composed path also protects nested markup and renderer shadow controls.
2024
+ for (const target of event.composedPath()) {
2025
+ if (!(target instanceof Element))
2026
+ continue;
2027
+ if (target.matches('.cell[role="gridcell"]')) {
2028
+ owningCell = target;
2029
+ break;
2030
+ }
2031
+ if (target === event.currentTarget)
2032
+ break;
2033
+ if (target.matches(CELL_CONTROL_SELECTOR))
2034
+ insideControl = true;
2035
+ }
2036
+ if (insideControl) {
2037
+ if (!owningCell)
2038
+ return;
2039
+ if (event.key === 'Escape') {
2040
+ event.preventDefault();
2041
+ event.stopPropagation();
2042
+ this.syncInteractionCell(owningCell);
2043
+ owningCell.focus({ preventScroll: true });
2044
+ return;
2045
+ }
2046
+ const focused = this.renderRoot.activeElement;
2047
+ if (event.key !== 'Tab' || !focused || focused === owningCell || !owningCell.contains(focused))
2048
+ return;
2049
+ // An open shadow widget owns the Tab order of its internal controls.
2050
+ if (focused.shadowRoot?.activeElement)
2051
+ return;
2052
+ const controls = this.getCellControls(owningCell).filter((control) => control.tabIndex >= 0
2053
+ || control === focused || control.matches('[contenteditable]:not([tabindex])'));
2054
+ const current = controls.indexOf(focused);
2055
+ // Shadow widgets own their internal Tab order; a focusable host is their entry point.
2056
+ if (current < 0)
2057
+ return;
2058
+ const direction = event.shiftKey ? -1 : 1;
2059
+ for (let index = current + direction; index >= 0 && index < controls.length; index += direction) {
2060
+ if (this.focusCellControl(controls[index])) {
2061
+ event.preventDefault();
2062
+ return;
2063
+ }
2064
+ }
2065
+ this.syncInteractionCell(owningCell);
2066
+ if (event.shiftKey) {
2067
+ event.preventDefault();
2068
+ owningCell.focus({ preventScroll: true });
2069
+ return;
2070
+ }
2071
+ // At the last control, resume the existing forward cell Tab navigation.
1899
2072
  }
1900
2073
  const rowCount = this.engine.getRowCount();
1901
2074
  const columnCount = this.getVisibleColumns().length;
2075
+ this.reconcileActiveCell(rowCount, columnCount);
1902
2076
  if (rowCount === 0 || columnCount === 0) {
1903
2077
  return;
1904
2078
  }
1905
- if ((event.key === 'Enter' || event.key === ' ') && !event.ctrlKey && !event.metaKey && !event.altKey) {
2079
+ if (event.key === 'F2' && !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey) {
2080
+ const active = this.activeCell ?? { rowIndex: 0, columnIndex: 0 };
2081
+ const cell = owningCell ?? this.renderRoot.querySelector(`.cell[data-row-index="${active.rowIndex}"][data-column-index="${active.columnIndex}"]`);
2082
+ if (cell) {
2083
+ for (const control of this.getCellControls(cell)) {
2084
+ if (this.focusCellControl(control)) {
2085
+ event.preventDefault();
2086
+ break;
2087
+ }
2088
+ }
2089
+ }
2090
+ return;
2091
+ }
2092
+ if ((event.key === 'Enter' || event.key === ' ') && !event.altKey) {
1906
2093
  const activeCell = this.activeCell ?? { rowIndex: 0, columnIndex: 0 };
1907
- if (this.isCellSelectionMode()) {
2094
+ if (this.isCellSelectionMode() && !event.ctrlKey && !event.metaKey) {
1908
2095
  const column = this.getVisibleColumns()[activeCell.columnIndex];
1909
2096
  if (column) {
1910
2097
  event.preventDefault();
2098
+ const unchanged = this.engine.getConfig().selection.mode === 'cell' && this.engine.isCellSelected(activeCell.rowIndex, column.key);
1911
2099
  this.engine.selectCell(activeCell.rowIndex, column.key);
1912
- this.announceCellSelection(activeCell.rowIndex, column.key);
2100
+ if (!unchanged)
2101
+ this.announceCellSelection(activeCell.rowIndex, column.key);
1913
2102
  this.requestUpdate();
1914
2103
  }
1915
2104
  }
1916
2105
  else if (this.isRowSelectionMode()) {
1917
2106
  event.preventDefault();
1918
- this.engine.selectRow(activeCell.rowIndex, 'replace');
1919
- this.announceRowSelection(activeCell.rowIndex);
1920
- this.requestUpdate();
2107
+ this.selectRow(activeCell.rowIndex, this.getRowSelectionIntent(event));
1921
2108
  }
1922
2109
  return;
1923
2110
  }
@@ -1996,9 +2183,7 @@ export class DataGrid extends LitElement {
1996
2183
  if (!this.isRowSelectionMode()) {
1997
2184
  return;
1998
2185
  }
1999
- this.engine.selectRow(rowIndex, this.getRowSelectionIntent(event));
2000
- this.announceRowSelection(rowIndex);
2001
- this.requestUpdate();
2186
+ this.selectRow(rowIndex, this.getRowSelectionIntent(event));
2002
2187
  }
2003
2188
  handleRowMouseDown(event) {
2004
2189
  if (this.isRowSelectionMode() && event.shiftKey) {
@@ -2012,8 +2197,10 @@ export class DataGrid extends LitElement {
2012
2197
  return;
2013
2198
  }
2014
2199
  event.stopPropagation();
2200
+ const unchanged = this.engine.getConfig().selection.mode === 'cell' && this.engine.isCellSelected(rowIndex, column.key);
2015
2201
  this.engine.selectCell(rowIndex, column.key);
2016
- this.announceCellSelection(rowIndex, column.key);
2202
+ if (!unchanged)
2203
+ this.announceCellSelection(rowIndex, column.key);
2017
2204
  this.requestUpdate();
2018
2205
  }
2019
2206
  renderCell(column, row, rowIndex) {
@@ -2066,7 +2253,6 @@ export class DataGrid extends LitElement {
2066
2253
  `;
2067
2254
  }
2068
2255
  set data(value) {
2069
- this.activeCell = null;
2070
2256
  this.clearQuickSearchDebounce();
2071
2257
  this.sourceRowCount = Array.isArray(value) ? value.length : 0;
2072
2258
  this.engine.setData(value ?? []);
@@ -2076,9 +2262,7 @@ export class DataGrid extends LitElement {
2076
2262
  this.requestUpdate();
2077
2263
  }
2078
2264
  handleRowCheckboxChange(rowIndex) {
2079
- this.engine.selectRow(rowIndex, 'toggle');
2080
- this.announceRowSelection(rowIndex);
2081
- this.requestUpdate();
2265
+ this.selectRow(rowIndex, 'toggle');
2082
2266
  }
2083
2267
  getDisplayedSelectedRowCount() {
2084
2268
  const rowCount = this.engine.getRowCount();
@@ -2109,13 +2293,89 @@ export class DataGrid extends LitElement {
2109
2293
  }
2110
2294
  this.requestUpdate();
2111
2295
  }
2296
+ get inferColumns() {
2297
+ return this._inferColumns;
2298
+ }
2299
+ set inferColumns(value) {
2300
+ if (this._inferColumns === value)
2301
+ return;
2302
+ this._inferColumns = value;
2303
+ this.requestUpdate();
2304
+ }
2305
+ /** Reconcile an explicit schema, optionally switching its persistence namespace atomically. */
2306
+ updateColumnSchema(value, applyOrder = false, storageKey = this._columnStateStorageKey) {
2307
+ const columns = value.map(column => ({ ...column }));
2308
+ const nextByKey = new Map(columns.map(column => [column.key, column]));
2309
+ if (nextByKey.size !== columns.length)
2310
+ throw new Error('Duplicate column schema keys.');
2311
+ const previousByKey = new Map(this.sourceColumnDefs.map(column => [column.key, column]));
2312
+ const activeCell = this.activeCell;
2313
+ const activeColumnKey = activeCell
2314
+ ? this.getVisibleColumns()[activeCell.columnIndex]?.key
2315
+ : undefined;
2316
+ const nextStorageKey = typeof storageKey === 'string' && storageKey.trim() ? storageKey : null;
2317
+ const storageChanged = nextStorageKey !== this._columnStateStorageKey;
2318
+ this.finishColumnResize(false);
2319
+ this.finishColumnReorder(false);
2320
+ if (storageChanged) {
2321
+ // Install the target schema and defaults before restoring its namespace.
2322
+ // Do not reconcile/persist the outgoing view, or carry its customization
2323
+ // into an unsaved target. A namespace switch itself performs no writes.
2324
+ this._columnStateStorageKey = nextStorageKey;
2325
+ this.sourceColumnDefs = columns;
2326
+ this.columnDefs = [...columns];
2327
+ this.columnWidths.clear();
2328
+ this.hiddenColumnKeys = new Set(columns.filter(column => column.hidden).map(column => column.key));
2329
+ this.restorePersistedColumnState(false);
2330
+ }
2331
+ else {
2332
+ for (const key of this.columnWidths.keys()) {
2333
+ if (!nextByKey.has(key))
2334
+ this.columnWidths.delete(key);
2335
+ }
2336
+ for (const key of this.hiddenColumnKeys) {
2337
+ if (!nextByKey.has(key))
2338
+ this.hiddenColumnKeys.delete(key);
2339
+ }
2340
+ for (const column of columns) {
2341
+ const previous = previousByKey.get(column.key);
2342
+ if (!previous || previous.width !== column.width)
2343
+ this.columnWidths.delete(column.key);
2344
+ if (!previous || previous.hidden !== column.hidden) {
2345
+ if (column.hidden)
2346
+ this.hiddenColumnKeys.add(column.key);
2347
+ else
2348
+ this.hiddenColumnKeys.delete(column.key);
2349
+ }
2350
+ }
2351
+ // Surviving columns keep user order; newly registered columns follow them.
2352
+ // An explicit Order change reapplies the complete deterministic schema order.
2353
+ const retained = this.columnDefs.filter(column => nextByKey.has(column.key))
2354
+ .map(column => nextByKey.get(column.key));
2355
+ const retainedKeys = new Set(retained.map(column => column.key));
2356
+ this.columnDefs = applyOrder ? columns : [
2357
+ ...retained, ...columns.filter(column => !retainedKeys.has(column.key))
2358
+ ];
2359
+ this.sourceColumnDefs = columns;
2360
+ this.persistColumnState();
2361
+ }
2362
+ this.clearBestFitWidths();
2363
+ if (activeCell) {
2364
+ const columnIndex = this.getVisibleColumns().findIndex(column => column.key === activeColumnKey);
2365
+ this.activeCell = columnIndex < 0 ? null : { ...activeCell, columnIndex };
2366
+ }
2367
+ this.requestUpdate();
2368
+ }
2112
2369
  set columns(value) {
2113
- this.activeCell = null;
2370
+ const activeCell = this.activeCell;
2114
2371
  this.sourceColumnDefs = Array.isArray(value) ? [...value] : [];
2115
2372
  this.columnDefs = [...this.sourceColumnDefs];
2116
2373
  this.hiddenColumnKeys = new Set(this.sourceColumnDefs.filter((column) => column.hidden).map((column) => column.key));
2117
2374
  this.clearBestFitWidths();
2118
2375
  this.restorePersistedColumnState();
2376
+ // Structural synchronization must not discard navigation. This also
2377
+ // restores the position after persisted column state has been applied.
2378
+ this.activeCell = activeCell;
2119
2379
  this.requestUpdate();
2120
2380
  }
2121
2381
  get columnStateStorageKey() {
@@ -2210,6 +2470,7 @@ export class DataGrid extends LitElement {
2210
2470
  }
2211
2471
  // Ensure start/end indices are within valid bounds to avoid empty renders
2212
2472
  const safeStartIndex = Math.max(0, Math.min(range.startIndex, Math.max(0, count)));
2473
+ let renderedRowStart = safeStartIndex;
2213
2474
  const safeEndIndex = Math.max(safeStartIndex, Math.min(range.endIndex, count));
2214
2475
  let rows = this.engine.getVisibleRows(safeStartIndex, safeEndIndex);
2215
2476
  if (rows.length === 0 && count > 0) {
@@ -2219,6 +2480,7 @@ export class DataGrid extends LitElement {
2219
2480
  const fallbackStart = Math.max(0, count - visibleCountFallback);
2220
2481
  const fallbackEnd = count;
2221
2482
  rows = this.engine.getVisibleRows(fallbackStart, fallbackEnd);
2483
+ renderedRowStart = fallbackStart;
2222
2484
  }
2223
2485
  const columns = this.getVisibleColumns();
2224
2486
  const columnWidths = columns.map((column) => this.getColumnWidth(column));
@@ -2230,6 +2492,23 @@ export class DataGrid extends LitElement {
2230
2492
  });
2231
2493
  const columnRange = this.columnVirtualizer.getState();
2232
2494
  const visibleColumns = columns.slice(columnRange.startIndex, columnRange.endIndex);
2495
+ this.reconcileActiveCell(count, columns.length);
2496
+ const activeCellIsRendered = this.activeCell !== null
2497
+ && this.activeCell.rowIndex >= renderedRowStart
2498
+ && this.activeCell.rowIndex < renderedRowStart + rows.length
2499
+ && this.activeCell.columnIndex >= columnRange.startIndex
2500
+ && this.activeCell.columnIndex < columnRange.endIndex;
2501
+ this.renderedActiveCellId = activeCellIsRendered && this.activeCell
2502
+ ? this.getGridCellId(this.activeCell.rowIndex, this.activeCell.columnIndex)
2503
+ : null;
2504
+ if (!activeCellIsRendered || this.renderedRowStart !== renderedRowStart || this.renderedColumnStart !== columnRange.startIndex) {
2505
+ // Preserve logical navigation when the cell leaves either rendered range.
2506
+ // A focused node can also be reused for another cell when the range moves.
2507
+ // Hand off once; later scroll renders don't touch focus on the viewport.
2508
+ this.shouldFocusViewport = this.gridCellHasFocus;
2509
+ }
2510
+ this.renderedRowStart = renderedRowStart;
2511
+ this.renderedColumnStart = columnRange.startIndex;
2233
2512
  const visibleColumnWidths = columnWidths.slice(columnRange.startIndex, columnRange.endIndex);
2234
2513
  const isFirstVisibleColumn = (column, index) => index === 0;
2235
2514
  const gridTemplate = [
@@ -2439,22 +2718,25 @@ export class DataGrid extends LitElement {
2439
2718
  </div>
2440
2719
  ${visibleColumns.length === 0
2441
2720
  ? html `<div class="column-chooser-empty">
2442
- <button type="button" @click=${() => this.toggleColumnChooser()}>Columns</button>
2721
+ <button class="columns-button" type="button" @click=${(event) => this.toggleColumnChooser(event)}>Columns</button>
2443
2722
  </div>`
2444
2723
  : ''}
2445
2724
  <div
2446
2725
  class="viewport"
2447
2726
  role="grid"
2727
+ aria-multiselectable=${['multi-row', 'multi-cell'].includes(this.engine.getConfig().selection.mode) ? 'true' : nothing}
2448
2728
  aria-label=${this.ariaLabel || 'Data grid'}
2449
2729
  aria-rowcount=${count + 1}
2450
2730
  aria-colcount=${columns.length + (this.isRowHeaderVisible() ? 1 : 0)}
2451
- aria-activedescendant=${this.activeCell ? this.getGridCellId(this.activeCell.rowIndex, this.activeCell.columnIndex) : undefined}
2731
+ aria-activedescendant=${this.renderedActiveCellId ?? nothing}
2452
2732
  aria-describedby=${`${this.gridId}-description`}
2453
2733
  aria-owns=${this.getHeaderRowId()}
2454
2734
  style=${`--grid-height: ${this.height}px;`}
2455
2735
  tabindex="0"
2456
2736
  @scroll=${this.handleScroll}
2457
2737
  @focus=${this.handleViewportFocus}
2738
+ @focusin=${this.handleGridFocusIn}
2739
+ @focusout=${() => { this.gridCellHasFocus = false; }}
2458
2740
 
2459
2741
  @keydown=${this.handleKeyDown}
2460
2742
  @click=${() => this.closeHeaderMenu()}
@@ -2466,7 +2748,7 @@ export class DataGrid extends LitElement {
2466
2748
  style=${`--offset-top: ${displayOffsetTop}px; padding-bottom: ${displayBottomPadding}px;`}
2467
2749
  >
2468
2750
  ${rows.map((row, index) => {
2469
- const rowIndex = range.startIndex + index;
2751
+ const rowIndex = renderedRowStart + index;
2470
2752
  const height = this.getRowHeight(rowIndex);
2471
2753
  return html `
2472
2754
  <div
@@ -2491,31 +2773,35 @@ export class DataGrid extends LitElement {
2491
2773
  ${pagination.enabled
2492
2774
  ? html `
2493
2775
  <div class="pagination" role="navigation" aria-label="Pagination">
2494
- <span>${pagination.totalRows} rows</span>
2495
- <label>
2496
- Rows per page
2497
- <select
2498
- aria-label="Rows per page"
2499
- @change=${(event) => this.setPageSize(Number(event.target.value))}
2500
- >
2501
- ${pageSizeOptions.map((pageSize) => html `
2502
- <option value=${pageSize} ?selected=${pageSize === pagination.pageSize}>
2503
- ${pageSize}
2504
- </option>
2505
- `)}
2506
- </select>
2507
- </label>
2508
- <span>Page ${pagination.pageCount === 0 ? 0 : pagination.pageIndex + 1} of ${pagination.pageCount}</span>
2509
- <button
2510
- type="button"
2511
- ?disabled=${pagination.pageIndex === 0}
2512
- @click=${() => this.setPage(pagination.pageIndex - 1)}
2513
- >Previous</button>
2514
- <button
2515
- type="button"
2516
- ?disabled=${pagination.pageCount === 0 || pagination.pageIndex >= pagination.pageCount - 1}
2517
- @click=${() => this.setPage(pagination.pageIndex + 1)}
2518
- >Next</button>
2776
+ <div class="pagination-summary">
2777
+ <span>${pagination.totalRows} rows</span>
2778
+ <label>
2779
+ Rows per page
2780
+ <select
2781
+ aria-label="Rows per page"
2782
+ @change=${(event) => this.setPageSize(Number(event.target.value))}
2783
+ >
2784
+ ${pageSizeOptions.map((pageSize) => html `
2785
+ <option value=${pageSize} ?selected=${pageSize === pagination.pageSize}>
2786
+ ${pageSize}
2787
+ </option>
2788
+ `)}
2789
+ </select>
2790
+ </label>
2791
+ </div>
2792
+ <div class="pagination-navigation">
2793
+ <span>Page ${pagination.pageCount === 0 ? 0 : pagination.pageIndex + 1} of ${pagination.pageCount}</span>
2794
+ <button
2795
+ type="button"
2796
+ ?disabled=${pagination.pageIndex === 0}
2797
+ @click=${() => this.setPage(pagination.pageIndex - 1)}
2798
+ >Previous</button>
2799
+ <button
2800
+ type="button"
2801
+ ?disabled=${pagination.pageCount === 0 || pagination.pageIndex >= pagination.pageCount - 1}
2802
+ @click=${() => this.setPage(pagination.pageIndex + 1)}
2803
+ >Next</button>
2804
+ </div>
2519
2805
  </div>
2520
2806
  `
2521
2807
  : ''}