@svgrid/grid 2.2.16 → 2.2.17

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 (290) hide show
  1. package/README.md +1 -1
  2. package/dist/GridFooter.svelte +12 -11
  3. package/dist/SvAnchor.svelte +65 -0
  4. package/dist/SvAnchor.svelte.d.ts +24 -0
  5. package/dist/SvAspectRatio.svelte +38 -0
  6. package/dist/SvAspectRatio.svelte.d.ts +16 -0
  7. package/dist/SvAutoComplete.svelte +52 -7
  8. package/dist/SvAutoComplete.svelte.d.ts +4 -1
  9. package/dist/SvBlockquote.svelte +43 -0
  10. package/dist/SvBlockquote.svelte.d.ts +16 -0
  11. package/dist/SvCalendar.svelte +33 -2
  12. package/dist/SvCalendar.svelte.d.ts +8 -1
  13. package/dist/SvCheckBox.svelte +6 -2
  14. package/dist/SvCode.svelte +52 -0
  15. package/dist/SvCode.svelte.d.ts +19 -0
  16. package/dist/SvCollapsible.svelte +75 -0
  17. package/dist/SvCollapsible.svelte.d.ts +27 -0
  18. package/dist/SvColorInput.svelte +11 -17
  19. package/dist/SvColorInput.svelte.d.ts +2 -0
  20. package/dist/SvComboBox.svelte +85 -8
  21. package/dist/SvComboBox.svelte.d.ts +14 -0
  22. package/dist/SvCountryInput.svelte +2 -2
  23. package/dist/SvCountryInput.svelte.d.ts +1 -1
  24. package/dist/SvDropDownList.svelte +154 -51
  25. package/dist/SvDropDownList.svelte.d.ts +17 -3
  26. package/dist/SvDurationInput.svelte +59 -41
  27. package/dist/SvDurationInput.svelte.d.ts +18 -0
  28. package/dist/SvField.svelte +203 -12
  29. package/dist/SvField.svelte.d.ts +45 -8
  30. package/dist/SvForm.svelte +195 -21
  31. package/dist/SvForm.svelte.d.ts +23 -0
  32. package/dist/SvGrid.controller.svelte.d.ts +12 -1
  33. package/dist/SvGrid.controller.svelte.js +87 -7
  34. package/dist/SvGrid.svelte +383 -181
  35. package/dist/SvGrid.types.d.ts +123 -2
  36. package/dist/SvGridChartView.svelte +70 -0
  37. package/dist/SvGridChartView.svelte.d.ts +11 -0
  38. package/dist/SvGridDropdown.svelte +9 -2
  39. package/dist/SvGridSelect.svelte +5 -2
  40. package/dist/SvGridSelect.svelte.d.ts +1 -1
  41. package/dist/SvGroup.svelte +41 -0
  42. package/dist/SvGroup.svelte.d.ts +27 -0
  43. package/dist/SvHoverCard.svelte +70 -0
  44. package/dist/SvHoverCard.svelte.d.ts +41 -0
  45. package/dist/SvKbd.svelte +51 -0
  46. package/dist/SvKbd.svelte.d.ts +18 -0
  47. package/dist/SvList.svelte +55 -0
  48. package/dist/SvList.svelte.d.ts +23 -0
  49. package/dist/SvListBox.svelte +166 -77
  50. package/dist/SvListBox.svelte.d.ts +17 -4
  51. package/dist/SvLoadingOverlay.svelte +53 -0
  52. package/dist/SvLoadingOverlay.svelte.d.ts +28 -0
  53. package/dist/SvMark.svelte +36 -0
  54. package/dist/SvMark.svelte.d.ts +14 -0
  55. package/dist/SvMaskedInput.svelte +50 -35
  56. package/dist/SvMaskedInput.svelte.d.ts +17 -2
  57. package/dist/SvMenuList.svelte +32 -3
  58. package/dist/SvMenubar.svelte +200 -0
  59. package/dist/SvMenubar.svelte.d.ts +18 -0
  60. package/dist/SvMultiSelect.svelte +7 -4
  61. package/dist/SvMultiSelect.svelte.d.ts +1 -1
  62. package/dist/SvNumberInput.svelte +79 -42
  63. package/dist/SvNumberInput.svelte.d.ts +25 -0
  64. package/dist/SvOtpInput.svelte +2 -2
  65. package/dist/SvPasswordInput.svelte +51 -31
  66. package/dist/SvPasswordInput.svelte.d.ts +17 -0
  67. package/dist/SvPhoneInput.svelte +39 -28
  68. package/dist/SvPhoneInput.svelte.d.ts +4 -0
  69. package/dist/SvPopconfirm.svelte +79 -0
  70. package/dist/SvPopconfirm.svelte.d.ts +42 -0
  71. package/dist/SvPopover.svelte +87 -29
  72. package/dist/SvPopover.svelte.d.ts +18 -4
  73. package/dist/SvRadioGroup.svelte +5 -1
  74. package/dist/SvResult.svelte +73 -0
  75. package/dist/SvResult.svelte.d.ts +28 -0
  76. package/dist/SvSegmented.svelte +114 -0
  77. package/dist/SvSegmented.svelte.d.ts +29 -0
  78. package/dist/SvSimpleGrid.svelte +41 -0
  79. package/dist/SvSimpleGrid.svelte.d.ts +25 -0
  80. package/dist/SvSlider.svelte +23 -10
  81. package/dist/SvSpinner.svelte +43 -0
  82. package/dist/SvSpinner.svelte.d.ts +20 -0
  83. package/dist/SvStack.svelte +37 -0
  84. package/dist/SvStack.svelte.d.ts +24 -0
  85. package/dist/SvSwitchButton.svelte +6 -1
  86. package/dist/SvTagsInput.svelte +9 -5
  87. package/dist/SvText.svelte +76 -0
  88. package/dist/SvText.svelte.d.ts +31 -0
  89. package/dist/SvTextInput.svelte +75 -43
  90. package/dist/SvTextInput.svelte.d.ts +28 -1
  91. package/dist/SvTimePicker.svelte +32 -2
  92. package/dist/SvTimePicker.svelte.d.ts +8 -9
  93. package/dist/SvTitle.svelte +57 -0
  94. package/dist/SvTitle.svelte.d.ts +24 -0
  95. package/dist/SvToaster.svelte +41 -6
  96. package/dist/SvToggleButton.svelte +4 -1
  97. package/dist/SvTooltip.svelte +68 -34
  98. package/dist/SvTooltip.svelte.d.ts +14 -2
  99. package/dist/SvTree.svelte +81 -21
  100. package/dist/SvTree.svelte.d.ts +3 -2
  101. package/dist/SvTreeSelect.svelte +5 -2
  102. package/dist/SvTreeSelect.svelte.d.ts +1 -1
  103. package/dist/SvVisuallyHidden.svelte +49 -0
  104. package/dist/SvVisuallyHidden.svelte.d.ts +19 -0
  105. package/dist/ai.d.ts +289 -0
  106. package/dist/ai.js +1013 -0
  107. package/dist/board-view.svelte.d.ts +22 -0
  108. package/dist/board-view.svelte.js +13 -0
  109. package/dist/build-api.js +36 -6
  110. package/dist/cdn/GridMenus-0AK9O7qG.js +420 -0
  111. package/dist/cdn/GridMenus-BjUoCXoD.js +416 -0
  112. package/dist/cdn/SvDateTimePicker-CYC7gRER.js +2176 -0
  113. package/dist/cdn/SvDateTimePicker-DtQKd4Ns.js +2198 -0
  114. package/dist/cdn/SvGridChart-BcLCYvw9.js +1195 -0
  115. package/dist/cdn/SvGridChart-BhnFdLd0.js +1203 -0
  116. package/dist/cdn/SvGridChartPanel-Cmx9DHAS.js +561 -0
  117. package/dist/cdn/SvGridChartPanel-DvjwxLvK.js +577 -0
  118. package/dist/cdn/SvGridChartView-B40DTJSX.js +54 -0
  119. package/dist/cdn/SvGridChartView-PwqI4s02.js +53 -0
  120. package/dist/cdn/SvGridDropdown-DjALc_4f.js +255 -0
  121. package/dist/cdn/SvGridDropdown-hpnHvOGt.js +254 -0
  122. package/dist/cdn/chart-T1usDN0o.js +1212 -0
  123. package/dist/cdn/disclose-version-DoD9AFD_.js +2958 -0
  124. package/dist/cdn/export-format-JT5pWVT8.js +561 -0
  125. package/dist/cdn/popover-P-9P2YYn.js +69 -0
  126. package/dist/cdn/rolldown-runtime-Dy4uBu1J.js +11 -0
  127. package/dist/cdn/src-BLhdLmoN.js +25707 -0
  128. package/dist/cdn/src-BwnUVj3T.js +25623 -0
  129. package/dist/cdn/svgrid.js +9 -32897
  130. package/dist/cdn/svgrid.svelte-external.js +9 -30052
  131. package/dist/cell-formatting.d.ts +2 -2
  132. package/dist/cell-render.js +4 -4
  133. package/dist/chart-view.svelte.d.ts +25 -0
  134. package/dist/chart-view.svelte.js +14 -0
  135. package/dist/createCombobox.svelte.d.ts +5 -0
  136. package/dist/createCombobox.svelte.js +21 -5
  137. package/dist/createDropdownList.svelte.d.ts +3 -0
  138. package/dist/createDropdownList.svelte.js +8 -2
  139. package/dist/createForm.svelte.d.ts +18 -0
  140. package/dist/createForm.svelte.js +51 -6
  141. package/dist/createMenu.svelte.js +27 -0
  142. package/dist/createPopoverSelect.svelte.d.ts +3 -0
  143. package/dist/createPopoverSelect.svelte.js +9 -1
  144. package/dist/createSlider.svelte.d.ts +8 -21
  145. package/dist/createSlider.svelte.js +34 -0
  146. package/dist/createTooltip.svelte.d.ts +12 -0
  147. package/dist/createTooltip.svelte.js +72 -9
  148. package/dist/editor-contract.d.ts +15 -0
  149. package/dist/export-provider.d.ts +27 -0
  150. package/dist/export-provider.js +9 -0
  151. package/dist/filter-operators.d.ts +6 -0
  152. package/dist/filter-operators.js +33 -0
  153. package/dist/form-field.d.ts +45 -2
  154. package/dist/grid-messages.d.ts +61 -0
  155. package/dist/grid-messages.js +72 -0
  156. package/dist/group-display.d.ts +62 -0
  157. package/dist/group-display.js +78 -0
  158. package/dist/index.d.ts +36 -4
  159. package/dist/index.js +43 -2
  160. package/dist/js-scroller.svelte.d.ts +35 -0
  161. package/dist/js-scroller.svelte.js +154 -0
  162. package/dist/list-option.d.ts +46 -0
  163. package/dist/list-option.js +53 -0
  164. package/dist/menus.js +25 -11
  165. package/dist/pivot-view.svelte.d.ts +72 -0
  166. package/dist/pivot-view.svelte.js +13 -0
  167. package/dist/positioning.d.ts +111 -0
  168. package/dist/positioning.js +191 -0
  169. package/dist/scheduler-model.d.ts +3 -0
  170. package/dist/scheduler-model.js +22 -5
  171. package/dist/svgrid-wrapper.types.d.ts +18 -0
  172. package/dist/toast-store.svelte.d.ts +54 -0
  173. package/dist/toast-store.svelte.js +65 -15
  174. package/package.json +9 -1
  175. package/src/GridFooter.svelte +12 -11
  176. package/src/SvAnchor.svelte +65 -0
  177. package/src/SvAspectRatio.svelte +38 -0
  178. package/src/SvAutoComplete.svelte +52 -7
  179. package/src/SvBlockquote.svelte +43 -0
  180. package/src/SvCalendar.svelte +33 -2
  181. package/src/SvCalendar.test.ts +15 -0
  182. package/src/SvCheckBox.svelte +6 -2
  183. package/src/SvCode.svelte +52 -0
  184. package/src/SvCollapsible.svelte +75 -0
  185. package/src/SvColorInput.svelte +11 -17
  186. package/src/SvComboBox.svelte +85 -8
  187. package/src/SvCountryInput.svelte +2 -2
  188. package/src/SvDropDownList.svelte +154 -51
  189. package/src/SvDurationInput.svelte +59 -41
  190. package/src/SvField.svelte +203 -12
  191. package/src/SvForm.svelte +195 -21
  192. package/src/SvForm.test.ts +112 -0
  193. package/src/SvGrid.controller.svelte.ts +88 -7
  194. package/src/SvGrid.svelte +383 -181
  195. package/src/SvGrid.types.ts +125 -1
  196. package/src/SvGridChartView.svelte +70 -0
  197. package/src/SvGridDropdown.svelte +9 -2
  198. package/src/SvGridSelect.svelte +5 -2
  199. package/src/SvGroup.svelte +41 -0
  200. package/src/SvHoverCard.svelte +70 -0
  201. package/src/SvKbd.svelte +51 -0
  202. package/src/SvList.svelte +55 -0
  203. package/src/SvListBox.svelte +166 -77
  204. package/src/SvLoadingOverlay.svelte +53 -0
  205. package/src/SvMark.svelte +36 -0
  206. package/src/SvMaskedInput.svelte +50 -35
  207. package/src/SvMenuList.svelte +32 -3
  208. package/src/SvMenubar.svelte +200 -0
  209. package/src/SvMultiSelect.svelte +7 -4
  210. package/src/SvNumberInput.svelte +79 -42
  211. package/src/SvOtpInput.svelte +2 -2
  212. package/src/SvPasswordInput.svelte +51 -31
  213. package/src/SvPhoneInput.svelte +39 -28
  214. package/src/SvPopconfirm.svelte +79 -0
  215. package/src/SvPopover.svelte +87 -29
  216. package/src/SvRadioGroup.svelte +5 -1
  217. package/src/SvResult.svelte +73 -0
  218. package/src/SvSegmented.svelte +114 -0
  219. package/src/SvSimpleGrid.svelte +41 -0
  220. package/src/SvSlider.svelte +23 -10
  221. package/src/SvSpinner.svelte +43 -0
  222. package/src/SvStack.svelte +37 -0
  223. package/src/SvSwitchButton.svelte +6 -1
  224. package/src/SvTagsInput.svelte +9 -5
  225. package/src/SvText.svelte +76 -0
  226. package/src/SvTextInput.svelte +75 -43
  227. package/src/SvTimePicker.svelte +32 -2
  228. package/src/SvTitle.svelte +57 -0
  229. package/src/SvToaster.svelte +41 -6
  230. package/src/SvToaster.test.ts +32 -0
  231. package/src/SvToggleButton.svelte +4 -1
  232. package/src/SvTooltip.svelte +68 -34
  233. package/src/SvTree.svelte +81 -21
  234. package/src/SvTreeSelect.svelte +5 -2
  235. package/src/SvVisuallyHidden.svelte +49 -0
  236. package/src/ai.test.ts +502 -0
  237. package/src/ai.ts +1391 -0
  238. package/src/board-view.svelte.ts +33 -0
  239. package/src/build-api.coverage.test.ts +101 -0
  240. package/src/build-api.ts +43 -18
  241. package/src/builtin-editors.grid.test.ts +1 -1
  242. package/src/cell-formatting.ts +5 -5
  243. package/src/cell-render.ts +4 -4
  244. package/src/chart-view.svelte.ts +36 -0
  245. package/src/createCombobox.svelte.ts +17 -5
  246. package/src/createDropdownList.svelte.ts +8 -2
  247. package/src/createForm.svelte.ts +59 -6
  248. package/src/createMenu.svelte.ts +23 -0
  249. package/src/createPopoverSelect.svelte.ts +8 -1
  250. package/src/createSlider.svelte.ts +20 -0
  251. package/src/createTooltip.svelte.ts +67 -9
  252. package/src/editor-contract.ts +16 -0
  253. package/src/export-provider.ts +40 -0
  254. package/src/filter-operators.ts +37 -0
  255. package/src/form-field.ts +42 -2
  256. package/src/grid-messages.test.ts +24 -0
  257. package/src/grid-messages.ts +137 -0
  258. package/src/group-display.test.ts +77 -0
  259. package/src/group-display.ts +117 -0
  260. package/src/i18n.grid.test.ts +73 -0
  261. package/src/index.ts +91 -2
  262. package/src/js-scroller.svelte.ts +173 -0
  263. package/src/list-option.test.ts +56 -0
  264. package/src/list-option.ts +81 -0
  265. package/src/menus.test.ts +32 -0
  266. package/src/menus.ts +25 -12
  267. package/src/pivot-view.svelte.ts +90 -0
  268. package/src/pivot-view.test.ts +38 -0
  269. package/src/pivot.grid.test.ts +102 -0
  270. package/src/positioning.test.ts +141 -0
  271. package/src/positioning.ts +266 -0
  272. package/src/scheduler-model.test.ts +16 -0
  273. package/src/scheduler-model.ts +24 -5
  274. package/src/svgrid-wrapper.types.ts +23 -0
  275. package/src/svgrid.charting.test.ts +8 -1
  276. package/src/svgrid.comments-autocomplete.test.ts +7 -2
  277. package/src/svgrid.context-menu.test.ts +8 -3
  278. package/src/toast-store.svelte.ts +98 -0
  279. package/src/toast-store.test.ts +62 -1
  280. package/src/ui-breadth.test.ts +138 -0
  281. package/src/ui-buttons.test.ts +39 -0
  282. package/src/ui-inputs.test.ts +2 -2
  283. package/src/ui-range.test.ts +9 -0
  284. package/src/ui-selection.test.ts +36 -0
  285. package/src/ui-tier1.test.ts +1 -1
  286. package/src/virtual.test.ts +20 -6
  287. package/dist/SvGridBoard.svelte +0 -2339
  288. package/dist/SvGridBoard.svelte.d.ts +0 -31
  289. package/src/SvGridBoard.svelte +0 -2339
  290. package/src/board.test.ts +0 -927
package/src/SvForm.svelte CHANGED
@@ -12,18 +12,30 @@
12
12
  * a flat `FormField[]` or mix in titled `FormSection`s; with `stepper` the
13
13
  * sections become a validated wizard. Emits `onSubmit(values)` only when valid.
14
14
  */
15
+ import { untrack } from 'svelte'
15
16
  import SvNumberInput from './SvNumberInput.svelte'
16
17
  import SvPasswordInput from './SvPasswordInput.svelte'
17
18
  import SvDropDownList from './SvDropDownList.svelte'
19
+ import SvMultiSelect from './SvMultiSelect.svelte'
18
20
  import SvCheckBox from './SvCheckBox.svelte'
19
21
  import SvSwitchButton from './SvSwitchButton.svelte'
20
22
  import SvDateTimePicker from './SvDateTimePicker.svelte'
21
23
  import SvColorInput from './SvColorInput.svelte'
22
24
  import SvRating from './SvRating.svelte'
25
+ import SvRadioGroup from './SvRadioGroup.svelte'
26
+ import SvSlider from './SvSlider.svelte'
27
+ import SvTagsInput from './SvTagsInput.svelte'
28
+ import SvPhoneInput from './SvPhoneInput.svelte'
29
+ import SvCountryInput from './SvCountryInput.svelte'
30
+ import SvMaskedInput from './SvMaskedInput.svelte'
31
+ import SvComboBox from './SvComboBox.svelte'
32
+ import SvFileUpload from './SvFileUpload.svelte'
23
33
  import SvButton from './SvButton.svelte'
24
34
  import SvStepper from './SvStepper.svelte'
35
+ import SvTabs from './SvTabs.svelte'
25
36
  import { isFormSection, type FormField, type FormEntry, type FormSection } from './form-field'
26
- import { createForm } from './createForm.svelte'
37
+ import { createForm, type FormMessages } from './createForm.svelte'
38
+ import type { EditorDir } from './editor-contract'
27
39
 
28
40
  type Props = {
29
41
  fields: ReadonlyArray<FormEntry>
@@ -41,25 +53,97 @@
41
53
  resetLabel?: string
42
54
  /** Render titled sections as a validated multi-step wizard (requires sections). */
43
55
  stepper?: boolean
56
+ /** Render titled sections as tabs (requires sections; ignored with `stepper`). */
57
+ tabs?: boolean
44
58
  /** Columns in the responsive field grid (a section can override its own). */
45
59
  columns?: number
46
60
  disabled?: boolean
61
+ /** Inject server-side errors (`{ field: message }`); applied reactively. */
62
+ serverErrors?: Record<string, string>
63
+ /** A form-level error message shown in a banner above the fields. */
64
+ formError?: string
65
+ /** Show a summary of field errors (with focus links) above the form after a
66
+ * failed submit. */
67
+ errorSummary?: boolean
68
+ /** Heading for the error summary. Default "Please fix the following:". */
69
+ errorSummaryTitle?: string
70
+ /**
71
+ * Re-seed the form when the `initial` object identity changes (e.g. editing a
72
+ * different record). `off` (default) seeds once; `always` re-seeds on every
73
+ * change; `ifPristine` re-seeds only when the user has no unsaved edits.
74
+ */
75
+ reinitialize?: 'off' | 'always' | 'ifPristine'
76
+ /** Text direction; `rtl` mirrors the form layout. */
77
+ dir?: EditorDir
78
+ /** Override the built-in generated strings (required / min-max items / checking). */
79
+ messages?: Partial<FormMessages>
47
80
  }
48
81
 
49
82
  let {
50
83
  fields, initial = {}, onSubmit, onChange, onCancel,
51
84
  submitLabel = 'Submit', cancelLabel = 'Cancel', showReset = false, resetLabel = 'Reset',
52
- stepper = false, columns = 1, disabled = false,
85
+ stepper = false, tabs = false, columns = 1, disabled = false,
86
+ serverErrors, formError, errorSummary = false, errorSummaryTitle = 'Please fix the following:',
87
+ reinitialize = 'off', dir, messages,
53
88
  }: Props = $props()
54
89
 
55
- const form = createForm({ fields: () => fields, initial, onSubmit, onChange })
56
- const submit = (e: Event) => { e.preventDefault(); form.submit() }
90
+ const resolvedDir = $derived(dir === 'ltr' || dir === 'rtl' ? dir : undefined)
91
+ const form = createForm({ fields: () => fields, initial, onSubmit, onChange, messages })
92
+ async function submit(e: Event) {
93
+ e.preventDefault()
94
+ const ok = await form.submit()
95
+ // In tabs mode, surface the first tab that has an error so it is not hidden.
96
+ if (!ok && tabbed) {
97
+ const first = form.firstErrorField()
98
+ const s = first ? sections.find((sec) => sec.fields.some((f) => f.name === first)) : undefined
99
+ if (s) activeTab = s.section
100
+ }
101
+ }
102
+
103
+ let formEl = $state<HTMLFormElement | null>(null)
104
+ // Apply server-side errors reactively (e.g. after a failed API submit). The
105
+ // write is untracked so reading `errors` inside setErrors does not make this
106
+ // effect depend on (and re-run from) its own write.
107
+ $effect(() => {
108
+ const se = serverErrors
109
+ if (se) untrack(() => form.setErrors(se))
110
+ })
111
+
112
+ // Opt-in re-seed when the `initial` identity changes. Only `initial` is tracked
113
+ // (read outside `untrack`); the isDirty check + reset run untracked so this
114
+ // never re-fires from its own writes.
115
+ let seeded = false
116
+ $effect(() => {
117
+ const init = initial
118
+ untrack(() => {
119
+ if (!seeded) { seeded = true; return } // the seed-once mount pass
120
+ if (reinitialize === 'off') return
121
+ if (reinitialize === 'ifPristine' && form.isDirty) return
122
+ form.reset(init)
123
+ })
124
+ })
125
+
126
+ const summaryItems = $derived(errorSummary ? form.errorList() : [])
127
+ function focusField(name: string) {
128
+ const wrap = formEl?.querySelector<HTMLElement>(`[data-field="${name}"]`)
129
+ wrap?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
130
+ wrap?.querySelector<HTMLElement>('input, select, textarea, button, [tabindex]')?.focus()
131
+ }
57
132
  const dateVal = (v: any): Date | null => (v instanceof Date ? v : v ? new Date(v) : null)
133
+ // Resolve a field's options, running the function form (cascading) against the
134
+ // live values so a child list updates when its parent changes.
135
+ const resolveOptions = (field: FormField) =>
136
+ typeof field.options === 'function' ? field.options(form.values) : (field.options ?? [])
58
137
 
59
138
  const sections = $derived(fields.filter(isFormSection) as FormSection[])
60
139
  const wizard = $derived(stepper && sections.length > 0)
140
+ const tabbed = $derived(tabs && !stepper && sections.length > 0)
61
141
  const stepItems = $derived(sections.map((s) => ({ label: s.section, description: s.description })))
62
142
 
143
+ let activeTab = $state<string | undefined>(undefined)
144
+ const sectionHasError = (s: FormSection) => s.fields.some((f) => form.error(f.name))
145
+ const tabItems = $derived(sections.map((s) => ({ id: s.section, label: sectionHasError(s) ? `${s.section} ⚠` : s.section })))
146
+
63
147
  let step = $state(0)
64
148
  async function next() {
65
149
  const s = sections[step]
@@ -79,32 +163,57 @@
79
163
  onChange: (v: any) => void
80
164
  onBlur?: () => void
81
165
  disabled: boolean
166
+ readonly?: boolean
167
+ invalid?: boolean
168
+ describedby?: string
82
169
  placeholder?: string
83
170
  options?: ReadonlyArray<{ value: string | number; label: string; color?: string }>
171
+ /** The source field, for type-specific extras (mask, min/max, loadOptions...). */
172
+ field?: FormField
84
173
  }
85
174
  </script>
86
175
 
87
176
  {#snippet control(o: ControlOpts)}
88
177
  {#if o.type === 'textarea'}
89
- <textarea id={o.id} class="sv-form__control sv-form__textarea" rows="3" disabled={o.disabled} placeholder={o.placeholder} value={o.value ?? ''} oninput={(e) => o.onChange(e.currentTarget.value)} onblur={o.onBlur}></textarea>
178
+ <textarea id={o.id} class="sv-form__control sv-form__textarea" rows="3" disabled={o.disabled} readonly={o.readonly} placeholder={o.placeholder} value={o.value ?? ''} aria-invalid={o.invalid ? 'true' : undefined} aria-describedby={o.describedby} oninput={(e) => o.onChange(e.currentTarget.value)} onblur={o.onBlur}></textarea>
90
179
  {:else if o.type === 'number'}
91
- <SvNumberInput value={o.value ?? null} disabled={o.disabled} onChange={o.onChange} />
180
+ <SvNumberInput id={o.id} value={o.value ?? null} disabled={o.disabled} readonly={o.readonly} invalid={o.invalid} block min={o.field?.min ?? -Infinity} max={o.field?.max ?? Infinity} step={o.field?.step ?? 1} precision={o.field?.precision} prefix={o.field?.prefix ?? ''} suffix={o.field?.suffix ?? ''} onChange={o.onChange} />
92
181
  {:else if o.type === 'password'}
93
- <SvPasswordInput value={o.value ?? ''} disabled={o.disabled} onChange={o.onChange} />
182
+ <SvPasswordInput id={o.id} value={o.value ?? ''} disabled={o.disabled} readonly={o.readonly} invalid={o.invalid} block onChange={o.onChange} />
183
+ {:else if o.type === 'mask'}
184
+ <SvMaskedInput id={o.id} value={o.value ?? ''} mask={o.field?.mask ?? ''} disabled={o.disabled} readonly={o.readonly} invalid={o.invalid} block placeholder={o.placeholder} onChange={(m) => o.onChange(m)} />
185
+ {:else if o.type === 'phone'}
186
+ <SvPhoneInput id={o.id} value={o.value ?? ''} disabled={o.disabled} invalid={o.invalid} block placeholder={o.placeholder} onChange={(v) => o.onChange(v)} />
187
+ {:else if o.type === 'country'}
188
+ <SvCountryInput id={o.id} value={o.value ?? null} disabled={o.disabled} invalid={o.invalid} onChange={o.onChange} />
94
189
  {:else if o.type === 'select'}
95
- <SvDropDownList options={o.options ?? []} value={o.value ?? null} disabled={o.disabled} placeholder={o.placeholder} onChange={o.onChange} />
190
+ <SvDropDownList id={o.id} options={o.options ?? []} value={o.value ?? null} disabled={o.disabled} readonly={o.readonly} invalid={o.invalid} placeholder={o.placeholder} onChange={o.onChange} />
191
+ {:else if o.type === 'combobox'}
192
+ <SvComboBox id={o.id} options={o.options ?? []} value={o.value ?? null} disabled={o.disabled} readonly={o.readonly} invalid={o.invalid} clearable placeholder={o.placeholder} loadOptions={o.field?.loadOptions ? (q) => o.field!.loadOptions!(q, form.values) : undefined} onChange={o.onChange} />
193
+ {:else if o.type === 'multiselect'}
194
+ <SvMultiSelect id={o.id} options={o.options ?? []} value={Array.isArray(o.value) ? o.value : []} disabled={o.disabled} readonly={o.readonly} invalid={o.invalid} placeholder={o.placeholder} onChange={o.onChange} />
195
+ {:else if o.type === 'radio'}
196
+ <SvRadioGroup id={o.id} options={o.options ?? []} value={o.value ?? null} disabled={o.disabled} readonly={o.readonly} invalid={o.invalid} orientation="horizontal" onChange={o.onChange} />
197
+ {:else if o.type === 'tags'}
198
+ <SvTagsInput id={o.id} value={Array.isArray(o.value) ? o.value : []} disabled={o.disabled} invalid={o.invalid} placeholder={o.placeholder} onChange={o.onChange} />
199
+ {:else if o.type === 'slider'}
200
+ <SvSlider id={o.id} value={o.value ?? o.field?.min ?? 0} min={o.field?.min ?? 0} max={o.field?.max ?? 100} step={o.field?.step ?? 1} disabled={o.disabled} readonly={o.readonly} invalid={o.invalid} showValue onChange={o.onChange} />
96
201
  {:else if o.type === 'checkbox'}
97
- <SvCheckBox checked={!!o.value} disabled={o.disabled} onChange={o.onChange}>{o.label}</SvCheckBox>
202
+ <SvCheckBox checked={!!o.value} disabled={o.disabled} readonly={o.readonly} invalid={o.invalid} onChange={o.onChange}>{o.label}</SvCheckBox>
98
203
  {:else if o.type === 'switch'}
99
- <div class="sv-form__inline"><SvSwitchButton checked={!!o.value} disabled={o.disabled} onChange={o.onChange} ariaLabel={o.label} /><span>{o.label}</span></div>
204
+ <div class="sv-form__inline"><SvSwitchButton checked={!!o.value} disabled={o.disabled} readonly={o.readonly} onChange={o.onChange} ariaLabel={o.label} /><span>{o.label}</span></div>
100
205
  {:else if o.type === 'date'}
101
206
  <SvDateTimePicker value={dateVal(o.value)} dropDownDisplayMode="calendar" formatString="yyyy-MM-dd" disabled={o.disabled} onChange={o.onChange} />
207
+ {:else if o.type === 'datetime'}
208
+ <SvDateTimePicker value={dateVal(o.value)} dropDownDisplayMode="both" disabled={o.disabled} onChange={o.onChange} />
102
209
  {:else if o.type === 'color'}
103
- <SvColorInput value={o.value ?? '#3b82f6'} disabled={o.disabled} onChange={o.onChange} />
210
+ <SvColorInput id={o.id} value={o.value ?? '#3b82f6'} disabled={o.disabled} invalid={o.invalid} onChange={o.onChange} />
211
+ {:else if o.type === 'file'}
212
+ <SvFileUpload accept={o.field?.accept} multiple={o.field?.multiple ?? false} onChange={o.onChange} />
104
213
  {:else if o.type === 'rating'}
105
- <SvRating value={o.value ?? 0} disabled={o.disabled} onChange={o.onChange} />
214
+ <SvRating id={o.id} value={o.value ?? 0} disabled={o.disabled} readonly={o.readonly} invalid={o.invalid} onChange={o.onChange} />
106
215
  {:else}
107
- <input id={o.id} class="sv-form__control" type={o.type} disabled={o.disabled} placeholder={o.placeholder} value={o.value ?? ''} oninput={(e) => o.onChange(e.currentTarget.value)} onblur={o.onBlur} />
216
+ <input id={o.id} class="sv-form__control" type={o.type} disabled={o.disabled} readonly={o.readonly} placeholder={o.placeholder} value={o.value ?? ''} aria-invalid={o.invalid ? 'true' : undefined} aria-describedby={o.describedby} oninput={(e) => o.onChange(e.currentTarget.value)} onblur={o.onBlur} />
108
217
  {/if}
109
218
  {/snippet}
110
219
 
@@ -116,12 +225,13 @@
116
225
  {#if type !== 'checkbox' && type !== 'switch'}
117
226
  <label class="sv-form__label" for={id}>{itf.label}{#if itf.required}<span class="sv-form__req" aria-hidden="true"> *</span>{/if}</label>
118
227
  {/if}
119
- {@render control({ id, type, label: itf.label, value: form.itemValue(arrayName, i, itf.name), onChange: (v) => form.setItemValue(arrayName, i, itf.name, v), onBlur: () => form.handleItemBlur(arrayName, i, itf.name), disabled, placeholder: itf.placeholder, options: itf.options })}
120
- {#if err}<span class="sv-form__error" role="alert">{err}</span>{/if}
228
+ {@render control({ id, type, label: itf.label, value: form.itemValue(arrayName, i, itf.name), onChange: (v) => form.setItemValue(arrayName, i, itf.name, v), onBlur: () => form.handleItemBlur(arrayName, i, itf.name), disabled, readonly: itf.readonly, invalid: !!err, describedby: err ? `${id}__error` : undefined, placeholder: itf.placeholder, options: resolveOptions(itf), field: itf })}
229
+ {#if err}<span class="sv-form__error" id={`${id}__error`} role="alert">{err}</span>{/if}
121
230
  </div>
122
231
  {/snippet}
123
232
 
124
233
  {#snippet renderArray(field: FormField)}
234
+ {@const count = form.arrayItems(field.name).length}
125
235
  <div class="sv-form__field is-full sv-form__array" class:has-error={form.error(field.name)}>
126
236
  <label class="sv-form__label">{field.label}{#if field.required}<span class="sv-form__req" aria-hidden="true"> *</span>{/if}</label>
127
237
  {#each form.arrayItems(field.name) as _item, i (i)}
@@ -129,7 +239,15 @@
129
239
  <div class="sv-form__grid" style:--cols={field.itemFields?.length ?? 1}>
130
240
  {#each field.itemFields ?? [] as itf (itf.name)}{@render renderItemField(field.name, i, itf)}{/each}
131
241
  </div>
132
- <button type="button" class="sv-form__aremove" disabled={disabled} onclick={() => form.removeItem(field.name, i)} aria-label="Remove item">&times;</button>
242
+ <div class="sv-form__arow-tools">
243
+ {#if count > 1}
244
+ <div class="sv-form__amove">
245
+ <button type="button" class="sv-form__amove-btn" disabled={disabled || i === 0} onclick={() => form.moveItem(field.name, i, i - 1)} aria-label="Move up" title="Move up">&#8593;</button>
246
+ <button type="button" class="sv-form__amove-btn" disabled={disabled || i === count - 1} onclick={() => form.moveItem(field.name, i, i + 1)} aria-label="Move down" title="Move down">&#8595;</button>
247
+ </div>
248
+ {/if}
249
+ <button type="button" class="sv-form__aremove" disabled={disabled} onclick={() => form.removeItem(field.name, i)} aria-label="Remove item">&times;</button>
250
+ </div>
133
251
  </div>
134
252
  {/each}
135
253
  <div><SvButton type="button" variant="outline" size="sm" disabled={disabled} onclick={() => form.addItem(field.name)}>{field.addLabel ?? '+ Add'}</SvButton></div>
@@ -144,17 +262,19 @@
144
262
  {@render renderArray(field)}
145
263
  {:else}
146
264
  {@const fieldDisabled = disabled || form.isDisabled(field.name)}
147
- <div class="sv-form__field" class:is-full={field.full || cols === 1} class:has-error={form.error(field.name)}>
265
+ <div class="sv-form__field" data-field={field.name} class:is-full={(field.full || (field.span ?? 1) >= cols) || cols === 1} class:has-error={form.error(field.name)} style:grid-column={field.span && field.span > 1 && field.span < cols ? `span ${field.span}` : undefined}>
148
266
  {#if type !== 'checkbox' && type !== 'switch'}
149
267
  <label class="sv-form__label" for={`f-${field.name}`}>
150
268
  {field.label}{#if field.required}<span class="sv-form__req" aria-hidden="true"> *</span>{/if}
151
269
  </label>
152
270
  {/if}
153
- {@render control({ id: `f-${field.name}`, type, label: field.label, value: form.value(field.name), onChange: (v) => form.setValue(field.name, v), onBlur: () => form.handleBlur(field.name), disabled: fieldDisabled, placeholder: field.placeholder, options: field.options })}
271
+ {@render control({ id: `f-${field.name}`, type, label: field.label, value: form.value(field.name), onChange: (v) => form.setValue(field.name, v), onBlur: () => form.handleBlur(field.name), disabled: fieldDisabled, readonly: field.readonly || !!field.computed, invalid: !!form.error(field.name), describedby: form.error(field.name) ? `f-${field.name}__error` : undefined, placeholder: field.placeholder, options: resolveOptions(field), field })}
154
272
  {#if form.error(field.name)}
155
- <span class="sv-form__error" role="alert">{form.error(field.name)}</span>
273
+ <span class="sv-form__error" id={`f-${field.name}__error`} role="alert">{form.error(field.name)}</span>
156
274
  {:else if form.isValidating(field.name)}
157
- <span class="sv-form__checking" role="status"><span class="sv-form__spinner" aria-hidden="true"></span>Checking…</span>
275
+ <span class="sv-form__checking" role="status"><span class="sv-form__spinner" aria-hidden="true"></span>{form.messages.checking}</span>
276
+ {:else if field.help}
277
+ <span class="sv-form__help">{field.help}</span>
158
278
  {/if}
159
279
  </div>
160
280
  {/if}
@@ -179,7 +299,20 @@
179
299
  </div>
180
300
  {/snippet}
181
301
 
182
- <form class="sv-form" onsubmit={submit} novalidate>
302
+ <form class="sv-form" bind:this={formEl} dir={resolvedDir} onsubmit={submit} novalidate>
303
+ {#if formError}
304
+ <div class="sv-form__banner" role="alert">{formError}</div>
305
+ {/if}
306
+ {#if errorSummary && summaryItems.length}
307
+ <div class="sv-form__summary" role="alert">
308
+ <p class="sv-form__summary-title">{errorSummaryTitle}</p>
309
+ <ul>
310
+ {#each summaryItems as it (it.name)}
311
+ <li><button type="button" class="sv-form__summary-link" onclick={() => focusField(it.name)}>{it.label}: {it.message}</button></li>
312
+ {/each}
313
+ </ul>
314
+ </div>
315
+ {/if}
183
316
  {#if wizard}
184
317
  {@const s = sections[Math.min(step, sections.length - 1)]!}
185
318
  <SvStepper steps={stepItems} current={step} onChange={(n) => (step = n)} linear />
@@ -201,6 +334,17 @@
201
334
  <SvButton type="submit" loading={form.submitting} disabled={disabled || form.submitting}>{submitLabel}</SvButton>
202
335
  {/if}
203
336
  </div>
337
+ {:else if tabbed}
338
+ <SvTabs tabs={tabItems} bind:value={activeTab} ariaLabel="Form sections">
339
+ {#snippet panel(id)}
340
+ {@const s = sections.find((sec) => sec.section === id)}
341
+ {#if s}
342
+ {#if s.description}<p class="sv-form__section-desc">{s.description}</p>{/if}
343
+ {@render grid(s.fields, s.columns ?? columns)}
344
+ {/if}
345
+ {/snippet}
346
+ </SvTabs>
347
+ {@render actions()}
204
348
  {:else if sections.length > 0}
205
349
  {#each fields as entry (isFormSection(entry) ? entry.section : entry.name)}
206
350
  {#if isFormSection(entry)}
@@ -240,6 +384,24 @@
240
384
  .sv-form__field.has-error .sv-form__control { border-color: var(--sg-danger, #dc2626); }
241
385
  .sv-form__inline { display: flex; align-items: center; gap: 8px; font-size: 13px; }
242
386
  .sv-form__error { font-size: 11.5px; color: var(--sg-danger, #dc2626); }
387
+ .sv-form__help { font-size: 11.5px; color: var(--sg-muted, #64748b); }
388
+ .sv-form__banner {
389
+ padding: 10px 12px; border-radius: 8px; font-size: 13px;
390
+ background: color-mix(in srgb, var(--sg-danger, #dc2626) 10%, transparent);
391
+ color: var(--sg-danger, #dc2626); border: 1px solid color-mix(in srgb, var(--sg-danger, #dc2626) 35%, transparent);
392
+ }
393
+ .sv-form__summary {
394
+ padding: 12px 14px; border-radius: 8px;
395
+ background: color-mix(in srgb, var(--sg-danger, #dc2626) 7%, transparent);
396
+ border: 1px solid color-mix(in srgb, var(--sg-danger, #dc2626) 30%, transparent);
397
+ }
398
+ .sv-form__summary-title { margin: 0 0 6px; font-size: 13px; font-weight: 700; color: var(--sg-danger, #dc2626); }
399
+ .sv-form__summary ul { margin: 0; padding-inline-start: 18px; display: flex; flex-direction: column; gap: 3px; }
400
+ .sv-form__summary-link {
401
+ background: none; border: 0; padding: 0; font: inherit; font-size: 12.5px; text-align: start;
402
+ color: var(--sg-danger, #dc2626); cursor: pointer; text-decoration: underline;
403
+ }
404
+ .sv-form__summary-link:hover { color: var(--sg-fg, #0f172a); }
243
405
  .sv-form__checking { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; color: var(--sg-muted, #64748b); }
244
406
  .sv-form__spinner {
245
407
  width: 11px; height: 11px; border-radius: 50%;
@@ -260,4 +422,16 @@
260
422
  }
261
423
  .sv-form__aremove:hover:not(:disabled) { color: var(--sg-danger, #dc2626); border-color: var(--sg-danger, #dc2626); }
262
424
  .sv-form__aremove:disabled { opacity: 0.5; cursor: default; }
425
+ .sv-form__arow-tools { display: flex; align-items: center; gap: 4px; margin-top: 10px; }
426
+ .sv-form__amove { display: inline-flex; flex-direction: column; }
427
+ .sv-form__amove-btn {
428
+ width: 22px; height: 14px; display: grid; place-items: center; padding: 0; cursor: pointer;
429
+ border: 1px solid var(--sg-border, #cbd5e1); background: var(--sg-input-bg, #fff); color: var(--sg-muted, #64748b);
430
+ font-size: 10px; line-height: 1;
431
+ }
432
+ .sv-form__amove-btn:first-child { border-radius: 6px 6px 0 0; border-bottom: 0; }
433
+ .sv-form__amove-btn:last-child { border-radius: 0 0 6px 6px; }
434
+ .sv-form__amove-btn:hover:not(:disabled) { color: var(--sg-accent, #2563eb); }
435
+ .sv-form__amove-btn:disabled { opacity: 0.4; cursor: default; }
436
+ .sv-form__arow-tools .sv-form__aremove { margin-top: 0; }
263
437
  </style>
@@ -78,6 +78,79 @@ describe('SvForm', () => {
78
78
  expect(onChange.mock.calls.at(-1)![0]).toMatchObject({ name: 'A' })
79
79
  } finally { destroy() }
80
80
  })
81
+
82
+ it('renders an error summary after a failed submit', () => {
83
+ const { target, destroy } = mountForm({ fields, errorSummary: true })
84
+ try {
85
+ submitForm(target)
86
+ const summary = target.querySelector('.sv-form__summary[role="alert"]')
87
+ expect(summary).not.toBeNull()
88
+ const links = target.querySelectorAll('.sv-form__summary-link')
89
+ expect(links.length).toBeGreaterThan(0)
90
+ expect(links[0]!.textContent).toContain('is required')
91
+ } finally { destroy() }
92
+ })
93
+
94
+ it('applies serverErrors reactively', () => {
95
+ const { target, destroy } = mountForm({ fields, serverErrors: { name: 'Name already taken' } })
96
+ try {
97
+ expect([...target.querySelectorAll('.sv-form__error')].some((e) => e.textContent?.includes('already taken'))).toBe(true)
98
+ } finally { destroy() }
99
+ })
100
+
101
+ it('overrides the built-in required message and links the error via aria-describedby', () => {
102
+ const { target, destroy } = mountForm({ fields, messages: { required: (l: string) => `${l} manquant` } })
103
+ try {
104
+ submitForm(target)
105
+ const err = target.querySelector('#f-name__error')
106
+ expect(err?.textContent).toBe('Name manquant')
107
+ // aria wiring: the input points at the error id
108
+ expect(target.querySelector('#f-name')?.getAttribute('aria-describedby')).toBe('f-name__error')
109
+ expect(target.querySelector('#f-name')?.getAttribute('aria-invalid')).toBe('true')
110
+ } finally { destroy() }
111
+ })
112
+
113
+ it('mirrors dir onto the form element', () => {
114
+ const { target, destroy } = mountForm({ fields, dir: 'rtl' })
115
+ try {
116
+ expect(target.querySelector('form.sv-form')?.getAttribute('dir')).toBe('rtl')
117
+ } finally { destroy() }
118
+ })
119
+
120
+ it('computed fields derive their value, render read-only, and are in the payload', async () => {
121
+ const onSubmit = vi.fn()
122
+ const cf = [
123
+ { name: 'qty', label: 'Qty', type: 'number' as const },
124
+ { name: 'price', label: 'Price', type: 'number' as const },
125
+ { name: 'total', label: 'Total', type: 'text' as const, computed: (v: any) => (v.qty ?? 0) * (v.price ?? 0) },
126
+ ]
127
+ const { target, destroy } = mountForm({ fields: cf, initial: { qty: 3, price: 4 }, onSubmit })
128
+ try {
129
+ const totalInput = target.querySelector<HTMLInputElement>('#f-total')!
130
+ expect(totalInput.value).toBe('12') // derived
131
+ expect(totalInput.readOnly).toBe(true) // read-only
132
+ submitForm(target); await tick()
133
+ expect(onSubmit).toHaveBeenCalledTimes(1)
134
+ expect(onSubmit.mock.calls[0]![0].total).toBe(12) // computed value in payload
135
+ } finally { destroy() }
136
+ })
137
+
138
+ it('cascading: changing a parent clears fields that dependOn it (onChange payload)', () => {
139
+ const onChange = vi.fn()
140
+ const cf = [
141
+ { name: 'country', label: 'Country', options: [{ value: 'us', label: 'US' }, { value: 'ca', label: 'CA' }] },
142
+ { name: 'state', label: 'State', dependsOn: 'country', options: (v: any) => (v.country === 'us' ? [{ value: 'ny', label: 'NY' }] : []) },
143
+ ]
144
+ // country + state are plain text inputs here (default type) so we can drive them via the DOM.
145
+ const { target, destroy } = mountForm({ fields: cf, initial: { country: 'us', state: 'ny' }, onChange })
146
+ try {
147
+ setInput(target, 'f-country', 'ca')
148
+ // The last onChange payload has the parent updated AND the child cleared.
149
+ const last = onChange.mock.calls.at(-1)![0]
150
+ expect(last.country).toBe('ca')
151
+ expect(last.state).toBeNull()
152
+ } finally { destroy() }
153
+ })
81
154
  })
82
155
 
83
156
  describe('SvForm - conditional fields', () => {
@@ -251,6 +324,33 @@ describe('SvForm - stepper wizard', () => {
251
324
  })
252
325
  })
253
326
 
327
+ describe('SvForm - tabs layout', () => {
328
+ const schema = [
329
+ { section: 'Profile', fields: [{ name: 'user', label: 'User', required: true }] },
330
+ { section: 'Bio', fields: [{ name: 'bio', label: 'Bio' }] },
331
+ ]
332
+
333
+ it('renders sections as tabs, one panel at a time; failed submit jumps to the erroring tab', async () => {
334
+ const onSubmit = vi.fn()
335
+ const { target, destroy } = mountForm({ fields: schema, tabs: true, onSubmit })
336
+ try {
337
+ const tabEls = target.querySelectorAll('[role="tab"]')
338
+ expect(tabEls.length).toBe(2)
339
+ // First tab active -> only its field is in the DOM
340
+ expect(target.querySelector('#f-user')).not.toBeNull()
341
+ expect(target.querySelector('#f-bio')).toBeNull()
342
+ // Switch to the Bio tab
343
+ ;[...target.querySelectorAll<HTMLElement>('[role="tab"]')].find((t) => t.textContent?.includes('Bio'))!.click()
344
+ flushSync()
345
+ expect(target.querySelector('#f-bio')).not.toBeNull()
346
+ // Submit with the required User empty -> blocked and jumps back to Profile
347
+ submitForm(target); await tick()
348
+ expect(onSubmit).not.toHaveBeenCalled()
349
+ expect(target.querySelector('#f-user')).not.toBeNull() // Profile tab is active again
350
+ } finally { destroy() }
351
+ })
352
+ })
353
+
254
354
  describe('SvForm - field arrays', () => {
255
355
  const schema = [
256
356
  { name: 'contacts', label: 'Contacts', type: 'array' as const, required: true, itemFields: [
@@ -296,4 +396,16 @@ describe('SvForm - field arrays', () => {
296
396
  expect([...target.querySelectorAll('.sv-form__error')].some((e) => e.textContent?.includes('Contacts is required'))).toBe(true)
297
397
  } finally { destroy() }
298
398
  })
399
+
400
+ it('reorder buttons move an item up/down', () => {
401
+ const { target, destroy } = mountForm({ fields: schema, initial: { contacts: [{ name: 'A', email: '' }, { name: 'B', email: '' }] } })
402
+ try {
403
+ const nameVals = () => [...target.querySelectorAll<HTMLInputElement>('.sv-form__array [id$="-name"]')].map((i) => i.value)
404
+ expect(nameVals()).toEqual(['A', 'B'])
405
+ ;(target.querySelectorAll<HTMLButtonElement>('button[aria-label="Move down"]')[0]!).click(); flushSync()
406
+ expect(nameVals()).toEqual(['B', 'A'])
407
+ ;(target.querySelectorAll<HTMLButtonElement>('button[aria-label="Move up"]')[1]!).click(); flushSync()
408
+ expect(nameVals()).toEqual(['A', 'B'])
409
+ } finally { destroy() }
410
+ })
299
411
  })
@@ -10,7 +10,6 @@ import {
10
10
  createSortedRowModel,
11
11
  createSvGrid,
12
12
  getGridCellDomId,
13
- rowsToChartSpec,
14
13
  sortFns,
15
14
  tableFeatures,
16
15
  rowSortingFeature,
@@ -94,6 +93,8 @@ import {
94
93
  import {
95
94
  createClipboard,
96
95
  } from "./clipboard";
96
+ import { resolveGridMessages } from "./grid-messages";
97
+ import { getPivotEngine, hasPivotEngine } from "./pivot-view.svelte";
97
98
  import {
98
99
  filterOperatorOptions,
99
100
  fallbackOperatorOption,
@@ -269,7 +270,28 @@ export type SvGridController<
269
270
  export function createSvGridController<
270
271
  TFeatures extends TableFeatures = TableFeatures,
271
272
  TData extends RowData = RowData,
272
- >(props: Props<TFeatures, TData>) {
273
+ >(rawProps: Props<TFeatures, TData>) {
274
+
275
+ // Runtime option overrides set via the imperative api (`api.setOption(key, value)`).
276
+ // Every controller read of a prop goes through the `props` proxy below (and the view
277
+ // reads controller-resolved gates like `_features` / `editingEnabled`, not the raw
278
+ // prop), so an override wins over the incoming prop and re-renders reactively -
279
+ // identical to the parent flipping that prop. A get-only Proxy (not a fresh spread)
280
+ // preserves reference identity for non-overridden keys, which the columns-identity
281
+ // column cache and the `data`/`columns` sync effects rely on.
282
+ let optionOverrides = $state<Record<string, unknown>>({});
283
+ const props = new Proxy(rawProps, {
284
+ // Read the override as a PROPERTY (tracked by `$state`), not via `in` (the `has`
285
+ // trap is not tracked) - so a reactive read of `props.X` re-runs when an override
286
+ // is set. `undefined` means "no override" and falls back to the incoming prop;
287
+ // `setOption(key, undefined)` clears by removing the key, which lands here as
288
+ // undefined too. Whole-object reassignment on write makes the signal fire.
289
+ get: (t, k) => {
290
+ const v =
291
+ typeof k === "string" ? optionOverrides[k] : undefined;
292
+ return v !== undefined ? v : Reflect.get(t, k);
293
+ },
294
+ }) as Props<TFeatures, TData>;
273
295
 
274
296
  // Resolved capability gates. Capabilities are OFF by default - a bare
275
297
  // grid is a plain read-only table, and each power feature is opted into
@@ -670,7 +692,8 @@ export function createSvGridController<
670
692
  state: {
671
693
  columnFilters: [],
672
694
  grouping: [],
673
- sorting: [],
695
+ // svelte-ignore state_referenced_locally
696
+ sorting: props.initialSorting ?? [],
674
697
  // svelte-ignore state_referenced_locally
675
698
  pagination: { pageIndex: 0, pageSize: props.pageSize ?? 10 },
676
699
  rowSelection: {},
@@ -1092,12 +1115,12 @@ export function createSvGridController<
1092
1115
 
1093
1116
  let rows = rawRows;
1094
1117
  if (globalFilter.trim()) {
1095
- const needle = normalizeForFilter(globalFilter, props.filterLocale);
1118
+ const needle = normalizeForFilter(globalFilter, (props.filterLocale ?? props.localization?.locale));
1096
1119
  rows = rows.filter((row) =>
1097
1120
  row
1098
1121
  .getAllCells()
1099
1122
  .some((cell) =>
1100
- normalizeForFilter(String(cell.getValue() ?? ""), props.filterLocale)
1123
+ normalizeForFilter(String(cell.getValue() ?? ""), (props.filterLocale ?? props.localization?.locale))
1101
1124
  .includes(needle),
1102
1125
  ),
1103
1126
  );
@@ -1119,7 +1142,7 @@ export function createSvGridController<
1119
1142
  applyExcelFilter(
1120
1143
  cellValue,
1121
1144
  { id: columnId, operator: op, value, valueTo: op === "between" ? valueTo : undefined },
1122
- { locale: props.filterLocale },
1145
+ { locale: (props.filterLocale ?? props.localization?.locale) },
1123
1146
  );
1124
1147
  // A column filter is active if either of its (up to two) conditions is.
1125
1148
  const menuFilters = Object.entries(filterMenuValues).filter(([_, f]) => {
@@ -1348,6 +1371,39 @@ export function createSvGridController<
1348
1371
  return c === true ? ({} as ChartingConfig<TData>) : (c as ChartingConfig<TData>);
1349
1372
  });
1350
1373
  const chartingEnabled = $derived(!!chartCfg);
1374
+ // Resolved chrome messages (English defaults merged with `localeText`). One
1375
+ // map every consumer (SvGrid.svelte, GridFooter, menus, filter labels) reads.
1376
+ const gridMessages = $derived(resolveGridMessages(props.localization?.text));
1377
+
1378
+ // ---- In-grid pivot mode ----
1379
+ // The pivot ENGINE ships in @svgrid/enterprise (registered via the pivot-view
1380
+ // seam). When `pivot` is set and the engine is present, the grid runs it over
1381
+ // the already filtered + sorted leaf rows and renders the pivot result in
1382
+ // place. `pivotModeOn` is uncontrolled (seeded from the prop) so a toolbar
1383
+ // toggle can flip between the pivot and the flat table over the same data.
1384
+ const pivotConfig = $derived(props.pivot ?? null);
1385
+ // Controlled when `pivotMode` prop is provided; otherwise internal state
1386
+ // (seeded to on when `pivot` is set) drives it, flipped by the toolbar toggle.
1387
+ let pivotModeInternal = $state(props.pivotMode ?? (props.pivot != null));
1388
+ const pivotModeOn = $derived(props.pivotMode ?? pivotModeInternal);
1389
+ function togglePivotMode(): void {
1390
+ const next = !pivotModeOn;
1391
+ pivotModeInternal = next;
1392
+ props.onPivotModeChange?.(next);
1393
+ }
1394
+ const pivotActive = $derived(!!pivotConfig && pivotModeOn && hasPivotEngine());
1395
+ const pivotResult = $derived.by(() => {
1396
+ if (!pivotActive || !pivotConfig) return null;
1397
+ const engine = getPivotEngine();
1398
+ if (!engine) return null;
1399
+ // Feed the pivot the filtered + sorted LEAF rows (drop any group rows); its
1400
+ // output must not be re-run through the column-filter pipeline.
1401
+ const leaves = allRowsBeforePagination
1402
+ .filter((r) => !r.subRows)
1403
+ .map((r) => r.original);
1404
+ return engine(leaves, pivotConfig as never);
1405
+ });
1406
+
1351
1407
  const chartIsCustom = $derived(typeof chartCfg?.buildSpec === "function");
1352
1408
  const chartingConfig = $derived(
1353
1409
  chartCfg
@@ -1609,6 +1665,18 @@ export function createSvGridController<
1609
1665
  };
1610
1666
  };
1611
1667
 
1668
+ // The chart derivation engine (rowsToChartSpec) is loaded lazily the first time
1669
+ // charting is enabled, keeping chart.ts (~11 KB gzip) out of the base <SvGrid>
1670
+ // bundle - it rides in the same lazy chunk as the chart panel. `chartSpec` stays
1671
+ // null until it resolves, a tick after the panel itself starts loading (which it
1672
+ // already does). The `getAggregate` server path uses the local bucketsToSpec and
1673
+ // does not need the engine.
1674
+ let chartEngine = $state<typeof import("./chart").rowsToChartSpec | null>(null);
1675
+ $effect(() => {
1676
+ if (chartingEnabled && !chartEngine)
1677
+ import("./chart").then((m) => (chartEngine = m.rowsToChartSpec));
1678
+ });
1679
+
1612
1680
  const chartSpec = $derived.by<ChartSpec | null>(() => {
1613
1681
  if (!chartingEnabled || !chartCfg) return null;
1614
1682
  if (chartCfg.getAggregate) {
@@ -1627,9 +1695,11 @@ export function createSvGridController<
1627
1695
  const category = fieldOf(effectiveChartDimensionId);
1628
1696
  const values = effectiveChartMeasureIds.map((id) => fieldOf(id)).filter((f): f is string => !!f);
1629
1697
  if (!category || !values.length) return null;
1698
+ // Engine not loaded yet (lazy): render nothing until it resolves.
1699
+ if (!chartEngine) return null;
1630
1700
  const seriesField = fieldOf(effectiveChartSeriesId);
1631
1701
 
1632
- const spec = rowsToChartSpec<Record<string, unknown>>(chartRows as Array<Record<string, unknown>>, {
1702
+ const spec = chartEngine<Record<string, unknown>>(chartRows as Array<Record<string, unknown>>, {
1633
1703
  type: chartType,
1634
1704
  category: category as string,
1635
1705
  value: values.length === 1 ? values[0]! : values,
@@ -2576,6 +2646,8 @@ export function createSvGridController<
2576
2646
 
2577
2647
  const ctx = {
2578
2648
  get props() { return props; },
2649
+ get optionOverrides() { return optionOverrides; },
2650
+ set optionOverrides(v) { optionOverrides = v as never; },
2579
2651
  get editingEnabled() { return editingEnabled; },
2580
2652
  get paginationEnabled() { return paginationEnabled; },
2581
2653
  get groupingControlsEnabled() { return groupingControlsEnabled; },
@@ -2813,6 +2885,15 @@ export function createSvGridController<
2813
2885
  get toolPanelHeaderLabel() { return toolPanelHeaderLabel; },
2814
2886
  get toggleColumnVisibleInPanel() { return toggleColumnVisibleInPanel; },
2815
2887
  get moveColumnInPanel() { return moveColumnInPanel; },
2888
+ // ---- Localized chrome strings ----
2889
+ get messages() { return gridMessages; },
2890
+ // ---- In-grid pivot mode ----
2891
+ get pivotConfig() { return pivotConfig; },
2892
+ get pivotActive() { return pivotActive; },
2893
+ get pivotResult() { return pivotResult; },
2894
+ get pivotModeOn() { return pivotModeOn; },
2895
+ get hasPivotEngine() { return hasPivotEngine(); },
2896
+ togglePivotMode,
2816
2897
  // ---- Integrated charting ----
2817
2898
  get chartingEnabled() { return chartingEnabled; },
2818
2899
  get chartingConfig() { return chartingConfig; },