@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
@@ -0,0 +1,210 @@
1
+ <script lang="ts" module>
2
+ export type TreeNode = {
3
+ id: string
4
+ label: string
5
+ children?: TreeNode[]
6
+ disabled?: boolean
7
+ }
8
+ </script>
9
+
10
+ <script lang="ts">
11
+ /**
12
+ * SvTree - a WAI-ARIA tree view. Expand/collapse, single-select highlight, and
13
+ * optional cascading tri-state checkboxes. Parity: Smart `smart-tree`. Keyboard:
14
+ * up/down move, left collapse/parent, right expand/child, Enter/Space select.
15
+ * The visible tree is flattened so roving-focus keyboard nav stays simple.
16
+ */
17
+ type Props = {
18
+ nodes: ReadonlyArray<TreeNode>
19
+ /** Selected node id (single-select highlight). */
20
+ selected?: string | null
21
+ onSelect?: (id: string) => void
22
+ /** Initially/controlled expanded ids. */
23
+ expandedIds?: string[]
24
+ onToggle?: (id: string, expanded: boolean) => void
25
+ /** Show cascading checkboxes; `checked` is the set of checked ids. */
26
+ checkable?: boolean
27
+ checked?: string[]
28
+ onCheck?: (ids: string[]) => void
29
+ ariaLabel?: string
30
+ }
31
+
32
+ let {
33
+ nodes,
34
+ selected = null,
35
+ onSelect,
36
+ expandedIds,
37
+ onToggle,
38
+ checkable = false,
39
+ checked = [],
40
+ onCheck,
41
+ ariaLabel,
42
+ }: Props = $props()
43
+
44
+ // Internal expanded set, seeded from the prop once (then self-managed unless
45
+ // the prop identity changes).
46
+ let expanded = $state<Set<string>>(new Set())
47
+ let seededExp = false
48
+ $effect(() => {
49
+ if (!seededExp) { seededExp = true; if (expandedIds) expanded = new Set(expandedIds) }
50
+ })
51
+
52
+ type Flat = { node: TreeNode; depth: number; hasChildren: boolean; open: boolean; parentId: string | null }
53
+
54
+ const flat = $derived.by<Flat[]>(() => {
55
+ const out: Flat[] = []
56
+ const walk = (list: ReadonlyArray<TreeNode>, depth: number, parentId: string | null) => {
57
+ for (const node of list) {
58
+ const hasChildren = !!node.children?.length
59
+ const open = expanded.has(node.id)
60
+ out.push({ node, depth, hasChildren, open, parentId })
61
+ if (hasChildren && open) walk(node.children!, depth + 1, node.id)
62
+ }
63
+ }
64
+ walk(nodes, 0, null)
65
+ return out
66
+ })
67
+
68
+ let active = $state(0)
69
+ $effect(() => { if (active >= flat.length) active = Math.max(0, flat.length - 1) })
70
+
71
+ const checkedSet = $derived(new Set(checked))
72
+
73
+ function descendantIds(node: TreeNode): string[] {
74
+ const ids: string[] = []
75
+ const walk = (n: TreeNode) => { for (const c of n.children ?? []) { ids.push(c.id); walk(c) } }
76
+ walk(node)
77
+ return ids
78
+ }
79
+ function checkState(node: TreeNode): 'checked' | 'indeterminate' | 'unchecked' {
80
+ if (!node.children?.length) return checkedSet.has(node.id) ? 'checked' : 'unchecked'
81
+ const desc = descendantIds(node)
82
+ const on = desc.filter((id) => checkedSet.has(id)).length
83
+ if (on === 0 && !checkedSet.has(node.id)) return 'unchecked'
84
+ if (on === desc.length) return 'checked'
85
+ return 'indeterminate'
86
+ }
87
+
88
+ function toggleExpand(node: TreeNode) {
89
+ if (!node.children?.length) return
90
+ const next = new Set(expanded)
91
+ const willOpen = !next.has(node.id)
92
+ willOpen ? next.add(node.id) : next.delete(node.id)
93
+ expanded = next
94
+ onToggle?.(node.id, willOpen)
95
+ }
96
+ function select(node: TreeNode) {
97
+ if (node.disabled) return
98
+ onSelect?.(node.id)
99
+ }
100
+ function toggleCheck(node: TreeNode) {
101
+ if (node.disabled) return
102
+ const next = new Set(checkedSet)
103
+ const ids = [node.id, ...descendantIds(node)]
104
+ const currentlyOn = checkState(node) === 'checked'
105
+ for (const id of ids) currentlyOn ? next.delete(id) : next.add(id)
106
+ onCheck?.([...next])
107
+ }
108
+
109
+ function onKeydown(e: KeyboardEvent) {
110
+ const item = flat[active]
111
+ if (!item) return
112
+ switch (e.key) {
113
+ case 'ArrowDown': e.preventDefault(); active = Math.min(active + 1, flat.length - 1); focusActive(); break
114
+ case 'ArrowUp': e.preventDefault(); active = Math.max(active - 1, 0); focusActive(); break
115
+ case 'ArrowRight':
116
+ e.preventDefault()
117
+ if (item.hasChildren && !item.open) toggleExpand(item.node)
118
+ else if (item.hasChildren && item.open) { active = Math.min(active + 1, flat.length - 1); focusActive() }
119
+ break
120
+ case 'ArrowLeft':
121
+ e.preventDefault()
122
+ if (item.hasChildren && item.open) toggleExpand(item.node)
123
+ else if (item.parentId) { const pi = flat.findIndex((f) => f.node.id === item.parentId); if (pi >= 0) { active = pi; focusActive() } }
124
+ break
125
+ case 'Enter': e.preventDefault(); select(item.node); break
126
+ case ' ': e.preventDefault(); checkable ? toggleCheck(item.node) : select(item.node); break
127
+ case 'Home': e.preventDefault(); active = 0; focusActive(); break
128
+ case 'End': e.preventDefault(); active = flat.length - 1; focusActive(); break
129
+ }
130
+ }
131
+
132
+ let treeEl: HTMLDivElement | null = null
133
+ function focusActive() {
134
+ queueMicrotask(() => treeEl?.querySelector<HTMLElement>(`[data-row="${active}"]`)?.focus())
135
+ }
136
+ </script>
137
+
138
+ <div bind:this={treeEl} class="sv-tree" role="tree" aria-label={ariaLabel} aria-multiselectable={checkable}>
139
+ {#each flat as item, i (item.node.id)}
140
+ {@const cs = checkable ? checkState(item.node) : 'unchecked'}
141
+ <div
142
+ class="sv-tree__row"
143
+ class:is-selected={item.node.id === selected}
144
+ class:is-disabled={item.node.disabled}
145
+ role="treeitem"
146
+ aria-level={item.depth + 1}
147
+ aria-selected={item.node.id === selected}
148
+ aria-expanded={item.hasChildren ? item.open : undefined}
149
+ aria-checked={checkable ? cs === 'checked' ? 'true' : cs === 'indeterminate' ? 'mixed' : 'false' : undefined}
150
+ data-row={i}
151
+ tabindex={i === active ? 0 : -1}
152
+ style:padding-left={`${item.depth * 18 + 6}px`}
153
+ onclick={() => { active = i; select(item.node) }}
154
+ onkeydown={onKeydown}
155
+ >
156
+ <button
157
+ type="button"
158
+ class="sv-tree__twist"
159
+ class:is-open={item.open}
160
+ class:is-leaf={!item.hasChildren}
161
+ tabindex="-1"
162
+ aria-hidden={!item.hasChildren}
163
+ onclick={(e) => { e.stopPropagation(); toggleExpand(item.node) }}
164
+ >
165
+ {#if item.hasChildren}
166
+ <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6" /></svg>
167
+ {/if}
168
+ </button>
169
+ {#if checkable}
170
+ <button type="button" class="sv-tree__check is-{cs}" tabindex="-1" aria-hidden="true" onclick={(e) => { e.stopPropagation(); toggleCheck(item.node) }}>
171
+ {#if cs === 'checked'}<svg viewBox="0 0 16 16"><path d="M3.5 8.5l3 3 6-6.5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" /></svg>
172
+ {:else if cs === 'indeterminate'}<svg viewBox="0 0 16 16"><path d="M4 8h8" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" /></svg>{/if}
173
+ </button>
174
+ {/if}
175
+ <span class="sv-tree__label">{item.node.label}</span>
176
+ </div>
177
+ {/each}
178
+ </div>
179
+
180
+ <style>
181
+ .sv-tree {
182
+ --_accent: var(--sg-accent, #2563eb);
183
+ width: 260px; padding: 4px; user-select: none;
184
+ background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a);
185
+ border: 1px solid var(--sg-border, #e2e8f0); border-radius: var(--sg-radius, 8px);
186
+ font-size: 13px;
187
+ }
188
+ .sv-tree__row {
189
+ display: flex; align-items: center; gap: 4px; height: 30px; padding-right: 8px;
190
+ border-radius: 6px; cursor: pointer; outline: none;
191
+ }
192
+ .sv-tree__row:hover:not(.is-disabled) { background: var(--sg-row-hover-bg, #f1f5f9); }
193
+ .sv-tree__row.is-selected { background: color-mix(in srgb, var(--_accent) 15%, transparent); color: var(--_accent); font-weight: 600; }
194
+ .sv-tree__row.is-disabled { opacity: 0.45; cursor: not-allowed; }
195
+ .sv-tree__row:focus-visible { box-shadow: inset 0 0 0 2px var(--sg-focus-ring, var(--_accent)); }
196
+ .sv-tree__twist {
197
+ display: grid; place-items: center; width: 18px; height: 18px; flex: none;
198
+ background: none; border: 0; color: var(--sg-muted, #64748b); cursor: pointer; transition: transform 0.12s;
199
+ }
200
+ .sv-tree__twist.is-open { transform: rotate(90deg); }
201
+ .sv-tree__twist.is-leaf { visibility: hidden; }
202
+ .sv-tree__check {
203
+ display: grid; place-items: center; width: 16px; height: 16px; flex: none; padding: 0;
204
+ border: 1.5px solid var(--sg-border, #cbd5e1); border-radius: 4px; background: var(--sg-input-bg, #fff);
205
+ color: var(--sg-on-accent, #fff); cursor: pointer;
206
+ }
207
+ .sv-tree__check.is-checked, .sv-tree__check.is-indeterminate { background: var(--_accent); border-color: var(--_accent); }
208
+ .sv-tree__check svg { width: 100%; height: 100%; }
209
+ .sv-tree__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
210
+ </style>
package/src/build-api.ts CHANGED
@@ -47,6 +47,18 @@ import {
47
47
  getDateFormatter,
48
48
  resolveDatePattern,
49
49
  } from "./cell-formatting";
50
+ import {
51
+ projectGridRows,
52
+ serializeDelimited,
53
+ serializeJson,
54
+ serializeMarkdown,
55
+ downloadTextFile,
56
+ copyTextToClipboard,
57
+ type GridExportColumn,
58
+ type GridExportScope,
59
+ type GridExportOptions,
60
+ type GridClipboardOptions,
61
+ } from "./export-format";
50
62
  import {
51
63
  RenderSnippetConfig,
52
64
  RenderComponentConfig,
@@ -128,6 +140,42 @@ export function createGridApi<
128
140
  function buildApi(): SvGridApi<TFeatures, TData> {
129
141
  const findColumn = (columnId: string) =>
130
142
  ctx.grid.getAllColumns().find((column: any) => column.id === columnId);
143
+
144
+ // --- Free data export (CSV / TSV / JSON + clipboard) helpers ----------
145
+ // Snapshot the VISIBLE columns (field + label + format + align) so the
146
+ // exporter reproduces on-screen values. Mirrors getColumns()'s visible set.
147
+ const exportColumns = (): GridExportColumn[] =>
148
+ ctx.allColumns
149
+ .map((c: any) => ({
150
+ field: (c.columnDef as { field?: string }).field as string,
151
+ header:
152
+ typeof c.columnDef.header === "string" ? c.columnDef.header : c.id,
153
+ format: c.columnDef.format,
154
+ align: getColumnAlign(c),
155
+ }))
156
+ .filter((c: GridExportColumn) => !!c.field);
157
+ // Rows for the requested scope: current view / checked / full dataset.
158
+ const exportScopeRows = (scope: GridExportScope): TData[] => {
159
+ if (scope === "all") return ctx.internalData as TData[];
160
+ const out: TData[] = [];
161
+ for (const row of ctx.allRows) {
162
+ if (isGroupRow(row)) continue;
163
+ if (scope === "selected" && !ctx.rowSelectionState[row.id]) continue;
164
+ out.push(row.original as TData);
165
+ }
166
+ return out;
167
+ };
168
+ const projectForExport = (
169
+ scope: GridExportScope,
170
+ columns: ReadonlyArray<string> | undefined,
171
+ rawValues: boolean | undefined,
172
+ ) =>
173
+ projectGridRows(
174
+ exportScopeRows(scope) as ReadonlyArray<Record<string, unknown>>,
175
+ exportColumns(),
176
+ { columns, rawValues },
177
+ );
178
+
131
179
  return {
132
180
  getCellValue(rowIndex, columnId) {
133
181
  const row = ctx.internalData[rowIndex];
@@ -409,6 +457,12 @@ export function createGridApi<
409
457
  ? c.columnDef.header
410
458
  : c.id,
411
459
  visible,
460
+ // Surface the format + alignment so exporters can reproduce the
461
+ // on-screen display value (currency symbol, date pattern, etc.)
462
+ // instead of dumping raw values. `getColumnAlign` applies the same
463
+ // editorType-based default the body uses.
464
+ format: c.columnDef.format,
465
+ align: getColumnAlign(c),
412
466
  });
413
467
  // Visible columns first, in their current visual order...
414
468
  const out = ctx.allColumns.map((c: any) => describe(c, true));
@@ -421,6 +475,67 @@ export function createGridApi<
421
475
  }
422
476
  return out;
423
477
  },
478
+ // ---- Free data export (CSV / TSV / JSON + clipboard) -----------------
479
+ async exportCsv(options: GridExportOptions = {}) {
480
+ const { records, fields } = projectForExport(
481
+ options.rows ?? "displayed",
482
+ options.columns,
483
+ options.rawValues,
484
+ );
485
+ const text = await serializeDelimited(records, fields, {
486
+ csv: { delimiter: ",", bom: options.bom ?? true },
487
+ signal: options.signal,
488
+ onProgress: options.onProgress,
489
+ });
490
+ if (options.download !== false)
491
+ downloadTextFile(text, `${options.filename ?? "grid"}.csv`, "text/csv;charset=utf-8");
492
+ return text;
493
+ },
494
+ async exportTsv(options: GridExportOptions = {}) {
495
+ const { records, fields } = projectForExport(
496
+ options.rows ?? "displayed",
497
+ options.columns,
498
+ options.rawValues,
499
+ );
500
+ const text = await serializeDelimited(records, fields, {
501
+ csv: { delimiter: "\t", bom: options.bom ?? true },
502
+ signal: options.signal,
503
+ onProgress: options.onProgress,
504
+ });
505
+ if (options.download !== false)
506
+ downloadTextFile(text, `${options.filename ?? "grid"}.tsv`, "text/tab-separated-values;charset=utf-8");
507
+ return text;
508
+ },
509
+ async exportJson(options: GridExportOptions = {}) {
510
+ const { records, fields } = projectForExport(
511
+ options.rows ?? "displayed",
512
+ options.columns,
513
+ options.rawValues,
514
+ );
515
+ const text = await serializeJson(records, fields, {
516
+ signal: options.signal,
517
+ onProgress: options.onProgress,
518
+ });
519
+ if (options.download !== false)
520
+ downloadTextFile(text, `${options.filename ?? "grid"}.json`, "application/json");
521
+ return text;
522
+ },
523
+ async copyToClipboard(options: GridClipboardOptions = {}) {
524
+ const { records, fields, align } = projectForExport(
525
+ options.rows ?? "displayed",
526
+ options.columns,
527
+ options.rawValues,
528
+ );
529
+ const format = options.format ?? "tsv";
530
+ const text =
531
+ format === "markdown"
532
+ ? await serializeMarkdown(records, fields, { align })
533
+ : await serializeDelimited(records, fields, {
534
+ csv: { delimiter: format === "csv" ? "," : "\t", bom: false },
535
+ });
536
+ await copyTextToClipboard(text);
537
+ return text;
538
+ },
424
539
  clearRowSelection() {
425
540
  // Wipe the internal selection map AND emit the change so any
426
541
  // consumer holding a derived `selectedRows` resets too.
@@ -81,6 +81,44 @@ describe('createCellRender / cellConditionalFormat', () => {
81
81
  })
82
82
  })
83
83
 
84
+ describe('createCellRender / computeCellValidity', () => {
85
+ const validateSalary = ({ value }: { value: unknown }) =>
86
+ Number(value) >= 1000 ? true : 'Salary must be at least $1,000'
87
+
88
+ it('returns valid when no validate hook is configured', () => {
89
+ const cr = createCellRender(makeCtx({ editedCellValues: {} }))
90
+ const out = cr.computeCellValidity(makeRow('r', { salary: 5 }), makeColumn('salary'))
91
+ expect(out).toEqual({ invalid: false, message: null })
92
+ })
93
+
94
+ it('flags bad base data on load (message becomes the tooltip)', () => {
95
+ const cr = createCellRender(makeCtx({ editedCellValues: {} }))
96
+ const col = makeColumn('salary', { validate: validateSalary })
97
+ const out = cr.computeCellValidity(makeRow('r3', { salary: -5000 }), col)
98
+ expect(out).toEqual({ invalid: true, message: 'Salary must be at least $1,000' })
99
+ })
100
+
101
+ it('re-validates against the edit overlay, not the stale cached base value', () => {
102
+ // The row still reports the old (bad) value via getCellValueByColumnId -
103
+ // inline edits are not written into the row's memoized value cache. The
104
+ // edited value lives in editedCellValues keyed rowId:colId; validity must
105
+ // read THAT so a cell clears/lights up live as the user types.
106
+ const col = makeColumn('salary', { validate: validateSalary })
107
+
108
+ const fixed = createCellRender(makeCtx({ editedCellValues: { 'r3:salary': 85000 } }))
109
+ expect(fixed.computeCellValidity(makeRow('r3', { salary: -5000 }), col)).toEqual({
110
+ invalid: false,
111
+ message: null,
112
+ })
113
+
114
+ const broken = createCellRender(makeCtx({ editedCellValues: { 'r1:salary': 5 } }))
115
+ expect(broken.computeCellValidity(makeRow('r1', { salary: 85000 }), col)).toEqual({
116
+ invalid: true,
117
+ message: 'Salary must be at least $1,000',
118
+ })
119
+ })
120
+ })
121
+
84
122
  describe('createCellRender / computeRowClass', () => {
85
123
  it('returns empty string when no rowClass configured', () => {
86
124
  const cr = createCellRender(makeCtx())
@@ -201,6 +201,48 @@ export function createCellRender<
201
201
  return null
202
202
  }
203
203
 
204
+ /**
205
+ * Resolve per-cell validity via the column's declarative `validate` hook.
206
+ * Runs for every rendered cell so pre-existing bad data is flagged on load.
207
+ * Returns `{ invalid, message }` - `invalid` drives the red highlight class,
208
+ * `message` (when present) becomes the cell's tooltip.
209
+ */
210
+ function computeCellValidity(
211
+ row: Row<TData>,
212
+ column: Column<TData>,
213
+ ): { invalid: boolean; message: string | null } {
214
+ const rule = column.columnDef.validate as
215
+ | ((params: {
216
+ value: unknown
217
+ row: TData
218
+ rowIndex: number
219
+ column: Column<TData>
220
+ }) => string | boolean | null | undefined)
221
+ | undefined
222
+ if (typeof rule !== "function") return { invalid: false, message: null }
223
+ // Read the edit-aware value: inline edits land in `editedCellValues`
224
+ // (keyed rowId:colId) and are NOT written into the row's memoized value
225
+ // cache, so `getCellValueByColumnId` would return the stale pre-edit value
226
+ // and validation would never re-run after an edit. Consult the overlay
227
+ // first - same rule the display path (getCellDisplayValue) and summaries
228
+ // use - so a cell re-validates live as the user types.
229
+ const edited = ctx.editedCellValues ?? {}
230
+ const key = getCellKey(row.id, column.id)
231
+ const value =
232
+ key in edited ? edited[key] : row.getCellValueByColumnId(column.id)
233
+ const out = rule({
234
+ value,
235
+ row: row.original as TData,
236
+ rowIndex: row.index,
237
+ column,
238
+ })
239
+ // Valid: null / undefined / true. Invalid: false or a message string.
240
+ if (out == null || out === true) return { invalid: false, message: null }
241
+ if (out === false) return { invalid: true, message: null }
242
+ const msg = String(out)
243
+ return { invalid: true, message: msg.trim() ? msg : null }
244
+ }
245
+
204
246
  /**
205
247
  * Resolve a per-cell note (a longer comment / annotation). Notes
206
248
  * come from the grid's `notes` prop - a `{ [rowId]: { [columnId]: string } }`
@@ -416,6 +458,7 @@ export function createCellRender<
416
458
  computeRowClass,
417
459
  computeCellClass,
418
460
  computeCellTooltip,
461
+ computeCellValidity,
419
462
  computeCellNote,
420
463
  getColumnEditorOptions,
421
464
  formatListCellValue,
@@ -668,6 +668,24 @@ describe('clearSelectedCells', () => {
668
668
  expect(ctx.grid.store.setState).toHaveBeenCalled()
669
669
  })
670
670
 
671
+ it('fires onCellValueChange per cleared cell (so formula engines recompute)', () => {
672
+ const onCellValueChange = vi.fn()
673
+ const ctx = makeCtx({
674
+ columns: [{ id: 'a', field: 'a', editable: true, editorType: 'text' }],
675
+ data: [{ a: 'hello' }],
676
+ })
677
+ ctx.props.onCellValueChange = onCellValueChange
678
+ ctx.selectionRange = {
679
+ anchor: { rowIndex: 0, colIndex: 0 },
680
+ focus: { rowIndex: 0, colIndex: 0 },
681
+ }
682
+ const cb = createClipboard(ctx)
683
+ cb.clearSelectedCells()
684
+ expect(onCellValueChange).toHaveBeenCalledWith(
685
+ expect.objectContaining({ rowIndex: 0, columnId: 'a', oldValue: 'hello', newValue: '' }),
686
+ )
687
+ })
688
+
671
689
  it('clears a number cell via parseEditorValue coercion', () => {
672
690
  const ctx = makeCtx({
673
691
  columns: [{ id: 'a', field: 'a', editable: true, editorType: 'number' }],
package/src/clipboard.ts CHANGED
@@ -488,6 +488,13 @@ export function createClipboard<
488
488
 
489
489
  const next = ctx.internalData.slice() as Array<TData>;
490
490
  let mutated = false;
491
+ const changes: Array<{
492
+ rowIndex: number
493
+ columnId: string
494
+ oldValue: unknown
495
+ newValue: unknown
496
+ row: TData
497
+ }> = [];
491
498
  for (let r = startRow; r <= endRow; r += 1) {
492
499
  const row = ctx.allRows[r];
493
500
  if (!row || isGroupRow(row)) continue;
@@ -511,8 +518,18 @@ export function createClipboard<
511
518
  // else. parseEditorValue handles the per-type coercion.
512
519
  const editorType = (column.columnDef.editorType ??
513
520
  "text") as CellEditorType;
514
- updated[column.columnDef.field] = parseEditorValue(editorType, "");
521
+ const field = column.columnDef.field;
522
+ const oldValue = updated[field];
523
+ const cleared = parseEditorValue(editorType, "");
524
+ updated[field] = cleared;
515
525
  rowChanged = true;
526
+ changes.push({
527
+ rowIndex: dataIndex,
528
+ columnId: column.id,
529
+ oldValue,
530
+ newValue: cleared,
531
+ row: updated as TData,
532
+ });
516
533
  }
517
534
  if (rowChanged) {
518
535
  next[dataIndex] = updated as TData;
@@ -522,6 +539,12 @@ export function createClipboard<
522
539
  if (mutated) {
523
540
  ctx.internalData = next;
524
541
  ctx.grid.store.setState((prev: any) => ({ ...prev }));
542
+ // Fire onCellValueChange per cleared cell - same as paste's writeCellRaw -
543
+ // so consumers (formula engines, autosave) recompute. Clear IS a value
544
+ // change; without this, a HyperFormula-backed grid wouldn't re-evaluate.
545
+ if (ctx.props.onCellValueChange) {
546
+ for (const ch of changes) ctx.props.onCellValueChange(ch);
547
+ }
525
548
  }
526
549
  return mutated;
527
550
  }
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it, vi } from 'vitest'
2
2
  import {
3
+ broadcastChannelTransport,
3
4
  createCollaboration,
4
5
  type CollabMessage,
5
6
  type CollabTransport,
@@ -37,6 +38,35 @@ describe('createCollaboration', () => {
37
38
  b.dispose()
38
39
  })
39
40
 
41
+ it('closes the transport channel on dispose (#82)', () => {
42
+ // The controller now forwards dispose to the transport...
43
+ const disposeSpy = vi.fn()
44
+ const collab = createCollaboration({
45
+ user: userA,
46
+ transport: { post: vi.fn(), subscribe: () => () => {}, dispose: disposeSpy },
47
+ })
48
+ collab.dispose()
49
+ expect(disposeSpy).toHaveBeenCalledTimes(1)
50
+
51
+ // ...and broadcastChannelTransport.dispose() closes the BroadcastChannel.
52
+ const close = vi.fn()
53
+ class FakeBroadcastChannel {
54
+ postMessage = vi.fn()
55
+ addEventListener = vi.fn()
56
+ removeEventListener = vi.fn()
57
+ close = close
58
+ constructor(public name: string) {}
59
+ }
60
+ const Orig = (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel
61
+ ;(globalThis as { BroadcastChannel?: unknown }).BroadcastChannel = FakeBroadcastChannel
62
+ try {
63
+ broadcastChannelTransport('room').dispose?.()
64
+ expect(close).toHaveBeenCalledTimes(1)
65
+ } finally {
66
+ ;(globalThis as { BroadcastChannel?: unknown }).BroadcastChannel = Orig
67
+ }
68
+ })
69
+
40
70
  it('setCell propagates a remote cursor', () => {
41
71
  const bus = makeBus()
42
72
  const onPeers = vi.fn()
@@ -32,6 +32,8 @@ export type CollabMessage =
32
32
  export type CollabTransport = {
33
33
  post(msg: CollabMessage): void
34
34
  subscribe(handler: (msg: CollabMessage) => void): () => void
35
+ /** Release any underlying resource (e.g. a BroadcastChannel). Optional. */
36
+ dispose?(): void
35
37
  }
36
38
 
37
39
  export type Collaboration = {
@@ -75,6 +77,9 @@ export function broadcastChannelTransport(name: string): CollabTransport {
75
77
  channel.addEventListener('message', listener)
76
78
  return () => channel.removeEventListener('message', listener)
77
79
  },
80
+ dispose() {
81
+ channel?.close()
82
+ },
78
83
  }
79
84
  }
80
85
 
@@ -156,6 +161,7 @@ export function createCollaboration(options: CollaborationOptions): Collaboratio
156
161
  transport.post({ kind: 'bye', userId: user.id })
157
162
  unsubscribe()
158
163
  if (pruneTimer) clearInterval(pruneTimer)
164
+ transport.dispose?.() // release the channel (BroadcastChannel etc.)
159
165
  },
160
166
  }
161
167
  }
package/src/core.ts CHANGED
@@ -221,9 +221,12 @@ export type ColumnDef<TFeatures extends TableFeatures, TData extends RowData> =
221
221
  editorType?:
222
222
  | 'text'
223
223
  | 'number'
224
- | 'date'
225
- | 'datetime'
226
- | 'time' // native <input type="time"> - HH:MM or HH:MM:SS
224
+ | 'date' // rich SvCalendar popover (opt out with 'date-native')
225
+ | 'datetime' // rich SvDateTimePicker (opt out with 'datetime-native')
226
+ | 'time' // rich SvTimePicker dial (opt out with 'time-native')
227
+ | 'date-native' // plain <input type="date">
228
+ | 'datetime-native' // plain <input type="datetime-local">
229
+ | 'time-native' // plain <input type="time"> - HH:MM or HH:MM:SS
227
230
  | 'password' // native <input type="password"> with masked rendering
228
231
  | 'checkbox'
229
232
  | 'list'
@@ -251,6 +254,27 @@ export type ColumnDef<TFeatures extends TableFeatures, TData extends RowData> =
251
254
  * string skips the tooltip.
252
255
  */
253
256
  tooltip?: string | ((ctx: CellContext<TData>) => string | null | undefined)
257
+ /**
258
+ * Declarative per-cell validation (Handsontable-style). Runs for EVERY
259
+ * rendered cell - including values already present in `data` on load, not
260
+ * just on edit - so bad data is flagged immediately. Invalid cells get the
261
+ * `sv-grid-cell-invalid` class (red highlight) and the returned message as
262
+ * their tooltip.
263
+ *
264
+ * Return value:
265
+ * - `null` / `undefined` / `true` → valid (no highlight)
266
+ * - `false` → invalid, no message
267
+ * - a non-empty `string` → invalid, string is the tooltip
268
+ *
269
+ * The value keeps rendering as-is (the grid does NOT roll it back); pair
270
+ * with `onCellValueChange` if you also want to reject the commit.
271
+ */
272
+ validate?: (params: {
273
+ value: unknown
274
+ row: TData
275
+ rowIndex: number
276
+ column: Column<TData>
277
+ }) => string | boolean | null | undefined
254
278
  /**
255
279
  * Gate editing per column or per cell.
256
280
  *