@ornery/ui-grid-vanilla 1.0.5 → 1.0.7

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/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, {