@ornery/ui-grid-vanilla 0.1.9 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +196 -0
  2. package/demo/index.html +3 -3
  3. package/demo/main.ts +1 -1
  4. package/dist/index.d.mts +584 -55
  5. package/dist/index.d.ts +584 -55
  6. package/dist/index.js +4887 -1404
  7. package/dist/index.mjs +5056 -1526
  8. package/package.json +11 -8
  9. package/scss-plugin.ts +65 -0
  10. package/src/adopt-styles.ts +39 -0
  11. package/src/attribute-bridge.ts +291 -0
  12. package/src/auto-height.test.ts +142 -0
  13. package/src/auto-resize.test.ts +206 -0
  14. package/src/cell-nav.test.ts +262 -0
  15. package/src/components/grid-body-cell/grid-body-cell.ts +23 -0
  16. package/src/components/grid-body-cell/index.ts +1 -0
  17. package/src/components/grid-cell-editor/grid-cell-editor.ts +104 -0
  18. package/src/components/grid-cell-editor/index.ts +1 -0
  19. package/src/components/grid-filter-cell/grid-filter-cell.scss +14 -0
  20. package/src/components/grid-filter-cell/grid-filter-cell.template.ts +20 -0
  21. package/src/components/grid-filter-cell/grid-filter-cell.ts +121 -0
  22. package/src/components/grid-filter-cell/index.ts +1 -0
  23. package/src/components/grid-group-row/grid-group-row.scss +24 -0
  24. package/src/components/grid-group-row/grid-group-row.template.ts +23 -0
  25. package/src/components/grid-group-row/grid-group-row.ts +94 -0
  26. package/src/components/grid-group-row/index.ts +1 -0
  27. package/src/components/grid-header-cell/grid-header-cell.ts +17 -0
  28. package/src/components/grid-header-cell/index.ts +1 -0
  29. package/src/components/grid-pagination/grid-pagination.scss +39 -0
  30. package/src/components/grid-pagination/grid-pagination.template.ts +40 -0
  31. package/src/components/grid-pagination/grid-pagination.ts +204 -0
  32. package/src/components/grid-pagination/index.ts +1 -0
  33. package/src/components/grid-template/grid-template.ts +166 -0
  34. package/src/components/grid-template/index.ts +1 -0
  35. package/src/events.ts +815 -0
  36. package/src/exporter.test.ts +186 -0
  37. package/src/focus.ts +353 -0
  38. package/src/framework-slots.ts +440 -0
  39. package/src/grid-controller.ts +1431 -20
  40. package/src/icons.ts +88 -0
  41. package/src/importer.test.ts +147 -0
  42. package/src/index.integration.test.ts +2 -2
  43. package/src/index.ts +9 -0
  44. package/src/infinite-scroll.test.ts +220 -0
  45. package/src/keyboard-navigation.test.ts +579 -0
  46. package/src/patch.ts +722 -0
  47. package/src/render.ts +465 -0
  48. package/src/row-edit.test.ts +191 -0
  49. package/src/row-selection.test.ts +550 -0
  50. package/src/save-state.test.ts +235 -0
  51. package/src/scss.d.ts +4 -0
  52. package/src/templates/index.ts +62 -0
  53. package/src/test-setup.ts +23 -0
  54. package/src/ui-grid-empty.template.ts +23 -0
  55. package/src/ui-grid-shell.scss +25 -0
  56. package/src/ui-grid-shell.template.ts +45 -0
  57. package/src/ui-grid-standalone.element.ts +751 -1032
  58. package/src/utils/cell-class.ts +70 -0
  59. package/src/utils/display-items.ts +15 -0
  60. package/src/utils/dom.ts +64 -0
  61. package/src/validate.test.ts +170 -0
  62. package/tsup.config.ts +10 -0
  63. package/vite.config.ts +10 -1
@@ -1,44 +1,105 @@
1
1
  import {
2
- GRID_CORE_CSS,
3
- SORT_DIRECTIONS,
4
- buildGridHeaderContext,
5
2
  canGridMoveColumns,
6
- downloadGridCsvFile,
7
- exportCsvRows,
8
- formatGridHeaderDisplayValue,
9
- getCellValue,
10
- sanitizeDownloadFilename,
3
+ interpolateGridTemplate,
11
4
  type DisplayItem,
12
5
  type GridColumnDef,
13
6
  type GridOptions,
14
7
  type GridRecord,
15
- type GroupItem,
16
8
  type GridRow,
9
+ type GroupItem,
17
10
  } from '@ornery/ui-grid-core';
11
+ import {
12
+ FrameworkSlotBridge,
13
+ type FrameworkRenderedSlotsConfig,
14
+ } from './framework-slots';
15
+ export type {
16
+ FrameworkRenderedSlotsConfig,
17
+ FrameworkCellSlot,
18
+ FrameworkHeaderSlot,
19
+ FrameworkFilterSlot,
20
+ FrameworkGroupRowSlot,
21
+ FrameworkExpandableRowSlot,
22
+ FrameworkEmptyStateSlot,
23
+ FrameworkSlotDelta,
24
+ } from './framework-slots';
25
+ import {
26
+ OBSERVED_GRID_ATTRIBUTES,
27
+ parseGridAttributeOptions,
28
+ } from './attribute-bridge';
29
+ import {
30
+ getTemplateMarkup as getTemplateMarkupFromLightDom,
31
+ renderBodyCell,
32
+ renderCellTemplate,
33
+ renderCellTemplateFromMarkup,
34
+ renderDisplayItem,
35
+ renderEmptyState,
36
+ renderExpandableTemplate,
37
+ renderFilterCell,
38
+ renderHeaderCell,
39
+ renderPagination,
40
+ renderSlotRegistry,
41
+ } from './render';
42
+ import {
43
+ fingerprintItems,
44
+ patchBodyCell,
45
+ patchCells,
46
+ patchExistingRows,
47
+ patchFilterCells,
48
+ patchGroupRow,
49
+ patchPagination,
50
+ reconcileBodyRoot,
51
+ reconcilePagination,
52
+ renderPatch,
53
+ } from './patch';
54
+ import {
55
+ applyFocusedCellClass,
56
+ commitAndMove,
57
+ focusCellElement,
58
+ handleRowHeaderCheckboxClick,
59
+ handleRowSelectionClick,
60
+ matchesKeyOverride,
61
+ measureAutoColumnWidth,
62
+ moveGridFocus,
63
+ scrollFocusedRowIntoView,
64
+ type GridMoveDirection,
65
+ } from './focus';
66
+ import {
67
+ bindEvents,
68
+ maybeTriggerInfiniteScroll,
69
+ observeTemplateSlots,
70
+ syncHeaderHorizontalScroll,
71
+ } from './events';
72
+ import { bodyCellClass } from './utils/cell-class';
73
+ import { asGroupItem, isRowItem } from './utils/display-items';
74
+ import { escapeHtml } from './utils/dom';
75
+ import {
76
+ IconRegistry,
77
+ type UiGridControlIconKey,
78
+ type UiGridIconDefinition,
79
+ type UiGridIconOverrides,
80
+ } from './icons';
81
+ export type { UiGridControlIconKey, UiGridIconDefinition, UiGridIconOverrides } from './icons';
18
82
  import {
19
83
  createVanillaGridController,
20
84
  type GridControllerSnapshot,
21
85
  type GridSaveState,
22
86
  type VanillaGridController,
23
87
  } from './grid-controller';
24
-
25
- function escapeHtml(value: unknown): string {
26
- const text = String(value ?? '');
27
- return text
28
- .replace(/&/g, '&')
29
- .replace(/</g, '&lt;')
30
- .replace(/>/g, '&gt;')
31
- .replace(/"/g, '&quot;')
32
- .replace(/'/g, '&#39;');
33
- }
34
-
35
- function asGroupItem(item: DisplayItem): GroupItem {
36
- return item as GroupItem;
37
- }
38
-
39
- function isRowItem(item: DisplayItem): item is DisplayItem & { kind: 'row'; row: GridRow } {
40
- return item.kind === 'row';
41
- }
88
+ import emptyTemplate from './ui-grid-empty.template';
89
+ import gridShellTemplate from './ui-grid-shell.template';
90
+ import {
91
+ bodyStaticMarkup,
92
+ bodyVirtualMarkup,
93
+ emptyDataMarkup,
94
+ filterRowMarkup,
95
+ } from './templates';
96
+ import { UIGridFilterCell } from './components/grid-filter-cell';
97
+ import { UIGridGroupRow } from './components/grid-group-row';
98
+ import { UIGridPagination } from './components/grid-pagination';
99
+ import { UIGridBodyCell } from './components/grid-body-cell';
100
+ import { UIGridHeaderCell } from './components/grid-header-cell';
101
+ import { UIGridTemplate } from './components/grid-template';
102
+ import { UIGridCellEditor } from './components/grid-cell-editor';
42
103
 
43
104
  export type VanillaUiGridElement = HTMLElement & {
44
105
  options: GridOptions;
@@ -52,123 +113,71 @@ export type VanillaUiGridElement = HTMLElement & {
52
113
  setData(rows: GridRecord[]): void;
53
114
  };
54
115
 
55
- export type UiGridControlIconKey =
56
- | 'sortNone'
57
- | 'sortAsc'
58
- | 'sortDesc'
59
- | 'group'
60
- | 'groupExpanded'
61
- | 'groupCollapsed'
62
- | 'treeExpanded'
63
- | 'treeCollapsed'
64
- | 'expandExpanded'
65
- | 'expandCollapsed'
66
- | 'pin'
67
- | 'pinLeft'
68
- | 'pinRight'
69
- | 'paginationPrev'
70
- | 'paginationNext';
71
-
72
- export interface UiGridIconDefinition {
73
- viewBox?: string;
74
- path: string;
75
- }
76
-
77
- export type UiGridIconOverrides = Partial<Record<UiGridControlIconKey, UiGridIconDefinition>>;
78
-
79
116
  type VanillaGridOptions = GridOptions & {
80
117
  iconOverrides?: UiGridIconOverrides;
81
118
  };
82
119
 
83
- const DEFAULT_ICONS: Record<UiGridControlIconKey, UiGridIconDefinition> = {
84
- sortNone: { path: 'M7 6h10v2H7V6Zm0 5h7v2H7v-2Zm0 5h4v2H7v-2Z' },
85
- sortAsc: { path: 'M12 5l-6 6h4v8h4v-8h4z' },
86
- sortDesc: { path: 'M12 19l6-6h-4V5h-4v8H6z' },
87
- group: { path: 'M4 6h8v4H4V6Zm0 8h8v4H4v-4Zm10-8h6v4h-6V6Zm0 8h6v4h-6v-4Z' },
88
- groupExpanded: { path: 'M7 10l5 5 5-5z' },
89
- groupCollapsed: { path: 'M10 7l5 5-5 5z' },
90
- treeExpanded: { path: 'M7 10l5 5 5-5z' },
91
- treeCollapsed: { path: 'M10 7l5 5-5 5z' },
92
- expandExpanded: { path: 'M7 10l5 5 5-5z' },
93
- expandCollapsed: { path: 'M10 7l5 5-5 5z' },
94
- pin: { path: 'M16 12V4h1V2H7v2h1v8l-2 2v2h5v6l1 1 1-1v-6h5v-2l-2-2z' },
95
- pinLeft: { path: 'M10 6 4 12l6 6v-4h10v-4H10V6z' },
96
- pinRight: { path: 'M14 6v4H4v4h10v4l6-6-6-6z' },
97
- paginationPrev: { path: 'M15.41 7.41 14 6l-6 6 6 6 1.41-1.41L10.83 12z' },
98
- paginationNext: { path: 'M8.59 16.59 10 18l6-6-6-6-1.41 1.41L13.17 12z' },
99
- };
100
-
101
120
  export class UiGridStandaloneElement extends HTMLElement {
102
- private controller: VanillaGridController | null = null;
103
- private snapshot: GridControllerSnapshot | null = null;
104
- private unsubscribe: (() => void) | null = null;
105
- private activeOptions: VanillaGridOptions | null = null;
106
- private attributeOptions: Partial<GridOptions> = {};
107
- private attributeSyncScheduled = false;
108
- private iconOverrides: UiGridIconOverrides = {};
109
- private templateObserver: MutationObserver | null = null;
110
- private openPinMenuColumn: string | null = null;
111
- private focusedCell: { rowId: string; columnName: string } | null = null;
112
- private draggedColumnName: string | null = null;
113
- private dropTargetColumnName: string | null = null;
114
- private scrollPosition = 0;
115
- private horizontalScrollPosition = 0;
116
- private scrollFrame: number | null = null;
117
- private suppressScrollEvent = false;
118
- private lastVirtualStartIndex = -1;
119
- private measuredHeaderStickyHeight = 0;
120
- private measuredFilterStickyHeight = 0;
121
- private stickyHeightRelayoutQueued = false;
122
- private benchmarkAverage = '—';
123
- private skipNextRender = false;
124
- private dataFrame: number | null = null;
125
- private pendingPatchedRowIds: Set<string> | null = null;
126
- private pendingDataRefreshMode: 'patch' | 'virtual' | 'full' | null = null;
127
- private lastScrollActivityAt = 0;
121
+ // Internal fields. Marked `public` (no access modifier) rather than
122
+ // `private` because the render / patch / events / focus modules live in
123
+ // their own files and read this state directly. TypeScript has no real
124
+ // "module-internal" visibility, so we use `@internal` JSDoc to flag
125
+ // fields that aren't part of the public API. Consumers of the web
126
+ // component see only `options`, `controlIcons`, `setFrameworkRenderedSlots`,
127
+ // and the template-facing strings (`gridTitle`, etc.).
128
+ /** @internal */ controller: VanillaGridController | null = null;
129
+ /** @internal */ snapshot: GridControllerSnapshot | null = null;
130
+ /** @internal */ unsubscribe: (() => void) | null = null;
131
+ /** @internal */ activeOptions: VanillaGridOptions | null = null;
132
+ /** @internal */ attributeOptions: Partial<GridOptions> = {};
133
+ /** @internal */ attributeSyncScheduled = false;
134
+ /** @internal Owns the SVG-icon registry: defaults merged with consumer
135
+ * overrides plus rendering helpers. See `./icons.ts`. */
136
+ icons = new IconRegistry();
137
+ /** @internal */ templateObserver: MutationObserver | null = null;
138
+ /** @internal */ openPinMenuColumn: string | null = null;
139
+ /** @internal */ focusedCell: { rowId: string; columnName: string } | null = null;
140
+ /** @internal */ draggedColumnName: string | null = null;
141
+ /** @internal */ dropTargetColumnName: string | null = null;
142
+ /** @internal */ scrollPosition = 0;
143
+ /** @internal */ horizontalScrollPosition = 0;
144
+ /** @internal */ scrollFrame: number | null = null;
145
+ /** @internal */ suppressScrollEvent = false;
146
+ /** @internal */ lastVirtualStartIndex = -1;
147
+ /** @internal */ measuredHeaderStickyHeight = 0;
148
+ /** @internal */ measuredFilterStickyHeight = 0;
149
+ /** @internal */ stickyHeightRelayoutQueued = false;
150
+ /** @internal */ benchmarkAverage = '—';
151
+ /** @internal */ skipNextRender = false;
152
+ /** @internal */ autoResizeObserver: ResizeObserver | null = null;
153
+ /** @internal */ autoResizeDebounceHandle: number | null = null;
154
+
155
+ // Template-facing properties for the grid shell template
156
+ gridTitle = 'Data grid';
157
+ gridTableStyle = '';
158
+ bodyViewportStyle = '';
159
+ templateColumns = '';
160
+ slotRegistry = '';
161
+ headerContent = '';
162
+ filterRowContent = '';
163
+ bodyContent = '';
164
+ paginationContent = '';
165
+ /** @internal */ dataFrame: number | null = null;
166
+ /** @internal */ pendingPatchedRowIds: Set<string> | null = null;
167
+ /** @internal */ pendingDataRefreshMode: 'patch' | 'virtual' | 'full' | null = null;
168
+ /** @internal */ lastScrollActivityAt = 0;
169
+ /** @internal */ lastStructureKey: string | null = null;
170
+ /** @internal */ lastItemsFingerprint: string | null = null;
171
+ /** @internal */ lastVirtualOffset = 0;
172
+ /** @internal */ lastTotalVirtualHeight = 0;
173
+
174
+ /**
175
+ * @internal Framework-rendered slot bridge. See `./framework-slots.ts`.
176
+ */
177
+ frameworkSlots = new FrameworkSlotBridge(this);
128
178
 
129
179
  static get observedAttributes(): string[] {
130
- return [
131
- // Scalar attributes
132
- 'grid-id',
133
- 'title',
134
- 'row-height',
135
- 'header-row-height',
136
- 'viewport-height',
137
- 'pagination-page-size',
138
- 'pagination-current-page',
139
- 'total-items',
140
- 'virtualization-threshold',
141
- 'tree-children-field',
142
- 'tree-indent',
143
- 'expandable-row-height',
144
- 'expandable-row-header-width',
145
- 'empty-message',
146
- 'infinite-scroll-rows-from-end',
147
- // JSON attributes
148
- 'column-defs',
149
- 'data',
150
- 'grouping',
151
- 'pagination-page-sizes',
152
- // Boolean flags
153
- 'enable-sorting',
154
- 'enable-filtering',
155
- 'enable-grouping',
156
- 'enable-pinning',
157
- 'enable-column-moving',
158
- 'enable-cell-edit',
159
- 'enable-cell-edit-on-focus',
160
- 'enable-pagination',
161
- 'enable-pagination-controls',
162
- 'use-external-pagination',
163
- 'enable-expandable',
164
- 'enable-tree-view',
165
- 'show-tree-expand-no-children',
166
- 'tree-row-header-always-visible',
167
- 'enable-auto-resize',
168
- 'enable-virtualization',
169
- 'infinite-scroll-up',
170
- 'infinite-scroll-down',
171
- ];
180
+ return [...OBSERVED_GRID_ATTRIBUTES];
172
181
  }
173
182
 
174
183
  get options(): GridOptions {
@@ -186,10 +195,36 @@ export class UiGridStandaloneElement extends HTMLElement {
186
195
 
187
196
  set options(value: GridOptions) {
188
197
  this.activeOptions = value as VanillaGridOptions;
189
- this.iconOverrides = this.activeOptions.iconOverrides ?? {};
198
+ this.icons.setOverrides(this.activeOptions.iconOverrides ?? {});
190
199
  this.ensureController(this.buildEffectiveOptions(this.activeOptions));
191
200
  }
192
201
 
202
+ /**
203
+ * Declare which slots are framework-rendered. When a slot is flagged, the
204
+ * element renders a `<slot name="…">` placeholder at the usual site instead
205
+ * of running its string-interpolation template, and dispatches
206
+ * `*SlotsChanged` events so a wrapper can insert light-DOM content with a
207
+ * matching `slot` attribute.
208
+ *
209
+ * Pass an empty object (or `{}`) to revert every slot kind back to the
210
+ * default interpolation path. Individual fields behave the same way:
211
+ * omitting `cells` means the cells list is unchanged; passing
212
+ * `cells: []` clears it.
213
+ *
214
+ * This is the bridge the Angular / React wrappers use to project native
215
+ * templates (ng-template, JSX render props) into the grid. Consumers of
216
+ * the web component directly can ignore this method — the default
217
+ * `<template slot="cell-columnName">` workflow still works.
218
+ */
219
+ setFrameworkRenderedSlots(config: FrameworkRenderedSlotsConfig): void {
220
+ if (this.frameworkSlots.configure(config)) {
221
+ // The set of slot placeholders changed — force a structural re-render
222
+ // so framework-rendered slots transition to `<slot>` nodes (or the
223
+ // reverse, when a slot is revoked).
224
+ this.render();
225
+ }
226
+ }
227
+
193
228
  attributeChangedCallback(
194
229
  _name: string,
195
230
  _oldValue: string | null,
@@ -206,198 +241,47 @@ export class UiGridStandaloneElement extends HTMLElement {
206
241
  }
207
242
 
208
243
  private syncAttributesToOptions(): void {
209
- this.attributeOptions = {};
210
-
211
- // Scalar string attributes
212
- const gridId = this.getAttribute('grid-id');
213
- if (gridId !== null) this.attributeOptions.id = gridId;
214
-
215
- const title = this.getAttribute('title');
216
- if (title !== null) this.attributeOptions.title = title;
217
-
218
- const emptyMessage = this.getAttribute('empty-message');
219
- if (emptyMessage !== null) this.attributeOptions.emptyMessage = emptyMessage;
220
-
221
- const treeChildrenField = this.getAttribute('tree-children-field');
222
- if (treeChildrenField !== null) this.attributeOptions.treeChildrenField = treeChildrenField;
223
-
224
- // Scalar number attributes
225
- const rowHeight = this.parseNumberAttribute('row-height');
226
- if (rowHeight !== undefined) this.attributeOptions.rowHeight = rowHeight;
227
-
228
- const headerRowHeight = this.parseNumberAttribute('header-row-height');
229
- if (headerRowHeight !== undefined)
230
- this.attributeOptions.headerRowHeight = headerRowHeight;
231
-
232
- const viewportHeight = this.parseNumberAttribute('viewport-height');
233
- if (viewportHeight !== undefined) this.attributeOptions.viewportHeight = viewportHeight;
234
-
235
- const paginationPageSize = this.parseNumberAttribute('pagination-page-size');
236
- if (paginationPageSize !== undefined)
237
- this.attributeOptions.paginationPageSize = paginationPageSize;
238
-
239
- const paginationCurrentPage = this.parseNumberAttribute('pagination-current-page');
240
- if (paginationCurrentPage !== undefined)
241
- this.attributeOptions.paginationCurrentPage = paginationCurrentPage;
242
-
243
- const totalItems = this.parseNumberAttribute('total-items');
244
- if (totalItems !== undefined) this.attributeOptions.totalItems = totalItems;
245
-
246
- const virtualizationThreshold = this.parseNumberAttribute('virtualization-threshold');
247
- if (virtualizationThreshold !== undefined)
248
- this.attributeOptions.virtualizationThreshold = virtualizationThreshold;
249
-
250
- const treeIndent = this.parseNumberAttribute('tree-indent');
251
- if (treeIndent !== undefined) this.attributeOptions.treeIndent = treeIndent;
252
-
253
- const expandableRowHeight = this.parseNumberAttribute('expandable-row-height');
254
- if (expandableRowHeight !== undefined)
255
- this.attributeOptions.expandableRowHeight = expandableRowHeight;
256
-
257
- const expandableRowHeaderWidth = this.parseNumberAttribute(
258
- 'expandable-row-header-width',
259
- );
260
- if (expandableRowHeaderWidth !== undefined)
261
- this.attributeOptions.expandableRowHeaderWidth = expandableRowHeaderWidth;
262
-
263
- const infiniteScrollRowsFromEnd = this.parseNumberAttribute(
264
- 'infinite-scroll-rows-from-end',
265
- );
266
- if (infiniteScrollRowsFromEnd !== undefined)
267
- this.attributeOptions.infiniteScrollRowsFromEnd = infiniteScrollRowsFromEnd;
268
-
269
- // Boolean attributes
270
- const enableSorting = this.parseBooleanAttribute('enable-sorting');
271
- if (enableSorting !== undefined) this.attributeOptions.enableSorting = enableSorting;
272
-
273
- const enableFiltering = this.parseBooleanAttribute('enable-filtering');
274
- if (enableFiltering !== undefined) this.attributeOptions.enableFiltering = enableFiltering;
275
-
276
- const enableGrouping = this.parseBooleanAttribute('enable-grouping');
277
- if (enableGrouping !== undefined) this.attributeOptions.enableGrouping = enableGrouping;
278
-
279
- const enablePinning = this.parseBooleanAttribute('enable-pinning');
280
- if (enablePinning !== undefined) this.attributeOptions.enablePinning = enablePinning;
281
-
282
- const enableColumnMoving = this.parseBooleanAttribute('enable-column-moving');
283
- if (enableColumnMoving !== undefined)
284
- this.attributeOptions.enableColumnMoving = enableColumnMoving;
285
-
286
- const enableCellEdit = this.parseBooleanAttribute('enable-cell-edit');
287
- if (enableCellEdit !== undefined) this.attributeOptions.enableCellEdit = enableCellEdit;
288
-
289
- const enableCellEditOnFocus = this.parseBooleanAttribute('enable-cell-edit-on-focus');
290
- if (enableCellEditOnFocus !== undefined)
291
- this.attributeOptions.enableCellEditOnFocus = enableCellEditOnFocus;
292
-
293
- const enablePagination = this.parseBooleanAttribute('enable-pagination');
294
- if (enablePagination !== undefined)
295
- this.attributeOptions.enablePagination = enablePagination;
296
-
297
- const enablePaginationControls = this.parseBooleanAttribute(
298
- 'enable-pagination-controls',
299
- );
300
- if (enablePaginationControls !== undefined)
301
- this.attributeOptions.enablePaginationControls = enablePaginationControls;
244
+ this.attributeOptions = parseGridAttributeOptions(this);
302
245
 
303
- const useExternalPagination = this.parseBooleanAttribute('use-external-pagination');
304
- if (useExternalPagination !== undefined)
305
- this.attributeOptions.useExternalPagination = useExternalPagination;
306
-
307
- const enableExpandable = this.parseBooleanAttribute('enable-expandable');
308
- if (enableExpandable !== undefined) this.attributeOptions.enableExpandable = enableExpandable;
309
-
310
- const enableTreeView = this.parseBooleanAttribute('enable-tree-view');
311
- if (enableTreeView !== undefined) this.attributeOptions.enableTreeView = enableTreeView;
312
-
313
- const showTreeExpandNoChildren = this.parseBooleanAttribute(
314
- 'show-tree-expand-no-children',
315
- );
316
- if (showTreeExpandNoChildren !== undefined)
317
- this.attributeOptions.showTreeExpandNoChildren = showTreeExpandNoChildren;
318
-
319
- const treeRowHeaderAlwaysVisible = this.parseBooleanAttribute(
320
- 'tree-row-header-always-visible',
321
- );
322
- if (treeRowHeaderAlwaysVisible !== undefined)
323
- this.attributeOptions.treeRowHeaderAlwaysVisible = treeRowHeaderAlwaysVisible;
324
-
325
- const enableAutoResize = this.parseBooleanAttribute('enable-auto-resize');
326
- if (enableAutoResize !== undefined) this.attributeOptions.enableAutoResize = enableAutoResize;
327
-
328
- const enableVirtualization = this.parseBooleanAttribute('enable-virtualization');
329
- if (enableVirtualization !== undefined)
330
- this.attributeOptions.enableVirtualization = enableVirtualization;
331
-
332
- const infiniteScrollUp = this.parseBooleanAttribute('infinite-scroll-up');
333
- if (infiniteScrollUp !== undefined) this.attributeOptions.infiniteScrollUp = infiniteScrollUp;
334
-
335
- const infiniteScrollDown = this.parseBooleanAttribute('infinite-scroll-down');
336
- if (infiniteScrollDown !== undefined)
337
- this.attributeOptions.infiniteScrollDown = infiniteScrollDown;
338
-
339
- // JSON attributes
340
- const columnDefs = this.parseJsonAttribute<GridColumnDef[]>('column-defs');
341
- if (columnDefs !== undefined) this.attributeOptions.columnDefs = columnDefs;
342
-
343
- const data = this.parseJsonAttribute<GridRecord[]>('data');
344
- if (data !== undefined) this.attributeOptions.data = data;
345
-
346
- const grouping = this.parseJsonAttribute('grouping');
347
- if (grouping !== undefined && grouping !== null)
348
- this.attributeOptions.grouping = grouping;
349
-
350
- const paginationPageSizes = this.parseJsonAttribute<number[] | null>(
351
- 'pagination-page-sizes',
352
- );
353
- if (paginationPageSizes !== undefined)
354
- this.attributeOptions.paginationPageSizes = paginationPageSizes;
355
-
356
- // Re-render with the merged options.
246
+ // Re-render with the merged options. Both paths funnel through
247
+ // buildEffectiveOptions so derived fields (e.g. the default
248
+ // `expandableRowTemplate` when `enableExpandable` is true) are applied
249
+ // regardless of whether options came from an imperative `grid.options =`
250
+ // assignment or purely from HTML attributes.
357
251
  if (this.activeOptions !== null) {
358
252
  this.ensureController(this.buildEffectiveOptions(this.activeOptions));
359
253
  } else {
360
- // Purely declarative path: attributes drive the grid without an imperative bridge.
361
- this.ensureController({
362
- id: '__ui-grid-pending__',
363
- data: [],
364
- columnDefs: [],
365
- ...this.attributeOptions,
366
- } as VanillaGridOptions);
367
- }
368
- }
369
-
370
- private parseBooleanAttribute(name: string): boolean | undefined {
371
- return this.hasAttribute(name) ? true : undefined;
372
- }
373
-
374
- private parseNumberAttribute(name: string): number | undefined {
375
- const raw = this.getAttribute(name);
376
- if (raw === null) return undefined;
377
- const parsed = Number(raw);
378
- return Number.isFinite(parsed) ? parsed : undefined;
379
- }
380
-
381
- private parseJsonAttribute<T = unknown>(name: string): T | undefined {
382
- const raw = this.getAttribute(name);
383
- if (raw === null) return undefined;
384
- try {
385
- return JSON.parse(raw) as T;
386
- } catch (e) {
387
- console.warn(`<ui-grid-element>: invalid JSON in "${name}" attribute`, e);
388
- return undefined;
254
+ // Purely declarative path: the attribute-only options are already
255
+ // populated on `this.attributeOptions` (including `data` / `columnDefs`
256
+ // when the consumer set `[attr.data]` / `[attr.column-defs]`). Pass
257
+ // the minimum identity so `buildEffectiveOptions`'s merge doesn't
258
+ // clobber attribute-provided arrays with empty fallbacks — the
259
+ // attribute values always win because the spread order is
260
+ // `{ ...attributeOptions, ...options }`.
261
+ const fallbackId = this.attributeOptions.id ?? '__ui-grid-pending__';
262
+ this.ensureController(
263
+ this.buildEffectiveOptions({
264
+ id: fallbackId,
265
+ data: this.attributeOptions.data ?? [],
266
+ columnDefs: this.attributeOptions.columnDefs ?? [],
267
+ } as VanillaGridOptions),
268
+ );
389
269
  }
390
270
  }
391
271
 
392
272
  get controlIcons(): UiGridIconOverrides {
393
- return this.iconOverrides;
273
+ return this.icons.getOverrides();
394
274
  }
395
275
 
396
276
  set controlIcons(value: UiGridIconOverrides) {
397
- this.iconOverrides = { ...value };
277
+ this.icons.setOverrides(value);
398
278
  this.render();
399
279
  }
400
280
 
281
+ private resolveIcon(key: UiGridControlIconKey): UiGridIconDefinition {
282
+ return this.icons.resolve(key);
283
+ }
284
+
401
285
  // ─────────────────────────────────────────────────────────────────
402
286
  // Individual property accessors (mirrors of HTML attributes)
403
287
  // ─────────────────────────────────────────────────────────────────
@@ -436,14 +320,6 @@ export class UiGridStandaloneElement extends HTMLElement {
436
320
  this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
437
321
  }
438
322
 
439
- get viewportHeight(): number {
440
- return this.options.viewportHeight ?? 560;
441
- }
442
- set viewportHeight(value: number) {
443
- this.activeOptions = { ...this.activeOptions, viewportHeight: value } as any;
444
- this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
445
- }
446
-
447
323
  get paginationPageSize(): number {
448
324
  return this.options.paginationPageSize ?? 25;
449
325
  }
@@ -509,7 +385,10 @@ export class UiGridStandaloneElement extends HTMLElement {
509
385
  }
510
386
 
511
387
  get emptyMessage(): string {
512
- return this.options.emptyMessage ?? 'No data available.';
388
+ // Fall back to the grid's label pack so consumers can re-localize the
389
+ // empty heading through `options.labels.emptyHeading` rather than
390
+ // hardcoding an English string here.
391
+ return this.options.emptyMessage ?? this.snapshot?.labels.emptyHeading ?? '';
513
392
  }
514
393
  set emptyMessage(value: string) {
515
394
  this.activeOptions = { ...this.activeOptions, emptyMessage: value } as any;
@@ -580,6 +459,14 @@ export class UiGridStandaloneElement extends HTMLElement {
580
459
  this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
581
460
  }
582
461
 
462
+ get enableColumnResizing(): boolean {
463
+ return (this.options as any).enableColumnResizing ?? true;
464
+ }
465
+ set enableColumnResizing(value: boolean) {
466
+ this.activeOptions = { ...this.activeOptions, enableColumnResizing: value } as any;
467
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
468
+ }
469
+
583
470
  get enableCellEdit(): boolean {
584
471
  return this.options.enableCellEdit ?? false;
585
472
  }
@@ -779,62 +666,14 @@ export class UiGridStandaloneElement extends HTMLElement {
779
666
  }
780
667
 
781
668
  private patchCells(changedRowIds?: ReadonlySet<string>): void {
782
- const snapshot = this.snapshot;
783
- if (!snapshot) {
784
- return;
785
- }
786
-
787
- const root = this.shadowRoot;
788
- if (!root) {
789
- return;
790
- }
791
-
792
- // Build O(1) lookup maps from the current pipeline snapshot.
793
- const rowMap = new Map<string, GridRow>();
794
- const rowIndexMap = new Map<string, number>();
795
- for (const row of snapshot.pipeline.visibleRows) {
796
- rowMap.set(row.id, row);
797
- rowIndexMap.set(row.id, rowIndexMap.size);
798
- }
799
- const colMap = new Map<string, GridColumnDef>();
800
- const templateMarkupMap = new Map<string, string | null>();
801
- for (const col of snapshot.visibleColumns) {
802
- colMap.set(col.name, col);
803
- templateMarkupMap.set(col.name, this.getTemplateMarkup(this.cellSlotName(col)));
804
- }
805
-
806
- // Patch every rendered body cell in-place.
807
- const cells = root.querySelectorAll<HTMLElement>('[data-row][data-column]');
808
- for (const cell of cells) {
809
- const rowId = cell.dataset['row'];
810
- const colName = cell.dataset['column'];
811
- if (!rowId || !colName) continue;
812
- if (changedRowIds && !changedRowIds.has(rowId)) continue;
813
-
814
- const row = rowMap.get(rowId);
815
- const column = colMap.get(colName);
816
- if (!row || !column) continue;
817
- if (cell.classList.contains('cell-editing')) continue;
818
-
819
- const cellContent = cell.querySelector<HTMLElement>('.cell-content');
820
- if (!cellContent) continue;
821
-
822
- const newContent = this.renderCellTemplateFromMarkup(
823
- row,
824
- column,
825
- rowIndexMap.get(rowId) ?? 0,
826
- templateMarkupMap.get(colName) ?? null,
827
- );
828
- if (cellContent.innerHTML !== newContent) {
829
- cellContent.innerHTML = newContent;
830
- }
831
- }
669
+ patchCells(this, changedRowIds);
832
670
  }
833
671
 
834
672
  connectedCallback(): void {
835
673
  this.ensureShadowRoot();
836
674
  this.bindEvents();
837
675
  this.observeTemplateSlots();
676
+ this.setupAutoResize();
838
677
 
839
678
  if (this.activeOptions) {
840
679
  this.ensureController(this.buildEffectiveOptions(this.activeOptions));
@@ -848,13 +687,18 @@ export class UiGridStandaloneElement extends HTMLElement {
848
687
  this.unsubscribe = null;
849
688
  this.templateObserver?.disconnect();
850
689
  this.templateObserver = null;
690
+ this.teardownAutoResize();
851
691
  if (this.scrollFrame !== null) {
852
692
  cancelAnimationFrame(this.scrollFrame);
853
693
  this.scrollFrame = null;
854
694
  }
695
+ // Emit `removed` events for every framework-rendered slot so wrappers
696
+ // can destroy their views.
697
+ this.frameworkSlots.flushRemovals();
855
698
  }
856
699
 
857
- private ensureShadowRoot(): ShadowRoot {
700
+ /** @internal */
701
+ ensureShadowRoot(): ShadowRoot {
858
702
  if (!this.shadowRoot) {
859
703
  this.attachShadow({ mode: 'open' });
860
704
  }
@@ -862,41 +706,103 @@ export class UiGridStandaloneElement extends HTMLElement {
862
706
  return this.shadowRoot as ShadowRoot;
863
707
  }
864
708
 
865
- private ensureController(options: VanillaGridOptions): void {
709
+ /** @internal */
710
+ ensureController(options: VanillaGridOptions): void {
866
711
  if (this.controller) {
867
712
  this.controller.setOptions(options);
868
713
  return;
869
714
  }
870
715
 
871
716
  this.controller = createVanillaGridController(options);
717
+ // Let the controller's cellNav.scrollToFocus delegate into our
718
+ // element's scroll-and-focus helpers.
719
+ this.controller.setCellNavScrollHandler((rowId, columnName) => {
720
+ if (!rowId || !columnName) return;
721
+ this.scrollFocusedRowIntoView(rowId);
722
+ this.focusCellElement(rowId, columnName);
723
+ });
724
+ // Infinite-scroll resetScroll() brings the viewport back to the top.
725
+ this.controller.setInfiniteScrollResetHandler(() => {
726
+ const bodyViewport = this.shadowRoot?.querySelector<HTMLElement>('.grid-body-viewport');
727
+ if (bodyViewport) {
728
+ bodyViewport.scrollTop = 0;
729
+ this.scrollPosition = 0;
730
+ }
731
+ });
732
+ // saveState scroll capture/restore — element owns the DOM element, so
733
+ // it provides the accessors the controller uses when save()/setState()
734
+ // handle scroll-position fields.
735
+ this.controller.setSaveStateScrollHandlers(
736
+ () => ({ scrollTop: this.scrollPosition, scrollLeft: this.horizontalScrollPosition }),
737
+ (scrollTop, scrollLeft) => {
738
+ const bodyViewport = this.shadowRoot?.querySelector<HTMLElement>('.grid-body-viewport');
739
+ if (!bodyViewport) return;
740
+ // Defer to a rAF so the body has finished laying out after refresh.
741
+ requestAnimationFrame(() => {
742
+ bodyViewport.scrollTop = scrollTop;
743
+ bodyViewport.scrollLeft = scrollLeft;
744
+ this.scrollPosition = scrollTop;
745
+ this.horizontalScrollPosition = scrollLeft;
746
+ // Keep header/filter strips aligned after a restore.
747
+ this.syncHeaderHorizontalScroll(scrollLeft);
748
+ });
749
+ },
750
+ );
751
+ // Importer file-picker: the element mounts a hidden <input type="file">
752
+ // the first time the controller asks for one, then keeps it around so
753
+ // subsequent imports reuse it. The controller stays DOM-free.
754
+ this.controller.setImporterFilePickerHandler(() => {
755
+ this.openImporterFilePicker();
756
+ });
872
757
  this.unsubscribe = this.controller.subscribe((snapshot) => {
873
758
  this.snapshot = snapshot;
874
759
  this.render();
875
760
  });
876
761
  }
877
762
 
878
- private observeTemplateSlots(): void {
879
- if (this.templateObserver) {
880
- return;
763
+ /** Hidden file input used by `gridApi.importer.importAFile()`. Lazily
764
+ * created on first use and reused thereafter. */
765
+ private importerFileInput: HTMLInputElement | null = null;
766
+
767
+ private openImporterFilePicker(): void {
768
+ if (!this.controller) return;
769
+ if (!this.importerFileInput) {
770
+ const input = document.createElement('input');
771
+ input.type = 'file';
772
+ input.accept = '.csv,.json,text/csv,application/json,text/plain';
773
+ input.style.display = 'none';
774
+ input.addEventListener('change', () => {
775
+ const file = input.files?.[0];
776
+ if (file && this.controller) {
777
+ void this.controller.gridApi.importer.importThisFile(file);
778
+ }
779
+ // Clear so the same file can be re-imported.
780
+ input.value = '';
781
+ });
782
+ this.appendChild(input);
783
+ this.importerFileInput = input;
881
784
  }
785
+ this.importerFileInput.click();
786
+ }
882
787
 
883
- this.templateObserver = new MutationObserver(() => {
884
- if (this.activeOptions) {
885
- this.ensureController(this.buildEffectiveOptions(this.activeOptions));
886
- } else {
887
- this.render();
888
- }
889
- });
788
+ private observeTemplateSlots(): void {
789
+ observeTemplateSlots(this);
790
+ }
890
791
 
891
- this.templateObserver.observe(this, {
892
- childList: true,
893
- subtree: true,
894
- attributes: true,
895
- attributeFilter: ['slot'],
896
- });
792
+ private bindEvents(): void {
793
+ bindEvents(this);
794
+ }
795
+
796
+ private syncHeaderHorizontalScroll(scrollLeft: number): void {
797
+ syncHeaderHorizontalScroll(this, scrollLeft);
798
+ }
799
+
800
+ private maybeTriggerInfiniteScroll(): void {
801
+ maybeTriggerInfiniteScroll(this);
897
802
  }
898
803
 
899
- private buildEffectiveOptions(options: VanillaGridOptions): VanillaGridOptions {
804
+ /** @internal */
805
+ buildEffectiveOptions(options: VanillaGridOptions): VanillaGridOptions {
900
806
  // Merge: attributes are overridden by explicit JS property values.
901
807
  // JS property always wins: { ...attributeOptions, ...options }
902
808
  const merged = {
@@ -904,8 +810,7 @@ export class UiGridStandaloneElement extends HTMLElement {
904
810
  ...options,
905
811
  } as VanillaGridOptions;
906
812
 
907
- const hasExpandableSlot = this.getTemplateMarkup('expandable-row') !== null;
908
- if (merged.enableExpandable && hasExpandableSlot && !merged.expandableRowTemplate) {
813
+ if (merged.enableExpandable && !merged.expandableRowTemplate) {
909
814
  merged.expandableRowTemplate = {
910
815
  createEmbeddedView: () => undefined,
911
816
  };
@@ -914,397 +819,28 @@ export class UiGridStandaloneElement extends HTMLElement {
914
819
  return merged;
915
820
  }
916
821
 
917
- private bindEvents(): void {
918
- const root = this.ensureShadowRoot();
919
- if ((root as ShadowRoot & { __uiGridBound?: boolean }).__uiGridBound) {
920
- return;
921
- }
922
-
923
- root.addEventListener('click', (event) => {
924
- const target = event.target as HTMLElement | null;
925
- if (!target || !this.controller || !this.snapshot) {
926
- return;
927
- }
928
-
929
- const actionNode = target.closest<HTMLElement>('[data-action]');
930
- if (!actionNode) {
931
- return;
932
- }
933
-
934
- const action = actionNode.dataset['action'];
935
- if (!action) {
936
- return;
937
- }
938
-
939
- if (this.openPinMenuColumn && !target.closest('.pin-control')) {
940
- this.openPinMenuColumn = null;
941
- this.render();
942
- return;
943
- }
944
-
945
- if (action === 'sort') {
946
- const columnName = actionNode.dataset['column'];
947
- if (columnName) {
948
- this.controller.toggleSort(columnName);
949
- }
950
- return;
951
- }
952
-
953
- if (action === 'group') {
954
- const columnName = actionNode.dataset['column'];
955
- if (columnName) {
956
- this.controller.toggleGrouping(columnName);
957
- }
958
- return;
959
- }
960
-
961
- if (action === 'toggle-group') {
962
- const groupId = actionNode.dataset['group'];
963
- if (groupId) {
964
- const collapsed = actionNode.dataset['collapsed'] !== 'true';
965
- this.controller.setCollapsedGroup(groupId, collapsed);
966
- }
967
- return;
968
- }
969
-
970
- if (action === 'pin-trigger') {
971
- const columnName = actionNode.dataset['column'];
972
- const column = columnName
973
- ? this.snapshot.visibleColumns.find((candidate) => candidate.name === columnName)
974
- : undefined;
975
-
976
- if (columnName && column) {
977
- if (this.controller.isPinned(column)) {
978
- this.openPinMenuColumn = null;
979
- this.controller.pinColumn(columnName, 'none');
980
- return;
981
- }
982
-
983
- this.openPinMenuColumn = this.openPinMenuColumn === columnName ? null : columnName;
984
- this.render();
985
- }
986
- return;
987
- }
988
-
989
- if (action === 'pin-left' || action === 'pin-right') {
990
- const columnName = actionNode.dataset['column'];
991
- if (columnName) {
992
- this.openPinMenuColumn = null;
993
- this.controller.pinColumn(columnName, action === 'pin-left' ? 'left' : 'right');
994
- }
995
- return;
996
- }
997
-
998
- if (action === 'toggle-tree') {
999
- const rowId = actionNode.dataset['row'];
1000
- if (rowId) {
1001
- this.controller.toggleTreeRow(rowId);
1002
- }
1003
- return;
1004
- }
1005
-
1006
- if (action === 'toggle-expand') {
1007
- const rowId = actionNode.dataset['row'];
1008
- if (rowId) {
1009
- this.controller.toggleRowExpansion(rowId);
1010
- }
1011
- return;
1012
- }
1013
-
1014
- if (action === 'page-prev') {
1015
- this.controller.seekPage(this.snapshot.currentPage - 1);
1016
- return;
1017
- }
1018
-
1019
- if (action === 'page-next') {
1020
- this.controller.seekPage(this.snapshot.currentPage + 1);
1021
- return;
1022
- }
1023
-
1024
- if (action === 'benchmark') {
1025
- const result = this.controller.gridApi.core.benchmark();
1026
- this.benchmarkAverage = result.averageMs.toFixed(2);
1027
- this.render();
1028
- return;
1029
- }
1030
-
1031
- if (action === 'export-csv') {
1032
- this.downloadCsv();
1033
- }
1034
- });
1035
-
1036
- root.addEventListener('focusin', (event) => {
1037
- const target = event.target as HTMLElement | null;
1038
- if (!target) {
1039
- return;
1040
- }
1041
-
1042
- const cell = target.closest<HTMLElement>('.body-cell[data-row][data-column]');
1043
- const rowId = cell?.dataset['row'];
1044
- const columnName = cell?.dataset['column'];
1045
- if (rowId && columnName) {
1046
- this.focusedCell = { rowId, columnName };
1047
- }
1048
- });
1049
-
1050
- root.addEventListener('focusout', (event) => {
1051
- const relatedTarget = (event as FocusEvent).relatedTarget as HTMLElement | null;
1052
- if (!relatedTarget?.closest('.body-cell[data-row][data-column]')) {
1053
- this.focusedCell = null;
1054
- }
1055
- });
1056
-
1057
- root.addEventListener('input', (event) => {
1058
- const target = event.target as HTMLElement | null;
1059
- if (!target || !this.controller) {
1060
- return;
1061
- }
1062
-
1063
- if (target instanceof HTMLInputElement && target.dataset['role'] === 'filter') {
1064
- const columnName = target.dataset['column'];
1065
- if (columnName) {
1066
- this.controller.setFilter(columnName, target.value);
1067
- }
1068
- }
1069
-
1070
- if (target instanceof HTMLInputElement && target.dataset['role'] === 'editor') {
1071
- this.controller.updateEditingValue(target.value);
1072
- }
1073
- });
1074
-
1075
- root.addEventListener('change', (event) => {
1076
- const target = event.target as HTMLElement | null;
1077
- if (!target || !this.controller) {
1078
- return;
1079
- }
1080
-
1081
- if (target instanceof HTMLSelectElement && target.dataset['role'] === 'page-size') {
1082
- const value = Number.parseInt(target.value, 10);
1083
- this.controller.setPageSize(value);
1084
- }
1085
- });
1086
-
1087
- root.addEventListener('dblclick', (event) => {
1088
- const target = event.target as HTMLElement | null;
1089
- if (!target || !this.controller) {
1090
- return;
1091
- }
1092
-
1093
- const cell = target.closest<HTMLElement>('.body-cell');
1094
- if (!cell) {
1095
- return;
1096
- }
1097
-
1098
- const rowId = cell.dataset['row'];
1099
- const columnName = cell.dataset['column'];
1100
- if (rowId && columnName) {
1101
- this.controller.beginCellEdit(rowId, columnName, event);
1102
- }
1103
- });
1104
-
1105
- root.addEventListener('keydown', (event) => {
1106
- const keyboardEvent = event as KeyboardEvent;
1107
- const target = event.target as HTMLElement | null;
1108
- if (!target || !this.controller) {
1109
- return;
1110
- }
1111
-
1112
- if (target instanceof HTMLInputElement && target.dataset['role'] === 'editor') {
1113
- if (keyboardEvent.key === 'Enter') {
1114
- event.preventDefault();
1115
- this.controller.commitCellEdit();
1116
- return;
1117
- }
1118
-
1119
- if (keyboardEvent.key === 'Escape') {
1120
- event.preventDefault();
1121
- this.controller.cancelCellEdit();
1122
- }
1123
-
1124
- return;
1125
- }
1126
-
1127
- const cell = target.closest<HTMLElement>('.body-cell');
1128
- if (!cell) {
1129
- return;
1130
- }
1131
-
1132
- const rowId = cell.dataset['row'];
1133
- const columnName = cell.dataset['column'];
1134
- if (!rowId || !columnName) {
1135
- return;
1136
- }
1137
-
1138
- if (keyboardEvent.key === 'Enter' || keyboardEvent.key === 'F2') {
1139
- event.preventDefault();
1140
- this.controller.beginCellEdit(rowId, columnName, event);
1141
- return;
1142
- }
1143
-
1144
- if (
1145
- keyboardEvent.key.length === 1 &&
1146
- !keyboardEvent.ctrlKey &&
1147
- !keyboardEvent.metaKey &&
1148
- !keyboardEvent.altKey
1149
- ) {
1150
- event.preventDefault();
1151
- this.controller.beginCellEdit(rowId, columnName, event);
1152
- this.controller.updateEditingValue(keyboardEvent.key);
1153
- }
1154
- });
1155
-
1156
- root.addEventListener(
1157
- 'blur',
1158
- (event) => {
1159
- const target = event.target as HTMLElement | null;
1160
- if (!target || !this.controller) {
1161
- return;
1162
- }
1163
-
1164
- if (target instanceof HTMLInputElement && target.dataset['role'] === 'editor') {
1165
- this.controller.commitCellEdit();
1166
- }
1167
- },
1168
- true,
1169
- );
1170
-
1171
- root.addEventListener('dragstart', (event) => {
1172
- const dragEvent = event as DragEvent;
1173
- const target = event.target as HTMLElement | null;
1174
- const headerCell = target?.closest<HTMLElement>('.header-cell[data-column]');
1175
-
1176
- if (!headerCell || !this.snapshot || !canGridMoveColumns(this.snapshot.options)) {
1177
- event.preventDefault();
1178
- return;
1179
- }
1180
-
1181
- const columnName = headerCell.dataset['column'];
1182
- if (!columnName || !dragEvent.dataTransfer) {
1183
- event.preventDefault();
1184
- return;
1185
- }
1186
-
1187
- this.draggedColumnName = columnName;
1188
- this.dropTargetColumnName = null;
1189
- dragEvent.dataTransfer.effectAllowed = 'move';
1190
- dragEvent.dataTransfer.setData('text/plain', columnName);
1191
- headerCell.classList.add('is-dragging');
1192
- });
1193
-
1194
- root.addEventListener('dragover', (event) => {
1195
- const dragEvent = event as DragEvent;
1196
- const target = event.target as HTMLElement | null;
1197
- const headerCell = target?.closest<HTMLElement>('.header-cell[data-column]');
1198
-
1199
- if (!headerCell || !this.draggedColumnName || !dragEvent.dataTransfer) {
1200
- return;
1201
- }
1202
-
1203
- const columnName = headerCell.dataset['column'];
1204
- if (!columnName || columnName === this.draggedColumnName) {
1205
- return;
1206
- }
1207
-
1208
- event.preventDefault();
1209
- dragEvent.dataTransfer.dropEffect = 'move';
1210
-
1211
- if (this.dropTargetColumnName !== columnName) {
1212
- root
1213
- .querySelectorAll('.header-cell.is-drag-target')
1214
- .forEach((element) => element.classList.remove('is-drag-target'));
1215
- this.dropTargetColumnName = columnName;
1216
- headerCell.classList.add('is-drag-target');
1217
- }
1218
- });
1219
-
1220
- root.addEventListener('dragleave', (event) => {
1221
- const target = event.target as HTMLElement | null;
1222
- const headerCell = target?.closest<HTMLElement>('.header-cell[data-column]');
1223
- if (headerCell && headerCell.dataset['column'] === this.dropTargetColumnName) {
1224
- headerCell.classList.remove('is-drag-target');
1225
- }
1226
- });
1227
-
1228
- root.addEventListener('drop', (event) => {
1229
- event.preventDefault();
1230
- const dragEvent = event as DragEvent;
1231
- const target = event.target as HTMLElement | null;
1232
- const headerCell = target?.closest<HTMLElement>('.header-cell[data-column]');
1233
- const targetColumn = headerCell?.dataset['column'];
1234
- const sourceColumn =
1235
- this.draggedColumnName ?? dragEvent.dataTransfer?.getData('text/plain') ?? null;
1236
-
1237
- this.draggedColumnName = null;
1238
- this.dropTargetColumnName = null;
1239
-
1240
- root
1241
- .querySelectorAll('.header-cell.is-dragging, .header-cell.is-drag-target')
1242
- .forEach((element) => element.classList.remove('is-dragging', 'is-drag-target'));
1243
-
1244
- if (!sourceColumn || !targetColumn || sourceColumn === targetColumn || !this.controller) {
1245
- return;
1246
- }
1247
-
1248
- this.controller.moveVisibleColumn(sourceColumn, targetColumn);
1249
- });
1250
-
1251
- root.addEventListener('dragend', () => {
1252
- this.draggedColumnName = null;
1253
- this.dropTargetColumnName = null;
1254
- root
1255
- .querySelectorAll('.header-cell.is-dragging, .header-cell.is-drag-target')
1256
- .forEach((element) => element.classList.remove('is-dragging', 'is-drag-target'));
1257
- });
1258
-
1259
- root.addEventListener(
1260
- 'scroll',
1261
- (event) => {
1262
- const target = event.target as HTMLElement | null;
1263
- if (!target?.classList.contains('grid-table') || this.suppressScrollEvent) {
1264
- return;
1265
- }
1266
-
1267
- this.lastScrollActivityAt = Date.now();
1268
- this.scrollPosition = target.scrollTop;
1269
- this.horizontalScrollPosition = target.scrollLeft;
1270
- if (!this.snapshot?.pipeline.virtualizationEnabled) {
1271
- return;
1272
- }
1273
-
1274
- const stickyChromeHeight =
1275
- this.measuredHeaderStickyHeight + this.measuredFilterStickyHeight;
1276
- const bodyScrollTop = Math.max(0, this.scrollPosition - stickyChromeHeight);
1277
- const overscan = 4;
1278
- const nextStartIndex = Math.max(
1279
- 0,
1280
- Math.floor(bodyScrollTop / this.snapshot.rowSize) - overscan,
1281
- );
1282
- if (nextStartIndex === this.lastVirtualStartIndex) {
1283
- return;
1284
- }
1285
-
1286
- if (this.scrollFrame !== null) {
1287
- cancelAnimationFrame(this.scrollFrame);
1288
- }
1289
-
1290
- this.scrollFrame = requestAnimationFrame(() => {
1291
- this.scrollFrame = null;
1292
- this.renderVirtualBody();
1293
- });
1294
- },
1295
- true,
1296
- );
1297
-
1298
- (root as ShadowRoot & { __uiGridBound?: boolean }).__uiGridBound = true;
1299
- }
1300
-
822
+ /**
823
+ * Ask the controller to evaluate whether the current scroll position
824
+ * should request more data at the top or bottom — ports the old grid's
825
+ * handleScroll → loadData check. The controller does the actual
826
+ * needLoadMoreData / needLoadMoreDataTop raise.
827
+ */
828
+ /**
829
+ * Mirror horizontal scroll from the body viewport onto the header + filter
830
+ * strips by setting `scrollLeft` on them. Because those strips are real
831
+ * scroll containers (with their scrollbars hidden), `position: sticky` on
832
+ * pinned header / filter cells anchors to the strip's scroll position the
833
+ * same way body cells anchor to `.grid-body-viewport`. This is the
834
+ * imperative sync strategy the old ui-grid used in ui-grid-viewport.js.
835
+ */
1301
836
  /**
1302
837
  * Targeted update for virtual scroll: replaces only the rendered rows inside
1303
838
  * the existing `.grid-virtual-body` without touching the scroll container.
1304
839
  * This preserves momentum/inertia scrolling because the `.grid-table` element
1305
840
  * is never destroyed.
1306
841
  */
1307
- private renderVirtualBody(): void {
842
+ /** @internal */
843
+ renderVirtualBody(): void {
1308
844
  const snapshot = this.snapshot;
1309
845
  if (!snapshot?.pipeline.virtualizationEnabled) {
1310
846
  this.render();
@@ -1325,12 +861,12 @@ export class UiGridStandaloneElement extends HTMLElement {
1325
861
  this.dataFrame = null;
1326
862
  }
1327
863
 
1328
- const stickyChromeHeight = this.measuredHeaderStickyHeight + this.measuredFilterStickyHeight;
1329
- const viewportHeight = snapshot.options.viewportHeight ?? 560;
1330
- const bodyViewportHeight = Math.max(snapshot.rowSize, viewportHeight - stickyChromeHeight);
1331
- const bodyScrollTop = Math.max(0, this.scrollPosition - stickyChromeHeight);
864
+ const bodyViewport = root?.querySelector<HTMLElement>('.grid-body-viewport');
865
+ const bodyViewportHeight = bodyViewport
866
+ ? Math.max(snapshot.rowSize, bodyViewport.clientHeight)
867
+ : Math.max(snapshot.rowSize, (snapshot.options.minRowsToShow ?? 10) * snapshot.rowSize);
1332
868
  const overscan = 4;
1333
- const startIndex = Math.max(0, Math.floor(bodyScrollTop / snapshot.rowSize) - overscan);
869
+ const startIndex = Math.max(0, Math.floor(this.scrollPosition / snapshot.rowSize) - overscan);
1334
870
  this.lastVirtualStartIndex = startIndex;
1335
871
  const visibleCount = Math.ceil(bodyViewportHeight / snapshot.rowSize) + overscan * 2;
1336
872
  const itemsToRender = snapshot.pipeline.displayItems.slice(
@@ -1343,9 +879,14 @@ export class UiGridStandaloneElement extends HTMLElement {
1343
879
  virtualBody.innerHTML = itemsToRender
1344
880
  .map((item, index) => this.renderDisplayItem(item, startIndex + index))
1345
881
  .join('');
882
+ // The body fingerprint now reflects the freshly-rendered slice so the
883
+ // next full render() can take the fast per-cell patch path.
884
+ this.lastItemsFingerprint = this.fingerprintItems(itemsToRender);
885
+ this.lastVirtualOffset = virtualOffset;
1346
886
  }
1347
887
 
1348
- private render(): void {
888
+ /** @internal */
889
+ render(): void {
1349
890
  if (this.skipNextRender) {
1350
891
  return;
1351
892
  }
@@ -1361,17 +902,164 @@ export class UiGridStandaloneElement extends HTMLElement {
1361
902
  }
1362
903
  const root = this.ensureShadowRoot();
1363
904
  const snapshot = this.snapshot;
1364
- const previousGridTable = root.querySelector<HTMLElement>('.grid-table');
1365
- if (previousGridTable && !this.suppressScrollEvent) {
1366
- this.scrollPosition = previousGridTable.scrollTop;
1367
- this.horizontalScrollPosition = previousGridTable.scrollLeft;
905
+ const previousViewport = root.querySelector<HTMLElement>('.grid-body-viewport');
906
+ if (previousViewport && !this.suppressScrollEvent) {
907
+ this.scrollPosition = previousViewport.scrollTop;
908
+ this.horizontalScrollPosition = previousViewport.scrollLeft;
1368
909
  }
1369
910
 
1370
911
  if (!snapshot) {
1371
- root.innerHTML = `<style>${GRID_CORE_CSS}</style><section class="grid-shell ui-grid-shell"><div class="empty-state ui-grid-no-row-overlay"><strong>No grid options provided.</strong></div></section>`;
912
+ this.lastStructureKey = null;
913
+ this.lastItemsFingerprint = null;
914
+ emptyTemplate({ message: 'No grid options provided.' }).connect(root);
1372
915
  return;
1373
916
  }
1374
917
 
918
+ const plan = this.buildRenderPlan(snapshot);
919
+
920
+ // The shadow-DOM structure is only safe to patch when the set of sub-regions,
921
+ // the visible column identity/order, and the pin/virt/filter/pagination
922
+ // toggles are unchanged. Otherwise the existing nodes don't line up with the
923
+ // new snapshot — fall back to a full re-mount.
924
+ const canPatch =
925
+ this.lastStructureKey !== null &&
926
+ this.lastStructureKey === plan.structureKey &&
927
+ root.querySelector('.grid-frame') !== null;
928
+
929
+ if (canPatch) {
930
+ this.renderPatch(plan, root);
931
+ } else {
932
+ this.renderFull(plan, root);
933
+ }
934
+ this.lastStructureKey = plan.structureKey;
935
+
936
+ const bodyViewport = root.querySelector<HTMLElement>('.grid-body-viewport');
937
+ if (bodyViewport && (this.scrollPosition > 0 || this.horizontalScrollPosition > 0)) {
938
+ this.suppressScrollEvent = true;
939
+ if (this.scrollPosition > 0) {
940
+ bodyViewport.scrollTop = this.scrollPosition;
941
+ }
942
+ if (this.horizontalScrollPosition > 0) {
943
+ bodyViewport.scrollLeft = this.horizontalScrollPosition;
944
+ }
945
+ // Keep the header/filter strips in sync on the first paint too.
946
+ this.syncHeaderHorizontalScroll(this.horizontalScrollPosition);
947
+ requestAnimationFrame(() => {
948
+ this.suppressScrollEvent = false;
949
+ });
950
+ }
951
+
952
+ const headerGrid = root.querySelector<HTMLElement>('.header-grid');
953
+ const filterGrid = root.querySelector<HTMLElement>('.filter-grid');
954
+ const nextHeaderStickyHeight = headerGrid?.offsetHeight ?? 0;
955
+ const nextFilterStickyHeight = filterGrid?.offsetHeight ?? 0;
956
+ const stickyHeightsChanged =
957
+ nextHeaderStickyHeight !== this.measuredHeaderStickyHeight ||
958
+ nextFilterStickyHeight !== this.measuredFilterStickyHeight;
959
+
960
+ this.measuredHeaderStickyHeight = nextHeaderStickyHeight;
961
+ this.measuredFilterStickyHeight = nextFilterStickyHeight;
962
+
963
+ if (stickyHeightsChanged && !this.stickyHeightRelayoutQueued) {
964
+ this.stickyHeightRelayoutQueued = true;
965
+ requestAnimationFrame(() => {
966
+ this.stickyHeightRelayoutQueued = false;
967
+ if (!this.snapshot) {
968
+ return;
969
+ }
970
+ this.render();
971
+ });
972
+ }
973
+
974
+ this.autoAdjustHeight(snapshot);
975
+ }
976
+
977
+ /** @internal */
978
+ private inAutoAdjust = false;
979
+ private lastAutoHeight = 0;
980
+
981
+ private autoAdjustHeight(snapshot: GridControllerSnapshot): void {
982
+ if (this.inAutoAdjust) return;
983
+ const options = snapshot.options;
984
+ if (options.enableMinHeightCheck === false) return;
985
+
986
+ const rowHeight = snapshot.rowSize;
987
+ const minRows = options.minRowsToShow ?? 10;
988
+ const headerHeight = this.measuredHeaderStickyHeight || options.headerRowHeight || 50;
989
+ const filterHeight = this.measuredFilterStickyHeight;
990
+ const paginationHeight = this.measuredPaginationHeight();
991
+ const minHeight = headerHeight + filterHeight + paginationHeight + (minRows * rowHeight);
992
+
993
+ if (this.clientHeight < minHeight || (paginationHeight > 0 && this.lastAutoHeight !== minHeight)) {
994
+ this.lastAutoHeight = minHeight;
995
+ this.style.height = `${minHeight}px`;
996
+ this.inAutoAdjust = true;
997
+ this.render();
998
+ this.inAutoAdjust = false;
999
+ }
1000
+ }
1001
+
1002
+ measuredPaginationHeight(): number {
1003
+ const paginationEl = this.shadowRoot?.querySelector<HTMLElement>('ui-grid-pagination');
1004
+ if (!paginationEl) return 0;
1005
+ return paginationEl.offsetHeight || 44;
1006
+ }
1007
+
1008
+ /** @internal */
1009
+ setupAutoResize(): void {
1010
+ if (this.autoResizeObserver) return;
1011
+ if (typeof ResizeObserver === 'undefined') return;
1012
+
1013
+ this.autoResizeObserver = new ResizeObserver((entries) => {
1014
+ const options = this.snapshot?.options;
1015
+ if (options?.enableAutoResize === false) return;
1016
+
1017
+ const entry = entries[0];
1018
+ if (!entry) return;
1019
+
1020
+ if (this.autoResizeDebounceHandle !== null) {
1021
+ cancelAnimationFrame(this.autoResizeDebounceHandle);
1022
+ }
1023
+ this.autoResizeDebounceHandle = requestAnimationFrame(() => {
1024
+ this.autoResizeDebounceHandle = null;
1025
+ if (!this.snapshot) return;
1026
+ this.render();
1027
+ });
1028
+ });
1029
+
1030
+ this.autoResizeObserver.observe(this);
1031
+ }
1032
+
1033
+ /** @internal */
1034
+ teardownAutoResize(): void {
1035
+ if (this.autoResizeObserver) {
1036
+ this.autoResizeObserver.disconnect();
1037
+ this.autoResizeObserver = null;
1038
+ }
1039
+ if (this.autoResizeDebounceHandle !== null) {
1040
+ cancelAnimationFrame(this.autoResizeDebounceHandle);
1041
+ this.autoResizeDebounceHandle = null;
1042
+ }
1043
+ }
1044
+
1045
+ private buildRenderPlan(snapshot: GridControllerSnapshot): {
1046
+ snapshot: GridControllerSnapshot;
1047
+ options: GridOptions;
1048
+ labels: GridControllerSnapshot['labels'];
1049
+ templateColumns: string;
1050
+ sortEnabled: boolean;
1051
+ filterEnabled: boolean;
1052
+ groupingEnabled: boolean;
1053
+ pinningEnabled: boolean;
1054
+ paginationEnabled: boolean;
1055
+ showPagination: boolean;
1056
+ virtualizationEnabled: boolean;
1057
+ itemsToRender: readonly DisplayItem[];
1058
+ startIndex: number;
1059
+ virtualOffset: number;
1060
+ totalVirtualHeight: number;
1061
+ structureKey: string;
1062
+ } {
1375
1063
  const controller = this.controller!;
1376
1064
  const options = snapshot.options;
1377
1065
  const labels = snapshot.labels;
@@ -1383,19 +1071,22 @@ export class UiGridStandaloneElement extends HTMLElement {
1383
1071
  const paginationEnabled = controller.isPaginationEnabled();
1384
1072
  const showPagination = controller.shouldShowPaginationControls();
1385
1073
  const virtualizationEnabled = snapshot.pipeline.virtualizationEnabled;
1386
- const viewportHeight = options.viewportHeight ?? 560;
1387
- const headerStickyTop = this.measuredHeaderStickyHeight || options.headerRowHeight || 50;
1388
- const stickyChromeHeight = this.measuredHeaderStickyHeight + this.measuredFilterStickyHeight;
1389
- const bodyViewportHeight = Math.max(snapshot.rowSize, viewportHeight - stickyChromeHeight);
1074
+ // Measure the body viewport from the DOM when available; on first paint
1075
+ // fall back to a reasonable default so virtualisation has something to
1076
+ // slice against until the element is measured.
1077
+ const bodyViewport = this.shadowRoot?.querySelector<HTMLElement>('.grid-body-viewport');
1078
+ const bodyViewportHeight = bodyViewport
1079
+ ? Math.max(snapshot.rowSize, bodyViewport.clientHeight)
1080
+ : Math.max(snapshot.rowSize, (options.minRowsToShow ?? 10) * snapshot.rowSize);
1081
+
1390
1082
  let startIndex = 0;
1391
- let itemsToRender = snapshot.pipeline.displayItems;
1083
+ let itemsToRender: readonly DisplayItem[] = snapshot.pipeline.displayItems;
1392
1084
  let virtualOffset = 0;
1393
1085
  const totalVirtualHeight = snapshot.pipeline.displayItems.length * snapshot.rowSize;
1394
1086
 
1395
1087
  if (virtualizationEnabled) {
1396
- const bodyScrollTop = Math.max(0, this.scrollPosition - stickyChromeHeight);
1397
1088
  const overscan = 4;
1398
- startIndex = Math.max(0, Math.floor(bodyScrollTop / snapshot.rowSize) - overscan);
1089
+ startIndex = Math.max(0, Math.floor(this.scrollPosition / snapshot.rowSize) - overscan);
1399
1090
  this.lastVirtualStartIndex = startIndex;
1400
1091
  const visibleCount = Math.ceil(bodyViewportHeight / snapshot.rowSize) + overscan * 2;
1401
1092
  itemsToRender = snapshot.pipeline.displayItems.slice(
@@ -1407,6 +1098,65 @@ export class UiGridStandaloneElement extends HTMLElement {
1407
1098
  this.lastVirtualStartIndex = -1;
1408
1099
  }
1409
1100
 
1101
+ // Structure key: only the outer-shell layout. Things that swap the body
1102
+ // container (virtualization flip, empty state, viewport scroll) or the
1103
+ // pagination element appearance are handled *inside* the patch path so a
1104
+ // filter keystroke that crosses the virtualization threshold doesn't
1105
+ // tear down the focused filter input.
1106
+ const columnFingerprint = snapshot.visibleColumns
1107
+ .map(
1108
+ (c) =>
1109
+ `${c.name}:${controller.isPinned(c) ? 'p' : ''}${controller.isPinnedLeftLast(c) ? 'L' : ''}${controller.isPinnedRightFirst(c) ? 'R' : ''}`,
1110
+ )
1111
+ .join('|');
1112
+ const structureKey = [
1113
+ columnFingerprint,
1114
+ filterEnabled ? '1' : '0',
1115
+ paginationEnabled ? '1' : '0',
1116
+ ].join('#');
1117
+
1118
+ return {
1119
+ snapshot,
1120
+ options,
1121
+ labels,
1122
+ templateColumns,
1123
+ sortEnabled,
1124
+ filterEnabled,
1125
+ groupingEnabled,
1126
+ pinningEnabled,
1127
+ paginationEnabled,
1128
+ showPagination,
1129
+ virtualizationEnabled,
1130
+ itemsToRender,
1131
+ startIndex,
1132
+ virtualOffset,
1133
+ totalVirtualHeight,
1134
+ structureKey,
1135
+ };
1136
+ }
1137
+
1138
+ private renderFull(
1139
+ plan: ReturnType<UiGridStandaloneElement['buildRenderPlan']>,
1140
+ _root: ShadowRoot,
1141
+ ): void {
1142
+ const {
1143
+ snapshot,
1144
+ options,
1145
+ labels,
1146
+ templateColumns,
1147
+ sortEnabled,
1148
+ filterEnabled,
1149
+ groupingEnabled,
1150
+ pinningEnabled,
1151
+ paginationEnabled,
1152
+ showPagination,
1153
+ virtualizationEnabled,
1154
+ itemsToRender,
1155
+ startIndex,
1156
+ virtualOffset,
1157
+ totalVirtualHeight,
1158
+ } = plan;
1159
+
1410
1160
  const slotRegistry = this.renderSlotRegistry(snapshot.visibleColumns);
1411
1161
  const header = snapshot.visibleColumns
1412
1162
  .map((column) =>
@@ -1415,9 +1165,10 @@ export class UiGridStandaloneElement extends HTMLElement {
1415
1165
  .join('');
1416
1166
 
1417
1167
  const filterRow = filterEnabled
1418
- ? `<div class="filter-grid ui-grid-header" style="grid-template-columns:${templateColumns}">${snapshot.visibleColumns
1419
- .map((column) => this.renderFilterCell(column))
1420
- .join('')}</div>`
1168
+ ? filterRowMarkup(
1169
+ templateColumns,
1170
+ snapshot.visibleColumns.map((column) => this.renderFilterCell(column)).join(''),
1171
+ )
1421
1172
  : '';
1422
1173
 
1423
1174
  const bodyContent = itemsToRender
@@ -1427,51 +1178,103 @@ export class UiGridStandaloneElement extends HTMLElement {
1427
1178
  const body =
1428
1179
  snapshot.pipeline.displayItems.length > 0
1429
1180
  ? virtualizationEnabled
1430
- ? `<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>`
1431
- : `<div class="body-grid ui-grid-canvas" style="grid-template-columns:${templateColumns}">${bodyContent}</div>`
1432
- : `<div class="empty-state ui-grid-no-row-overlay"><strong>${escapeHtml(options.emptyMessage ?? labels.emptyHeading)}</strong><p>${escapeHtml(labels.emptyDescription)}</p></div>`;
1181
+ ? bodyVirtualMarkup(templateColumns, totalVirtualHeight, virtualOffset, bodyContent)
1182
+ : bodyStaticMarkup(templateColumns, bodyContent)
1183
+ : this.renderEmptyState(
1184
+ options.emptyMessage ?? labels.emptyHeading,
1185
+ labels.emptyDescription,
1186
+ );
1433
1187
 
1434
1188
  const pagination = paginationEnabled && showPagination ? this.renderPagination(snapshot) : '';
1435
- const hasViewportScroll = virtualizationEnabled || options.viewportHeight !== undefined;
1436
- const gridTableStyle = `${hasViewportScroll ? `height:${viewportHeight}px;overflow-y:auto;` : ''}--ui-grid-header-sticky-top:${headerStickyTop}px;`;
1437
1189
 
1438
- 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>`;
1190
+ this.gridTitle = escapeHtml(options.title ?? 'Data grid');
1191
+ const stickyTop = this.measuredHeaderStickyHeight || options.headerRowHeight || 50;
1192
+ const paginationHeight = this.measuredPaginationHeight();
1193
+ const tableHeight = this.clientHeight || ((options.minRowsToShow ?? 10) * snapshot.rowSize + (this.measuredHeaderStickyHeight || options.headerRowHeight || 50) + this.measuredFilterStickyHeight + paginationHeight);
1194
+ this.gridTableStyle = `--ui-grid-header-sticky-top:${stickyTop}px;height:${tableHeight}px;`;
1195
+ this.bodyViewportStyle = '';
1196
+ this.templateColumns = templateColumns;
1197
+ this.slotRegistry = slotRegistry;
1198
+ this.headerContent = header;
1199
+ this.filterRowContent = filterRow;
1200
+ this.bodyContent = body;
1201
+ this.paginationContent = pagination;
1202
+
1203
+ gridShellTemplate(this).connect();
1204
+
1205
+ // Flush framework-rendered slot deltas — dispatches cellSlotsChanged /
1206
+ // expandableRowSlotsChanged / groupRowSlotsChanged / headerSlotsChanged /
1207
+ // filterSlotsChanged / emptyStateSlotChanged with the adds + removes
1208
+ // relative to the previous render.
1209
+ this.frameworkSlots.flush();
1210
+
1211
+ // After a full mount, the new DOM exactly matches the current item set —
1212
+ // seed the fingerprint so the next render can take the fast patch path.
1213
+ this.lastItemsFingerprint = this.fingerprintItems(plan.itemsToRender);
1214
+ this.lastVirtualOffset = plan.virtualOffset;
1215
+ this.lastTotalVirtualHeight = plan.totalVirtualHeight;
1216
+ }
1217
+
1218
+ private renderPatch(
1219
+ plan: ReturnType<UiGridStandaloneElement['buildRenderPlan']>,
1220
+ root: ShadowRoot,
1221
+ ): void {
1222
+ renderPatch(this, plan, root);
1223
+ }
1439
1224
 
1440
- const gridTable = root.querySelector<HTMLElement>('.grid-table');
1441
- if (gridTable && (this.scrollPosition > 0 || this.horizontalScrollPosition > 0)) {
1442
- this.suppressScrollEvent = true;
1443
- if (this.scrollPosition > 0) {
1444
- gridTable.scrollTop = this.scrollPosition;
1445
- }
1446
- if (this.horizontalScrollPosition > 0) {
1447
- gridTable.scrollLeft = this.horizontalScrollPosition;
1448
- }
1449
- requestAnimationFrame(() => {
1450
- this.suppressScrollEvent = false;
1451
- });
1452
- }
1225
+ private reconcileBodyRoot(
1226
+ root: ShadowRoot,
1227
+ kind: 'empty' | 'virtual' | 'static',
1228
+ options: GridOptions,
1229
+ labels: GridControllerSnapshot['labels'],
1230
+ templateColumns: string,
1231
+ virtualOffset: number,
1232
+ totalVirtualHeight: number,
1233
+ ): HTMLElement | null {
1234
+ return reconcileBodyRoot(this, root, kind, options, labels, templateColumns, virtualOffset, totalVirtualHeight);
1235
+ }
1236
+
1237
+ private reconcilePagination(
1238
+ root: ShadowRoot,
1239
+ snapshot: GridControllerSnapshot,
1240
+ shouldShow: boolean,
1241
+ ): void {
1242
+ reconcilePagination(this, root, snapshot, shouldShow);
1243
+ }
1453
1244
 
1454
- const headerGrid = root.querySelector<HTMLElement>('.header-grid');
1455
- const filterGrid = root.querySelector<HTMLElement>('.filter-grid');
1456
- const nextHeaderStickyHeight = headerGrid?.offsetHeight ?? 0;
1457
- const nextFilterStickyHeight = filterGrid?.offsetHeight ?? 0;
1458
- const stickyHeightsChanged =
1459
- nextHeaderStickyHeight !== this.measuredHeaderStickyHeight ||
1460
- nextFilterStickyHeight !== this.measuredFilterStickyHeight;
1245
+ private patchFilterCells(filterGrid: HTMLElement, snapshot: GridControllerSnapshot): void {
1246
+ patchFilterCells(this, filterGrid, snapshot);
1247
+ }
1461
1248
 
1462
- this.measuredHeaderStickyHeight = nextHeaderStickyHeight;
1463
- this.measuredFilterStickyHeight = nextFilterStickyHeight;
1249
+ private fingerprintItems(items: readonly DisplayItem[]): string {
1250
+ return fingerprintItems(items);
1251
+ }
1464
1252
 
1465
- if (stickyHeightsChanged && !this.stickyHeightRelayoutQueued) {
1466
- this.stickyHeightRelayoutQueued = true;
1467
- requestAnimationFrame(() => {
1468
- this.stickyHeightRelayoutQueued = false;
1469
- if (!this.snapshot) {
1470
- return;
1471
- }
1472
- this.render();
1473
- });
1474
- }
1253
+ private patchExistingRows(
1254
+ bodyContainer: HTMLElement,
1255
+ snapshot: GridControllerSnapshot,
1256
+ itemsToRender: readonly DisplayItem[],
1257
+ startIndex: number,
1258
+ ): void {
1259
+ patchExistingRows(this, bodyContainer, snapshot, itemsToRender, startIndex);
1260
+ }
1261
+
1262
+ private patchGroupRow(el: HTMLElement, group: GroupItem): void {
1263
+ patchGroupRow(this, el, group);
1264
+ }
1265
+
1266
+ private patchBodyCell(
1267
+ cell: HTMLElement,
1268
+ row: GridRow,
1269
+ column: GridColumnDef,
1270
+ displayIndex: number,
1271
+ templateMarkupMap: Map<string, string | null>,
1272
+ ): void {
1273
+ patchBodyCell(this, cell, row, column, displayIndex, templateMarkupMap);
1274
+ }
1275
+
1276
+ private patchPagination(paginationEl: HTMLElement, snapshot: GridControllerSnapshot): void {
1277
+ patchPagination(this, paginationEl, snapshot);
1475
1278
  }
1476
1279
 
1477
1280
  private renderHeaderCell(
@@ -1481,155 +1284,102 @@ export class UiGridStandaloneElement extends HTMLElement {
1481
1284
  pinningEnabled: boolean,
1482
1285
  options: GridOptions,
1483
1286
  ): string {
1484
- const controller = this.controller!;
1485
- const sortDirection = controller.getSortDirection(column);
1486
- const sortLabel = controller.sortButtonLabel(column);
1487
- const groupingLabel = controller.groupingButtonLabel(column);
1488
- const canSort = sortEnabled && controller.isColumnSortable(column);
1489
- const canGroup = groupingEnabled && column.enableGrouping !== false;
1490
- const canPin = pinningEnabled && controller.isColumnPinnable(column);
1491
- const isPinned = controller.isPinned(column);
1492
- const pinOffset = isPinned ? controller.pinnedOffset(column) : null;
1493
- const isPinnedLeftLast = controller.isPinnedLeftLast(column);
1494
- const isPinnedRightFirst = controller.isPinnedRightFirst(column);
1495
- const isDragTarget = this.dropTargetColumnName === column.name;
1496
- const isDragging = this.draggedColumnName === column.name;
1497
- const classes = [
1498
- 'header-cell',
1499
- sortDirection !== SORT_DIRECTIONS.none ? 'is-active' : '',
1500
- isPinned ? 'is-pinned' : '',
1501
- isPinnedLeftLast ? 'is-pinned-left-last' : '',
1502
- isPinnedRightFirst ? 'is-pinned-right-first' : '',
1503
- this.openPinMenuColumn === column.name ? 'is-pin-menu-open' : '',
1504
- isDragTarget ? 'is-drag-target' : '',
1505
- isDragging ? 'is-dragging' : '',
1506
- ]
1507
- .filter(Boolean)
1508
- .join(' ');
1509
-
1510
- const stickyStyle = pinOffset ? `${pinOffset.side}:${pinOffset.offset};` : '';
1511
-
1512
- const sortIconKey =
1513
- sortDirection === SORT_DIRECTIONS.asc
1514
- ? 'sortAsc'
1515
- : sortDirection === SORT_DIRECTIONS.desc
1516
- ? 'sortDesc'
1517
- : 'sortNone';
1518
-
1519
- const draggable = canGridMoveColumns(options) ? 'draggable="true"' : '';
1520
- const pinLabel = isPinned
1521
- ? (this.snapshot?.labels.unpin ?? 'Unpin')
1522
- : (this.snapshot?.labels.pinColumn ?? 'Pin');
1523
- const headerValue = escapeHtml(formatGridHeaderDisplayValue(buildGridHeaderContext(column)));
1524
-
1525
- 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>`;
1287
+ return renderHeaderCell(this, column, sortEnabled, groupingEnabled, pinningEnabled, options);
1526
1288
  }
1527
1289
 
1528
1290
  private renderFilterCell(column: GridColumnDef): string {
1529
- const value = this.snapshot?.activeFilters[column.name] ?? '';
1530
- const controller = this.controller!;
1531
- const canFilter = controller.isColumnFilterable(column);
1532
- const pinOffset = controller.isPinned(column) ? controller.pinnedOffset(column) : null;
1533
- const classes = [
1534
- 'filter-cell',
1535
- controller.isPinned(column) ? 'is-pinned' : '',
1536
- controller.isPinnedLeftLast(column) ? 'is-pinned-left-last' : '',
1537
- controller.isPinnedRightFirst(column) ? 'is-pinned-right-first' : '',
1538
- ]
1539
- .filter(Boolean)
1540
- .join(' ');
1541
- const stickyStyle = pinOffset ? `${pinOffset.side}:${pinOffset.offset};` : '';
1542
- return `<label class="${classes}" style="${stickyStyle}"><input class="ui-grid-filter-input" data-role="filter" data-column="${escapeHtml(column.name)}" placeholder="${escapeHtml(controller.filterPlaceholder(column))}" value="${escapeHtml(value)}" ${canFilter ? '' : 'disabled'}></label>`;
1291
+ return renderFilterCell(this, column);
1543
1292
  }
1544
1293
 
1545
- private renderDisplayItem(item: DisplayItem, displayIndex: number): string {
1546
- if (!this.snapshot || !this.controller) {
1547
- return '';
1548
- }
1294
+ /**
1295
+ * Commit the in-flight edit, then move keyboard focus relative to the
1296
+ * committed cell. Splits commit + focus across two paint frames: commit
1297
+ * runs synchronously (editor unmounts, cell re-renders with the new value),
1298
+ * then focus moves to the adjacent cell. Scheduling the focus in a
1299
+ * microtask avoids racing the blur handler's re-entry guard and ensures
1300
+ * the destination cell is fully reattached in the DOM.
1301
+ *
1302
+ * If the source cell was being edited, the destination cell enters edit
1303
+ * mode too (provided the destination column is editable). This keeps
1304
+ * Tab/Enter nav inside an edit session continuous — you keep editing as
1305
+ * you move. Arrow nav outside an edit session does NOT auto-enter edit.
1306
+ */
1307
+ private commitAndMove(
1308
+ fromRowId: string | null,
1309
+ fromColumnName: string | null,
1310
+ direction: 'left' | 'right' | 'up' | 'down',
1311
+ ): void {
1312
+ commitAndMove(this, fromRowId, fromColumnName, direction);
1313
+ }
1549
1314
 
1550
- if (item.kind === 'group') {
1551
- const group = asGroupItem(item);
1552
- const iconKey = group.collapsed ? 'groupCollapsed' : 'groupExpanded';
1553
- const disclosureLabel = this.controller.groupDisclosureLabel(group);
1554
- return `<button type="button" class="group-row ui-grid-row ui-grid-group-row" data-action="toggle-group" data-group="${escapeHtml(group.id)}" data-collapsed="${group.collapsed ? 'true' : 'false'}" style="grid-column: 1 / -1; padding-inline-start:${group.depth * 20 + 10}px">${this.renderIconWithClass('toggle-icon group-disclosure-icon', iconKey)}<span class="sr-only">${escapeHtml(disclosureLabel)}</span><strong>${escapeHtml(group.field)}: ${escapeHtml(group.label)}</strong><span>${group.count} ${escapeHtml(this.snapshot.labels.groupRowsSuffix)}</span></button>`;
1555
- }
1315
+ private moveGridFocus(
1316
+ direction: GridMoveDirection,
1317
+ rowId: string | null,
1318
+ columnName: string | null,
1319
+ opts: { resumeEdit?: boolean } = {},
1320
+ ): void {
1321
+ moveGridFocus(this, direction, rowId, columnName, opts);
1322
+ }
1556
1323
 
1557
- if (item.kind === 'expandable') {
1558
- const row = (item as DisplayItem & { row: GridRow }).row;
1559
- return `<div class="expandable-row ui-grid-row ui-grid-expandable-row" style="grid-column:1 / -1">${this.renderExpandableTemplate(row)}</div>`;
1560
- }
1324
+ private scrollFocusedRowIntoView(rowId: string): void {
1325
+ scrollFocusedRowIntoView(this, rowId);
1326
+ }
1561
1327
 
1562
- if (!isRowItem(item)) {
1563
- return '';
1564
- }
1328
+ private handleRowSelectionClick(rowId: string, columnName: string, event: Event): void {
1329
+ handleRowSelectionClick(this, rowId, columnName, event);
1330
+ }
1565
1331
 
1566
- return this.snapshot.visibleColumns
1567
- .map((column) => this.renderBodyCell(item.row, column, displayIndex))
1568
- .join('');
1332
+ private handleRowHeaderCheckboxClick(rowId: string, event: Event): void {
1333
+ handleRowHeaderCheckboxClick(this, rowId, event);
1569
1334
  }
1570
1335
 
1571
- private renderBodyCell(row: GridRow, column: GridColumnDef, displayIndex: number): string {
1572
- if (!this.snapshot || !this.controller) {
1573
- return '';
1574
- }
1336
+ private matchesKeyOverride(
1337
+ override: {
1338
+ keyCode?: number;
1339
+ key?: string;
1340
+ shiftKey?: boolean;
1341
+ ctrlKey?: boolean;
1342
+ altKey?: boolean;
1343
+ metaKey?: boolean;
1344
+ },
1345
+ event: KeyboardEvent,
1346
+ ): boolean {
1347
+ return matchesKeyOverride(override, event);
1348
+ }
1575
1349
 
1576
- const controller = this.controller;
1577
- const rowId = row.id;
1578
- const columnName = column.name;
1579
- const editing = controller.isEditingCell(rowId, columnName);
1580
- const pinOffset = controller.isPinned(column) ? controller.pinnedOffset(column) : null;
1581
- const treeToggle = controller.showTreeToggle(row, column)
1582
- ? `<button type="button" class="row-toggle" data-action="toggle-tree" data-row="${escapeHtml(rowId)}" aria-label="${escapeHtml(controller.treeToggleLabel(row))}">${this.renderIconWithClass('toggle-icon', controller.isTreeRowExpanded(row) ? 'treeExpanded' : 'treeCollapsed')}<span class="sr-only">${escapeHtml(controller.treeToggleLabel(row))}</span></button>`
1583
- : '';
1584
- const expandToggle = controller.showExpandToggle(row, column)
1585
- ? `<button type="button" class="row-toggle row-toggle-expand" data-action="toggle-expand" data-row="${escapeHtml(rowId)}" aria-label="${escapeHtml(controller.expandToggleLabel(row))}">${this.renderIconWithClass('toggle-icon', row.expanded ? 'expandExpanded' : 'expandCollapsed')}<span class="sr-only">${escapeHtml(controller.expandToggleLabel(row))}</span></button>`
1586
- : '';
1350
+ private applyFocusedCellClass(
1351
+ previous: { rowId: string; columnName: string } | null,
1352
+ next: { rowId: string; columnName: string } | null,
1353
+ ): void {
1354
+ applyFocusedCellClass(this, previous, next);
1355
+ }
1356
+
1357
+ private focusCellElement(rowId: string | null, columnName: string | null): void {
1358
+ focusCellElement(this, rowId, columnName);
1359
+ }
1587
1360
 
1588
- const content = editing
1589
- ? `<input class="cell-editor" data-role="editor" data-row="${escapeHtml(rowId)}" data-column="${escapeHtml(columnName)}" type="${escapeHtml(controller.editorInputType(column))}" value="${escapeHtml(this.snapshot.editingValue)}">`
1590
- : this.renderCellTemplate(row, column, displayIndex);
1591
-
1592
- const classes = [
1593
- 'body-cell',
1594
- 'ui-grid-cell',
1595
- displayIndex % 2 !== 0 ? 'body-cell-odd' : '',
1596
- column.align === 'center' ? 'align-center' : '',
1597
- column.align === 'end' ? 'align-end' : '',
1598
- controller.isPinned(column) ? 'is-pinned' : '',
1599
- controller.isPinnedLeftLast(column) ? 'is-pinned-left-last' : '',
1600
- controller.isPinnedRightFirst(column) ? 'is-pinned-right-first' : '',
1601
- this.focusedCell?.rowId === rowId && this.focusedCell.columnName === columnName
1602
- ? 'cell-focused'
1603
- : '',
1604
- editing ? 'cell-editing' : '',
1605
- ]
1606
- .filter(Boolean)
1607
- .join(' ');
1608
-
1609
- const stickyStyle = pinOffset ? `${pinOffset.side}:${pinOffset.offset};` : '';
1610
- return `<div class="${classes}" tabindex="0" data-row="${escapeHtml(rowId)}" data-column="${escapeHtml(columnName)}" style="${stickyStyle}"><div class="cell-shell" style="padding-inline-start:${escapeHtml(controller.cellIndent(row, column))}">${treeToggle}${expandToggle}<div class="cell-content">${content}</div></div></div>`;
1361
+ private measureAutoColumnWidth(columnName: string): number {
1362
+ return measureAutoColumnWidth(this, columnName);
1363
+ }
1364
+
1365
+ private renderDisplayItem(item: DisplayItem, displayIndex: number): string {
1366
+ return renderDisplayItem(this, item, displayIndex);
1367
+ }
1368
+
1369
+ private renderBodyCell(row: GridRow, column: GridColumnDef, displayIndex: number): string {
1370
+ return renderBodyCell(this, row, column, displayIndex);
1611
1371
  }
1612
1372
 
1613
1373
  private renderPagination(snapshot: GridControllerSnapshot): string {
1614
- const pageSizes = snapshot.options.paginationPageSizes ?? [10, 25, 50, 100];
1615
- return `<footer class="pagination-bar ui-grid-pagination"><p>${snapshot.firstRowIndex + 1}-${snapshot.lastRowIndex + 1} of ${snapshot.pipeline.totalItems}</p><div class="pagination-controls"><button type="button" class="action action-secondary pagination-button" data-action="page-prev" aria-label="${escapeHtml(snapshot.labels.paginationPrevious)}" ${snapshot.currentPage <= 1 ? 'disabled' : ''}>${this.renderIconWithClass('pagination-icon', 'paginationPrev')}<span class="sr-only">${escapeHtml(snapshot.labels.paginationPrevious)}</span></button><span>${escapeHtml(snapshot.labels.paginationPage)} ${snapshot.currentPage} ${escapeHtml(snapshot.labels.paginationOf)} ${snapshot.totalPages}</span><button type="button" class="action action-secondary pagination-button" data-action="page-next" aria-label="${escapeHtml(snapshot.labels.paginationNext)}" ${snapshot.currentPage >= snapshot.totalPages ? 'disabled' : ''}>${this.renderIconWithClass('pagination-icon', 'paginationNext')}<span class="sr-only">${escapeHtml(snapshot.labels.paginationNext)}</span></button><label class="pagination-size"><span class="sr-only">${escapeHtml(snapshot.labels.paginationRows)}</span><select class="page-size" data-role="page-size">${pageSizes
1616
- .map(
1617
- (size) =>
1618
- `<option value="${size}" ${size === snapshot.pageSize ? 'selected' : ''}>${size}</option>`,
1619
- )
1620
- .join('')}</select></label></div></footer>`;
1374
+ return renderPagination(this, snapshot);
1621
1375
  }
1622
1376
 
1623
1377
  private renderSlotRegistry(columns: readonly GridColumnDef[]): string {
1624
- const cellSlots = columns
1625
- .map((column) => `<slot name="${escapeHtml(this.cellSlotName(column))}"></slot>`)
1626
- .join('');
1627
- return `<div hidden class="slot-registry">${cellSlots}<slot name="expandable-row"></slot></div>`;
1378
+ return renderSlotRegistry(columns);
1628
1379
  }
1629
1380
 
1630
1381
  private renderCellTemplate(row: GridRow, column: GridColumnDef, displayIndex: number): string {
1631
- const templateMarkup = this.getTemplateMarkup(this.cellSlotName(column));
1632
- return this.renderCellTemplateFromMarkup(row, column, displayIndex, templateMarkup);
1382
+ return renderCellTemplate(this, row, column, displayIndex);
1633
1383
  }
1634
1384
 
1635
1385
  private renderCellTemplateFromMarkup(
@@ -1638,62 +1388,20 @@ export class UiGridStandaloneElement extends HTMLElement {
1638
1388
  displayIndex: number,
1639
1389
  templateMarkup: string | null,
1640
1390
  ): string {
1641
- if (!templateMarkup) {
1642
- return `<span class="cell-value">${escapeHtml(this.controller?.displayValue(row, column) ?? '')}</span>`;
1643
- }
1644
-
1645
- const rawRow = row.entity as GridRecord;
1646
- const rawValue = getCellValue(rawRow, column);
1647
- const valueText = rawValue == null ? '' : String(rawValue);
1648
- return this.interpolateTemplate(templateMarkup, {
1649
- $implicit: rawValue,
1650
- value: rawValue,
1651
- valueText,
1652
- valueLower: valueText.toLowerCase(),
1653
- row: rawRow,
1654
- column,
1655
- rowIndex: this.rowIndexFor(row, displayIndex),
1656
- });
1391
+ return renderCellTemplateFromMarkup(this, row, column, displayIndex, templateMarkup);
1657
1392
  }
1658
1393
 
1659
1394
  private renderExpandableTemplate(row: GridRow): string {
1660
- const templateMarkup = this.getTemplateMarkup('expandable-row');
1661
- if (!templateMarkup) {
1662
- return `<p>${escapeHtml(String(row.entity['name'] ?? row.id))}</p>`;
1663
- }
1664
-
1665
- return this.interpolateTemplate(templateMarkup, {
1666
- $implicit: row.entity,
1667
- row: row.entity,
1668
- expanded: row.expanded,
1669
- rowIndex: this.rowIndexFor(row),
1670
- });
1395
+ return renderExpandableTemplate(this, row);
1671
1396
  }
1672
1397
 
1673
1398
  private getTemplateMarkup(slotName: string): string | null {
1674
- const template = this.querySelector<HTMLTemplateElement>(`template[slot="${slotName}"]`);
1675
- return template?.innerHTML ?? null;
1676
- }
1677
-
1678
- private interpolateTemplate(templateMarkup: string, context: Record<string, unknown>): string {
1679
- return templateMarkup.replace(/{{\s*([^}]+?)\s*}}/g, (_match, expression) => {
1680
- const value = this.resolveTemplateValue(context, String(expression).trim());
1681
- return escapeHtml(value);
1682
- });
1399
+ return getTemplateMarkupFromLightDom(this, slotName);
1683
1400
  }
1684
1401
 
1685
- private resolveTemplateValue(context: Record<string, unknown>, expression: string): unknown {
1686
- const segments = expression.split('.').filter(Boolean);
1687
- let current: unknown = context;
1688
-
1689
- for (const segment of segments) {
1690
- if (current == null || typeof current !== 'object') {
1691
- return '';
1692
- }
1693
- current = (current as Record<string, unknown>)[segment];
1694
- }
1695
-
1696
- return current ?? '';
1402
+ /** @internal Used by the render module to interpolate consumer templates. */
1403
+ interpolateTemplate(templateMarkup: string, context: Record<string, unknown>): string {
1404
+ return interpolateGridTemplate(templateMarkup, context, escapeHtml);
1697
1405
  }
1698
1406
 
1699
1407
  private rowIndexFor(row: GridRow, fallback = 0): number {
@@ -1723,7 +1431,9 @@ export class UiGridStandaloneElement extends HTMLElement {
1723
1431
  }
1724
1432
 
1725
1433
  for (let index = 0; index < previousSnapshot.visibleColumns.length; index += 1) {
1726
- if (previousSnapshot.visibleColumns[index]?.name !== nextSnapshot.visibleColumns[index]?.name) {
1434
+ if (
1435
+ previousSnapshot.visibleColumns[index]?.name !== nextSnapshot.visibleColumns[index]?.name
1436
+ ) {
1727
1437
  return { mode: 'full', changedRowIds: null };
1728
1438
  }
1729
1439
  }
@@ -1781,12 +1491,12 @@ export class UiGridStandaloneElement extends HTMLElement {
1781
1491
  };
1782
1492
  }
1783
1493
 
1784
- const stickyChromeHeight = this.measuredHeaderStickyHeight + this.measuredFilterStickyHeight;
1785
- const viewportHeight = snapshot.options.viewportHeight ?? 560;
1786
- const bodyViewportHeight = Math.max(snapshot.rowSize, viewportHeight - stickyChromeHeight);
1787
- const bodyScrollTop = Math.max(0, this.scrollPosition - stickyChromeHeight);
1494
+ const bodyViewport = this.shadowRoot?.querySelector<HTMLElement>('.grid-body-viewport');
1495
+ const bodyViewportHeight = bodyViewport
1496
+ ? Math.max(snapshot.rowSize, bodyViewport.clientHeight)
1497
+ : Math.max(snapshot.rowSize, (snapshot.options.minRowsToShow ?? 10) * snapshot.rowSize);
1788
1498
  const overscan = 4;
1789
- const startIndex = Math.max(0, Math.floor(bodyScrollTop / snapshot.rowSize) - overscan);
1499
+ const startIndex = Math.max(0, Math.floor(this.scrollPosition / snapshot.rowSize) - overscan);
1790
1500
  const visibleCount = Math.ceil(bodyViewportHeight / snapshot.rowSize) + overscan * 2;
1791
1501
 
1792
1502
  return {
@@ -1802,29 +1512,38 @@ export class UiGridStandaloneElement extends HTMLElement {
1802
1512
  return `cell-${column.name}`;
1803
1513
  }
1804
1514
 
1805
- private downloadCsv(): void {
1806
- if (!this.snapshot) {
1515
+ private renderEmptyState(headingRaw: string, descriptionRaw: string): string {
1516
+ return renderEmptyState(this, headingRaw, descriptionRaw);
1517
+ }
1518
+
1519
+ /** @internal */
1520
+ downloadCsv(
1521
+ rowType: 'all' | 'visible' | 'selected' = 'visible',
1522
+ colType: 'all' | 'visible' = 'visible',
1523
+ ): void {
1524
+ if (!this.controller) {
1807
1525
  return;
1808
1526
  }
1809
-
1810
- const csv = exportCsvRows(this.snapshot.visibleColumns, this.snapshot.pipeline.visibleRows);
1811
- const filename = sanitizeDownloadFilename(`${this.snapshot.options.id}.csv`);
1812
- downloadGridCsvFile(csv, filename);
1527
+ this.controller.gridApi.exporter.csvExport(rowType, colType);
1813
1528
  }
1814
1529
 
1815
1530
  private renderControlIcon(key: UiGridControlIconKey): string {
1816
- return this.renderIconWithClass('control-icon', key);
1531
+ return this.icons.renderControlIcon(key);
1817
1532
  }
1818
1533
 
1819
1534
  private renderIconWithClass(svgClass: string, key: UiGridControlIconKey): string {
1820
- const icon = this.iconOverrides[key] ?? DEFAULT_ICONS[key];
1821
- const viewBox = escapeHtml(icon.viewBox ?? '0 0 24 24');
1822
- const path = escapeHtml(icon.path);
1823
- return `<svg class="${svgClass}" viewBox="${viewBox}" aria-hidden="true" focusable="false"><path d="${path}"></path></svg>`;
1535
+ return this.icons.renderIconWithClass(svgClass, key);
1824
1536
  }
1825
1537
  }
1826
1538
 
1827
1539
  export async function defineStandaloneUiGridElement(tagName = 'ui-grid-element'): Promise<void> {
1540
+ UIGridFilterCell.define();
1541
+ UIGridGroupRow.define();
1542
+ UIGridPagination.define();
1543
+ UIGridBodyCell.define();
1544
+ UIGridHeaderCell.define();
1545
+ UIGridTemplate.define();
1546
+ UIGridCellEditor.define();
1828
1547
  if (!customElements.get(tagName)) {
1829
1548
  customElements.define(tagName, UiGridStandaloneElement);
1830
1549
  }