@svgrid/grid 1.2.21 → 1.2.23

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 (190) hide show
  1. package/dist/GridFooter.svelte +34 -20
  2. package/dist/GridFooter.svelte.d.ts +8 -0
  3. package/dist/SvAutoComplete.svelte +100 -0
  4. package/dist/SvAutoComplete.svelte.d.ts +16 -0
  5. package/dist/SvButton.svelte +97 -0
  6. package/dist/SvButton.svelte.d.ts +23 -0
  7. package/dist/SvCalendar.svelte +669 -0
  8. package/dist/SvCalendar.svelte.d.ts +70 -0
  9. package/dist/SvCheckBox.svelte +81 -0
  10. package/dist/SvCheckBox.svelte.d.ts +19 -0
  11. package/dist/SvColorInput.svelte +187 -0
  12. package/dist/SvColorInput.svelte.d.ts +15 -0
  13. package/dist/SvComboBox.svelte +130 -0
  14. package/dist/SvComboBox.svelte.d.ts +15 -0
  15. package/dist/SvCountryInput.svelte +123 -0
  16. package/dist/SvCountryInput.svelte.d.ts +14 -0
  17. package/dist/SvDateTimePicker.svelte +322 -0
  18. package/dist/SvDateTimePicker.svelte.d.ts +37 -0
  19. package/dist/SvDropDownList.svelte +143 -0
  20. package/dist/SvDropDownList.svelte.d.ts +15 -0
  21. package/dist/SvForm.svelte +147 -0
  22. package/dist/SvForm.svelte.d.ts +29 -0
  23. package/dist/SvGauge.svelte +99 -0
  24. package/dist/SvGauge.svelte.d.ts +31 -0
  25. package/dist/SvGrid.controller.svelte.d.ts +16 -2
  26. package/dist/SvGrid.controller.svelte.js +63 -8
  27. package/dist/SvGrid.css +84 -31
  28. package/dist/SvGrid.helpers.d.ts +4 -0
  29. package/dist/SvGrid.helpers.js +40 -12
  30. package/dist/SvGrid.svelte +84 -5
  31. package/dist/SvGrid.types.d.ts +36 -0
  32. package/dist/SvGridDropdown.svelte +16 -30
  33. package/dist/SvGridDropdown.svelte.d.ts +4 -0
  34. package/dist/SvListBox.svelte +122 -0
  35. package/dist/SvListBox.svelte.d.ts +19 -0
  36. package/dist/SvMaskedInput.svelte +0 -0
  37. package/dist/SvMaskedInput.svelte.d.ts +15 -0
  38. package/dist/SvNumberInput.svelte +0 -0
  39. package/dist/SvNumberInput.svelte.d.ts +22 -0
  40. package/dist/SvPasswordInput.svelte +110 -0
  41. package/dist/SvPasswordInput.svelte.d.ts +22 -0
  42. package/dist/SvPhoneInput.svelte +0 -0
  43. package/dist/SvPhoneInput.svelte.d.ts +19 -0
  44. package/dist/SvRadioGroup.svelte +108 -0
  45. package/dist/SvRadioGroup.svelte.d.ts +23 -0
  46. package/dist/SvRating.svelte +111 -0
  47. package/dist/SvRating.svelte.d.ts +20 -0
  48. package/dist/SvRepeatButton.svelte +89 -0
  49. package/dist/SvRepeatButton.svelte.d.ts +21 -0
  50. package/dist/SvSlider.svelte +182 -0
  51. package/dist/SvSlider.svelte.d.ts +25 -0
  52. package/dist/SvSwitchButton.svelte +94 -0
  53. package/dist/SvSwitchButton.svelte.d.ts +19 -0
  54. package/dist/SvTabs.svelte +122 -0
  55. package/dist/SvTabs.svelte.d.ts +26 -0
  56. package/dist/SvTagsInput.svelte +81 -0
  57. package/dist/SvTagsInput.svelte.d.ts +19 -0
  58. package/dist/SvTimePicker.svelte +359 -0
  59. package/dist/SvTimePicker.svelte.d.ts +27 -0
  60. package/dist/SvToggleButton.svelte +57 -0
  61. package/dist/SvToggleButton.svelte.d.ts +16 -0
  62. package/dist/SvTree.svelte +210 -0
  63. package/dist/SvTree.svelte.d.ts +29 -0
  64. package/dist/build-api.js +78 -1
  65. package/dist/cdn/svgrid.js +10022 -5904
  66. package/dist/cdn/svgrid.svelte-external.js +8531 -4429
  67. package/dist/cell-render.d.ts +4 -0
  68. package/dist/cell-render.js +35 -1
  69. package/dist/clipboard.js +19 -1
  70. package/dist/collaboration.d.ts +2 -0
  71. package/dist/collaboration.js +4 -0
  72. package/dist/core.d.ts +22 -1
  73. package/dist/countries.d.ts +15 -0
  74. package/dist/countries.js +61 -0
  75. package/dist/datetime/date-core.d.ts +88 -0
  76. package/dist/datetime/date-core.js +165 -0
  77. package/dist/datetime/date-format.d.ts +39 -0
  78. package/dist/datetime/date-format.js +371 -0
  79. package/dist/datetime/date-restrict.d.ts +23 -0
  80. package/dist/datetime/date-restrict.js +50 -0
  81. package/dist/datetime/date-selection.d.ts +25 -0
  82. package/dist/datetime/date-selection.js +94 -0
  83. package/dist/datetime/mask.d.ts +21 -0
  84. package/dist/datetime/mask.js +90 -0
  85. package/dist/editing.d.ts +1 -1
  86. package/dist/editing.js +7 -0
  87. package/dist/editors/cell-editors.d.ts +2 -2
  88. package/dist/editors/cell-editors.js +3 -1
  89. package/dist/export-format.d.ts +178 -0
  90. package/dist/export-format.js +419 -0
  91. package/dist/hyperformula-adapter.d.ts +4 -0
  92. package/dist/hyperformula-adapter.js +15 -6
  93. package/dist/index.d.ts +36 -0
  94. package/dist/index.js +41 -0
  95. package/dist/list-option.d.ts +10 -0
  96. package/dist/list-option.js +7 -0
  97. package/dist/menus.js +18 -2
  98. package/dist/popover.d.ts +48 -0
  99. package/dist/popover.js +65 -0
  100. package/dist/row-resize.d.ts +4 -4
  101. package/dist/row-resize.js +8 -5
  102. package/dist/selection.d.ts +6 -0
  103. package/dist/selection.js +31 -5
  104. package/dist/server-data-source.d.ts +43 -0
  105. package/dist/server-data-source.js +100 -0
  106. package/dist/sparkline.js +8 -4
  107. package/dist/svgrid-wrapper.types.d.ts +26 -1
  108. package/package.json +6 -1
  109. package/src/GridFooter.svelte +34 -20
  110. package/src/SvAutoComplete.svelte +100 -0
  111. package/src/SvButton.svelte +97 -0
  112. package/src/SvCalendar.svelte +669 -0
  113. package/src/SvCalendar.test.ts +211 -0
  114. package/src/SvCheckBox.svelte +81 -0
  115. package/src/SvColorInput.svelte +187 -0
  116. package/src/SvComboBox.svelte +130 -0
  117. package/src/SvCountryInput.svelte +123 -0
  118. package/src/SvDateTimePicker.svelte +322 -0
  119. package/src/SvDateTimePicker.test.ts +136 -0
  120. package/src/SvDropDownList.svelte +143 -0
  121. package/src/SvForm.svelte +147 -0
  122. package/src/SvGauge.svelte +99 -0
  123. package/src/SvGrid.controller.svelte.ts +74 -8
  124. package/src/SvGrid.css +84 -31
  125. package/src/SvGrid.helpers.ts +41 -12
  126. package/src/SvGrid.svelte +84 -5
  127. package/src/SvGrid.types.ts +33 -0
  128. package/src/SvGridDropdown.svelte +16 -30
  129. package/src/SvListBox.svelte +122 -0
  130. package/src/SvMaskedInput.svelte +0 -0
  131. package/src/SvNumberInput.svelte +0 -0
  132. package/src/SvPasswordInput.svelte +110 -0
  133. package/src/SvPhoneInput.svelte +0 -0
  134. package/src/SvRadioGroup.svelte +108 -0
  135. package/src/SvRating.svelte +111 -0
  136. package/src/SvRepeatButton.svelte +89 -0
  137. package/src/SvSlider.svelte +182 -0
  138. package/src/SvSwitchButton.svelte +94 -0
  139. package/src/SvTabs.svelte +122 -0
  140. package/src/SvTagsInput.svelte +81 -0
  141. package/src/SvTimePicker.svelte +359 -0
  142. package/src/SvTimePicker.test.ts +98 -0
  143. package/src/SvToggleButton.svelte +57 -0
  144. package/src/SvTree.svelte +210 -0
  145. package/src/build-api.ts +115 -0
  146. package/src/cell-render.test.ts +38 -0
  147. package/src/cell-render.ts +43 -0
  148. package/src/clipboard.test.ts +18 -0
  149. package/src/clipboard.ts +24 -1
  150. package/src/collaboration.test.ts +30 -0
  151. package/src/collaboration.ts +6 -0
  152. package/src/core.ts +27 -3
  153. package/src/countries.ts +71 -0
  154. package/src/datetime/date-core.test.ts +217 -0
  155. package/src/datetime/date-core.ts +204 -0
  156. package/src/datetime/date-format.test.ts +121 -0
  157. package/src/datetime/date-format.ts +317 -0
  158. package/src/datetime/date-restrict.ts +60 -0
  159. package/src/datetime/date-selection.test.ts +129 -0
  160. package/src/datetime/date-selection.ts +137 -0
  161. package/src/datetime/mask.test.ts +36 -0
  162. package/src/datetime/mask.ts +84 -0
  163. package/src/editing.test.ts +22 -0
  164. package/src/editing.ts +7 -1
  165. package/src/editors/cell-editors.ts +7 -1
  166. package/src/export-data-api.test.ts +126 -0
  167. package/src/export-format.test.ts +107 -0
  168. package/src/export-format.ts +598 -0
  169. package/src/hyperformula-adapter.test.ts +35 -1
  170. package/src/hyperformula-adapter.ts +18 -6
  171. package/src/index.ts +71 -0
  172. package/src/list-option.ts +15 -0
  173. package/src/menus.ts +21 -2
  174. package/src/popover.ts +79 -0
  175. package/src/row-resize.test.ts +25 -0
  176. package/src/row-resize.ts +8 -5
  177. package/src/selection.test.ts +36 -4
  178. package/src/selection.ts +30 -9
  179. package/src/server-data-source.test.ts +201 -0
  180. package/src/server-data-source.ts +148 -0
  181. package/src/sparkline.test.ts +9 -0
  182. package/src/sparkline.ts +9 -4
  183. package/src/svgrid-wrapper.types.ts +28 -1
  184. package/src/svgrid.behavior.test.ts +27 -0
  185. package/src/svgrid.new-features.wrapper.test.ts +27 -1
  186. package/src/ui-buttons.test.ts +105 -0
  187. package/src/ui-composite.test.ts +89 -0
  188. package/src/ui-inputs.test.ts +92 -0
  189. package/src/ui-range.test.ts +61 -0
  190. package/src/ui-selection.test.ts +106 -0
package/src/index.ts CHANGED
@@ -1,4 +1,27 @@
1
1
  export { formatNumericWithConfig, resolveDatePattern } from './cell-formatting'
2
+ export {
3
+ coerceExportDate,
4
+ formatValueForExport,
5
+ projectGridRows,
6
+ toExcelNumFmt,
7
+ valueForExcel,
8
+ serializeDelimited,
9
+ serializeJson,
10
+ serializeMarkdown,
11
+ serializeHtml,
12
+ serializeXml,
13
+ downloadTextFile,
14
+ downloadBlobFile,
15
+ copyTextToClipboard,
16
+ type GridExportOptions,
17
+ type GridExportScope,
18
+ type GridExportColumn,
19
+ type GridClipboardOptions,
20
+ type GridClipboardFormat,
21
+ type SerializeProgress,
22
+ type SerializeOptions,
23
+ type CsvOptions,
24
+ } from './export-format'
2
25
 
3
26
  export {
4
27
  columnFilteringFeature,
@@ -46,6 +69,54 @@ export { createGrid, createSvGrid, type SvelteGrid } from './createGrid.svelte'
46
69
  export { createGridState, createSvGridState } from './createGridState.svelte'
47
70
  export { subscribeGrid, subscribeSvGrid } from './subscribe'
48
71
  export { default as SvGrid } from './SvGrid.svelte'
72
+ export { default as SvGridDropdown } from './SvGridDropdown.svelte'
73
+ export { default as SvCalendar, type CalendarValue, type CalendarPreset, type CalendarAnimation } from './SvCalendar.svelte'
74
+ export { default as SvTimePicker, type TimeValue } from './SvTimePicker.svelte'
75
+ export { default as SvDateTimePicker, type DateTimeValue } from './SvDateTimePicker.svelte'
76
+ // UI kit - Group B (buttons & toggles)
77
+ export { default as SvButton } from './SvButton.svelte'
78
+ export { default as SvRepeatButton } from './SvRepeatButton.svelte'
79
+ export { default as SvToggleButton } from './SvToggleButton.svelte'
80
+ export { default as SvSwitchButton } from './SvSwitchButton.svelte'
81
+ export { default as SvCheckBox } from './SvCheckBox.svelte'
82
+ export { default as SvRadioGroup, type RadioOption } from './SvRadioGroup.svelte'
83
+ export { default as SvRating } from './SvRating.svelte'
84
+ // UI kit - Group C (text inputs)
85
+ export { default as SvNumberInput } from './SvNumberInput.svelte'
86
+ export { default as SvPasswordInput } from './SvPasswordInput.svelte'
87
+ export { default as SvMaskedInput } from './SvMaskedInput.svelte'
88
+ export { default as SvPhoneInput } from './SvPhoneInput.svelte'
89
+ export { default as SvColorInput } from './SvColorInput.svelte'
90
+ export { applyMask, unmask, isMaskComplete } from './datetime/mask'
91
+ export { COUNTRIES, COUNTRY_BY_CODE, flagEmoji, type Country } from './countries'
92
+ // UI kit - Group D (selection overlays)
93
+ export { default as SvListBox } from './SvListBox.svelte'
94
+ export { default as SvDropDownList } from './SvDropDownList.svelte'
95
+ export { default as SvComboBox } from './SvComboBox.svelte'
96
+ export { default as SvAutoComplete } from './SvAutoComplete.svelte'
97
+ export { default as SvTagsInput } from './SvTagsInput.svelte'
98
+ export { default as SvCountryInput } from './SvCountryInput.svelte'
99
+ export { filterOptions, type ListOption } from './list-option'
100
+ // UI kit - Group E (range & feedback)
101
+ export { default as SvSlider } from './SvSlider.svelte'
102
+ export { default as SvGauge } from './SvGauge.svelte'
103
+ // UI kit - Group F (composite / layout)
104
+ export { default as SvTabs, type TabItem } from './SvTabs.svelte'
105
+ export { default as SvTree, type TreeNode as SvTreeNode } from './SvTree.svelte'
106
+ export { default as SvForm, type FormField, type FormFieldType } from './SvForm.svelte'
107
+ export { anchoredRect, portalToBody, PANEL_THEME_VARS, type AnchoredRect } from './popover'
108
+ export {
109
+ toDate, startOfDay, addDays, addMonths, addYears, clampDate, isSameDay,
110
+ monthMatrix, isoWeek, decadeRange, withTime, snapMinute, type DateLike,
111
+ } from './datetime/date-core'
112
+ export { formatDate, parseDate, tokenizeMask } from './datetime/date-format'
113
+ export {
114
+ selectDate, rangeDays, isSelected, isMultiSelectMode, emptySelection,
115
+ type SelectionMode, type SelectionState,
116
+ } from './datetime/date-selection'
117
+ export {
118
+ isDisabledDay, isOutOfRange, isRestricted, isImportant, type RestrictOptions,
119
+ } from './datetime/date-restrict'
49
120
  export { default as FlexRender } from './FlexRender.svelte'
50
121
  export { renderComponent, renderSnippet } from './render-component'
51
122
  export { default as SvGridChart } from './SvGridChart.svelte'
@@ -0,0 +1,15 @@
1
+ /** Shared option shape for the SvGrid UI-kit selection controls. */
2
+ export type ListOption = {
3
+ value: string | number
4
+ label: string
5
+ disabled?: boolean
6
+ /** Optional group heading this option belongs under. */
7
+ group?: string
8
+ }
9
+
10
+ /** Case-insensitive substring filter over option labels. */
11
+ export function filterOptions(options: ReadonlyArray<ListOption>, query: string): ListOption[] {
12
+ const q = query.trim().toLowerCase()
13
+ if (!q) return [...options]
14
+ return options.filter((o) => o.label.toLowerCase().includes(q))
15
+ }
package/src/menus.ts CHANGED
@@ -368,23 +368,42 @@ export function createMenus<
368
368
  }
369
369
  }
370
370
 
371
+ // External pagination is controlled: emit the requested page/size and let
372
+ // the consumer fetch + update props. Never mutate local pagination state.
373
+ function emitExternalPagination(pageIndex: number, pageSize: number): boolean {
374
+ if (ctx.props.externalPagination !== true) return false;
375
+ ctx.props.onPaginationChange?.({ pageIndex: Math.max(0, pageIndex), pageSize });
376
+ ctx.scrollContainer?.scrollTo?.({ top: 0 });
377
+ return true;
378
+ }
379
+
371
380
  function changePage(delta: number) {
381
+ if (
382
+ emitExternalPagination(
383
+ (ctx.props.pageIndex ?? 0) + delta,
384
+ ctx.props.pageSize ?? 10,
385
+ )
386
+ )
387
+ return;
372
388
  ctx.grid.setPagination((prev: any) => ({
373
389
  ...prev,
374
390
  pageIndex: Math.max((prev?.pageIndex ?? 0) + delta, 0),
375
391
  }));
376
- ctx.scrollContainer?.scrollTo({ top: 0 });
392
+ ctx.scrollContainer?.scrollTo?.({ top: 0 });
377
393
  }
378
394
 
379
395
  function goToPage(pageIndex: number) {
396
+ if (emitExternalPagination(pageIndex, ctx.props.pageSize ?? 10)) return;
380
397
  ctx.grid.setPagination((prev: any) => ({
381
398
  ...prev,
382
399
  pageIndex: Math.max(0, pageIndex),
383
400
  }));
384
- ctx.scrollContainer?.scrollTo({ top: 0 });
401
+ ctx.scrollContainer?.scrollTo?.({ top: 0 });
385
402
  }
386
403
 
387
404
  function setPageSize(pageSize: number) {
405
+ // External mode: changing page size resets to the first page.
406
+ if (emitExternalPagination(0, pageSize)) return;
388
407
  ctx.grid.setPagination((prev: any) => {
389
408
  const oldSize = prev?.pageSize ?? 10;
390
409
  const oldIndex = prev?.pageIndex ?? 0;
package/src/popover.ts ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * popover - shared helpers for panels that must escape the grid's
3
+ * `overflow:hidden` scroll container by portalling to <body>: the theme-var
4
+ * snapshot + portal action, and the fixed-position anchor/flip math. Rune-free
5
+ * and DOM-only so it is reused verbatim by SvGridDropdown, SvDateTimePicker and
6
+ * any future popover. The per-component `$effect` wiring (scroll/resize
7
+ * reposition, outside-click close) stays in the component since it needs the
8
+ * component's own open state.
9
+ */
10
+
11
+ /**
12
+ * Theme tokens a portalled panel must carry with it. `--sg-*` custom properties
13
+ * are scoped to whatever wrapper the grid sits inside (e.g. a per-preset theme
14
+ * class); moving the panel to <body> leaves that scope, so we snapshot the
15
+ * resolved values and pin them inline. Keep this list in sync with the tokens
16
+ * any popover panel actually consumes.
17
+ */
18
+ export const PANEL_THEME_VARS = [
19
+ '--sg-accent', '--sg-bg', '--sg-fg', '--sg-border', '--sg-muted',
20
+ '--sg-header-bg', '--sg-row-hover-bg', '--sg-selection-bg',
21
+ '--sg-input-bg', '--sg-input-border', '--sg-selection-fg',
22
+ ] as const
23
+
24
+ /**
25
+ * Svelte action: snapshot the resolved theme tokens from the node's current
26
+ * (in-scope) position, then detach it and append to <body> so it can never be
27
+ * clipped by an ancestor's overflow. Removes itself on destroy.
28
+ */
29
+ export function portalToBody(node: HTMLElement, vars: ReadonlyArray<string> = PANEL_THEME_VARS) {
30
+ const cs = getComputedStyle(node)
31
+ for (const v of vars) {
32
+ const val = cs.getPropertyValue(v).trim()
33
+ if (val) node.style.setProperty(v, val)
34
+ }
35
+ document.body.appendChild(node)
36
+ return {
37
+ destroy() {
38
+ if (node.parentNode === document.body) document.body.removeChild(node)
39
+ },
40
+ }
41
+ }
42
+
43
+ export type AnchoredRect = { top: number; left: number; width: number; openUpward: boolean }
44
+
45
+ export type AnchorOptions = {
46
+ /** Estimated panel height, used to decide whether to flip upward. */
47
+ estimatedHeight: number
48
+ /** Gap between trigger and panel. Default 2px. */
49
+ gap?: number
50
+ /** Force the panel at least this wide (else it matches the trigger). */
51
+ minWidth?: number
52
+ /** Keep the panel within the viewport horizontally. Default true. */
53
+ clampHorizontal?: boolean
54
+ }
55
+
56
+ /**
57
+ * Compute a `position: fixed` rect anchored to `triggerRect`, flipping above the
58
+ * trigger when there isn't room below and there's more room above. Mirrors the
59
+ * behavior SvGridDropdown shipped with, generalized with min-width + horizontal
60
+ * clamping for wider panels (date/time popovers).
61
+ */
62
+ export function anchoredRect(triggerRect: DOMRect, opts: AnchorOptions): AnchoredRect {
63
+ const gap = opts.gap ?? 2
64
+ const spaceBelow = window.innerHeight - triggerRect.bottom
65
+ const spaceAbove = triggerRect.top
66
+ const openUpward = spaceBelow < opts.estimatedHeight && spaceAbove > spaceBelow
67
+ const width = Math.max(triggerRect.width, opts.minWidth ?? 0)
68
+ let left = triggerRect.left
69
+ if (opts.clampHorizontal !== false) {
70
+ const maxLeft = window.innerWidth - width - 4
71
+ left = Math.max(4, Math.min(left, maxLeft))
72
+ }
73
+ return {
74
+ top: openUpward ? triggerRect.top - opts.estimatedHeight - gap : triggerRect.bottom + gap,
75
+ left,
76
+ width,
77
+ openUpward,
78
+ }
79
+ }
@@ -131,6 +131,31 @@ describe('rowResize - strip injection', () => {
131
131
  action.destroy()
132
132
  })
133
133
 
134
+ it('also injects a strip into the built-in row-number gutter (showRowNumbers)', () => {
135
+ // The native gutter cell is `.sv-grid-cell.sv-grid-row-number-cell`, NOT
136
+ // `.sv-row-gutter`; the action must recognise it so `showRowNumbers` grids
137
+ // are row-resizable without a custom gutter column.
138
+ const host = document.createElement('div')
139
+ const table = document.createElement('table')
140
+ const tbody = document.createElement('tbody')
141
+ const tr = document.createElement('tr')
142
+ tr.className = 'sv-grid-row'
143
+ const gutter = document.createElement('td')
144
+ gutter.className = 'sv-grid-cell sv-grid-row-number-cell'
145
+ const marker = document.createElement('span')
146
+ marker.setAttribute('data-svgrid-row', '0')
147
+ gutter.appendChild(marker)
148
+ tr.appendChild(gutter)
149
+ tbody.appendChild(tr)
150
+ table.appendChild(tbody)
151
+ host.appendChild(table)
152
+ document.body.appendChild(host)
153
+ track(host)
154
+ const action = rowResize(host, { onResize: vi.fn() })
155
+ expect(tr.querySelector(`.${STRIP_CLASS}`)).not.toBeNull()
156
+ action.destroy()
157
+ })
158
+
134
159
  it('does not override an already-positioned gutter cell', () => {
135
160
  const { host, rows } = buildGrid([{}])
136
161
  track(host)
package/src/row-resize.ts CHANGED
@@ -4,10 +4,10 @@
4
4
  * reads per-row heights from a reactive store.
5
5
  *
6
6
  * The action is OFF until a consumer attaches it AND a row-header
7
- * gutter cell exists in each row. A row-header cell is any TD with
8
- * the `sv-row-gutter` class (typically the one rendering the row
9
- * number 1, 2, 3, ...). Demos opt in by setting
10
- * `cellClass: 'sv-row-gutter'` on the gutter column.
7
+ * gutter cell exists in each row. A row-header cell is either the
8
+ * grid's built-in row-number gutter (enable `showRowNumbers`) or any
9
+ * TD tagged with the `sv-row-gutter` class (set
10
+ * `cellClass: 'sv-row-gutter'` on a custom gutter column).
11
11
  *
12
12
  * <script>
13
13
  * let heights = $state<Record<number, number>>({})
@@ -116,8 +116,11 @@ export function rowResize(node: HTMLElement, opts: RowResizeOptions) {
116
116
  for (const tr of rows) {
117
117
  if (tr.classList.contains('sv-grid-header-row')) continue
118
118
  if (tr.classList.contains('sv-grid-row-spacer')) continue
119
+ // A row-header gutter cell: either a user column tagged
120
+ // `sv-row-gutter`, or the grid's built-in row-number gutter
121
+ // (`showRowNumbers`), whose cell is `.sv-grid-row-number-cell`.
119
122
  const gutter = tr.querySelector<HTMLTableCellElement>(
120
- '.sv-grid-cell.sv-row-gutter',
123
+ '.sv-grid-cell.sv-row-gutter, .sv-grid-cell.sv-grid-row-number-cell',
121
124
  )
122
125
  if (!gutter) continue
123
126
  if (gutter.querySelector(`:scope > .${STRIP_CLASS}`)) continue
@@ -310,22 +310,26 @@ describe('createSelection - onCellPointerDown', () => {
310
310
  expect(ctx.grid.setActiveCell).not.toHaveBeenCalled()
311
311
  })
312
312
 
313
- it('ignores checkbox columns (by editorType)', () => {
313
+ it('starts a drag on checkbox columns too (by editorType)', () => {
314
+ // A range drag must be able to START on a checkbox cell; the plain-click
315
+ // toggle still runs via onCellClick (same-cell click).
314
316
  const ctx = baseCtx({
315
317
  allColumns: [makeColumn('c0', { editorType: 'checkbox' })],
316
318
  })
317
319
  const sel = createSelection(ctx)
318
320
  sel.onCellPointerDown(0, 0, { button: 0, shiftKey: false } as any)
319
- expect(ctx.grid.setActiveCell).not.toHaveBeenCalled()
321
+ expect(ctx.grid.setActiveCell).toHaveBeenCalled()
322
+ expect(ctx.isDraggingSelection).toBe(true)
320
323
  })
321
324
 
322
- it('ignores boolean-valued cells', () => {
325
+ it('starts a drag on boolean-valued cells too', () => {
323
326
  const ctx = baseCtx({
324
327
  getCellDisplayValue: vi.fn(() => true),
325
328
  })
326
329
  const sel = createSelection(ctx)
327
330
  sel.onCellPointerDown(0, 0, { button: 0, shiftKey: false } as any)
328
- expect(ctx.grid.setActiveCell).not.toHaveBeenCalled()
331
+ expect(ctx.grid.setActiveCell).toHaveBeenCalled()
332
+ expect(ctx.isDraggingSelection).toBe(true)
329
333
  })
330
334
 
331
335
  it('plain click sets active + selection and starts a drag', () => {
@@ -720,3 +724,31 @@ describe('createSelection - scrollActiveCellIntoView', () => {
720
724
  expect(container.scrollLeft).toBe(320)
721
725
  })
722
726
  })
727
+
728
+ describe('createSelection - fillMarqueeEdges (Excel fill marquee)', () => {
729
+ // Union rectangle spans rows 1..4, cols 0..2 (source cols 0..1 extended to col 2,
730
+ // source rows 1..2 extended to row 4).
731
+ const fillDrag = {
732
+ sourceMinRow: 1, sourceMaxRow: 2, sourceMinCol: 0, sourceMaxCol: 1,
733
+ targetRow: 4, targetCol: 2,
734
+ }
735
+
736
+ it('returns null when there is no fill drag', () => {
737
+ const sel = createSelection(makeCtx())
738
+ expect(sel.fillMarqueeEdges(1, 0)).toBeNull()
739
+ })
740
+
741
+ it('flags the outer edges of the whole target rectangle', () => {
742
+ const sel = createSelection(makeCtx({ fillDrag }))
743
+ expect(sel.fillMarqueeEdges(1, 0)).toEqual({ top: true, bottom: false, left: true, right: false })
744
+ expect(sel.fillMarqueeEdges(4, 2)).toEqual({ top: false, bottom: true, left: false, right: true })
745
+ expect(sel.fillMarqueeEdges(1, 1)).toEqual({ top: true, bottom: false, left: false, right: false })
746
+ })
747
+
748
+ it('returns null for interior cells and cells outside the rectangle', () => {
749
+ const sel = createSelection(makeCtx({ fillDrag }))
750
+ expect(sel.fillMarqueeEdges(2, 1)).toBeNull() // interior
751
+ expect(sel.fillMarqueeEdges(0, 0)).toBeNull() // above
752
+ expect(sel.fillMarqueeEdges(5, 2)).toBeNull() // below
753
+ })
754
+ })
package/src/selection.ts CHANGED
@@ -396,6 +396,30 @@ export function createSelection<
396
396
  return !inSource;
397
397
  }
398
398
 
399
+ /**
400
+ * Which edges of the fill-drag rectangle a cell sits on, so the render can
401
+ * paint a single dashed marquee around the WHOLE target range (source +
402
+ * extension) - the Excel fill-handle look - instead of a thick per-cell
403
+ * border. Returns null when the cell isn't on the rectangle's boundary.
404
+ */
405
+ function fillMarqueeEdges(rowIndex: number, colIndex: number) {
406
+ const d = ctx.fillDrag;
407
+ if (!d) return null;
408
+ const minR = Math.min(d.sourceMinRow, d.targetRow);
409
+ const maxR = Math.max(d.sourceMaxRow, d.targetRow);
410
+ const minC = Math.min(d.sourceMinCol, d.targetCol);
411
+ const maxC = Math.max(d.sourceMaxCol, d.targetCol);
412
+ if (rowIndex < minR || rowIndex > maxR || colIndex < minC || colIndex > maxC) {
413
+ return null;
414
+ }
415
+ const top = rowIndex === minR;
416
+ const bottom = rowIndex === maxR;
417
+ const left = colIndex === minC;
418
+ const right = colIndex === maxC;
419
+ if (!top && !bottom && !left && !right) return null;
420
+ return { top, bottom, left, right };
421
+ }
422
+
399
423
  /** Look up a column by id without depending on `buildApi`'s private
400
424
  * closure (those helpers don't exist at this scope). */
401
425
  function findColumnById(columnId: string) {
@@ -411,15 +435,11 @@ export function createSelection<
411
435
  const row = ctx.allRows[rowIndex];
412
436
  const column = ctx.allColumns[colIndex];
413
437
  if (!row || !column || isGroupRow(row)) return;
414
- const cellValue = ctx.getCellDisplayValue(
415
- row.id,
416
- column.id,
417
- getColumnBaseValue(row, column),
418
- );
419
- const isCheckboxColumn =
420
- column.columnDef.editorType === "checkbox" ||
421
- typeof cellValue === "boolean";
422
- if (isCheckboxColumn) return; // let onCellClick toggle the checkbox
438
+ // NOTE: we no longer bail for checkbox columns. Bailing here meant a
439
+ // range drag could never START on a checkbox cell. A plain click still
440
+ // toggles the checkbox via onCellClick (a same-cell click), while a
441
+ // cross-cell drag fires its `click` on a common ancestor - not the cell -
442
+ // so it selects a range without toggling.
423
443
 
424
444
  const active = ctx.grid.getState().activeCell;
425
445
  ctx.activeAtPointerDown = active
@@ -566,6 +586,7 @@ export function createSelection<
566
586
  extendSelection,
567
587
  isCellInSelectedRange,
568
588
  getCellRangeEdges,
589
+ fillMarqueeEdges,
569
590
  getSelectionRects,
570
591
  isInFillPreview,
571
592
  findColumnById,
@@ -36,6 +36,17 @@ describe('createServerDataSource', () => {
36
36
  expect(states.some((s) => s.loading)).toBe(true)
37
37
  })
38
38
 
39
+ it('dispose() during an in-flight fetch clears the loading state (#83)', async () => {
40
+ const src = makeSource(250, 20) // 20ms delay keeps the fetch in-flight
41
+ const ctl = createServerDataSource(src, { pageSize: 50, onChange: () => {} })
42
+ ctl.refresh()
43
+ expect(ctl.getState().loading).toBe(true)
44
+ ctl.dispose()
45
+ expect(ctl.getState().loading).toBe(false)
46
+ await new Promise((r) => setTimeout(r, 30)) // let the fetch settle
47
+ expect(ctl.getState().loading).toBe(false) // stays cleared, not stuck
48
+ })
49
+
39
50
  it('setPage fetches the right slice', async () => {
40
51
  const src = makeSource(250)
41
52
  const ctl = createServerDataSource(src, { pageSize: 50, onChange: () => {} })
@@ -86,3 +97,193 @@ describe('createServerDataSource', () => {
86
97
  expect(ctl.getState().rows).toHaveLength(0)
87
98
  })
88
99
  })
100
+
101
+ describe('createServerDataSource mutations', () => {
102
+ type URow = { id: number; name: string }
103
+
104
+ function writable() {
105
+ let store: URow[] = [
106
+ { id: 0, name: 'a' },
107
+ { id: 1, name: 'b' },
108
+ { id: 2, name: 'c' },
109
+ ]
110
+ return {
111
+ getRows: vi.fn(async (req: { startRow: number; endRow: number }) => ({
112
+ rows: store.slice(req.startRow, req.endRow),
113
+ rowCount: store.length,
114
+ })),
115
+ createRow: vi.fn(async (input: Partial<URow>) => {
116
+ const row: URow = { id: store.length, name: input.name ?? '' }
117
+ store = [...store, row]
118
+ return row
119
+ }),
120
+ updateRow: vi.fn(async (id: string, patch: Partial<URow>) => {
121
+ store = store.map((r) => (String(r.id) === id ? { ...r, ...patch } : r))
122
+ return store.find((r) => String(r.id) === id)!
123
+ }),
124
+ deleteRow: vi.fn(async (id: string) => {
125
+ store = store.filter((r) => String(r.id) !== id)
126
+ }),
127
+ } as unknown as ServerDataSource<URow> & {
128
+ getRows: ReturnType<typeof vi.fn>
129
+ createRow: ReturnType<typeof vi.fn>
130
+ updateRow: ReturnType<typeof vi.fn>
131
+ deleteRow: ReturnType<typeof vi.fn>
132
+ }
133
+ }
134
+
135
+ it('createRow writes through and refreshes the current page', async () => {
136
+ const src = writable()
137
+ const ctl = createServerDataSource(src, { pageSize: 50, onChange: () => {} })
138
+ ctl.refresh()
139
+ await flush()
140
+ const created = await ctl.createRow({ name: 'z' })
141
+ expect(created).toEqual({ id: 3, name: 'z' })
142
+ expect(src.createRow).toHaveBeenCalledWith({ name: 'z' })
143
+ // the follow-up refresh re-fetched, so total + rows reflect the write
144
+ expect(ctl.getState().total).toBe(4)
145
+ expect(ctl.getState().rows.some((r) => r.name === 'z')).toBe(true)
146
+ })
147
+
148
+ it('toggles `saving` around a mutation', async () => {
149
+ const src = writable()
150
+ const states: Array<{ saving: boolean }> = []
151
+ const ctl = createServerDataSource(src, { pageSize: 50, onChange: (s) => states.push(s) })
152
+ const p = ctl.createRow({ name: 'x' })
153
+ expect(ctl.getState().saving).toBe(true) // synchronous emit before the await
154
+ await p
155
+ expect(ctl.getState().saving).toBe(false)
156
+ expect(states.some((s) => s.saving)).toBe(true)
157
+ })
158
+
159
+ it('updateRow and deleteRow call through and refresh', async () => {
160
+ const src = writable()
161
+ const ctl = createServerDataSource(src, { pageSize: 50, onChange: () => {} })
162
+ ctl.refresh()
163
+ await flush()
164
+ const updated = await ctl.updateRow('1', { name: 'B!' })
165
+ expect(updated).toEqual({ id: 1, name: 'B!' })
166
+ expect(src.updateRow).toHaveBeenCalledWith('1', { name: 'B!' })
167
+ expect(ctl.getState().rows.find((r) => r.id === 1)?.name).toBe('B!')
168
+
169
+ await ctl.deleteRow('0')
170
+ expect(src.deleteRow).toHaveBeenCalledWith('0')
171
+ expect(ctl.getState().total).toBe(2)
172
+ expect(ctl.getState().rows.some((r) => r.id === 0)).toBe(false)
173
+ })
174
+
175
+ it('rejects when the source has no matching writer', async () => {
176
+ const readonly: ServerDataSource<URow> = {
177
+ getRows: async () => ({ rows: [], rowCount: 0 }),
178
+ }
179
+ const ctl = createServerDataSource(readonly, { onChange: () => {} })
180
+ await expect(ctl.createRow({ name: 'x' })).rejects.toThrow(/does not implement createRow/)
181
+ await expect(ctl.updateRow('1', {})).rejects.toThrow(/does not implement updateRow/)
182
+ await expect(ctl.deleteRow('1')).rejects.toThrow(/does not implement deleteRow/)
183
+ })
184
+
185
+ it('rejects mutations after dispose, without calling the source', async () => {
186
+ const src = writable()
187
+ const ctl = createServerDataSource(src, { onChange: () => {} })
188
+ ctl.dispose()
189
+ await expect(ctl.deleteRow('0')).rejects.toThrow(/disposed/)
190
+ expect(src.deleteRow).not.toHaveBeenCalled()
191
+ })
192
+
193
+ it('leaves read state untouched when a mutation fails', async () => {
194
+ const src = writable()
195
+ src.createRow.mockRejectedValueOnce(new Error('boom'))
196
+ const ctl = createServerDataSource(src, { pageSize: 50, onChange: () => {} })
197
+ ctl.refresh()
198
+ await flush()
199
+ await expect(ctl.createRow({ name: 'x' })).rejects.toThrow('boom')
200
+ expect(ctl.getState().saving).toBe(false)
201
+ expect(ctl.getState().total).toBe(3) // unchanged; no refresh on failure
202
+ })
203
+ })
204
+
205
+ describe('createServerDataSource optimistic mutations', () => {
206
+ type URow = { id: number; name: string }
207
+ const opts = (onChange: (s: any) => void = () => {}) => ({
208
+ pageSize: 50,
209
+ optimistic: true,
210
+ getRowId: (r: URow) => String(r.id),
211
+ onChange,
212
+ })
213
+
214
+ function writable() {
215
+ let store: URow[] = [
216
+ { id: 0, name: 'a' },
217
+ { id: 1, name: 'b' },
218
+ { id: 2, name: 'c' },
219
+ ]
220
+ return {
221
+ getRows: vi.fn(async (req: { startRow: number; endRow: number }) => ({
222
+ rows: store.slice(req.startRow, req.endRow),
223
+ rowCount: store.length,
224
+ })),
225
+ updateRow: vi.fn(async (id: string, patch: Partial<URow>) => {
226
+ store = store.map((r) => (String(r.id) === id ? { ...r, ...patch } : r))
227
+ return store.find((r) => String(r.id) === id)!
228
+ }),
229
+ deleteRow: vi.fn(async (id: string) => {
230
+ store = store.filter((r) => String(r.id) !== id)
231
+ }),
232
+ } as any
233
+ }
234
+
235
+ it('applies an update to local rows immediately, before the server resolves', async () => {
236
+ const src = writable()
237
+ src.updateRow.mockImplementationOnce(
238
+ (id: string, patch: any) => new Promise((r) => setTimeout(() => r({ id: Number(id), ...patch }), 30)),
239
+ )
240
+ const ctl = createServerDataSource(src, opts())
241
+ ctl.refresh()
242
+ await flush()
243
+ const p = ctl.updateRow('1', { name: 'B!' })
244
+ // synchronously reflected, no refetch
245
+ expect(ctl.getState().rows.find((r) => r.id === 1)?.name).toBe('B!')
246
+ expect(ctl.getState().saving).toBe(true)
247
+ await p
248
+ expect(ctl.getState().saving).toBe(false)
249
+ // getRows only called once (the initial refresh) - no refetch for the update
250
+ expect(src.getRows).toHaveBeenCalledTimes(1)
251
+ })
252
+
253
+ it('rolls an update back on error', async () => {
254
+ const src = writable()
255
+ src.updateRow.mockRejectedValueOnce(new Error('nope'))
256
+ const ctl = createServerDataSource(src, opts())
257
+ ctl.refresh()
258
+ await flush()
259
+ await expect(ctl.updateRow('1', { name: 'B!' })).rejects.toThrow('nope')
260
+ expect(ctl.getState().rows.find((r) => r.id === 1)?.name).toBe('b') // restored
261
+ })
262
+
263
+ it('removes a row immediately on delete and decrements total, restoring on error', async () => {
264
+ const src = writable()
265
+ const ctl = createServerDataSource(src, opts())
266
+ ctl.refresh()
267
+ await flush()
268
+
269
+ const p = ctl.deleteRow('0')
270
+ expect(ctl.getState().rows.some((r) => r.id === 0)).toBe(false)
271
+ expect(ctl.getState().total).toBe(2)
272
+ await p
273
+
274
+ src.deleteRow.mockRejectedValueOnce(new Error('locked'))
275
+ await expect(ctl.deleteRow('1')).rejects.toThrow('locked')
276
+ expect(ctl.getState().rows.some((r) => r.id === 1)).toBe(true) // restored
277
+ expect(ctl.getState().total).toBe(2)
278
+ })
279
+
280
+ it('falls back to a refetch when the row is not on the current page', async () => {
281
+ const src = writable()
282
+ const ctl = createServerDataSource(src, opts())
283
+ ctl.refresh()
284
+ await flush()
285
+ await ctl.updateRow('999', { name: 'ghost' }) // not present locally
286
+ // fell back to mutate() which refreshes: getRows called again
287
+ expect(src.getRows).toHaveBeenCalledTimes(2)
288
+ })
289
+ })