@tipolox/litgrid-web 1.0.3 → 1.2.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.
@@ -0,0 +1,3317 @@
1
+ // src/components/DataGrid.ts
2
+ import { LitElement, css, html, nothing, render as litRender } from 'lit'
3
+ import { cache } from 'lit/directives/cache.js'
4
+ import {
5
+ createGridEngine,
6
+ type FilterOperator,
7
+ type GridConfig,
8
+ type GridFilter,
9
+ type GridSelection,
10
+ type GridRowKey,
11
+ type SelectionChangeDetail,
12
+ type GridExternalState,
13
+ type GridQueryChangeDetail,
14
+ type SortDirection,
15
+ type SelectionIntent
16
+ } from '@tipolox/litgrid-core'
17
+ import {
18
+ calculateDisplayLayout,
19
+ createVariableVirtualizer,
20
+ createVirtualizer,
21
+ mapDisplayScrollOffset
22
+ } from '@tipolox/litgrid-renderer'
23
+ import type { GridColumn, GridTheme } from '../types.js'
24
+ import { darkThemeStyles } from '../theme/darkTheme.js'
25
+ import { lightThemeStyles } from '../theme/lightTheme.js'
26
+ import { MIN_COLUMN_WIDTH, MIN_ROW_HEIGHT } from '../sizing/constants.js'
27
+ import { normalizeColumnWidth, resolveColumnWidth } from '../sizing/columnSizing.js'
28
+ import { normalizeRowHeight } from '../sizing/rowSizing.js'
29
+ import { serializeSelectedCells } from '../clipboard/serializeSelectedCells.js'
30
+ import { serializeSelectedRows } from '../clipboard/serializeSelectedRows.js'
31
+ import {
32
+ createColumnState,
33
+ parseColumnState,
34
+ resolveColumnState,
35
+ type ColumnStateChangeDetail,
36
+ type GridColumnState
37
+ } from '../columnState/columnState.js'
38
+
39
+ const CELL_CONTROL_SELECTOR = 'a[href], area[href], button, input, select, textarea, summary, audio[controls], video[controls], [tabindex], [contenteditable]:not([contenteditable="false"])'
40
+
41
+
42
+
43
+ type ColumnResizeState = {
44
+ columnKey: string
45
+ startX: number
46
+ startWidth: number
47
+ }
48
+
49
+ type RowResizeState = {
50
+ rowIndex: number
51
+ startY: number
52
+ startHeight: number
53
+ }
54
+
55
+ type ColumnReorderState = {
56
+ columnKey: string
57
+ startX: number
58
+ sourceIndex: number
59
+ targetIndex: number
60
+ hasMoved: boolean
61
+ }
62
+
63
+ type ActiveCell = {
64
+ rowIndex: number
65
+ columnIndex: number
66
+ }
67
+
68
+
69
+ const FILTER_OPERATORS: Array<{ value: FilterOperator; label: string }> = [
70
+ { value: 'contains', label: 'Contains' },
71
+ { value: 'equals', label: 'Equals' },
72
+ { value: 'startsWith', label: 'Starts with' },
73
+ { value: 'endsWith', label: 'Ends with' },
74
+ { value: 'isEmpty', label: 'Is empty' },
75
+ { value: 'isNotEmpty', label: 'Is not empty' },
76
+ { value: 'greaterThan', label: 'Greater than' },
77
+ { value: 'greaterThanOrEqual', label: 'Greater than or equal' },
78
+ { value: 'lessThan', label: 'Less than' },
79
+ { value: 'lessThanOrEqual', label: 'Less than or equal' }
80
+ ]
81
+
82
+ export class DataGrid extends LitElement {
83
+ private static nextInstanceId = 0
84
+
85
+ private readonly gridId = `litgrid-${DataGrid.nextInstanceId++}`
86
+ private engine = createGridEngine()
87
+ private fixedRowVirtualizer = createVirtualizer({
88
+ rowHeight: 36,
89
+ viewportHeight: 320,
90
+ overscan: 4
91
+ })
92
+ private variableRowVirtualizer = createVariableVirtualizer({
93
+ sizes: [],
94
+ viewportSize: 320,
95
+ overscan: 4
96
+ })
97
+ private columnVirtualizer = createVariableVirtualizer({
98
+ sizes: [],
99
+ viewportSize: 0,
100
+ overscan: 2
101
+ })
102
+ private rowHeight = 36
103
+ private height = 320
104
+ private overscan = 4
105
+ private columnOverscan = 2
106
+ private _bestFitSampleSize = 10
107
+ private horizontalScrollLeft = 0
108
+ private viewportWidth = 0
109
+ private scrollbarWidth = 0
110
+ private columnDefs: GridColumn[] = []
111
+ private sourceColumnDefs: GridColumn[] = []
112
+ private _inferColumns = true
113
+ private columnWidths = new Map<string, number>()
114
+ private hiddenColumnKeys = new Set<string>()
115
+ private _columnStateStorageKey: string | null = null
116
+ private rowHeights = new Map<number, number>()
117
+
118
+ private bestFitWidths = new Map<string, Map<number, number>>()
119
+ private columnResizeState: ColumnResizeState | null = null
120
+ private rowResizeState: RowResizeState | null = null
121
+ private columnReorderState: ColumnReorderState | null = null
122
+ private columnDragPreview: HTMLElement | null = null
123
+ private previousBodyCursor = ''
124
+ private previousBodyUserSelect = ''
125
+ private activeHeaderMenu: string | null = null
126
+ private headerMenuCapabilities = { sortable: true, filterable: true }
127
+ private columnChooserOpen = false
128
+ private columnChooserPosition: { left: number; top: number } | null = null
129
+ private quickSearchVisible = false
130
+ private quickSearchDraft = ''
131
+ private quickSearchDebounceId: ReturnType<typeof setTimeout> | null = null
132
+ private sourceRowCount = 0
133
+ private filterDrafts = new Map<string, GridFilter>()
134
+ private measureContainer: HTMLElement | null = null
135
+ private viewportElement: HTMLElement | null = null
136
+ private scrollRAFId: number | null = null
137
+ private pendingScrollTop = 0
138
+ private pendingScrollLeft = 0
139
+ private previousPendingScrollTop = 0
140
+ private headerRowElement: HTMLElement | null = null
141
+ private activeCell: ActiveCell | null = null
142
+
143
+ private shouldFocusActiveCell = false
144
+ private gridCellHasFocus = false
145
+ private shouldFocusViewport = false
146
+ private renderedActiveCellId: string | null = null
147
+ private renderedRowStart = 0
148
+ private renderedColumnStart = 0
149
+ private headerMenuTrigger: HTMLButtonElement | null = null
150
+ private shouldFocusHeaderMenu = false
151
+ private shouldRestoreHeaderMenuFocus = false
152
+ private screenReaderMessage = ''
153
+ private lastScreenReaderAnnouncement = ''
154
+ private paginationFocusTarget: HTMLButtonElement | null = null
155
+
156
+ // Use a larger cap (2^26) to avoid hitting browser element-height limits.
157
+
158
+ private maxScrollableHeight = 67108864
159
+ private lastTotalSize = 0
160
+ private lastDisplayTotal = 0
161
+ quickSearchDebounceThreshold = 10000
162
+ quickSearchDebounceMs = 150
163
+ declare theme: GridTheme
164
+ declare ariaLabel: string
165
+ declare ariaDescription: string
166
+ declare screenReaderAnnouncements: boolean
167
+
168
+ static properties = {
169
+ theme: { type: String, reflect: true },
170
+ ariaLabel: { type: String, attribute: 'aria-label', reflect: true },
171
+ ariaDescription: { type: String, attribute: 'aria-description', reflect: true },
172
+ screenReaderAnnouncements: { type: Boolean, attribute: 'screen-reader-announcements', reflect: true }
173
+ }
174
+
175
+ constructor() {
176
+ super()
177
+ this.theme = 'light'
178
+ this.ariaLabel = 'Data grid'
179
+ this.ariaDescription = ''
180
+ this.screenReaderAnnouncements = true
181
+ this.engine.onSelectionChange(detail => {
182
+ this.requestUpdate()
183
+ this.dispatchEvent(new CustomEvent<SelectionChangeDetail>('selection-change', {
184
+ detail, bubbles: true, composed: true
185
+ }))
186
+ })
187
+ this.engine.onQueryChange(detail => {
188
+ this.quickSearchDraft = detail.query.search
189
+ this.clearBestFitWidths()
190
+ this.resetVerticalScroll()
191
+ this.requestUpdate()
192
+ this.dispatchEvent(new CustomEvent<GridQueryChangeDetail>('query-change', {
193
+ detail, bubbles: true, composed: true
194
+ }))
195
+ })
196
+ }
197
+
198
+ static styles = [lightThemeStyles, darkThemeStyles, css`
199
+ :host {
200
+ display: block;
201
+ min-width: 0;
202
+ max-width: 100%;
203
+ color: var(--litgrid-color-text);
204
+ font: 14px/1.4 system-ui, sans-serif;
205
+ }
206
+
207
+ .shell {
208
+ position: relative;
209
+ border: 1px solid var(--litgrid-color-border-strong);
210
+ border-radius: 12px;
211
+ background: var(--litgrid-color-surface);
212
+ box-shadow: var(--litgrid-shadow-shell);
213
+ overflow: hidden;
214
+ }
215
+
216
+ .viewport {
217
+ overflow: auto;
218
+ height: var(--grid-height, 320px);
219
+ }
220
+
221
+ .screen-reader-only {
222
+ position: absolute;
223
+ width: 1px;
224
+ height: 1px;
225
+ padding: 0;
226
+ margin: -1px;
227
+ overflow: hidden;
228
+ clip: rect(0, 0, 0, 0);
229
+ white-space: nowrap;
230
+ border: 0;
231
+ }
232
+
233
+ .header-column-row,
234
+ .row {
235
+ display: grid;
236
+ grid-template-columns: var(--virtual-grid-template);
237
+ }
238
+
239
+ .header-shell {
240
+ position: relative;
241
+ z-index: 3;
242
+ display: grid;
243
+ grid-template-columns: var(--header-grid-template);
244
+ border-bottom: 1px solid var(--litgrid-color-border);
245
+ background: var(--litgrid-color-surface-subtle);
246
+ }
247
+
248
+ .header-clip {
249
+ overflow: visible;
250
+ z-index: 3;
251
+ }
252
+
253
+ .header-column-row {
254
+ position: relative;
255
+ z-index: 2;
256
+ width: var(--total-column-width);
257
+ color: var(--litgrid-color-text-header);
258
+ font-size: 12px;
259
+ font-weight: 700;
260
+ letter-spacing: 0.03em;
261
+ text-transform: uppercase;
262
+ }
263
+
264
+ .header-cell,
265
+ .row-header-cell,
266
+ .cell {
267
+ box-sizing: border-box;
268
+ min-width: 0;
269
+ padding: 0 12px;
270
+ overflow: hidden;
271
+ text-overflow: ellipsis;
272
+ white-space: nowrap;
273
+ }
274
+
275
+ .header-cell {
276
+ position: relative;
277
+ z-index: 2;
278
+ overflow: visible;
279
+ height: 36px;
280
+ display: flex;
281
+ align-items: center;
282
+ border-right: 1px solid var(--litgrid-color-border-subtle);
283
+ cursor: grab;
284
+ touch-action: none;
285
+ }
286
+
287
+ .header-cell-reordering {
288
+ opacity: 0.55;
289
+ cursor: grabbing;
290
+ }
291
+
292
+ .header-cell-drop-before::before,
293
+ .header-cell-drop-after::after {
294
+ position: absolute;
295
+ top: 0;
296
+ bottom: 0;
297
+ z-index: 4;
298
+ width: 2px;
299
+ background: var(--litgrid-color-accent);
300
+ content: '';
301
+ pointer-events: none;
302
+ }
303
+
304
+ .header-cell-drop-before::before {
305
+ left: -1px;
306
+ }
307
+
308
+ .header-cell-drop-after::after {
309
+ right: -1px;
310
+ }
311
+
312
+ .row-header-cell {
313
+ position: sticky;
314
+ left: 0;
315
+ z-index: 1;
316
+ display: flex;
317
+ align-items: center;
318
+ justify-content: center;
319
+ width: var(--row-header-width, 0px);
320
+ border-right: 1px solid var(--litgrid-color-border);
321
+ background: var(--litgrid-color-surface-subtle);
322
+ color: var(--litgrid-color-text-subtle);
323
+ font-size: 12px;
324
+ font-weight: 600;
325
+ user-select: none;
326
+ }
327
+
328
+ .header-row-header {
329
+ position: relative;
330
+ /* Cover translated headers without clipping menus below the header. */
331
+ z-index: 4;
332
+ height: 36px;
333
+ border-right: 1px solid var(--litgrid-color-border);
334
+ }
335
+
336
+ .header-label {
337
+ min-width: 0;
338
+ overflow: hidden;
339
+ text-overflow: ellipsis;
340
+ }
341
+
342
+ .header-actions {
343
+ display: inline-flex;
344
+ align-items: center;
345
+ gap: 4px;
346
+ margin-left: auto;
347
+ position: relative;
348
+ z-index: 2;
349
+ }
350
+
351
+ .header-action-button {
352
+ border: none;
353
+ background: transparent;
354
+ cursor: pointer;
355
+ color: var(--litgrid-color-text-muted);
356
+ font-size: 16px;
357
+ line-height: 1;
358
+ padding: 4px;
359
+ border-radius: 4px;
360
+ }
361
+
362
+ .header-action-button:hover {
363
+ background: var(--litgrid-color-control-hover);
364
+ }
365
+
366
+ .header-menu {
367
+ position: absolute;
368
+ top: calc(100% + 4px);
369
+ right: 0;
370
+ left: auto;
371
+ display: flex;
372
+ flex-direction: column;
373
+ min-width: 180px;
374
+ border: 1px solid var(--litgrid-color-border);
375
+ border-radius: 8px;
376
+ background: var(--litgrid-color-surface);
377
+ box-shadow: var(--litgrid-shadow-menu);
378
+ padding: 6px 0;
379
+ z-index: 10000;
380
+ pointer-events: auto;
381
+ }
382
+
383
+ .header-menu-right {
384
+ left: 0;
385
+ right: auto;
386
+ }
387
+
388
+ .header-menu button {
389
+ width: 100%;
390
+ border: none;
391
+ background: transparent;
392
+ text-align: left;
393
+ padding: 10px 12px;
394
+ cursor: pointer;
395
+ color: var(--litgrid-color-text-strong);
396
+ font-size: 13px;
397
+ }
398
+
399
+ .header-menu-divider {
400
+ height: 1px;
401
+ margin: 6px 0;
402
+ background: var(--litgrid-color-border-subtle);
403
+ border: none;
404
+ }
405
+
406
+ .header-menu button:hover {
407
+ background: var(--litgrid-color-surface-hover);
408
+ }
409
+
410
+ .quick-search {
411
+ display: flex;
412
+ align-items: center;
413
+ gap: 8px;
414
+ padding: 8px 12px;
415
+ border-bottom: 1px solid var(--litgrid-color-border);
416
+ background: var(--litgrid-color-surface-subtle);
417
+ }
418
+
419
+ .quick-search input {
420
+ flex: 1;
421
+ min-width: 0;
422
+ border: 1px solid var(--litgrid-color-border);
423
+ border-radius: 6px;
424
+ background: var(--litgrid-color-surface);
425
+ color: var(--litgrid-color-text-strong);
426
+ font: inherit;
427
+ padding: 7px 9px;
428
+ }
429
+
430
+ .quick-search button {
431
+ border: 1px solid var(--litgrid-color-border);
432
+ border-radius: 6px;
433
+ background: var(--litgrid-color-surface);
434
+ color: var(--litgrid-color-text-strong);
435
+ font: inherit;
436
+ padding: 7px 9px;
437
+ cursor: pointer;
438
+ }
439
+
440
+ .header-filter {
441
+ display: grid;
442
+ gap: 6px;
443
+ padding: 8px 12px;
444
+ border-top: 1px solid var(--litgrid-color-border-subtle);
445
+ }
446
+
447
+ .header-filter select,
448
+ .header-filter input {
449
+ box-sizing: border-box;
450
+ width: 100%;
451
+ min-width: 0;
452
+ border: 1px solid var(--litgrid-color-border);
453
+ border-radius: 4px;
454
+ padding: 6px 8px;
455
+ font: inherit;
456
+ }
457
+
458
+ .header-filter-actions {
459
+ display: flex;
460
+ gap: 6px;
461
+ }
462
+
463
+ .header-filter-actions button {
464
+ flex: 1;
465
+ padding: 6px 8px;
466
+ }
467
+
468
+ .header-filter-indicator {
469
+ margin-left: 4px;
470
+ color: var(--litgrid-color-accent);
471
+ font-size: 14px;
472
+ }
473
+
474
+ .resize-handle {
475
+ position: absolute;
476
+ top: 0;
477
+ right: -4px;
478
+ z-index: 1;
479
+ width: 8px;
480
+ height: 100%;
481
+ cursor: col-resize;
482
+ touch-action: none;
483
+ }
484
+
485
+ .resize-handle::after {
486
+ content: '';
487
+ position: absolute;
488
+ top: 8px;
489
+ right: 3px;
490
+ width: 1px;
491
+ height: calc(100% - 16px);
492
+ background: var(--litgrid-color-resize-indicator);
493
+ opacity: 0;
494
+ transition: opacity 120ms ease;
495
+ }
496
+
497
+ .resize-handle:hover::after {
498
+ opacity: 1;
499
+ }
500
+
501
+ .row-resize-handle {
502
+ position: absolute;
503
+ right: 0;
504
+ bottom: -4px;
505
+ left: 0;
506
+ z-index: 2;
507
+ height: 8px;
508
+ cursor: row-resize;
509
+ touch-action: none;
510
+ }
511
+
512
+ .row-resize-handle::after {
513
+ content: '';
514
+ position: absolute;
515
+ right: 10px;
516
+ bottom: 3px;
517
+ left: 10px;
518
+ height: 1px;
519
+ background: var(--litgrid-color-resize-indicator);
520
+ opacity: 0;
521
+ transition: opacity 120ms ease;
522
+ }
523
+
524
+ .row-resize-handle:hover::after {
525
+ opacity: 1;
526
+ }
527
+
528
+ .header-cell:last-child,
529
+ .cell:last-child {
530
+ border-right: 0;
531
+ }
532
+
533
+ .column-spacer {
534
+ min-width: 0;
535
+ }
536
+
537
+ .header-gutter {
538
+ height: 36px;
539
+ border-left: 1px solid var(--litgrid-color-border-subtle);
540
+ background: var(--litgrid-color-surface-subtle);
541
+ }
542
+
543
+ .content {
544
+ box-sizing: border-box;
545
+ transform: translateY(var(--offset-top, 0px));
546
+ width: var(--total-row-width);
547
+ }
548
+
549
+ .row {
550
+ box-sizing: border-box;
551
+ width: var(--total-row-width);
552
+ border-bottom: 1px solid var(--litgrid-color-row-border);
553
+ background:
554
+ linear-gradient(90deg, var(--litgrid-color-row-start), var(--litgrid-color-row-end));
555
+ }
556
+
557
+ .row-selectable,
558
+ .cell-selectable {
559
+ cursor: pointer;
560
+ }
561
+
562
+ .row-selected {
563
+ background: var(--litgrid-color-accent-soft);
564
+ box-shadow: inset 3px 0 0 var(--litgrid-color-accent);
565
+ }
566
+
567
+ .row:last-child {
568
+ border-bottom: 0;
569
+ }
570
+
571
+ .cell {
572
+ display: flex;
573
+ align-items: center;
574
+ border-right: 1px solid var(--litgrid-color-cell-border);
575
+ }
576
+
577
+ .cell-selected {
578
+ background: var(--litgrid-color-accent-selected);
579
+ outline: 2px solid var(--litgrid-color-accent);
580
+ outline-offset: -2px;
581
+ }
582
+
583
+ .cell-active {
584
+ outline: 2px solid var(--litgrid-color-accent);
585
+ outline-offset: -2px;
586
+ }
587
+
588
+
589
+ .row-selected .row-header-cell {
590
+ background: var(--litgrid-color-accent-selected);
591
+ color: var(--litgrid-color-accent-text);
592
+ }
593
+
594
+ .selection-checkbox {
595
+ width: 16px;
596
+ height: 16px;
597
+ margin: 0;
598
+ accent-color: var(--litgrid-color-accent);
599
+ cursor: pointer;
600
+ }
601
+
602
+ .selection-checkbox:disabled {
603
+ cursor: default;
604
+ }
605
+
606
+ .column-chooser-dialog {
607
+ position: absolute;
608
+ top: 36px;
609
+ right: 8px;
610
+ z-index: 6;
611
+ min-width: 180px;
612
+ padding: 6px;
613
+ border: 1px solid var(--litgrid-color-border);
614
+ border-radius: 6px;
615
+ background: var(--litgrid-color-surface);
616
+ box-shadow: var(--litgrid-shadow-dialog);
617
+ }
618
+
619
+ .column-chooser-empty {
620
+ display: flex;
621
+ justify-content: flex-end;
622
+ padding: 6px 8px;
623
+ border-bottom: 1px solid var(--litgrid-color-border-subtle);
624
+ background: var(--litgrid-color-surface-subtle);
625
+ }
626
+
627
+ .column-chooser-empty button {
628
+ border: 1px solid var(--litgrid-color-border);
629
+ border-radius: 6px;
630
+ background: var(--litgrid-color-surface);
631
+ color: var(--litgrid-color-text-header);
632
+ font: inherit;
633
+ padding: 4px 8px;
634
+ cursor: pointer;
635
+ }
636
+
637
+ .column-chooser-dialog,
638
+ .column-chooser-menu {
639
+ padding-top: 34px;
640
+ }
641
+
642
+ .column-chooser-close {
643
+ position: absolute;
644
+ top: 5px;
645
+ right: 5px;
646
+ width: 24px !important;
647
+ min-width: 24px;
648
+ height: 24px;
649
+ padding: 0 !important;
650
+ border: 0 !important;
651
+ background: transparent !important;
652
+ color: var(--litgrid-color-text-muted);
653
+ font-size: 20px !important;
654
+ line-height: 1;
655
+ cursor: pointer;
656
+ }
657
+
658
+ .column-chooser-dialog label,
659
+ .column-chooser-menu label {
660
+ display: flex;
661
+ align-items: center;
662
+ gap: 8px;
663
+ padding: 5px 6px;
664
+ color: var(--litgrid-color-text-header);
665
+ cursor: pointer;
666
+ }
667
+
668
+ .pagination {
669
+ display: flex;
670
+ flex-wrap: wrap;
671
+ align-items: center;
672
+ justify-content: flex-end;
673
+ gap: 10px;
674
+ min-height: 48px;
675
+ padding: 8px 12px;
676
+ border-top: 1px solid var(--litgrid-color-border);
677
+ color: var(--litgrid-color-text-muted);
678
+ font-size: 13px;
679
+ }
680
+
681
+ .pagination-summary,
682
+ .pagination-navigation {
683
+ display: flex;
684
+ flex-wrap: wrap;
685
+ align-items: center;
686
+ gap: 10px;
687
+ min-width: 0;
688
+ }
689
+
690
+ .pagination span,
691
+ .pagination label {
692
+ white-space: nowrap;
693
+ }
694
+
695
+ .pagination label {
696
+ display: inline-flex;
697
+ align-items: center;
698
+ gap: 4px;
699
+ }
700
+
701
+ .pagination button,
702
+ .pagination select {
703
+ flex-shrink: 0;
704
+ border: 1px solid var(--litgrid-color-border);
705
+ border-radius: 6px;
706
+ background: var(--litgrid-color-surface);
707
+ color: var(--litgrid-color-text-strong);
708
+ font: inherit;
709
+ padding: 5px 8px;
710
+ }
711
+
712
+ .pagination button:not(:disabled) {
713
+ cursor: pointer;
714
+ }
715
+
716
+ .pagination button:disabled {
717
+ cursor: not-allowed;
718
+ opacity: 0.5;
719
+ }
720
+ `]
721
+
722
+ firstUpdated() {
723
+ this.updateScrollbarWidth()
724
+ }
725
+
726
+ willUpdate() {
727
+ // Reconcile after schema and persisted visibility settle, before triggers disappear.
728
+ this.reconcileHeaderMenuCapabilities()
729
+ }
730
+
731
+ updated() {
732
+ const paginationTarget = this.paginationFocusTarget
733
+ this.paginationFocusTarget = null
734
+ if (paginationTarget?.isConnected && paginationTarget.disabled) {
735
+ this.renderRoot.querySelector<HTMLElement>('.pagination button:not([disabled]), .pagination select')?.focus()
736
+ }
737
+ this.updateScrollbarWidth()
738
+
739
+ if (this.shouldFocusViewport) {
740
+ this.shouldFocusViewport = false
741
+ this.viewportElement?.focus({ preventScroll: true })
742
+ }
743
+ this.focusActiveCell()
744
+ this.focusHeaderMenu()
745
+
746
+ // Detect actual DOM spacer height after render. Some browsers clamp
747
+
748
+ // element heights internally; read the measured spacer height and
749
+ // use it for scroll mapping if it's lower than our intended display size.
750
+ try {
751
+ const viewport = this.renderRoot.querySelector('.viewport') as HTMLElement | null
752
+ const spacer = viewport?.firstElementChild as HTMLElement | null
753
+
754
+ if (spacer instanceof HTMLElement) {
755
+ const measured = spacer.offsetHeight || 0
756
+ if (measured > 0 && measured !== this.lastDisplayTotal) {
757
+ // Update the measured value for scroll scaling to use.
758
+ // Don't request update to avoid loops; the scroll handler will use this value.
759
+ this.lastDisplayTotal = measured
760
+ }
761
+ }
762
+ } catch (e) {
763
+ // ignore
764
+ }
765
+ }
766
+
767
+ disconnectedCallback() {
768
+ this.stopColumnResize()
769
+ this.stopRowResize()
770
+ this.stopColumnReorder()
771
+ this.clearQuickSearchDebounce()
772
+ if (this.scrollRAFId !== null) {
773
+ cancelAnimationFrame(this.scrollRAFId)
774
+ this.scrollRAFId = null
775
+ }
776
+ const viewportElement = this.renderRoot.querySelector('.viewport') as HTMLElement | null
777
+ if (viewportElement) {
778
+ viewportElement.removeEventListener('scroll', this.handleScroll)
779
+ }
780
+ this.headerRowElement = null
781
+ this.viewportElement = null
782
+ if (this.measureContainer && this.measureContainer.parentNode) {
783
+ this.measureContainer.parentNode.removeChild(this.measureContainer)
784
+ }
785
+ this.measureContainer = null
786
+ // call LitElement lifecycle
787
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
788
+ // @ts-ignore
789
+ if (super.disconnectedCallback) super.disconnectedCallback()
790
+ }
791
+
792
+ private updateVirtualizer(
793
+ next: Partial<{ viewportHeight: number; overscan: number }> = {}
794
+ ) {
795
+ const options = {
796
+ viewportSize: this.height,
797
+ overscan: this.overscan,
798
+ ...next
799
+ }
800
+
801
+ this.height = options.viewportHeight ?? this.height
802
+ this.overscan = options.overscan
803
+
804
+ this.fixedRowVirtualizer.setOptions({
805
+ rowHeight: this.rowHeight,
806
+ viewportHeight: this.height,
807
+ overscan: this.overscan
808
+ })
809
+
810
+ this.variableRowVirtualizer.setOptions(options)
811
+ }
812
+
813
+ private updateScrollbarWidth() {
814
+ const viewport = this.renderRoot.querySelector('.viewport')
815
+
816
+ if (!(viewport instanceof HTMLElement)) {
817
+ return
818
+ }
819
+ this.viewportElement = viewport
820
+
821
+ const nextWidth = viewport.offsetWidth - viewport.clientWidth
822
+ const nextViewportWidth = viewport.clientWidth
823
+
824
+ if (nextWidth !== this.scrollbarWidth || nextViewportWidth !== this.viewportWidth) {
825
+ this.scrollbarWidth = nextWidth
826
+ this.viewportWidth = nextViewportWidth
827
+ this.requestUpdate()
828
+ }
829
+ }
830
+
831
+ private getResolvedColumns(): GridColumn[] {
832
+ if (this.columnDefs.length > 0 || !this._inferColumns) {
833
+ return this.columnDefs
834
+ }
835
+
836
+ const firstRow = this.engine.getRows()[0]
837
+
838
+ if (firstRow && typeof firstRow === 'object' && !Array.isArray(firstRow)) {
839
+ return Object.keys(firstRow).map((key) => ({
840
+ key,
841
+ header: key
842
+ }))
843
+ }
844
+
845
+ return [
846
+ {
847
+ key: 'value',
848
+ header: 'Value',
849
+ accessor: (row) => row
850
+ }
851
+ ]
852
+ }
853
+
854
+ private getVisibleColumns() {
855
+ return this.getResolvedColumns().filter((column) => !this.hiddenColumnKeys.has(column.key))
856
+ }
857
+
858
+
859
+ getColumnWidth(column: GridColumn) {
860
+ return resolveColumnWidth(column, this.columnWidths.get(column.key))
861
+ }
862
+
863
+ private isRowHeaderEnabled() {
864
+ return this.engine.getConfig().rowHeader.enabled
865
+ }
866
+
867
+ private hasCheckboxSelection() {
868
+ const selection = this.engine.getConfig().selection
869
+ return selection.checkboxes && selection.mode === 'multi-row'
870
+ }
871
+
872
+ private isRowHeaderVisible() {
873
+ return this.isRowHeaderEnabled() || this.hasCheckboxSelection()
874
+ }
875
+
876
+ private getRowHeaderWidth() {
877
+ return this.isRowHeaderVisible() ? this.engine.getConfig().rowHeader.width : 0
878
+ }
879
+
880
+ private textMeasureContext: CanvasRenderingContext2D | null = null
881
+
882
+ getRowHeight(rowIndex: number) {
883
+ return this.rowHeights.get(rowIndex) ?? this.rowHeight
884
+ }
885
+
886
+ get bestFitSampleSize() {
887
+ return this._bestFitSampleSize
888
+ }
889
+
890
+ set bestFitSampleSize(value: number) {
891
+ if (this._bestFitSampleSize === value) {
892
+ return
893
+ }
894
+
895
+ this._bestFitSampleSize = value
896
+ this.clearBestFitWidths()
897
+ }
898
+
899
+ private clearBestFitWidths() {
900
+ this.bestFitWidths.clear()
901
+ }
902
+
903
+ private getBestFitWidth(columnKey: string, sampleSize: number) {
904
+ return this.bestFitWidths.get(columnKey)?.get(sampleSize)
905
+ }
906
+
907
+ private setBestFitWidth(columnKey: string, sampleSize: number, width: number) {
908
+ const widths = this.bestFitWidths.get(columnKey) ?? new Map<number, number>()
909
+ widths.set(sampleSize, width)
910
+ this.bestFitWidths.set(columnKey, widths)
911
+ }
912
+
913
+ private getRowSizes() {
914
+ const count = this.engine.getRowCount()
915
+ return Array.from({ length: count }, (_, index) =>
916
+ normalizeRowHeight(this.getRowHeight(index))
917
+ )
918
+ }
919
+
920
+ private hasCustomRowHeights() {
921
+ return this.rowHeights.size > 0
922
+ }
923
+
924
+ setRowHeight(rowIndex: number, height: number) {
925
+ this.rowHeights.set(rowIndex, normalizeRowHeight(height))
926
+ this.requestUpdate()
927
+ }
928
+
929
+ resetRowHeight(rowIndex: number) {
930
+ this.rowHeights.delete(rowIndex)
931
+ this.requestUpdate()
932
+ }
933
+
934
+ resetAllRowHeights() {
935
+ this.rowHeights.clear()
936
+ this.requestUpdate()
937
+ }
938
+
939
+ setColumnWidth(columnKey: string, width: number) {
940
+ const nextWidth = normalizeColumnWidth(width)
941
+
942
+ if (this.columnWidths.get(columnKey) === nextWidth) return
943
+
944
+ this.columnWidths.set(columnKey, nextWidth)
945
+ this.requestUpdate()
946
+ this.commitColumnState('resize')
947
+ }
948
+
949
+ resetColumnWidth(columnKey: string) {
950
+ if (!this.columnWidths.delete(columnKey)) return
951
+
952
+ this.requestUpdate()
953
+ this.commitColumnState('resize')
954
+ }
955
+
956
+ resetAllColumnWidths() {
957
+ if (this.columnWidths.size === 0) return
958
+
959
+ this.columnWidths.clear()
960
+ this.requestUpdate()
961
+ this.commitColumnState('resize')
962
+ }
963
+
964
+
965
+ getColumnOrder() {
966
+ return this.getResolvedColumns().map((column) => column.key)
967
+ }
968
+
969
+ private applyColumnOrder(columnDefs: GridColumn[], columnKey: string, previousIndex: number, currentIndex: number) {
970
+ this.columnDefs = columnDefs
971
+ this.requestUpdate()
972
+ this.dispatchEvent(new CustomEvent('column-reorder', {
973
+ bubbles: true,
974
+ composed: true,
975
+ detail: {
976
+ columnKey,
977
+ previousIndex,
978
+ currentIndex,
979
+ columnOrder: this.getColumnOrder()
980
+ }
981
+ }))
982
+ this.commitColumnState('reorder')
983
+
984
+ }
985
+
986
+ moveColumn(columnKey: string, targetIndex: number) {
987
+ const columns = this.getResolvedColumns()
988
+ const previousIndex = columns.findIndex((column) => column.key === columnKey)
989
+ if (previousIndex < 0) return
990
+
991
+ const currentIndex = Math.max(0, Math.min(Math.floor(targetIndex), columns.length - 1))
992
+ if (previousIndex === currentIndex) return
993
+
994
+ const nextColumns = [...columns]
995
+ const [column] = nextColumns.splice(previousIndex, 1)
996
+ nextColumns.splice(currentIndex, 0, column)
997
+ this.applyColumnOrder(nextColumns, columnKey, previousIndex, currentIndex)
998
+ }
999
+
1000
+ setColumnOrder(columnKeys: string[]) {
1001
+ const columns = this.getResolvedColumns()
1002
+ const columnsByKey = new Map(columns.map((column) => [column.key, column]))
1003
+ const seen = new Set<string>()
1004
+ const orderedColumns: GridColumn[] = []
1005
+
1006
+ for (const key of columnKeys) {
1007
+ const column = columnsByKey.get(key)
1008
+ if (column && !seen.has(key)) {
1009
+ seen.add(key)
1010
+ orderedColumns.push(column)
1011
+ }
1012
+ }
1013
+ for (const column of columns) {
1014
+ if (!seen.has(column.key)) orderedColumns.push(column)
1015
+ }
1016
+
1017
+ const previousOrder = columns.map((column) => column.key)
1018
+ const currentOrder = orderedColumns.map((column) => column.key)
1019
+ if (previousOrder.every((key, index) => key === currentOrder[index])) return
1020
+
1021
+ this.columnDefs = orderedColumns
1022
+ this.activeCell = null
1023
+ this.requestUpdate()
1024
+ this.commitColumnState('reorder')
1025
+ }
1026
+
1027
+ resetColumnOrder() {
1028
+ const sourceOrder = this.sourceColumnDefs.map((column) => column.key)
1029
+ if (this.getColumnOrder().every((key, index) => key === sourceOrder[index])) return
1030
+
1031
+ this.columnDefs = [...this.sourceColumnDefs]
1032
+ this.activeCell = null
1033
+ this.requestUpdate()
1034
+ this.commitColumnState('reorder')
1035
+ }
1036
+
1037
+
1038
+ setColumnVisible(columnKey: string, visible: boolean) {
1039
+ if (!this.getResolvedColumns().some((column) => column.key === columnKey)) return
1040
+ if (this.isColumnVisible(columnKey) === visible) return
1041
+
1042
+ const activeColumnKey = this.activeCell
1043
+ ? this.getVisibleColumns()[this.activeCell.columnIndex]?.key
1044
+ : undefined
1045
+
1046
+ if (visible) {
1047
+ this.hiddenColumnKeys.delete(columnKey)
1048
+ } else {
1049
+ this.hiddenColumnKeys.add(columnKey)
1050
+ }
1051
+
1052
+ if (this.activeCell) {
1053
+ const nextColumnIndex = this.getVisibleColumns().findIndex((column) => column.key === activeColumnKey)
1054
+ this.activeCell = nextColumnIndex < 0
1055
+ ? null
1056
+ : { ...this.activeCell, columnIndex: nextColumnIndex }
1057
+ }
1058
+
1059
+ this.requestUpdate()
1060
+ this.announceScreenReader(`${this.getColumnLabel(columnKey)} column ${visible ? 'shown' : 'hidden'}.`)
1061
+ this.dispatchEvent(new CustomEvent('column-visibility-change', {
1062
+ bubbles: true,
1063
+ composed: true,
1064
+ detail: { columnKey, visible, visibleColumnKeys: this.getVisibleColumnKeys() }
1065
+ }))
1066
+ this.commitColumnState('visibility')
1067
+
1068
+ }
1069
+
1070
+ isColumnVisible(columnKey: string) {
1071
+ return this.getResolvedColumns().some((column) => column.key === columnKey) && !this.hiddenColumnKeys.has(columnKey)
1072
+ }
1073
+
1074
+ getVisibleColumnKeys() {
1075
+ return this.getVisibleColumns().map((column) => column.key)
1076
+ }
1077
+
1078
+ getColumnState(): GridColumnState {
1079
+ return createColumnState(this.getResolvedColumns(), this.columnWidths, this.hiddenColumnKeys)
1080
+ }
1081
+
1082
+ setColumnState(state: GridColumnState) {
1083
+ const resolvedState = resolveColumnState(state, this.sourceColumnDefs)
1084
+ if (!resolvedState) return
1085
+
1086
+ this.columnDefs = resolvedState.columns
1087
+ this.columnWidths = resolvedState.widths
1088
+ this.hiddenColumnKeys = resolvedState.hiddenKeys
1089
+ this.activeCell = null
1090
+ this.requestUpdate()
1091
+ this.commitColumnState('set')
1092
+ }
1093
+
1094
+ resetColumnState() {
1095
+ this.columnDefs = [...this.sourceColumnDefs]
1096
+ this.columnWidths.clear()
1097
+ this.hiddenColumnKeys = new Set(
1098
+ this.sourceColumnDefs.filter((column) => column.hidden).map((column) => column.key)
1099
+ )
1100
+ this.activeCell = null
1101
+ this.removePersistedColumnState()
1102
+ this.requestUpdate()
1103
+ this.dispatchColumnStateChange('reset')
1104
+ }
1105
+
1106
+ private getColumnStateStorage() {
1107
+ try {
1108
+ return globalThis.localStorage
1109
+ } catch {
1110
+ return null
1111
+ }
1112
+ }
1113
+
1114
+ private restorePersistedColumnState(notify = true) {
1115
+ if (!this._columnStateStorageKey || this.sourceColumnDefs.length === 0) return
1116
+
1117
+ const storage = this.getColumnStateStorage()
1118
+ if (!storage) return
1119
+
1120
+ try {
1121
+ const state = parseColumnState(storage.getItem(this._columnStateStorageKey))
1122
+ const resolvedState = state && resolveColumnState(state, this.sourceColumnDefs)
1123
+ if (!resolvedState) return
1124
+
1125
+ this.columnDefs = resolvedState.columns
1126
+ this.columnWidths = resolvedState.widths
1127
+ this.hiddenColumnKeys = resolvedState.hiddenKeys
1128
+ this.activeCell = null
1129
+ this.requestUpdate()
1130
+ if (notify) this.dispatchColumnStateChange('restore')
1131
+ } catch {
1132
+ // Storage may be unavailable or blocked by browser privacy settings.
1133
+ }
1134
+ }
1135
+
1136
+ private persistColumnState() {
1137
+ if (!this._columnStateStorageKey) return
1138
+
1139
+ const storage = this.getColumnStateStorage()
1140
+ if (!storage) return
1141
+
1142
+ try {
1143
+ storage.setItem(this._columnStateStorageKey, JSON.stringify(this.getColumnState()))
1144
+ } catch {
1145
+ // Persistence is optional; preserve the in-memory state if storage fails.
1146
+ }
1147
+ }
1148
+
1149
+ private removePersistedColumnState() {
1150
+ if (!this._columnStateStorageKey) return
1151
+
1152
+ const storage = this.getColumnStateStorage()
1153
+ if (!storage) return
1154
+
1155
+ try {
1156
+ storage.removeItem(this._columnStateStorageKey)
1157
+ } catch {
1158
+ // Persistence is optional; preserve the in-memory state if storage fails.
1159
+ }
1160
+ }
1161
+
1162
+ private commitColumnState(reason: ColumnStateChangeDetail['reason']) {
1163
+ this.persistColumnState()
1164
+ this.dispatchColumnStateChange(reason)
1165
+ }
1166
+
1167
+ private dispatchColumnStateChange(reason: ColumnStateChangeDetail['reason']) {
1168
+ this.dispatchEvent(new CustomEvent<ColumnStateChangeDetail>('column-state-change', {
1169
+ bubbles: true,
1170
+ composed: true,
1171
+ detail: { reason, state: this.getColumnState() }
1172
+ }))
1173
+ }
1174
+
1175
+ resetColumnVisibility() {
1176
+
1177
+ const hiddenColumnKeys = new Set(this.getResolvedColumns().filter((column) => column.hidden).map((column) => column.key))
1178
+
1179
+ for (const column of this.getResolvedColumns()) {
1180
+ this.setColumnVisible(column.key, !hiddenColumnKeys.has(column.key))
1181
+ }
1182
+ }
1183
+
1184
+ private toggleHeaderMenu(columnKey: string, event: MouseEvent) {
1185
+ event.stopPropagation()
1186
+ this.columnChooserOpen = false
1187
+ this.columnChooserPosition = null
1188
+ if (this.activeHeaderMenu !== columnKey) {
1189
+ const activeFilter = this.engine.getFilters().find((filter) => filter.columnKey === columnKey)
1190
+ this.filterDrafts.set(columnKey, activeFilter ?? {
1191
+ columnKey,
1192
+ operator: 'contains',
1193
+ value: ''
1194
+ })
1195
+ }
1196
+ const isOpening = this.activeHeaderMenu !== columnKey
1197
+ this.headerMenuCapabilities = this.getColumnCapabilities(columnKey)
1198
+
1199
+ this.activeHeaderMenu = isOpening ? columnKey : null
1200
+ this.headerMenuTrigger = isOpening && event.currentTarget instanceof HTMLButtonElement
1201
+ ? event.currentTarget
1202
+ : null
1203
+ this.shouldFocusHeaderMenu = isOpening
1204
+ this.requestUpdate()
1205
+ }
1206
+
1207
+ private getColumnCapabilities(columnKey: string) {
1208
+ const column = this.getResolvedColumns().find(column => column.key === columnKey)
1209
+ return { sortable: !!column && column.sortable !== false, filterable: !!column && column.filterable !== false }
1210
+ }
1211
+
1212
+ private reconcileHeaderMenuCapabilities() {
1213
+ if (this.activeHeaderMenu === null) return
1214
+ const next = this.getColumnCapabilities(this.activeHeaderMenu)
1215
+ if (!this.isColumnVisible(this.activeHeaderMenu)
1216
+ || next.sortable !== this.headerMenuCapabilities.sortable || next.filterable !== this.headerMenuCapabilities.filterable) {
1217
+ this.closeHeaderMenu(true)
1218
+ }
1219
+ }
1220
+
1221
+ private closeHeaderMenu(restoreFocus = false) {
1222
+ if (this.activeHeaderMenu === null && !this.columnChooserOpen) return
1223
+ this.activeHeaderMenu = null
1224
+ this.columnChooserOpen = false
1225
+ this.columnChooserPosition = null
1226
+ this.shouldFocusHeaderMenu = false
1227
+ this.shouldRestoreHeaderMenuFocus = restoreFocus
1228
+ this.requestUpdate()
1229
+ }
1230
+
1231
+ private toggleColumnChooser(event?: MouseEvent) {
1232
+ if (this.columnChooserOpen) {
1233
+ this.closeColumnChooser(true)
1234
+ return
1235
+ }
1236
+
1237
+ if (event?.currentTarget instanceof HTMLElement) {
1238
+ if (!this.activeHeaderMenu && event.currentTarget instanceof HTMLButtonElement) {
1239
+ this.headerMenuTrigger = event.currentTarget
1240
+ }
1241
+ const shell = this.renderRoot.querySelector('.shell') as HTMLElement | null
1242
+ const menu = event.currentTarget.closest('.header-menu') as HTMLElement | null
1243
+ const anchorBounds = (menu ?? event.currentTarget).getBoundingClientRect()
1244
+ const shellBounds = shell?.getBoundingClientRect()
1245
+ this.columnChooserPosition = shellBounds
1246
+ ? {
1247
+ left: anchorBounds.left - shellBounds.left,
1248
+ top: anchorBounds.top - shellBounds.top
1249
+ }
1250
+ : null
1251
+ } else {
1252
+ this.columnChooserPosition = null
1253
+ }
1254
+
1255
+ this.columnChooserOpen = true
1256
+ this.activeHeaderMenu = null
1257
+ this.shouldFocusHeaderMenu = true
1258
+ this.requestUpdate()
1259
+ }
1260
+
1261
+ private closeColumnChooser(restoreFocus = false) {
1262
+ if (!this.columnChooserOpen) return
1263
+ this.columnChooserOpen = false
1264
+ this.columnChooserPosition = null
1265
+ this.shouldFocusHeaderMenu = false
1266
+ this.shouldRestoreHeaderMenuFocus = restoreFocus
1267
+ this.requestUpdate()
1268
+ }
1269
+
1270
+ private focusHeaderMenu() {
1271
+ if (this.shouldRestoreHeaderMenuFocus) {
1272
+ this.shouldRestoreHeaderMenuFocus = false
1273
+ const target = this.headerMenuTrigger?.isConnected
1274
+ ? this.headerMenuTrigger
1275
+ : this.renderRoot.querySelector<HTMLElement>('.header-action-button, .columns-button')
1276
+ ?? this.viewportElement
1277
+ target?.focus()
1278
+ this.headerMenuTrigger = null
1279
+ return
1280
+ }
1281
+
1282
+ if (!this.shouldFocusHeaderMenu) return
1283
+
1284
+ const firstControl = this.renderRoot.querySelector(
1285
+ '.header-menu button, .header-menu input, .header-menu select, .column-chooser-dialog button, .column-chooser-dialog input'
1286
+ ) as HTMLElement | null
1287
+
1288
+ if (firstControl) {
1289
+ this.shouldFocusHeaderMenu = false
1290
+ firstControl.focus()
1291
+ }
1292
+ }
1293
+
1294
+ private handleShellKeyDown(event: KeyboardEvent) {
1295
+ if (event.key !== 'Escape' || (this.activeHeaderMenu === null && !this.columnChooserOpen)) {
1296
+ return
1297
+ }
1298
+
1299
+ event.preventDefault()
1300
+ event.stopPropagation()
1301
+ this.closeHeaderMenu(true)
1302
+ }
1303
+
1304
+ private handleViewportFocus(event: FocusEvent) {
1305
+ if (event.target !== event.currentTarget || this.activeCell || this.engine.getRowCount() === 0 || this.getVisibleColumns().length === 0) {
1306
+ return
1307
+ }
1308
+
1309
+ this.setActiveCell(0, 0)
1310
+ this.requestUpdate()
1311
+ }
1312
+
1313
+ private handleGridFocusIn(event: FocusEvent) {
1314
+ const target = event.composedPath()[0]
1315
+ this.gridCellHasFocus = target instanceof HTMLElement && target.classList.contains('cell')
1316
+ }
1317
+
1318
+ private announceScreenReader(message: string) {
1319
+ if (!this.screenReaderAnnouncements || !message || message === this.lastScreenReaderAnnouncement) {
1320
+ return
1321
+ }
1322
+
1323
+ this.lastScreenReaderAnnouncement = message
1324
+ this.screenReaderMessage = message
1325
+ this.requestUpdate()
1326
+ }
1327
+
1328
+ private getScreenReaderDescription() {
1329
+ if (this.ariaDescription) return this.ariaDescription
1330
+ const mode = this.engine.getConfig().selection.mode
1331
+ const navigation = 'Use arrow keys to move between cells. Press F2 to interact with controls in a cell and Escape to return to cell navigation.'
1332
+ if (mode === 'none') return navigation
1333
+ const selection = mode === 'multi-row'
1334
+ ? 'Press Enter or Space to select a row. Hold Control or Command to toggle a row, or Shift to select a range of rows.'
1335
+ : mode === 'multi-cell'
1336
+ ? 'Press Enter or Space to toggle a cell.'
1337
+ : `Press Enter or Space to select a ${mode === 'row' ? 'row' : 'cell'}.`
1338
+ return `${navigation} ${selection} Press Control or Command C to copy selected cells or rows.`
1339
+ }
1340
+
1341
+ private getColumnLabel(columnKey: string) {
1342
+ const column = this.getVisibleColumns().find((item) => item.key === columnKey)
1343
+ ?? this.getResolvedColumns().find((item) => item.key === columnKey)
1344
+
1345
+ return column?.header ?? columnKey
1346
+ }
1347
+
1348
+ private getActiveCellAnnouncement() {
1349
+ if (!this.activeCell) return ''
1350
+
1351
+ const { rowIndex, columnIndex } = this.activeCell
1352
+ const column = this.getVisibleColumns()[columnIndex]
1353
+ const row = this.engine.getVisibleRows(rowIndex, rowIndex + 1)[0]
1354
+ if (!column || row === undefined) return ''
1355
+
1356
+ const value = this.formatCellValue(this.getCellValue(column, row, rowIndex)) || 'blank'
1357
+ const selected = this.isCellSelectionMode()
1358
+ ? this.engine.isCellSelected(rowIndex, column.key)
1359
+ : this.isRowSelectionMode() && this.engine.isRowSelected(rowIndex)
1360
+
1361
+ return `${this.getColumnLabel(column.key)}, row ${rowIndex + 1} of ${this.engine.getRowCount()}, column ${columnIndex + 1} of ${this.getVisibleColumns().length}, ${value}${selected ? ', selected' : ''}.`
1362
+ }
1363
+
1364
+ private announceActiveCell() {
1365
+ this.announceScreenReader(this.getActiveCellAnnouncement())
1366
+ }
1367
+
1368
+ private announceRowSelection(rowIndex: number, intent: SelectionIntent = 'replace') {
1369
+ const anchor = this.engine.getSelection().anchorRowIndex ?? rowIndex
1370
+ if (intent === 'range' && this.engine.getConfig().selection.rowKey !== undefined) {
1371
+ this.announceScreenReader(`${this.getDisplayedSelectedRowCount()} displayed rows selected.`)
1372
+ return
1373
+ }
1374
+ this.announceScreenReader(intent === 'range' && anchor !== rowIndex
1375
+ ? `Rows ${Math.min(anchor, rowIndex) + 1} through ${Math.max(anchor, rowIndex) + 1} selected.`
1376
+ : `Row ${rowIndex + 1} ${this.engine.isRowSelected(rowIndex) ? 'selected' : 'deselected'}.`)
1377
+ }
1378
+
1379
+ private announceCellSelection(rowIndex: number, columnKey: string) {
1380
+ this.announceScreenReader(`${this.getColumnLabel(columnKey)}, row ${rowIndex + 1} ${this.engine.isCellSelected(rowIndex, columnKey) ? 'selected' : 'deselected'}.`)
1381
+ }
1382
+
1383
+ private announceVisibleRowCount(context: string) {
1384
+ if (this.engine.getConfig().queryMode === 'external') {
1385
+ this.announceScreenReader(`${context}. Server query requested.`)
1386
+ return
1387
+ }
1388
+ const rowCount = this.engine.getRowCount()
1389
+ this.announceScreenReader(rowCount === 0 ? `${context}. No matching rows.` : `${context}. ${rowCount} ${rowCount === 1 ? 'row' : 'rows'} shown.`)
1390
+ }
1391
+
1392
+
1393
+
1394
+ private handleSortAscending(columnKey: string) {
1395
+ if (!this.getColumnCapabilities(columnKey).sortable) return
1396
+ this.engine.sortBy(columnKey, 'asc')
1397
+ this.announceScreenReader(`${this.getColumnLabel(columnKey)} ${this.engine.getConfig().queryMode === 'external' ? 'ascending sort requested' : 'sorted ascending'}.`)
1398
+ this.requestUpdate()
1399
+ this.closeHeaderMenu(true)
1400
+ }
1401
+
1402
+ private handleSortDescending(columnKey: string) {
1403
+ if (!this.getColumnCapabilities(columnKey).sortable) return
1404
+ this.engine.sortBy(columnKey, 'desc')
1405
+ this.announceScreenReader(`${this.getColumnLabel(columnKey)} ${this.engine.getConfig().queryMode === 'external' ? 'descending sort requested' : 'sorted descending'}.`)
1406
+ this.requestUpdate()
1407
+ this.closeHeaderMenu(true)
1408
+ }
1409
+
1410
+ private handleClearSort(columnKey: string) {
1411
+ if (!this.getColumnCapabilities(columnKey).sortable) return
1412
+ this.engine.clearSort()
1413
+ this.announceScreenReader(this.engine.getConfig().queryMode === 'external' ? 'Clear sorting requested.' : 'Sorting cleared.')
1414
+ this.requestUpdate()
1415
+ this.closeHeaderMenu(true)
1416
+ }
1417
+
1418
+ private handleBestFit(columnKey: string) {
1419
+ this.bestFitColumn(columnKey)
1420
+ this.closeHeaderMenu(true)
1421
+ }
1422
+
1423
+ private handleBestFitAllColumns() {
1424
+ this.bestFitAllColumns()
1425
+ this.closeHeaderMenu(true)
1426
+ }
1427
+
1428
+ private getTextMeasureContext() {
1429
+ if (this.textMeasureContext) {
1430
+ return this.textMeasureContext
1431
+ }
1432
+
1433
+ const canvas = document.createElement('canvas')
1434
+ const context = canvas.getContext('2d')
1435
+
1436
+ if (!context) {
1437
+ throw new Error('Unable to create text measurement context')
1438
+ }
1439
+
1440
+ const style = window.getComputedStyle(this)
1441
+ context.font = style.font || '14px system-ui'
1442
+ this.textMeasureContext = context
1443
+
1444
+ return context
1445
+ }
1446
+
1447
+ private measureTextWidth(text: string) {
1448
+ const context = this.getTextMeasureContext()
1449
+ return context.measureText(text).width
1450
+ }
1451
+
1452
+ private formatBestFitValue(column: GridColumn, row: unknown, rowIndex: number) {
1453
+ if (column.render) {
1454
+ const rendered = column.render(this.getCellValue(column, row, rowIndex), row, rowIndex)
1455
+
1456
+ if (typeof rendered === 'string' || typeof rendered === 'number') {
1457
+ return String(rendered)
1458
+ }
1459
+ }
1460
+
1461
+ return String(this.getCellValue(column, row, rowIndex))
1462
+ }
1463
+
1464
+ private computeColumnBestFit(columnKey: string, sampleSize = this.bestFitSampleSize) {
1465
+ const resolvedColumns = this.getResolvedColumns()
1466
+ const column = resolvedColumns.find((item) => item.key === columnKey)
1467
+
1468
+ if (!column) {
1469
+ return null
1470
+ }
1471
+
1472
+ const cachedWidth = this.getBestFitWidth(columnKey, sampleSize)
1473
+ if (cachedWidth !== undefined) {
1474
+ return cachedWidth
1475
+ }
1476
+
1477
+ const rowCount = this.engine.getRowCount()
1478
+ const rows = this.engine.getVisibleRows(0, Math.min(rowCount, sampleSize))
1479
+ const headerText = column.header ?? column.key
1480
+ const headerWidth = this.measureTextWidth(String(headerText).toUpperCase())
1481
+ const headerControlPadding = 44
1482
+
1483
+ let maxWidth = headerWidth + headerControlPadding
1484
+
1485
+ // Ensure measurement container exists
1486
+ if (!this.measureContainer) {
1487
+ try {
1488
+ this.measureContainer = document.createElement('div')
1489
+ Object.assign(this.measureContainer.style, {
1490
+ position: 'absolute',
1491
+ left: '-9999px',
1492
+ top: '0px',
1493
+ visibility: 'hidden',
1494
+ height: 'auto',
1495
+ width: 'auto',
1496
+ whiteSpace: 'nowrap',
1497
+ overflow: 'visible'
1498
+ })
1499
+ document.body.appendChild(this.measureContainer)
1500
+ } catch (e) {
1501
+ this.measureContainer = null
1502
+ }
1503
+ }
1504
+
1505
+ rows.forEach((row, index) => {
1506
+ // Prefer renderer output when available
1507
+ if (column.render) {
1508
+ const rendered = column.render(this.getCellValue(column, row, index), row, index)
1509
+
1510
+ if (typeof rendered === 'string' || typeof rendered === 'number') {
1511
+ maxWidth = Math.max(maxWidth, this.measureTextWidth(String(rendered)))
1512
+ return
1513
+ }
1514
+
1515
+ // If we have a measurement container, try rendering TemplateResult/Nodes into it
1516
+ if (this.measureContainer) {
1517
+ const wrapper = document.createElement('div')
1518
+ wrapper.style.whiteSpace = 'nowrap'
1519
+ this.measureContainer.appendChild(wrapper)
1520
+
1521
+ try {
1522
+ // Try to render Lit TemplateResult or nodes into the hidden wrapper
1523
+ // If `litRender` fails for this value, fall back to text measurement
1524
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
1525
+ // @ts-ignore
1526
+ litRender(rendered, wrapper)
1527
+ maxWidth = Math.max(maxWidth, Math.ceil(wrapper.scrollWidth))
1528
+ } catch (e) {
1529
+ const text = this.formatBestFitValue(column, row, index)
1530
+ maxWidth = Math.max(maxWidth, this.measureTextWidth(text))
1531
+ } finally {
1532
+ // clean up
1533
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
1534
+ // @ts-ignore
1535
+ litRender(undefined, wrapper)
1536
+ this.measureContainer.removeChild(wrapper)
1537
+ }
1538
+
1539
+ return
1540
+ }
1541
+ }
1542
+
1543
+ const value = this.formatBestFitValue(column, row, index)
1544
+ maxWidth = Math.max(maxWidth, this.measureTextWidth(value))
1545
+ })
1546
+
1547
+ const width = normalizeColumnWidth(Math.ceil(maxWidth + 24))
1548
+ this.setBestFitWidth(columnKey, sampleSize, width)
1549
+
1550
+ return width
1551
+ }
1552
+
1553
+ private getFilterDraft(columnKey: string) {
1554
+ return this.filterDrafts.get(columnKey) ?? {
1555
+ columnKey,
1556
+ operator: 'contains' as FilterOperator,
1557
+ value: ''
1558
+ }
1559
+ }
1560
+
1561
+ private updateFilterDraft(columnKey: string, changes: Partial<GridFilter>) {
1562
+ this.filterDrafts.set(columnKey, { ...this.getFilterDraft(columnKey), ...changes })
1563
+ this.requestUpdate()
1564
+ }
1565
+
1566
+ private isValueFreeFilter(operator: FilterOperator) {
1567
+ return operator === 'isEmpty' || operator === 'isNotEmpty'
1568
+ }
1569
+
1570
+ private resetVerticalScroll() {
1571
+ const viewport = this.renderRoot?.querySelector('.viewport') as HTMLElement | null
1572
+ if (viewport) {
1573
+ viewport.scrollTop = 0
1574
+ }
1575
+ this.pendingScrollTop = 0
1576
+ this.previousPendingScrollTop = 0
1577
+ this.fixedRowVirtualizer.setScrollOffset(0)
1578
+ this.variableRowVirtualizer.setScrollOffset(0)
1579
+ }
1580
+
1581
+ private clearQuickSearchDebounce() {
1582
+ if (this.quickSearchDebounceId !== null) {
1583
+ clearTimeout(this.quickSearchDebounceId)
1584
+ this.quickSearchDebounceId = null
1585
+ }
1586
+ }
1587
+
1588
+ private applyQuickSearch(query: string) {
1589
+ this.engine.setQuickSearch(query)
1590
+ this.resetVerticalScroll()
1591
+ this.announceVisibleRowCount(query ? 'Quick search updated' : 'Quick search cleared')
1592
+ this.requestUpdate()
1593
+ }
1594
+
1595
+ setQuickSearch(query: string) {
1596
+ this.clearQuickSearchDebounce()
1597
+ this.quickSearchDraft = query
1598
+ this.applyQuickSearch(query)
1599
+ }
1600
+
1601
+ clearQuickSearch() {
1602
+ this.setQuickSearch('')
1603
+ }
1604
+
1605
+ getQuickSearch() {
1606
+ return this.engine.getQuickSearch()
1607
+ }
1608
+
1609
+ private toggleQuickSearch() {
1610
+ this.quickSearchVisible = !this.quickSearchVisible
1611
+ this.closeHeaderMenu(true)
1612
+ }
1613
+
1614
+ private handleQuickSearchInput(query: string) {
1615
+ this.quickSearchDraft = query
1616
+ this.clearQuickSearchDebounce()
1617
+
1618
+ const threshold = Math.max(0, Math.floor(this.quickSearchDebounceThreshold))
1619
+ const debounceMs = Math.max(0, Math.floor(this.quickSearchDebounceMs))
1620
+
1621
+ const external = this.engine.getConfig().queryMode === 'external'
1622
+ if (debounceMs === 0 || (!external && this.sourceRowCount < threshold)) {
1623
+ this.applyQuickSearch(query)
1624
+ return
1625
+ }
1626
+
1627
+ this.quickSearchDebounceId = setTimeout(() => {
1628
+ this.quickSearchDebounceId = null
1629
+ this.applyQuickSearch(query)
1630
+ }, debounceMs)
1631
+ this.requestUpdate()
1632
+ }
1633
+
1634
+ setFilter(filter: GridFilter) {
1635
+ this.engine.setFilter(filter)
1636
+ this.filterDrafts.set(filter.columnKey, { ...filter })
1637
+ this.resetVerticalScroll()
1638
+ this.announceVisibleRowCount(`${this.getColumnLabel(filter.columnKey)} filter applied`)
1639
+ this.requestUpdate()
1640
+ }
1641
+
1642
+ clearFilter(columnKey?: string) {
1643
+ this.engine.clearFilter(columnKey)
1644
+ if (columnKey) {
1645
+ this.filterDrafts.delete(columnKey)
1646
+ } else {
1647
+ this.filterDrafts.clear()
1648
+ }
1649
+ this.resetVerticalScroll()
1650
+ this.announceVisibleRowCount(columnKey ? `${this.getColumnLabel(columnKey)} filter cleared` : 'All filters cleared')
1651
+ this.requestUpdate()
1652
+ }
1653
+
1654
+ getFilters() {
1655
+ return this.engine.getFilters()
1656
+ }
1657
+
1658
+ sortBy(columnKey: string, direction: SortDirection) {
1659
+ this.engine.sortBy(columnKey, direction)
1660
+ this.resetVerticalScroll()
1661
+ this.requestUpdate()
1662
+ }
1663
+
1664
+ clearSort() {
1665
+ this.engine.clearSort()
1666
+ this.resetVerticalScroll()
1667
+ this.requestUpdate()
1668
+ }
1669
+
1670
+ getQuery() { return this.engine.getQuery() }
1671
+ refreshQuery() { this.engine.refreshQuery() }
1672
+ get externalState() { return this.engine.getExternalState() }
1673
+ set externalState(value: GridExternalState | null) {
1674
+ if (value) this.setExternalState(value)
1675
+ }
1676
+ setExternalState(value: GridExternalState): boolean {
1677
+ if (!this.engine.setExternalState(value)) return false
1678
+ this.clearQuickSearchDebounce()
1679
+ this.quickSearchDraft = this.engine.getQuickSearch()
1680
+ this.filterDrafts = new Map(this.engine.getFilters().map(filter => [filter.columnKey, filter]))
1681
+ this.sourceRowCount = this.engine.getRowCount()
1682
+ this.clearBestFitWidths()
1683
+ this.requestUpdate()
1684
+ return true
1685
+ }
1686
+
1687
+ private getPageLabel() {
1688
+ const pagination = this.engine.getPagination()
1689
+ if (this.engine.getConfig().queryMode === 'external' && pagination.totalKnown && pagination.pageIndex > 0 && pagination.pageIndex >= pagination.pageCount) return `Page ${pagination.pageIndex + 1} (out of range)`
1690
+ return pagination.totalKnown === false ? `Page ${pagination.pageIndex + 1}`
1691
+ : `Page ${pagination.pageCount === 0 ? 0 : pagination.pageIndex + 1} of ${pagination.pageCount}`
1692
+ }
1693
+
1694
+ setPage(pageIndex: number) {
1695
+ this.rememberPaginationFocus()
1696
+ this.engine.setPage(pageIndex)
1697
+ this.resetVerticalScroll()
1698
+ const pagination = this.engine.getPagination()
1699
+ this.announceScreenReader(`${this.getPageLabel()}.`)
1700
+ this.requestUpdate()
1701
+ }
1702
+
1703
+ setPageSize(pageSize: number) {
1704
+ this.rememberPaginationFocus()
1705
+ this.engine.setPageSize(pageSize)
1706
+ this.resetVerticalScroll()
1707
+ const pagination = this.engine.getPagination()
1708
+ this.announceScreenReader(`${pagination.pageSize} rows per page. ${this.getPageLabel()}.`)
1709
+ this.requestUpdate()
1710
+ }
1711
+
1712
+ getPagination() {
1713
+ return this.engine.getPagination()
1714
+ }
1715
+
1716
+ private rememberPaginationFocus() {
1717
+ const active = this.shadowRoot?.activeElement
1718
+ if (active instanceof HTMLButtonElement && active.closest('.pagination')) this.paginationFocusTarget = active
1719
+ }
1720
+
1721
+ getTotalRowCount() {
1722
+ return this.engine.getTotalRowCount()
1723
+ }
1724
+
1725
+ selectRow(rowIndex: number, intent?: SelectionIntent) {
1726
+ if (!this.isRowSelectionMode() || !Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= this.engine.getRowCount()) return
1727
+ const effectiveIntent = this.engine.getConfig().selection.mode === 'row' ? 'replace' : intent ?? 'replace'
1728
+ const previous = this.engine.getSelection()
1729
+ // The engine's range operation always produces a contiguous anchored range.
1730
+ // Its endpoint and size identify a repeated operation without scanning rows.
1731
+ const unchanged = effectiveIntent === 'range'
1732
+ ? previous.rowIndex === rowIndex && previous.rowIndexes.size === Math.abs((previous.anchorRowIndex ?? rowIndex) - rowIndex) + 1
1733
+ : effectiveIntent === 'replace' && previous.rowIndexes.size === 1 && previous.rowIndexes.has(rowIndex)
1734
+ this.engine.selectRow(rowIndex, effectiveIntent)
1735
+ if (!unchanged) this.announceRowSelection(rowIndex, effectiveIntent)
1736
+ this.requestUpdate()
1737
+ }
1738
+
1739
+ private announceAllRowsSelected() {
1740
+ const count = this.engine.getSelectableRowCount()
1741
+ const qualifier = count === this.engine.getRowCount() ? '' : 'selectable '
1742
+ this.announceScreenReader(`All ${count} ${qualifier}displayed rows selected.`)
1743
+ }
1744
+
1745
+ selectAllRows() {
1746
+ if (this.engine.getConfig().selection.mode !== 'multi-row' || this.engine.getSelectableRowCount() === 0) return
1747
+ const unchanged = this.engine.getSelection().rowIndexes.size === this.engine.getSelectableRowCount()
1748
+ this.engine.selectAllRows()
1749
+ if (!unchanged) this.announceAllRowsSelected()
1750
+ this.requestUpdate()
1751
+ }
1752
+
1753
+ clearSelection() {
1754
+ const previous = this.engine.getSelection()
1755
+ const hadSelection = previous.rowIndexes.size > 0 || previous.cells.size > 0
1756
+ this.engine.clearSelection()
1757
+ if (hadSelection) this.announceScreenReader('Selection cleared.')
1758
+ this.requestUpdate()
1759
+ }
1760
+
1761
+ getSelection(): GridSelection {
1762
+ return this.engine.getSelection()
1763
+ }
1764
+
1765
+ getSelectionSnapshot(): SelectionChangeDetail {
1766
+ return this.engine.getSelectionSnapshot()
1767
+ }
1768
+
1769
+ selectRowByKey(key: GridRowKey, intent?: SelectionIntent): boolean {
1770
+ const previous = this.engine.getSelectionSnapshot()
1771
+ const selected = this.engine.selectRowByKey(key, intent)
1772
+ const next = this.engine.getSelectionSnapshot()
1773
+ if (selected && next !== previous) {
1774
+ const index = next.rows.find(row => row.key === key)?.rowIndex
1775
+ ?? previous.rows.find(row => row.key === key)?.rowIndex
1776
+ if (index !== undefined) this.announceRowSelection(index, intent)
1777
+ }
1778
+ return selected
1779
+ }
1780
+
1781
+ isRowSelected(rowIndex: number) {
1782
+ return this.engine.isRowSelected(rowIndex)
1783
+ }
1784
+
1785
+ isCellSelected(rowIndex: number, columnKey: string) {
1786
+ return this.engine.isCellSelected(rowIndex, columnKey)
1787
+ }
1788
+
1789
+ private getSelectedCellsClipboardText() {
1790
+ return serializeSelectedCells({
1791
+ selection: this.engine.getSelection(),
1792
+ rows: this.engine.getRows(),
1793
+ columns: this.getVisibleColumns(),
1794
+ getCellValue: (column, row, rowIndex) => this.getCellValue(column, row, rowIndex),
1795
+ formatValue: (value) => this.formatCellValue(value)
1796
+ })
1797
+ }
1798
+
1799
+ async copySelectedCells() {
1800
+ const text = this.getSelectedCellsClipboardText()
1801
+
1802
+ if (text === null || !navigator.clipboard?.writeText) {
1803
+ return false
1804
+ }
1805
+
1806
+ try {
1807
+ await navigator.clipboard.writeText(text)
1808
+ return true
1809
+ } catch {
1810
+ return false
1811
+ }
1812
+ }
1813
+
1814
+ private getSelectedRowsClipboardText() {
1815
+ return serializeSelectedRows({
1816
+ selection: this.engine.getSelection(),
1817
+ rows: this.engine.getRows(),
1818
+ columns: this.getVisibleColumns(),
1819
+ getCellValue: (column, row, rowIndex) => this.getCellValue(column, row, rowIndex),
1820
+ formatValue: (value) => this.formatCellValue(value)
1821
+ })
1822
+ }
1823
+
1824
+ async copySelectedRows() {
1825
+ const text = this.getSelectedRowsClipboardText()
1826
+
1827
+ if (text === null || !navigator.clipboard?.writeText) {
1828
+ return false
1829
+ }
1830
+
1831
+ try {
1832
+ await navigator.clipboard.writeText(text)
1833
+ return true
1834
+ } catch {
1835
+ return false
1836
+ }
1837
+ }
1838
+
1839
+ private applyHeaderFilter(columnKey: string) {
1840
+ if (!this.getColumnCapabilities(columnKey).filterable) return
1841
+ const filter = this.getFilterDraft(columnKey)
1842
+ this.setFilter({
1843
+ ...filter,
1844
+ value: this.isValueFreeFilter(filter.operator) ? undefined : filter.value
1845
+ })
1846
+ this.closeHeaderMenu(true)
1847
+ }
1848
+
1849
+ private clearHeaderFilter(columnKey: string) {
1850
+ if (!this.getColumnCapabilities(columnKey).filterable) return
1851
+ this.clearFilter(columnKey)
1852
+ this.closeHeaderMenu(true)
1853
+ }
1854
+
1855
+ private isColumnFiltered(columnKey: string) {
1856
+ return this.engine.getFilters().some((filter) => filter.columnKey === columnKey)
1857
+ }
1858
+
1859
+ bestFitColumn(columnKey: string) {
1860
+ const width = this.computeColumnBestFit(columnKey)
1861
+
1862
+ if (width !== null) {
1863
+ this.setColumnWidth(columnKey, width)
1864
+ }
1865
+
1866
+ return width
1867
+ }
1868
+
1869
+ bestFitAllColumns() {
1870
+ const columns = this.getVisibleColumns()
1871
+
1872
+ const widths = columns.map((column) => ({
1873
+ key: column.key,
1874
+ width: this.computeColumnBestFit(column.key)
1875
+ }))
1876
+
1877
+ widths.forEach((item) => {
1878
+ if (item.width !== null) {
1879
+ this.setColumnWidth(item.key, item.width)
1880
+ }
1881
+ })
1882
+
1883
+ return widths
1884
+ }
1885
+
1886
+ private startBodyResize(cursor: string) {
1887
+ this.previousBodyCursor = document.body.style.cursor
1888
+ this.previousBodyUserSelect = document.body.style.userSelect
1889
+ document.body.style.cursor = cursor
1890
+ document.body.style.userSelect = 'none'
1891
+ }
1892
+
1893
+ private stopBodyResize() {
1894
+ document.body.style.cursor = this.previousBodyCursor
1895
+ document.body.style.userSelect = this.previousBodyUserSelect
1896
+ }
1897
+
1898
+ private startColumnReorder(event: PointerEvent, column: GridColumn) {
1899
+ if (event.button !== 0 || this.columnResizeState || (event.target as Element).closest('button, input, select, .resize-handle')) {
1900
+ return
1901
+ }
1902
+
1903
+ const sourceIndex = this.getResolvedColumns().findIndex((item) => item.key === column.key)
1904
+ if (sourceIndex < 0) return
1905
+
1906
+ event.preventDefault()
1907
+ this.columnReorderState = {
1908
+ columnKey: column.key,
1909
+ startX: event.clientX,
1910
+ sourceIndex,
1911
+ targetIndex: sourceIndex,
1912
+ hasMoved: false
1913
+ }
1914
+ window.addEventListener('pointermove', this.handleColumnReorder)
1915
+ window.addEventListener('pointerup', this.stopColumnReorder)
1916
+ window.addEventListener('pointercancel', this.stopColumnReorder)
1917
+ }
1918
+
1919
+ private handleColumnReorder = (event: PointerEvent) => {
1920
+ const state = this.columnReorderState
1921
+ if (!state) return
1922
+
1923
+ if (!state.hasMoved && Math.abs(event.clientX - state.startX) < 5) return
1924
+ if (!state.hasMoved) {
1925
+ state.hasMoved = true
1926
+ this.startBodyResize('grabbing')
1927
+ }
1928
+ this.updateColumnDragPreview(state.columnKey, event.clientX, event.clientY)
1929
+
1930
+ const viewport = this.viewportElement ?? (this.renderRoot.querySelector('.viewport') as HTMLElement | null)
1931
+ if (viewport) {
1932
+ const bounds = viewport.getBoundingClientRect()
1933
+ const edgeSize = 32
1934
+ if (event.clientX < bounds.left + edgeSize) {
1935
+ viewport.scrollLeft = Math.max(0, viewport.scrollLeft - 16)
1936
+ } else if (event.clientX > bounds.right - edgeSize) {
1937
+ viewport.scrollLeft += 16
1938
+ }
1939
+ }
1940
+
1941
+ const headers = Array.from(this.renderRoot.querySelectorAll<HTMLElement>('.header-cell[data-column-key]'))
1942
+ const target = headers.find((header) => {
1943
+ const bounds = header.getBoundingClientRect()
1944
+ return event.clientX >= bounds.left && event.clientX <= bounds.right
1945
+ })
1946
+ if (!target) {
1947
+ this.requestUpdate()
1948
+ return
1949
+ }
1950
+
1951
+ const targetKey = target.dataset.columnKey
1952
+ const targetIndex = this.getResolvedColumns().findIndex((column) => column.key === targetKey)
1953
+ if (targetIndex < 0) return
1954
+
1955
+ const bounds = target.getBoundingClientRect()
1956
+ let nextIndex = targetIndex + (event.clientX > bounds.left + bounds.width / 2 ? 1 : 0)
1957
+ if (state.sourceIndex < nextIndex) nextIndex -= 1
1958
+ state.targetIndex = Math.max(0, Math.min(nextIndex, this.getResolvedColumns().length - 1))
1959
+ this.requestUpdate()
1960
+ }
1961
+
1962
+ private updateColumnDragPreview(columnKey: string, clientX: number, clientY: number) {
1963
+ if (!this.columnDragPreview) {
1964
+ const column = this.getResolvedColumns().find((item) => item.key === columnKey)
1965
+ const source = this.renderRoot.querySelector<HTMLElement>(`.header-cell[data-column-key="${columnKey}"]`)
1966
+ if (!column || !source) return
1967
+
1968
+ const bounds = source.getBoundingClientRect()
1969
+ const theme = getComputedStyle(this)
1970
+ const preview = document.createElement('div')
1971
+ preview.textContent = column.header ?? column.key
1972
+ Object.assign(preview.style, {
1973
+ position: 'fixed',
1974
+ zIndex: '10001',
1975
+ boxSizing: 'border-box',
1976
+ width: `${bounds.width}px`,
1977
+ height: `${bounds.height}px`,
1978
+ padding: '0 12px',
1979
+ display: 'flex',
1980
+ alignItems: 'center',
1981
+ overflow: 'hidden',
1982
+ textOverflow: 'ellipsis',
1983
+ whiteSpace: 'nowrap',
1984
+ border: `1px solid ${theme.getPropertyValue('--litgrid-color-accent')}`,
1985
+ borderRadius: '4px',
1986
+ background: theme.getPropertyValue('--litgrid-color-surface'),
1987
+ color: theme.getPropertyValue('--litgrid-color-text-header'),
1988
+ font: '700 12px/1.4 system-ui, sans-serif',
1989
+ letterSpacing: '0.03em',
1990
+ textTransform: 'uppercase',
1991
+ boxShadow: theme.getPropertyValue('--litgrid-shadow-dialog'),
1992
+ opacity: '0.95',
1993
+ pointerEvents: 'none'
1994
+ })
1995
+ document.body.appendChild(preview)
1996
+ this.columnDragPreview = preview
1997
+ }
1998
+
1999
+ const preview = this.columnDragPreview
2000
+ preview.style.left = `${clientX - preview.offsetWidth / 2}px`
2001
+ preview.style.top = `${clientY - preview.offsetHeight / 2}px`
2002
+ }
2003
+
2004
+ private removeColumnDragPreview() {
2005
+ this.columnDragPreview?.remove()
2006
+ this.columnDragPreview = null
2007
+ }
2008
+
2009
+ private stopColumnReorder = () => {
2010
+ this.finishColumnReorder()
2011
+ }
2012
+
2013
+ private finishColumnReorder(commit = true) {
2014
+ const state = this.columnReorderState
2015
+ if (!state) return
2016
+
2017
+ this.columnReorderState = null
2018
+ window.removeEventListener('pointermove', this.handleColumnReorder)
2019
+ window.removeEventListener('pointerup', this.stopColumnReorder)
2020
+ window.removeEventListener('pointercancel', this.stopColumnReorder)
2021
+ this.removeColumnDragPreview()
2022
+ if (state.hasMoved) this.stopBodyResize()
2023
+
2024
+ if (commit && state.hasMoved && state.sourceIndex !== state.targetIndex) {
2025
+ this.moveColumn(state.columnKey, state.targetIndex)
2026
+ } else {
2027
+ this.requestUpdate()
2028
+ }
2029
+ }
2030
+
2031
+ private getGridCellId(rowIndex: number, columnIndex: number) {
2032
+ return `${this.gridId}-cell-${rowIndex}-${columnIndex}`
2033
+ }
2034
+
2035
+ private getHeaderRowId() {
2036
+ return `${this.gridId}-header-row`
2037
+ }
2038
+
2039
+ private getHeaderMenuId(columnKey: string) {
2040
+ return `${this.gridId}-header-menu-${encodeURIComponent(columnKey)}`
2041
+ }
2042
+
2043
+ private getRowHeaderColumnHeaderId() {
2044
+ return `${this.gridId}-row-header-columnheader`
2045
+ }
2046
+
2047
+ private getAriaColumnIndex(columnIndex: number) {
2048
+ return columnIndex + (this.isRowHeaderVisible() ? 2 : 1)
2049
+ }
2050
+
2051
+ private getHeaderSortDirection(columnKey: string) {
2052
+ const sort = this.engine.getSort()
2053
+ return sort.columnKey === columnKey && sort.direction
2054
+ ? sort.direction === 'asc' ? 'ascending' : 'descending'
2055
+ : 'none'
2056
+ }
2057
+
2058
+ private getHeaderCellClass(column: GridColumn) {
2059
+ const classes = ['header-cell']
2060
+ const state = this.columnReorderState
2061
+ if (!state || !state.hasMoved) return classes.join(' ')
2062
+
2063
+ if (state.columnKey === column.key) classes.push('header-cell-reordering')
2064
+ const columnIndex = this.getResolvedColumns().findIndex((item) => item.key === column.key)
2065
+ if (columnIndex === state.targetIndex && state.sourceIndex !== state.targetIndex) {
2066
+ classes.push(state.sourceIndex < state.targetIndex ? 'header-cell-drop-after' : 'header-cell-drop-before')
2067
+ }
2068
+ return classes.join(' ')
2069
+ }
2070
+
2071
+ private startColumnResize(event: PointerEvent, column: GridColumn) {
2072
+ event.preventDefault()
2073
+ event.stopPropagation()
2074
+
2075
+ this.columnResizeState = {
2076
+ columnKey: column.key,
2077
+ startX: event.clientX,
2078
+ startWidth: this.getColumnWidth(column)
2079
+ }
2080
+ this.startBodyResize('col-resize')
2081
+ window.addEventListener('pointermove', this.handleColumnResize)
2082
+ window.addEventListener('pointerup', this.stopColumnResize)
2083
+ }
2084
+
2085
+ private handleColumnResize = (event: PointerEvent) => {
2086
+ if (!this.columnResizeState) {
2087
+ return
2088
+ }
2089
+
2090
+ const nextWidth = Math.max(
2091
+ MIN_COLUMN_WIDTH,
2092
+ this.columnResizeState.startWidth + event.clientX - this.columnResizeState.startX
2093
+ )
2094
+
2095
+ this.columnWidths.set(this.columnResizeState.columnKey, nextWidth)
2096
+ this.requestUpdate()
2097
+ }
2098
+
2099
+ private stopColumnResize = () => {
2100
+ this.finishColumnResize()
2101
+ }
2102
+
2103
+ private finishColumnResize(commit = true) {
2104
+ if (!this.columnResizeState) {
2105
+ return
2106
+ }
2107
+
2108
+ this.columnResizeState = null
2109
+ if (commit) this.commitColumnState('resize')
2110
+
2111
+ this.stopBodyResize()
2112
+ window.removeEventListener('pointermove', this.handleColumnResize)
2113
+ window.removeEventListener('pointerup', this.stopColumnResize)
2114
+ }
2115
+
2116
+ private startRowResize(event: PointerEvent, rowIndex: number) {
2117
+ event.preventDefault()
2118
+ event.stopPropagation()
2119
+
2120
+ this.rowResizeState = {
2121
+ rowIndex,
2122
+ startY: event.clientY,
2123
+ startHeight: this.getRowHeight(rowIndex)
2124
+ }
2125
+ this.startBodyResize('row-resize')
2126
+ window.addEventListener('pointermove', this.handleRowResize)
2127
+ window.addEventListener('pointerup', this.stopRowResize)
2128
+ }
2129
+
2130
+ private handleRowResize = (event: PointerEvent) => {
2131
+ if (!this.rowResizeState) {
2132
+ return
2133
+ }
2134
+
2135
+ const nextHeight = Math.max(
2136
+ MIN_ROW_HEIGHT,
2137
+ this.rowResizeState.startHeight + event.clientY - this.rowResizeState.startY
2138
+ )
2139
+
2140
+ this.setRowHeight(this.rowResizeState.rowIndex, nextHeight)
2141
+ }
2142
+
2143
+ private stopRowResize = () => {
2144
+ if (!this.rowResizeState) {
2145
+ return
2146
+ }
2147
+
2148
+ this.rowResizeState = null
2149
+ this.stopBodyResize()
2150
+ window.removeEventListener('pointermove', this.handleRowResize)
2151
+ window.removeEventListener('pointerup', this.stopRowResize)
2152
+ }
2153
+
2154
+ private getCellValue(column: GridColumn, row: unknown, rowIndex: number) {
2155
+ if (column.accessor) {
2156
+ return column.accessor(row, rowIndex)
2157
+ }
2158
+
2159
+ if (row && typeof row === 'object' && column.key in row) {
2160
+ return (row as Record<string, unknown>)[column.key]
2161
+ }
2162
+
2163
+ return ''
2164
+ }
2165
+
2166
+ private formatCellValue(value: unknown) {
2167
+ if (value == null) {
2168
+ return ''
2169
+ }
2170
+
2171
+ if (typeof value === 'object') {
2172
+ return JSON.stringify(value)
2173
+ }
2174
+
2175
+ return String(value)
2176
+ }
2177
+
2178
+ private getSelectionMode() {
2179
+ return this.engine.getSelection().mode
2180
+ }
2181
+
2182
+ private isRowSelectionMode() {
2183
+ const mode = this.getSelectionMode()
2184
+
2185
+ return mode === 'row' || mode === 'multi-row'
2186
+ }
2187
+
2188
+ private isCellSelectionMode() {
2189
+ const mode = this.getSelectionMode()
2190
+
2191
+ return mode === 'cell' || mode === 'multi-cell'
2192
+ }
2193
+
2194
+ private getRowClass(rowIndex: number) {
2195
+ const classes = ['row']
2196
+
2197
+ if (this.isRowSelectionMode()) {
2198
+ classes.push('row-selectable')
2199
+ }
2200
+
2201
+ if (this.engine.isRowSelected(rowIndex)) {
2202
+ classes.push('row-selected')
2203
+ }
2204
+
2205
+ return classes.join(' ')
2206
+ }
2207
+
2208
+ private getCellClass(rowIndex: number, columnKey: string) {
2209
+ const classes = ['cell']
2210
+
2211
+ if (this.isCellSelectionMode()) {
2212
+ classes.push('cell-selectable')
2213
+ }
2214
+
2215
+ if (this.engine.isCellSelected(rowIndex, columnKey)) {
2216
+ classes.push('cell-selected')
2217
+ }
2218
+
2219
+ if (this.activeCell?.rowIndex === rowIndex && this.getVisibleColumns()[this.activeCell.columnIndex]?.key === columnKey) {
2220
+ classes.push('cell-active')
2221
+ }
2222
+
2223
+ return classes.join(' ')
2224
+ }
2225
+
2226
+ private setActiveCell(rowIndex: number, columnIndex: number, shouldFocus = false) {
2227
+ const rowCount = this.engine.getRowCount()
2228
+ const columnCount = this.getVisibleColumns().length
2229
+
2230
+ if (rowCount === 0 || columnCount === 0) {
2231
+ this.activeCell = null
2232
+ return
2233
+ }
2234
+
2235
+ this.activeCell = {
2236
+ rowIndex: Math.max(0, Math.min(rowIndex, rowCount - 1)),
2237
+ columnIndex: Math.max(0, Math.min(columnIndex, columnCount - 1))
2238
+ }
2239
+ this.shouldFocusActiveCell = shouldFocus
2240
+ this.announceActiveCell()
2241
+ }
2242
+
2243
+ private reconcileActiveCell(rowCount: number, columnCount: number) {
2244
+ if (!this.activeCell) return
2245
+ if (rowCount === 0 || columnCount === 0) {
2246
+ this.activeCell = null
2247
+ this.shouldFocusActiveCell = false
2248
+ return
2249
+ }
2250
+ const rowIndex = Math.min(this.activeCell.rowIndex, rowCount - 1)
2251
+ const columnIndex = Math.min(this.activeCell.columnIndex, columnCount - 1)
2252
+ if (rowIndex !== this.activeCell.rowIndex || columnIndex !== this.activeCell.columnIndex) {
2253
+ this.activeCell = { rowIndex, columnIndex }
2254
+ this.shouldFocusActiveCell = this.gridCellHasFocus
2255
+ }
2256
+ }
2257
+
2258
+ private focusActiveCell() {
2259
+ if (!this.shouldFocusActiveCell || !this.activeCell || !this.renderedActiveCellId) {
2260
+ return
2261
+ }
2262
+
2263
+ const cell = this.renderRoot.querySelector(
2264
+ `.cell[data-row-index="${this.activeCell.rowIndex}"][data-column-index="${this.activeCell.columnIndex}"]`
2265
+ ) as HTMLElement | null
2266
+
2267
+ if (cell) {
2268
+ this.shouldFocusActiveCell = false
2269
+ cell.focus()
2270
+ }
2271
+ }
2272
+
2273
+ private getRowIndexAtOffset(offset: number) {
2274
+ const rowCount = this.engine.getRowCount()
2275
+ if (rowCount === 0) {
2276
+ return 0
2277
+ }
2278
+
2279
+ if (!this.hasCustomRowHeights()) {
2280
+ return Math.max(0, Math.min(Math.floor(offset / this.rowHeight), rowCount - 1))
2281
+ }
2282
+
2283
+ const rowSizes = this.getRowSizes()
2284
+ const rowOffsets = new Array<number>(rowCount + 1)
2285
+ rowOffsets[0] = 0
2286
+ for (let index = 0; index < rowCount; index += 1) {
2287
+ rowOffsets[index + 1] = rowOffsets[index] + rowSizes[index]
2288
+ }
2289
+
2290
+ const targetOffset = Math.max(0, Math.min(offset, rowOffsets[rowCount] - 1))
2291
+ let low = 0
2292
+ let high = rowCount - 1
2293
+
2294
+ while (low <= high) {
2295
+ const middle = Math.floor((low + high) / 2)
2296
+ const middleOffset = rowOffsets[middle]
2297
+ const middleBottom = rowOffsets[middle + 1]
2298
+
2299
+ if (targetOffset < middleOffset) {
2300
+ high = middle - 1
2301
+ } else if (targetOffset >= middleBottom) {
2302
+ low = middle + 1
2303
+ } else {
2304
+ return middle
2305
+ }
2306
+ }
2307
+
2308
+ return Math.max(0, Math.min(low, rowCount - 1))
2309
+ }
2310
+
2311
+ private getPageNavigationRowIndex(rowIndex: number, direction: -1 | 1) {
2312
+ const rowTop = this.hasCustomRowHeights()
2313
+ ? this.getRowSizes().slice(0, rowIndex).reduce((total, height) => total + height, 0)
2314
+ : rowIndex * this.rowHeight
2315
+
2316
+ return this.getRowIndexAtOffset(rowTop + direction * this.height)
2317
+ }
2318
+
2319
+ private getTabNavigationCell(direction: -1 | 1, rowCount: number, columnCount: number) {
2320
+ if (!this.activeCell) {
2321
+ return direction === 1
2322
+ ? { rowIndex: 0, columnIndex: 0 }
2323
+ : { rowIndex: rowCount - 1, columnIndex: columnCount - 1 }
2324
+ }
2325
+
2326
+ const nextIndex = this.activeCell.rowIndex * columnCount + this.activeCell.columnIndex + direction
2327
+ if (nextIndex < 0 || nextIndex >= rowCount * columnCount) {
2328
+ return null
2329
+ }
2330
+
2331
+ return {
2332
+ rowIndex: Math.floor(nextIndex / columnCount),
2333
+ columnIndex: nextIndex % columnCount
2334
+ }
2335
+ }
2336
+
2337
+ private scrollActiveCellIntoView() {
2338
+ const viewport = this.viewportElement ?? (this.renderRoot.querySelector('.viewport') as HTMLElement | null)
2339
+ const activeCell = this.activeCell
2340
+
2341
+ if (!viewport || !activeCell) {
2342
+ return
2343
+ }
2344
+
2345
+ const rowTop = this.hasCustomRowHeights()
2346
+ ? this.getRowSizes().slice(0, activeCell.rowIndex).reduce((total, height) => total + height, 0)
2347
+ : activeCell.rowIndex * this.rowHeight
2348
+ const rowBottom = rowTop + this.getRowHeight(activeCell.rowIndex)
2349
+ const displayTotal = this.lastDisplayTotal || this.lastTotalSize
2350
+ const availableVirtualSize = Math.max(0, this.lastTotalSize - this.height)
2351
+ const availableDisplaySize = Math.max(0, displayTotal - this.height)
2352
+ const currentVirtualOffset = availableDisplaySize > 0
2353
+ ? (viewport.scrollTop * availableVirtualSize) / availableDisplaySize
2354
+ : 0
2355
+ let nextVirtualOffset = currentVirtualOffset
2356
+
2357
+ if (rowTop < currentVirtualOffset) {
2358
+ nextVirtualOffset = rowTop
2359
+ } else if (rowBottom > currentVirtualOffset + this.height) {
2360
+ nextVirtualOffset = rowBottom - this.height
2361
+ }
2362
+
2363
+ if (nextVirtualOffset !== currentVirtualOffset) {
2364
+ viewport.scrollTop = availableVirtualSize > 0
2365
+ ? (Math.max(0, Math.min(nextVirtualOffset, availableVirtualSize)) * availableDisplaySize) / availableVirtualSize
2366
+ : 0
2367
+ }
2368
+
2369
+ const columns = this.getVisibleColumns()
2370
+ const columnLeft = columns
2371
+ .slice(0, activeCell.columnIndex)
2372
+ .reduce((total, column) => total + this.getColumnWidth(column), 0)
2373
+ const columnRight = columnLeft + this.getColumnWidth(columns[activeCell.columnIndex])
2374
+ const bodyViewportWidth = Math.max(0, viewport.clientWidth - this.getRowHeaderWidth())
2375
+
2376
+ if (columnLeft < viewport.scrollLeft) {
2377
+ viewport.scrollLeft = columnLeft
2378
+ } else if (columnRight > viewport.scrollLeft + bodyViewportWidth) {
2379
+ viewport.scrollLeft = Math.max(0, columnRight - bodyViewportWidth)
2380
+ }
2381
+ }
2382
+
2383
+ private getCellControls(cell: HTMLElement) {
2384
+ // Only inspect the cell explicitly entered by the user, never a render or
2385
+ // virtualization path. Custom focusable hosts can own shadow controls.
2386
+ return Array.from(cell.querySelectorAll<HTMLElement>(CELL_CONTROL_SELECTOR))
2387
+ .filter((control) => !control.matches(':disabled, input[type="hidden"]')
2388
+ && !control.closest('[hidden], [inert]'))
2389
+ }
2390
+
2391
+ private focusCellControl(control: HTMLElement) {
2392
+ control.focus({ preventScroll: true })
2393
+ return (control.getRootNode() as Document | ShadowRoot).activeElement === control
2394
+ }
2395
+
2396
+ private syncInteractionCell(cell: HTMLElement) {
2397
+ const rowIndex = Number(cell.dataset.rowIndex)
2398
+ const columnIndex = Number(cell.dataset.columnIndex)
2399
+ if (this.activeCell?.rowIndex !== rowIndex || this.activeCell.columnIndex !== columnIndex) {
2400
+ this.setActiveCell(rowIndex, columnIndex)
2401
+ this.requestUpdate()
2402
+ }
2403
+ }
2404
+
2405
+ private handleKeyDown(event: KeyboardEvent) {
2406
+ if (event.defaultPrevented) return
2407
+
2408
+ let owningCell: HTMLElement | undefined
2409
+ let insideControl = false
2410
+ // The composed path also protects nested markup and renderer shadow controls.
2411
+ for (const target of event.composedPath()) {
2412
+ if (!(target instanceof Element)) continue
2413
+ if (target.matches('.cell[role="gridcell"]')) {
2414
+ owningCell = target as HTMLElement
2415
+ break
2416
+ }
2417
+ if (target === event.currentTarget) break
2418
+ if (target.matches(CELL_CONTROL_SELECTOR)) insideControl = true
2419
+ }
2420
+ if (insideControl) {
2421
+ if (!owningCell) return
2422
+ if (event.key === 'Escape') {
2423
+ event.preventDefault()
2424
+ event.stopPropagation()
2425
+ this.syncInteractionCell(owningCell)
2426
+ owningCell.focus({ preventScroll: true })
2427
+ return
2428
+ }
2429
+ const focused = (this.renderRoot as ShadowRoot).activeElement
2430
+ if (event.key !== 'Tab' || !focused || focused === owningCell || !owningCell.contains(focused)) return
2431
+ // An open shadow widget owns the Tab order of its internal controls.
2432
+ if (focused.shadowRoot?.activeElement) return
2433
+
2434
+ const controls = this.getCellControls(owningCell).filter((control) => control.tabIndex >= 0
2435
+ || control === focused || control.matches('[contenteditable]:not([tabindex])'))
2436
+ const current = controls.indexOf(focused as HTMLElement)
2437
+ // Shadow widgets own their internal Tab order; a focusable host is their entry point.
2438
+ if (current < 0) return
2439
+ const direction = event.shiftKey ? -1 : 1
2440
+ for (let index = current + direction; index >= 0 && index < controls.length; index += direction) {
2441
+ if (this.focusCellControl(controls[index])) {
2442
+ event.preventDefault()
2443
+ return
2444
+ }
2445
+ }
2446
+ this.syncInteractionCell(owningCell)
2447
+ if (event.shiftKey) {
2448
+ event.preventDefault()
2449
+ owningCell.focus({ preventScroll: true })
2450
+ return
2451
+ }
2452
+ // At the last control, resume the existing forward cell Tab navigation.
2453
+ }
2454
+
2455
+ const rowCount = this.engine.getRowCount()
2456
+ const columnCount = this.getVisibleColumns().length
2457
+ this.reconcileActiveCell(rowCount, columnCount)
2458
+ if (rowCount === 0 || columnCount === 0) {
2459
+ return
2460
+ }
2461
+
2462
+ if (event.key === 'F2' && !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey) {
2463
+ const active = this.activeCell ?? { rowIndex: 0, columnIndex: 0 }
2464
+ const cell = owningCell ?? this.renderRoot.querySelector<HTMLElement>(
2465
+ `.cell[data-row-index="${active.rowIndex}"][data-column-index="${active.columnIndex}"]`
2466
+ )
2467
+ if (cell) {
2468
+ for (const control of this.getCellControls(cell)) {
2469
+ if (this.focusCellControl(control)) {
2470
+ event.preventDefault()
2471
+ break
2472
+ }
2473
+ }
2474
+ }
2475
+ return
2476
+ }
2477
+
2478
+ if ((event.key === 'Enter' || event.key === ' ') && !event.altKey) {
2479
+ const activeCell = this.activeCell ?? { rowIndex: 0, columnIndex: 0 }
2480
+
2481
+
2482
+ if (this.isCellSelectionMode() && !event.ctrlKey && !event.metaKey) {
2483
+ const column = this.getVisibleColumns()[activeCell.columnIndex]
2484
+ if (column) {
2485
+ event.preventDefault()
2486
+ const unchanged = this.engine.getConfig().selection.mode === 'cell' && this.engine.isCellSelected(activeCell.rowIndex, column.key)
2487
+ this.engine.selectCell(activeCell.rowIndex, column.key)
2488
+ if (!unchanged) this.announceCellSelection(activeCell.rowIndex, column.key)
2489
+ this.requestUpdate()
2490
+ }
2491
+ } else if (this.isRowSelectionMode()) {
2492
+ event.preventDefault()
2493
+ this.selectRow(activeCell.rowIndex, this.getRowSelectionIntent(event))
2494
+ }
2495
+ return
2496
+ }
2497
+
2498
+ if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === 'c') {
2499
+ const cellText = this.getSelectedCellsClipboardText()
2500
+ const rowText = cellText === null ? this.getSelectedRowsClipboardText() : null
2501
+
2502
+ if (cellText !== null || rowText !== null) {
2503
+ event.preventDefault()
2504
+ void (cellText !== null ? this.copySelectedCells() : this.copySelectedRows())
2505
+ }
2506
+ return
2507
+ }
2508
+
2509
+ if (event.key === 'Tab') {
2510
+ const nextCell = this.getTabNavigationCell(event.shiftKey ? -1 : 1, rowCount, columnCount)
2511
+ if (!nextCell) {
2512
+ return
2513
+ }
2514
+
2515
+ event.preventDefault()
2516
+ this.setActiveCell(nextCell.rowIndex, nextCell.columnIndex, true)
2517
+ this.scrollActiveCellIntoView()
2518
+ this.requestUpdate()
2519
+ return
2520
+ }
2521
+
2522
+ const activeCell = this.activeCell ?? { rowIndex: 0, columnIndex: 0 }
2523
+ const deltas: Record<string, ActiveCell> = {
2524
+ ArrowUp: { rowIndex: -1, columnIndex: 0 },
2525
+ ArrowDown: { rowIndex: 1, columnIndex: 0 },
2526
+ ArrowLeft: { rowIndex: 0, columnIndex: -1 },
2527
+ ArrowRight: { rowIndex: 0, columnIndex: 1 }
2528
+ }
2529
+ const delta = deltas[event.key]
2530
+ const isGridBoundaryNavigation = event.ctrlKey || event.metaKey
2531
+ let nextCell: ActiveCell | null = null
2532
+
2533
+ if (delta) {
2534
+ nextCell = {
2535
+ rowIndex: activeCell.rowIndex + delta.rowIndex,
2536
+ columnIndex: activeCell.columnIndex + delta.columnIndex
2537
+ }
2538
+ } else if (event.key === 'Home') {
2539
+ nextCell = {
2540
+ rowIndex: isGridBoundaryNavigation ? 0 : activeCell.rowIndex,
2541
+ columnIndex: 0
2542
+ }
2543
+ } else if (event.key === 'End') {
2544
+ nextCell = {
2545
+ rowIndex: isGridBoundaryNavigation ? rowCount - 1 : activeCell.rowIndex,
2546
+ columnIndex: columnCount - 1
2547
+ }
2548
+ } else if (event.key === 'PageUp' || event.key === 'PageDown') {
2549
+ nextCell = {
2550
+ rowIndex: this.getPageNavigationRowIndex(activeCell.rowIndex, event.key === 'PageUp' ? -1 : 1),
2551
+ columnIndex: activeCell.columnIndex
2552
+ }
2553
+ }
2554
+
2555
+ if (!nextCell) {
2556
+ return
2557
+ }
2558
+
2559
+ event.preventDefault()
2560
+ this.setActiveCell(nextCell.rowIndex, nextCell.columnIndex, true)
2561
+ this.scrollActiveCellIntoView()
2562
+ this.requestUpdate()
2563
+ }
2564
+
2565
+ private getRowSelectionIntent(event: MouseEvent | KeyboardEvent): SelectionIntent {
2566
+ if (event.shiftKey) {
2567
+ return 'range'
2568
+ }
2569
+
2570
+ if (event.ctrlKey || event.metaKey) {
2571
+ return 'toggle'
2572
+ }
2573
+
2574
+ return 'replace'
2575
+ }
2576
+
2577
+ private handleRowClick(event: MouseEvent, rowIndex: number) {
2578
+ if (!this.isRowSelectionMode()) {
2579
+ return
2580
+ }
2581
+
2582
+ this.selectRow(rowIndex, this.getRowSelectionIntent(event))
2583
+ }
2584
+
2585
+ private handleRowMouseDown(event: MouseEvent) {
2586
+ if (this.isRowSelectionMode() && event.shiftKey) {
2587
+ event.preventDefault()
2588
+ }
2589
+ }
2590
+
2591
+ private handleCellClick(event: Event, rowIndex: number, column: GridColumn) {
2592
+ this.setActiveCell(rowIndex, this.getVisibleColumns().findIndex((item) => item.key === column.key))
2593
+
2594
+ if (!this.isCellSelectionMode()) {
2595
+ this.requestUpdate()
2596
+ return
2597
+ }
2598
+
2599
+ event.stopPropagation()
2600
+ const unchanged = this.engine.getConfig().selection.mode === 'cell' && this.engine.isCellSelected(rowIndex, column.key)
2601
+ this.engine.selectCell(rowIndex, column.key)
2602
+ if (!unchanged) this.announceCellSelection(rowIndex, column.key)
2603
+ this.requestUpdate()
2604
+ }
2605
+
2606
+ private renderCell(column: GridColumn, row: unknown, rowIndex: number) {
2607
+
2608
+ const value = this.getCellValue(column, row, rowIndex)
2609
+ const rendered = column.render
2610
+ ? column.render(value, row, rowIndex)
2611
+ : this.formatCellValue(value)
2612
+
2613
+ const content =
2614
+ typeof rendered === 'string' || typeof rendered === 'number'
2615
+ ? html`${rendered}`
2616
+ : (rendered as any)
2617
+
2618
+ return html`
2619
+ <div
2620
+ id=${this.getGridCellId(rowIndex, this.getVisibleColumns().findIndex((item) => item.key === column.key))}
2621
+ class=${this.getCellClass(rowIndex, column.key)}
2622
+ role="gridcell"
2623
+ aria-colindex=${this.getAriaColumnIndex(this.getVisibleColumns().findIndex((item) => item.key === column.key))}
2624
+ aria-selected=${this.isCellSelectionMode() ? String(this.engine.isCellSelected(rowIndex, column.key)) : undefined}
2625
+ data-row-index=${rowIndex}
2626
+ data-column-index=${this.getVisibleColumns().findIndex((item) => item.key === column.key)}
2627
+ tabindex=${this.activeCell?.rowIndex === rowIndex && this.getVisibleColumns()[this.activeCell.columnIndex]?.key === column.key ? '0' : '-1'}
2628
+ @click=${(event: Event) => this.handleCellClick(event, rowIndex, column)}
2629
+ >
2630
+
2631
+ ${cache(content)}
2632
+ </div>
2633
+ `
2634
+ }
2635
+
2636
+ private renderRowHeader(rowIndex: number) {
2637
+ if (!this.isRowHeaderVisible()) {
2638
+ return ''
2639
+ }
2640
+
2641
+ return html`
2642
+ <div class="row-header-cell" role="rowheader" aria-colindex="1">
2643
+ ${this.hasCheckboxSelection()
2644
+ ? html`<input
2645
+ class="selection-checkbox"
2646
+ type="checkbox"
2647
+ aria-label=${`Select row ${rowIndex + 1}`}
2648
+ .checked=${this.engine.isRowSelected(rowIndex)}
2649
+ @click=${(event: Event) => event.stopPropagation()}
2650
+ @change=${() => this.handleRowCheckboxChange(rowIndex)}
2651
+ />`
2652
+ : html`<span>${rowIndex + 1}</span>`}
2653
+ <span
2654
+ class="row-resize-handle"
2655
+ aria-hidden="true"
2656
+ @pointerdown=${(event: PointerEvent) => this.startRowResize(event, rowIndex)}
2657
+ ></span>
2658
+ </div>
2659
+ `
2660
+ }
2661
+
2662
+ set data(value: unknown[]) {
2663
+ if (this.engine.getConfig().queryMode === 'external') return
2664
+ this.clearQuickSearchDebounce()
2665
+
2666
+ this.sourceRowCount = Array.isArray(value) ? value.length : 0
2667
+ this.engine.setData(value ?? [])
2668
+ this.quickSearchDraft = this.engine.getQuickSearch()
2669
+ this.clearBestFitWidths()
2670
+ this.announceScreenReader(this.engine.getRowCount() === 0 ? 'Grid is empty.' : `${this.engine.getRowCount()} ${this.engine.getRowCount() === 1 ? 'row' : 'rows'} loaded.`)
2671
+ this.requestUpdate()
2672
+ }
2673
+
2674
+ private handleRowCheckboxChange(rowIndex: number) {
2675
+ this.selectRow(rowIndex, 'toggle')
2676
+ }
2677
+
2678
+ private getDisplayedSelectedRowCount() {
2679
+ return this.engine.getSelectionSnapshot().rowIndexes.length
2680
+ }
2681
+
2682
+ private areAllDisplayedRowsSelected() {
2683
+ const rowCount = this.engine.getSelectableRowCount()
2684
+ return rowCount > 0 && this.getDisplayedSelectedRowCount() === rowCount
2685
+ }
2686
+
2687
+ private hasPartiallySelectedDisplayedRows() {
2688
+ const selectedCount = this.getDisplayedSelectedRowCount()
2689
+ return selectedCount > 0 && selectedCount < this.engine.getSelectableRowCount()
2690
+ }
2691
+
2692
+ private handleSelectAllRows() {
2693
+ if (this.areAllDisplayedRowsSelected()) {
2694
+ this.engine.clearSelection()
2695
+ this.announceScreenReader('All displayed rows deselected.')
2696
+ } else {
2697
+ this.engine.selectAllRows()
2698
+ this.announceAllRowsSelected()
2699
+ }
2700
+ this.requestUpdate()
2701
+ }
2702
+
2703
+ get inferColumns() {
2704
+ return this._inferColumns
2705
+ }
2706
+
2707
+ set inferColumns(value: boolean) {
2708
+ if (this._inferColumns === value) return
2709
+ this._inferColumns = value
2710
+ this.requestUpdate()
2711
+ }
2712
+
2713
+ /** Reconcile an explicit schema, optionally switching its persistence namespace atomically. */
2714
+ updateColumnSchema(value: GridColumn[], applyOrder = false, storageKey = this._columnStateStorageKey) {
2715
+ const columns = value.map(column => ({ ...column }))
2716
+ const nextByKey = new Map(columns.map(column => [column.key, column]))
2717
+ if (nextByKey.size !== columns.length) throw new Error('Duplicate column schema keys.')
2718
+ const previousByKey = new Map(this.sourceColumnDefs.map(column => [column.key, column]))
2719
+ const activeCell = this.activeCell
2720
+ const activeColumnKey = activeCell
2721
+ ? this.getVisibleColumns()[activeCell.columnIndex]?.key
2722
+ : undefined
2723
+ const nextStorageKey = typeof storageKey === 'string' && storageKey.trim() ? storageKey : null
2724
+ const storageChanged = nextStorageKey !== this._columnStateStorageKey
2725
+
2726
+ this.finishColumnResize(false)
2727
+ this.finishColumnReorder(false)
2728
+ if (storageChanged) {
2729
+ // Install the target schema and defaults before restoring its namespace.
2730
+ // Do not reconcile/persist the outgoing view, or carry its customization
2731
+ // into an unsaved target. A namespace switch itself performs no writes.
2732
+ this._columnStateStorageKey = nextStorageKey
2733
+ this.sourceColumnDefs = columns
2734
+ this.columnDefs = [...columns]
2735
+ this.columnWidths.clear()
2736
+ this.hiddenColumnKeys = new Set(columns.filter(column => column.hidden).map(column => column.key))
2737
+ this.restorePersistedColumnState(false)
2738
+ } else {
2739
+ for (const key of this.columnWidths.keys()) {
2740
+ if (!nextByKey.has(key)) this.columnWidths.delete(key)
2741
+ }
2742
+ for (const key of this.hiddenColumnKeys) {
2743
+ if (!nextByKey.has(key)) this.hiddenColumnKeys.delete(key)
2744
+ }
2745
+ for (const column of columns) {
2746
+ const previous = previousByKey.get(column.key)
2747
+ if (!previous || previous.width !== column.width) this.columnWidths.delete(column.key)
2748
+ if (!previous || previous.hidden !== column.hidden) {
2749
+ if (column.hidden) this.hiddenColumnKeys.add(column.key)
2750
+ else this.hiddenColumnKeys.delete(column.key)
2751
+ }
2752
+ }
2753
+
2754
+ // Surviving columns keep user order; newly registered columns follow them.
2755
+ // An explicit Order change reapplies the complete deterministic schema order.
2756
+ const retained = this.columnDefs.filter(column => nextByKey.has(column.key))
2757
+ .map(column => nextByKey.get(column.key)!)
2758
+ const retainedKeys = new Set(retained.map(column => column.key))
2759
+ this.columnDefs = applyOrder ? columns : [
2760
+ ...retained, ...columns.filter(column => !retainedKeys.has(column.key))
2761
+ ]
2762
+ this.sourceColumnDefs = columns
2763
+ this.persistColumnState()
2764
+ }
2765
+ this.clearBestFitWidths()
2766
+ if (activeCell) {
2767
+ const columnIndex = this.getVisibleColumns().findIndex(column => column.key === activeColumnKey)
2768
+ this.activeCell = columnIndex < 0 ? null : { ...activeCell, columnIndex }
2769
+ }
2770
+ this.requestUpdate()
2771
+ }
2772
+
2773
+ set columns(value: GridColumn[]) {
2774
+ const activeCell = this.activeCell
2775
+ this.sourceColumnDefs = Array.isArray(value) ? [...value] : []
2776
+ this.columnDefs = [...this.sourceColumnDefs]
2777
+ this.hiddenColumnKeys = new Set(this.sourceColumnDefs.filter((column) => column.hidden).map((column) => column.key))
2778
+
2779
+ this.clearBestFitWidths()
2780
+ this.restorePersistedColumnState()
2781
+ // Structural synchronization must not discard navigation. This also
2782
+ // restores the position after persisted column state has been applied.
2783
+ this.activeCell = activeCell
2784
+ this.requestUpdate()
2785
+ }
2786
+
2787
+ get columnStateStorageKey() {
2788
+ return this._columnStateStorageKey
2789
+ }
2790
+
2791
+ set columnStateStorageKey(value: string | null) {
2792
+ const nextKey = typeof value === 'string' && value.trim() ? value : null
2793
+ if (this._columnStateStorageKey === nextKey) return
2794
+
2795
+ this._columnStateStorageKey = nextKey
2796
+ this.restorePersistedColumnState()
2797
+ }
2798
+
2799
+ set config(value: GridConfig) {
2800
+
2801
+ this.engine.setConfig(value ?? {})
2802
+ this.requestUpdate()
2803
+ }
2804
+
2805
+ set viewportHeight(value: number) {
2806
+ this.updateVirtualizer({
2807
+ viewportHeight: Number(value)
2808
+ })
2809
+ this.requestUpdate()
2810
+ }
2811
+
2812
+ set overscanCount(value: number) {
2813
+ this.updateVirtualizer({
2814
+ overscan: Number(value)
2815
+ })
2816
+ this.requestUpdate()
2817
+ }
2818
+
2819
+ set columnOverscanCount(value: number) {
2820
+ this.columnOverscan = Number(value)
2821
+ this.requestUpdate()
2822
+ }
2823
+
2824
+ set virtualRowHeight(value: number) {
2825
+ this.rowHeight = Number(value)
2826
+ this.requestUpdate()
2827
+ }
2828
+
2829
+ private handleScroll = (event: Event) => {
2830
+ const target = event.currentTarget as HTMLElement | null
2831
+ const scrollLeft = target?.scrollLeft ?? 0
2832
+ const scrollTop = target?.scrollTop ?? 0
2833
+
2834
+ this.pendingScrollTop = scrollTop
2835
+ this.pendingScrollLeft = scrollLeft
2836
+ this.horizontalScrollLeft = scrollLeft
2837
+
2838
+ this.headerRowElement = this.headerRowElement ?? (this.renderRoot.querySelector('.header-column-row') as HTMLElement | null)
2839
+ if (this.headerRowElement) {
2840
+ this.headerRowElement.style.transform = `translateX(${-scrollLeft}px)`
2841
+ }
2842
+
2843
+ if (this.scrollRAFId !== null) {
2844
+ return
2845
+ }
2846
+
2847
+ this.scrollRAFId = requestAnimationFrame(() => {
2848
+ this.scrollRAFId = null
2849
+ const rowVirtualizer = this.hasCustomRowHeights()
2850
+ ? this.variableRowVirtualizer
2851
+ : this.fixedRowVirtualizer
2852
+
2853
+ // Map the visible scrollTop (capped display space) to the virtualizer's
2854
+ // real scroll offset when we've capped the spacer height for very large grids.
2855
+ // Use the measured `lastDisplayTotal` when available, otherwise fall
2856
+ // back to the intended display size (min(totalSize, maxScrollableHeight)).
2857
+ const measuredDisplay = this.lastDisplayTotal > 0 ? this.lastDisplayTotal : Math.min(this.lastTotalSize || 0, this.maxScrollableHeight)
2858
+ const viewportH = Math.max(0, this.height || 0)
2859
+ const scrollMapping = mapDisplayScrollOffset({
2860
+ displayScrollOffset: this.pendingScrollTop,
2861
+ previousDisplayScrollOffset: this.previousPendingScrollTop,
2862
+ displaySize: measuredDisplay,
2863
+ totalSize: this.lastTotalSize,
2864
+ viewportSize: viewportH
2865
+ })
2866
+
2867
+
2868
+ rowVirtualizer.setScrollOffset(scrollMapping.scrollOffset)
2869
+ this.columnVirtualizer.setScrollOffset(this.pendingScrollLeft)
2870
+ // track last applied pending scroll so we can detect scroll direction
2871
+ this.previousPendingScrollTop = this.pendingScrollTop
2872
+ this.requestUpdate()
2873
+ })
2874
+ }
2875
+
2876
+ render() {
2877
+ const count = this.engine.getRowCount()
2878
+ const pagination = this.engine.getPagination()
2879
+ const external = this.engine.getConfig().queryMode === 'external'
2880
+ const externalState = external ? this.engine.getExternalState() : null
2881
+ const outOfRange = external && pagination.totalKnown && pagination.pageIndex > 0 && pagination.pageIndex >= pagination.pageCount
2882
+ const externalStatus = externalState?.loading ? 'Loading results.' : externalState?.error
2883
+ ? externalState.error : outOfRange ? 'Requested page is out of range. Choose a previous page.'
2884
+ : count === 0 ? (pagination.totalRows === 0 ? 'No results.' : 'No rows on this page.') : ''
2885
+ const pageSizeOptions = [25, 50, 100].includes(pagination.pageSize)
2886
+ ? [25, 50, 100]
2887
+ : [pagination.pageSize, 25, 50, 100].sort((left, right) => left - right)
2888
+ const useVariableRows = this.hasCustomRowHeights()
2889
+
2890
+ if (useVariableRows) {
2891
+ const rowSizes = this.getRowSizes()
2892
+ this.variableRowVirtualizer.setOptions({
2893
+ sizes: rowSizes,
2894
+ viewportSize: this.height,
2895
+ overscan: this.overscan
2896
+ })
2897
+ } else {
2898
+ this.fixedRowVirtualizer.setOptions({
2899
+ rowHeight: this.rowHeight,
2900
+ viewportHeight: this.height,
2901
+ overscan: this.overscan
2902
+ })
2903
+ }
2904
+
2905
+ const range = useVariableRows
2906
+ ? this.variableRowVirtualizer.getState()
2907
+ : this.fixedRowVirtualizer.getState(count)
2908
+ const totalSize = range.totalSize
2909
+ // Intended display size (what we *want* the spacer to be)
2910
+ const intendedDisplayTotal = Math.min(totalSize, this.maxScrollableHeight)
2911
+ // A transformed row-count change invalidates any prior measured spacer
2912
+ // height. Render the new intended height first; `updated()` will replace
2913
+ // it only if the browser clamps the spacer in the DOM.
2914
+ const totalSizeChanged = totalSize !== this.lastTotalSize
2915
+ // If we've measured a clamped spacer height in `updated()`, prefer
2916
+ // the measured value when it's smaller than the intended display size.
2917
+ const displayTotal = !totalSizeChanged && this.lastDisplayTotal > 0 && this.lastDisplayTotal < intendedDisplayTotal
2918
+ ? this.lastDisplayTotal
2919
+ : intendedDisplayTotal
2920
+ // Compute displayed offset for the content by scaling the virtualizer's
2921
+ // `offsetTop` into display coordinates. This keeps the translated content
2922
+ // aligned with the virtualizer even when the browser clamps the spacer.
2923
+ const virtualOffsetTop = range.offsetTop
2924
+ const renderedSize = range.visibleSize
2925
+ const bottomPadding = range.bottomPadding
2926
+ const { displayBottomPadding, displayOffsetTop, displayScale } = calculateDisplayLayout({
2927
+ totalSize,
2928
+ displaySize: displayTotal,
2929
+ virtualOffsetTop,
2930
+ visibleSize: renderedSize,
2931
+ bottomPadding
2932
+ })
2933
+
2934
+ // Keep scroll mapping in sync with the newly rendered spacer until a
2935
+ // browser measurement provides a lower, clamped height.
2936
+ this.lastTotalSize = totalSize
2937
+ if (totalSizeChanged || !this.lastDisplayTotal) {
2938
+ this.lastDisplayTotal = displayTotal
2939
+ }
2940
+ // Ensure start/end indices are within valid bounds to avoid empty renders
2941
+ const safeStartIndex = Math.max(0, Math.min(range.startIndex, Math.max(0, count)))
2942
+ let renderedRowStart = safeStartIndex
2943
+ const safeEndIndex = Math.max(safeStartIndex, Math.min(range.endIndex, count))
2944
+ let rows = this.engine.getVisibleRows(safeStartIndex, safeEndIndex)
2945
+ if (rows.length === 0 && count > 0) {
2946
+ // If we ended up with no rows (edge-case due to rounding/clamping),
2947
+ // request the last page of rows as a fallback so the grid never appears empty.
2948
+ const visibleCountFallback = Math.max(1, Math.ceil(this.height / Math.max(1, this.rowHeight)))
2949
+ const fallbackStart = Math.max(0, count - visibleCountFallback)
2950
+ const fallbackEnd = count
2951
+ rows = this.engine.getVisibleRows(fallbackStart, fallbackEnd)
2952
+ renderedRowStart = fallbackStart
2953
+ }
2954
+ const columns = this.getVisibleColumns()
2955
+ const columnWidths = columns.map((column) => this.getColumnWidth(column))
2956
+ const rowHeaderWidth = this.getRowHeaderWidth()
2957
+
2958
+ this.columnVirtualizer.setOptions({
2959
+ sizes: columnWidths,
2960
+ viewportSize: Math.max(0, this.viewportWidth - rowHeaderWidth),
2961
+ overscan: this.columnOverscan
2962
+ })
2963
+
2964
+ const columnRange = this.columnVirtualizer.getState()
2965
+ const visibleColumns = columns.slice(columnRange.startIndex, columnRange.endIndex)
2966
+ this.reconcileActiveCell(count, columns.length)
2967
+ const activeCellIsRendered = this.activeCell !== null
2968
+ && this.activeCell.rowIndex >= renderedRowStart
2969
+ && this.activeCell.rowIndex < renderedRowStart + rows.length
2970
+ && this.activeCell.columnIndex >= columnRange.startIndex
2971
+ && this.activeCell.columnIndex < columnRange.endIndex
2972
+ this.renderedActiveCellId = activeCellIsRendered && this.activeCell
2973
+ ? this.getGridCellId(this.activeCell.rowIndex, this.activeCell.columnIndex)
2974
+ : null
2975
+ if (!activeCellIsRendered || this.renderedRowStart !== renderedRowStart || this.renderedColumnStart !== columnRange.startIndex) {
2976
+ // Preserve logical navigation when the cell leaves either rendered range.
2977
+ // A focused node can also be reused for another cell when the range moves.
2978
+ // Hand off once; later scroll renders don't touch focus on the viewport.
2979
+ this.shouldFocusViewport = this.gridCellHasFocus
2980
+ }
2981
+ this.renderedRowStart = renderedRowStart
2982
+ this.renderedColumnStart = columnRange.startIndex
2983
+ const visibleColumnWidths = columnWidths.slice(
2984
+ columnRange.startIndex,
2985
+ columnRange.endIndex
2986
+ )
2987
+ const isFirstVisibleColumn = (column: GridColumn, index: number) => index === 0
2988
+ const gridTemplate = [
2989
+ ...(this.isRowHeaderVisible() ? [`${rowHeaderWidth}px`] : []),
2990
+ `${columnRange.leftPadding}px`,
2991
+ ...visibleColumnWidths.map((width) => `${width}px`),
2992
+ `${columnRange.rightPadding}px`
2993
+ ].join(' ')
2994
+ const columnGridTemplate = [
2995
+ `${columnRange.leftPadding}px`,
2996
+ ...visibleColumnWidths.map((width) => `${width}px`),
2997
+ `${columnRange.rightPadding}px`
2998
+ ].join(' ')
2999
+ const totalColumnWidth = columnRange.totalSize
3000
+ const totalRowWidth = totalColumnWidth + rowHeaderWidth
3001
+ const headerGridTemplate = this.isRowHeaderVisible()
3002
+ ? `${rowHeaderWidth}px minmax(0, 1fr) ${this.scrollbarWidth}px`
3003
+ : `minmax(0, 1fr) ${this.scrollbarWidth}px`
3004
+
3005
+ return html`
3006
+ <div
3007
+ class="shell"
3008
+ aria-busy=${external ? String(!!externalState?.loading) : nothing}
3009
+ 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;`}
3010
+ @keydown=${this.handleShellKeyDown}
3011
+ >
3012
+ <p id=${`${this.gridId}-description`} class="screen-reader-only">${this.getScreenReaderDescription()}</p>
3013
+ ${external && externalStatus ? html`<div class="external-status" role=${externalState?.error ? 'alert' : 'status'}>${externalStatus}</div>` : nothing}
3014
+ ${this.screenReaderAnnouncements
3015
+ ? html`<div class="screen-reader-only" role="status" aria-live="polite" aria-atomic="true">${this.screenReaderMessage}</div>`
3016
+ : ''}
3017
+ ${this.quickSearchVisible || external
3018
+ ? html`
3019
+ <div class="quick-search" role="search">
3020
+ <input
3021
+ aria-label="Quick search"
3022
+ placeholder="Search all columns"
3023
+ .value=${this.quickSearchDraft}
3024
+ @input=${(event: Event) =>
3025
+ this.handleQuickSearchInput((event.target as HTMLInputElement).value)}
3026
+ />
3027
+ <button type="button" @click=${() => this.clearQuickSearch()}>Clear</button>
3028
+ </div>
3029
+ `
3030
+ : ''}
3031
+ ${this.columnChooserOpen
3032
+ ? html`<div
3033
+ class="column-chooser-dialog"
3034
+ role="dialog"
3035
+ aria-label="Columns"
3036
+ style=${this.columnChooserPosition
3037
+ ? `left: ${this.columnChooserPosition.left}px; top: ${this.columnChooserPosition.top}px; right: auto;`
3038
+ : ''}
3039
+ >
3040
+ <button class="column-chooser-close" type="button" aria-label="Close columns" @click=${() => this.closeColumnChooser(true)}>×</button>
3041
+ ${this.getResolvedColumns().map((column) => html`
3042
+ <label>
3043
+ <input
3044
+ type="checkbox"
3045
+ .checked=${this.isColumnVisible(column.key)}
3046
+ @change=${(event: Event) => this.setColumnVisible(
3047
+ column.key,
3048
+ (event.target as HTMLInputElement).checked
3049
+ )}
3050
+ />
3051
+ ${column.header ?? column.key}
3052
+ </label>
3053
+ `)}
3054
+ </div>`
3055
+ : ''}
3056
+ <div class="header-shell">
3057
+ ${this.isRowHeaderVisible()
3058
+ ? html`
3059
+ <div id=${this.getRowHeaderColumnHeaderId()} class="row-header-cell header-row-header" role="columnheader" aria-colindex="1">
3060
+ ${this.hasCheckboxSelection()
3061
+ ? html`<input
3062
+ class="selection-checkbox"
3063
+ type="checkbox"
3064
+ aria-label="Select all displayed rows"
3065
+ .checked=${this.areAllDisplayedRowsSelected()}
3066
+ .indeterminate=${this.hasPartiallySelectedDisplayedRows()}
3067
+ ?disabled=${this.engine.getSelectableRowCount() === 0}
3068
+ @change=${() => this.handleSelectAllRows()}
3069
+ />`
3070
+ : html`<span>#</span>`}
3071
+ </div>
3072
+ `
3073
+ : ''}
3074
+ <div class="header-clip">
3075
+ <div
3076
+ id=${this.getHeaderRowId()}
3077
+ class="header-column-row"
3078
+ role="row"
3079
+ aria-rowindex="1"
3080
+ aria-owns=${this.isRowHeaderVisible() ? this.getRowHeaderColumnHeaderId() : undefined}
3081
+ style=${`grid-template-columns: ${columnGridTemplate}; transform: translateX(${-this.horizontalScrollLeft}px);`}
3082
+ >
3083
+ <div class="column-spacer" aria-hidden="true"></div>
3084
+ ${visibleColumns.map(
3085
+ (column, columnIndex) => html`
3086
+ <div
3087
+ class=${this.getHeaderCellClass(column)}
3088
+ role="columnheader"
3089
+ aria-colindex=${this.getAriaColumnIndex(columnRange.startIndex + columnIndex)}
3090
+ aria-sort=${this.getHeaderSortDirection(column.key)}
3091
+ data-column-key=${column.key}
3092
+ @pointerdown=${(event: PointerEvent) => this.startColumnReorder(event, column)}
3093
+ @click=${() => this.closeHeaderMenu()}
3094
+ >
3095
+ <span class="header-label">${column.header ?? column.key}</span>
3096
+ ${this.isColumnFiltered(column.key)
3097
+ ? html`<span class="header-filter-indicator" aria-label="Filter active">●</span>`
3098
+ : ''}
3099
+ <div class="header-actions" @click=${(event: Event) => event.stopPropagation()}>
3100
+ <button
3101
+ class="header-action-button"
3102
+ @click=${(event: MouseEvent) => this.toggleHeaderMenu(column.key, event)}
3103
+ aria-label="Show column actions"
3104
+ aria-haspopup="dialog"
3105
+ aria-controls=${this.getHeaderMenuId(column.key)}
3106
+ aria-expanded=${String(this.activeHeaderMenu === column.key)}
3107
+ type="button"
3108
+ >⋯</button>
3109
+ ${this.activeHeaderMenu === column.key
3110
+ ? this.columnChooserOpen
3111
+ ? html`
3112
+ <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) => event.stopPropagation()}>
3113
+ <button class="column-chooser-close" type="button" aria-label="Close columns" @click=${() => this.closeColumnChooser(true)}>×</button>
3114
+ ${this.getResolvedColumns().map((column) => html`
3115
+ <label>
3116
+ <input
3117
+ type="checkbox"
3118
+ .checked=${this.isColumnVisible(column.key)}
3119
+ @change=${(event: Event) => this.setColumnVisible(
3120
+ column.key,
3121
+ (event.target as HTMLInputElement).checked
3122
+ )}
3123
+ />
3124
+ ${column.header ?? column.key}
3125
+ </label>
3126
+ `)}
3127
+ </div>
3128
+ `
3129
+ : html`
3130
+ <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) => event.stopPropagation()}>
3131
+ <button type="button" @click=${() => this.toggleQuickSearch()}>
3132
+ ${this.quickSearchVisible ? 'Hide Quick Search' : 'Show Quick Search'}
3133
+ </button>
3134
+ <hr class="header-menu-divider" />
3135
+ ${column.sortable === false ? '' : html`
3136
+ <button type="button" @click=${() => this.handleSortAscending(column.key)}>
3137
+ Sort Asc
3138
+ </button>
3139
+ <button type="button" @click=${() => this.handleSortDescending(column.key)}>
3140
+ Sort Desc
3141
+ </button>
3142
+ <button type="button" @click=${() => this.handleClearSort(column.key)}>
3143
+ Clear Sort
3144
+ </button>
3145
+ `}
3146
+ <button type="button" @click=${(event: MouseEvent) => this.toggleColumnChooser(event)}>
3147
+ Columns
3148
+ </button>
3149
+ ${(() => {
3150
+ if (column.filterable === false) return ''
3151
+ const draft = this.getFilterDraft(column.key)
3152
+ const valueFree = this.isValueFreeFilter(draft.operator)
3153
+
3154
+ return html`
3155
+ <div class="header-filter">
3156
+ <select
3157
+ aria-label="Filter operator"
3158
+ @change=${(event: Event) => this.updateFilterDraft(
3159
+ column.key,
3160
+ { operator: (event.target as HTMLSelectElement).value as FilterOperator }
3161
+ )}
3162
+ >
3163
+ ${FILTER_OPERATORS.map((operator) => html`
3164
+ <option
3165
+ value=${operator.value}
3166
+ ?selected=${operator.value === draft.operator}
3167
+ >${operator.label}</option>
3168
+ `)}
3169
+ </select>
3170
+ ${valueFree
3171
+ ? ''
3172
+ : html`<input
3173
+ aria-label="Filter value"
3174
+ .value=${String(draft.value ?? '')}
3175
+ @input=${(event: Event) => this.updateFilterDraft(
3176
+ column.key,
3177
+ { value: (event.target as HTMLInputElement).value }
3178
+ )}
3179
+ />`}
3180
+ <div class="header-filter-actions">
3181
+ <button type="button" @click=${() => this.applyHeaderFilter(column.key)}>
3182
+ Apply Filter
3183
+ </button>
3184
+ <button type="button" @click=${() => this.clearHeaderFilter(column.key)}>
3185
+ Clear Filter
3186
+ </button>
3187
+ </div>
3188
+ </div>
3189
+ `
3190
+ })()}
3191
+ <hr class="header-menu-divider" />
3192
+ <button type="button" @click=${() => this.handleBestFit(column.key)}>
3193
+ Best Fit
3194
+ </button>
3195
+ <button type="button" @click=${() => this.handleBestFitAllColumns()}>
3196
+ Best Fit All Columns
3197
+ </button>
3198
+ </div>
3199
+ `
3200
+ : ''}
3201
+ </div>
3202
+ <span
3203
+ class="resize-handle"
3204
+ aria-hidden="true"
3205
+ @pointerdown=${(event: PointerEvent) =>
3206
+ this.startColumnResize(event, column)}
3207
+ ></span>
3208
+ </div>
3209
+ `
3210
+ )}
3211
+ <div class="column-spacer" aria-hidden="true"></div>
3212
+ </div>
3213
+ </div>
3214
+ <div class="header-gutter"></div>
3215
+ </div>
3216
+ ${visibleColumns.length === 0
3217
+ ? html`<div class="column-chooser-empty">
3218
+ <button class="columns-button" type="button" @click=${(event: MouseEvent) => this.toggleColumnChooser(event)}>Columns</button>
3219
+ </div>`
3220
+ : ''}
3221
+ <div
3222
+ class="viewport"
3223
+ role="grid"
3224
+ aria-multiselectable=${['multi-row', 'multi-cell'].includes(this.engine.getConfig().selection.mode) ? 'true' : nothing}
3225
+ aria-label=${this.ariaLabel || 'Data grid'}
3226
+ aria-rowcount=${count + 1}
3227
+ aria-colcount=${columns.length + (this.isRowHeaderVisible() ? 1 : 0)}
3228
+ aria-activedescendant=${this.renderedActiveCellId ?? nothing}
3229
+ aria-describedby=${`${this.gridId}-description`}
3230
+ aria-owns=${this.getHeaderRowId()}
3231
+ style=${`--grid-height: ${this.height}px;`}
3232
+ tabindex="0"
3233
+ @scroll=${this.handleScroll}
3234
+ @focus=${this.handleViewportFocus}
3235
+ @focusin=${this.handleGridFocusIn}
3236
+ @focusout=${() => { this.gridCellHasFocus = false }}
3237
+
3238
+ @keydown=${this.handleKeyDown}
3239
+ @click=${() => this.closeHeaderMenu()}
3240
+
3241
+ >
3242
+ <div style=${`height: ${displayTotal}px;`}>
3243
+ <div
3244
+ class="content"
3245
+ style=${`--offset-top: ${displayOffsetTop}px; padding-bottom: ${displayBottomPadding}px;`}
3246
+ >
3247
+ ${rows.map((row, index) => {
3248
+ const rowIndex = renderedRowStart + index
3249
+ const height = this.getRowHeight(rowIndex)
3250
+
3251
+ return html`
3252
+ <div
3253
+ class=${this.getRowClass(rowIndex)}
3254
+ role="row"
3255
+ aria-rowindex=${rowIndex + 2}
3256
+ aria-selected=${this.isRowSelectionMode() ? String(this.engine.isRowSelected(rowIndex)) : undefined}
3257
+ style=${`height: ${height}px;`}
3258
+ @mousedown=${this.handleRowMouseDown}
3259
+ @click=${(event: MouseEvent) =>
3260
+ this.handleRowClick(event, rowIndex)}
3261
+ >
3262
+ ${this.renderRowHeader(rowIndex)}
3263
+ <div class="column-spacer" aria-hidden="true"></div>
3264
+ ${visibleColumns.map((column) =>
3265
+ this.renderCell(column, row, rowIndex)
3266
+ )}
3267
+ <div class="column-spacer" aria-hidden="true"></div>
3268
+ </div>
3269
+ `
3270
+ })}
3271
+ </div>
3272
+ </div>
3273
+ </div>
3274
+ ${pagination.enabled
3275
+ ? html`
3276
+ <div class="pagination" role="navigation" aria-label="Pagination">
3277
+ <div class="pagination-summary">
3278
+ <span>${pagination.totalKnown === false ? 'Total unknown' : `${pagination.totalRows} rows`}</span>
3279
+ <label>
3280
+ Rows per page
3281
+ <select
3282
+ aria-label="Rows per page"
3283
+ @change=${(event: Event) =>
3284
+ this.setPageSize(Number((event.target as HTMLSelectElement).value))}
3285
+ >
3286
+ ${pageSizeOptions.map((pageSize) => html`
3287
+ <option value=${pageSize} ?selected=${pageSize === pagination.pageSize}>
3288
+ ${pageSize}
3289
+ </option>
3290
+ `)}
3291
+ </select>
3292
+ </label>
3293
+ </div>
3294
+ <div class="pagination-navigation">
3295
+ <span>${this.getPageLabel()}</span>
3296
+ <button
3297
+ type="button"
3298
+ ?disabled=${pagination.pageIndex === 0}
3299
+ @click=${() => this.setPage(pagination.pageIndex - 1)}
3300
+ >Previous</button>
3301
+ <button
3302
+ type="button"
3303
+ ?disabled=${pagination.totalKnown === false ? !pagination.hasMore : pagination.pageCount === 0 || pagination.pageIndex >= pagination.pageCount - 1}
3304
+ @click=${() => this.setPage(pagination.pageIndex + 1)}
3305
+ >Next</button>
3306
+ </div>
3307
+ </div>
3308
+ `
3309
+ : ''}
3310
+ </div>
3311
+ `
3312
+ }
3313
+ }
3314
+
3315
+ if (!customElements.get('yc-grid')) {
3316
+ customElements.define('yc-grid', DataGrid)
3317
+ }