@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
@@ -8,6 +8,12 @@
8
8
  * It's headless and framework-agnostic on purpose: wire `setSort` /
9
9
  * `setFilter` / `setPage` to the grid's controlled callbacks, and render the
10
10
  * grid from the `{ rows, total, loading }` it hands you. See the demo.
11
+ *
12
+ * The write side (`createRow` / `updateRow` / `deleteRow`) is optional: a
13
+ * source that only implements `getRows` stays a pure read model, and the
14
+ * matching controller methods throw a clear error if called. Mutations are
15
+ * non-optimistic for now - the grid reflects a change only after the
16
+ * follow-up re-fetch of the current page lands.
11
17
  */
12
18
  export type ServerSortModel = Array<{ id: string; desc: boolean }>
13
19
 
@@ -44,12 +50,23 @@ export type ServerResult<TData> = {
44
50
 
45
51
  export type ServerDataSource<TData> = {
46
52
  getRows(request: ServerRequest): Promise<ServerResult<TData>>
53
+ /**
54
+ * Optional write side. Implement whichever your backend supports; the
55
+ * controller exposes matching `createRow` / `updateRow` / `deleteRow`
56
+ * methods that call through and then `refresh()` the current page.
57
+ * Calling a controller method whose source counterpart is missing throws.
58
+ */
59
+ createRow?(input: Partial<TData>): Promise<TData>
60
+ updateRow?(id: string, patch: Partial<TData>): Promise<TData>
61
+ deleteRow?(id: string): Promise<void>
47
62
  }
48
63
 
49
64
  export type ServerState<TData> = {
50
65
  rows: ReadonlyArray<TData>
51
66
  total: number
52
67
  loading: boolean
68
+ /** True while a create / update / delete mutation is in flight. */
69
+ saving: boolean
53
70
  error: unknown
54
71
  pageIndex: number
55
72
  pageSize: number
@@ -65,6 +82,23 @@ export type ServerController<TData> = {
65
82
  setFilter(filterModel: ServerFilterModel): void
66
83
  setPage(pageIndex: number): void
67
84
  setPageSize(pageSize: number): void
85
+ /**
86
+ * Create a row through the source, then refresh the current page. Resolves
87
+ * with the created row. Rejects if the source has no `createRow` (or if the
88
+ * create itself fails - the read state is left untouched on failure).
89
+ */
90
+ createRow(input: Partial<TData>): Promise<TData>
91
+ /**
92
+ * Update a row by id through the source. Non-optimistic: refreshes the page.
93
+ * Optimistic (see `optimistic` + `getRowId` options): patches the local row
94
+ * immediately, then reconciles with the server result, rolling back on error.
95
+ */
96
+ updateRow(id: string, patch: Partial<TData>): Promise<TData>
97
+ /**
98
+ * Delete a row by id through the source. Non-optimistic: refreshes the page.
99
+ * Optimistic: removes the local row immediately, restoring it on error.
100
+ */
101
+ deleteRow(id: string): Promise<void>
68
102
  getState(): ServerState<TData>
69
103
  /** Stop accepting in-flight responses (call on unmount). */
70
104
  dispose(): void
@@ -74,6 +108,15 @@ export type ServerControllerOptions<TData> = {
74
108
  pageSize?: number
75
109
  /** Called whenever any of `rows` / `total` / `loading` / page changes. */
76
110
  onChange: (state: ServerState<TData>) => void
111
+ /**
112
+ * Apply `updateRow` / `deleteRow` to the local rows immediately (before the
113
+ * server confirms) and roll back on error - so edits feel instant and no
114
+ * refetch is needed. Requires `getRowId` to locate rows; ignored without it.
115
+ * A subsequent `refresh()` reconciles ordering/filtering. Default false.
116
+ */
117
+ optimistic?: boolean
118
+ /** Resolve a row's stable id, so optimistic update/delete can find it in `rows`. */
119
+ getRowId?: (row: TData) => string
77
120
  }
78
121
 
79
122
  export function createServerDataSource<TData>(
@@ -84,6 +127,7 @@ export function createServerDataSource<TData>(
84
127
  rows: [],
85
128
  total: 0,
86
129
  loading: false,
130
+ saving: false,
87
131
  error: null,
88
132
  pageIndex: 0,
89
133
  pageSize: options.pageSize ?? 50,
@@ -132,8 +176,105 @@ export function createServerDataSource<TData>(
132
176
  }
133
177
  }
134
178
 
179
+ // Shared mutation lifecycle: flip `saving`, run the write, refresh the
180
+ // current page on success, and always clear `saving`. A missing source
181
+ // method (or a disposed controller) rejects before anything is emitted.
182
+ function mutate<T>(name: string, thunk: (() => Promise<T>) | null): Promise<T> {
183
+ if (disposed) {
184
+ return Promise.reject(new Error('createServerDataSource: controller is disposed'))
185
+ }
186
+ if (!thunk) {
187
+ return Promise.reject(
188
+ new Error(`createServerDataSource: the datasource does not implement ${name}()`),
189
+ )
190
+ }
191
+ state.saving = true
192
+ emit()
193
+ return (async () => {
194
+ try {
195
+ const result = await thunk()
196
+ await fetchPage()
197
+ return result
198
+ } finally {
199
+ state.saving = false
200
+ emit()
201
+ }
202
+ })()
203
+ }
204
+
205
+ const optimistic = !!options.optimistic && !!options.getRowId
206
+ const getRowId = options.getRowId
207
+
208
+ // Optimistic update: patch the local row, reconcile with the server result,
209
+ // roll back on error. Falls back to the plain refresh path when the row
210
+ // isn't on the current page (nothing local to update).
211
+ async function optimisticUpdate(
212
+ id: string,
213
+ patch: Partial<TData>,
214
+ fn: (id: string, patch: Partial<TData>) => Promise<TData>,
215
+ ): Promise<TData> {
216
+ if (disposed) throw new Error('createServerDataSource: controller is disposed')
217
+ const prevRows = state.rows
218
+ const idx = prevRows.findIndex((r) => getRowId!(r) === id)
219
+ if (idx < 0) return mutate('updateRow', () => fn(id, patch))
220
+
221
+ state.rows = [...prevRows.slice(0, idx), { ...prevRows[idx]!, ...patch }, ...prevRows.slice(idx + 1)]
222
+ state.saving = true
223
+ emit()
224
+ try {
225
+ const result = await fn(id, patch)
226
+ state.rows = state.rows.map((r) => (getRowId!(r) === id ? result : r))
227
+ return result
228
+ } catch (err) {
229
+ state.rows = prevRows
230
+ throw err
231
+ } finally {
232
+ state.saving = false
233
+ emit()
234
+ }
235
+ }
236
+
237
+ // Optimistic delete: drop the local row + decrement total, restore on error.
238
+ async function optimisticDelete(
239
+ id: string,
240
+ fn: (id: string) => Promise<void>,
241
+ ): Promise<void> {
242
+ if (disposed) throw new Error('createServerDataSource: controller is disposed')
243
+ const prevRows = state.rows
244
+ const prevTotal = state.total
245
+ const next = prevRows.filter((r) => getRowId!(r) !== id)
246
+ if (next.length === prevRows.length) return mutate('deleteRow', () => fn(id))
247
+
248
+ state.rows = next
249
+ state.total = Math.max(0, prevTotal - (prevRows.length - next.length))
250
+ state.saving = true
251
+ emit()
252
+ try {
253
+ await fn(id)
254
+ } catch (err) {
255
+ state.rows = prevRows
256
+ state.total = prevTotal
257
+ throw err
258
+ } finally {
259
+ state.saving = false
260
+ emit()
261
+ }
262
+ }
263
+
135
264
  return {
136
265
  refresh: fetchPage,
266
+ createRow: (input) =>
267
+ mutate('createRow', source.createRow ? () => source.createRow!(input) : null),
268
+ updateRow: (id, patch) => {
269
+ if (!source.updateRow) return mutate('updateRow', null)
270
+ const fn = source.updateRow
271
+ return optimistic ? optimisticUpdate(id, patch, fn) : mutate('updateRow', () => fn(id, patch))
272
+ },
273
+ deleteRow: (id) => {
274
+ if (!source.deleteRow) return mutate('deleteRow', null)
275
+ const fn = source.deleteRow
276
+ return optimistic ? optimisticDelete(id, fn) : mutate('deleteRow', () => fn(id))
277
+ },
137
278
  setSort(sortModel) {
138
279
  state.sortModel = sortModel
139
280
  state.pageIndex = 0
@@ -158,6 +299,13 @@ export function createServerDataSource<TData>(
158
299
  getState: () => ({ ...state }),
159
300
  dispose() {
160
301
  disposed = true
302
+ // An in-flight fetch's resolution short-circuits on `disposed`, so it
303
+ // never clears `loading`. Clear it here (and emit) so a disposed
304
+ // controller doesn't report a permanent loading state.
305
+ if (state.loading) {
306
+ state.loading = false
307
+ emit()
308
+ }
161
309
  },
162
310
  }
163
311
  }
@@ -34,6 +34,15 @@ describe('buildSparkline', () => {
34
34
  expect(g.areaPath.endsWith('Z')).toBe(true)
35
35
  })
36
36
 
37
+ it('renders a visible mark for a single data point (#84)', () => {
38
+ // A lone point used to produce just "M x,y" (a moveto), which draws
39
+ // nothing. The fix centres it and emits a short segment.
40
+ const g = buildSparkline([5], { type: 'line', width: 100, height: 20 })!
41
+ expect(g.linePath).not.toBe('')
42
+ expect(g.linePath).toMatch(/M[\d.]+,[\d.]+ L[\d.]+,[\d.]+/)
43
+ expect(g.lastPoint).not.toBeNull()
44
+ })
45
+
37
46
  it('emits one bar per value and flags negatives', () => {
38
47
  const g = buildSparkline([-2, 4, -1], { type: 'bar' })!
39
48
  expect(g.bars).toHaveLength(3)
package/src/sparkline.ts CHANGED
@@ -137,12 +137,17 @@ export function buildSparkline(
137
137
  const n = values.length
138
138
  const stepX = n > 1 ? innerW / (n - 1) : 0
139
139
  const pts = values.map((v, i) => ({
140
- x: pad + i * stepX,
140
+ // A single point has no span to walk, so centre it rather than pinning it
141
+ // to the left edge.
142
+ x: n === 1 ? pad + innerW / 2 : pad + i * stepX,
141
143
  y: pad + innerH - ((v - min) / span) * innerH,
142
144
  }))
143
- const linePath = pts
144
- .map((p, i) => `${i === 0 ? 'M' : 'L'}${round(p.x)},${round(p.y)}`)
145
- .join(' ')
145
+ const linePath =
146
+ n === 1
147
+ ? // One point produces only a moveto, which draws nothing. Emit a tiny
148
+ // horizontal segment so a single value still renders a visible mark.
149
+ `M${round(pts[0]!.x - 1)},${round(pts[0]!.y)} L${round(pts[0]!.x + 1)},${round(pts[0]!.y)}`
150
+ : pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${round(p.x)},${round(p.y)}`).join(' ')
146
151
  const first = pts[0]!
147
152
  const last = pts[pts.length - 1]!
148
153
  const baseY = pad + innerH
@@ -1,4 +1,5 @@
1
- import type { ColumnDef, RowData, SvGridOptions, TableFeatures } from './core'
1
+ import type { CellFormatConfig, ColumnDef, RowData, SvGridOptions, TableFeatures } from './core'
2
+ import type { GridExportOptions, GridClipboardOptions } from './export-format'
2
3
 
3
4
  export type SvGridFilterOperator =
4
5
  | 'contains'
@@ -197,8 +198,34 @@ export type SvGridApi<
197
198
  field?: string
198
199
  header: string
199
200
  visible: boolean
201
+ /** The column's `format` config, when set. Lets an exporter reproduce
202
+ * the on-screen display value (currency, date pattern, etc.). */
203
+ format?: CellFormatConfig
204
+ /** Effective horizontal alignment ('left' | 'center' | 'right'). */
205
+ align?: 'left' | 'center' | 'right'
200
206
  }>
201
207
 
208
+ // ----- Free data export (CSV / TSV / JSON + clipboard) -----
209
+ /**
210
+ * Export the grid to a **CSV** file. Free in the community grid; the
211
+ * richer Excel / PDF / styled formats live in @svgrid/enterprise. Values
212
+ * are formatted as shown on screen (pass `rawValues: true` for raw). Rows
213
+ * default to the current view (`rows: 'selected' | 'all'` to change).
214
+ * Resolves with the serialized text; pass `download: false` to skip the
215
+ * browser download and just get the string.
216
+ */
217
+ exportCsv(options?: GridExportOptions): Promise<string>
218
+ /** Export the grid to a **TSV** file (tab-separated). See `exportCsv`. */
219
+ exportTsv(options?: GridExportOptions): Promise<string>
220
+ /** Export the grid to a **JSON** file (array of `{ field: value }`). */
221
+ exportJson(options?: GridExportOptions): Promise<string>
222
+ /**
223
+ * Copy the grid to the system clipboard. `format: 'tsv'` (default) pastes
224
+ * straight into Excel / Sheets; `'csv'` / `'markdown'` also supported.
225
+ * Resolves with the copied text.
226
+ */
227
+ copyToClipboard(options?: GridClipboardOptions): Promise<string>
228
+
202
229
  /** Clear every checked row. Emits `onRowSelectionChange({}, [])`. */
203
230
  clearRowSelection(): void
204
231
 
@@ -351,6 +351,33 @@ describe('SvGrid - capability shortcuts', () => {
351
351
  }
352
352
  })
353
353
 
354
+ it('paginationPosition mounts + gates the bottom pager', async () => {
355
+ // The top pager is gated on measurement (jsdom does not measure), so it's not
356
+ // asserted here; the visible top pager is covered by the browser/e2e check.
357
+ // This guards the mount path (a top footer rendered pre-measure used to crash).
358
+ for (const paginationPosition of ['top', 'both', 'bottom'] as const) {
359
+ const g = await mountGrid({ showPagination: true, paginationPosition })
360
+ try {
361
+ await tick()
362
+ expect(g.api).toBeTruthy()
363
+ const bottoms = [...g.target.querySelectorAll('.sv-grid-pagination')].filter((el) => !el.classList.contains('sv-grid-pagination-top'))
364
+ expect(bottoms.length).toBe(paginationPosition === 'top' ? 0 : 1) // no bottom pager when position is 'top'
365
+ } finally {
366
+ g.destroy()
367
+ }
368
+ }
369
+ })
370
+
371
+ it('pageSizeOptions is accepted by the pager', async () => {
372
+ const g = await mountGrid({ showPagination: true, pageSizeOptions: [5, 15, 45] })
373
+ try {
374
+ await tick()
375
+ expect(g.target.querySelector('.sv-grid-pagination')).toBeTruthy()
376
+ } finally {
377
+ g.destroy()
378
+ }
379
+ })
380
+
354
381
  it('editable={false} blocks inline editing entry points', async () => {
355
382
  const { api, target, destroy } = await mountGrid({ editable: false })
356
383
  try {
@@ -75,11 +75,37 @@ describe('SvGrid wrapper - cellClass + rowClass', () => {
75
75
 
76
76
  it('computes the cell-level class per column and threads it onto the <td>', () => {
77
77
  expect(source).toMatch(/function computeCellClass/)
78
- expect(source).toMatch(/userCellClass = computeCellClass\(row, rendered\.column\)/)
78
+ expect(source).toMatch(/userCellClass = computeCellClass\(\s*row,\s*rendered\.column,?\s*\)/)
79
79
  expect(source).toMatch(/class=\{`sv-grid-cell \$\{userCellClass\}`\}/)
80
80
  })
81
81
  })
82
82
 
83
+ describe('SvGrid wrapper - declarative cell validation (validate hook)', () => {
84
+ it('reads the column validate hook with the documented return union', () => {
85
+ expect(source).toMatch(/column\.columnDef\.validate/)
86
+ expect(source).toMatch(/=>\s*string\s*\|\s*boolean\s*\|\s*null\s*\|\s*undefined/)
87
+ })
88
+
89
+ it('computes per-cell validity in the render layer (runs for every rendered cell)', () => {
90
+ expect(source).toMatch(/function computeCellValidity/)
91
+ expect(source).toMatch(/columnDef\.validate/)
92
+ // Valid when null / undefined / true; invalid on false or a message.
93
+ expect(source).toMatch(/if \(out == null \|\| out === true\)/)
94
+ })
95
+
96
+ it('threads the invalid class + message tooltip onto both <td> render paths', () => {
97
+ const hits = source.match(/class:sv-grid-cell-invalid=\{cellValidity\.invalid\}/g) ?? []
98
+ expect(hits.length).toBeGreaterThanOrEqual(2)
99
+ // Message wins over the plain column tooltip when the cell is invalid.
100
+ expect(source).toMatch(/cellValidity\.invalid && cellValidity\.message/)
101
+ })
102
+
103
+ it('forwards computeCellValidity through the controller so the wrapper can read it', () => {
104
+ expect(source).toMatch(/get computeCellValidity\(\)\s*\{\s*return computeCellValidity;/)
105
+ expect(source).toMatch(/computeCellValidity\s*=\s*\$derived\(ctrl\.computeCellValidity\)/)
106
+ })
107
+ })
108
+
83
109
  describe('SvGrid wrapper - column-layout API', () => {
84
110
  it('declares setColumnWidth + getColumnWidths in the public type', () => {
85
111
  expect(types).toContain('setColumnWidth(columnId: string, width: number): void')
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Group B (buttons & toggles) component tests. Each asserts the emitted value /
3
+ * ARIA state a real interaction produces.
4
+ */
5
+ import { describe, expect, it } from 'vitest'
6
+ import { mount, unmount, flushSync } from 'svelte'
7
+ import SvToggleButton from './SvToggleButton.svelte'
8
+ import SvSwitchButton from './SvSwitchButton.svelte'
9
+ import SvCheckBox from './SvCheckBox.svelte'
10
+ import SvRadioGroup from './SvRadioGroup.svelte'
11
+ import SvRating from './SvRating.svelte'
12
+
13
+ function mnt(Comp: any, props: Record<string, unknown>) {
14
+ const target = document.createElement('div')
15
+ document.body.appendChild(target)
16
+ const app = mount(Comp, { target, props: props as any })
17
+ flushSync()
18
+ return { target, destroy: () => { unmount(app); target.remove() } }
19
+ }
20
+
21
+ describe('SvToggleButton', () => {
22
+ it('reflects pressed and emits the flipped value', () => {
23
+ let got: boolean | undefined
24
+ const { target, destroy } = mnt(SvToggleButton, { pressed: false, onChange: (v: boolean) => (got = v) })
25
+ try {
26
+ const btn = target.querySelector<HTMLButtonElement>('.sv-toggle')!
27
+ expect(btn.getAttribute('aria-pressed')).toBe('false')
28
+ btn.click(); flushSync()
29
+ expect(got).toBe(true)
30
+ } finally { destroy() }
31
+ })
32
+ })
33
+
34
+ describe('SvSwitchButton', () => {
35
+ it('has role switch and toggles', () => {
36
+ let got: boolean | undefined
37
+ const { target, destroy } = mnt(SvSwitchButton, { checked: false, onChange: (v: boolean) => (got = v) })
38
+ try {
39
+ const sw = target.querySelector<HTMLButtonElement>('[role="switch"]')!
40
+ expect(sw.getAttribute('aria-checked')).toBe('false')
41
+ sw.click(); flushSync()
42
+ expect(got).toBe(true)
43
+ } finally { destroy() }
44
+ })
45
+ it('ArrowRight turns on, ArrowLeft off', () => {
46
+ const states: boolean[] = []
47
+ const { target, destroy } = mnt(SvSwitchButton, { checked: false, onChange: (v: boolean) => states.push(v) })
48
+ try {
49
+ const sw = target.querySelector<HTMLButtonElement>('[role="switch"]')!
50
+ sw.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))
51
+ flushSync()
52
+ expect(states.at(-1)).toBe(true)
53
+ } finally { destroy() }
54
+ })
55
+ })
56
+
57
+ describe('SvCheckBox', () => {
58
+ it('toggles and reports aria-checked mixed when indeterminate', () => {
59
+ let got: boolean | undefined
60
+ const a = mnt(SvCheckBox, { checked: false, onChange: (v: boolean) => (got = v) })
61
+ try {
62
+ const box = a.target.querySelector<HTMLButtonElement>('[role="checkbox"]')!
63
+ box.click(); flushSync()
64
+ expect(got).toBe(true)
65
+ } finally { a.destroy() }
66
+ const b = mnt(SvCheckBox, { indeterminate: true })
67
+ try {
68
+ expect(b.target.querySelector('[role="checkbox"]')!.getAttribute('aria-checked')).toBe('mixed')
69
+ } finally { b.destroy() }
70
+ })
71
+ })
72
+
73
+ describe('SvRadioGroup', () => {
74
+ it('selects on click and arrow-navigates', () => {
75
+ const picks: unknown[] = []
76
+ const opts = [{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }, { value: 'c', label: 'C' }]
77
+ const { target, destroy } = mnt(SvRadioGroup, { options: opts, value: 'a', onChange: (v: unknown) => picks.push(v) })
78
+ try {
79
+ const radios = target.querySelectorAll<HTMLButtonElement>('[role="radio"]')
80
+ expect(radios[0]!.getAttribute('aria-checked')).toBe('true')
81
+ radios[2]!.click(); flushSync()
82
+ expect(picks.at(-1)).toBe('c')
83
+ // roving tabindex: selected is 0, others -1
84
+ radios[0]!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
85
+ flushSync()
86
+ expect(picks.at(-1)).toBe('b')
87
+ } finally { destroy() }
88
+ })
89
+ })
90
+
91
+ describe('SvRating', () => {
92
+ it('clicking a star emits its value; keyboard steps', () => {
93
+ const vals: number[] = []
94
+ const { target, destroy } = mnt(SvRating, { value: 2, onChange: (v: number) => vals.push(v) })
95
+ try {
96
+ const stars = target.querySelectorAll<HTMLButtonElement>('.sv-rating__star')
97
+ expect(stars.length).toBe(5)
98
+ stars[3]!.click(); flushSync()
99
+ expect(vals.at(-1)).toBe(4)
100
+ target.querySelector<HTMLElement>('.sv-rating')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))
101
+ flushSync()
102
+ expect(vals.at(-1)).toBe(3) // 2 + 1 step
103
+ } finally { destroy() }
104
+ })
105
+ })
@@ -0,0 +1,89 @@
1
+ /** Group F (tabs / tree / form) component tests. */
2
+ import { describe, expect, it } from 'vitest'
3
+ import { mount, unmount, flushSync } from 'svelte'
4
+ import SvTabs from './SvTabs.svelte'
5
+ import SvTree from './SvTree.svelte'
6
+ import SvForm from './SvForm.svelte'
7
+
8
+ function mnt(Comp: any, props: Record<string, unknown>) {
9
+ const target = document.createElement('div')
10
+ document.body.appendChild(target)
11
+ const app = mount(Comp, { target, props: props as any })
12
+ flushSync()
13
+ return { target, destroy: () => { unmount(app); target.remove() } }
14
+ }
15
+
16
+ describe('SvTabs', () => {
17
+ const tabs = [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }, { id: 'c', label: 'C', disabled: true }]
18
+ it('selects on click and arrow-navigates skipping disabled', () => {
19
+ let got = ''
20
+ const { target, destroy } = mnt(SvTabs, { tabs, value: 'a', onChange: (id: string) => (got = id) })
21
+ try {
22
+ const els = target.querySelectorAll<HTMLButtonElement>('[role="tab"]')
23
+ expect(els[0]!.getAttribute('aria-selected')).toBe('true')
24
+ els[1]!.click(); flushSync()
25
+ expect(got).toBe('b')
26
+ // ArrowRight from b wraps past disabled c back to a
27
+ els[1]!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))
28
+ flushSync()
29
+ expect(got).toBe('a')
30
+ } finally { destroy() }
31
+ })
32
+ })
33
+
34
+ describe('SvTree', () => {
35
+ const nodes = [
36
+ { id: '1', label: 'Root', children: [
37
+ { id: '1a', label: 'Child A' },
38
+ { id: '1b', label: 'Child B', children: [{ id: '1b1', label: 'Leaf' }] },
39
+ ] },
40
+ { id: '2', label: 'Sibling' },
41
+ ]
42
+ it('expands to reveal children and selects', () => {
43
+ let sel = ''
44
+ const { target, destroy } = mnt(SvTree, { nodes, expandedIds: ['1'], onSelect: (id: string) => (sel = id) })
45
+ try {
46
+ // Root expanded -> shows 1, 1a, 1b, 2 (1b collapsed)
47
+ expect(target.querySelectorAll('[role="treeitem"]').length).toBe(4)
48
+ const childA = Array.from(target.querySelectorAll<HTMLElement>('[role="treeitem"]')).find((r) => r.textContent?.includes('Child A'))!
49
+ childA.click(); flushSync()
50
+ expect(sel).toBe('1a')
51
+ } finally { destroy() }
52
+ })
53
+ it('cascading checkbox checks all descendants', () => {
54
+ let checked: string[] = []
55
+ const { target, destroy } = mnt(SvTree, { nodes, expandedIds: ['1', '1b'], checkable: true, checked, onCheck: (ids: string[]) => (checked = ids) })
56
+ try {
57
+ const rootCheck = target.querySelector<HTMLButtonElement>('.sv-tree__check')!
58
+ rootCheck.click(); flushSync()
59
+ // Root + all descendants: 1,1a,1b,1b1
60
+ expect(new Set(checked)).toEqual(new Set(['1', '1a', '1b', '1b1']))
61
+ } finally { destroy() }
62
+ })
63
+ })
64
+
65
+ describe('SvForm', () => {
66
+ const fields = [
67
+ { name: 'name', label: 'Name', required: true },
68
+ { name: 'age', label: 'Age', type: 'number' as const },
69
+ ]
70
+ it('blocks submit and shows errors when required is empty', () => {
71
+ let submitted: any = null
72
+ const { target, destroy } = mnt(SvForm, { fields, onSubmit: (v: any) => (submitted = v) })
73
+ try {
74
+ target.querySelector<HTMLFormElement>('.sv-form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
75
+ flushSync()
76
+ expect(submitted).toBeNull()
77
+ expect(target.querySelector('.sv-form__error')!.textContent).toContain('required')
78
+ } finally { destroy() }
79
+ })
80
+ it('submits values when valid', () => {
81
+ let submitted: any = null
82
+ const { target, destroy } = mnt(SvForm, { fields, initial: { name: 'Ada' }, onSubmit: (v: any) => (submitted = v) })
83
+ try {
84
+ target.querySelector<HTMLFormElement>('.sv-form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
85
+ flushSync()
86
+ expect(submitted).toEqual({ name: 'Ada' })
87
+ } finally { destroy() }
88
+ })
89
+ })
@@ -0,0 +1,92 @@
1
+ /** Group C (text inputs) component tests. */
2
+ import { describe, expect, it } from 'vitest'
3
+ import { mount, unmount, flushSync } from 'svelte'
4
+ import SvNumberInput from './SvNumberInput.svelte'
5
+ import SvPasswordInput from './SvPasswordInput.svelte'
6
+ import SvMaskedInput from './SvMaskedInput.svelte'
7
+ import SvPhoneInput from './SvPhoneInput.svelte'
8
+
9
+ function mnt(Comp: any, props: Record<string, unknown>) {
10
+ const target = document.createElement('div')
11
+ document.body.appendChild(target)
12
+ const app = mount(Comp, { target, props: props as any })
13
+ flushSync()
14
+ return { target, destroy: () => { unmount(app); target.remove() } }
15
+ }
16
+
17
+ describe('SvNumberInput', () => {
18
+ it('clamps to max on commit', () => {
19
+ let got: number | null | undefined
20
+ const { target, destroy } = mnt(SvNumberInput, { value: 5, min: 0, max: 10, step: 2, onChange: (v: number | null) => (got = v) })
21
+ try {
22
+ const input = target.querySelector<HTMLInputElement>('.sv-num__input')!
23
+ input.dispatchEvent(new FocusEvent('focus'))
24
+ input.value = '99'
25
+ input.dispatchEvent(new Event('input', { bubbles: true }))
26
+ input.dispatchEvent(new FocusEvent('blur'))
27
+ flushSync()
28
+ expect(got).toBe(10)
29
+ } finally { destroy() }
30
+ })
31
+ it('ArrowDown steps down by step from the current value', () => {
32
+ let got: number | null | undefined
33
+ const { target, destroy } = mnt(SvNumberInput, { value: 8, min: 0, max: 10, step: 2, onChange: (v: number | null) => (got = v) })
34
+ try {
35
+ target.querySelector<HTMLInputElement>('.sv-num__input')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
36
+ flushSync()
37
+ expect(got).toBe(6)
38
+ } finally { destroy() }
39
+ })
40
+ })
41
+
42
+ describe('SvPasswordInput', () => {
43
+ it('reveal toggle flips the input type', () => {
44
+ const { target, destroy } = mnt(SvPasswordInput, { value: 'secret' })
45
+ try {
46
+ const input = target.querySelector<HTMLInputElement>('.sv-pw__input')!
47
+ expect(input.type).toBe('password')
48
+ target.querySelector<HTMLButtonElement>('.sv-pw__eye')!.click()
49
+ flushSync()
50
+ expect(target.querySelector<HTMLInputElement>('.sv-pw__input')!.type).toBe('text')
51
+ } finally { destroy() }
52
+ })
53
+ it('computes a strength score', () => {
54
+ const { target, destroy } = mnt(SvPasswordInput, { value: 'Abcdef1!', showStrength: true })
55
+ try {
56
+ expect(target.querySelector('.sv-pw__strength')!.textContent).toBe('Strong')
57
+ } finally { destroy() }
58
+ })
59
+ })
60
+
61
+ describe('SvMaskedInput', () => {
62
+ it('formats input to the mask and reports raw', () => {
63
+ let raw = ''
64
+ let masked = ''
65
+ const { target, destroy } = mnt(SvMaskedInput, {
66
+ mask: '(###) ###-####',
67
+ onChange: (m: string, r: string) => { masked = m; raw = r },
68
+ })
69
+ try {
70
+ const input = target.querySelector<HTMLInputElement>('.sv-masked')!
71
+ input.value = '1234567890'
72
+ input.dispatchEvent(new Event('input', { bubbles: true }))
73
+ flushSync()
74
+ expect(masked).toBe('(123) 456-7890')
75
+ expect(raw).toBe('1234567890')
76
+ } finally { destroy() }
77
+ })
78
+ })
79
+
80
+ describe('SvPhoneInput', () => {
81
+ it('emits E.164 with dial code and national digits', () => {
82
+ let out = ''
83
+ const { target, destroy } = mnt(SvPhoneInput, { country: 'US', onChange: (v: string) => (out = v) })
84
+ try {
85
+ const input = target.querySelector<HTMLInputElement>('.sv-phone__number')!
86
+ input.value = '(415) 555-1234'
87
+ input.dispatchEvent(new Event('input', { bubbles: true }))
88
+ flushSync()
89
+ expect(out).toBe('+14155551234')
90
+ } finally { destroy() }
91
+ })
92
+ })