@svgrid/grid 1.1.1 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/FlexRender.svelte +96 -96
  2. package/dist/SvGrid.controller.svelte.js +17 -6
  3. package/dist/SvGrid.css +2012 -2012
  4. package/dist/SvGrid.svelte +2569 -2346
  5. package/dist/build-api.js +2 -2
  6. package/dist/cell-values.d.ts +1 -1
  7. package/dist/cell-values.js +7 -7
  8. package/dist/column-types.js +1 -1
  9. package/dist/core.d.ts +1 -1
  10. package/dist/core.js +2 -2
  11. package/dist/summaries.js +4 -4
  12. package/package.json +1 -1
  13. package/src/FlexRender.svelte +96 -96
  14. package/src/SvGrid.controller.svelte.ts +2363 -2352
  15. package/src/SvGrid.css +2012 -2012
  16. package/src/SvGrid.svelte +2569 -2346
  17. package/src/SvGrid.types.ts +537 -537
  18. package/src/a11y.contract.test.ts +49 -49
  19. package/src/a11y.test.ts +59 -59
  20. package/src/a11y.ts +59 -59
  21. package/src/build-api.ts +683 -683
  22. package/src/cell-formatting.ts +169 -169
  23. package/src/cell-values.ts +4 -4
  24. package/src/column-types.ts +1 -1
  25. package/src/core.performance.test.ts +30 -30
  26. package/src/core.ts +1077 -1077
  27. package/src/createGrid.svelte.ts +42 -42
  28. package/src/createGrid.test.ts +10 -10
  29. package/src/createGridState.svelte.ts +17 -17
  30. package/src/editing.ts +669 -669
  31. package/src/flex-render.ts +3 -3
  32. package/src/index.ts +208 -208
  33. package/src/keyboard.test.ts +59 -59
  34. package/src/keyboard.ts +97 -97
  35. package/src/merge-objects.ts +48 -48
  36. package/src/render-component.ts +28 -28
  37. package/src/spreadsheet.test.ts +489 -489
  38. package/src/spreadsheet.ts +304 -304
  39. package/src/static-functions.ts +11 -11
  40. package/src/subscribe.ts +38 -38
  41. package/src/summaries.ts +4 -4
  42. package/src/svgrid-wrapper.types.ts +412 -412
  43. package/src/svgrid.features.test.ts +157 -157
  44. package/src/svgrid.wrapper.test.ts +40 -40
  45. package/src/virtualization/column-virtualizer.test.ts +27 -27
  46. package/src/virtualization/column-virtualizer.ts +30 -30
  47. package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
  48. package/src/virtualization/types.ts +30 -30
  49. package/src/virtualization/virtualizer.test.ts +47 -47
  50. package/src/virtualization/virtualizer.ts +296 -296
@@ -1,2346 +1,2569 @@
1
- <script
2
- lang="ts"
3
- generics="TFeatures extends TableFeatures = TableFeatures, TData extends RowData = RowData"
4
- >
5
- import {
6
- getGridCellA11yProps,
7
- getGridCellDomId,
8
- getGridHeaderA11yProps,
9
- getGridRootA11yProps,
10
- getGridRowA11yProps,
11
- type EditorContext,
12
- type CellEditorOption,
13
- type Column,
14
- type Row,
15
- type RowData,
16
- type TableFeatures,
17
- } from "./index";
18
- import "./sv-grid-scrollbar";
19
- import "./SvGrid.css";
20
- import type { Snippet } from "svelte";
21
- import {
22
- RenderSnippetConfig,
23
- RenderComponentConfig,
24
- } from "./render-component";
25
- import {
26
- buildSparkline,
27
- toSparklineValues,
28
- } from "./sparkline";
29
- import SvGridDropdown from "./SvGridDropdown.svelte";
30
- import type {
31
- Props,
32
- SelectionPoint,
33
- SelectionRange,
34
- CellEditState,
35
- FilterOperator,
36
- FilterOption,
37
- MenuPosition,
38
- } from "./SvGrid.types";
39
- import {
40
- cfTextStyle,
41
- fmtStat,
42
- getEditableInputValue,
43
- getEditorInputType,
44
- toValueArray,
45
- getOptionLabel,
46
- getOptionColor,
47
- colorfulChipStyle,
48
- getEditorClass,
49
- } from "./SvGrid.helpers";
50
- import {
51
- createSvGridController,
52
- } from "./SvGrid.controller.svelte";
53
- import GridMenus from "./GridMenus.svelte";
54
- import GridFooter from "./GridFooter.svelte";
55
- let props: Props<TFeatures, TData> = $props();
56
- const ctrl = createSvGridController(props);
57
-
58
- // ---- View facade: re-bind the controller's reactive members as locals so the
59
- // markup stays identical. Assignable state + bindings + DOM refs use c.* directly.
60
- const paginationEnabled = $derived(ctrl.paginationEnabled);
61
- const filterRowValues = $derived(ctrl.filterRowValues);
62
- const filterMenuValues = $derived(ctrl.filterMenuValues);
63
- const tooltip = $derived(ctrl.tooltip);
64
- const showTooltipFor = $derived(ctrl.showTooltipFor);
65
- const hideTooltip = $derived(ctrl.hideTooltip);
66
- const findHits = $derived(ctrl.findHits);
67
- const headerHeight = $derived(ctrl.headerHeight);
68
- const resizingColumnId = $derived(ctrl.resizingColumnId);
69
- const selectionColumnWidth = $derived(ctrl.selectionColumnWidth);
70
- const rowNumberColumnWidth = $derived(ctrl.rowNumberColumnWidth);
71
- const showRowNumbersEffective = $derived(ctrl.showRowNumbersEffective);
72
- const columnMenuFor = $derived(ctrl.columnMenuFor);
73
- const filterMenuFor = $derived(ctrl.filterMenuFor);
74
- const operatorMenuFor = $derived(ctrl.operatorMenuFor);
75
- const viewportWidth = $derived(ctrl.viewportWidth);
76
- const viewportHeight = $derived(ctrl.viewportHeight);
77
- const scrollMetrics = $derived(ctrl.scrollMetrics);
78
- const hasVerticalOverflow = $derived(ctrl.hasVerticalOverflow);
79
- const showGlobalFilterEffective = $derived(ctrl.showGlobalFilterEffective);
80
- const showFilterRowEffective = $derived(ctrl.showFilterRowEffective);
81
- const showInlineColumnFilterEffective = $derived(ctrl.showInlineColumnFilterEffective);
82
- const showRowSelectionEffective = $derived(ctrl.showRowSelectionEffective);
83
- const grid = $derived(ctrl.grid);
84
- const allColumns = $derived(ctrl.allColumns);
85
- const headerGroups = $derived(ctrl.headerGroups);
86
- const groupHeaderRows = $derived(ctrl.groupHeaderRows);
87
- const cellPinStyle = $derived(ctrl.cellPinStyle);
88
- const isColumnPinned = $derived(ctrl.isColumnPinned);
89
- const colDragId = $derived(ctrl.colDragId);
90
- const colDropOnId = $derived(ctrl.colDropOnId);
91
- const colDropSide = $derived(ctrl.colDropSide);
92
- const onColumnHeaderDragStart = $derived(ctrl.onColumnHeaderDragStart);
93
- const onColumnHeaderDragOver = $derived(ctrl.onColumnHeaderDragOver);
94
- const onColumnHeaderDragLeave = $derived(ctrl.onColumnHeaderDragLeave);
95
- const onColumnHeaderDrop = $derived(ctrl.onColumnHeaderDrop);
96
- const onColumnHeaderDragEnd = $derived(ctrl.onColumnHeaderDragEnd);
97
- // Managed row dragging
98
- const rowDragManagedEffective = $derived(props.rowDragManaged === true);
99
- const rowDropIndex = $derived(ctrl.rowDropIndex);
100
- const rowDropSide = $derived(ctrl.rowDropSide);
101
- const onRowDragStart = $derived(ctrl.onRowDragStart);
102
- const onRowDragOver = $derived(ctrl.onRowDragOver);
103
- const onRowDragLeave = $derived(ctrl.onRowDragLeave);
104
- const onRowDrop = $derived(ctrl.onRowDrop);
105
- const onRowsContainerDragOver = $derived(ctrl.onRowsContainerDragOver);
106
- const onRowsContainerDrop = $derived(ctrl.onRowsContainerDrop);
107
- const onRowDragEndHandler = $derived(ctrl.onRowDragEnd);
108
- // Drag attributes spread onto each body <tr>; empty object when disabled so
109
- // rows stay non-draggable and no handlers fire.
110
- function rowDragAttrs(rowIndex: number) {
111
- if (!rowDragManagedEffective) return {};
112
- return {
113
- draggable: true,
114
- ondragstart: (e: DragEvent) => onRowDragStart(e, rowIndex),
115
- ondragover: (e: DragEvent) => onRowDragOver(e, rowIndex),
116
- ondragleave: () => onRowDragLeave(rowIndex),
117
- ondrop: (e: DragEvent) => onRowDrop(e, rowIndex),
118
- ondragend: () => onRowDragEndHandler(),
119
- };
120
- }
121
- function rowDropClass(rowIndex: number) {
122
- if (!rowDragManagedEffective || rowDropIndex !== rowIndex) return "";
123
- return rowDropSide === "after"
124
- ? "sv-grid-row-drop-after"
125
- : "sv-grid-row-drop-before";
126
- }
127
- const getColumnBaseValue = $derived(ctrl.getColumnBaseValue);
128
- const hasConditionalFormats = $derived(ctrl.hasConditionalFormats);
129
- const cellConditionalFormat = $derived(ctrl.cellConditionalFormat);
130
- const isGroupRow = $derived(ctrl.isGroupRow);
131
- const sortDirectionByColumn = $derived(ctrl.sortDirectionByColumn);
132
- const groupingColumns = $derived(ctrl.groupingColumns);
133
- const paginationState = $derived(ctrl.paginationState);
134
- const allRowsBeforePagination = $derived(ctrl.allRowsBeforePagination);
135
- const allRows = $derived(ctrl.allRows);
136
- const statusBarEnabled = $derived(ctrl.statusBarEnabled);
137
- const statusBarAggregates = $derived(ctrl.statusBarAggregates);
138
- const statusBarStats = $derived(ctrl.statusBarStats);
139
- const toolPanelEnabled = $derived(ctrl.toolPanelEnabled);
140
- const toolPanelColumns = $derived(ctrl.toolPanelColumns);
141
- const toolPanelHeaderLabel = $derived(ctrl.toolPanelHeaderLabel);
142
- const toggleColumnVisibleInPanel = $derived(ctrl.toggleColumnVisibleInPanel);
143
- const moveColumnInPanel = $derived(ctrl.moveColumnInPanel);
144
- const toggleGroupInPanel = $derived(ctrl.toggleGroupInPanel);
145
- const virtualizer = $derived(ctrl.virtualizer);
146
- const rowVirtualizationEnabled = $derived(ctrl.rowVirtualizationEnabled);
147
- const columnVirtualizationEnabled = $derived(ctrl.columnVirtualizationEnabled);
148
- const virtualRows = $derived(ctrl.virtualRows);
149
- // DOM-space spacer heights + capped total: identical to the logical
150
- // virtualizer values for normal grids, scaled down past
151
- // MAX_DOM_SCROLL_HEIGHT so huge grids stay scrollable to the last row on
152
- // mobile (where the max element height is lowest).
153
- const rowTopSpacer = $derived(ctrl.rowTopSpacer);
154
- const rowBottomSpacer = $derived(ctrl.rowBottomSpacer);
155
- const rowDomTotalSize = $derived(ctrl.rowDomTotalSize);
156
- const renderedColumns = $derived(ctrl.renderedColumns);
157
- const totalColumnWidth = $derived(ctrl.totalColumnWidth);
158
-
159
- // ---- Built-in cell flash (ColumnDef `cellFlash`) ----------------------
160
- // Flashes a cell when its value changes (edits, streaming feeds, server
161
- // pushes). Keyed by rowId so virtualization recycling a <td> into a new row
162
- // on scroll does NOT trigger a spurious flash - only a same-row value change
163
- // does. Inert (active:false) for columns without `cellFlash`.
164
- function cellFlashAction(
165
- node: HTMLElement,
166
- params: { rowId: string; value: unknown; active: boolean; className: string },
167
- ) {
168
- let prevRow = params.rowId;
169
- let prev = params.value;
170
- return {
171
- update(next: { rowId: string; value: unknown; active: boolean; className: string }) {
172
- if (next.rowId !== prevRow) {
173
- // A different row scrolled into this recycled slot - reset, no flash.
174
- prevRow = next.rowId;
175
- prev = next.value;
176
- return;
177
- }
178
- if (next.active && next.className && !Object.is(next.value, prev)) {
179
- node.classList.remove(next.className);
180
- void node.offsetWidth; // reflow so the animation restarts
181
- node.classList.add(next.className);
182
- }
183
- prev = next.value;
184
- },
185
- };
186
- }
187
- function flashClassFor(cfg: boolean | { className?: string } | undefined): string {
188
- if (cfg && typeof cfg === "object" && cfg.className) return cfg.className;
189
- return "sv-grid-cell-flash";
190
- }
191
-
192
- // ---- Full-row editing -------------------------------------------------
193
- const fullRowEdit = $derived(ctrl.fullRowEdit);
194
- // Commit the whole row when the user clicks away from its editors (Excel /
195
- // AG-Grid feel). Clicking within any full-row editor keeps editing.
196
- $effect(() => {
197
- if (!fullRowEdit) return;
198
- const onDown = (e: PointerEvent) => {
199
- const t = e.target as HTMLElement | null;
200
- if (t && t.closest(".sv-grid-fr-editor")) return;
201
- ctrl.commitFullRowEdit();
202
- };
203
- document.addEventListener("pointerdown", onDown, true);
204
- return () => document.removeEventListener("pointerdown", onDown, true);
205
- });
206
- function fullRowKeydown(e: KeyboardEvent) {
207
- if (e.key === "Enter") {
208
- e.preventDefault();
209
- ctrl.commitFullRowEdit();
210
- ctrl.gridRootEl?.focus({ preventScroll: true });
211
- } else if (e.key === "Escape") {
212
- e.preventDefault();
213
- ctrl.cancelFullRowEdit();
214
- ctrl.gridRootEl?.focus({ preventScroll: true });
215
- }
216
- }
217
- function optionValueOf(o: unknown): string {
218
- return typeof o === "object" && o !== null && "value" in o
219
- ? String((o as { value: unknown }).value)
220
- : String(o);
221
- }
222
- function optionLabelOf(o: unknown): string {
223
- return typeof o === "object" && o !== null && "label" in o
224
- ? String((o as { label: unknown }).label)
225
- : String(o);
226
- }
227
- const hasHorizontalOverflow = $derived(ctrl.hasHorizontalOverflow);
228
- const columnWindowStart = $derived(ctrl.columnWindowStart);
229
- const columnWindowRightSpacer = $derived(ctrl.columnWindowRightSpacer);
230
- const activeCell = $derived(ctrl.activeCell);
231
- const activeDescendantId = $derived(ctrl.activeDescendantId);
232
- const summaryByColumn = $derived(ctrl.summaryByColumn);
233
- const hasMeasured = $derived(ctrl.hasMeasured);
234
- const onBodyScroll = $derived(ctrl.onBodyScroll);
235
- const computeRowClass = $derived(ctrl.computeRowClass);
236
- const computeCellClass = $derived(ctrl.computeCellClass);
237
- const computeCellTooltip = $derived(ctrl.computeCellTooltip);
238
- const computeCellNote = $derived(ctrl.computeCellNote);
239
- const getCellDisplayValue = $derived(ctrl.getCellDisplayValue);
240
- const getColumnAlign = $derived(ctrl.getColumnAlign);
241
- const getColumnEditorOptions = $derived(ctrl.getColumnEditorOptions);
242
- const formatListCellValue = $derived(ctrl.formatListCellValue);
243
- const formatCellValue = $derived(ctrl.formatCellValue);
244
- const getPinnedCellValue = $derived(ctrl.getPinnedCellValue);
245
- const formatPinnedValue = $derived(ctrl.formatPinnedValue);
246
- const computePinnedCellClass = $derived(ctrl.computePinnedCellClass);
247
- const isRowSelected = $derived(ctrl.isRowSelected);
248
- const toggleRowSelectionById = $derived(ctrl.toggleRowSelectionById);
249
- const headerSelectionState = $derived(ctrl.headerSelectionState);
250
- const toggleSelectAllRows = $derived(ctrl.toggleSelectAllRows);
251
- const setActiveCell = $derived(ctrl.setActiveCell);
252
- const scrollActiveCellIntoView = $derived(ctrl.scrollActiveCellIntoView);
253
- const getColumnWidth = $derived(ctrl.getColumnWidth);
254
- const startColumnResize = $derived(ctrl.startColumnResize);
255
- const getCellRangeEdges = $derived(ctrl.getCellRangeEdges);
256
- const fillHandleCell = $derived(ctrl.fillHandleCell);
257
- const isInFillPreview = $derived(ctrl.isInFillPreview);
258
- const startFillDrag = $derived(ctrl.startFillDrag);
259
- const onCellPointerDown = $derived(ctrl.onCellPointerDown);
260
- const onCellPointerEnter = $derived(ctrl.onCellPointerEnter);
261
- const endDragSelection = $derived(ctrl.endDragSelection);
262
- const onWindowPointerMove = $derived(ctrl.onWindowPointerMove);
263
- const onCellClick = $derived(ctrl.onCellClick);
264
- const emitCellDoubleClick = $derived(ctrl.emitCellDoubleClick);
265
- const openContextMenu = $derived(ctrl.openContextMenu);
266
- const saveEditingCell = $derived(ctrl.saveEditingCell);
267
- const updateEditingCellValue = $derived(ctrl.updateEditingCellValue);
268
- const onEditorKeyDown = $derived(ctrl.onEditorKeyDown);
269
- const focusOnMount = $derived(ctrl.focusOnMount);
270
- const onHeaderSortClick = $derived(ctrl.onHeaderSortClick);
271
- const onGridKeyDown = $derived(ctrl.onGridKeyDown);
272
- const onGridPaste = $derived(ctrl.onGridPaste);
273
- const changePage = $derived(ctrl.changePage);
274
- const goToPage = $derived(ctrl.goToPage);
275
- const setPageSize = $derived(ctrl.setPageSize);
276
- const updateFilterRow = $derived(ctrl.updateFilterRow);
277
- const updateFilterMenuValue = $derived(ctrl.updateFilterMenuValue);
278
- const updateFilterMenuValueTo = $derived(ctrl.updateFilterMenuValueTo);
279
- const updateFilterOperator = $derived(ctrl.updateFilterOperator);
280
- const operatorsForColumn = $derived(ctrl.operatorsForColumn);
281
- const clearColumnFilter = $derived(ctrl.clearColumnFilter);
282
- const toggleCheckboxWithKeyboard = $derived(ctrl.toggleCheckboxWithKeyboard);
283
- const operatorOption = $derived(ctrl.operatorOption);
284
- const defaultOperatorFor = $derived(ctrl.defaultOperatorFor);
285
- const isColumnFiltered = $derived(ctrl.isColumnFiltered);
286
- const openColumnMenu = $derived(ctrl.openColumnMenu);
287
- const openFilterMenu = $derived(ctrl.openFilterMenu);
288
- const openOperatorMenu = $derived(ctrl.openOperatorMenu);
289
- const onWindowKeydown = $derived(ctrl.onWindowKeydown);
290
- </script>
291
-
292
- <svelte:window
293
- onkeydown={onWindowKeydown}
294
- onpointerup={endDragSelection}
295
- onpointermove={onWindowPointerMove}
296
- />
297
-
298
- {#if props.loading && !props.loadingOverlay}
299
- <div class="sv-grid-state sv-grid-state-loading" role="status">
300
- Loading grid data...
301
- </div>
302
- {:else if props.error}
303
- <div class="sv-grid-state sv-grid-state-error" role="alert">
304
- {props.error}
305
- </div>
306
- {:else}
307
- {#snippet icon(name: string)}
308
- <svg
309
- class="sv-grid-icon"
310
- viewBox="0 0 24 24"
311
- fill="none"
312
- stroke="currentColor"
313
- stroke-width="2.2"
314
- stroke-linecap="round"
315
- stroke-linejoin="round"
316
- aria-hidden="true"
317
- >
318
- {#if name === "sort"}
319
- <path d="M8 10l4-4 4 4" />
320
- <path d="M8 14l4 4 4-4" />
321
- {:else if name === "sort-asc"}
322
- <path d="M6 14l6-6 6 6" />
323
- {:else if name === "sort-desc"}
324
- <path d="M6 10l6 6 6-6" />
325
- {:else if name === "filter"}
326
- <path d="M3 5h18l-7 8v6l-4 2v-8z" />
327
- {:else if name === "menu"}
328
- <path d="M4 7h16" />
329
- <path d="M4 12h16" />
330
- <path d="M4 17h16" />
331
- {:else if name === "group"}
332
- <path d="M12 3l8 4.5-8 4.5-8-4.5z" />
333
- <path d="M4 12l8 4.5 8-4.5" />
334
- <path d="M4 16.5l8 4.5 8-4.5" />
335
- {:else if name === "x"}
336
- <path d="M18 6L6 18" />
337
- <path d="M6 6l12 12" />
338
- {:else if name === "chevron-down"}
339
- <path d="M6 9l6 6 6-6" />
340
- {:else if name === "op-contains"}
341
- <circle cx="11" cy="11" r="6" />
342
- <path d="M20 20l-4.5-4.5" />
343
- {:else if name === "op-equals"}
344
- <path d="M5 9.5h14" />
345
- <path d="M5 14.5h14" />
346
- {:else if name === "op-startsWith"}
347
- <path d="M5 5v14" />
348
- <path d="M9 9h10" />
349
- <path d="M9 15h7" />
350
- {:else if name === "op-greaterThan"}
351
- <path d="M8 5l9 7-9 7" />
352
- {:else if name === "op-lessThan"}
353
- <path d="M16 5l-9 7 9 7" />
354
- {:else if name === "op-isBlank"}
355
- <circle cx="12" cy="12" r="8" />
356
- <path d="M6.5 6.5l11 11" />
357
- {:else if name === "autosize"}
358
- <path d="M3 12h18" />
359
- <path d="M3 12l4-4" />
360
- <path d="M3 12l4 4" />
361
- <path d="M21 12l-4-4" />
362
- <path d="M21 12l-4 4" />
363
- {:else if name === "columns"}
364
- <rect x="3" y="4" width="5" height="16" rx="1" />
365
- <rect x="10" y="4" width="5" height="16" rx="1" />
366
- <rect x="17" y="4" width="4" height="16" rx="1" />
367
- {:else if name === "reset"}
368
- <path d="M3 4v6h6" />
369
- <path d="M3.5 10A9 9 0 1 0 6 5.3" />
370
- {/if}
371
- </svg>
372
- {/snippet}
373
-
374
- {#snippet cellBody(
375
- row: Row<TData>,
376
- column: Column<TData>,
377
- cellValue: unknown,
378
- )}
379
- {#if column.columnDef.editorType === "checkbox" || typeof cellValue === "boolean"}
380
- <div
381
- class="sv-grid-checkbox sv-grid-checkbox-readonly"
382
- role="checkbox"
383
- aria-checked={Boolean(cellValue)}
384
- aria-readonly="true"
385
- aria-label={toolPanelHeaderLabel(column)}
386
- ></div>
387
- {:else if (column.columnDef.editorType === "list" || column.columnDef.editorType === "chips") && column.columnDef.cell == null}
388
- {@const arr = Array.isArray(cellValue)
389
- ? cellValue
390
- : cellValue == null || cellValue === ""
391
- ? []
392
- : [cellValue]}
393
- {@const opts = getColumnEditorOptions(column, row)}
394
- {@const isChipsType = column.columnDef.editorType === "chips"}
395
- {@const anyColored = arr.some((v) => getOptionColor(opts, v))}
396
- {#if arr.length > 0 && (isChipsType || anyColored)}
397
- <!-- Render as chips when:
398
- - the column is `chips` (always pill-style), OR
399
- - the column is `list` and at least one selected option has
400
- a `color` (so single-list cells like Priority "high" get
401
- a pill too, not just plain text). -->
402
- <div class="sv-grid-chips-display">
403
- {#each arr as v (String(v))}
404
- <span
405
- class="sv-grid-chip"
406
- style={colorfulChipStyle(getOptionColor(opts, v))}
407
- >
408
- {getOptionLabel(opts, v)}
409
- </span>
410
- {/each}
411
- </div>
412
- {:else}
413
- {formatListCellValue(column, cellValue, row)}
414
- {/if}
415
- {:else if column.columnDef.sparkline && column.columnDef.cell == null}
416
- {@const geo = buildSparkline(
417
- toSparklineValues(cellValue),
418
- column.columnDef.sparkline,
419
- )}
420
- {#if geo}
421
- {@const vals = toSparklineValues(cellValue)}
422
- <svg
423
- class="sv-grid-sparkline"
424
- width={geo.width}
425
- height={geo.height}
426
- viewBox={`0 0 ${geo.width} ${geo.height}`}
427
- preserveAspectRatio="none"
428
- role="img"
429
- aria-label={`Sparkline, ${vals.length} points, last ${vals[vals.length - 1]}`}
430
- >
431
- {#if geo.areaPath}
432
- <path
433
- d={geo.areaPath}
434
- fill={geo.color}
435
- fill-opacity="0.18"
436
- stroke="none"
437
- />
438
- {/if}
439
- {#if geo.linePath}
440
- <path
441
- d={geo.linePath}
442
- fill="none"
443
- stroke={geo.color}
444
- stroke-width={geo.lineWidth}
445
- stroke-linejoin="round"
446
- stroke-linecap="round"
447
- />
448
- {/if}
449
- {#each geo.bars as bar, i (i)}
450
- <rect
451
- x={bar.x}
452
- y={bar.y}
453
- width={bar.w}
454
- height={bar.h}
455
- rx="0.5"
456
- fill={bar.negative ? geo.negativeColor : geo.color}
457
- />
458
- {/each}
459
- {#if geo.lastPoint}
460
- <circle
461
- cx={geo.lastPoint.x}
462
- cy={geo.lastPoint.y}
463
- r={geo.lineWidth + 0.5}
464
- fill={geo.color}
465
- />
466
- {/if}
467
- </svg>
468
- {/if}
469
- {:else}
470
- {#if row.depth > 0 && column.id === allColumns[0]?.id}
471
- <span
472
- class="sv-grid-group-child-indent"
473
- style={`width: ${row.depth * 20}px;`}
474
- aria-hidden="true"
475
- ></span>
476
- {/if}
477
- {@const cellTemplate = column.columnDef.cell}
478
- {#if typeof cellTemplate === "function"}
479
- {@const rendered = cellTemplate({
480
- cell: {
481
- id: `${row.id}_${column.id}`,
482
- row,
483
- column,
484
- getValue: () => cellValue,
485
- getContext: () => ({}) as any,
486
- },
487
- row,
488
- column,
489
- table: grid,
490
- getValue: () => cellValue,
491
- })}
492
- {#if rendered instanceof RenderSnippetConfig}
493
- {@render rendered.snippet(rendered.params)}
494
- {:else if rendered instanceof RenderComponentConfig}
495
- <rendered.component {...rendered.props ?? {}} />
496
- {:else if typeof rendered === "string" || typeof rendered === "number"}
497
- {rendered}
498
- {:else}
499
- {formatCellValue(column, cellValue, row)}
500
- {/if}
501
- {:else if typeof cellTemplate === "string"}
502
- {cellTemplate}
503
- {:else}
504
- {formatCellValue(column, cellValue, row)}
505
- {/if}
506
- {/if}
507
- {/snippet}
508
-
509
- <!-- Cell content wrapped with conditional-formatting overlays. The color-
510
- scale fill and data bar are absolutely-positioned layers BEHIND the
511
- text (the app stylesheet forces `.sv-grid-cell` background with
512
- !important, so an overlay is the only way to tint reliably). Text
513
- color / weight and the icon-set glyph ride on the content wrapper.
514
- Falls straight through to `cellBody` when no format applies, so the
515
- common (unformatted) path pays nothing. -->
516
- {#snippet cellBodyWithFormat(
517
- row: Row<TData>,
518
- column: Column<TData>,
519
- cellValue: unknown,
520
- )}
521
- {@const cf = cellConditionalFormat(row, column, cellValue)}
522
- {#if cf && (cf.background || cf.dataBar || cf.icon || cf.color || cf.fontWeight != null)}
523
- {#if cf.background}
524
- <div class="sv-grid-cf-bg" style={`background:${cf.background}`}></div>
525
- {/if}
526
- {#if cf.dataBar}
527
- <div
528
- class="sv-grid-cf-bar"
529
- style={`width:${cf.dataBar.percent}%;background:${cf.dataBar.color}`}
530
- ></div>
531
- {/if}
532
- <span class="sv-grid-cf-content" style={cfTextStyle(cf)}>
533
- {#if cf.icon}<span class="sv-grid-cf-icon">{cf.icon}</span>{/if}
534
- {#if !cf.iconOnly}{@render cellBody(row, column, cellValue)}{/if}
535
- </span>
536
- {:else}
537
- {@render cellBody(row, column, cellValue)}
538
- {/if}
539
- {/snippet}
540
-
541
- <!-- Active cell editor. Branches on `ctrl.editingCell.editorType`. Rendered
542
- only when a cell is in edit mode; the call site supplies the column
543
- AND row so we can resolve row-dependent `editorOptions` (cascade). -->
544
- {#snippet editorBody(column: Column<TData>, row: Row<TData>)}
545
- {#if column.columnDef.cellEditor}
546
- <!-- Custom editor slot. The columnDef provides a snippet that
547
- receives the editor context (value + commit + cancel) so the
548
- consumer fully owns the in-cell UI. -->
549
- {@const customEditor = column.columnDef.cellEditor as unknown as import('svelte').Snippet<[EditorContext<TData>]>}
550
- {@render customEditor({
551
- cell: row.getAllCells().find((c) => c.column.id === column.id)!,
552
- row,
553
- column,
554
- table: grid,
555
- getValue: () => ctrl.editingCell?.value,
556
- value: ctrl.editingCell?.value,
557
- update: (next: unknown) => {
558
- // Stage the draft without closing. Live-preview controls
559
- // (sliders, color pickers) call this on every input tick.
560
- ctrl.editingCell = ctrl.editingCell ? { ...ctrl.editingCell, value: next } : ctrl.editingCell
561
- },
562
- commit: (next?: unknown) => {
563
- // Write + close. If the caller passed a value, stage it
564
- // first; otherwise save whatever update() last wrote.
565
- if (next !== undefined) {
566
- ctrl.editingCell = ctrl.editingCell ? { ...ctrl.editingCell, value: next } : ctrl.editingCell
567
- }
568
- saveEditingCell()
569
- },
570
- cancel: () => {
571
- ctrl.editingCell = null
572
- ctrl.gridRootEl?.focus({ preventScroll: true })
573
- },
574
- })}
575
- {:else if ctrl.editingCell?.editorType === "checkbox"}
576
- <button
577
- type="button"
578
- class="sv-grid-checkbox"
579
- role="checkbox"
580
- aria-checked={Boolean(ctrl.editingCell.value)}
581
- aria-label="Edit checkbox value"
582
- onclick={(event) => {
583
- event.stopPropagation();
584
- const nextValue = !Boolean(ctrl.editingCell?.value);
585
- ctrl.editingCell = ctrl.editingCell
586
- ? { ...ctrl.editingCell, value: nextValue }
587
- : ctrl.editingCell;
588
- saveEditingCell();
589
- }}
590
- onkeydown={(event) =>
591
- toggleCheckboxWithKeyboard(event, () => {
592
- event.stopPropagation();
593
- const nextValue = !Boolean(ctrl.editingCell?.value);
594
- ctrl.editingCell = ctrl.editingCell
595
- ? { ...ctrl.editingCell, value: nextValue }
596
- : ctrl.editingCell;
597
- saveEditingCell();
598
- })}
599
- onblur={() => saveEditingCell()}
600
- ></button>
601
- {:else if ctrl.editingCell?.editorType === "list"}
602
- {@const opts = getColumnEditorOptions(column, row)}
603
- {@const multi = column.columnDef.editorMultiple === true}
604
- <SvGridDropdown
605
- options={opts}
606
- value={ctrl.editingCell?.value}
607
- multiple={multi}
608
- placeholder="Select…"
609
- onChange={(next) => {
610
- ctrl.editingCell = ctrl.editingCell
611
- ? { ...ctrl.editingCell, value: next }
612
- : ctrl.editingCell;
613
- }}
614
- onCommit={() => saveEditingCell()}
615
- onCancel={() => {
616
- ctrl.editingCell = null;
617
- ctrl.gridRootEl?.focus({ preventScroll: true });
618
- }}
619
- />
620
- {:else if ctrl.editingCell?.editorType === "chips"}
621
- {@const opts = getColumnEditorOptions(column, row)}
622
- {@const multi = column.columnDef.editorMultiple === true}
623
- {@const selectedArr = toValueArray(ctrl.editingCell?.value)}
624
- {@render chipsEditor(opts, multi, selectedArr)}
625
- {:else if ctrl.editingCell?.editorType === "rating"}
626
- {@const ratingVal = Math.max(0, Math.min(5, Math.round(Number(ctrl.editingCell?.value) || 0)))}
627
- <span
628
- class="sv-grid-rating-editor"
629
- role="radiogroup"
630
- aria-label="Rating"
631
- >
632
- {#each [1, 2, 3, 4, 5] as n (n)}
633
- <button
634
- type="button"
635
- role="radio"
636
- aria-checked={ratingVal >= n}
637
- aria-label={`${n} ${n === 1 ? "star" : "stars"}`}
638
- class={`sv-grid-rating-star ${ratingVal >= n ? "sv-grid-rating-star-on" : ""}`}
639
- onmousedown={(event) => event.preventDefault()}
640
- onclick={(event) => {
641
- event.stopPropagation();
642
- ctrl.editingCell = ctrl.editingCell
643
- ? { ...ctrl.editingCell, value: n }
644
- : ctrl.editingCell;
645
- saveEditingCell();
646
- }}
647
- onkeydown={onEditorKeyDown}
648
- >★</button>
649
- {/each}
650
- <button
651
- type="button"
652
- aria-label="Clear rating"
653
- class="sv-grid-rating-clear"
654
- onmousedown={(event) => event.preventDefault()}
655
- onclick={(event) => {
656
- event.stopPropagation();
657
- ctrl.editingCell = ctrl.editingCell
658
- ? { ...ctrl.editingCell, value: 0 }
659
- : ctrl.editingCell;
660
- saveEditingCell();
661
- }}
662
- >×</button>
663
- </span>
664
- {:else if ctrl.editingCell?.editorType === "select"}
665
- <!-- Custom dropdown: opens a themed popover identical in feel to
666
- the existing 'list' editor (single-select, no typeahead). -->
667
- {@const selectOpts = getColumnEditorOptions(column, row)}
668
- <SvGridDropdown
669
- options={selectOpts}
670
- value={ctrl.editingCell?.value}
671
- multiple={false}
672
- placeholder="Select…"
673
- onChange={(next) => {
674
- ctrl.editingCell = ctrl.editingCell
675
- ? { ...ctrl.editingCell, value: next }
676
- : ctrl.editingCell;
677
- }}
678
- onCommit={() => saveEditingCell()}
679
- onCancel={() => {
680
- ctrl.editingCell = null;
681
- ctrl.gridRootEl?.focus({ preventScroll: true });
682
- }}
683
- />
684
- {:else if ctrl.editingCell?.editorType === "rich-select"}
685
- <!-- Searchable combobox: same popover as 'select' with a
686
- typeahead filter input baked in at the top. -->
687
- {@const richOpts = getColumnEditorOptions(column, row)}
688
- <SvGridDropdown
689
- options={richOpts}
690
- value={ctrl.editingCell?.value}
691
- multiple={false}
692
- searchable={true}
693
- placeholder="Search…"
694
- onChange={(next) => {
695
- ctrl.editingCell = ctrl.editingCell
696
- ? { ...ctrl.editingCell, value: next }
697
- : ctrl.editingCell;
698
- }}
699
- onCommit={() => saveEditingCell()}
700
- onCancel={() => {
701
- ctrl.editingCell = null;
702
- ctrl.gridRootEl?.focus({ preventScroll: true });
703
- }}
704
- />
705
- {:else if ctrl.editingCell?.editorType === "textarea"}
706
- <!-- Multi-line editor. Commits on Tab, Ctrl/Cmd+Enter, or blur.
707
- Plain Enter inserts a newline (the whole point of textarea).
708
- Esc cancels. -->
709
- <textarea
710
- use:focusOnMount
711
- class="sv-grid-cell-editor sv-grid-cell-editor-textarea"
712
- rows="4"
713
- value={String(ctrl.editingCell?.value ?? "")}
714
- onpointerdown={(event) => event.stopPropagation()}
715
- oninput={(event) =>
716
- updateEditingCellValue(
717
- (event.currentTarget as HTMLTextAreaElement).value,
718
- )}
719
- onkeydown={(event) => {
720
- event.stopPropagation()
721
- if (event.key === "Escape") {
722
- event.preventDefault()
723
- ctrl.editingCell = null
724
- ctrl.gridRootEl?.focus({ preventScroll: true })
725
- return
726
- }
727
- // Tab and Ctrl/Cmd+Enter both commit. Plain Enter inserts a newline.
728
- if (event.key === "Tab" || (event.key === "Enter" && (event.ctrlKey || event.metaKey))) {
729
- event.preventDefault()
730
- saveEditingCell()
731
- ctrl.gridRootEl?.focus({ preventScroll: true })
732
- }
733
- }}
734
- onblur={() => saveEditingCell()}
735
- ></textarea>
736
- {:else if ctrl.editingCell?.editorType === "autocomplete"}
737
- <!-- Free-text autocomplete: a text input with a live-filtered
738
- suggestion list. Typing edits the value freely; clicking a
739
- suggestion (or blur) commits. Accepts values not in the list. -->
740
- {@const acOpts = getColumnEditorOptions(column, row)}
741
- {@const acText = String(ctrl.editingCell?.value ?? "")}
742
- {@const acFiltered = acText.trim()
743
- ? acOpts.filter((o) =>
744
- String(o.label).toLowerCase().includes(acText.toLowerCase()),
745
- )
746
- : acOpts}
747
- <div class="sv-grid-autocomplete">
748
- <input
749
- use:focusOnMount
750
- class="sv-grid-cell-editor sv-grid-cell-editor-autocomplete"
751
- type="text"
752
- value={acText}
753
- onpointerdown={(event) => event.stopPropagation()}
754
- oninput={(event) =>
755
- updateEditingCellValue(
756
- (event.currentTarget as HTMLInputElement).value,
757
- )}
758
- onblur={() => saveEditingCell()}
759
- onkeydown={onEditorKeyDown}
760
- />
761
- {#if acFiltered.length > 0}
762
- <div class="sv-grid-autocomplete-list" role="listbox">
763
- {#each acFiltered.slice(0, 50) as opt (opt.value)}
764
- <button
765
- type="button"
766
- class="sv-grid-autocomplete-option"
767
- role="option"
768
- aria-selected={String(opt.value) === acText}
769
- onmousedown={(event) => {
770
- event.preventDefault();
771
- ctrl.editingCell = ctrl.editingCell
772
- ? { ...ctrl.editingCell, value: opt.value }
773
- : ctrl.editingCell;
774
- saveEditingCell();
775
- }}
776
- >{opt.label}</button>
777
- {/each}
778
- </div>
779
- {/if}
780
- </div>
781
- {:else if ctrl.editingCell?.editorType === "color"}
782
- <!-- Native <input type="color"> opens its picker in a separate OS
783
- overlay; once the picker closes, focus stays on the input so
784
- `blur` never fires on its own. Commit on `change` (which fires
785
- exactly once when the picker is dismissed) so the chosen color
786
- is saved without needing the user to click elsewhere. -->
787
- <input
788
- use:focusOnMount
789
- class={getEditorClass("color")}
790
- type="color"
791
- value={getEditableInputValue("color", ctrl.editingCell?.value)}
792
- oninput={(event) =>
793
- updateEditingCellValue(
794
- (event.currentTarget as HTMLInputElement).value,
795
- )}
796
- onchange={(event) => {
797
- updateEditingCellValue(
798
- (event.currentTarget as HTMLInputElement).value,
799
- );
800
- saveEditingCell();
801
- }}
802
- onblur={() => saveEditingCell()}
803
- onkeydown={onEditorKeyDown}
804
- />
805
- {:else}
806
- <input
807
- use:focusOnMount
808
- class={getEditorClass(ctrl.editingCell?.editorType ?? "text")}
809
- type={getEditorInputType(ctrl.editingCell?.editorType ?? "text")}
810
- value={getEditableInputValue(
811
- ctrl.editingCell?.editorType ?? "text",
812
- ctrl.editingCell?.value,
813
- )}
814
- oninput={(event) =>
815
- updateEditingCellValue(
816
- (event.currentTarget as HTMLInputElement).value,
817
- )}
818
- onblur={() => saveEditingCell()}
819
- onkeydown={onEditorKeyDown}
820
- />
821
- {/if}
822
- {/snippet}
823
-
824
- <!-- Full-row editor: a lightweight inline editor per editable cell, shown
825
- for every editable column of the row in full-row edit. Kept separate
826
- from `editorBody` (which owns the 14 rich single-cell editors) so
827
- cell editing is untouched. Covers the common editor types. -->
828
- {#snippet fullRowEditor(column: Column<TData>, row: Row<TData>)}
829
- {@const et = column.columnDef.editorType ?? "text"}
830
- {@const val = fullRowEdit?.draft[column.id]}
831
- {#if et === "checkbox"}
832
- <input
833
- type="checkbox"
834
- class="sv-grid-fr-editor sv-grid-fr-checkbox"
835
- checked={Boolean(val)}
836
- onchange={(e) => ctrl.setFullRowDraft(column.id, e.currentTarget.checked)}
837
- onkeydown={fullRowKeydown}
838
- onpointerdown={(e) => e.stopPropagation()}
839
- onclick={(e) => e.stopPropagation()}
840
- />
841
- {:else if et === "list" || et === "select" || et === "rich-select"}
842
- {@const opts = getColumnEditorOptions(column, row)}
843
- <select
844
- class="sv-grid-cell-editor sv-grid-fr-editor"
845
- value={String(val ?? "")}
846
- onchange={(e) => ctrl.setFullRowDraft(column.id, e.currentTarget.value)}
847
- onkeydown={fullRowKeydown}
848
- onpointerdown={(e) => e.stopPropagation()}
849
- onclick={(e) => e.stopPropagation()}
850
- >
851
- {#each opts as o (optionValueOf(o))}
852
- <option value={optionValueOf(o)}>{optionLabelOf(o)}</option>
853
- {/each}
854
- </select>
855
- {:else}
856
- {@const inputType =
857
- et === "number" ? "number"
858
- : et === "date" ? "date"
859
- : et === "datetime" ? "datetime-local"
860
- : et === "time" ? "time"
861
- : et === "password" ? "password"
862
- : "text"}
863
- <input
864
- type={inputType}
865
- class="sv-grid-cell-editor sv-grid-fr-editor"
866
- value={String(val ?? "")}
867
- oninput={(e) => ctrl.setFullRowDraft(column.id, e.currentTarget.value)}
868
- onkeydown={fullRowKeydown}
869
- onpointerdown={(e) => e.stopPropagation()}
870
- onclick={(e) => e.stopPropagation()}
871
- />
872
- {/if}
873
- {/snippet}
874
-
875
- {#snippet chipsEditor(
876
- opts: CellEditorOption[],
877
- multi: boolean,
878
- selectedArr: Array<string | number>,
879
- )}
880
- {#if opts.length > 0}
881
- <!-- Options-driven chips editor: defer to the custom dropdown,
882
- which renders the selected values as chips in its trigger and
883
- pops out a styled listbox. Identical UX to the list editor
884
- with renderChipsInTrigger flipped on. -->
885
- <SvGridDropdown
886
- options={opts}
887
- value={ctrl.editingCell?.value}
888
- multiple={multi}
889
- placeholder="Pick…"
890
- renderChipsInTrigger={true}
891
- onChange={(next) => {
892
- ctrl.editingCell = ctrl.editingCell
893
- ? { ...ctrl.editingCell, value: next }
894
- : ctrl.editingCell;
895
- }}
896
- onCommit={() => saveEditingCell()}
897
- onCancel={() => {
898
- ctrl.editingCell = null;
899
- ctrl.gridRootEl?.focus({ preventScroll: true });
900
- }}
901
- />
902
- {:else}
903
- <!-- Free-form chips: typed tags. Enter / comma commits a chip,
904
- Backspace on empty input removes the last chip, blur saves. -->
905
- <div
906
- class="sv-grid-cell-editor sv-grid-cell-editor-chips"
907
- role="group"
908
- tabindex={-1}
909
- >
910
- <div class="sv-grid-chips-row">
911
- {#each selectedArr as v, idx (String(v) + "_" + idx)}
912
- <span class="sv-grid-chip sv-grid-chip-removable">
913
- {String(v)}
914
- <button
915
- type="button"
916
- class="sv-grid-chip-remove"
917
- aria-label="Remove {String(v)}"
918
- onmousedown={(event) => event.preventDefault()}
919
- onclick={() => {
920
- const next = selectedArr.filter((_, i) => i !== idx);
921
- ctrl.editingCell = ctrl.editingCell
922
- ? {
923
- ...ctrl.editingCell,
924
- value: multi ? next : (next[0] ?? null),
925
- }
926
- : ctrl.editingCell;
927
- }}>×</button
928
- >
929
- </span>
930
- {/each}
931
- <input
932
- use:focusOnMount
933
- class="sv-grid-chip-input"
934
- type="text"
935
- placeholder={multi ? "Type, Enter to add" : "Type a value"}
936
- onkeydown={(event) => {
937
- if (event.key === "Enter" || (multi && event.key === ",")) {
938
- event.preventDefault();
939
- event.stopPropagation();
940
- const input = event.currentTarget as HTMLInputElement;
941
- const raw = input.value.trim();
942
- if (raw) {
943
- const next = multi ? [...selectedArr, raw] : [raw];
944
- ctrl.editingCell = ctrl.editingCell
945
- ? { ...ctrl.editingCell, value: multi ? next : raw }
946
- : ctrl.editingCell;
947
- input.value = "";
948
- if (!multi) saveEditingCell();
949
- }
950
- } else if (event.key === "Escape") {
951
- onEditorKeyDown(event);
952
- } else if (event.key === "Backspace") {
953
- const input = event.currentTarget as HTMLInputElement;
954
- if (input.value === "" && selectedArr.length > 0) {
955
- event.preventDefault();
956
- const next = selectedArr.slice(0, -1);
957
- ctrl.editingCell = ctrl.editingCell
958
- ? {
959
- ...ctrl.editingCell,
960
- value: multi ? next : (next[0] ?? null),
961
- }
962
- : ctrl.editingCell;
963
- }
964
- }
965
- }}
966
- onblur={() => saveEditingCell()}
967
- />
968
- {#if multi}
969
- <button
970
- type="button"
971
- class="sv-grid-chip-commit"
972
- onmousedown={(event) => event.preventDefault()}
973
- onclick={() => saveEditingCell()}
974
- aria-label="Commit chip selection">Done</button
975
- >
976
- {/if}
977
- </div>
978
- </div>
979
- {/if}
980
- {/snippet}
981
-
982
- {#snippet groupRowContent(row: Row<TData>)}
983
- {@const groupingColumnId = groupingColumns[row.depth] ?? ""}
984
- {@const groupingColumn = allColumns.find((c) => c.id === groupingColumnId)}
985
- {@const headerLabel =
986
- typeof groupingColumn?.columnDef.header === "string"
987
- ? groupingColumn.columnDef.header
988
- : groupingColumnId}
989
- {@const groupValueRaw = row.getCellValueByColumnId(groupingColumnId)}
990
- {@const groupValue = groupingColumn
991
- ? formatCellValue(groupingColumn, groupValueRaw, row)
992
- : String(groupValueRaw ?? "")}
993
- {@const count = row.leafCount ?? row.subRows?.length ?? 0}
994
- <div
995
- class="sv-grid-group-content"
996
- style={`padding-left: ${row.depth * 20}px;`}
997
- >
998
- <button
999
- type="button"
1000
- class="sv-grid-group-toggle"
1001
- aria-expanded={row.getIsExpanded?.() ? "true" : "false"}
1002
- aria-label={row.getIsExpanded?.() ? "Collapse group" : "Expand group"}
1003
- onclick={(event) => {
1004
- event.stopPropagation();
1005
- row.toggleExpanded?.();
1006
- }}>{row.getIsExpanded?.() ? "▾" : "▸"}</button
1007
- >
1008
- <span class="sv-grid-group-label">{headerLabel}: {groupValue}</span>
1009
- <span class="sv-grid-group-count"
1010
- >{count} {count === 1 ? "row" : "rows"}</span
1011
- >
1012
- {#each allColumns as col (col.id)}
1013
- {#if col.columnDef.aggregate && col.id !== groupingColumnId}
1014
- {@const aggVal = row.getCellValueByColumnId(col.id)}
1015
- {#if aggVal != null && aggVal !== ""}
1016
- <span class="sv-grid-group-agg">
1017
- <span class="sv-grid-group-agg-label"
1018
- >{typeof col.columnDef.header === "string"
1019
- ? col.columnDef.header
1020
- : col.id}</span
1021
- >
1022
- {formatCellValue(col, aggVal, row)}
1023
- </span>
1024
- {/if}
1025
- {/if}
1026
- {/each}
1027
- </div>
1028
- {/snippet}
1029
-
1030
- <!-- A full-width detail row: one colspan cell spanning every column,
1031
- hosting the consumer's `renderDetailRow` snippet. Auto height (no
1032
- fixed row height) so the panel grows to fit its content. -->
1033
- {#snippet detailRowMarkup(detailRow: Row<TData>, detailRowIndex: number)}
1034
- <tr
1035
- class="sv-grid-row sv-grid-detail-row"
1036
- {...getGridRowA11yProps(detailRowIndex + 1)}
1037
- >
1038
- <td
1039
- class="sv-grid-cell sv-grid-detail-cell"
1040
- colspan={allColumns.length +
1041
- (showRowNumbersEffective ? 1 : 0) +
1042
- (showRowSelectionEffective ? 1 : 0)}
1043
- >
1044
- {#if props.renderDetailRow}
1045
- {@render props.renderDetailRow({
1046
- row: detailRow.original as TData,
1047
- rowIndex: detailRowIndex,
1048
- })}
1049
- {/if}
1050
- </td>
1051
- </tr>
1052
- {/snippet}
1053
-
1054
- <!-- A single pinned row (top or bottom). Read-only by design: no
1055
- inline editing, no row-selection checkbox, no fill handle.
1056
- Position-sticky CSS keeps it anchored to the top of the body or
1057
- the bottom of the viewport while the rest scrolls. -->
1058
- {#snippet pinnedRowBody(rowData: TData, where: "top" | "bottom", index: number)}
1059
- <tr
1060
- class={`sv-grid-row sv-grid-pinned-row sv-grid-pinned-row-${where}`}
1061
- data-pinned-row={where}
1062
- data-pinned-index={index}
1063
- >
1064
- {#if showRowNumbersEffective}
1065
- <td
1066
- class="sv-grid-cell sv-grid-row-number-cell"
1067
- style={`width: ${rowNumberColumnWidth}px; min-width: ${rowNumberColumnWidth}px; max-width: ${rowNumberColumnWidth}px; left: 0;`}
1068
- >{where === "top" ? "↑" : "↓"}</td>
1069
- {/if}
1070
- {#if showRowSelectionEffective}
1071
- <td
1072
- class="sv-grid-cell sv-grid-selection-cell"
1073
- style={`width: ${selectionColumnWidth}px; min-width: ${selectionColumnWidth}px; max-width: ${selectionColumnWidth}px; left: ${showRowNumbersEffective ? rowNumberColumnWidth : 0}px;`}
1074
- ></td>
1075
- {/if}
1076
- {#if columnVirtualizationEnabled && columnWindowStart > 0}
1077
- <td
1078
- class="sv-grid-cell sv-grid-cell-spacer"
1079
- aria-hidden="true"
1080
- style={`width: ${columnWindowStart}px; min-width: ${columnWindowStart}px; max-width: ${columnWindowStart}px;`}
1081
- ></td>
1082
- {/if}
1083
- {#each renderedColumns as rendered (rendered.column.id)}
1084
- {@const value = getPinnedCellValue(rowData, rendered.column)}
1085
- {@const userCellClass = computePinnedCellClass(rowData, rendered.column)}
1086
- <td
1087
- class={`sv-grid-cell ${userCellClass}`}
1088
- data-col-id={rendered.column.id}
1089
- data-pinned={isColumnPinned(rendered.column.id) ?? undefined}
1090
- style={`width: ${rendered.item.size}px; min-width: ${rendered.item.size}px; max-width: ${rendered.item.size}px; ${cellPinStyle(rendered.column.id)}`}
1091
- >{formatPinnedValue(rendered.column, value)}</td>
1092
- {/each}
1093
- {#if columnVirtualizationEnabled && columnWindowRightSpacer > 0}
1094
- <td
1095
- class="sv-grid-cell sv-grid-cell-spacer"
1096
- aria-hidden="true"
1097
- style={`width: ${columnWindowRightSpacer}px; min-width: ${columnWindowRightSpacer}px; max-width: ${columnWindowRightSpacer}px;`}
1098
- ></td>
1099
- {/if}
1100
- </tr>
1101
- {/snippet}
1102
-
1103
- <div
1104
- class="sv-grid-root"
1105
- class:sv-grid-root-fill={props.containerHeight === "100%"}
1106
- >
1107
- {#if showGlobalFilterEffective}
1108
- <label class="sv-grid-global-filter">
1109
- Filter all rows
1110
- <input bind:value={ctrl.globalFilter} placeholder="Type to filter..." />
1111
- </label>
1112
- {/if}
1113
-
1114
- {#if toolPanelEnabled}
1115
- <div class="sv-grid-toolbar">
1116
- <button
1117
- type="button"
1118
- class="sv-grid-toolbar-btn"
1119
- class:is-active={ctrl.toolPanelOpen}
1120
- aria-label={ctrl.toolPanelOpen ? "Close tool panel" : "Open tool panel (columns & filters)"}
1121
- aria-expanded={ctrl.toolPanelOpen}
1122
- onclick={() => (ctrl.toolPanelOpen = !ctrl.toolPanelOpen)}
1123
- >
1124
- <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1125
- <rect x="3" y="4" width="6" height="16" rx="1" />
1126
- <rect x="11" y="4" width="4" height="16" rx="1" />
1127
- <rect x="17" y="4" width="4" height="16" rx="1" />
1128
- </svg>
1129
- Columns &amp; Filters
1130
- </button>
1131
- </div>
1132
- {/if}
1133
-
1134
- <div
1135
- class="sv-grid-shell"
1136
- style={`height: ${
1137
- typeof props.containerHeight === "string"
1138
- ? props.containerHeight
1139
- : `${props.containerHeight ?? 520}px`
1140
- }; --sg-thead-h: ${headerHeight}px; --sg-pinned-row-h: ${(typeof props.rowHeight === 'number' ? props.rowHeight : 30)}px;`}
1141
- >
1142
- <div
1143
- class="sv-grid-container sv-grid-container-custom-scrollbars"
1144
- bind:this={ctrl.scrollContainer}
1145
- onscroll={onBodyScroll}
1146
- style={`overflow: auto; position: relative; height: calc(100% - ${hasMeasured && hasHorizontalOverflow ? 16 : 0}px);`}
1147
- >
1148
- <table
1149
- bind:this={ctrl.gridRootEl}
1150
- class="sv-grid-table"
1151
- {...getGridRootA11yProps({
1152
- activeDescendantId,
1153
- rowCount: allRows.length,
1154
- colCount: allColumns.length,
1155
- })}
1156
- onkeydown={onGridKeyDown}
1157
- onpaste={onGridPaste}
1158
- style={`min-width: ${totalColumnWidth}px;`}
1159
- >
1160
- <!-- svelte-ignore a11y_no_redundant_roles -->
1161
- <thead class="sv-grid-head" bind:this={ctrl.theadEl} role="rowgroup">
1162
- <!-- Multi-level group header rows. Only present when the
1163
- consumer's column tree has `columns: [...]` nesting.
1164
- Each TH spans the leaf widths underneath via the
1165
- precomputed `widthPx` + `colSpan` from groupHeaderRows. -->
1166
- {#each groupHeaderRows as row (row.id)}
1167
- <tr
1168
- class="sv-grid-row sv-grid-header-row sv-grid-group-header-row"
1169
- {...getGridRowA11yProps()}
1170
- style={props.headerHeight
1171
- ? `height: ${props.headerHeight}px;`
1172
- : undefined}
1173
- >
1174
- {#if showRowNumbersEffective}
1175
- <th
1176
- class="sv-grid-column sv-grid-row-number-column"
1177
- style={`width: ${rowNumberColumnWidth}px; min-width: ${rowNumberColumnWidth}px; max-width: ${rowNumberColumnWidth}px; left: 0;`}
1178
- aria-hidden="true"
1179
- ></th>
1180
- {/if}
1181
- {#if showRowSelectionEffective}
1182
- <th
1183
- class="sv-grid-column sv-grid-selection-column"
1184
- style={`width: ${selectionColumnWidth}px; min-width: ${selectionColumnWidth}px; max-width: ${selectionColumnWidth}px; left: ${showRowNumbersEffective ? rowNumberColumnWidth : 0}px;`}
1185
- aria-hidden="true"
1186
- ></th>
1187
- {/if}
1188
- {#each row.cells as cell (cell.key)}
1189
- <th
1190
- class="sv-grid-column sv-grid-group-header-cell"
1191
- class:sv-grid-group-header-placeholder={cell.isPlaceholder}
1192
- colspan={cell.colSpan}
1193
- style={`width: ${cell.widthPx}px; min-width: ${cell.widthPx}px; max-width: ${cell.widthPx}px;`}
1194
- >
1195
- {#if !cell.isPlaceholder}
1196
- {#if cell.collapsible}
1197
- <button
1198
- type="button"
1199
- class="sv-grid-group-toggle"
1200
- class:is-collapsed={cell.collapsed}
1201
- aria-expanded={!cell.collapsed}
1202
- aria-label={cell.collapsed ? `Expand ${cell.label}` : `Collapse ${cell.label}`}
1203
- title={cell.collapsed ? "Expand group" : "Collapse group"}
1204
- onclick={() => ctrl.toggleColumnGroup(cell.groupId!)}
1205
- >
1206
- <span class="sv-grid-group-header-label">{cell.label}</span>
1207
- <svg class="sv-grid-group-caret" viewBox="0 0 16 16" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1208
- <polyline points="4 6 8 10 12 6"></polyline>
1209
- </svg>
1210
- </button>
1211
- {:else}
1212
- <span class="sv-grid-group-header-label">{cell.label}</span>
1213
- {/if}
1214
- {/if}
1215
- </th>
1216
- {/each}
1217
- </tr>
1218
- {/each}
1219
- {#each headerGroups as headerGroup (headerGroup.id)}
1220
- <tr
1221
- class="sv-grid-row sv-grid-header-row"
1222
- {...getGridRowA11yProps()}
1223
- style={props.headerHeight
1224
- ? `height: ${props.headerHeight}px;`
1225
- : undefined}
1226
- >
1227
- {#if showRowNumbersEffective}
1228
- <th
1229
- class="sv-grid-column sv-grid-row-number-column"
1230
- style={`width: ${rowNumberColumnWidth}px; min-width: ${rowNumberColumnWidth}px; max-width: ${rowNumberColumnWidth}px; left: 0;`}
1231
- aria-label="Row number"
1232
- >
1233
- <span class="sv-grid-row-number-head">#</span>
1234
- </th>
1235
- {/if}
1236
- {#if showRowSelectionEffective}
1237
- <th
1238
- class="sv-grid-column sv-grid-selection-column"
1239
- style={`width: ${selectionColumnWidth}px; min-width: ${selectionColumnWidth}px; max-width: ${selectionColumnWidth}px; left: ${showRowNumbersEffective ? rowNumberColumnWidth : 0}px;`}
1240
- >
1241
- <button
1242
- type="button"
1243
- class="sv-grid-checkbox"
1244
- role="checkbox"
1245
- aria-checked={headerSelectionState === "all"
1246
- ? "true"
1247
- : headerSelectionState === "some"
1248
- ? "mixed"
1249
- : "false"}
1250
- aria-label="Select all rows"
1251
- onclick={toggleSelectAllRows}
1252
- onkeydown={(event) =>
1253
- toggleCheckboxWithKeyboard(event, toggleSelectAllRows)}
1254
- ></button>
1255
- </th>
1256
- {/if}
1257
- {#if columnVirtualizationEnabled && columnWindowStart > 0}
1258
- <th
1259
- class="sv-grid-column sv-grid-column-spacer"
1260
- aria-hidden="true"
1261
- style={`width: ${columnWindowStart}px; min-width: ${columnWindowStart}px; max-width: ${columnWindowStart}px;`}
1262
- ></th>
1263
- {/if}
1264
- {#each renderedColumns as rendered (rendered.column.id)}
1265
- {@const header = headerGroup.headers[rendered.item.index]}
1266
- {#if header}
1267
- {@const sortDirection =
1268
- sortDirectionByColumn[header.column.id]}
1269
- {@const isGrouped = groupingColumns.includes(
1270
- header.column.id,
1271
- )}
1272
- <th
1273
- class="sv-grid-column"
1274
- class:is-drag-target-before={colDropOnId === header.column.id && colDropSide === "before"}
1275
- class:is-drag-target-after={colDropOnId === header.column.id && colDropSide === "after"}
1276
- class:is-dragging={colDragId === header.column.id}
1277
- data-svgrid-header-col={header.column.id}
1278
- data-align={getColumnAlign(rendered.column)}
1279
- data-pinned={isColumnPinned(rendered.column.id) ??
1280
- undefined}
1281
- draggable={(props.enableColumnReorder ?? false) ? true : undefined}
1282
- ondragstart={(e) =>
1283
- (props.enableColumnReorder ?? false) &&
1284
- onColumnHeaderDragStart(e, header.column.id)}
1285
- ondragover={(e) =>
1286
- (props.enableColumnReorder ?? false) &&
1287
- onColumnHeaderDragOver(e, header.column.id)}
1288
- ondragleave={() =>
1289
- (props.enableColumnReorder ?? false) &&
1290
- onColumnHeaderDragLeave(header.column.id)}
1291
- ondrop={(e) =>
1292
- (props.enableColumnReorder ?? false) &&
1293
- onColumnHeaderDrop(e, header.column.id)}
1294
- ondragend={() =>
1295
- (props.enableColumnReorder ?? false) &&
1296
- onColumnHeaderDragEnd()}
1297
- style={`width: ${rendered.item.size}px; min-width: ${rendered.item.size}px; max-width: ${rendered.item.size}px; ${cellPinStyle(rendered.column.id)}`}
1298
- {...getGridHeaderA11yProps({
1299
- sortable: header.column.getCanSort(),
1300
- sortDirection:
1301
- sortDirection === "asc"
1302
- ? "ascending"
1303
- : sortDirection === "desc"
1304
- ? "descending"
1305
- : "none",
1306
- })}
1307
- >
1308
- {#if !header.isPlaceholder}
1309
- <div class="sv-grid-header-cell">
1310
- {#if typeof header.column.columnDef.header === "function"}
1311
- {@const rendered = header.column.columnDef.header(header.getContext())}
1312
- <!-- Custom header (snippet/component): rendered
1313
- OUTSIDE the sort button so the consumer's
1314
- own interactive elements (menu buttons,
1315
- dropdowns, etc) are valid DOM and receive
1316
- their own clicks. Clicking blank header
1317
- background still triggers sort via the
1318
- wrapper's role=button + click handler. -->
1319
- <div
1320
- class="sv-grid-header-custom"
1321
- role="button"
1322
- tabindex="-1"
1323
- onclick={(event) => {
1324
- if (!header.column.getCanSort()) return
1325
- // If the click landed on an interactive
1326
- // element inside the custom header, let
1327
- // that element handle it.
1328
- const t = event.target as HTMLElement | null
1329
- if (t && t.closest('button, a, input, select, textarea, [role="button"], [role="menuitem"]') &&
1330
- !(t.classList.contains('sv-grid-header-custom'))) return
1331
- onHeaderSortClick(event, header.column.id)
1332
- }}
1333
- onkeydown={(event) => {
1334
- if (event.key !== 'Enter' && event.key !== ' ') return
1335
- if (!header.column.getCanSort()) return
1336
- event.preventDefault()
1337
- onHeaderSortClick(event as unknown as MouseEvent, header.column.id)
1338
- }}
1339
- >
1340
- {#if rendered instanceof RenderSnippetConfig}
1341
- {@render rendered.snippet(rendered.params)}
1342
- {:else if rendered instanceof RenderComponentConfig}
1343
- <rendered.component {...rendered.props ?? {}} />
1344
- {:else if typeof rendered === "string" || typeof rendered === "number"}
1345
- {rendered}
1346
- {:else}
1347
- {header.id}
1348
- {/if}
1349
- {#if header.column.getCanSort()}
1350
- {#if sortDirection === "asc"}
1351
- <span class="sv-grid-header-icon"
1352
- >{@render icon("sort-asc")}</span>
1353
- {:else if sortDirection === "desc"}
1354
- <span class="sv-grid-header-icon"
1355
- >{@render icon("sort-desc")}</span>
1356
- {/if}
1357
- {/if}
1358
- </div>
1359
- {:else}
1360
- <button
1361
- type="button"
1362
- class="sv-grid-header-sort"
1363
- onclick={(event) =>
1364
- onHeaderSortClick(event, header.column.id)}
1365
- >
1366
- <span class="sv-grid-header-label">
1367
- {typeof header.column.columnDef.header === "string"
1368
- ? header.column.columnDef.header
1369
- : header.id}
1370
- </span>
1371
- {#if header.column.getCanSort()}
1372
- {#if sortDirection === "asc"}
1373
- <span class="sv-grid-header-icon"
1374
- >{@render icon("sort-asc")}</span
1375
- >
1376
- {:else if sortDirection === "desc"}
1377
- <span class="sv-grid-header-icon"
1378
- >{@render icon("sort-desc")}</span
1379
- >
1380
- {:else}
1381
- <span
1382
- class="sv-grid-header-icon sv-grid-header-icon-hint"
1383
- >{@render icon("sort")}</span
1384
- >
1385
- {/if}
1386
- {/if}
1387
- </button>
1388
- {/if}
1389
- {#if isGrouped}
1390
- <span
1391
- class="sv-grid-header-icon sv-grid-header-icon-flag"
1392
- title="Grouped">{@render icon("group")}</span
1393
- >
1394
- {/if}
1395
- {#if header.column.getCanFilter()}
1396
- <button
1397
- type="button"
1398
- class="sv-grid-col-menu-btn sv-grid-col-filter-btn"
1399
- class:is-open={filterMenuFor === header.column.id}
1400
- class:is-active={isColumnFiltered(
1401
- header.column.id,
1402
- )}
1403
- aria-label="Filter"
1404
- aria-haspopup="menu"
1405
- onclick={(event) =>
1406
- openFilterMenu(event, header.column.id)}
1407
- >
1408
- {@render icon("filter")}
1409
- </button>
1410
- {/if}
1411
- <button
1412
- type="button"
1413
- class="sv-grid-col-menu-btn"
1414
- class:is-open={columnMenuFor === header.column.id}
1415
- aria-label="Column menu"
1416
- aria-haspopup="menu"
1417
- onclick={(event) =>
1418
- openColumnMenu(event, header.column.id)}
1419
- >
1420
- {@render icon("menu")}
1421
- </button>
1422
- </div>
1423
- {#if showInlineColumnFilterEffective && header.column.getCanFilter()}
1424
- <input
1425
- class="sv-grid-column-filter"
1426
- placeholder="Filter"
1427
- oninput={(event) => {
1428
- const value = (
1429
- event.currentTarget as HTMLInputElement
1430
- ).value;
1431
- grid.setColumnFilters((prev) => [
1432
- ...prev.filter(
1433
- (entry) => entry.id !== header.column.id,
1434
- ),
1435
- ...(value
1436
- ? [
1437
- {
1438
- id: header.column.id,
1439
- value,
1440
- fn: "includesString" as const,
1441
- },
1442
- ]
1443
- : []),
1444
- ]);
1445
- }}
1446
- />
1447
- {/if}
1448
- <div
1449
- class="sv-grid-resize-handle"
1450
- class:is-resizing={resizingColumnId ===
1451
- header.column.id}
1452
- role="separator"
1453
- aria-orientation="vertical"
1454
- aria-label="Resize column"
1455
- onpointerdown={(event) =>
1456
- startColumnResize(event, header.column.id)}
1457
- ondblclick={(event) => event.stopPropagation()}
1458
- ></div>
1459
- {/if}
1460
- </th>
1461
- {/if}
1462
- {/each}
1463
- {#if columnVirtualizationEnabled && columnWindowRightSpacer > 0}
1464
- <th
1465
- class="sv-grid-column sv-grid-column-spacer"
1466
- aria-hidden="true"
1467
- style={`width: ${columnWindowRightSpacer}px; min-width: ${columnWindowRightSpacer}px; max-width: ${columnWindowRightSpacer}px;`}
1468
- ></th>
1469
- {/if}
1470
- </tr>
1471
- {#if showFilterRowEffective}
1472
- <tr {...getGridRowA11yProps()}>
1473
- {#if showRowNumbersEffective}
1474
- <th
1475
- class="sv-grid-column sv-grid-row-number-column"
1476
- style={`width: ${rowNumberColumnWidth}px; min-width: ${rowNumberColumnWidth}px; max-width: ${rowNumberColumnWidth}px; left: 0;`}
1477
- ></th>
1478
- {/if}
1479
- {#if showRowSelectionEffective}
1480
- <th
1481
- class="sv-grid-column sv-grid-selection-column"
1482
- style={`width: ${selectionColumnWidth}px; min-width: ${selectionColumnWidth}px; max-width: ${selectionColumnWidth}px; left: ${showRowNumbersEffective ? rowNumberColumnWidth : 0}px;`}
1483
- ></th>
1484
- {/if}
1485
- {#if columnVirtualizationEnabled && columnWindowStart > 0}
1486
- <th
1487
- class="sv-grid-column sv-grid-column-spacer"
1488
- aria-hidden="true"
1489
- style={`width: ${columnWindowStart}px; min-width: ${columnWindowStart}px; max-width: ${columnWindowStart}px;`}
1490
- ></th>
1491
- {/if}
1492
- {#each renderedColumns as rendered (rendered.column.id)}
1493
- {@const activeOperator =
1494
- filterMenuValues[rendered.column.id]?.operator ??
1495
- defaultOperatorFor(rendered.column)}
1496
- <th
1497
- class="sv-grid-column"
1498
- data-pinned={isColumnPinned(rendered.column.id) ??
1499
- undefined}
1500
- style={`width: ${rendered.item.size}px; min-width: ${rendered.item.size}px; max-width: ${rendered.item.size}px; ${cellPinStyle(rendered.column.id)}`}
1501
- >
1502
- <div class="sv-grid-filter-row-control">
1503
- <button
1504
- type="button"
1505
- class="sv-grid-filter-operator-btn"
1506
- class:is-open={operatorMenuFor === rendered.column.id}
1507
- title={`Condition: ${operatorOption(activeOperator).label}`}
1508
- aria-label={`Filter condition: ${operatorOption(activeOperator).label}`}
1509
- onclick={(event) =>
1510
- openOperatorMenu(event, rendered.column.id)}
1511
- >
1512
- <span class="sv-grid-header-icon"
1513
- >{@render icon(
1514
- operatorOption(activeOperator).iconName,
1515
- )}</span
1516
- >
1517
- <span class="sv-grid-caret"
1518
- >{@render icon("chevron-down")}</span
1519
- >
1520
- </button>
1521
- {#if activeOperator !== "isBlank"}
1522
- {@const frType = getEditorInputType(
1523
- rendered.column.columnDef.editorType ?? "text",
1524
- )}
1525
- <input
1526
- class="sv-grid-filter-value"
1527
- type={frType}
1528
- placeholder={activeOperator === "between" ? "From" : "Filter…"}
1529
- data-svgrid-filter-col={rendered.column.id}
1530
- value={filterRowValues[rendered.column.id] ?? ""}
1531
- oninput={(event) =>
1532
- updateFilterRow(
1533
- rendered.column.id,
1534
- (event.currentTarget as HTMLInputElement).value,
1535
- )}
1536
- />
1537
- {#if activeOperator === "between"}
1538
- <input
1539
- class="sv-grid-filter-value sv-grid-filter-value-to"
1540
- type={frType}
1541
- placeholder="To"
1542
- value={filterMenuValues[rendered.column.id]?.valueTo ?? ""}
1543
- oninput={(event) =>
1544
- updateFilterMenuValueTo(
1545
- rendered.column.id,
1546
- (event.currentTarget as HTMLInputElement).value,
1547
- )}
1548
- />
1549
- {/if}
1550
- {/if}
1551
- </div>
1552
- </th>
1553
- {/each}
1554
- {#if columnVirtualizationEnabled && columnWindowRightSpacer > 0}
1555
- <th
1556
- class="sv-grid-column sv-grid-column-spacer"
1557
- aria-hidden="true"
1558
- style={`width: ${columnWindowRightSpacer}px; min-width: ${columnWindowRightSpacer}px; max-width: ${columnWindowRightSpacer}px;`}
1559
- ></th>
1560
- {/if}
1561
- </tr>
1562
- {/if}
1563
- {/each}
1564
- </thead>
1565
- {#if props.pinnedTopRows && props.pinnedTopRows.length > 0}
1566
- <!-- svelte-ignore a11y_no_redundant_roles -->
1567
- <tbody class="sv-grid-pinned sv-grid-pinned-top-body" role="rowgroup">
1568
- {#each props.pinnedTopRows as r, i (i)}
1569
- {@render pinnedRowBody(r, "top", i)}
1570
- {/each}
1571
- </tbody>
1572
- {/if}
1573
- <!-- svelte-ignore a11y_no_redundant_roles -->
1574
- <tbody
1575
- class="sv-grid-body"
1576
- role="rowgroup"
1577
- ondragover={rowDragManagedEffective ? onRowsContainerDragOver : undefined}
1578
- ondrop={rowDragManagedEffective ? onRowsContainerDrop : undefined}
1579
- >
1580
- {#if !allRows.length && !(props.loading && props.loadingOverlay)}
1581
- <tr class="sv-grid-row sv-grid-empty-row">
1582
- <td
1583
- class="sv-grid-cell sv-grid-empty-cell"
1584
- colSpan={allColumns.length +
1585
- (showRowNumbersEffective ? 1 : 0) +
1586
- (showRowSelectionEffective ? 1 : 0)}
1587
- >
1588
- {props.emptyMessage ?? "No rows to display."}
1589
- </td>
1590
- </tr>
1591
- {:else if rowVirtualizationEnabled}
1592
- {#if rowTopSpacer > 0}
1593
- <tr class="sv-grid-row sv-grid-row-spacer" aria-hidden="true">
1594
- <td
1595
- class="sv-grid-cell sv-grid-cell-spacer"
1596
- style={`height: ${rowTopSpacer}px; padding: 0; border: 0;`}
1597
- colSpan={allColumns.length +
1598
- (showRowNumbersEffective ? 1 : 0) +
1599
- (showRowSelectionEffective ? 1 : 0)}
1600
- ></td>
1601
- </tr>
1602
- {/if}
1603
- {#each virtualRows as rowItem (rowItem.key)}
1604
- {@const rowIndex = rowItem.index}
1605
- {@const row = allRows[rowIndex]}
1606
- {#if row}
1607
- {#if props.isDetailRow?.(row.original as TData, rowIndex)}
1608
- {@render detailRowMarkup(row, rowIndex)}
1609
- {:else if isGroupRow(row)}
1610
- <tr
1611
- class="sv-grid-row sv-grid-group-row"
1612
- class:sv-grid-row-selected={isRowSelected(row.id)}
1613
- aria-level={row.depth + 1}
1614
- aria-expanded={row.getIsExpanded?.() ? "true" : "false"}
1615
- {...getGridRowA11yProps(rowIndex + 1)}
1616
- style={`height: ${rowItem.size}px;`}
1617
- >
1618
- <td
1619
- class="sv-grid-cell sv-grid-group-cell"
1620
- class:sv-grid-cell-active={activeCell.rowIndex ===
1621
- rowIndex}
1622
- colspan={allColumns.length +
1623
- (showRowNumbersEffective ? 1 : 0) +
1624
- (showRowSelectionEffective ? 1 : 0)}
1625
- onclick={() => row.toggleExpanded?.()}
1626
- >
1627
- {@render groupRowContent(row)}
1628
- </td>
1629
- </tr>
1630
- {:else}
1631
- {@const userRowClass = computeRowClass(row, rowIndex)}
1632
- <tr
1633
- class={`sv-grid-row ${userRowClass} ${rowDropClass(rowIndex)}`}
1634
- class:sv-grid-row-selected={isRowSelected(row.id)}
1635
- class:sv-grid-row-alt={props.zebraRows && rowIndex % 2 === 1}
1636
- class:sv-grid-row-draggable={rowDragManagedEffective}
1637
- {...getGridRowA11yProps(rowIndex + 1)}
1638
- {...rowDragAttrs(rowIndex)}
1639
- style={`height: ${rowItem.size}px;`}
1640
- >
1641
- {#if showRowNumbersEffective}
1642
- <td
1643
- class="sv-grid-cell sv-grid-row-number-cell"
1644
- style={`width: ${rowNumberColumnWidth}px; min-width: ${rowNumberColumnWidth}px; max-width: ${rowNumberColumnWidth}px; left: 0;`}
1645
- >{rowIndex + 1}</td
1646
- >
1647
- {/if}
1648
- {#if showRowSelectionEffective}
1649
- <td
1650
- class="sv-grid-cell sv-grid-selection-cell"
1651
- style={`width: ${selectionColumnWidth}px; min-width: ${selectionColumnWidth}px; max-width: ${selectionColumnWidth}px; left: ${showRowNumbersEffective ? rowNumberColumnWidth : 0}px;`}
1652
- onclick={() => toggleRowSelectionById(row.id)}
1653
- >
1654
- <button
1655
- type="button"
1656
- class="sv-grid-checkbox"
1657
- role="checkbox"
1658
- aria-checked={isRowSelected(row.id)}
1659
- aria-label="Select row"
1660
- onclick={(event) => {
1661
- event.stopPropagation();
1662
- toggleRowSelectionById(row.id);
1663
- }}
1664
- onkeydown={(event) =>
1665
- toggleCheckboxWithKeyboard(event, () => {
1666
- event.stopPropagation();
1667
- toggleRowSelectionById(row.id);
1668
- })}
1669
- ></button>
1670
- </td>
1671
- {/if}
1672
- {#if columnVirtualizationEnabled && columnWindowStart > 0}
1673
- <td
1674
- class="sv-grid-cell sv-grid-cell-spacer"
1675
- aria-hidden="true"
1676
- style={`width: ${columnWindowStart}px; min-width: ${columnWindowStart}px; max-width: ${columnWindowStart}px;`}
1677
- ></td>
1678
- {/if}
1679
- {#each renderedColumns as rendered (rendered.column.id)}
1680
- {@const colIndex = rendered.item.index}
1681
- {@const baseValue = getColumnBaseValue(
1682
- row,
1683
- rendered.column,
1684
- )}
1685
- {@const cellValue = getCellDisplayValue(
1686
- row.id,
1687
- rendered.column.id,
1688
- baseValue,
1689
- )}
1690
- {@const isEditing =
1691
- ctrl.editingCell?.rowId === row.id &&
1692
- ctrl.editingCell?.columnId === rendered.column.id}
1693
- {@const inRowEdit = !!fullRowEdit &&
1694
- fullRowEdit.rowId === row.id &&
1695
- rendered.column.id in fullRowEdit.draft}
1696
- {@const rangeEdges = getCellRangeEdges(
1697
- rowIndex,
1698
- colIndex,
1699
- )}
1700
- {@const hasFillHandle =
1701
- fillHandleCell &&
1702
- fillHandleCell.rowIndex === rowIndex &&
1703
- fillHandleCell.colIndex === colIndex}
1704
- {@const userCellClass = computeCellClass(row, rendered.column)}
1705
- {@const cellTooltip = computeCellTooltip(row, rendered.column)}
1706
- {@const cellNote = computeCellNote(row, rendered.column)}
1707
- <td
1708
- class={`sv-grid-cell ${userCellClass}`}
1709
- class:sv-grid-cell-editing={isEditing || inRowEdit}
1710
- class:sv-grid-cell-active={activeCell.rowIndex ===
1711
- rowIndex && activeCell.colIndex === colIndex}
1712
- class:sv-grid-cell-has-fill-handle={hasFillHandle}
1713
- class:sv-grid-cell-cf={hasConditionalFormats}
1714
- class:sv-grid-cell-has-note={cellNote != null}
1715
- data-svgrid-row={rowIndex}
1716
- data-svgrid-col={colIndex}
1717
- data-col-id={rendered.column.id}
1718
- data-align={getColumnAlign(rendered.column)}
1719
- data-pinned={isColumnPinned(rendered.column.id) ??
1720
- undefined}
1721
- data-selected-range={rangeEdges ? "true" : undefined}
1722
- data-range-top={rangeEdges?.top ? "true" : undefined}
1723
- data-range-bottom={rangeEdges?.bottom
1724
- ? "true"
1725
- : undefined}
1726
- data-range-left={rangeEdges?.left
1727
- ? "true"
1728
- : undefined}
1729
- data-range-right={rangeEdges?.right
1730
- ? "true"
1731
- : undefined}
1732
- data-fill-preview={isInFillPreview(rowIndex, colIndex)
1733
- ? "true"
1734
- : undefined}
1735
- style={`width: ${rendered.item.size}px; min-width: ${rendered.item.size}px; max-width: ${rendered.item.size}px; ${cellPinStyle(rendered.column.id)}`}
1736
- onpointerdown={(event) =>
1737
- onCellPointerDown(rowIndex, colIndex, event)}
1738
- onpointerenter={() =>
1739
- onCellPointerEnter(rowIndex, colIndex)}
1740
- ondblclick={() =>
1741
- emitCellDoubleClick(rowIndex, colIndex)}
1742
- onclick={() => onCellClick(rowIndex, colIndex)}
1743
- oncontextmenu={(event) =>
1744
- openContextMenu(event, rowIndex, colIndex, rendered.column.id)}
1745
- use:cellFlashAction={{
1746
- rowId: row.id,
1747
- value: cellValue,
1748
- active: !!rendered.column.columnDef.cellFlash,
1749
- className: flashClassFor(rendered.column.columnDef.cellFlash),
1750
- }}
1751
- {...getGridCellA11yProps({
1752
- id: getGridCellDomId("svgrid", rowIndex, colIndex),
1753
- rowIndex: rowIndex + 1,
1754
- colIndex: colIndex + 1,
1755
- selected: isRowSelected(row.id),
1756
- })}
1757
- >
1758
- {#if inRowEdit}
1759
- {@render fullRowEditor(rendered.column, row)}
1760
- {:else if isEditing}
1761
- {@render editorBody(rendered.column, row)}
1762
- {:else}
1763
- {@render cellBodyWithFormat(row, rendered.column, cellValue)}
1764
- {/if}
1765
- {#if !isEditing && fillHandleCell && fillHandleCell.rowIndex === rowIndex && fillHandleCell.colIndex === colIndex}
1766
- <!-- Excel-style fill handle: drag down/right to
1767
- extend the selection and pattern-fill the new
1768
- cells on release. Rendered inside the bottom-
1769
- right cell of the selection range (or active
1770
- cell if there's no range). -->
1771
- <div
1772
- class="sv-grid-fill-handle"
1773
- role="button"
1774
- aria-label="Fill handle"
1775
- onpointerdown={(event) =>
1776
- startFillDrag(event, rowIndex, colIndex)}
1777
- ></div>
1778
- {/if}
1779
- {#if cellNote != null && !isEditing}
1780
- <span
1781
- class="sv-grid-cell-note-corner"
1782
- aria-label="Note"
1783
- onpointerenter={(event) => {
1784
- event.stopPropagation()
1785
- showTooltipFor(event.currentTarget as HTMLElement, cellNote)
1786
- }}
1787
- onpointerleave={(event) => {
1788
- event.stopPropagation()
1789
- hideTooltip()
1790
- }}
1791
- ></span>
1792
- {/if}
1793
- </td>
1794
- {/each}
1795
- {#if columnVirtualizationEnabled && columnWindowRightSpacer > 0}
1796
- <td
1797
- class="sv-grid-cell sv-grid-cell-spacer"
1798
- aria-hidden="true"
1799
- style={`width: ${columnWindowRightSpacer}px; min-width: ${columnWindowRightSpacer}px; max-width: ${columnWindowRightSpacer}px;`}
1800
- ></td>
1801
- {/if}
1802
- </tr>
1803
- {/if}
1804
- {/if}
1805
- {/each}
1806
- {#if rowBottomSpacer > 0}
1807
- <tr class="sv-grid-row sv-grid-row-spacer" aria-hidden="true">
1808
- <td
1809
- class="sv-grid-cell sv-grid-cell-spacer"
1810
- style={`height: ${rowBottomSpacer}px; padding: 0; border: 0;`}
1811
- colSpan={allColumns.length +
1812
- (showRowNumbersEffective ? 1 : 0) +
1813
- (showRowSelectionEffective ? 1 : 0)}
1814
- ></td>
1815
- </tr>
1816
- {/if}
1817
- {:else}
1818
- {#each allRows as row, rowIndex (row.id)}
1819
- {#if props.isDetailRow?.(row.original as TData, rowIndex)}
1820
- {@render detailRowMarkup(row, rowIndex)}
1821
- {:else if isGroupRow(row)}
1822
- <tr
1823
- class="sv-grid-row sv-grid-group-row"
1824
- class:sv-grid-row-selected={isRowSelected(row.id)}
1825
- aria-level={row.depth + 1}
1826
- aria-expanded={row.getIsExpanded?.() ? "true" : "false"}
1827
- {...getGridRowA11yProps(rowIndex + 1)}
1828
- >
1829
- <td
1830
- class="sv-grid-cell sv-grid-group-cell"
1831
- class:sv-grid-cell-active={activeCell.rowIndex ===
1832
- rowIndex}
1833
- colspan={allColumns.length +
1834
- (showRowNumbersEffective ? 1 : 0) +
1835
- (showRowSelectionEffective ? 1 : 0)}
1836
- onclick={() => row.toggleExpanded?.()}
1837
- >
1838
- {@render groupRowContent(row)}
1839
- </td>
1840
- </tr>
1841
- {:else}
1842
- {@const userRowClass = computeRowClass(row, rowIndex)}
1843
- <tr
1844
- class={`sv-grid-row ${userRowClass} ${rowDropClass(rowIndex)}`}
1845
- class:sv-grid-row-selected={isRowSelected(row.id)}
1846
- class:sv-grid-row-alt={props.zebraRows && rowIndex % 2 === 1}
1847
- class:sv-grid-row-draggable={rowDragManagedEffective}
1848
- {...getGridRowA11yProps(rowIndex + 1)}
1849
- {...rowDragAttrs(rowIndex)}
1850
- >
1851
- {#if showRowNumbersEffective}
1852
- <td
1853
- class="sv-grid-cell sv-grid-row-number-cell"
1854
- style={`width: ${rowNumberColumnWidth}px; min-width: ${rowNumberColumnWidth}px; max-width: ${rowNumberColumnWidth}px; left: 0;`}
1855
- >{rowIndex + 1}</td
1856
- >
1857
- {/if}
1858
- {#if showRowSelectionEffective}
1859
- <td
1860
- class="sv-grid-cell sv-grid-selection-cell"
1861
- style={`width: ${selectionColumnWidth}px; min-width: ${selectionColumnWidth}px; max-width: ${selectionColumnWidth}px; left: ${showRowNumbersEffective ? rowNumberColumnWidth : 0}px;`}
1862
- onclick={() => toggleRowSelectionById(row.id)}
1863
- >
1864
- <button
1865
- type="button"
1866
- class="sv-grid-checkbox"
1867
- role="checkbox"
1868
- aria-checked={isRowSelected(row.id)}
1869
- aria-label="Select row"
1870
- onclick={(event) => {
1871
- event.stopPropagation();
1872
- toggleRowSelectionById(row.id);
1873
- }}
1874
- onkeydown={(event) =>
1875
- toggleCheckboxWithKeyboard(event, () => {
1876
- event.stopPropagation();
1877
- toggleRowSelectionById(row.id);
1878
- })}
1879
- ></button>
1880
- </td>
1881
- {/if}
1882
- {#if columnVirtualizationEnabled && columnWindowStart > 0}
1883
- <td
1884
- class="sv-grid-cell sv-grid-cell-spacer"
1885
- aria-hidden="true"
1886
- style={`width: ${columnWindowStart}px; min-width: ${columnWindowStart}px; max-width: ${columnWindowStart}px;`}
1887
- ></td>
1888
- {/if}
1889
- {#each renderedColumns as rendered (rendered.column.id)}
1890
- {@const colIndex = rendered.item.index}
1891
- {@const baseValue = getColumnBaseValue(
1892
- row,
1893
- rendered.column,
1894
- )}
1895
- {@const cellValue = getCellDisplayValue(
1896
- row.id,
1897
- rendered.column.id,
1898
- baseValue,
1899
- )}
1900
- {@const isEditing =
1901
- ctrl.editingCell?.rowId === row.id &&
1902
- ctrl.editingCell?.columnId === rendered.column.id}
1903
- {@const inRowEdit = !!fullRowEdit &&
1904
- fullRowEdit.rowId === row.id &&
1905
- rendered.column.id in fullRowEdit.draft}
1906
- {@const rangeEdges = getCellRangeEdges(
1907
- rowIndex,
1908
- colIndex,
1909
- )}
1910
- {@const userCellClass = computeCellClass(row, rendered.column)}
1911
- {@const cellTooltip = computeCellTooltip(row, rendered.column)}
1912
- {@const cellNote = computeCellNote(row, rendered.column)}
1913
- <td
1914
- class={`sv-grid-cell ${userCellClass}`}
1915
- class:sv-grid-cell-editing={isEditing || inRowEdit}
1916
- class:sv-grid-cell-active={activeCell.rowIndex ===
1917
- rowIndex && activeCell.colIndex === colIndex}
1918
- class:sv-grid-cell-cf={hasConditionalFormats}
1919
- class:sv-grid-cell-has-note={cellNote != null}
1920
- data-svgrid-row={rowIndex}
1921
- data-svgrid-col={colIndex}
1922
- data-col-id={rendered.column.id}
1923
- data-pinned={isColumnPinned(rendered.column.id) ??
1924
- undefined}
1925
- data-selected-range={rangeEdges ? "true" : undefined}
1926
- data-range-top={rangeEdges?.top ? "true" : undefined}
1927
- data-range-bottom={rangeEdges?.bottom
1928
- ? "true"
1929
- : undefined}
1930
- data-range-left={rangeEdges?.left ? "true" : undefined}
1931
- data-range-right={rangeEdges?.right
1932
- ? "true"
1933
- : undefined}
1934
- style={`width: ${rendered.item.size}px; min-width: ${rendered.item.size}px; max-width: ${rendered.item.size}px; ${cellPinStyle(rendered.column.id)}`}
1935
- onpointerdown={(event) =>
1936
- onCellPointerDown(rowIndex, colIndex, event)}
1937
- onpointerenter={(event) => {
1938
- onCellPointerEnter(rowIndex, colIndex)
1939
- // Column tooltip fires on whole-cell hover.
1940
- // Per-cell notes are gated on the corner hot-
1941
- // zone below (Excel-style: hover the small
1942
- // triangle to read the note).
1943
- if (cellTooltip) showTooltipFor(event.currentTarget as HTMLElement, cellTooltip)
1944
- }}
1945
- onpointerleave={hideTooltip}
1946
- ondblclick={() => emitCellDoubleClick(rowIndex, colIndex)}
1947
- onclick={() => onCellClick(rowIndex, colIndex)}
1948
- oncontextmenu={(event) =>
1949
- openContextMenu(event, rowIndex, colIndex, rendered.column.id)}
1950
- use:cellFlashAction={{
1951
- rowId: row.id,
1952
- value: cellValue,
1953
- active: !!rendered.column.columnDef.cellFlash,
1954
- className: flashClassFor(rendered.column.columnDef.cellFlash),
1955
- }}
1956
- {...getGridCellA11yProps({
1957
- id: getGridCellDomId("svgrid", rowIndex, colIndex),
1958
- rowIndex: rowIndex + 1,
1959
- colIndex: colIndex + 1,
1960
- selected: isRowSelected(row.id),
1961
- })}
1962
- >
1963
- {#if inRowEdit}
1964
- {@render fullRowEditor(rendered.column, row)}
1965
- {:else if isEditing}
1966
- {@render editorBody(rendered.column, row)}
1967
- {:else}
1968
- {@render cellBodyWithFormat(row, rendered.column, cellValue)}
1969
- {/if}
1970
- {#if cellNote != null && !isEditing}
1971
- <!-- Excel-style per-cell note indicator. The
1972
- triangle itself is the hot-zone; hover
1973
- just the corner to see the note (Excel
1974
- red-dot behaviour). The cell-level
1975
- tooltip handler only shows the column
1976
- tooltip, so the two surfaces stay
1977
- separate. -->
1978
- <span
1979
- class="sv-grid-cell-note-corner"
1980
- aria-label="Note"
1981
- onpointerenter={(event) => {
1982
- event.stopPropagation()
1983
- showTooltipFor(event.currentTarget as HTMLElement, cellNote)
1984
- }}
1985
- onpointerleave={(event) => {
1986
- event.stopPropagation()
1987
- hideTooltip()
1988
- }}
1989
- ></span>
1990
- {/if}
1991
- </td>
1992
- {/each}
1993
- {#if columnVirtualizationEnabled && columnWindowRightSpacer > 0}
1994
- <td
1995
- class="sv-grid-cell sv-grid-cell-spacer"
1996
- aria-hidden="true"
1997
- style={`width: ${columnWindowRightSpacer}px; min-width: ${columnWindowRightSpacer}px; max-width: ${columnWindowRightSpacer}px;`}
1998
- ></td>
1999
- {/if}
2000
- </tr>
2001
- {/if}
2002
- {/each}
2003
- {/if}
2004
- </tbody>
2005
- {#if props.pinnedBottomRows && props.pinnedBottomRows.length > 0}
2006
- <!-- svelte-ignore a11y_no_redundant_roles -->
2007
- <tbody class="sv-grid-pinned sv-grid-pinned-bottom-body" role="rowgroup">
2008
- {#each props.pinnedBottomRows as r, i (i)}
2009
- {@render pinnedRowBody(r, "bottom", i)}
2010
- {/each}
2011
- </tbody>
2012
- {/if}
2013
- {#if props.enableRowSummaries ?? true}
2014
- <!-- svelte-ignore a11y_no_redundant_roles -->
2015
- <tfoot class="sv-grid-foot" role="rowgroup">
2016
- <tr
2017
- class="sv-grid-row sv-grid-summary-row"
2018
- {...getGridRowA11yProps()}
2019
- >
2020
- {#if showRowNumbersEffective}
2021
- <!-- Row-number column has no aggregate; the digit it normally
2022
- shows is the row index, which doesn't make sense to sum. -->
2023
- <th
2024
- class="sv-grid-column sv-grid-summary-column sv-grid-row-number-column"
2025
- style={`width: ${rowNumberColumnWidth}px; min-width: ${rowNumberColumnWidth}px; max-width: ${rowNumberColumnWidth}px; left: 0;`}
2026
- ></th>
2027
- {/if}
2028
- {#if showRowSelectionEffective}
2029
- <!-- Selection column is checkbox-only; no aggregate. -->
2030
- <th
2031
- class="sv-grid-column sv-grid-summary-column sv-grid-selection-column"
2032
- style={`width: ${selectionColumnWidth}px; min-width: ${selectionColumnWidth}px; max-width: ${selectionColumnWidth}px; left: ${showRowNumbersEffective ? rowNumberColumnWidth : 0}px;`}
2033
- ></th>
2034
- {/if}
2035
- {#if columnVirtualizationEnabled && columnWindowStart > 0}
2036
- <th
2037
- class="sv-grid-column sv-grid-column-spacer"
2038
- aria-hidden="true"
2039
- style={`width: ${columnWindowStart}px; min-width: ${columnWindowStart}px; max-width: ${columnWindowStart}px;`}
2040
- ></th>
2041
- {/if}
2042
- {#each renderedColumns as rendered (rendered.column.id)}
2043
- <th
2044
- class="sv-grid-column sv-grid-summary-column"
2045
- data-pinned={isColumnPinned(rendered.column.id) ??
2046
- undefined}
2047
- style={`width: ${rendered.item.size}px; min-width: ${rendered.item.size}px; max-width: ${rendered.item.size}px; ${cellPinStyle(rendered.column.id)}`}
2048
- >
2049
- {summaryByColumn[rendered.column.id] ?? ""}
2050
- </th>
2051
- {/each}
2052
- {#if columnVirtualizationEnabled && columnWindowRightSpacer > 0}
2053
- <th
2054
- class="sv-grid-column sv-grid-column-spacer"
2055
- aria-hidden="true"
2056
- style={`width: ${columnWindowRightSpacer}px; min-width: ${columnWindowRightSpacer}px; max-width: ${columnWindowRightSpacer}px;`}
2057
- ></th>
2058
- {/if}
2059
- </tr>
2060
- </tfoot>
2061
- {/if}
2062
- </table>
2063
- </div>
2064
- <!-- 16-px placeholder above the vertical scrollbar that matches the
2065
- header height - fills the gap so the scrollbar starts exactly
2066
- under the header row. Only needed when the vertical scrollbar
2067
- is actually rendered; without this guard the block stays as a
2068
- visible colored rectangle in the top-right corner even on
2069
- demos with no vertical overflow. -->
2070
- {#if hasMeasured && hasVerticalOverflow}
2071
- <div
2072
- class="sv-grid-scrollbar-corner"
2073
- aria-hidden="true"
2074
- style={`height: ${headerHeight}px;`}
2075
- ></div>
2076
- {/if}
2077
- <!-- Scrollbar attributes come from AUTHORITATIVE sources, not the
2078
- DOM. `scrollMetrics.scrollHeight/scrollWidth` are read inside a
2079
- Svelte derived during render - the browser hasn't painted yet,
2080
- so those values lag one frame behind the real layout. The
2081
- scrollbar's own `hidden`-check then trips on stale `content-size
2082
- <= viewport-size`, disables itself, and the user can't drag it.
2083
- Using `virtualRowTotalSize` / `totalColumnWidth` (which come
2084
- straight from the virtualizers' authoritative state) avoids the
2085
- lag - the scrollbar always sees the same overflow numbers our
2086
- gating did. -->
2087
- {#if hasMeasured && hasVerticalOverflow}
2088
- <!-- content-size = the container's actual `scrollHeight`.
2089
- The scroll container's real `scrollHeight` includes
2090
- EVERYTHING in the scroll content: the sticky thead, the
2091
- tbody (top spacer + rendered rows + bottom spacer), and a
2092
- sticky tfoot if rendered. Computing it from
2093
- `virtualRowTotalSize + headerHeight` works in the common
2094
- case but undercounts when the footer is present or the
2095
- header is more than one row, leaving the last few rows
2096
- beyond the scrollbar's reach. We read it from the DOM
2097
- once `hasMeasured` is true (i.e. after the first
2098
- ResizeObserver tick), and fall back to the virtualizer
2099
- math if for some reason the DOM read returns 0 (early
2100
- render races). -->
2101
- <sv-grid-scrollbar
2102
- class="sv-grid-scrollbar sv-grid-scrollbar-vertical"
2103
- bind:this={ctrl.verticalScrollbarEl}
2104
- orientation="vertical"
2105
- viewport-size={viewportHeight}
2106
- content-size={scrollMetrics.scrollHeight ||
2107
- rowDomTotalSize + headerHeight}
2108
- value={scrollMetrics.scrollTop}
2109
- step={typeof props.rowHeight === 'number' ? props.rowHeight : 30}
2110
- style={`top: ${headerHeight}px; height: calc(100% - ${headerHeight + (hasHorizontalOverflow ? 16 : 0)}px);`}
2111
- ></sv-grid-scrollbar>
2112
- {/if}
2113
- {#if hasMeasured && hasHorizontalOverflow}
2114
- {@const horizontalContentSize =
2115
- totalColumnWidth +
2116
- (showRowNumbersEffective ? rowNumberColumnWidth : 0) +
2117
- (showRowSelectionEffective ? selectionColumnWidth : 0)}
2118
- <sv-grid-scrollbar
2119
- class="sv-grid-scrollbar sv-grid-scrollbar-horizontal"
2120
- bind:this={ctrl.horizontalScrollbarEl}
2121
- orientation="horizontal"
2122
- viewport-size={viewportWidth}
2123
- content-size={scrollMetrics.scrollWidth || horizontalContentSize}
2124
- value={scrollMetrics.scrollLeft}
2125
- step={props.columnWidth ?? 140}
2126
- style={`width: calc(100% - ${hasVerticalOverflow ? 16 : 0}px);`}
2127
- ></sv-grid-scrollbar>
2128
- {/if}
2129
- {#if hasMeasured && hasVerticalOverflow && hasHorizontalOverflow}
2130
- <div class="sv-grid-scrollbar-corner-br" aria-hidden="true"></div>
2131
- {/if}
2132
- </div>
2133
-
2134
- <GridFooter {ctrl} />
2135
-
2136
- {#if ctrl.findOpen}
2137
- <!-- Find-in-grid overlay. Anchored to the TOP of the grid root so
2138
- it tracks the grid even when the page scrolls. Ctrl+F opens;
2139
- Enter cycles to the next hit; Esc closes. -->
2140
- <div class="sv-grid-find" role="search" aria-label="Find in grid">
2141
- <svg class="sv-grid-find-icon" viewBox="0 0 16 16" aria-hidden="true">
2142
- <circle cx="7" cy="7" r="4.5" fill="none" stroke="currentColor" stroke-width="1.5"/>
2143
- <line x1="10.2" y1="10.2" x2="14" y2="14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
2144
- </svg>
2145
- <input
2146
- class="sv-grid-find-input"
2147
- type="search"
2148
- placeholder="Find in grid…"
2149
- autofocus
2150
- bind:value={ctrl.findQuery}
2151
- oninput={() => (ctrl.findHitIndex = 0)}
2152
- onkeydown={(event) => {
2153
- event.stopPropagation()
2154
- if (event.key === 'Enter') {
2155
- event.preventDefault()
2156
- if (findHits.length === 0) return
2157
- ctrl.findHitIndex = (ctrl.findHitIndex + (event.shiftKey ? -1 : 1) + findHits.length) % findHits.length
2158
- const hit = findHits[ctrl.findHitIndex]
2159
- if (hit) { setActiveCell(hit.rowIndex, hit.colIndex); scrollActiveCellIntoView(hit.rowIndex, hit.colIndex) }
2160
- }
2161
- if (event.key === 'Escape') { event.preventDefault(); ctrl.findOpen = false; ctrl.findQuery = '' }
2162
- }}
2163
- />
2164
- <span class="sv-grid-find-count">
2165
- {findHits.length === 0 && ctrl.findQuery.trim()
2166
- ? 'No matches'
2167
- : findHits.length === 0
2168
- ? ''
2169
- : `${ctrl.findHitIndex + 1} of ${findHits.length}`}
2170
- </span>
2171
- <button type="button" class="sv-grid-find-step" aria-label="Previous match"
2172
- disabled={findHits.length === 0}
2173
- onclick={() => {
2174
- ctrl.findHitIndex = (ctrl.findHitIndex - 1 + findHits.length) % findHits.length
2175
- const hit = findHits[ctrl.findHitIndex]
2176
- if (hit) { setActiveCell(hit.rowIndex, hit.colIndex); scrollActiveCellIntoView(hit.rowIndex, hit.colIndex) }
2177
- }}>↑</button>
2178
- <button type="button" class="sv-grid-find-step" aria-label="Next match"
2179
- disabled={findHits.length === 0}
2180
- onclick={() => {
2181
- ctrl.findHitIndex = (ctrl.findHitIndex + 1) % findHits.length
2182
- const hit = findHits[ctrl.findHitIndex]
2183
- if (hit) { setActiveCell(hit.rowIndex, hit.colIndex); scrollActiveCellIntoView(hit.rowIndex, hit.colIndex) }
2184
- }}>↓</button>
2185
- <button type="button" class="sv-grid-find-close" aria-label="Close find"
2186
- onclick={() => { ctrl.findOpen = false; ctrl.findQuery = '' }}>✕</button>
2187
- </div>
2188
- {/if}
2189
-
2190
- {#if props.loading && props.loadingOverlay}
2191
- <div class="sv-grid-loading-overlay" role="status" aria-live="polite">
2192
- <div class="sv-grid-loading-bar"></div>
2193
- {#if allRows.length === 0}
2194
- <div class="sv-grid-skeleton" aria-hidden="true">
2195
- {#each Array(props.loadingSkeletonRows ?? 8) as _, r (r)}
2196
- <div class="sv-grid-skeleton-row">
2197
- {#each allColumns as col (col.id)}
2198
- <div
2199
- class="sv-grid-skeleton-cell"
2200
- style={`width:${getColumnWidth(col.id)}px`}
2201
- >
2202
- <span class="sv-grid-skeleton-bar"></span>
2203
- </div>
2204
- {/each}
2205
- </div>
2206
- {/each}
2207
- </div>
2208
- {/if}
2209
- <span class="sv-grid-sr-only">Loading…</span>
2210
- </div>
2211
- {/if}
2212
-
2213
- {#if toolPanelEnabled}
2214
- {#if ctrl.toolPanelOpen}
2215
- {@const panelTab = ctrl.toolPanelTab}
2216
- <aside class="sv-grid-tool-panel" aria-label="Tool panel">
2217
- <div class="sv-grid-tool-panel-head">
2218
- <span>{panelTab === "filters" ? "Filters" : "Columns"}</span>
2219
- <button
2220
- type="button"
2221
- class="sv-grid-tool-panel-close"
2222
- aria-label="Close"
2223
- onclick={() => (ctrl.toolPanelOpen = false)}>✕</button
2224
- >
2225
- </div>
2226
- <div class="sv-grid-tool-panel-tabs" role="tablist">
2227
- <button
2228
- type="button"
2229
- class="sv-grid-tool-panel-tab"
2230
- class:is-active={panelTab === "columns"}
2231
- role="tab"
2232
- aria-selected={panelTab === "columns"}
2233
- onclick={() => (ctrl.toolPanelTab = "columns")}>Columns</button
2234
- >
2235
- <button
2236
- type="button"
2237
- class="sv-grid-tool-panel-tab"
2238
- class:is-active={panelTab === "filters"}
2239
- role="tab"
2240
- aria-selected={panelTab === "filters"}
2241
- onclick={() => (ctrl.toolPanelTab = "filters")}>Filters</button
2242
- >
2243
- </div>
2244
- {#if panelTab === "columns"}
2245
- <ul class="sv-grid-tool-panel-list">
2246
- {#each toolPanelColumns as column, i (column.id)}
2247
- {@const visible = !ctrl.hiddenColumns[column.id]}
2248
- {@const grouped = groupingColumns.includes(column.id)}
2249
- <li class="sv-grid-tool-panel-item">
2250
- <label class="sv-grid-tool-panel-vis">
2251
- <input
2252
- type="checkbox"
2253
- checked={visible}
2254
- onchange={() => toggleColumnVisibleInPanel(column.id)}
2255
- />
2256
- <span class="sv-grid-tool-panel-name">{toolPanelHeaderLabel(column)}</span>
2257
- </label>
2258
- <span class="sv-grid-tool-panel-actions">
2259
- <button
2260
- type="button"
2261
- class="sv-grid-tool-panel-btn"
2262
- class:is-active={grouped}
2263
- aria-label={grouped ? "Ungroup" : "Group by"}
2264
- title={grouped ? "Ungroup" : "Group by this column"}
2265
- onclick={() => toggleGroupInPanel(column.id)}>⊞</button
2266
- >
2267
- <button
2268
- type="button"
2269
- class="sv-grid-tool-panel-btn"
2270
- aria-label="Move up"
2271
- disabled={i === 0}
2272
- onclick={() => moveColumnInPanel(column.id, -1)}>↑</button
2273
- >
2274
- <button
2275
- type="button"
2276
- class="sv-grid-tool-panel-btn"
2277
- aria-label="Move down"
2278
- disabled={i === toolPanelColumns.length - 1}
2279
- onclick={() => moveColumnInPanel(column.id, 1)}>↓</button
2280
- >
2281
- </span>
2282
- </li>
2283
- {/each}
2284
- </ul>
2285
- {:else}
2286
- <!-- Filters tab: one filter control per filterable column, sharing
2287
- the same filterMenuValues state as the column menu / filter row. -->
2288
- <div class="sv-grid-tool-panel-filters">
2289
- {#each toolPanelColumns as column (column.id)}
2290
- {#if column.columnDef.field && column.columnDef.filterable !== false}
2291
- {@const active = filterMenuValues[column.id]?.operator ?? defaultOperatorFor(column)}
2292
- {@const fType = getEditorInputType(column.columnDef.editorType ?? "text")}
2293
- <div class="sv-grid-tp-filter" class:is-filtered={isColumnFiltered(column.id)}>
2294
- <div class="sv-grid-tp-filter-head">
2295
- <span class="sv-grid-tp-filter-name">{toolPanelHeaderLabel(column)}</span>
2296
- {#if isColumnFiltered(column.id)}
2297
- <button
2298
- type="button"
2299
- class="sv-grid-tp-filter-clear"
2300
- aria-label={`Clear ${toolPanelHeaderLabel(column)} filter`}
2301
- title="Clear filter"
2302
- onclick={() => clearColumnFilter(column.id)}>✕</button
2303
- >
2304
- {/if}
2305
- </div>
2306
- <select
2307
- class="sv-grid-tp-filter-op"
2308
- aria-label={`${toolPanelHeaderLabel(column)} filter condition`}
2309
- value={active}
2310
- onchange={(e) => updateFilterOperator(column.id, e.currentTarget.value as FilterOperator)}
2311
- >
2312
- {#each operatorsForColumn(column) as option (option.value)}
2313
- <option value={option.value}>{operatorOption(option.value).label}</option>
2314
- {/each}
2315
- </select>
2316
- {#if active !== "isBlank"}
2317
- <input
2318
- class="sv-grid-tp-filter-input"
2319
- type={fType}
2320
- placeholder={active === "between" ? "From" : "Filter…"}
2321
- value={filterMenuValues[column.id]?.value ?? ""}
2322
- oninput={(e) => updateFilterMenuValue(column.id, e.currentTarget.value)}
2323
- />
2324
- {#if active === "between"}
2325
- <input
2326
- class="sv-grid-tp-filter-input"
2327
- type={fType}
2328
- placeholder="To"
2329
- value={filterMenuValues[column.id]?.valueTo ?? ""}
2330
- oninput={(e) => updateFilterMenuValueTo(column.id, e.currentTarget.value)}
2331
- />
2332
- {/if}
2333
- {/if}
2334
- </div>
2335
- {/if}
2336
- {/each}
2337
- </div>
2338
- {/if}
2339
- </aside>
2340
- {/if}
2341
- {/if}
2342
- </div>
2343
- <!-- /.sv-grid-root -->
2344
-
2345
- <GridMenus {ctrl} {icon} />
2346
- {/if}
1
+ <script
2
+ lang="ts"
3
+ generics="TFeatures extends TableFeatures = TableFeatures, TData extends RowData = RowData"
4
+ >
5
+ import {
6
+ getGridCellA11yProps,
7
+ getGridCellDomId,
8
+ getGridHeaderA11yProps,
9
+ getGridRootA11yProps,
10
+ getGridRowA11yProps,
11
+ type EditorContext,
12
+ type CellEditorOption,
13
+ type Column,
14
+ type Row,
15
+ type RowData,
16
+ type TableFeatures,
17
+ } from "./index";
18
+ import "./sv-grid-scrollbar";
19
+ import "./SvGrid.css";
20
+ import type { Snippet } from "svelte";
21
+ import {
22
+ RenderSnippetConfig,
23
+ RenderComponentConfig,
24
+ } from "./render-component";
25
+ import { buildSparkline, toSparklineValues } from "./sparkline";
26
+ import SvGridDropdown from "./SvGridDropdown.svelte";
27
+ import type {
28
+ Props,
29
+ SelectionPoint,
30
+ SelectionRange,
31
+ CellEditState,
32
+ FilterOperator,
33
+ FilterOption,
34
+ MenuPosition,
35
+ } from "./SvGrid.types";
36
+ import {
37
+ cfTextStyle,
38
+ fmtStat,
39
+ getEditableInputValue,
40
+ getEditorInputType,
41
+ toValueArray,
42
+ getOptionLabel,
43
+ getOptionColor,
44
+ colorfulChipStyle,
45
+ getEditorClass,
46
+ } from "./SvGrid.helpers";
47
+ import { createSvGridController } from "./SvGrid.controller.svelte";
48
+ import GridMenus from "./GridMenus.svelte";
49
+ import GridFooter from "./GridFooter.svelte";
50
+ let props: Props<TFeatures, TData> = $props();
51
+ const ctrl = createSvGridController(props);
52
+
53
+ // ---- View facade: re-bind the controller's reactive members as locals so the
54
+ // markup stays identical. Assignable state + bindings + DOM refs use c.* directly.
55
+ const paginationEnabled = $derived(ctrl.paginationEnabled);
56
+ const filterRowValues = $derived(ctrl.filterRowValues);
57
+ const filterMenuValues = $derived(ctrl.filterMenuValues);
58
+ const tooltip = $derived(ctrl.tooltip);
59
+ const showTooltipFor = $derived(ctrl.showTooltipFor);
60
+ const hideTooltip = $derived(ctrl.hideTooltip);
61
+ const findHits = $derived(ctrl.findHits);
62
+ const headerHeight = $derived(ctrl.headerHeight);
63
+ const resizingColumnId = $derived(ctrl.resizingColumnId);
64
+ const selectionColumnWidth = $derived(ctrl.selectionColumnWidth);
65
+ const rowNumberColumnWidth = $derived(ctrl.rowNumberColumnWidth);
66
+ const showRowNumbersEffective = $derived(ctrl.showRowNumbersEffective);
67
+ const columnMenuFor = $derived(ctrl.columnMenuFor);
68
+ const filterMenuFor = $derived(ctrl.filterMenuFor);
69
+ const operatorMenuFor = $derived(ctrl.operatorMenuFor);
70
+ const viewportWidth = $derived(ctrl.viewportWidth);
71
+ const viewportHeight = $derived(ctrl.viewportHeight);
72
+ const scrollMetrics = $derived(ctrl.scrollMetrics);
73
+ const hasVerticalOverflow = $derived(ctrl.hasVerticalOverflow);
74
+ const showGlobalFilterEffective = $derived(ctrl.showGlobalFilterEffective);
75
+ const showFilterRowEffective = $derived(ctrl.showFilterRowEffective);
76
+ const showInlineColumnFilterEffective = $derived(
77
+ ctrl.showInlineColumnFilterEffective,
78
+ );
79
+ const showRowSelectionEffective = $derived(ctrl.showRowSelectionEffective);
80
+ const grid = $derived(ctrl.grid);
81
+ const allColumns = $derived(ctrl.allColumns);
82
+ const headerGroups = $derived(ctrl.headerGroups);
83
+ const groupHeaderRows = $derived(ctrl.groupHeaderRows);
84
+ const cellPinStyle = $derived(ctrl.cellPinStyle);
85
+ const isColumnPinned = $derived(ctrl.isColumnPinned);
86
+ const colDragId = $derived(ctrl.colDragId);
87
+ const colDropOnId = $derived(ctrl.colDropOnId);
88
+ const colDropSide = $derived(ctrl.colDropSide);
89
+ const onColumnHeaderDragStart = $derived(ctrl.onColumnHeaderDragStart);
90
+ const onColumnHeaderDragOver = $derived(ctrl.onColumnHeaderDragOver);
91
+ const onColumnHeaderDragLeave = $derived(ctrl.onColumnHeaderDragLeave);
92
+ const onColumnHeaderDrop = $derived(ctrl.onColumnHeaderDrop);
93
+ const onColumnHeaderDragEnd = $derived(ctrl.onColumnHeaderDragEnd);
94
+ // Managed row dragging
95
+ const rowDragManagedEffective = $derived(props.rowDragManaged === true);
96
+ const rowDropIndex = $derived(ctrl.rowDropIndex);
97
+ const rowDropSide = $derived(ctrl.rowDropSide);
98
+ const onRowDragStart = $derived(ctrl.onRowDragStart);
99
+ const onRowDragOver = $derived(ctrl.onRowDragOver);
100
+ const onRowDragLeave = $derived(ctrl.onRowDragLeave);
101
+ const onRowDrop = $derived(ctrl.onRowDrop);
102
+ const onRowsContainerDragOver = $derived(ctrl.onRowsContainerDragOver);
103
+ const onRowsContainerDrop = $derived(ctrl.onRowsContainerDrop);
104
+ const onRowDragEndHandler = $derived(ctrl.onRowDragEnd);
105
+ // Drag attributes spread onto each body <tr>; empty object when disabled so
106
+ // rows stay non-draggable and no handlers fire.
107
+ function rowDragAttrs(rowIndex: number) {
108
+ if (!rowDragManagedEffective) return {};
109
+ return {
110
+ draggable: true,
111
+ ondragstart: (e: DragEvent) => onRowDragStart(e, rowIndex),
112
+ ondragover: (e: DragEvent) => onRowDragOver(e, rowIndex),
113
+ ondragleave: () => onRowDragLeave(rowIndex),
114
+ ondrop: (e: DragEvent) => onRowDrop(e, rowIndex),
115
+ ondragend: () => onRowDragEndHandler(),
116
+ };
117
+ }
118
+ function rowDropClass(rowIndex: number) {
119
+ if (!rowDragManagedEffective || rowDropIndex !== rowIndex) return "";
120
+ return rowDropSide === "after"
121
+ ? "sv-grid-row-drop-after"
122
+ : "sv-grid-row-drop-before";
123
+ }
124
+ const getColumnBaseValue = $derived(ctrl.getColumnBaseValue);
125
+ const hasConditionalFormats = $derived(ctrl.hasConditionalFormats);
126
+ const cellConditionalFormat = $derived(ctrl.cellConditionalFormat);
127
+ const isGroupRow = $derived(ctrl.isGroupRow);
128
+ const sortDirectionByColumn = $derived(ctrl.sortDirectionByColumn);
129
+ const groupingColumns = $derived(ctrl.groupingColumns);
130
+ const paginationState = $derived(ctrl.paginationState);
131
+ const allRowsBeforePagination = $derived(ctrl.allRowsBeforePagination);
132
+ const allRows = $derived(ctrl.allRows);
133
+ const statusBarEnabled = $derived(ctrl.statusBarEnabled);
134
+ const statusBarAggregates = $derived(ctrl.statusBarAggregates);
135
+ const statusBarStats = $derived(ctrl.statusBarStats);
136
+ const toolPanelEnabled = $derived(ctrl.toolPanelEnabled);
137
+ const toolPanelColumns = $derived(ctrl.toolPanelColumns);
138
+ const toolPanelHeaderLabel = $derived(ctrl.toolPanelHeaderLabel);
139
+ const toggleColumnVisibleInPanel = $derived(ctrl.toggleColumnVisibleInPanel);
140
+ const moveColumnInPanel = $derived(ctrl.moveColumnInPanel);
141
+ const toggleGroupInPanel = $derived(ctrl.toggleGroupInPanel);
142
+ const virtualizer = $derived(ctrl.virtualizer);
143
+ const rowVirtualizationEnabled = $derived(ctrl.rowVirtualizationEnabled);
144
+ const columnVirtualizationEnabled = $derived(
145
+ ctrl.columnVirtualizationEnabled,
146
+ );
147
+ const virtualRows = $derived(ctrl.virtualRows);
148
+ // DOM-space spacer heights + capped total: identical to the logical
149
+ // virtualizer values for normal grids, scaled down past
150
+ // MAX_DOM_SCROLL_HEIGHT so huge grids stay scrollable to the last row on
151
+ // mobile (where the max element height is lowest).
152
+ const rowTopSpacer = $derived(ctrl.rowTopSpacer);
153
+ const rowBottomSpacer = $derived(ctrl.rowBottomSpacer);
154
+ const rowDomTotalSize = $derived(ctrl.rowDomTotalSize);
155
+ const renderedColumns = $derived(ctrl.renderedColumns);
156
+ const totalColumnWidth = $derived(ctrl.totalColumnWidth);
157
+
158
+ // ---- Built-in cell flash (ColumnDef `cellFlash`) ----------------------
159
+ // Flashes a cell when its value changes (edits, streaming feeds, server
160
+ // pushes). Keyed by rowId so virtualization recycling a <td> into a new row
161
+ // on scroll does NOT trigger a spurious flash - only a same-row value change
162
+ // does. Inert (active:false) for columns without `cellFlash`.
163
+ function cellFlashAction(
164
+ node: HTMLElement,
165
+ params: {
166
+ rowId: string;
167
+ value: unknown;
168
+ active: boolean;
169
+ className: string;
170
+ },
171
+ ) {
172
+ let prevRow = params.rowId;
173
+ let prev = params.value;
174
+ return {
175
+ update(next: {
176
+ rowId: string;
177
+ value: unknown;
178
+ active: boolean;
179
+ className: string;
180
+ }) {
181
+ if (next.rowId !== prevRow) {
182
+ // A different row scrolled into this recycled slot - reset, no flash.
183
+ prevRow = next.rowId;
184
+ prev = next.value;
185
+ return;
186
+ }
187
+ if (next.active && next.className && !Object.is(next.value, prev)) {
188
+ node.classList.remove(next.className);
189
+ void node.offsetWidth; // reflow so the animation restarts
190
+ node.classList.add(next.className);
191
+ }
192
+ prev = next.value;
193
+ },
194
+ };
195
+ }
196
+ function flashClassFor(
197
+ cfg: boolean | { className?: string } | undefined,
198
+ ): string {
199
+ console.log("flash cell");
200
+ if (cfg && typeof cfg === "object" && cfg.className) return cfg.className;
201
+ return "sv-grid-cell-flash";
202
+ }
203
+
204
+ // ---- Full-row editing -------------------------------------------------
205
+ const fullRowEdit = $derived(ctrl.fullRowEdit);
206
+ // Commit the whole row when the user clicks away from its editors (Excel /
207
+ // AG-Grid feel). Clicking within any full-row editor keeps editing.
208
+ $effect(() => {
209
+ if (!fullRowEdit) return;
210
+ const onDown = (e: PointerEvent) => {
211
+ const t = e.target as HTMLElement | null;
212
+ if (t && t.closest(".sv-grid-fr-editor")) return;
213
+ ctrl.commitFullRowEdit();
214
+ };
215
+ document.addEventListener("pointerdown", onDown, true);
216
+ return () => document.removeEventListener("pointerdown", onDown, true);
217
+ });
218
+ function fullRowKeydown(e: KeyboardEvent) {
219
+ if (e.key === "Enter") {
220
+ e.preventDefault();
221
+ ctrl.commitFullRowEdit();
222
+ ctrl.gridRootEl?.focus({ preventScroll: true });
223
+ } else if (e.key === "Escape") {
224
+ e.preventDefault();
225
+ ctrl.cancelFullRowEdit();
226
+ ctrl.gridRootEl?.focus({ preventScroll: true });
227
+ }
228
+ }
229
+ function optionValueOf(o: unknown): string {
230
+ return typeof o === "object" && o !== null && "value" in o
231
+ ? String((o as { value: unknown }).value)
232
+ : String(o);
233
+ }
234
+ function optionLabelOf(o: unknown): string {
235
+ return typeof o === "object" && o !== null && "label" in o
236
+ ? String((o as { label: unknown }).label)
237
+ : String(o);
238
+ }
239
+ const hasHorizontalOverflow = $derived(ctrl.hasHorizontalOverflow);
240
+ const columnWindowStart = $derived(ctrl.columnWindowStart);
241
+ const columnWindowRightSpacer = $derived(ctrl.columnWindowRightSpacer);
242
+ const activeCell = $derived(ctrl.activeCell);
243
+ const activeDescendantId = $derived(ctrl.activeDescendantId);
244
+ const summaryByColumn = $derived(ctrl.summaryByColumn);
245
+ const hasMeasured = $derived(ctrl.hasMeasured);
246
+ const onBodyScroll = $derived(ctrl.onBodyScroll);
247
+ const computeRowClass = $derived(ctrl.computeRowClass);
248
+ const computeCellClass = $derived(ctrl.computeCellClass);
249
+ const computeCellTooltip = $derived(ctrl.computeCellTooltip);
250
+ const computeCellNote = $derived(ctrl.computeCellNote);
251
+ const getCellDisplayValue = $derived(ctrl.getCellDisplayValue);
252
+ const getColumnAlign = $derived(ctrl.getColumnAlign);
253
+ const getColumnEditorOptions = $derived(ctrl.getColumnEditorOptions);
254
+ const formatListCellValue = $derived(ctrl.formatListCellValue);
255
+ const formatCellValue = $derived(ctrl.formatCellValue);
256
+ const getPinnedCellValue = $derived(ctrl.getPinnedCellValue);
257
+ const formatPinnedValue = $derived(ctrl.formatPinnedValue);
258
+ const computePinnedCellClass = $derived(ctrl.computePinnedCellClass);
259
+ const isRowSelected = $derived(ctrl.isRowSelected);
260
+ const toggleRowSelectionById = $derived(ctrl.toggleRowSelectionById);
261
+ const headerSelectionState = $derived(ctrl.headerSelectionState);
262
+ const toggleSelectAllRows = $derived(ctrl.toggleSelectAllRows);
263
+ const setActiveCell = $derived(ctrl.setActiveCell);
264
+ const scrollActiveCellIntoView = $derived(ctrl.scrollActiveCellIntoView);
265
+ const getColumnWidth = $derived(ctrl.getColumnWidth);
266
+ const startColumnResize = $derived(ctrl.startColumnResize);
267
+ const getCellRangeEdges = $derived(ctrl.getCellRangeEdges);
268
+ const fillHandleCell = $derived(ctrl.fillHandleCell);
269
+ const isInFillPreview = $derived(ctrl.isInFillPreview);
270
+ const startFillDrag = $derived(ctrl.startFillDrag);
271
+ const onCellPointerDown = $derived(ctrl.onCellPointerDown);
272
+ const onCellPointerEnter = $derived(ctrl.onCellPointerEnter);
273
+ const endDragSelection = $derived(ctrl.endDragSelection);
274
+ const onWindowPointerMove = $derived(ctrl.onWindowPointerMove);
275
+ const onCellClick = $derived(ctrl.onCellClick);
276
+ const emitCellDoubleClick = $derived(ctrl.emitCellDoubleClick);
277
+ const openContextMenu = $derived(ctrl.openContextMenu);
278
+ const saveEditingCell = $derived(ctrl.saveEditingCell);
279
+ const updateEditingCellValue = $derived(ctrl.updateEditingCellValue);
280
+ const onEditorKeyDown = $derived(ctrl.onEditorKeyDown);
281
+ const focusOnMount = $derived(ctrl.focusOnMount);
282
+ const onHeaderSortClick = $derived(ctrl.onHeaderSortClick);
283
+ const onGridKeyDown = $derived(ctrl.onGridKeyDown);
284
+ const onGridPaste = $derived(ctrl.onGridPaste);
285
+ const changePage = $derived(ctrl.changePage);
286
+ const goToPage = $derived(ctrl.goToPage);
287
+ const setPageSize = $derived(ctrl.setPageSize);
288
+ const updateFilterRow = $derived(ctrl.updateFilterRow);
289
+ const updateFilterMenuValue = $derived(ctrl.updateFilterMenuValue);
290
+ const updateFilterMenuValueTo = $derived(ctrl.updateFilterMenuValueTo);
291
+ const updateFilterOperator = $derived(ctrl.updateFilterOperator);
292
+ const operatorsForColumn = $derived(ctrl.operatorsForColumn);
293
+ const clearColumnFilter = $derived(ctrl.clearColumnFilter);
294
+ const toggleCheckboxWithKeyboard = $derived(ctrl.toggleCheckboxWithKeyboard);
295
+ const operatorOption = $derived(ctrl.operatorOption);
296
+ const defaultOperatorFor = $derived(ctrl.defaultOperatorFor);
297
+ const isColumnFiltered = $derived(ctrl.isColumnFiltered);
298
+ const openColumnMenu = $derived(ctrl.openColumnMenu);
299
+ const openFilterMenu = $derived(ctrl.openFilterMenu);
300
+ const openOperatorMenu = $derived(ctrl.openOperatorMenu);
301
+ const onWindowKeydown = $derived(ctrl.onWindowKeydown);
302
+ </script>
303
+
304
+ <svelte:window
305
+ onkeydown={onWindowKeydown}
306
+ onpointerup={endDragSelection}
307
+ onpointermove={onWindowPointerMove}
308
+ />
309
+
310
+ {#if props.loading && !props.loadingOverlay}
311
+ <div class="sv-grid-state sv-grid-state-loading" role="status">
312
+ Loading grid data...
313
+ </div>
314
+ {:else if props.error}
315
+ <div class="sv-grid-state sv-grid-state-error" role="alert">
316
+ {props.error}
317
+ </div>
318
+ {:else}
319
+ {#snippet icon(name: string)}
320
+ <svg
321
+ class="sv-grid-icon"
322
+ viewBox="0 0 24 24"
323
+ fill="none"
324
+ stroke="currentColor"
325
+ stroke-width="2.2"
326
+ stroke-linecap="round"
327
+ stroke-linejoin="round"
328
+ aria-hidden="true"
329
+ >
330
+ {#if name === "sort"}
331
+ <path d="M8 10l4-4 4 4" />
332
+ <path d="M8 14l4 4 4-4" />
333
+ {:else if name === "sort-asc"}
334
+ <path d="M6 14l6-6 6 6" />
335
+ {:else if name === "sort-desc"}
336
+ <path d="M6 10l6 6 6-6" />
337
+ {:else if name === "filter"}
338
+ <path d="M3 5h18l-7 8v6l-4 2v-8z" />
339
+ {:else if name === "menu"}
340
+ <path d="M4 7h16" />
341
+ <path d="M4 12h16" />
342
+ <path d="M4 17h16" />
343
+ {:else if name === "group"}
344
+ <path d="M12 3l8 4.5-8 4.5-8-4.5z" />
345
+ <path d="M4 12l8 4.5 8-4.5" />
346
+ <path d="M4 16.5l8 4.5 8-4.5" />
347
+ {:else if name === "x"}
348
+ <path d="M18 6L6 18" />
349
+ <path d="M6 6l12 12" />
350
+ {:else if name === "chevron-down"}
351
+ <path d="M6 9l6 6 6-6" />
352
+ {:else if name === "op-contains"}
353
+ <circle cx="11" cy="11" r="6" />
354
+ <path d="M20 20l-4.5-4.5" />
355
+ {:else if name === "op-equals"}
356
+ <path d="M5 9.5h14" />
357
+ <path d="M5 14.5h14" />
358
+ {:else if name === "op-startsWith"}
359
+ <path d="M5 5v14" />
360
+ <path d="M9 9h10" />
361
+ <path d="M9 15h7" />
362
+ {:else if name === "op-greaterThan"}
363
+ <path d="M8 5l9 7-9 7" />
364
+ {:else if name === "op-lessThan"}
365
+ <path d="M16 5l-9 7 9 7" />
366
+ {:else if name === "op-isBlank"}
367
+ <circle cx="12" cy="12" r="8" />
368
+ <path d="M6.5 6.5l11 11" />
369
+ {:else if name === "autosize"}
370
+ <path d="M3 12h18" />
371
+ <path d="M3 12l4-4" />
372
+ <path d="M3 12l4 4" />
373
+ <path d="M21 12l-4-4" />
374
+ <path d="M21 12l-4 4" />
375
+ {:else if name === "columns"}
376
+ <rect x="3" y="4" width="5" height="16" rx="1" />
377
+ <rect x="10" y="4" width="5" height="16" rx="1" />
378
+ <rect x="17" y="4" width="4" height="16" rx="1" />
379
+ {:else if name === "reset"}
380
+ <path d="M3 4v6h6" />
381
+ <path d="M3.5 10A9 9 0 1 0 6 5.3" />
382
+ {/if}
383
+ </svg>
384
+ {/snippet}
385
+
386
+ {#snippet cellBody(
387
+ row: Row<TData>,
388
+ column: Column<TData>,
389
+ cellValue: unknown,
390
+ )}
391
+ {#if column.columnDef.editorType === "checkbox" || typeof cellValue === "boolean"}
392
+ <div
393
+ class="sv-grid-checkbox sv-grid-checkbox-readonly"
394
+ role="checkbox"
395
+ aria-checked={Boolean(cellValue)}
396
+ aria-readonly="true"
397
+ aria-label={toolPanelHeaderLabel(column)}
398
+ ></div>
399
+ {:else if (column.columnDef.editorType === "list" || column.columnDef.editorType === "chips") && column.columnDef.cell == null}
400
+ {@const arr = Array.isArray(cellValue)
401
+ ? cellValue
402
+ : cellValue == null || cellValue === ""
403
+ ? []
404
+ : [cellValue]}
405
+ {@const opts = getColumnEditorOptions(column, row)}
406
+ {@const isChipsType = column.columnDef.editorType === "chips"}
407
+ {@const anyColored = arr.some((v) => getOptionColor(opts, v))}
408
+ {#if arr.length > 0 && (isChipsType || anyColored)}
409
+ <!-- Render as chips when:
410
+ - the column is `chips` (always pill-style), OR
411
+ - the column is `list` and at least one selected option has
412
+ a `color` (so single-list cells like Priority "high" get
413
+ a pill too, not just plain text). -->
414
+ <div class="sv-grid-chips-display">
415
+ {#each arr as v (String(v))}
416
+ <span
417
+ class="sv-grid-chip"
418
+ style={colorfulChipStyle(getOptionColor(opts, v))}
419
+ >
420
+ {getOptionLabel(opts, v)}
421
+ </span>
422
+ {/each}
423
+ </div>
424
+ {:else}
425
+ {formatListCellValue(column, cellValue, row)}
426
+ {/if}
427
+ {:else if column.columnDef.sparkline && column.columnDef.cell == null}
428
+ {@const geo = buildSparkline(
429
+ toSparklineValues(cellValue),
430
+ column.columnDef.sparkline,
431
+ )}
432
+ {#if geo}
433
+ {@const vals = toSparklineValues(cellValue)}
434
+ <svg
435
+ class="sv-grid-sparkline"
436
+ width={geo.width}
437
+ height={geo.height}
438
+ viewBox={`0 0 ${geo.width} ${geo.height}`}
439
+ preserveAspectRatio="none"
440
+ role="img"
441
+ aria-label={`Sparkline, ${vals.length} points, last ${vals[vals.length - 1]}`}
442
+ >
443
+ {#if geo.areaPath}
444
+ <path
445
+ d={geo.areaPath}
446
+ fill={geo.color}
447
+ fill-opacity="0.18"
448
+ stroke="none"
449
+ />
450
+ {/if}
451
+ {#if geo.linePath}
452
+ <path
453
+ d={geo.linePath}
454
+ fill="none"
455
+ stroke={geo.color}
456
+ stroke-width={geo.lineWidth}
457
+ stroke-linejoin="round"
458
+ stroke-linecap="round"
459
+ />
460
+ {/if}
461
+ {#each geo.bars as bar, i (i)}
462
+ <rect
463
+ x={bar.x}
464
+ y={bar.y}
465
+ width={bar.w}
466
+ height={bar.h}
467
+ rx="0.5"
468
+ fill={bar.negative ? geo.negativeColor : geo.color}
469
+ />
470
+ {/each}
471
+ {#if geo.lastPoint}
472
+ <circle
473
+ cx={geo.lastPoint.x}
474
+ cy={geo.lastPoint.y}
475
+ r={geo.lineWidth + 0.5}
476
+ fill={geo.color}
477
+ />
478
+ {/if}
479
+ </svg>
480
+ {/if}
481
+ {:else}
482
+ {#if row.depth > 0 && column.id === allColumns[0]?.id}
483
+ <span
484
+ class="sv-grid-group-child-indent"
485
+ style={`width: ${row.depth * 20}px;`}
486
+ aria-hidden="true"
487
+ ></span>
488
+ {/if}
489
+ {@const cellTemplate = column.columnDef.cell}
490
+ {#if typeof cellTemplate === "function"}
491
+ {@const rendered = cellTemplate({
492
+ cell: {
493
+ id: `${row.id}_${column.id}`,
494
+ row,
495
+ column,
496
+ getValue: () => cellValue,
497
+ getContext: () => ({}) as any,
498
+ },
499
+ row,
500
+ column,
501
+ table: grid,
502
+ getValue: () => cellValue,
503
+ })}
504
+ {#if rendered instanceof RenderSnippetConfig}
505
+ {@render rendered.snippet(rendered.params)}
506
+ {:else if rendered instanceof RenderComponentConfig}
507
+ <rendered.component {...rendered.props ?? {}} />
508
+ {:else if typeof rendered === "string" || typeof rendered === "number"}
509
+ {rendered}
510
+ {:else}
511
+ {formatCellValue(column, cellValue, row)}
512
+ {/if}
513
+ {:else if typeof cellTemplate === "string"}
514
+ {cellTemplate}
515
+ {:else}
516
+ {formatCellValue(column, cellValue, row)}
517
+ {/if}
518
+ {/if}
519
+ {/snippet}
520
+
521
+ <!-- Cell content wrapped with conditional-formatting overlays. The color-
522
+ scale fill and data bar are absolutely-positioned layers BEHIND the
523
+ text (the app stylesheet forces `.sv-grid-cell` background with
524
+ !important, so an overlay is the only way to tint reliably). Text
525
+ color / weight and the icon-set glyph ride on the content wrapper.
526
+ Falls straight through to `cellBody` when no format applies, so the
527
+ common (unformatted) path pays nothing. -->
528
+ {#snippet cellBodyWithFormat(
529
+ row: Row<TData>,
530
+ column: Column<TData>,
531
+ cellValue: unknown,
532
+ )}
533
+ {@const cf = cellConditionalFormat(row, column, cellValue)}
534
+ {#if cf && (cf.background || cf.dataBar || cf.icon || cf.color || cf.fontWeight != null)}
535
+ {#if cf.background}
536
+ <div class="sv-grid-cf-bg" style={`background:${cf.background}`}></div>
537
+ {/if}
538
+ {#if cf.dataBar}
539
+ <div
540
+ class="sv-grid-cf-bar"
541
+ style={`width:${cf.dataBar.percent}%;background:${cf.dataBar.color}`}
542
+ ></div>
543
+ {/if}
544
+ <span class="sv-grid-cf-content" style={cfTextStyle(cf)}>
545
+ {#if cf.icon}<span class="sv-grid-cf-icon">{cf.icon}</span>{/if}
546
+ {#if !cf.iconOnly}{@render cellBody(row, column, cellValue)}{/if}
547
+ </span>
548
+ {:else}
549
+ {@render cellBody(row, column, cellValue)}
550
+ {/if}
551
+ {/snippet}
552
+
553
+ <!-- Active cell editor. Branches on `ctrl.editingCell.editorType`. Rendered
554
+ only when a cell is in edit mode; the call site supplies the column
555
+ AND row so we can resolve row-dependent `editorOptions` (cascade). -->
556
+ {#snippet editorBody(column: Column<TData>, row: Row<TData>)}
557
+ {#if column.columnDef.cellEditor}
558
+ <!-- Custom editor slot. The columnDef provides a snippet that
559
+ receives the editor context (value + commit + cancel) so the
560
+ consumer fully owns the in-cell UI. -->
561
+ {@const customEditor = column.columnDef
562
+ .cellEditor as unknown as import("svelte").Snippet<
563
+ [EditorContext<TData>]
564
+ >}
565
+ {@render customEditor({
566
+ cell: row.getAllCells().find((c) => c.column.id === column.id)!,
567
+ row,
568
+ column,
569
+ table: grid,
570
+ getValue: () => ctrl.editingCell?.value,
571
+ value: ctrl.editingCell?.value,
572
+ update: (next: unknown) => {
573
+ // Stage the draft without closing. Live-preview controls
574
+ // (sliders, color pickers) call this on every input tick.
575
+ ctrl.editingCell = ctrl.editingCell
576
+ ? { ...ctrl.editingCell, value: next }
577
+ : ctrl.editingCell;
578
+ },
579
+ commit: (next?: unknown) => {
580
+ // Write + close. If the caller passed a value, stage it
581
+ // first; otherwise save whatever update() last wrote.
582
+ if (next !== undefined) {
583
+ ctrl.editingCell = ctrl.editingCell
584
+ ? { ...ctrl.editingCell, value: next }
585
+ : ctrl.editingCell;
586
+ }
587
+ saveEditingCell();
588
+ },
589
+ cancel: () => {
590
+ ctrl.editingCell = null;
591
+ ctrl.gridRootEl?.focus({ preventScroll: true });
592
+ },
593
+ })}
594
+ {:else if ctrl.editingCell?.editorType === "checkbox"}
595
+ <button
596
+ type="button"
597
+ class="sv-grid-checkbox"
598
+ role="checkbox"
599
+ aria-checked={Boolean(ctrl.editingCell.value)}
600
+ aria-label="Edit checkbox value"
601
+ onclick={(event) => {
602
+ event.stopPropagation();
603
+ const nextValue = !Boolean(ctrl.editingCell?.value);
604
+ ctrl.editingCell = ctrl.editingCell
605
+ ? { ...ctrl.editingCell, value: nextValue }
606
+ : ctrl.editingCell;
607
+ saveEditingCell();
608
+ }}
609
+ onkeydown={(event) =>
610
+ toggleCheckboxWithKeyboard(event, () => {
611
+ event.stopPropagation();
612
+ const nextValue = !Boolean(ctrl.editingCell?.value);
613
+ ctrl.editingCell = ctrl.editingCell
614
+ ? { ...ctrl.editingCell, value: nextValue }
615
+ : ctrl.editingCell;
616
+ saveEditingCell();
617
+ })}
618
+ onblur={() => saveEditingCell()}
619
+ ></button>
620
+ {:else if ctrl.editingCell?.editorType === "list"}
621
+ {@const opts = getColumnEditorOptions(column, row)}
622
+ {@const multi = column.columnDef.editorMultiple === true}
623
+ <SvGridDropdown
624
+ options={opts}
625
+ value={ctrl.editingCell?.value}
626
+ multiple={multi}
627
+ placeholder="Select…"
628
+ onChange={(next) => {
629
+ ctrl.editingCell = ctrl.editingCell
630
+ ? { ...ctrl.editingCell, value: next }
631
+ : ctrl.editingCell;
632
+ }}
633
+ onCommit={() => saveEditingCell()}
634
+ onCancel={() => {
635
+ ctrl.editingCell = null;
636
+ ctrl.gridRootEl?.focus({ preventScroll: true });
637
+ }}
638
+ />
639
+ {:else if ctrl.editingCell?.editorType === "chips"}
640
+ {@const opts = getColumnEditorOptions(column, row)}
641
+ {@const multi = column.columnDef.editorMultiple === true}
642
+ {@const selectedArr = toValueArray(ctrl.editingCell?.value)}
643
+ {@render chipsEditor(opts, multi, selectedArr)}
644
+ {:else if ctrl.editingCell?.editorType === "rating"}
645
+ {@const ratingVal = Math.max(
646
+ 0,
647
+ Math.min(5, Math.round(Number(ctrl.editingCell?.value) || 0)),
648
+ )}
649
+ <span class="sv-grid-rating-editor" role="radiogroup" aria-label="Rating">
650
+ {#each [1, 2, 3, 4, 5] as n (n)}
651
+ <button
652
+ type="button"
653
+ role="radio"
654
+ aria-checked={ratingVal >= n}
655
+ aria-label={`${n} ${n === 1 ? "star" : "stars"}`}
656
+ class={`sv-grid-rating-star ${ratingVal >= n ? "sv-grid-rating-star-on" : ""}`}
657
+ onmousedown={(event) => event.preventDefault()}
658
+ onclick={(event) => {
659
+ event.stopPropagation();
660
+ ctrl.editingCell = ctrl.editingCell
661
+ ? { ...ctrl.editingCell, value: n }
662
+ : ctrl.editingCell;
663
+ saveEditingCell();
664
+ }}
665
+ onkeydown={onEditorKeyDown}>★</button
666
+ >
667
+ {/each}
668
+ <button
669
+ type="button"
670
+ aria-label="Clear rating"
671
+ class="sv-grid-rating-clear"
672
+ onmousedown={(event) => event.preventDefault()}
673
+ onclick={(event) => {
674
+ event.stopPropagation();
675
+ ctrl.editingCell = ctrl.editingCell
676
+ ? { ...ctrl.editingCell, value: 0 }
677
+ : ctrl.editingCell;
678
+ saveEditingCell();
679
+ }}>×</button
680
+ >
681
+ </span>
682
+ {:else if ctrl.editingCell?.editorType === "select"}
683
+ <!-- Custom dropdown: opens a themed popover identical in feel to
684
+ the existing 'list' editor (single-select, no typeahead). -->
685
+ {@const selectOpts = getColumnEditorOptions(column, row)}
686
+ <SvGridDropdown
687
+ options={selectOpts}
688
+ value={ctrl.editingCell?.value}
689
+ multiple={false}
690
+ placeholder="Select…"
691
+ onChange={(next) => {
692
+ ctrl.editingCell = ctrl.editingCell
693
+ ? { ...ctrl.editingCell, value: next }
694
+ : ctrl.editingCell;
695
+ }}
696
+ onCommit={() => saveEditingCell()}
697
+ onCancel={() => {
698
+ ctrl.editingCell = null;
699
+ ctrl.gridRootEl?.focus({ preventScroll: true });
700
+ }}
701
+ />
702
+ {:else if ctrl.editingCell?.editorType === "rich-select"}
703
+ <!-- Searchable combobox: same popover as 'select' with a
704
+ typeahead filter input baked in at the top. -->
705
+ {@const richOpts = getColumnEditorOptions(column, row)}
706
+ <SvGridDropdown
707
+ options={richOpts}
708
+ value={ctrl.editingCell?.value}
709
+ multiple={false}
710
+ searchable={true}
711
+ placeholder="Search…"
712
+ onChange={(next) => {
713
+ ctrl.editingCell = ctrl.editingCell
714
+ ? { ...ctrl.editingCell, value: next }
715
+ : ctrl.editingCell;
716
+ }}
717
+ onCommit={() => saveEditingCell()}
718
+ onCancel={() => {
719
+ ctrl.editingCell = null;
720
+ ctrl.gridRootEl?.focus({ preventScroll: true });
721
+ }}
722
+ />
723
+ {:else if ctrl.editingCell?.editorType === "textarea"}
724
+ <!-- Multi-line editor. Commits on Tab, Ctrl/Cmd+Enter, or blur.
725
+ Plain Enter inserts a newline (the whole point of textarea).
726
+ Esc cancels. -->
727
+ <textarea
728
+ use:focusOnMount
729
+ class="sv-grid-cell-editor sv-grid-cell-editor-textarea"
730
+ rows="4"
731
+ value={String(ctrl.editingCell?.value ?? "")}
732
+ onpointerdown={(event) => event.stopPropagation()}
733
+ oninput={(event) =>
734
+ updateEditingCellValue(
735
+ (event.currentTarget as HTMLTextAreaElement).value,
736
+ )}
737
+ onkeydown={(event) => {
738
+ event.stopPropagation();
739
+ if (event.key === "Escape") {
740
+ event.preventDefault();
741
+ ctrl.editingCell = null;
742
+ ctrl.gridRootEl?.focus({ preventScroll: true });
743
+ return;
744
+ }
745
+ // Tab and Ctrl/Cmd+Enter both commit. Plain Enter inserts a newline.
746
+ if (
747
+ event.key === "Tab" ||
748
+ (event.key === "Enter" && (event.ctrlKey || event.metaKey))
749
+ ) {
750
+ event.preventDefault();
751
+ saveEditingCell();
752
+ ctrl.gridRootEl?.focus({ preventScroll: true });
753
+ }
754
+ }}
755
+ onblur={() => saveEditingCell()}
756
+ ></textarea>
757
+ {:else if ctrl.editingCell?.editorType === "autocomplete"}
758
+ <!-- Free-text autocomplete: a text input with a live-filtered
759
+ suggestion list. Typing edits the value freely; clicking a
760
+ suggestion (or blur) commits. Accepts values not in the list. -->
761
+ {@const acOpts = getColumnEditorOptions(column, row)}
762
+ {@const acText = String(ctrl.editingCell?.value ?? "")}
763
+ {@const acFiltered = acText.trim()
764
+ ? acOpts.filter((o) =>
765
+ String(o.label).toLowerCase().includes(acText.toLowerCase()),
766
+ )
767
+ : acOpts}
768
+ <div class="sv-grid-autocomplete">
769
+ <input
770
+ use:focusOnMount
771
+ class="sv-grid-cell-editor sv-grid-cell-editor-autocomplete"
772
+ type="text"
773
+ value={acText}
774
+ onpointerdown={(event) => event.stopPropagation()}
775
+ oninput={(event) =>
776
+ updateEditingCellValue(
777
+ (event.currentTarget as HTMLInputElement).value,
778
+ )}
779
+ onblur={() => saveEditingCell()}
780
+ onkeydown={onEditorKeyDown}
781
+ />
782
+ {#if acFiltered.length > 0}
783
+ <div class="sv-grid-autocomplete-list" role="listbox">
784
+ {#each acFiltered.slice(0, 50) as opt (opt.value)}
785
+ <button
786
+ type="button"
787
+ class="sv-grid-autocomplete-option"
788
+ role="option"
789
+ aria-selected={String(opt.value) === acText}
790
+ onmousedown={(event) => {
791
+ event.preventDefault();
792
+ ctrl.editingCell = ctrl.editingCell
793
+ ? { ...ctrl.editingCell, value: opt.value }
794
+ : ctrl.editingCell;
795
+ saveEditingCell();
796
+ }}>{opt.label}</button
797
+ >
798
+ {/each}
799
+ </div>
800
+ {/if}
801
+ </div>
802
+ {:else if ctrl.editingCell?.editorType === "color"}
803
+ <!-- Native <input type="color"> opens its picker in a separate OS
804
+ overlay; once the picker closes, focus stays on the input so
805
+ `blur` never fires on its own. Commit on `change` (which fires
806
+ exactly once when the picker is dismissed) so the chosen color
807
+ is saved without needing the user to click elsewhere. -->
808
+ <input
809
+ use:focusOnMount
810
+ class={getEditorClass("color")}
811
+ type="color"
812
+ value={getEditableInputValue("color", ctrl.editingCell?.value)}
813
+ oninput={(event) =>
814
+ updateEditingCellValue(
815
+ (event.currentTarget as HTMLInputElement).value,
816
+ )}
817
+ onchange={(event) => {
818
+ updateEditingCellValue(
819
+ (event.currentTarget as HTMLInputElement).value,
820
+ );
821
+ saveEditingCell();
822
+ }}
823
+ onblur={() => saveEditingCell()}
824
+ onkeydown={onEditorKeyDown}
825
+ />
826
+ {:else}
827
+ <input
828
+ use:focusOnMount
829
+ class={getEditorClass(ctrl.editingCell?.editorType ?? "text")}
830
+ type={getEditorInputType(ctrl.editingCell?.editorType ?? "text")}
831
+ value={getEditableInputValue(
832
+ ctrl.editingCell?.editorType ?? "text",
833
+ ctrl.editingCell?.value,
834
+ )}
835
+ oninput={(event) =>
836
+ updateEditingCellValue(
837
+ (event.currentTarget as HTMLInputElement).value,
838
+ )}
839
+ onblur={() => saveEditingCell()}
840
+ onkeydown={onEditorKeyDown}
841
+ />
842
+ {/if}
843
+ {/snippet}
844
+
845
+ <!-- Full-row editor: a lightweight inline editor per editable cell, shown
846
+ for every editable column of the row in full-row edit. Kept separate
847
+ from `editorBody` (which owns the 14 rich single-cell editors) so
848
+ cell editing is untouched. Covers the common editor types. -->
849
+ {#snippet fullRowEditor(column: Column<TData>, row: Row<TData>)}
850
+ {@const et = column.columnDef.editorType ?? "text"}
851
+ {@const val = fullRowEdit?.draft[column.id]}
852
+ {#if et === "checkbox"}
853
+ <input
854
+ type="checkbox"
855
+ class="sv-grid-fr-editor sv-grid-fr-checkbox"
856
+ checked={Boolean(val)}
857
+ onchange={(e) =>
858
+ ctrl.setFullRowDraft(column.id, e.currentTarget.checked)}
859
+ onkeydown={fullRowKeydown}
860
+ onpointerdown={(e) => e.stopPropagation()}
861
+ onclick={(e) => e.stopPropagation()}
862
+ />
863
+ {:else if et === "list" || et === "select" || et === "rich-select"}
864
+ {@const opts = getColumnEditorOptions(column, row)}
865
+ <select
866
+ class="sv-grid-cell-editor sv-grid-fr-editor"
867
+ value={String(val ?? "")}
868
+ onchange={(e) => ctrl.setFullRowDraft(column.id, e.currentTarget.value)}
869
+ onkeydown={fullRowKeydown}
870
+ onpointerdown={(e) => e.stopPropagation()}
871
+ onclick={(e) => e.stopPropagation()}
872
+ >
873
+ {#each opts as o (optionValueOf(o))}
874
+ <option value={optionValueOf(o)}>{optionLabelOf(o)}</option>
875
+ {/each}
876
+ </select>
877
+ {:else}
878
+ {@const inputType =
879
+ et === "number"
880
+ ? "number"
881
+ : et === "date"
882
+ ? "date"
883
+ : et === "datetime"
884
+ ? "datetime-local"
885
+ : et === "time"
886
+ ? "time"
887
+ : et === "password"
888
+ ? "password"
889
+ : "text"}
890
+ <input
891
+ type={inputType}
892
+ class="sv-grid-cell-editor sv-grid-fr-editor"
893
+ value={String(val ?? "")}
894
+ oninput={(e) => ctrl.setFullRowDraft(column.id, e.currentTarget.value)}
895
+ onkeydown={fullRowKeydown}
896
+ onpointerdown={(e) => e.stopPropagation()}
897
+ onclick={(e) => e.stopPropagation()}
898
+ />
899
+ {/if}
900
+ {/snippet}
901
+
902
+ {#snippet chipsEditor(
903
+ opts: CellEditorOption[],
904
+ multi: boolean,
905
+ selectedArr: Array<string | number>,
906
+ )}
907
+ {#if opts.length > 0}
908
+ <!-- Options-driven chips editor: defer to the custom dropdown,
909
+ which renders the selected values as chips in its trigger and
910
+ pops out a styled listbox. Identical UX to the list editor
911
+ with renderChipsInTrigger flipped on. -->
912
+ <SvGridDropdown
913
+ options={opts}
914
+ value={ctrl.editingCell?.value}
915
+ multiple={multi}
916
+ placeholder="Pick…"
917
+ renderChipsInTrigger={true}
918
+ onChange={(next) => {
919
+ ctrl.editingCell = ctrl.editingCell
920
+ ? { ...ctrl.editingCell, value: next }
921
+ : ctrl.editingCell;
922
+ }}
923
+ onCommit={() => saveEditingCell()}
924
+ onCancel={() => {
925
+ ctrl.editingCell = null;
926
+ ctrl.gridRootEl?.focus({ preventScroll: true });
927
+ }}
928
+ />
929
+ {:else}
930
+ <!-- Free-form chips: typed tags. Enter / comma commits a chip,
931
+ Backspace on empty input removes the last chip, blur saves. -->
932
+ <div
933
+ class="sv-grid-cell-editor sv-grid-cell-editor-chips"
934
+ role="group"
935
+ tabindex={-1}
936
+ >
937
+ <div class="sv-grid-chips-row">
938
+ {#each selectedArr as v, idx (String(v) + "_" + idx)}
939
+ <span class="sv-grid-chip sv-grid-chip-removable">
940
+ {String(v)}
941
+ <button
942
+ type="button"
943
+ class="sv-grid-chip-remove"
944
+ aria-label="Remove {String(v)}"
945
+ onmousedown={(event) => event.preventDefault()}
946
+ onclick={() => {
947
+ const next = selectedArr.filter((_, i) => i !== idx);
948
+ ctrl.editingCell = ctrl.editingCell
949
+ ? {
950
+ ...ctrl.editingCell,
951
+ value: multi ? next : (next[0] ?? null),
952
+ }
953
+ : ctrl.editingCell;
954
+ }}>×</button
955
+ >
956
+ </span>
957
+ {/each}
958
+ <input
959
+ use:focusOnMount
960
+ class="sv-grid-chip-input"
961
+ type="text"
962
+ placeholder={multi ? "Type, Enter to add" : "Type a value"}
963
+ onkeydown={(event) => {
964
+ if (event.key === "Enter" || (multi && event.key === ",")) {
965
+ event.preventDefault();
966
+ event.stopPropagation();
967
+ const input = event.currentTarget as HTMLInputElement;
968
+ const raw = input.value.trim();
969
+ if (raw) {
970
+ const next = multi ? [...selectedArr, raw] : [raw];
971
+ ctrl.editingCell = ctrl.editingCell
972
+ ? { ...ctrl.editingCell, value: multi ? next : raw }
973
+ : ctrl.editingCell;
974
+ input.value = "";
975
+ if (!multi) saveEditingCell();
976
+ }
977
+ } else if (event.key === "Escape") {
978
+ onEditorKeyDown(event);
979
+ } else if (event.key === "Backspace") {
980
+ const input = event.currentTarget as HTMLInputElement;
981
+ if (input.value === "" && selectedArr.length > 0) {
982
+ event.preventDefault();
983
+ const next = selectedArr.slice(0, -1);
984
+ ctrl.editingCell = ctrl.editingCell
985
+ ? {
986
+ ...ctrl.editingCell,
987
+ value: multi ? next : (next[0] ?? null),
988
+ }
989
+ : ctrl.editingCell;
990
+ }
991
+ }
992
+ }}
993
+ onblur={() => saveEditingCell()}
994
+ />
995
+ {#if multi}
996
+ <button
997
+ type="button"
998
+ class="sv-grid-chip-commit"
999
+ onmousedown={(event) => event.preventDefault()}
1000
+ onclick={() => saveEditingCell()}
1001
+ aria-label="Commit chip selection">Done</button
1002
+ >
1003
+ {/if}
1004
+ </div>
1005
+ </div>
1006
+ {/if}
1007
+ {/snippet}
1008
+
1009
+ {#snippet groupRowContent(row: Row<TData>)}
1010
+ {@const groupingColumnId = groupingColumns[row.depth] ?? ""}
1011
+ {@const groupingColumn = allColumns.find((c) => c.id === groupingColumnId)}
1012
+ {@const headerLabel =
1013
+ typeof groupingColumn?.columnDef.header === "string"
1014
+ ? groupingColumn.columnDef.header
1015
+ : groupingColumnId}
1016
+ {@const groupValueRaw = row.getCellValueByColumnId(groupingColumnId)}
1017
+ {@const groupValue = groupingColumn
1018
+ ? formatCellValue(groupingColumn, groupValueRaw, row)
1019
+ : String(groupValueRaw ?? "")}
1020
+ {@const count = row.leafCount ?? row.subRows?.length ?? 0}
1021
+ <div
1022
+ class="sv-grid-group-content"
1023
+ style={`padding-left: ${row.depth * 20}px;`}
1024
+ >
1025
+ <button
1026
+ type="button"
1027
+ class="sv-grid-group-toggle"
1028
+ aria-expanded={row.getIsExpanded?.() ? "true" : "false"}
1029
+ aria-label={row.getIsExpanded?.() ? "Collapse group" : "Expand group"}
1030
+ onclick={(event) => {
1031
+ event.stopPropagation();
1032
+ row.toggleExpanded?.();
1033
+ }}>{row.getIsExpanded?.() ? "▾" : "▸"}</button
1034
+ >
1035
+ <span class="sv-grid-group-label">{headerLabel}: {groupValue}</span>
1036
+ <span class="sv-grid-group-count"
1037
+ >{count} {count === 1 ? "row" : "rows"}</span
1038
+ >
1039
+ {#each allColumns as col (col.id)}
1040
+ {#if col.columnDef.aggregate && col.id !== groupingColumnId}
1041
+ {@const aggVal = row.getCellValueByColumnId(col.id)}
1042
+ {#if aggVal != null && aggVal !== ""}
1043
+ <span class="sv-grid-group-agg">
1044
+ <span class="sv-grid-group-agg-label"
1045
+ >{typeof col.columnDef.header === "string"
1046
+ ? col.columnDef.header
1047
+ : col.id}</span
1048
+ >
1049
+ {formatCellValue(col, aggVal, row)}
1050
+ </span>
1051
+ {/if}
1052
+ {/if}
1053
+ {/each}
1054
+ </div>
1055
+ {/snippet}
1056
+
1057
+ <!-- A full-width detail row: one colspan cell spanning every column,
1058
+ hosting the consumer's `renderDetailRow` snippet. Auto height (no
1059
+ fixed row height) so the panel grows to fit its content. -->
1060
+ {#snippet detailRowMarkup(detailRow: Row<TData>, detailRowIndex: number)}
1061
+ <tr
1062
+ class="sv-grid-row sv-grid-detail-row"
1063
+ {...getGridRowA11yProps(detailRowIndex + 1)}
1064
+ >
1065
+ <td
1066
+ class="sv-grid-cell sv-grid-detail-cell"
1067
+ colspan={allColumns.length +
1068
+ (showRowNumbersEffective ? 1 : 0) +
1069
+ (showRowSelectionEffective ? 1 : 0)}
1070
+ >
1071
+ {#if props.renderDetailRow}
1072
+ {@render props.renderDetailRow({
1073
+ row: detailRow.original as TData,
1074
+ rowIndex: detailRowIndex,
1075
+ })}
1076
+ {/if}
1077
+ </td>
1078
+ </tr>
1079
+ {/snippet}
1080
+
1081
+ <!-- A single pinned row (top or bottom). Read-only by design: no
1082
+ inline editing, no row-selection checkbox, no fill handle.
1083
+ Position-sticky CSS keeps it anchored to the top of the body or
1084
+ the bottom of the viewport while the rest scrolls. -->
1085
+ {#snippet pinnedRowBody(
1086
+ rowData: TData,
1087
+ where: "top" | "bottom",
1088
+ index: number,
1089
+ )}
1090
+ <tr
1091
+ class={`sv-grid-row sv-grid-pinned-row sv-grid-pinned-row-${where}`}
1092
+ data-pinned-row={where}
1093
+ data-pinned-index={index}
1094
+ >
1095
+ {#if showRowNumbersEffective}
1096
+ <td
1097
+ class="sv-grid-cell sv-grid-row-number-cell"
1098
+ style={`width: ${rowNumberColumnWidth}px; min-width: ${rowNumberColumnWidth}px; max-width: ${rowNumberColumnWidth}px; left: 0;`}
1099
+ >{where === "top" ? "↑" : "↓"}</td
1100
+ >
1101
+ {/if}
1102
+ {#if showRowSelectionEffective}
1103
+ <td
1104
+ class="sv-grid-cell sv-grid-selection-cell"
1105
+ style={`width: ${selectionColumnWidth}px; min-width: ${selectionColumnWidth}px; max-width: ${selectionColumnWidth}px; left: ${showRowNumbersEffective ? rowNumberColumnWidth : 0}px;`}
1106
+ ></td>
1107
+ {/if}
1108
+ {#if columnVirtualizationEnabled && columnWindowStart > 0}
1109
+ <td
1110
+ class="sv-grid-cell sv-grid-cell-spacer"
1111
+ aria-hidden="true"
1112
+ style={`width: ${columnWindowStart}px; min-width: ${columnWindowStart}px; max-width: ${columnWindowStart}px;`}
1113
+ ></td>
1114
+ {/if}
1115
+ {#each renderedColumns as rendered (rendered.column.id)}
1116
+ {@const value = getPinnedCellValue(rowData, rendered.column)}
1117
+ {@const userCellClass = computePinnedCellClass(
1118
+ rowData,
1119
+ rendered.column,
1120
+ )}
1121
+ <td
1122
+ class={`sv-grid-cell ${userCellClass}`}
1123
+ data-col-id={rendered.column.id}
1124
+ data-pinned={isColumnPinned(rendered.column.id) ?? undefined}
1125
+ style={`width: ${rendered.item.size}px; min-width: ${rendered.item.size}px; max-width: ${rendered.item.size}px; ${cellPinStyle(rendered.column.id)}`}
1126
+ >{formatPinnedValue(rendered.column, value)}</td
1127
+ >
1128
+ {/each}
1129
+ {#if columnVirtualizationEnabled && columnWindowRightSpacer > 0}
1130
+ <td
1131
+ class="sv-grid-cell sv-grid-cell-spacer"
1132
+ aria-hidden="true"
1133
+ style={`width: ${columnWindowRightSpacer}px; min-width: ${columnWindowRightSpacer}px; max-width: ${columnWindowRightSpacer}px;`}
1134
+ ></td>
1135
+ {/if}
1136
+ </tr>
1137
+ {/snippet}
1138
+
1139
+ <div
1140
+ class="sv-grid-root"
1141
+ class:sv-grid-root-fill={props.containerHeight === "100%"}
1142
+ >
1143
+ {#if showGlobalFilterEffective}
1144
+ <label class="sv-grid-global-filter">
1145
+ Filter all rows
1146
+ <input bind:value={ctrl.globalFilter} placeholder="Type to filter..." />
1147
+ </label>
1148
+ {/if}
1149
+
1150
+ {#if toolPanelEnabled}
1151
+ <div class="sv-grid-toolbar">
1152
+ <button
1153
+ type="button"
1154
+ class="sv-grid-toolbar-btn"
1155
+ class:is-active={ctrl.toolPanelOpen}
1156
+ aria-label={ctrl.toolPanelOpen
1157
+ ? "Close tool panel"
1158
+ : "Open tool panel (columns & filters)"}
1159
+ aria-expanded={ctrl.toolPanelOpen}
1160
+ onclick={() => (ctrl.toolPanelOpen = !ctrl.toolPanelOpen)}
1161
+ >
1162
+ <svg
1163
+ viewBox="0 0 24 24"
1164
+ width="15"
1165
+ height="15"
1166
+ fill="none"
1167
+ stroke="currentColor"
1168
+ stroke-width="2"
1169
+ stroke-linecap="round"
1170
+ stroke-linejoin="round"
1171
+ aria-hidden="true"
1172
+ >
1173
+ <rect x="3" y="4" width="6" height="16" rx="1" />
1174
+ <rect x="11" y="4" width="4" height="16" rx="1" />
1175
+ <rect x="17" y="4" width="4" height="16" rx="1" />
1176
+ </svg>
1177
+ Columns &amp; Filters
1178
+ </button>
1179
+ </div>
1180
+ {/if}
1181
+
1182
+ <div
1183
+ class="sv-grid-shell"
1184
+ style={`height: ${
1185
+ typeof props.containerHeight === "string"
1186
+ ? props.containerHeight
1187
+ : `${props.containerHeight ?? 520}px`
1188
+ }; --sg-thead-h: ${headerHeight}px; --sg-pinned-row-h: ${typeof props.rowHeight === "number" ? props.rowHeight : 30}px;`}
1189
+ >
1190
+ <div
1191
+ class="sv-grid-container sv-grid-container-custom-scrollbars"
1192
+ bind:this={ctrl.scrollContainer}
1193
+ onscroll={onBodyScroll}
1194
+ style={`overflow: auto; position: relative; height: calc(100% - ${hasMeasured && hasHorizontalOverflow ? 16 : 0}px);`}
1195
+ >
1196
+ <table
1197
+ bind:this={ctrl.gridRootEl}
1198
+ class="sv-grid-table"
1199
+ {...getGridRootA11yProps({
1200
+ activeDescendantId,
1201
+ rowCount: allRows.length,
1202
+ colCount: allColumns.length,
1203
+ })}
1204
+ onkeydown={onGridKeyDown}
1205
+ onpaste={onGridPaste}
1206
+ style={`min-width: ${totalColumnWidth}px;`}
1207
+ >
1208
+ <!-- svelte-ignore a11y_no_redundant_roles -->
1209
+ <thead class="sv-grid-head" bind:this={ctrl.theadEl} role="rowgroup">
1210
+ <!-- Multi-level group header rows. Only present when the
1211
+ consumer's column tree has `columns: [...]` nesting.
1212
+ Each TH spans the leaf widths underneath via the
1213
+ precomputed `widthPx` + `colSpan` from groupHeaderRows. -->
1214
+ {#each groupHeaderRows as row (row.id)}
1215
+ <tr
1216
+ class="sv-grid-row sv-grid-header-row sv-grid-group-header-row"
1217
+ {...getGridRowA11yProps()}
1218
+ style={props.headerHeight
1219
+ ? `height: ${props.headerHeight}px;`
1220
+ : undefined}
1221
+ >
1222
+ {#if showRowNumbersEffective}
1223
+ <th
1224
+ class="sv-grid-column sv-grid-row-number-column"
1225
+ style={`width: ${rowNumberColumnWidth}px; min-width: ${rowNumberColumnWidth}px; max-width: ${rowNumberColumnWidth}px; left: 0;`}
1226
+ aria-hidden="true"
1227
+ ></th>
1228
+ {/if}
1229
+ {#if showRowSelectionEffective}
1230
+ <th
1231
+ class="sv-grid-column sv-grid-selection-column"
1232
+ style={`width: ${selectionColumnWidth}px; min-width: ${selectionColumnWidth}px; max-width: ${selectionColumnWidth}px; left: ${showRowNumbersEffective ? rowNumberColumnWidth : 0}px;`}
1233
+ aria-hidden="true"
1234
+ ></th>
1235
+ {/if}
1236
+ {#each row.cells as cell (cell.key)}
1237
+ <th
1238
+ class="sv-grid-column sv-grid-group-header-cell"
1239
+ class:sv-grid-group-header-placeholder={cell.isPlaceholder}
1240
+ colspan={cell.colSpan}
1241
+ style={`width: ${cell.widthPx}px; min-width: ${cell.widthPx}px; max-width: ${cell.widthPx}px;`}
1242
+ >
1243
+ {#if !cell.isPlaceholder}
1244
+ {#if cell.collapsible}
1245
+ <button
1246
+ type="button"
1247
+ class="sv-grid-group-toggle"
1248
+ class:is-collapsed={cell.collapsed}
1249
+ aria-expanded={!cell.collapsed}
1250
+ aria-label={cell.collapsed
1251
+ ? `Expand ${cell.label}`
1252
+ : `Collapse ${cell.label}`}
1253
+ title={cell.collapsed
1254
+ ? "Expand group"
1255
+ : "Collapse group"}
1256
+ onclick={() => ctrl.toggleColumnGroup(cell.groupId!)}
1257
+ >
1258
+ <span class="sv-grid-group-header-label"
1259
+ >{cell.label}</span
1260
+ >
1261
+ <svg
1262
+ class="sv-grid-group-caret"
1263
+ viewBox="0 0 16 16"
1264
+ width="10"
1265
+ height="10"
1266
+ fill="none"
1267
+ stroke="currentColor"
1268
+ stroke-width="2.5"
1269
+ stroke-linecap="round"
1270
+ stroke-linejoin="round"
1271
+ aria-hidden="true"
1272
+ >
1273
+ <polyline points="4 6 8 10 12 6"></polyline>
1274
+ </svg>
1275
+ </button>
1276
+ {:else}
1277
+ <span class="sv-grid-group-header-label"
1278
+ >{cell.label}</span
1279
+ >
1280
+ {/if}
1281
+ {/if}
1282
+ </th>
1283
+ {/each}
1284
+ </tr>
1285
+ {/each}
1286
+ {#each headerGroups as headerGroup (headerGroup.id)}
1287
+ <tr
1288
+ class="sv-grid-row sv-grid-header-row"
1289
+ {...getGridRowA11yProps()}
1290
+ style={props.headerHeight
1291
+ ? `height: ${props.headerHeight}px;`
1292
+ : undefined}
1293
+ >
1294
+ {#if showRowNumbersEffective}
1295
+ <th
1296
+ class="sv-grid-column sv-grid-row-number-column"
1297
+ style={`width: ${rowNumberColumnWidth}px; min-width: ${rowNumberColumnWidth}px; max-width: ${rowNumberColumnWidth}px; left: 0;`}
1298
+ aria-label="Row number"
1299
+ >
1300
+ <span class="sv-grid-row-number-head">#</span>
1301
+ </th>
1302
+ {/if}
1303
+ {#if showRowSelectionEffective}
1304
+ <th
1305
+ class="sv-grid-column sv-grid-selection-column"
1306
+ style={`width: ${selectionColumnWidth}px; min-width: ${selectionColumnWidth}px; max-width: ${selectionColumnWidth}px; left: ${showRowNumbersEffective ? rowNumberColumnWidth : 0}px;`}
1307
+ >
1308
+ <button
1309
+ type="button"
1310
+ class="sv-grid-checkbox"
1311
+ role="checkbox"
1312
+ aria-checked={headerSelectionState === "all"
1313
+ ? "true"
1314
+ : headerSelectionState === "some"
1315
+ ? "mixed"
1316
+ : "false"}
1317
+ aria-label="Select all rows"
1318
+ onclick={toggleSelectAllRows}
1319
+ onkeydown={(event) =>
1320
+ toggleCheckboxWithKeyboard(event, toggleSelectAllRows)}
1321
+ ></button>
1322
+ </th>
1323
+ {/if}
1324
+ {#if columnVirtualizationEnabled && columnWindowStart > 0}
1325
+ <th
1326
+ class="sv-grid-column sv-grid-column-spacer"
1327
+ aria-hidden="true"
1328
+ style={`width: ${columnWindowStart}px; min-width: ${columnWindowStart}px; max-width: ${columnWindowStart}px;`}
1329
+ ></th>
1330
+ {/if}
1331
+ {#each renderedColumns as rendered (rendered.column.id)}
1332
+ {@const header = headerGroup.headers[rendered.item.index]}
1333
+ {#if header}
1334
+ {@const sortDirection =
1335
+ sortDirectionByColumn[header.column.id]}
1336
+ {@const isGrouped = groupingColumns.includes(
1337
+ header.column.id,
1338
+ )}
1339
+ <th
1340
+ class="sv-grid-column"
1341
+ class:is-drag-target-before={colDropOnId ===
1342
+ header.column.id && colDropSide === "before"}
1343
+ class:is-drag-target-after={colDropOnId ===
1344
+ header.column.id && colDropSide === "after"}
1345
+ class:is-dragging={colDragId === header.column.id}
1346
+ data-svgrid-header-col={header.column.id}
1347
+ data-align={getColumnAlign(rendered.column)}
1348
+ data-pinned={isColumnPinned(rendered.column.id) ??
1349
+ undefined}
1350
+ draggable={(props.enableColumnReorder ?? false)
1351
+ ? true
1352
+ : undefined}
1353
+ ondragstart={(e) =>
1354
+ (props.enableColumnReorder ?? false) &&
1355
+ onColumnHeaderDragStart(e, header.column.id)}
1356
+ ondragover={(e) =>
1357
+ (props.enableColumnReorder ?? false) &&
1358
+ onColumnHeaderDragOver(e, header.column.id)}
1359
+ ondragleave={() =>
1360
+ (props.enableColumnReorder ?? false) &&
1361
+ onColumnHeaderDragLeave(header.column.id)}
1362
+ ondrop={(e) =>
1363
+ (props.enableColumnReorder ?? false) &&
1364
+ onColumnHeaderDrop(e, header.column.id)}
1365
+ ondragend={() =>
1366
+ (props.enableColumnReorder ?? false) &&
1367
+ onColumnHeaderDragEnd()}
1368
+ style={`width: ${rendered.item.size}px; min-width: ${rendered.item.size}px; max-width: ${rendered.item.size}px; ${cellPinStyle(rendered.column.id)}`}
1369
+ {...getGridHeaderA11yProps({
1370
+ sortable: header.column.getCanSort(),
1371
+ sortDirection:
1372
+ sortDirection === "asc"
1373
+ ? "ascending"
1374
+ : sortDirection === "desc"
1375
+ ? "descending"
1376
+ : "none",
1377
+ })}
1378
+ >
1379
+ {#if !header.isPlaceholder}
1380
+ <div class="sv-grid-header-cell">
1381
+ {#if typeof header.column.columnDef.header === "function"}
1382
+ {@const rendered = header.column.columnDef.header(
1383
+ header.getContext(),
1384
+ )}
1385
+ <!-- Custom header (snippet/component): rendered
1386
+ OUTSIDE the sort button so the consumer's
1387
+ own interactive elements (menu buttons,
1388
+ dropdowns, etc) are valid DOM and receive
1389
+ their own clicks. Clicking blank header
1390
+ background still triggers sort via the
1391
+ wrapper's role=button + click handler. -->
1392
+ <div
1393
+ class="sv-grid-header-custom"
1394
+ role="button"
1395
+ tabindex="-1"
1396
+ onclick={(event) => {
1397
+ if (!header.column.getCanSort()) return;
1398
+ // If the click landed on an interactive
1399
+ // element inside the custom header, let
1400
+ // that element handle it.
1401
+ const t = event.target as HTMLElement | null;
1402
+ if (
1403
+ t &&
1404
+ t.closest(
1405
+ 'button, a, input, select, textarea, [role="button"], [role="menuitem"]',
1406
+ ) &&
1407
+ !t.classList.contains("sv-grid-header-custom")
1408
+ )
1409
+ return;
1410
+ onHeaderSortClick(event, header.column.id);
1411
+ }}
1412
+ onkeydown={(event) => {
1413
+ if (event.key !== "Enter" && event.key !== " ")
1414
+ return;
1415
+ if (!header.column.getCanSort()) return;
1416
+ event.preventDefault();
1417
+ onHeaderSortClick(
1418
+ event as unknown as MouseEvent,
1419
+ header.column.id,
1420
+ );
1421
+ }}
1422
+ >
1423
+ {#if rendered instanceof RenderSnippetConfig}
1424
+ {@render rendered.snippet(rendered.params)}
1425
+ {:else if rendered instanceof RenderComponentConfig}
1426
+ <rendered.component {...rendered.props ?? {}} />
1427
+ {:else if typeof rendered === "string" || typeof rendered === "number"}
1428
+ {rendered}
1429
+ {:else}
1430
+ {header.id}
1431
+ {/if}
1432
+ {#if header.column.getCanSort()}
1433
+ {#if sortDirection === "asc"}
1434
+ <span class="sv-grid-header-icon"
1435
+ >{@render icon("sort-asc")}</span
1436
+ >
1437
+ {:else if sortDirection === "desc"}
1438
+ <span class="sv-grid-header-icon"
1439
+ >{@render icon("sort-desc")}</span
1440
+ >
1441
+ {/if}
1442
+ {/if}
1443
+ </div>
1444
+ {:else}
1445
+ <button
1446
+ type="button"
1447
+ class="sv-grid-header-sort"
1448
+ onclick={(event) =>
1449
+ onHeaderSortClick(event, header.column.id)}
1450
+ >
1451
+ <span class="sv-grid-header-label">
1452
+ {typeof header.column.columnDef.header ===
1453
+ "string"
1454
+ ? header.column.columnDef.header
1455
+ : header.id}
1456
+ </span>
1457
+ {#if header.column.getCanSort()}
1458
+ {#if sortDirection === "asc"}
1459
+ <span class="sv-grid-header-icon"
1460
+ >{@render icon("sort-asc")}</span
1461
+ >
1462
+ {:else if sortDirection === "desc"}
1463
+ <span class="sv-grid-header-icon"
1464
+ >{@render icon("sort-desc")}</span
1465
+ >
1466
+ {:else}
1467
+ <span
1468
+ class="sv-grid-header-icon sv-grid-header-icon-hint"
1469
+ >{@render icon("sort")}</span
1470
+ >
1471
+ {/if}
1472
+ {/if}
1473
+ </button>
1474
+ {/if}
1475
+ {#if isGrouped}
1476
+ <span
1477
+ class="sv-grid-header-icon sv-grid-header-icon-flag"
1478
+ title="Grouped">{@render icon("group")}</span
1479
+ >
1480
+ {/if}
1481
+ {#if header.column.getCanFilter()}
1482
+ <button
1483
+ type="button"
1484
+ class="sv-grid-col-menu-btn sv-grid-col-filter-btn"
1485
+ class:is-open={filterMenuFor === header.column.id}
1486
+ class:is-active={isColumnFiltered(
1487
+ header.column.id,
1488
+ )}
1489
+ aria-label="Filter"
1490
+ aria-haspopup="menu"
1491
+ onclick={(event) =>
1492
+ openFilterMenu(event, header.column.id)}
1493
+ >
1494
+ {@render icon("filter")}
1495
+ </button>
1496
+ {/if}
1497
+ <button
1498
+ type="button"
1499
+ class="sv-grid-col-menu-btn"
1500
+ class:is-open={columnMenuFor === header.column.id}
1501
+ aria-label="Column menu"
1502
+ aria-haspopup="menu"
1503
+ onclick={(event) =>
1504
+ openColumnMenu(event, header.column.id)}
1505
+ >
1506
+ {@render icon("menu")}
1507
+ </button>
1508
+ </div>
1509
+ {#if showInlineColumnFilterEffective && header.column.getCanFilter()}
1510
+ <input
1511
+ class="sv-grid-column-filter"
1512
+ placeholder="Filter"
1513
+ oninput={(event) => {
1514
+ const value = (
1515
+ event.currentTarget as HTMLInputElement
1516
+ ).value;
1517
+ grid.setColumnFilters((prev) => [
1518
+ ...prev.filter(
1519
+ (entry) => entry.id !== header.column.id,
1520
+ ),
1521
+ ...(value
1522
+ ? [
1523
+ {
1524
+ id: header.column.id,
1525
+ value,
1526
+ fn: "includesString" as const,
1527
+ },
1528
+ ]
1529
+ : []),
1530
+ ]);
1531
+ }}
1532
+ />
1533
+ {/if}
1534
+ <div
1535
+ class="sv-grid-resize-handle"
1536
+ class:is-resizing={resizingColumnId ===
1537
+ header.column.id}
1538
+ role="separator"
1539
+ aria-orientation="vertical"
1540
+ aria-label="Resize column"
1541
+ onpointerdown={(event) =>
1542
+ startColumnResize(event, header.column.id)}
1543
+ ondblclick={(event) => event.stopPropagation()}
1544
+ ></div>
1545
+ {/if}
1546
+ </th>
1547
+ {/if}
1548
+ {/each}
1549
+ {#if columnVirtualizationEnabled && columnWindowRightSpacer > 0}
1550
+ <th
1551
+ class="sv-grid-column sv-grid-column-spacer"
1552
+ aria-hidden="true"
1553
+ style={`width: ${columnWindowRightSpacer}px; min-width: ${columnWindowRightSpacer}px; max-width: ${columnWindowRightSpacer}px;`}
1554
+ ></th>
1555
+ {/if}
1556
+ </tr>
1557
+ {#if showFilterRowEffective}
1558
+ <tr {...getGridRowA11yProps()}>
1559
+ {#if showRowNumbersEffective}
1560
+ <th
1561
+ class="sv-grid-column sv-grid-row-number-column"
1562
+ style={`width: ${rowNumberColumnWidth}px; min-width: ${rowNumberColumnWidth}px; max-width: ${rowNumberColumnWidth}px; left: 0;`}
1563
+ ></th>
1564
+ {/if}
1565
+ {#if showRowSelectionEffective}
1566
+ <th
1567
+ class="sv-grid-column sv-grid-selection-column"
1568
+ style={`width: ${selectionColumnWidth}px; min-width: ${selectionColumnWidth}px; max-width: ${selectionColumnWidth}px; left: ${showRowNumbersEffective ? rowNumberColumnWidth : 0}px;`}
1569
+ ></th>
1570
+ {/if}
1571
+ {#if columnVirtualizationEnabled && columnWindowStart > 0}
1572
+ <th
1573
+ class="sv-grid-column sv-grid-column-spacer"
1574
+ aria-hidden="true"
1575
+ style={`width: ${columnWindowStart}px; min-width: ${columnWindowStart}px; max-width: ${columnWindowStart}px;`}
1576
+ ></th>
1577
+ {/if}
1578
+ {#each renderedColumns as rendered (rendered.column.id)}
1579
+ {@const activeOperator =
1580
+ filterMenuValues[rendered.column.id]?.operator ??
1581
+ defaultOperatorFor(rendered.column)}
1582
+ <th
1583
+ class="sv-grid-column"
1584
+ data-pinned={isColumnPinned(rendered.column.id) ??
1585
+ undefined}
1586
+ style={`width: ${rendered.item.size}px; min-width: ${rendered.item.size}px; max-width: ${rendered.item.size}px; ${cellPinStyle(rendered.column.id)}`}
1587
+ >
1588
+ <div class="sv-grid-filter-row-control">
1589
+ <button
1590
+ type="button"
1591
+ class="sv-grid-filter-operator-btn"
1592
+ class:is-open={operatorMenuFor === rendered.column.id}
1593
+ title={`Condition: ${operatorOption(activeOperator).label}`}
1594
+ aria-label={`Filter condition: ${operatorOption(activeOperator).label}`}
1595
+ onclick={(event) =>
1596
+ openOperatorMenu(event, rendered.column.id)}
1597
+ >
1598
+ <span class="sv-grid-header-icon"
1599
+ >{@render icon(
1600
+ operatorOption(activeOperator).iconName,
1601
+ )}</span
1602
+ >
1603
+ <span class="sv-grid-caret"
1604
+ >{@render icon("chevron-down")}</span
1605
+ >
1606
+ </button>
1607
+ {#if activeOperator !== "isBlank"}
1608
+ {@const frType = getEditorInputType(
1609
+ rendered.column.columnDef.editorType ?? "text",
1610
+ )}
1611
+ <input
1612
+ class="sv-grid-filter-value"
1613
+ type={frType}
1614
+ placeholder={activeOperator === "between"
1615
+ ? "From"
1616
+ : "Filter…"}
1617
+ data-svgrid-filter-col={rendered.column.id}
1618
+ value={filterRowValues[rendered.column.id] ?? ""}
1619
+ oninput={(event) =>
1620
+ updateFilterRow(
1621
+ rendered.column.id,
1622
+ (event.currentTarget as HTMLInputElement).value,
1623
+ )}
1624
+ />
1625
+ {#if activeOperator === "between"}
1626
+ <input
1627
+ class="sv-grid-filter-value sv-grid-filter-value-to"
1628
+ type={frType}
1629
+ placeholder="To"
1630
+ value={filterMenuValues[rendered.column.id]
1631
+ ?.valueTo ?? ""}
1632
+ oninput={(event) =>
1633
+ updateFilterMenuValueTo(
1634
+ rendered.column.id,
1635
+ (event.currentTarget as HTMLInputElement)
1636
+ .value,
1637
+ )}
1638
+ />
1639
+ {/if}
1640
+ {/if}
1641
+ </div>
1642
+ </th>
1643
+ {/each}
1644
+ {#if columnVirtualizationEnabled && columnWindowRightSpacer > 0}
1645
+ <th
1646
+ class="sv-grid-column sv-grid-column-spacer"
1647
+ aria-hidden="true"
1648
+ style={`width: ${columnWindowRightSpacer}px; min-width: ${columnWindowRightSpacer}px; max-width: ${columnWindowRightSpacer}px;`}
1649
+ ></th>
1650
+ {/if}
1651
+ </tr>
1652
+ {/if}
1653
+ {/each}
1654
+ </thead>
1655
+ {#if props.pinnedTopRows && props.pinnedTopRows.length > 0}
1656
+ <!-- svelte-ignore a11y_no_redundant_roles -->
1657
+ <tbody
1658
+ class="sv-grid-pinned sv-grid-pinned-top-body"
1659
+ role="rowgroup"
1660
+ >
1661
+ {#each props.pinnedTopRows as r, i (i)}
1662
+ {@render pinnedRowBody(r, "top", i)}
1663
+ {/each}
1664
+ </tbody>
1665
+ {/if}
1666
+ <!-- svelte-ignore a11y_no_redundant_roles -->
1667
+ <tbody
1668
+ class="sv-grid-body"
1669
+ role="rowgroup"
1670
+ ondragover={rowDragManagedEffective
1671
+ ? onRowsContainerDragOver
1672
+ : undefined}
1673
+ ondrop={rowDragManagedEffective ? onRowsContainerDrop : undefined}
1674
+ >
1675
+ {#if !allRows.length && !(props.loading && props.loadingOverlay)}
1676
+ <tr class="sv-grid-row sv-grid-empty-row">
1677
+ <td
1678
+ class="sv-grid-cell sv-grid-empty-cell"
1679
+ colSpan={allColumns.length +
1680
+ (showRowNumbersEffective ? 1 : 0) +
1681
+ (showRowSelectionEffective ? 1 : 0)}
1682
+ >
1683
+ {props.emptyMessage ?? "No rows to display."}
1684
+ </td>
1685
+ </tr>
1686
+ {:else if rowVirtualizationEnabled}
1687
+ {#if rowTopSpacer > 0}
1688
+ <tr class="sv-grid-row sv-grid-row-spacer" aria-hidden="true">
1689
+ <td
1690
+ class="sv-grid-cell sv-grid-cell-spacer"
1691
+ style={`height: ${rowTopSpacer}px; padding: 0; border: 0;`}
1692
+ colSpan={allColumns.length +
1693
+ (showRowNumbersEffective ? 1 : 0) +
1694
+ (showRowSelectionEffective ? 1 : 0)}
1695
+ ></td>
1696
+ </tr>
1697
+ {/if}
1698
+ {#each virtualRows as rowItem (rowItem.key)}
1699
+ {@const rowIndex = rowItem.index}
1700
+ {@const row = allRows[rowIndex]}
1701
+ {#if row}
1702
+ {#if props.isDetailRow?.(row.original as TData, rowIndex)}
1703
+ {@render detailRowMarkup(row, rowIndex)}
1704
+ {:else if isGroupRow(row)}
1705
+ <tr
1706
+ class="sv-grid-row sv-grid-group-row"
1707
+ class:sv-grid-row-selected={isRowSelected(row.id)}
1708
+ aria-level={row.depth + 1}
1709
+ aria-expanded={row.getIsExpanded?.() ? "true" : "false"}
1710
+ {...getGridRowA11yProps(rowIndex + 1)}
1711
+ style={`height: ${rowItem.size}px;`}
1712
+ >
1713
+ <td
1714
+ class="sv-grid-cell sv-grid-group-cell"
1715
+ class:sv-grid-cell-active={activeCell.rowIndex ===
1716
+ rowIndex}
1717
+ colspan={allColumns.length +
1718
+ (showRowNumbersEffective ? 1 : 0) +
1719
+ (showRowSelectionEffective ? 1 : 0)}
1720
+ onclick={() => row.toggleExpanded?.()}
1721
+ >
1722
+ {@render groupRowContent(row)}
1723
+ </td>
1724
+ </tr>
1725
+ {:else}
1726
+ {@const userRowClass = computeRowClass(row, rowIndex)}
1727
+ <tr
1728
+ class={`sv-grid-row ${userRowClass} ${rowDropClass(rowIndex)}`}
1729
+ class:sv-grid-row-selected={isRowSelected(row.id)}
1730
+ class:sv-grid-row-alt={props.zebraRows &&
1731
+ rowIndex % 2 === 1}
1732
+ class:sv-grid-row-draggable={rowDragManagedEffective}
1733
+ {...getGridRowA11yProps(rowIndex + 1)}
1734
+ {...rowDragAttrs(rowIndex)}
1735
+ style={`height: ${rowItem.size}px;`}
1736
+ >
1737
+ {#if showRowNumbersEffective}
1738
+ <td
1739
+ class="sv-grid-cell sv-grid-row-number-cell"
1740
+ style={`width: ${rowNumberColumnWidth}px; min-width: ${rowNumberColumnWidth}px; max-width: ${rowNumberColumnWidth}px; left: 0;`}
1741
+ >{rowIndex + 1}</td
1742
+ >
1743
+ {/if}
1744
+ {#if showRowSelectionEffective}
1745
+ <td
1746
+ class="sv-grid-cell sv-grid-selection-cell"
1747
+ style={`width: ${selectionColumnWidth}px; min-width: ${selectionColumnWidth}px; max-width: ${selectionColumnWidth}px; left: ${showRowNumbersEffective ? rowNumberColumnWidth : 0}px;`}
1748
+ onclick={() => toggleRowSelectionById(row.id)}
1749
+ >
1750
+ <button
1751
+ type="button"
1752
+ class="sv-grid-checkbox"
1753
+ role="checkbox"
1754
+ aria-checked={isRowSelected(row.id)}
1755
+ aria-label="Select row"
1756
+ onclick={(event) => {
1757
+ event.stopPropagation();
1758
+ toggleRowSelectionById(row.id);
1759
+ }}
1760
+ onkeydown={(event) =>
1761
+ toggleCheckboxWithKeyboard(event, () => {
1762
+ event.stopPropagation();
1763
+ toggleRowSelectionById(row.id);
1764
+ })}
1765
+ ></button>
1766
+ </td>
1767
+ {/if}
1768
+ {#if columnVirtualizationEnabled && columnWindowStart > 0}
1769
+ <td
1770
+ class="sv-grid-cell sv-grid-cell-spacer"
1771
+ aria-hidden="true"
1772
+ style={`width: ${columnWindowStart}px; min-width: ${columnWindowStart}px; max-width: ${columnWindowStart}px;`}
1773
+ ></td>
1774
+ {/if}
1775
+ {#each renderedColumns as rendered (rendered.column.id)}
1776
+ {@const colIndex = rendered.item.index}
1777
+ {@const baseValue = getColumnBaseValue(
1778
+ row,
1779
+ rendered.column,
1780
+ )}
1781
+ {@const cellValue = getCellDisplayValue(
1782
+ row.id,
1783
+ rendered.column.id,
1784
+ baseValue,
1785
+ )}
1786
+ {@const isEditing =
1787
+ ctrl.editingCell?.rowId === row.id &&
1788
+ ctrl.editingCell?.columnId === rendered.column.id}
1789
+ {@const inRowEdit =
1790
+ !!fullRowEdit &&
1791
+ fullRowEdit.rowId === row.id &&
1792
+ rendered.column.id in fullRowEdit.draft}
1793
+ {@const rangeEdges = getCellRangeEdges(
1794
+ rowIndex,
1795
+ colIndex,
1796
+ )}
1797
+ {@const hasFillHandle =
1798
+ fillHandleCell &&
1799
+ fillHandleCell.rowIndex === rowIndex &&
1800
+ fillHandleCell.colIndex === colIndex}
1801
+ {@const userCellClass = computeCellClass(
1802
+ row,
1803
+ rendered.column,
1804
+ )}
1805
+ {@const cellTooltip = computeCellTooltip(
1806
+ row,
1807
+ rendered.column,
1808
+ )}
1809
+ {@const cellNote = computeCellNote(
1810
+ row,
1811
+ rendered.column,
1812
+ )}
1813
+ <td
1814
+ class={`sv-grid-cell ${userCellClass}`}
1815
+ class:sv-grid-cell-editing={isEditing || inRowEdit}
1816
+ class:sv-grid-cell-active={activeCell.rowIndex ===
1817
+ rowIndex && activeCell.colIndex === colIndex}
1818
+ class:sv-grid-cell-has-fill-handle={hasFillHandle}
1819
+ class:sv-grid-cell-cf={hasConditionalFormats}
1820
+ class:sv-grid-cell-has-note={cellNote != null}
1821
+ data-svgrid-row={rowIndex}
1822
+ data-svgrid-col={colIndex}
1823
+ data-col-id={rendered.column.id}
1824
+ data-align={getColumnAlign(rendered.column)}
1825
+ data-pinned={isColumnPinned(rendered.column.id) ??
1826
+ undefined}
1827
+ data-selected-range={rangeEdges ? "true" : undefined}
1828
+ data-range-top={rangeEdges?.top ? "true" : undefined}
1829
+ data-range-bottom={rangeEdges?.bottom
1830
+ ? "true"
1831
+ : undefined}
1832
+ data-range-left={rangeEdges?.left
1833
+ ? "true"
1834
+ : undefined}
1835
+ data-range-right={rangeEdges?.right
1836
+ ? "true"
1837
+ : undefined}
1838
+ data-fill-preview={isInFillPreview(rowIndex, colIndex)
1839
+ ? "true"
1840
+ : undefined}
1841
+ style={`width: ${rendered.item.size}px; min-width: ${rendered.item.size}px; max-width: ${rendered.item.size}px; ${cellPinStyle(rendered.column.id)}`}
1842
+ onpointerdown={(event) =>
1843
+ onCellPointerDown(rowIndex, colIndex, event)}
1844
+ onpointerenter={() =>
1845
+ onCellPointerEnter(rowIndex, colIndex)}
1846
+ ondblclick={() =>
1847
+ emitCellDoubleClick(rowIndex, colIndex)}
1848
+ onclick={() => onCellClick(rowIndex, colIndex)}
1849
+ oncontextmenu={(event) =>
1850
+ openContextMenu(
1851
+ event,
1852
+ rowIndex,
1853
+ colIndex,
1854
+ rendered.column.id,
1855
+ )}
1856
+ use:cellFlashAction={{
1857
+ rowId: row.id,
1858
+ value: cellValue,
1859
+ active: !!rendered.column.columnDef.cellFlash,
1860
+ className: flashClassFor(
1861
+ rendered.column.columnDef.cellFlash,
1862
+ ),
1863
+ }}
1864
+ {...getGridCellA11yProps({
1865
+ id: getGridCellDomId("svgrid", rowIndex, colIndex),
1866
+ rowIndex: rowIndex + 1,
1867
+ colIndex: colIndex + 1,
1868
+ selected: isRowSelected(row.id),
1869
+ })}
1870
+ >
1871
+ {#if inRowEdit}
1872
+ {@render fullRowEditor(rendered.column, row)}
1873
+ {:else if isEditing}
1874
+ {@render editorBody(rendered.column, row)}
1875
+ {:else}
1876
+ {@render cellBodyWithFormat(
1877
+ row,
1878
+ rendered.column,
1879
+ cellValue,
1880
+ )}
1881
+ {/if}
1882
+ {#if !isEditing && fillHandleCell && fillHandleCell.rowIndex === rowIndex && fillHandleCell.colIndex === colIndex}
1883
+ <!-- Excel-style fill handle: drag down/right to
1884
+ extend the selection and pattern-fill the new
1885
+ cells on release. Rendered inside the bottom-
1886
+ right cell of the selection range (or active
1887
+ cell if there's no range). -->
1888
+ <div
1889
+ class="sv-grid-fill-handle"
1890
+ role="button"
1891
+ aria-label="Fill handle"
1892
+ onpointerdown={(event) =>
1893
+ startFillDrag(event, rowIndex, colIndex)}
1894
+ ></div>
1895
+ {/if}
1896
+ {#if cellNote != null && !isEditing}
1897
+ <span
1898
+ class="sv-grid-cell-note-corner"
1899
+ aria-label="Note"
1900
+ onpointerenter={(event) => {
1901
+ event.stopPropagation();
1902
+ showTooltipFor(
1903
+ event.currentTarget as HTMLElement,
1904
+ cellNote,
1905
+ );
1906
+ }}
1907
+ onpointerleave={(event) => {
1908
+ event.stopPropagation();
1909
+ hideTooltip();
1910
+ }}
1911
+ ></span>
1912
+ {/if}
1913
+ </td>
1914
+ {/each}
1915
+ {#if columnVirtualizationEnabled && columnWindowRightSpacer > 0}
1916
+ <td
1917
+ class="sv-grid-cell sv-grid-cell-spacer"
1918
+ aria-hidden="true"
1919
+ style={`width: ${columnWindowRightSpacer}px; min-width: ${columnWindowRightSpacer}px; max-width: ${columnWindowRightSpacer}px;`}
1920
+ ></td>
1921
+ {/if}
1922
+ </tr>
1923
+ {/if}
1924
+ {/if}
1925
+ {/each}
1926
+ {#if rowBottomSpacer > 0}
1927
+ <tr class="sv-grid-row sv-grid-row-spacer" aria-hidden="true">
1928
+ <td
1929
+ class="sv-grid-cell sv-grid-cell-spacer"
1930
+ style={`height: ${rowBottomSpacer}px; padding: 0; border: 0;`}
1931
+ colSpan={allColumns.length +
1932
+ (showRowNumbersEffective ? 1 : 0) +
1933
+ (showRowSelectionEffective ? 1 : 0)}
1934
+ ></td>
1935
+ </tr>
1936
+ {/if}
1937
+ {:else}
1938
+ {#each allRows as row, rowIndex (row.id)}
1939
+ {#if props.isDetailRow?.(row.original as TData, rowIndex)}
1940
+ {@render detailRowMarkup(row, rowIndex)}
1941
+ {:else if isGroupRow(row)}
1942
+ <tr
1943
+ class="sv-grid-row sv-grid-group-row"
1944
+ class:sv-grid-row-selected={isRowSelected(row.id)}
1945
+ aria-level={row.depth + 1}
1946
+ aria-expanded={row.getIsExpanded?.() ? "true" : "false"}
1947
+ {...getGridRowA11yProps(rowIndex + 1)}
1948
+ >
1949
+ <td
1950
+ class="sv-grid-cell sv-grid-group-cell"
1951
+ class:sv-grid-cell-active={activeCell.rowIndex ===
1952
+ rowIndex}
1953
+ colspan={allColumns.length +
1954
+ (showRowNumbersEffective ? 1 : 0) +
1955
+ (showRowSelectionEffective ? 1 : 0)}
1956
+ onclick={() => row.toggleExpanded?.()}
1957
+ >
1958
+ {@render groupRowContent(row)}
1959
+ </td>
1960
+ </tr>
1961
+ {:else}
1962
+ {@const userRowClass = computeRowClass(row, rowIndex)}
1963
+ <tr
1964
+ class={`sv-grid-row ${userRowClass} ${rowDropClass(rowIndex)}`}
1965
+ class:sv-grid-row-selected={isRowSelected(row.id)}
1966
+ class:sv-grid-row-alt={props.zebraRows &&
1967
+ rowIndex % 2 === 1}
1968
+ class:sv-grid-row-draggable={rowDragManagedEffective}
1969
+ {...getGridRowA11yProps(rowIndex + 1)}
1970
+ {...rowDragAttrs(rowIndex)}
1971
+ >
1972
+ {#if showRowNumbersEffective}
1973
+ <td
1974
+ class="sv-grid-cell sv-grid-row-number-cell"
1975
+ style={`width: ${rowNumberColumnWidth}px; min-width: ${rowNumberColumnWidth}px; max-width: ${rowNumberColumnWidth}px; left: 0;`}
1976
+ >{rowIndex + 1}</td
1977
+ >
1978
+ {/if}
1979
+ {#if showRowSelectionEffective}
1980
+ <td
1981
+ class="sv-grid-cell sv-grid-selection-cell"
1982
+ style={`width: ${selectionColumnWidth}px; min-width: ${selectionColumnWidth}px; max-width: ${selectionColumnWidth}px; left: ${showRowNumbersEffective ? rowNumberColumnWidth : 0}px;`}
1983
+ onclick={() => toggleRowSelectionById(row.id)}
1984
+ >
1985
+ <button
1986
+ type="button"
1987
+ class="sv-grid-checkbox"
1988
+ role="checkbox"
1989
+ aria-checked={isRowSelected(row.id)}
1990
+ aria-label="Select row"
1991
+ onclick={(event) => {
1992
+ event.stopPropagation();
1993
+ toggleRowSelectionById(row.id);
1994
+ }}
1995
+ onkeydown={(event) =>
1996
+ toggleCheckboxWithKeyboard(event, () => {
1997
+ event.stopPropagation();
1998
+ toggleRowSelectionById(row.id);
1999
+ })}
2000
+ ></button>
2001
+ </td>
2002
+ {/if}
2003
+ {#if columnVirtualizationEnabled && columnWindowStart > 0}
2004
+ <td
2005
+ class="sv-grid-cell sv-grid-cell-spacer"
2006
+ aria-hidden="true"
2007
+ style={`width: ${columnWindowStart}px; min-width: ${columnWindowStart}px; max-width: ${columnWindowStart}px;`}
2008
+ ></td>
2009
+ {/if}
2010
+ {#each renderedColumns as rendered (rendered.column.id)}
2011
+ {@const colIndex = rendered.item.index}
2012
+ {@const baseValue = getColumnBaseValue(
2013
+ row,
2014
+ rendered.column,
2015
+ )}
2016
+ {@const cellValue = getCellDisplayValue(
2017
+ row.id,
2018
+ rendered.column.id,
2019
+ baseValue,
2020
+ )}
2021
+ {@const isEditing =
2022
+ ctrl.editingCell?.rowId === row.id &&
2023
+ ctrl.editingCell?.columnId === rendered.column.id}
2024
+ {@const inRowEdit =
2025
+ !!fullRowEdit &&
2026
+ fullRowEdit.rowId === row.id &&
2027
+ rendered.column.id in fullRowEdit.draft}
2028
+ {@const rangeEdges = getCellRangeEdges(
2029
+ rowIndex,
2030
+ colIndex,
2031
+ )}
2032
+ {@const userCellClass = computeCellClass(
2033
+ row,
2034
+ rendered.column,
2035
+ )}
2036
+ {@const cellTooltip = computeCellTooltip(
2037
+ row,
2038
+ rendered.column,
2039
+ )}
2040
+ {@const cellNote = computeCellNote(row, rendered.column)}
2041
+ <td
2042
+ class={`sv-grid-cell ${userCellClass}`}
2043
+ class:sv-grid-cell-editing={isEditing || inRowEdit}
2044
+ class:sv-grid-cell-active={activeCell.rowIndex ===
2045
+ rowIndex && activeCell.colIndex === colIndex}
2046
+ class:sv-grid-cell-cf={hasConditionalFormats}
2047
+ class:sv-grid-cell-has-note={cellNote != null}
2048
+ data-svgrid-row={rowIndex}
2049
+ data-svgrid-col={colIndex}
2050
+ data-col-id={rendered.column.id}
2051
+ data-pinned={isColumnPinned(rendered.column.id) ??
2052
+ undefined}
2053
+ data-selected-range={rangeEdges ? "true" : undefined}
2054
+ data-range-top={rangeEdges?.top ? "true" : undefined}
2055
+ data-range-bottom={rangeEdges?.bottom
2056
+ ? "true"
2057
+ : undefined}
2058
+ data-range-left={rangeEdges?.left ? "true" : undefined}
2059
+ data-range-right={rangeEdges?.right
2060
+ ? "true"
2061
+ : undefined}
2062
+ style={`width: ${rendered.item.size}px; min-width: ${rendered.item.size}px; max-width: ${rendered.item.size}px; ${cellPinStyle(rendered.column.id)}`}
2063
+ onpointerdown={(event) =>
2064
+ onCellPointerDown(rowIndex, colIndex, event)}
2065
+ onpointerenter={(event) => {
2066
+ onCellPointerEnter(rowIndex, colIndex);
2067
+ // Column tooltip fires on whole-cell hover.
2068
+ // Per-cell notes are gated on the corner hot-
2069
+ // zone below (Excel-style: hover the small
2070
+ // triangle to read the note).
2071
+ if (cellTooltip)
2072
+ showTooltipFor(
2073
+ event.currentTarget as HTMLElement,
2074
+ cellTooltip,
2075
+ );
2076
+ }}
2077
+ onpointerleave={hideTooltip}
2078
+ ondblclick={() =>
2079
+ emitCellDoubleClick(rowIndex, colIndex)}
2080
+ onclick={() => onCellClick(rowIndex, colIndex)}
2081
+ oncontextmenu={(event) =>
2082
+ openContextMenu(
2083
+ event,
2084
+ rowIndex,
2085
+ colIndex,
2086
+ rendered.column.id,
2087
+ )}
2088
+ use:cellFlashAction={{
2089
+ rowId: row.id,
2090
+ value: cellValue,
2091
+ active: !!rendered.column.columnDef.cellFlash,
2092
+ className: flashClassFor(
2093
+ rendered.column.columnDef.cellFlash,
2094
+ ),
2095
+ }}
2096
+ {...getGridCellA11yProps({
2097
+ id: getGridCellDomId("svgrid", rowIndex, colIndex),
2098
+ rowIndex: rowIndex + 1,
2099
+ colIndex: colIndex + 1,
2100
+ selected: isRowSelected(row.id),
2101
+ })}
2102
+ >
2103
+ {#if inRowEdit}
2104
+ {@render fullRowEditor(rendered.column, row)}
2105
+ {:else if isEditing}
2106
+ {@render editorBody(rendered.column, row)}
2107
+ {:else}
2108
+ {@render cellBodyWithFormat(
2109
+ row,
2110
+ rendered.column,
2111
+ cellValue,
2112
+ )}
2113
+ {/if}
2114
+ {#if cellNote != null && !isEditing}
2115
+ <!-- Excel-style per-cell note indicator. The
2116
+ triangle itself is the hot-zone; hover
2117
+ just the corner to see the note (Excel
2118
+ red-dot behaviour). The cell-level
2119
+ tooltip handler only shows the column
2120
+ tooltip, so the two surfaces stay
2121
+ separate. -->
2122
+ <span
2123
+ class="sv-grid-cell-note-corner"
2124
+ aria-label="Note"
2125
+ onpointerenter={(event) => {
2126
+ event.stopPropagation();
2127
+ showTooltipFor(
2128
+ event.currentTarget as HTMLElement,
2129
+ cellNote,
2130
+ );
2131
+ }}
2132
+ onpointerleave={(event) => {
2133
+ event.stopPropagation();
2134
+ hideTooltip();
2135
+ }}
2136
+ ></span>
2137
+ {/if}
2138
+ </td>
2139
+ {/each}
2140
+ {#if columnVirtualizationEnabled && columnWindowRightSpacer > 0}
2141
+ <td
2142
+ class="sv-grid-cell sv-grid-cell-spacer"
2143
+ aria-hidden="true"
2144
+ style={`width: ${columnWindowRightSpacer}px; min-width: ${columnWindowRightSpacer}px; max-width: ${columnWindowRightSpacer}px;`}
2145
+ ></td>
2146
+ {/if}
2147
+ </tr>
2148
+ {/if}
2149
+ {/each}
2150
+ {/if}
2151
+ </tbody>
2152
+ {#if props.pinnedBottomRows && props.pinnedBottomRows.length > 0}
2153
+ <!-- svelte-ignore a11y_no_redundant_roles -->
2154
+ <tbody
2155
+ class="sv-grid-pinned sv-grid-pinned-bottom-body"
2156
+ role="rowgroup"
2157
+ >
2158
+ {#each props.pinnedBottomRows as r, i (i)}
2159
+ {@render pinnedRowBody(r, "bottom", i)}
2160
+ {/each}
2161
+ </tbody>
2162
+ {/if}
2163
+ {#if props.enableRowSummaries ?? true}
2164
+ <!-- svelte-ignore a11y_no_redundant_roles -->
2165
+ <tfoot class="sv-grid-foot" role="rowgroup">
2166
+ <tr
2167
+ class="sv-grid-row sv-grid-summary-row"
2168
+ {...getGridRowA11yProps()}
2169
+ >
2170
+ {#if showRowNumbersEffective}
2171
+ <!-- Row-number column has no aggregate; the digit it normally
2172
+ shows is the row index, which doesn't make sense to sum. -->
2173
+ <th
2174
+ class="sv-grid-column sv-grid-summary-column sv-grid-row-number-column"
2175
+ style={`width: ${rowNumberColumnWidth}px; min-width: ${rowNumberColumnWidth}px; max-width: ${rowNumberColumnWidth}px; left: 0;`}
2176
+ ></th>
2177
+ {/if}
2178
+ {#if showRowSelectionEffective}
2179
+ <!-- Selection column is checkbox-only; no aggregate. -->
2180
+ <th
2181
+ class="sv-grid-column sv-grid-summary-column sv-grid-selection-column"
2182
+ style={`width: ${selectionColumnWidth}px; min-width: ${selectionColumnWidth}px; max-width: ${selectionColumnWidth}px; left: ${showRowNumbersEffective ? rowNumberColumnWidth : 0}px;`}
2183
+ ></th>
2184
+ {/if}
2185
+ {#if columnVirtualizationEnabled && columnWindowStart > 0}
2186
+ <th
2187
+ class="sv-grid-column sv-grid-column-spacer"
2188
+ aria-hidden="true"
2189
+ style={`width: ${columnWindowStart}px; min-width: ${columnWindowStart}px; max-width: ${columnWindowStart}px;`}
2190
+ ></th>
2191
+ {/if}
2192
+ {#each renderedColumns as rendered (rendered.column.id)}
2193
+ <th
2194
+ class="sv-grid-column sv-grid-summary-column"
2195
+ data-pinned={isColumnPinned(rendered.column.id) ??
2196
+ undefined}
2197
+ style={`width: ${rendered.item.size}px; min-width: ${rendered.item.size}px; max-width: ${rendered.item.size}px; ${cellPinStyle(rendered.column.id)}`}
2198
+ >
2199
+ {summaryByColumn[rendered.column.id] ?? ""}
2200
+ </th>
2201
+ {/each}
2202
+ {#if columnVirtualizationEnabled && columnWindowRightSpacer > 0}
2203
+ <th
2204
+ class="sv-grid-column sv-grid-column-spacer"
2205
+ aria-hidden="true"
2206
+ style={`width: ${columnWindowRightSpacer}px; min-width: ${columnWindowRightSpacer}px; max-width: ${columnWindowRightSpacer}px;`}
2207
+ ></th>
2208
+ {/if}
2209
+ </tr>
2210
+ </tfoot>
2211
+ {/if}
2212
+ </table>
2213
+ </div>
2214
+ <!-- 16-px placeholder above the vertical scrollbar that matches the
2215
+ header height - fills the gap so the scrollbar starts exactly
2216
+ under the header row. Only needed when the vertical scrollbar
2217
+ is actually rendered; without this guard the block stays as a
2218
+ visible colored rectangle in the top-right corner even on
2219
+ demos with no vertical overflow. -->
2220
+ {#if hasMeasured && hasVerticalOverflow}
2221
+ <div
2222
+ class="sv-grid-scrollbar-corner"
2223
+ aria-hidden="true"
2224
+ style={`height: ${headerHeight}px;`}
2225
+ ></div>
2226
+ {/if}
2227
+ <!-- Scrollbar attributes come from AUTHORITATIVE sources, not the
2228
+ DOM. `scrollMetrics.scrollHeight/scrollWidth` are read inside a
2229
+ Svelte derived during render - the browser hasn't painted yet,
2230
+ so those values lag one frame behind the real layout. The
2231
+ scrollbar's own `hidden`-check then trips on stale `content-size
2232
+ <= viewport-size`, disables itself, and the user can't drag it.
2233
+ Using `virtualRowTotalSize` / `totalColumnWidth` (which come
2234
+ straight from the virtualizers' authoritative state) avoids the
2235
+ lag - the scrollbar always sees the same overflow numbers our
2236
+ gating did. -->
2237
+ {#if hasMeasured && hasVerticalOverflow}
2238
+ <!-- content-size = the container's actual `scrollHeight`.
2239
+ The scroll container's real `scrollHeight` includes
2240
+ EVERYTHING in the scroll content: the sticky thead, the
2241
+ tbody (top spacer + rendered rows + bottom spacer), and a
2242
+ sticky tfoot if rendered. Computing it from
2243
+ `virtualRowTotalSize + headerHeight` works in the common
2244
+ case but undercounts when the footer is present or the
2245
+ header is more than one row, leaving the last few rows
2246
+ beyond the scrollbar's reach. We read it from the DOM
2247
+ once `hasMeasured` is true (i.e. after the first
2248
+ ResizeObserver tick), and fall back to the virtualizer
2249
+ math if for some reason the DOM read returns 0 (early
2250
+ render races). -->
2251
+ <sv-grid-scrollbar
2252
+ class="sv-grid-scrollbar sv-grid-scrollbar-vertical"
2253
+ bind:this={ctrl.verticalScrollbarEl}
2254
+ orientation="vertical"
2255
+ viewport-size={viewportHeight}
2256
+ content-size={scrollMetrics.scrollHeight ||
2257
+ rowDomTotalSize + headerHeight}
2258
+ value={scrollMetrics.scrollTop}
2259
+ step={typeof props.rowHeight === "number" ? props.rowHeight : 30}
2260
+ style={`top: ${headerHeight}px; height: calc(100% - ${headerHeight + (hasHorizontalOverflow ? 16 : 0)}px);`}
2261
+ ></sv-grid-scrollbar>
2262
+ {/if}
2263
+ {#if hasMeasured && hasHorizontalOverflow}
2264
+ {@const horizontalContentSize =
2265
+ totalColumnWidth +
2266
+ (showRowNumbersEffective ? rowNumberColumnWidth : 0) +
2267
+ (showRowSelectionEffective ? selectionColumnWidth : 0)}
2268
+ <sv-grid-scrollbar
2269
+ class="sv-grid-scrollbar sv-grid-scrollbar-horizontal"
2270
+ bind:this={ctrl.horizontalScrollbarEl}
2271
+ orientation="horizontal"
2272
+ viewport-size={viewportWidth}
2273
+ content-size={scrollMetrics.scrollWidth || horizontalContentSize}
2274
+ value={scrollMetrics.scrollLeft}
2275
+ step={props.columnWidth ?? 140}
2276
+ style={`width: calc(100% - ${hasVerticalOverflow ? 16 : 0}px);`}
2277
+ ></sv-grid-scrollbar>
2278
+ {/if}
2279
+ {#if hasMeasured && hasVerticalOverflow && hasHorizontalOverflow}
2280
+ <div class="sv-grid-scrollbar-corner-br" aria-hidden="true"></div>
2281
+ {/if}
2282
+ </div>
2283
+
2284
+ <GridFooter {ctrl} />
2285
+
2286
+ {#if ctrl.findOpen}
2287
+ <!-- Find-in-grid overlay. Anchored to the TOP of the grid root so
2288
+ it tracks the grid even when the page scrolls. Ctrl+F opens;
2289
+ Enter cycles to the next hit; Esc closes. -->
2290
+ <div class="sv-grid-find" role="search" aria-label="Find in grid">
2291
+ <svg class="sv-grid-find-icon" viewBox="0 0 16 16" aria-hidden="true">
2292
+ <circle
2293
+ cx="7"
2294
+ cy="7"
2295
+ r="4.5"
2296
+ fill="none"
2297
+ stroke="currentColor"
2298
+ stroke-width="1.5"
2299
+ />
2300
+ <line
2301
+ x1="10.2"
2302
+ y1="10.2"
2303
+ x2="14"
2304
+ y2="14"
2305
+ stroke="currentColor"
2306
+ stroke-width="1.5"
2307
+ stroke-linecap="round"
2308
+ />
2309
+ </svg>
2310
+ <input
2311
+ class="sv-grid-find-input"
2312
+ type="search"
2313
+ placeholder="Find in grid…"
2314
+ autofocus
2315
+ bind:value={ctrl.findQuery}
2316
+ oninput={() => (ctrl.findHitIndex = 0)}
2317
+ onkeydown={(event) => {
2318
+ event.stopPropagation();
2319
+ if (event.key === "Enter") {
2320
+ event.preventDefault();
2321
+ if (findHits.length === 0) return;
2322
+ ctrl.findHitIndex =
2323
+ (ctrl.findHitIndex +
2324
+ (event.shiftKey ? -1 : 1) +
2325
+ findHits.length) %
2326
+ findHits.length;
2327
+ const hit = findHits[ctrl.findHitIndex];
2328
+ if (hit) {
2329
+ setActiveCell(hit.rowIndex, hit.colIndex);
2330
+ scrollActiveCellIntoView(hit.rowIndex, hit.colIndex);
2331
+ }
2332
+ }
2333
+ if (event.key === "Escape") {
2334
+ event.preventDefault();
2335
+ ctrl.findOpen = false;
2336
+ ctrl.findQuery = "";
2337
+ }
2338
+ }}
2339
+ />
2340
+ <span class="sv-grid-find-count">
2341
+ {findHits.length === 0 && ctrl.findQuery.trim()
2342
+ ? "No matches"
2343
+ : findHits.length === 0
2344
+ ? ""
2345
+ : `${ctrl.findHitIndex + 1} of ${findHits.length}`}
2346
+ </span>
2347
+ <button
2348
+ type="button"
2349
+ class="sv-grid-find-step"
2350
+ aria-label="Previous match"
2351
+ disabled={findHits.length === 0}
2352
+ onclick={() => {
2353
+ ctrl.findHitIndex =
2354
+ (ctrl.findHitIndex - 1 + findHits.length) % findHits.length;
2355
+ const hit = findHits[ctrl.findHitIndex];
2356
+ if (hit) {
2357
+ setActiveCell(hit.rowIndex, hit.colIndex);
2358
+ scrollActiveCellIntoView(hit.rowIndex, hit.colIndex);
2359
+ }
2360
+ }}>↑</button
2361
+ >
2362
+ <button
2363
+ type="button"
2364
+ class="sv-grid-find-step"
2365
+ aria-label="Next match"
2366
+ disabled={findHits.length === 0}
2367
+ onclick={() => {
2368
+ ctrl.findHitIndex = (ctrl.findHitIndex + 1) % findHits.length;
2369
+ const hit = findHits[ctrl.findHitIndex];
2370
+ if (hit) {
2371
+ setActiveCell(hit.rowIndex, hit.colIndex);
2372
+ scrollActiveCellIntoView(hit.rowIndex, hit.colIndex);
2373
+ }
2374
+ }}>↓</button
2375
+ >
2376
+ <button
2377
+ type="button"
2378
+ class="sv-grid-find-close"
2379
+ aria-label="Close find"
2380
+ onclick={() => {
2381
+ ctrl.findOpen = false;
2382
+ ctrl.findQuery = "";
2383
+ }}>✕</button
2384
+ >
2385
+ </div>
2386
+ {/if}
2387
+
2388
+ {#if props.loading && props.loadingOverlay}
2389
+ <div class="sv-grid-loading-overlay" role="status" aria-live="polite">
2390
+ <div class="sv-grid-loading-bar"></div>
2391
+ {#if allRows.length === 0}
2392
+ <div class="sv-grid-skeleton" aria-hidden="true">
2393
+ {#each Array(props.loadingSkeletonRows ?? 8) as _, r (r)}
2394
+ <div class="sv-grid-skeleton-row">
2395
+ {#each allColumns as col (col.id)}
2396
+ <div
2397
+ class="sv-grid-skeleton-cell"
2398
+ style={`width:${getColumnWidth(col.id)}px`}
2399
+ >
2400
+ <span class="sv-grid-skeleton-bar"></span>
2401
+ </div>
2402
+ {/each}
2403
+ </div>
2404
+ {/each}
2405
+ </div>
2406
+ {/if}
2407
+ <span class="sv-grid-sr-only">Loading…</span>
2408
+ </div>
2409
+ {/if}
2410
+
2411
+ {#if toolPanelEnabled}
2412
+ {#if ctrl.toolPanelOpen}
2413
+ {@const panelTab = ctrl.toolPanelTab}
2414
+ <aside class="sv-grid-tool-panel" aria-label="Tool panel">
2415
+ <div class="sv-grid-tool-panel-head">
2416
+ <span>{panelTab === "filters" ? "Filters" : "Columns"}</span>
2417
+ <button
2418
+ type="button"
2419
+ class="sv-grid-tool-panel-close"
2420
+ aria-label="Close"
2421
+ onclick={() => (ctrl.toolPanelOpen = false)}>✕</button
2422
+ >
2423
+ </div>
2424
+ <div class="sv-grid-tool-panel-tabs" role="tablist">
2425
+ <button
2426
+ type="button"
2427
+ class="sv-grid-tool-panel-tab"
2428
+ class:is-active={panelTab === "columns"}
2429
+ role="tab"
2430
+ aria-selected={panelTab === "columns"}
2431
+ onclick={() => (ctrl.toolPanelTab = "columns")}>Columns</button
2432
+ >
2433
+ <button
2434
+ type="button"
2435
+ class="sv-grid-tool-panel-tab"
2436
+ class:is-active={panelTab === "filters"}
2437
+ role="tab"
2438
+ aria-selected={panelTab === "filters"}
2439
+ onclick={() => (ctrl.toolPanelTab = "filters")}>Filters</button
2440
+ >
2441
+ </div>
2442
+ {#if panelTab === "columns"}
2443
+ <ul class="sv-grid-tool-panel-list">
2444
+ {#each toolPanelColumns as column, i (column.id)}
2445
+ {@const visible = !ctrl.hiddenColumns[column.id]}
2446
+ {@const grouped = groupingColumns.includes(column.id)}
2447
+ <li class="sv-grid-tool-panel-item">
2448
+ <label class="sv-grid-tool-panel-vis">
2449
+ <input
2450
+ type="checkbox"
2451
+ checked={visible}
2452
+ onchange={() => toggleColumnVisibleInPanel(column.id)}
2453
+ />
2454
+ <span class="sv-grid-tool-panel-name"
2455
+ >{toolPanelHeaderLabel(column)}</span
2456
+ >
2457
+ </label>
2458
+ <span class="sv-grid-tool-panel-actions">
2459
+ <button
2460
+ type="button"
2461
+ class="sv-grid-tool-panel-btn"
2462
+ class:is-active={grouped}
2463
+ aria-label={grouped ? "Ungroup" : "Group by"}
2464
+ title={grouped ? "Ungroup" : "Group by this column"}
2465
+ onclick={() => toggleGroupInPanel(column.id)}>⊞</button
2466
+ >
2467
+ <button
2468
+ type="button"
2469
+ class="sv-grid-tool-panel-btn"
2470
+ aria-label="Move up"
2471
+ disabled={i === 0}
2472
+ onclick={() => moveColumnInPanel(column.id, -1)}>↑</button
2473
+ >
2474
+ <button
2475
+ type="button"
2476
+ class="sv-grid-tool-panel-btn"
2477
+ aria-label="Move down"
2478
+ disabled={i === toolPanelColumns.length - 1}
2479
+ onclick={() => moveColumnInPanel(column.id, 1)}>↓</button
2480
+ >
2481
+ </span>
2482
+ </li>
2483
+ {/each}
2484
+ </ul>
2485
+ {:else}
2486
+ <!-- Filters tab: one filter control per filterable column, sharing
2487
+ the same filterMenuValues state as the column menu / filter row. -->
2488
+ <div class="sv-grid-tool-panel-filters">
2489
+ {#each toolPanelColumns as column (column.id)}
2490
+ {#if column.columnDef.field && column.columnDef.filterable !== false}
2491
+ {@const active =
2492
+ filterMenuValues[column.id]?.operator ??
2493
+ defaultOperatorFor(column)}
2494
+ {@const fType = getEditorInputType(
2495
+ column.columnDef.editorType ?? "text",
2496
+ )}
2497
+ <div
2498
+ class="sv-grid-tp-filter"
2499
+ class:is-filtered={isColumnFiltered(column.id)}
2500
+ >
2501
+ <div class="sv-grid-tp-filter-head">
2502
+ <span class="sv-grid-tp-filter-name"
2503
+ >{toolPanelHeaderLabel(column)}</span
2504
+ >
2505
+ {#if isColumnFiltered(column.id)}
2506
+ <button
2507
+ type="button"
2508
+ class="sv-grid-tp-filter-clear"
2509
+ aria-label={`Clear ${toolPanelHeaderLabel(column)} filter`}
2510
+ title="Clear filter"
2511
+ onclick={() => clearColumnFilter(column.id)}>✕</button
2512
+ >
2513
+ {/if}
2514
+ </div>
2515
+ <select
2516
+ class="sv-grid-tp-filter-op"
2517
+ aria-label={`${toolPanelHeaderLabel(column)} filter condition`}
2518
+ value={active}
2519
+ onchange={(e) =>
2520
+ updateFilterOperator(
2521
+ column.id,
2522
+ e.currentTarget.value as FilterOperator,
2523
+ )}
2524
+ >
2525
+ {#each operatorsForColumn(column) as option (option.value)}
2526
+ <option value={option.value}
2527
+ >{operatorOption(option.value).label}</option
2528
+ >
2529
+ {/each}
2530
+ </select>
2531
+ {#if active !== "isBlank"}
2532
+ <input
2533
+ class="sv-grid-tp-filter-input"
2534
+ type={fType}
2535
+ placeholder={active === "between" ? "From" : "Filter…"}
2536
+ value={filterMenuValues[column.id]?.value ?? ""}
2537
+ oninput={(e) =>
2538
+ updateFilterMenuValue(
2539
+ column.id,
2540
+ e.currentTarget.value,
2541
+ )}
2542
+ />
2543
+ {#if active === "between"}
2544
+ <input
2545
+ class="sv-grid-tp-filter-input"
2546
+ type={fType}
2547
+ placeholder="To"
2548
+ value={filterMenuValues[column.id]?.valueTo ?? ""}
2549
+ oninput={(e) =>
2550
+ updateFilterMenuValueTo(
2551
+ column.id,
2552
+ e.currentTarget.value,
2553
+ )}
2554
+ />
2555
+ {/if}
2556
+ {/if}
2557
+ </div>
2558
+ {/if}
2559
+ {/each}
2560
+ </div>
2561
+ {/if}
2562
+ </aside>
2563
+ {/if}
2564
+ {/if}
2565
+ </div>
2566
+ <!-- /.sv-grid-root -->
2567
+
2568
+ <GridMenus {ctrl} {icon} />
2569
+ {/if}