@ornery/ui-grid-vanilla 5.0.2 → 5.0.3

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 (48) hide show
  1. package/dist/adopt-styles.d.ts +1 -0
  2. package/dist/adopt-styles.d.ts.map +1 -1
  3. package/dist/browser/ui-grid-element.js +254 -73
  4. package/dist/components/grid-filter-cell/grid-filter-cell.template.d.ts.map +1 -1
  5. package/dist/components/grid-group-row/grid-group-row.d.ts +3 -0
  6. package/dist/components/grid-group-row/grid-group-row.d.ts.map +1 -1
  7. package/dist/components/grid-group-row/grid-group-row.template.d.ts +1 -0
  8. package/dist/components/grid-group-row/grid-group-row.template.d.ts.map +1 -1
  9. package/dist/framework-slots.d.ts +2 -0
  10. package/dist/framework-slots.d.ts.map +1 -1
  11. package/dist/grid-controller.d.ts +7 -0
  12. package/dist/grid-controller.d.ts.map +1 -1
  13. package/dist/index.js +234 -65
  14. package/dist/index.mjs +236 -66
  15. package/dist/pagination-range-label.d.ts +8 -0
  16. package/dist/pagination-range-label.d.ts.map +1 -0
  17. package/dist/patch.d.ts +1 -1
  18. package/dist/patch.d.ts.map +1 -1
  19. package/dist/render.d.ts.map +1 -1
  20. package/dist/templates/index.d.ts +2 -1
  21. package/dist/templates/index.d.ts.map +1 -1
  22. package/dist/ui-grid-standalone.element.d.ts +10 -0
  23. package/dist/ui-grid-standalone.element.d.ts.map +1 -1
  24. package/package.json +2 -2
  25. package/src/adopt-styles.ts +31 -1
  26. package/src/auto-resize.test.ts +4 -2
  27. package/src/components/grid-filter-cell/grid-filter-cell.template.ts +4 -3
  28. package/src/components/grid-group-row/grid-group-row.template.ts +6 -4
  29. package/src/components/grid-group-row/grid-group-row.ts +5 -1
  30. package/src/framework-slots.ts +38 -17
  31. package/src/grid-controller.ts +79 -12
  32. package/src/group-row.test.ts +66 -0
  33. package/src/index.integration.test.ts +32 -13
  34. package/src/pagination-range-label.test.ts +85 -0
  35. package/src/pagination-range-label.ts +10 -0
  36. package/src/pagination-range.test.ts +60 -0
  37. package/src/patch.ts +31 -9
  38. package/src/render.pagination.test.ts +37 -0
  39. package/src/render.ts +34 -10
  40. package/src/row-height.test.ts +52 -0
  41. package/src/scrollbar-compensation.test.ts +52 -0
  42. package/src/selection-pinning.test.ts +39 -0
  43. package/src/slot-updates.test.ts +137 -0
  44. package/src/templates/index.ts +8 -4
  45. package/src/theming-hooks.test.ts +66 -0
  46. package/src/ui-grid-empty.template.ts +1 -1
  47. package/src/ui-grid-shell.template.ts +2 -2
  48. package/src/ui-grid-standalone.element.ts +41 -2
@@ -0,0 +1,52 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+
3
+ import { GRID_CORE_CSS } from '@ornery/ui-grid-core';
4
+ import { mountVanillaUiGrid, type UiGridApi } from './index';
5
+
6
+ async function waitFor<T>(resolve: () => T | null | undefined, timeoutMs = 5000): Promise<T> {
7
+ const startedAt = Date.now();
8
+ while (Date.now() - startedAt < timeoutMs) {
9
+ const value = resolve();
10
+ if (value) return value;
11
+ await new Promise((next) => window.setTimeout(next, 20));
12
+ }
13
+ throw new Error('Timed out waiting for expected element state');
14
+ }
15
+
16
+ describe('body scrollbar compensation', () => {
17
+ beforeEach(() => {
18
+ document.body.innerHTML = '<div id="app"></div>';
19
+ });
20
+ afterEach(() => {
21
+ document.body.innerHTML = '';
22
+ });
23
+
24
+ it('narrows the header and filter strips by the body scrollbar width', async () => {
25
+ let api: UiGridApi | undefined;
26
+ const grid = await mountVanillaUiGrid(
27
+ document.getElementById('app')!,
28
+ {
29
+ id: 'scrollbar-compensation',
30
+ enableFiltering: true,
31
+ data: [{ id: 'r1', name: 'Alpha' }],
32
+ columnDefs: [{ name: 'name' }],
33
+ onRegisterApi: (registered) => {
34
+ api = registered as UiGridApi;
35
+ },
36
+ },
37
+ undefined,
38
+ 'ui-grid-element-scrollbar-test',
39
+ );
40
+ await waitFor(() => api);
41
+ const root = grid.shadowRoot!;
42
+ const viewport = await waitFor(() => root.querySelector<HTMLElement>('.grid-body-viewport'));
43
+ Object.defineProperty(viewport, 'offsetWidth', { configurable: true, value: 1000 });
44
+ Object.defineProperty(viewport, 'clientWidth', { configurable: true, value: 985 });
45
+
46
+ api!.core.refresh();
47
+
48
+ const frame = root.querySelector<HTMLElement>('.grid-frame')!;
49
+ await waitFor(() => frame.style.getPropertyValue('--ui-grid-scrollbar-width') === '15px');
50
+ expect(GRID_CORE_CSS).toContain('width: calc(100% - var(--ui-grid-scrollbar-width, 0px))');
51
+ });
52
+ });
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { createVanillaGridController } from './grid-controller';
4
+
5
+ const make = (pinClient: boolean) =>
6
+ createVanillaGridController({
7
+ id: 'selection-pinning',
8
+ data: [{ id: 'r1', client: 'A', amount: 1 }],
9
+ columnDefs: [
10
+ { name: 'client', width: '200px', ...(pinClient ? { pinnedLeft: true } : {}) },
11
+ { name: 'amount', width: '120px' },
12
+ ],
13
+ enablePinning: true,
14
+ enableRowSelection: true,
15
+ enableRowHeaderSelection: true,
16
+ selectionRowHeaderWidth: 30,
17
+ });
18
+
19
+ const column = (controller: ReturnType<typeof make>, name: string) =>
20
+ controller.getSnapshot().visibleColumns.find((candidate) => candidate.name === name)!;
21
+
22
+ describe('selection column pinning', () => {
23
+ it('pins the selection column with left-pinned columns and offsets them past it', () => {
24
+ const controller = make(true);
25
+ const selection = column(controller, 'selectionRowHeaderCol');
26
+ const client = column(controller, 'client');
27
+
28
+ expect(controller.isPinned(selection)).toBe(true);
29
+ expect(controller.pinnedOffset(selection)).toEqual({ side: 'left', offset: '0px' });
30
+ expect(controller.pinnedOffset(client)).toEqual({ side: 'left', offset: 'calc(30px)' });
31
+ expect(controller.isPinnedLeftLast(client)).toBe(true);
32
+ expect(controller.getSnapshot().pinnedColumns).toEqual({ client: 'left' });
33
+ });
34
+
35
+ it('leaves the selection column unpinned when nothing is pinned left', () => {
36
+ const controller = make(false);
37
+ expect(controller.isPinned(column(controller, 'selectionRowHeaderCol'))).toBe(false);
38
+ });
39
+ });
@@ -0,0 +1,137 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+
3
+ import { createVanillaGridController } from './grid-controller';
4
+ import { mountVanillaUiGrid, type UiGridApi } from './index';
5
+ import type { FrameworkFilterSlot, FrameworkSlotDelta } from './framework-slots';
6
+
7
+ async function waitFor<T>(resolve: () => T | null | undefined, timeoutMs = 5000): Promise<T> {
8
+ const startedAt = Date.now();
9
+ while (Date.now() - startedAt < timeoutMs) {
10
+ const value = resolve();
11
+ if (value) return value;
12
+ await new Promise((next) => window.setTimeout(next, 20));
13
+ }
14
+ throw new Error('Timed out waiting for expected element state');
15
+ }
16
+
17
+ describe('framework slot updates', () => {
18
+ beforeEach(() => {
19
+ document.body.innerHTML = '<div id="app"></div>';
20
+ });
21
+ afterEach(() => {
22
+ document.body.innerHTML = '';
23
+ });
24
+
25
+ async function mount(id: string) {
26
+ const tag = 'ui-grid-element-slot-updates-test';
27
+ let api: UiGridApi | undefined;
28
+ const grid = await mountVanillaUiGrid(
29
+ document.getElementById('app')!,
30
+ {
31
+ id,
32
+ enableFiltering: true,
33
+ data: [
34
+ { id: 'r1', name: 'Alpha' },
35
+ { id: 'r2', name: 'Beta' },
36
+ ],
37
+ columnDefs: [{ name: 'name' }],
38
+ onRegisterApi: (registered) => {
39
+ api = registered as UiGridApi;
40
+ },
41
+ },
42
+ undefined,
43
+ tag,
44
+ );
45
+ await waitFor(() => api);
46
+ await waitFor(() => grid.shadowRoot?.querySelector('.body-cell'));
47
+ return { grid, api: api! };
48
+ }
49
+
50
+ it('turns filter slots on after the first render', async () => {
51
+ const { grid } = await mount('slot-toggle');
52
+ const root = grid.shadowRoot!;
53
+ expect(root.querySelector('ui-grid-filter-cell[data-column="name"]')).not.toBeNull();
54
+
55
+ const deltas: FrameworkSlotDelta<FrameworkFilterSlot>[] = [];
56
+ grid.addEventListener('filterSlotsChanged', (event) =>
57
+ deltas.push((event as CustomEvent<FrameworkSlotDelta<FrameworkFilterSlot>>).detail),
58
+ );
59
+ grid.setFrameworkRenderedSlots({ filters: ['name'] });
60
+
61
+ await waitFor(() => root.querySelector('slot[name="filter-name"]'));
62
+ expect(root.querySelector('ui-grid-filter-cell[data-column="name"]')).toBeNull();
63
+ expect(deltas.at(-1)?.added.map((slot) => slot.slotName)).toEqual(['filter-name']);
64
+ });
65
+
66
+ it('reports a filter slot whose value changed as updated', async () => {
67
+ const { grid } = await mount('slot-updated');
68
+ grid.setFrameworkRenderedSlots({ filters: ['name'] });
69
+ await waitFor(() => grid.shadowRoot!.querySelector('slot[name="filter-name"]'));
70
+
71
+ const deltas: FrameworkSlotDelta<FrameworkFilterSlot>[] = [];
72
+ grid.addEventListener('filterSlotsChanged', (event) =>
73
+ deltas.push((event as CustomEvent<FrameworkSlotDelta<FrameworkFilterSlot>>).detail),
74
+ );
75
+ (grid as unknown as { controller: { setFilter(name: string, value: string): void } }).controller.setFilter('name', 'Al');
76
+
77
+ const delta = await waitFor(() => deltas.find((d) => (d.updated ?? []).length > 0));
78
+ expect(delta.added).toEqual([]);
79
+ expect(delta.removed).toEqual([]);
80
+ expect(delta.updated?.map((slot) => [slot.slotName, slot.value])).toEqual([['filter-name', 'Al']]);
81
+ });
82
+
83
+ it.each(['left', 'right'] as const)('keeps %s-pinned filter slots aligned after resizing', async (side) => {
84
+ const { grid } = await mount(`slot-resize-${side}`);
85
+ const pinned = side === 'left' ? { pinnedLeft: true } : { pinnedRight: true };
86
+ grid.options = {
87
+ ...grid.options,
88
+ enablePinning: true,
89
+ columnDefs: [
90
+ { name: 'name', width: '180px', ...pinned },
91
+ { name: 'other', width: '120px', ...pinned },
92
+ ],
93
+ };
94
+ const filtered = side === 'left' ? 'other' : 'name';
95
+ const resized = side === 'left' ? 'name' : 'other';
96
+ grid.setFrameworkRenderedSlots({ filters: [filtered] });
97
+ const slot = await waitFor(() => grid.shadowRoot!.querySelector(`slot[name="filter-${filtered}"]`));
98
+ const wrapper = slot.parentElement!;
99
+ expect(wrapper.style[side]).toBe(side === 'left' ? 'calc(180px)' : 'calc(120px)');
100
+ const controller = (grid as unknown as { controller: ReturnType<typeof createVanillaGridController> }).controller;
101
+ controller.setColumnWidthOverride(resized, 240);
102
+ await waitFor(() => grid.shadowRoot!.querySelector(`[data-column="${resized}"]`));
103
+ expect(wrapper.style[side]).toBe('calc(240px)');
104
+ expect(wrapper.querySelector('slot')).toBe(slot);
105
+ expect(wrapper.classList.contains('is-pinned')).toBe(true);
106
+ });
107
+
108
+ it('fires emptyStateSlotsChanged and the original singular name', async () => {
109
+ const { grid } = await mount('empty-event');
110
+ const seen: string[] = [];
111
+ grid.addEventListener('emptyStateSlotsChanged', () => seen.push('plural'));
112
+ grid.addEventListener('emptyStateSlotChanged', () => seen.push('singular'));
113
+
114
+ grid.setFrameworkRenderedSlots({ emptyState: true });
115
+ grid.options = { ...grid.options, data: [] };
116
+
117
+ await waitFor(() => seen.length >= 2);
118
+ expect(seen.slice(0, 2)).toEqual(['plural', 'singular']);
119
+ });
120
+ });
121
+
122
+ describe('columnSizeChanged', () => {
123
+ it('fires with the column and its new width when a column is resized', () => {
124
+ const controller = createVanillaGridController({
125
+ id: 'column-size',
126
+ data: [{ id: 'r1', name: 'Alpha' }],
127
+ columnDefs: [{ name: 'name' }],
128
+ });
129
+ const events: Array<[string, string]> = [];
130
+ controller.gridApi.core.on.columnSizeChanged((name, width) => events.push([name, width]));
131
+
132
+ controller.setColumnWidthOverride('name', 180.4);
133
+ controller.setColumnWidthOverride('name', 180);
134
+
135
+ expect(events).toEqual([['name', '180px']]);
136
+ });
137
+ });
@@ -21,8 +21,12 @@ export function filterRowMarkup(templateColumns: string, filterCells: string): s
21
21
  return `<div class="filter-grid ui-grid-header" style="grid-template-columns:${templateColumns}">${filterCells}</div>`;
22
22
  }
23
23
 
24
- export function bodyVirtualMarkup(templateColumns: string, totalVirtualHeight: number, virtualOffset: number, bodyContent: string): string {
25
- return `<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>`;
24
+ export function virtualSpacerStyle(totalVirtualHeight: number, rowSize: number): string {
25
+ return `height:${totalVirtualHeight}px;--ui-grid-row-height:${rowSize}px`;
26
+ }
27
+
28
+ export function bodyVirtualMarkup(templateColumns: string, totalVirtualHeight: number, virtualOffset: number, bodyContent: string, rowSize: number): string {
29
+ return `<div class="grid-virtual-spacer" style="${virtualSpacerStyle(totalVirtualHeight, rowSize)}"><div class="body-grid ui-grid-canvas grid-virtual-body" style="grid-template-columns:${templateColumns};top:${virtualOffset}px">${bodyContent}</div></div>`;
26
30
  }
27
31
 
28
32
  export function bodyStaticMarkup(templateColumns: string, bodyContent: string): string {
@@ -30,11 +34,11 @@ export function bodyStaticMarkup(templateColumns: string, bodyContent: string):
30
34
  }
31
35
 
32
36
  export function emptyDataMarkup(heading: string, description: string): string {
33
- return `<div class="empty-state ui-grid-no-row-overlay"><strong>${heading}</strong><p>${description}</p></div>`;
37
+ return `<div part="empty-state" class="empty-state ui-grid-no-row-overlay"><strong>${heading}</strong><p>${description}</p></div>`;
34
38
  }
35
39
 
36
40
  export function expandableRowMarkup(expandableContent: string): string {
37
- return `<div class="expandable-row ui-grid-row ui-grid-expandable-row" style="grid-column:1 / -1">${expandableContent}</div>`;
41
+ return `<div part="expandable-row" class="expandable-row ui-grid-row ui-grid-expandable-row" style="grid-column:1 / -1">${expandableContent}</div>`;
38
42
  }
39
43
 
40
44
  export function treeToggleMarkup(rowId: string, toggleLabel: string, iconViewBox: string, iconPath: string): string {
@@ -0,0 +1,66 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+
3
+ import { adoptStyles } from './adopt-styles';
4
+ import { mountVanillaUiGrid, type UiGridApi } from './index';
5
+
6
+ async function waitFor<T>(resolve: () => T | null | undefined, timeoutMs = 5000): Promise<T> {
7
+ const startedAt = Date.now();
8
+ while (Date.now() - startedAt < timeoutMs) {
9
+ const value = resolve();
10
+ if (value) return value;
11
+ await new Promise((next) => window.setTimeout(next, 20));
12
+ }
13
+ throw new Error('Timed out waiting for expected element state');
14
+ }
15
+
16
+ describe('theming hooks', () => {
17
+ beforeEach(() => {
18
+ document.body.innerHTML = '<div id="app"></div>';
19
+ });
20
+ afterEach(() => {
21
+ document.body.innerHTML = '';
22
+ });
23
+
24
+ async function mount() {
25
+ let api: UiGridApi | undefined;
26
+ const grid = await mountVanillaUiGrid(
27
+ document.getElementById('app')!,
28
+ {
29
+ id: 'theming-hooks',
30
+ enableFiltering: true,
31
+ enablePinning: true,
32
+ data: [{ id: 'r1', name: 'Alpha' }],
33
+ columnDefs: [{ name: 'name' }],
34
+ onRegisterApi: (registered) => {
35
+ api = registered as UiGridApi;
36
+ },
37
+ },
38
+ undefined,
39
+ 'ui-grid-element-theming-test',
40
+ );
41
+ await waitFor(() => api);
42
+ await waitFor(() => grid.shadowRoot?.querySelector('.body-cell'));
43
+ return grid;
44
+ }
45
+
46
+ it('renders the documented part hooks', async () => {
47
+ const root = (await mount()).shadowRoot!;
48
+ const parts = new Set([...root.querySelectorAll('[part]')].map((node) => node.getAttribute('part')));
49
+ for (const part of ['grid-frame', 'header', 'header-cell', 'header-label', 'header-actions', 'pin-menu', 'filter-cell', 'body-cell']) {
50
+ expect(parts, part).toContain(part);
51
+ }
52
+ });
53
+
54
+ it('keeps addStyles sheets after the grid sheets, and removes them', async () => {
55
+ const grid = await mount();
56
+ const root = grid.shadowRoot!;
57
+ const remove = grid.addStyles('.body-cell{color:red}');
58
+ const added = root.adoptedStyleSheets.at(-1)!;
59
+
60
+ adoptStyles(root, '.late{color:blue}', {});
61
+ expect(root.adoptedStyleSheets.at(-1)).toBe(added);
62
+
63
+ remove();
64
+ expect(root.adoptedStyleSheets).not.toContain(added);
65
+ });
66
+ });
@@ -14,7 +14,7 @@ export default function emptyTemplate(ctx: EmptyContext) {
14
14
  adoptStyles(root, shellCss, STYLE_KEY);
15
15
  root.innerHTML =
16
16
  `<section class="grid-shell ui-grid-shell">` +
17
- `<div class="empty-state ui-grid-no-row-overlay">` +
17
+ `<div part="empty-state" class="empty-state ui-grid-no-row-overlay">` +
18
18
  `<strong>${ctx.message}</strong>` +
19
19
  `</div>` +
20
20
  `</section>`;
@@ -24,10 +24,10 @@ export default function gridShellTemplate(ctx: ShellContext) {
24
24
  adoptStyles(target, shellCss, STYLE_KEY);
25
25
  target.innerHTML =
26
26
  `${ctx.slotRegistry}` +
27
- `<section class="grid-frame ui-grid" role="grid" aria-label="${ctx.gridTitle}">` +
27
+ `<section class="grid-frame ui-grid" part="grid-frame" role="grid" aria-label="${ctx.gridTitle}">` +
28
28
  `<div class="grid-table ui-grid-contents-wrapper" style="${ctx.gridTableStyle}">` +
29
29
  `<div class="grid-header-strip">` +
30
- `<div class="header-grid ui-grid-header ui-grid-header-canvas" style="grid-template-columns:${ctx.templateColumns}">` +
30
+ `<div class="header-grid ui-grid-header ui-grid-header-canvas" part="header" style="grid-template-columns:${ctx.templateColumns}">` +
31
31
  ctx.headerContent +
32
32
  `</div>` +
33
33
  `</div>` +
@@ -85,6 +85,7 @@ import {
85
85
  type GridSaveState,
86
86
  type VanillaGridController,
87
87
  } from './grid-controller';
88
+ import { addUserStyles } from './adopt-styles';
88
89
  import emptyTemplate from './ui-grid-empty.template';
89
90
  import gridShellTemplate from './ui-grid-shell.template';
90
91
  import {
@@ -117,6 +118,11 @@ export type VanillaUiGridElement = HTMLElement & {
117
118
  * full contract.
118
119
  */
119
120
  setFrameworkRenderedSlots(config: FrameworkRenderedSlotsConfig): void;
121
+ /**
122
+ * Add CSS to the grid's shadow root, after the grid's own styles so it wins
123
+ * at equal specificity. Returns a function that removes it.
124
+ */
125
+ addStyles(cssText: string): () => void;
120
126
  };
121
127
 
122
128
  type VanillaGridOptions = GridOptions & {
@@ -214,6 +220,11 @@ export class UiGridStandaloneElement extends HTMLElement {
214
220
  this.ensureController(this.buildEffectiveOptions(this.activeOptions));
215
221
  }
216
222
 
223
+ /** Append application CSS after the grid styles; return a removal callback. */
224
+ addStyles(cssText: string): () => void {
225
+ return addUserStyles(this.ensureShadowRoot(), cssText);
226
+ }
227
+
217
228
  /**
218
229
  * Declare which slots are framework-rendered. When a slot is flagged, the
219
230
  * element renders a `<slot name="…">` placeholder at the usual site instead
@@ -240,6 +251,9 @@ export class UiGridStandaloneElement extends HTMLElement {
240
251
  // hasCell() / hasHeader() / etc. — values not captured by the
241
252
  // row-level fingerprint.
242
253
  this.lastRowStateFingerprints.clear();
254
+ // The patch path keeps existing nodes (e.g. a <ui-grid-filter-cell>
255
+ // that should now be a <slot>), so the next pass must be a full render.
256
+ this.lastStructureKey = null;
243
257
  this.render();
244
258
  }
245
259
  }
@@ -707,6 +721,9 @@ export class UiGridStandaloneElement extends HTMLElement {
707
721
  this.templateObserver?.disconnect();
708
722
  this.templateObserver = null;
709
723
  this.teardownAutoResize();
724
+ this.scrollbarObserver?.disconnect();
725
+ this.scrollbarObserver = null;
726
+ this.observedBodyViewport = null;
710
727
  if (this.scrollFrame !== null) {
711
728
  cancelAnimationFrame(this.scrollFrame);
712
729
  this.scrollFrame = null;
@@ -1050,9 +1067,30 @@ export class UiGridStandaloneElement extends HTMLElement {
1050
1067
  });
1051
1068
  }
1052
1069
 
1070
+ this.syncScrollbarCompensation(root);
1053
1071
  this.autoAdjustHeight(snapshot);
1054
1072
  }
1055
1073
 
1074
+ private scrollbarObserver: ResizeObserver | null = null;
1075
+ private observedBodyViewport: HTMLElement | null = null;
1076
+
1077
+ // The header and filter strips have no scrollbar, so they are narrowed by the
1078
+ // body viewport's to keep right-pinned cells and scroll range aligned (#7402).
1079
+ private syncScrollbarCompensation(root: ShadowRoot): void {
1080
+ const frame = root.querySelector<HTMLElement>('.grid-frame');
1081
+ const viewport = root.querySelector<HTMLElement>('.grid-body-viewport');
1082
+ if (!frame || !viewport) return;
1083
+ const width = `${Math.max(0, viewport.offsetWidth - viewport.clientWidth)}px`;
1084
+ if (frame.style.getPropertyValue('--ui-grid-scrollbar-width') !== width) {
1085
+ frame.style.setProperty('--ui-grid-scrollbar-width', width);
1086
+ }
1087
+ if (viewport === this.observedBodyViewport || typeof ResizeObserver === 'undefined') return;
1088
+ this.scrollbarObserver ??= new ResizeObserver(() => this.syncScrollbarCompensation(root));
1089
+ if (this.observedBodyViewport) this.scrollbarObserver.unobserve(this.observedBodyViewport);
1090
+ this.scrollbarObserver.observe(viewport);
1091
+ this.observedBodyViewport = viewport;
1092
+ }
1093
+
1056
1094
  /** @internal */
1057
1095
  private inAutoAdjust = false;
1058
1096
  private lastAutoHeight = 0;
@@ -1259,7 +1297,7 @@ export class UiGridStandaloneElement extends HTMLElement {
1259
1297
  const body =
1260
1298
  snapshot.pipeline.displayItems.length > 0
1261
1299
  ? virtualizationEnabled
1262
- ? bodyVirtualMarkup(templateColumns, totalVirtualHeight, virtualOffset, bodyContent)
1300
+ ? bodyVirtualMarkup(templateColumns, totalVirtualHeight, virtualOffset, bodyContent, snapshot.rowSize)
1263
1301
  : bodyStaticMarkup(templateColumns, bodyContent)
1264
1302
  : this.renderEmptyState(
1265
1303
  options.emptyMessage ?? labels.emptyHeading,
@@ -1318,8 +1356,9 @@ export class UiGridStandaloneElement extends HTMLElement {
1318
1356
  templateColumns: string,
1319
1357
  virtualOffset: number,
1320
1358
  totalVirtualHeight: number,
1359
+ rowSize: number,
1321
1360
  ): HTMLElement | null {
1322
- return reconcileBodyRoot(this, root, kind, options, labels, templateColumns, virtualOffset, totalVirtualHeight);
1361
+ return reconcileBodyRoot(this, root, kind, options, labels, templateColumns, virtualOffset, totalVirtualHeight, rowSize);
1323
1362
  }
1324
1363
 
1325
1364
  private reconcilePagination(