@coreui/data-grid 0.1.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/LICENSE +3 -0
  2. package/README.md +37 -0
  3. package/dist/css/data-grid.css +444 -0
  4. package/dist/css/data-grid.css.map +1 -0
  5. package/dist/css/data-grid.min.css +5 -0
  6. package/dist/css/data-grid.min.css.map +1 -0
  7. package/dist/js/csv.cjs +68 -0
  8. package/dist/js/csv.cjs.map +1 -0
  9. package/dist/js/csv.mjs +63 -0
  10. package/dist/js/csv.mjs.map +1 -0
  11. package/dist/js/data-grid.esm.js +9618 -0
  12. package/dist/js/data-grid.esm.js.map +1 -0
  13. package/dist/js/data-grid.esm.min.js +8 -0
  14. package/dist/js/data-grid.esm.min.js.map +1 -0
  15. package/dist/js/data-grid.js +9635 -0
  16. package/dist/js/data-grid.js.map +1 -0
  17. package/dist/js/data-grid.min.js +8 -0
  18. package/dist/js/data-grid.min.js.map +1 -0
  19. package/dist/js/data-grid.mjs +9618 -0
  20. package/dist/js/data-grid.mjs.map +1 -0
  21. package/dist/js/filters.cjs +143 -0
  22. package/dist/js/filters.cjs.map +1 -0
  23. package/dist/js/filters.mjs +137 -0
  24. package/dist/js/filters.mjs.map +1 -0
  25. package/dist/js/labels.cjs +76 -0
  26. package/dist/js/labels.cjs.map +1 -0
  27. package/dist/js/labels.mjs +71 -0
  28. package/dist/js/labels.mjs.map +1 -0
  29. package/dist/types/base-component.d.ts +19 -0
  30. package/dist/types/data-grid.d.ts +128 -0
  31. package/dist/types/dom/event-handler.d.ts +11 -0
  32. package/dist/types/dom/manipulator.d.ts +9 -0
  33. package/dist/types/engine/active-cell.d.ts +32 -0
  34. package/dist/types/engine/data-source.d.ts +19 -0
  35. package/dist/types/engine/scheduler.d.ts +11 -0
  36. package/dist/types/engine/table.d.ts +9 -0
  37. package/dist/types/engine/virtualizer.d.ts +21 -0
  38. package/dist/types/filters.d.mts +1 -0
  39. package/dist/types/filters.d.ts +13 -0
  40. package/dist/types/icons.d.ts +21 -0
  41. package/dist/types/index.d.mts +1 -0
  42. package/dist/types/index.d.ts +12 -0
  43. package/dist/types/labels.d.mts +1 -0
  44. package/dist/types/labels.d.ts +9 -0
  45. package/dist/types/types.d.ts +262 -0
  46. package/dist/types/ui/body.d.ts +34 -0
  47. package/dist/types/ui/column-chooser.d.ts +27 -0
  48. package/dist/types/ui/editor.d.ts +8 -0
  49. package/dist/types/ui/filter-menu.d.ts +21 -0
  50. package/dist/types/ui/header.d.ts +50 -0
  51. package/dist/types/ui/loading.d.ts +12 -0
  52. package/dist/types/ui/menu.d.ts +17 -0
  53. package/dist/types/ui/pagination.d.ts +17 -0
  54. package/dist/types/ui/pinning.d.ts +17 -0
  55. package/dist/types/ui/toolbar.d.ts +34 -0
  56. package/dist/types/ui/tooltip.d.ts +10 -0
  57. package/dist/types/util/config.d.ts +11 -0
  58. package/dist/types/util/csv.d.mts +1 -0
  59. package/dist/types/util/csv.d.ts +19 -0
  60. package/dist/types/util/debounce.d.ts +7 -0
  61. package/dist/types/util/index.d.ts +10 -0
  62. package/dist/types/util/sanitizer.d.ts +11 -0
  63. package/package.json +147 -0
  64. package/scss/data-grid.scss +559 -0
  65. package/src/base-component.d.ts +19 -0
  66. package/src/base-component.js +90 -0
  67. package/src/data-grid.ts +2208 -0
  68. package/src/dom/data.js +59 -0
  69. package/src/dom/event-handler.d.ts +11 -0
  70. package/src/dom/event-handler.js +331 -0
  71. package/src/dom/manipulator.d.ts +9 -0
  72. package/src/dom/manipulator.js +75 -0
  73. package/src/engine/active-cell.ts +195 -0
  74. package/src/engine/data-source.ts +63 -0
  75. package/src/engine/scheduler.ts +42 -0
  76. package/src/engine/table.ts +122 -0
  77. package/src/engine/virtualizer.ts +57 -0
  78. package/src/filters.ts +176 -0
  79. package/src/icons.ts +60 -0
  80. package/src/index.ts +56 -0
  81. package/src/labels.ts +70 -0
  82. package/src/types.ts +334 -0
  83. package/src/ui/body.ts +348 -0
  84. package/src/ui/column-chooser.ts +183 -0
  85. package/src/ui/editor.ts +98 -0
  86. package/src/ui/filter-menu.ts +398 -0
  87. package/src/ui/header.ts +441 -0
  88. package/src/ui/loading.ts +39 -0
  89. package/src/ui/menu.ts +218 -0
  90. package/src/ui/pagination.ts +150 -0
  91. package/src/ui/pinning.ts +53 -0
  92. package/src/ui/toolbar.ts +144 -0
  93. package/src/ui/tooltip.ts +113 -0
  94. package/src/util/config.d.ts +11 -0
  95. package/src/util/config.js +69 -0
  96. package/src/util/csv.ts +85 -0
  97. package/src/util/debounce.ts +18 -0
  98. package/src/util/index.d.ts +10 -0
  99. package/src/util/index.js +310 -0
  100. package/src/util/sanitizer.d.ts +11 -0
  101. package/src/util/sanitizer.js +162 -0
@@ -0,0 +1,2208 @@
1
+ /**
2
+ * --------------------------------------------------------------------------
3
+ * CoreUI PRO data-grid.ts
4
+ * License (https://coreui.io/pro/license/)
5
+ * --------------------------------------------------------------------------
6
+ */
7
+
8
+ import type { Table, TableState } from '@tanstack/table-core'
9
+ import BaseComponent from './base-component.js'
10
+ import EventHandler from './dom/event-handler.js'
11
+ import {
12
+ createActiveCellManager, resolveNavigation, type ActiveCell, type ActiveCellContext, type ActiveCellManager
13
+ } from './engine/active-cell'
14
+ import { createDataSource, type GridDataSource } from './engine/data-source'
15
+ import { createScheduler, type Scheduler } from './engine/scheduler'
16
+ import { createGridTable } from './engine/table'
17
+ import { createGridVirtualizer, type GridVirtualizer } from './engine/virtualizer'
18
+ import {
19
+ DEFAULT_COLUMN_MENU_ICON, DEFAULT_FILTER_ICON, DEFAULT_HIDE_COLUMN_ICON, DEFAULT_MOVE_LEFT_ICON, DEFAULT_MOVE_RIGHT_ICON,
20
+ DEFAULT_PIN_LEFT_ICON, DEFAULT_PIN_RIGHT_ICON, DEFAULT_SORT_ASCENDING_ICON, DEFAULT_SORT_DESCENDING_ICON,
21
+ DEFAULT_SORT_NEUTRAL_ICON, DEFAULT_TOOLBAR_COLUMNS_ICON, DEFAULT_TOOLBAR_EXPORT_ICON,
22
+ DEFAULT_TOOLBAR_REDO_ICON, DEFAULT_TOOLBAR_UNDO_ICON, DEFAULT_UNPIN_ICON
23
+ } from './icons'
24
+ import { DEFAULT_LABELS, interpolate } from './labels'
25
+ import type {
26
+ DataGridColumn, DataGridColumnFilterValue, DataGridEditableOptions, DataGridEditor, DataGridEditorContext,
27
+ DataGridItem, DataGridLabels, DataGridMenuAction, DataGridOptions, DataGridSlot, DataGridSlotContext, DataGridState
28
+ } from './types'
29
+ import { createBody, type GridBody } from './ui/body'
30
+ import { createBuiltInEditor } from './ui/editor'
31
+ import { createHeader, type GridHeader } from './ui/header'
32
+ import { createLoading, type GridLoading } from './ui/loading'
33
+ import { createFilterMenu, type GridFilterMenu } from './ui/filter-menu'
34
+ import { createMenu, type GridMenu } from './ui/menu'
35
+ import { createPagination } from './ui/pagination'
36
+ import type { GridPinning, PinnedCellInfo } from './ui/pinning'
37
+ import { createColumnChooser, type ColumnChooserColumn, type GridColumnChooser } from './ui/column-chooser'
38
+ import { createToolbar, resolveToolbar, type ResolvedToolbar } from './ui/toolbar'
39
+ import { createTooltip, type GridTooltip } from './ui/tooltip'
40
+ import {
41
+ downloadCsv, exportCsv, type CsvDownloadOptions, type CsvOptions
42
+ } from './util/csv'
43
+ import { debounce } from './util/debounce'
44
+ import { defineJQueryPlugin } from './util/index.js'
45
+ import { DefaultAllowlist, sanitizeHtml, SVGAllowlist } from './util/sanitizer.js'
46
+
47
+ /**
48
+ * Constants
49
+ */
50
+
51
+ const NAME = 'data-grid'
52
+ const DATA_KEY = 'coreui.data-grid'
53
+ const EVENT_KEY = `.${DATA_KEY}`
54
+
55
+ const EVENT_CHANGE = `change${EVENT_KEY}`
56
+ const EVENT_CLICK = `click${EVENT_KEY}`
57
+ const EVENT_DATA_ERROR = `dataError${EVENT_KEY}`
58
+ const EVENT_DATA_LOAD = `dataLoad${EVENT_KEY}`
59
+ const EVENT_DBLCLICK = `dblclick${EVENT_KEY}`
60
+ const EVENT_EDIT_CANCEL = `editCancel${EVENT_KEY}`
61
+ const EVENT_EDIT_COMMIT = `editCommit${EVENT_KEY}`
62
+ const EVENT_EDIT_START = `editStart${EVENT_KEY}`
63
+ const EVENT_FILTER_CHANGE = `filterChange${EVENT_KEY}`
64
+ const EVENT_INPUT = `input${EVENT_KEY}`
65
+ const EVENT_ORDER_CHANGE = `orderChange${EVENT_KEY}`
66
+ const EVENT_PAGINATION_CHANGE = `paginationChange${EVENT_KEY}`
67
+ const EVENT_PINNING_CHANGE = `pinningChange${EVENT_KEY}`
68
+ const EVENT_SELECTION_CHANGE = `selectionChange${EVENT_KEY}`
69
+ const EVENT_SIZING_CHANGE = `sizingChange${EVENT_KEY}`
70
+ const EVENT_SORTING_CHANGE = `sortingChange${EVENT_KEY}`
71
+ const EVENT_VISIBILITY_CHANGE = `visibilityChange${EVENT_KEY}`
72
+
73
+ const EVENT_FOCUSIN = `focusin${EVENT_KEY}`
74
+ const EVENT_FOCUSOUT = `focusout${EVENT_KEY}`
75
+ const EVENT_KEYDOWN = `keydown${EVENT_KEY}`
76
+
77
+ const EVENT_DRAGEND = `dragend${EVENT_KEY}`
78
+ const EVENT_DRAGLEAVE = `dragleave${EVENT_KEY}`
79
+ const EVENT_DRAGOVER = `dragover${EVENT_KEY}`
80
+ const EVENT_DRAGSTART = `dragstart${EVENT_KEY}`
81
+ const EVENT_DROP = `drop${EVENT_KEY}`
82
+ const EVENT_MOUSEDOWN = `mousedown${EVENT_KEY}`
83
+ const EVENT_POINTERDOWN = `pointerdown${EVENT_KEY}`
84
+ const EVENT_TOUCHSTART = `touchstart${EVENT_KEY}`
85
+
86
+ const CLASS_NAME_DATA_GRID = 'data-grid'
87
+ const CLASS_NAME_DRAGGING = 'data-grid-dragging'
88
+ const CLASS_NAME_EDITOR_POPUP = 'data-grid-editor-popup'
89
+ const CLASS_NAME_DROP_TARGET = 'data-grid-drop-target'
90
+ const CLASS_NAME_PAGINATION_TOP = 'data-grid-pagination-top'
91
+ const CLASS_NAME_PINNED = 'data-grid-pinned'
92
+ const CLASS_NAME_RESIZABLE = 'data-grid-resizable'
93
+ const CLASS_NAME_TABLE = 'table'
94
+ const CLASS_NAME_VIEWPORT = 'data-grid-viewport'
95
+
96
+ const SELECTOR_COLUMN_FILTER = 'input[data-coreui-column-filter]'
97
+ const SELECTOR_DRAGGABLE = 'th[data-coreui-draggable]'
98
+ const SELECTOR_MENU_BUTTON = 'button[data-coreui-menu]'
99
+ const SELECTOR_FILTER_BUTTON = 'button[data-coreui-filter]'
100
+ const SELECTOR_GLOBAL_FILTER = 'input[data-coreui-global-filter]'
101
+ const SELECTOR_PAGE = 'button[data-coreui-page]'
102
+ const SELECTOR_PAGE_SIZE = 'select[data-coreui-page-size]'
103
+ const SELECTOR_RESIZER = '[data-coreui-resizer]'
104
+ const SELECTOR_ROW_SELECT = 'input[data-coreui-row-select]'
105
+ const SELECTOR_SELECT_ALL = 'input[data-coreui-select-all]'
106
+ const SELECTOR_SORT_BUTTON = '.data-grid-sort-button'
107
+ const SELECTOR_TOOLBAR_COLUMNS = 'button[data-coreui-toolbar-columns]'
108
+ const SELECTOR_TOOLBAR_EXPORT = 'button[data-coreui-toolbar-export]'
109
+ const SELECTOR_TOOLBAR_REDO = 'button[data-coreui-toolbar-redo]'
110
+ const SELECTOR_TOOLBAR_UNDO = 'button[data-coreui-toolbar-undo]'
111
+
112
+ const FILTER_DEBOUNCE_MS = 250
113
+ const DEFAULT_PAGE_SIZE_OPTIONS = [5, 10, 20, 50]
114
+
115
+ let editorErrorCount = 0
116
+
117
+ // History entries hold the ROW ID, not the item reference - an immutable
118
+ // app replaces the object on every commit, so undo/redo resolve the current
119
+ // item from the core row model at emit time.
120
+ interface DataGridEditCommitEntry {
121
+ rowId: string
122
+ columnId: string
123
+ value: unknown
124
+ previousValue: unknown
125
+ }
126
+
127
+ const STATE_STORAGE_PREFIX = 'coreui-data-grid:'
128
+ const STATE_AUTOSAVE_DEBOUNCE_MS = 250
129
+ const HISTORY_LIMIT = 100
130
+
131
+ const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]'
132
+
133
+ const applyTabIndex = (cellElement: HTMLElement, isTabbable: boolean) => {
134
+ const value = isTabbable ? 0 : -1
135
+ if (cellElement.tabIndex !== value) {
136
+ cellElement.tabIndex = value
137
+ }
138
+ }
139
+
140
+ const Default: DataGridOptions = {
141
+ cellNavigation: false,
142
+ columnFilters: false,
143
+ columnMenu: false,
144
+ columnMenuIcon: DEFAULT_COLUMN_MENU_ICON,
145
+ columnOrder: false,
146
+ columnPinning: false,
147
+ columnSizing: false,
148
+ columnVisibility: false,
149
+ columns: [],
150
+ dataProvider: null,
151
+ editing: false,
152
+ empty: 'No items found',
153
+ filterIcon: DEFAULT_FILTER_ICON,
154
+ globalFilter: false,
155
+ hideColumnIcon: DEFAULT_HIDE_COLUMN_ICON,
156
+ history: false,
157
+ itemKey: null,
158
+ items: [],
159
+ labels: {},
160
+ moveLeftIcon: DEFAULT_MOVE_LEFT_ICON,
161
+ moveRightIcon: DEFAULT_MOVE_RIGHT_ICON,
162
+ overscan: 10,
163
+ pagination: false,
164
+ pinLeftIcon: DEFAULT_PIN_LEFT_ICON,
165
+ pinRightIcon: DEFAULT_PIN_RIGHT_ICON,
166
+ rowHeight: 44,
167
+ rowSelection: false,
168
+ sanitize: true,
169
+ sanitizeFn: null,
170
+ slots: {},
171
+ sortAscendingIcon: DEFAULT_SORT_ASCENDING_ICON,
172
+ sortDescendingIcon: DEFAULT_SORT_DESCENDING_ICON,
173
+ sortNeutralIcon: DEFAULT_SORT_NEUTRAL_ICON,
174
+ sorterVisibility: false,
175
+ sorting: true,
176
+ stateKey: null,
177
+ toolbar: false,
178
+ toolbarColumnsIcon: DEFAULT_TOOLBAR_COLUMNS_ICON,
179
+ toolbarExportIcon: DEFAULT_TOOLBAR_EXPORT_ICON,
180
+ toolbarRedoIcon: DEFAULT_TOOLBAR_REDO_ICON,
181
+ toolbarUndoIcon: DEFAULT_TOOLBAR_UNDO_ICON,
182
+ unpinIcon: DEFAULT_UNPIN_ICON,
183
+ virtualization: true
184
+ }
185
+
186
+ const DefaultType = {
187
+ cellNavigation: 'boolean',
188
+ columnFilters: 'boolean',
189
+ columnMenu: '(boolean|function)',
190
+ columnMenuIcon: 'string',
191
+ columnOrder: '(boolean|array)',
192
+ columnPinning: '(boolean|object)',
193
+ columnSizing: '(boolean|object)',
194
+ columnVisibility: '(boolean|object)',
195
+ columns: 'array',
196
+ dataProvider: '(function|null)',
197
+ editing: 'boolean',
198
+ empty: '(string|function)',
199
+ filterIcon: 'string',
200
+ globalFilter: 'boolean',
201
+ hideColumnIcon: 'string',
202
+ history: 'boolean',
203
+ itemKey: '(function|null)',
204
+ items: 'array',
205
+ labels: 'object',
206
+ moveLeftIcon: 'string',
207
+ moveRightIcon: 'string',
208
+ overscan: 'number',
209
+ pagination: '(boolean|object)',
210
+ pinLeftIcon: 'string',
211
+ pinRightIcon: 'string',
212
+ rowHeight: 'number',
213
+ rowSelection: '(boolean|object)',
214
+ sanitize: 'boolean',
215
+ sanitizeFn: '(function|null)',
216
+ slots: 'object',
217
+ sortAscendingIcon: 'string',
218
+ sortDescendingIcon: 'string',
219
+ sortNeutralIcon: 'string',
220
+ sorterVisibility: '(string|boolean)',
221
+ sorting: '(boolean|object)',
222
+ stateKey: '(string|null)',
223
+ toolbar: '(boolean|object)',
224
+ toolbarColumnsIcon: 'string',
225
+ toolbarExportIcon: 'string',
226
+ toolbarRedoIcon: 'string',
227
+ toolbarUndoIcon: 'string',
228
+ unpinIcon: 'string',
229
+ virtualization: 'boolean'
230
+ }
231
+
232
+ /**
233
+ * Class definition
234
+ */
235
+
236
+ class DataGrid extends BaseComponent {
237
+ declare _config: DataGridOptions
238
+ private _table!: Table<DataGridItem>
239
+ private _scheduler!: Scheduler
240
+ private _virtualizer: GridVirtualizer | null = null
241
+ private _header!: GridHeader
242
+ private _body!: GridBody
243
+ private _toolbar: DataGridSlot | null = null
244
+ private _paginations: Array<{ element: HTMLElement; refresh: () => void; dispose?: () => void }> = []
245
+ private _emptySlot: DataGridSlot | null = null
246
+ private _dataSource: GridDataSource | null = null
247
+ private _loading: GridLoading | null = null
248
+ private _loadFailed = false
249
+ private _dataVersion = 0
250
+ private _viewportElement!: HTMLDivElement
251
+ private _tableElement!: HTMLTableElement
252
+ private _liveRegion: HTMLElement | null = null
253
+ private _labels!: DataGridLabels
254
+ private _selectionAnchorIndex: number | null = null
255
+ private _activeCell: ActiveCellManager | null = null
256
+ private _containsFocus = false
257
+ private _editingCell: {
258
+ rowId: string
259
+ columnId: string
260
+ rowIndex: number
261
+ item: DataGridItem
262
+ previousValue: unknown
263
+ } | null = null
264
+ private _undoStack: DataGridEditCommitEntry[] = []
265
+ private _redoStack: DataGridEditCommitEntry[] = []
266
+ private _persistState: (() => void) | null = null
267
+ private _editor: DataGridEditor | null = null
268
+ // Removed by reference - a namespace-wide off(document, ...) would tear
269
+ // down every other grid instance's outside-commit handler with it.
270
+ private _editorPointerdownHandler: ((event: Event) => void) | null = null
271
+ private _editorPopup: HTMLElement | null = null
272
+ private _editorErrorElement: HTMLElement | null = null
273
+ private _editorFocusPending = false
274
+ private _columnsByKey = new Map<string, DataGridColumn>()
275
+ private _dragColumn: string | null = null
276
+ private _menu: GridMenu | null = null
277
+ private _tooltip: GridTooltip | null = null
278
+ private _filterMenu: GridFilterMenu | null = null
279
+ private _columnChooser: GridColumnChooser | null = null
280
+ private _toolbarConfig: ResolvedToolbar | null = null
281
+
282
+ constructor(element: Element | string, config?: Partial<DataGridOptions>) {
283
+ super(element, config)
284
+
285
+ if (!this._element) {
286
+ return
287
+ }
288
+
289
+ this._labels = { ...DEFAULT_LABELS, ...this._config.labels }
290
+ this._scheduler = createScheduler(() => this._update())
291
+ this._table = this._createTable()
292
+
293
+ this._liveRegion = document.createElement('div')
294
+ this._liveRegion.classList.add('visually-hidden')
295
+ this._liveRegion.setAttribute('aria-live', 'polite')
296
+ this._element.append(this._liveRegion)
297
+
298
+ this._render()
299
+ this._addEventListeners()
300
+ this._tooltip = createTooltip(this._element)
301
+ // A persisted view restores before the first server fetch, so the initial
302
+ // request already carries the user's sorting/filters/page.
303
+ this._restorePersistedState()
304
+ this._initDataSource()
305
+ }
306
+
307
+ // Getters
308
+ get table(): Table<DataGridItem> {
309
+ return this._table
310
+ }
311
+
312
+ static get Default(): DataGridOptions {
313
+ return Default
314
+ }
315
+
316
+ static get DefaultType(): Record<string, string> {
317
+ return DefaultType
318
+ }
319
+
320
+ static get NAME(): string {
321
+ return NAME
322
+ }
323
+
324
+ static getInstance(element: Element | string | null): DataGrid | null {
325
+ return super.getInstance(element) as DataGrid | null
326
+ }
327
+
328
+ static getOrCreateInstance(element: Element | string, config?: Partial<DataGridOptions>): DataGrid {
329
+ return super.getOrCreateInstance(element, config) as DataGrid
330
+ }
331
+
332
+ // Public
333
+ getSelectedItems(): DataGridItem[] {
334
+ return this._table.getSelectedRowModel().rows.map(row => row.original)
335
+ }
336
+
337
+ getCsv(options?: CsvOptions): string {
338
+ return exportCsv(this._table, options)
339
+ }
340
+
341
+ downloadCsv(options?: CsvDownloadOptions): void {
342
+ downloadCsv(this._table, options)
343
+ }
344
+
345
+ // A serializable snapshot of every user-adjustable state slice. Feed it
346
+ // back through restoreState() - scroll position and edit drafts excluded.
347
+ getState(): DataGridState {
348
+ const state = this._table.getState()
349
+ return {
350
+ columnFilters: state.columnFilters,
351
+ columnOrder: state.columnOrder,
352
+ columnPinning: state.columnPinning,
353
+ columnSizing: state.columnSizing,
354
+ columnVisibility: state.columnVisibility,
355
+ globalFilter: (state.globalFilter as string | undefined) ?? '',
356
+ pagination: state.pagination,
357
+ rowSelection: state.rowSelection,
358
+ sorting: state.sorting
359
+ }
360
+ }
361
+
362
+ restoreState(state: Partial<DataGridState>): void {
363
+ this._table.setState(previous => ({ ...previous, ...state }))
364
+
365
+ // The built-in search input is uncontrolled - sync it to the restored
366
+ // filter so the UI matches the state.
367
+ if (state.globalFilter !== undefined) {
368
+ const input = this._element.querySelector<HTMLInputElement>(SELECTOR_GLOBAL_FILTER)
369
+ if (input) {
370
+ input.value = state.globalFilter
371
+ }
372
+ }
373
+
374
+ this._scheduler.schedule()
375
+ }
376
+
377
+ // Re-emits the last edit commit inverted (value and previousValue swap) -
378
+ // the app reverts the data through the same editCommit channel it already
379
+ // handles. New commits clear the redo stack.
380
+ undo(): void {
381
+ this._applyHistoryEntry(this._undoStack, this._redoStack, true)
382
+ }
383
+
384
+ redo(): void {
385
+ this._applyHistoryEntry(this._redoStack, this._undoStack, false)
386
+ }
387
+
388
+ private _applyHistoryEntry(
389
+ from: DataGridEditCommitEntry[],
390
+ to: DataGridEditCommitEntry[],
391
+ inverted: boolean
392
+ ): void {
393
+ const entry = from.pop()
394
+ if (!entry) {
395
+ return
396
+ }
397
+
398
+ // The row may be gone (data replaced without it) - drop the entry.
399
+ const row = this._table.getCoreRowModel().rowsById[entry.rowId]
400
+ if (!row) {
401
+ this._updateHistoryButtons()
402
+ return
403
+ }
404
+
405
+ to.push(entry)
406
+ EventHandler.trigger(this._element, EVENT_EDIT_COMMIT, {
407
+ item: row.original,
408
+ columnId: entry.columnId,
409
+ value: inverted ? entry.previousValue : entry.value,
410
+ previousValue: inverted ? entry.value : entry.previousValue
411
+ })
412
+ this._announce(inverted ? this._labels.undoneAnnouncement : this._labels.redoneAnnouncement)
413
+ this._updateHistoryButtons()
414
+ }
415
+
416
+ // Replaces the data in place, preserving sorting/filters/selection/scroll.
417
+ // No-op in server-side mode - the dataProvider owns the data there.
418
+ setItems(items: DataGridItem[]): void {
419
+ if (this._config.dataProvider) {
420
+ return
421
+ }
422
+
423
+ // External data replacement invalidates the draft - the app owns the data.
424
+ this._stopEditing({ commit: false })
425
+ this._config.items = items
426
+ this._dataVersion++
427
+ this._table.setOptions(previous => ({ ...previous, data: items }))
428
+ this._scheduler.schedule()
429
+ }
430
+
431
+ // Full rebuild: recreates the table, wiping sorting/filters/selection/scroll.
432
+ // Use setItems() to swap data while keeping user state.
433
+ update(config: Partial<DataGridOptions>): void {
434
+ this._config = this._getConfig({ ...this._config, ...config })
435
+ this._labels = { ...DEFAULT_LABELS, ...this._config.labels }
436
+
437
+ this._teardownUi()
438
+ this._selectionAnchorIndex = null
439
+ this._loadFailed = false
440
+ this._dataVersion++
441
+ this._table = this._createTable()
442
+ this._render()
443
+ this._initDataSource()
444
+ }
445
+
446
+ dispose(): void {
447
+ this._teardownUi()
448
+ this._scheduler.dispose()
449
+ this._tooltip?.dispose()
450
+ this._tooltip = null
451
+ if (this._liveRegion) {
452
+ this._liveRegion.remove()
453
+ }
454
+
455
+ this._element.classList.remove(CLASS_NAME_DATA_GRID)
456
+ this._element.removeAttribute('data-coreui-sorter-visibility')
457
+
458
+ super.dispose()
459
+ }
460
+
461
+ // Private
462
+ // Editing needs the active-cell substrate (Enter/F2 target the active cell).
463
+ _configAfterMerge(config: DataGridOptions): DataGridOptions {
464
+ if (config.editing) {
465
+ config.cellNavigation = true
466
+ }
467
+
468
+ return config
469
+ }
470
+
471
+ private _createTable(): Table<DataGridItem> {
472
+ return createGridTable(this._config, (previous, next) => {
473
+ this._notifyStateChange(previous, next)
474
+ this._scheduler.schedule()
475
+ })
476
+ }
477
+
478
+ private _teardownUi(): void {
479
+ this._stopEditing({ commit: false })
480
+
481
+ if (this._dataSource) {
482
+ this._dataSource.dispose()
483
+ this._dataSource = null
484
+ }
485
+
486
+ this._header?.dispose()
487
+
488
+ if (this._menu) {
489
+ this._menu.dispose()
490
+ this._menu = null
491
+ }
492
+
493
+ if (this._filterMenu) {
494
+ this._filterMenu.dispose()
495
+ this._filterMenu = null
496
+ }
497
+
498
+ if (this._columnChooser) {
499
+ this._columnChooser.dispose()
500
+ this._columnChooser = null
501
+ }
502
+
503
+ if (this._loading) {
504
+ this._loading.element.remove()
505
+ this._loading = null
506
+ }
507
+
508
+ if (this._virtualizer) {
509
+ this._virtualizer.destroy()
510
+ this._virtualizer = null
511
+ }
512
+
513
+ if (this._toolbar) {
514
+ this._toolbar.dispose?.()
515
+ this._toolbar.element.remove()
516
+ this._toolbar = null
517
+ }
518
+
519
+ for (const pagination of this._paginations) {
520
+ pagination.dispose?.()
521
+ pagination.element.remove()
522
+ }
523
+
524
+ this._paginations = []
525
+
526
+ if (this._emptySlot) {
527
+ this._emptySlot.dispose?.()
528
+ this._emptySlot = null
529
+ }
530
+
531
+ if (this._viewportElement) {
532
+ this._viewportElement.remove()
533
+ }
534
+ }
535
+
536
+ private _slotContext(): DataGridSlotContext {
537
+ return { table: this._table, labels: this._labels }
538
+ }
539
+
540
+ private _isColumnSortable(column: DataGridColumn): boolean {
541
+ return Boolean(this._config.sorting) && column.sortable !== false
542
+ }
543
+
544
+ private _isColumnFilterable(column: DataGridColumn): boolean {
545
+ return this._config.columnFilters && column.filterable !== false
546
+ }
547
+
548
+ private _isColumnResizable(column: DataGridColumn): boolean {
549
+ return Boolean(this._config.columnSizing) && column.resizable !== false
550
+ }
551
+
552
+ private _sanitizeIcon(icon: string): string {
553
+ return this._config.sanitize ? sanitizeHtml(icon, SVGAllowlist, this._config.sanitizeFn) : icon
554
+ }
555
+
556
+ private _hasColumnMenu(column: DataGridColumn): boolean {
557
+ if (!this._config.columnMenu) {
558
+ return false
559
+ }
560
+
561
+ // A builder can add items regardless of enabled features, so ask it directly.
562
+ if (typeof this._config.columnMenu === 'function') {
563
+ return this._getMenuActions(column.key).length > 0
564
+ }
565
+
566
+ return (
567
+ this._isColumnSortable(column) ||
568
+ Boolean(this._config.columnPinning) ||
569
+ (Boolean(this._config.columnVisibility) && column.hideable !== false) ||
570
+ (Boolean(this._config.columnOrder) && column.movable !== false)
571
+ )
572
+ }
573
+
574
+ private _renderToolbar(): void {
575
+ const toolbarConfig = resolveToolbar(this._config)
576
+ this._toolbarConfig = toolbarConfig
577
+
578
+ if (this._config.slots.toolbar) {
579
+ this._toolbar = this._config.slots.toolbar(this._slotContext())
580
+ this._element.append(this._toolbar.element)
581
+ return
582
+ }
583
+
584
+ if (!toolbarConfig.columns && !toolbarConfig.export && !toolbarConfig.search && !toolbarConfig.history) {
585
+ return
586
+ }
587
+
588
+ this._toolbar = createToolbar({
589
+ columns: toolbarConfig.columns,
590
+ export: toolbarConfig.export,
591
+ icons: {
592
+ columns: this._sanitizeIcon(this._config.toolbarColumnsIcon),
593
+ export: this._sanitizeIcon(this._config.toolbarExportIcon),
594
+ redo: this._sanitizeIcon(this._config.toolbarRedoIcon),
595
+ undo: this._sanitizeIcon(this._config.toolbarUndoIcon)
596
+ },
597
+ history: toolbarConfig.history,
598
+ labels: this._labels,
599
+ search: toolbarConfig.search
600
+ })
601
+ this._element.append(this._toolbar.element)
602
+
603
+ if (toolbarConfig.columns) {
604
+ this._columnChooser = createColumnChooser({
605
+ root: this._element as HTMLElement,
606
+ labels: this._labels,
607
+ getColumns: () => this._chooserColumns(),
608
+ toggleVisibility: (columnId, visible) => {
609
+ this._table.getColumn(columnId)?.toggleVisibility(visible)
610
+ },
611
+ showAll: () => this._table.toggleAllColumnsVisible(true),
612
+ reset: () => this._table.setColumnVisibility(this._table.initialState.columnVisibility ?? {})
613
+ })
614
+ }
615
+ }
616
+
617
+ private _render(): void {
618
+ this._columnsByKey = new Map(this._config.columns.map(column => [column.key, column]))
619
+ this._activeCell = this._config.cellNavigation ?
620
+ createActiveCellManager(() => this._scheduler.schedule()) :
621
+ null
622
+ this._element.classList.add(CLASS_NAME_DATA_GRID)
623
+ this._element.classList.toggle(CLASS_NAME_RESIZABLE, Boolean(this._config.columnSizing))
624
+ this._element.classList.toggle(CLASS_NAME_PINNED, Boolean(this._config.columnPinning))
625
+ if (this._config.sorterVisibility) {
626
+ this._element.setAttribute('data-coreui-sorter-visibility', this._config.sorterVisibility)
627
+ } else {
628
+ this._element.removeAttribute('data-coreui-sorter-visibility')
629
+ }
630
+
631
+ const { slots } = this._config
632
+ this._renderToolbar()
633
+
634
+ if (slots.empty) {
635
+ this._emptySlot = slots.empty(this._slotContext())
636
+ }
637
+
638
+ const selectionEnabled = Boolean(this._config.rowSelection)
639
+ const selectionOptions = typeof this._config.rowSelection === 'object' ? this._config.rowSelection : {}
640
+ // Server-side mode implies pagination — virtualized infinite scroll over a
641
+ // dataProvider is out of scope until 0.3.0.
642
+ const paginationEnabled = Boolean(this._config.pagination) || Boolean(this._config.dataProvider)
643
+ const paginationOptions = typeof this._config.pagination === 'object' ? this._config.pagination : {}
644
+
645
+ if (this._config.columnMenu) {
646
+ this._menu = createMenu({
647
+ root: this._element as HTMLElement,
648
+ getActions: columnKey => this._getMenuActions(columnKey)
649
+ })
650
+ }
651
+
652
+ if (this._config.columnFilters) {
653
+ this._filterMenu = createFilterMenu({
654
+ root: this._element as HTMLElement,
655
+ labels: this._labels,
656
+ getColumn: columnKey => this._columnsByKey.get(columnKey) ?? null,
657
+ getFacetedValues: columnKey =>
658
+ [...(this._table.getColumn(columnKey)?.getFacetedUniqueValues().keys() ?? [])],
659
+ getFilterValue: columnKey =>
660
+ this._table.getColumn(columnKey)?.getFilterValue() as DataGridColumnFilterValue | undefined,
661
+ applyFilter: (columnKey, value) => {
662
+ this._table.getColumn(columnKey)?.setFilterValue(value)
663
+ }
664
+ })
665
+ }
666
+
667
+ this._header = createHeader({
668
+ cellNavigation: this._config.cellNavigation,
669
+ columns: this._config.columns,
670
+ draggable: Boolean(this._config.columnOrder),
671
+ filterRow:
672
+ this._config.columnFilters &&
673
+ this._config.columns.some(column => column.filter && this._isColumnFilterable(column)),
674
+ hasMenu: column => this._hasColumnMenu(column),
675
+ hasFilterMenu: column =>
676
+ Boolean(this._filterMenu) && this._isColumnFilterable(column) && !column.filter,
677
+ icons: {
678
+ columnMenu: this._sanitizeIcon(this._config.columnMenuIcon),
679
+ filter: this._sanitizeIcon(this._config.filterIcon),
680
+ sortAscending: this._sanitizeIcon(this._config.sortAscendingIcon),
681
+ sortDescending: this._sanitizeIcon(this._config.sortDescendingIcon),
682
+ sortNeutral: this._sanitizeIcon(this._config.sortNeutralIcon)
683
+ },
684
+ isFilterable: column => this._isColumnFilterable(column),
685
+ isResizable: column => this._isColumnResizable(column),
686
+ isSortable: column => this._isColumnSortable(column),
687
+ labels: this._labels,
688
+ resizable: Boolean(this._config.columnSizing),
689
+ selection: selectionEnabled,
690
+ selectAll: selectionEnabled && selectionOptions.selectAll !== false,
691
+ table: this._table
692
+ })
693
+ this._body = createBody({
694
+ cellNavigation: this._config.cellNavigation,
695
+ getDataVersion: () => this._dataVersion,
696
+ getEditingCell: () =>
697
+ (this._editingCell ? { rowId: this._editingCell.rowId, columnId: this._editingCell.columnId } : null),
698
+ // Popup editors overlay the cell; its content stays in place.
699
+ getEditorElement: () => (this._editor && !this._editor.popup ? this._editor.element : null),
700
+ getPinning: () => (this._config.columnPinning ? this._getPinning() : null),
701
+ getRowIndexOffset: () => {
702
+ if (!this._config.pagination && !this._config.dataProvider) {
703
+ return 0
704
+ }
705
+
706
+ const { pageIndex, pageSize } = this._table.getState().pagination
707
+ return pageIndex * pageSize
708
+ },
709
+ getRows: () => this._table.getRowModel().rows,
710
+ getVisibleColumns: () => this._visibleColumns(),
711
+ getVirtualizer: () => this._virtualizer?.instance ?? null,
712
+ headerRowCount: this._header.rowCount,
713
+ labels: this._labels,
714
+ renderEmpty: () => {
715
+ if (this._emptySlot) {
716
+ return this._emptySlot.element
717
+ }
718
+
719
+ if (this._loadFailed) {
720
+ return this._labels.loadError
721
+ }
722
+
723
+ return typeof this._config.empty === 'function' ? this._config.empty() : this._config.empty
724
+ },
725
+ sanitizeContent: html =>
726
+ (this._config.sanitize ? sanitizeHtml(html, DefaultAllowlist, this._config.sanitizeFn) : html),
727
+ selection: selectionEnabled
728
+ })
729
+
730
+ this._tableElement = document.createElement('table')
731
+ this._tableElement.classList.add(CLASS_NAME_TABLE)
732
+ if (this._config.cellNavigation) {
733
+ this._tableElement.setAttribute('role', 'grid')
734
+ }
735
+
736
+ this._tableElement.append(this._header.element, this._body.element)
737
+
738
+ this._viewportElement = document.createElement('div')
739
+ this._viewportElement.classList.add(CLASS_NAME_VIEWPORT)
740
+ if (this._config.cellNavigation) {
741
+ // Focus parks here when the focused row is recycled out of the window;
742
+ // the keydown fallback then scrolls the active cell back and refocuses.
743
+ this._viewportElement.tabIndex = -1
744
+ }
745
+
746
+ this._viewportElement.append(this._tableElement)
747
+
748
+ if (this._config.dataProvider) {
749
+ this._loading = createLoading(this._labels)
750
+ this._viewportElement.append(this._loading.element)
751
+ }
752
+
753
+ this._element.append(this._viewportElement)
754
+
755
+ if (paginationEnabled) {
756
+ const position = paginationOptions.position ?? 'bottom'
757
+ const paginationSlot = slots.pagination
758
+ const createInstance = () => {
759
+ if (paginationSlot) {
760
+ const slot = paginationSlot(this._slotContext())
761
+ return { element: slot.element, refresh: () => slot.update?.(), dispose: () => slot.dispose?.() }
762
+ }
763
+
764
+ const instance = createPagination({
765
+ info: paginationOptions.info ?? true,
766
+ labels: this._labels,
767
+ pageSizeOptions: paginationOptions.pageSizeOptions ?? DEFAULT_PAGE_SIZE_OPTIONS
768
+ })
769
+ return { element: instance.element, refresh: () => instance.update(this._paginationState()), dispose: undefined }
770
+ }
771
+
772
+ if (position === 'top' || position === 'both') {
773
+ const pagination = createInstance()
774
+ // The top-margin class styles the built-in module; a custom slot owns
775
+ // its own markup, so don't impose it (keeps vanilla/React in parity).
776
+ if (!paginationSlot) {
777
+ pagination.element.classList.add(CLASS_NAME_PAGINATION_TOP)
778
+ }
779
+
780
+ this._viewportElement.before(pagination.element)
781
+ this._paginations.push(pagination)
782
+ }
783
+
784
+ if (position === 'bottom' || position === 'both') {
785
+ const pagination = createInstance()
786
+ this._element.append(pagination.element)
787
+ this._paginations.push(pagination)
788
+ }
789
+ } else if (this._config.virtualization) {
790
+ this._virtualizer = createGridVirtualizer({
791
+ count: this._table.getRowModel().rows.length,
792
+ getScrollElement: () => this._viewportElement,
793
+ onChange: () => this._scheduler.schedule(),
794
+ overscan: this._config.overscan,
795
+ rowHeight: this._config.rowHeight
796
+ })
797
+ }
798
+
799
+ this._update()
800
+ }
801
+
802
+ private _addEventListeners(): void {
803
+ // Registered unconditionally (update() can flip cellNavigation on later);
804
+ // the handlers bail out when the feature is off.
805
+ EventHandler.on(this._element, EVENT_KEYDOWN, event => {
806
+ this._onCellNavigationKeydown(event as unknown as KeyboardEvent)
807
+ })
808
+
809
+ EventHandler.on(this._element, EVENT_FOCUSIN, event => {
810
+ this._containsFocus = true
811
+ this._onCellFocusIn(event as unknown as FocusEvent)
812
+ })
813
+
814
+ EventHandler.on(this._element, EVENT_FOCUSOUT, event => {
815
+ const related = (event as unknown as FocusEvent).relatedTarget as Node | null
816
+ if (!related || !this._element.contains(related)) {
817
+ this._containsFocus = false
818
+ }
819
+ })
820
+
821
+ EventHandler.on(this._element, EVENT_DBLCLICK, event => {
822
+ if (!this._config.editing) {
823
+ return
824
+ }
825
+
826
+ const found = this._navigableCellFromTarget(event.target as HTMLElement)
827
+ if (found && found.cell.rowIndex >= 0) {
828
+ this._activeCell?.set(found.cell)
829
+ this._startEditing(found.cell)
830
+ }
831
+ })
832
+
833
+ EventHandler.on(this._element, EVENT_CLICK, SELECTOR_SORT_BUTTON, event => {
834
+ this._toggleColumnSorting(event)
835
+ })
836
+
837
+ const applyColumnFilter = debounce((key: string, value: string) => {
838
+ if (this._table) {
839
+ this._table.getColumn(key)?.setFilterValue(value === '' ? undefined : value)
840
+ }
841
+ }, FILTER_DEBOUNCE_MS)
842
+
843
+ EventHandler.on(this._element, EVENT_INPUT, SELECTOR_COLUMN_FILTER, event => {
844
+ const input = (event.delegateTarget ?? event.target) as HTMLInputElement
845
+ applyColumnFilter(input.dataset.coreuiColumnFilter!, input.value)
846
+ })
847
+
848
+ const applyGlobalFilter = debounce((value: string) => {
849
+ if (this._table) {
850
+ this._table.setGlobalFilter(value === '' ? undefined : value)
851
+ }
852
+ }, FILTER_DEBOUNCE_MS)
853
+
854
+ EventHandler.on(this._element, EVENT_INPUT, SELECTOR_GLOBAL_FILTER, event => {
855
+ const input = (event.delegateTarget ?? event.target) as HTMLInputElement
856
+ applyGlobalFilter(input.value)
857
+ })
858
+
859
+ EventHandler.on(this._element, EVENT_CLICK, SELECTOR_MENU_BUTTON, event => {
860
+ const button = (event.delegateTarget ?? event.target) as HTMLElement
861
+ this._menu?.toggle(button, button.dataset.coreuiMenu!)
862
+ })
863
+
864
+ EventHandler.on(this._element, EVENT_CLICK, SELECTOR_FILTER_BUTTON, event => {
865
+ const button = (event.delegateTarget ?? event.target) as HTMLElement
866
+ this._filterMenu?.toggle(button, button.dataset.coreuiFilter!)
867
+ })
868
+
869
+ EventHandler.on(this._element, EVENT_CLICK, SELECTOR_TOOLBAR_COLUMNS, event => {
870
+ const button = (event.delegateTarget ?? event.target) as HTMLElement
871
+ this._columnChooser?.toggle(button)
872
+ })
873
+
874
+ EventHandler.on(this._element, EVENT_CLICK, SELECTOR_TOOLBAR_EXPORT, () => {
875
+ this.downloadCsv({ scope: 'filtered', ...this._toolbarConfig?.exportOptions })
876
+ })
877
+
878
+ EventHandler.on(this._element, EVENT_CLICK, SELECTOR_TOOLBAR_UNDO, () => {
879
+ this.undo()
880
+ })
881
+
882
+ EventHandler.on(this._element, EVENT_CLICK, SELECTOR_TOOLBAR_REDO, () => {
883
+ this.redo()
884
+ })
885
+
886
+ EventHandler.on(this._element, EVENT_DRAGSTART, SELECTOR_DRAGGABLE, event => {
887
+ this._onColumnDragStart(event)
888
+ })
889
+
890
+ EventHandler.on(this._element, EVENT_DRAGOVER, SELECTOR_DRAGGABLE, event => {
891
+ if (!this._dragColumn) {
892
+ return
893
+ }
894
+
895
+ const target = (event.delegateTarget ?? event.target) as HTMLElement
896
+ const targetKey = target.dataset.coreuiColumn
897
+ // A drop is only valid within the drag source's pinned region; leaving
898
+ // other targets un-prevented shows the browser's no-drop cursor there.
899
+ const source = this._table.getColumn(this._dragColumn)
900
+ const targetColumn = targetKey ? this._table.getColumn(targetKey) : undefined
901
+ if (!source || !targetColumn || targetKey === this._dragColumn || source.getIsPinned() !== targetColumn.getIsPinned()) {
902
+ return
903
+ }
904
+
905
+ event.preventDefault()
906
+ target.classList.add(CLASS_NAME_DROP_TARGET)
907
+ })
908
+
909
+ EventHandler.on(this._element, EVENT_DRAGLEAVE, SELECTOR_DRAGGABLE, event => {
910
+ const target = (event.delegateTarget ?? event.target) as HTMLElement
911
+ target.classList.remove(CLASS_NAME_DROP_TARGET)
912
+ })
913
+
914
+ EventHandler.on(this._element, EVENT_DROP, SELECTOR_DRAGGABLE, event => {
915
+ this._onColumnDrop(event)
916
+ })
917
+
918
+ EventHandler.on(this._element, EVENT_DRAGEND, SELECTOR_DRAGGABLE, () => {
919
+ this._endColumnDrag()
920
+ })
921
+
922
+ EventHandler.on(this._element, EVENT_MOUSEDOWN, SELECTOR_RESIZER, event => {
923
+ this._startColumnResize(event)
924
+ })
925
+
926
+ EventHandler.on(this._element, EVENT_TOUCHSTART, SELECTOR_RESIZER, event => {
927
+ this._startColumnResize(event)
928
+ })
929
+
930
+ EventHandler.on(this._element, EVENT_CLICK, SELECTOR_ROW_SELECT, event => {
931
+ this._toggleRowSelection(event)
932
+ })
933
+
934
+ EventHandler.on(this._element, EVENT_CHANGE, SELECTOR_SELECT_ALL, event => {
935
+ const input = (event.delegateTarget ?? event.target) as HTMLInputElement
936
+ this._table.toggleAllRowsSelected(input.checked)
937
+ })
938
+
939
+ EventHandler.on(this._element, EVENT_CLICK, SELECTOR_PAGE, event => {
940
+ const button = (event.delegateTarget ?? event.target) as HTMLButtonElement
941
+ const page = button.dataset.coreuiPage!
942
+
943
+ if (page === 'first') {
944
+ this._table.firstPage()
945
+ return
946
+ }
947
+
948
+ if (page === 'prev') {
949
+ this._table.previousPage()
950
+ return
951
+ }
952
+
953
+ if (page === 'next') {
954
+ this._table.nextPage()
955
+ return
956
+ }
957
+
958
+ if (page === 'last') {
959
+ this._table.lastPage()
960
+ return
961
+ }
962
+
963
+ this._table.setPageIndex(Number(page))
964
+ })
965
+
966
+ EventHandler.on(this._element, EVENT_CHANGE, SELECTOR_PAGE_SIZE, event => {
967
+ const select = (event.delegateTarget ?? event.target) as HTMLSelectElement
968
+ this._table.setPageSize(Number(select.value))
969
+ })
970
+ }
971
+
972
+ private _toggleColumnSorting(event: Event & Record<string, unknown>): void {
973
+ const button = (event.delegateTarget ?? event.target) as HTMLElement
974
+ const key = button.closest('th')?.dataset.coreuiColumn
975
+ if (!key) {
976
+ return
977
+ }
978
+
979
+ const column = this._table.getColumn(key)
980
+ column?.getToggleSortingHandler()?.(event)
981
+ }
982
+
983
+ // Maps an event target to the navigable cell that holds it: a data cell
984
+ // (rowIndex from the row model) or a header label-row cell (rowIndex -1).
985
+ // Selection cells, the filter row, and grid chrome outside the table map to
986
+ // null.
987
+ private _navigableCellFromTarget(target: HTMLElement): { cellElement: HTMLElement; cell: ActiveCell } | null {
988
+ const cellElement = target.closest<HTMLElement>('td, th')
989
+ if (!cellElement || !this._tableElement.contains(cellElement)) {
990
+ return null
991
+ }
992
+
993
+ const columnId = cellElement.dataset.coreuiColumn
994
+ if (!columnId) {
995
+ return null
996
+ }
997
+
998
+ const rowElement = cellElement.closest('tr')!
999
+
1000
+ if (rowElement.parentElement === this._body.element) {
1001
+ const index = rowElement.dataset.coreuiIndex
1002
+ if (index === undefined) {
1003
+ return null
1004
+ }
1005
+
1006
+ return { cellElement, cell: { rowIndex: Number(index), columnId } }
1007
+ }
1008
+
1009
+ if (rowElement === this._header.element.firstElementChild) {
1010
+ return { cellElement, cell: { rowIndex: -1, columnId } }
1011
+ }
1012
+
1013
+ return null
1014
+ }
1015
+
1016
+ private _onCellFocusIn(event: FocusEvent): void {
1017
+ if (!this._activeCell) {
1018
+ return
1019
+ }
1020
+
1021
+ const found = this._navigableCellFromTarget(event.target as HTMLElement)
1022
+ if (found) {
1023
+ // The browser already moved focus (click, descend) - only sync state.
1024
+ this._activeCell.set(found.cell)
1025
+ }
1026
+ }
1027
+
1028
+ private _onCellNavigationKeydown(event: KeyboardEvent): void {
1029
+ if (!this._activeCell) {
1030
+ return
1031
+ }
1032
+
1033
+ const target = event.target as HTMLElement
1034
+
1035
+ // Ctrl/Cmd+Z / Ctrl+Shift+Z / Ctrl+Y - edit history (MUI/AG parity).
1036
+ // Not while an editor is open (the input's native undo wins) and not
1037
+ // from form controls (filter/search inputs own their undo).
1038
+ if (
1039
+ this._config.history &&
1040
+ (event.ctrlKey || event.metaKey) &&
1041
+ !this._editingCell &&
1042
+ !target.matches('input, textarea, select')
1043
+ ) {
1044
+ const key = event.key.toLowerCase()
1045
+ if (key === 'z' && !event.shiftKey) {
1046
+ event.preventDefault()
1047
+ this.undo()
1048
+ return
1049
+ }
1050
+
1051
+ if ((key === 'z' && event.shiftKey) || key === 'y') {
1052
+ event.preventDefault()
1053
+ this.redo()
1054
+ return
1055
+ }
1056
+ }
1057
+
1058
+ if (this._editingCell && this._editScopeContains(target)) {
1059
+ // A defaultPrevented key was consumed by the editor (a rich editor
1060
+ // closing its own overlay on Escape, an autocomplete accepting on
1061
+ // Enter) - one key, one layer.
1062
+ if (event.key === 'Enter') {
1063
+ if (!event.defaultPrevented) {
1064
+ event.preventDefault()
1065
+ this._stopEditing({ commit: true })
1066
+ }
1067
+
1068
+ return
1069
+ }
1070
+
1071
+ if (event.key === 'Escape') {
1072
+ if (!event.defaultPrevented) {
1073
+ this._cancelEdit()
1074
+ }
1075
+
1076
+ return
1077
+ }
1078
+
1079
+ if (event.key === 'Tab') {
1080
+ event.preventDefault()
1081
+ this._moveEditing(event.shiftKey ? -1 : 1)
1082
+ return
1083
+ }
1084
+
1085
+ // Everything else (arrows, typing) stays native inside the editor.
1086
+ return
1087
+ }
1088
+
1089
+ // Focus fell back to the viewport after the active row was recycled out
1090
+ // of the window: any navigation key scrolls the active cell back in view
1091
+ // and refocuses it.
1092
+ if (target === this._viewportElement) {
1093
+ const active = this._activeCell.get()
1094
+ if (active && event.key !== 'Tab') {
1095
+ event.preventDefault()
1096
+ this._setActiveCell(active)
1097
+ }
1098
+
1099
+ return
1100
+ }
1101
+
1102
+ const found = this._navigableCellFromTarget(target)
1103
+ if (!found) {
1104
+ return
1105
+ }
1106
+
1107
+ if (target !== found.cellElement) {
1108
+ // Focus is descended into the cell's content; Escape ascends back.
1109
+ if (event.key === 'Escape') {
1110
+ found.cellElement.focus({ preventScroll: true })
1111
+ }
1112
+
1113
+ return
1114
+ }
1115
+
1116
+ if (event.key === 'Enter' || event.key === 'F2') {
1117
+ event.preventDefault()
1118
+ const config = this._columnsByKey.get(found.cell.columnId)
1119
+ if (found.cell.rowIndex === -1) {
1120
+ if (event.key === 'Enter' && config && this._isColumnSortable(config)) {
1121
+ this._table.getColumn(found.cell.columnId)?.getToggleSortingHandler()?.(event)
1122
+ }
1123
+
1124
+ return
1125
+ }
1126
+
1127
+ if (config && this._isColumnEditable(config)) {
1128
+ this._startEditing(found.cell)
1129
+ return
1130
+ }
1131
+
1132
+ if (event.key === 'Enter') {
1133
+ this._descendIntoCell(found.cellElement)
1134
+ }
1135
+
1136
+ return
1137
+ }
1138
+
1139
+ if (event.key === ' ') {
1140
+ if (this._config.rowSelection && found.cell.rowIndex >= 0) {
1141
+ event.preventDefault()
1142
+ const row = this._table.getRowModel().rows[found.cell.rowIndex]
1143
+ row?.toggleSelected()
1144
+ this._selectionAnchorIndex = found.cell.rowIndex
1145
+ }
1146
+
1147
+ return
1148
+ }
1149
+
1150
+ const result = resolveNavigation(event.key, event.ctrlKey || event.metaKey, found.cell, this._activeCellContext())
1151
+ if (!result) {
1152
+ return
1153
+ }
1154
+
1155
+ event.preventDefault()
1156
+
1157
+ if (result.pageDelta === 1 && this._table.getCanNextPage()) {
1158
+ this._table.nextPage()
1159
+ } else if (result.pageDelta === -1 && this._table.getCanPreviousPage()) {
1160
+ this._table.previousPage()
1161
+ }
1162
+
1163
+ this._setActiveCell(result.cell)
1164
+ }
1165
+
1166
+ private _descendIntoCell(cellElement: HTMLElement): void {
1167
+ cellElement.querySelector<HTMLElement>(FOCUSABLE_SELECTOR)?.focus({ preventScroll: true })
1168
+ }
1169
+
1170
+ private _isColumnEditable(column: DataGridColumn): boolean {
1171
+ if (!this._config.editing) {
1172
+ return false
1173
+ }
1174
+
1175
+ // Opt-IN, unlike the opt-out feature booleans - editing is invasive. A
1176
+ // custom editor is itself the opt-in.
1177
+ return Boolean(column.editor) || column.editable === true || typeof column.editable === 'object'
1178
+ }
1179
+
1180
+ private _startEditing(cell: ActiveCell): void {
1181
+ const config = this._columnsByKey.get(cell.columnId)
1182
+ if (!config || !this._isColumnEditable(config)) {
1183
+ return
1184
+ }
1185
+
1186
+ this._stopEditing({ commit: true, force: true })
1187
+
1188
+ const row = this._table.getRowModel().rows[cell.rowIndex]
1189
+ if (!row) {
1190
+ return
1191
+ }
1192
+
1193
+ const value = row.getValue(cell.columnId)
1194
+ const context: DataGridEditorContext = {
1195
+ item: row.original,
1196
+ column: config,
1197
+ value,
1198
+ commit: committed => this._commitEdit(committed),
1199
+ cancel: () => this._cancelEdit(),
1200
+ labels: this._labels
1201
+ }
1202
+ const editable: DataGridEditableOptions = typeof config.editable === 'object' ? config.editable : {}
1203
+ const editor = config.editor ? config.editor(context) : createBuiltInEditor(editable, context)
1204
+
1205
+ this._editingCell = {
1206
+ rowId: row.id,
1207
+ columnId: cell.columnId,
1208
+ rowIndex: cell.rowIndex,
1209
+ item: row.original,
1210
+ previousValue: value
1211
+ }
1212
+ this._editor = editor
1213
+ this._editorFocusPending = true
1214
+
1215
+ EventHandler.trigger(this._element, EVENT_EDIT_START, { item: row.original, columnId: cell.columnId })
1216
+
1217
+ if (editor.popup) {
1218
+ this._mountEditorPopup(cell)
1219
+ }
1220
+
1221
+ // Commit on blur (inline editors) and on pointerdown outside the edit
1222
+ // scope (popup editors, portaled overlays) - blur alone cannot commit,
1223
+ // picking a date in a calendar blurs the input mid-edit.
1224
+ this._editorPointerdownHandler = (domEvent: Event) => {
1225
+ if (!this._editScopeContains(domEvent.target as Node)) {
1226
+ this._stopEditing({ commit: true })
1227
+ }
1228
+ }
1229
+
1230
+ EventHandler.on(document, EVENT_POINTERDOWN, this._editorPointerdownHandler)
1231
+ EventHandler.on(editor.element, EVENT_FOCUSOUT, (domEvent: Event) => {
1232
+ const related = (domEvent as FocusEvent).relatedTarget as Node | null
1233
+ if (related && !this._editScopeContains(related)) {
1234
+ this._stopEditing({ commit: true })
1235
+ }
1236
+ })
1237
+
1238
+ this._scheduler.schedule()
1239
+ }
1240
+
1241
+ // The edit scope: the editing cell, the editor element, its popup layer,
1242
+ // and anything the editor claims via contains() (portaled overlays).
1243
+ private _editScopeContains(target: Node | null): boolean {
1244
+ if (!target || !this._editingCell || !this._editor) {
1245
+ return false
1246
+ }
1247
+
1248
+ if (this._editor.contains?.(target) || this._editor.element.contains(target) || this._editorPopup?.contains(target)) {
1249
+ return true
1250
+ }
1251
+
1252
+ const cellElement = this._cellElementFor({
1253
+ rowIndex: this._editingCell.rowIndex,
1254
+ columnId: this._editingCell.columnId
1255
+ })
1256
+ return Boolean(cellElement?.contains(target))
1257
+ }
1258
+
1259
+ private _mountEditorPopup(cell: ActiveCell): void {
1260
+ const cellElement = this._cellElementFor(cell)
1261
+ const popup = document.createElement('div')
1262
+ popup.classList.add(CLASS_NAME_EDITOR_POPUP)
1263
+
1264
+ if (cellElement) {
1265
+ const rootRect = this._element.getBoundingClientRect()
1266
+ const cellRect = cellElement.getBoundingClientRect()
1267
+ popup.style.top = `${cellRect.top - rootRect.top}px`
1268
+ popup.style.left = `${cellRect.left - rootRect.left}px`
1269
+ popup.style.minWidth = `${cellRect.width}px`
1270
+ }
1271
+
1272
+ popup.append(this._editor!.element)
1273
+ this._element.append(popup)
1274
+ this._editorPopup = popup
1275
+ }
1276
+
1277
+ private _commitEdit(value: unknown): void {
1278
+ const editing = this._editingCell
1279
+ if (!editing) {
1280
+ return
1281
+ }
1282
+
1283
+ const validate = this._columnsByKey.get(editing.columnId)?.editValidate
1284
+ if (validate) {
1285
+ const result = validate(value, editing.item)
1286
+ if (result !== true) {
1287
+ this._showEditorInvalid(typeof result === 'string' ? result : '')
1288
+ return
1289
+ }
1290
+ }
1291
+
1292
+ this._teardownEditor()
1293
+ // The grid never mutates items - the app applies the change (setItems /
1294
+ // items prop) and stays the single source of truth.
1295
+ if (this._config.history) {
1296
+ this._undoStack.push({
1297
+ rowId: editing.rowId, columnId: editing.columnId, value, previousValue: editing.previousValue
1298
+ })
1299
+ if (this._undoStack.length > HISTORY_LIMIT) {
1300
+ this._undoStack.shift()
1301
+ }
1302
+
1303
+ this._redoStack = []
1304
+ this._updateHistoryButtons()
1305
+ }
1306
+
1307
+ EventHandler.trigger(this._element, EVENT_EDIT_COMMIT, {
1308
+ item: editing.item,
1309
+ columnId: editing.columnId,
1310
+ value,
1311
+ previousValue: editing.previousValue
1312
+ })
1313
+ this._activeCell?.set({ rowIndex: editing.rowIndex, columnId: editing.columnId }, { focus: true })
1314
+ }
1315
+
1316
+ private _cancelEdit(): void {
1317
+ const editing = this._editingCell
1318
+ if (!editing) {
1319
+ return
1320
+ }
1321
+
1322
+ this._teardownEditor()
1323
+ EventHandler.trigger(this._element, EVENT_EDIT_CANCEL, { item: editing.item, columnId: editing.columnId })
1324
+ this._activeCell?.set({ rowIndex: editing.rowIndex, columnId: editing.columnId }, { focus: true })
1325
+ }
1326
+
1327
+ private _stopEditing(options: { commit: boolean; force?: boolean }): void {
1328
+ if (!this._editingCell) {
1329
+ return
1330
+ }
1331
+
1332
+ if (options.commit && this._editor?.getValue) {
1333
+ this._commitEdit(this._editor.getValue())
1334
+ // An invalid value blocks the commit; when the edit cannot survive
1335
+ // anyway (row recycled away, row set changed), drop the draft.
1336
+ if (this._editingCell && options.force) {
1337
+ this._cancelEdit()
1338
+ }
1339
+
1340
+ return
1341
+ }
1342
+
1343
+ // A custom editor without getValue owns its commits - stopping from the
1344
+ // outside can only cancel.
1345
+ this._cancelEdit()
1346
+ }
1347
+
1348
+ private _showEditorInvalid(message: string): void {
1349
+ const editor = this._editor
1350
+ if (!editor) {
1351
+ return
1352
+ }
1353
+
1354
+ const input = editor.element.matches('input, select, textarea') ?
1355
+ editor.element :
1356
+ editor.element.querySelector<HTMLElement>('input, select, textarea')
1357
+ if (!input) {
1358
+ return
1359
+ }
1360
+
1361
+ if (!this._editorErrorElement) {
1362
+ this._editorErrorElement = document.createElement('div')
1363
+ this._editorErrorElement.classList.add('visually-hidden')
1364
+ this._editorErrorElement.id = `data-grid-editor-error-${++editorErrorCount}`
1365
+ this._element.append(this._editorErrorElement)
1366
+ }
1367
+
1368
+ this._editorErrorElement.textContent = message
1369
+ input.classList.add('is-invalid')
1370
+ input.setAttribute('aria-invalid', 'true')
1371
+ input.setAttribute('aria-errormessage', this._editorErrorElement.id)
1372
+ }
1373
+
1374
+ private _moveEditing(direction: -1 | 1): void {
1375
+ const editing = this._editingCell
1376
+ if (!editing) {
1377
+ return
1378
+ }
1379
+
1380
+ const columns = this._visibleColumns()
1381
+ const currentIndex = columns.findIndex(column => column.key === editing.columnId)
1382
+ let next: DataGridColumn | null = null
1383
+ for (let index = currentIndex + direction; index >= 0 && index < columns.length; index += direction) {
1384
+ if (this._isColumnEditable(columns[index])) {
1385
+ next = columns[index]
1386
+ break
1387
+ }
1388
+ }
1389
+
1390
+ this._stopEditing({ commit: true })
1391
+ if (this._editingCell) {
1392
+ // An invalid value blocked the commit - stay.
1393
+ return
1394
+ }
1395
+
1396
+ if (next) {
1397
+ const cell = { rowIndex: editing.rowIndex, columnId: next.key }
1398
+ this._activeCell?.set(cell, { focus: true })
1399
+ this._startEditing(cell)
1400
+ }
1401
+ }
1402
+
1403
+ private _teardownEditor(): void {
1404
+ if (this._editorPointerdownHandler) {
1405
+ EventHandler.off(document, EVENT_POINTERDOWN, this._editorPointerdownHandler)
1406
+ this._editorPointerdownHandler = null
1407
+ }
1408
+
1409
+ if (this._editor) {
1410
+ EventHandler.off(this._editor.element, EVENT_FOCUSOUT)
1411
+ this._editor.dispose?.()
1412
+ }
1413
+
1414
+ if (this._editorPopup) {
1415
+ this._editorPopup.remove()
1416
+ this._editorPopup = null
1417
+ }
1418
+
1419
+ if (this._editorErrorElement) {
1420
+ this._editorErrorElement.remove()
1421
+ this._editorErrorElement = null
1422
+ }
1423
+
1424
+ this._editor = null
1425
+ this._editingCell = null
1426
+ this._editorFocusPending = false
1427
+ this._scheduler.schedule()
1428
+ }
1429
+
1430
+ private _setActiveCell(cell: ActiveCell): void {
1431
+ this._activeCell?.set(cell, { focus: true })
1432
+ if (this._virtualizer && cell.rowIndex >= 0) {
1433
+ // No-op when the row is already in view (align: auto); otherwise the
1434
+ // focus request stays pending until the row renders on the next flush.
1435
+ this._virtualizer.instance.scrollToIndex(cell.rowIndex)
1436
+ }
1437
+ }
1438
+
1439
+ private _activeCellContext(): ActiveCellContext {
1440
+ const rowCount = this._table.getRowModel().rows.length
1441
+ const paginated = Boolean(this._config.pagination) || Boolean(this._config.dataProvider)
1442
+ const viewportRows = Math.floor((this._viewportElement?.clientHeight ?? 0) / this._config.rowHeight)
1443
+
1444
+ return {
1445
+ columnIds: this._visibleLeafColumns().map(column => column.id),
1446
+ rowCount,
1447
+ pageStep: paginated ?
1448
+ this._table.getState().pagination.pageSize :
1449
+ Math.max(1, viewportRows || rowCount),
1450
+ paginated
1451
+ }
1452
+ }
1453
+
1454
+ private _cellElementFor(cell: ActiveCell): HTMLElement | null {
1455
+ if (cell.rowIndex === -1) {
1456
+ return this._header.element.querySelector<HTMLElement>(
1457
+ `tr:first-child th[data-coreui-column="${cell.columnId}"]`
1458
+ )
1459
+ }
1460
+
1461
+ const rowElement = this._body.element.querySelector<HTMLElement>(`tr[data-coreui-index="${cell.rowIndex}"]`)
1462
+ return rowElement?.querySelector<HTMLElement>(`td[data-coreui-column="${cell.columnId}"]`) ?? null
1463
+ }
1464
+
1465
+ // The roving-tabindex pass: runs after every render/recycle flush, in the
1466
+ // same slot as the aria attributes, so focus is always re-derived from
1467
+ // state (never from what the recycler left in the DOM).
1468
+ private _applyCellNavigation(): void {
1469
+ if (!this._activeCell) {
1470
+ return
1471
+ }
1472
+
1473
+ const context = this._activeCellContext()
1474
+ this._activeCell.clamp(context)
1475
+
1476
+ this._tableElement.setAttribute(
1477
+ 'aria-colcount',
1478
+ String(context.columnIds.length + (this._config.rowSelection ? 1 : 0))
1479
+ )
1480
+
1481
+ const active = this._activeCell.get()
1482
+ // With no active cell yet, the first data cell (or first header cell in
1483
+ // an empty grid) is the grid's single tab stop.
1484
+ const tabbable = active ?? (context.columnIds.length > 0 ?
1485
+ { rowIndex: context.rowCount > 0 ? 0 : -1, columnId: context.columnIds[0] } :
1486
+ null)
1487
+
1488
+ for (const cellElement of this._header.element.querySelectorAll<HTMLElement>('tr:first-child th[data-coreui-column]')) {
1489
+ applyTabIndex(
1490
+ cellElement,
1491
+ tabbable !== null && tabbable.rowIndex === -1 && cellElement.dataset.coreuiColumn === tabbable.columnId
1492
+ )
1493
+ }
1494
+
1495
+ for (const rowElement of this._body.element.querySelectorAll<HTMLElement>('tr[data-coreui-index]')) {
1496
+ const isActiveRow = tabbable !== null && rowElement.dataset.coreuiIndex === String(tabbable.rowIndex)
1497
+ for (const cellElement of rowElement.querySelectorAll<HTMLElement>('td[data-coreui-column]')) {
1498
+ applyTabIndex(cellElement, isActiveRow && cellElement.dataset.coreuiColumn === tabbable!.columnId)
1499
+ }
1500
+ }
1501
+
1502
+ if (!active) {
1503
+ return
1504
+ }
1505
+
1506
+ const activeElement = this._cellElementFor(active)
1507
+
1508
+ if (activeElement) {
1509
+ if (this._activeCell.consumeFocusRequest()) {
1510
+ activeElement.focus({ preventScroll: true })
1511
+ }
1512
+
1513
+ return
1514
+ }
1515
+
1516
+ // The active cell scrolled out of the rendered window while focused: park
1517
+ // focus on the viewport so the keydown fallback stays inside the grid.
1518
+ if (this._containsFocus && !this._element.contains(document.activeElement)) {
1519
+ this._viewportElement.focus({ preventScroll: true })
1520
+ }
1521
+ }
1522
+
1523
+ private _getMenuActions(columnKey: string): DataGridMenuAction[] {
1524
+ const column = this._table.getColumn(columnKey)
1525
+ const config = this._columnsByKey.get(columnKey)
1526
+ if (!column || !config) {
1527
+ return []
1528
+ }
1529
+
1530
+ const actions: DataGridMenuAction[] = []
1531
+
1532
+ if (this._isColumnSortable(config)) {
1533
+ const sorted = column.getIsSorted()
1534
+ actions.push(
1535
+ {
1536
+ key: 'sort-asc',
1537
+ label: this._labels.sortAscending,
1538
+ disabled: sorted === 'asc',
1539
+ group: 'sorting',
1540
+ icon: this._sanitizeIcon(this._config.sortAscendingIcon),
1541
+ run: () => column.toggleSorting(false)
1542
+ },
1543
+ {
1544
+ key: 'sort-desc',
1545
+ label: this._labels.sortDescending,
1546
+ disabled: sorted === 'desc',
1547
+ group: 'sorting',
1548
+ icon: this._sanitizeIcon(this._config.sortDescendingIcon),
1549
+ run: () => column.toggleSorting(true)
1550
+ }
1551
+ )
1552
+
1553
+ if (sorted) {
1554
+ actions.push({
1555
+ key: 'clear-sort',
1556
+ label: this._labels.clearSort,
1557
+ group: 'sorting',
1558
+ icon: this._sanitizeIcon(this._config.sortNeutralIcon),
1559
+ run: () => column.clearSorting()
1560
+ })
1561
+ }
1562
+ }
1563
+
1564
+ if (this._config.columnPinning) {
1565
+ const pinned = column.getIsPinned()
1566
+ if (pinned !== 'left') {
1567
+ actions.push({
1568
+ key: 'pin-left',
1569
+ label: this._labels.pinLeft,
1570
+ group: 'pinning',
1571
+ icon: this._sanitizeIcon(this._config.pinLeftIcon),
1572
+ run: () => column.pin('left')
1573
+ })
1574
+ }
1575
+
1576
+ if (pinned !== 'right') {
1577
+ actions.push({
1578
+ key: 'pin-right',
1579
+ label: this._labels.pinRight,
1580
+ group: 'pinning',
1581
+ icon: this._sanitizeIcon(this._config.pinRightIcon),
1582
+ run: () => column.pin('right')
1583
+ })
1584
+ }
1585
+
1586
+ if (pinned) {
1587
+ actions.push({
1588
+ key: 'unpin',
1589
+ label: this._labels.unpin,
1590
+ group: 'pinning',
1591
+ icon: this._sanitizeIcon(this._config.unpinIcon),
1592
+ run: () => column.pin(false)
1593
+ })
1594
+ }
1595
+ }
1596
+
1597
+ if (this._config.columnOrder && config.movable !== false) {
1598
+ const region = this._visibleLeafColumns().filter(item => item.getIsPinned() === column.getIsPinned())
1599
+ const index = region.findIndex(item => item.id === columnKey)
1600
+ actions.push(
1601
+ {
1602
+ key: 'move-left',
1603
+ label: this._labels.moveLeft,
1604
+ disabled: index <= 0,
1605
+ group: 'ordering',
1606
+ icon: this._sanitizeIcon(this._config.moveLeftIcon),
1607
+ run: () => this._moveColumn(columnKey, -1)
1608
+ },
1609
+ {
1610
+ key: 'move-right',
1611
+ label: this._labels.moveRight,
1612
+ disabled: index === region.length - 1,
1613
+ group: 'ordering',
1614
+ icon: this._sanitizeIcon(this._config.moveRightIcon),
1615
+ run: () => this._moveColumn(columnKey, 1)
1616
+ }
1617
+ )
1618
+ }
1619
+
1620
+ if (this._config.columnVisibility && column.getCanHide()) {
1621
+ actions.push({
1622
+ key: 'hide',
1623
+ label: this._labels.hideColumn,
1624
+ group: 'visibility',
1625
+ icon: this._sanitizeIcon(this._config.hideColumnIcon),
1626
+ run: () => column.toggleVisibility(false)
1627
+ })
1628
+ }
1629
+
1630
+ // A builder receives the built-in actions (icons already sanitized) and the
1631
+ // column, and returns the final list — reorder, filter, or add custom items.
1632
+ const builder = typeof this._config.columnMenu === 'function' ? this._config.columnMenu : null
1633
+ const finalActions = builder ? builder({ column: config, defaultActions: actions }) : actions
1634
+
1635
+ // Sanitize again so custom items' icons pass through the SVG allow list too
1636
+ // (re-sanitizing the built-in icons is a no-op).
1637
+ return finalActions.map(action =>
1638
+ (action.icon ? { ...action, icon: this._sanitizeIcon(action.icon) } : action)
1639
+ )
1640
+ }
1641
+
1642
+ // All leaf ids in the current order, hidden columns included - building a new
1643
+ // order from visible ids only would drop hidden columns, and TanStack appends
1644
+ // missing ids at the end when they are shown again.
1645
+ private _orderedLeafIds(): string[] {
1646
+ const allIds = this._table.getAllLeafColumns().map(column => column.id)
1647
+ const order = this._table.getState().columnOrder
1648
+ if (order.length === 0) {
1649
+ return allIds
1650
+ }
1651
+
1652
+ const known = new Set(allIds)
1653
+ const ordered = order.filter(id => known.has(id))
1654
+ const seen = new Set(ordered)
1655
+ return [...ordered, ...allIds.filter(id => !seen.has(id))]
1656
+ }
1657
+
1658
+ private _moveColumn(columnKey: string, direction: -1 | 1): void {
1659
+ const column = this._table.getColumn(columnKey)
1660
+ if (!column) {
1661
+ return
1662
+ }
1663
+
1664
+ const region = column.getIsPinned()
1665
+ const ids = this._orderedLeafIds()
1666
+ const regionIds = this._visibleLeafColumns()
1667
+ .filter(item => item.getIsPinned() === region)
1668
+ .map(item => item.id)
1669
+ const neighbor = regionIds[regionIds.indexOf(columnKey) + direction]
1670
+ if (!neighbor) {
1671
+ return
1672
+ }
1673
+
1674
+ const from = ids.indexOf(columnKey)
1675
+ const to = ids.indexOf(neighbor)
1676
+ ids[from] = neighbor
1677
+ ids[to] = columnKey
1678
+ this._table.setColumnOrder(ids)
1679
+ }
1680
+
1681
+ private _onColumnDragStart(event: Event & Record<string, unknown>): void {
1682
+ // A drag started from the resize handle is a resize, not a reorder. The
1683
+ // handle's mousedown is preventDefault'd so the browser never starts a drag
1684
+ // there; this ancestry check is a secondary guard.
1685
+ if ((event.target as HTMLElement).closest?.(SELECTOR_RESIZER)) {
1686
+ event.preventDefault()
1687
+ return
1688
+ }
1689
+
1690
+ const target = (event.delegateTarget ?? event.target) as HTMLElement
1691
+ this._dragColumn = target.dataset.coreuiColumn ?? null
1692
+ target.classList.add(CLASS_NAME_DRAGGING)
1693
+
1694
+ const { dataTransfer } = event as unknown as DragEvent
1695
+ if (dataTransfer) {
1696
+ dataTransfer.effectAllowed = 'move'
1697
+ dataTransfer.setData('text/plain', this._dragColumn ?? '')
1698
+ }
1699
+ }
1700
+
1701
+ private _onColumnDrop(event: Event & Record<string, unknown>): void {
1702
+ event.preventDefault()
1703
+ const target = (event.delegateTarget ?? event.target) as HTMLElement
1704
+ const targetKey = target.dataset.coreuiColumn
1705
+ const sourceKey = this._dragColumn
1706
+ this._endColumnDrag()
1707
+
1708
+ if (!sourceKey || !targetKey || sourceKey === targetKey) {
1709
+ return
1710
+ }
1711
+
1712
+ const source = this._table.getColumn(sourceKey)
1713
+ const targetColumn = this._table.getColumn(targetKey)
1714
+ // Reordering never crosses a pinning boundary — pin membership is state,
1715
+ // not position.
1716
+ if (!source || !targetColumn || source.getIsPinned() !== targetColumn.getIsPinned()) {
1717
+ return
1718
+ }
1719
+
1720
+ const ids = this._orderedLeafIds()
1721
+ const from = ids.indexOf(sourceKey)
1722
+ const to = ids.indexOf(targetKey)
1723
+ if (from === -1 || to === -1) {
1724
+ return
1725
+ }
1726
+
1727
+ // Removing the source first makes inserting at the target's original index
1728
+ // land after it when dragging right and before it when dragging left.
1729
+ ids.splice(from, 1)
1730
+ ids.splice(to, 0, sourceKey)
1731
+ this._table.setColumnOrder(ids)
1732
+ }
1733
+
1734
+ private _endColumnDrag(): void {
1735
+ this._dragColumn = null
1736
+ for (const cell of this._element.querySelectorAll(`.${CLASS_NAME_DRAGGING}, .${CLASS_NAME_DROP_TARGET}`)) {
1737
+ cell.classList.remove(CLASS_NAME_DRAGGING, CLASS_NAME_DROP_TARGET)
1738
+ }
1739
+ }
1740
+
1741
+ private _startColumnResize(event: Event & Record<string, unknown>): void {
1742
+ const target = (event.delegateTarget ?? event.target) as HTMLElement
1743
+ const key = target.closest('th')?.dataset.coreuiColumn
1744
+ if (!key) {
1745
+ return
1746
+ }
1747
+
1748
+ // Stop the browser from starting a native column-reorder drag from the
1749
+ // draggable th when the press lands on the resize handle inside it.
1750
+ if (event.type === 'mousedown') {
1751
+ event.preventDefault()
1752
+ }
1753
+
1754
+ const header = this._table.getHeaderGroups()[0]?.headers.find(item => item.column.id === key)
1755
+ header?.getResizeHandler()(event)
1756
+ }
1757
+
1758
+ private _toggleRowSelection(event: Event & Record<string, unknown>): void {
1759
+ const input = (event.delegateTarget ?? event.target) as HTMLInputElement
1760
+ const rowElement = input.closest('tr')
1761
+ if (!rowElement) {
1762
+ return
1763
+ }
1764
+
1765
+ const index = Number(rowElement.dataset.coreuiIndex)
1766
+ const { rows } = this._table.getRowModel()
1767
+ const selected = input.checked
1768
+
1769
+ if ((event as unknown as MouseEvent).shiftKey && this._selectionAnchorIndex !== null) {
1770
+ const start = Math.min(this._selectionAnchorIndex, index)
1771
+ const end = Math.max(this._selectionAnchorIndex, index)
1772
+ this._table.setRowSelection(previous => {
1773
+ const next = { ...previous }
1774
+ for (const row of rows.slice(start, end + 1)) {
1775
+ if (selected) {
1776
+ next[row.id] = true
1777
+ } else {
1778
+ delete next[row.id]
1779
+ }
1780
+ }
1781
+
1782
+ return next
1783
+ })
1784
+ } else {
1785
+ rows[index]?.toggleSelected(selected)
1786
+ }
1787
+
1788
+ this._selectionAnchorIndex = index
1789
+ }
1790
+
1791
+ private _initDataSource(): void {
1792
+ const { dataProvider } = this._config
1793
+ if (!dataProvider) {
1794
+ return
1795
+ }
1796
+
1797
+ this._dataSource = createDataSource({
1798
+ provider: request => dataProvider(request),
1799
+ getRequest: () => {
1800
+ const state = this._table.getState()
1801
+ return {
1802
+ columnFilters: state.columnFilters,
1803
+ globalFilter: (state.globalFilter as string | undefined) ?? '',
1804
+ pagination: state.pagination,
1805
+ sorting: state.sorting
1806
+ }
1807
+ },
1808
+ onLoad: response => {
1809
+ this._stopEditing({ commit: false })
1810
+ this._loadFailed = false
1811
+ this._dataVersion++
1812
+ this._table.setOptions(previous => ({
1813
+ ...previous,
1814
+ data: response.items,
1815
+ rowCount: response.totalRows
1816
+ }))
1817
+ EventHandler.trigger(this._element, EVENT_DATA_LOAD, {
1818
+ items: response.items,
1819
+ totalRows: response.totalRows
1820
+ })
1821
+ this._announceResults(response.totalRows)
1822
+ this._scheduler.schedule()
1823
+ },
1824
+ onError: error => {
1825
+ this._stopEditing({ commit: false })
1826
+ this._loadFailed = true
1827
+ this._dataVersion++
1828
+ this._table.setOptions(previous => ({ ...previous, data: [], rowCount: 0 }))
1829
+ EventHandler.trigger(this._element, EVENT_DATA_ERROR, { error })
1830
+ this._scheduler.schedule()
1831
+ },
1832
+ onLoadingChange: loading => {
1833
+ if (this._loading) {
1834
+ this._loading.setVisible(loading)
1835
+ }
1836
+
1837
+ this._viewportElement.setAttribute('aria-busy', String(loading))
1838
+ }
1839
+ })
1840
+
1841
+ this._dataSource.load()
1842
+ }
1843
+
1844
+ // Visible leaf columns in VISUAL order: left-pinned, center (columnOrder),
1845
+ // right-pinned — getVisibleLeafColumns() alone does not regroup pinning.
1846
+ private _visibleLeafColumns() {
1847
+ return [
1848
+ ...this._table.getLeftVisibleLeafColumns(),
1849
+ ...this._table.getCenterVisibleLeafColumns(),
1850
+ ...this._table.getRightVisibleLeafColumns()
1851
+ ]
1852
+ }
1853
+
1854
+ private _chooserColumns(): ColumnChooserColumn[] {
1855
+ const columns: ColumnChooserColumn[] = []
1856
+ for (const id of this._orderedLeafIds()) {
1857
+ const column = this._table.getColumn(id)
1858
+ const config = this._columnsByKey.get(id)
1859
+ if (!column || !config) {
1860
+ continue
1861
+ }
1862
+
1863
+ columns.push({
1864
+ id,
1865
+ label: config.label ?? config.key,
1866
+ canHide: column.getCanHide(),
1867
+ visible: column.getIsVisible()
1868
+ })
1869
+ }
1870
+
1871
+ return columns
1872
+ }
1873
+
1874
+ private _visibleColumns(): DataGridColumn[] {
1875
+ const columns: DataGridColumn[] = []
1876
+ for (const column of this._visibleLeafColumns()) {
1877
+ const config = this._columnsByKey.get(column.id)
1878
+ if (config) {
1879
+ columns.push(config)
1880
+ }
1881
+ }
1882
+
1883
+ return columns
1884
+ }
1885
+
1886
+ private _getColumnSizes(): Record<string, number> {
1887
+ const sizes: Record<string, number> = {}
1888
+ for (const header of this._table.getHeaderGroups()[0]?.headers ?? []) {
1889
+ sizes[header.column.id] = header.getSize()
1890
+ }
1891
+
1892
+ return sizes
1893
+ }
1894
+
1895
+ private _getFilteredColumns(): string[] {
1896
+ return this._table.getState().columnFilters.map(filter => filter.id)
1897
+ }
1898
+
1899
+ private _getPinning(): GridPinning {
1900
+ // Visible columns only - getLeftLeafColumns/getPinnedIndex include hidden
1901
+ // columns, which would leave the edge class on a column nobody sees.
1902
+ const leftColumns = this._table.getLeftLeafColumns().filter(column => column.getIsVisible())
1903
+ const rightColumns = this._table.getRightLeafColumns().filter(column => column.getIsVisible())
1904
+ // The selection cell is not a TanStack column; anchor it to the left as
1905
+ // soon as any data column is pinned there so the checkbox travels with them.
1906
+ const selectPinned = Boolean(this._config.rowSelection) && leftColumns.length > 0
1907
+ const columns: Record<string, PinnedCellInfo> = {}
1908
+
1909
+ for (const [index, column] of leftColumns.entries()) {
1910
+ columns[column.id] = {
1911
+ side: 'left',
1912
+ offset: column.getStart('left'),
1913
+ edge: index === leftColumns.length - 1,
1914
+ afterSelect: selectPinned
1915
+ }
1916
+ }
1917
+
1918
+ for (const [index, column] of rightColumns.entries()) {
1919
+ columns[column.id] = {
1920
+ side: 'right',
1921
+ offset: column.getAfter('right'),
1922
+ edge: index === 0,
1923
+ afterSelect: false
1924
+ }
1925
+ }
1926
+
1927
+ return {
1928
+ columns,
1929
+ select: selectPinned ?
1930
+ {
1931
+ side: 'left', offset: 0, edge: false, afterSelect: false
1932
+ } :
1933
+ null
1934
+ }
1935
+ }
1936
+
1937
+ private _storageKey(): string | null {
1938
+ return this._config.stateKey ? `${STATE_STORAGE_PREFIX}${this._config.stateKey}` : null
1939
+ }
1940
+
1941
+ private _restorePersistedState(): void {
1942
+ const key = this._storageKey()
1943
+ if (!key) {
1944
+ return
1945
+ }
1946
+
1947
+ let state: Partial<DataGridState> | null = null
1948
+ try {
1949
+ const raw = window.localStorage.getItem(key)
1950
+ state = raw ? (JSON.parse(raw) as Partial<DataGridState>) : null
1951
+ } catch {
1952
+ return
1953
+ }
1954
+
1955
+ if (!state) {
1956
+ return
1957
+ }
1958
+
1959
+ this.restoreState(state)
1960
+ }
1961
+
1962
+ private _updateHistoryButtons(): void {
1963
+ const undoButton = this._element.querySelector<HTMLButtonElement>(SELECTOR_TOOLBAR_UNDO)
1964
+ if (undoButton) {
1965
+ undoButton.disabled = this._undoStack.length === 0
1966
+ }
1967
+
1968
+ const redoButton = this._element.querySelector<HTMLButtonElement>(SELECTOR_TOOLBAR_REDO)
1969
+ if (redoButton) {
1970
+ redoButton.disabled = this._redoStack.length === 0
1971
+ }
1972
+ }
1973
+
1974
+ private _announceResults(count: number): void {
1975
+ this._announce(interpolate(this._labels.resultsAnnouncement, { count }))
1976
+ }
1977
+
1978
+ private _announce(message: string): void {
1979
+ if (this._liveRegion) {
1980
+ this._liveRegion.textContent = message
1981
+ }
1982
+ }
1983
+
1984
+ private _getTotalRows(): number {
1985
+ if (this._config.dataProvider) {
1986
+ return this._table.options.rowCount ?? 0
1987
+ }
1988
+
1989
+ return this._table.getFilteredRowModel().rows.length
1990
+ }
1991
+
1992
+ private _notifyStateChange(previous: TableState, next: TableState): void {
1993
+ // TanStack's resize handler keeps document-level listeners until mouseup;
1994
+ // a dispose mid-drag would re-enter here against a torn-down instance.
1995
+ if (!this._element) {
1996
+ return
1997
+ }
1998
+
1999
+ // stateKey persistence is automatic - every state change (debounced)
2000
+ // rewrites the snapshot, so the view survives a reload with no explicit
2001
+ // save step.
2002
+ if (this._config.stateKey) {
2003
+ if (!this._persistState) {
2004
+ this._persistState = debounce(() => {
2005
+ const key = this._storageKey()
2006
+ if (!key) {
2007
+ return
2008
+ }
2009
+
2010
+ try {
2011
+ window.localStorage.setItem(key, JSON.stringify(this.getState()))
2012
+ } catch {
2013
+ // Quota/privacy-mode failures degrade to a non-persistent grid.
2014
+ }
2015
+ }, STATE_AUTOSAVE_DEBOUNCE_MS)
2016
+ }
2017
+
2018
+ this._persistState()
2019
+ }
2020
+
2021
+ // The shift-click range anchor and the active cell are indexes into the
2022
+ // current row set; any transition that reshuffles rows would make them
2023
+ // point at arbitrary ones. An in-flight edit stops (commit) first.
2024
+ // Pagination compares by VALUE: a data replacement makes TanStack
2025
+ // auto-reset the page index, which replaces the state object without
2026
+ // changing it - identity alone would kill an edit right after its
2027
+ // editCommit handler applied the change.
2028
+ if (
2029
+ previous.sorting !== next.sorting ||
2030
+ previous.columnFilters !== next.columnFilters ||
2031
+ previous.globalFilter !== next.globalFilter ||
2032
+ previous.pagination.pageIndex !== next.pagination.pageIndex ||
2033
+ previous.pagination.pageSize !== next.pagination.pageSize
2034
+ ) {
2035
+ this._stopEditing({ commit: true, force: true })
2036
+ this._selectionAnchorIndex = null
2037
+ this._activeCell?.reset()
2038
+ }
2039
+
2040
+ if (previous.sorting !== next.sorting) {
2041
+ EventHandler.trigger(this._element, EVENT_SORTING_CHANGE, {
2042
+ sorting: next.sorting
2043
+ })
2044
+ }
2045
+
2046
+ const filtersChanged =
2047
+ previous.columnFilters !== next.columnFilters || previous.globalFilter !== next.globalFilter
2048
+
2049
+ if (filtersChanged) {
2050
+ EventHandler.trigger(this._element, EVENT_FILTER_CHANGE, {
2051
+ columnFilters: next.columnFilters,
2052
+ globalFilter: next.globalFilter ?? ''
2053
+ })
2054
+
2055
+ if (!this._config.dataProvider) {
2056
+ this._announceResults(this._table.getFilteredRowModel().rows.length)
2057
+ }
2058
+ }
2059
+
2060
+ if (previous.rowSelection !== next.rowSelection) {
2061
+ EventHandler.trigger(this._element, EVENT_SELECTION_CHANGE, {
2062
+ rowSelection: next.rowSelection,
2063
+ selectedItems: this.getSelectedItems()
2064
+ })
2065
+ }
2066
+
2067
+ if (previous.pagination !== next.pagination) {
2068
+ EventHandler.trigger(this._element, EVENT_PAGINATION_CHANGE, {
2069
+ pagination: next.pagination
2070
+ })
2071
+ }
2072
+
2073
+ if (previous.columnSizing !== next.columnSizing) {
2074
+ EventHandler.trigger(this._element, EVENT_SIZING_CHANGE, {
2075
+ columnSizing: next.columnSizing
2076
+ })
2077
+ }
2078
+
2079
+ if (previous.columnPinning !== next.columnPinning) {
2080
+ EventHandler.trigger(this._element, EVENT_PINNING_CHANGE, {
2081
+ columnPinning: next.columnPinning
2082
+ })
2083
+ }
2084
+
2085
+ if (previous.columnOrder !== next.columnOrder) {
2086
+ EventHandler.trigger(this._element, EVENT_ORDER_CHANGE, {
2087
+ columnOrder: next.columnOrder
2088
+ })
2089
+ }
2090
+
2091
+ if (previous.columnVisibility !== next.columnVisibility) {
2092
+ EventHandler.trigger(this._element, EVENT_VISIBILITY_CHANGE, {
2093
+ columnVisibility: next.columnVisibility
2094
+ })
2095
+ }
2096
+
2097
+ if (this._dataSource) {
2098
+ const refetchNeeded = filtersChanged || previous.sorting !== next.sorting
2099
+
2100
+ if (refetchNeeded && next.pagination.pageIndex !== 0) {
2101
+ // Back to page one first; the resulting pagination change re-enters
2102
+ // this handler and triggers the (debounced) fetch exactly once.
2103
+ this._table.setPageIndex(0)
2104
+ return
2105
+ }
2106
+
2107
+ if (refetchNeeded || previous.pagination !== next.pagination) {
2108
+ this._dataSource.load()
2109
+ }
2110
+ }
2111
+ }
2112
+
2113
+ private _update(): void {
2114
+ if (this._virtualizer) {
2115
+ this._virtualizer.setCount(this._table.getRowModel().rows.length)
2116
+ this._virtualizer.instance._willUpdate()
2117
+ }
2118
+
2119
+ // Scrolling the editing row out of the rendered window commits the edit
2120
+ // (AG behavior) - BEFORE the recycler wipes the editor's DOM.
2121
+ if (this._editingCell && this._virtualizer) {
2122
+ const { rowIndex } = this._editingCell
2123
+ const rendered = this._virtualizer.instance.getVirtualItems().some(item => item.index === rowIndex)
2124
+ if (!rendered) {
2125
+ this._stopEditing({ commit: true, force: true })
2126
+ }
2127
+ }
2128
+
2129
+ this._tableElement.setAttribute(
2130
+ 'aria-rowcount',
2131
+ String(this._header.rowCount + this._getTotalRows())
2132
+ )
2133
+
2134
+ this._header.update({
2135
+ filteredColumns: this._config.columnFilters ? this._getFilteredColumns() : null,
2136
+ order: this._visibleLeafColumns().map(column => column.id),
2137
+ pinning: this._config.columnPinning ? this._getPinning() : null,
2138
+ resizingColumn: this._config.columnSizing ?
2139
+ this._table.getState().columnSizingInfo.isResizingColumn :
2140
+ false,
2141
+ sizes: (this._config.columnSizing || this._config.columnPinning) ? this._getColumnSizes() : null,
2142
+ sorting: this._table.getState().sorting,
2143
+ selectAll: this._config.rowSelection ?
2144
+ {
2145
+ checked: this._table.getIsAllRowsSelected(),
2146
+ indeterminate: this._table.getIsSomeRowsSelected()
2147
+ } :
2148
+ null
2149
+ })
2150
+ this._body.update()
2151
+ this._applyCellNavigation()
2152
+
2153
+ // The editor element enters the DOM during the body pass (inline) or at
2154
+ // mount (popup) - focus it once it is connected.
2155
+ if (this._editorFocusPending && this._editor?.element.isConnected) {
2156
+ this._editorFocusPending = false
2157
+ if (this._editor.focus) {
2158
+ this._editor.focus()
2159
+ } else {
2160
+ (this._editor.element.querySelector<HTMLElement>(FOCUSABLE_SELECTOR) ?? this._editor.element).focus({
2161
+ preventScroll: true
2162
+ })
2163
+ }
2164
+ }
2165
+
2166
+ this._toolbar?.update?.()
2167
+ this._emptySlot?.update?.()
2168
+
2169
+ for (const instance of this._paginations) {
2170
+ instance.refresh()
2171
+ }
2172
+ }
2173
+
2174
+ private _paginationState() {
2175
+ const { pagination } = this._table.getState()
2176
+ return {
2177
+ pageCount: this._table.getPageCount(),
2178
+ pageIndex: pagination.pageIndex,
2179
+ pageSize: pagination.pageSize,
2180
+ totalRows: this._getTotalRows()
2181
+ }
2182
+ }
2183
+
2184
+ // Static
2185
+ static jQueryInterface(
2186
+ this: { each: (callback: (this: HTMLElement) => void) => unknown },
2187
+ config?: string | Partial<DataGridOptions>
2188
+ ) {
2189
+ return this.each(function () {
2190
+ const data = DataGrid.getOrCreateInstance(this, typeof config === 'string' ? undefined : config)
2191
+
2192
+ if (typeof config !== 'string') {
2193
+ return
2194
+ }
2195
+
2196
+ const methods = data as unknown as Record<string, (() => void) | undefined>
2197
+ if (methods[config] === undefined || config.startsWith('_') || config === 'constructor') {
2198
+ throw new TypeError(`No method named "${config}"`)
2199
+ }
2200
+
2201
+ methods[config]!()
2202
+ })
2203
+ }
2204
+ }
2205
+
2206
+ defineJQueryPlugin(DataGrid)
2207
+
2208
+ export default DataGrid