@ornery/ui-grid-vanilla 1.0.5 → 1.0.6

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ornery/ui-grid-vanilla",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "description": "Vanilla DOM wrapper for the Rust/WASM-backed @ornery/ui-grid-core custom element",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -16,7 +16,7 @@
16
16
  }
17
17
  },
18
18
  "dependencies": {
19
- "@ornery/ui-grid-core": "1.0.5"
19
+ "@ornery/ui-grid-core": "1.0.6"
20
20
  },
21
21
  "devDependencies": {
22
22
  "happy-dom": "^20.9.0",
@@ -29,8 +29,9 @@
29
29
  },
30
30
  "scripts": {
31
31
  "start": "SASS_PATH=node_modules vite serve",
32
- "build": "npm run build --prefix ../ui-grid-core && SASS_PATH=node_modules tsup",
33
- "watch": "npm run build --prefix ../ui-grid-core && SASS_PATH=node_modules tsup --watch",
34
- "test": "SASS_PATH=node_modules vitest run"
32
+ "build": "SASS_PATH=node_modules tsup",
33
+ "watch": "SASS_PATH=node_modules tsup --watch",
34
+ "test": "SASS_PATH=node_modules vitest run --config vitest.config.ts",
35
+ "test:coverage": "SASS_PATH=node_modules vitest run --config vitest.config.ts --coverage"
35
36
  }
36
37
  }
@@ -0,0 +1,419 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+
3
+ import { clearRustWasmGridEngine } from '@ornery/ui-grid-core';
4
+
5
+ import {
6
+ mountVanillaUiGrid,
7
+ type GridOptions,
8
+ type UiGridApi,
9
+ type VanillaUiGridElement,
10
+ } from './index';
11
+
12
+ /**
13
+ * Regression tests for two bugs reported around the cell selection / row
14
+ * selection / expandable-rows interaction:
15
+ *
16
+ * 1. Click-on-projected-content focus retention — when a consumer projects
17
+ * a `<span slot="cell-…">` into a body cell (Angular ng-template, React
18
+ * portal, or vanilla `<template slot="cell-…">`), clicks on that
19
+ * light-DOM content used to fail to update `cell-focused` because the
20
+ * handler used `closest('.body-cell')` which doesn't cross shadow
21
+ * boundaries. The fix walks `composedPath()` instead.
22
+ *
23
+ * 2. Patch-path row-state cache — a per-row visual fingerprint short-
24
+ * circuits the patch loop when nothing relevant changed (selection /
25
+ * focus / expand / dirty / saving / error / validate / treeLevel /
26
+ * indent). This makes selection toggles in grids with both row
27
+ * selection and expandable rows enabled cheap. The tests below pin the
28
+ * correctness of the cache: selecting one row only touches that row's
29
+ * cells; toggling expand only touches that row; validate state changes
30
+ * flip the invalid class.
31
+ */
32
+
33
+ async function waitFor<T>(resolve: () => T | null | undefined, timeoutMs = 5000): Promise<T> {
34
+ const startedAt = Date.now();
35
+ while (Date.now() - startedAt < timeoutMs) {
36
+ const value = resolve();
37
+ if (value) return value;
38
+ await new Promise((r) => window.setTimeout(r, 10));
39
+ }
40
+ throw new Error('Timed out waiting for expected state');
41
+ }
42
+
43
+ const TAG = 'ui-grid-element-vanilla-cell-state-test';
44
+
45
+ interface Row extends Record<string, unknown> {
46
+ id: string;
47
+ name: string;
48
+ status: string;
49
+ }
50
+
51
+ function rows(): Row[] {
52
+ return [
53
+ { id: 'r1', name: 'Alpha', status: 'Active' },
54
+ { id: 'r2', name: 'Beta', status: 'Pilot' },
55
+ { id: 'r3', name: 'Gamma', status: 'Draft' },
56
+ ];
57
+ }
58
+
59
+ function baseOptions(overrides: Partial<GridOptions> = {}): GridOptions {
60
+ let capturedApi: UiGridApi | undefined;
61
+ const options: GridOptions = {
62
+ id: 'cell-state-grid',
63
+ data: rows(),
64
+ columnDefs: [
65
+ { name: 'name', displayName: 'Name' },
66
+ { name: 'status', displayName: 'Status' },
67
+ ],
68
+ rowIdentity: (entity) => String((entity as { id: string }).id),
69
+ enableRowSelection: true,
70
+ onRegisterApi: (api) => {
71
+ capturedApi = api as UiGridApi;
72
+ },
73
+ ...overrides,
74
+ };
75
+ Object.defineProperty(options, '__api', { enumerable: false, get: () => capturedApi });
76
+ return options;
77
+ }
78
+
79
+ function getApi(options: GridOptions): UiGridApi {
80
+ const api = (options as unknown as { __api: UiGridApi | undefined }).__api;
81
+ if (!api) throw new Error('Grid API was never registered');
82
+ return api;
83
+ }
84
+
85
+ async function mountGrid(options: GridOptions): Promise<{
86
+ grid: VanillaUiGridElement;
87
+ shadow: ShadowRoot;
88
+ }> {
89
+ const target = document.getElementById('app')!;
90
+ const grid = await mountVanillaUiGrid(target, options, undefined, TAG);
91
+ const shadow = await waitFor(() => grid.shadowRoot);
92
+ await waitFor(() => shadow.querySelector('.body-cell[data-row="r1"][data-column="name"]'));
93
+ return { grid, shadow };
94
+ }
95
+
96
+ function cellIn(root: ShadowRoot, rowId: string, columnName: string): HTMLElement {
97
+ const selector = `.body-cell[data-row="${rowId}"][data-column="${columnName}"]`;
98
+ const el = root.querySelector<HTMLElement>(selector);
99
+ if (!el) throw new Error(`Cell ${rowId}/${columnName} not rendered`);
100
+ return el;
101
+ }
102
+
103
+ function dispatchClick(el: HTMLElement): MouseEvent {
104
+ const event = new MouseEvent('click', {
105
+ bubbles: true,
106
+ cancelable: true,
107
+ composed: true,
108
+ button: 0,
109
+ });
110
+ el.dispatchEvent(event);
111
+ return event;
112
+ }
113
+
114
+ describe('cell selection state retention', () => {
115
+ beforeEach(() => {
116
+ document.body.innerHTML = '<div id="app"></div>';
117
+ });
118
+ afterEach(() => {
119
+ clearRustWasmGridEngine();
120
+ document.body.innerHTML = '';
121
+ });
122
+
123
+ it('moves the cell-focused class from the previous cell to the newly clicked cell', async () => {
124
+ const { shadow } = await mountGrid(baseOptions({ enableRowSelection: false }));
125
+
126
+ const a = cellIn(shadow, 'r1', 'name');
127
+ const b = cellIn(shadow, 'r2', 'status');
128
+
129
+ dispatchClick(a);
130
+ expect(a.classList.contains('cell-focused')).toBe(true);
131
+ expect(a.getAttribute('data-focused')).toBe('true');
132
+
133
+ dispatchClick(b);
134
+ expect(b.classList.contains('cell-focused')).toBe(true);
135
+ expect(b.getAttribute('data-focused')).toBe('true');
136
+ // Critical: the previously focused cell must lose the class, otherwise
137
+ // both cells appear "selected" simultaneously.
138
+ expect(a.classList.contains('cell-focused')).toBe(false);
139
+ expect(a.getAttribute('data-focused')).toBe('false');
140
+ });
141
+
142
+ it('clicks on a deeply nested element inside a body cell focus the host body cell (composedPath traversal)', async () => {
143
+ // Sanity-check: the click handler walks composedPath() instead of
144
+ // closest('.body-cell'), so any descendant click — including child
145
+ // elements far below the body-cell — still resolves to the host.
146
+ // This is the same code path that handles framework-projected
147
+ // light-DOM content (Angular ng-template, React render props), where
148
+ // the deepest target sits OUTSIDE the body-cell's DOM ancestor chain.
149
+ const { shadow } = await mountGrid(baseOptions({ enableRowSelection: false }));
150
+
151
+ const cell = cellIn(shadow, 'r1', 'name');
152
+ const cellValue = cell.querySelector<HTMLElement>('.cell-value');
153
+ if (!cellValue) throw new Error('Expected default .cell-value span inside the cell');
154
+
155
+ expect(cell.classList.contains('cell-focused')).toBe(false);
156
+ dispatchClick(cellValue);
157
+ expect(cell.classList.contains('cell-focused')).toBe(true);
158
+ expect(cell.getAttribute('data-focused')).toBe('true');
159
+
160
+ // Clicking inside another cell's deepest descendant clears the prior
161
+ // cell's focus and applies it to the new one.
162
+ const other = cellIn(shadow, 'r2', 'status');
163
+ const otherValue = other.querySelector<HTMLElement>('.cell-value')!;
164
+ dispatchClick(otherValue);
165
+ expect(cell.classList.contains('cell-focused')).toBe(false);
166
+ expect(cell.getAttribute('data-focused')).toBe('false');
167
+ expect(other.classList.contains('cell-focused')).toBe(true);
168
+ });
169
+
170
+ it('renders a per-cell <slot> placeholder when a column is registered as framework-rendered', async () => {
171
+ // Pins the bridge that the Angular wrapper depends on: declaring a
172
+ // column as framework-rendered swaps its body cell from the default
173
+ // string-interpolated content to a `<slot name="cell-…-rowId">`
174
+ // placeholder. The wrapper then attaches a light-DOM node with a
175
+ // matching `slot` attribute to project its rendered template.
176
+ const { grid, shadow } = await mountGrid(baseOptions({ enableRowSelection: false }));
177
+
178
+ grid.setFrameworkRenderedSlots({ cells: ['name'] });
179
+ const slotInCell = await waitFor(() => {
180
+ const cell = shadow.querySelector<HTMLElement>(
181
+ '.body-cell[data-row="r1"][data-column="name"]',
182
+ );
183
+ return cell?.querySelector<HTMLSlotElement>('slot') ?? null;
184
+ });
185
+
186
+ // Slot name must include the row identity so the wrapper can match
187
+ // each projected content node to its cell.
188
+ const slotName = slotInCell.getAttribute('name');
189
+ expect(slotName).toBe('cell-name-r1');
190
+ // Each row gets its own unique slot name.
191
+ const r2Slot = shadow.querySelector<HTMLSlotElement>(
192
+ '.body-cell[data-row="r2"][data-column="name"] slot',
193
+ );
194
+ expect(r2Slot?.getAttribute('name')).toBe('cell-name-r2');
195
+ });
196
+
197
+ it('uses composedPath() to locate the body cell so framework-projected clicks resolve correctly in real browsers', async () => {
198
+ // The Angular / React wrappers project light-DOM content (Angular
199
+ // ng-template, React render props) into per-cell slots. In a real
200
+ // browser the resulting click event has the shadow-DOM body-cell in
201
+ // its composedPath. Test environments (jsdom + happy-dom alike)
202
+ // don't simulate slot-driven event flow, so we directly validate the
203
+ // helper logic by dispatching a click whose composedPath synthetically
204
+ // includes the body cell — which is what a real browser produces.
205
+ const { shadow } = await mountGrid(baseOptions({ enableRowSelection: false }));
206
+
207
+ const targetCell = cellIn(shadow, 'r2', 'status');
208
+
209
+ // Build a click event whose `composedPath` is overridden to include a
210
+ // synthetic light-DOM node FOLLOWED by the real shadow-DOM body cell —
211
+ // mirroring the path real browsers produce for a click on slotted
212
+ // content. Without the composedPath fix the click handler would only
213
+ // see the light-DOM portion (everything before the body-cell) and miss
214
+ // the cell entirely.
215
+ const projected = document.createElement('span');
216
+ projected.className = 'projected-cell';
217
+ const path: EventTarget[] = [
218
+ projected,
219
+ targetCell,
220
+ shadow,
221
+ ];
222
+ const event = new MouseEvent('click', {
223
+ bubbles: true,
224
+ cancelable: true,
225
+ composed: true,
226
+ button: 0,
227
+ });
228
+ Object.defineProperty(event, 'composedPath', {
229
+ value: () => path,
230
+ configurable: true,
231
+ });
232
+ Object.defineProperty(event, 'target', { value: projected, configurable: true });
233
+
234
+ expect(targetCell.classList.contains('cell-focused')).toBe(false);
235
+ shadow.dispatchEvent(event);
236
+ expect(targetCell.classList.contains('cell-focused')).toBe(true);
237
+ expect(targetCell.getAttribute('data-focused')).toBe('true');
238
+
239
+ // A subsequent normal click on a different cell clears the focus.
240
+ const other = cellIn(shadow, 'r3', 'name');
241
+ dispatchClick(other);
242
+ expect(targetCell.classList.contains('cell-focused')).toBe(false);
243
+ expect(other.classList.contains('cell-focused')).toBe(true);
244
+ });
245
+ });
246
+
247
+ describe('row selection + expand patch correctness', () => {
248
+ beforeEach(() => {
249
+ document.body.innerHTML = '<div id="app"></div>';
250
+ });
251
+ afterEach(() => {
252
+ clearRustWasmGridEngine();
253
+ document.body.innerHTML = '';
254
+ });
255
+
256
+ function selectionExpandOptions(): GridOptions {
257
+ // Provide a no-op expandable template object so canGridExpandRows
258
+ // returns true. The template body itself isn't exercised here.
259
+ const template = { createEmbeddedView: () => undefined };
260
+ return baseOptions({
261
+ enableRowSelection: true,
262
+ enableExpandable: true,
263
+ expandableRowTemplate:
264
+ template as unknown as GridOptions['expandableRowTemplate'],
265
+ });
266
+ }
267
+
268
+ it('toggling selection on one row only flips the ui-grid-row-selected class on cells of that row', async () => {
269
+ const options = selectionExpandOptions();
270
+ const { shadow } = await mountGrid(options);
271
+
272
+ const before = {
273
+ r1Name: cellIn(shadow, 'r1', 'name').classList.contains('ui-grid-row-selected'),
274
+ r2Name: cellIn(shadow, 'r2', 'name').classList.contains('ui-grid-row-selected'),
275
+ r3Name: cellIn(shadow, 'r3', 'name').classList.contains('ui-grid-row-selected'),
276
+ };
277
+ expect(before).toEqual({ r1Name: false, r2Name: false, r3Name: false });
278
+
279
+ getApi(options).selection.selectRow(options.data[1] as Record<string, unknown>);
280
+ await waitFor(() =>
281
+ cellIn(shadow, 'r2', 'name').classList.contains('ui-grid-row-selected') ? {} : null,
282
+ );
283
+
284
+ // Only r2's cells flipped — r1 / r3 remain unselected. Both cell
285
+ // columns of r2 must be flagged so the row stripe shows across all
286
+ // columns.
287
+ expect(cellIn(shadow, 'r1', 'name').classList.contains('ui-grid-row-selected')).toBe(false);
288
+ expect(cellIn(shadow, 'r1', 'status').classList.contains('ui-grid-row-selected')).toBe(false);
289
+ expect(cellIn(shadow, 'r2', 'name').classList.contains('ui-grid-row-selected')).toBe(true);
290
+ expect(cellIn(shadow, 'r2', 'status').classList.contains('ui-grid-row-selected')).toBe(true);
291
+ expect(cellIn(shadow, 'r3', 'name').classList.contains('ui-grid-row-selected')).toBe(false);
292
+ expect(cellIn(shadow, 'r3', 'status').classList.contains('ui-grid-row-selected')).toBe(false);
293
+
294
+ // Unselect — class must drop.
295
+ getApi(options).selection.unSelectRow(options.data[1] as Record<string, unknown>);
296
+ await waitFor(() =>
297
+ !cellIn(shadow, 'r2', 'name').classList.contains('ui-grid-row-selected') ? {} : null,
298
+ );
299
+ expect(cellIn(shadow, 'r2', 'name').classList.contains('ui-grid-row-selected')).toBe(false);
300
+ expect(cellIn(shadow, 'r2', 'status').classList.contains('ui-grid-row-selected')).toBe(false);
301
+ });
302
+
303
+ it('toggling expansion on a row inserts/removes the expandable detail and updates that row only', async () => {
304
+ const options = selectionExpandOptions();
305
+ const { shadow } = await mountGrid(options);
306
+
307
+ expect(shadow.querySelector('.expandable-row')).toBeNull();
308
+
309
+ getApi(options).expandable.toggleRowExpansion(options.data[1] as Record<string, unknown>);
310
+ await waitFor(() => shadow.querySelector('.expandable-row'));
311
+
312
+ // Exactly one expandable-row appears after toggling a single row.
313
+ expect(shadow.querySelectorAll('.expandable-row').length).toBe(1);
314
+
315
+ // Other rows' selection / focus state must not have flipped.
316
+ expect(cellIn(shadow, 'r1', 'name').classList.contains('ui-grid-row-selected')).toBe(false);
317
+ expect(cellIn(shadow, 'r3', 'name').classList.contains('ui-grid-row-selected')).toBe(false);
318
+
319
+ // Collapse — the detail row goes away.
320
+ getApi(options).expandable.toggleRowExpansion(options.data[1] as Record<string, unknown>);
321
+ await waitFor(() => (shadow.querySelector('.expandable-row') === null ? {} : null));
322
+ expect(shadow.querySelector('.expandable-row')).toBeNull();
323
+ });
324
+
325
+ it('selection toggle preserves cell-focused on a different row', async () => {
326
+ // The patch path's per-row fingerprint must NOT clobber the cell-focused
327
+ // class on a row that wasn't touched by the selection toggle. This was
328
+ // the actual bug — without proper "focused cell touches this row"
329
+ // tracking, the focused cell could either lose its class on the next
330
+ // refresh or keep it on a row it has moved off of.
331
+ const options = selectionExpandOptions();
332
+ const { shadow } = await mountGrid(options);
333
+
334
+ const r1Cell = cellIn(shadow, 'r1', 'name');
335
+ dispatchClick(r1Cell);
336
+ expect(r1Cell.classList.contains('cell-focused')).toBe(true);
337
+
338
+ // Selecting a different row triggers a full refresh; r1's cell-focused
339
+ // class must survive because focusedCell didn't move.
340
+ getApi(options).selection.selectRow(options.data[1] as Record<string, unknown>);
341
+ await waitFor(() =>
342
+ cellIn(shadow, 'r2', 'name').classList.contains('ui-grid-row-selected') ? {} : null,
343
+ );
344
+ expect(r1Cell.classList.contains('cell-focused')).toBe(true);
345
+ expect(r1Cell.getAttribute('data-focused')).toBe('true');
346
+ });
347
+
348
+ it('expand toggle attaches to the first data column (not the selection checkbox column) and renders to the left of cell content', async () => {
349
+ // Regression test for the visual bug where the expand toggle
350
+ // rendered inside the selectionRowHeaderCol cell (column index 0).
351
+ // isGridPrimaryColumn() now skips the synthetic selection column so
352
+ // the toggle attaches to the actual first data column. The toggle
353
+ // sits BEFORE `.cell-content` so the disclosure chevron hugs the
354
+ // leading edge of the cell, matching the tree-toggle convention.
355
+ const options = selectionExpandOptions();
356
+ const { shadow } = await mountGrid(options);
357
+
358
+ const selectionCellR1 = shadow.querySelector<HTMLElement>(
359
+ '.body-cell[data-row="r1"][data-column="selectionRowHeaderCol"]',
360
+ );
361
+ expect(selectionCellR1).not.toBeNull();
362
+ expect(selectionCellR1!.querySelector('.row-toggle-expand')).toBeNull();
363
+
364
+ const nameCellR1 = cellIn(shadow, 'r1', 'name');
365
+ const expandToggle = nameCellR1.querySelector<HTMLElement>('.row-toggle-expand');
366
+ expect(expandToggle).not.toBeNull();
367
+
368
+ // Inside the cell-shell the toggle must come BEFORE .cell-content
369
+ // so it sits on the leading edge of the cell. DOCUMENT_POSITION_FOLLOWING
370
+ // (0x04) means cellContent is AFTER expandToggle.
371
+ const cellContent = nameCellR1.querySelector<HTMLElement>('.cell-content')!;
372
+ const expectedFollowing = Node.DOCUMENT_POSITION_FOLLOWING;
373
+ expect(expandToggle!.compareDocumentPosition(cellContent) & expectedFollowing).toBe(
374
+ expectedFollowing,
375
+ );
376
+
377
+ const selectionCheckbox = selectionCellR1!.querySelector<HTMLElement>(
378
+ '.ui-grid-selection-row-header-buttons',
379
+ );
380
+ expect(selectionCheckbox).not.toBeNull();
381
+ });
382
+
383
+ it('validate state flip flows through the patch fast-path so the invalid class appears', async () => {
384
+ // Validate writes `$$invalid<col>` directly on the row entity. The
385
+ // entity reference doesn't change, so the row fingerprint cache must
386
+ // sample those keys to detect the validity flip.
387
+ const options = selectionExpandOptions();
388
+ options.enableCellEdit = true;
389
+ options.columnDefs = options.columnDefs.map((c) =>
390
+ c.name === 'name'
391
+ ? { ...c, enableCellEdit: true, validators: { required: true } }
392
+ : c,
393
+ );
394
+
395
+ const { shadow } = await mountGrid(options);
396
+ const api = getApi(options);
397
+
398
+ // Drive r1 invalid. Refresh emits a new snapshot; the patch path's
399
+ // fingerprint cache must detect the new invalid bit and flip the class.
400
+ const col = options.columnDefs.find((c) => c.name === 'name')!;
401
+ const target = options.data[0] as Record<string, unknown>;
402
+ await api.validate.runValidators(target, col, '', 'Alpha');
403
+ api.core.refresh();
404
+
405
+ await waitFor(() =>
406
+ shadow.querySelector('.body-cell[data-row="r1"][data-column="name"].ui-grid-cell-invalid'),
407
+ );
408
+ expect(
409
+ cellIn(shadow, 'r1', 'name').classList.contains('ui-grid-cell-invalid'),
410
+ ).toBe(true);
411
+ // Other rows / columns must NOT be flagged.
412
+ expect(
413
+ cellIn(shadow, 'r1', 'status').classList.contains('ui-grid-cell-invalid'),
414
+ ).toBe(false);
415
+ expect(
416
+ cellIn(shadow, 'r2', 'name').classList.contains('ui-grid-cell-invalid'),
417
+ ).toBe(false);
418
+ });
419
+ });
package/src/events.ts CHANGED
@@ -28,6 +28,24 @@ import type { UiGridStandaloneElement } from './ui-grid-standalone.element';
28
28
 
29
29
  type BoundShadowRoot = ShadowRoot & { __uiGridBound?: boolean };
30
30
 
31
+ /**
32
+ * Find the `.body-cell[data-row][data-column]` ancestor of an event by walking
33
+ * the composed event path. Plain `closest()` from the click target stops at
34
+ * shadow boundaries — for clicks on framework-projected light-DOM cell
35
+ * content (Angular ng-template, React render props), the body cell sits in
36
+ * the shadow tree on the other side of the slot. composedPath includes both
37
+ * sides, so we just scan it.
38
+ */
39
+ function bodyCellFromEvent(event: Event): HTMLElement | null {
40
+ const path = event.composedPath();
41
+ for (const node of path) {
42
+ if (node instanceof HTMLElement && node.classList.contains('body-cell')) {
43
+ if (node.dataset['row'] && node.dataset['column']) return node;
44
+ }
45
+ }
46
+ return null;
47
+ }
48
+
31
49
  /**
32
50
  * Watch the element's light DOM for `<template slot="…">` changes so
33
51
  * consumer template edits trigger a re-render. Ports the old grid's
@@ -37,7 +55,23 @@ type BoundShadowRoot = ShadowRoot & { __uiGridBound?: boolean };
37
55
  export function observeTemplateSlots(el: UiGridStandaloneElement): void {
38
56
  if (el.templateObserver) return;
39
57
 
40
- el.templateObserver = new MutationObserver(() => {
58
+ el.templateObserver = new MutationObserver((mutations) => {
59
+ // We only care about consumer-provided `<template slot="…">` nodes —
60
+ // their innerHTML feeds the string-interpolation render path. Skip
61
+ // mutations that only touched non-template slotted nodes (e.g. the
62
+ // `<span slot="cell-…">` wrappers Angular / React wrappers append to
63
+ // project framework-rendered content). Otherwise those framework
64
+ // wrappers would re-trigger ensureController on every slot delta and
65
+ // loop forever.
66
+ const involvesTemplate = mutationsTouchTemplateSlot(mutations);
67
+ if (!involvesTemplate) return;
68
+
69
+ // Consumer light-DOM templates (`<template slot="cell-…">`) feed the
70
+ // string-interpolation render path. When they're added/removed/edited
71
+ // the per-row visual fingerprint cache becomes stale (the fingerprint
72
+ // doesn't track template content), so blow it away to force the next
73
+ // patch pass to re-render every cell shell.
74
+ el.lastRowStateFingerprints.clear();
41
75
  if (el.activeOptions) {
42
76
  el.ensureController(el.buildEffectiveOptions(el.activeOptions));
43
77
  } else {
@@ -53,6 +87,23 @@ export function observeTemplateSlots(el: UiGridStandaloneElement): void {
53
87
  });
54
88
  }
55
89
 
90
+ function mutationsTouchTemplateSlot(mutations: MutationRecord[]): boolean {
91
+ for (const m of mutations) {
92
+ if (m.type === 'attributes' && m.target instanceof HTMLTemplateElement) {
93
+ return true;
94
+ }
95
+ if (m.type === 'childList') {
96
+ for (const node of m.addedNodes) {
97
+ if (node instanceof HTMLTemplateElement && node.hasAttribute('slot')) return true;
98
+ }
99
+ for (const node of m.removedNodes) {
100
+ if (node instanceof HTMLTemplateElement && node.hasAttribute('slot')) return true;
101
+ }
102
+ }
103
+ }
104
+ return false;
105
+ }
106
+
56
107
  /**
57
108
  * Mirror horizontal scroll from the body viewport onto the header +
58
109
  * filter strips. The strips are real scroll containers (necessary for
@@ -115,7 +166,7 @@ export function bindEvents(el: UiGridStandaloneElement): void {
115
166
  // don't reliably focus a div with tabindex="0" on click, so we force it
116
167
  // here. This is required for arrow-key nav to work — otherwise focus
117
168
  // stays on the grid-table scroll container and arrows just scroll.
118
- const clickedCell = realTarget.closest?.<HTMLElement>('.body-cell[data-row][data-column]') ?? null;
169
+ const clickedCell = bodyCellFromEvent(event);
119
170
  if (clickedCell && !realTarget.closest('[data-role="editor"]')) {
120
171
  const rowId = clickedCell.dataset['row'];
121
172
  const columnName = clickedCell.dataset['column'];
@@ -140,11 +191,8 @@ export function bindEvents(el: UiGridStandaloneElement): void {
140
191
  // Row-header checkbox column click — mirrors the old
141
192
  // selectionRowHeaderButtons directive: even when enableFullRowSelection
142
193
  // is off, clicking the header checkbox selects the row.
143
- const checkboxNode = realTarget.closest?.<HTMLElement>(
144
- '.body-cell[data-row][data-column="selectionRowHeaderCol"]',
145
- );
146
- if (checkboxNode) {
147
- const rowId = checkboxNode.dataset['row'];
194
+ if (clickedCell?.dataset['column'] === 'selectionRowHeaderCol') {
195
+ const rowId = clickedCell.dataset['row'];
148
196
  if (rowId) {
149
197
  event.stopPropagation();
150
198
  handleRowHeaderCheckboxClick(el, rowId, event);
@@ -255,9 +303,10 @@ export function bindEvents(el: UiGridStandaloneElement): void {
255
303
  }
256
304
 
257
305
  if (action === 'benchmark') {
258
- const result = el.controller.gridApi.core.benchmark();
259
- el.benchmarkAverage = result.averageMs.toFixed(2);
260
- el.render();
306
+ void el.controller.gridApi.core.benchmark().then((result) => {
307
+ el.benchmarkAverage = result.averageMs.toFixed(2);
308
+ el.render();
309
+ });
261
310
  return;
262
311
  }
263
312
 
@@ -270,13 +319,16 @@ export function bindEvents(el: UiGridStandaloneElement): void {
270
319
  // focusin / focusout: keep `focusedCell` in sync with DOM focus
271
320
  // ─────────────────────────────────────────────────────────────────
272
321
  root.addEventListener('focusin', (event) => {
273
- const target = event.target as HTMLElement | null;
274
- if (!target) return;
275
- const cell = target.closest<HTMLElement>('.body-cell[data-row][data-column]');
322
+ const cell = bodyCellFromEvent(event);
276
323
  const rowId = cell?.dataset['row'];
277
324
  const columnName = cell?.dataset['column'];
278
325
  if (rowId && columnName) {
279
- el.focusedCell = { rowId, columnName };
326
+ const previous = el.focusedCell;
327
+ const next = { rowId, columnName };
328
+ if (!previous || previous.rowId !== rowId || previous.columnName !== columnName) {
329
+ el.focusedCell = next;
330
+ applyFocusedCellClass(el, previous, next);
331
+ }
280
332
  }
281
333
  });
282
334
 
@@ -325,7 +377,7 @@ export function bindEvents(el: UiGridStandaloneElement): void {
325
377
  if (realTarget.closest('.filter-cell')) return;
326
378
  const resolved = el.controller.getResolvedSelectionOptions();
327
379
  if (!resolved.enableRowSelection || !resolved.multiSelect) return;
328
- const startCell = realTarget.closest<HTMLElement>('.body-cell[data-row][data-column]');
380
+ const startCell = bodyCellFromEvent(event);
329
381
  if (!startCell) return;
330
382
  const startRowId = startCell.dataset['row'];
331
383
  if (!startRowId) return;
@@ -356,9 +408,7 @@ export function bindEvents(el: UiGridStandaloneElement): void {
356
408
  };
357
409
 
358
410
  const handleMove = (moveEvent: MouseEvent): void => {
359
- const path = moveEvent.composedPath();
360
- const firstEl = path[0] as HTMLElement | undefined;
361
- const cell = firstEl?.closest?.<HTMLElement>('.body-cell[data-row][data-column]');
411
+ const cell = bodyCellFromEvent(moveEvent);
362
412
  if (!cell) return;
363
413
  const rowId = cell.dataset['row'];
364
414
  if (!rowId) return;
@@ -455,7 +505,7 @@ export function bindEvents(el: UiGridStandaloneElement): void {
455
505
  }
456
506
  }
457
507
 
458
- const cell = target.closest<HTMLElement>('.body-cell');
508
+ const cell = bodyCellFromEvent(event);
459
509
  if (!cell) return;
460
510
 
461
511
  const rowId = cell.dataset['row'];
@@ -473,11 +523,13 @@ export function bindEvents(el: UiGridStandaloneElement): void {
473
523
  const target = event.target as HTMLElement | null;
474
524
  if (!target || !el.controller) return;
475
525
 
526
+ const onBodyCell = bodyCellFromEvent(event);
527
+
476
528
  // cellNav.keyDownOverrides: declared overrides skip cellnav's default
477
529
  // handling and raise viewPortKeyDown so consumers can disable built-in
478
530
  // key handling per-key.
479
531
  const overrides = el.controller.getOptions().keyDownOverrides ?? [];
480
- if (overrides.length && target.closest('.body-cell')) {
532
+ if (overrides.length && onBodyCell) {
481
533
  for (const override of overrides) {
482
534
  if (matchesKeyOverride(override, keyboardEvent)) {
483
535
  el.controller.raiseCellNavKeyEvent('keydown', keyboardEvent);
@@ -490,7 +542,7 @@ export function bindEvents(el: UiGridStandaloneElement): void {
490
542
  if (
491
543
  (keyboardEvent.ctrlKey || keyboardEvent.metaKey) &&
492
544
  (keyboardEvent.key === 'a' || keyboardEvent.key === 'A') &&
493
- target.closest('.body-cell')
545
+ onBodyCell
494
546
  ) {
495
547
  const resolved = el.controller.getResolvedSelectionOptions();
496
548
  if (resolved.enableRowSelection && resolved.multiSelect) {
@@ -502,7 +554,7 @@ export function bindEvents(el: UiGridStandaloneElement): void {
502
554
 
503
555
  // Space toggles the row when on the checkbox column or in full-row-selection mode.
504
556
  if (keyboardEvent.key === ' ' || keyboardEvent.key === 'Spacebar') {
505
- const cell = target.closest<HTMLElement>('.body-cell[data-row][data-column]');
557
+ const cell = onBodyCell;
506
558
  const rowId = cell?.dataset['row'];
507
559
  const columnName = cell?.dataset['column'];
508
560
  if (cell && rowId && columnName) {
@@ -567,10 +619,9 @@ export function bindEvents(el: UiGridStandaloneElement): void {
567
619
  // cell).
568
620
  let rowId: string | undefined;
569
621
  let columnName: string | undefined;
570
- const cell = target.closest<HTMLElement>('.body-cell');
571
- if (cell) {
572
- rowId = cell.dataset['row'];
573
- columnName = cell.dataset['column'];
622
+ if (onBodyCell) {
623
+ rowId = onBodyCell.dataset['row'];
624
+ columnName = onBodyCell.dataset['column'];
574
625
  } else if (el.focusedCell) {
575
626
  rowId = el.focusedCell.rowId;
576
627
  columnName = el.focusedCell.columnName;
@@ -261,6 +261,24 @@ export class FrameworkSlotBridge {
261
261
  });
262
262
  }
263
263
 
264
+ /**
265
+ * Carry forward every previously-known cell slot for `rowId` into the
266
+ * current pending set. Used by the structural patch path when a row's
267
+ * fingerprint matches the previous render's: `stageCell` would normally
268
+ * re-record the slot during cell rendering, but the fingerprint
269
+ * short-circuit skips the render pass for unchanged rows. Without this,
270
+ * `flush()` would diff pending (missing the row's slots) against last
271
+ * (containing them) and emit a spurious `removed` for every cell of the
272
+ * unchanged row, destroying the framework wrappers' projected content.
273
+ */
274
+ carryRowCells(rowId: string): void {
275
+ for (const [slotName, slot] of this.lastCellSlots) {
276
+ if (slot.rowId === rowId) {
277
+ this.pendingCellSlots.set(slotName, slot);
278
+ }
279
+ }
280
+ }
281
+
264
282
  stageExpandableRow(row: GridRow, rowIndex: number): void {
265
283
  const slotName = expandableRowSlotName(row);
266
284
  this.pendingExpandableRowSlots.set(slotName, {