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