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