@ornery/ui-grid-vanilla 0.1.10 → 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 (62) hide show
  1. package/README.md +196 -0
  2. package/demo/main.ts +0 -1
  3. package/dist/index.d.mts +575 -55
  4. package/dist/index.d.ts +575 -55
  5. package/dist/index.js +4894 -1503
  6. package/dist/index.mjs +4994 -1556
  7. package/package.json +11 -8
  8. package/scss-plugin.ts +65 -0
  9. package/src/adopt-styles.ts +39 -0
  10. package/src/attribute-bridge.ts +291 -0
  11. package/src/auto-height.test.ts +142 -0
  12. package/src/auto-resize.test.ts +206 -0
  13. package/src/cell-nav.test.ts +262 -0
  14. package/src/components/grid-body-cell/grid-body-cell.ts +23 -0
  15. package/src/components/grid-body-cell/index.ts +1 -0
  16. package/src/components/grid-cell-editor/grid-cell-editor.ts +104 -0
  17. package/src/components/grid-cell-editor/index.ts +1 -0
  18. package/src/components/grid-filter-cell/grid-filter-cell.scss +14 -0
  19. package/src/components/grid-filter-cell/grid-filter-cell.template.ts +20 -0
  20. package/src/components/grid-filter-cell/grid-filter-cell.ts +121 -0
  21. package/src/components/grid-filter-cell/index.ts +1 -0
  22. package/src/components/grid-group-row/grid-group-row.scss +24 -0
  23. package/src/components/grid-group-row/grid-group-row.template.ts +23 -0
  24. package/src/components/grid-group-row/grid-group-row.ts +94 -0
  25. package/src/components/grid-group-row/index.ts +1 -0
  26. package/src/components/grid-header-cell/grid-header-cell.ts +17 -0
  27. package/src/components/grid-header-cell/index.ts +1 -0
  28. package/src/components/grid-pagination/grid-pagination.scss +39 -0
  29. package/src/components/grid-pagination/grid-pagination.template.ts +40 -0
  30. package/src/components/grid-pagination/grid-pagination.ts +204 -0
  31. package/src/components/grid-pagination/index.ts +1 -0
  32. package/src/components/grid-template/grid-template.ts +166 -0
  33. package/src/components/grid-template/index.ts +1 -0
  34. package/src/events.ts +815 -0
  35. package/src/exporter.test.ts +186 -0
  36. package/src/focus.ts +353 -0
  37. package/src/framework-slots.ts +440 -0
  38. package/src/grid-controller.ts +1402 -18
  39. package/src/icons.ts +88 -0
  40. package/src/importer.test.ts +147 -0
  41. package/src/index.integration.test.ts +2 -2
  42. package/src/index.ts +9 -0
  43. package/src/infinite-scroll.test.ts +220 -0
  44. package/src/keyboard-navigation.test.ts +579 -0
  45. package/src/patch.ts +722 -0
  46. package/src/render.ts +465 -0
  47. package/src/row-edit.test.ts +191 -0
  48. package/src/row-selection.test.ts +550 -0
  49. package/src/save-state.test.ts +235 -0
  50. package/src/scss.d.ts +4 -0
  51. package/src/templates/index.ts +62 -0
  52. package/src/test-setup.ts +23 -0
  53. package/src/ui-grid-empty.template.ts +23 -0
  54. package/src/ui-grid-shell.scss +25 -0
  55. package/src/ui-grid-shell.template.ts +45 -0
  56. package/src/ui-grid-standalone.element.ts +741 -1112
  57. package/src/utils/cell-class.ts +70 -0
  58. package/src/utils/display-items.ts +15 -0
  59. package/src/utils/dom.ts +64 -0
  60. package/src/validate.test.ts +170 -0
  61. package/tsup.config.ts +10 -0
  62. package/vite.config.ts +10 -1
@@ -1,47 +1,105 @@
1
1
  import {
2
- GRID_CORE_CSS,
3
- SORT_DIRECTIONS,
4
- buildGridHeaderContext,
5
2
  canGridMoveColumns,
6
- downloadGridCsvFile,
7
- exportCsvRows,
8
- findNextGridCell,
9
- formatGridHeaderDisplayValue,
10
- getCellValue,
11
- isGridNavigationKey,
12
- isPrintableGridKey,
13
- sanitizeDownloadFilename,
3
+ interpolateGridTemplate,
14
4
  type DisplayItem,
15
5
  type GridColumnDef,
16
6
  type GridOptions,
17
7
  type GridRecord,
18
- type GroupItem,
19
8
  type GridRow,
9
+ type GroupItem,
20
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';
21
82
  import {
22
83
  createVanillaGridController,
23
84
  type GridControllerSnapshot,
24
85
  type GridSaveState,
25
86
  type VanillaGridController,
26
87
  } from './grid-controller';
27
-
28
- function escapeHtml(value: unknown): string {
29
- const text = String(value ?? '');
30
- return text
31
- .replace(/&/g, '&')
32
- .replace(/</g, '&lt;')
33
- .replace(/>/g, '&gt;')
34
- .replace(/"/g, '&quot;')
35
- .replace(/'/g, '&#39;');
36
- }
37
-
38
- function asGroupItem(item: DisplayItem): GroupItem {
39
- return item as GroupItem;
40
- }
41
-
42
- function isRowItem(item: DisplayItem): item is DisplayItem & { kind: 'row'; row: GridRow } {
43
- return item.kind === 'row';
44
- }
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';
45
103
 
46
104
  export type VanillaUiGridElement = HTMLElement & {
47
105
  options: GridOptions;
@@ -55,123 +113,71 @@ export type VanillaUiGridElement = HTMLElement & {
55
113
  setData(rows: GridRecord[]): void;
56
114
  };
57
115
 
58
- export type UiGridControlIconKey =
59
- | 'sortNone'
60
- | 'sortAsc'
61
- | 'sortDesc'
62
- | 'group'
63
- | 'groupExpanded'
64
- | 'groupCollapsed'
65
- | 'treeExpanded'
66
- | 'treeCollapsed'
67
- | 'expandExpanded'
68
- | 'expandCollapsed'
69
- | 'pin'
70
- | 'pinLeft'
71
- | 'pinRight'
72
- | 'paginationPrev'
73
- | 'paginationNext';
74
-
75
- export interface UiGridIconDefinition {
76
- viewBox?: string;
77
- path: string;
78
- }
79
-
80
- export type UiGridIconOverrides = Partial<Record<UiGridControlIconKey, UiGridIconDefinition>>;
81
-
82
116
  type VanillaGridOptions = GridOptions & {
83
117
  iconOverrides?: UiGridIconOverrides;
84
118
  };
85
119
 
86
- const DEFAULT_ICONS: Record<UiGridControlIconKey, UiGridIconDefinition> = {
87
- sortNone: { path: 'M7 6h10v2H7V6Zm0 5h7v2H7v-2Zm0 5h4v2H7v-2Z' },
88
- sortAsc: { path: 'M12 5l-6 6h4v8h4v-8h4z' },
89
- sortDesc: { path: 'M12 19l6-6h-4V5h-4v8H6z' },
90
- group: { path: 'M4 6h8v4H4V6Zm0 8h8v4H4v-4Zm10-8h6v4h-6V6Zm0 8h6v4h-6v-4Z' },
91
- groupExpanded: { path: 'M7 10l5 5 5-5z' },
92
- groupCollapsed: { path: 'M10 7l5 5-5 5z' },
93
- treeExpanded: { path: 'M7 10l5 5 5-5z' },
94
- treeCollapsed: { path: 'M10 7l5 5-5 5z' },
95
- expandExpanded: { path: 'M7 10l5 5 5-5z' },
96
- expandCollapsed: { path: 'M10 7l5 5-5 5z' },
97
- pin: { path: 'M16 12V4h1V2H7v2h1v8l-2 2v2h5v6l1 1 1-1v-6h5v-2l-2-2z' },
98
- pinLeft: { path: 'M10 6 4 12l6 6v-4h10v-4H10V6z' },
99
- pinRight: { path: 'M14 6v4H4v4h10v4l6-6-6-6z' },
100
- paginationPrev: { path: 'M15.41 7.41 14 6l-6 6 6 6 1.41-1.41L10.83 12z' },
101
- paginationNext: { path: 'M8.59 16.59 10 18l6-6-6-6-1.41 1.41L13.17 12z' },
102
- };
103
-
104
120
  export class UiGridStandaloneElement extends HTMLElement {
105
- private controller: VanillaGridController | null = null;
106
- private snapshot: GridControllerSnapshot | null = null;
107
- private unsubscribe: (() => void) | null = null;
108
- private activeOptions: VanillaGridOptions | null = null;
109
- private attributeOptions: Partial<GridOptions> = {};
110
- private attributeSyncScheduled = false;
111
- private iconOverrides: UiGridIconOverrides = {};
112
- private templateObserver: MutationObserver | null = null;
113
- private openPinMenuColumn: string | null = null;
114
- private focusedCell: { rowId: string; columnName: string } | null = null;
115
- private draggedColumnName: string | null = null;
116
- private dropTargetColumnName: string | null = null;
117
- private scrollPosition = 0;
118
- private horizontalScrollPosition = 0;
119
- private scrollFrame: number | null = null;
120
- private suppressScrollEvent = false;
121
- private lastVirtualStartIndex = -1;
122
- private measuredHeaderStickyHeight = 0;
123
- private measuredFilterStickyHeight = 0;
124
- private stickyHeightRelayoutQueued = false;
125
- private benchmarkAverage = '—';
126
- private skipNextRender = false;
127
- private dataFrame: number | null = null;
128
- private pendingPatchedRowIds: Set<string> | null = null;
129
- private pendingDataRefreshMode: 'patch' | 'virtual' | 'full' | null = null;
130
- 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);
131
178
 
132
179
  static get observedAttributes(): string[] {
133
- return [
134
- // Scalar attributes
135
- 'grid-id',
136
- 'title',
137
- 'row-height',
138
- 'header-row-height',
139
- 'viewport-height',
140
- 'pagination-page-size',
141
- 'pagination-current-page',
142
- 'total-items',
143
- 'virtualization-threshold',
144
- 'tree-children-field',
145
- 'tree-indent',
146
- 'expandable-row-height',
147
- 'expandable-row-header-width',
148
- 'empty-message',
149
- 'infinite-scroll-rows-from-end',
150
- // JSON attributes
151
- 'column-defs',
152
- 'data',
153
- 'grouping',
154
- 'pagination-page-sizes',
155
- // Boolean flags
156
- 'enable-sorting',
157
- 'enable-filtering',
158
- 'enable-grouping',
159
- 'enable-pinning',
160
- 'enable-column-moving',
161
- 'enable-cell-edit',
162
- 'enable-cell-edit-on-focus',
163
- 'enable-pagination',
164
- 'enable-pagination-controls',
165
- 'use-external-pagination',
166
- 'enable-expandable',
167
- 'enable-tree-view',
168
- 'show-tree-expand-no-children',
169
- 'tree-row-header-always-visible',
170
- 'enable-auto-resize',
171
- 'enable-virtualization',
172
- 'infinite-scroll-up',
173
- 'infinite-scroll-down',
174
- ];
180
+ return [...OBSERVED_GRID_ATTRIBUTES];
175
181
  }
176
182
 
177
183
  get options(): GridOptions {
@@ -189,10 +195,36 @@ export class UiGridStandaloneElement extends HTMLElement {
189
195
 
190
196
  set options(value: GridOptions) {
191
197
  this.activeOptions = value as VanillaGridOptions;
192
- this.iconOverrides = this.activeOptions.iconOverrides ?? {};
198
+ this.icons.setOverrides(this.activeOptions.iconOverrides ?? {});
193
199
  this.ensureController(this.buildEffectiveOptions(this.activeOptions));
194
200
  }
195
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
+
196
228
  attributeChangedCallback(
197
229
  _name: string,
198
230
  _oldValue: string | null,
@@ -209,202 +241,47 @@ export class UiGridStandaloneElement extends HTMLElement {
209
241
  }
210
242
 
211
243
  private syncAttributesToOptions(): void {
212
- this.attributeOptions = {};
213
-
214
- // Scalar string attributes
215
- const gridId = this.getAttribute('grid-id');
216
- if (gridId !== null) this.attributeOptions.id = gridId;
217
-
218
- const title = this.getAttribute('title');
219
- if (title !== null) this.attributeOptions.title = title;
220
-
221
- const emptyMessage = this.getAttribute('empty-message');
222
- if (emptyMessage !== null) this.attributeOptions.emptyMessage = emptyMessage;
223
-
224
- const treeChildrenField = this.getAttribute('tree-children-field');
225
- if (treeChildrenField !== null) this.attributeOptions.treeChildrenField = treeChildrenField;
226
-
227
- // Scalar number attributes
228
- const rowHeight = this.parseNumberAttribute('row-height');
229
- if (rowHeight !== undefined) this.attributeOptions.rowHeight = rowHeight;
230
-
231
- const headerRowHeight = this.parseNumberAttribute('header-row-height');
232
- if (headerRowHeight !== undefined)
233
- this.attributeOptions.headerRowHeight = headerRowHeight;
234
-
235
- const viewportHeight = this.parseNumberAttribute('viewport-height');
236
- if (viewportHeight !== undefined) this.attributeOptions.viewportHeight = viewportHeight;
237
-
238
- const paginationPageSize = this.parseNumberAttribute('pagination-page-size');
239
- if (paginationPageSize !== undefined)
240
- this.attributeOptions.paginationPageSize = paginationPageSize;
241
-
242
- const paginationCurrentPage = this.parseNumberAttribute('pagination-current-page');
243
- if (paginationCurrentPage !== undefined)
244
- this.attributeOptions.paginationCurrentPage = paginationCurrentPage;
245
-
246
- const totalItems = this.parseNumberAttribute('total-items');
247
- if (totalItems !== undefined) this.attributeOptions.totalItems = totalItems;
248
-
249
- const virtualizationThreshold = this.parseNumberAttribute('virtualization-threshold');
250
- if (virtualizationThreshold !== undefined)
251
- this.attributeOptions.virtualizationThreshold = virtualizationThreshold;
252
-
253
- const treeIndent = this.parseNumberAttribute('tree-indent');
254
- if (treeIndent !== undefined) this.attributeOptions.treeIndent = treeIndent;
255
-
256
- const expandableRowHeight = this.parseNumberAttribute('expandable-row-height');
257
- if (expandableRowHeight !== undefined)
258
- this.attributeOptions.expandableRowHeight = expandableRowHeight;
259
-
260
- const expandableRowHeaderWidth = this.parseNumberAttribute(
261
- 'expandable-row-header-width',
262
- );
263
- if (expandableRowHeaderWidth !== undefined)
264
- this.attributeOptions.expandableRowHeaderWidth = expandableRowHeaderWidth;
265
-
266
- const infiniteScrollRowsFromEnd = this.parseNumberAttribute(
267
- 'infinite-scroll-rows-from-end',
268
- );
269
- if (infiniteScrollRowsFromEnd !== undefined)
270
- this.attributeOptions.infiniteScrollRowsFromEnd = infiniteScrollRowsFromEnd;
271
-
272
- // Boolean attributes
273
- const enableSorting = this.parseBooleanAttribute('enable-sorting');
274
- if (enableSorting !== undefined) this.attributeOptions.enableSorting = enableSorting;
275
-
276
- const enableFiltering = this.parseBooleanAttribute('enable-filtering');
277
- if (enableFiltering !== undefined) this.attributeOptions.enableFiltering = enableFiltering;
278
-
279
- const enableGrouping = this.parseBooleanAttribute('enable-grouping');
280
- if (enableGrouping !== undefined) this.attributeOptions.enableGrouping = enableGrouping;
281
-
282
- const enablePinning = this.parseBooleanAttribute('enable-pinning');
283
- if (enablePinning !== undefined) this.attributeOptions.enablePinning = enablePinning;
284
-
285
- const enableColumnMoving = this.parseBooleanAttribute('enable-column-moving');
286
- if (enableColumnMoving !== undefined)
287
- this.attributeOptions.enableColumnMoving = enableColumnMoving;
288
-
289
- const enableColumnResizing = this.parseBooleanAttribute('enable-column-resizing');
290
- if (enableColumnResizing !== undefined)
291
- this.attributeOptions.enableColumnResizing = enableColumnResizing;
292
-
293
- const enableCellEdit = this.parseBooleanAttribute('enable-cell-edit');
294
- if (enableCellEdit !== undefined) this.attributeOptions.enableCellEdit = enableCellEdit;
295
-
296
- const enableCellEditOnFocus = this.parseBooleanAttribute('enable-cell-edit-on-focus');
297
- if (enableCellEditOnFocus !== undefined)
298
- this.attributeOptions.enableCellEditOnFocus = enableCellEditOnFocus;
299
-
300
- const enablePagination = this.parseBooleanAttribute('enable-pagination');
301
- if (enablePagination !== undefined)
302
- this.attributeOptions.enablePagination = enablePagination;
303
-
304
- const enablePaginationControls = this.parseBooleanAttribute(
305
- 'enable-pagination-controls',
306
- );
307
- if (enablePaginationControls !== undefined)
308
- this.attributeOptions.enablePaginationControls = enablePaginationControls;
309
-
310
- const useExternalPagination = this.parseBooleanAttribute('use-external-pagination');
311
- if (useExternalPagination !== undefined)
312
- this.attributeOptions.useExternalPagination = useExternalPagination;
313
-
314
- const enableExpandable = this.parseBooleanAttribute('enable-expandable');
315
- if (enableExpandable !== undefined) this.attributeOptions.enableExpandable = enableExpandable;
244
+ this.attributeOptions = parseGridAttributeOptions(this);
316
245
 
317
- const enableTreeView = this.parseBooleanAttribute('enable-tree-view');
318
- if (enableTreeView !== undefined) this.attributeOptions.enableTreeView = enableTreeView;
319
-
320
- const showTreeExpandNoChildren = this.parseBooleanAttribute(
321
- 'show-tree-expand-no-children',
322
- );
323
- if (showTreeExpandNoChildren !== undefined)
324
- this.attributeOptions.showTreeExpandNoChildren = showTreeExpandNoChildren;
325
-
326
- const treeRowHeaderAlwaysVisible = this.parseBooleanAttribute(
327
- 'tree-row-header-always-visible',
328
- );
329
- if (treeRowHeaderAlwaysVisible !== undefined)
330
- this.attributeOptions.treeRowHeaderAlwaysVisible = treeRowHeaderAlwaysVisible;
331
-
332
- const enableAutoResize = this.parseBooleanAttribute('enable-auto-resize');
333
- if (enableAutoResize !== undefined) this.attributeOptions.enableAutoResize = enableAutoResize;
334
-
335
- const enableVirtualization = this.parseBooleanAttribute('enable-virtualization');
336
- if (enableVirtualization !== undefined)
337
- this.attributeOptions.enableVirtualization = enableVirtualization;
338
-
339
- const infiniteScrollUp = this.parseBooleanAttribute('infinite-scroll-up');
340
- if (infiniteScrollUp !== undefined) this.attributeOptions.infiniteScrollUp = infiniteScrollUp;
341
-
342
- const infiniteScrollDown = this.parseBooleanAttribute('infinite-scroll-down');
343
- if (infiniteScrollDown !== undefined)
344
- this.attributeOptions.infiniteScrollDown = infiniteScrollDown;
345
-
346
- // JSON attributes
347
- const columnDefs = this.parseJsonAttribute<GridColumnDef[]>('column-defs');
348
- if (columnDefs !== undefined) this.attributeOptions.columnDefs = columnDefs;
349
-
350
- const data = this.parseJsonAttribute<GridRecord[]>('data');
351
- if (data !== undefined) this.attributeOptions.data = data;
352
-
353
- const grouping = this.parseJsonAttribute('grouping');
354
- if (grouping !== undefined && grouping !== null)
355
- this.attributeOptions.grouping = grouping;
356
-
357
- const paginationPageSizes = this.parseJsonAttribute<number[] | null>(
358
- 'pagination-page-sizes',
359
- );
360
- if (paginationPageSizes !== undefined)
361
- this.attributeOptions.paginationPageSizes = paginationPageSizes;
362
-
363
- // 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.
364
251
  if (this.activeOptions !== null) {
365
252
  this.ensureController(this.buildEffectiveOptions(this.activeOptions));
366
253
  } else {
367
- // Purely declarative path: attributes drive the grid without an imperative bridge.
368
- this.ensureController({
369
- id: '__ui-grid-pending__',
370
- data: [],
371
- columnDefs: [],
372
- ...this.attributeOptions,
373
- } as VanillaGridOptions);
374
- }
375
- }
376
-
377
- private parseBooleanAttribute(name: string): boolean | undefined {
378
- return this.hasAttribute(name) ? true : undefined;
379
- }
380
-
381
- private parseNumberAttribute(name: string): number | undefined {
382
- const raw = this.getAttribute(name);
383
- if (raw === null) return undefined;
384
- const parsed = Number(raw);
385
- return Number.isFinite(parsed) ? parsed : undefined;
386
- }
387
-
388
- private parseJsonAttribute<T = unknown>(name: string): T | undefined {
389
- const raw = this.getAttribute(name);
390
- if (raw === null) return undefined;
391
- try {
392
- return JSON.parse(raw) as T;
393
- } catch (e) {
394
- console.warn(`<ui-grid-element>: invalid JSON in "${name}" attribute`, e);
395
- 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
+ );
396
269
  }
397
270
  }
398
271
 
399
272
  get controlIcons(): UiGridIconOverrides {
400
- return this.iconOverrides;
273
+ return this.icons.getOverrides();
401
274
  }
402
275
 
403
276
  set controlIcons(value: UiGridIconOverrides) {
404
- this.iconOverrides = { ...value };
277
+ this.icons.setOverrides(value);
405
278
  this.render();
406
279
  }
407
280
 
281
+ private resolveIcon(key: UiGridControlIconKey): UiGridIconDefinition {
282
+ return this.icons.resolve(key);
283
+ }
284
+
408
285
  // ─────────────────────────────────────────────────────────────────
409
286
  // Individual property accessors (mirrors of HTML attributes)
410
287
  // ─────────────────────────────────────────────────────────────────
@@ -443,14 +320,6 @@ export class UiGridStandaloneElement extends HTMLElement {
443
320
  this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
444
321
  }
445
322
 
446
- get viewportHeight(): number {
447
- return this.options.viewportHeight ?? 560;
448
- }
449
- set viewportHeight(value: number) {
450
- this.activeOptions = { ...this.activeOptions, viewportHeight: value } as any;
451
- this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
452
- }
453
-
454
323
  get paginationPageSize(): number {
455
324
  return this.options.paginationPageSize ?? 25;
456
325
  }
@@ -516,7 +385,10 @@ export class UiGridStandaloneElement extends HTMLElement {
516
385
  }
517
386
 
518
387
  get emptyMessage(): string {
519
- 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 ?? '';
520
392
  }
521
393
  set emptyMessage(value: string) {
522
394
  this.activeOptions = { ...this.activeOptions, emptyMessage: value } as any;
@@ -794,62 +666,14 @@ export class UiGridStandaloneElement extends HTMLElement {
794
666
  }
795
667
 
796
668
  private patchCells(changedRowIds?: ReadonlySet<string>): void {
797
- const snapshot = this.snapshot;
798
- if (!snapshot) {
799
- return;
800
- }
801
-
802
- const root = this.shadowRoot;
803
- if (!root) {
804
- return;
805
- }
806
-
807
- // Build O(1) lookup maps from the current pipeline snapshot.
808
- const rowMap = new Map<string, GridRow>();
809
- const rowIndexMap = new Map<string, number>();
810
- for (const row of snapshot.pipeline.visibleRows) {
811
- rowMap.set(row.id, row);
812
- rowIndexMap.set(row.id, rowIndexMap.size);
813
- }
814
- const colMap = new Map<string, GridColumnDef>();
815
- const templateMarkupMap = new Map<string, string | null>();
816
- for (const col of snapshot.visibleColumns) {
817
- colMap.set(col.name, col);
818
- templateMarkupMap.set(col.name, this.getTemplateMarkup(this.cellSlotName(col)));
819
- }
820
-
821
- // Patch every rendered body cell in-place.
822
- const cells = root.querySelectorAll<HTMLElement>('[data-row][data-column]');
823
- for (const cell of cells) {
824
- const rowId = cell.dataset['row'];
825
- const colName = cell.dataset['column'];
826
- if (!rowId || !colName) continue;
827
- if (changedRowIds && !changedRowIds.has(rowId)) continue;
828
-
829
- const row = rowMap.get(rowId);
830
- const column = colMap.get(colName);
831
- if (!row || !column) continue;
832
- if (cell.classList.contains('cell-editing')) continue;
833
-
834
- const cellContent = cell.querySelector<HTMLElement>('.cell-content');
835
- if (!cellContent) continue;
836
-
837
- const newContent = this.renderCellTemplateFromMarkup(
838
- row,
839
- column,
840
- rowIndexMap.get(rowId) ?? 0,
841
- templateMarkupMap.get(colName) ?? null,
842
- );
843
- if (cellContent.innerHTML !== newContent) {
844
- cellContent.innerHTML = newContent;
845
- }
846
- }
669
+ patchCells(this, changedRowIds);
847
670
  }
848
671
 
849
672
  connectedCallback(): void {
850
673
  this.ensureShadowRoot();
851
674
  this.bindEvents();
852
675
  this.observeTemplateSlots();
676
+ this.setupAutoResize();
853
677
 
854
678
  if (this.activeOptions) {
855
679
  this.ensureController(this.buildEffectiveOptions(this.activeOptions));
@@ -863,13 +687,18 @@ export class UiGridStandaloneElement extends HTMLElement {
863
687
  this.unsubscribe = null;
864
688
  this.templateObserver?.disconnect();
865
689
  this.templateObserver = null;
690
+ this.teardownAutoResize();
866
691
  if (this.scrollFrame !== null) {
867
692
  cancelAnimationFrame(this.scrollFrame);
868
693
  this.scrollFrame = null;
869
694
  }
695
+ // Emit `removed` events for every framework-rendered slot so wrappers
696
+ // can destroy their views.
697
+ this.frameworkSlots.flushRemovals();
870
698
  }
871
699
 
872
- private ensureShadowRoot(): ShadowRoot {
700
+ /** @internal */
701
+ ensureShadowRoot(): ShadowRoot {
873
702
  if (!this.shadowRoot) {
874
703
  this.attachShadow({ mode: 'open' });
875
704
  }
@@ -877,41 +706,103 @@ export class UiGridStandaloneElement extends HTMLElement {
877
706
  return this.shadowRoot as ShadowRoot;
878
707
  }
879
708
 
880
- private ensureController(options: VanillaGridOptions): void {
709
+ /** @internal */
710
+ ensureController(options: VanillaGridOptions): void {
881
711
  if (this.controller) {
882
712
  this.controller.setOptions(options);
883
713
  return;
884
714
  }
885
715
 
886
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
+ });
887
757
  this.unsubscribe = this.controller.subscribe((snapshot) => {
888
758
  this.snapshot = snapshot;
889
759
  this.render();
890
760
  });
891
761
  }
892
762
 
893
- private observeTemplateSlots(): void {
894
- if (this.templateObserver) {
895
- 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;
896
784
  }
785
+ this.importerFileInput.click();
786
+ }
897
787
 
898
- this.templateObserver = new MutationObserver(() => {
899
- if (this.activeOptions) {
900
- this.ensureController(this.buildEffectiveOptions(this.activeOptions));
901
- } else {
902
- this.render();
903
- }
904
- });
788
+ private observeTemplateSlots(): void {
789
+ observeTemplateSlots(this);
790
+ }
905
791
 
906
- this.templateObserver.observe(this, {
907
- childList: true,
908
- subtree: true,
909
- attributes: true,
910
- attributeFilter: ['slot'],
911
- });
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);
912
802
  }
913
803
 
914
- private buildEffectiveOptions(options: VanillaGridOptions): VanillaGridOptions {
804
+ /** @internal */
805
+ buildEffectiveOptions(options: VanillaGridOptions): VanillaGridOptions {
915
806
  // Merge: attributes are overridden by explicit JS property values.
916
807
  // JS property always wins: { ...attributeOptions, ...options }
917
808
  const merged = {
@@ -919,8 +810,7 @@ export class UiGridStandaloneElement extends HTMLElement {
919
810
  ...options,
920
811
  } as VanillaGridOptions;
921
812
 
922
- const hasExpandableSlot = this.getTemplateMarkup('expandable-row') !== null;
923
- if (merged.enableExpandable && hasExpandableSlot && !merged.expandableRowTemplate) {
813
+ if (merged.enableExpandable && !merged.expandableRowTemplate) {
924
814
  merged.expandableRowTemplate = {
925
815
  createEmbeddedView: () => undefined,
926
816
  };
@@ -929,450 +819,28 @@ export class UiGridStandaloneElement extends HTMLElement {
929
819
  return merged;
930
820
  }
931
821
 
932
- private bindEvents(): void {
933
- const root = this.ensureShadowRoot();
934
- if ((root as ShadowRoot & { __uiGridBound?: boolean }).__uiGridBound) {
935
- return;
936
- }
937
-
938
- root.addEventListener('click', (event) => {
939
- const target = event.target as HTMLElement | null;
940
- if (!target || !this.controller || !this.snapshot) {
941
- return;
942
- }
943
-
944
- const actionNode = target.closest<HTMLElement>('[data-action]');
945
- if (!actionNode) {
946
- return;
947
- }
948
-
949
- const action = actionNode.dataset['action'];
950
- if (!action) {
951
- return;
952
- }
953
-
954
- if (this.openPinMenuColumn && !target.closest('.pin-control')) {
955
- this.openPinMenuColumn = null;
956
- this.render();
957
- return;
958
- }
959
-
960
- if (action === 'sort') {
961
- const columnName = actionNode.dataset['column'];
962
- if (columnName) {
963
- this.controller.toggleSort(columnName);
964
- }
965
- return;
966
- }
967
-
968
- if (action === 'group') {
969
- const columnName = actionNode.dataset['column'];
970
- if (columnName) {
971
- this.controller.toggleGrouping(columnName);
972
- }
973
- return;
974
- }
975
-
976
- if (action === 'toggle-group') {
977
- const groupId = actionNode.dataset['group'];
978
- if (groupId) {
979
- const collapsed = actionNode.dataset['collapsed'] !== 'true';
980
- this.controller.setCollapsedGroup(groupId, collapsed);
981
- }
982
- return;
983
- }
984
-
985
- if (action === 'pin-trigger') {
986
- const columnName = actionNode.dataset['column'];
987
- const column = columnName
988
- ? this.snapshot.visibleColumns.find((candidate) => candidate.name === columnName)
989
- : undefined;
990
-
991
- if (columnName && column) {
992
- if (this.controller.isPinned(column)) {
993
- this.openPinMenuColumn = null;
994
- this.controller.pinColumn(columnName, 'none');
995
- return;
996
- }
997
-
998
- this.openPinMenuColumn = this.openPinMenuColumn === columnName ? null : columnName;
999
- this.render();
1000
- }
1001
- return;
1002
- }
1003
-
1004
- if (action === 'pin-left' || action === 'pin-right') {
1005
- const columnName = actionNode.dataset['column'];
1006
- if (columnName) {
1007
- this.openPinMenuColumn = null;
1008
- this.controller.pinColumn(columnName, action === 'pin-left' ? 'left' : 'right');
1009
- }
1010
- return;
1011
- }
1012
-
1013
- if (action === 'toggle-tree') {
1014
- const rowId = actionNode.dataset['row'];
1015
- if (rowId) {
1016
- this.controller.toggleTreeRow(rowId);
1017
- }
1018
- return;
1019
- }
1020
-
1021
- if (action === 'toggle-expand') {
1022
- const rowId = actionNode.dataset['row'];
1023
- if (rowId) {
1024
- this.controller.toggleRowExpansion(rowId);
1025
- }
1026
- return;
1027
- }
1028
-
1029
- if (action === 'page-prev') {
1030
- this.controller.seekPage(this.snapshot.currentPage - 1);
1031
- return;
1032
- }
1033
-
1034
- if (action === 'page-next') {
1035
- this.controller.seekPage(this.snapshot.currentPage + 1);
1036
- return;
1037
- }
1038
-
1039
- if (action === 'benchmark') {
1040
- const result = this.controller.gridApi.core.benchmark();
1041
- this.benchmarkAverage = result.averageMs.toFixed(2);
1042
- this.render();
1043
- return;
1044
- }
1045
-
1046
- if (action === 'export-csv') {
1047
- this.downloadCsv();
1048
- }
1049
- });
1050
-
1051
- root.addEventListener('focusin', (event) => {
1052
- const target = event.target as HTMLElement | null;
1053
- if (!target) {
1054
- return;
1055
- }
1056
-
1057
- const cell = target.closest<HTMLElement>('.body-cell[data-row][data-column]');
1058
- const rowId = cell?.dataset['row'];
1059
- const columnName = cell?.dataset['column'];
1060
- if (rowId && columnName) {
1061
- this.focusedCell = { rowId, columnName };
1062
- }
1063
- });
1064
-
1065
- root.addEventListener('focusout', (event) => {
1066
- const relatedTarget = (event as FocusEvent).relatedTarget as HTMLElement | null;
1067
- if (!relatedTarget?.closest('.body-cell[data-row][data-column]')) {
1068
- this.focusedCell = null;
1069
- }
1070
- });
1071
-
1072
- root.addEventListener('input', (event) => {
1073
- const target = event.target as HTMLElement | null;
1074
- if (!target || !this.controller) {
1075
- return;
1076
- }
1077
-
1078
- if (target instanceof HTMLInputElement && target.dataset['role'] === 'filter') {
1079
- const columnName = target.dataset['column'];
1080
- if (columnName) {
1081
- this.controller.setFilter(columnName, target.value);
1082
- }
1083
- }
1084
-
1085
- if (target instanceof HTMLInputElement && target.dataset['role'] === 'editor') {
1086
- this.controller.updateEditingValue(target.value);
1087
- }
1088
- });
1089
-
1090
- root.addEventListener('change', (event) => {
1091
- const target = event.target as HTMLElement | null;
1092
- if (!target || !this.controller) {
1093
- return;
1094
- }
1095
-
1096
- if (target instanceof HTMLSelectElement && target.dataset['role'] === 'page-size') {
1097
- const value = Number.parseInt(target.value, 10);
1098
- this.controller.setPageSize(value);
1099
- }
1100
- });
1101
-
1102
- root.addEventListener('mousedown', (event) => {
1103
- const target = event.target as HTMLElement | null;
1104
- if (!target || !this.controller) return;
1105
-
1106
- const resizer = target.closest<HTMLElement>('.column-resizer[data-column]');
1107
- if (!resizer || !this.controller.canResizeColumns()) return;
1108
-
1109
- const columnName = resizer.dataset['column'];
1110
- if (!columnName) return;
1111
-
1112
- const headerCell = resizer.closest<HTMLElement>('.header-cell');
1113
- if (!headerCell) return;
1114
-
1115
- event.preventDefault();
1116
- event.stopPropagation();
1117
-
1118
- const startX = (event as MouseEvent).clientX;
1119
- const startWidth = headerCell.getBoundingClientRect().width;
1120
- let lastWidth = startWidth;
1121
-
1122
- const handleMove = (moveEvent: MouseEvent): void => {
1123
- lastWidth = Math.max(88, startWidth + (moveEvent.clientX - startX));
1124
-
1125
- // Write directly to DOM — skip full refresh while dragging.
1126
- const newTemplate = this.controller!.buildTemplateColumnsWithOverride(columnName, lastWidth);
1127
- const root = this.shadowRoot ?? this;
1128
- (root as ShadowRoot | HTMLElement).querySelectorAll<HTMLElement>('.header-grid, .filter-grid, .body-grid').forEach((el) => {
1129
- el.style.gridTemplateColumns = newTemplate;
1130
- });
1131
- };
1132
-
1133
- const handleUp = (): void => {
1134
- window.removeEventListener('mousemove', handleMove);
1135
- window.removeEventListener('mouseup', handleUp);
1136
- // Commit final width once — triggers one full refresh.
1137
- this.controller!.setColumnWidthOverride(columnName, lastWidth);
1138
- };
1139
-
1140
- window.addEventListener('mousemove', handleMove);
1141
- window.addEventListener('mouseup', handleUp);
1142
- });
1143
-
1144
- root.addEventListener('dblclick', (event) => {
1145
- const target = event.target as HTMLElement | null;
1146
- if (!target || !this.controller) {
1147
- return;
1148
- }
1149
-
1150
- const resizer = target.closest<HTMLElement>('.column-resizer[data-column]');
1151
- if (resizer && this.controller.canResizeColumns()) {
1152
- const columnName = resizer.dataset['column'];
1153
- if (columnName) {
1154
- event.preventDefault();
1155
- event.stopPropagation();
1156
- this.controller.setColumnWidthOverride(columnName, this.measureAutoColumnWidth(columnName));
1157
- return;
1158
- }
1159
- }
1160
-
1161
- const cell = target.closest<HTMLElement>('.body-cell');
1162
- if (!cell) {
1163
- return;
1164
- }
1165
-
1166
- const rowId = cell.dataset['row'];
1167
- const columnName = cell.dataset['column'];
1168
- if (rowId && columnName) {
1169
- this.controller.beginCellEdit(rowId, columnName, event);
1170
- }
1171
- });
1172
-
1173
- root.addEventListener('keydown', (event) => {
1174
- const keyboardEvent = event as KeyboardEvent;
1175
- const target = event.target as HTMLElement | null;
1176
- if (!target || !this.controller) {
1177
- return;
1178
- }
1179
-
1180
- if (target instanceof HTMLInputElement && target.dataset['role'] === 'editor') {
1181
- if (keyboardEvent.key === 'Enter') {
1182
- event.preventDefault();
1183
- this.controller.commitCellEdit();
1184
- return;
1185
- }
1186
-
1187
- if (keyboardEvent.key === 'Escape') {
1188
- event.preventDefault();
1189
- this.controller.cancelCellEdit();
1190
- }
1191
-
1192
- return;
1193
- }
1194
-
1195
- const cell = target.closest<HTMLElement>('.body-cell');
1196
- if (!cell) {
1197
- return;
1198
- }
1199
-
1200
- const rowId = cell.dataset['row'];
1201
- const columnName = cell.dataset['column'];
1202
- if (!rowId || !columnName) {
1203
- return;
1204
- }
1205
-
1206
- if (keyboardEvent.key === 'Enter' || keyboardEvent.key === 'F2') {
1207
- event.preventDefault();
1208
- this.controller.beginCellEdit(rowId, columnName, event);
1209
- return;
1210
- }
1211
-
1212
- if (
1213
- keyboardEvent.key.length === 1 &&
1214
- !keyboardEvent.ctrlKey &&
1215
- !keyboardEvent.metaKey &&
1216
- !keyboardEvent.altKey
1217
- ) {
1218
- event.preventDefault();
1219
- this.controller.beginCellEdit(rowId, columnName, event);
1220
- this.controller.updateEditingValue(keyboardEvent.key);
1221
- }
1222
- });
1223
-
1224
- root.addEventListener(
1225
- 'blur',
1226
- (event) => {
1227
- const target = event.target as HTMLElement | null;
1228
- if (!target || !this.controller) {
1229
- return;
1230
- }
1231
-
1232
- if (target instanceof HTMLInputElement && target.dataset['role'] === 'editor') {
1233
- this.controller.commitCellEdit();
1234
- }
1235
- },
1236
- true,
1237
- );
1238
-
1239
- root.addEventListener('dragstart', (event) => {
1240
- const dragEvent = event as DragEvent;
1241
- const target = event.target as HTMLElement | null;
1242
- const headerCell = target?.closest<HTMLElement>('.header-cell[data-column]');
1243
-
1244
- if (!headerCell || !this.snapshot || !canGridMoveColumns(this.snapshot.options)) {
1245
- event.preventDefault();
1246
- return;
1247
- }
1248
-
1249
- const columnName = headerCell.dataset['column'];
1250
- if (!columnName || !dragEvent.dataTransfer) {
1251
- event.preventDefault();
1252
- return;
1253
- }
1254
-
1255
- this.draggedColumnName = columnName;
1256
- this.dropTargetColumnName = null;
1257
- dragEvent.dataTransfer.effectAllowed = 'move';
1258
- dragEvent.dataTransfer.setData('text/plain', columnName);
1259
- headerCell.classList.add('is-dragging');
1260
- });
1261
-
1262
- root.addEventListener('dragover', (event) => {
1263
- const dragEvent = event as DragEvent;
1264
- const target = event.target as HTMLElement | null;
1265
- const headerCell = target?.closest<HTMLElement>('.header-cell[data-column]');
1266
-
1267
- if (!headerCell || !this.draggedColumnName || !dragEvent.dataTransfer) {
1268
- return;
1269
- }
1270
-
1271
- const columnName = headerCell.dataset['column'];
1272
- if (!columnName || columnName === this.draggedColumnName) {
1273
- return;
1274
- }
1275
-
1276
- event.preventDefault();
1277
- dragEvent.dataTransfer.dropEffect = 'move';
1278
-
1279
- if (this.dropTargetColumnName !== columnName) {
1280
- root
1281
- .querySelectorAll('.header-cell.is-drag-target')
1282
- .forEach((element) => element.classList.remove('is-drag-target'));
1283
- this.dropTargetColumnName = columnName;
1284
- headerCell.classList.add('is-drag-target');
1285
- }
1286
- });
1287
-
1288
- root.addEventListener('dragleave', (event) => {
1289
- const target = event.target as HTMLElement | null;
1290
- const headerCell = target?.closest<HTMLElement>('.header-cell[data-column]');
1291
- if (headerCell && headerCell.dataset['column'] === this.dropTargetColumnName) {
1292
- headerCell.classList.remove('is-drag-target');
1293
- }
1294
- });
1295
-
1296
- root.addEventListener('drop', (event) => {
1297
- event.preventDefault();
1298
- const dragEvent = event as DragEvent;
1299
- const target = event.target as HTMLElement | null;
1300
- const headerCell = target?.closest<HTMLElement>('.header-cell[data-column]');
1301
- const targetColumn = headerCell?.dataset['column'];
1302
- const sourceColumn =
1303
- this.draggedColumnName ?? dragEvent.dataTransfer?.getData('text/plain') ?? null;
1304
-
1305
- this.draggedColumnName = null;
1306
- this.dropTargetColumnName = null;
1307
-
1308
- root
1309
- .querySelectorAll('.header-cell.is-dragging, .header-cell.is-drag-target')
1310
- .forEach((element) => element.classList.remove('is-dragging', 'is-drag-target'));
1311
-
1312
- if (!sourceColumn || !targetColumn || sourceColumn === targetColumn || !this.controller) {
1313
- return;
1314
- }
1315
-
1316
- this.controller.moveVisibleColumn(sourceColumn, targetColumn);
1317
- });
1318
-
1319
- root.addEventListener('dragend', () => {
1320
- this.draggedColumnName = null;
1321
- this.dropTargetColumnName = null;
1322
- root
1323
- .querySelectorAll('.header-cell.is-dragging, .header-cell.is-drag-target')
1324
- .forEach((element) => element.classList.remove('is-dragging', 'is-drag-target'));
1325
- });
1326
-
1327
- root.addEventListener(
1328
- 'scroll',
1329
- (event) => {
1330
- const target = event.target as HTMLElement | null;
1331
- if (!target?.classList.contains('grid-table') || this.suppressScrollEvent) {
1332
- return;
1333
- }
1334
-
1335
- this.lastScrollActivityAt = Date.now();
1336
- this.scrollPosition = target.scrollTop;
1337
- this.horizontalScrollPosition = target.scrollLeft;
1338
- if (!this.snapshot?.pipeline.virtualizationEnabled) {
1339
- return;
1340
- }
1341
-
1342
- const stickyChromeHeight =
1343
- this.measuredHeaderStickyHeight + this.measuredFilterStickyHeight;
1344
- const bodyScrollTop = Math.max(0, this.scrollPosition - stickyChromeHeight);
1345
- const overscan = 4;
1346
- const nextStartIndex = Math.max(
1347
- 0,
1348
- Math.floor(bodyScrollTop / this.snapshot.rowSize) - overscan,
1349
- );
1350
- if (nextStartIndex === this.lastVirtualStartIndex) {
1351
- return;
1352
- }
1353
-
1354
- if (this.scrollFrame !== null) {
1355
- cancelAnimationFrame(this.scrollFrame);
1356
- }
1357
-
1358
- this.scrollFrame = requestAnimationFrame(() => {
1359
- this.scrollFrame = null;
1360
- this.renderVirtualBody();
1361
- });
1362
- },
1363
- true,
1364
- );
1365
-
1366
- (root as ShadowRoot & { __uiGridBound?: boolean }).__uiGridBound = true;
1367
- }
1368
-
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
+ */
1369
836
  /**
1370
837
  * Targeted update for virtual scroll: replaces only the rendered rows inside
1371
838
  * the existing `.grid-virtual-body` without touching the scroll container.
1372
839
  * This preserves momentum/inertia scrolling because the `.grid-table` element
1373
840
  * is never destroyed.
1374
841
  */
1375
- private renderVirtualBody(): void {
842
+ /** @internal */
843
+ renderVirtualBody(): void {
1376
844
  const snapshot = this.snapshot;
1377
845
  if (!snapshot?.pipeline.virtualizationEnabled) {
1378
846
  this.render();
@@ -1393,12 +861,12 @@ export class UiGridStandaloneElement extends HTMLElement {
1393
861
  this.dataFrame = null;
1394
862
  }
1395
863
 
1396
- const stickyChromeHeight = this.measuredHeaderStickyHeight + this.measuredFilterStickyHeight;
1397
- const viewportHeight = snapshot.options.viewportHeight ?? 560;
1398
- const bodyViewportHeight = Math.max(snapshot.rowSize, viewportHeight - stickyChromeHeight);
1399
- 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);
1400
868
  const overscan = 4;
1401
- const startIndex = Math.max(0, Math.floor(bodyScrollTop / snapshot.rowSize) - overscan);
869
+ const startIndex = Math.max(0, Math.floor(this.scrollPosition / snapshot.rowSize) - overscan);
1402
870
  this.lastVirtualStartIndex = startIndex;
1403
871
  const visibleCount = Math.ceil(bodyViewportHeight / snapshot.rowSize) + overscan * 2;
1404
872
  const itemsToRender = snapshot.pipeline.displayItems.slice(
@@ -1411,9 +879,14 @@ export class UiGridStandaloneElement extends HTMLElement {
1411
879
  virtualBody.innerHTML = itemsToRender
1412
880
  .map((item, index) => this.renderDisplayItem(item, startIndex + index))
1413
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;
1414
886
  }
1415
887
 
1416
- private render(): void {
888
+ /** @internal */
889
+ render(): void {
1417
890
  if (this.skipNextRender) {
1418
891
  return;
1419
892
  }
@@ -1429,17 +902,164 @@ export class UiGridStandaloneElement extends HTMLElement {
1429
902
  }
1430
903
  const root = this.ensureShadowRoot();
1431
904
  const snapshot = this.snapshot;
1432
- const previousGridTable = root.querySelector<HTMLElement>('.grid-table');
1433
- if (previousGridTable && !this.suppressScrollEvent) {
1434
- this.scrollPosition = previousGridTable.scrollTop;
1435
- 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;
1436
909
  }
1437
910
 
1438
911
  if (!snapshot) {
1439
- 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);
1440
915
  return;
1441
916
  }
1442
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
+ } {
1443
1063
  const controller = this.controller!;
1444
1064
  const options = snapshot.options;
1445
1065
  const labels = snapshot.labels;
@@ -1451,19 +1071,22 @@ export class UiGridStandaloneElement extends HTMLElement {
1451
1071
  const paginationEnabled = controller.isPaginationEnabled();
1452
1072
  const showPagination = controller.shouldShowPaginationControls();
1453
1073
  const virtualizationEnabled = snapshot.pipeline.virtualizationEnabled;
1454
- const viewportHeight = options.viewportHeight ?? 560;
1455
- const headerStickyTop = this.measuredHeaderStickyHeight || options.headerRowHeight || 50;
1456
- const stickyChromeHeight = this.measuredHeaderStickyHeight + this.measuredFilterStickyHeight;
1457
- 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
+
1458
1082
  let startIndex = 0;
1459
- let itemsToRender = snapshot.pipeline.displayItems;
1083
+ let itemsToRender: readonly DisplayItem[] = snapshot.pipeline.displayItems;
1460
1084
  let virtualOffset = 0;
1461
1085
  const totalVirtualHeight = snapshot.pipeline.displayItems.length * snapshot.rowSize;
1462
1086
 
1463
1087
  if (virtualizationEnabled) {
1464
- const bodyScrollTop = Math.max(0, this.scrollPosition - stickyChromeHeight);
1465
1088
  const overscan = 4;
1466
- startIndex = Math.max(0, Math.floor(bodyScrollTop / snapshot.rowSize) - overscan);
1089
+ startIndex = Math.max(0, Math.floor(this.scrollPosition / snapshot.rowSize) - overscan);
1467
1090
  this.lastVirtualStartIndex = startIndex;
1468
1091
  const visibleCount = Math.ceil(bodyViewportHeight / snapshot.rowSize) + overscan * 2;
1469
1092
  itemsToRender = snapshot.pipeline.displayItems.slice(
@@ -1475,6 +1098,65 @@ export class UiGridStandaloneElement extends HTMLElement {
1475
1098
  this.lastVirtualStartIndex = -1;
1476
1099
  }
1477
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
+
1478
1160
  const slotRegistry = this.renderSlotRegistry(snapshot.visibleColumns);
1479
1161
  const header = snapshot.visibleColumns
1480
1162
  .map((column) =>
@@ -1483,9 +1165,10 @@ export class UiGridStandaloneElement extends HTMLElement {
1483
1165
  .join('');
1484
1166
 
1485
1167
  const filterRow = filterEnabled
1486
- ? `<div class="filter-grid ui-grid-header" style="grid-template-columns:${templateColumns}">${snapshot.visibleColumns
1487
- .map((column) => this.renderFilterCell(column))
1488
- .join('')}</div>`
1168
+ ? filterRowMarkup(
1169
+ templateColumns,
1170
+ snapshot.visibleColumns.map((column) => this.renderFilterCell(column)).join(''),
1171
+ )
1489
1172
  : '';
1490
1173
 
1491
1174
  const bodyContent = itemsToRender
@@ -1495,51 +1178,103 @@ export class UiGridStandaloneElement extends HTMLElement {
1495
1178
  const body =
1496
1179
  snapshot.pipeline.displayItems.length > 0
1497
1180
  ? virtualizationEnabled
1498
- ? `<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>`
1499
- : `<div class="body-grid ui-grid-canvas" style="grid-template-columns:${templateColumns}">${bodyContent}</div>`
1500
- : `<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
+ );
1501
1187
 
1502
1188
  const pagination = paginationEnabled && showPagination ? this.renderPagination(snapshot) : '';
1503
- const hasViewportScroll = virtualizationEnabled || options.viewportHeight !== undefined;
1504
- const gridTableStyle = `${hasViewportScroll ? `height:${viewportHeight}px;overflow-y:auto;` : ''}--ui-grid-header-sticky-top:${headerStickyTop}px;`;
1505
1189
 
1506
- 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
+ }
1507
1224
 
1508
- const gridTable = root.querySelector<HTMLElement>('.grid-table');
1509
- if (gridTable && (this.scrollPosition > 0 || this.horizontalScrollPosition > 0)) {
1510
- this.suppressScrollEvent = true;
1511
- if (this.scrollPosition > 0) {
1512
- gridTable.scrollTop = this.scrollPosition;
1513
- }
1514
- if (this.horizontalScrollPosition > 0) {
1515
- gridTable.scrollLeft = this.horizontalScrollPosition;
1516
- }
1517
- requestAnimationFrame(() => {
1518
- this.suppressScrollEvent = false;
1519
- });
1520
- }
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
+ }
1521
1244
 
1522
- const headerGrid = root.querySelector<HTMLElement>('.header-grid');
1523
- const filterGrid = root.querySelector<HTMLElement>('.filter-grid');
1524
- const nextHeaderStickyHeight = headerGrid?.offsetHeight ?? 0;
1525
- const nextFilterStickyHeight = filterGrid?.offsetHeight ?? 0;
1526
- const stickyHeightsChanged =
1527
- nextHeaderStickyHeight !== this.measuredHeaderStickyHeight ||
1528
- nextFilterStickyHeight !== this.measuredFilterStickyHeight;
1245
+ private patchFilterCells(filterGrid: HTMLElement, snapshot: GridControllerSnapshot): void {
1246
+ patchFilterCells(this, filterGrid, snapshot);
1247
+ }
1529
1248
 
1530
- this.measuredHeaderStickyHeight = nextHeaderStickyHeight;
1531
- this.measuredFilterStickyHeight = nextFilterStickyHeight;
1249
+ private fingerprintItems(items: readonly DisplayItem[]): string {
1250
+ return fingerprintItems(items);
1251
+ }
1532
1252
 
1533
- if (stickyHeightsChanged && !this.stickyHeightRelayoutQueued) {
1534
- this.stickyHeightRelayoutQueued = true;
1535
- requestAnimationFrame(() => {
1536
- this.stickyHeightRelayoutQueued = false;
1537
- if (!this.snapshot) {
1538
- return;
1539
- }
1540
- this.render();
1541
- });
1542
- }
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);
1543
1278
  }
1544
1279
 
1545
1280
  private renderHeaderCell(
@@ -1549,177 +1284,102 @@ export class UiGridStandaloneElement extends HTMLElement {
1549
1284
  pinningEnabled: boolean,
1550
1285
  options: GridOptions,
1551
1286
  ): string {
1552
- const controller = this.controller!;
1553
- const sortDirection = controller.getSortDirection(column);
1554
- const sortLabel = controller.sortButtonLabel(column);
1555
- const groupingLabel = controller.groupingButtonLabel(column);
1556
- const canSort = sortEnabled && controller.isColumnSortable(column);
1557
- const canGroup = groupingEnabled && column.enableGrouping !== false;
1558
- const canPin = pinningEnabled && controller.isColumnPinnable(column);
1559
- const isPinned = controller.isPinned(column);
1560
- const pinOffset = isPinned ? controller.pinnedOffset(column) : null;
1561
- const isPinnedLeftLast = controller.isPinnedLeftLast(column);
1562
- const isPinnedRightFirst = controller.isPinnedRightFirst(column);
1563
- const isDragTarget = this.dropTargetColumnName === column.name;
1564
- const isDragging = this.draggedColumnName === column.name;
1565
- const classes = [
1566
- 'header-cell',
1567
- sortDirection !== SORT_DIRECTIONS.none ? 'is-active' : '',
1568
- isPinned ? 'is-pinned' : '',
1569
- isPinnedLeftLast ? 'is-pinned-left-last' : '',
1570
- isPinnedRightFirst ? 'is-pinned-right-first' : '',
1571
- this.openPinMenuColumn === column.name ? 'is-pin-menu-open' : '',
1572
- isDragTarget ? 'is-drag-target' : '',
1573
- isDragging ? 'is-dragging' : '',
1574
- ]
1575
- .filter(Boolean)
1576
- .join(' ');
1577
-
1578
- const stickyStyle = pinOffset ? `${pinOffset.side}:${pinOffset.offset};` : '';
1579
-
1580
- const sortIconKey =
1581
- sortDirection === SORT_DIRECTIONS.asc
1582
- ? 'sortAsc'
1583
- : sortDirection === SORT_DIRECTIONS.desc
1584
- ? 'sortDesc'
1585
- : 'sortNone';
1586
-
1587
- const draggable = canGridMoveColumns(options) ? 'draggable="true"' : '';
1588
- const pinLabel = isPinned
1589
- ? (this.snapshot?.labels.unpin ?? 'Unpin')
1590
- : (this.snapshot?.labels.pinColumn ?? 'Pin');
1591
- const canResize = controller.canResizeColumns();
1592
- const headerValue = escapeHtml(formatGridHeaderDisplayValue(buildGridHeaderContext(column)));
1593
- const resizerHtml = canResize
1594
- ? `<button type="button" class="column-resizer" data-action="resize" data-column="${escapeHtml(column.name)}" aria-label="Resize ${escapeHtml(headerValue)} column" title="Drag to resize, double-click to auto fit"></button>`
1595
- : '';
1596
-
1597
- 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>${resizerHtml}</div>`;
1287
+ return renderHeaderCell(this, column, sortEnabled, groupingEnabled, pinningEnabled, options);
1598
1288
  }
1599
1289
 
1600
1290
  private renderFilterCell(column: GridColumnDef): string {
1601
- const value = this.snapshot?.activeFilters[column.name] ?? '';
1602
- const controller = this.controller!;
1603
- const canFilter = controller.isColumnFilterable(column);
1604
- const pinOffset = controller.isPinned(column) ? controller.pinnedOffset(column) : null;
1605
- const classes = [
1606
- 'filter-cell',
1607
- controller.isPinned(column) ? 'is-pinned' : '',
1608
- controller.isPinnedLeftLast(column) ? 'is-pinned-left-last' : '',
1609
- controller.isPinnedRightFirst(column) ? 'is-pinned-right-first' : '',
1610
- ]
1611
- .filter(Boolean)
1612
- .join(' ');
1613
- const stickyStyle = pinOffset ? `${pinOffset.side}:${pinOffset.offset};` : '';
1614
- 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);
1615
1292
  }
1616
1293
 
1617
- private measureAutoColumnWidth(columnName: string): number {
1618
- const root = this.shadowRoot;
1619
- if (root == null) return 176;
1620
- const escaped = CSS.escape ? CSS.escape(columnName) : columnName.replace(/([\\".#:[\](){}+~> ])/g, '\\$1');
1621
- const selectors = [
1622
- `.header-cell[data-column="${escaped}"]`,
1623
- `.filter-cell[data-column="${escaped}"]`,
1624
- `.body-cell[data-column="${escaped}"] .cell-shell`,
1625
- ];
1626
- let maxWidth = 0;
1627
- for (const selector of selectors) {
1628
- for (const el of root.querySelectorAll<HTMLElement>(selector)) {
1629
- maxWidth = Math.max(maxWidth, el.scrollWidth);
1630
- }
1631
- }
1632
- return maxWidth + 12;
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);
1633
1313
  }
1634
1314
 
1635
- private renderDisplayItem(item: DisplayItem, displayIndex: number): string {
1636
- if (!this.snapshot || !this.controller) {
1637
- return '';
1638
- }
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
+ }
1639
1323
 
1640
- if (item.kind === 'group') {
1641
- const group = asGroupItem(item);
1642
- const iconKey = group.collapsed ? 'groupCollapsed' : 'groupExpanded';
1643
- const disclosureLabel = this.controller.groupDisclosureLabel(group);
1644
- 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>`;
1645
- }
1324
+ private scrollFocusedRowIntoView(rowId: string): void {
1325
+ scrollFocusedRowIntoView(this, rowId);
1326
+ }
1646
1327
 
1647
- if (item.kind === 'expandable') {
1648
- const row = (item as DisplayItem & { row: GridRow }).row;
1649
- return `<div class="expandable-row ui-grid-row ui-grid-expandable-row" style="grid-column:1 / -1">${this.renderExpandableTemplate(row)}</div>`;
1650
- }
1328
+ private handleRowSelectionClick(rowId: string, columnName: string, event: Event): void {
1329
+ handleRowSelectionClick(this, rowId, columnName, event);
1330
+ }
1651
1331
 
1652
- if (!isRowItem(item)) {
1653
- return '';
1654
- }
1332
+ private handleRowHeaderCheckboxClick(rowId: string, event: Event): void {
1333
+ handleRowHeaderCheckboxClick(this, rowId, event);
1334
+ }
1655
1335
 
1656
- return this.snapshot.visibleColumns
1657
- .map((column) => this.renderBodyCell(item.row, column, displayIndex))
1658
- .join('');
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);
1659
1348
  }
1660
1349
 
1661
- private renderBodyCell(row: GridRow, column: GridColumnDef, displayIndex: number): string {
1662
- if (!this.snapshot || !this.controller) {
1663
- return '';
1664
- }
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
+ }
1665
1356
 
1666
- const controller = this.controller;
1667
- const rowId = row.id;
1668
- const columnName = column.name;
1669
- const editing = controller.isEditingCell(rowId, columnName);
1670
- const pinOffset = controller.isPinned(column) ? controller.pinnedOffset(column) : null;
1671
- const treeToggle = controller.showTreeToggle(row, column)
1672
- ? `<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>`
1673
- : '';
1674
- const expandToggle = controller.showExpandToggle(row, column)
1675
- ? `<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>`
1676
- : '';
1357
+ private focusCellElement(rowId: string | null, columnName: string | null): void {
1358
+ focusCellElement(this, rowId, columnName);
1359
+ }
1677
1360
 
1678
- const content = editing
1679
- ? `<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)}">`
1680
- : this.renderCellTemplate(row, column, displayIndex);
1681
-
1682
- const classes = [
1683
- 'body-cell',
1684
- 'ui-grid-cell',
1685
- displayIndex % 2 !== 0 ? 'body-cell-odd' : '',
1686
- column.align === 'center' ? 'align-center' : '',
1687
- column.align === 'end' ? 'align-end' : '',
1688
- controller.isPinned(column) ? 'is-pinned' : '',
1689
- controller.isPinnedLeftLast(column) ? 'is-pinned-left-last' : '',
1690
- controller.isPinnedRightFirst(column) ? 'is-pinned-right-first' : '',
1691
- this.focusedCell?.rowId === rowId && this.focusedCell.columnName === columnName
1692
- ? 'cell-focused'
1693
- : '',
1694
- editing ? 'cell-editing' : '',
1695
- ]
1696
- .filter(Boolean)
1697
- .join(' ');
1698
-
1699
- const stickyStyle = pinOffset ? `${pinOffset.side}:${pinOffset.offset};` : '';
1700
- 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);
1701
1371
  }
1702
1372
 
1703
1373
  private renderPagination(snapshot: GridControllerSnapshot): string {
1704
- const pageSizes = snapshot.options.paginationPageSizes ?? [10, 25, 50, 100];
1705
- 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
1706
- .map(
1707
- (size) =>
1708
- `<option value="${size}" ${size === snapshot.pageSize ? 'selected' : ''}>${size}</option>`,
1709
- )
1710
- .join('')}</select></label></div></footer>`;
1374
+ return renderPagination(this, snapshot);
1711
1375
  }
1712
1376
 
1713
1377
  private renderSlotRegistry(columns: readonly GridColumnDef[]): string {
1714
- const cellSlots = columns
1715
- .map((column) => `<slot name="${escapeHtml(this.cellSlotName(column))}"></slot>`)
1716
- .join('');
1717
- return `<div hidden class="slot-registry">${cellSlots}<slot name="expandable-row"></slot></div>`;
1378
+ return renderSlotRegistry(columns);
1718
1379
  }
1719
1380
 
1720
1381
  private renderCellTemplate(row: GridRow, column: GridColumnDef, displayIndex: number): string {
1721
- const templateMarkup = this.getTemplateMarkup(this.cellSlotName(column));
1722
- return this.renderCellTemplateFromMarkup(row, column, displayIndex, templateMarkup);
1382
+ return renderCellTemplate(this, row, column, displayIndex);
1723
1383
  }
1724
1384
 
1725
1385
  private renderCellTemplateFromMarkup(
@@ -1728,62 +1388,20 @@ export class UiGridStandaloneElement extends HTMLElement {
1728
1388
  displayIndex: number,
1729
1389
  templateMarkup: string | null,
1730
1390
  ): string {
1731
- if (!templateMarkup) {
1732
- return `<span class="cell-value">${escapeHtml(this.controller?.displayValue(row, column) ?? '')}</span>`;
1733
- }
1734
-
1735
- const rawRow = row.entity as GridRecord;
1736
- const rawValue = getCellValue(rawRow, column);
1737
- const valueText = rawValue == null ? '' : String(rawValue);
1738
- return this.interpolateTemplate(templateMarkup, {
1739
- $implicit: rawValue,
1740
- value: rawValue,
1741
- valueText,
1742
- valueLower: valueText.toLowerCase(),
1743
- row: rawRow,
1744
- column,
1745
- rowIndex: this.rowIndexFor(row, displayIndex),
1746
- });
1391
+ return renderCellTemplateFromMarkup(this, row, column, displayIndex, templateMarkup);
1747
1392
  }
1748
1393
 
1749
1394
  private renderExpandableTemplate(row: GridRow): string {
1750
- const templateMarkup = this.getTemplateMarkup('expandable-row');
1751
- if (!templateMarkup) {
1752
- return `<p>${escapeHtml(String(row.entity['name'] ?? row.id))}</p>`;
1753
- }
1754
-
1755
- return this.interpolateTemplate(templateMarkup, {
1756
- $implicit: row.entity,
1757
- row: row.entity,
1758
- expanded: row.expanded,
1759
- rowIndex: this.rowIndexFor(row),
1760
- });
1395
+ return renderExpandableTemplate(this, row);
1761
1396
  }
1762
1397
 
1763
1398
  private getTemplateMarkup(slotName: string): string | null {
1764
- const template = this.querySelector<HTMLTemplateElement>(`template[slot="${slotName}"]`);
1765
- return template?.innerHTML ?? null;
1766
- }
1767
-
1768
- private interpolateTemplate(templateMarkup: string, context: Record<string, unknown>): string {
1769
- return templateMarkup.replace(/{{\s*([^}]+?)\s*}}/g, (_match, expression) => {
1770
- const value = this.resolveTemplateValue(context, String(expression).trim());
1771
- return escapeHtml(value);
1772
- });
1399
+ return getTemplateMarkupFromLightDom(this, slotName);
1773
1400
  }
1774
1401
 
1775
- private resolveTemplateValue(context: Record<string, unknown>, expression: string): unknown {
1776
- const segments = expression.split('.').filter(Boolean);
1777
- let current: unknown = context;
1778
-
1779
- for (const segment of segments) {
1780
- if (current == null || typeof current !== 'object') {
1781
- return '';
1782
- }
1783
- current = (current as Record<string, unknown>)[segment];
1784
- }
1785
-
1786
- 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);
1787
1405
  }
1788
1406
 
1789
1407
  private rowIndexFor(row: GridRow, fallback = 0): number {
@@ -1813,7 +1431,9 @@ export class UiGridStandaloneElement extends HTMLElement {
1813
1431
  }
1814
1432
 
1815
1433
  for (let index = 0; index < previousSnapshot.visibleColumns.length; index += 1) {
1816
- if (previousSnapshot.visibleColumns[index]?.name !== nextSnapshot.visibleColumns[index]?.name) {
1434
+ if (
1435
+ previousSnapshot.visibleColumns[index]?.name !== nextSnapshot.visibleColumns[index]?.name
1436
+ ) {
1817
1437
  return { mode: 'full', changedRowIds: null };
1818
1438
  }
1819
1439
  }
@@ -1871,12 +1491,12 @@ export class UiGridStandaloneElement extends HTMLElement {
1871
1491
  };
1872
1492
  }
1873
1493
 
1874
- const stickyChromeHeight = this.measuredHeaderStickyHeight + this.measuredFilterStickyHeight;
1875
- const viewportHeight = snapshot.options.viewportHeight ?? 560;
1876
- const bodyViewportHeight = Math.max(snapshot.rowSize, viewportHeight - stickyChromeHeight);
1877
- 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);
1878
1498
  const overscan = 4;
1879
- const startIndex = Math.max(0, Math.floor(bodyScrollTop / snapshot.rowSize) - overscan);
1499
+ const startIndex = Math.max(0, Math.floor(this.scrollPosition / snapshot.rowSize) - overscan);
1880
1500
  const visibleCount = Math.ceil(bodyViewportHeight / snapshot.rowSize) + overscan * 2;
1881
1501
 
1882
1502
  return {
@@ -1892,29 +1512,38 @@ export class UiGridStandaloneElement extends HTMLElement {
1892
1512
  return `cell-${column.name}`;
1893
1513
  }
1894
1514
 
1895
- private downloadCsv(): void {
1896
- 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) {
1897
1525
  return;
1898
1526
  }
1899
-
1900
- const csv = exportCsvRows(this.snapshot.visibleColumns, this.snapshot.pipeline.visibleRows);
1901
- const filename = sanitizeDownloadFilename(`${this.snapshot.options.id}.csv`);
1902
- downloadGridCsvFile(csv, filename);
1527
+ this.controller.gridApi.exporter.csvExport(rowType, colType);
1903
1528
  }
1904
1529
 
1905
1530
  private renderControlIcon(key: UiGridControlIconKey): string {
1906
- return this.renderIconWithClass('control-icon', key);
1531
+ return this.icons.renderControlIcon(key);
1907
1532
  }
1908
1533
 
1909
1534
  private renderIconWithClass(svgClass: string, key: UiGridControlIconKey): string {
1910
- const icon = this.iconOverrides[key] ?? DEFAULT_ICONS[key];
1911
- const viewBox = escapeHtml(icon.viewBox ?? '0 0 24 24');
1912
- const path = escapeHtml(icon.path);
1913
- return `<svg class="${svgClass}" viewBox="${viewBox}" aria-hidden="true" focusable="false"><path d="${path}"></path></svg>`;
1535
+ return this.icons.renderIconWithClass(svgClass, key);
1914
1536
  }
1915
1537
  }
1916
1538
 
1917
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();
1918
1547
  if (!customElements.get(tagName)) {
1919
1548
  customElements.define(tagName, UiGridStandaloneElement);
1920
1549
  }