@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
package/src/types.ts ADDED
@@ -0,0 +1,334 @@
1
+ /**
2
+ * --------------------------------------------------------------------------
3
+ * CoreUI PRO data-grid types.ts
4
+ * License (https://coreui.io/pro/license/)
5
+ * --------------------------------------------------------------------------
6
+ */
7
+
8
+ import type {
9
+ Column, ColumnFiltersState, ColumnOrderState, ColumnPinningState, ColumnSizingState, PaginationState,
10
+ RowSelectionState, SortingState, Table, VisibilityState
11
+ } from '@tanstack/table-core'
12
+ import type { CsvDownloadOptions } from './util/csv'
13
+
14
+ export type {
15
+ Column,
16
+ ColumnFiltersState,
17
+ ColumnOrderState,
18
+ ColumnPinningState,
19
+ ColumnSizingState,
20
+ PaginationState,
21
+ RowSelectionState,
22
+ SortingState,
23
+ Table,
24
+ VisibilityState
25
+ } from '@tanstack/table-core'
26
+
27
+ export type DataGridItem = Record<string, unknown>
28
+
29
+ export interface DataGridColumnFilterContext {
30
+ column: Column<DataGridItem, unknown>
31
+ table: Table<DataGridItem>
32
+ labels: DataGridLabels
33
+ }
34
+
35
+ export type DataGridColumnFilterFactory = (context: DataGridColumnFilterContext) => DataGridSlot
36
+
37
+ export type DataGridFilterType = 'text' | 'number' | 'date' | 'select'
38
+
39
+ export type DataGridFilterOperator =
40
+ | 'contains'
41
+ | 'notContains'
42
+ | 'equals'
43
+ | 'notEquals'
44
+ | 'startsWith'
45
+ | 'endsWith'
46
+ | 'blank'
47
+ | 'notBlank'
48
+ | 'greaterThan'
49
+ | 'greaterThanOrEqual'
50
+ | 'lessThan'
51
+ | 'lessThanOrEqual'
52
+ | 'between'
53
+
54
+ export interface DataGridFilterCondition {
55
+ operator: DataGridFilterOperator
56
+ value?: unknown
57
+ // 'between' upper bound.
58
+ value2?: unknown
59
+ }
60
+
61
+ // The shape carried inside the TanStack columnFilters state: a plain string
62
+ // stays "text contains" (the quick input), the menu writes the structured
63
+ // forms.
64
+ export type DataGridColumnFilterValue =
65
+ | string
66
+ | { conditions: DataGridFilterCondition[]; join: 'and' | 'or' }
67
+ | { operator: 'in'; value: unknown[] }
68
+
69
+ export type DataGridEditorType = 'text' | 'number' | 'select'
70
+
71
+ export interface DataGridEditableOptions {
72
+ type?: DataGridEditorType
73
+ // number editor bounds/step.
74
+ min?: number
75
+ max?: number
76
+ step?: number
77
+ // select editor options: primitives or explicit label/value pairs.
78
+ options?: Array<string | number | { label: string; value: unknown }>
79
+ }
80
+
81
+ export interface DataGridEditorContext {
82
+ item: DataGridItem
83
+ column: DataGridColumn
84
+ value: unknown
85
+ commit: (value: unknown) => void
86
+ cancel: () => void
87
+ labels: DataGridLabels
88
+ }
89
+
90
+ // The editor contract. Inline editors render inside the cell; popup: true
91
+ // renders into an overlay anchored to the cell (rich editors whose UI extends
92
+ // beyond the cell - date pickers, autocompletes, multi-selects). getValue
93
+ // feeds Enter/Tab/blur commits; contains() extends the edit scope to overlays
94
+ // portaled outside the editor element.
95
+ export interface DataGridEditor {
96
+ element: HTMLElement
97
+ contains?: (target: Node) => boolean
98
+ dispose?: () => void
99
+ focus?: () => void
100
+ getValue?: () => unknown
101
+ popup?: boolean
102
+ }
103
+
104
+ export type DataGridEditorFactory = (context: DataGridEditorContext) => DataGridEditor
105
+
106
+ // The editCommit payload. The grid never mutates items - the app applies the
107
+ // change and stays the single source of truth.
108
+ export interface DataGridEditCommitEvent {
109
+ item: DataGridItem
110
+ columnId: string
111
+ value: unknown
112
+ previousValue: unknown
113
+ }
114
+
115
+ export interface DataGridColumn {
116
+ key: string
117
+ editable?: boolean | DataGridEditableOptions
118
+ editor?: DataGridEditorFactory
119
+ editValidate?: (value: unknown, item: DataGridItem) => true | string
120
+ filter?: DataGridColumnFilterFactory
121
+ filterable?: boolean
122
+ filterFn?: (value: unknown, filterValue: unknown, item: DataGridItem) => boolean
123
+ filterType?: DataGridFilterType
124
+ formatter?: (value: unknown, item: DataGridItem) => string
125
+ hideable?: boolean
126
+ label?: string
127
+ movable?: boolean
128
+ render?: (item: DataGridItem, index: number) => Node | string
129
+ resizable?: boolean
130
+ sortable?: boolean
131
+ style?: Partial<CSSStyleDeclaration>
132
+ width?: number
133
+ }
134
+
135
+ export interface DataGridMenuAction {
136
+ key: string
137
+ label: string
138
+ disabled?: boolean
139
+ group?: string
140
+ icon?: string
141
+ run: () => void
142
+ }
143
+
144
+ export interface DataGridColumnMenuContext {
145
+ column: DataGridColumn
146
+ defaultActions: DataGridMenuAction[]
147
+ }
148
+
149
+ export type DataGridColumnMenuBuilder = (context: DataGridColumnMenuContext) => DataGridMenuAction[]
150
+
151
+ export interface DataGridLabels {
152
+ addCondition: string
153
+ applyFilter: string
154
+ clearFilter: string
155
+ clearSort: string
156
+ columnMenu: string
157
+ filterAction: string
158
+ filterColumn: string
159
+ firstPage: string
160
+ globalFilterLabel: string
161
+ globalFilterPlaceholder: string
162
+ hideColumn: string
163
+ itemsInfo: string
164
+ joinAnd: string
165
+ joinOr: string
166
+ lastPage: string
167
+ loadError: string
168
+ loading: string
169
+ moveLeft: string
170
+ moveRight: string
171
+ nextPage: string
172
+ operatorBetween: string
173
+ operatorBlank: string
174
+ operatorContains: string
175
+ operatorEndsWith: string
176
+ operatorEquals: string
177
+ operatorGreaterThan: string
178
+ operatorGreaterThanOrEqual: string
179
+ operatorLessThan: string
180
+ operatorLessThanOrEqual: string
181
+ operatorNotBlank: string
182
+ operatorNotContains: string
183
+ operatorNotEquals: string
184
+ operatorStartsWith: string
185
+ pageSizeLabel: string
186
+ paginationLabel: string
187
+ pinLeft: string
188
+ pinRight: string
189
+ previousPage: string
190
+ redoneAnnouncement: string
191
+ resetColumns: string
192
+ resultsAnnouncement: string
193
+ searchValues: string
194
+ selectAllRows: string
195
+ selectAllValues: string
196
+ selectRow: string
197
+ showAllColumns: string
198
+ sortAscending: string
199
+ sortDescending: string
200
+ toolbarColumns: string
201
+ toolbarExport: string
202
+ toolbarRedo: string
203
+ toolbarUndo: string
204
+ undoneAnnouncement: string
205
+ unpin: string
206
+ }
207
+
208
+ export interface DataGridColumnSizingOptions {
209
+ mode?: 'onChange' | 'onEnd'
210
+ }
211
+
212
+ export interface DataGridSlotContext {
213
+ table: Table<DataGridItem>
214
+ labels: DataGridLabels
215
+ }
216
+
217
+ export interface DataGridSlot {
218
+ element: HTMLElement
219
+ update?: () => void
220
+ dispose?: () => void
221
+ }
222
+
223
+ export type DataGridSlotFactory = (context: DataGridSlotContext) => DataGridSlot
224
+
225
+ export interface DataGridSlots {
226
+ toolbar: DataGridSlotFactory
227
+ pagination: DataGridSlotFactory
228
+ empty: DataGridSlotFactory
229
+ }
230
+
231
+ export interface DataGridToolbarOptions {
232
+ columns?: boolean
233
+ export?: boolean | CsvDownloadOptions
234
+ history?: boolean
235
+ search?: boolean
236
+ }
237
+
238
+ export interface DataGridSortingOptions {
239
+ multiple?: boolean
240
+ resetable?: boolean
241
+ }
242
+
243
+ export type DataGridSorterVisibility = 'always' | 'hover' | false
244
+
245
+ export interface DataGridRowSelectionOptions {
246
+ selectAll?: boolean
247
+ }
248
+
249
+ export interface DataGridPaginationInfoState {
250
+ pageCount: number
251
+ pageIndex: number
252
+ pageSize: number
253
+ totalRows: number
254
+ }
255
+
256
+ export interface DataGridPaginationOptions {
257
+ info?: boolean | ((state: DataGridPaginationInfoState) => string)
258
+ pageSize?: number
259
+ pageSizeOptions?: number[]
260
+ position?: 'top' | 'bottom' | 'both'
261
+ }
262
+
263
+ // The serializable grid state snapshot: every user-adjustable TanStack slice.
264
+ // getState() returns it, restoreState() applies it, and `stateKey` persists
265
+ // it to localStorage (scroll position and edit drafts are not state).
266
+ export interface DataGridState {
267
+ columnFilters: ColumnFiltersState
268
+ columnOrder: ColumnOrderState
269
+ columnPinning: ColumnPinningState
270
+ columnSizing: ColumnSizingState
271
+ columnVisibility: VisibilityState
272
+ globalFilter: string
273
+ pagination: PaginationState
274
+ rowSelection: RowSelectionState
275
+ sorting: SortingState
276
+ }
277
+
278
+ export interface DataGridDataRequest {
279
+ columnFilters: ColumnFiltersState
280
+ globalFilter: string
281
+ pagination: PaginationState
282
+ sorting: SortingState
283
+ }
284
+
285
+ export interface DataGridDataResponse {
286
+ items: DataGridItem[]
287
+ totalRows: number
288
+ }
289
+
290
+ export interface DataGridOptions {
291
+ cellNavigation: boolean
292
+ columnFilters: boolean
293
+ columnMenu: boolean | DataGridColumnMenuBuilder
294
+ columnMenuIcon: string
295
+ columnOrder: boolean | ColumnOrderState
296
+ columnPinning: boolean | ColumnPinningState
297
+ columnSizing: boolean | DataGridColumnSizingOptions
298
+ columnVisibility: boolean | VisibilityState
299
+ columns: DataGridColumn[]
300
+ dataProvider: ((request: DataGridDataRequest) => Promise<DataGridDataResponse>) | null
301
+ editing: boolean
302
+ empty: string | (() => Node | string)
303
+ filterIcon: string
304
+ globalFilter: boolean
305
+ hideColumnIcon: string
306
+ history: boolean
307
+ itemKey: ((item: DataGridItem, index: number) => string) | null
308
+ items: DataGridItem[]
309
+ labels: Partial<DataGridLabels>
310
+ moveLeftIcon: string
311
+ moveRightIcon: string
312
+ overscan: number
313
+ pagination: boolean | DataGridPaginationOptions
314
+ pinLeftIcon: string
315
+ pinRightIcon: string
316
+ rowHeight: number
317
+ rowSelection: boolean | DataGridRowSelectionOptions
318
+ sanitize: boolean
319
+ sanitizeFn: ((html: string) => string) | null
320
+ slots: Partial<DataGridSlots>
321
+ sortAscendingIcon: string
322
+ sortDescendingIcon: string
323
+ sortNeutralIcon: string
324
+ sorterVisibility: DataGridSorterVisibility
325
+ sorting: boolean | DataGridSortingOptions
326
+ stateKey: string | null
327
+ toolbar: boolean | DataGridToolbarOptions
328
+ toolbarColumnsIcon: string
329
+ toolbarExportIcon: string
330
+ toolbarRedoIcon: string
331
+ toolbarUndoIcon: string
332
+ unpinIcon: string
333
+ virtualization: boolean
334
+ }
package/src/ui/body.ts ADDED
@@ -0,0 +1,348 @@
1
+ /**
2
+ * --------------------------------------------------------------------------
3
+ * CoreUI PRO data-grid ui/body.ts
4
+ * License (https://coreui.io/pro/license/)
5
+ * --------------------------------------------------------------------------
6
+ */
7
+
8
+ import type { Row } from '@tanstack/table-core'
9
+ import type { GridVirtualizerInstance } from '../engine/virtualizer'
10
+ import type { DataGridColumn, DataGridItem, DataGridLabels } from '../types'
11
+ import { applyPinnedCell, type GridPinning } from './pinning'
12
+
13
+ const CLASS_NAME_EMPTY = 'data-grid-empty'
14
+ const FOCUSABLE_SELECTOR = 'a[href], button, input, select, textarea, [tabindex]'
15
+ const CLASS_NAME_ROW_ODD = 'data-grid-row-odd'
16
+ const CLASS_NAME_ROW_SELECTED = 'table-active'
17
+ const CLASS_NAME_SELECT_CELL = 'data-grid-select-cell'
18
+ const CLASS_NAME_SPACER = 'data-grid-spacer'
19
+
20
+ export interface GridBodyContext {
21
+ // With cellNavigation on, rows/cells carry grid roles, every cell (and the
22
+ // interactive content inside it) drops out of the tab order, and cells are
23
+ // addressable by data-coreui-column - the roving tabindex pass in the grid
24
+ // root owns which single cell is tabbable.
25
+ cellNavigation: boolean
26
+ headerRowCount: number
27
+ labels: DataGridLabels
28
+ getDataVersion: () => number
29
+ // Inline editing: the cell being edited (keyed by rowId + columnId, NOT
30
+ // index - recycling changes indexes) renders the editor element instead of
31
+ // its content until commit/cancel. Null when editing is off or idle.
32
+ getEditingCell: () => { rowId: string; columnId: string } | null
33
+ getEditorElement: () => HTMLElement | null
34
+ getPinning: () => GridPinning | null
35
+ // Absolute index of the page's first row (pagination mode), else 0 - keeps
36
+ // aria-rowindex absolute like aria-rowcount.
37
+ getRowIndexOffset: () => number
38
+ getRows: () => Array<Row<DataGridItem>>
39
+ // Visible leaf columns in table order (pinning + columnOrder + visibility).
40
+ getVisibleColumns: () => DataGridColumn[]
41
+ getVirtualizer: () => GridVirtualizerInstance | null
42
+ renderEmpty: () => Node | string
43
+ // Guards every string injected via innerHTML (render results, empty state).
44
+ sanitizeContent: (html: string) => string
45
+ selection: boolean
46
+ }
47
+
48
+ export interface GridBody {
49
+ element: HTMLTableSectionElement
50
+ update: () => void
51
+ }
52
+
53
+ const setCellContent = (
54
+ cellElement: HTMLTableCellElement,
55
+ content: Node | string,
56
+ sanitizeContent: (html: string) => string
57
+ ): void => {
58
+ if (typeof content === 'string') {
59
+ cellElement.innerHTML = sanitizeContent(content)
60
+ return
61
+ }
62
+
63
+ // Re-inserting the same node detaches it (losing focus/selection in a custom
64
+ // empty slot); skip when it is already the sole child.
65
+ if (cellElement.childNodes.length === 1 && cellElement.firstChild === content) {
66
+ return
67
+ }
68
+
69
+ cellElement.replaceChildren(content)
70
+ }
71
+
72
+ export const createBody = (context: GridBodyContext): GridBody => {
73
+ const element = document.createElement('tbody')
74
+
75
+ // Snapshot of the current layout, refreshed once per update() — cells are
76
+ // rebuilt (not recycled) when the visible column set or order changes.
77
+ let currentColumns: DataGridColumn[] = context.getVisibleColumns()
78
+ let layoutSignature = currentColumns.map(column => column.key).join('|')
79
+ let cellCount = currentColumns.length + (context.selection ? 1 : 0)
80
+
81
+ const buildRowCells = (rowElement: HTMLTableRowElement): void => {
82
+ rowElement.replaceChildren()
83
+
84
+ if (context.cellNavigation) {
85
+ rowElement.setAttribute('role', 'row')
86
+ }
87
+
88
+ const offset = context.selection ? 1 : 0
89
+
90
+ if (context.selection) {
91
+ const cellElement = document.createElement('td')
92
+ cellElement.classList.add(CLASS_NAME_SELECT_CELL)
93
+ const input = document.createElement('input')
94
+ input.type = 'checkbox'
95
+ input.classList.add('form-check-input')
96
+ input.setAttribute('aria-label', context.labels.selectRow)
97
+ input.dataset.coreuiRowSelect = ''
98
+ if (context.cellNavigation) {
99
+ cellElement.setAttribute('role', 'gridcell')
100
+ cellElement.setAttribute('aria-colindex', '1')
101
+ input.tabIndex = -1
102
+ }
103
+
104
+ cellElement.append(input)
105
+ rowElement.append(cellElement)
106
+ }
107
+
108
+ for (const [index, column] of currentColumns.entries()) {
109
+ const cellElement = document.createElement('td')
110
+ if (context.cellNavigation) {
111
+ cellElement.setAttribute('role', 'gridcell')
112
+ cellElement.setAttribute('aria-colindex', String(offset + index + 1))
113
+ cellElement.dataset.coreuiColumn = column.key
114
+ cellElement.tabIndex = -1
115
+ }
116
+
117
+ rowElement.append(cellElement)
118
+ }
119
+
120
+ rowElement.dataset.coreuiLayout = layoutSignature
121
+ }
122
+
123
+ const createRowElement = (): HTMLTableRowElement => {
124
+ const rowElement = document.createElement('tr')
125
+ buildRowCells(rowElement)
126
+ return rowElement
127
+ }
128
+
129
+ const updateRowCells = (rowElement: HTMLTableRowElement, row: Row<DataGridItem>, index: number): void => {
130
+ const offset = context.selection ? 1 : 0
131
+ const editingCell = context.getEditingCell()
132
+
133
+ for (const [cellIndex, column] of currentColumns.entries()) {
134
+ const cellElement = rowElement.children[cellIndex + offset] as HTMLTableCellElement
135
+
136
+ if (editingCell && editingCell.rowId === row.id && editingCell.columnId === column.key) {
137
+ const editorElement = context.getEditorElement()
138
+ if (editorElement) {
139
+ cellElement.replaceChildren(editorElement)
140
+ continue
141
+ }
142
+ }
143
+
144
+ if (column.render) {
145
+ setCellContent(cellElement, column.render(row.original, index), context.sanitizeContent)
146
+ if (context.cellNavigation) {
147
+ for (const focusable of cellElement.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)) {
148
+ focusable.tabIndex = -1
149
+ }
150
+ }
151
+
152
+ continue
153
+ }
154
+
155
+ const value = row.getValue(column.key)
156
+ cellElement.textContent = column.formatter ?
157
+ column.formatter(value, row.original) :
158
+ String(value ?? '')
159
+ }
160
+ }
161
+
162
+ const updateRowElement = (rowElement: HTMLTableRowElement, row: Row<DataGridItem>, index: number): void => {
163
+ if (rowElement.dataset.coreuiLayout !== layoutSignature) {
164
+ buildRowCells(rowElement)
165
+ delete rowElement.dataset.coreuiRow
166
+ }
167
+
168
+ if (context.selection) {
169
+ const input = rowElement.firstElementChild!.firstElementChild as HTMLInputElement
170
+ const selected = row.getIsSelected()
171
+ input.checked = selected
172
+ rowElement.classList.toggle(CLASS_NAME_ROW_SELECTED, selected)
173
+ rowElement.setAttribute('aria-selected', String(selected))
174
+ }
175
+
176
+ // Cell content is immutable for a given (data version, row id, index)
177
+ // triple — data replacements (dataProvider loads, update()) bump the
178
+ // version — so recycled elements skip the cell pass. This keeps custom
179
+ // render callbacks off the scroll hot path, where all three are stable.
180
+ const rowKey = `${context.getDataVersion()}:${row.id}`
181
+ if (rowElement.dataset.coreuiRow !== rowKey || rowElement.dataset.coreuiIndex !== String(index)) {
182
+ updateRowCells(rowElement, row, index)
183
+ rowElement.dataset.coreuiRow = rowKey
184
+ }
185
+
186
+ rowElement.dataset.coreuiIndex = String(index)
187
+ rowElement.setAttribute('aria-rowindex', String(context.headerRowCount + context.getRowIndexOffset() + index + 1))
188
+ rowElement.classList.toggle(CLASS_NAME_ROW_ODD, index % 2 === 1)
189
+
190
+ if (currentPinning) {
191
+ const pinning = currentPinning
192
+ const offset = context.selection ? 1 : 0
193
+ if (context.selection) {
194
+ applyPinnedCell(rowElement.children[0] as HTMLTableCellElement, pinning.select)
195
+ }
196
+
197
+ for (const [cellIndex, column] of currentColumns.entries()) {
198
+ applyPinnedCell(rowElement.children[cellIndex + offset] as HTMLTableCellElement, pinning.columns[column.key] ?? null)
199
+ }
200
+ }
201
+ }
202
+
203
+ const createSpacer = (): HTMLTableRowElement => {
204
+ const rowElement = document.createElement('tr')
205
+ rowElement.classList.add(CLASS_NAME_SPACER)
206
+ rowElement.setAttribute('aria-hidden', 'true')
207
+
208
+ const cellElement = document.createElement('td')
209
+ cellElement.colSpan = Math.max(cellCount, 1)
210
+ rowElement.append(cellElement)
211
+
212
+ return rowElement
213
+ }
214
+
215
+ const setSpacerHeight = (spacerElement: HTMLTableRowElement, height: number): void => {
216
+ spacerElement.style.height = `${height}px`;
217
+ (spacerElement.firstElementChild as HTMLTableCellElement).style.height = `${height}px`
218
+ spacerElement.style.display = height > 0 ? '' : 'none'
219
+ }
220
+
221
+ const topSpacer = createSpacer()
222
+ const bottomSpacer = createSpacer()
223
+ let spacersAttached = false
224
+ let emptyElement: HTMLTableRowElement | null = null
225
+ let rowElements: HTMLTableRowElement[] = []
226
+ let currentPinning: GridPinning | null = null
227
+ const rowPool: HTMLTableRowElement[] = []
228
+
229
+ const renderEmpty = () => {
230
+ spacersAttached = false
231
+ rowPool.push(...rowElements)
232
+ rowElements = []
233
+
234
+ if (!emptyElement) {
235
+ emptyElement = document.createElement('tr')
236
+ emptyElement.classList.add(CLASS_NAME_EMPTY)
237
+ emptyElement.append(document.createElement('td'))
238
+ }
239
+
240
+ const cellElement = emptyElement.firstElementChild as HTMLTableCellElement
241
+ cellElement.colSpan = Math.max(cellCount, 1)
242
+ setCellContent(cellElement, context.renderEmpty(), context.sanitizeContent)
243
+
244
+ element.replaceChildren(emptyElement)
245
+ }
246
+
247
+ const renderAll = (rows: Array<Row<DataGridItem>>) => {
248
+ if (emptyElement) {
249
+ emptyElement.remove()
250
+ }
251
+
252
+ while (rowElements.length > rows.length) {
253
+ const rowElement = rowElements.pop()!
254
+ rowElement.remove()
255
+ rowPool.push(rowElement)
256
+ }
257
+
258
+ while (rowElements.length < rows.length) {
259
+ const rowElement = rowPool.pop() ?? createRowElement()
260
+ element.append(rowElement)
261
+ rowElements.push(rowElement)
262
+ }
263
+
264
+ for (const [index, row] of rows.entries()) {
265
+ updateRowElement(rowElements[index], row, index)
266
+ }
267
+ }
268
+
269
+ const renderWindow = (rows: Array<Row<DataGridItem>>, virtualizer: GridVirtualizerInstance) => {
270
+ if (!spacersAttached) {
271
+ element.replaceChildren(topSpacer, bottomSpacer)
272
+ spacersAttached = true
273
+ rowElements = []
274
+ }
275
+
276
+ const virtualItems = virtualizer.getVirtualItems()
277
+ const totalSize = virtualizer.getTotalSize()
278
+ const paddingTop = virtualItems.length > 0 ? virtualItems[0].start : 0
279
+ const paddingBottom = virtualItems.length > 0 ?
280
+ totalSize - virtualItems[virtualItems.length - 1].end :
281
+ totalSize
282
+
283
+ setSpacerHeight(topSpacer, paddingTop)
284
+ setSpacerHeight(bottomSpacer, paddingBottom)
285
+
286
+ while (rowElements.length > virtualItems.length) {
287
+ const rowElement = rowElements.pop()!
288
+ rowElement.remove()
289
+ rowPool.push(rowElement)
290
+ }
291
+
292
+ while (rowElements.length < virtualItems.length) {
293
+ const rowElement = rowPool.pop() ?? createRowElement()
294
+ bottomSpacer.before(rowElement)
295
+ rowElements.push(rowElement)
296
+ }
297
+
298
+ for (const [index, virtualItem] of virtualItems.entries()) {
299
+ updateRowElement(rowElements[index], rows[virtualItem.index], virtualItem.index)
300
+ }
301
+ }
302
+
303
+ let editingSignature: string | null = null
304
+
305
+ return {
306
+ element,
307
+ update() {
308
+ const rows = context.getRows()
309
+ currentPinning = context.getPinning()
310
+
311
+ // The immutable-cell contract skips the cell pass on recycled rows, so
312
+ // an editing transition (start, commit, cancel, move) must force the
313
+ // rendered rows to rebuild their cells - once, not per frame.
314
+ const editingCell = context.getEditingCell()
315
+ const nextEditingSignature = editingCell ? `${editingCell.rowId}:${editingCell.columnId}` : null
316
+ if (nextEditingSignature !== editingSignature) {
317
+ editingSignature = nextEditingSignature
318
+ for (const rowElement of rowElements) {
319
+ delete rowElement.dataset.coreuiRow
320
+ }
321
+ }
322
+
323
+ const columns = context.getVisibleColumns()
324
+ const signature = columns.map(column => column.key).join('|')
325
+ if (signature !== layoutSignature) {
326
+ currentColumns = columns
327
+ layoutSignature = signature
328
+ cellCount = currentColumns.length + (context.selection ? 1 : 0);
329
+ (topSpacer.firstElementChild as HTMLTableCellElement).colSpan = Math.max(cellCount, 1);
330
+ (bottomSpacer.firstElementChild as HTMLTableCellElement).colSpan = Math.max(cellCount, 1)
331
+ }
332
+
333
+ if (rows.length === 0) {
334
+ renderEmpty()
335
+ return
336
+ }
337
+
338
+ const virtualizer = context.getVirtualizer()
339
+
340
+ if (virtualizer) {
341
+ renderWindow(rows, virtualizer)
342
+ return
343
+ }
344
+
345
+ renderAll(rows)
346
+ }
347
+ }
348
+ }