@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
package/src/events.ts ADDED
@@ -0,0 +1,815 @@
1
+ /**
2
+ * DOM event wiring for the grid element.
3
+ *
4
+ * One entry point — `bindEvents(el)` — attaches every shadow-root event
5
+ * listener the grid needs: click, focusin/out, input, mousedown (selection
6
+ * drag + column resize), dblclick, keydown, blur, dragstart/over/leave/
7
+ * drop/end, wheel, scroll. Each handler is a standalone function declared
8
+ * inside `bindEvents` so it closes over the element and keeps the coupling
9
+ * local.
10
+ *
11
+ * Supporting helpers (`observeTemplateSlots`, `syncHeaderHorizontalScroll`,
12
+ * `maybeTriggerInfiniteScroll`) also live here because they are only used
13
+ * by the event path.
14
+ */
15
+
16
+ import { canGridMoveColumns } from '@ornery/ui-grid-core';
17
+ import {
18
+ applyFocusedCellClass,
19
+ commitAndMove,
20
+ focusCellElement,
21
+ handleRowHeaderCheckboxClick,
22
+ handleRowSelectionClick,
23
+ matchesKeyOverride,
24
+ measureAutoColumnWidth,
25
+ moveGridFocus,
26
+ } from './focus';
27
+ import type { UiGridStandaloneElement } from './ui-grid-standalone.element';
28
+
29
+ type BoundShadowRoot = ShadowRoot & { __uiGridBound?: boolean };
30
+
31
+ /**
32
+ * Watch the element's light DOM for `<template slot="…">` changes so
33
+ * consumer template edits trigger a re-render. Ports the old grid's
34
+ * observer that let template updates flow through without an explicit
35
+ * options re-assign.
36
+ */
37
+ export function observeTemplateSlots(el: UiGridStandaloneElement): void {
38
+ if (el.templateObserver) return;
39
+
40
+ el.templateObserver = new MutationObserver(() => {
41
+ if (el.activeOptions) {
42
+ el.ensureController(el.buildEffectiveOptions(el.activeOptions));
43
+ } else {
44
+ el.render();
45
+ }
46
+ });
47
+
48
+ el.templateObserver.observe(el, {
49
+ childList: true,
50
+ subtree: true,
51
+ attributes: true,
52
+ attributeFilter: ['slot'],
53
+ });
54
+ }
55
+
56
+ /**
57
+ * Mirror horizontal scroll from the body viewport onto the header +
58
+ * filter strips. The strips are real scroll containers (necessary for
59
+ * `position: sticky` on pinned cells), so they need scrollLeft set
60
+ * imperatively to stay aligned with the body.
61
+ */
62
+ export function syncHeaderHorizontalScroll(
63
+ el: UiGridStandaloneElement,
64
+ scrollLeft: number,
65
+ ): void {
66
+ const root = el.shadowRoot;
67
+ if (!root) return;
68
+ const headerStrip = root.querySelector<HTMLElement>('.grid-header-strip');
69
+ const filterStrip = root.querySelector<HTMLElement>('.grid-filter-strip');
70
+ if (headerStrip && headerStrip.scrollLeft !== scrollLeft) {
71
+ headerStrip.scrollLeft = scrollLeft;
72
+ }
73
+ if (filterStrip && filterStrip.scrollLeft !== scrollLeft) {
74
+ filterStrip.scrollLeft = scrollLeft;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Ask the controller to evaluate whether the current scroll position
80
+ * should request more data at the top or bottom — ports the old grid's
81
+ * `handleScroll → loadData` check. The controller raises
82
+ * `needLoadMoreData` / `needLoadMoreDataTop` when thresholds are crossed.
83
+ */
84
+ export function maybeTriggerInfiniteScroll(el: UiGridStandaloneElement): void {
85
+ const snapshot = el.snapshot;
86
+ if (!snapshot || !el.controller) return;
87
+ if (snapshot.options.enableInfiniteScroll === false) return;
88
+ const bodyViewport = el.shadowRoot?.querySelector<HTMLElement>('.grid-body-viewport');
89
+ if (!bodyViewport) return;
90
+ const rowSize = snapshot.rowSize || 1;
91
+ const startIndex = Math.floor(el.scrollPosition / rowSize);
92
+ const viewportRows = Math.max(1, Math.floor(bodyViewport.clientHeight / rowSize));
93
+ const visibleRows = snapshot.pipeline.visibleRows.length;
94
+ el.controller.evaluateInfiniteScroll(startIndex, visibleRows, viewportRows);
95
+ }
96
+
97
+ /**
98
+ * Attach every shadow-root listener the grid needs. Idempotent: a
99
+ * `__uiGridBound` marker on the shadow root prevents re-binding on
100
+ * reconnect.
101
+ */
102
+ export function bindEvents(el: UiGridStandaloneElement): void {
103
+ const root = el.ensureShadowRoot() as BoundShadowRoot;
104
+ if (root.__uiGridBound) return;
105
+
106
+ // ─────────────────────────────────────────────────────────────────
107
+ // click: body-cell focus seed, row-header checkbox, [data-action] dispatch
108
+ // ─────────────────────────────────────────────────────────────────
109
+ root.addEventListener('click', (event) => {
110
+ // Use composedPath to reach into sub-component shadow DOMs (pagination buttons, etc.).
111
+ const realTarget = (event.composedPath()[0] ?? event.target) as HTMLElement | null;
112
+ if (!realTarget || !el.controller || !el.snapshot) return;
113
+
114
+ // Clicking inside a body cell seeds keyboard navigation. Some browsers
115
+ // don't reliably focus a div with tabindex="0" on click, so we force it
116
+ // here. This is required for arrow-key nav to work — otherwise focus
117
+ // stays on the grid-table scroll container and arrows just scroll.
118
+ const clickedCell = realTarget.closest?.<HTMLElement>('.body-cell[data-row][data-column]') ?? null;
119
+ if (clickedCell && !realTarget.closest('[data-role="editor"]')) {
120
+ const rowId = clickedCell.dataset['row'];
121
+ const columnName = clickedCell.dataset['column'];
122
+ if (rowId && columnName) {
123
+ const previous = el.focusedCell;
124
+ const next = { rowId, columnName };
125
+ el.focusedCell = next;
126
+ applyFocusedCellClass(el, previous, next);
127
+ el.controller.setCellNavFocus(rowId, columnName);
128
+ const activeInShadow = (el.shadowRoot?.activeElement ?? null) as HTMLElement | null;
129
+ if (activeInShadow !== clickedCell && !activeInShadow?.closest?.('.body-cell')) {
130
+ try {
131
+ clickedCell.focus({ preventScroll: true });
132
+ } catch {
133
+ clickedCell.focus();
134
+ }
135
+ }
136
+ handleRowSelectionClick(el, rowId, columnName, event);
137
+ }
138
+ }
139
+
140
+ // Row-header checkbox column click — mirrors the old
141
+ // selectionRowHeaderButtons directive: even when enableFullRowSelection
142
+ // 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'];
148
+ if (rowId) {
149
+ event.stopPropagation();
150
+ handleRowHeaderCheckboxClick(el, rowId, event);
151
+ }
152
+ }
153
+
154
+ // Walk the composed path to find [data-action] across shadow boundaries.
155
+ let actionNode: HTMLElement | null = null;
156
+ for (const node of event.composedPath()) {
157
+ if (node instanceof HTMLElement && node.dataset['action']) {
158
+ actionNode = node;
159
+ break;
160
+ }
161
+ }
162
+ if (!actionNode) return;
163
+
164
+ const action = actionNode.dataset['action'];
165
+ if (!action) return;
166
+
167
+ if (el.openPinMenuColumn && !realTarget.closest('.pin-control')) {
168
+ el.openPinMenuColumn = null;
169
+ el.render();
170
+ return;
171
+ }
172
+
173
+ if (action === 'sort') {
174
+ const columnName = actionNode.dataset['column'];
175
+ if (columnName) el.controller.toggleSort(columnName);
176
+ return;
177
+ }
178
+
179
+ if (action === 'select-all') {
180
+ const resolvedSel = el.controller.getResolvedSelectionOptions();
181
+ if (el.snapshot?.selectAll) {
182
+ el.controller.clearSelectedRows(event);
183
+ if (resolvedSel.noUnselect) {
184
+ el.controller.selectRowByVisibleIndex(0, event);
185
+ }
186
+ } else if (resolvedSel.multiSelect) {
187
+ el.controller.selectAllVisibleRows(event);
188
+ }
189
+ return;
190
+ }
191
+
192
+ if (action === 'group') {
193
+ const columnName = actionNode.dataset['column'];
194
+ if (columnName) el.controller.toggleGrouping(columnName);
195
+ return;
196
+ }
197
+
198
+ if (action === 'toggle-group') {
199
+ const groupId = actionNode.dataset['group'];
200
+ if (groupId) {
201
+ const collapsed = actionNode.dataset['collapsed'] !== 'true';
202
+ el.controller.setCollapsedGroup(groupId, collapsed);
203
+ }
204
+ return;
205
+ }
206
+
207
+ if (action === 'pin-trigger') {
208
+ const columnName = actionNode.dataset['column'];
209
+ const column = columnName
210
+ ? el.snapshot.visibleColumns.find((candidate) => candidate.name === columnName)
211
+ : undefined;
212
+
213
+ if (columnName && column) {
214
+ if (el.controller.isPinned(column)) {
215
+ el.openPinMenuColumn = null;
216
+ el.controller.pinColumn(columnName, 'none');
217
+ return;
218
+ }
219
+
220
+ el.openPinMenuColumn = el.openPinMenuColumn === columnName ? null : columnName;
221
+ el.render();
222
+ }
223
+ return;
224
+ }
225
+
226
+ if (action === 'pin-left' || action === 'pin-right') {
227
+ const columnName = actionNode.dataset['column'];
228
+ if (columnName) {
229
+ el.openPinMenuColumn = null;
230
+ el.controller.pinColumn(columnName, action === 'pin-left' ? 'left' : 'right');
231
+ }
232
+ return;
233
+ }
234
+
235
+ if (action === 'toggle-tree') {
236
+ const rowId = actionNode.dataset['row'];
237
+ if (rowId) el.controller.toggleTreeRow(rowId);
238
+ return;
239
+ }
240
+
241
+ if (action === 'toggle-expand') {
242
+ const rowId = actionNode.dataset['row'];
243
+ if (rowId) el.controller.toggleRowExpansion(rowId);
244
+ return;
245
+ }
246
+
247
+ if (action === 'page-prev') {
248
+ el.controller.seekPage(el.snapshot.currentPage - 1);
249
+ return;
250
+ }
251
+
252
+ if (action === 'page-next') {
253
+ el.controller.seekPage(el.snapshot.currentPage + 1);
254
+ return;
255
+ }
256
+
257
+ if (action === 'benchmark') {
258
+ const result = el.controller.gridApi.core.benchmark();
259
+ el.benchmarkAverage = result.averageMs.toFixed(2);
260
+ el.render();
261
+ return;
262
+ }
263
+
264
+ if (action === 'export-csv') {
265
+ el.downloadCsv();
266
+ }
267
+ });
268
+
269
+ // ─────────────────────────────────────────────────────────────────
270
+ // focusin / focusout: keep `focusedCell` in sync with DOM focus
271
+ // ─────────────────────────────────────────────────────────────────
272
+ 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]');
276
+ const rowId = cell?.dataset['row'];
277
+ const columnName = cell?.dataset['column'];
278
+ if (rowId && columnName) {
279
+ el.focusedCell = { rowId, columnName };
280
+ }
281
+ });
282
+
283
+ root.addEventListener('focusout', (event) => {
284
+ const relatedTarget = (event as FocusEvent).relatedTarget as HTMLElement | null;
285
+ if (!relatedTarget?.closest('.body-cell[data-row][data-column]')) {
286
+ el.focusedCell = null;
287
+ }
288
+ });
289
+
290
+ // ─────────────────────────────────────────────────────────────────
291
+ // input: filter inputs + cell editors
292
+ // ─────────────────────────────────────────────────────────────────
293
+ root.addEventListener('input', (event) => {
294
+ const target = (event.composedPath()[0] ?? event.target) as HTMLElement | null;
295
+ if (!target || !el.controller) return;
296
+
297
+ if (target instanceof HTMLInputElement && target.dataset['role'] === 'filter') {
298
+ const columnName = target.dataset['column'];
299
+ if (columnName) el.controller.setFilter(columnName, target.value);
300
+ }
301
+
302
+ if (target instanceof HTMLInputElement && target.dataset['role'] === 'editor') {
303
+ el.controller.updateEditingValue(target.value);
304
+ }
305
+ });
306
+
307
+ // Listen for the composed custom event from the pagination component's shadow DOM.
308
+ root.addEventListener('grid-page-size', ((event: CustomEvent<{ pageSize: number }>) => {
309
+ if (el.controller) el.controller.setPageSize(event.detail.pageSize);
310
+ }) as EventListener);
311
+
312
+ // ─────────────────────────────────────────────────────────────────
313
+ // mousedown #1: drag-paint row selection
314
+ // ─────────────────────────────────────────────────────────────────
315
+ root.addEventListener('mousedown', (event) => {
316
+ const mouseEvent = event as MouseEvent;
317
+ if (mouseEvent.button !== 0) return;
318
+ const realTarget = (event.composedPath()[0] ?? event.target) as HTMLElement | null;
319
+ if (!realTarget || !el.controller || !el.snapshot) return;
320
+ // Don't interfere with column resize / actions / editor / anything in
321
+ // the header or filter row.
322
+ if (realTarget.closest('.column-resizer')) return;
323
+ if (realTarget.closest('[data-role="editor"]')) return;
324
+ if (realTarget.closest('.header-cell')) return;
325
+ if (realTarget.closest('.filter-cell')) return;
326
+ const resolved = el.controller.getResolvedSelectionOptions();
327
+ if (!resolved.enableRowSelection || !resolved.multiSelect) return;
328
+ const startCell = realTarget.closest<HTMLElement>('.body-cell[data-row][data-column]');
329
+ if (!startCell) return;
330
+ const startRowId = startCell.dataset['row'];
331
+ if (!startRowId) return;
332
+ const isCheckboxCol = startCell.dataset['column'] === 'selectionRowHeaderCol';
333
+ if (!isCheckboxCol && !resolved.enableFullRowSelection) return;
334
+ // Clicks with modifier keys go through the single-click handler
335
+ // instead of drag-paint, so shift/ctrl don't accidentally commit a
336
+ // one-row drag on mouseup.
337
+ if (mouseEvent.shiftKey || mouseEvent.ctrlKey || mouseEvent.metaKey) return;
338
+
339
+ // Drag-paint tracking. We don't touch the start row on mousedown — the
340
+ // click handler owns single-click semantics. The starting row only
341
+ // gets painted once we observe a mousemove onto a DIFFERENT row.
342
+ const startSelected = el.snapshot.selectedRowIds.has(startRowId);
343
+ const targetSelected = !startSelected;
344
+ const touched = new Set<string>();
345
+ let dragStarted = false;
346
+
347
+ const paintRow = (rowId: string): void => {
348
+ if (touched.has(rowId)) return;
349
+ touched.add(rowId);
350
+ const currentlySelected = el.snapshot?.selectedRowIds.has(rowId) ?? false;
351
+ if (currentlySelected === targetSelected) return;
352
+ const row = el.controller!.findRowByIdPublic(rowId);
353
+ if (!row) return;
354
+ if (targetSelected) el.controller!.selectRow(row.entity, event);
355
+ else el.controller!.unSelectRow(row.entity, event);
356
+ };
357
+
358
+ 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]');
362
+ if (!cell) return;
363
+ const rowId = cell.dataset['row'];
364
+ if (!rowId) return;
365
+ if (!dragStarted) {
366
+ // First move onto a different row promotes this gesture to a drag —
367
+ // paint the starting row and suppress the trailing click.
368
+ if (rowId === startRowId) return;
369
+ dragStarted = true;
370
+ paintRow(startRowId);
371
+ }
372
+ paintRow(rowId);
373
+ };
374
+
375
+ const suppressNextClick = (e: Event): void => {
376
+ e.stopPropagation();
377
+ e.preventDefault();
378
+ root.removeEventListener('click', suppressNextClick, true);
379
+ };
380
+
381
+ const handleUp = (): void => {
382
+ window.removeEventListener('mousemove', handleMove, true);
383
+ window.removeEventListener('mouseup', handleUp, true);
384
+ if (dragStarted) {
385
+ root.addEventListener('click', suppressNextClick, true);
386
+ }
387
+ };
388
+
389
+ window.addEventListener('mousemove', handleMove, true);
390
+ window.addEventListener('mouseup', handleUp, true);
391
+ });
392
+
393
+ // ─────────────────────────────────────────────────────────────────
394
+ // mousedown #2: column resize
395
+ // ─────────────────────────────────────────────────────────────────
396
+ root.addEventListener('mousedown', (event) => {
397
+ const target = event.target as HTMLElement | null;
398
+ if (!target || !el.controller) return;
399
+
400
+ const resizer = target.closest<HTMLElement>('.column-resizer[data-column]');
401
+ if (!resizer || !el.controller.canResizeColumns()) return;
402
+
403
+ const columnName = resizer.dataset['column'];
404
+ if (!columnName) return;
405
+
406
+ const headerCell = resizer.closest<HTMLElement>('.header-cell');
407
+ if (!headerCell) return;
408
+
409
+ event.preventDefault();
410
+ event.stopPropagation();
411
+
412
+ const startX = (event as MouseEvent).clientX;
413
+ const startWidth = headerCell.getBoundingClientRect().width;
414
+ let lastWidth = startWidth;
415
+
416
+ const handleMove = (moveEvent: MouseEvent): void => {
417
+ lastWidth = Math.max(88, startWidth + (moveEvent.clientX - startX));
418
+
419
+ // Write directly to DOM — skip the full refresh while dragging.
420
+ const nextTemplate = el.controller!.buildTemplateColumnsWithOverride(columnName, lastWidth);
421
+ const writeRoot = el.shadowRoot ?? el;
422
+ (writeRoot as ShadowRoot | HTMLElement)
423
+ .querySelectorAll<HTMLElement>('.header-grid, .filter-grid, .body-grid')
424
+ .forEach((targetEl) => {
425
+ targetEl.style.gridTemplateColumns = nextTemplate;
426
+ });
427
+ };
428
+
429
+ const handleUp = (): void => {
430
+ window.removeEventListener('mousemove', handleMove);
431
+ window.removeEventListener('mouseup', handleUp);
432
+ // Commit final width once — triggers one full refresh.
433
+ el.controller!.setColumnWidthOverride(columnName, lastWidth);
434
+ };
435
+
436
+ window.addEventListener('mousemove', handleMove);
437
+ window.addEventListener('mouseup', handleUp);
438
+ });
439
+
440
+ // ─────────────────────────────────────────────────────────────────
441
+ // dblclick: auto-size column, begin cell edit
442
+ // ─────────────────────────────────────────────────────────────────
443
+ root.addEventListener('dblclick', (event) => {
444
+ const target = event.target as HTMLElement | null;
445
+ if (!target || !el.controller) return;
446
+
447
+ const resizer = target.closest<HTMLElement>('.column-resizer[data-column]');
448
+ if (resizer && el.controller.canResizeColumns()) {
449
+ const columnName = resizer.dataset['column'];
450
+ if (columnName) {
451
+ event.preventDefault();
452
+ event.stopPropagation();
453
+ el.controller.setColumnWidthOverride(columnName, measureAutoColumnWidth(el, columnName));
454
+ return;
455
+ }
456
+ }
457
+
458
+ const cell = target.closest<HTMLElement>('.body-cell');
459
+ if (!cell) return;
460
+
461
+ const rowId = cell.dataset['row'];
462
+ const columnName = cell.dataset['column'];
463
+ if (rowId && columnName) {
464
+ el.controller.beginCellEdit(rowId, columnName, event);
465
+ }
466
+ });
467
+
468
+ // ─────────────────────────────────────────────────────────────────
469
+ // keydown: cellNav + editor shortcuts
470
+ // ─────────────────────────────────────────────────────────────────
471
+ root.addEventListener('keydown', (event) => {
472
+ const keyboardEvent = event as KeyboardEvent;
473
+ const target = event.target as HTMLElement | null;
474
+ if (!target || !el.controller) return;
475
+
476
+ // cellNav.keyDownOverrides: declared overrides skip cellnav's default
477
+ // handling and raise viewPortKeyDown so consumers can disable built-in
478
+ // key handling per-key.
479
+ const overrides = el.controller.getOptions().keyDownOverrides ?? [];
480
+ if (overrides.length && target.closest('.body-cell')) {
481
+ for (const override of overrides) {
482
+ if (matchesKeyOverride(override, keyboardEvent)) {
483
+ el.controller.raiseCellNavKeyEvent('keydown', keyboardEvent);
484
+ return;
485
+ }
486
+ }
487
+ }
488
+
489
+ // Ctrl/Cmd+A on a body cell — select all. Gated by enableRowSelection + multiSelect.
490
+ if (
491
+ (keyboardEvent.ctrlKey || keyboardEvent.metaKey) &&
492
+ (keyboardEvent.key === 'a' || keyboardEvent.key === 'A') &&
493
+ target.closest('.body-cell')
494
+ ) {
495
+ const resolved = el.controller.getResolvedSelectionOptions();
496
+ if (resolved.enableRowSelection && resolved.multiSelect) {
497
+ event.preventDefault();
498
+ el.controller.selectAllRows(event);
499
+ return;
500
+ }
501
+ }
502
+
503
+ // Space toggles the row when on the checkbox column or in full-row-selection mode.
504
+ if (keyboardEvent.key === ' ' || keyboardEvent.key === 'Spacebar') {
505
+ const cell = target.closest<HTMLElement>('.body-cell[data-row][data-column]');
506
+ const rowId = cell?.dataset['row'];
507
+ const columnName = cell?.dataset['column'];
508
+ if (cell && rowId && columnName) {
509
+ const resolved = el.controller.getResolvedSelectionOptions();
510
+ if (resolved.enableRowSelection) {
511
+ const onCheckboxCol = columnName === 'selectionRowHeaderCol';
512
+ if (onCheckboxCol || resolved.enableFullRowSelection) {
513
+ event.preventDefault();
514
+ const row = el.controller.findRowByIdPublic(rowId);
515
+ if (row) {
516
+ if (resolved.multiSelect && !resolved.modifierKeysToMultiSelect) {
517
+ el.controller.toggleRowSelectionByEntity(row.entity, event);
518
+ } else {
519
+ el.controller.setMultiSelect(false);
520
+ el.controller.toggleRowSelectionByEntity(row.entity, event);
521
+ el.controller.setMultiSelect(resolved.multiSelect);
522
+ }
523
+ }
524
+ return;
525
+ }
526
+ }
527
+ }
528
+ }
529
+
530
+ if (target instanceof HTMLInputElement && target.dataset['role'] === 'editor') {
531
+ if (keyboardEvent.key === 'Enter') {
532
+ event.preventDefault();
533
+ event.stopPropagation();
534
+ const fromRow = target.dataset['row'] ?? null;
535
+ const fromCol = target.dataset['column'] ?? null;
536
+ const direction = keyboardEvent.shiftKey ? 'up' : 'down';
537
+ commitAndMove(el, fromRow, fromCol, direction);
538
+ return;
539
+ }
540
+
541
+ if (keyboardEvent.key === 'Escape') {
542
+ event.preventDefault();
543
+ event.stopPropagation();
544
+ const fromRow = target.dataset['row'] ?? null;
545
+ const fromCol = target.dataset['column'] ?? null;
546
+ el.controller.cancelCellEdit();
547
+ focusCellElement(el, fromRow, fromCol);
548
+ return;
549
+ }
550
+
551
+ if (keyboardEvent.key === 'Tab') {
552
+ event.preventDefault();
553
+ event.stopPropagation();
554
+ const fromRow = target.dataset['row'] ?? null;
555
+ const fromCol = target.dataset['column'] ?? null;
556
+ const direction = keyboardEvent.shiftKey ? 'left' : 'right';
557
+ commitAndMove(el, fromRow, fromCol, direction);
558
+ return;
559
+ }
560
+
561
+ return;
562
+ }
563
+
564
+ // Derive the logical cell either from the event target (clicked cell) or
565
+ // from the element's tracked focusedCell state (covers the case where
566
+ // the browser routed the event to the scroll container instead of the
567
+ // cell).
568
+ let rowId: string | undefined;
569
+ 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'];
574
+ } else if (el.focusedCell) {
575
+ rowId = el.focusedCell.rowId;
576
+ columnName = el.focusedCell.columnName;
577
+ }
578
+ if (!rowId || !columnName) return;
579
+
580
+ switch (keyboardEvent.key) {
581
+ case 'ArrowLeft':
582
+ event.preventDefault();
583
+ moveGridFocus(el, 'left', rowId, columnName);
584
+ return;
585
+ case 'ArrowRight':
586
+ event.preventDefault();
587
+ moveGridFocus(el, 'right', rowId, columnName);
588
+ return;
589
+ case 'ArrowUp':
590
+ event.preventDefault();
591
+ moveGridFocus(el, 'up', rowId, columnName);
592
+ return;
593
+ case 'ArrowDown':
594
+ event.preventDefault();
595
+ moveGridFocus(el, 'down', rowId, columnName);
596
+ return;
597
+ case 'Tab':
598
+ event.preventDefault();
599
+ moveGridFocus(el, keyboardEvent.shiftKey ? 'left' : 'right', rowId, columnName);
600
+ return;
601
+ case 'Home':
602
+ event.preventDefault();
603
+ moveGridFocus(el, keyboardEvent.ctrlKey ? 'top' : 'rowStart', rowId, columnName);
604
+ return;
605
+ case 'End':
606
+ event.preventDefault();
607
+ moveGridFocus(el, keyboardEvent.ctrlKey ? 'bottom' : 'rowEnd', rowId, columnName);
608
+ return;
609
+ case 'Enter':
610
+ case 'F2':
611
+ event.preventDefault();
612
+ el.controller.beginCellEdit(rowId, columnName, event);
613
+ return;
614
+ default:
615
+ break;
616
+ }
617
+
618
+ if (
619
+ keyboardEvent.key.length === 1 &&
620
+ !keyboardEvent.ctrlKey &&
621
+ !keyboardEvent.metaKey &&
622
+ !keyboardEvent.altKey
623
+ ) {
624
+ event.preventDefault();
625
+ // Pass the typed character as the initial value — beginCellEdit seeds
626
+ // the editor with this instead of the cell's current value, so the
627
+ // first keystroke isn't lost to the "input is focused, skip value
628
+ // overwrite" guard in ui-grid-cell-editor.
629
+ el.controller.beginCellEdit(rowId, columnName, event, keyboardEvent.key);
630
+ }
631
+ });
632
+
633
+ // ─────────────────────────────────────────────────────────────────
634
+ // blur: commit edit on capture
635
+ // ─────────────────────────────────────────────────────────────────
636
+ root.addEventListener(
637
+ 'blur',
638
+ (event) => {
639
+ const target = event.target as HTMLElement | null;
640
+ if (!target || !el.controller) return;
641
+ if (target instanceof HTMLInputElement && target.dataset['role'] === 'editor') {
642
+ el.controller.commitCellEdit();
643
+ }
644
+ },
645
+ true,
646
+ );
647
+
648
+ // ─────────────────────────────────────────────────────────────────
649
+ // drag & drop: column reorder
650
+ // ─────────────────────────────────────────────────────────────────
651
+ root.addEventListener('dragstart', (event) => {
652
+ const dragEvent = event as DragEvent;
653
+ const target = event.target as HTMLElement | null;
654
+ const headerCell = target?.closest<HTMLElement>('.header-cell[data-column]');
655
+
656
+ if (!headerCell || !el.snapshot || !canGridMoveColumns(el.snapshot.options)) {
657
+ event.preventDefault();
658
+ return;
659
+ }
660
+
661
+ const columnName = headerCell.dataset['column'];
662
+ if (!columnName || !dragEvent.dataTransfer) {
663
+ event.preventDefault();
664
+ return;
665
+ }
666
+
667
+ el.draggedColumnName = columnName;
668
+ el.dropTargetColumnName = null;
669
+ dragEvent.dataTransfer.effectAllowed = 'move';
670
+ dragEvent.dataTransfer.setData('text/plain', columnName);
671
+ headerCell.classList.add('is-dragging');
672
+ });
673
+
674
+ root.addEventListener('dragover', (event) => {
675
+ const dragEvent = event as DragEvent;
676
+ const target = event.target as HTMLElement | null;
677
+ const headerCell = target?.closest<HTMLElement>('.header-cell[data-column]');
678
+
679
+ if (!headerCell || !el.draggedColumnName || !dragEvent.dataTransfer) return;
680
+
681
+ const columnName = headerCell.dataset['column'];
682
+ if (!columnName || columnName === el.draggedColumnName) return;
683
+
684
+ event.preventDefault();
685
+ dragEvent.dataTransfer.dropEffect = 'move';
686
+
687
+ if (el.dropTargetColumnName !== columnName) {
688
+ root.querySelectorAll('.header-cell.is-drag-target').forEach((element) => {
689
+ element.classList.remove('is-drag-target');
690
+ });
691
+ el.dropTargetColumnName = columnName;
692
+ headerCell.classList.add('is-drag-target');
693
+ }
694
+ });
695
+
696
+ root.addEventListener('dragleave', (event) => {
697
+ // `dragleave` bubbles from every descendant as the cursor moves over
698
+ // child nodes (the header-label span, the sort/pin buttons, etc.), so
699
+ // we must only clear the drop indicator when the pointer has actually
700
+ // left the header cell — i.e. when `relatedTarget` is outside it.
701
+ const dragEvent = event as DragEvent;
702
+ const target = event.target as HTMLElement | null;
703
+ const headerCell = target?.closest<HTMLElement>('.header-cell[data-column]');
704
+ if (!headerCell) return;
705
+ const related = dragEvent.relatedTarget as Node | null;
706
+ if (related && headerCell.contains(related)) return; // still inside the same header cell
707
+ if (headerCell.dataset['column'] === el.dropTargetColumnName) {
708
+ headerCell.classList.remove('is-drag-target');
709
+ el.dropTargetColumnName = null;
710
+ }
711
+ });
712
+
713
+ root.addEventListener('drop', (event) => {
714
+ event.preventDefault();
715
+ const dragEvent = event as DragEvent;
716
+ const target = event.target as HTMLElement | null;
717
+ const headerCell = target?.closest<HTMLElement>('.header-cell[data-column]');
718
+ const targetColumn = headerCell?.dataset['column'];
719
+ const sourceColumn =
720
+ el.draggedColumnName ?? dragEvent.dataTransfer?.getData('text/plain') ?? null;
721
+
722
+ el.draggedColumnName = null;
723
+ el.dropTargetColumnName = null;
724
+
725
+ root
726
+ .querySelectorAll('.header-cell.is-dragging, .header-cell.is-drag-target')
727
+ .forEach((element) => {
728
+ element.classList.remove('is-dragging', 'is-drag-target');
729
+ });
730
+
731
+ if (!sourceColumn || !targetColumn || sourceColumn === targetColumn || !el.controller) return;
732
+
733
+ el.controller.moveVisibleColumn(sourceColumn, targetColumn);
734
+ });
735
+
736
+ root.addEventListener('dragend', () => {
737
+ el.draggedColumnName = null;
738
+ el.dropTargetColumnName = null;
739
+ root
740
+ .querySelectorAll('.header-cell.is-dragging, .header-cell.is-drag-target')
741
+ .forEach((element) => {
742
+ element.classList.remove('is-dragging', 'is-drag-target');
743
+ });
744
+ });
745
+
746
+ // ─────────────────────────────────────────────────────────────────
747
+ // wheel on strips → forward to body viewport
748
+ // ─────────────────────────────────────────────────────────────────
749
+ // Forward wheel gestures originating inside the header / filter strips
750
+ // to the body viewport. The strips are technically scroll containers
751
+ // (necessary for `position: sticky` on pinned cells to anchor), but we
752
+ // don't want the user scrolling them independently — that would desync
753
+ // them from the body.
754
+ const onStripWheel = (event: WheelEvent): void => {
755
+ const target = event.target as HTMLElement | null;
756
+ const strip = target?.closest('.grid-header-strip, .grid-filter-strip');
757
+ if (!strip) return;
758
+ const bodyViewport = el.shadowRoot?.querySelector<HTMLElement>('.grid-body-viewport');
759
+ if (!bodyViewport) return;
760
+ event.preventDefault();
761
+ bodyViewport.scrollLeft += event.deltaX;
762
+ bodyViewport.scrollTop += event.deltaY;
763
+ };
764
+ root.addEventListener('wheel', onStripWheel as EventListener, { passive: false });
765
+
766
+ // ─────────────────────────────────────────────────────────────────
767
+ // scroll: virtualization window shift + infinite-scroll threshold
768
+ // ─────────────────────────────────────────────────────────────────
769
+ root.addEventListener(
770
+ 'scroll',
771
+ (event) => {
772
+ const target = event.target as HTMLElement | null;
773
+ if (!target || el.suppressScrollEvent) return;
774
+
775
+ if (!target.classList.contains('grid-body-viewport')) return;
776
+
777
+ el.lastScrollActivityAt = Date.now();
778
+ el.scrollPosition = target.scrollTop;
779
+ el.horizontalScrollPosition = target.scrollLeft;
780
+
781
+ syncHeaderHorizontalScroll(el, target.scrollLeft);
782
+
783
+ const snapshot = el.snapshot;
784
+ if (!snapshot) return;
785
+
786
+ if (snapshot.pipeline.virtualizationEnabled) {
787
+ const overscan = 4;
788
+ const nextStartIndex = Math.max(
789
+ 0,
790
+ Math.floor(el.scrollPosition / snapshot.rowSize) - overscan,
791
+ );
792
+ const startChanged = nextStartIndex !== el.lastVirtualStartIndex;
793
+
794
+ if (el.scrollFrame !== null) cancelAnimationFrame(el.scrollFrame);
795
+ el.scrollFrame = requestAnimationFrame(() => {
796
+ el.scrollFrame = null;
797
+ if (startChanged) el.renderVirtualBody();
798
+ maybeTriggerInfiniteScroll(el);
799
+ });
800
+ return;
801
+ }
802
+
803
+ // Non-virtualized path: still evaluate infinite-scroll thresholds so
804
+ // large static datasets can page in via needLoadMoreData.
805
+ if (el.scrollFrame !== null) cancelAnimationFrame(el.scrollFrame);
806
+ el.scrollFrame = requestAnimationFrame(() => {
807
+ el.scrollFrame = null;
808
+ maybeTriggerInfiniteScroll(el);
809
+ });
810
+ },
811
+ true,
812
+ );
813
+
814
+ root.__uiGridBound = true;
815
+ }