@svgrid/grid 1.1.0 → 1.1.1

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