@dxos/lit-grid 0.6.12 → 0.6.13-main.09887cd

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/src/dx-grid-axis-resize-handle.d.ts +16 -0
  2. package/dist/src/dx-grid-axis-resize-handle.d.ts.map +1 -0
  3. package/dist/src/dx-grid-axis-resize-handle.js +96 -0
  4. package/dist/src/dx-grid-axis-resize-handle.js.map +1 -0
  5. package/dist/src/dx-grid.d.ts +117 -0
  6. package/dist/src/dx-grid.d.ts.map +1 -0
  7. package/dist/src/dx-grid.js +1070 -0
  8. package/dist/src/dx-grid.js.map +1 -0
  9. package/dist/src/dx-grid.lit-stories.d.ts +42 -0
  10. package/dist/src/dx-grid.lit-stories.d.ts.map +1 -0
  11. package/dist/src/dx-grid.lit-stories.js +166 -0
  12. package/dist/src/dx-grid.lit-stories.js.map +1 -0
  13. package/dist/src/index.d.ts +3 -0
  14. package/dist/src/index.d.ts.map +1 -0
  15. package/dist/src/index.js +6 -0
  16. package/dist/src/index.js.map +1 -0
  17. package/dist/src/types.d.ts +110 -0
  18. package/dist/src/types.d.ts.map +1 -0
  19. package/dist/src/types.js +44 -0
  20. package/dist/src/types.js.map +1 -0
  21. package/dist/src/util.d.ts +9 -0
  22. package/dist/src/util.d.ts.map +1 -0
  23. package/dist/src/util.js +19 -0
  24. package/dist/src/util.js.map +1 -0
  25. package/dist/types/src/dx-grid-axis-resize-handle.d.ts +16 -0
  26. package/dist/types/src/dx-grid-axis-resize-handle.d.ts.map +1 -0
  27. package/dist/types/src/dx-grid-axis-resize-handle.js +96 -0
  28. package/dist/types/src/dx-grid-axis-resize-handle.js.map +1 -0
  29. package/dist/types/src/dx-grid.d.ts +95 -61
  30. package/dist/types/src/dx-grid.d.ts.map +1 -1
  31. package/dist/types/src/dx-grid.js +1070 -0
  32. package/dist/types/src/dx-grid.js.map +1 -0
  33. package/dist/types/src/dx-grid.lit-stories.d.ts +27 -2
  34. package/dist/types/src/dx-grid.lit-stories.d.ts.map +1 -1
  35. package/dist/types/src/dx-grid.lit-stories.js +166 -0
  36. package/dist/types/src/dx-grid.lit-stories.js.map +1 -0
  37. package/dist/types/src/index.js +6 -0
  38. package/dist/types/src/index.js.map +1 -0
  39. package/dist/types/src/types.d.ts +102 -1
  40. package/dist/types/src/types.d.ts.map +1 -1
  41. package/dist/types/src/types.js +44 -0
  42. package/dist/types/src/types.js.map +1 -0
  43. package/dist/types/src/util.d.ts +9 -0
  44. package/dist/types/src/util.d.ts.map +1 -0
  45. package/dist/types/src/util.js +19 -0
  46. package/dist/types/src/util.js.map +1 -0
  47. package/package.json +5 -6
  48. package/src/dx-grid-axis-resize-handle.ts +87 -0
  49. package/src/dx-grid.lit-stories.ts +148 -21
  50. package/src/dx-grid.pcss +72 -68
  51. package/src/dx-grid.ts +934 -329
  52. package/src/types.ts +139 -1
  53. package/src/util.ts +28 -0
  54. package/dist/lib/browser/index.mjs +0 -578
  55. package/dist/lib/browser/index.mjs.map +0 -7
  56. package/dist/lib/browser/meta.json +0 -1
package/src/dx-grid.ts CHANGED
@@ -2,11 +2,42 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { LitElement, html } from 'lit';
6
- import { customElement, state, property, eventOptions } from 'lit/decorators.js';
5
+ import { LitElement, html, nothing } from 'lit';
6
+ import { customElement, state, property } from 'lit/decorators.js';
7
7
  import { ref, createRef, type Ref } from 'lit/directives/ref.js';
8
-
9
- import { DxAxisResize, type DxAxisResizeProps, type DxGridAxis } from './types';
8
+ import { styleMap } from 'lit/directives/style-map.js';
9
+ import { unsafeStatic, html as staticHtml } from 'lit/static-html.js';
10
+
11
+ // eslint-disable-next-line unused-imports/no-unused-imports
12
+ import './dx-grid-axis-resize-handle';
13
+ import {
14
+ type DxGridAxisMetaProps,
15
+ type DxGridAxisSizes,
16
+ type DxGridCellIndex,
17
+ type DxGridCellValue,
18
+ DxAxisResize,
19
+ type DxAxisResizeInternal,
20
+ DxEditRequest,
21
+ type DxGridAxisMeta,
22
+ type DxGridCells,
23
+ DxGridCellsSelect,
24
+ type DxGridFixedPlane,
25
+ type DxGridFrozenAxes,
26
+ type DxGridFrozenColsPlane,
27
+ type DxGridFrozenPlane,
28
+ type DxGridFrozenRowsPlane,
29
+ type DxGridMode,
30
+ type DxGridPlane,
31
+ type DxGridPlaneCells,
32
+ type DxGridPlaneRange,
33
+ type DxGridPlaneRecord,
34
+ type DxGridPointer,
35
+ type DxGridPosition,
36
+ type DxGridPositionNullable,
37
+ type DxGridAxis,
38
+ type DxGridSelectionProps,
39
+ } from './types';
40
+ import { separator, toCellIndex } from './util';
10
41
 
11
42
  /**
12
43
  * The size in pixels of the gap between cells
@@ -14,10 +45,14 @@ import { DxAxisResize, type DxAxisResizeProps, type DxGridAxis } from './types';
14
45
  const gap = 1;
15
46
 
16
47
  /**
17
- * This should be about the width of the `1` numeral so resize is triggered as the row header column’s intrinsic size
18
- * changes when scrolling vertically.
48
+ * ResizeObserver notices even subpixel changes, only respond to changes of at least 1px.
19
49
  */
20
- const resizeTolerance = 8;
50
+ const resizeTolerance = 1;
51
+
52
+ /**
53
+ * The amount of pixels the primary pointer has to move after PointerDown to engage in selection.
54
+ */
55
+ const selectTolerance = 4;
21
56
 
22
57
  //
23
58
  // `overscan` is the number of columns or rows to render outside of the viewport
@@ -33,116 +68,225 @@ const sizeColMax = 1024;
33
68
  const sizeRowMin = 16;
34
69
  const sizeRowMax = 1024;
35
70
 
36
- /**
37
- * Separator for serializing cell position vectors
38
- */
39
- const separator = ',';
71
+ const shouldSelect = (pointer: DxGridPointer, { pageX, pageY }: PointerEvent) => {
72
+ if (pointer?.state === 'maybeSelecting') {
73
+ return Math.hypot(Math.abs(pointer.pageX - pageX), Math.abs(pointer.pageY - pageY)) >= selectTolerance;
74
+ } else {
75
+ return false;
76
+ }
77
+ };
40
78
 
41
- //
42
- // A1 notation is the fallback for numbering columns and rows.
43
- //
79
+ const selectionProps = (selectionStart: DxGridPosition, selectionEnd: DxGridPosition): DxGridSelectionProps => {
80
+ const colMin = Math.min(selectionStart.col, selectionEnd.col);
81
+ const colMax = Math.max(selectionStart.col, selectionEnd.col);
82
+ const rowMin = Math.min(selectionStart.row, selectionEnd.row);
83
+ const rowMax = Math.max(selectionStart.row, selectionEnd.row);
84
+ const plane = selectionStart.plane;
85
+ const visible = colMin !== colMax || rowMin !== rowMax;
86
+ return { colMin, colMax, rowMin, rowMax, plane, visible };
87
+ };
44
88
 
45
- const colToA1Notation = (col: number): string => {
89
+ const cellSelected = (col: number, row: number, plane: DxGridPlane, selection: DxGridSelectionProps): boolean => {
46
90
  return (
47
- (col >= 26 ? String.fromCharCode('A'.charCodeAt(0) + Math.floor(col / 26) - 1) : '') +
48
- String.fromCharCode('A'.charCodeAt(0) + (col % 26))
91
+ plane === selection.plane &&
92
+ col >= selection.colMin &&
93
+ col <= selection.colMax &&
94
+ row >= selection.rowMin &&
95
+ row <= selection.rowMax
49
96
  );
50
97
  };
51
98
 
52
- const rowToA1Notation = (row: number): string => {
53
- return `${row + 1}`;
99
+ const closestAction = (target: EventTarget | null): { action: string | null; actionEl: HTMLElement | null } => {
100
+ const actionEl: HTMLElement | null = (target as HTMLElement | null)?.closest('[data-dx-grid-action]') ?? null;
101
+ return { actionEl, action: actionEl?.getAttribute('data-dx-grid-action') ?? null };
54
102
  };
55
103
 
56
- export type CellValue = {
57
- /**
58
- * The content value
59
- */
60
- value: string;
61
- /**
62
- * If this is a merged cell, the bottomright-most of the range in numeric notation, otherwise undefined.
63
- */
64
- end?: string;
65
- /**
66
- * CSS inline styles to apply to the gridcell element
67
- */
68
- style?: string;
104
+ export const closestCell = (target: EventTarget | null, actionEl?: HTMLElement | null): DxGridPositionNullable => {
105
+ let cellElement = actionEl;
106
+ if (!cellElement) {
107
+ const { action, actionEl } = closestAction(target);
108
+ if (action === 'cell') {
109
+ cellElement = actionEl as HTMLElement;
110
+ }
111
+ }
112
+ if (cellElement) {
113
+ const col = parseInt(cellElement.getAttribute('aria-colindex') ?? 'never');
114
+ const row = parseInt(cellElement.getAttribute('aria-rowindex') ?? 'never');
115
+ const plane = (cellElement.closest('[data-dx-grid-plane]')?.getAttribute('data-dx-grid-plane') ??
116
+ 'grid') as DxGridPlane;
117
+ return { plane, col, row };
118
+ } else {
119
+ return null;
120
+ }
121
+ };
122
+
123
+ const resolveRowPlane = (plane: DxGridPlane): 'grid' | DxGridFrozenRowsPlane => {
124
+ switch (plane) {
125
+ case 'fixedStartStart':
126
+ case 'fixedStartEnd':
127
+ case 'frozenRowsStart':
128
+ return 'frozenRowsStart';
129
+ case 'fixedEndStart':
130
+ case 'fixedEndEnd':
131
+ case 'frozenRowsEnd':
132
+ return 'frozenRowsEnd';
133
+ default:
134
+ return 'grid';
135
+ }
136
+ };
137
+
138
+ const resolveColPlane = (plane: DxGridPlane): 'grid' | DxGridFrozenColsPlane => {
139
+ switch (plane) {
140
+ case 'fixedStartStart':
141
+ case 'fixedEndStart':
142
+ case 'frozenColsStart':
143
+ return 'frozenColsStart';
144
+ case 'fixedStartEnd':
145
+ case 'fixedEndEnd':
146
+ case 'frozenColsEnd':
147
+ return 'frozenColsEnd';
148
+ default:
149
+ return 'grid';
150
+ }
69
151
  };
70
152
 
71
- type AxisMeta = {
72
- size: number;
73
- description?: string;
74
- resizeable?: boolean;
153
+ const resolveResizePlane = (resizeAxis: DxGridAxis, cellPlane: DxGridPlane): 'grid' | DxGridFrozenPlane => {
154
+ switch (cellPlane) {
155
+ case 'fixedStartStart':
156
+ return resizeAxis === 'col' ? 'frozenColsStart' : 'frozenRowsStart';
157
+ case 'fixedStartEnd':
158
+ return resizeAxis === 'col' ? 'frozenColsEnd' : 'frozenRowsStart';
159
+ case 'fixedEndStart':
160
+ return resizeAxis === 'col' ? 'frozenColsStart' : 'frozenRowsEnd';
161
+ case 'fixedEndEnd':
162
+ return resizeAxis === 'col' ? 'frozenColsEnd' : 'frozenRowsEnd';
163
+ case 'frozenColsStart':
164
+ case 'frozenColsEnd':
165
+ return resizeAxis === 'col' ? cellPlane : 'grid';
166
+ case 'frozenRowsStart':
167
+ case 'frozenRowsEnd':
168
+ return resizeAxis === 'row' ? cellPlane : 'grid';
169
+ default:
170
+ return cellPlane;
171
+ }
75
172
  };
76
173
 
77
- export type DxGridProps = Partial<Pick<DxGrid, 'cells' | 'rows' | 'columns' | 'rowDefault' | 'columnDefault'>>;
174
+ const isSameCell = (a: DxGridPositionNullable, b: DxGridPositionNullable) =>
175
+ a &&
176
+ b &&
177
+ a.plane === b.plane &&
178
+ Number.isFinite(a.col) &&
179
+ Number.isFinite(a.row) &&
180
+ a.col === b.col &&
181
+ a.row === b.row;
78
182
 
79
- const localChId = (c0: number) => `ch--${c0}`;
80
- const localRhId = (r0: number) => `rh--${r0}`;
183
+ const defaultRowSize = 32;
81
184
 
82
- const getPage = (axis: string, event: PointerEvent) => (axis === 'col' ? event.pageX : event.pageY);
185
+ const defaultColSize = 180;
83
186
 
84
187
  @customElement('dx-grid')
85
188
  export class DxGrid extends LitElement {
189
+ constructor() {
190
+ super();
191
+ this.addEventListener('dx-axis-resize-internal', this.handleAxisResizeInternal as EventListener);
192
+ this.addEventListener('wheel', this.handleWheel, { passive: true });
193
+ this.addEventListener('pointerdown', this.handlePointerDown);
194
+ this.addEventListener('pointermove', this.handlePointerMove);
195
+ this.addEventListener('pointerup', this.handlePointerUp);
196
+ this.addEventListener('pointerleave', this.handlePointerUp);
197
+ this.addEventListener('focus', this.handleFocus, { capture: true });
198
+ this.addEventListener('blur', this.handleBlur, { capture: true });
199
+ this.addEventListener('keydown', this.handleKeydown);
200
+ }
201
+
202
+ @property({ type: String })
203
+ gridId: string = 'default-grid-id';
204
+
86
205
  @property({ type: Object })
87
- rowDefault: AxisMeta = { size: 32 };
206
+ rowDefault: DxGridPlaneRecord<DxGridFrozenRowsPlane, DxGridAxisMetaProps> = {
207
+ grid: { size: defaultRowSize },
208
+ };
88
209
 
89
210
  @property({ type: Object })
90
- columnDefault: AxisMeta = { size: 180 };
211
+ columnDefault: DxGridPlaneRecord<DxGridFrozenColsPlane, DxGridAxisMetaProps> = {
212
+ grid: { size: defaultColSize },
213
+ };
91
214
 
92
215
  @property({ type: Object })
93
- rows: Record<string, AxisMeta> = {};
216
+ rows: DxGridAxisMeta = { grid: {} };
94
217
 
95
218
  @property({ type: Object })
96
- columns: Record<string, AxisMeta> = {};
219
+ columns: DxGridAxisMeta = { grid: {} };
97
220
 
98
221
  @property({ type: Object })
99
- cells: Record<string, CellValue> = {};
222
+ initialCells: DxGridCells = { grid: {} };
223
+
224
+ @property({ type: String })
225
+ mode: DxGridMode = 'browse';
226
+
227
+ @property({ type: Number })
228
+ limitColumns: number = Infinity;
229
+
230
+ @property({ type: Number })
231
+ limitRows: number = Infinity;
232
+
233
+ @property({ type: Object })
234
+ frozen: DxGridFrozenAxes = {};
235
+
236
+ /**
237
+ * When this function is defined, it is used first to try to get a value for a cell, and otherwise will fall back
238
+ * to `cells`.
239
+ */
240
+ getCells: ((nextRange: DxGridPlaneRange, plane: DxGridPlane) => DxGridPlaneCells) | null = null;
241
+
242
+ @state()
243
+ private cells: DxGridCells = { grid: {} };
100
244
 
101
245
  //
102
246
  // `pos`, short for ‘position’, is the position in pixels of the viewport from the origin.
103
247
  //
104
248
 
105
249
  @state()
106
- posInline = 0;
250
+ private posInline = 0;
107
251
 
108
252
  @state()
109
- posBlock = 0;
253
+ private posBlock = 0;
110
254
 
111
255
  //
112
256
  // `size` (when not suffixed with ‘row’ or ‘col’, see above) is the size in pixels of the viewport.
113
257
  //
114
258
 
115
259
  @state()
116
- sizeInline = 0;
260
+ private sizeInline = 0;
117
261
 
118
262
  @state()
119
- sizeBlock = 0;
263
+ private sizeBlock = 0;
120
264
 
121
265
  //
122
266
  // `overscan` is the amount in pixels to offset the grid content due to the number of overscanned columns or rows.
123
267
  //
124
268
 
125
269
  @state()
126
- overscanInline = 0;
270
+ private overscanInline = 0;
127
271
 
128
272
  @state()
129
- overscanBlock = 0;
273
+ private overscanBlock = 0;
130
274
 
131
275
  //
132
276
  // `bin`, not short for anything, is the range in pixels within which virtualization does not need to reassess.
133
277
  //
134
278
 
135
279
  @state()
136
- binInlineMin = 0;
280
+ private binInlineMin = 0;
137
281
 
138
282
  @state()
139
- binInlineMax = this.colSize(0);
283
+ private binInlineMax = defaultColSize;
140
284
 
141
285
  @state()
142
- binBlockMin = 0;
286
+ private binBlockMin = 0;
143
287
 
144
288
  @state()
145
- binBlockMax = this.rowSize(0);
289
+ private binBlockMax = defaultRowSize;
146
290
 
147
291
  //
148
292
  // `vis`, short for ‘visible’, is the range in numeric index of the columns or rows which should be rendered within
@@ -150,97 +294,229 @@ export class DxGrid extends LitElement {
150
294
  //
151
295
 
152
296
  @state()
153
- visColMin = 0;
297
+ private visColMin = 0;
154
298
 
155
299
  @state()
156
- visColMax = 1;
300
+ private visColMax = 1;
157
301
 
158
302
  @state()
159
- visRowMin = 0;
303
+ private visRowMin = 0;
160
304
 
161
305
  @state()
162
- visRowMax = 1;
306
+ private visRowMax = 1;
163
307
 
164
308
  //
165
309
  // `template` is the rendered value of `grid-{axis}-template`.
166
310
  //
167
311
  @state()
168
- templateColumns = `${this.colSize(0)}px`;
312
+ private templateGridColumns = '0';
313
+
314
+ @state()
315
+ private templatefrozenColsStart = '';
169
316
 
170
317
  @state()
171
- templateRows = `${this.rowSize(0)}px`;
318
+ private templatefrozenColsEnd = '';
319
+
320
+ @state()
321
+ private templateGridRows = '0';
322
+
323
+ @state()
324
+ private templatefrozenRowsStart = '';
325
+
326
+ @state()
327
+ private templatefrozenRowsEnd = '';
172
328
 
173
329
  //
174
- // Resize state and handlers
330
+ // Focus, selection, and resize states
175
331
  //
176
332
 
177
333
  @state()
178
- colSizes: Record<string, number> = {};
334
+ private pointer: DxGridPointer = null;
179
335
 
180
336
  @state()
181
- rowSizes: Record<string, number> = {};
337
+ private colSizes: DxGridAxisSizes = { grid: {} };
182
338
 
183
339
  @state()
184
- resizing: null | (DxAxisResizeProps & { page: number }) = null;
185
-
186
- handlePointerDown = (event: PointerEvent) => {
187
- const actionEl = (event.target as HTMLElement)?.closest('[data-dx-grid-action]');
188
- const action = actionEl?.getAttribute('data-dx-grid-action');
189
- if (action) {
190
- if (action.startsWith('resize')) {
191
- const [resize, index] = action.split(',');
192
- const [_, axis] = resize.split('-');
193
- this.resizing = {
194
- axis: axis as DxGridAxis,
195
- size: axis === 'col' ? this.colSize(index) : this.rowSize(index),
196
- page: getPage(axis, event),
197
- index,
198
- };
340
+ private rowSizes: DxGridAxisSizes = { grid: {} };
341
+
342
+ @state()
343
+ private focusActive: boolean = false;
344
+
345
+ @state()
346
+ private focusedCell: DxGridPosition = { plane: 'grid', col: 0, row: 0 };
347
+
348
+ @state()
349
+ private selectionStart: DxGridPosition = { plane: 'grid', col: 0, row: 0 };
350
+
351
+ @state()
352
+ private selectionEnd: DxGridPosition = { plane: 'grid', col: 0, row: 0 };
353
+
354
+ //
355
+ // Limits
356
+ //
357
+
358
+ @state()
359
+ private intrinsicInlineSize: number = Infinity;
360
+
361
+ @state()
362
+ private intrinsicBlockSize: number = Infinity;
363
+
364
+ //
365
+ // Primary pointer and keyboard handlers
366
+ //
367
+
368
+ private dispatchEditRequest(initialContent?: string) {
369
+ this.snapPosToFocusedCell();
370
+ // Without deferring, the event dispatches before `focusedCellBox` can get updated bounds of the cell, hence:
371
+ queueMicrotask(() =>
372
+ this.dispatchEvent(
373
+ new DxEditRequest({
374
+ cellIndex: toCellIndex(this.focusedCell),
375
+ cellBox: this.focusedCellBox(),
376
+ initialContent,
377
+ }),
378
+ ),
379
+ );
380
+ }
381
+
382
+ private handlePointerDown = (event: PointerEvent) => {
383
+ if (event.isPrimary) {
384
+ const { action, actionEl } = closestAction(event.target);
385
+ if (action) {
386
+ if (action === 'cell') {
387
+ const cellCoords = closestCell(event.target, actionEl);
388
+ if (cellCoords) {
389
+ this.pointer = { state: 'maybeSelecting', pageX: event.pageX, pageY: event.pageY };
390
+ this.selectionStart = cellCoords;
391
+ this.selectionEnd = cellCoords;
392
+ }
393
+ if (this.mode === 'edit') {
394
+ event.preventDefault();
395
+ } else {
396
+ if (this.focusActive && isSameCell(this.focusedCell, cellCoords)) {
397
+ this.dispatchEditRequest();
398
+ }
399
+ }
400
+ }
199
401
  }
200
402
  }
201
403
  };
202
404
 
203
- handlePointerUp = (_event: PointerEvent) => {
204
- if (this.resizing) {
205
- const resizeEvent = new DxAxisResize({
206
- axis: this.resizing.axis,
207
- index: this.resizing.index,
208
- size: this[this.resizing.axis === 'col' ? 'colSize' : 'rowSize'](this.resizing.index),
209
- });
210
- this.dispatchEvent(resizeEvent);
211
- this.resizing = null;
405
+ private handlePointerUp = (event: PointerEvent) => {
406
+ const cell = closestCell(event.target);
407
+ if (cell) {
408
+ this.selectionEnd = cell;
409
+ this.dispatchEvent(
410
+ new DxGridCellsSelect({
411
+ start: this.selectionStart,
412
+ end: this.selectionEnd,
413
+ }),
414
+ );
212
415
  }
416
+ this.pointer = null;
213
417
  };
214
418
 
215
- handlePointerMove = (event: PointerEvent) => {
216
- if (this.resizing) {
217
- const delta = getPage(this.resizing.axis, event) - this.resizing.page;
218
- if (this.resizing.axis === 'col') {
219
- const nextSize = Math.max(sizeColMin, Math.min(sizeColMax, this.resizing.size + delta));
220
- this.colSizes = { ...this.colSizes, [this.resizing.index]: nextSize };
221
- this.updateVisInline();
222
- } else {
223
- const nextSize = Math.max(sizeRowMin, Math.min(sizeRowMax, this.resizing.size + delta));
224
- this.rowSizes = { ...this.rowSizes, [this.resizing.index]: nextSize };
225
- this.updateVisBlock();
419
+ private handlePointerMove = (event: PointerEvent) => {
420
+ if (shouldSelect(this.pointer, event)) {
421
+ this.pointer = { state: 'selecting' };
422
+ } else if (this.pointer?.state === 'selecting') {
423
+ const cell = closestCell(event.target);
424
+ if (
425
+ cell &&
426
+ cell.plane === this.selectionStart.plane &&
427
+ (cell.col !== this.selectionEnd.col || cell.row !== this.selectionEnd.row)
428
+ ) {
429
+ this.selectionEnd = cell;
226
430
  }
227
431
  }
228
432
  };
229
433
 
434
+ private handleKeydown(event: KeyboardEvent) {
435
+ if (this.focusActive && this.mode === 'browse') {
436
+ // Adjust state
437
+ switch (event.key) {
438
+ case 'ArrowDown':
439
+ this.focusedCell = { ...this.focusedCell, row: Math.min(this.limitRows - 1, this.focusedCell.row + 1) };
440
+ break;
441
+ case 'ArrowUp':
442
+ this.focusedCell = { ...this.focusedCell, row: Math.max(0, this.focusedCell.row - 1) };
443
+ break;
444
+ case 'ArrowRight':
445
+ this.focusedCell = { ...this.focusedCell, col: Math.min(this.limitColumns - 1, this.focusedCell.col + 1) };
446
+ break;
447
+ case 'ArrowLeft':
448
+ this.focusedCell = { ...this.focusedCell, col: Math.max(0, this.focusedCell.col - 1) };
449
+ break;
450
+ }
451
+ // Emit edit request if relevant
452
+ switch (event.key) {
453
+ case 'Enter':
454
+ this.dispatchEditRequest();
455
+ break;
456
+ default:
457
+ if (event.key.length === 1 && event.key.match(/\P{Cc}/u)) {
458
+ this.dispatchEditRequest(event.key);
459
+ }
460
+ break;
461
+ }
462
+ // Handle virtualization & focus consequences
463
+ switch (event.key) {
464
+ case 'ArrowDown':
465
+ case 'ArrowUp':
466
+ case 'ArrowRight':
467
+ case 'ArrowLeft':
468
+ event.preventDefault();
469
+ this.snapPosToFocusedCell();
470
+ break;
471
+ }
472
+ }
473
+ }
474
+
230
475
  //
231
476
  // Accessors
232
477
  //
233
478
 
234
- private colSize(c: number | string) {
235
- return this.colSizes?.[c] ?? this.columnDefault.size;
479
+ private colSize(c: number | string, plane: DxGridPlane) {
480
+ const resolvedPlane = resolveColPlane(plane);
481
+ return this.colSizes?.[resolvedPlane]?.[c] ?? this.columnDefault[resolvedPlane]?.size ?? defaultColSize;
482
+ }
483
+
484
+ private rowSize(r: number | string, plane: DxGridPlane) {
485
+ const resolvedPlane = resolveRowPlane(plane);
486
+ return this.rowSizes?.[resolvedPlane]?.[r] ?? this.rowDefault[resolvedPlane]?.size ?? defaultRowSize;
236
487
  }
237
488
 
238
- private rowSize(r: number | string) {
239
- return this.rowSizes?.[r] ?? this.rowDefault.size;
489
+ private cell(c: number | string, r: number | string, plane: DxGridPlane): DxGridCellValue | undefined {
490
+ const index: DxGridCellIndex = `${c}${separator}${r}`;
491
+ return this.cells?.[plane]?.[index] ?? this.initialCells?.[plane]?.[index];
240
492
  }
241
493
 
242
- private getCell(c: number | string, r: number | string) {
243
- return this.cells[`${c}${separator}${r}`];
494
+ private cellActive(c: number | string, r: number | string, plane: DxGridPlane): boolean {
495
+ return (
496
+ this.focusActive && this.focusedCell.plane === plane && this.focusedCell.col === c && this.focusedCell.row === r
497
+ );
498
+ }
499
+
500
+ private focusedCellBox(): DxEditRequest['cellBox'] {
501
+ const cellElement = this.focusedCellElement();
502
+ const cellSize = {
503
+ inlineSize: this.colSize(this.focusedCell.col, this.focusedCell.plane),
504
+ blockSize: this.rowSize(this.focusedCell.row, this.focusedCell.plane),
505
+ };
506
+ if (!cellElement) {
507
+ return { insetInlineStart: NaN, insetBlockStart: NaN, ...cellSize };
508
+ }
509
+ const contentElement = cellElement.offsetParent as HTMLElement;
510
+ // Note that storing `offset` in state causes performance issues, so instead the transform is parsed here.
511
+ const [_translate3d, inlineStr, blockStr] = contentElement.style.transform.split(/[()]|px,?\s?/);
512
+ const contentOffsetInline = parseFloat(inlineStr);
513
+ const contentOffsetBlock = parseFloat(blockStr);
514
+ const offsetParent = contentElement.offsetParent as HTMLElement;
515
+ return {
516
+ insetInlineStart: cellElement.offsetLeft + contentOffsetInline + offsetParent.offsetLeft,
517
+ insetBlockStart: cellElement.offsetTop + contentOffsetBlock + offsetParent.offsetTop,
518
+ ...cellSize,
519
+ };
244
520
  }
245
521
 
246
522
  //
@@ -248,7 +524,7 @@ export class DxGrid extends LitElement {
248
524
  //
249
525
 
250
526
  @state()
251
- observer = new ResizeObserver((entries) => {
527
+ private observer = new ResizeObserver((entries) => {
252
528
  const { inlineSize, blockSize } = entries?.[0]?.contentBoxSize?.[0] ?? {
253
529
  inlineSize: 0,
254
530
  blockSize: 0,
@@ -261,97 +537,126 @@ export class DxGrid extends LitElement {
261
537
  this.sizeInline = inlineSize;
262
538
  this.sizeBlock = blockSize;
263
539
  this.updateVis();
540
+ queueMicrotask(() => this.updatePos());
264
541
  }
265
542
  });
266
543
 
267
- viewportRef: Ref<HTMLDivElement> = createRef();
544
+ private viewportRef: Ref<HTMLDivElement> = createRef();
268
545
 
269
- handleWheel = ({ deltaX, deltaY }: WheelEvent) => {
270
- this.posInline = Math.max(0, this.posInline + deltaX);
271
- this.posBlock = Math.max(0, this.posBlock + deltaY);
272
- if (
273
- this.posInline >= this.binInlineMin &&
274
- this.posInline < this.binInlineMax &&
275
- this.posBlock >= this.binBlockMin &&
276
- this.posBlock < this.binBlockMax
277
- ) {
278
- // do nothing
279
- } else {
280
- // console.info(
281
- // '[updating bounds]',
282
- // 'wheel',
283
- // [this.binInlineMin, this.posInline, this.binInlineMax],
284
- // [this.binBlockMin, this.posBlock, this.binBlockMax],
285
- // );
286
- this.updateVis();
546
+ private maybeUpdateVisInline = () => {
547
+ if (this.posInline < this.binInlineMin || this.posInline >= this.binInlineMax) {
548
+ this.updateVisInline();
549
+ }
550
+ };
551
+
552
+ private maybeUpdateVisBlock = () => {
553
+ if (this.posBlock < this.binBlockMin || this.posBlock >= this.binBlockMax) {
554
+ this.updateVisBlock();
555
+ }
556
+ };
557
+
558
+ private updatePosInline(inline?: number) {
559
+ this.posInline = Math.max(0, Math.min(this.intrinsicInlineSize - this.sizeInline, inline ?? this.posInline));
560
+ this.maybeUpdateVisInline();
561
+ }
562
+
563
+ private updatePosBlock(block?: number) {
564
+ this.posBlock = Math.max(0, Math.min(this.intrinsicBlockSize - this.sizeBlock, block ?? this.posBlock));
565
+ this.maybeUpdateVisBlock();
566
+ }
567
+
568
+ private updatePos(inline?: number, block?: number) {
569
+ this.updatePosInline(inline);
570
+ this.updatePosBlock(block);
571
+ }
572
+
573
+ private handleWheel = ({ deltaX, deltaY }: Pick<WheelEvent, 'deltaX' | 'deltaY'>) => {
574
+ if (this.mode === 'browse') {
575
+ this.updatePos(this.posInline + deltaX, this.posBlock + deltaY);
287
576
  }
288
577
  };
289
578
 
290
579
  private updateVisInline() {
291
580
  // todo: avoid starting from zero
292
581
  let colIndex = 0;
293
- let pxInline = this.colSize(colIndex);
582
+ let pxInline = this.colSize(colIndex, 'grid');
294
583
 
295
584
  while (pxInline < this.posInline) {
296
585
  colIndex += 1;
297
- pxInline += this.colSize(colIndex) + gap;
586
+ pxInline += this.colSize(colIndex, 'grid') + gap;
298
587
  }
299
588
 
300
589
  this.visColMin = colIndex - overscanCol;
301
590
 
302
- this.binInlineMin = pxInline - this.colSize(colIndex) - gap;
591
+ this.binInlineMin = pxInline - this.colSize(colIndex, 'grid') - gap;
303
592
  this.binInlineMax = pxInline + gap;
304
593
 
305
594
  this.overscanInline =
306
595
  [...Array(overscanCol)].reduce((acc, _, c0) => {
307
- acc += this.colSize(this.visColMin + c0);
596
+ acc += this.colSize(this.visColMin + c0, 'grid');
308
597
  return acc;
309
598
  }, 0) +
310
599
  gap * (overscanCol - 1);
311
600
 
312
- while (pxInline < this.binInlineMax + this.sizeInline + gap) {
601
+ while (pxInline < this.binInlineMax + this.sizeInline - gap * 2) {
313
602
  colIndex += 1;
314
- pxInline += this.colSize(colIndex) + gap;
603
+ pxInline += this.colSize(colIndex, 'grid') + gap;
315
604
  }
316
605
 
317
- this.visColMax = colIndex + overscanCol;
606
+ this.visColMax = Math.min(this.limitColumns, colIndex + overscanCol);
607
+
608
+ this.templateGridColumns = [...Array(this.visColMax - this.visColMin)]
609
+ .map((_, c0) => `${this.colSize(this.visColMin + c0, 'grid')}px`)
610
+ .join(' ');
318
611
 
319
- this.templateColumns = [...Array(this.visColMax - this.visColMin)]
320
- .map((_, c0) => `${this.colSize(this.visColMin + c0)}px`)
612
+ this.templatefrozenColsStart = [...Array(this.frozen.frozenColsStart ?? 0)]
613
+ .map((_, c0) => `${this.colSize(c0, 'frozenColsStart')}px`)
614
+ .join(' ');
615
+
616
+ this.templatefrozenColsEnd = [...Array(this.frozen.frozenColsEnd ?? 0)]
617
+ .map((_, c0) => `${this.colSize(c0, 'frozenColsEnd')}px`)
321
618
  .join(' ');
322
619
  }
323
620
 
324
621
  private updateVisBlock() {
325
622
  // todo: avoid starting from zero
326
623
  let rowIndex = 0;
327
- let pxBlock = this.rowSize(rowIndex);
624
+ let pxBlock = this.rowSize(rowIndex, 'grid');
328
625
 
329
626
  while (pxBlock < this.posBlock) {
330
627
  rowIndex += 1;
331
- pxBlock += this.rowSize(rowIndex) + gap;
628
+ pxBlock += this.rowSize(rowIndex, 'grid') + gap;
332
629
  }
333
630
 
334
631
  this.visRowMin = rowIndex - overscanRow;
335
632
 
336
- this.binBlockMin = pxBlock - this.rowSize(rowIndex) - gap;
633
+ this.binBlockMin = pxBlock - this.rowSize(rowIndex, 'grid') - gap;
337
634
  this.binBlockMax = pxBlock + gap;
338
635
 
339
636
  this.overscanBlock =
340
637
  [...Array(overscanRow)].reduce((acc, _, r0) => {
341
- acc += this.rowSize(this.visRowMin + r0);
638
+ acc += this.rowSize(this.visRowMin + r0, 'grid');
342
639
  return acc;
343
640
  }, 0) +
344
641
  gap * (overscanRow - 1);
345
642
 
346
- while (pxBlock < this.binBlockMax + this.sizeBlock) {
643
+ while (pxBlock < this.binBlockMax + this.sizeBlock - gap * 2) {
347
644
  rowIndex += 1;
348
- pxBlock += this.rowSize(rowIndex) + gap;
645
+ pxBlock += this.rowSize(rowIndex, 'grid') + gap;
349
646
  }
350
647
 
351
- this.visRowMax = rowIndex + overscanRow;
648
+ this.visRowMax = Math.min(this.limitRows, rowIndex + overscanRow);
649
+
650
+ this.templateGridRows = [...Array(this.visRowMax - this.visRowMin)]
651
+ .map((_, r0) => `${this.rowSize(this.visRowMin + r0, 'grid')}px`)
652
+ .join(' ');
352
653
 
353
- this.templateRows = [...Array(this.visRowMax - this.visRowMin)]
354
- .map((_, r0) => `${this.rowSize(this.visRowMin + r0)}px`)
654
+ this.templatefrozenRowsStart = [...Array(this.frozen.frozenRowsStart ?? 0)]
655
+ .map((_, r0) => `${this.rowSize(r0, 'frozenRowsStart')}px`)
656
+ .join(' ');
657
+
658
+ this.templatefrozenRowsEnd = [...Array(this.frozen.frozenRowsEnd ?? 0)]
659
+ .map((_, r0) => `${this.rowSize(r0, 'frozenRowsEnd')}px`)
355
660
  .join(' ');
356
661
  }
357
662
 
@@ -360,130 +665,277 @@ export class DxGrid extends LitElement {
360
665
  this.updateVisBlock();
361
666
  }
362
667
 
363
- // Focus handlers
668
+ private updateCells(includeFixed?: boolean) {
669
+ this.cells.grid = this.getCells!(
670
+ {
671
+ start: { col: this.visColMin, row: this.visRowMin },
672
+ end: { col: this.visColMax, row: this.visRowMax },
673
+ },
674
+ 'grid',
675
+ );
676
+ Object.entries(this.frozen)
677
+ .filter(([_, limit]) => limit && limit > 0)
678
+ .forEach(([plane, limit]) => {
679
+ this.cells[plane as DxGridFrozenPlane] = this.getCells!(
680
+ plane.startsWith('frozenRows')
681
+ ? {
682
+ start: { col: this.visColMin, row: 0 },
683
+ end: { col: this.visColMax, row: limit },
684
+ }
685
+ : {
686
+ start: { col: 0, row: this.visRowMin },
687
+ end: { col: limit, row: this.visRowMax },
688
+ },
689
+ plane as DxGridFrozenPlane,
690
+ );
691
+ });
692
+ if (includeFixed) {
693
+ if ((this.frozen.frozenColsStart ?? 0) > 0 && (this.frozen.frozenRowsStart ?? 0) > 0) {
694
+ this.cells.fixedStartStart = this.getCells!(
695
+ {
696
+ start: { col: 0, row: 0 },
697
+ end: { col: this.frozen.frozenColsStart!, row: this.frozen.frozenRowsStart! },
698
+ },
699
+ 'fixedStartStart',
700
+ );
701
+ }
702
+ if ((this.frozen.frozenColsEnd ?? 0) > 0 && (this.frozen.frozenRowsStart ?? 0) > 0) {
703
+ this.cells.fixedStartEnd = this.getCells!(
704
+ {
705
+ start: { col: 0, row: 0 },
706
+ end: { col: this.frozen.frozenColsEnd!, row: this.frozen.frozenRowsStart! },
707
+ },
708
+ 'fixedStartEnd',
709
+ );
710
+ }
711
+ if ((this.frozen.frozenColsStart ?? 0) > 0 && (this.frozen.frozenRowsEnd ?? 0) > 0) {
712
+ this.cells.fixedEndStart = this.getCells!(
713
+ {
714
+ start: { col: 0, row: 0 },
715
+ end: { col: this.frozen.frozenColsStart!, row: this.frozen.frozenRowsEnd! },
716
+ },
717
+ 'fixedEndStart',
718
+ );
719
+ }
720
+ if ((this.frozen.frozenColsEnd ?? 0) > 0 && (this.frozen.frozenRowsEnd ?? 0) > 0) {
721
+ this.cells.fixedEndEnd = this.getCells!(
722
+ {
723
+ start: { col: 0, row: 0 },
724
+ end: { col: this.frozen.frozenColsEnd!, row: this.frozen.frozenRowsEnd! },
725
+ },
726
+ 'fixedEndEnd',
727
+ );
728
+ }
729
+ }
730
+ }
364
731
 
365
- @state()
366
- focusedCell: Record<DxGridAxis, number> = { col: 0, row: 0 };
732
+ // Focus handlers
367
733
 
368
- @state()
369
- focusActive: boolean = false;
734
+ setFocus(coords: DxGridPosition, snap = true) {
735
+ this.focusedCell = coords;
736
+ this.focusActive = true;
737
+ if (snap) {
738
+ this.snapPosToFocusedCell();
739
+ }
740
+ }
370
741
 
371
- @eventOptions({ capture: true })
372
- handleFocus(event: FocusEvent) {
373
- const target = event.target as HTMLElement;
374
- const action = target.getAttribute('data-dx-grid-action');
375
- if (action === 'cell') {
376
- const c = parseInt(target.getAttribute('aria-colindex') ?? 'never');
377
- const r = parseInt(target.getAttribute('aria-rowindex') ?? 'never');
378
- this.focusedCell = { col: c, row: r };
742
+ private handleFocus(event: FocusEvent) {
743
+ const cellCoords = closestCell(event.target);
744
+ if (cellCoords) {
379
745
  this.focusActive = true;
746
+ if (
747
+ this.focusedCell.plane !== cellCoords.plane ||
748
+ this.focusedCell.col !== cellCoords.col ||
749
+ this.focusedCell.row !== cellCoords.row
750
+ ) {
751
+ this.focusedCell = cellCoords;
752
+ }
380
753
  }
381
754
  }
382
755
 
383
- @eventOptions({ capture: true })
384
- handleBlur(event: FocusEvent) {
756
+ private handleBlur(event: FocusEvent) {
385
757
  // Only unset `focusActive` if focus is not moving to an element within the grid.
386
- if (
387
- !event.relatedTarget ||
388
- (event.relatedTarget as HTMLElement).closest('.dx-grid__viewport') !== this.viewportRef.value
389
- ) {
758
+ if (!event.relatedTarget || !(event.relatedTarget as HTMLElement).closest(`[data-grid="${this.gridId}"]`)) {
390
759
  this.focusActive = false;
391
760
  }
392
761
  }
393
762
 
763
+ private focusedCellQuery() {
764
+ return `[data-dx-grid-plane=${this.focusedCell.plane}] > [aria-colindex="${this.focusedCell.col}"][aria-rowindex="${this.focusedCell.row}"]`;
765
+ }
766
+
767
+ private focusedCellElement() {
768
+ return this.viewportRef.value?.querySelector(this.focusedCellQuery()) as HTMLElement | null;
769
+ }
770
+
771
+ //
772
+ // `outOfVis` returns by how many rows/cols the focused cell is outside of the `vis` range for an axis, inset by a
773
+ // `delta`, otherwise zero if it is within that range.
774
+ //
775
+
776
+ private focusedCellRowOutOfVis(minDelta = 0, maxDelta = minDelta) {
777
+ return this.focusedCell.row <= this.visRowMin + minDelta
778
+ ? this.focusedCell.row - (this.visRowMin + minDelta)
779
+ : this.focusedCell.row >= this.visRowMax - maxDelta
780
+ ? -(this.focusedCell.row - this.visRowMax - maxDelta)
781
+ : 0;
782
+ }
783
+
784
+ private focusedCellColOutOfVis(minDelta = 0, maxDelta = minDelta) {
785
+ return this.focusedCell.col <= this.visColMin + minDelta
786
+ ? this.focusedCell.col - (this.visColMin + minDelta)
787
+ : this.focusedCell.col >= this.visColMax - maxDelta
788
+ ? -(this.focusedCell.col - this.visColMax - maxDelta)
789
+ : 0;
790
+ }
791
+
792
+ private focusedCellOutOfVis(colDelta = 0, rowDelta = colDelta): { col: number; row: number } {
793
+ switch (this.focusedCell.plane) {
794
+ case 'grid':
795
+ return { row: this.focusedCellRowOutOfVis(rowDelta), col: this.focusedCellColOutOfVis(colDelta) };
796
+ case 'frozenRowsStart':
797
+ case 'frozenRowsEnd':
798
+ return { col: this.focusedCellColOutOfVis(colDelta), row: 0 };
799
+ case 'frozenColsStart':
800
+ case 'frozenColsEnd':
801
+ return { col: 0, row: this.focusedCellRowOutOfVis(rowDelta) };
802
+ default:
803
+ return { col: 0, row: 0 };
804
+ }
805
+ }
806
+
394
807
  /**
395
808
  * Moves focus to the cell with actual focus, otherwise moves focus to the viewport.
396
809
  */
397
- refocus() {
398
- (this.focusedCell.row < this.visRowMin ||
399
- this.focusedCell.row > this.visRowMax ||
400
- this.focusedCell.col < this.visColMin ||
401
- this.focusedCell.col > this.visColMax
402
- ? this.viewportRef.value
403
- : (this.viewportRef.value?.querySelector(
404
- `[aria-colindex="${this.focusedCell.col}"][aria-rowindex="${this.focusedCell.row}"]`,
405
- ) as HTMLElement | null)
406
- )?.focus({ preventScroll: true });
810
+ refocus(increment?: 'col' | 'row', delta: 1 | -1 = 1) {
811
+ switch (increment) {
812
+ case 'row':
813
+ this.focusedCell = { ...this.focusedCell, row: this.focusedCell.row + delta };
814
+ break;
815
+ case 'col':
816
+ this.focusedCell = { ...this.focusedCell, col: this.focusedCell.col + delta };
817
+ }
818
+ if (increment) {
819
+ this.snapPosToFocusedCell();
820
+ }
821
+ queueMicrotask(() => {
822
+ const outOfVis = this.focusedCellOutOfVis(overscanCol, overscanRow);
823
+ if (outOfVis.col !== 0 || outOfVis.row !== 0) {
824
+ this.viewportRef.value?.focus({ preventScroll: true });
825
+ } else {
826
+ const activeFocusedCell = document.activeElement?.closest(this.focusedCellQuery());
827
+ if (!activeFocusedCell) {
828
+ this.focusedCellElement()?.focus({ preventScroll: true });
829
+ }
830
+ }
831
+ });
832
+ }
833
+
834
+ private findPosInlineFromVisColMin(deltaCols: number) {
835
+ return [...Array(deltaCols)].reduce(
836
+ (acc, _, c0) => acc - this.colSize(this.visColMin - c0, 'grid') - gap,
837
+ this.binInlineMin + gap,
838
+ );
839
+ }
840
+
841
+ private findPosBlockFromVisRowMin(deltaRows: number) {
842
+ return [...Array(deltaRows)].reduce(
843
+ (acc, _, r0) => acc - this.rowSize(this.visRowMin - r0, 'grid') - gap,
844
+ this.binBlockMin + gap,
845
+ );
407
846
  }
408
847
 
409
848
  /**
410
849
  * Updates `pos` so that a cell in focus is fully within the viewport
411
850
  */
412
851
  snapPosToFocusedCell() {
413
- if (
414
- this.focusedCell.col < this.visColMin ||
415
- this.focusedCell.col > this.visColMax ||
416
- this.focusedCell.row < this.visRowMin ||
417
- this.focusedCell.row > this.visRowMax
418
- ) {
419
- // console.warn('Snapping position to a focused cell that is not already mounted is unsupported.');
420
- } else if (
421
- this.focusedCell.col > this.visColMin + overscanCol &&
422
- this.focusedCell.col < this.visColMax - overscanCol - 1 &&
423
- this.focusedCell.row > this.visRowMin + overscanRow &&
424
- this.focusedCell.row < this.visRowMax - overscanRow - 1
425
- ) {
426
- // console.log(
427
- // '[within bounds]',
428
- // this.focusedCell,
429
- // [this.visColMin, this.visColMax, overscanCol],
430
- // [this.visRowMin, this.visRowMax, overscanRow],
431
- // );
432
- } else {
433
- if (this.focusedCell.col <= this.visColMin + overscanCol) {
434
- this.posInline = this.binInlineMin;
435
- this.updateVisInline();
436
- } else if (this.focusedCell.col >= this.visColMax - overscanCol - 1) {
437
- const sizeSumCol = [...Array(this.focusedCell.col - this.visColMin)].reduce((acc, _, c0) => {
438
- acc += this.colSize(this.visColMin + overscanCol + c0) + gap;
439
- return acc;
440
- }, 0);
441
- this.posInline = this.binInlineMin + sizeSumCol + gap * 2 - this.sizeInline;
442
- this.updateVisInline();
443
- }
852
+ const outOfVis = this.focusedCellOutOfVis(overscanCol, overscanRow);
853
+ if (outOfVis.col < 0) {
854
+ this.posInline = this.findPosInlineFromVisColMin(-outOfVis.col);
855
+ this.updateVisInline();
856
+ } else if (outOfVis.col > 0) {
857
+ const sizeSumCol = [...Array(this.focusedCell.col - this.visColMin)].reduce((acc, _, c0) => {
858
+ acc += this.colSize(this.visColMin + overscanCol + c0, 'grid') + gap;
859
+ return acc;
860
+ }, 0);
861
+ this.posInline = Math.max(
862
+ 0,
863
+ Math.min(this.intrinsicInlineSize - this.sizeInline, this.binInlineMin + sizeSumCol - this.sizeInline),
864
+ );
865
+ this.updateVisInline();
866
+ }
444
867
 
445
- if (this.focusedCell.row <= this.visRowMin + overscanRow) {
446
- this.posBlock = this.binBlockMin;
447
- this.updateVisBlock();
448
- } else if (this.focusedCell.row >= this.visRowMax - overscanRow - 1) {
449
- const sizeSumRow = [...Array(this.focusedCell.row - this.visRowMin)].reduce((acc, _, r0) => {
450
- acc += this.rowSize(this.visRowMin + overscanRow + r0) + gap;
451
- return acc;
452
- }, 0);
453
- this.posBlock = this.binBlockMin + sizeSumRow + gap * 2 - this.sizeBlock;
454
- this.updateVisBlock();
455
- }
868
+ if (outOfVis.row < 0) {
869
+ this.posBlock = this.findPosBlockFromVisRowMin(-outOfVis.row);
870
+ this.updateVisBlock();
871
+ } else if (outOfVis.row > 0) {
872
+ const sizeSumRow = [...Array(this.focusedCell.row - this.visRowMin)].reduce((acc, _, r0) => {
873
+ acc += this.rowSize(this.visRowMin + overscanRow + r0, 'grid') + gap;
874
+ return acc;
875
+ }, 0);
876
+ this.posBlock = Math.max(
877
+ 0,
878
+ Math.min(this.intrinsicBlockSize - this.sizeBlock, this.binBlockMin + sizeSumRow - this.sizeBlock),
879
+ );
880
+ this.updateVisBlock();
456
881
  }
457
882
  }
458
883
 
459
- // Keyboard interactions
460
- handleKeydown(event: KeyboardEvent) {
461
- if (this.focusActive) {
462
- // Adjust state
463
- switch (event.key) {
464
- case 'ArrowDown':
465
- this.focusedCell = { ...this.focusedCell, row: this.focusedCell.row + 1 };
466
- break;
467
- case 'ArrowUp':
468
- this.focusedCell = { ...this.focusedCell, row: Math.max(0, this.focusedCell.row - 1) };
469
- break;
470
- case 'ArrowRight':
471
- this.focusedCell = { ...this.focusedCell, col: this.focusedCell.col + 1 };
472
- break;
473
- case 'ArrowLeft':
474
- this.focusedCell = { ...this.focusedCell, col: Math.max(0, this.focusedCell.col - 1) };
475
- break;
476
- }
477
- // Handle virtualization & focus consequences
478
- switch (event.key) {
479
- case 'ArrowDown':
480
- case 'ArrowUp':
481
- case 'ArrowRight':
482
- case 'ArrowLeft':
483
- event.preventDefault();
484
- this.snapPosToFocusedCell();
485
- break;
486
- }
884
+ //
885
+ // Map scroll DOM methods to virtualized value.
886
+ //
887
+
888
+ override get scrollLeft() {
889
+ return this.posInline;
890
+ }
891
+
892
+ override set scrollLeft(nextValue: number) {
893
+ this.posInline = nextValue;
894
+ this.maybeUpdateVisInline();
895
+ }
896
+
897
+ override get scrollTop() {
898
+ return this.posBlock;
899
+ }
900
+
901
+ override set scrollTop(nextValue: number) {
902
+ this.posBlock = nextValue;
903
+ this.maybeUpdateVisBlock();
904
+ }
905
+
906
+ //
907
+ // Resize handlers
908
+ //
909
+
910
+ private axisResizeable(plane: 'grid' | DxGridFrozenPlane, axis: DxGridAxis, index: number | string) {
911
+ return axis === 'col'
912
+ ? !!(this.columns[plane]?.[index]?.resizeable ?? this.columnDefault[plane as DxGridFrozenColsPlane]?.resizeable)
913
+ : !!(this.rows[plane]?.[index]?.resizeable ?? this.rowDefault[plane as DxGridFrozenRowsPlane]?.resizeable);
914
+ }
915
+
916
+ private handleAxisResizeInternal(event: DxAxisResizeInternal) {
917
+ event.stopPropagation();
918
+ const { plane, axis, delta, size, index, state } = event;
919
+ if (axis === 'col') {
920
+ const nextSize = Math.max(sizeColMin, Math.min(sizeColMax, size + delta));
921
+ this.colSizes = { ...this.colSizes, [plane]: { ...this.colSizes[plane], [index]: nextSize } };
922
+ this.updateVisInline();
923
+ this.updateIntrinsicInlineSize();
924
+ } else {
925
+ const nextSize = Math.max(sizeRowMin, Math.min(sizeRowMax, size + delta));
926
+ this.rowSizes = { ...this.colSizes, [plane]: { ...this.rowSizes[plane], [index]: nextSize } };
927
+ this.updateVisBlock();
928
+ this.updateIntrinsicBlockSize();
929
+ }
930
+ if (state === 'dropped') {
931
+ this.dispatchEvent(
932
+ new DxAxisResize({
933
+ plane,
934
+ axis,
935
+ index,
936
+ size: this[`${axis}Size`](index, plane),
937
+ }),
938
+ );
487
939
  }
488
940
  }
489
941
 
@@ -491,116 +943,261 @@ export class DxGrid extends LitElement {
491
943
  // Render and other lifecycle methods
492
944
  //
493
945
 
946
+ private renderFixed(plane: DxGridFixedPlane, selection: DxGridSelectionProps) {
947
+ const colPlane = resolveColPlane(plane) as DxGridFrozenPlane;
948
+ const rowPlane = resolveRowPlane(plane) as DxGridFrozenPlane;
949
+ const cols = this.frozen[colPlane];
950
+ const rows = this.frozen[rowPlane];
951
+ return (cols ?? 0) > 0 && (rows ?? 0) > 0
952
+ ? html`<div
953
+ role="none"
954
+ data-dx-grid-plane=${plane}
955
+ class="dx-grid__plane--fixed"
956
+ style=${styleMap({
957
+ 'grid-template-columns': this[`template${colPlane}`],
958
+ 'grid-template-rows': this[`template${rowPlane}`],
959
+ })}
960
+ >
961
+ ${[...Array(cols)].map((_, c) => {
962
+ return [...Array(rows)].map((_, r) => {
963
+ return this.renderCell(c, r, plane, cellSelected(c, r, plane, selection));
964
+ });
965
+ })}
966
+ </div>`
967
+ : null;
968
+ }
969
+
970
+ private renderFrozenRows(
971
+ plane: DxGridFrozenRowsPlane,
972
+ visibleCols: number,
973
+ offsetInline: number,
974
+ selection: DxGridSelectionProps,
975
+ ) {
976
+ const rowPlane = resolveRowPlane(plane) as DxGridFrozenPlane;
977
+ const rows = this.frozen[rowPlane];
978
+ return (rows ?? 0) > 0
979
+ ? html`<div role="none" class="dx-grid__plane--frozen-row">
980
+ <div
981
+ role="none"
982
+ data-dx-grid-plane=${plane}
983
+ class="dx-grid__plane--frozen-row__content"
984
+ style="transform:translate3d(${offsetInline}px,0,0);grid-template-columns:${this
985
+ .templateGridColumns};grid-template-rows:${this[`template${rowPlane}`]}"
986
+ >
987
+ ${[...Array(visibleCols)].map((_, c0) => {
988
+ return [...Array(rows)].map((_, r) => {
989
+ const c = this.visColMin + c0;
990
+ return this.renderCell(c, r, plane, cellSelected(c, r, plane, selection), c0, r);
991
+ });
992
+ })}
993
+ </div>
994
+ </div>`
995
+ : null;
996
+ }
997
+
998
+ private renderFrozenColumns(
999
+ plane: DxGridFrozenColsPlane,
1000
+ visibleRows: number,
1001
+ offsetBlock: number,
1002
+ selection: DxGridSelectionProps,
1003
+ ) {
1004
+ const colPlane = resolveColPlane(plane) as DxGridFrozenPlane;
1005
+ const cols = this.frozen[colPlane];
1006
+ return (cols ?? 0) > 0
1007
+ ? html`<div role="none" class="dx-grid__plane--frozen-col">
1008
+ <div
1009
+ role="none"
1010
+ data-dx-grid-plane=${plane}
1011
+ class="dx-grid__plane--frozen-col__content"
1012
+ style="transform:translate3d(0,${offsetBlock}px,0);grid-template-rows:${this
1013
+ .templateGridRows};grid-template-columns:${this[`template${colPlane}`]}"
1014
+ >
1015
+ ${[...Array(cols)].map((_, c) => {
1016
+ return [...Array(visibleRows)].map((_, r0) => {
1017
+ const r = this.visRowMin + r0;
1018
+ return this.renderCell(c, r, plane, cellSelected(c, r, plane, selection), c, r0);
1019
+ });
1020
+ })}
1021
+ </div>
1022
+ </div>`
1023
+ : null;
1024
+ }
1025
+
1026
+ private renderCell(col: number, row: number, plane: DxGridPlane, selected?: boolean, visCol = col, visRow = row) {
1027
+ const cell = this.cell(col, row, plane);
1028
+ const active = this.cellActive(col, row, plane);
1029
+ const resizeIndex = cell?.resizeHandle ? (cell.resizeHandle === 'col' ? col : row) : undefined;
1030
+ const resizePlane = cell?.resizeHandle ? resolveResizePlane(cell.resizeHandle, plane) : undefined;
1031
+ const accessory = cell?.accessoryHtml ? staticHtml`${unsafeStatic(cell.accessoryHtml)}` : null;
1032
+ return html`<div
1033
+ role="gridcell"
1034
+ tabindex="0"
1035
+ ?inert=${col < 0 || row < 0}
1036
+ ?aria-selected=${selected}
1037
+ class=${cell || active
1038
+ ? (cell?.className ? cell.className + ' ' : '') + (active ? 'dx-grid__cell--active' : '')
1039
+ : nothing}
1040
+ aria-colindex=${col}
1041
+ aria-rowindex=${row}
1042
+ data-dx-grid-action="cell"
1043
+ style="grid-column:${visCol + 1};grid-row:${visRow + 1}"
1044
+ >
1045
+ ${cell?.value}${accessory}${cell?.resizeHandle &&
1046
+ this.axisResizeable(resizePlane!, cell.resizeHandle, resizeIndex!)
1047
+ ? html`<dx-grid-axis-resize-handle
1048
+ axis=${cell.resizeHandle}
1049
+ plane=${resizePlane}
1050
+ index=${resizeIndex}
1051
+ size=${this[`${cell.resizeHandle}Size`](resizeIndex!, plane)}
1052
+ ></dx-grid-axis-resize-handle>`
1053
+ : null}
1054
+ </div>`;
1055
+ }
1056
+
494
1057
  override render() {
495
1058
  const visibleCols = this.visColMax - this.visColMin;
496
1059
  const visibleRows = this.visRowMax - this.visRowMin;
497
- const offsetInline = gap + this.binInlineMin - this.posInline - this.overscanInline;
498
- const offsetBlock = gap + this.binBlockMin - this.posBlock - this.overscanBlock;
1060
+ const offsetInline = this.binInlineMin - this.posInline - this.overscanInline;
1061
+ const offsetBlock = this.binBlockMin - this.posBlock - this.overscanBlock;
1062
+ const selection = selectionProps(this.selectionStart, this.selectionEnd);
499
1063
 
500
1064
  return html`<div
501
1065
  role="none"
502
1066
  class="dx-grid"
503
- @pointerdown=${this.handlePointerDown}
504
- @pointerup=${this.handlePointerUp}
505
- @pointermove=${this.handlePointerMove}
506
- @focus=${this.handleFocus}
507
- @blur=${this.handleBlur}
508
- @keydown=${this.handleKeydown}
1067
+ style=${styleMap({
1068
+ 'grid-template-columns': `${this.templatefrozenColsStart ? 'min-content ' : ''}minmax(0, ${
1069
+ Number.isFinite(this.limitColumns) ? `${this.intrinsicInlineSize}px` : '1fr'
1070
+ })${this.templatefrozenColsEnd ? ' min-content' : ''}`,
1071
+ 'grid-template-rows': `${this.templatefrozenRowsStart ? 'min-content ' : ''}minmax(0, ${
1072
+ Number.isFinite(this.limitRows) ? `${this.intrinsicBlockSize}px` : '1fr'
1073
+ })${this.templatefrozenRowsEnd ? ' min-content' : ''}`,
1074
+ })}
1075
+ data-grid=${this.gridId}
1076
+ data-grid-mode=${this.mode}
1077
+ ?data-grid-select=${selection.visible}
509
1078
  >
510
- <div role="none" class="dx-grid__corner"></div>
511
- <div role="none" class="dx-grid__columnheader">
512
- <div
513
- role="none"
514
- class="dx-grid__columnheader__content"
515
- style="transform:translate3d(${offsetInline}px,0,0);grid-template-columns:${this.templateColumns};"
516
- >
517
- ${[...Array(visibleCols)].map((_, c0) => {
518
- const c = this.visColMin + c0;
519
- return html`<div
520
- role="columnheader"
521
- ?inert=${c < 0}
522
- style="block-size:${this.rowDefault.size}px;grid-column:${c0 + 1}/${c0 + 2};"
523
- >
524
- <span id=${localChId(c0)}>${colToA1Notation(c)}</span>
525
- ${(this.columns[c]?.resizeable ?? this.columnDefault.resizeable) &&
526
- html`<button class="dx-grid__resize-handle" data-dx-grid-action=${`resize-col,${c}`}>
527
- <span class="sr-only">Resize</span>
528
- </button>`}
529
- </div>`;
530
- })}
531
- </div>
532
- </div>
533
- <div role="none" class="dx-grid__corner"></div>
534
- <div role="none" class="dx-grid__rowheader">
1079
+ ${this.renderFixed('fixedStartStart', selection)}${this.renderFrozenRows(
1080
+ 'frozenRowsStart',
1081
+ visibleCols,
1082
+ offsetInline,
1083
+ selection,
1084
+ )}${this.renderFixed('fixedStartEnd', selection)}${this.renderFrozenColumns(
1085
+ 'frozenColsStart',
1086
+ visibleRows,
1087
+ offsetBlock,
1088
+ selection,
1089
+ )}
1090
+ <div role="grid" class="dx-grid__plane--grid" tabindex="0" ${ref(this.viewportRef)}>
535
1091
  <div
536
1092
  role="none"
537
- class="dx-grid__rowheader__content"
538
- style="transform:translate3d(0,${offsetBlock}px,0);grid-template-rows:${this.templateRows};"
539
- >
540
- ${[...Array(visibleRows)].map((_, r0) => {
541
- const r = this.visRowMin + r0;
542
- return html`<div role="rowheader" ?inert=${r < 0} style="grid-row:${r0 + 1}/${r0 + 2}">
543
- <span id=${localRhId(r0)}>${rowToA1Notation(r)}</span>
544
- ${(this.rows[r]?.resizeable ?? this.rowDefault.resizeable) &&
545
- html`<button class="dx-grid__resize-handle" data-dx-grid-action=${`resize-row,${r}`}>
546
- <span class="sr-only">Resize</span>
547
- </button>`}
548
- </div>`;
549
- })}
550
- </div>
551
- </div>
552
- <div role="grid" class="dx-grid__viewport" tabindex="0" @wheel=${this.handleWheel} ${ref(this.viewportRef)}>
553
- <div
554
- role="none"
555
- class="dx-grid__content"
1093
+ class="dx-grid__plane--grid__content"
1094
+ data-dx-grid-plane="grid"
556
1095
  style="transform:translate3d(${offsetInline}px,${offsetBlock}px,0);grid-template-columns:${this
557
- .templateColumns};grid-template-rows:${this.templateRows};"
1096
+ .templateGridColumns};grid-template-rows:${this.templateGridRows};"
558
1097
  >
559
1098
  ${[...Array(visibleCols)].map((_, c0) => {
560
1099
  return [...Array(visibleRows)].map((_, r0) => {
561
1100
  const c = c0 + this.visColMin;
562
1101
  const r = r0 + this.visRowMin;
563
- const cell = this.getCell(c, r);
564
- return html`<div
565
- role="gridcell"
566
- tabindex="0"
567
- ?inert=${c < 0 || r < 0}
568
- aria-rowindex=${r}
569
- aria-colindex=${c}
570
- data-dx-grid-action="cell"
571
- style="grid-column:${c0 + 1};grid-row:${r0 + 1}"
572
- >
573
- ${cell?.value}
574
- </div>`;
1102
+ return this.renderCell(c, r, 'grid', cellSelected(c, r, 'grid', selection), c0, r0);
575
1103
  });
576
1104
  })}
577
1105
  </div>
578
1106
  </div>
579
- <div role="none" class="dx-grid__scrollbar" aria-orientation="vertical">
580
- <div role="none" class="dx-grid__scrollbar__thumb"></div>
581
- </div>
582
- <div role="none" class="dx-grid__corner"></div>
583
- <div role="none" class="dx-grid__scrollbar" aria-orientation="horizontal">
584
- <div role="none" class="dx-grid__scrollbar__thumb"></div>
585
- </div>
586
- <div role="none" class="dx-grid__corner"></div>
1107
+ ${this.renderFrozenColumns('frozenColsEnd', visibleRows, offsetBlock, selection)}${this.renderFixed(
1108
+ 'fixedEndStart',
1109
+ selection,
1110
+ )}${this.renderFrozenRows('frozenRowsEnd', visibleCols, offsetInline, selection)}${this.renderFixed(
1111
+ 'fixedEndEnd',
1112
+ selection,
1113
+ )}
587
1114
  </div>`;
588
1115
  }
589
1116
 
1117
+ private updateIntrinsicInlineSize() {
1118
+ this.intrinsicInlineSize = Number.isFinite(this.limitColumns)
1119
+ ? [...Array(this.limitColumns)].reduce((acc, _, c0) => acc + this.colSize(c0, 'grid'), 0) +
1120
+ gap * (this.limitColumns - 1)
1121
+ : Infinity;
1122
+ }
1123
+
1124
+ private updateIntrinsicBlockSize() {
1125
+ this.intrinsicBlockSize = Number.isFinite(this.limitRows)
1126
+ ? [...Array(this.limitRows)].reduce((acc, _, r0) => acc + this.rowSize(r0, 'grid'), 0) +
1127
+ gap * (this.limitRows - 1)
1128
+ : Infinity;
1129
+ }
1130
+
1131
+ private updateIntrinsicSizes() {
1132
+ this.updateIntrinsicInlineSize();
1133
+ this.updateIntrinsicBlockSize();
1134
+ }
1135
+
590
1136
  override firstUpdated() {
1137
+ if (this.getCells) {
1138
+ this.updateCells(true);
1139
+ }
591
1140
  this.observer.observe(this.viewportRef.value!);
592
- this.colSizes = Object.entries(this.columns).reduce((acc: Record<string, number>, [colId, colMeta]) => {
593
- if (colMeta?.size) {
594
- acc[colId] = colMeta.size;
595
- }
596
- return acc;
597
- }, {});
598
- this.rowSizes = Object.entries(this.rows).reduce((acc: Record<string, number>, [rowId, rowMeta]) => {
599
- if (rowMeta?.size) {
600
- acc[rowId] = rowMeta.size;
601
- }
602
- return acc;
603
- }, {});
1141
+ this.colSizes = Object.entries(this.columns).reduce(
1142
+ (acc: DxGridAxisSizes, [plane, planeColMeta]) => {
1143
+ acc[plane as 'grid' | DxGridFrozenPlane] = Object.entries(planeColMeta).reduce(
1144
+ (planeAcc: Record<string, number>, [col, colMeta]) => {
1145
+ if (colMeta?.size) {
1146
+ planeAcc[col] = colMeta.size;
1147
+ }
1148
+ return planeAcc;
1149
+ },
1150
+ {},
1151
+ );
1152
+ return acc;
1153
+ },
1154
+ { grid: {} },
1155
+ );
1156
+ this.rowSizes = Object.entries(this.rows).reduce(
1157
+ (acc: DxGridAxisSizes, [plane, planeRowMeta]) => {
1158
+ acc[plane as 'grid' | DxGridFrozenPlane] = Object.entries(planeRowMeta).reduce(
1159
+ (planeAcc: Record<string, number>, [row, rowMeta]) => {
1160
+ if (rowMeta?.size) {
1161
+ planeAcc[row] = rowMeta.size;
1162
+ }
1163
+ return planeAcc;
1164
+ },
1165
+ {},
1166
+ );
1167
+ return acc;
1168
+ },
1169
+ { grid: {} },
1170
+ );
1171
+ this.updateIntrinsicSizes();
1172
+ }
1173
+
1174
+ override willUpdate(changedProperties: Map<string, any>) {
1175
+ if (
1176
+ this.getCells &&
1177
+ (changedProperties.has('initialCells') ||
1178
+ changedProperties.has('visColMin') ||
1179
+ changedProperties.has('visColMax') ||
1180
+ changedProperties.has('visRowMin') ||
1181
+ changedProperties.has('visRowMax'))
1182
+ ) {
1183
+ this.updateCells();
1184
+ }
1185
+
1186
+ if (changedProperties.has('rowDefault') || changedProperties.has('rows') || changedProperties.has('limitRows')) {
1187
+ this.updateIntrinsicBlockSize();
1188
+ this.updatePosBlock();
1189
+ this.updateVisBlock();
1190
+ }
1191
+
1192
+ if (
1193
+ changedProperties.has('colDefault') ||
1194
+ changedProperties.has('columns') ||
1195
+ changedProperties.has('limitColumns')
1196
+ ) {
1197
+ this.updateIntrinsicInlineSize();
1198
+ this.updatePosInline();
1199
+ this.updateVisInline();
1200
+ }
604
1201
  }
605
1202
 
606
1203
  override updated(changedProperties: Map<string, any>) {
@@ -613,10 +1210,16 @@ export class DxGrid extends LitElement {
613
1210
  }
614
1211
  }
615
1212
 
1213
+ public updateIfWithinBounds({ col, row }: { col: number; row: number }): boolean {
1214
+ if (col >= this.visColMin && col <= this.visColMax && row >= this.visRowMin && row <= this.visRowMax) {
1215
+ this.requestUpdate();
1216
+ return true;
1217
+ }
1218
+ return false;
1219
+ }
1220
+
616
1221
  override disconnectedCallback() {
617
1222
  super.disconnectedCallback();
618
- // console.log('[disconnected]', this.viewportRef.value);
619
- // TODO(thure): Will this even work?
620
1223
  if (this.viewportRef.value) {
621
1224
  this.observer.unobserve(this.viewportRef.value);
622
1225
  }
@@ -626,3 +1229,5 @@ export class DxGrid extends LitElement {
626
1229
  return this;
627
1230
  }
628
1231
  }
1232
+
1233
+ export { rowToA1Notation, colToA1Notation } from './util';