@tipolox/litgrid-web 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 (50) hide show
  1. package/dist/DataGrid.d.ts +2 -0
  2. package/dist/DataGrid.d.ts.map +1 -0
  3. package/dist/DataGrid.js +2 -0
  4. package/dist/DataGrid.js.map +1 -0
  5. package/dist/LICENSE +21 -0
  6. package/dist/clipboard/serializeSelectedCells.d.ts +12 -0
  7. package/dist/clipboard/serializeSelectedCells.d.ts.map +1 -0
  8. package/dist/clipboard/serializeSelectedCells.js +37 -0
  9. package/dist/clipboard/serializeSelectedCells.js.map +1 -0
  10. package/dist/clipboard/serializeSelectedRows.d.ts +12 -0
  11. package/dist/clipboard/serializeSelectedRows.d.ts.map +1 -0
  12. package/dist/clipboard/serializeSelectedRows.js +21 -0
  13. package/dist/clipboard/serializeSelectedRows.js.map +1 -0
  14. package/dist/columnState/columnState.d.ts +23 -0
  15. package/dist/columnState/columnState.d.ts.map +1 -0
  16. package/dist/columnState/columnState.js +70 -0
  17. package/dist/columnState/columnState.js.map +1 -0
  18. package/dist/components/DataGrid.d.ts +249 -0
  19. package/dist/components/DataGrid.d.ts.map +1 -0
  20. package/dist/components/DataGrid.js +2567 -0
  21. package/dist/components/DataGrid.js.map +1 -0
  22. package/dist/index.d.ts +3 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +2 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/sizing/columnSizing.d.ts +4 -0
  27. package/dist/sizing/columnSizing.d.ts.map +1 -0
  28. package/dist/sizing/columnSizing.js +17 -0
  29. package/dist/sizing/columnSizing.js.map +1 -0
  30. package/dist/sizing/constants.d.ts +4 -0
  31. package/dist/sizing/constants.d.ts.map +1 -0
  32. package/dist/sizing/constants.js +4 -0
  33. package/dist/sizing/constants.js.map +1 -0
  34. package/dist/sizing/rowSizing.d.ts +2 -0
  35. package/dist/sizing/rowSizing.d.ts.map +1 -0
  36. package/dist/sizing/rowSizing.js +5 -0
  37. package/dist/sizing/rowSizing.js.map +1 -0
  38. package/dist/theme/darkTheme.d.ts +6 -0
  39. package/dist/theme/darkTheme.d.ts.map +1 -0
  40. package/dist/theme/darkTheme.js +34 -0
  41. package/dist/theme/darkTheme.js.map +1 -0
  42. package/dist/theme/lightTheme.d.ts +6 -0
  43. package/dist/theme/lightTheme.d.ts.map +1 -0
  44. package/dist/theme/lightTheme.js +35 -0
  45. package/dist/theme/lightTheme.js.map +1 -0
  46. package/dist/types.d.ts +85 -0
  47. package/dist/types.d.ts.map +1 -0
  48. package/dist/types.js +2 -0
  49. package/dist/types.js.map +1 -0
  50. package/package.json +50 -0
@@ -0,0 +1,2567 @@
1
+ // src/components/DataGrid.ts
2
+ import { LitElement, css, html, render as litRender } from 'lit';
3
+ import { cache } from 'lit/directives/cache.js';
4
+ import { createGridEngine } from '@tipolox/litgrid-core';
5
+ import { calculateDisplayLayout, createVariableVirtualizer, createVirtualizer, mapDisplayScrollOffset } from '@tipolox/litgrid-renderer';
6
+ import { darkThemeStyles } from '../theme/darkTheme.js';
7
+ import { lightThemeStyles } from '../theme/lightTheme.js';
8
+ import { MIN_COLUMN_WIDTH, MIN_ROW_HEIGHT } from '../sizing/constants.js';
9
+ import { normalizeColumnWidth, resolveColumnWidth } from '../sizing/columnSizing.js';
10
+ import { normalizeRowHeight } from '../sizing/rowSizing.js';
11
+ import { serializeSelectedCells } from '../clipboard/serializeSelectedCells.js';
12
+ import { serializeSelectedRows } from '../clipboard/serializeSelectedRows.js';
13
+ import { createColumnState, parseColumnState, resolveColumnState } from '../columnState/columnState.js';
14
+ const FILTER_OPERATORS = [
15
+ { value: 'contains', label: 'Contains' },
16
+ { value: 'equals', label: 'Equals' },
17
+ { value: 'startsWith', label: 'Starts with' },
18
+ { value: 'endsWith', label: 'Ends with' },
19
+ { value: 'isEmpty', label: 'Is empty' },
20
+ { value: 'isNotEmpty', label: 'Is not empty' },
21
+ { value: 'greaterThan', label: 'Greater than' },
22
+ { value: 'greaterThanOrEqual', label: 'Greater than or equal' },
23
+ { value: 'lessThan', label: 'Less than' },
24
+ { value: 'lessThanOrEqual', label: 'Less than or equal' }
25
+ ];
26
+ export class DataGrid extends LitElement {
27
+ static { this.nextInstanceId = 0; }
28
+ static { this.properties = {
29
+ theme: { type: String, reflect: true },
30
+ ariaLabel: { type: String, attribute: 'aria-label', reflect: true },
31
+ ariaDescription: { type: String, attribute: 'aria-description', reflect: true },
32
+ screenReaderAnnouncements: { type: Boolean, attribute: 'screen-reader-announcements', reflect: true }
33
+ }; }
34
+ constructor() {
35
+ super();
36
+ this.gridId = `litgrid-${DataGrid.nextInstanceId++}`;
37
+ this.engine = createGridEngine();
38
+ this.fixedRowVirtualizer = createVirtualizer({
39
+ rowHeight: 36,
40
+ viewportHeight: 320,
41
+ overscan: 4
42
+ });
43
+ this.variableRowVirtualizer = createVariableVirtualizer({
44
+ sizes: [],
45
+ viewportSize: 320,
46
+ overscan: 4
47
+ });
48
+ this.columnVirtualizer = createVariableVirtualizer({
49
+ sizes: [],
50
+ viewportSize: 0,
51
+ overscan: 2
52
+ });
53
+ this.rowHeight = 36;
54
+ this.height = 320;
55
+ this.overscan = 4;
56
+ this.columnOverscan = 2;
57
+ this._bestFitSampleSize = 10;
58
+ this.horizontalScrollLeft = 0;
59
+ this.viewportWidth = 0;
60
+ this.scrollbarWidth = 0;
61
+ this.columnDefs = [];
62
+ this.sourceColumnDefs = [];
63
+ this.columnWidths = new Map();
64
+ this.hiddenColumnKeys = new Set();
65
+ this._columnStateStorageKey = null;
66
+ this.rowHeights = new Map();
67
+ this.bestFitWidths = new Map();
68
+ this.columnResizeState = null;
69
+ this.rowResizeState = null;
70
+ this.columnReorderState = null;
71
+ this.columnDragPreview = null;
72
+ this.previousBodyCursor = '';
73
+ this.previousBodyUserSelect = '';
74
+ this.activeHeaderMenu = null;
75
+ this.columnChooserOpen = false;
76
+ this.columnChooserPosition = null;
77
+ this.quickSearchVisible = false;
78
+ this.quickSearchDraft = '';
79
+ this.quickSearchDebounceId = null;
80
+ this.sourceRowCount = 0;
81
+ this.filterDrafts = new Map();
82
+ this.measureContainer = null;
83
+ this.viewportElement = null;
84
+ this.scrollRAFId = null;
85
+ this.pendingScrollTop = 0;
86
+ this.pendingScrollLeft = 0;
87
+ this.previousPendingScrollTop = 0;
88
+ this.headerRowElement = null;
89
+ this.activeCell = null;
90
+ this.shouldFocusActiveCell = false;
91
+ this.headerMenuTrigger = null;
92
+ this.shouldFocusHeaderMenu = false;
93
+ this.shouldRestoreHeaderMenuFocus = false;
94
+ this.screenReaderMessage = '';
95
+ this.lastScreenReaderAnnouncement = '';
96
+ // Use a larger cap (2^26) to avoid hitting browser element-height limits.
97
+ this.maxScrollableHeight = 67108864;
98
+ this.lastTotalSize = 0;
99
+ this.lastDisplayTotal = 0;
100
+ this.quickSearchDebounceThreshold = 10000;
101
+ this.quickSearchDebounceMs = 150;
102
+ this.textMeasureContext = null;
103
+ this.handleColumnReorder = (event) => {
104
+ const state = this.columnReorderState;
105
+ if (!state)
106
+ return;
107
+ if (!state.hasMoved && Math.abs(event.clientX - state.startX) < 5)
108
+ return;
109
+ if (!state.hasMoved) {
110
+ state.hasMoved = true;
111
+ this.startBodyResize('grabbing');
112
+ }
113
+ this.updateColumnDragPreview(state.columnKey, event.clientX, event.clientY);
114
+ const viewport = this.viewportElement ?? this.renderRoot.querySelector('.viewport');
115
+ if (viewport) {
116
+ const bounds = viewport.getBoundingClientRect();
117
+ const edgeSize = 32;
118
+ if (event.clientX < bounds.left + edgeSize) {
119
+ viewport.scrollLeft = Math.max(0, viewport.scrollLeft - 16);
120
+ }
121
+ else if (event.clientX > bounds.right - edgeSize) {
122
+ viewport.scrollLeft += 16;
123
+ }
124
+ }
125
+ const headers = Array.from(this.renderRoot.querySelectorAll('.header-cell[data-column-key]'));
126
+ const target = headers.find((header) => {
127
+ const bounds = header.getBoundingClientRect();
128
+ return event.clientX >= bounds.left && event.clientX <= bounds.right;
129
+ });
130
+ if (!target) {
131
+ this.requestUpdate();
132
+ return;
133
+ }
134
+ const targetKey = target.dataset.columnKey;
135
+ const targetIndex = this.getResolvedColumns().findIndex((column) => column.key === targetKey);
136
+ if (targetIndex < 0)
137
+ return;
138
+ const bounds = target.getBoundingClientRect();
139
+ let nextIndex = targetIndex + (event.clientX > bounds.left + bounds.width / 2 ? 1 : 0);
140
+ if (state.sourceIndex < nextIndex)
141
+ nextIndex -= 1;
142
+ state.targetIndex = Math.max(0, Math.min(nextIndex, this.getResolvedColumns().length - 1));
143
+ this.requestUpdate();
144
+ };
145
+ this.stopColumnReorder = () => {
146
+ const state = this.columnReorderState;
147
+ if (!state)
148
+ return;
149
+ this.columnReorderState = null;
150
+ window.removeEventListener('pointermove', this.handleColumnReorder);
151
+ window.removeEventListener('pointerup', this.stopColumnReorder);
152
+ window.removeEventListener('pointercancel', this.stopColumnReorder);
153
+ this.removeColumnDragPreview();
154
+ if (state.hasMoved)
155
+ this.stopBodyResize();
156
+ if (state.hasMoved && state.sourceIndex !== state.targetIndex) {
157
+ this.moveColumn(state.columnKey, state.targetIndex);
158
+ }
159
+ else {
160
+ this.requestUpdate();
161
+ }
162
+ };
163
+ this.handleColumnResize = (event) => {
164
+ if (!this.columnResizeState) {
165
+ return;
166
+ }
167
+ const nextWidth = Math.max(MIN_COLUMN_WIDTH, this.columnResizeState.startWidth + event.clientX - this.columnResizeState.startX);
168
+ this.columnWidths.set(this.columnResizeState.columnKey, nextWidth);
169
+ this.requestUpdate();
170
+ };
171
+ this.stopColumnResize = () => {
172
+ if (!this.columnResizeState) {
173
+ return;
174
+ }
175
+ this.columnResizeState = null;
176
+ this.commitColumnState('resize');
177
+ this.stopBodyResize();
178
+ window.removeEventListener('pointermove', this.handleColumnResize);
179
+ window.removeEventListener('pointerup', this.stopColumnResize);
180
+ };
181
+ this.handleRowResize = (event) => {
182
+ if (!this.rowResizeState) {
183
+ return;
184
+ }
185
+ const nextHeight = Math.max(MIN_ROW_HEIGHT, this.rowResizeState.startHeight + event.clientY - this.rowResizeState.startY);
186
+ this.setRowHeight(this.rowResizeState.rowIndex, nextHeight);
187
+ };
188
+ this.stopRowResize = () => {
189
+ if (!this.rowResizeState) {
190
+ return;
191
+ }
192
+ this.rowResizeState = null;
193
+ this.stopBodyResize();
194
+ window.removeEventListener('pointermove', this.handleRowResize);
195
+ window.removeEventListener('pointerup', this.stopRowResize);
196
+ };
197
+ this.handleScroll = (event) => {
198
+ const target = event.currentTarget;
199
+ const scrollLeft = target?.scrollLeft ?? 0;
200
+ const scrollTop = target?.scrollTop ?? 0;
201
+ this.pendingScrollTop = scrollTop;
202
+ this.pendingScrollLeft = scrollLeft;
203
+ this.horizontalScrollLeft = scrollLeft;
204
+ this.headerRowElement = this.headerRowElement ?? this.renderRoot.querySelector('.header-column-row');
205
+ if (this.headerRowElement) {
206
+ this.headerRowElement.style.transform = `translateX(${-scrollLeft}px)`;
207
+ }
208
+ if (this.scrollRAFId !== null) {
209
+ return;
210
+ }
211
+ this.scrollRAFId = requestAnimationFrame(() => {
212
+ this.scrollRAFId = null;
213
+ const rowVirtualizer = this.hasCustomRowHeights()
214
+ ? this.variableRowVirtualizer
215
+ : this.fixedRowVirtualizer;
216
+ // Map the visible scrollTop (capped display space) to the virtualizer's
217
+ // real scroll offset when we've capped the spacer height for very large grids.
218
+ // Use the measured `lastDisplayTotal` when available, otherwise fall
219
+ // back to the intended display size (min(totalSize, maxScrollableHeight)).
220
+ const measuredDisplay = this.lastDisplayTotal > 0 ? this.lastDisplayTotal : Math.min(this.lastTotalSize || 0, this.maxScrollableHeight);
221
+ const viewportH = Math.max(0, this.height || 0);
222
+ const scrollMapping = mapDisplayScrollOffset({
223
+ displayScrollOffset: this.pendingScrollTop,
224
+ previousDisplayScrollOffset: this.previousPendingScrollTop,
225
+ displaySize: measuredDisplay,
226
+ totalSize: this.lastTotalSize,
227
+ viewportSize: viewportH
228
+ });
229
+ // Debug: large-grid scroll mapping (kept behind a high threshold)
230
+ if (this.lastTotalSize > 6000000) {
231
+ // eslint-disable-next-line no-console
232
+ console.log('[yc-grid] scroll map', {
233
+ pendingScrollTop: this.pendingScrollTop,
234
+ lastTotalSize: this.lastTotalSize,
235
+ lastDisplayTotal: this.lastDisplayTotal,
236
+ measuredDisplay,
237
+ viewportH,
238
+ availableDisplay: scrollMapping.availableDisplaySize,
239
+ availableVirtual: scrollMapping.availableVirtualSize,
240
+ mappedScrollOffset: scrollMapping.scrollOffset
241
+ });
242
+ }
243
+ rowVirtualizer.setScrollOffset(scrollMapping.scrollOffset);
244
+ this.columnVirtualizer.setScrollOffset(this.pendingScrollLeft);
245
+ // track last applied pending scroll so we can detect scroll direction
246
+ this.previousPendingScrollTop = this.pendingScrollTop;
247
+ this.requestUpdate();
248
+ });
249
+ };
250
+ this.theme = 'light';
251
+ this.ariaLabel = 'Data grid';
252
+ this.ariaDescription = '';
253
+ this.screenReaderAnnouncements = true;
254
+ }
255
+ static { this.styles = [lightThemeStyles, darkThemeStyles, css `
256
+ :host {
257
+ display: block;
258
+ color: var(--litgrid-color-text);
259
+ font: 14px/1.4 system-ui, sans-serif;
260
+ }
261
+
262
+ .shell {
263
+ position: relative;
264
+ border: 1px solid var(--litgrid-color-border-strong);
265
+ border-radius: 12px;
266
+ background: var(--litgrid-color-surface);
267
+ box-shadow: var(--litgrid-shadow-shell);
268
+ overflow: hidden;
269
+ }
270
+
271
+ .viewport {
272
+ overflow: auto;
273
+ height: var(--grid-height, 320px);
274
+ }
275
+
276
+ .screen-reader-only {
277
+ position: absolute;
278
+ width: 1px;
279
+ height: 1px;
280
+ padding: 0;
281
+ margin: -1px;
282
+ overflow: hidden;
283
+ clip: rect(0, 0, 0, 0);
284
+ white-space: nowrap;
285
+ border: 0;
286
+ }
287
+
288
+ .header-column-row,
289
+ .row {
290
+ display: grid;
291
+ grid-template-columns: var(--virtual-grid-template);
292
+ }
293
+
294
+ .header-shell {
295
+ position: relative;
296
+ z-index: 3;
297
+ display: grid;
298
+ grid-template-columns: var(--header-grid-template);
299
+ border-bottom: 1px solid var(--litgrid-color-border);
300
+ background: var(--litgrid-color-surface-subtle);
301
+ }
302
+
303
+ .header-clip {
304
+ overflow: visible;
305
+ z-index: 3;
306
+ }
307
+
308
+ .header-column-row {
309
+ position: relative;
310
+ z-index: 2;
311
+ width: var(--total-column-width);
312
+ color: var(--litgrid-color-text-header);
313
+ font-size: 12px;
314
+ font-weight: 700;
315
+ letter-spacing: 0.03em;
316
+ text-transform: uppercase;
317
+ }
318
+
319
+ .header-cell,
320
+ .row-header-cell,
321
+ .cell {
322
+ box-sizing: border-box;
323
+ min-width: 0;
324
+ padding: 0 12px;
325
+ overflow: hidden;
326
+ text-overflow: ellipsis;
327
+ white-space: nowrap;
328
+ }
329
+
330
+ .header-cell {
331
+ position: relative;
332
+ z-index: 2;
333
+ overflow: visible;
334
+ height: 36px;
335
+ display: flex;
336
+ align-items: center;
337
+ border-right: 1px solid var(--litgrid-color-border-subtle);
338
+ cursor: grab;
339
+ touch-action: none;
340
+ }
341
+
342
+ .header-cell-reordering {
343
+ opacity: 0.55;
344
+ cursor: grabbing;
345
+ }
346
+
347
+ .header-cell-drop-before::before,
348
+ .header-cell-drop-after::after {
349
+ position: absolute;
350
+ top: 0;
351
+ bottom: 0;
352
+ z-index: 4;
353
+ width: 2px;
354
+ background: var(--litgrid-color-accent);
355
+ content: '';
356
+ pointer-events: none;
357
+ }
358
+
359
+ .header-cell-drop-before::before {
360
+ left: -1px;
361
+ }
362
+
363
+ .header-cell-drop-after::after {
364
+ right: -1px;
365
+ }
366
+
367
+ .row-header-cell {
368
+ position: sticky;
369
+ left: 0;
370
+ z-index: 1;
371
+ display: flex;
372
+ align-items: center;
373
+ justify-content: center;
374
+ width: var(--row-header-width, 0px);
375
+ border-right: 1px solid var(--litgrid-color-border);
376
+ background: var(--litgrid-color-surface-subtle);
377
+ color: var(--litgrid-color-text-subtle);
378
+ font-size: 12px;
379
+ font-weight: 600;
380
+ user-select: none;
381
+ }
382
+
383
+ .header-row-header {
384
+ position: relative;
385
+ height: 36px;
386
+ border-right: 1px solid var(--litgrid-color-border);
387
+ }
388
+
389
+ .header-label {
390
+ min-width: 0;
391
+ overflow: hidden;
392
+ text-overflow: ellipsis;
393
+ }
394
+
395
+ .header-actions {
396
+ display: inline-flex;
397
+ align-items: center;
398
+ gap: 4px;
399
+ margin-left: auto;
400
+ position: relative;
401
+ z-index: 2;
402
+ }
403
+
404
+ .header-action-button {
405
+ border: none;
406
+ background: transparent;
407
+ cursor: pointer;
408
+ color: var(--litgrid-color-text-muted);
409
+ font-size: 16px;
410
+ line-height: 1;
411
+ padding: 4px;
412
+ border-radius: 4px;
413
+ }
414
+
415
+ .header-action-button:hover {
416
+ background: var(--litgrid-color-control-hover);
417
+ }
418
+
419
+ .header-menu {
420
+ position: absolute;
421
+ top: calc(100% + 4px);
422
+ right: 0;
423
+ left: auto;
424
+ display: flex;
425
+ flex-direction: column;
426
+ min-width: 180px;
427
+ border: 1px solid var(--litgrid-color-border);
428
+ border-radius: 8px;
429
+ background: var(--litgrid-color-surface);
430
+ box-shadow: var(--litgrid-shadow-menu);
431
+ padding: 6px 0;
432
+ z-index: 10000;
433
+ pointer-events: auto;
434
+ }
435
+
436
+ .header-menu-right {
437
+ left: 0;
438
+ right: auto;
439
+ }
440
+
441
+ .header-menu button {
442
+ width: 100%;
443
+ border: none;
444
+ background: transparent;
445
+ text-align: left;
446
+ padding: 10px 12px;
447
+ cursor: pointer;
448
+ color: var(--litgrid-color-text-strong);
449
+ font-size: 13px;
450
+ }
451
+
452
+ .header-menu-divider {
453
+ height: 1px;
454
+ margin: 6px 0;
455
+ background: var(--litgrid-color-border-subtle);
456
+ border: none;
457
+ }
458
+
459
+ .header-menu button:hover {
460
+ background: var(--litgrid-color-surface-hover);
461
+ }
462
+
463
+ .quick-search {
464
+ display: flex;
465
+ align-items: center;
466
+ gap: 8px;
467
+ padding: 8px 12px;
468
+ border-bottom: 1px solid var(--litgrid-color-border);
469
+ background: var(--litgrid-color-surface-subtle);
470
+ }
471
+
472
+ .quick-search input {
473
+ flex: 1;
474
+ min-width: 0;
475
+ border: 1px solid var(--litgrid-color-border);
476
+ border-radius: 6px;
477
+ background: var(--litgrid-color-surface);
478
+ color: var(--litgrid-color-text-strong);
479
+ font: inherit;
480
+ padding: 7px 9px;
481
+ }
482
+
483
+ .quick-search button {
484
+ border: 1px solid var(--litgrid-color-border);
485
+ border-radius: 6px;
486
+ background: var(--litgrid-color-surface);
487
+ color: var(--litgrid-color-text-strong);
488
+ font: inherit;
489
+ padding: 7px 9px;
490
+ cursor: pointer;
491
+ }
492
+
493
+ .header-filter {
494
+ display: grid;
495
+ gap: 6px;
496
+ padding: 8px 12px;
497
+ border-top: 1px solid var(--litgrid-color-border-subtle);
498
+ }
499
+
500
+ .header-filter select,
501
+ .header-filter input {
502
+ box-sizing: border-box;
503
+ width: 100%;
504
+ min-width: 0;
505
+ border: 1px solid var(--litgrid-color-border);
506
+ border-radius: 4px;
507
+ padding: 6px 8px;
508
+ font: inherit;
509
+ }
510
+
511
+ .header-filter-actions {
512
+ display: flex;
513
+ gap: 6px;
514
+ }
515
+
516
+ .header-filter-actions button {
517
+ flex: 1;
518
+ padding: 6px 8px;
519
+ }
520
+
521
+ .header-filter-indicator {
522
+ margin-left: 4px;
523
+ color: var(--litgrid-color-accent);
524
+ font-size: 14px;
525
+ }
526
+
527
+ .resize-handle {
528
+ position: absolute;
529
+ top: 0;
530
+ right: -4px;
531
+ z-index: 1;
532
+ width: 8px;
533
+ height: 100%;
534
+ cursor: col-resize;
535
+ touch-action: none;
536
+ }
537
+
538
+ .resize-handle::after {
539
+ content: '';
540
+ position: absolute;
541
+ top: 8px;
542
+ right: 3px;
543
+ width: 1px;
544
+ height: calc(100% - 16px);
545
+ background: var(--litgrid-color-resize-indicator);
546
+ opacity: 0;
547
+ transition: opacity 120ms ease;
548
+ }
549
+
550
+ .resize-handle:hover::after {
551
+ opacity: 1;
552
+ }
553
+
554
+ .row-resize-handle {
555
+ position: absolute;
556
+ right: 0;
557
+ bottom: -4px;
558
+ left: 0;
559
+ z-index: 2;
560
+ height: 8px;
561
+ cursor: row-resize;
562
+ touch-action: none;
563
+ }
564
+
565
+ .row-resize-handle::after {
566
+ content: '';
567
+ position: absolute;
568
+ right: 10px;
569
+ bottom: 3px;
570
+ left: 10px;
571
+ height: 1px;
572
+ background: var(--litgrid-color-resize-indicator);
573
+ opacity: 0;
574
+ transition: opacity 120ms ease;
575
+ }
576
+
577
+ .row-resize-handle:hover::after {
578
+ opacity: 1;
579
+ }
580
+
581
+ .header-cell:last-child,
582
+ .cell:last-child {
583
+ border-right: 0;
584
+ }
585
+
586
+ .column-spacer {
587
+ min-width: 0;
588
+ }
589
+
590
+ .header-gutter {
591
+ height: 36px;
592
+ border-left: 1px solid var(--litgrid-color-border-subtle);
593
+ background: var(--litgrid-color-surface-subtle);
594
+ }
595
+
596
+ .content {
597
+ box-sizing: border-box;
598
+ transform: translateY(var(--offset-top, 0px));
599
+ width: var(--total-row-width);
600
+ }
601
+
602
+ .row {
603
+ box-sizing: border-box;
604
+ width: var(--total-row-width);
605
+ border-bottom: 1px solid var(--litgrid-color-row-border);
606
+ background:
607
+ linear-gradient(90deg, var(--litgrid-color-row-start), var(--litgrid-color-row-end));
608
+ }
609
+
610
+ .row-selectable,
611
+ .cell-selectable {
612
+ cursor: pointer;
613
+ }
614
+
615
+ .row-selected {
616
+ background: var(--litgrid-color-accent-soft);
617
+ box-shadow: inset 3px 0 0 var(--litgrid-color-accent);
618
+ }
619
+
620
+ .row:last-child {
621
+ border-bottom: 0;
622
+ }
623
+
624
+ .cell {
625
+ display: flex;
626
+ align-items: center;
627
+ border-right: 1px solid var(--litgrid-color-cell-border);
628
+ }
629
+
630
+ .cell-selected {
631
+ background: var(--litgrid-color-accent-selected);
632
+ outline: 2px solid var(--litgrid-color-accent);
633
+ outline-offset: -2px;
634
+ }
635
+
636
+ .cell-active {
637
+ outline: 2px solid var(--litgrid-color-accent);
638
+ outline-offset: -2px;
639
+ }
640
+
641
+
642
+ .row-selected .row-header-cell {
643
+ background: var(--litgrid-color-accent-selected);
644
+ color: var(--litgrid-color-accent-text);
645
+ }
646
+
647
+ .selection-checkbox {
648
+ width: 16px;
649
+ height: 16px;
650
+ margin: 0;
651
+ accent-color: var(--litgrid-color-accent);
652
+ cursor: pointer;
653
+ }
654
+
655
+ .selection-checkbox:disabled {
656
+ cursor: default;
657
+ }
658
+
659
+ .column-chooser-dialog {
660
+ position: absolute;
661
+ top: 36px;
662
+ right: 8px;
663
+ z-index: 6;
664
+ min-width: 180px;
665
+ padding: 6px;
666
+ border: 1px solid var(--litgrid-color-border);
667
+ border-radius: 6px;
668
+ background: var(--litgrid-color-surface);
669
+ box-shadow: var(--litgrid-shadow-dialog);
670
+ }
671
+
672
+ .column-chooser-empty {
673
+ display: flex;
674
+ justify-content: flex-end;
675
+ padding: 6px 8px;
676
+ border-bottom: 1px solid var(--litgrid-color-border-subtle);
677
+ background: var(--litgrid-color-surface-subtle);
678
+ }
679
+
680
+ .column-chooser-empty button {
681
+ border: 1px solid var(--litgrid-color-border);
682
+ border-radius: 6px;
683
+ background: var(--litgrid-color-surface);
684
+ color: var(--litgrid-color-text-header);
685
+ font: inherit;
686
+ padding: 4px 8px;
687
+ cursor: pointer;
688
+ }
689
+
690
+ .column-chooser-dialog,
691
+ .column-chooser-menu {
692
+ padding-top: 34px;
693
+ }
694
+
695
+ .column-chooser-close {
696
+ position: absolute;
697
+ top: 5px;
698
+ right: 5px;
699
+ width: 24px !important;
700
+ min-width: 24px;
701
+ height: 24px;
702
+ padding: 0 !important;
703
+ border: 0 !important;
704
+ background: transparent !important;
705
+ color: var(--litgrid-color-text-muted);
706
+ font-size: 20px !important;
707
+ line-height: 1;
708
+ cursor: pointer;
709
+ }
710
+
711
+ .column-chooser-dialog label,
712
+ .column-chooser-menu label {
713
+ display: flex;
714
+ align-items: center;
715
+ gap: 8px;
716
+ padding: 5px 6px;
717
+ color: var(--litgrid-color-text-header);
718
+ cursor: pointer;
719
+ }
720
+
721
+ .pagination {
722
+ display: flex;
723
+ align-items: center;
724
+ justify-content: flex-end;
725
+ gap: 10px;
726
+ min-height: 48px;
727
+ padding: 8px 12px;
728
+ border-top: 1px solid var(--litgrid-color-border);
729
+ color: var(--litgrid-color-text-muted);
730
+ font-size: 13px;
731
+ }
732
+
733
+ .pagination button,
734
+ .pagination select {
735
+ border: 1px solid var(--litgrid-color-border);
736
+ border-radius: 6px;
737
+ background: var(--litgrid-color-surface);
738
+ color: var(--litgrid-color-text-strong);
739
+ font: inherit;
740
+ padding: 5px 8px;
741
+ }
742
+
743
+ .pagination button:not(:disabled) {
744
+ cursor: pointer;
745
+ }
746
+
747
+ .pagination button:disabled {
748
+ cursor: not-allowed;
749
+ opacity: 0.5;
750
+ }
751
+ `]; }
752
+ firstUpdated() {
753
+ this.updateScrollbarWidth();
754
+ }
755
+ updated() {
756
+ this.updateScrollbarWidth();
757
+ this.focusActiveCell();
758
+ this.focusHeaderMenu();
759
+ // Detect actual DOM spacer height after render. Some browsers clamp
760
+ // element heights internally; read the measured spacer height and
761
+ // use it for scroll mapping if it's lower than our intended display size.
762
+ try {
763
+ const viewport = this.renderRoot.querySelector('.viewport');
764
+ const spacer = viewport?.firstElementChild;
765
+ if (spacer instanceof HTMLElement) {
766
+ const measured = spacer.offsetHeight || 0;
767
+ if (measured > 0 && measured !== this.lastDisplayTotal) {
768
+ // Update the measured value for scroll scaling to use.
769
+ // Don't request update to avoid loops; the scroll handler will use this value.
770
+ this.lastDisplayTotal = measured;
771
+ }
772
+ }
773
+ }
774
+ catch (e) {
775
+ // ignore
776
+ }
777
+ }
778
+ disconnectedCallback() {
779
+ this.stopColumnResize();
780
+ this.stopRowResize();
781
+ this.stopColumnReorder();
782
+ this.clearQuickSearchDebounce();
783
+ if (this.scrollRAFId !== null) {
784
+ cancelAnimationFrame(this.scrollRAFId);
785
+ this.scrollRAFId = null;
786
+ }
787
+ const viewportElement = this.renderRoot.querySelector('.viewport');
788
+ if (viewportElement) {
789
+ viewportElement.removeEventListener('scroll', this.handleScroll);
790
+ }
791
+ this.headerRowElement = null;
792
+ if (this.measureContainer && this.measureContainer.parentNode) {
793
+ this.measureContainer.parentNode.removeChild(this.measureContainer);
794
+ }
795
+ this.measureContainer = null;
796
+ // call LitElement lifecycle
797
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
798
+ // @ts-ignore
799
+ if (super.disconnectedCallback)
800
+ super.disconnectedCallback();
801
+ }
802
+ updateVirtualizer(next = {}) {
803
+ const options = {
804
+ viewportSize: this.height,
805
+ overscan: this.overscan,
806
+ ...next
807
+ };
808
+ this.height = options.viewportHeight ?? this.height;
809
+ this.overscan = options.overscan;
810
+ this.fixedRowVirtualizer.setOptions({
811
+ rowHeight: this.rowHeight,
812
+ viewportHeight: this.height,
813
+ overscan: this.overscan
814
+ });
815
+ this.variableRowVirtualizer.setOptions(options);
816
+ }
817
+ updateScrollbarWidth() {
818
+ const viewport = this.renderRoot.querySelector('.viewport');
819
+ if (!(viewport instanceof HTMLElement)) {
820
+ return;
821
+ }
822
+ const nextWidth = viewport.offsetWidth - viewport.clientWidth;
823
+ const nextViewportWidth = viewport.clientWidth;
824
+ if (nextWidth !== this.scrollbarWidth || nextViewportWidth !== this.viewportWidth) {
825
+ this.scrollbarWidth = nextWidth;
826
+ this.viewportWidth = nextViewportWidth;
827
+ this.requestUpdate();
828
+ }
829
+ }
830
+ getResolvedColumns() {
831
+ if (this.columnDefs.length > 0) {
832
+ return this.columnDefs;
833
+ }
834
+ const firstRow = this.engine.getRows()[0];
835
+ if (firstRow && typeof firstRow === 'object' && !Array.isArray(firstRow)) {
836
+ return Object.keys(firstRow).map((key) => ({
837
+ key,
838
+ header: key
839
+ }));
840
+ }
841
+ return [
842
+ {
843
+ key: 'value',
844
+ header: 'Value',
845
+ accessor: (row) => row
846
+ }
847
+ ];
848
+ }
849
+ getVisibleColumns() {
850
+ return this.getResolvedColumns().filter((column) => !this.hiddenColumnKeys.has(column.key));
851
+ }
852
+ getColumnWidth(column) {
853
+ return resolveColumnWidth(column, this.columnWidths.get(column.key));
854
+ }
855
+ isRowHeaderEnabled() {
856
+ return this.engine.getConfig().rowHeader.enabled;
857
+ }
858
+ hasCheckboxSelection() {
859
+ const selection = this.engine.getConfig().selection;
860
+ return selection.checkboxes && selection.mode === 'multi-row';
861
+ }
862
+ isRowHeaderVisible() {
863
+ return this.isRowHeaderEnabled() || this.hasCheckboxSelection();
864
+ }
865
+ getRowHeaderWidth() {
866
+ return this.isRowHeaderVisible() ? this.engine.getConfig().rowHeader.width : 0;
867
+ }
868
+ getRowHeight(rowIndex) {
869
+ return this.rowHeights.get(rowIndex) ?? this.rowHeight;
870
+ }
871
+ get bestFitSampleSize() {
872
+ return this._bestFitSampleSize;
873
+ }
874
+ set bestFitSampleSize(value) {
875
+ if (this._bestFitSampleSize === value) {
876
+ return;
877
+ }
878
+ this._bestFitSampleSize = value;
879
+ this.clearBestFitWidths();
880
+ }
881
+ clearBestFitWidths() {
882
+ this.bestFitWidths.clear();
883
+ }
884
+ getBestFitWidth(columnKey, sampleSize) {
885
+ return this.bestFitWidths.get(columnKey)?.get(sampleSize);
886
+ }
887
+ setBestFitWidth(columnKey, sampleSize, width) {
888
+ const widths = this.bestFitWidths.get(columnKey) ?? new Map();
889
+ widths.set(sampleSize, width);
890
+ this.bestFitWidths.set(columnKey, widths);
891
+ }
892
+ getRowSizes() {
893
+ const count = this.engine.getRowCount();
894
+ return Array.from({ length: count }, (_, index) => normalizeRowHeight(this.getRowHeight(index)));
895
+ }
896
+ hasCustomRowHeights() {
897
+ return this.rowHeights.size > 0;
898
+ }
899
+ setRowHeight(rowIndex, height) {
900
+ this.rowHeights.set(rowIndex, normalizeRowHeight(height));
901
+ this.requestUpdate();
902
+ }
903
+ resetRowHeight(rowIndex) {
904
+ this.rowHeights.delete(rowIndex);
905
+ this.requestUpdate();
906
+ }
907
+ resetAllRowHeights() {
908
+ this.rowHeights.clear();
909
+ this.requestUpdate();
910
+ }
911
+ setColumnWidth(columnKey, width) {
912
+ const nextWidth = normalizeColumnWidth(width);
913
+ if (this.columnWidths.get(columnKey) === nextWidth)
914
+ return;
915
+ this.columnWidths.set(columnKey, nextWidth);
916
+ this.requestUpdate();
917
+ this.commitColumnState('resize');
918
+ }
919
+ resetColumnWidth(columnKey) {
920
+ if (!this.columnWidths.delete(columnKey))
921
+ return;
922
+ this.requestUpdate();
923
+ this.commitColumnState('resize');
924
+ }
925
+ resetAllColumnWidths() {
926
+ if (this.columnWidths.size === 0)
927
+ return;
928
+ this.columnWidths.clear();
929
+ this.requestUpdate();
930
+ this.commitColumnState('resize');
931
+ }
932
+ getColumnOrder() {
933
+ return this.getResolvedColumns().map((column) => column.key);
934
+ }
935
+ applyColumnOrder(columnDefs, columnKey, previousIndex, currentIndex) {
936
+ this.columnDefs = columnDefs;
937
+ this.requestUpdate();
938
+ this.dispatchEvent(new CustomEvent('column-reorder', {
939
+ bubbles: true,
940
+ composed: true,
941
+ detail: {
942
+ columnKey,
943
+ previousIndex,
944
+ currentIndex,
945
+ columnOrder: this.getColumnOrder()
946
+ }
947
+ }));
948
+ this.commitColumnState('reorder');
949
+ }
950
+ moveColumn(columnKey, targetIndex) {
951
+ const columns = this.getResolvedColumns();
952
+ const previousIndex = columns.findIndex((column) => column.key === columnKey);
953
+ if (previousIndex < 0)
954
+ return;
955
+ const currentIndex = Math.max(0, Math.min(Math.floor(targetIndex), columns.length - 1));
956
+ if (previousIndex === currentIndex)
957
+ return;
958
+ const nextColumns = [...columns];
959
+ const [column] = nextColumns.splice(previousIndex, 1);
960
+ nextColumns.splice(currentIndex, 0, column);
961
+ this.applyColumnOrder(nextColumns, columnKey, previousIndex, currentIndex);
962
+ }
963
+ setColumnOrder(columnKeys) {
964
+ const columns = this.getResolvedColumns();
965
+ const columnsByKey = new Map(columns.map((column) => [column.key, column]));
966
+ const seen = new Set();
967
+ const orderedColumns = [];
968
+ for (const key of columnKeys) {
969
+ const column = columnsByKey.get(key);
970
+ if (column && !seen.has(key)) {
971
+ seen.add(key);
972
+ orderedColumns.push(column);
973
+ }
974
+ }
975
+ for (const column of columns) {
976
+ if (!seen.has(column.key))
977
+ orderedColumns.push(column);
978
+ }
979
+ const previousOrder = columns.map((column) => column.key);
980
+ const currentOrder = orderedColumns.map((column) => column.key);
981
+ if (previousOrder.every((key, index) => key === currentOrder[index]))
982
+ return;
983
+ this.columnDefs = orderedColumns;
984
+ this.activeCell = null;
985
+ this.requestUpdate();
986
+ this.commitColumnState('reorder');
987
+ }
988
+ resetColumnOrder() {
989
+ const sourceOrder = this.sourceColumnDefs.map((column) => column.key);
990
+ if (this.getColumnOrder().every((key, index) => key === sourceOrder[index]))
991
+ return;
992
+ this.columnDefs = [...this.sourceColumnDefs];
993
+ this.activeCell = null;
994
+ this.requestUpdate();
995
+ this.commitColumnState('reorder');
996
+ }
997
+ setColumnVisible(columnKey, visible) {
998
+ if (!this.getResolvedColumns().some((column) => column.key === columnKey))
999
+ return;
1000
+ if (this.isColumnVisible(columnKey) === visible)
1001
+ return;
1002
+ const activeColumnKey = this.activeCell
1003
+ ? this.getVisibleColumns()[this.activeCell.columnIndex]?.key
1004
+ : undefined;
1005
+ if (visible) {
1006
+ this.hiddenColumnKeys.delete(columnKey);
1007
+ }
1008
+ else {
1009
+ this.hiddenColumnKeys.add(columnKey);
1010
+ }
1011
+ if (this.activeCell) {
1012
+ const nextColumnIndex = this.getVisibleColumns().findIndex((column) => column.key === activeColumnKey);
1013
+ this.activeCell = nextColumnIndex < 0
1014
+ ? null
1015
+ : { ...this.activeCell, columnIndex: nextColumnIndex };
1016
+ }
1017
+ this.requestUpdate();
1018
+ this.announceScreenReader(`${this.getColumnLabel(columnKey)} column ${visible ? 'shown' : 'hidden'}.`);
1019
+ this.dispatchEvent(new CustomEvent('column-visibility-change', {
1020
+ bubbles: true,
1021
+ composed: true,
1022
+ detail: { columnKey, visible, visibleColumnKeys: this.getVisibleColumnKeys() }
1023
+ }));
1024
+ this.commitColumnState('visibility');
1025
+ }
1026
+ isColumnVisible(columnKey) {
1027
+ return this.getResolvedColumns().some((column) => column.key === columnKey) && !this.hiddenColumnKeys.has(columnKey);
1028
+ }
1029
+ getVisibleColumnKeys() {
1030
+ return this.getVisibleColumns().map((column) => column.key);
1031
+ }
1032
+ getColumnState() {
1033
+ return createColumnState(this.getResolvedColumns(), this.columnWidths, this.hiddenColumnKeys);
1034
+ }
1035
+ setColumnState(state) {
1036
+ const resolvedState = resolveColumnState(state, this.sourceColumnDefs);
1037
+ if (!resolvedState)
1038
+ return;
1039
+ this.columnDefs = resolvedState.columns;
1040
+ this.columnWidths = resolvedState.widths;
1041
+ this.hiddenColumnKeys = resolvedState.hiddenKeys;
1042
+ this.activeCell = null;
1043
+ this.requestUpdate();
1044
+ this.commitColumnState('set');
1045
+ }
1046
+ resetColumnState() {
1047
+ this.columnDefs = [...this.sourceColumnDefs];
1048
+ this.columnWidths.clear();
1049
+ this.hiddenColumnKeys = new Set(this.sourceColumnDefs.filter((column) => column.hidden).map((column) => column.key));
1050
+ this.activeCell = null;
1051
+ this.removePersistedColumnState();
1052
+ this.requestUpdate();
1053
+ this.dispatchColumnStateChange('reset');
1054
+ }
1055
+ getColumnStateStorage() {
1056
+ try {
1057
+ return globalThis.localStorage;
1058
+ }
1059
+ catch {
1060
+ return null;
1061
+ }
1062
+ }
1063
+ restorePersistedColumnState() {
1064
+ if (!this._columnStateStorageKey || this.sourceColumnDefs.length === 0)
1065
+ return;
1066
+ const storage = this.getColumnStateStorage();
1067
+ if (!storage)
1068
+ return;
1069
+ try {
1070
+ const state = parseColumnState(storage.getItem(this._columnStateStorageKey));
1071
+ const resolvedState = state && resolveColumnState(state, this.sourceColumnDefs);
1072
+ if (!resolvedState)
1073
+ return;
1074
+ this.columnDefs = resolvedState.columns;
1075
+ this.columnWidths = resolvedState.widths;
1076
+ this.hiddenColumnKeys = resolvedState.hiddenKeys;
1077
+ this.activeCell = null;
1078
+ this.requestUpdate();
1079
+ this.dispatchColumnStateChange('restore');
1080
+ }
1081
+ catch {
1082
+ // Storage may be unavailable or blocked by browser privacy settings.
1083
+ }
1084
+ }
1085
+ persistColumnState() {
1086
+ if (!this._columnStateStorageKey)
1087
+ return;
1088
+ const storage = this.getColumnStateStorage();
1089
+ if (!storage)
1090
+ return;
1091
+ try {
1092
+ storage.setItem(this._columnStateStorageKey, JSON.stringify(this.getColumnState()));
1093
+ }
1094
+ catch {
1095
+ // Persistence is optional; preserve the in-memory state if storage fails.
1096
+ }
1097
+ }
1098
+ removePersistedColumnState() {
1099
+ if (!this._columnStateStorageKey)
1100
+ return;
1101
+ const storage = this.getColumnStateStorage();
1102
+ if (!storage)
1103
+ return;
1104
+ try {
1105
+ storage.removeItem(this._columnStateStorageKey);
1106
+ }
1107
+ catch {
1108
+ // Persistence is optional; preserve the in-memory state if storage fails.
1109
+ }
1110
+ }
1111
+ commitColumnState(reason) {
1112
+ this.persistColumnState();
1113
+ this.dispatchColumnStateChange(reason);
1114
+ }
1115
+ dispatchColumnStateChange(reason) {
1116
+ this.dispatchEvent(new CustomEvent('column-state-change', {
1117
+ bubbles: true,
1118
+ composed: true,
1119
+ detail: { reason, state: this.getColumnState() }
1120
+ }));
1121
+ }
1122
+ resetColumnVisibility() {
1123
+ const hiddenColumnKeys = new Set(this.getResolvedColumns().filter((column) => column.hidden).map((column) => column.key));
1124
+ for (const column of this.getResolvedColumns()) {
1125
+ this.setColumnVisible(column.key, !hiddenColumnKeys.has(column.key));
1126
+ }
1127
+ }
1128
+ toggleHeaderMenu(columnKey, event) {
1129
+ event.stopPropagation();
1130
+ this.columnChooserOpen = false;
1131
+ this.columnChooserPosition = null;
1132
+ if (this.activeHeaderMenu !== columnKey) {
1133
+ const activeFilter = this.engine.getFilters().find((filter) => filter.columnKey === columnKey);
1134
+ this.filterDrafts.set(columnKey, activeFilter ?? {
1135
+ columnKey,
1136
+ operator: 'contains',
1137
+ value: ''
1138
+ });
1139
+ }
1140
+ const isOpening = this.activeHeaderMenu !== columnKey;
1141
+ this.activeHeaderMenu = isOpening ? columnKey : null;
1142
+ this.headerMenuTrigger = isOpening && event.currentTarget instanceof HTMLButtonElement
1143
+ ? event.currentTarget
1144
+ : null;
1145
+ this.shouldFocusHeaderMenu = isOpening;
1146
+ this.requestUpdate();
1147
+ }
1148
+ closeHeaderMenu(restoreFocus = false) {
1149
+ if (this.activeHeaderMenu === null && !this.columnChooserOpen)
1150
+ return;
1151
+ this.activeHeaderMenu = null;
1152
+ this.columnChooserOpen = false;
1153
+ this.columnChooserPosition = null;
1154
+ this.shouldFocusHeaderMenu = false;
1155
+ this.shouldRestoreHeaderMenuFocus = restoreFocus && this.headerMenuTrigger !== null;
1156
+ this.requestUpdate();
1157
+ }
1158
+ toggleColumnChooser(event) {
1159
+ if (this.columnChooserOpen) {
1160
+ this.closeColumnChooser(true);
1161
+ return;
1162
+ }
1163
+ if (event?.currentTarget instanceof HTMLElement) {
1164
+ const shell = this.renderRoot.querySelector('.shell');
1165
+ const menu = event.currentTarget.closest('.header-menu');
1166
+ const anchorBounds = (menu ?? event.currentTarget).getBoundingClientRect();
1167
+ const shellBounds = shell?.getBoundingClientRect();
1168
+ this.columnChooserPosition = shellBounds
1169
+ ? {
1170
+ left: anchorBounds.left - shellBounds.left,
1171
+ top: anchorBounds.top - shellBounds.top
1172
+ }
1173
+ : null;
1174
+ }
1175
+ else {
1176
+ this.columnChooserPosition = null;
1177
+ }
1178
+ this.columnChooserOpen = true;
1179
+ this.activeHeaderMenu = null;
1180
+ this.shouldFocusHeaderMenu = true;
1181
+ this.requestUpdate();
1182
+ }
1183
+ closeColumnChooser(restoreFocus = false) {
1184
+ if (!this.columnChooserOpen)
1185
+ return;
1186
+ this.columnChooserOpen = false;
1187
+ this.columnChooserPosition = null;
1188
+ this.shouldFocusHeaderMenu = false;
1189
+ this.shouldRestoreHeaderMenuFocus = restoreFocus && this.headerMenuTrigger !== null;
1190
+ this.requestUpdate();
1191
+ }
1192
+ focusHeaderMenu() {
1193
+ if (this.shouldRestoreHeaderMenuFocus) {
1194
+ this.shouldRestoreHeaderMenuFocus = false;
1195
+ this.headerMenuTrigger?.focus();
1196
+ this.headerMenuTrigger = null;
1197
+ return;
1198
+ }
1199
+ if (!this.shouldFocusHeaderMenu)
1200
+ return;
1201
+ const firstControl = this.renderRoot.querySelector('.header-menu button, .header-menu input, .header-menu select, .column-chooser-dialog button, .column-chooser-dialog input');
1202
+ if (firstControl) {
1203
+ this.shouldFocusHeaderMenu = false;
1204
+ firstControl.focus();
1205
+ }
1206
+ }
1207
+ handleShellKeyDown(event) {
1208
+ if (event.key !== 'Escape' || (this.activeHeaderMenu === null && !this.columnChooserOpen)) {
1209
+ return;
1210
+ }
1211
+ event.preventDefault();
1212
+ event.stopPropagation();
1213
+ this.closeHeaderMenu(true);
1214
+ }
1215
+ handleViewportFocus(event) {
1216
+ if (event.target !== event.currentTarget || this.activeCell) {
1217
+ return;
1218
+ }
1219
+ this.setActiveCell(0, 0);
1220
+ this.requestUpdate();
1221
+ }
1222
+ announceScreenReader(message) {
1223
+ if (!this.screenReaderAnnouncements || !message || message === this.lastScreenReaderAnnouncement) {
1224
+ return;
1225
+ }
1226
+ this.lastScreenReaderAnnouncement = message;
1227
+ this.screenReaderMessage = message;
1228
+ this.requestUpdate();
1229
+ }
1230
+ getScreenReaderDescription() {
1231
+ return this.ariaDescription || 'Use arrow keys to move between cells. Press Enter or Space to select. Press Control or Command C to copy selected cells or rows.';
1232
+ }
1233
+ getColumnLabel(columnKey) {
1234
+ const column = this.getVisibleColumns().find((item) => item.key === columnKey)
1235
+ ?? this.getResolvedColumns().find((item) => item.key === columnKey);
1236
+ return column?.header ?? columnKey;
1237
+ }
1238
+ getActiveCellAnnouncement() {
1239
+ if (!this.activeCell)
1240
+ return '';
1241
+ const { rowIndex, columnIndex } = this.activeCell;
1242
+ const column = this.getVisibleColumns()[columnIndex];
1243
+ const row = this.engine.getVisibleRows(rowIndex, rowIndex + 1)[0];
1244
+ if (!column || row === undefined)
1245
+ return '';
1246
+ const value = this.formatCellValue(this.getCellValue(column, row, rowIndex)) || 'blank';
1247
+ const selected = this.isCellSelectionMode()
1248
+ ? this.engine.isCellSelected(rowIndex, column.key)
1249
+ : this.isRowSelectionMode() && this.engine.isRowSelected(rowIndex);
1250
+ return `${this.getColumnLabel(column.key)}, row ${rowIndex + 1} of ${this.engine.getRowCount()}, column ${columnIndex + 1} of ${this.getVisibleColumns().length}, ${value}${selected ? ', selected' : ''}.`;
1251
+ }
1252
+ announceActiveCell() {
1253
+ this.announceScreenReader(this.getActiveCellAnnouncement());
1254
+ }
1255
+ announceRowSelection(rowIndex) {
1256
+ this.announceScreenReader(`Row ${rowIndex + 1} ${this.engine.isRowSelected(rowIndex) ? 'selected' : 'deselected'}.`);
1257
+ }
1258
+ announceCellSelection(rowIndex, columnKey) {
1259
+ this.announceScreenReader(`${this.getColumnLabel(columnKey)}, row ${rowIndex + 1} ${this.engine.isCellSelected(rowIndex, columnKey) ? 'selected' : 'deselected'}.`);
1260
+ }
1261
+ announceVisibleRowCount(context) {
1262
+ const rowCount = this.engine.getRowCount();
1263
+ this.announceScreenReader(rowCount === 0 ? `${context}. No matching rows.` : `${context}. ${rowCount} ${rowCount === 1 ? 'row' : 'rows'} shown.`);
1264
+ }
1265
+ handleSortAscending(columnKey) {
1266
+ this.engine.sortBy(columnKey, 'asc');
1267
+ this.announceScreenReader(`${this.getColumnLabel(columnKey)} sorted ascending.`);
1268
+ this.requestUpdate();
1269
+ this.closeHeaderMenu();
1270
+ }
1271
+ handleSortDescending(columnKey) {
1272
+ this.engine.sortBy(columnKey, 'desc');
1273
+ this.announceScreenReader(`${this.getColumnLabel(columnKey)} sorted descending.`);
1274
+ this.requestUpdate();
1275
+ this.closeHeaderMenu();
1276
+ }
1277
+ handleClearSort() {
1278
+ this.engine.clearSort();
1279
+ this.announceScreenReader('Sorting cleared.');
1280
+ this.requestUpdate();
1281
+ this.closeHeaderMenu();
1282
+ }
1283
+ handleBestFit(columnKey) {
1284
+ this.bestFitColumn(columnKey);
1285
+ this.closeHeaderMenu();
1286
+ }
1287
+ handleBestFitAllColumns() {
1288
+ this.bestFitAllColumns();
1289
+ this.closeHeaderMenu();
1290
+ }
1291
+ getTextMeasureContext() {
1292
+ if (this.textMeasureContext) {
1293
+ return this.textMeasureContext;
1294
+ }
1295
+ const canvas = document.createElement('canvas');
1296
+ const context = canvas.getContext('2d');
1297
+ if (!context) {
1298
+ throw new Error('Unable to create text measurement context');
1299
+ }
1300
+ const style = window.getComputedStyle(this);
1301
+ context.font = style.font || '14px system-ui';
1302
+ this.textMeasureContext = context;
1303
+ return context;
1304
+ }
1305
+ measureTextWidth(text) {
1306
+ const context = this.getTextMeasureContext();
1307
+ return context.measureText(text).width;
1308
+ }
1309
+ formatBestFitValue(column, row, rowIndex) {
1310
+ if (column.render) {
1311
+ const rendered = column.render(this.getCellValue(column, row, rowIndex), row, rowIndex);
1312
+ if (typeof rendered === 'string' || typeof rendered === 'number') {
1313
+ return String(rendered);
1314
+ }
1315
+ }
1316
+ return String(this.getCellValue(column, row, rowIndex));
1317
+ }
1318
+ computeColumnBestFit(columnKey, sampleSize = this.bestFitSampleSize) {
1319
+ const resolvedColumns = this.getResolvedColumns();
1320
+ const column = resolvedColumns.find((item) => item.key === columnKey);
1321
+ if (!column) {
1322
+ return null;
1323
+ }
1324
+ const cachedWidth = this.getBestFitWidth(columnKey, sampleSize);
1325
+ if (cachedWidth !== undefined) {
1326
+ return cachedWidth;
1327
+ }
1328
+ const rowCount = this.engine.getRowCount();
1329
+ const rows = this.engine.getVisibleRows(0, Math.min(rowCount, sampleSize));
1330
+ const headerText = column.header ?? column.key;
1331
+ const headerWidth = this.measureTextWidth(String(headerText).toUpperCase());
1332
+ const headerControlPadding = 44;
1333
+ let maxWidth = headerWidth + headerControlPadding;
1334
+ // Ensure measurement container exists
1335
+ if (!this.measureContainer) {
1336
+ try {
1337
+ this.measureContainer = document.createElement('div');
1338
+ Object.assign(this.measureContainer.style, {
1339
+ position: 'absolute',
1340
+ left: '-9999px',
1341
+ top: '0px',
1342
+ visibility: 'hidden',
1343
+ height: 'auto',
1344
+ width: 'auto',
1345
+ whiteSpace: 'nowrap',
1346
+ overflow: 'visible'
1347
+ });
1348
+ document.body.appendChild(this.measureContainer);
1349
+ }
1350
+ catch (e) {
1351
+ this.measureContainer = null;
1352
+ }
1353
+ }
1354
+ rows.forEach((row, index) => {
1355
+ // Prefer renderer output when available
1356
+ if (column.render) {
1357
+ const rendered = column.render(this.getCellValue(column, row, index), row, index);
1358
+ if (typeof rendered === 'string' || typeof rendered === 'number') {
1359
+ maxWidth = Math.max(maxWidth, this.measureTextWidth(String(rendered)));
1360
+ return;
1361
+ }
1362
+ // If we have a measurement container, try rendering TemplateResult/Nodes into it
1363
+ if (this.measureContainer) {
1364
+ const wrapper = document.createElement('div');
1365
+ wrapper.style.whiteSpace = 'nowrap';
1366
+ this.measureContainer.appendChild(wrapper);
1367
+ try {
1368
+ // Try to render Lit TemplateResult or nodes into the hidden wrapper
1369
+ // If `litRender` fails for this value, fall back to text measurement
1370
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
1371
+ // @ts-ignore
1372
+ litRender(rendered, wrapper);
1373
+ maxWidth = Math.max(maxWidth, Math.ceil(wrapper.scrollWidth));
1374
+ }
1375
+ catch (e) {
1376
+ const text = this.formatBestFitValue(column, row, index);
1377
+ maxWidth = Math.max(maxWidth, this.measureTextWidth(text));
1378
+ }
1379
+ finally {
1380
+ // clean up
1381
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
1382
+ // @ts-ignore
1383
+ litRender(undefined, wrapper);
1384
+ this.measureContainer.removeChild(wrapper);
1385
+ }
1386
+ return;
1387
+ }
1388
+ }
1389
+ const value = this.formatBestFitValue(column, row, index);
1390
+ maxWidth = Math.max(maxWidth, this.measureTextWidth(value));
1391
+ });
1392
+ const width = normalizeColumnWidth(Math.ceil(maxWidth + 24));
1393
+ this.setBestFitWidth(columnKey, sampleSize, width);
1394
+ return width;
1395
+ }
1396
+ getFilterDraft(columnKey) {
1397
+ return this.filterDrafts.get(columnKey) ?? {
1398
+ columnKey,
1399
+ operator: 'contains',
1400
+ value: ''
1401
+ };
1402
+ }
1403
+ updateFilterDraft(columnKey, changes) {
1404
+ this.filterDrafts.set(columnKey, { ...this.getFilterDraft(columnKey), ...changes });
1405
+ this.requestUpdate();
1406
+ }
1407
+ isValueFreeFilter(operator) {
1408
+ return operator === 'isEmpty' || operator === 'isNotEmpty';
1409
+ }
1410
+ resetVerticalScroll() {
1411
+ const viewport = this.renderRoot?.querySelector('.viewport');
1412
+ if (viewport) {
1413
+ viewport.scrollTop = 0;
1414
+ }
1415
+ this.pendingScrollTop = 0;
1416
+ this.previousPendingScrollTop = 0;
1417
+ this.fixedRowVirtualizer.setScrollOffset(0);
1418
+ this.variableRowVirtualizer.setScrollOffset(0);
1419
+ }
1420
+ clearQuickSearchDebounce() {
1421
+ if (this.quickSearchDebounceId !== null) {
1422
+ clearTimeout(this.quickSearchDebounceId);
1423
+ this.quickSearchDebounceId = null;
1424
+ }
1425
+ }
1426
+ applyQuickSearch(query) {
1427
+ this.engine.setQuickSearch(query);
1428
+ this.resetVerticalScroll();
1429
+ this.announceVisibleRowCount(query ? 'Quick search updated' : 'Quick search cleared');
1430
+ this.requestUpdate();
1431
+ }
1432
+ setQuickSearch(query) {
1433
+ this.clearQuickSearchDebounce();
1434
+ this.quickSearchDraft = query;
1435
+ this.applyQuickSearch(query);
1436
+ }
1437
+ clearQuickSearch() {
1438
+ this.setQuickSearch('');
1439
+ }
1440
+ getQuickSearch() {
1441
+ return this.engine.getQuickSearch();
1442
+ }
1443
+ toggleQuickSearch() {
1444
+ this.quickSearchVisible = !this.quickSearchVisible;
1445
+ this.closeHeaderMenu();
1446
+ }
1447
+ handleQuickSearchInput(query) {
1448
+ this.quickSearchDraft = query;
1449
+ this.clearQuickSearchDebounce();
1450
+ const threshold = Math.max(0, Math.floor(this.quickSearchDebounceThreshold));
1451
+ const debounceMs = Math.max(0, Math.floor(this.quickSearchDebounceMs));
1452
+ if (this.sourceRowCount < threshold || debounceMs === 0) {
1453
+ this.applyQuickSearch(query);
1454
+ return;
1455
+ }
1456
+ this.quickSearchDebounceId = setTimeout(() => {
1457
+ this.quickSearchDebounceId = null;
1458
+ this.applyQuickSearch(query);
1459
+ }, debounceMs);
1460
+ this.requestUpdate();
1461
+ }
1462
+ setFilter(filter) {
1463
+ this.engine.setFilter(filter);
1464
+ this.filterDrafts.set(filter.columnKey, { ...filter });
1465
+ this.resetVerticalScroll();
1466
+ this.announceVisibleRowCount(`${this.getColumnLabel(filter.columnKey)} filter applied`);
1467
+ this.requestUpdate();
1468
+ }
1469
+ clearFilter(columnKey) {
1470
+ this.engine.clearFilter(columnKey);
1471
+ if (columnKey) {
1472
+ this.filterDrafts.delete(columnKey);
1473
+ }
1474
+ else {
1475
+ this.filterDrafts.clear();
1476
+ }
1477
+ this.resetVerticalScroll();
1478
+ this.announceVisibleRowCount(columnKey ? `${this.getColumnLabel(columnKey)} filter cleared` : 'All filters cleared');
1479
+ this.requestUpdate();
1480
+ }
1481
+ getFilters() {
1482
+ return this.engine.getFilters();
1483
+ }
1484
+ setPage(pageIndex) {
1485
+ this.engine.setPage(pageIndex);
1486
+ this.resetVerticalScroll();
1487
+ const pagination = this.engine.getPagination();
1488
+ this.announceScreenReader(`Page ${pagination.pageCount === 0 ? 0 : pagination.pageIndex + 1} of ${pagination.pageCount}.`);
1489
+ this.requestUpdate();
1490
+ }
1491
+ setPageSize(pageSize) {
1492
+ this.engine.setPageSize(pageSize);
1493
+ this.resetVerticalScroll();
1494
+ const pagination = this.engine.getPagination();
1495
+ this.announceScreenReader(`${pagination.pageSize} rows per page. Page ${pagination.pageCount === 0 ? 0 : pagination.pageIndex + 1} of ${pagination.pageCount}.`);
1496
+ this.requestUpdate();
1497
+ }
1498
+ getPagination() {
1499
+ return this.engine.getPagination();
1500
+ }
1501
+ getTotalRowCount() {
1502
+ return this.engine.getTotalRowCount();
1503
+ }
1504
+ selectRow(rowIndex, intent) {
1505
+ this.engine.selectRow(rowIndex, intent);
1506
+ this.announceRowSelection(rowIndex);
1507
+ this.requestUpdate();
1508
+ }
1509
+ selectAllRows() {
1510
+ this.engine.selectAllRows();
1511
+ this.announceScreenReader(`All ${this.engine.getRowCount()} displayed rows selected.`);
1512
+ this.requestUpdate();
1513
+ }
1514
+ clearSelection() {
1515
+ this.engine.clearSelection();
1516
+ this.announceScreenReader('Selection cleared.');
1517
+ this.requestUpdate();
1518
+ }
1519
+ getSelection() {
1520
+ return this.engine.getSelection();
1521
+ }
1522
+ isRowSelected(rowIndex) {
1523
+ return this.engine.isRowSelected(rowIndex);
1524
+ }
1525
+ isCellSelected(rowIndex, columnKey) {
1526
+ return this.engine.isCellSelected(rowIndex, columnKey);
1527
+ }
1528
+ getSelectedCellsClipboardText() {
1529
+ return serializeSelectedCells({
1530
+ selection: this.engine.getSelection(),
1531
+ rows: this.engine.getRows(),
1532
+ columns: this.getVisibleColumns(),
1533
+ getCellValue: (column, row, rowIndex) => this.getCellValue(column, row, rowIndex),
1534
+ formatValue: (value) => this.formatCellValue(value)
1535
+ });
1536
+ }
1537
+ async copySelectedCells() {
1538
+ const text = this.getSelectedCellsClipboardText();
1539
+ if (text === null || !navigator.clipboard?.writeText) {
1540
+ return false;
1541
+ }
1542
+ try {
1543
+ await navigator.clipboard.writeText(text);
1544
+ return true;
1545
+ }
1546
+ catch {
1547
+ return false;
1548
+ }
1549
+ }
1550
+ getSelectedRowsClipboardText() {
1551
+ return serializeSelectedRows({
1552
+ selection: this.engine.getSelection(),
1553
+ rows: this.engine.getRows(),
1554
+ columns: this.getVisibleColumns(),
1555
+ getCellValue: (column, row, rowIndex) => this.getCellValue(column, row, rowIndex),
1556
+ formatValue: (value) => this.formatCellValue(value)
1557
+ });
1558
+ }
1559
+ async copySelectedRows() {
1560
+ const text = this.getSelectedRowsClipboardText();
1561
+ if (text === null || !navigator.clipboard?.writeText) {
1562
+ return false;
1563
+ }
1564
+ try {
1565
+ await navigator.clipboard.writeText(text);
1566
+ return true;
1567
+ }
1568
+ catch {
1569
+ return false;
1570
+ }
1571
+ }
1572
+ applyHeaderFilter(columnKey) {
1573
+ const filter = this.getFilterDraft(columnKey);
1574
+ this.setFilter({
1575
+ ...filter,
1576
+ value: this.isValueFreeFilter(filter.operator) ? undefined : filter.value
1577
+ });
1578
+ this.closeHeaderMenu();
1579
+ }
1580
+ clearHeaderFilter(columnKey) {
1581
+ this.clearFilter(columnKey);
1582
+ this.closeHeaderMenu();
1583
+ }
1584
+ isColumnFiltered(columnKey) {
1585
+ return this.engine.getFilters().some((filter) => filter.columnKey === columnKey);
1586
+ }
1587
+ bestFitColumn(columnKey) {
1588
+ const width = this.computeColumnBestFit(columnKey);
1589
+ if (width !== null) {
1590
+ this.setColumnWidth(columnKey, width);
1591
+ }
1592
+ return width;
1593
+ }
1594
+ bestFitAllColumns() {
1595
+ const columns = this.getVisibleColumns();
1596
+ const widths = columns.map((column) => ({
1597
+ key: column.key,
1598
+ width: this.computeColumnBestFit(column.key)
1599
+ }));
1600
+ widths.forEach((item) => {
1601
+ if (item.width !== null) {
1602
+ this.setColumnWidth(item.key, item.width);
1603
+ }
1604
+ });
1605
+ return widths;
1606
+ }
1607
+ startBodyResize(cursor) {
1608
+ this.previousBodyCursor = document.body.style.cursor;
1609
+ this.previousBodyUserSelect = document.body.style.userSelect;
1610
+ document.body.style.cursor = cursor;
1611
+ document.body.style.userSelect = 'none';
1612
+ }
1613
+ stopBodyResize() {
1614
+ document.body.style.cursor = this.previousBodyCursor;
1615
+ document.body.style.userSelect = this.previousBodyUserSelect;
1616
+ }
1617
+ startColumnReorder(event, column) {
1618
+ if (event.button !== 0 || this.columnResizeState || event.target.closest('button, input, select, .resize-handle')) {
1619
+ return;
1620
+ }
1621
+ const sourceIndex = this.getResolvedColumns().findIndex((item) => item.key === column.key);
1622
+ if (sourceIndex < 0)
1623
+ return;
1624
+ event.preventDefault();
1625
+ this.columnReorderState = {
1626
+ columnKey: column.key,
1627
+ startX: event.clientX,
1628
+ sourceIndex,
1629
+ targetIndex: sourceIndex,
1630
+ hasMoved: false
1631
+ };
1632
+ window.addEventListener('pointermove', this.handleColumnReorder);
1633
+ window.addEventListener('pointerup', this.stopColumnReorder);
1634
+ window.addEventListener('pointercancel', this.stopColumnReorder);
1635
+ }
1636
+ updateColumnDragPreview(columnKey, clientX, clientY) {
1637
+ if (!this.columnDragPreview) {
1638
+ const column = this.getResolvedColumns().find((item) => item.key === columnKey);
1639
+ const source = this.renderRoot.querySelector(`.header-cell[data-column-key="${columnKey}"]`);
1640
+ if (!column || !source)
1641
+ return;
1642
+ const bounds = source.getBoundingClientRect();
1643
+ const theme = getComputedStyle(this);
1644
+ const preview = document.createElement('div');
1645
+ preview.textContent = column.header ?? column.key;
1646
+ Object.assign(preview.style, {
1647
+ position: 'fixed',
1648
+ zIndex: '10001',
1649
+ boxSizing: 'border-box',
1650
+ width: `${bounds.width}px`,
1651
+ height: `${bounds.height}px`,
1652
+ padding: '0 12px',
1653
+ display: 'flex',
1654
+ alignItems: 'center',
1655
+ overflow: 'hidden',
1656
+ textOverflow: 'ellipsis',
1657
+ whiteSpace: 'nowrap',
1658
+ border: `1px solid ${theme.getPropertyValue('--litgrid-color-accent')}`,
1659
+ borderRadius: '4px',
1660
+ background: theme.getPropertyValue('--litgrid-color-surface'),
1661
+ color: theme.getPropertyValue('--litgrid-color-text-header'),
1662
+ font: '700 12px/1.4 system-ui, sans-serif',
1663
+ letterSpacing: '0.03em',
1664
+ textTransform: 'uppercase',
1665
+ boxShadow: theme.getPropertyValue('--litgrid-shadow-dialog'),
1666
+ opacity: '0.95',
1667
+ pointerEvents: 'none'
1668
+ });
1669
+ document.body.appendChild(preview);
1670
+ this.columnDragPreview = preview;
1671
+ }
1672
+ const preview = this.columnDragPreview;
1673
+ preview.style.left = `${clientX - preview.offsetWidth / 2}px`;
1674
+ preview.style.top = `${clientY - preview.offsetHeight / 2}px`;
1675
+ }
1676
+ removeColumnDragPreview() {
1677
+ this.columnDragPreview?.remove();
1678
+ this.columnDragPreview = null;
1679
+ }
1680
+ getGridCellId(rowIndex, columnIndex) {
1681
+ return `${this.gridId}-cell-${rowIndex}-${columnIndex}`;
1682
+ }
1683
+ getHeaderRowId() {
1684
+ return `${this.gridId}-header-row`;
1685
+ }
1686
+ getHeaderMenuId(columnKey) {
1687
+ return `${this.gridId}-header-menu-${encodeURIComponent(columnKey)}`;
1688
+ }
1689
+ getRowHeaderColumnHeaderId() {
1690
+ return `${this.gridId}-row-header-columnheader`;
1691
+ }
1692
+ getAriaColumnIndex(columnIndex) {
1693
+ return columnIndex + (this.isRowHeaderVisible() ? 2 : 1);
1694
+ }
1695
+ getHeaderSortDirection(columnKey) {
1696
+ const sort = this.engine.getSort();
1697
+ return sort.columnKey === columnKey && sort.direction
1698
+ ? sort.direction === 'asc' ? 'ascending' : 'descending'
1699
+ : 'none';
1700
+ }
1701
+ getHeaderCellClass(column) {
1702
+ const classes = ['header-cell'];
1703
+ const state = this.columnReorderState;
1704
+ if (!state || !state.hasMoved)
1705
+ return classes.join(' ');
1706
+ if (state.columnKey === column.key)
1707
+ classes.push('header-cell-reordering');
1708
+ const columnIndex = this.getResolvedColumns().findIndex((item) => item.key === column.key);
1709
+ if (columnIndex === state.targetIndex && state.sourceIndex !== state.targetIndex) {
1710
+ classes.push(state.sourceIndex < state.targetIndex ? 'header-cell-drop-after' : 'header-cell-drop-before');
1711
+ }
1712
+ return classes.join(' ');
1713
+ }
1714
+ startColumnResize(event, column) {
1715
+ event.preventDefault();
1716
+ event.stopPropagation();
1717
+ this.columnResizeState = {
1718
+ columnKey: column.key,
1719
+ startX: event.clientX,
1720
+ startWidth: this.getColumnWidth(column)
1721
+ };
1722
+ this.startBodyResize('col-resize');
1723
+ window.addEventListener('pointermove', this.handleColumnResize);
1724
+ window.addEventListener('pointerup', this.stopColumnResize);
1725
+ }
1726
+ startRowResize(event, rowIndex) {
1727
+ event.preventDefault();
1728
+ event.stopPropagation();
1729
+ this.rowResizeState = {
1730
+ rowIndex,
1731
+ startY: event.clientY,
1732
+ startHeight: this.getRowHeight(rowIndex)
1733
+ };
1734
+ this.startBodyResize('row-resize');
1735
+ window.addEventListener('pointermove', this.handleRowResize);
1736
+ window.addEventListener('pointerup', this.stopRowResize);
1737
+ }
1738
+ getCellValue(column, row, rowIndex) {
1739
+ if (column.accessor) {
1740
+ return column.accessor(row, rowIndex);
1741
+ }
1742
+ if (row && typeof row === 'object' && column.key in row) {
1743
+ return row[column.key];
1744
+ }
1745
+ return '';
1746
+ }
1747
+ formatCellValue(value) {
1748
+ if (value == null) {
1749
+ return '';
1750
+ }
1751
+ if (typeof value === 'object') {
1752
+ return JSON.stringify(value);
1753
+ }
1754
+ return String(value);
1755
+ }
1756
+ getSelectionMode() {
1757
+ return this.engine.getSelection().mode;
1758
+ }
1759
+ isRowSelectionMode() {
1760
+ const mode = this.getSelectionMode();
1761
+ return mode === 'row' || mode === 'multi-row';
1762
+ }
1763
+ isCellSelectionMode() {
1764
+ const mode = this.getSelectionMode();
1765
+ return mode === 'cell' || mode === 'multi-cell';
1766
+ }
1767
+ getRowClass(rowIndex) {
1768
+ const classes = ['row'];
1769
+ if (this.isRowSelectionMode()) {
1770
+ classes.push('row-selectable');
1771
+ }
1772
+ if (this.engine.isRowSelected(rowIndex)) {
1773
+ classes.push('row-selected');
1774
+ }
1775
+ return classes.join(' ');
1776
+ }
1777
+ getCellClass(rowIndex, columnKey) {
1778
+ const classes = ['cell'];
1779
+ if (this.isCellSelectionMode()) {
1780
+ classes.push('cell-selectable');
1781
+ }
1782
+ if (this.engine.isCellSelected(rowIndex, columnKey)) {
1783
+ classes.push('cell-selected');
1784
+ }
1785
+ if (this.activeCell?.rowIndex === rowIndex && this.getVisibleColumns()[this.activeCell.columnIndex]?.key === columnKey) {
1786
+ classes.push('cell-active');
1787
+ }
1788
+ return classes.join(' ');
1789
+ }
1790
+ setActiveCell(rowIndex, columnIndex, shouldFocus = false) {
1791
+ const rowCount = this.engine.getRowCount();
1792
+ const columnCount = this.getVisibleColumns().length;
1793
+ if (rowCount === 0 || columnCount === 0) {
1794
+ this.activeCell = null;
1795
+ return;
1796
+ }
1797
+ this.activeCell = {
1798
+ rowIndex: Math.max(0, Math.min(rowIndex, rowCount - 1)),
1799
+ columnIndex: Math.max(0, Math.min(columnIndex, columnCount - 1))
1800
+ };
1801
+ this.shouldFocusActiveCell = shouldFocus;
1802
+ this.announceActiveCell();
1803
+ }
1804
+ focusActiveCell() {
1805
+ if (!this.shouldFocusActiveCell || !this.activeCell) {
1806
+ return;
1807
+ }
1808
+ const cell = this.renderRoot.querySelector(`.cell[data-row-index="${this.activeCell.rowIndex}"][data-column-index="${this.activeCell.columnIndex}"]`);
1809
+ if (cell) {
1810
+ this.shouldFocusActiveCell = false;
1811
+ cell.focus();
1812
+ }
1813
+ }
1814
+ getRowIndexAtOffset(offset) {
1815
+ const rowCount = this.engine.getRowCount();
1816
+ if (rowCount === 0) {
1817
+ return 0;
1818
+ }
1819
+ if (!this.hasCustomRowHeights()) {
1820
+ return Math.max(0, Math.min(Math.floor(offset / this.rowHeight), rowCount - 1));
1821
+ }
1822
+ const rowSizes = this.getRowSizes();
1823
+ const rowOffsets = new Array(rowCount + 1);
1824
+ rowOffsets[0] = 0;
1825
+ for (let index = 0; index < rowCount; index += 1) {
1826
+ rowOffsets[index + 1] = rowOffsets[index] + rowSizes[index];
1827
+ }
1828
+ const targetOffset = Math.max(0, Math.min(offset, rowOffsets[rowCount] - 1));
1829
+ let low = 0;
1830
+ let high = rowCount - 1;
1831
+ while (low <= high) {
1832
+ const middle = Math.floor((low + high) / 2);
1833
+ const middleOffset = rowOffsets[middle];
1834
+ const middleBottom = rowOffsets[middle + 1];
1835
+ if (targetOffset < middleOffset) {
1836
+ high = middle - 1;
1837
+ }
1838
+ else if (targetOffset >= middleBottom) {
1839
+ low = middle + 1;
1840
+ }
1841
+ else {
1842
+ return middle;
1843
+ }
1844
+ }
1845
+ return Math.max(0, Math.min(low, rowCount - 1));
1846
+ }
1847
+ getPageNavigationRowIndex(rowIndex, direction) {
1848
+ const rowTop = this.hasCustomRowHeights()
1849
+ ? this.getRowSizes().slice(0, rowIndex).reduce((total, height) => total + height, 0)
1850
+ : rowIndex * this.rowHeight;
1851
+ return this.getRowIndexAtOffset(rowTop + direction * this.height);
1852
+ }
1853
+ getTabNavigationCell(direction, rowCount, columnCount) {
1854
+ if (!this.activeCell) {
1855
+ return direction === 1
1856
+ ? { rowIndex: 0, columnIndex: 0 }
1857
+ : { rowIndex: rowCount - 1, columnIndex: columnCount - 1 };
1858
+ }
1859
+ const nextIndex = this.activeCell.rowIndex * columnCount + this.activeCell.columnIndex + direction;
1860
+ if (nextIndex < 0 || nextIndex >= rowCount * columnCount) {
1861
+ return null;
1862
+ }
1863
+ return {
1864
+ rowIndex: Math.floor(nextIndex / columnCount),
1865
+ columnIndex: nextIndex % columnCount
1866
+ };
1867
+ }
1868
+ scrollActiveCellIntoView() {
1869
+ const viewport = this.viewportElement ?? this.renderRoot.querySelector('.viewport');
1870
+ const activeCell = this.activeCell;
1871
+ if (!viewport || !activeCell) {
1872
+ return;
1873
+ }
1874
+ const rowTop = this.hasCustomRowHeights()
1875
+ ? this.getRowSizes().slice(0, activeCell.rowIndex).reduce((total, height) => total + height, 0)
1876
+ : activeCell.rowIndex * this.rowHeight;
1877
+ const rowBottom = rowTop + this.getRowHeight(activeCell.rowIndex);
1878
+ const displayTotal = this.lastDisplayTotal || this.lastTotalSize;
1879
+ const availableVirtualSize = Math.max(0, this.lastTotalSize - this.height);
1880
+ const availableDisplaySize = Math.max(0, displayTotal - this.height);
1881
+ const currentVirtualOffset = availableDisplaySize > 0
1882
+ ? (viewport.scrollTop * availableVirtualSize) / availableDisplaySize
1883
+ : 0;
1884
+ let nextVirtualOffset = currentVirtualOffset;
1885
+ if (rowTop < currentVirtualOffset) {
1886
+ nextVirtualOffset = rowTop;
1887
+ }
1888
+ else if (rowBottom > currentVirtualOffset + this.height) {
1889
+ nextVirtualOffset = rowBottom - this.height;
1890
+ }
1891
+ if (nextVirtualOffset !== currentVirtualOffset) {
1892
+ viewport.scrollTop = availableVirtualSize > 0
1893
+ ? (Math.max(0, Math.min(nextVirtualOffset, availableVirtualSize)) * availableDisplaySize) / availableVirtualSize
1894
+ : 0;
1895
+ }
1896
+ const columns = this.getVisibleColumns();
1897
+ const columnLeft = columns
1898
+ .slice(0, activeCell.columnIndex)
1899
+ .reduce((total, column) => total + this.getColumnWidth(column), 0);
1900
+ const columnRight = columnLeft + this.getColumnWidth(columns[activeCell.columnIndex]);
1901
+ const bodyViewportWidth = Math.max(0, viewport.clientWidth - this.getRowHeaderWidth());
1902
+ if (columnLeft < viewport.scrollLeft) {
1903
+ viewport.scrollLeft = columnLeft;
1904
+ }
1905
+ else if (columnRight > viewport.scrollLeft + bodyViewportWidth) {
1906
+ viewport.scrollLeft = Math.max(0, columnRight - bodyViewportWidth);
1907
+ }
1908
+ }
1909
+ handleKeyDown(event) {
1910
+ const target = event.target;
1911
+ if (target instanceof HTMLInputElement || target instanceof HTMLSelectElement || target instanceof HTMLButtonElement) {
1912
+ return;
1913
+ }
1914
+ const rowCount = this.engine.getRowCount();
1915
+ const columnCount = this.getVisibleColumns().length;
1916
+ if (rowCount === 0 || columnCount === 0) {
1917
+ return;
1918
+ }
1919
+ if ((event.key === 'Enter' || event.key === ' ') && !event.ctrlKey && !event.metaKey && !event.altKey) {
1920
+ const activeCell = this.activeCell ?? { rowIndex: 0, columnIndex: 0 };
1921
+ if (this.isCellSelectionMode()) {
1922
+ const column = this.getVisibleColumns()[activeCell.columnIndex];
1923
+ if (column) {
1924
+ event.preventDefault();
1925
+ this.engine.selectCell(activeCell.rowIndex, column.key);
1926
+ this.announceCellSelection(activeCell.rowIndex, column.key);
1927
+ this.requestUpdate();
1928
+ }
1929
+ }
1930
+ else if (this.isRowSelectionMode()) {
1931
+ event.preventDefault();
1932
+ this.engine.selectRow(activeCell.rowIndex, 'replace');
1933
+ this.announceRowSelection(activeCell.rowIndex);
1934
+ this.requestUpdate();
1935
+ }
1936
+ return;
1937
+ }
1938
+ if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === 'c') {
1939
+ const cellText = this.getSelectedCellsClipboardText();
1940
+ const rowText = cellText === null ? this.getSelectedRowsClipboardText() : null;
1941
+ if (cellText !== null || rowText !== null) {
1942
+ event.preventDefault();
1943
+ void (cellText !== null ? this.copySelectedCells() : this.copySelectedRows());
1944
+ }
1945
+ return;
1946
+ }
1947
+ if (event.key === 'Tab') {
1948
+ const nextCell = this.getTabNavigationCell(event.shiftKey ? -1 : 1, rowCount, columnCount);
1949
+ if (!nextCell) {
1950
+ return;
1951
+ }
1952
+ event.preventDefault();
1953
+ this.setActiveCell(nextCell.rowIndex, nextCell.columnIndex, true);
1954
+ this.scrollActiveCellIntoView();
1955
+ this.requestUpdate();
1956
+ return;
1957
+ }
1958
+ const activeCell = this.activeCell ?? { rowIndex: 0, columnIndex: 0 };
1959
+ const deltas = {
1960
+ ArrowUp: { rowIndex: -1, columnIndex: 0 },
1961
+ ArrowDown: { rowIndex: 1, columnIndex: 0 },
1962
+ ArrowLeft: { rowIndex: 0, columnIndex: -1 },
1963
+ ArrowRight: { rowIndex: 0, columnIndex: 1 }
1964
+ };
1965
+ const delta = deltas[event.key];
1966
+ const isGridBoundaryNavigation = event.ctrlKey || event.metaKey;
1967
+ let nextCell = null;
1968
+ if (delta) {
1969
+ nextCell = {
1970
+ rowIndex: activeCell.rowIndex + delta.rowIndex,
1971
+ columnIndex: activeCell.columnIndex + delta.columnIndex
1972
+ };
1973
+ }
1974
+ else if (event.key === 'Home') {
1975
+ nextCell = {
1976
+ rowIndex: isGridBoundaryNavigation ? 0 : activeCell.rowIndex,
1977
+ columnIndex: 0
1978
+ };
1979
+ }
1980
+ else if (event.key === 'End') {
1981
+ nextCell = {
1982
+ rowIndex: isGridBoundaryNavigation ? rowCount - 1 : activeCell.rowIndex,
1983
+ columnIndex: columnCount - 1
1984
+ };
1985
+ }
1986
+ else if (event.key === 'PageUp' || event.key === 'PageDown') {
1987
+ nextCell = {
1988
+ rowIndex: this.getPageNavigationRowIndex(activeCell.rowIndex, event.key === 'PageUp' ? -1 : 1),
1989
+ columnIndex: activeCell.columnIndex
1990
+ };
1991
+ }
1992
+ if (!nextCell) {
1993
+ return;
1994
+ }
1995
+ event.preventDefault();
1996
+ this.setActiveCell(nextCell.rowIndex, nextCell.columnIndex, true);
1997
+ this.scrollActiveCellIntoView();
1998
+ this.requestUpdate();
1999
+ }
2000
+ getRowSelectionIntent(event) {
2001
+ if (event.shiftKey) {
2002
+ return 'range';
2003
+ }
2004
+ if (event.ctrlKey || event.metaKey) {
2005
+ return 'toggle';
2006
+ }
2007
+ return 'replace';
2008
+ }
2009
+ handleRowClick(event, rowIndex) {
2010
+ if (!this.isRowSelectionMode()) {
2011
+ return;
2012
+ }
2013
+ this.engine.selectRow(rowIndex, this.getRowSelectionIntent(event));
2014
+ this.announceRowSelection(rowIndex);
2015
+ this.requestUpdate();
2016
+ }
2017
+ handleRowMouseDown(event) {
2018
+ if (this.isRowSelectionMode() && event.shiftKey) {
2019
+ event.preventDefault();
2020
+ }
2021
+ }
2022
+ handleCellClick(event, rowIndex, column) {
2023
+ this.setActiveCell(rowIndex, this.getVisibleColumns().findIndex((item) => item.key === column.key));
2024
+ if (!this.isCellSelectionMode()) {
2025
+ this.requestUpdate();
2026
+ return;
2027
+ }
2028
+ event.stopPropagation();
2029
+ this.engine.selectCell(rowIndex, column.key);
2030
+ this.announceCellSelection(rowIndex, column.key);
2031
+ this.requestUpdate();
2032
+ }
2033
+ renderCell(column, row, rowIndex) {
2034
+ const value = this.getCellValue(column, row, rowIndex);
2035
+ const rendered = column.render
2036
+ ? column.render(value, row, rowIndex)
2037
+ : this.formatCellValue(value);
2038
+ const content = typeof rendered === 'string' || typeof rendered === 'number'
2039
+ ? html `${rendered}`
2040
+ : rendered;
2041
+ return html `
2042
+ <div
2043
+ id=${this.getGridCellId(rowIndex, this.getVisibleColumns().findIndex((item) => item.key === column.key))}
2044
+ class=${this.getCellClass(rowIndex, column.key)}
2045
+ role="gridcell"
2046
+ aria-colindex=${this.getAriaColumnIndex(this.getVisibleColumns().findIndex((item) => item.key === column.key))}
2047
+ aria-selected=${this.isCellSelectionMode() ? String(this.engine.isCellSelected(rowIndex, column.key)) : undefined}
2048
+ data-row-index=${rowIndex}
2049
+ data-column-index=${this.getVisibleColumns().findIndex((item) => item.key === column.key)}
2050
+ tabindex=${this.activeCell?.rowIndex === rowIndex && this.getVisibleColumns()[this.activeCell.columnIndex]?.key === column.key ? '0' : '-1'}
2051
+ @click=${(event) => this.handleCellClick(event, rowIndex, column)}
2052
+ >
2053
+
2054
+ ${cache(content)}
2055
+ </div>
2056
+ `;
2057
+ }
2058
+ renderRowHeader(rowIndex) {
2059
+ if (!this.isRowHeaderVisible()) {
2060
+ return '';
2061
+ }
2062
+ return html `
2063
+ <div class="row-header-cell" role="rowheader" aria-colindex="1">
2064
+ ${this.hasCheckboxSelection()
2065
+ ? html `<input
2066
+ class="selection-checkbox"
2067
+ type="checkbox"
2068
+ aria-label=${`Select row ${rowIndex + 1}`}
2069
+ .checked=${this.engine.isRowSelected(rowIndex)}
2070
+ @click=${(event) => event.stopPropagation()}
2071
+ @change=${() => this.handleRowCheckboxChange(rowIndex)}
2072
+ />`
2073
+ : html `<span>${rowIndex + 1}</span>`}
2074
+ <span
2075
+ class="row-resize-handle"
2076
+ aria-hidden="true"
2077
+ @pointerdown=${(event) => this.startRowResize(event, rowIndex)}
2078
+ ></span>
2079
+ </div>
2080
+ `;
2081
+ }
2082
+ set data(value) {
2083
+ this.activeCell = null;
2084
+ this.clearQuickSearchDebounce();
2085
+ this.sourceRowCount = Array.isArray(value) ? value.length : 0;
2086
+ this.engine.setData(value ?? []);
2087
+ this.quickSearchDraft = this.engine.getQuickSearch();
2088
+ this.clearBestFitWidths();
2089
+ this.announceScreenReader(this.engine.getRowCount() === 0 ? 'Grid is empty.' : `${this.engine.getRowCount()} ${this.engine.getRowCount() === 1 ? 'row' : 'rows'} loaded.`);
2090
+ this.requestUpdate();
2091
+ }
2092
+ handleRowCheckboxChange(rowIndex) {
2093
+ this.engine.selectRow(rowIndex, 'toggle');
2094
+ this.announceRowSelection(rowIndex);
2095
+ this.requestUpdate();
2096
+ }
2097
+ getDisplayedSelectedRowCount() {
2098
+ const rowCount = this.engine.getRowCount();
2099
+ let selectedCount = 0;
2100
+ for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) {
2101
+ if (this.engine.isRowSelected(rowIndex)) {
2102
+ selectedCount += 1;
2103
+ }
2104
+ }
2105
+ return selectedCount;
2106
+ }
2107
+ areAllDisplayedRowsSelected() {
2108
+ const rowCount = this.engine.getRowCount();
2109
+ return rowCount > 0 && this.getDisplayedSelectedRowCount() === rowCount;
2110
+ }
2111
+ hasPartiallySelectedDisplayedRows() {
2112
+ const selectedCount = this.getDisplayedSelectedRowCount();
2113
+ return selectedCount > 0 && selectedCount < this.engine.getRowCount();
2114
+ }
2115
+ handleSelectAllRows() {
2116
+ if (this.areAllDisplayedRowsSelected()) {
2117
+ this.engine.clearSelection();
2118
+ this.announceScreenReader('All displayed rows deselected.');
2119
+ }
2120
+ else {
2121
+ this.engine.selectAllRows();
2122
+ this.announceScreenReader(`All ${this.engine.getRowCount()} displayed rows selected.`);
2123
+ }
2124
+ this.requestUpdate();
2125
+ }
2126
+ set columns(value) {
2127
+ this.activeCell = null;
2128
+ this.sourceColumnDefs = Array.isArray(value) ? [...value] : [];
2129
+ this.columnDefs = [...this.sourceColumnDefs];
2130
+ this.hiddenColumnKeys = new Set(this.sourceColumnDefs.filter((column) => column.hidden).map((column) => column.key));
2131
+ this.clearBestFitWidths();
2132
+ this.restorePersistedColumnState();
2133
+ this.requestUpdate();
2134
+ }
2135
+ get columnStateStorageKey() {
2136
+ return this._columnStateStorageKey;
2137
+ }
2138
+ set columnStateStorageKey(value) {
2139
+ const nextKey = typeof value === 'string' && value.trim() ? value : null;
2140
+ if (this._columnStateStorageKey === nextKey)
2141
+ return;
2142
+ this._columnStateStorageKey = nextKey;
2143
+ this.restorePersistedColumnState();
2144
+ }
2145
+ set config(value) {
2146
+ this.engine.setConfig(value ?? {});
2147
+ this.requestUpdate();
2148
+ }
2149
+ set viewportHeight(value) {
2150
+ this.updateVirtualizer({
2151
+ viewportHeight: Number(value)
2152
+ });
2153
+ this.requestUpdate();
2154
+ }
2155
+ set overscanCount(value) {
2156
+ this.updateVirtualizer({
2157
+ overscan: Number(value)
2158
+ });
2159
+ this.requestUpdate();
2160
+ }
2161
+ set columnOverscanCount(value) {
2162
+ this.columnOverscan = Number(value);
2163
+ this.requestUpdate();
2164
+ }
2165
+ set virtualRowHeight(value) {
2166
+ this.rowHeight = Number(value);
2167
+ this.requestUpdate();
2168
+ }
2169
+ render() {
2170
+ const count = this.engine.getRowCount();
2171
+ const pagination = this.engine.getPagination();
2172
+ const pageSizeOptions = [25, 50, 100].includes(pagination.pageSize)
2173
+ ? [25, 50, 100]
2174
+ : [pagination.pageSize, 25, 50, 100].sort((left, right) => left - right);
2175
+ const useVariableRows = this.hasCustomRowHeights();
2176
+ if (useVariableRows) {
2177
+ const rowSizes = this.getRowSizes();
2178
+ this.variableRowVirtualizer.setOptions({
2179
+ sizes: rowSizes,
2180
+ viewportSize: this.height,
2181
+ overscan: this.overscan
2182
+ });
2183
+ }
2184
+ else {
2185
+ this.fixedRowVirtualizer.setOptions({
2186
+ rowHeight: this.rowHeight,
2187
+ viewportHeight: this.height,
2188
+ overscan: this.overscan
2189
+ });
2190
+ }
2191
+ const range = useVariableRows
2192
+ ? this.variableRowVirtualizer.getState()
2193
+ : this.fixedRowVirtualizer.getState(count);
2194
+ const totalSize = range.totalSize;
2195
+ // Intended display size (what we *want* the spacer to be)
2196
+ const intendedDisplayTotal = Math.min(totalSize, this.maxScrollableHeight);
2197
+ // A transformed row-count change invalidates any prior measured spacer
2198
+ // height. Render the new intended height first; `updated()` will replace
2199
+ // it only if the browser clamps the spacer in the DOM.
2200
+ const totalSizeChanged = totalSize !== this.lastTotalSize;
2201
+ // If we've measured a clamped spacer height in `updated()`, prefer
2202
+ // the measured value when it's smaller than the intended display size.
2203
+ const displayTotal = !totalSizeChanged && this.lastDisplayTotal > 0 && this.lastDisplayTotal < intendedDisplayTotal
2204
+ ? this.lastDisplayTotal
2205
+ : intendedDisplayTotal;
2206
+ // Compute displayed offset for the content by scaling the virtualizer's
2207
+ // `offsetTop` into display coordinates. This keeps the translated content
2208
+ // aligned with the virtualizer even when the browser clamps the spacer.
2209
+ const virtualOffsetTop = range.offsetTop;
2210
+ const renderedSize = range.visibleSize;
2211
+ const bottomPadding = range.bottomPadding;
2212
+ const { displayBottomPadding, displayOffsetTop, displayScale } = calculateDisplayLayout({
2213
+ totalSize,
2214
+ displaySize: displayTotal,
2215
+ virtualOffsetTop,
2216
+ visibleSize: renderedSize,
2217
+ bottomPadding
2218
+ });
2219
+ // If the mapped display offset differs substantially from the user's
2220
+ // actual scrollTop, log for diagnostics (helps catch mapping/race issues).
2221
+ if (this.lastDisplayTotal > 0) {
2222
+ const diff = Math.abs(displayOffsetTop - this.pendingScrollTop);
2223
+ if (diff > Math.max(100, this.rowHeight)) {
2224
+ // eslint-disable-next-line no-console
2225
+ console.log('[yc-grid] offset-mismatch', { displayOffsetTop, pendingScrollTop: this.pendingScrollTop, diff });
2226
+ }
2227
+ }
2228
+ // Keep scroll mapping in sync with the newly rendered spacer until a
2229
+ // browser measurement provides a lower, clamped height.
2230
+ this.lastTotalSize = totalSize;
2231
+ if (totalSizeChanged || !this.lastDisplayTotal) {
2232
+ this.lastDisplayTotal = displayTotal;
2233
+ }
2234
+ if (totalSize > 6000000) {
2235
+ // eslint-disable-next-line no-console
2236
+ console.log('[yc-grid] virtualizer state', {
2237
+ count,
2238
+ totalSize,
2239
+ displayTotal,
2240
+ displayScale,
2241
+ offsetTop: range.offsetTop,
2242
+ bottomPadding,
2243
+ startIndex: range.startIndex,
2244
+ endIndex: range.endIndex
2245
+ });
2246
+ }
2247
+ // Ensure start/end indices are within valid bounds to avoid empty renders
2248
+ const safeStartIndex = Math.max(0, Math.min(range.startIndex, Math.max(0, count)));
2249
+ const safeEndIndex = Math.max(safeStartIndex, Math.min(range.endIndex, count));
2250
+ let rows = this.engine.getVisibleRows(safeStartIndex, safeEndIndex);
2251
+ if (rows.length === 0 && count > 0) {
2252
+ // If we ended up with no rows (edge-case due to rounding/clamping),
2253
+ // request the last page of rows as a fallback so the grid never appears empty.
2254
+ const visibleCountFallback = Math.max(1, Math.ceil(this.height / Math.max(1, this.rowHeight)));
2255
+ const fallbackStart = Math.max(0, count - visibleCountFallback);
2256
+ const fallbackEnd = count;
2257
+ // eslint-disable-next-line no-console
2258
+ console.log('[yc-grid] empty-render-fallback', { safeStartIndex, safeEndIndex, fallbackStart, fallbackEnd });
2259
+ rows = this.engine.getVisibleRows(fallbackStart, fallbackEnd);
2260
+ }
2261
+ const columns = this.getVisibleColumns();
2262
+ const columnWidths = columns.map((column) => this.getColumnWidth(column));
2263
+ const rowHeaderWidth = this.getRowHeaderWidth();
2264
+ this.columnVirtualizer.setOptions({
2265
+ sizes: columnWidths,
2266
+ viewportSize: Math.max(0, this.viewportWidth - rowHeaderWidth),
2267
+ overscan: this.columnOverscan
2268
+ });
2269
+ const columnRange = this.columnVirtualizer.getState();
2270
+ const visibleColumns = columns.slice(columnRange.startIndex, columnRange.endIndex);
2271
+ const visibleColumnWidths = columnWidths.slice(columnRange.startIndex, columnRange.endIndex);
2272
+ const isFirstVisibleColumn = (column, index) => index === 0;
2273
+ const gridTemplate = [
2274
+ ...(this.isRowHeaderVisible() ? [`${rowHeaderWidth}px`] : []),
2275
+ `${columnRange.leftPadding}px`,
2276
+ ...visibleColumnWidths.map((width) => `${width}px`),
2277
+ `${columnRange.rightPadding}px`
2278
+ ].join(' ');
2279
+ const columnGridTemplate = [
2280
+ `${columnRange.leftPadding}px`,
2281
+ ...visibleColumnWidths.map((width) => `${width}px`),
2282
+ `${columnRange.rightPadding}px`
2283
+ ].join(' ');
2284
+ const totalColumnWidth = columnRange.totalSize;
2285
+ const totalRowWidth = totalColumnWidth + rowHeaderWidth;
2286
+ const headerGridTemplate = this.isRowHeaderVisible()
2287
+ ? `${rowHeaderWidth}px minmax(0, 1fr) ${this.scrollbarWidth}px`
2288
+ : `minmax(0, 1fr) ${this.scrollbarWidth}px`;
2289
+ return html `
2290
+ <div
2291
+ class="shell"
2292
+ style=${`--virtual-grid-template: ${gridTemplate}; --total-column-width: ${totalColumnWidth}px; --total-row-width: ${totalRowWidth}px; --row-header-width: ${rowHeaderWidth}px; --header-grid-template: ${headerGridTemplate}; --scrollbar-width: ${this.scrollbarWidth}px;`}
2293
+ @keydown=${this.handleShellKeyDown}
2294
+ >
2295
+ <p id=${`${this.gridId}-description`} class="screen-reader-only">${this.getScreenReaderDescription()}</p>
2296
+ ${this.screenReaderAnnouncements
2297
+ ? html `<div class="screen-reader-only" role="status" aria-live="polite" aria-atomic="true">${this.screenReaderMessage}</div>`
2298
+ : ''}
2299
+ ${this.quickSearchVisible
2300
+ ? html `
2301
+ <div class="quick-search" role="search">
2302
+ <input
2303
+ aria-label="Quick search"
2304
+ placeholder="Search all columns"
2305
+ .value=${this.quickSearchDraft}
2306
+ @input=${(event) => this.handleQuickSearchInput(event.target.value)}
2307
+ />
2308
+ <button type="button" @click=${() => this.clearQuickSearch()}>Clear</button>
2309
+ </div>
2310
+ `
2311
+ : ''}
2312
+ ${this.columnChooserOpen
2313
+ ? html `<div
2314
+ class="column-chooser-dialog"
2315
+ role="dialog"
2316
+ aria-label="Columns"
2317
+ style=${this.columnChooserPosition
2318
+ ? `left: ${this.columnChooserPosition.left}px; top: ${this.columnChooserPosition.top}px; right: auto;`
2319
+ : ''}
2320
+ >
2321
+ <button class="column-chooser-close" type="button" aria-label="Close columns" @click=${() => this.closeColumnChooser(true)}>×</button>
2322
+ ${this.getResolvedColumns().map((column) => html `
2323
+ <label>
2324
+ <input
2325
+ type="checkbox"
2326
+ .checked=${this.isColumnVisible(column.key)}
2327
+ @change=${(event) => this.setColumnVisible(column.key, event.target.checked)}
2328
+ />
2329
+ ${column.header ?? column.key}
2330
+ </label>
2331
+ `)}
2332
+ </div>`
2333
+ : ''}
2334
+ <div class="header-shell">
2335
+ ${this.isRowHeaderVisible()
2336
+ ? html `
2337
+ <div id=${this.getRowHeaderColumnHeaderId()} class="row-header-cell header-row-header" role="columnheader" aria-colindex="1">
2338
+ ${this.hasCheckboxSelection()
2339
+ ? html `<input
2340
+ class="selection-checkbox"
2341
+ type="checkbox"
2342
+ aria-label="Select all displayed rows"
2343
+ .checked=${this.areAllDisplayedRowsSelected()}
2344
+ .indeterminate=${this.hasPartiallySelectedDisplayedRows()}
2345
+ ?disabled=${this.engine.getRowCount() === 0}
2346
+ @change=${() => this.handleSelectAllRows()}
2347
+ />`
2348
+ : html `<span>#</span>`}
2349
+ </div>
2350
+ `
2351
+ : ''}
2352
+ <div class="header-clip">
2353
+ <div
2354
+ id=${this.getHeaderRowId()}
2355
+ class="header-column-row"
2356
+ role="row"
2357
+ aria-rowindex="1"
2358
+ aria-owns=${this.isRowHeaderVisible() ? this.getRowHeaderColumnHeaderId() : undefined}
2359
+ style=${`grid-template-columns: ${columnGridTemplate}; transform: translateX(${-this.horizontalScrollLeft}px);`}
2360
+ >
2361
+ <div class="column-spacer" aria-hidden="true"></div>
2362
+ ${visibleColumns.map((column, columnIndex) => html `
2363
+ <div
2364
+ class=${this.getHeaderCellClass(column)}
2365
+ role="columnheader"
2366
+ aria-colindex=${this.getAriaColumnIndex(columnRange.startIndex + columnIndex)}
2367
+ aria-sort=${this.getHeaderSortDirection(column.key)}
2368
+ data-column-key=${column.key}
2369
+ @pointerdown=${(event) => this.startColumnReorder(event, column)}
2370
+ @click=${() => this.closeHeaderMenu()}
2371
+ >
2372
+ <span class="header-label">${column.header ?? column.key}</span>
2373
+ ${this.isColumnFiltered(column.key)
2374
+ ? html `<span class="header-filter-indicator" aria-label="Filter active">●</span>`
2375
+ : ''}
2376
+ <div class="header-actions" @click=${(event) => event.stopPropagation()}>
2377
+ <button
2378
+ class="header-action-button"
2379
+ @click=${(event) => this.toggleHeaderMenu(column.key, event)}
2380
+ aria-label="Show column actions"
2381
+ aria-haspopup="dialog"
2382
+ aria-controls=${this.getHeaderMenuId(column.key)}
2383
+ aria-expanded=${String(this.activeHeaderMenu === column.key)}
2384
+ type="button"
2385
+ >⋯</button>
2386
+ ${this.activeHeaderMenu === column.key
2387
+ ? this.columnChooserOpen
2388
+ ? html `
2389
+ <div id=${this.getHeaderMenuId(column.key)} class="header-menu column-chooser-menu ${columnIndex === 0 ? 'header-menu-right' : ''}" role="dialog" aria-label="Columns" @click=${(event) => event.stopPropagation()}>
2390
+ <button class="column-chooser-close" type="button" aria-label="Close columns" @click=${() => this.closeColumnChooser(true)}>×</button>
2391
+ ${this.getResolvedColumns().map((column) => html `
2392
+ <label>
2393
+ <input
2394
+ type="checkbox"
2395
+ .checked=${this.isColumnVisible(column.key)}
2396
+ @change=${(event) => this.setColumnVisible(column.key, event.target.checked)}
2397
+ />
2398
+ ${column.header ?? column.key}
2399
+ </label>
2400
+ `)}
2401
+ </div>
2402
+ `
2403
+ : html `
2404
+ <div id=${this.getHeaderMenuId(column.key)} class="header-menu ${columnIndex === 0 ? 'header-menu-right' : ''}" role="dialog" aria-label=${`${column.header ?? column.key} column actions`} @click=${(event) => event.stopPropagation()}>
2405
+ <button type="button" @click=${() => this.toggleQuickSearch()}>
2406
+ ${this.quickSearchVisible ? 'Hide Quick Search' : 'Show Quick Search'}
2407
+ </button>
2408
+ <hr class="header-menu-divider" />
2409
+ <button type="button" @click=${() => this.handleSortAscending(column.key)}>
2410
+ Sort Asc
2411
+ </button>
2412
+ <button type="button" @click=${() => this.handleSortDescending(column.key)}>
2413
+ Sort Desc
2414
+ </button>
2415
+ <button type="button" @click=${() => this.handleClearSort()}>
2416
+ Clear Sort
2417
+ </button>
2418
+ <button type="button" @click=${(event) => this.toggleColumnChooser(event)}>
2419
+ Columns
2420
+ </button>
2421
+ ${(() => {
2422
+ const draft = this.getFilterDraft(column.key);
2423
+ const valueFree = this.isValueFreeFilter(draft.operator);
2424
+ return html `
2425
+ <div class="header-filter">
2426
+ <select
2427
+ aria-label="Filter operator"
2428
+ @change=${(event) => this.updateFilterDraft(column.key, { operator: event.target.value })}
2429
+ >
2430
+ ${FILTER_OPERATORS.map((operator) => html `
2431
+ <option
2432
+ value=${operator.value}
2433
+ ?selected=${operator.value === draft.operator}
2434
+ >${operator.label}</option>
2435
+ `)}
2436
+ </select>
2437
+ ${valueFree
2438
+ ? ''
2439
+ : html `<input
2440
+ aria-label="Filter value"
2441
+ .value=${String(draft.value ?? '')}
2442
+ @input=${(event) => this.updateFilterDraft(column.key, { value: event.target.value })}
2443
+ />`}
2444
+ <div class="header-filter-actions">
2445
+ <button type="button" @click=${() => this.applyHeaderFilter(column.key)}>
2446
+ Apply Filter
2447
+ </button>
2448
+ <button type="button" @click=${() => this.clearHeaderFilter(column.key)}>
2449
+ Clear Filter
2450
+ </button>
2451
+ </div>
2452
+ </div>
2453
+ `;
2454
+ })()}
2455
+ <hr class="header-menu-divider" />
2456
+ <button type="button" @click=${() => this.handleBestFit(column.key)}>
2457
+ Best Fit
2458
+ </button>
2459
+ <button type="button" @click=${() => this.handleBestFitAllColumns()}>
2460
+ Best Fit All Columns
2461
+ </button>
2462
+ </div>
2463
+ `
2464
+ : ''}
2465
+ </div>
2466
+ <span
2467
+ class="resize-handle"
2468
+ aria-hidden="true"
2469
+ @pointerdown=${(event) => this.startColumnResize(event, column)}
2470
+ ></span>
2471
+ </div>
2472
+ `)}
2473
+ <div class="column-spacer" aria-hidden="true"></div>
2474
+ </div>
2475
+ </div>
2476
+ <div class="header-gutter"></div>
2477
+ </div>
2478
+ ${visibleColumns.length === 0
2479
+ ? html `<div class="column-chooser-empty">
2480
+ <button type="button" @click=${() => this.toggleColumnChooser()}>Columns</button>
2481
+ </div>`
2482
+ : ''}
2483
+ <div
2484
+ class="viewport"
2485
+ role="grid"
2486
+ aria-label=${this.ariaLabel || 'Data grid'}
2487
+ aria-rowcount=${count + 1}
2488
+ aria-colcount=${columns.length + (this.isRowHeaderVisible() ? 1 : 0)}
2489
+ aria-activedescendant=${this.activeCell ? this.getGridCellId(this.activeCell.rowIndex, this.activeCell.columnIndex) : undefined}
2490
+ aria-describedby=${`${this.gridId}-description`}
2491
+ aria-owns=${this.getHeaderRowId()}
2492
+ style=${`--grid-height: ${this.height}px;`}
2493
+ tabindex="0"
2494
+ @scroll=${this.handleScroll}
2495
+ @focus=${this.handleViewportFocus}
2496
+
2497
+ @keydown=${this.handleKeyDown}
2498
+ @click=${() => this.closeHeaderMenu()}
2499
+
2500
+ >
2501
+ <div style=${`height: ${displayTotal}px;`}>
2502
+ <div
2503
+ class="content"
2504
+ style=${`--offset-top: ${displayOffsetTop}px; padding-bottom: ${displayBottomPadding}px;`}
2505
+ >
2506
+ ${rows.map((row, index) => {
2507
+ const rowIndex = range.startIndex + index;
2508
+ const height = this.getRowHeight(rowIndex);
2509
+ return html `
2510
+ <div
2511
+ class=${this.getRowClass(rowIndex)}
2512
+ role="row"
2513
+ aria-rowindex=${rowIndex + 2}
2514
+ aria-selected=${this.isRowSelectionMode() ? String(this.engine.isRowSelected(rowIndex)) : undefined}
2515
+ style=${`height: ${height}px;`}
2516
+ @mousedown=${this.handleRowMouseDown}
2517
+ @click=${(event) => this.handleRowClick(event, rowIndex)}
2518
+ >
2519
+ ${this.renderRowHeader(rowIndex)}
2520
+ <div class="column-spacer" aria-hidden="true"></div>
2521
+ ${visibleColumns.map((column) => this.renderCell(column, row, rowIndex))}
2522
+ <div class="column-spacer" aria-hidden="true"></div>
2523
+ </div>
2524
+ `;
2525
+ })}
2526
+ </div>
2527
+ </div>
2528
+ </div>
2529
+ ${pagination.enabled
2530
+ ? html `
2531
+ <div class="pagination" role="navigation" aria-label="Pagination">
2532
+ <span>${pagination.totalRows} rows</span>
2533
+ <label>
2534
+ Rows per page
2535
+ <select
2536
+ aria-label="Rows per page"
2537
+ @change=${(event) => this.setPageSize(Number(event.target.value))}
2538
+ >
2539
+ ${pageSizeOptions.map((pageSize) => html `
2540
+ <option value=${pageSize} ?selected=${pageSize === pagination.pageSize}>
2541
+ ${pageSize}
2542
+ </option>
2543
+ `)}
2544
+ </select>
2545
+ </label>
2546
+ <span>Page ${pagination.pageCount === 0 ? 0 : pagination.pageIndex + 1} of ${pagination.pageCount}</span>
2547
+ <button
2548
+ type="button"
2549
+ ?disabled=${pagination.pageIndex === 0}
2550
+ @click=${() => this.setPage(pagination.pageIndex - 1)}
2551
+ >Previous</button>
2552
+ <button
2553
+ type="button"
2554
+ ?disabled=${pagination.pageCount === 0 || pagination.pageIndex >= pagination.pageCount - 1}
2555
+ @click=${() => this.setPage(pagination.pageIndex + 1)}
2556
+ >Next</button>
2557
+ </div>
2558
+ `
2559
+ : ''}
2560
+ </div>
2561
+ `;
2562
+ }
2563
+ }
2564
+ if (!customElements.get('yc-grid')) {
2565
+ customElements.define('yc-grid', DataGrid);
2566
+ }
2567
+ //# sourceMappingURL=DataGrid.js.map