@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.
Files changed (63) hide show
  1. package/README.md +196 -0
  2. package/demo/index.html +3 -3
  3. package/demo/main.ts +1 -1
  4. package/dist/index.d.mts +584 -55
  5. package/dist/index.d.ts +584 -55
  6. package/dist/index.js +4887 -1404
  7. package/dist/index.mjs +5056 -1526
  8. package/package.json +11 -8
  9. package/scss-plugin.ts +65 -0
  10. package/src/adopt-styles.ts +39 -0
  11. package/src/attribute-bridge.ts +291 -0
  12. package/src/auto-height.test.ts +142 -0
  13. package/src/auto-resize.test.ts +206 -0
  14. package/src/cell-nav.test.ts +262 -0
  15. package/src/components/grid-body-cell/grid-body-cell.ts +23 -0
  16. package/src/components/grid-body-cell/index.ts +1 -0
  17. package/src/components/grid-cell-editor/grid-cell-editor.ts +104 -0
  18. package/src/components/grid-cell-editor/index.ts +1 -0
  19. package/src/components/grid-filter-cell/grid-filter-cell.scss +14 -0
  20. package/src/components/grid-filter-cell/grid-filter-cell.template.ts +20 -0
  21. package/src/components/grid-filter-cell/grid-filter-cell.ts +121 -0
  22. package/src/components/grid-filter-cell/index.ts +1 -0
  23. package/src/components/grid-group-row/grid-group-row.scss +24 -0
  24. package/src/components/grid-group-row/grid-group-row.template.ts +23 -0
  25. package/src/components/grid-group-row/grid-group-row.ts +94 -0
  26. package/src/components/grid-group-row/index.ts +1 -0
  27. package/src/components/grid-header-cell/grid-header-cell.ts +17 -0
  28. package/src/components/grid-header-cell/index.ts +1 -0
  29. package/src/components/grid-pagination/grid-pagination.scss +39 -0
  30. package/src/components/grid-pagination/grid-pagination.template.ts +40 -0
  31. package/src/components/grid-pagination/grid-pagination.ts +204 -0
  32. package/src/components/grid-pagination/index.ts +1 -0
  33. package/src/components/grid-template/grid-template.ts +166 -0
  34. package/src/components/grid-template/index.ts +1 -0
  35. package/src/events.ts +815 -0
  36. package/src/exporter.test.ts +186 -0
  37. package/src/focus.ts +353 -0
  38. package/src/framework-slots.ts +440 -0
  39. package/src/grid-controller.ts +1431 -20
  40. package/src/icons.ts +88 -0
  41. package/src/importer.test.ts +147 -0
  42. package/src/index.integration.test.ts +2 -2
  43. package/src/index.ts +9 -0
  44. package/src/infinite-scroll.test.ts +220 -0
  45. package/src/keyboard-navigation.test.ts +579 -0
  46. package/src/patch.ts +722 -0
  47. package/src/render.ts +465 -0
  48. package/src/row-edit.test.ts +191 -0
  49. package/src/row-selection.test.ts +550 -0
  50. package/src/save-state.test.ts +235 -0
  51. package/src/scss.d.ts +4 -0
  52. package/src/templates/index.ts +62 -0
  53. package/src/test-setup.ts +23 -0
  54. package/src/ui-grid-empty.template.ts +23 -0
  55. package/src/ui-grid-shell.scss +25 -0
  56. package/src/ui-grid-shell.template.ts +45 -0
  57. package/src/ui-grid-standalone.element.ts +751 -1032
  58. package/src/utils/cell-class.ts +70 -0
  59. package/src/utils/display-items.ts +15 -0
  60. package/src/utils/dom.ts +64 -0
  61. package/src/validate.test.ts +170 -0
  62. package/tsup.config.ts +10 -0
  63. 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
+ }