@svgrid/grid 2.1.22 → 2.2.0

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 (295) hide show
  1. package/README.md +9 -2
  2. package/dist/DockNodeView.svelte +306 -0
  3. package/dist/DockNodeView.svelte.d.ts +7 -0
  4. package/dist/DockPopoutHost.svelte +17 -0
  5. package/dist/DockPopoutHost.svelte.d.ts +14 -0
  6. package/dist/FlexRender.svelte +96 -96
  7. package/dist/GridFooter.svelte +178 -178
  8. package/dist/GridMenus.svelte +83 -3
  9. package/dist/SvAutoComplete.svelte +5 -3
  10. package/dist/SvCarousel.svelte +141 -163
  11. package/dist/SvColorInput.svelte +187 -186
  12. package/dist/SvColorInput.svelte.d.ts +1 -1
  13. package/dist/SvComboBox.svelte +7 -3
  14. package/dist/SvCommand.svelte +28 -85
  15. package/dist/SvContextMenu.svelte +116 -116
  16. package/dist/SvCountryInput.svelte +1 -1
  17. package/dist/SvDateRangeInput.svelte +1 -1
  18. package/dist/SvDateTimePicker.svelte +9 -3
  19. package/dist/SvDateTimePicker.svelte.d.ts +3 -0
  20. package/dist/SvDockLayout.svelte +244 -0
  21. package/dist/SvDockLayout.svelte.d.ts +77 -0
  22. package/dist/SvDockManager.svelte +922 -0
  23. package/dist/SvDockManager.svelte.d.ts +106 -0
  24. package/dist/SvDrawer.svelte +250 -228
  25. package/dist/SvDrawer.svelte.d.ts +4 -0
  26. package/dist/SvDropDownList.svelte +85 -13
  27. package/dist/SvDropDownList.svelte.d.ts +3 -0
  28. package/dist/SvField.svelte +21 -4
  29. package/dist/SvField.svelte.d.ts +1 -0
  30. package/dist/SvForm.svelte +201 -91
  31. package/dist/SvForm.svelte.d.ts +15 -22
  32. package/dist/SvGrid.controller.svelte.d.ts +32 -1
  33. package/dist/SvGrid.controller.svelte.js +156 -27
  34. package/dist/SvGrid.css +193 -5
  35. package/dist/SvGrid.svelte +3131 -2786
  36. package/dist/SvGrid.types.d.ts +452 -1
  37. package/dist/SvGridBoard.svelte +2339 -2339
  38. package/dist/SvGridChart.svelte +1724 -1724
  39. package/dist/SvGridChartPanel.svelte +485 -485
  40. package/dist/SvGridDropdown.svelte +689 -682
  41. package/dist/SvGridSelect.svelte +1 -0
  42. package/dist/SvGroupCell.svelte +109 -109
  43. package/dist/SvListBox.svelte +36 -14
  44. package/dist/SvMaskedInput.svelte +4 -3
  45. package/dist/SvMaskedInput.svelte.d.ts +1 -1
  46. package/dist/SvMenu.svelte +124 -124
  47. package/dist/SvMenuList.svelte +117 -165
  48. package/dist/SvMenuList.svelte.d.ts +2 -13
  49. package/dist/SvModal.svelte +12 -29
  50. package/dist/SvMultiSelect.svelte +1 -0
  51. package/dist/SvNumberInput.svelte +131 -130
  52. package/dist/SvNumberInput.svelte.d.ts +1 -1
  53. package/dist/SvOtpInput.svelte +158 -158
  54. package/dist/SvPagination.svelte +18 -18
  55. package/dist/SvPasswordInput.svelte +3 -2
  56. package/dist/SvPasswordInput.svelte.d.ts +1 -1
  57. package/dist/SvPhoneInput.svelte +3 -2
  58. package/dist/SvPhoneInput.svelte.d.ts +1 -1
  59. package/dist/SvPopover.svelte +139 -139
  60. package/dist/SvRowGroupPanel.svelte +170 -170
  61. package/dist/SvScrollArea.svelte +61 -61
  62. package/dist/SvStepper.svelte +6 -9
  63. package/dist/SvTagsInput.svelte +3 -2
  64. package/dist/SvTagsInput.svelte.d.ts +1 -1
  65. package/dist/SvTooltip.svelte +127 -138
  66. package/dist/SvTour.svelte +204 -176
  67. package/dist/SvTree.svelte +377 -377
  68. package/dist/SvTreeSelect.svelte +230 -229
  69. package/dist/build-api.js +76 -54
  70. package/dist/cdn/svgrid.js +10913 -6450
  71. package/dist/cdn/svgrid.svelte-external.js +9628 -5481
  72. package/dist/chart-export.js +8 -8
  73. package/dist/columns.js +6 -0
  74. package/dist/conditional-formatting.d.ts +67 -3
  75. package/dist/conditional-formatting.js +124 -17
  76. package/dist/createCarousel.svelte.d.ts +72 -0
  77. package/dist/createCarousel.svelte.js +80 -0
  78. package/dist/createCommand.svelte.d.ts +62 -0
  79. package/dist/createCommand.svelte.js +109 -0
  80. package/dist/createForm.svelte.d.ts +62 -0
  81. package/dist/createForm.svelte.js +374 -0
  82. package/dist/createMenu.svelte.d.ts +88 -0
  83. package/dist/createMenu.svelte.js +153 -0
  84. package/dist/createOverlay.svelte.d.ts +38 -0
  85. package/dist/createOverlay.svelte.js +68 -0
  86. package/dist/createPagination.d.ts +74 -0
  87. package/dist/createPagination.js +71 -0
  88. package/dist/createPopoverSelect.svelte.js +6 -12
  89. package/dist/createStepper.d.ts +44 -0
  90. package/dist/createStepper.js +26 -0
  91. package/dist/createTooltip.svelte.d.ts +32 -0
  92. package/dist/createTooltip.svelte.js +61 -0
  93. package/dist/datetime/timezone.d.ts +36 -0
  94. package/dist/datetime/timezone.js +110 -0
  95. package/dist/dock-context.d.ts +52 -0
  96. package/dist/dock-context.js +1 -0
  97. package/dist/dock-manager-model.d.ts +136 -0
  98. package/dist/dock-manager-model.js +514 -0
  99. package/dist/dock-model.d.ts +105 -0
  100. package/dist/dock-model.js +288 -0
  101. package/dist/dock-popout.d.ts +30 -0
  102. package/dist/dock-popout.js +81 -0
  103. package/dist/editor-contract.d.ts +2 -0
  104. package/dist/filter-operators.d.ts +6 -0
  105. package/dist/filter-operators.js +35 -2
  106. package/dist/filtering/excel-filters.d.ts +18 -1
  107. package/dist/filtering/excel-filters.js +56 -0
  108. package/dist/form-field.d.ts +73 -0
  109. package/dist/form-field.js +15 -0
  110. package/dist/index.d.ts +25 -5
  111. package/dist/index.js +40 -4
  112. package/dist/list-nav.d.ts +18 -0
  113. package/dist/list-nav.js +30 -0
  114. package/dist/list-option.d.ts +2 -0
  115. package/dist/menu-item.d.ts +20 -0
  116. package/dist/menu-item.js +1 -0
  117. package/dist/menus.d.ts +5 -0
  118. package/dist/menus.js +68 -16
  119. package/dist/panel-resize.d.ts +25 -0
  120. package/dist/panel-resize.js +40 -0
  121. package/dist/popover.d.ts +29 -3
  122. package/dist/popover.js +22 -6
  123. package/dist/recurrence.d.ts +33 -7
  124. package/dist/recurrence.js +151 -13
  125. package/dist/row-drag.d.ts +8 -0
  126. package/dist/row-drag.js +11 -0
  127. package/dist/scheduler-ical.d.ts +30 -0
  128. package/dist/scheduler-ical.js +180 -0
  129. package/dist/scheduler-model.d.ts +269 -0
  130. package/dist/scheduler-model.js +602 -0
  131. package/dist/scheduler-view.svelte.d.ts +22 -0
  132. package/dist/scheduler-view.svelte.js +13 -0
  133. package/dist/spreadsheet.js +20 -12
  134. package/dist/ui-app.types.d.ts +3 -0
  135. package/package.json +14 -10
  136. package/src/DockNodeView.svelte +306 -0
  137. package/src/DockPopoutHost.svelte +17 -0
  138. package/src/FlexRender.svelte +96 -96
  139. package/src/GridFooter.svelte +178 -178
  140. package/src/GridMenus.svelte +83 -3
  141. package/src/SvAutoComplete.svelte +5 -3
  142. package/src/SvCarousel.svelte +141 -163
  143. package/src/SvCarousel.test.ts +68 -0
  144. package/src/SvColorInput.svelte +187 -186
  145. package/src/SvComboBox.svelte +7 -3
  146. package/src/SvCommand.svelte +28 -85
  147. package/src/SvCommand.test.ts +78 -0
  148. package/src/SvContextMenu.svelte +116 -116
  149. package/src/SvCountryInput.svelte +1 -1
  150. package/src/SvDateRangeInput.svelte +1 -1
  151. package/src/SvDateTimePicker.svelte +9 -3
  152. package/src/SvDockLayout.svelte +244 -0
  153. package/src/SvDockManager.svelte +922 -0
  154. package/src/SvDrawer.svelte +250 -228
  155. package/src/SvDropDownList.svelte +85 -13
  156. package/src/SvField.svelte +21 -4
  157. package/src/SvForm.svelte +201 -91
  158. package/src/SvForm.test.ts +299 -0
  159. package/src/SvGrid.controller.svelte.ts +3158 -3034
  160. package/src/SvGrid.css +193 -5
  161. package/src/SvGrid.svelte +3131 -2786
  162. package/src/SvGrid.types.ts +1489 -1027
  163. package/src/SvGridBoard.svelte +2339 -2339
  164. package/src/SvGridChart.svelte +1724 -1724
  165. package/src/SvGridChartPanel.svelte +485 -485
  166. package/src/SvGridDropdown.svelte +689 -682
  167. package/src/SvGridSelect.svelte +1 -0
  168. package/src/SvGroupCell.svelte +109 -109
  169. package/src/SvListBox.svelte +36 -14
  170. package/src/SvMaskedInput.svelte +4 -3
  171. package/src/SvMenu.svelte +124 -124
  172. package/src/SvMenu.test.ts +97 -73
  173. package/src/SvMenuList.svelte +117 -165
  174. package/src/SvModal.svelte +12 -29
  175. package/src/SvModal.test.ts +82 -0
  176. package/src/SvMultiSelect.svelte +1 -0
  177. package/src/SvNumberInput.svelte +131 -130
  178. package/src/SvOtpInput.svelte +158 -158
  179. package/src/SvPagination.svelte +18 -18
  180. package/src/SvPasswordInput.svelte +3 -2
  181. package/src/SvPhoneInput.svelte +3 -2
  182. package/src/SvPopover.svelte +139 -139
  183. package/src/SvRowGroupPanel.svelte +170 -170
  184. package/src/SvScrollArea.svelte +61 -61
  185. package/src/SvStepper.svelte +6 -9
  186. package/src/SvTagsInput.svelte +3 -2
  187. package/src/SvTooltip.svelte +127 -138
  188. package/src/SvTooltip.test.ts +73 -0
  189. package/src/SvTour.svelte +204 -176
  190. package/src/SvTree.svelte +377 -377
  191. package/src/SvTreeSelect.svelte +230 -229
  192. package/src/a11y.contract.test.ts +49 -49
  193. package/src/a11y.test.ts +59 -59
  194. package/src/a11y.ts +61 -61
  195. package/src/build-api.ts +870 -847
  196. package/src/cell-formatting.ts +169 -169
  197. package/src/cell-render.ts +469 -469
  198. package/src/chart-export.ts +201 -201
  199. package/src/chart.ts +2296 -2296
  200. package/src/collaboration.test.ts +104 -104
  201. package/src/collaboration.ts +167 -167
  202. package/src/columns.ts +5 -0
  203. package/src/conditional-formatting.test.ts +104 -0
  204. package/src/conditional-formatting.ts +176 -19
  205. package/src/core.performance.test.ts +30 -30
  206. package/src/core.ts +1111 -1111
  207. package/src/createAutocomplete.svelte.ts +132 -132
  208. package/src/createCarousel.svelte.ts +104 -0
  209. package/src/createCombobox.svelte.ts +179 -179
  210. package/src/createCommand.svelte.ts +125 -0
  211. package/src/createCountryInput.svelte.ts +157 -157
  212. package/src/createDropdownList.svelte.ts +162 -162
  213. package/src/createForm.svelte.ts +333 -0
  214. package/src/createGrid.svelte.ts +42 -42
  215. package/src/createGrid.test.ts +10 -10
  216. package/src/createGridState.svelte.ts +17 -17
  217. package/src/createMenu.svelte.ts +202 -0
  218. package/src/createOverlay.svelte.ts +101 -0
  219. package/src/createPagination.test.ts +65 -0
  220. package/src/createPagination.ts +84 -0
  221. package/src/createPopoverSelect.svelte.ts +206 -210
  222. package/src/createStepper.test.ts +62 -0
  223. package/src/createStepper.ts +61 -0
  224. package/src/createTooltip.svelte.ts +86 -0
  225. package/src/createTree.svelte.ts +319 -319
  226. package/src/datetime/timezone.test.ts +69 -0
  227. package/src/datetime/timezone.ts +134 -0
  228. package/src/dock-context.ts +47 -0
  229. package/src/dock-manager-model.test.ts +275 -0
  230. package/src/dock-manager-model.ts +593 -0
  231. package/src/dock-model.test.ts +163 -0
  232. package/src/dock-model.ts +368 -0
  233. package/src/dock-popout.ts +94 -0
  234. package/src/editing.test.ts +859 -859
  235. package/src/editing.ts +675 -675
  236. package/src/editor-contract.ts +2 -0
  237. package/src/export-data-api.test.ts +126 -126
  238. package/src/export-format.test.ts +107 -107
  239. package/src/export-format.ts +598 -598
  240. package/src/filter-operators.test.ts +59 -0
  241. package/src/filter-operators.ts +35 -2
  242. package/src/filtering/excel-filters.test.ts +86 -1
  243. package/src/filtering/excel-filters.ts +65 -0
  244. package/src/flex-render.ts +3 -3
  245. package/src/form-field.ts +87 -0
  246. package/src/index.ts +623 -469
  247. package/src/keyboard.test.ts +59 -59
  248. package/src/keyboard.ts +97 -97
  249. package/src/list-nav.test.ts +49 -0
  250. package/src/list-nav.ts +29 -0
  251. package/src/list-option.ts +2 -0
  252. package/src/menu-item.ts +21 -0
  253. package/src/menus.test.ts +48 -0
  254. package/src/menus.ts +639 -582
  255. package/src/merge-objects.ts +48 -48
  256. package/src/overlays.test.ts +90 -90
  257. package/src/panel-resize.ts +52 -0
  258. package/src/popover.bounds.test.ts +93 -0
  259. package/src/popover.ts +51 -7
  260. package/src/recurrence.test.ts +88 -1
  261. package/src/recurrence.ts +166 -15
  262. package/src/render-component.ts +28 -28
  263. package/src/row-drag.ts +10 -0
  264. package/src/scheduler-ical.test.ts +68 -0
  265. package/src/scheduler-ical.ts +180 -0
  266. package/src/scheduler-model.test.ts +546 -0
  267. package/src/scheduler-model.ts +853 -0
  268. package/src/scheduler-view.svelte.ts +33 -0
  269. package/src/selection.test.ts +754 -754
  270. package/src/selection.ts +600 -600
  271. package/src/server-data-source.test.ts +289 -289
  272. package/src/server-data-source.ts +413 -413
  273. package/src/sparkline.test.ts +68 -68
  274. package/src/sparkline.ts +169 -169
  275. package/src/spreadsheet.test.ts +489 -489
  276. package/src/spreadsheet.ts +312 -304
  277. package/src/static-functions.ts +11 -11
  278. package/src/subscribe.ts +38 -38
  279. package/src/svgrid-wrapper.types.ts +501 -501
  280. package/src/svgrid.behavior.test.ts +706 -706
  281. package/src/svgrid.charting.test.ts +527 -527
  282. package/src/svgrid.effect-driven-filter.svelte.test.ts +116 -0
  283. package/src/svgrid.features.test.ts +157 -157
  284. package/src/svgrid.new-features.wrapper.test.ts +251 -251
  285. package/src/svgrid.wrapper.test.ts +40 -40
  286. package/src/test-setup.ts +62 -62
  287. package/src/themes/index.ts +159 -159
  288. package/src/ui-app.types.ts +3 -0
  289. package/src/virtual.test.ts +6 -4
  290. package/src/virtualization/column-virtualizer.test.ts +27 -27
  291. package/src/virtualization/column-virtualizer.ts +30 -30
  292. package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
  293. package/src/virtualization/types.ts +30 -30
  294. package/src/virtualization/virtualizer.test.ts +47 -47
  295. package/src/virtualization/virtualizer.ts +296 -296
@@ -0,0 +1,333 @@
1
+ /**
2
+ * createForm - the HEADLESS core behind <SvForm>: values / errors / touched
3
+ * state, required + rule + custom validation (validate-on-blur-then-live), and a
4
+ * submit that validates every field first. No markup, no controls - you render
5
+ * the fields however you like and read/write through the core.
6
+ *
7
+ * ```svelte
8
+ * <script lang="ts">
9
+ * import { createForm } from '@svgrid/grid'
10
+ * const form = createForm({ fields: () => fields, onSubmit: (v) => save(v) })
11
+ * </script>
12
+ * <form onsubmit={(e) => { e.preventDefault(); form.submit() }}>
13
+ * {#each fields as f}
14
+ * <input value={form.value(f.name) ?? ''}
15
+ * oninput={(e) => form.setValue(f.name, e.currentTarget.value)}
16
+ * onblur={() => form.handleBlur(f.name)} />
17
+ * {#if form.error(f.name)}<span>{form.error(f.name)}</span>{/if}
18
+ * {/each}
19
+ * </form>
20
+ * ```
21
+ */
22
+ import { flattenFields, type FormField, type FormEntry } from './form-field'
23
+ import { runRules } from './validators'
24
+
25
+ export type FormConfig = {
26
+ /** The schema - a mix of flat fields and titled sections. */
27
+ fields: () => ReadonlyArray<FormEntry>
28
+ /** Seed values on creation. */
29
+ initial?: Record<string, any>
30
+ /** Called with the (visible-field) values on a valid submit. May be async;
31
+ * `submitting` stays true until the returned promise settles. */
32
+ onSubmit?: (values: Record<string, any>) => void | Promise<void>
33
+ onChange?: (values: Record<string, any>) => void
34
+ }
35
+
36
+ /** Deep-ish equality for dirty tracking: identity for primitives, structural
37
+ * (JSON) for arrays/objects. Good enough for form values. */
38
+ function eq(a: unknown, b: unknown): boolean {
39
+ if (Object.is(a, b)) return true
40
+ if (a && b && typeof a === 'object' && typeof b === 'object') {
41
+ try { return JSON.stringify(a) === JSON.stringify(b) } catch { return false }
42
+ }
43
+ return false
44
+ }
45
+
46
+ /** Resolve a `boolean | (values) => boolean` field flag. */
47
+ function resolveFlag(
48
+ flag: boolean | ((values: Record<string, any>) => boolean) | undefined,
49
+ dflt: boolean,
50
+ values: Record<string, any>,
51
+ ): boolean {
52
+ if (flag == null) return dflt
53
+ return typeof flag === 'function' ? !!flag(values) : !!flag
54
+ }
55
+
56
+ export function createForm(config: FormConfig) {
57
+ let values = $state<Record<string, any>>({ ...(config.initial ?? {}) })
58
+ let errors = $state<Record<string, string>>({})
59
+ let touched = $state<Record<string, boolean>>({})
60
+ let submitting = $state(false)
61
+ let validating = $state<Record<string, boolean>>({})
62
+ // Baseline for dirty tracking + reset. Rebased when `reset(next)` is passed.
63
+ let baseline = { ...(config.initial ?? {}) }
64
+ // Async-validation bookkeeping: debounce timers + a per-field run sequence so
65
+ // only the latest response wins (stale responses are dropped).
66
+ const asyncTimers = new Map<string, ReturnType<typeof setTimeout>>()
67
+ const asyncSeq = new Map<string, number>()
68
+
69
+ // The flat field list (sections flattened) drives all values/validation.
70
+ const flat = () => flattenFields(config.fields())
71
+ const fieldOf = (name: string) => flat().find((f) => f.name === name)
72
+ const isVisible = (name: string) => resolveFlag(fieldOf(name)?.visible, true, values)
73
+ const isDisabled = (name: string) => resolveFlag(fieldOf(name)?.disabled, false, values)
74
+
75
+ function setError(name: string, err: string | null | undefined) {
76
+ const next = { ...errors }
77
+ if (err) next[name] = err
78
+ else delete next[name]
79
+ errors = next
80
+ }
81
+
82
+ /** The synchronous checks: required, then declarative rules, then a sync
83
+ * `validate`. Returns the first error message, or null. `ctx` is the values
84
+ * object rules/validate see - the form values, or a single item for array
85
+ * rows (so within-row cross-field rules work). */
86
+ function syncError(field: FormField, v: any, ctx: Record<string, any> = values): string | null {
87
+ if (field.required && (v == null || v === '' || (Array.isArray(v) && !v.length)))
88
+ return `${field.label} is required`
89
+ if (field.rules) { const e = runRules(v, field.rules, ctx); if (e) return e }
90
+ if (field.validate) { const e = field.validate(v, ctx); if (e) return e }
91
+ return null
92
+ }
93
+
94
+ function cancelAsync(name: string) {
95
+ const t = asyncTimers.get(name)
96
+ if (t) { clearTimeout(t); asyncTimers.delete(name) }
97
+ // Bump the sequence so any in-flight response is treated as stale.
98
+ asyncSeq.set(name, (asyncSeq.get(name) ?? 0) + 1)
99
+ if (validating[name]) validating = { ...validating, [name]: false }
100
+ }
101
+
102
+ /** Run a field's async validator now (guarded against stale responses). */
103
+ async function runAsync(name: string): Promise<boolean> {
104
+ const field = fieldOf(name)
105
+ if (!field?.asyncValidate || !isVisible(name)) return true
106
+ if (syncError(field, values[name])) return false // sync error already shown
107
+ const seq = (asyncSeq.get(name) ?? 0) + 1
108
+ asyncSeq.set(name, seq)
109
+ validating = { ...validating, [name]: true }
110
+ try {
111
+ const err = await field.asyncValidate(values[name], values)
112
+ if (asyncSeq.get(name) !== seq) return true // superseded - ignore
113
+ setError(name, err ?? null)
114
+ return !err
115
+ } finally {
116
+ if (asyncSeq.get(name) === seq) validating = { ...validating, [name]: false }
117
+ }
118
+ }
119
+
120
+ function scheduleAsync(name: string) {
121
+ const field = fieldOf(name)
122
+ if (!field?.asyncValidate) return
123
+ const prev = asyncTimers.get(name)
124
+ if (prev) clearTimeout(prev)
125
+ asyncTimers.set(name, setTimeout(() => { asyncTimers.delete(name); runAsync(name) }, field.asyncDebounce ?? 300))
126
+ }
127
+
128
+ function validateField(name: string): boolean {
129
+ const field = fieldOf(name)
130
+ if (!field) return true
131
+ // Hidden fields never block submit; clear any stale error + pending check.
132
+ if (!isVisible(name)) { setError(name, null); cancelAsync(name); return true }
133
+ if (field.type === 'array') return validateArray(field)
134
+ const err = syncError(field, values[name])
135
+ setError(name, err)
136
+ if (err) { cancelAsync(name); return false }
137
+ // Sync passed - kick off the debounced async check if there is one.
138
+ if (field.asyncValidate) scheduleAsync(name)
139
+ return true
140
+ }
141
+
142
+ // --- Field arrays (repeatable groups) -----------------------------------
143
+ const arrayItems = (name: string): Record<string, any>[] =>
144
+ Array.isArray(values[name]) ? values[name] : []
145
+ const itemValue = (name: string, i: number, field: string) => arrayItems(name)[i]?.[field]
146
+ const itemError = (name: string, i: number, field: string) => errors[`${name}.${i}.${field}`]
147
+
148
+ function clearNamespacedErrors(name: string) {
149
+ const prefix = `${name}.`
150
+ const next = { ...errors }
151
+ let changed = false
152
+ for (const k of Object.keys(next)) if (k.startsWith(prefix)) { delete next[k]; changed = true }
153
+ if (changed) errors = next
154
+ }
155
+
156
+ function setItems(name: string, next: Record<string, any>[]) {
157
+ values = { ...values, [name]: next }
158
+ config.onChange?.(values)
159
+ }
160
+
161
+ function addItem(name: string, item?: Record<string, any>) {
162
+ const seed = item ?? Object.fromEntries((fieldOf(name)?.itemFields ?? []).map((f) => [f.name, undefined]))
163
+ setItems(name, [...arrayItems(name), seed])
164
+ }
165
+ function removeItem(name: string, i: number) {
166
+ const items = arrayItems(name).slice()
167
+ items.splice(i, 1)
168
+ clearNamespacedErrors(name) // item indices shift - drop stale keys
169
+ setItems(name, items)
170
+ }
171
+ function moveItem(name: string, from: number, to: number) {
172
+ const items = arrayItems(name).slice()
173
+ if (from < 0 || from >= items.length || to < 0 || to >= items.length) return
174
+ const [it] = items.splice(from, 1)
175
+ items.splice(to, 0, it as Record<string, any>)
176
+ clearNamespacedErrors(name)
177
+ setItems(name, items)
178
+ }
179
+ function setItemValue(name: string, i: number, field: string, v: any) {
180
+ setItems(name, arrayItems(name).map((it, ix) => (ix === i ? { ...it, [field]: v } : it)))
181
+ if (touched[`${name}.${i}.${field}`]) validateItemField(name, i, field)
182
+ }
183
+ function handleItemBlur(name: string, i: number, field: string) {
184
+ touched = { ...touched, [`${name}.${i}.${field}`]: true }
185
+ validateItemField(name, i, field)
186
+ }
187
+ function validateItemField(name: string, i: number, fieldName: string): boolean {
188
+ const itemField = fieldOf(name)?.itemFields?.find((f) => f.name === fieldName)
189
+ if (!itemField) return true
190
+ const item = arrayItems(name)[i] ?? {}
191
+ const err = syncError(itemField, item[fieldName], item)
192
+ setError(`${name}.${i}.${fieldName}`, err)
193
+ return !err
194
+ }
195
+ function validateArray(field: FormField): boolean {
196
+ const name = field.name
197
+ const items = arrayItems(name)
198
+ let arrErr: string | null = null
199
+ if (field.required && items.length === 0) arrErr = `${field.label} is required`
200
+ else if (field.minItems != null && items.length < field.minItems) arrErr = `Add at least ${field.minItems} item(s)`
201
+ else if (field.maxItems != null && items.length > field.maxItems) arrErr = `At most ${field.maxItems} item(s)`
202
+ setError(name, arrErr)
203
+ let ok = !arrErr
204
+ clearNamespacedErrors(name) // rebuild fresh per-item errors
205
+ for (let i = 0; i < items.length; i++)
206
+ for (const itf of field.itemFields ?? [])
207
+ if (!validateItemField(name, i, itf.name)) ok = false
208
+ return ok
209
+ }
210
+
211
+ function setValue(name: string, v: any) {
212
+ values = { ...values, [name]: v }
213
+ config.onChange?.(values)
214
+ // Re-validate live once the field has been blurred at least once.
215
+ if (touched[name]) validateField(name)
216
+ }
217
+
218
+ function handleBlur(name: string) {
219
+ touched = { ...touched, [name]: true }
220
+ validateField(name)
221
+ }
222
+
223
+ async function submit(): Promise<boolean> {
224
+ let ok = true
225
+ const t: Record<string, boolean> = { ...touched }
226
+ for (const f of flat()) {
227
+ if (!isVisible(f.name)) continue
228
+ t[f.name] = true
229
+ if (!validateField(f.name)) ok = false
230
+ }
231
+ touched = t
232
+ if (!ok) return false
233
+ // Await async validators (run now, skipping their debounce). Any failure blocks.
234
+ const asyncFields = flat().filter((f) => f.asyncValidate && isVisible(f.name))
235
+ if (asyncFields.length) {
236
+ for (const f of asyncFields) { const timer = asyncTimers.get(f.name); if (timer) { clearTimeout(timer); asyncTimers.delete(f.name) } }
237
+ const results = await Promise.all(asyncFields.map((f) => runAsync(f.name)))
238
+ if (results.some((r) => !r)) return false
239
+ }
240
+ // Exclude hidden fields from the submitted payload; keep any non-field keys.
241
+ const payload = { ...values }
242
+ for (const f of flat()) if (!isVisible(f.name)) delete payload[f.name]
243
+ submitting = true
244
+ try {
245
+ await config.onSubmit?.(payload)
246
+ } finally {
247
+ submitting = false
248
+ }
249
+ return true
250
+ }
251
+
252
+ /** Validate a subset of fields by name (sync + their async validators). Used
253
+ * to gate a wizard step before advancing. Returns true when all pass. */
254
+ async function validateFields(names: string[]): Promise<boolean> {
255
+ let ok = true
256
+ const t = { ...touched }
257
+ for (const name of names) {
258
+ if (!isVisible(name)) continue
259
+ t[name] = true
260
+ if (!validateField(name)) ok = false
261
+ }
262
+ touched = t
263
+ if (!ok) return false
264
+ const asyncNames = names.filter((n) => fieldOf(n)?.asyncValidate && isVisible(n))
265
+ if (asyncNames.length) {
266
+ for (const n of asyncNames) { const timer = asyncTimers.get(n); if (timer) { clearTimeout(timer); asyncTimers.delete(n) } }
267
+ const results = await Promise.all(asyncNames.map((n) => runAsync(n)))
268
+ if (results.some((r) => !r)) return false
269
+ }
270
+ return true
271
+ }
272
+
273
+ /** Restore values to the baseline (or a new one), clearing errors + touched. */
274
+ function reset(next?: Record<string, any>) {
275
+ if (next) baseline = { ...next }
276
+ values = { ...baseline }
277
+ errors = {}
278
+ touched = {}
279
+ config.onChange?.(values)
280
+ }
281
+
282
+ /** Inject errors (e.g. server-side validation after submit) and mark those
283
+ * fields touched so the messages show. Empty message clears a field's error. */
284
+ function setErrors(map: Record<string, string>) {
285
+ const nextErr = { ...errors }
286
+ const nextTouched = { ...touched }
287
+ for (const [k, msg] of Object.entries(map)) {
288
+ if (msg) { nextErr[k] = msg; nextTouched[k] = true }
289
+ else delete nextErr[k]
290
+ }
291
+ errors = nextErr
292
+ touched = nextTouched
293
+ }
294
+
295
+ const isFieldDirty = (name: string) => !eq(values[name], baseline[name])
296
+
297
+ return {
298
+ get values() { return values },
299
+ get submitting() { return submitting },
300
+ get isDirty() {
301
+ const keys = new Set([...Object.keys(values), ...Object.keys(baseline)])
302
+ for (const k of keys) if (!eq(values[k], baseline[k])) return true
303
+ return false
304
+ },
305
+ value: (name: string) => values[name],
306
+ error: (name: string): string | undefined => errors[name],
307
+ isTouched: (name: string) => !!touched[name],
308
+ hasError: (name: string) => !!errors[name],
309
+ isValidating: (name: string) => !!validating[name],
310
+ isFieldDirty,
311
+ isVisible,
312
+ isDisabled,
313
+ entries: () => config.fields(),
314
+ // Field arrays
315
+ arrayItems,
316
+ itemValue,
317
+ itemError,
318
+ addItem,
319
+ removeItem,
320
+ moveItem,
321
+ setItemValue,
322
+ handleItemBlur,
323
+ setValue,
324
+ handleBlur,
325
+ validateField,
326
+ validateFields,
327
+ submit,
328
+ reset,
329
+ setErrors,
330
+ }
331
+ }
332
+
333
+ export type Form = ReturnType<typeof createForm>
@@ -1,42 +1,42 @@
1
- import { createSvGridCore } from './core'
2
- import type {
3
- RowData,
4
- SvGrid,
5
- SvGridOptions,
6
- TableFeatures,
7
- } from './core'
8
-
9
- export type SvelteGrid<
10
- TFeatures extends TableFeatures,
11
- TData extends RowData,
12
- TSelected = {},
13
- > = SvGrid<TData> & {
14
- readonly state: Readonly<TSelected>
15
- }
16
-
17
- export function createSvGrid<
18
- TFeatures extends TableFeatures,
19
- TData extends RowData,
20
- TSelected = {},
21
- >(
22
- gridOptions: SvGridOptions<TFeatures, TData>,
23
- selector: (state: Record<string, any>) => TSelected = () => ({}) as TSelected,
24
- ): SvelteGrid<TFeatures, TData, TSelected> {
25
- const grid = createSvGridCore(gridOptions) as SvelteGrid<TFeatures, TData, TSelected>
26
- let selected = $state(selector(grid.store.state))
27
- grid.store.subscribe(() => {
28
- selected = selector(grid.store.state)
29
- })
30
-
31
- Object.defineProperty(grid, 'state', {
32
- get() {
33
- return selected
34
- },
35
- configurable: true,
36
- enumerable: true,
37
- })
38
-
39
- return grid
40
- }
41
-
42
- export const createGrid = createSvGrid
1
+ import { createSvGridCore } from './core'
2
+ import type {
3
+ RowData,
4
+ SvGrid,
5
+ SvGridOptions,
6
+ TableFeatures,
7
+ } from './core'
8
+
9
+ export type SvelteGrid<
10
+ TFeatures extends TableFeatures,
11
+ TData extends RowData,
12
+ TSelected = {},
13
+ > = SvGrid<TData> & {
14
+ readonly state: Readonly<TSelected>
15
+ }
16
+
17
+ export function createSvGrid<
18
+ TFeatures extends TableFeatures,
19
+ TData extends RowData,
20
+ TSelected = {},
21
+ >(
22
+ gridOptions: SvGridOptions<TFeatures, TData>,
23
+ selector: (state: Record<string, any>) => TSelected = () => ({}) as TSelected,
24
+ ): SvelteGrid<TFeatures, TData, TSelected> {
25
+ const grid = createSvGridCore(gridOptions) as SvelteGrid<TFeatures, TData, TSelected>
26
+ let selected = $state(selector(grid.store.state))
27
+ grid.store.subscribe(() => {
28
+ selected = selector(grid.store.state)
29
+ })
30
+
31
+ Object.defineProperty(grid, 'state', {
32
+ get() {
33
+ return selected
34
+ },
35
+ configurable: true,
36
+ enumerable: true,
37
+ })
38
+
39
+ return grid
40
+ }
41
+
42
+ export const createGrid = createSvGrid
@@ -1,10 +1,10 @@
1
- import { describe, expect, it } from 'vitest'
2
- import { createGridState } from './index'
3
-
4
- describe('createGrid', () => {
5
- it('createGridState updates controlled values', () => {
6
- const [sorting, setSorting] = createGridState([{ id: 'age', desc: false }])
7
- setSorting((prev) => prev.map((entry) => ({ ...entry, desc: true })))
8
- expect(sorting()[0]?.desc).toBe(true)
9
- })
10
- })
1
+ import { describe, expect, it } from 'vitest'
2
+ import { createGridState } from './index'
3
+
4
+ describe('createGrid', () => {
5
+ it('createGridState updates controlled values', () => {
6
+ const [sorting, setSorting] = createGridState([{ id: 'age', desc: false }])
7
+ setSorting((prev) => prev.map((entry) => ({ ...entry, desc: true })))
8
+ expect(sorting()[0]?.desc).toBe(true)
9
+ })
10
+ })
@@ -1,17 +1,17 @@
1
- import type { Updater } from './core'
2
-
3
- export function createGridState<TState>(
4
- initialValue: TState,
5
- ): [() => TState, (updater: Updater<TState>) => void] {
6
- let value = $state(initialValue)
7
-
8
- return [
9
- () => value,
10
- (updater: Updater<TState>) => {
11
- if (updater instanceof Function) value = updater(value)
12
- else value = updater
13
- },
14
- ]
15
- }
16
-
17
- export const createSvGridState = createGridState
1
+ import type { Updater } from './core'
2
+
3
+ export function createGridState<TState>(
4
+ initialValue: TState,
5
+ ): [() => TState, (updater: Updater<TState>) => void] {
6
+ let value = $state(initialValue)
7
+
8
+ return [
9
+ () => value,
10
+ (updater: Updater<TState>) => {
11
+ if (updater instanceof Function) value = updater(value)
12
+ else value = updater
13
+ },
14
+ ]
15
+ }
16
+
17
+ export const createSvGridState = createGridState
@@ -0,0 +1,202 @@
1
+ /**
2
+ * createMenu - the HEADLESS core behind <SvMenuList> / <SvMenu>, in the same
3
+ * spirit as `createListbox`: a runes-based state machine for ONE menu level -
4
+ * roving focus over enabled items, submenu open/collapse, and the full WAI-ARIA
5
+ * menu keyboard contract (arrows, Home/End, Enter/Space, Escape, Tab, and
6
+ * rtl-aware ArrowLeft/Right) - with **prop-getters** you spread onto your own
7
+ * markup. No styles, no DOM assumptions.
8
+ *
9
+ * A menu tree is recursive, so you create one core per nesting level (exactly
10
+ * what the recursive <SvMenuList> does). The styled component is just one
11
+ * renderer over this core.
12
+ *
13
+ * ```svelte
14
+ * <script lang="ts">
15
+ * import { createMenu, type MenuItem } from '@svgrid/grid'
16
+ * let listEl = $state<HTMLElement | null>(null)
17
+ * const menu = createMenu({
18
+ * items: () => items,
19
+ * onSelect: (it) => run(it),
20
+ * onClose: () => close(),
21
+ * focusItem: (i) => queueMicrotask(() => listEl?.querySelector(`[data-mi="${i}"]`)?.focus()),
22
+ * })
23
+ * </script>
24
+ * <div bind:this={listEl} {...menu.listProps()}>
25
+ * {#each items as item, i}
26
+ * <button {...menu.itemProps(i)}>{item.label}</button>
27
+ * {/each}
28
+ * </div>
29
+ * ```
30
+ */
31
+ import type { MenuItem } from './menu-item'
32
+ import type { EditorDir } from './editor-contract'
33
+ import { enabledIndices, wrapMove } from './list-nav'
34
+
35
+ /** Reactive inputs are getters so the core tracks live prop changes (the same
36
+ * controlled pattern as `createListbox`). */
37
+ export type MenuConfig = {
38
+ items: () => ReadonlyArray<MenuItem>
39
+ /** Report the chosen leaf item (a non-parent menuitem). */
40
+ onSelect: (item: MenuItem) => void
41
+ /** Close the WHOLE menu tree - a leaf was chosen, or Escape / Tab / dismissal. */
42
+ onClose: () => void
43
+ /** This level is a submenu: enables ArrowLeft (or ArrowRight under rtl) to
44
+ * collapse back into the opener. The root level has nowhere to collapse. */
45
+ submenu?: () => boolean
46
+ /** Collapse just THIS submenu level and refocus the item that opened it. */
47
+ onCollapse?: () => void
48
+ /** Text direction; under `rtl` the open/collapse arrow keys swap. */
49
+ dir?: () => EditorDir | undefined
50
+ /** DOM focus hook, invoked ONLY on keyboard navigation (never on hover) so the
51
+ * core stays DOM-free. Receives the item index that should take focus. */
52
+ focusItem?: (index: number) => void
53
+ }
54
+
55
+ export type MenuItemProps = {
56
+ role: 'menuitem'
57
+ 'data-mi': number
58
+ tabindex: 0 | -1
59
+ 'aria-haspopup': 'menu' | undefined
60
+ 'aria-expanded': boolean | undefined
61
+ 'aria-disabled': true | undefined
62
+ disabled: boolean | undefined
63
+ onkeydown: (event: KeyboardEvent) => void
64
+ onpointerenter: () => void
65
+ onclick: () => void
66
+ }
67
+
68
+ let uid = 0
69
+
70
+ export function createMenu(config: MenuConfig) {
71
+ const items = () => config.items()
72
+ const submenu = () => config.submenu?.() ?? false
73
+ const rtl = () => (config.dir?.() ?? 'ltr') === 'rtl'
74
+
75
+ // -1 = nothing focused yet (the first enabled item is the tab stop).
76
+ let active = $state(-1)
77
+ let openSub = $state(-1)
78
+
79
+ // Real (non-separator, non-disabled) item indices, for roving focus.
80
+ const focusables = $derived(
81
+ enabledIndices(items().length, (i) => {
82
+ const it = items()[i]
83
+ return !it || it.separator === true || it.disabled === true
84
+ }),
85
+ )
86
+
87
+ /** Move focus to item `i` (keyboard): highlight it, close any open submenu at
88
+ * this level, and drive real DOM focus through the host's hook. */
89
+ function focus(i: number) {
90
+ active = i
91
+ openSub = -1
92
+ config.focusItem?.(i)
93
+ }
94
+ function move(delta: number) {
95
+ const next = wrapMove(focusables, active, delta)
96
+ if (next >= 0) focus(next)
97
+ }
98
+ function first() {
99
+ const f = focusables[0]
100
+ if (f != null) focus(f)
101
+ }
102
+ function last() {
103
+ const l = focusables.at(-1)
104
+ if (l != null) focus(l)
105
+ }
106
+
107
+ /** Hover highlight: sets active WITHOUT stealing DOM focus, and opens a parent
108
+ * item's submenu (mirrors the pointerenter behaviour). */
109
+ function hover(i: number) {
110
+ const it = items()[i]
111
+ if (!it || it.disabled || it.separator) return
112
+ active = i
113
+ openSub = it.children?.length ? i : -1
114
+ }
115
+
116
+ function choose(item: MenuItem) {
117
+ if (item.disabled || item.separator) return
118
+ if (item.children?.length) {
119
+ openSub = items().indexOf(item)
120
+ return
121
+ }
122
+ item.onSelect?.()
123
+ config.onSelect(item)
124
+ config.onClose()
125
+ }
126
+
127
+ function onItemKeydown(e: KeyboardEvent, i: number) {
128
+ const item = items()[i]
129
+ // Open this item's submenu (ArrowRight, or ArrowLeft under rtl).
130
+ const openThis = () => {
131
+ if (item?.children?.length) {
132
+ e.preventDefault()
133
+ openSub = i
134
+ }
135
+ }
136
+ // Collapse just THIS list back into its parent (opposite key under rtl).
137
+ const collapseThis = () => {
138
+ if (submenu()) {
139
+ e.preventDefault()
140
+ config.onCollapse?.()
141
+ }
142
+ }
143
+ switch (e.key) {
144
+ case 'ArrowDown': e.preventDefault(); move(1); break
145
+ case 'ArrowUp': e.preventDefault(); move(-1); break
146
+ case 'Home': e.preventDefault(); first(); break
147
+ case 'End': e.preventDefault(); last(); break
148
+ case 'ArrowRight': (rtl() ? collapseThis : openThis)(); break
149
+ case 'ArrowLeft': (rtl() ? openThis : collapseThis)(); break
150
+ case 'Enter':
151
+ case ' ': e.preventDefault(); if (item) choose(item); break
152
+ case 'Escape': e.preventDefault(); config.onClose(); break
153
+ // Tab leaves the menu entirely (WAI-ARIA menu-button pattern): close the
154
+ // whole tree rather than trapping focus inside a portalled panel.
155
+ case 'Tab': config.onClose(); break
156
+ }
157
+ }
158
+
159
+ function itemTabIndex(i: number): 0 | -1 {
160
+ return active === i || (active === -1 && i === focusables[0]) ? 0 : -1
161
+ }
162
+
163
+ return {
164
+ get active() { return active },
165
+ get openSub() { return openSub },
166
+ /** Enabled (focusable) item indices. */
167
+ get focusables() { return focusables },
168
+ isActive: (i: number) => i === active,
169
+ isSubOpen: (i: number) => i === openSub,
170
+ itemTabIndex,
171
+ move,
172
+ first,
173
+ last,
174
+ /** Highlight + DOM-focus item `i` (e.g. to refocus a submenu's opener). */
175
+ focus,
176
+ hover,
177
+ choose,
178
+ onItemKeydown,
179
+ /** Spread onto the menu container element. */
180
+ listProps: () => ({ role: 'menu' as const }),
181
+ /** Spread onto the menuitem element at `i`. */
182
+ itemProps: (i: number): MenuItemProps => {
183
+ const it = items()[i]
184
+ const hasKids = !!it?.children?.length
185
+ return {
186
+ role: 'menuitem',
187
+ 'data-mi': i,
188
+ tabindex: itemTabIndex(i),
189
+ 'aria-haspopup': hasKids ? 'menu' : undefined,
190
+ 'aria-expanded': hasKids ? openSub === i : undefined,
191
+ 'aria-disabled': it?.disabled ? true : undefined,
192
+ disabled: it?.disabled,
193
+ onkeydown: (e: KeyboardEvent) => onItemKeydown(e, i),
194
+ onpointerenter: () => hover(i),
195
+ onclick: () => { if (it) choose(it) },
196
+ }
197
+ },
198
+ }
199
+ }
200
+
201
+ export type Menu = ReturnType<typeof createMenu>
202
+ export type { MenuItem }