@ornery/ui-grid-vanilla 0.1.9 → 1.0.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.
- package/README.md +196 -0
- package/demo/index.html +3 -3
- package/demo/main.ts +1 -1
- package/dist/index.d.mts +584 -55
- package/dist/index.d.ts +584 -55
- package/dist/index.js +4887 -1404
- package/dist/index.mjs +5056 -1526
- package/package.json +11 -8
- package/scss-plugin.ts +65 -0
- package/src/adopt-styles.ts +39 -0
- package/src/attribute-bridge.ts +291 -0
- package/src/auto-height.test.ts +142 -0
- package/src/auto-resize.test.ts +206 -0
- package/src/cell-nav.test.ts +262 -0
- package/src/components/grid-body-cell/grid-body-cell.ts +23 -0
- package/src/components/grid-body-cell/index.ts +1 -0
- package/src/components/grid-cell-editor/grid-cell-editor.ts +104 -0
- package/src/components/grid-cell-editor/index.ts +1 -0
- package/src/components/grid-filter-cell/grid-filter-cell.scss +14 -0
- package/src/components/grid-filter-cell/grid-filter-cell.template.ts +20 -0
- package/src/components/grid-filter-cell/grid-filter-cell.ts +121 -0
- package/src/components/grid-filter-cell/index.ts +1 -0
- package/src/components/grid-group-row/grid-group-row.scss +24 -0
- package/src/components/grid-group-row/grid-group-row.template.ts +23 -0
- package/src/components/grid-group-row/grid-group-row.ts +94 -0
- package/src/components/grid-group-row/index.ts +1 -0
- package/src/components/grid-header-cell/grid-header-cell.ts +17 -0
- package/src/components/grid-header-cell/index.ts +1 -0
- package/src/components/grid-pagination/grid-pagination.scss +39 -0
- package/src/components/grid-pagination/grid-pagination.template.ts +40 -0
- package/src/components/grid-pagination/grid-pagination.ts +204 -0
- package/src/components/grid-pagination/index.ts +1 -0
- package/src/components/grid-template/grid-template.ts +166 -0
- package/src/components/grid-template/index.ts +1 -0
- package/src/events.ts +815 -0
- package/src/exporter.test.ts +186 -0
- package/src/focus.ts +353 -0
- package/src/framework-slots.ts +440 -0
- package/src/grid-controller.ts +1431 -20
- package/src/icons.ts +88 -0
- package/src/importer.test.ts +147 -0
- package/src/index.integration.test.ts +2 -2
- package/src/index.ts +9 -0
- package/src/infinite-scroll.test.ts +220 -0
- package/src/keyboard-navigation.test.ts +579 -0
- package/src/patch.ts +722 -0
- package/src/render.ts +465 -0
- package/src/row-edit.test.ts +191 -0
- package/src/row-selection.test.ts +550 -0
- package/src/save-state.test.ts +235 -0
- package/src/scss.d.ts +4 -0
- package/src/templates/index.ts +62 -0
- package/src/test-setup.ts +23 -0
- package/src/ui-grid-empty.template.ts +23 -0
- package/src/ui-grid-shell.scss +25 -0
- package/src/ui-grid-shell.template.ts +45 -0
- package/src/ui-grid-standalone.element.ts +751 -1032
- package/src/utils/cell-class.ts +70 -0
- package/src/utils/display-items.ts +15 -0
- package/src/utils/dom.ts +64 -0
- package/src/validate.test.ts +170 -0
- package/tsup.config.ts +10 -0
- package/vite.config.ts +10 -1
package/src/patch.ts
ADDED
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Patch path: apply the next snapshot onto the existing shadow-DOM tree
|
|
3
|
+
* without rebuilding it. The initial mount produces the full structure
|
|
4
|
+
* via `renderFull`; subsequent renders pass through `renderPatch` here,
|
|
5
|
+
* which walks the existing nodes and only mutates what changed. This is
|
|
6
|
+
* what keeps focus inside mounted `<ui-grid-cell-editor>` components and
|
|
7
|
+
* what makes per-keystroke snapshot rebroadcasts cheap.
|
|
8
|
+
*
|
|
9
|
+
* Every function takes the element as its first argument and reads its
|
|
10
|
+
* internal state directly. See the @internal field markers on the class
|
|
11
|
+
* for the coupling contract.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
DisplayItem,
|
|
16
|
+
GridColumnDef,
|
|
17
|
+
GridOptions,
|
|
18
|
+
GridRow,
|
|
19
|
+
GroupItem,
|
|
20
|
+
} from '@ornery/ui-grid-core';
|
|
21
|
+
import type { GridControllerSnapshot } from './grid-controller';
|
|
22
|
+
import {
|
|
23
|
+
renderCellShellContents,
|
|
24
|
+
renderCellTemplateFromMarkup,
|
|
25
|
+
renderDisplayItem,
|
|
26
|
+
renderEmptyState,
|
|
27
|
+
renderExpandableTemplate,
|
|
28
|
+
renderHeaderCell,
|
|
29
|
+
renderPagination,
|
|
30
|
+
} from './render';
|
|
31
|
+
import {
|
|
32
|
+
bodyStaticMarkup,
|
|
33
|
+
bodyVirtualMarkup,
|
|
34
|
+
emptyDataMarkup,
|
|
35
|
+
} from './templates';
|
|
36
|
+
import type { UiGridStandaloneElement } from './ui-grid-standalone.element';
|
|
37
|
+
import { bodyCellClass } from './utils/cell-class';
|
|
38
|
+
import { asGroupItem, isRowItem } from './utils/display-items';
|
|
39
|
+
import {
|
|
40
|
+
createFromMarkup,
|
|
41
|
+
escapeHtml,
|
|
42
|
+
setAttr,
|
|
43
|
+
setClass,
|
|
44
|
+
setStyle,
|
|
45
|
+
swapBodyChild,
|
|
46
|
+
} from './utils/dom';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Fast-path data refresh: walk every rendered body cell and re-interpolate
|
|
50
|
+
* its content. Used when the data changed but the row / column identity
|
|
51
|
+
* didn't, so we can avoid a full render + scroll-position restore.
|
|
52
|
+
*/
|
|
53
|
+
export function patchCells(
|
|
54
|
+
el: UiGridStandaloneElement,
|
|
55
|
+
changedRowIds?: ReadonlySet<string>,
|
|
56
|
+
): void {
|
|
57
|
+
const snapshot = el.snapshot;
|
|
58
|
+
if (!snapshot) return;
|
|
59
|
+
const root = el.shadowRoot;
|
|
60
|
+
if (!root) return;
|
|
61
|
+
|
|
62
|
+
// Build O(1) lookup maps from the current pipeline snapshot.
|
|
63
|
+
const rowMap = new Map<string, GridRow>();
|
|
64
|
+
const rowIndexMap = new Map<string, number>();
|
|
65
|
+
for (const row of snapshot.pipeline.visibleRows) {
|
|
66
|
+
rowMap.set(row.id, row);
|
|
67
|
+
rowIndexMap.set(row.id, rowIndexMap.size);
|
|
68
|
+
}
|
|
69
|
+
const colMap = new Map<string, GridColumnDef>();
|
|
70
|
+
const templateMarkupMap = new Map<string, string | null>();
|
|
71
|
+
for (const col of snapshot.visibleColumns) {
|
|
72
|
+
colMap.set(col.name, col);
|
|
73
|
+
templateMarkupMap.set(col.name, getTemplateMarkup(el, `cell-${col.name}`));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Patch every rendered body cell in-place.
|
|
77
|
+
const cells = root.querySelectorAll<HTMLElement>('[data-row][data-column]');
|
|
78
|
+
for (const cell of cells) {
|
|
79
|
+
const rowId = cell.dataset['row'];
|
|
80
|
+
const colName = cell.dataset['column'];
|
|
81
|
+
if (!rowId || !colName) continue;
|
|
82
|
+
if (changedRowIds && !changedRowIds.has(rowId)) continue;
|
|
83
|
+
|
|
84
|
+
const row = rowMap.get(rowId);
|
|
85
|
+
const column = colMap.get(colName);
|
|
86
|
+
if (!row || !column) continue;
|
|
87
|
+
if (cell.classList.contains('cell-editing')) continue;
|
|
88
|
+
|
|
89
|
+
const cellContent = cell.querySelector<HTMLElement>('.cell-content');
|
|
90
|
+
if (!cellContent) continue;
|
|
91
|
+
|
|
92
|
+
const newContent = renderCellTemplateFromMarkup(
|
|
93
|
+
el,
|
|
94
|
+
row,
|
|
95
|
+
column,
|
|
96
|
+
rowIndexMap.get(rowId) ?? 0,
|
|
97
|
+
templateMarkupMap.get(colName) ?? null,
|
|
98
|
+
);
|
|
99
|
+
if (cellContent.innerHTML !== newContent) {
|
|
100
|
+
cellContent.innerHTML = newContent;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Data-only patch: no slot structure change, but any cells that were
|
|
105
|
+
// re-rendered have staged a fresh context. Merge them back into the
|
|
106
|
+
// bridge's last-snapshot without firing an add/remove event — wrappers
|
|
107
|
+
// that need per-tick context updates should subscribe to
|
|
108
|
+
// `rowsVisibleChanged` instead.
|
|
109
|
+
el.frameworkSlots.mergePendingIntoLast();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Structural patch: apply a full `RenderPlan` to the existing shadow-DOM
|
|
114
|
+
* tree. Replaces `renderFull`'s innerHTML-dump path whenever the structure
|
|
115
|
+
* key hasn't changed between snapshots.
|
|
116
|
+
*/
|
|
117
|
+
export function renderPatch(
|
|
118
|
+
el: UiGridStandaloneElement,
|
|
119
|
+
plan: {
|
|
120
|
+
snapshot: GridControllerSnapshot;
|
|
121
|
+
options: GridOptions;
|
|
122
|
+
labels: GridControllerSnapshot['labels'];
|
|
123
|
+
templateColumns: string;
|
|
124
|
+
sortEnabled: boolean;
|
|
125
|
+
filterEnabled: boolean;
|
|
126
|
+
groupingEnabled: boolean;
|
|
127
|
+
pinningEnabled: boolean;
|
|
128
|
+
paginationEnabled: boolean;
|
|
129
|
+
showPagination: boolean;
|
|
130
|
+
virtualizationEnabled: boolean;
|
|
131
|
+
itemsToRender: readonly DisplayItem[];
|
|
132
|
+
startIndex: number;
|
|
133
|
+
virtualOffset: number;
|
|
134
|
+
totalVirtualHeight: number;
|
|
135
|
+
},
|
|
136
|
+
root: ShadowRoot,
|
|
137
|
+
): void {
|
|
138
|
+
const {
|
|
139
|
+
snapshot,
|
|
140
|
+
options,
|
|
141
|
+
labels,
|
|
142
|
+
templateColumns,
|
|
143
|
+
sortEnabled,
|
|
144
|
+
filterEnabled,
|
|
145
|
+
groupingEnabled,
|
|
146
|
+
pinningEnabled,
|
|
147
|
+
paginationEnabled,
|
|
148
|
+
showPagination,
|
|
149
|
+
virtualizationEnabled,
|
|
150
|
+
itemsToRender,
|
|
151
|
+
startIndex,
|
|
152
|
+
virtualOffset,
|
|
153
|
+
totalVirtualHeight,
|
|
154
|
+
} = plan;
|
|
155
|
+
|
|
156
|
+
// Grid frame aria-label.
|
|
157
|
+
const gridFrame = root.querySelector<HTMLElement>('.grid-frame');
|
|
158
|
+
const nextTitle = escapeHtml(options.title ?? 'Data grid');
|
|
159
|
+
if (gridFrame && gridFrame.getAttribute('aria-label') !== nextTitle) {
|
|
160
|
+
gridFrame.setAttribute('aria-label', nextTitle);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Grid table wrapper styles (sticky-top CSS var + explicit height).
|
|
164
|
+
const gridTable = root.querySelector<HTMLElement>('.grid-table');
|
|
165
|
+
const stickyTop = el.measuredHeaderStickyHeight || options.headerRowHeight || 50;
|
|
166
|
+
const tableHeight = el.clientHeight || ((options.minRowsToShow ?? 10) * snapshot.rowSize + (el.measuredHeaderStickyHeight || options.headerRowHeight || 50) + el.measuredFilterStickyHeight + el.measuredPaginationHeight());
|
|
167
|
+
const nextTableStyle = `--ui-grid-header-sticky-top:${stickyTop}px;height:${tableHeight}px;`;
|
|
168
|
+
if (gridTable && gridTable.getAttribute('style') !== nextTableStyle) {
|
|
169
|
+
gridTable.setAttribute('style', nextTableStyle);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Header grid: track sizes + cells.
|
|
173
|
+
const headerGrid = root.querySelector<HTMLElement>('.header-grid');
|
|
174
|
+
if (headerGrid) {
|
|
175
|
+
const nextHeaderStyle = `grid-template-columns:${templateColumns}`;
|
|
176
|
+
if (headerGrid.getAttribute('style') !== nextHeaderStyle) {
|
|
177
|
+
headerGrid.setAttribute('style', nextHeaderStyle);
|
|
178
|
+
}
|
|
179
|
+
const headerHtml = snapshot.visibleColumns
|
|
180
|
+
.map((column) =>
|
|
181
|
+
renderHeaderCell(el, column, sortEnabled, groupingEnabled, pinningEnabled, options),
|
|
182
|
+
)
|
|
183
|
+
.join('');
|
|
184
|
+
if (headerGrid.innerHTML !== headerHtml) {
|
|
185
|
+
headerGrid.innerHTML = headerHtml;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Filter row: update attributes on existing <ui-grid-filter-cell> elements
|
|
190
|
+
// (keyed by data-column). This is the path that preserves input focus.
|
|
191
|
+
if (filterEnabled) {
|
|
192
|
+
const filterGrid = root.querySelector<HTMLElement>('.filter-grid');
|
|
193
|
+
if (filterGrid) {
|
|
194
|
+
const nextFilterStyle = `grid-template-columns:${templateColumns}`;
|
|
195
|
+
if (filterGrid.getAttribute('style') !== nextFilterStyle) {
|
|
196
|
+
filterGrid.setAttribute('style', nextFilterStyle);
|
|
197
|
+
}
|
|
198
|
+
patchFilterCells(el, filterGrid, snapshot);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Body region: innerHTML-swap just the grid rows. The scroll container
|
|
203
|
+
// (.grid-body-viewport) is never replaced, so scroll position survives naturally.
|
|
204
|
+
patchBodyRegion(
|
|
205
|
+
el,
|
|
206
|
+
root,
|
|
207
|
+
snapshot,
|
|
208
|
+
options,
|
|
209
|
+
labels,
|
|
210
|
+
templateColumns,
|
|
211
|
+
virtualizationEnabled,
|
|
212
|
+
itemsToRender,
|
|
213
|
+
startIndex,
|
|
214
|
+
virtualOffset,
|
|
215
|
+
totalVirtualHeight,
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
// Pagination: show/hide + patch attributes. Lives inside .grid-table
|
|
219
|
+
// (after .grid-body-viewport) so the flex column layout constrains the
|
|
220
|
+
// body viewport height and keeps pagination visible.
|
|
221
|
+
reconcilePagination(el, root, snapshot, paginationEnabled && showPagination);
|
|
222
|
+
|
|
223
|
+
// Flush framework-rendered slot deltas — see renderFull().
|
|
224
|
+
el.frameworkSlots.flush();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Ensures the grid-body-viewport contains the right body node for the
|
|
229
|
+
* desired kind (empty-state / virtual / static), reusing the existing
|
|
230
|
+
* node where possible. Returns the body container the caller should
|
|
231
|
+
* patch into, or null for the empty state.
|
|
232
|
+
*/
|
|
233
|
+
export function reconcileBodyRoot(
|
|
234
|
+
el: UiGridStandaloneElement,
|
|
235
|
+
root: ShadowRoot,
|
|
236
|
+
kind: 'empty' | 'virtual' | 'static',
|
|
237
|
+
options: GridOptions,
|
|
238
|
+
labels: GridControllerSnapshot['labels'],
|
|
239
|
+
templateColumns: string,
|
|
240
|
+
virtualOffset: number,
|
|
241
|
+
totalVirtualHeight: number,
|
|
242
|
+
): HTMLElement | null {
|
|
243
|
+
const bodyViewport = root.querySelector<HTMLElement>('.grid-body-viewport');
|
|
244
|
+
if (!bodyViewport) return null;
|
|
245
|
+
|
|
246
|
+
const currentEmpty = bodyViewport.querySelector<HTMLElement>(':scope > .empty-state');
|
|
247
|
+
const currentVirtual = bodyViewport.querySelector<HTMLElement>(':scope > .grid-virtual-spacer');
|
|
248
|
+
const currentStatic = bodyViewport.querySelector<HTMLElement>(':scope > .body-grid');
|
|
249
|
+
const currentNode = currentEmpty ?? currentVirtual ?? currentStatic;
|
|
250
|
+
|
|
251
|
+
if (kind === 'empty') {
|
|
252
|
+
const headingRaw = options.emptyMessage ?? labels.emptyHeading;
|
|
253
|
+
const descriptionRaw = labels.emptyDescription;
|
|
254
|
+
const markup = renderEmptyState(el, headingRaw, descriptionRaw);
|
|
255
|
+
// In framework-rendered mode the markup is a <slot> placeholder, so
|
|
256
|
+
// compare outerHTML to decide whether we can in-place patch or need
|
|
257
|
+
// to swap the whole node. The slot-driven path just re-swaps because
|
|
258
|
+
// the content is framework-owned anyway.
|
|
259
|
+
if (el.frameworkSlots.hasEmptyState()) {
|
|
260
|
+
if (currentEmpty && currentEmpty.querySelector('slot')) {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
const fresh = createFromMarkup(markup);
|
|
264
|
+
if (fresh) swapBodyChild(bodyViewport, currentNode, fresh);
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
const heading = escapeHtml(headingRaw);
|
|
268
|
+
const description = escapeHtml(descriptionRaw);
|
|
269
|
+
if (currentEmpty) {
|
|
270
|
+
const strong = currentEmpty.querySelector('strong');
|
|
271
|
+
const p = currentEmpty.querySelector('p');
|
|
272
|
+
if (strong && strong.innerHTML !== heading) strong.innerHTML = heading;
|
|
273
|
+
if (p && p.innerHTML !== description) p.innerHTML = description;
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
const fresh = createFromMarkup(emptyDataMarkup(heading, description));
|
|
277
|
+
if (fresh) swapBodyChild(bodyViewport, currentNode, fresh);
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (kind === 'virtual') {
|
|
282
|
+
if (currentVirtual) {
|
|
283
|
+
return currentVirtual.querySelector<HTMLElement>('.grid-virtual-body');
|
|
284
|
+
}
|
|
285
|
+
const fresh = createFromMarkup(
|
|
286
|
+
bodyVirtualMarkup(templateColumns, totalVirtualHeight, virtualOffset, ''),
|
|
287
|
+
);
|
|
288
|
+
if (fresh) swapBodyChild(bodyViewport, currentNode, fresh);
|
|
289
|
+
return fresh?.querySelector<HTMLElement>('.grid-virtual-body') ?? null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// static
|
|
293
|
+
if (currentStatic) return currentStatic;
|
|
294
|
+
const fresh = createFromMarkup(bodyStaticMarkup(templateColumns, ''));
|
|
295
|
+
if (fresh) swapBodyChild(bodyViewport, currentNode, fresh);
|
|
296
|
+
return fresh;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Pagination reconciliation: mount, patch, or remove the `<ui-grid-pagination>`
|
|
301
|
+
* based on whether the snapshot should show it. Lives inside `.grid-table`
|
|
302
|
+
* (after `.grid-body-viewport`) matching the Angular layout.
|
|
303
|
+
*/
|
|
304
|
+
export function reconcilePagination(
|
|
305
|
+
el: UiGridStandaloneElement,
|
|
306
|
+
root: ShadowRoot,
|
|
307
|
+
snapshot: GridControllerSnapshot,
|
|
308
|
+
shouldShow: boolean,
|
|
309
|
+
): void {
|
|
310
|
+
const gridTable = root.querySelector<HTMLElement>('.grid-table');
|
|
311
|
+
if (!gridTable) return;
|
|
312
|
+
const existing = gridTable.querySelector<HTMLElement>(':scope > ui-grid-pagination');
|
|
313
|
+
|
|
314
|
+
if (!shouldShow) {
|
|
315
|
+
if (existing) existing.remove();
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (existing) {
|
|
320
|
+
patchPagination(el, existing, snapshot);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Mount a fresh pagination element at the end of .grid-table.
|
|
325
|
+
const wrapper = document.createElement('div');
|
|
326
|
+
wrapper.innerHTML = renderPagination(el, snapshot);
|
|
327
|
+
const fresh = wrapper.firstElementChild as HTMLElement | null;
|
|
328
|
+
if (fresh) {
|
|
329
|
+
gridTable.appendChild(fresh);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Patch the filter-cell data-attrs on the existing `<ui-grid-filter-cell>`
|
|
335
|
+
* instances — this preserves native input focus + caret across snapshot
|
|
336
|
+
* rebuilds. `renderPatch` takes this path instead of swapping innerHTML.
|
|
337
|
+
*/
|
|
338
|
+
export function patchFilterCells(
|
|
339
|
+
el: UiGridStandaloneElement,
|
|
340
|
+
filterGrid: HTMLElement,
|
|
341
|
+
snapshot: GridControllerSnapshot,
|
|
342
|
+
): void {
|
|
343
|
+
const controller = el.controller!;
|
|
344
|
+
const existing = new Map<string, HTMLElement>();
|
|
345
|
+
for (const e of filterGrid.querySelectorAll<HTMLElement>('ui-grid-filter-cell[data-column]')) {
|
|
346
|
+
const column = e.dataset['column'];
|
|
347
|
+
if (column) existing.set(column, e);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
for (const column of snapshot.visibleColumns) {
|
|
351
|
+
const target = existing.get(column.name);
|
|
352
|
+
if (!target) continue;
|
|
353
|
+
const value = snapshot.activeFilters[column.name] ?? '';
|
|
354
|
+
const canFilter = controller.isColumnFilterable(column);
|
|
355
|
+
const pinOffset = controller.isPinned(column) ? controller.pinnedOffset(column) : null;
|
|
356
|
+
const stickyStyle = pinOffset ? `${pinOffset.side}:${pinOffset.offset};` : '';
|
|
357
|
+
setAttr(target, 'data-value', value);
|
|
358
|
+
setAttr(target, 'data-placeholder', controller.filterPlaceholder(column));
|
|
359
|
+
setAttr(target, 'data-disabled', String(!canFilter));
|
|
360
|
+
setAttr(target, 'data-pinned', String(controller.isPinned(column)));
|
|
361
|
+
setAttr(target, 'data-pinned-left-last', String(controller.isPinnedLeftLast(column)));
|
|
362
|
+
setAttr(target, 'data-pinned-right-first', String(controller.isPinnedRightFirst(column)));
|
|
363
|
+
setAttr(target, 'data-sticky-style', stickyStyle);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Reconcile the body region: pick the right container kind (empty /
|
|
369
|
+
* virtual / static), apply its styles, and either in-place-patch or
|
|
370
|
+
* innerHTML-swap the rows depending on whether the fingerprint changed.
|
|
371
|
+
*/
|
|
372
|
+
export function patchBodyRegion(
|
|
373
|
+
el: UiGridStandaloneElement,
|
|
374
|
+
root: ShadowRoot,
|
|
375
|
+
snapshot: GridControllerSnapshot,
|
|
376
|
+
options: GridOptions,
|
|
377
|
+
labels: GridControllerSnapshot['labels'],
|
|
378
|
+
templateColumns: string,
|
|
379
|
+
virtualizationEnabled: boolean,
|
|
380
|
+
itemsToRender: readonly DisplayItem[],
|
|
381
|
+
startIndex: number,
|
|
382
|
+
virtualOffset: number,
|
|
383
|
+
totalVirtualHeight: number,
|
|
384
|
+
): void {
|
|
385
|
+
const hasRows = snapshot.pipeline.displayItems.length > 0;
|
|
386
|
+
const desiredKind: 'empty' | 'virtual' | 'static' = !hasRows
|
|
387
|
+
? 'empty'
|
|
388
|
+
: virtualizationEnabled
|
|
389
|
+
? 'virtual'
|
|
390
|
+
: 'static';
|
|
391
|
+
|
|
392
|
+
const bodyContainer = reconcileBodyRoot(
|
|
393
|
+
el,
|
|
394
|
+
root,
|
|
395
|
+
desiredKind,
|
|
396
|
+
options,
|
|
397
|
+
labels,
|
|
398
|
+
templateColumns,
|
|
399
|
+
virtualOffset,
|
|
400
|
+
totalVirtualHeight,
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
if (!hasRows) {
|
|
404
|
+
el.lastItemsFingerprint = '';
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const itemsFingerprint = fingerprintItems(itemsToRender);
|
|
409
|
+
|
|
410
|
+
if (virtualizationEnabled) {
|
|
411
|
+
const spacer = root.querySelector<HTMLElement>('.grid-virtual-spacer');
|
|
412
|
+
if (spacer && totalVirtualHeight !== el.lastTotalVirtualHeight) {
|
|
413
|
+
spacer.setAttribute('style', `height:${totalVirtualHeight}px`);
|
|
414
|
+
el.lastTotalVirtualHeight = totalVirtualHeight;
|
|
415
|
+
}
|
|
416
|
+
if (bodyContainer) {
|
|
417
|
+
const nextBodyStyle = `grid-template-columns:${templateColumns};top:${virtualOffset}px`;
|
|
418
|
+
if (bodyContainer.getAttribute('style') !== nextBodyStyle) {
|
|
419
|
+
bodyContainer.setAttribute('style', nextBodyStyle);
|
|
420
|
+
el.lastVirtualOffset = virtualOffset;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
} else if (bodyContainer) {
|
|
424
|
+
const nextStyle = `grid-template-columns:${templateColumns}`;
|
|
425
|
+
if (bodyContainer.getAttribute('style') !== nextStyle) {
|
|
426
|
+
bodyContainer.setAttribute('style', nextStyle);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (!bodyContainer) {
|
|
431
|
+
el.lastItemsFingerprint = itemsFingerprint;
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Fast path: item layout identical to last render — patch each existing
|
|
436
|
+
// cell / group row in place. This preserves focus inside any mounted
|
|
437
|
+
// <ui-grid-cell-editor>, and avoids parsing a fresh HTML string for every
|
|
438
|
+
// keystroke / unrelated snapshot (extreme perf path).
|
|
439
|
+
if (el.lastItemsFingerprint === itemsFingerprint) {
|
|
440
|
+
patchExistingRows(el, bodyContainer, snapshot, itemsToRender, startIndex);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Slow path: row set changed (paging, sort, group toggle, tree expand,
|
|
445
|
+
// virtualization window scrolled). Swap innerHTML so all fragments rebuild.
|
|
446
|
+
const bodyContent = itemsToRender
|
|
447
|
+
.map((item, index) => renderDisplayItem(el, item, startIndex + index))
|
|
448
|
+
.join('');
|
|
449
|
+
if (bodyContainer.innerHTML !== bodyContent) {
|
|
450
|
+
bodyContainer.innerHTML = bodyContent;
|
|
451
|
+
}
|
|
452
|
+
el.lastItemsFingerprint = itemsFingerprint;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Lean per-item fingerprint: `kind:id` joined by `|`. If any row/group is
|
|
457
|
+
* added/removed/reordered between renders, the fingerprint shifts and the
|
|
458
|
+
* body-region patch falls back to the innerHTML path.
|
|
459
|
+
*/
|
|
460
|
+
export function fingerprintItems(items: readonly DisplayItem[]): string {
|
|
461
|
+
const parts: string[] = [];
|
|
462
|
+
for (const item of items) {
|
|
463
|
+
if (item.kind === 'group') {
|
|
464
|
+
parts.push(`g:${(item as GroupItem).id}`);
|
|
465
|
+
} else if (item.kind === 'expandable') {
|
|
466
|
+
parts.push(`e:${(item as DisplayItem & { row: GridRow }).row.id}`);
|
|
467
|
+
} else if (isRowItem(item)) {
|
|
468
|
+
parts.push(`r:${item.row.id}`);
|
|
469
|
+
} else {
|
|
470
|
+
parts.push('?');
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return parts.join('|');
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Walk the rendered body container, match existing DOM nodes to display
|
|
478
|
+
* items by identity, and patch each in place. Called only on the fast
|
|
479
|
+
* path where the fingerprint matches.
|
|
480
|
+
*/
|
|
481
|
+
export function patchExistingRows(
|
|
482
|
+
el: UiGridStandaloneElement,
|
|
483
|
+
bodyContainer: HTMLElement,
|
|
484
|
+
snapshot: GridControllerSnapshot,
|
|
485
|
+
itemsToRender: readonly DisplayItem[],
|
|
486
|
+
startIndex: number,
|
|
487
|
+
): void {
|
|
488
|
+
const controller = el.controller!;
|
|
489
|
+
void controller;
|
|
490
|
+
const columns = snapshot.visibleColumns;
|
|
491
|
+
|
|
492
|
+
// Build O(1) lookup maps from a single pass over bodyContainer.children.
|
|
493
|
+
// This replaces the previous O(rows×cols) querySelector approach.
|
|
494
|
+
const groupEls = new Map<string, HTMLElement>();
|
|
495
|
+
const cellEls = new Map<string, Map<string, HTMLElement>>();
|
|
496
|
+
const expandableEls: HTMLElement[] = [];
|
|
497
|
+
for (let c = 0; c < bodyContainer.children.length; c++) {
|
|
498
|
+
const node = bodyContainer.children[c] as HTMLElement;
|
|
499
|
+
const tag = node.tagName;
|
|
500
|
+
if (tag === 'UI-GRID-GROUP-ROW') {
|
|
501
|
+
const id = node.dataset['group'];
|
|
502
|
+
if (id) groupEls.set(id, node);
|
|
503
|
+
} else if (tag === 'UI-GRID-BODY-CELL') {
|
|
504
|
+
const rowId = node.dataset['row'];
|
|
505
|
+
const colName = node.dataset['column'];
|
|
506
|
+
if (rowId && colName) {
|
|
507
|
+
let rowMap = cellEls.get(rowId);
|
|
508
|
+
if (!rowMap) {
|
|
509
|
+
rowMap = new Map<string, HTMLElement>();
|
|
510
|
+
cellEls.set(rowId, rowMap);
|
|
511
|
+
}
|
|
512
|
+
rowMap.set(colName, node);
|
|
513
|
+
}
|
|
514
|
+
} else if (tag === 'DIV' && node.classList.contains('expandable-row')) {
|
|
515
|
+
expandableEls.push(node);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const templateMarkupMap = new Map<string, string | null>();
|
|
520
|
+
for (const col of columns) {
|
|
521
|
+
templateMarkupMap.set(col.name, getTemplateMarkup(el, `cell-${col.name}`));
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
let expandableIndex = 0;
|
|
525
|
+
for (let i = 0; i < itemsToRender.length; i++) {
|
|
526
|
+
const item = itemsToRender[i]!;
|
|
527
|
+
const displayIndex = startIndex + i;
|
|
528
|
+
|
|
529
|
+
if (item.kind === 'group') {
|
|
530
|
+
const group = asGroupItem(item);
|
|
531
|
+
const target = groupEls.get(group.id);
|
|
532
|
+
if (target) {
|
|
533
|
+
patchGroupRow(el, target, group);
|
|
534
|
+
}
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (item.kind === 'expandable') {
|
|
539
|
+
// Patch expandable rows so data changes are reflected even when the
|
|
540
|
+
// fingerprint (which only tracks row identity) stays the same.
|
|
541
|
+
const target = expandableEls[expandableIndex++];
|
|
542
|
+
if (target) {
|
|
543
|
+
const row = (item as DisplayItem & { row: GridRow }).row;
|
|
544
|
+
const nextHtml = renderExpandableTemplate(el, row);
|
|
545
|
+
if (target.innerHTML !== nextHtml) {
|
|
546
|
+
target.innerHTML = nextHtml;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (!isRowItem(item)) continue;
|
|
553
|
+
|
|
554
|
+
const row = item.row;
|
|
555
|
+
const rowCells = cellEls.get(row.id);
|
|
556
|
+
if (!rowCells) continue;
|
|
557
|
+
for (const column of columns) {
|
|
558
|
+
const target = rowCells.get(column.name);
|
|
559
|
+
if (!target) continue;
|
|
560
|
+
patchBodyCell(el, target, row, column, displayIndex, templateMarkupMap);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Patch a group-row element's data-attrs. */
|
|
566
|
+
export function patchGroupRow(
|
|
567
|
+
el: UiGridStandaloneElement,
|
|
568
|
+
target: HTMLElement,
|
|
569
|
+
group: GroupItem,
|
|
570
|
+
): void {
|
|
571
|
+
const snapshot = el.snapshot!;
|
|
572
|
+
const controller = el.controller!;
|
|
573
|
+
const iconKey = group.collapsed ? 'groupCollapsed' : 'groupExpanded';
|
|
574
|
+
const icon = el.icons.resolve(iconKey);
|
|
575
|
+
setAttr(target, 'data-collapsed', group.collapsed ? 'true' : 'false');
|
|
576
|
+
setAttr(target, 'data-field', group.field);
|
|
577
|
+
setAttr(target, 'data-label', group.label);
|
|
578
|
+
setAttr(target, 'data-count', String(group.count));
|
|
579
|
+
setAttr(target, 'data-depth', String(group.depth));
|
|
580
|
+
setAttr(target, 'data-disclosure-label', controller.groupDisclosureLabel(group));
|
|
581
|
+
setAttr(target, 'data-icon-path', icon.path);
|
|
582
|
+
setAttr(target, 'data-icon-view-box', icon.viewBox ?? '0 0 24 24');
|
|
583
|
+
setAttr(target, 'data-rows-suffix', snapshot.labels.groupRowsSuffix);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Patch a body-cell element in place: class, sticky style, data-attrs,
|
|
587
|
+
* and the cell-shell's interpolated content. */
|
|
588
|
+
export function patchBodyCell(
|
|
589
|
+
el: UiGridStandaloneElement,
|
|
590
|
+
cell: HTMLElement,
|
|
591
|
+
row: GridRow,
|
|
592
|
+
column: GridColumnDef,
|
|
593
|
+
displayIndex: number,
|
|
594
|
+
templateMarkupMap: Map<string, string | null>,
|
|
595
|
+
): void {
|
|
596
|
+
const controller = el.controller!;
|
|
597
|
+
const rowId = row.id;
|
|
598
|
+
const columnName = column.name;
|
|
599
|
+
const editing = controller.isEditingCell(rowId, columnName);
|
|
600
|
+
const isPinned = controller.isPinned(column);
|
|
601
|
+
const pinOffset = isPinned ? controller.pinnedOffset(column) : null;
|
|
602
|
+
const stickyStyle = pinOffset ? `${pinOffset.side}:${pinOffset.offset};` : '';
|
|
603
|
+
const isFocused =
|
|
604
|
+
el.focusedCell?.rowId === rowId && el.focusedCell.columnName === columnName;
|
|
605
|
+
const isPinnedLeftLast = controller.isPinnedLeftLast(column);
|
|
606
|
+
const isPinnedRightFirst = controller.isPinnedRightFirst(column);
|
|
607
|
+
const isOdd = displayIndex % 2 !== 0;
|
|
608
|
+
const align = column.align ?? '';
|
|
609
|
+
|
|
610
|
+
const isRowSelected = el.snapshot?.selectedRowIds.has(rowId) ?? false;
|
|
611
|
+
const isRowFocused = el.snapshot?.focusedRowId === rowId;
|
|
612
|
+
// Visual state is written directly (className / style) since the
|
|
613
|
+
// <ui-grid-body-cell> custom element no longer translates data-* into
|
|
614
|
+
// visual state. data-* attrs still drive event delegation and CSS hooks.
|
|
615
|
+
const isCellInvalid = el.controller?.gridApi.validate.isInvalid(row.entity, column) ?? false;
|
|
616
|
+
setClass(
|
|
617
|
+
cell,
|
|
618
|
+
bodyCellClass(
|
|
619
|
+
isOdd,
|
|
620
|
+
align,
|
|
621
|
+
isPinned,
|
|
622
|
+
isPinnedLeftLast,
|
|
623
|
+
isPinnedRightFirst,
|
|
624
|
+
isFocused,
|
|
625
|
+
editing,
|
|
626
|
+
isRowSelected,
|
|
627
|
+
isRowFocused,
|
|
628
|
+
row.isDirty,
|
|
629
|
+
row.isSaving,
|
|
630
|
+
row.isError,
|
|
631
|
+
isCellInvalid,
|
|
632
|
+
),
|
|
633
|
+
);
|
|
634
|
+
setStyle(cell, stickyStyle);
|
|
635
|
+
setAttr(cell, 'data-odd', String(isOdd));
|
|
636
|
+
setAttr(cell, 'data-align', align);
|
|
637
|
+
setAttr(cell, 'data-pinned', String(isPinned));
|
|
638
|
+
setAttr(cell, 'data-pinned-left-last', String(isPinnedLeftLast));
|
|
639
|
+
setAttr(cell, 'data-pinned-right-first', String(isPinnedRightFirst));
|
|
640
|
+
setAttr(cell, 'data-focused', String(isFocused));
|
|
641
|
+
setAttr(cell, 'data-editing', String(editing));
|
|
642
|
+
setAttr(cell, 'data-sticky-style', stickyStyle);
|
|
643
|
+
|
|
644
|
+
const cellShell = cell.querySelector<HTMLElement>(':scope > .cell-shell');
|
|
645
|
+
if (!cellShell) return;
|
|
646
|
+
|
|
647
|
+
const indentStyle = `padding-inline-start:${controller.cellIndent(row, column)}`;
|
|
648
|
+
if (cellShell.getAttribute('style') !== indentStyle) {
|
|
649
|
+
cellShell.setAttribute('style', indentStyle);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (editing) {
|
|
653
|
+
// Preserve the mounted <ui-grid-cell-editor> if present — just patch its
|
|
654
|
+
// data-value. This is the path that preserves input focus + caret
|
|
655
|
+
// across every keystroke's snapshot rebroadcast.
|
|
656
|
+
const editor = cellShell.querySelector<HTMLElement>('ui-grid-cell-editor');
|
|
657
|
+
const cellContent = cellShell.querySelector<HTMLElement>(':scope > .cell-content');
|
|
658
|
+
if (editor && cellContent) {
|
|
659
|
+
setAttr(editor, 'data-row', rowId);
|
|
660
|
+
setAttr(editor, 'data-column', columnName);
|
|
661
|
+
setAttr(editor, 'data-type', controller.editorInputType(column));
|
|
662
|
+
setAttr(editor, 'data-value', el.snapshot?.editingValue ?? '');
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
// Transitioned from non-editing → editing: mount editor once by
|
|
666
|
+
// rebuilding the cell-shell contents (toggles + cell-content + editor).
|
|
667
|
+
const editingShellHtml = renderCellShellContents(el, row, column, displayIndex, true);
|
|
668
|
+
if (cellShell.innerHTML !== editingShellHtml) {
|
|
669
|
+
cellShell.innerHTML = editingShellHtml;
|
|
670
|
+
}
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Not editing: rebuild the cell-shell's inner contents. This covers toggle
|
|
675
|
+
// buttons (tree/expandable) which can appear/disappear as expandable slots
|
|
676
|
+
// or tree rows come and go. The guard below skips the DOM write when the
|
|
677
|
+
// rendered string matches the current DOM — steady-state typing / paging /
|
|
678
|
+
// data refresh inside the same row layout is a no-op.
|
|
679
|
+
const nextInner = renderCellShellContents(el, row, column, displayIndex, false, templateMarkupMap);
|
|
680
|
+
if (cellShell.innerHTML !== nextInner) {
|
|
681
|
+
cellShell.innerHTML = nextInner;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** Patch the pagination component's data-attrs without re-mounting it. */
|
|
686
|
+
export function patchPagination(
|
|
687
|
+
el: UiGridStandaloneElement,
|
|
688
|
+
paginationEl: HTMLElement,
|
|
689
|
+
snapshot: GridControllerSnapshot,
|
|
690
|
+
): void {
|
|
691
|
+
const pageSizes = snapshot.options.paginationPageSizes ?? [10, 25, 50, 100];
|
|
692
|
+
const prevIcon = el.icons.resolve('paginationPrev');
|
|
693
|
+
const nextIcon = el.icons.resolve('paginationNext');
|
|
694
|
+
setAttr(
|
|
695
|
+
paginationEl,
|
|
696
|
+
'data-range-label',
|
|
697
|
+
`${snapshot.firstRowIndex + 1}-${snapshot.lastRowIndex + 1} of ${snapshot.pipeline.totalItems}`,
|
|
698
|
+
);
|
|
699
|
+
setAttr(paginationEl, 'data-current-page', String(snapshot.currentPage));
|
|
700
|
+
setAttr(paginationEl, 'data-total-pages', String(snapshot.totalPages));
|
|
701
|
+
setAttr(paginationEl, 'data-page-label', snapshot.labels.paginationPage);
|
|
702
|
+
setAttr(paginationEl, 'data-of-label', snapshot.labels.paginationOf);
|
|
703
|
+
setAttr(paginationEl, 'data-prev-label', snapshot.labels.paginationPrevious);
|
|
704
|
+
setAttr(paginationEl, 'data-next-label', snapshot.labels.paginationNext);
|
|
705
|
+
setAttr(paginationEl, 'data-rows-label', snapshot.labels.paginationRows);
|
|
706
|
+
setAttr(paginationEl, 'data-prev-icon-path', prevIcon.path);
|
|
707
|
+
setAttr(paginationEl, 'data-prev-icon-view-box', prevIcon.viewBox ?? '0 0 24 24');
|
|
708
|
+
setAttr(paginationEl, 'data-next-icon-path', nextIcon.path);
|
|
709
|
+
setAttr(paginationEl, 'data-next-icon-view-box', nextIcon.viewBox ?? '0 0 24 24');
|
|
710
|
+
setAttr(paginationEl, 'data-page-sizes', JSON.stringify(pageSizes));
|
|
711
|
+
setAttr(paginationEl, 'data-page-size', String(snapshot.pageSize));
|
|
712
|
+
setAttr(paginationEl, 'data-prev-disabled', String(snapshot.currentPage <= 1));
|
|
713
|
+
setAttr(paginationEl, 'data-next-disabled', String(snapshot.currentPage >= snapshot.totalPages));
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** Read a consumer-provided `<template slot="…">` off the element. Duplicated
|
|
717
|
+
* with `render.getTemplateMarkup` because the patch path needs it without
|
|
718
|
+
* pulling in the whole render module's circular dependency. */
|
|
719
|
+
function getTemplateMarkup(el: UiGridStandaloneElement, slotName: string): string | null {
|
|
720
|
+
const template = el.querySelector<HTMLTemplateElement>(`template[slot="${slotName}"]`);
|
|
721
|
+
return template?.innerHTML ?? null;
|
|
722
|
+
}
|