@ornery/ui-grid-vanilla 0.1.7 → 0.1.8-hotfix-1

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.
@@ -1,9 +1,11 @@
1
1
  import {
2
2
  GRID_CORE_CSS,
3
3
  SORT_DIRECTIONS,
4
+ buildGridHeaderContext,
4
5
  canGridMoveColumns,
5
6
  downloadGridCsvFile,
6
7
  exportCsvRows,
8
+ formatGridHeaderDisplayValue,
7
9
  getCellValue,
8
10
  sanitizeDownloadFilename,
9
11
  type DisplayItem,
@@ -42,6 +44,12 @@ export type VanillaUiGridElement = HTMLElement & {
42
44
  options: GridOptions;
43
45
  getState(): GridSaveState | null;
44
46
  setState(state: Partial<GridSaveState>): void;
47
+ /**
48
+ * Fast-path data update: patches only visible cell content in the existing
49
+ * shadow DOM. Does NOT rebuild the scroll container or header, so scrolling
50
+ * remains completely smooth while data ticks in the background.
51
+ */
52
+ setData(rows: GridRecord[]): void;
45
53
  };
46
54
 
47
55
  export type UiGridControlIconKey =
@@ -95,6 +103,8 @@ export class UiGridStandaloneElement extends HTMLElement {
95
103
  private snapshot: GridControllerSnapshot | null = null;
96
104
  private unsubscribe: (() => void) | null = null;
97
105
  private activeOptions: VanillaGridOptions | null = null;
106
+ private attributeOptions: Partial<GridOptions> = {};
107
+ private attributeSyncScheduled = false;
98
108
  private iconOverrides: UiGridIconOverrides = {};
99
109
  private templateObserver: MutationObserver | null = null;
100
110
  private openPinMenuColumn: string | null = null;
@@ -109,6 +119,53 @@ export class UiGridStandaloneElement extends HTMLElement {
109
119
  private measuredFilterStickyHeight = 0;
110
120
  private stickyHeightRelayoutQueued = false;
111
121
  private benchmarkAverage = '—';
122
+ private skipNextRender = false;
123
+ private dataFrame: number | null = null;
124
+
125
+ static get observedAttributes(): string[] {
126
+ return [
127
+ // Scalar attributes
128
+ 'grid-id',
129
+ 'title',
130
+ 'row-height',
131
+ 'header-row-height',
132
+ 'viewport-height',
133
+ 'pagination-page-size',
134
+ 'pagination-current-page',
135
+ 'total-items',
136
+ 'virtualization-threshold',
137
+ 'tree-children-field',
138
+ 'tree-indent',
139
+ 'expandable-row-height',
140
+ 'expandable-row-header-width',
141
+ 'empty-message',
142
+ 'infinite-scroll-rows-from-end',
143
+ // JSON attributes
144
+ 'column-defs',
145
+ 'data',
146
+ 'grouping',
147
+ 'pagination-page-sizes',
148
+ // Boolean flags
149
+ 'enable-sorting',
150
+ 'enable-filtering',
151
+ 'enable-grouping',
152
+ 'enable-pinning',
153
+ 'enable-column-moving',
154
+ 'enable-cell-edit',
155
+ 'enable-cell-edit-on-focus',
156
+ 'enable-pagination',
157
+ 'enable-pagination-controls',
158
+ 'use-external-pagination',
159
+ 'enable-expandable',
160
+ 'enable-tree-view',
161
+ 'show-tree-expand-no-children',
162
+ 'tree-row-header-always-visible',
163
+ 'enable-auto-resize',
164
+ 'enable-virtualization',
165
+ 'infinite-scroll-up',
166
+ 'infinite-scroll-down',
167
+ ];
168
+ }
112
169
 
113
170
  get options(): GridOptions {
114
171
  return (
@@ -126,6 +183,197 @@ export class UiGridStandaloneElement extends HTMLElement {
126
183
  this.ensureController(this.buildEffectiveOptions(this.activeOptions));
127
184
  }
128
185
 
186
+ attributeChangedCallback(
187
+ _name: string,
188
+ _oldValue: string | null,
189
+ _newValue: string | null,
190
+ ): void {
191
+ // Debounce multiple rapid attribute changes via microtask.
192
+ if (!this.attributeSyncScheduled) {
193
+ this.attributeSyncScheduled = true;
194
+ queueMicrotask(() => {
195
+ this.attributeSyncScheduled = false;
196
+ this.syncAttributesToOptions();
197
+ });
198
+ }
199
+ }
200
+
201
+ private syncAttributesToOptions(): void {
202
+ this.attributeOptions = {};
203
+
204
+ // Scalar string attributes
205
+ const gridId = this.getAttribute('grid-id');
206
+ if (gridId !== null) this.attributeOptions.id = gridId;
207
+
208
+ const title = this.getAttribute('title');
209
+ if (title !== null) this.attributeOptions.title = title;
210
+
211
+ const emptyMessage = this.getAttribute('empty-message');
212
+ if (emptyMessage !== null) this.attributeOptions.emptyMessage = emptyMessage;
213
+
214
+ const treeChildrenField = this.getAttribute('tree-children-field');
215
+ if (treeChildrenField !== null) this.attributeOptions.treeChildrenField = treeChildrenField;
216
+
217
+ // Scalar number attributes
218
+ const rowHeight = this.parseNumberAttribute('row-height');
219
+ if (rowHeight !== undefined) this.attributeOptions.rowHeight = rowHeight;
220
+
221
+ const headerRowHeight = this.parseNumberAttribute('header-row-height');
222
+ if (headerRowHeight !== undefined)
223
+ this.attributeOptions.headerRowHeight = headerRowHeight;
224
+
225
+ const viewportHeight = this.parseNumberAttribute('viewport-height');
226
+ if (viewportHeight !== undefined) this.attributeOptions.viewportHeight = viewportHeight;
227
+
228
+ const paginationPageSize = this.parseNumberAttribute('pagination-page-size');
229
+ if (paginationPageSize !== undefined)
230
+ this.attributeOptions.paginationPageSize = paginationPageSize;
231
+
232
+ const paginationCurrentPage = this.parseNumberAttribute('pagination-current-page');
233
+ if (paginationCurrentPage !== undefined)
234
+ this.attributeOptions.paginationCurrentPage = paginationCurrentPage;
235
+
236
+ const totalItems = this.parseNumberAttribute('total-items');
237
+ if (totalItems !== undefined) this.attributeOptions.totalItems = totalItems;
238
+
239
+ const virtualizationThreshold = this.parseNumberAttribute('virtualization-threshold');
240
+ if (virtualizationThreshold !== undefined)
241
+ this.attributeOptions.virtualizationThreshold = virtualizationThreshold;
242
+
243
+ const treeIndent = this.parseNumberAttribute('tree-indent');
244
+ if (treeIndent !== undefined) this.attributeOptions.treeIndent = treeIndent;
245
+
246
+ const expandableRowHeight = this.parseNumberAttribute('expandable-row-height');
247
+ if (expandableRowHeight !== undefined)
248
+ this.attributeOptions.expandableRowHeight = expandableRowHeight;
249
+
250
+ const expandableRowHeaderWidth = this.parseNumberAttribute(
251
+ 'expandable-row-header-width',
252
+ );
253
+ if (expandableRowHeaderWidth !== undefined)
254
+ this.attributeOptions.expandableRowHeaderWidth = expandableRowHeaderWidth;
255
+
256
+ const infiniteScrollRowsFromEnd = this.parseNumberAttribute(
257
+ 'infinite-scroll-rows-from-end',
258
+ );
259
+ if (infiniteScrollRowsFromEnd !== undefined)
260
+ this.attributeOptions.infiniteScrollRowsFromEnd = infiniteScrollRowsFromEnd;
261
+
262
+ // Boolean attributes
263
+ const enableSorting = this.parseBooleanAttribute('enable-sorting');
264
+ if (enableSorting !== undefined) this.attributeOptions.enableSorting = enableSorting;
265
+
266
+ const enableFiltering = this.parseBooleanAttribute('enable-filtering');
267
+ if (enableFiltering !== undefined) this.attributeOptions.enableFiltering = enableFiltering;
268
+
269
+ const enableGrouping = this.parseBooleanAttribute('enable-grouping');
270
+ if (enableGrouping !== undefined) this.attributeOptions.enableGrouping = enableGrouping;
271
+
272
+ const enablePinning = this.parseBooleanAttribute('enable-pinning');
273
+ if (enablePinning !== undefined) this.attributeOptions.enablePinning = enablePinning;
274
+
275
+ const enableColumnMoving = this.parseBooleanAttribute('enable-column-moving');
276
+ if (enableColumnMoving !== undefined)
277
+ this.attributeOptions.enableColumnMoving = enableColumnMoving;
278
+
279
+ const enableCellEdit = this.parseBooleanAttribute('enable-cell-edit');
280
+ if (enableCellEdit !== undefined) this.attributeOptions.enableCellEdit = enableCellEdit;
281
+
282
+ const enableCellEditOnFocus = this.parseBooleanAttribute('enable-cell-edit-on-focus');
283
+ if (enableCellEditOnFocus !== undefined)
284
+ this.attributeOptions.enableCellEditOnFocus = enableCellEditOnFocus;
285
+
286
+ const enablePagination = this.parseBooleanAttribute('enable-pagination');
287
+ if (enablePagination !== undefined)
288
+ this.attributeOptions.enablePagination = enablePagination;
289
+
290
+ const enablePaginationControls = this.parseBooleanAttribute(
291
+ 'enable-pagination-controls',
292
+ );
293
+ if (enablePaginationControls !== undefined)
294
+ this.attributeOptions.enablePaginationControls = enablePaginationControls;
295
+
296
+ const useExternalPagination = this.parseBooleanAttribute('use-external-pagination');
297
+ if (useExternalPagination !== undefined)
298
+ this.attributeOptions.useExternalPagination = useExternalPagination;
299
+
300
+ const enableExpandable = this.parseBooleanAttribute('enable-expandable');
301
+ if (enableExpandable !== undefined) this.attributeOptions.enableExpandable = enableExpandable;
302
+
303
+ const enableTreeView = this.parseBooleanAttribute('enable-tree-view');
304
+ if (enableTreeView !== undefined) this.attributeOptions.enableTreeView = enableTreeView;
305
+
306
+ const showTreeExpandNoChildren = this.parseBooleanAttribute(
307
+ 'show-tree-expand-no-children',
308
+ );
309
+ if (showTreeExpandNoChildren !== undefined)
310
+ this.attributeOptions.showTreeExpandNoChildren = showTreeExpandNoChildren;
311
+
312
+ const treeRowHeaderAlwaysVisible = this.parseBooleanAttribute(
313
+ 'tree-row-header-always-visible',
314
+ );
315
+ if (treeRowHeaderAlwaysVisible !== undefined)
316
+ this.attributeOptions.treeRowHeaderAlwaysVisible = treeRowHeaderAlwaysVisible;
317
+
318
+ const enableAutoResize = this.parseBooleanAttribute('enable-auto-resize');
319
+ if (enableAutoResize !== undefined) this.attributeOptions.enableAutoResize = enableAutoResize;
320
+
321
+ const enableVirtualization = this.parseBooleanAttribute('enable-virtualization');
322
+ if (enableVirtualization !== undefined)
323
+ this.attributeOptions.enableVirtualization = enableVirtualization;
324
+
325
+ const infiniteScrollUp = this.parseBooleanAttribute('infinite-scroll-up');
326
+ if (infiniteScrollUp !== undefined) this.attributeOptions.infiniteScrollUp = infiniteScrollUp;
327
+
328
+ const infiniteScrollDown = this.parseBooleanAttribute('infinite-scroll-down');
329
+ if (infiniteScrollDown !== undefined)
330
+ this.attributeOptions.infiniteScrollDown = infiniteScrollDown;
331
+
332
+ // JSON attributes
333
+ const columnDefs = this.parseJsonAttribute<GridColumnDef[]>('column-defs');
334
+ if (columnDefs !== undefined) this.attributeOptions.columnDefs = columnDefs;
335
+
336
+ const data = this.parseJsonAttribute<GridRecord[]>('data');
337
+ if (data !== undefined) this.attributeOptions.data = data;
338
+
339
+ const grouping = this.parseJsonAttribute('grouping');
340
+ if (grouping !== undefined && grouping !== null)
341
+ this.attributeOptions.grouping = grouping;
342
+
343
+ const paginationPageSizes = this.parseJsonAttribute<number[] | null>(
344
+ 'pagination-page-sizes',
345
+ );
346
+ if (paginationPageSizes !== undefined)
347
+ this.attributeOptions.paginationPageSizes = paginationPageSizes;
348
+
349
+ // Re-render with the merged options.
350
+ if (this.activeOptions) {
351
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions));
352
+ }
353
+ }
354
+
355
+ private parseBooleanAttribute(name: string): boolean | undefined {
356
+ return this.hasAttribute(name) ? true : undefined;
357
+ }
358
+
359
+ private parseNumberAttribute(name: string): number | undefined {
360
+ const raw = this.getAttribute(name);
361
+ if (raw === null) return undefined;
362
+ const parsed = Number(raw);
363
+ return Number.isFinite(parsed) ? parsed : undefined;
364
+ }
365
+
366
+ private parseJsonAttribute<T = unknown>(name: string): T | undefined {
367
+ const raw = this.getAttribute(name);
368
+ if (raw === null) return undefined;
369
+ try {
370
+ return JSON.parse(raw) as T;
371
+ } catch (e) {
372
+ console.warn(`<ui-grid-element>: invalid JSON in "${name}" attribute`, e);
373
+ return undefined;
374
+ }
375
+ }
376
+
129
377
  get controlIcons(): UiGridIconOverrides {
130
378
  return this.iconOverrides;
131
379
  }
@@ -135,6 +383,308 @@ export class UiGridStandaloneElement extends HTMLElement {
135
383
  this.render();
136
384
  }
137
385
 
386
+ // ─────────────────────────────────────────────────────────────────
387
+ // Individual property accessors (mirrors of HTML attributes)
388
+ // ─────────────────────────────────────────────────────────────────
389
+
390
+ get gridId(): string {
391
+ return this.options.id ?? '';
392
+ }
393
+ set gridId(value: string) {
394
+ this.activeOptions = { ...this.activeOptions, id: value } as any;
395
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
396
+ }
397
+
398
+ // @ts-ignore: Intentional override of HTMLElement.title
399
+ get title(): string {
400
+ return this.options.title ?? '';
401
+ }
402
+ // @ts-ignore: Intentional override of HTMLElement.title
403
+ set title(value: string) {
404
+ this.activeOptions = { ...this.activeOptions, title: value } as any;
405
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
406
+ }
407
+
408
+ get rowHeight(): number {
409
+ return this.options.rowHeight ?? 40;
410
+ }
411
+ set rowHeight(value: number) {
412
+ this.activeOptions = { ...this.activeOptions, rowHeight: value } as any;
413
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
414
+ }
415
+
416
+ get headerRowHeight(): number {
417
+ return this.options.headerRowHeight ?? 50;
418
+ }
419
+ set headerRowHeight(value: number) {
420
+ this.activeOptions = { ...this.activeOptions, headerRowHeight: value } as any;
421
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
422
+ }
423
+
424
+ get viewportHeight(): number {
425
+ return this.options.viewportHeight ?? 560;
426
+ }
427
+ set viewportHeight(value: number) {
428
+ this.activeOptions = { ...this.activeOptions, viewportHeight: value } as any;
429
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
430
+ }
431
+
432
+ get paginationPageSize(): number {
433
+ return this.options.paginationPageSize ?? 25;
434
+ }
435
+ set paginationPageSize(value: number) {
436
+ this.activeOptions = { ...this.activeOptions, paginationPageSize: value } as any;
437
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
438
+ }
439
+
440
+ get paginationCurrentPage(): number {
441
+ return this.options.paginationCurrentPage ?? 1;
442
+ }
443
+ set paginationCurrentPage(value: number) {
444
+ this.activeOptions = { ...this.activeOptions, paginationCurrentPage: value } as any;
445
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
446
+ }
447
+
448
+ get totalItems(): number | undefined {
449
+ return this.options.totalItems;
450
+ }
451
+ set totalItems(value: number | undefined) {
452
+ this.activeOptions = { ...this.activeOptions, totalItems: value } as any;
453
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
454
+ }
455
+
456
+ get virtualizationThreshold(): number {
457
+ return this.options.virtualizationThreshold ?? 40;
458
+ }
459
+ set virtualizationThreshold(value: number) {
460
+ this.activeOptions = { ...this.activeOptions, virtualizationThreshold: value } as any;
461
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
462
+ }
463
+
464
+ get treeChildrenField(): string {
465
+ return this.options.treeChildrenField ?? 'children';
466
+ }
467
+ set treeChildrenField(value: string) {
468
+ this.activeOptions = { ...this.activeOptions, treeChildrenField: value } as any;
469
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
470
+ }
471
+
472
+ get treeIndent(): number {
473
+ return this.options.treeIndent ?? 20;
474
+ }
475
+ set treeIndent(value: number) {
476
+ this.activeOptions = { ...this.activeOptions, treeIndent: value } as any;
477
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
478
+ }
479
+
480
+ get expandableRowHeight(): number {
481
+ return this.options.expandableRowHeight ?? 150;
482
+ }
483
+ set expandableRowHeight(value: number) {
484
+ this.activeOptions = { ...this.activeOptions, expandableRowHeight: value } as any;
485
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
486
+ }
487
+
488
+ get expandableRowHeaderWidth(): number {
489
+ return this.options.expandableRowHeaderWidth ?? 40;
490
+ }
491
+ set expandableRowHeaderWidth(value: number) {
492
+ this.activeOptions = { ...this.activeOptions, expandableRowHeaderWidth: value } as any;
493
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
494
+ }
495
+
496
+ get emptyMessage(): string {
497
+ return this.options.emptyMessage ?? 'No data available.';
498
+ }
499
+ set emptyMessage(value: string) {
500
+ this.activeOptions = { ...this.activeOptions, emptyMessage: value } as any;
501
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
502
+ }
503
+
504
+ get infiniteScrollRowsFromEnd(): number {
505
+ return this.options.infiniteScrollRowsFromEnd ?? 10;
506
+ }
507
+ set infiniteScrollRowsFromEnd(value: number) {
508
+ this.activeOptions = { ...this.activeOptions, infiniteScrollRowsFromEnd: value } as any;
509
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
510
+ }
511
+
512
+ get columnDefs(): readonly GridColumnDef[] {
513
+ return this.options.columnDefs;
514
+ }
515
+ set columnDefs(value: readonly GridColumnDef[]) {
516
+ this.activeOptions = { ...this.activeOptions, columnDefs: value } as any;
517
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
518
+ }
519
+
520
+ get data(): readonly GridRecord[] {
521
+ return this.options.data;
522
+ }
523
+ set data(value: readonly GridRecord[]) {
524
+ this.activeOptions = { ...this.activeOptions, data: value } as any;
525
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
526
+ }
527
+
528
+ get enableSorting(): boolean {
529
+ return this.options.enableSorting ?? true;
530
+ }
531
+ set enableSorting(value: boolean) {
532
+ this.activeOptions = { ...this.activeOptions, enableSorting: value } as any;
533
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
534
+ }
535
+
536
+ get enableFiltering(): boolean {
537
+ return this.options.enableFiltering ?? true;
538
+ }
539
+ set enableFiltering(value: boolean) {
540
+ this.activeOptions = { ...this.activeOptions, enableFiltering: value } as any;
541
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
542
+ }
543
+
544
+ get enableGrouping(): boolean {
545
+ return this.options.enableGrouping ?? true;
546
+ }
547
+ set enableGrouping(value: boolean) {
548
+ this.activeOptions = { ...this.activeOptions, enableGrouping: value } as any;
549
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
550
+ }
551
+
552
+ get enablePinning(): boolean {
553
+ return this.options.enablePinning ?? true;
554
+ }
555
+ set enablePinning(value: boolean) {
556
+ this.activeOptions = { ...this.activeOptions, enablePinning: value } as any;
557
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
558
+ }
559
+
560
+ get enableColumnMoving(): boolean {
561
+ return this.options.enableColumnMoving ?? true;
562
+ }
563
+ set enableColumnMoving(value: boolean) {
564
+ this.activeOptions = { ...this.activeOptions, enableColumnMoving: value } as any;
565
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
566
+ }
567
+
568
+ get enableCellEdit(): boolean {
569
+ return this.options.enableCellEdit ?? false;
570
+ }
571
+ set enableCellEdit(value: boolean) {
572
+ this.activeOptions = { ...this.activeOptions, enableCellEdit: value } as any;
573
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
574
+ }
575
+
576
+ get enableCellEditOnFocus(): boolean {
577
+ return this.options.enableCellEditOnFocus ?? false;
578
+ }
579
+ set enableCellEditOnFocus(value: boolean) {
580
+ this.activeOptions = { ...this.activeOptions, enableCellEditOnFocus: value } as any;
581
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
582
+ }
583
+
584
+ get enablePagination(): boolean {
585
+ return this.options.enablePagination ?? false;
586
+ }
587
+ set enablePagination(value: boolean) {
588
+ this.activeOptions = { ...this.activeOptions, enablePagination: value } as any;
589
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
590
+ }
591
+
592
+ get enablePaginationControls(): boolean {
593
+ return this.options.enablePaginationControls ?? true;
594
+ }
595
+ set enablePaginationControls(value: boolean) {
596
+ this.activeOptions = { ...this.activeOptions, enablePaginationControls: value } as any;
597
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
598
+ }
599
+
600
+ get useExternalPagination(): boolean {
601
+ return this.options.useExternalPagination ?? false;
602
+ }
603
+ set useExternalPagination(value: boolean) {
604
+ this.activeOptions = { ...this.activeOptions, useExternalPagination: value } as any;
605
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
606
+ }
607
+
608
+ get enableExpandable(): boolean {
609
+ return this.options.enableExpandable ?? false;
610
+ }
611
+ set enableExpandable(value: boolean) {
612
+ this.activeOptions = { ...this.activeOptions, enableExpandable: value } as any;
613
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
614
+ }
615
+
616
+ get enableTreeView(): boolean {
617
+ return this.options.enableTreeView ?? false;
618
+ }
619
+ set enableTreeView(value: boolean) {
620
+ this.activeOptions = { ...this.activeOptions, enableTreeView: value } as any;
621
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
622
+ }
623
+
624
+ get showTreeExpandNoChildren(): boolean {
625
+ return this.options.showTreeExpandNoChildren ?? false;
626
+ }
627
+ set showTreeExpandNoChildren(value: boolean) {
628
+ this.activeOptions = { ...this.activeOptions, showTreeExpandNoChildren: value } as any;
629
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
630
+ }
631
+
632
+ get treeRowHeaderAlwaysVisible(): boolean {
633
+ return this.options.treeRowHeaderAlwaysVisible ?? false;
634
+ }
635
+ set treeRowHeaderAlwaysVisible(value: boolean) {
636
+ this.activeOptions = { ...this.activeOptions, treeRowHeaderAlwaysVisible: value } as any;
637
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
638
+ }
639
+
640
+ get enableAutoResize(): boolean {
641
+ return this.options.enableAutoResize ?? false;
642
+ }
643
+ set enableAutoResize(value: boolean) {
644
+ this.activeOptions = { ...this.activeOptions, enableAutoResize: value } as any;
645
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
646
+ }
647
+
648
+ get enableVirtualization(): boolean {
649
+ return this.options.enableVirtualization ?? true;
650
+ }
651
+ set enableVirtualization(value: boolean) {
652
+ this.activeOptions = { ...this.activeOptions, enableVirtualization: value } as any;
653
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
654
+ }
655
+
656
+ get infiniteScrollUp(): boolean {
657
+ return this.options.infiniteScrollUp ?? false;
658
+ }
659
+ set infiniteScrollUp(value: boolean) {
660
+ this.activeOptions = { ...this.activeOptions, infiniteScrollUp: value } as any;
661
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
662
+ }
663
+
664
+ get infiniteScrollDown(): boolean {
665
+ return this.options.infiniteScrollDown ?? false;
666
+ }
667
+ set infiniteScrollDown(value: boolean) {
668
+ this.activeOptions = { ...this.activeOptions, infiniteScrollDown: value } as any;
669
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
670
+ }
671
+
672
+ get grouping(): unknown {
673
+ return this.options.grouping;
674
+ }
675
+ set grouping(value: unknown) {
676
+ this.activeOptions = { ...this.activeOptions, grouping: value } as any;
677
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
678
+ }
679
+
680
+ get paginationPageSizes(): number[] | null | undefined {
681
+ return this.options.paginationPageSizes;
682
+ }
683
+ set paginationPageSizes(value: number[] | null | undefined) {
684
+ this.activeOptions = { ...this.activeOptions, paginationPageSizes: value } as any;
685
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
686
+ }
687
+
138
688
  getState(): GridSaveState | null {
139
689
  return this.controller?.getState() ?? null;
140
690
  }
@@ -143,6 +693,83 @@ export class UiGridStandaloneElement extends HTMLElement {
143
693
  this.controller?.setState(state);
144
694
  }
145
695
 
696
+ /**
697
+ * Fast-path: update only row data without rebuilding the shadow DOM.
698
+ * The pipeline is updated synchronously; DOM patching is deferred to the
699
+ * next animation frame so it never interrupts an in-progress scroll paint.
700
+ * If multiple ticks arrive before the frame fires, only the latest wins.
701
+ * If a scroll-triggered full render fires first, the frame is cancelled
702
+ * (the fresh innerHTML already reflects the latest data).
703
+ */
704
+ setData(rows: GridRecord[]): void {
705
+ const controller = this.controller;
706
+ if (!controller) {
707
+ return;
708
+ }
709
+
710
+ // Update the pipeline synchronously (cheap, no DOM work).
711
+ this.skipNextRender = true;
712
+ controller.refreshData(rows);
713
+ this.skipNextRender = false;
714
+
715
+ // Schedule DOM patching on the next paint — latest call wins.
716
+ if (this.dataFrame !== null) {
717
+ cancelAnimationFrame(this.dataFrame);
718
+ }
719
+ this.dataFrame = requestAnimationFrame(() => {
720
+ this.dataFrame = null;
721
+ this.patchCells();
722
+ });
723
+ }
724
+
725
+ private patchCells(): void {
726
+ const snapshot = this.snapshot;
727
+ if (!snapshot) {
728
+ return;
729
+ }
730
+
731
+ const root = this.shadowRoot;
732
+ if (!root) {
733
+ return;
734
+ }
735
+
736
+ // Build O(1) lookup maps from the current pipeline snapshot.
737
+ const rowMap = new Map<string, GridRow>();
738
+ for (const row of snapshot.pipeline.visibleRows) {
739
+ rowMap.set(row.id, row);
740
+ }
741
+ const colMap = new Map<string, GridColumnDef>();
742
+ for (const col of snapshot.visibleColumns) {
743
+ colMap.set(col.name, col);
744
+ }
745
+
746
+ // Patch every rendered body cell in-place.
747
+ const cells = root.querySelectorAll<HTMLElement>('[data-row][data-column]');
748
+ for (const cell of cells) {
749
+ const rowId = cell.dataset['row'];
750
+ const colName = cell.dataset['column'];
751
+ if (!rowId || !colName) continue;
752
+
753
+ const row = rowMap.get(rowId);
754
+ const column = colMap.get(colName);
755
+ if (!row || !column) continue;
756
+
757
+ const cellShell = cell.querySelector<HTMLElement>('.cell-shell');
758
+ if (!cellShell) continue;
759
+
760
+ const newContent = this.renderCellTemplate(row, column, 0);
761
+ const toggles = cellShell.querySelectorAll<HTMLElement>('.row-toggle');
762
+ if (toggles.length === 0) {
763
+ cellShell.innerHTML = newContent;
764
+ } else {
765
+ const toggleHtml = Array.from(toggles)
766
+ .map((t) => t.outerHTML)
767
+ .join('');
768
+ cellShell.innerHTML = toggleHtml + newContent;
769
+ }
770
+ }
771
+ }
772
+
146
773
  connectedCallback(): void {
147
774
  this.ensureShadowRoot();
148
775
  this.bindEvents();
@@ -209,17 +836,21 @@ export class UiGridStandaloneElement extends HTMLElement {
209
836
  }
210
837
 
211
838
  private buildEffectiveOptions(options: VanillaGridOptions): VanillaGridOptions {
839
+ // Merge: attributes are overridden by explicit JS property values.
840
+ // JS property always wins: { ...attributeOptions, ...options }
841
+ const merged = {
842
+ ...this.attributeOptions,
843
+ ...options,
844
+ } as VanillaGridOptions;
845
+
212
846
  const hasExpandableSlot = this.getTemplateMarkup('expandable-row') !== null;
213
- if (options.enableExpandable && hasExpandableSlot && !options.expandableRowTemplate) {
214
- return {
215
- ...options,
216
- expandableRowTemplate: {
217
- createEmbeddedView: () => undefined,
218
- },
847
+ if (merged.enableExpandable && hasExpandableSlot && !merged.expandableRowTemplate) {
848
+ merged.expandableRowTemplate = {
849
+ createEmbeddedView: () => undefined,
219
850
  };
220
851
  }
221
852
 
222
- return options;
853
+ return merged;
223
854
  }
224
855
 
225
856
  private bindEvents(): void {
@@ -595,7 +1226,7 @@ export class UiGridStandaloneElement extends HTMLElement {
595
1226
 
596
1227
  this.scrollFrame = requestAnimationFrame(() => {
597
1228
  this.scrollFrame = null;
598
- this.render();
1229
+ this.renderVirtualBody();
599
1230
  });
600
1231
  },
601
1232
  true,
@@ -604,7 +1235,67 @@ export class UiGridStandaloneElement extends HTMLElement {
604
1235
  (root as ShadowRoot & { __uiGridBound?: boolean }).__uiGridBound = true;
605
1236
  }
606
1237
 
1238
+ /**
1239
+ * Targeted update for virtual scroll: replaces only the rendered rows inside
1240
+ * the existing `.grid-virtual-body` without touching the scroll container.
1241
+ * This preserves momentum/inertia scrolling because the `.grid-table` element
1242
+ * is never destroyed.
1243
+ */
1244
+ private renderVirtualBody(): void {
1245
+ const snapshot = this.snapshot;
1246
+ if (!snapshot?.pipeline.virtualizationEnabled) {
1247
+ this.render();
1248
+ return;
1249
+ }
1250
+
1251
+ const root = this.shadowRoot;
1252
+ const virtualBody = root?.querySelector<HTMLElement>('.grid-virtual-body');
1253
+ if (!root || !virtualBody) {
1254
+ this.render();
1255
+ return;
1256
+ }
1257
+
1258
+ // Cancel any pending cell-only patch — the fresh rows we are about to
1259
+ // render already reflect the latest snapshot data.
1260
+ if (this.dataFrame !== null) {
1261
+ cancelAnimationFrame(this.dataFrame);
1262
+ this.dataFrame = null;
1263
+ }
1264
+
1265
+ const stickyChromeHeight = this.measuredHeaderStickyHeight + this.measuredFilterStickyHeight;
1266
+ const viewportHeight = snapshot.options.viewportHeight ?? 560;
1267
+ const bodyViewportHeight = Math.max(snapshot.rowSize, viewportHeight - stickyChromeHeight);
1268
+ const bodyScrollTop = Math.max(0, this.scrollPosition - stickyChromeHeight);
1269
+ const overscan = 4;
1270
+ const startIndex = Math.max(0, Math.floor(bodyScrollTop / snapshot.rowSize) - overscan);
1271
+ this.lastVirtualStartIndex = startIndex;
1272
+ const visibleCount = Math.ceil(bodyViewportHeight / snapshot.rowSize) + overscan * 2;
1273
+ const itemsToRender = snapshot.pipeline.displayItems.slice(
1274
+ startIndex,
1275
+ Math.min(snapshot.pipeline.displayItems.length, startIndex + visibleCount),
1276
+ );
1277
+ const virtualOffset = startIndex * snapshot.rowSize;
1278
+
1279
+ virtualBody.style.top = `${virtualOffset}px`;
1280
+ virtualBody.innerHTML = itemsToRender
1281
+ .map((item, index) => this.renderDisplayItem(item, startIndex + index))
1282
+ .join('');
1283
+ }
1284
+
607
1285
  private render(): void {
1286
+ if (this.skipNextRender) {
1287
+ return;
1288
+ }
1289
+ // A full render already incorporates the latest data — cancel any pending
1290
+ // frame-based updates to avoid redundant DOM mutations immediately after.
1291
+ if (this.dataFrame !== null) {
1292
+ cancelAnimationFrame(this.dataFrame);
1293
+ this.dataFrame = null;
1294
+ }
1295
+ if (this.scrollFrame !== null) {
1296
+ cancelAnimationFrame(this.scrollFrame);
1297
+ this.scrollFrame = null;
1298
+ }
608
1299
  const root = this.ensureShadowRoot();
609
1300
  const snapshot = this.snapshot;
610
1301
  const previousGridTable = root.querySelector<HTMLElement>('.grid-table');
@@ -632,10 +1323,6 @@ export class UiGridStandaloneElement extends HTMLElement {
632
1323
  const headerStickyTop = this.measuredHeaderStickyHeight || options.headerRowHeight || 50;
633
1324
  const stickyChromeHeight = this.measuredHeaderStickyHeight + this.measuredFilterStickyHeight;
634
1325
  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
1326
  let startIndex = 0;
640
1327
  let itemsToRender = snapshot.pipeline.displayItems;
641
1328
  let virtualOffset = 0;
@@ -681,12 +1368,9 @@ export class UiGridStandaloneElement extends HTMLElement {
681
1368
  : `<div class="empty-state ui-grid-no-row-overlay"><strong>${escapeHtml(options.emptyMessage ?? labels.emptyHeading)}</strong><p>${escapeHtml(labels.emptyDescription)}</p></div>`;
682
1369
 
683
1370
  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
1371
  const gridTableStyle = `${virtualizationEnabled ? `height:${viewportHeight}px;overflow-y:auto;` : ''}--ui-grid-header-sticky-top:${headerStickyTop}px;`;
688
1372
 
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>`;
1373
+ root.innerHTML = `<style>${GRID_CORE_CSS}</style>${slotRegistry}<section class="grid-frame ui-grid" role="grid" aria-label="${escapeHtml(options.title ?? 'Data grid')}"><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>`;
690
1374
 
691
1375
  const gridTable = root.querySelector<HTMLElement>('.grid-table');
692
1376
  if (gridTable && this.scrollPosition > 0 && virtualizationEnabled) {
@@ -766,8 +1450,9 @@ export class UiGridStandaloneElement extends HTMLElement {
766
1450
  const pinLabel = isPinned
767
1451
  ? (this.snapshot?.labels.unpin ?? 'Unpin')
768
1452
  : (this.snapshot?.labels.pinColumn ?? 'Pin');
1453
+ const headerValue = escapeHtml(formatGridHeaderDisplayValue(buildGridHeaderContext(column)));
769
1454
 
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>`;
1455
+ return `<div class="${classes}" data-column="${escapeHtml(column.name)}" ${draggable} style="${stickyStyle}"><span class="header-label">${headerValue}</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
1456
  }
772
1457
 
773
1458
  private renderFilterCell(column: GridColumnDef): string {