@tipolox/litgrid-web 1.0.3 → 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.
- package/README.md +1 -0
- package/dist/components/DataGrid.d.ts +7 -0
- package/dist/components/DataGrid.d.ts.map +1 -1
- package/dist/components/DataGrid.js +109 -27
- package/dist/components/DataGrid.js.map +1 -1
- package/dist/types.d.ts +4 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +8 -4
- package/src/DataGrid.ts +1 -0
- package/src/clipboard/serializeSelectedCells.ts +62 -0
- package/src/clipboard/serializeSelectedRows.ts +42 -0
- package/src/columnState/columnState.ts +102 -0
- package/src/components/DataGrid.ts +3188 -0
- package/src/index.ts +16 -0
- package/src/sizing/columnSizing.ts +25 -0
- package/src/sizing/constants.ts +3 -0
- package/src/sizing/rowSizing.ts +5 -0
- package/src/theme/darkTheme.ts +34 -0
- package/src/theme/lightTheme.ts +35 -0
- package/src/types.ts +108 -0
|
@@ -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
|
+
}
|