@ornery/ui-grid-vanilla 0.1.7-hotfix

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.
@@ -0,0 +1,972 @@
1
+ import {
2
+ GRID_CORE_CSS,
3
+ SORT_DIRECTIONS,
4
+ canGridMoveColumns,
5
+ downloadGridCsvFile,
6
+ exportCsvRows,
7
+ getCellValue,
8
+ sanitizeDownloadFilename,
9
+ type DisplayItem,
10
+ type GridColumnDef,
11
+ type GridOptions,
12
+ type GridRecord,
13
+ type GroupItem,
14
+ type GridRow,
15
+ } from '@ornery/ui-grid-core';
16
+ import {
17
+ createVanillaGridController,
18
+ type GridControllerSnapshot,
19
+ type GridSaveState,
20
+ type VanillaGridController,
21
+ } from './grid-controller';
22
+
23
+ function escapeHtml(value: unknown): string {
24
+ const text = String(value ?? '');
25
+ return text
26
+ .replace(/&/g, '&')
27
+ .replace(/</g, '&lt;')
28
+ .replace(/>/g, '&gt;')
29
+ .replace(/"/g, '&quot;')
30
+ .replace(/'/g, '&#39;');
31
+ }
32
+
33
+ function asGroupItem(item: DisplayItem): GroupItem {
34
+ return item as GroupItem;
35
+ }
36
+
37
+ function isRowItem(item: DisplayItem): item is DisplayItem & { kind: 'row'; row: GridRow } {
38
+ return item.kind === 'row';
39
+ }
40
+
41
+ export type VanillaUiGridElement = HTMLElement & {
42
+ options: GridOptions;
43
+ getState(): GridSaveState | null;
44
+ setState(state: Partial<GridSaveState>): void;
45
+ };
46
+
47
+ export type UiGridControlIconKey =
48
+ | 'sortNone'
49
+ | 'sortAsc'
50
+ | 'sortDesc'
51
+ | 'group'
52
+ | 'groupExpanded'
53
+ | 'groupCollapsed'
54
+ | 'treeExpanded'
55
+ | 'treeCollapsed'
56
+ | 'expandExpanded'
57
+ | 'expandCollapsed'
58
+ | 'pin'
59
+ | 'pinLeft'
60
+ | 'pinRight'
61
+ | 'paginationPrev'
62
+ | 'paginationNext';
63
+
64
+ export interface UiGridIconDefinition {
65
+ viewBox?: string;
66
+ path: string;
67
+ }
68
+
69
+ export type UiGridIconOverrides = Partial<Record<UiGridControlIconKey, UiGridIconDefinition>>;
70
+
71
+ type VanillaGridOptions = GridOptions & {
72
+ iconOverrides?: UiGridIconOverrides;
73
+ };
74
+
75
+ const DEFAULT_ICONS: Record<UiGridControlIconKey, UiGridIconDefinition> = {
76
+ sortNone: { path: 'M7 6h10v2H7V6Zm0 5h7v2H7v-2Zm0 5h4v2H7v-2Z' },
77
+ sortAsc: { path: 'M12 5l-6 6h4v8h4v-8h4z' },
78
+ sortDesc: { path: 'M12 19l6-6h-4V5h-4v8H6z' },
79
+ group: { path: 'M4 6h8v4H4V6Zm0 8h8v4H4v-4Zm10-8h6v4h-6V6Zm0 8h6v4h-6v-4Z' },
80
+ groupExpanded: { path: 'M7 10l5 5 5-5z' },
81
+ groupCollapsed: { path: 'M10 7l5 5-5 5z' },
82
+ treeExpanded: { path: 'M7 10l5 5 5-5z' },
83
+ treeCollapsed: { path: 'M10 7l5 5-5 5z' },
84
+ expandExpanded: { path: 'M7 10l5 5 5-5z' },
85
+ expandCollapsed: { path: 'M10 7l5 5-5 5z' },
86
+ pin: { path: 'M16 12V4h1V2H7v2h1v8l-2 2v2h5v6l1 1 1-1v-6h5v-2l-2-2z' },
87
+ pinLeft: { path: 'M10 6 4 12l6 6v-4h10v-4H10V6z' },
88
+ pinRight: { path: 'M14 6v4H4v4h10v4l6-6-6-6z' },
89
+ paginationPrev: { path: 'M15.41 7.41 14 6l-6 6 6 6 1.41-1.41L10.83 12z' },
90
+ paginationNext: { path: 'M8.59 16.59 10 18l6-6-6-6-1.41 1.41L13.17 12z' },
91
+ };
92
+
93
+ export class UiGridStandaloneElement extends HTMLElement {
94
+ private controller: VanillaGridController | null = null;
95
+ private snapshot: GridControllerSnapshot | null = null;
96
+ private unsubscribe: (() => void) | null = null;
97
+ private activeOptions: VanillaGridOptions | null = null;
98
+ private iconOverrides: UiGridIconOverrides = {};
99
+ private templateObserver: MutationObserver | null = null;
100
+ private openPinMenuColumn: string | null = null;
101
+ private focusedCell: { rowId: string; columnName: string } | null = null;
102
+ private draggedColumnName: string | null = null;
103
+ private dropTargetColumnName: string | null = null;
104
+ private scrollPosition = 0;
105
+ private scrollFrame: number | null = null;
106
+ private suppressScrollEvent = false;
107
+ private lastVirtualStartIndex = -1;
108
+ private measuredHeaderStickyHeight = 0;
109
+ private measuredFilterStickyHeight = 0;
110
+ private stickyHeightRelayoutQueued = false;
111
+ private benchmarkAverage = '—';
112
+
113
+ get options(): GridOptions {
114
+ return (
115
+ this.activeOptions ?? {
116
+ id: '__ui-grid-pending__',
117
+ data: [],
118
+ columnDefs: [],
119
+ }
120
+ );
121
+ }
122
+
123
+ set options(value: GridOptions) {
124
+ this.activeOptions = value as VanillaGridOptions;
125
+ this.iconOverrides = this.activeOptions.iconOverrides ?? {};
126
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions));
127
+ }
128
+
129
+ get controlIcons(): UiGridIconOverrides {
130
+ return this.iconOverrides;
131
+ }
132
+
133
+ set controlIcons(value: UiGridIconOverrides) {
134
+ this.iconOverrides = { ...value };
135
+ this.render();
136
+ }
137
+
138
+ getState(): GridSaveState | null {
139
+ return this.controller?.getState() ?? null;
140
+ }
141
+
142
+ setState(state: Partial<GridSaveState>): void {
143
+ this.controller?.setState(state);
144
+ }
145
+
146
+ connectedCallback(): void {
147
+ this.ensureShadowRoot();
148
+ this.bindEvents();
149
+ this.observeTemplateSlots();
150
+
151
+ if (this.activeOptions) {
152
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions));
153
+ } else {
154
+ this.render();
155
+ }
156
+ }
157
+
158
+ disconnectedCallback(): void {
159
+ this.unsubscribe?.();
160
+ this.unsubscribe = null;
161
+ this.templateObserver?.disconnect();
162
+ this.templateObserver = null;
163
+ if (this.scrollFrame !== null) {
164
+ cancelAnimationFrame(this.scrollFrame);
165
+ this.scrollFrame = null;
166
+ }
167
+ }
168
+
169
+ private ensureShadowRoot(): ShadowRoot {
170
+ if (!this.shadowRoot) {
171
+ this.attachShadow({ mode: 'open' });
172
+ }
173
+
174
+ return this.shadowRoot as ShadowRoot;
175
+ }
176
+
177
+ private ensureController(options: VanillaGridOptions): void {
178
+ if (this.controller) {
179
+ this.controller.setOptions(options);
180
+ return;
181
+ }
182
+
183
+ this.controller = createVanillaGridController(options);
184
+ this.unsubscribe = this.controller.subscribe((snapshot) => {
185
+ this.snapshot = snapshot;
186
+ this.render();
187
+ });
188
+ }
189
+
190
+ private observeTemplateSlots(): void {
191
+ if (this.templateObserver) {
192
+ return;
193
+ }
194
+
195
+ this.templateObserver = new MutationObserver(() => {
196
+ if (this.activeOptions) {
197
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions));
198
+ } else {
199
+ this.render();
200
+ }
201
+ });
202
+
203
+ this.templateObserver.observe(this, {
204
+ childList: true,
205
+ subtree: true,
206
+ attributes: true,
207
+ attributeFilter: ['slot'],
208
+ });
209
+ }
210
+
211
+ private buildEffectiveOptions(options: VanillaGridOptions): VanillaGridOptions {
212
+ const hasExpandableSlot = this.getTemplateMarkup('expandable-row') !== null;
213
+ if (options.enableExpandable && hasExpandableSlot && !options.expandableRowTemplate) {
214
+ return {
215
+ ...options,
216
+ expandableRowTemplate: {
217
+ createEmbeddedView: () => undefined,
218
+ },
219
+ };
220
+ }
221
+
222
+ return options;
223
+ }
224
+
225
+ private bindEvents(): void {
226
+ const root = this.ensureShadowRoot();
227
+ if ((root as ShadowRoot & { __uiGridBound?: boolean }).__uiGridBound) {
228
+ return;
229
+ }
230
+
231
+ root.addEventListener('click', (event) => {
232
+ const target = event.target as HTMLElement | null;
233
+ if (!target || !this.controller || !this.snapshot) {
234
+ return;
235
+ }
236
+
237
+ const actionNode = target.closest<HTMLElement>('[data-action]');
238
+ if (!actionNode) {
239
+ return;
240
+ }
241
+
242
+ const action = actionNode.dataset['action'];
243
+ if (!action) {
244
+ return;
245
+ }
246
+
247
+ if (this.openPinMenuColumn && !target.closest('.pin-control')) {
248
+ this.openPinMenuColumn = null;
249
+ this.render();
250
+ return;
251
+ }
252
+
253
+ if (action === 'sort') {
254
+ const columnName = actionNode.dataset['column'];
255
+ if (columnName) {
256
+ this.controller.toggleSort(columnName);
257
+ }
258
+ return;
259
+ }
260
+
261
+ if (action === 'group') {
262
+ const columnName = actionNode.dataset['column'];
263
+ if (columnName) {
264
+ this.controller.toggleGrouping(columnName);
265
+ }
266
+ return;
267
+ }
268
+
269
+ if (action === 'toggle-group') {
270
+ const groupId = actionNode.dataset['group'];
271
+ if (groupId) {
272
+ const collapsed = actionNode.dataset['collapsed'] !== 'true';
273
+ this.controller.setCollapsedGroup(groupId, collapsed);
274
+ }
275
+ return;
276
+ }
277
+
278
+ if (action === 'pin-trigger') {
279
+ const columnName = actionNode.dataset['column'];
280
+ const column = columnName
281
+ ? this.snapshot.visibleColumns.find((candidate) => candidate.name === columnName)
282
+ : undefined;
283
+
284
+ if (columnName && column) {
285
+ if (this.controller.isPinned(column)) {
286
+ this.openPinMenuColumn = null;
287
+ this.controller.pinColumn(columnName, 'none');
288
+ return;
289
+ }
290
+
291
+ this.openPinMenuColumn = this.openPinMenuColumn === columnName ? null : columnName;
292
+ this.render();
293
+ }
294
+ return;
295
+ }
296
+
297
+ if (action === 'pin-left' || action === 'pin-right') {
298
+ const columnName = actionNode.dataset['column'];
299
+ if (columnName) {
300
+ this.openPinMenuColumn = null;
301
+ this.controller.pinColumn(columnName, action === 'pin-left' ? 'left' : 'right');
302
+ }
303
+ return;
304
+ }
305
+
306
+ if (action === 'toggle-tree') {
307
+ const rowId = actionNode.dataset['row'];
308
+ if (rowId) {
309
+ this.controller.toggleTreeRow(rowId);
310
+ }
311
+ return;
312
+ }
313
+
314
+ if (action === 'toggle-expand') {
315
+ const rowId = actionNode.dataset['row'];
316
+ if (rowId) {
317
+ this.controller.toggleRowExpansion(rowId);
318
+ }
319
+ return;
320
+ }
321
+
322
+ if (action === 'page-prev') {
323
+ this.controller.seekPage(this.snapshot.currentPage - 1);
324
+ return;
325
+ }
326
+
327
+ if (action === 'page-next') {
328
+ this.controller.seekPage(this.snapshot.currentPage + 1);
329
+ return;
330
+ }
331
+
332
+ if (action === 'benchmark') {
333
+ const result = this.controller.gridApi.core.benchmark();
334
+ this.benchmarkAverage = result.averageMs.toFixed(2);
335
+ this.render();
336
+ return;
337
+ }
338
+
339
+ if (action === 'export-csv') {
340
+ this.downloadCsv();
341
+ }
342
+ });
343
+
344
+ root.addEventListener('focusin', (event) => {
345
+ const target = event.target as HTMLElement | null;
346
+ if (!target) {
347
+ return;
348
+ }
349
+
350
+ const cell = target.closest<HTMLElement>('.body-cell[data-row][data-column]');
351
+ const rowId = cell?.dataset['row'];
352
+ const columnName = cell?.dataset['column'];
353
+ if (rowId && columnName) {
354
+ this.focusedCell = { rowId, columnName };
355
+ }
356
+ });
357
+
358
+ root.addEventListener('focusout', (event) => {
359
+ const relatedTarget = (event as FocusEvent).relatedTarget as HTMLElement | null;
360
+ if (!relatedTarget?.closest('.body-cell[data-row][data-column]')) {
361
+ this.focusedCell = null;
362
+ }
363
+ });
364
+
365
+ root.addEventListener('input', (event) => {
366
+ const target = event.target as HTMLElement | null;
367
+ if (!target || !this.controller) {
368
+ return;
369
+ }
370
+
371
+ if (target instanceof HTMLInputElement && target.dataset['role'] === 'filter') {
372
+ const columnName = target.dataset['column'];
373
+ if (columnName) {
374
+ this.controller.setFilter(columnName, target.value);
375
+ }
376
+ }
377
+
378
+ if (target instanceof HTMLInputElement && target.dataset['role'] === 'editor') {
379
+ this.controller.updateEditingValue(target.value);
380
+ }
381
+ });
382
+
383
+ root.addEventListener('change', (event) => {
384
+ const target = event.target as HTMLElement | null;
385
+ if (!target || !this.controller) {
386
+ return;
387
+ }
388
+
389
+ if (target instanceof HTMLSelectElement && target.dataset['role'] === 'page-size') {
390
+ const value = Number.parseInt(target.value, 10);
391
+ this.controller.setPageSize(value);
392
+ }
393
+ });
394
+
395
+ root.addEventListener('dblclick', (event) => {
396
+ const target = event.target as HTMLElement | null;
397
+ if (!target || !this.controller) {
398
+ return;
399
+ }
400
+
401
+ const cell = target.closest<HTMLElement>('.body-cell');
402
+ if (!cell) {
403
+ return;
404
+ }
405
+
406
+ const rowId = cell.dataset['row'];
407
+ const columnName = cell.dataset['column'];
408
+ if (rowId && columnName) {
409
+ this.controller.beginCellEdit(rowId, columnName, event);
410
+ }
411
+ });
412
+
413
+ root.addEventListener('keydown', (event) => {
414
+ const keyboardEvent = event as KeyboardEvent;
415
+ const target = event.target as HTMLElement | null;
416
+ if (!target || !this.controller) {
417
+ return;
418
+ }
419
+
420
+ if (target instanceof HTMLInputElement && target.dataset['role'] === 'editor') {
421
+ if (keyboardEvent.key === 'Enter') {
422
+ event.preventDefault();
423
+ this.controller.commitCellEdit();
424
+ return;
425
+ }
426
+
427
+ if (keyboardEvent.key === 'Escape') {
428
+ event.preventDefault();
429
+ this.controller.cancelCellEdit();
430
+ }
431
+
432
+ return;
433
+ }
434
+
435
+ const cell = target.closest<HTMLElement>('.body-cell');
436
+ if (!cell) {
437
+ return;
438
+ }
439
+
440
+ const rowId = cell.dataset['row'];
441
+ const columnName = cell.dataset['column'];
442
+ if (!rowId || !columnName) {
443
+ return;
444
+ }
445
+
446
+ if (keyboardEvent.key === 'Enter' || keyboardEvent.key === 'F2') {
447
+ event.preventDefault();
448
+ this.controller.beginCellEdit(rowId, columnName, event);
449
+ return;
450
+ }
451
+
452
+ if (
453
+ keyboardEvent.key.length === 1 &&
454
+ !keyboardEvent.ctrlKey &&
455
+ !keyboardEvent.metaKey &&
456
+ !keyboardEvent.altKey
457
+ ) {
458
+ event.preventDefault();
459
+ this.controller.beginCellEdit(rowId, columnName, event);
460
+ this.controller.updateEditingValue(keyboardEvent.key);
461
+ }
462
+ });
463
+
464
+ root.addEventListener(
465
+ 'blur',
466
+ (event) => {
467
+ const target = event.target as HTMLElement | null;
468
+ if (!target || !this.controller) {
469
+ return;
470
+ }
471
+
472
+ if (target instanceof HTMLInputElement && target.dataset['role'] === 'editor') {
473
+ this.controller.commitCellEdit();
474
+ }
475
+ },
476
+ true,
477
+ );
478
+
479
+ root.addEventListener('dragstart', (event) => {
480
+ const dragEvent = event as DragEvent;
481
+ const target = event.target as HTMLElement | null;
482
+ const headerCell = target?.closest<HTMLElement>('.header-cell[data-column]');
483
+
484
+ if (!headerCell || !this.snapshot || !canGridMoveColumns(this.snapshot.options)) {
485
+ event.preventDefault();
486
+ return;
487
+ }
488
+
489
+ const columnName = headerCell.dataset['column'];
490
+ if (!columnName || !dragEvent.dataTransfer) {
491
+ event.preventDefault();
492
+ return;
493
+ }
494
+
495
+ this.draggedColumnName = columnName;
496
+ this.dropTargetColumnName = null;
497
+ dragEvent.dataTransfer.effectAllowed = 'move';
498
+ dragEvent.dataTransfer.setData('text/plain', columnName);
499
+ headerCell.classList.add('is-dragging');
500
+ });
501
+
502
+ root.addEventListener('dragover', (event) => {
503
+ const dragEvent = event as DragEvent;
504
+ const target = event.target as HTMLElement | null;
505
+ const headerCell = target?.closest<HTMLElement>('.header-cell[data-column]');
506
+
507
+ if (!headerCell || !this.draggedColumnName || !dragEvent.dataTransfer) {
508
+ return;
509
+ }
510
+
511
+ const columnName = headerCell.dataset['column'];
512
+ if (!columnName || columnName === this.draggedColumnName) {
513
+ return;
514
+ }
515
+
516
+ event.preventDefault();
517
+ dragEvent.dataTransfer.dropEffect = 'move';
518
+
519
+ if (this.dropTargetColumnName !== columnName) {
520
+ root
521
+ .querySelectorAll('.header-cell.is-drag-target')
522
+ .forEach((element) => element.classList.remove('is-drag-target'));
523
+ this.dropTargetColumnName = columnName;
524
+ headerCell.classList.add('is-drag-target');
525
+ }
526
+ });
527
+
528
+ root.addEventListener('dragleave', (event) => {
529
+ const target = event.target as HTMLElement | null;
530
+ const headerCell = target?.closest<HTMLElement>('.header-cell[data-column]');
531
+ if (headerCell && headerCell.dataset['column'] === this.dropTargetColumnName) {
532
+ headerCell.classList.remove('is-drag-target');
533
+ }
534
+ });
535
+
536
+ root.addEventListener('drop', (event) => {
537
+ event.preventDefault();
538
+ const dragEvent = event as DragEvent;
539
+ const target = event.target as HTMLElement | null;
540
+ const headerCell = target?.closest<HTMLElement>('.header-cell[data-column]');
541
+ const targetColumn = headerCell?.dataset['column'];
542
+ const sourceColumn =
543
+ this.draggedColumnName ?? dragEvent.dataTransfer?.getData('text/plain') ?? null;
544
+
545
+ this.draggedColumnName = null;
546
+ this.dropTargetColumnName = null;
547
+
548
+ root
549
+ .querySelectorAll('.header-cell.is-dragging, .header-cell.is-drag-target')
550
+ .forEach((element) => element.classList.remove('is-dragging', 'is-drag-target'));
551
+
552
+ if (!sourceColumn || !targetColumn || sourceColumn === targetColumn || !this.controller) {
553
+ return;
554
+ }
555
+
556
+ this.controller.moveVisibleColumn(sourceColumn, targetColumn);
557
+ });
558
+
559
+ root.addEventListener('dragend', () => {
560
+ this.draggedColumnName = null;
561
+ this.dropTargetColumnName = null;
562
+ root
563
+ .querySelectorAll('.header-cell.is-dragging, .header-cell.is-drag-target')
564
+ .forEach((element) => element.classList.remove('is-dragging', 'is-drag-target'));
565
+ });
566
+
567
+ root.addEventListener(
568
+ 'scroll',
569
+ (event) => {
570
+ const target = event.target as HTMLElement | null;
571
+ if (!target?.classList.contains('grid-table') || this.suppressScrollEvent) {
572
+ return;
573
+ }
574
+
575
+ this.scrollPosition = target.scrollTop;
576
+ if (!this.snapshot?.pipeline.virtualizationEnabled) {
577
+ return;
578
+ }
579
+
580
+ const stickyChromeHeight =
581
+ this.measuredHeaderStickyHeight + this.measuredFilterStickyHeight;
582
+ const bodyScrollTop = Math.max(0, this.scrollPosition - stickyChromeHeight);
583
+ const overscan = 4;
584
+ const nextStartIndex = Math.max(
585
+ 0,
586
+ Math.floor(bodyScrollTop / this.snapshot.rowSize) - overscan,
587
+ );
588
+ if (nextStartIndex === this.lastVirtualStartIndex) {
589
+ return;
590
+ }
591
+
592
+ if (this.scrollFrame !== null) {
593
+ cancelAnimationFrame(this.scrollFrame);
594
+ }
595
+
596
+ this.scrollFrame = requestAnimationFrame(() => {
597
+ this.scrollFrame = null;
598
+ this.render();
599
+ });
600
+ },
601
+ true,
602
+ );
603
+
604
+ (root as ShadowRoot & { __uiGridBound?: boolean }).__uiGridBound = true;
605
+ }
606
+
607
+ private render(): void {
608
+ const root = this.ensureShadowRoot();
609
+ const snapshot = this.snapshot;
610
+ const previousGridTable = root.querySelector<HTMLElement>('.grid-table');
611
+ if (previousGridTable && !this.suppressScrollEvent) {
612
+ this.scrollPosition = previousGridTable.scrollTop;
613
+ }
614
+
615
+ if (!snapshot) {
616
+ root.innerHTML = `<style>${GRID_CORE_CSS}</style><section class="grid-shell ui-grid-shell"><div class="empty-state ui-grid-no-row-overlay"><strong>No grid options provided.</strong></div></section>`;
617
+ return;
618
+ }
619
+
620
+ const controller = this.controller!;
621
+ const options = snapshot.options;
622
+ const labels = snapshot.labels;
623
+ const templateColumns = snapshot.gridTemplateColumns;
624
+ const sortEnabled = controller.isSortingEnabled();
625
+ const filterEnabled = controller.isFilteringEnabled();
626
+ const groupingEnabled = controller.isGroupingEnabled();
627
+ const pinningEnabled = controller.isPinningEnabled();
628
+ const paginationEnabled = controller.isPaginationEnabled();
629
+ const showPagination = controller.shouldShowPaginationControls();
630
+ const virtualizationEnabled = snapshot.pipeline.virtualizationEnabled;
631
+ const viewportHeight = options.viewportHeight ?? 560;
632
+ const headerStickyTop = this.measuredHeaderStickyHeight || options.headerRowHeight || 50;
633
+ const stickyChromeHeight = this.measuredHeaderStickyHeight + this.measuredFilterStickyHeight;
634
+ const bodyViewportHeight = Math.max(snapshot.rowSize, viewportHeight - stickyChromeHeight);
635
+ const visibleRowCount = snapshot.pipeline.visibleRows.length;
636
+ const totalRows = options.data.length;
637
+ const pinnedCount = Object.keys(snapshot.pinnedColumns).length;
638
+
639
+ let startIndex = 0;
640
+ let itemsToRender = snapshot.pipeline.displayItems;
641
+ let virtualOffset = 0;
642
+ const totalVirtualHeight = snapshot.pipeline.displayItems.length * snapshot.rowSize;
643
+
644
+ if (virtualizationEnabled) {
645
+ const bodyScrollTop = Math.max(0, this.scrollPosition - stickyChromeHeight);
646
+ const overscan = 4;
647
+ startIndex = Math.max(0, Math.floor(bodyScrollTop / snapshot.rowSize) - overscan);
648
+ this.lastVirtualStartIndex = startIndex;
649
+ const visibleCount = Math.ceil(bodyViewportHeight / snapshot.rowSize) + overscan * 2;
650
+ itemsToRender = snapshot.pipeline.displayItems.slice(
651
+ startIndex,
652
+ Math.min(snapshot.pipeline.displayItems.length, startIndex + visibleCount),
653
+ );
654
+ virtualOffset = startIndex * snapshot.rowSize;
655
+ } else {
656
+ this.lastVirtualStartIndex = -1;
657
+ }
658
+
659
+ const slotRegistry = this.renderSlotRegistry(snapshot.visibleColumns);
660
+ const header = snapshot.visibleColumns
661
+ .map((column) =>
662
+ this.renderHeaderCell(column, sortEnabled, groupingEnabled, pinningEnabled, options),
663
+ )
664
+ .join('');
665
+
666
+ const filterRow = filterEnabled
667
+ ? `<div class="filter-grid ui-grid-header" style="grid-template-columns:${templateColumns}">${snapshot.visibleColumns
668
+ .map((column) => this.renderFilterCell(column))
669
+ .join('')}</div>`
670
+ : '';
671
+
672
+ const bodyContent = itemsToRender
673
+ .map((item, index) => this.renderDisplayItem(item, startIndex + index))
674
+ .join('');
675
+
676
+ const body =
677
+ snapshot.pipeline.displayItems.length > 0
678
+ ? virtualizationEnabled
679
+ ? `<div class="grid-virtual-spacer" style="height:${totalVirtualHeight}px"><div class="body-grid ui-grid-canvas grid-virtual-body" style="grid-template-columns:${templateColumns};top:${virtualOffset}px">${bodyContent}</div></div>`
680
+ : `<div class="body-grid ui-grid-canvas" style="grid-template-columns:${templateColumns}">${bodyContent}</div>`
681
+ : `<div class="empty-state ui-grid-no-row-overlay"><strong>${escapeHtml(options.emptyMessage ?? labels.emptyHeading)}</strong><p>${escapeHtml(labels.emptyDescription)}</p></div>`;
682
+
683
+ const pagination = paginationEnabled && showPagination ? this.renderPagination(snapshot) : '';
684
+ const hero = `<header class="grid-hero"><div><p class="eyebrow">Vanilla web component</p><h1>${escapeHtml(options.title ?? 'UI Grid')}</h1><p class="deck">Framework-neutral custom element with Shadow DOM styling, slot templates, pinning, virtualization, editing, tree view, grouping, and state restore.</p></div><div class="hero-actions"><button type="button" class="action action-secondary" data-action="benchmark">Benchmark</button><button type="button" class="action action-secondary" data-action="export-csv">Export CSV</button><div class="stats-card"><span>${visibleRowCount}</span><small>${escapeHtml(labels.statsVisibleRows)}</small></div></div></header>`;
685
+ const metrics = `<section class="metrics-strip" aria-label="Grid metrics"><article><strong>${snapshot.pipeline.pipelineMs.toFixed(2)} ms</strong><span>pipeline</span></article><article><strong>${virtualizationEnabled ? 'On' : 'Off'}</strong><span>virtualization</span></article><article><strong>${pinnedCount}</strong><span>pinned columns</span></article><article><strong>${escapeHtml(this.benchmarkAverage)}</strong><span>benchmark avg</span></article></section>`;
686
+ const toolbar = `<div class="grid-toolbar"><div><strong>${visibleRowCount}</strong><span>${escapeHtml(labels.toolbarOf)} ${totalRows} ${escapeHtml(labels.toolbarRows)}</span></div><p>${escapeHtml(options.id)}</p></div>`;
687
+ const gridTableStyle = `${virtualizationEnabled ? `height:${viewportHeight}px;overflow-y:auto;` : ''}--ui-grid-header-sticky-top:${headerStickyTop}px;`;
688
+
689
+ root.innerHTML = `<style>${GRID_CORE_CSS}</style><section class="grid-shell ui-grid-shell">${slotRegistry}${hero}${metrics}<section class="grid-frame ui-grid" role="grid" aria-label="${escapeHtml(options.title ?? 'Data grid')}">${toolbar}<div class="grid-table ui-grid-contents-wrapper" style="${gridTableStyle}"><div class="header-grid ui-grid-header ui-grid-header-canvas" style="grid-template-columns:${templateColumns}">${header}</div>${filterRow}${body}</div>${pagination}</section></section>`;
690
+
691
+ const gridTable = root.querySelector<HTMLElement>('.grid-table');
692
+ if (gridTable && this.scrollPosition > 0 && virtualizationEnabled) {
693
+ this.suppressScrollEvent = true;
694
+ gridTable.scrollTop = this.scrollPosition;
695
+ requestAnimationFrame(() => {
696
+ this.suppressScrollEvent = false;
697
+ });
698
+ }
699
+
700
+ const headerGrid = root.querySelector<HTMLElement>('.header-grid');
701
+ const filterGrid = root.querySelector<HTMLElement>('.filter-grid');
702
+ const nextHeaderStickyHeight = headerGrid?.offsetHeight ?? 0;
703
+ const nextFilterStickyHeight = filterGrid?.offsetHeight ?? 0;
704
+ const stickyHeightsChanged =
705
+ nextHeaderStickyHeight !== this.measuredHeaderStickyHeight ||
706
+ nextFilterStickyHeight !== this.measuredFilterStickyHeight;
707
+
708
+ this.measuredHeaderStickyHeight = nextHeaderStickyHeight;
709
+ this.measuredFilterStickyHeight = nextFilterStickyHeight;
710
+
711
+ if (stickyHeightsChanged && !this.stickyHeightRelayoutQueued) {
712
+ this.stickyHeightRelayoutQueued = true;
713
+ requestAnimationFrame(() => {
714
+ this.stickyHeightRelayoutQueued = false;
715
+ if (!this.snapshot) {
716
+ return;
717
+ }
718
+ this.render();
719
+ });
720
+ }
721
+ }
722
+
723
+ private renderHeaderCell(
724
+ column: GridColumnDef,
725
+ sortEnabled: boolean,
726
+ groupingEnabled: boolean,
727
+ pinningEnabled: boolean,
728
+ options: GridOptions,
729
+ ): string {
730
+ const controller = this.controller!;
731
+ const sortDirection = controller.getSortDirection(column);
732
+ const sortLabel = controller.sortButtonLabel(column);
733
+ const groupingLabel = controller.groupingButtonLabel(column);
734
+ const canSort = sortEnabled && controller.isColumnSortable(column);
735
+ const canGroup = groupingEnabled && column.enableGrouping !== false;
736
+ const canPin = pinningEnabled && controller.isColumnPinnable(column);
737
+ const isPinned = controller.isPinned(column);
738
+ const pinOffset = isPinned ? controller.pinnedOffset(column) : null;
739
+ const isPinnedLeftLast = controller.isPinnedLeftLast(column);
740
+ const isPinnedRightFirst = controller.isPinnedRightFirst(column);
741
+ const isDragTarget = this.dropTargetColumnName === column.name;
742
+ const isDragging = this.draggedColumnName === column.name;
743
+ const classes = [
744
+ 'header-cell',
745
+ sortDirection !== SORT_DIRECTIONS.none ? 'is-active' : '',
746
+ isPinned ? 'is-pinned' : '',
747
+ isPinnedLeftLast ? 'is-pinned-left-last' : '',
748
+ isPinnedRightFirst ? 'is-pinned-right-first' : '',
749
+ this.openPinMenuColumn === column.name ? 'is-pin-menu-open' : '',
750
+ isDragTarget ? 'is-drag-target' : '',
751
+ isDragging ? 'is-dragging' : '',
752
+ ]
753
+ .filter(Boolean)
754
+ .join(' ');
755
+
756
+ const stickyStyle = pinOffset ? `${pinOffset.side}:${pinOffset.offset};` : '';
757
+
758
+ const sortIconKey =
759
+ sortDirection === SORT_DIRECTIONS.asc
760
+ ? 'sortAsc'
761
+ : sortDirection === SORT_DIRECTIONS.desc
762
+ ? 'sortDesc'
763
+ : 'sortNone';
764
+
765
+ const draggable = canGridMoveColumns(options) ? 'draggable="true"' : '';
766
+ const pinLabel = isPinned
767
+ ? (this.snapshot?.labels.unpin ?? 'Unpin')
768
+ : (this.snapshot?.labels.pinColumn ?? 'Pin');
769
+
770
+ return `<div class="${classes}" data-column="${escapeHtml(column.name)}" ${draggable} style="${stickyStyle}"><span class="header-label">${escapeHtml(controller.headerLabel(column))}</span><span class="header-actions">${sortEnabled ? `<button type="button" class="header-action" data-action="sort" data-column="${escapeHtml(column.name)}" aria-label="${escapeHtml(sortLabel)}" ${canSort ? '' : 'disabled'}>${this.renderControlIcon(sortIconKey)}<span class="sr-only">${escapeHtml(sortLabel)}</span></button>` : ''}${groupingEnabled ? `<button type="button" class="chip-action${controller.isColumnGrouped(column) ? ' chip-action-active' : ''}" data-action="group" data-column="${escapeHtml(column.name)}" aria-label="${escapeHtml(groupingLabel)}" ${canGroup ? '' : 'disabled'}>${this.renderControlIcon('group')}<span class="sr-only">${escapeHtml(groupingLabel)}</span></button>` : ''}${canPin ? `<div class="pin-control${this.openPinMenuColumn === column.name ? ' pin-control-open' : ''}"><button type="button" class="chip-action pin-trigger${isPinned ? ' chip-action-active' : ''}" data-action="pin-trigger" data-column="${escapeHtml(column.name)}" aria-label="${escapeHtml(pinLabel)}">${this.renderControlIcon('pin')}<span class="sr-only">${escapeHtml(pinLabel)}</span></button><div class="pin-menu" role="menu" aria-label="${escapeHtml(pinLabel)}"><button type="button" class="pin-menu-action" data-action="pin-left" data-column="${escapeHtml(column.name)}" aria-label="${escapeHtml(this.snapshot?.labels.pinLeft ?? 'Pin left')}">${this.renderIconWithClass('control-icon', 'pinLeft')}<span class="sr-only">${escapeHtml(this.snapshot?.labels.pinLeft ?? 'Pin left')}</span></button><button type="button" class="pin-menu-action" data-action="pin-right" data-column="${escapeHtml(column.name)}" aria-label="${escapeHtml(this.snapshot?.labels.pinRight ?? 'Pin right')}">${this.renderIconWithClass('control-icon', 'pinRight')}<span class="sr-only">${escapeHtml(this.snapshot?.labels.pinRight ?? 'Pin right')}</span></button></div></div>` : ''}</span></div>`;
771
+ }
772
+
773
+ private renderFilterCell(column: GridColumnDef): string {
774
+ const value = this.snapshot?.activeFilters[column.name] ?? '';
775
+ const controller = this.controller!;
776
+ const canFilter = controller.isColumnFilterable(column);
777
+ const pinOffset = controller.isPinned(column) ? controller.pinnedOffset(column) : null;
778
+ const classes = [
779
+ 'filter-cell',
780
+ controller.isPinned(column) ? 'is-pinned' : '',
781
+ controller.isPinnedLeftLast(column) ? 'is-pinned-left-last' : '',
782
+ controller.isPinnedRightFirst(column) ? 'is-pinned-right-first' : '',
783
+ ]
784
+ .filter(Boolean)
785
+ .join(' ');
786
+ const stickyStyle = pinOffset ? `${pinOffset.side}:${pinOffset.offset};` : '';
787
+ return `<label class="${classes}" style="${stickyStyle}"><input class="ui-grid-filter-input" data-role="filter" data-column="${escapeHtml(column.name)}" placeholder="${escapeHtml(controller.filterPlaceholder(column))}" value="${escapeHtml(value)}" ${canFilter ? '' : 'disabled'}></label>`;
788
+ }
789
+
790
+ private renderDisplayItem(item: DisplayItem, displayIndex: number): string {
791
+ if (!this.snapshot || !this.controller) {
792
+ return '';
793
+ }
794
+
795
+ if (item.kind === 'group') {
796
+ const group = asGroupItem(item);
797
+ const iconKey = group.collapsed ? 'groupCollapsed' : 'groupExpanded';
798
+ const disclosureLabel = this.controller.groupDisclosureLabel(group);
799
+ return `<button type="button" class="group-row ui-grid-row ui-grid-group-row" data-action="toggle-group" data-group="${escapeHtml(group.id)}" data-collapsed="${group.collapsed ? 'true' : 'false'}" style="grid-column: 1 / -1; padding-inline-start:${group.depth * 20 + 10}px">${this.renderIconWithClass('toggle-icon group-disclosure-icon', iconKey)}<span class="sr-only">${escapeHtml(disclosureLabel)}</span><strong>${escapeHtml(group.field)}: ${escapeHtml(group.label)}</strong><span>${group.count} ${escapeHtml(this.snapshot.labels.groupRowsSuffix)}</span></button>`;
800
+ }
801
+
802
+ if (item.kind === 'expandable') {
803
+ const row = (item as DisplayItem & { row: GridRow }).row;
804
+ return `<div class="expandable-row ui-grid-row ui-grid-expandable-row" style="grid-column:1 / -1">${this.renderExpandableTemplate(row)}</div>`;
805
+ }
806
+
807
+ if (!isRowItem(item)) {
808
+ return '';
809
+ }
810
+
811
+ return this.snapshot.visibleColumns
812
+ .map((column) => this.renderBodyCell(item.row, column, displayIndex))
813
+ .join('');
814
+ }
815
+
816
+ private renderBodyCell(row: GridRow, column: GridColumnDef, displayIndex: number): string {
817
+ if (!this.snapshot || !this.controller) {
818
+ return '';
819
+ }
820
+
821
+ const controller = this.controller;
822
+ const rowId = row.id;
823
+ const columnName = column.name;
824
+ const editing = controller.isEditingCell(rowId, columnName);
825
+ const pinOffset = controller.isPinned(column) ? controller.pinnedOffset(column) : null;
826
+ const treeToggle = controller.showTreeToggle(row, column)
827
+ ? `<button type="button" class="row-toggle" data-action="toggle-tree" data-row="${escapeHtml(rowId)}" aria-label="${escapeHtml(controller.treeToggleLabel(row))}">${this.renderIconWithClass('toggle-icon', controller.isTreeRowExpanded(row) ? 'treeExpanded' : 'treeCollapsed')}<span class="sr-only">${escapeHtml(controller.treeToggleLabel(row))}</span></button>`
828
+ : '';
829
+ const expandToggle = controller.showExpandToggle(row, column)
830
+ ? `<button type="button" class="row-toggle row-toggle-expand" data-action="toggle-expand" data-row="${escapeHtml(rowId)}" aria-label="${escapeHtml(controller.expandToggleLabel(row))}">${this.renderIconWithClass('toggle-icon', row.expanded ? 'expandExpanded' : 'expandCollapsed')}<span class="sr-only">${escapeHtml(controller.expandToggleLabel(row))}</span></button>`
831
+ : '';
832
+
833
+ const content = editing
834
+ ? `<input class="cell-editor" data-role="editor" data-row="${escapeHtml(rowId)}" data-column="${escapeHtml(columnName)}" type="${escapeHtml(controller.editorInputType(column))}" value="${escapeHtml(this.snapshot.editingValue)}">`
835
+ : this.renderCellTemplate(row, column, displayIndex);
836
+
837
+ const classes = [
838
+ 'body-cell',
839
+ 'ui-grid-cell',
840
+ displayIndex % 2 !== 0 ? 'body-cell-odd' : '',
841
+ column.align === 'center' ? 'align-center' : '',
842
+ column.align === 'end' ? 'align-end' : '',
843
+ controller.isPinned(column) ? 'is-pinned' : '',
844
+ controller.isPinnedLeftLast(column) ? 'is-pinned-left-last' : '',
845
+ controller.isPinnedRightFirst(column) ? 'is-pinned-right-first' : '',
846
+ this.focusedCell?.rowId === rowId && this.focusedCell.columnName === columnName
847
+ ? 'cell-focused'
848
+ : '',
849
+ editing ? 'cell-editing' : '',
850
+ ]
851
+ .filter(Boolean)
852
+ .join(' ');
853
+
854
+ const stickyStyle = pinOffset ? `${pinOffset.side}:${pinOffset.offset};` : '';
855
+ return `<div class="${classes}" tabindex="0" data-row="${escapeHtml(rowId)}" data-column="${escapeHtml(columnName)}" style="${stickyStyle}"><div class="cell-shell" style="padding-inline-start:${escapeHtml(controller.cellIndent(row, column))}">${treeToggle}${expandToggle}${content}</div></div>`;
856
+ }
857
+
858
+ private renderPagination(snapshot: GridControllerSnapshot): string {
859
+ const pageSizes = snapshot.options.paginationPageSizes ?? [10, 25, 50, 100];
860
+ return `<footer class="pagination-bar ui-grid-pagination"><p>${snapshot.firstRowIndex + 1}-${snapshot.lastRowIndex + 1} of ${snapshot.pipeline.totalItems}</p><div class="pagination-controls"><button type="button" class="action action-secondary pagination-button" data-action="page-prev" aria-label="${escapeHtml(snapshot.labels.paginationPrevious)}" ${snapshot.currentPage <= 1 ? 'disabled' : ''}>${this.renderIconWithClass('pagination-icon', 'paginationPrev')}<span class="sr-only">${escapeHtml(snapshot.labels.paginationPrevious)}</span></button><span>${escapeHtml(snapshot.labels.paginationPage)} ${snapshot.currentPage} ${escapeHtml(snapshot.labels.paginationOf)} ${snapshot.totalPages}</span><button type="button" class="action action-secondary pagination-button" data-action="page-next" aria-label="${escapeHtml(snapshot.labels.paginationNext)}" ${snapshot.currentPage >= snapshot.totalPages ? 'disabled' : ''}>${this.renderIconWithClass('pagination-icon', 'paginationNext')}<span class="sr-only">${escapeHtml(snapshot.labels.paginationNext)}</span></button><label class="pagination-size"><span class="sr-only">${escapeHtml(snapshot.labels.paginationRows)}</span><select class="page-size" data-role="page-size">${pageSizes
861
+ .map(
862
+ (size) =>
863
+ `<option value="${size}" ${size === snapshot.pageSize ? 'selected' : ''}>${size}</option>`,
864
+ )
865
+ .join('')}</select></label></div></footer>`;
866
+ }
867
+
868
+ private renderSlotRegistry(columns: readonly GridColumnDef[]): string {
869
+ const cellSlots = columns
870
+ .map((column) => `<slot name="${escapeHtml(this.cellSlotName(column))}"></slot>`)
871
+ .join('');
872
+ return `<div hidden class="slot-registry">${cellSlots}<slot name="expandable-row"></slot></div>`;
873
+ }
874
+
875
+ private renderCellTemplate(row: GridRow, column: GridColumnDef, displayIndex: number): string {
876
+ const templateMarkup = this.getTemplateMarkup(this.cellSlotName(column));
877
+ if (!templateMarkup) {
878
+ return `<span class="cell-value">${escapeHtml(this.controller?.displayValue(row, column) ?? '')}</span>`;
879
+ }
880
+
881
+ const rawRow = row.entity as GridRecord;
882
+ const rawValue = getCellValue(rawRow, column);
883
+ const valueText = rawValue == null ? '' : String(rawValue);
884
+ return this.interpolateTemplate(templateMarkup, {
885
+ $implicit: rawValue,
886
+ value: rawValue,
887
+ valueText,
888
+ valueLower: valueText.toLowerCase(),
889
+ row: rawRow,
890
+ column,
891
+ rowIndex: this.rowIndexFor(row, displayIndex),
892
+ });
893
+ }
894
+
895
+ private renderExpandableTemplate(row: GridRow): string {
896
+ const templateMarkup = this.getTemplateMarkup('expandable-row');
897
+ if (!templateMarkup) {
898
+ return `<p>${escapeHtml(String(row.entity['name'] ?? row.id))}</p>`;
899
+ }
900
+
901
+ return this.interpolateTemplate(templateMarkup, {
902
+ $implicit: row.entity,
903
+ row: row.entity,
904
+ expanded: row.expanded,
905
+ rowIndex: this.rowIndexFor(row),
906
+ });
907
+ }
908
+
909
+ private getTemplateMarkup(slotName: string): string | null {
910
+ const template = this.querySelector<HTMLTemplateElement>(`template[slot="${slotName}"]`);
911
+ return template?.innerHTML ?? null;
912
+ }
913
+
914
+ private interpolateTemplate(templateMarkup: string, context: Record<string, unknown>): string {
915
+ return templateMarkup.replace(/{{\s*([^}]+?)\s*}}/g, (_match, expression) => {
916
+ const value = this.resolveTemplateValue(context, String(expression).trim());
917
+ return escapeHtml(value);
918
+ });
919
+ }
920
+
921
+ private resolveTemplateValue(context: Record<string, unknown>, expression: string): unknown {
922
+ const segments = expression.split('.').filter(Boolean);
923
+ let current: unknown = context;
924
+
925
+ for (const segment of segments) {
926
+ if (current == null || typeof current !== 'object') {
927
+ return '';
928
+ }
929
+ current = (current as Record<string, unknown>)[segment];
930
+ }
931
+
932
+ return current ?? '';
933
+ }
934
+
935
+ private rowIndexFor(row: GridRow, fallback = 0): number {
936
+ return (
937
+ this.snapshot?.pipeline.visibleRows.findIndex((candidate) => candidate.id === row.id) ??
938
+ fallback
939
+ );
940
+ }
941
+
942
+ private cellSlotName(column: GridColumnDef): string {
943
+ return `cell-${column.name}`;
944
+ }
945
+
946
+ private downloadCsv(): void {
947
+ if (!this.snapshot) {
948
+ return;
949
+ }
950
+
951
+ const csv = exportCsvRows(this.snapshot.visibleColumns, this.snapshot.pipeline.visibleRows);
952
+ const filename = sanitizeDownloadFilename(`${this.snapshot.options.id}.csv`);
953
+ downloadGridCsvFile(csv, filename);
954
+ }
955
+
956
+ private renderControlIcon(key: UiGridControlIconKey): string {
957
+ return this.renderIconWithClass('control-icon', key);
958
+ }
959
+
960
+ private renderIconWithClass(svgClass: string, key: UiGridControlIconKey): string {
961
+ const icon = this.iconOverrides[key] ?? DEFAULT_ICONS[key];
962
+ const viewBox = escapeHtml(icon.viewBox ?? '0 0 24 24');
963
+ const path = escapeHtml(icon.path);
964
+ return `<svg class="${svgClass}" viewBox="${viewBox}" aria-hidden="true" focusable="false"><path d="${path}"></path></svg>`;
965
+ }
966
+ }
967
+
968
+ export async function defineStandaloneUiGridElement(tagName = 'ui-grid-element'): Promise<void> {
969
+ if (!customElements.get(tagName)) {
970
+ customElements.define(tagName, UiGridStandaloneElement);
971
+ }
972
+ }