@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,66 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+
3
+ import { mountVanillaUiGrid, type UiGridApi } from './index';
4
+
5
+ async function waitFor<T>(resolve: () => T | null | undefined, timeoutMs = 5000): Promise<T> {
6
+ const startedAt = Date.now();
7
+ while (Date.now() - startedAt < timeoutMs) {
8
+ const value = resolve();
9
+ if (value) return value;
10
+ await new Promise((next) => window.setTimeout(next, 20));
11
+ }
12
+ throw new Error('Timed out waiting for expected element state');
13
+ }
14
+
15
+ describe('group row', () => {
16
+ beforeEach(() => {
17
+ document.body.innerHTML = '<div id="app"></div>';
18
+ });
19
+ afterEach(() => {
20
+ document.body.innerHTML = '';
21
+ });
22
+
23
+ it('shows the column header, the grouped value and a singular suffix', async () => {
24
+ let api: UiGridApi | undefined;
25
+ const grid = await mountVanillaUiGrid(
26
+ document.getElementById('app')!,
27
+ {
28
+ id: 'group-row',
29
+ enableGrouping: true,
30
+ grouping: { groupBy: ['current_status'] },
31
+ data: [
32
+ { id: 'a', done: true },
33
+ { id: 'b', done: false },
34
+ { id: 'c', done: false },
35
+ ],
36
+ columnDefs: [
37
+ {
38
+ name: 'current_status',
39
+ displayName: 'Status',
40
+ valueGetter: (row) => (row['done'] ? 'Finished <b>' : 'Open'),
41
+ },
42
+ ],
43
+ onRegisterApi: (registered) => {
44
+ api = registered as UiGridApi;
45
+ },
46
+ },
47
+ undefined,
48
+ 'ui-grid-element-group-row-test',
49
+ );
50
+ await waitFor(() => api);
51
+ const rows = await waitFor(() => {
52
+ const found = [...(grid.shadowRoot?.querySelectorAll('ui-grid-group-row') ?? [])];
53
+ return found.length === 2 && found.every((row) => row.shadowRoot?.textContent) ? found : null;
54
+ });
55
+
56
+ const text = rows.map((row) => [
57
+ row.shadowRoot!.querySelector('strong')!.textContent,
58
+ row.shadowRoot!.querySelector('strong + span')!.textContent,
59
+ ]);
60
+ expect(text).toEqual([
61
+ ['Status: Finished <b>', '1 row'],
62
+ ['Status: Open', '2 rows'],
63
+ ]);
64
+ expect(rows[0].shadowRoot!.querySelector('b')).toBeNull();
65
+ });
66
+ });
@@ -181,9 +181,24 @@ describe('mountVanillaUiGrid integration', () => {
181
181
  ]);
182
182
  });
183
183
 
184
- it('lazy-loads the wasm engine once and refreshes through it when ready', async () => {
185
- const enableSpy = vi.spyOn(core, 'enableUiGridWasmEngine').mockImplementation(async () => {
186
- core.registerWasmCoreModule({});
184
+ it('never loads the wasm engine on its own', async () => {
185
+ const enableSpy = vi.spyOn(core, 'enableUiGridWasmEngine');
186
+ const controller = createVanillaGridController({
187
+ id: 'controller-wasm-opt-in',
188
+ data: [{ id: 'r1', name: 'Gamma' }],
189
+ columnDefs: [{ name: 'name' }],
190
+ });
191
+
192
+ controller.setFilter('name', 'Gamma');
193
+ await new Promise((resolveNext) => window.setTimeout(resolveNext, 20));
194
+
195
+ expect(enableSpy).not.toHaveBeenCalled();
196
+ expect(core.getWasmCoreInitializationState()).toBe('idle');
197
+ enableSpy.mockRestore();
198
+ });
199
+
200
+ it('switches to the wasm engine when a load the app started finishes', async () => {
201
+ const fakeRustEngine = () =>
187
202
  core.registerRustWasmGridEngine({
188
203
  buildPipeline(context) {
189
204
  return {
@@ -200,7 +215,17 @@ describe('mountVanillaUiGrid integration', () => {
200
215
  };
201
216
  },
202
217
  });
203
- });
218
+ let finishLoad: (module: object) => void = () => undefined;
219
+ core.setUiGridWasmPackageLoader(
220
+ () =>
221
+ new Promise((resolveLoad) => {
222
+ finishLoad = (module) => {
223
+ fakeRustEngine();
224
+ resolveLoad(module);
225
+ };
226
+ }),
227
+ );
228
+ const enabled = core.enableUiGridWasmEngine();
204
229
 
205
230
  const controller = createVanillaGridController({
206
231
  id: 'controller-wasm-bootstrap',
@@ -212,22 +237,16 @@ describe('mountVanillaUiGrid integration', () => {
212
237
  enableSorting: true,
213
238
  enableFiltering: true,
214
239
  });
240
+ expect(controller.getSnapshot().pipeline.pipelineMs).not.toBe(123);
215
241
 
216
- expect(enableSpy).toHaveBeenCalledTimes(1);
217
-
242
+ finishLoad({});
243
+ await enabled;
218
244
  await waitFor(() => {
219
245
  const snapshot = controller.getSnapshot();
220
246
  return activeGridEngineBackend() === 'rust-wasm' && snapshot.pipeline.pipelineMs === 123
221
247
  ? snapshot
222
248
  : null;
223
249
  });
224
-
225
- controller.setFilter('name', 'Alpha');
226
- controller.sortColumn('name', SORT_DIRECTIONS.asc);
227
-
228
- expect(enableSpy).toHaveBeenCalledTimes(1);
229
- expect(controller.getSnapshot().pipeline.pipelineMs).toBe(123);
230
- enableSpy.mockRestore();
231
250
  });
232
251
 
233
252
  it('applies overridable SVG icons for controls', async () => {
@@ -0,0 +1,85 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+
3
+ import { clearRustWasmGridEngine } from '@ornery/ui-grid-core';
4
+
5
+ import { mountVanillaUiGrid, type GridOptions, type VanillaUiGridElement } from './index';
6
+ import { formatPaginationRangeLabel } from './pagination-range-label';
7
+
8
+ const TAG = 'ui-grid-element-vanilla-pagination-label-test';
9
+
10
+ interface Row extends Record<string, unknown> {
11
+ id: string;
12
+ name: string;
13
+ }
14
+
15
+ async function waitFor<T>(resolve: () => T | null | undefined, timeoutMs = 5000): Promise<T> {
16
+ const startedAt = Date.now();
17
+ while (Date.now() - startedAt < timeoutMs) {
18
+ const value = resolve();
19
+ if (value) return value;
20
+ await new Promise((r) => window.setTimeout(r, 10));
21
+ }
22
+ throw new Error('Timed out waiting for expected state');
23
+ }
24
+
25
+ function externalPagedOptions(data: Row[], totalItems: number): GridOptions {
26
+ return {
27
+ id: 'pagination-label-grid',
28
+ data,
29
+ totalItems,
30
+ columnDefs: [{ name: 'name', displayName: 'Name' }],
31
+ enablePagination: true,
32
+ useExternalPagination: true,
33
+ paginationPageSizes: [10],
34
+ rowIdentity: (entity) => String((entity as Row).id),
35
+ };
36
+ }
37
+
38
+ function paginationLabel(grid: VanillaUiGridElement): string | null {
39
+ const pagination = grid.shadowRoot?.querySelector('ui-grid-pagination');
40
+ return pagination?.getAttribute('data-range-label') ?? null;
41
+ }
42
+
43
+ describe('pager range label', () => {
44
+ afterEach(() => {
45
+ clearRustWasmGridEngine();
46
+ document.body.replaceChildren();
47
+ });
48
+
49
+ it('reads "0 of 0" after the grid updates from rows to zero rows', async () => {
50
+ const target = document.createElement('div');
51
+ document.body.appendChild(target);
52
+ const grid = await mountVanillaUiGrid(
53
+ target,
54
+ externalPagedOptions(
55
+ [
56
+ { id: 'r1', name: 'Alpha' },
57
+ { id: 'r2', name: 'Beta' },
58
+ ],
59
+ 2,
60
+ ),
61
+ undefined,
62
+ TAG,
63
+ );
64
+ await waitFor(() => grid.shadowRoot?.querySelector('ui-grid-pagination'));
65
+ expect(paginationLabel(grid)).toBe('1-2 of 2');
66
+
67
+ grid.options = externalPagedOptions([], 0);
68
+
69
+ const label = await waitFor(() => {
70
+ const current = paginationLabel(grid);
71
+ return current && current !== '1-2 of 2' ? current : null;
72
+ });
73
+ expect(label).toBe('0 of 0');
74
+ });
75
+ });
76
+
77
+ describe('formatPaginationRangeLabel', () => {
78
+ it('formats a populated page as a 1-based range', () => {
79
+ expect(formatPaginationRangeLabel({ firstRowIndex: 10, lastRowIndex: 19, totalItems: 42 })).toBe('11-20 of 42');
80
+ });
81
+
82
+ it('formats an empty result as "0 of 0"', () => {
83
+ expect(formatPaginationRangeLabel({ firstRowIndex: 0, lastRowIndex: 0, totalItems: 0 })).toBe('0 of 0');
84
+ });
85
+ });
@@ -0,0 +1,10 @@
1
+ /** Pager range label ("1-10 of 42"). Shared by the initial render and the
2
+ * patch path so both show the same text for empty results ("0 of 0"). */
3
+ export function formatPaginationRangeLabel(range: {
4
+ firstRowIndex: number;
5
+ lastRowIndex: number;
6
+ totalItems: number;
7
+ }): string {
8
+ if (range.totalItems === 0) return '0 of 0';
9
+ return `${range.firstRowIndex + 1}-${range.lastRowIndex + 1} of ${range.totalItems}`;
10
+ }
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { createVanillaGridController } from './grid-controller';
4
+
5
+ const page = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `r${i}`, name: `Row ${i}` }));
6
+
7
+ describe('pagination range', () => {
8
+ it('describes the server page within totalItems under external pagination', () => {
9
+ const controller = createVanillaGridController({
10
+ id: 'external-range',
11
+ data: page(10),
12
+ columnDefs: [{ name: 'name' }],
13
+ enablePagination: true,
14
+ useExternalPagination: true,
15
+ paginationPageSize: 10,
16
+ totalItems: 12,
17
+ });
18
+
19
+ let snapshot = controller.getSnapshot();
20
+ expect([snapshot.firstRowIndex, snapshot.lastRowIndex]).toEqual([0, 9]);
21
+
22
+ controller.setOptions({ ...controller.getSnapshot().options, data: page(2) });
23
+ controller.seekPage(2);
24
+ snapshot = controller.getSnapshot();
25
+ expect([snapshot.firstRowIndex, snapshot.lastRowIndex]).toEqual([10, 11]);
26
+ expect(snapshot.pipeline.visibleRows).toHaveLength(2);
27
+ });
28
+
29
+ it('honors a declarative server page while preserving interactive page changes', () => {
30
+ const options = {
31
+ id: 'controlled-range', data: page(2), columnDefs: [{ name: 'name' }],
32
+ enablePagination: true, useExternalPagination: true, paginationPageSize: 10,
33
+ paginationCurrentPage: 2, totalItems: 32,
34
+ };
35
+ const controller = createVanillaGridController(options);
36
+ expect(controller.getSnapshot().firstRowIndex).toBe(10);
37
+ expect(controller.gridApi.pagination.getFirstRowIndex()).toBe(10);
38
+ controller.seekPage(3);
39
+ controller.setOptions({ ...options, data: page(10) });
40
+ expect(controller.getSnapshot().firstRowIndex).toBe(20);
41
+ controller.setOptions({ ...options, paginationCurrentPage: 4 });
42
+ expect(controller.getSnapshot().firstRowIndex).toBe(30);
43
+ expect(controller.gridApi.pagination.getLastRowIndex()).toBe(31);
44
+ controller.dispose();
45
+ });
46
+
47
+ it('keeps client-side ranges unchanged', () => {
48
+ const controller = createVanillaGridController({
49
+ id: 'client-range',
50
+ data: page(12),
51
+ columnDefs: [{ name: 'name' }],
52
+ enablePagination: true,
53
+ paginationPageSize: 10,
54
+ });
55
+ controller.seekPage(2);
56
+ const snapshot = controller.getSnapshot();
57
+ expect([snapshot.firstRowIndex, snapshot.lastRowIndex]).toEqual([10, 11]);
58
+ expect(snapshot.pipeline.visibleRows).toHaveLength(2);
59
+ });
60
+ });
package/src/patch.ts CHANGED
@@ -31,9 +31,11 @@ import {
31
31
  import {
32
32
  bodyStaticMarkup,
33
33
  bodyVirtualMarkup,
34
+ virtualSpacerStyle,
34
35
  emptyDataMarkup,
35
36
  } from './templates';
36
37
  import type { UiGridStandaloneElement } from './ui-grid-standalone.element';
38
+ import { formatPaginationRangeLabel } from './pagination-range-label';
37
39
  import { bodyCellClass } from './utils/cell-class';
38
40
  import { asGroupItem, isRowItem } from './utils/display-items';
39
41
  import {
@@ -239,6 +241,7 @@ export function reconcileBodyRoot(
239
241
  templateColumns: string,
240
242
  virtualOffset: number,
241
243
  totalVirtualHeight: number,
244
+ rowSize: number,
242
245
  ): HTMLElement | null {
243
246
  const bodyViewport = root.querySelector<HTMLElement>('.grid-body-viewport');
244
247
  if (!bodyViewport) return null;
@@ -283,7 +286,7 @@ export function reconcileBodyRoot(
283
286
  return currentVirtual.querySelector<HTMLElement>('.grid-virtual-body');
284
287
  }
285
288
  const fresh = createFromMarkup(
286
- bodyVirtualMarkup(templateColumns, totalVirtualHeight, virtualOffset, ''),
289
+ bodyVirtualMarkup(templateColumns, totalVirtualHeight, virtualOffset, '', rowSize),
287
290
  );
288
291
  if (fresh) swapBodyChild(bodyViewport, currentNode, fresh);
289
292
  return fresh?.querySelector<HTMLElement>('.grid-virtual-body') ?? null;
@@ -342,16 +345,29 @@ export function patchFilterCells(
342
345
  ): void {
343
346
  const controller = el.controller!;
344
347
  const existing = new Map<string, HTMLElement>();
345
- for (const e of filterGrid.querySelectorAll<HTMLElement>('ui-grid-filter-cell[data-column]')) {
348
+ for (const e of filterGrid.querySelectorAll<HTMLElement>(
349
+ 'ui-grid-filter-cell[data-column], .ui-grid-filter-container[data-column]',
350
+ )) {
346
351
  const column = e.dataset['column'];
347
352
  if (column) existing.set(column, e);
348
353
  }
349
354
 
350
355
  for (const column of snapshot.visibleColumns) {
351
- const target = existing.get(column.name);
352
- if (!target) continue;
353
356
  const value = snapshot.activeFilters[column.name] ?? '';
354
357
  const canFilter = controller.isColumnFilterable(column);
358
+ if (el.frameworkSlots.hasFilter(column.name)) {
359
+ // Framework-rendered filter cells keep their <slot> placeholder; re-stage
360
+ // so the flush does not report the slot as removed.
361
+ el.frameworkSlots.stageFilter(column, value, controller.filterPlaceholder(column), !canFilter);
362
+ const target = existing.get(column.name);
363
+ const pinOffset = controller.isPinned(column) ? controller.pinnedOffset(column) : null;
364
+ if (target) {
365
+ setAttr(target, 'style', pinOffset ? `${pinOffset.side}:${pinOffset.offset};` : '');
366
+ }
367
+ continue;
368
+ }
369
+ const target = existing.get(column.name);
370
+ if (!target) continue;
355
371
  const pinOffset = controller.isPinned(column) ? controller.pinnedOffset(column) : null;
356
372
  const stickyStyle = pinOffset ? `${pinOffset.side}:${pinOffset.offset};` : '';
357
373
  setAttr(target, 'data-value', value);
@@ -398,6 +414,7 @@ export function patchBodyRegion(
398
414
  templateColumns,
399
415
  virtualOffset,
400
416
  totalVirtualHeight,
417
+ snapshot.rowSize,
401
418
  );
402
419
 
403
420
  if (!hasRows) {
@@ -409,8 +426,9 @@ export function patchBodyRegion(
409
426
 
410
427
  if (virtualizationEnabled) {
411
428
  const spacer = root.querySelector<HTMLElement>('.grid-virtual-spacer');
412
- if (spacer && totalVirtualHeight !== el.lastTotalVirtualHeight) {
413
- spacer.setAttribute('style', `height:${totalVirtualHeight}px`);
429
+ const spacerStyle = virtualSpacerStyle(totalVirtualHeight, snapshot.rowSize);
430
+ if (spacer && spacer.getAttribute('style') !== spacerStyle) {
431
+ spacer.setAttribute('style', spacerStyle);
414
432
  el.lastTotalVirtualHeight = totalVirtualHeight;
415
433
  }
416
434
  if (bodyContainer) {
@@ -685,19 +703,19 @@ export function patchGroupRow(
685
703
  target: HTMLElement,
686
704
  group: GroupItem,
687
705
  ): void {
688
- const snapshot = el.snapshot!;
689
706
  const controller = el.controller!;
690
707
  const iconKey = group.collapsed ? 'groupCollapsed' : 'groupExpanded';
691
708
  const icon = el.icons.resolve(iconKey);
692
709
  setAttr(target, 'data-collapsed', group.collapsed ? 'true' : 'false');
693
710
  setAttr(target, 'data-field', group.field);
711
+ setAttr(target, 'data-column-label', controller.groupColumnLabel(group));
694
712
  setAttr(target, 'data-label', group.label);
695
713
  setAttr(target, 'data-count', String(group.count));
696
714
  setAttr(target, 'data-depth', String(group.depth));
697
715
  setAttr(target, 'data-disclosure-label', controller.groupDisclosureLabel(group));
698
716
  setAttr(target, 'data-icon-path', icon.path);
699
717
  setAttr(target, 'data-icon-view-box', icon.viewBox ?? '0 0 24 24');
700
- setAttr(target, 'data-rows-suffix', snapshot.labels.groupRowsSuffix);
718
+ setAttr(target, 'data-rows-suffix', controller.groupRowsSuffix(group));
701
719
  }
702
720
 
703
721
  /** Patch a body-cell element in place: class, sticky style, data-attrs,
@@ -811,7 +829,11 @@ export function patchPagination(
811
829
  setAttr(
812
830
  paginationEl,
813
831
  'data-range-label',
814
- `${snapshot.firstRowIndex + 1}-${snapshot.lastRowIndex + 1} of ${snapshot.pipeline.totalItems}`,
832
+ formatPaginationRangeLabel({
833
+ firstRowIndex: snapshot.firstRowIndex,
834
+ lastRowIndex: snapshot.lastRowIndex,
835
+ totalItems: snapshot.pipeline.totalItems,
836
+ }),
815
837
  );
816
838
  setAttr(paginationEl, 'data-current-page', String(snapshot.currentPage));
817
839
  setAttr(paginationEl, 'data-total-pages', String(snapshot.totalPages));
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { GridControllerSnapshot } from './grid-controller';
3
+ import type { UiGridStandaloneElement } from './ui-grid-standalone.element';
4
+ import { renderPagination } from './render';
5
+
6
+ const icon = { path: 'M0 0', viewBox: '0 0 24 24' };
7
+ const el = { icons: { resolve: () => icon } } as unknown as UiGridStandaloneElement;
8
+
9
+ function snapshotWith(overrides: Partial<GridControllerSnapshot>): GridControllerSnapshot {
10
+ return {
11
+ options: { enablePagination: true, useExternalPagination: true, paginationPageSizes: [10] },
12
+ labels: {
13
+ paginationPage: 'Page',
14
+ paginationOf: 'of',
15
+ paginationPrevious: 'Previous',
16
+ paginationNext: 'Next',
17
+ paginationRows: 'Rows',
18
+ },
19
+ pipeline: { totalItems: 0 },
20
+ currentPage: 1,
21
+ totalPages: 1,
22
+ firstRowIndex: 0,
23
+ lastRowIndex: 0,
24
+ pageSize: 10,
25
+ ...overrides,
26
+ } as unknown as GridControllerSnapshot;
27
+ }
28
+
29
+ describe('renderPagination range label', () => {
30
+ it('shows "0 of 0" with previous and next disabled when there are no rows', () => {
31
+ const markup = renderPagination(el, snapshotWith({}));
32
+
33
+ expect(markup).toContain('data-range-label="0 of 0"');
34
+ expect(markup).toContain('data-prev-disabled="true"');
35
+ expect(markup).toContain('data-next-disabled="true"');
36
+ });
37
+ });
package/src/render.ts CHANGED
@@ -42,6 +42,7 @@ import {
42
42
  slotRegistryMarkup,
43
43
  treeToggleMarkup,
44
44
  } from './templates';
45
+ import { formatPaginationRangeLabel } from './pagination-range-label';
45
46
  import type { UiGridStandaloneElement } from './ui-grid-standalone.element';
46
47
  import { bodyCellClass, headerCellClass } from './utils/cell-class';
47
48
  import { asGroupItem, isRowItem } from './utils/display-items';
@@ -70,7 +71,12 @@ export function renderHeaderCell(
70
71
  const inner = showSelectAll
71
72
  ? `<button type="button" class="ui-grid-selection-select-all" data-action="select-all" aria-label="Select all rows"${selectAll ? ' aria-checked="true"' : ' aria-checked="false"'}><span class="ui-grid-selection-checkbox${selectAll ? ' ui-grid-selection-checkbox-checked' : ''}"></span></button>`
72
73
  : '';
73
- return `<ui-grid-header-cell class="header-cell ui-grid-selection-row-header" data-column="selectionRowHeaderCol">${inner}</ui-grid-header-cell>`;
74
+ const pinOffset = controller.isPinned(column) ? controller.pinnedOffset(column) : null;
75
+ const pinClass = pinOffset
76
+ ? ` is-pinned${controller.isPinnedLeftLast(column) ? ' is-pinned-left-last' : ''}`
77
+ : '';
78
+ const stickyStyle = pinOffset ? ` style="${escapeHtml(`${pinOffset.side}:${pinOffset.offset};`)}"` : '';
79
+ return `<ui-grid-header-cell part="header-cell" class="header-cell ui-grid-selection-row-header${pinClass}"${stickyStyle} data-column="selectionRowHeaderCol">${inner}</ui-grid-header-cell>`;
74
80
  }
75
81
 
76
82
  // Framework-rendered header: emit a bare cell with a slot placeholder.
@@ -82,7 +88,16 @@ export function renderHeaderCell(
82
88
  const isPinned = controller.isPinned(column);
83
89
  const pinOffset = isPinned ? controller.pinnedOffset(column) : null;
84
90
  const stickyStyle = pinOffset ? `${pinOffset.side}:${pinOffset.offset};` : '';
85
- return `<ui-grid-header-cell class="header-cell"${stickyStyle ? ` style="${escapeHtml(stickyStyle)}"` : ''} data-column="${escapeHtml(column.name)}" data-pinned="${isPinned}" data-sticky-style="${escapeHtml(stickyStyle)}"><slot name="${escapeHtml(slotName)}"></slot></ui-grid-header-cell>`;
91
+ const slotClassName = headerCellClass(
92
+ false,
93
+ isPinned,
94
+ controller.isPinnedLeftLast(column),
95
+ controller.isPinnedRightFirst(column),
96
+ false,
97
+ false,
98
+ false,
99
+ );
100
+ return `<ui-grid-header-cell part="header-cell" class="${slotClassName}"${stickyStyle ? ` style="${escapeHtml(stickyStyle)}"` : ''} data-column="${escapeHtml(column.name)}" data-pinned="${isPinned}" data-sticky-style="${escapeHtml(stickyStyle)}"><slot name="${escapeHtml(slotName)}"></slot></ui-grid-header-cell>`;
86
101
  }
87
102
 
88
103
  const sortDirection = controller.getSortDirection(column);
@@ -128,7 +143,7 @@ export function renderHeaderCell(
128
143
  );
129
144
  // className/style/draggable are pre-computed here so the <ui-grid-header-cell>
130
145
  // upgrade is a no-op at parse time. `data-*` stay for event delegation.
131
- return `<ui-grid-header-cell class="${className}"${isDraggable ? ' draggable="true"' : ''}${stickyStyle ? ` style="${escapeHtml(stickyStyle)}"` : ''} data-column="${escapeHtml(column.name)}" data-sort-active="${sortDirection !== SORT_DIRECTIONS.none}" data-pinned="${isPinned}" data-pinned-left-last="${isPinnedLeftLast}" data-pinned-right-first="${isPinnedRightFirst}" data-pin-menu-open="${isPinMenuOpen}" data-drag-target="${isDragTarget}" data-dragging="${isDragging}" data-draggable="${isDraggable}" data-sticky-style="${escapeHtml(stickyStyle)}"><span class="header-label">${headerValue}</span><span class="header-actions">${canSort ? `<button type="button" class="header-action" data-action="sort" data-column="${escapeHtml(column.name)}" aria-label="${escapeHtml(sortLabel)}">${el.icons.renderControlIcon(sortIconKey)}<span class="sr-only">${escapeHtml(sortLabel)}</span></button>` : ''}${canGroup ? `<button type="button" class="chip-action${controller.isColumnGrouped(column) ? ' chip-action-active' : ''}" data-action="group" data-column="${escapeHtml(column.name)}" aria-label="${escapeHtml(groupingLabel)}">${el.icons.renderControlIcon('group')}<span class="sr-only">${escapeHtml(groupingLabel)}</span></button>` : ''}${canPin ? `<div class="pin-control${isPinMenuOpen ? ' 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)}">${el.icons.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(el.snapshot?.labels.pinLeft ?? 'Pin left')}">${el.icons.renderIconWithClass('control-icon', 'pinLeft')}<span class="sr-only">${escapeHtml(el.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(el.snapshot?.labels.pinRight ?? 'Pin right')}">${el.icons.renderIconWithClass('control-icon', 'pinRight')}<span class="sr-only">${escapeHtml(el.snapshot?.labels.pinRight ?? 'Pin right')}</span></button></div></div>` : ''}</span>${resizerHtml}</ui-grid-header-cell>`;
146
+ return `<ui-grid-header-cell part="header-cell" class="${className}"${isDraggable ? ' draggable="true"' : ''}${stickyStyle ? ` style="${escapeHtml(stickyStyle)}"` : ''} data-column="${escapeHtml(column.name)}" data-sort-active="${sortDirection !== SORT_DIRECTIONS.none}" data-pinned="${isPinned}" data-pinned-left-last="${isPinnedLeftLast}" data-pinned-right-first="${isPinnedRightFirst}" data-pin-menu-open="${isPinMenuOpen}" data-drag-target="${isDragTarget}" data-dragging="${isDragging}" data-draggable="${isDraggable}" data-sticky-style="${escapeHtml(stickyStyle)}"><span class="header-label" part="header-label">${headerValue}</span><span class="header-actions" part="header-actions">${canSort ? `<button type="button" class="header-action" data-action="sort" data-column="${escapeHtml(column.name)}" aria-label="${escapeHtml(sortLabel)}">${el.icons.renderControlIcon(sortIconKey)}<span class="sr-only">${escapeHtml(sortLabel)}</span></button>` : ''}${canGroup ? `<button type="button" class="chip-action${controller.isColumnGrouped(column) ? ' chip-action-active' : ''}" data-action="group" data-column="${escapeHtml(column.name)}" aria-label="${escapeHtml(groupingLabel)}">${el.icons.renderControlIcon('group')}<span class="sr-only">${escapeHtml(groupingLabel)}</span></button>` : ''}${canPin ? `<div class="pin-control${isPinMenuOpen ? ' 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)}">${el.icons.renderControlIcon('pin')}<span class="sr-only">${escapeHtml(pinLabel)}</span></button><div class="pin-menu" part="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(el.snapshot?.labels.pinLeft ?? 'Pin left')}">${el.icons.renderIconWithClass('control-icon', 'pinLeft')}<span class="sr-only">${escapeHtml(el.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(el.snapshot?.labels.pinRight ?? 'Pin right')}">${el.icons.renderIconWithClass('control-icon', 'pinRight')}<span class="sr-only">${escapeHtml(el.snapshot?.labels.pinRight ?? 'Pin right')}</span></button></div></div>` : ''}</span>${resizerHtml}</ui-grid-header-cell>`;
132
147
  }
133
148
 
134
149
  /** Render the filter-row cell for one column. Selection column gets a bare
@@ -138,7 +153,8 @@ export function renderFilterCell(el: UiGridStandaloneElement, column: GridColumn
138
153
  if (column.name === 'selectionRowHeaderCol') {
139
154
  const pinOffset = el.controller!.isPinned(column) ? el.controller!.pinnedOffset(column) : null;
140
155
  const stickyStyle = pinOffset ? `${pinOffset.side}:${pinOffset.offset};` : '';
141
- return `<div class="filter-cell ui-grid-selection-row-header" data-column="selectionRowHeaderCol"${stickyStyle ? ` style="${escapeHtml(stickyStyle)}"` : ''}></div>`;
156
+ const pinClass = pinOffset ? ' is-pinned' : '';
157
+ return `<div part="filter-cell" class="filter-cell ui-grid-selection-row-header${pinClass}" data-column="selectionRowHeaderCol"${stickyStyle ? ` style="${escapeHtml(stickyStyle)}"` : ''}></div>`;
142
158
  }
143
159
  const value = el.snapshot?.activeFilters[column.name] ?? '';
144
160
  const controller = el.controller!;
@@ -149,10 +165,13 @@ export function renderFilterCell(el: UiGridStandaloneElement, column: GridColumn
149
165
  if (el.frameworkSlots.hasFilter(column.name)) {
150
166
  el.frameworkSlots.stageFilter(column, value, controller.filterPlaceholder(column), !canFilter);
151
167
  const slotName = filterSlotName(column);
152
- return `<div class="filter-cell ui-grid-filter-container" data-column="${escapeHtml(column.name)}"${stickyStyle ? ` style="${escapeHtml(stickyStyle)}"` : ''}><slot name="${escapeHtml(slotName)}"></slot></div>`;
168
+ const pinClass = pinOffset
169
+ ? ` is-pinned${controller.isPinnedLeftLast(column) ? ' is-pinned-left-last' : ''}${controller.isPinnedRightFirst(column) ? ' is-pinned-right-first' : ''}`
170
+ : '';
171
+ return `<div part="filter-cell" class="filter-cell ui-grid-filter-container${pinClass}" data-column="${escapeHtml(column.name)}"${stickyStyle ? ` style="${escapeHtml(stickyStyle)}"` : ''}><slot name="${escapeHtml(slotName)}"></slot></div>`;
153
172
  }
154
173
 
155
- return `<ui-grid-filter-cell data-column="${escapeHtml(column.name)}" data-value="${escapeHtml(value)}" data-placeholder="${escapeHtml(controller.filterPlaceholder(column))}" data-disabled="${!canFilter}" data-pinned="${controller.isPinned(column)}" data-pinned-left-last="${controller.isPinnedLeftLast(column)}" data-pinned-right-first="${controller.isPinnedRightFirst(column)}" data-sticky-style="${escapeHtml(stickyStyle)}"></ui-grid-filter-cell>`;
174
+ return `<ui-grid-filter-cell part="filter-cell" data-column="${escapeHtml(column.name)}" data-value="${escapeHtml(value)}" data-placeholder="${escapeHtml(controller.filterPlaceholder(column))}" data-disabled="${!canFilter}" data-pinned="${controller.isPinned(column)}" data-pinned-left-last="${controller.isPinnedLeftLast(column)}" data-pinned-right-first="${controller.isPinnedRightFirst(column)}" data-sticky-style="${escapeHtml(stickyStyle)}"></ui-grid-filter-cell>`;
156
175
  }
157
176
 
158
177
  /** Render one display-item: group row, expandable row, or the cells of a
@@ -169,12 +188,12 @@ export function renderDisplayItem(
169
188
  if (el.frameworkSlots.hasGroupRow()) {
170
189
  el.frameworkSlots.stageGroupRow(group);
171
190
  const slotName = groupRowSlotName(group);
172
- return `<div class="group-row ui-grid-row ui-grid-group-row" data-group="${escapeHtml(group.id)}" style="grid-column:1 / -1"><slot name="${escapeHtml(slotName)}"></slot></div>`;
191
+ return `<div part="group-row" class="group-row ui-grid-row ui-grid-group-row" data-group="${escapeHtml(group.id)}" style="grid-column:1 / -1"><slot name="${escapeHtml(slotName)}"></slot></div>`;
173
192
  }
174
193
  const iconKey = group.collapsed ? 'groupCollapsed' : 'groupExpanded';
175
194
  const icon = el.icons.resolve(iconKey);
176
195
  const disclosureLabel = el.controller.groupDisclosureLabel(group);
177
- return `<ui-grid-group-row data-action="toggle-group" data-group="${escapeHtml(group.id)}" data-collapsed="${group.collapsed ? 'true' : 'false'}" data-field="${escapeHtml(group.field)}" data-label="${escapeHtml(group.label)}" data-count="${group.count}" data-depth="${group.depth}" data-disclosure-label="${escapeHtml(disclosureLabel)}" data-icon-path="${icon.path}" data-icon-view-box="${icon.viewBox ?? '0 0 24 24'}" data-rows-suffix="${escapeHtml(el.snapshot.labels.groupRowsSuffix)}"></ui-grid-group-row>`;
196
+ return `<ui-grid-group-row part="group-row" data-action="toggle-group" data-group="${escapeHtml(group.id)}" data-collapsed="${group.collapsed ? 'true' : 'false'}" data-field="${escapeHtml(group.field)}" data-column-label="${escapeHtml(el.controller.groupColumnLabel(group))}" data-label="${escapeHtml(group.label)}" data-count="${group.count}" data-depth="${group.depth}" data-disclosure-label="${escapeHtml(disclosureLabel)}" data-icon-path="${icon.path}" data-icon-view-box="${icon.viewBox ?? '0 0 24 24'}" data-rows-suffix="${escapeHtml(el.controller.groupRowsSuffix(group))}"></ui-grid-group-row>`;
178
197
  }
179
198
 
180
199
  if (item.kind === 'expandable') {
@@ -268,7 +287,7 @@ export function renderBodyCell(
268
287
  const titleAttr = isCellInvalid
269
288
  ? ` title="${escapeHtml(controller.gridApi.validate.getTitleFormattedErrors(row.entity, column))}"`
270
289
  : '';
271
- return `<ui-grid-body-cell class="${className}" tabindex="0"${stickyStyle ? ` style="${escapeHtml(stickyStyle)}"` : ''}${titleAttr} data-row="${escapeHtml(rowId)}" data-column="${escapeHtml(columnName)}" data-odd="${displayIndex % 2 !== 0}" data-align="${escapeHtml(align)}" data-pinned="${isPinned}" data-pinned-left-last="${isPinnedLeftLast}" data-pinned-right-first="${isPinnedRightFirst}" data-focused="${isFocused}" data-editing="${editing}" data-sticky-style="${escapeHtml(stickyStyle)}"><div class="cell-shell" style="padding-inline-start:${escapeHtml(controller.cellIndent(row, column))}">${treeToggle}${expandToggle}<div class="cell-content">${content}</div></div></ui-grid-body-cell>`;
290
+ return `<ui-grid-body-cell part="body-cell" class="${className}" tabindex="0"${stickyStyle ? ` style="${escapeHtml(stickyStyle)}"` : ''}${titleAttr} data-row="${escapeHtml(rowId)}" data-column="${escapeHtml(columnName)}" data-odd="${displayIndex % 2 !== 0}" data-align="${escapeHtml(align)}" data-pinned="${isPinned}" data-pinned-left-last="${isPinnedLeftLast}" data-pinned-right-first="${isPinnedRightFirst}" data-focused="${isFocused}" data-editing="${editing}" data-sticky-style="${escapeHtml(stickyStyle)}"><div class="cell-shell" style="padding-inline-start:${escapeHtml(controller.cellIndent(row, column))}">${treeToggle}${expandToggle}<div class="cell-content">${content}</div></div></ui-grid-body-cell>`;
272
291
  }
273
292
 
274
293
  /** Render the contents of `.cell-shell` (tree/expand toggles + the
@@ -331,7 +350,12 @@ export function renderPagination(
331
350
  const pageSizes = snapshot.options.paginationPageSizes ?? [10, 25, 50, 100];
332
351
  const prevIcon = el.icons.resolve('paginationPrev');
333
352
  const nextIcon = el.icons.resolve('paginationNext');
334
- return `<ui-grid-pagination data-range-label="${escapeHtml(`${snapshot.firstRowIndex + 1}-${snapshot.lastRowIndex + 1} of ${snapshot.pipeline.totalItems}`)}" data-current-page="${snapshot.currentPage}" data-total-pages="${snapshot.totalPages}" data-page-label="${escapeHtml(snapshot.labels.paginationPage)}" data-of-label="${escapeHtml(snapshot.labels.paginationOf)}" data-prev-label="${escapeHtml(snapshot.labels.paginationPrevious)}" data-next-label="${escapeHtml(snapshot.labels.paginationNext)}" data-rows-label="${escapeHtml(snapshot.labels.paginationRows)}" data-prev-icon-path="${prevIcon.path}" data-prev-icon-view-box="${prevIcon.viewBox ?? '0 0 24 24'}" data-next-icon-path="${nextIcon.path}" data-next-icon-view-box="${nextIcon.viewBox ?? '0 0 24 24'}" data-page-sizes="${escapeHtml(JSON.stringify(pageSizes))}" data-page-size="${snapshot.pageSize}" data-prev-disabled="${snapshot.currentPage <= 1}" data-next-disabled="${snapshot.currentPage >= snapshot.totalPages}"></ui-grid-pagination>`;
353
+ const rangeLabel = formatPaginationRangeLabel({
354
+ firstRowIndex: snapshot.firstRowIndex,
355
+ lastRowIndex: snapshot.lastRowIndex,
356
+ totalItems: snapshot.pipeline.totalItems,
357
+ });
358
+ return `<ui-grid-pagination part="pagination" data-range-label="${escapeHtml(rangeLabel)}" data-current-page="${snapshot.currentPage}" data-total-pages="${snapshot.totalPages}" data-page-label="${escapeHtml(snapshot.labels.paginationPage)}" data-of-label="${escapeHtml(snapshot.labels.paginationOf)}" data-prev-label="${escapeHtml(snapshot.labels.paginationPrevious)}" data-next-label="${escapeHtml(snapshot.labels.paginationNext)}" data-rows-label="${escapeHtml(snapshot.labels.paginationRows)}" data-prev-icon-path="${prevIcon.path}" data-prev-icon-view-box="${prevIcon.viewBox ?? '0 0 24 24'}" data-next-icon-path="${nextIcon.path}" data-next-icon-view-box="${nextIcon.viewBox ?? '0 0 24 24'}" data-page-sizes="${escapeHtml(JSON.stringify(pageSizes))}" data-page-size="${snapshot.pageSize}" data-prev-disabled="${snapshot.currentPage <= 1}" data-next-disabled="${snapshot.currentPage >= snapshot.totalPages}"></ui-grid-pagination>`;
335
359
  }
336
360
 
337
361
  /** Hidden `<slot>` registry block so the element's shadow DOM exposes a
@@ -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('virtual row height', () => {
17
+ beforeEach(() => {
18
+ document.body.innerHTML = '<div id="app"></div>';
19
+ });
20
+ afterEach(() => {
21
+ document.body.innerHTML = '';
22
+ });
23
+
24
+ it('holds virtualized rows to rowHeight', async () => {
25
+ let api: UiGridApi | undefined;
26
+ const grid = await mountVanillaUiGrid(
27
+ document.getElementById('app')!,
28
+ {
29
+ id: 'row-height',
30
+ rowHeight: 60,
31
+ virtualizationThreshold: 5,
32
+ data: Array.from({ length: 50 }, (_, i) => ({ id: `r${i}`, name: `Row ${i}` })),
33
+ columnDefs: [{ name: 'name' }],
34
+ onRegisterApi: (registered) => {
35
+ api = registered as UiGridApi;
36
+ },
37
+ },
38
+ undefined,
39
+ 'ui-grid-element-row-height-test',
40
+ );
41
+ await waitFor(() => api);
42
+ const spacer = await waitFor(() =>
43
+ grid.shadowRoot?.querySelector<HTMLElement>('.grid-virtual-spacer'),
44
+ );
45
+
46
+ expect(spacer.style.getPropertyValue('--ui-grid-row-height')).toBe('60px');
47
+ expect(spacer.style.height).toBe('3000px');
48
+ expect(GRID_CORE_CSS).toMatch(
49
+ /\.grid-virtual-body > :not\(\.expandable-row\) \{[^}]*height: var\(--ui-grid-row-height\)/,
50
+ );
51
+ });
52
+ });