@bquery/bquery 1.7.0 → 1.8.1

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 (262) hide show
  1. package/README.md +760 -716
  2. package/dist/{a11y-C5QOVvRn.js → a11y-DVBCy09c.js} +3 -3
  3. package/dist/a11y-DVBCy09c.js.map +1 -0
  4. package/dist/a11y.es.mjs +1 -1
  5. package/dist/component/library.d.ts.map +1 -1
  6. package/dist/{component-CuuTijA6.js → component-L3-JfOFz.js} +5 -5
  7. package/dist/component-L3-JfOFz.js.map +1 -0
  8. package/dist/component.es.mjs +1 -1
  9. package/dist/{config-BW35FKuA.js → config-DhT9auRm.js} +1 -1
  10. package/dist/{config-BW35FKuA.js.map → config-DhT9auRm.js.map} +1 -1
  11. package/dist/{constraints-3lV9yyBw.js → constraints-D5RHQLmP.js} +1 -1
  12. package/dist/constraints-D5RHQLmP.js.map +1 -0
  13. package/dist/core/collection.d.ts +86 -0
  14. package/dist/core/collection.d.ts.map +1 -1
  15. package/dist/core/element.d.ts +28 -0
  16. package/dist/core/element.d.ts.map +1 -1
  17. package/dist/core/shared.d.ts +6 -0
  18. package/dist/core/shared.d.ts.map +1 -1
  19. package/dist/core-DdtZHzsS.js +168 -0
  20. package/dist/core-DdtZHzsS.js.map +1 -0
  21. package/dist/{core-Cjl7GUu8.js → core-EMYSLzaT.js} +289 -259
  22. package/dist/core-EMYSLzaT.js.map +1 -0
  23. package/dist/core.es.mjs +48 -47
  24. package/dist/{custom-directives-7wAShnnd.js → custom-directives-Dr4C5lVV.js} +1 -1
  25. package/dist/custom-directives-Dr4C5lVV.js.map +1 -0
  26. package/dist/{devtools-D2fQLhDN.js → devtools-BhB2iDPT.js} +2 -2
  27. package/dist/devtools-BhB2iDPT.js.map +1 -0
  28. package/dist/devtools.es.mjs +1 -1
  29. package/dist/{dnd-B8EgyzaI.js → dnd-NwZBYh4l.js} +1 -1
  30. package/dist/dnd-NwZBYh4l.js.map +1 -0
  31. package/dist/dnd.es.mjs +1 -1
  32. package/dist/{env-NeVmr4Gf.js → env-CTdvLaH2.js} +1 -1
  33. package/dist/env-CTdvLaH2.js.map +1 -0
  34. package/dist/forms/create-form.d.ts.map +1 -1
  35. package/dist/forms/index.d.ts +3 -2
  36. package/dist/forms/index.d.ts.map +1 -1
  37. package/dist/forms/types.d.ts +46 -0
  38. package/dist/forms/types.d.ts.map +1 -1
  39. package/dist/forms/use-field.d.ts +34 -0
  40. package/dist/forms/use-field.d.ts.map +1 -0
  41. package/dist/forms/validators.d.ts +25 -0
  42. package/dist/forms/validators.d.ts.map +1 -1
  43. package/dist/forms-UcRHsYxC.js +227 -0
  44. package/dist/forms-UcRHsYxC.js.map +1 -0
  45. package/dist/forms.es.mjs +14 -12
  46. package/dist/full.d.ts +17 -26
  47. package/dist/full.d.ts.map +1 -1
  48. package/dist/full.es.mjs +206 -181
  49. package/dist/full.iife.js +33 -33
  50. package/dist/full.iife.js.map +1 -1
  51. package/dist/full.umd.js +33 -33
  52. package/dist/full.umd.js.map +1 -1
  53. package/dist/function-Cybd57JV.js +33 -0
  54. package/dist/function-Cybd57JV.js.map +1 -0
  55. package/dist/{i18n-BnnhTFOS.js → i18n-kuF6Ekj6.js} +3 -3
  56. package/dist/i18n-kuF6Ekj6.js.map +1 -0
  57. package/dist/i18n.es.mjs +1 -1
  58. package/dist/index.es.mjs +251 -228
  59. package/dist/media/breakpoints.d.ts.map +1 -1
  60. package/dist/media/types.d.ts +2 -2
  61. package/dist/media/types.d.ts.map +1 -1
  62. package/dist/{media-Di2Ta22s.js → media-i-fB5WxI.js} +3 -3
  63. package/dist/media-i-fB5WxI.js.map +1 -0
  64. package/dist/media.es.mjs +1 -1
  65. package/dist/{motion-qPj_TYGv.js → motion-BJsAuULb.js} +2 -2
  66. package/dist/motion-BJsAuULb.js.map +1 -0
  67. package/dist/motion.es.mjs +1 -1
  68. package/dist/{mount-SM07RUa6.js → mount-B4Y8bk8Z.js} +5 -5
  69. package/dist/mount-B4Y8bk8Z.js.map +1 -0
  70. package/dist/{platform-CPbCprb6.js → platform-Dw2gE3zI.js} +3 -3
  71. package/dist/{platform-CPbCprb6.js.map → platform-Dw2gE3zI.js.map} +1 -1
  72. package/dist/platform.es.mjs +2 -2
  73. package/dist/plugin/registry.d.ts.map +1 -1
  74. package/dist/{plugin-cPoOHFLY.js → plugin-C2WuC8SF.js} +20 -18
  75. package/dist/plugin-C2WuC8SF.js.map +1 -0
  76. package/dist/plugin.es.mjs +1 -1
  77. package/dist/reactive/async-data.d.ts +28 -3
  78. package/dist/reactive/async-data.d.ts.map +1 -1
  79. package/dist/reactive/computed.d.ts +3 -0
  80. package/dist/reactive/computed.d.ts.map +1 -1
  81. package/dist/reactive/effect.d.ts +3 -0
  82. package/dist/reactive/effect.d.ts.map +1 -1
  83. package/dist/reactive/http.d.ts +194 -0
  84. package/dist/reactive/http.d.ts.map +1 -0
  85. package/dist/reactive/index.d.ts +2 -2
  86. package/dist/reactive/index.d.ts.map +1 -1
  87. package/dist/reactive/pagination.d.ts +126 -0
  88. package/dist/reactive/pagination.d.ts.map +1 -0
  89. package/dist/reactive/polling.d.ts +55 -0
  90. package/dist/reactive/polling.d.ts.map +1 -0
  91. package/dist/reactive/readonly.d.ts +20 -1
  92. package/dist/reactive/readonly.d.ts.map +1 -1
  93. package/dist/reactive/rest.d.ts +293 -0
  94. package/dist/reactive/rest.d.ts.map +1 -0
  95. package/dist/reactive/scope.d.ts +140 -0
  96. package/dist/reactive/scope.d.ts.map +1 -0
  97. package/dist/reactive/signal.d.ts +16 -2
  98. package/dist/reactive/signal.d.ts.map +1 -1
  99. package/dist/reactive/to-value.d.ts +57 -0
  100. package/dist/reactive/to-value.d.ts.map +1 -0
  101. package/dist/reactive/websocket.d.ts +285 -0
  102. package/dist/reactive/websocket.d.ts.map +1 -0
  103. package/dist/reactive-DwkhUJfP.js +1148 -0
  104. package/dist/reactive-DwkhUJfP.js.map +1 -0
  105. package/dist/reactive.es.mjs +38 -19
  106. package/dist/{registry-CWf368tT.js → registry-B08iilIh.js} +1 -1
  107. package/dist/{registry-CWf368tT.js.map → registry-B08iilIh.js.map} +1 -1
  108. package/dist/router/constraints.d.ts.map +1 -1
  109. package/dist/router/index.d.ts +1 -1
  110. package/dist/router/index.d.ts.map +1 -1
  111. package/dist/router/router.d.ts.map +1 -1
  112. package/dist/router/state.d.ts +25 -2
  113. package/dist/router/state.d.ts.map +1 -1
  114. package/dist/router-CQikC9Ed.js +492 -0
  115. package/dist/router-CQikC9Ed.js.map +1 -0
  116. package/dist/router.es.mjs +9 -8
  117. package/dist/ssr/hydrate.d.ts.map +1 -1
  118. package/dist/{ssr-B2qd_WBB.js → ssr-_dAcGdzu.js} +4 -4
  119. package/dist/ssr-_dAcGdzu.js.map +1 -0
  120. package/dist/ssr.es.mjs +1 -1
  121. package/dist/store/persisted.d.ts.map +1 -1
  122. package/dist/{store-DWpyH6p5.js → store-Cb3gPRve.js} +7 -7
  123. package/dist/store-Cb3gPRve.js.map +1 -0
  124. package/dist/store.es.mjs +2 -2
  125. package/dist/storybook.es.mjs.map +1 -1
  126. package/dist/{testing-CsqjNUyy.js → testing-C5Sjfsna.js} +8 -8
  127. package/dist/testing-C5Sjfsna.js.map +1 -0
  128. package/dist/testing.es.mjs +1 -1
  129. package/dist/{type-guards-Do9DWgNp.js → type-guards-BMX2c0LP.js} +1 -1
  130. package/dist/{type-guards-Do9DWgNp.js.map → type-guards-BMX2c0LP.js.map} +1 -1
  131. package/dist/untrack-D0fnO5k2.js +36 -0
  132. package/dist/untrack-D0fnO5k2.js.map +1 -0
  133. package/dist/view/custom-directives.d.ts.map +1 -1
  134. package/dist/view.es.mjs +4 -4
  135. package/package.json +177 -177
  136. package/src/a11y/announce.ts +131 -131
  137. package/src/a11y/audit.ts +314 -314
  138. package/src/a11y/index.ts +68 -68
  139. package/src/a11y/media-preferences.ts +255 -255
  140. package/src/a11y/roving-tab-index.ts +164 -164
  141. package/src/a11y/skip-link.ts +255 -255
  142. package/src/a11y/trap-focus.ts +184 -184
  143. package/src/a11y/types.ts +183 -183
  144. package/src/component/component.ts +599 -599
  145. package/src/component/html.ts +153 -153
  146. package/src/component/index.ts +52 -52
  147. package/src/component/library.ts +540 -542
  148. package/src/component/scope.ts +212 -212
  149. package/src/component/types.ts +310 -310
  150. package/src/core/collection.ts +876 -707
  151. package/src/core/element.ts +1015 -981
  152. package/src/core/env.ts +60 -60
  153. package/src/core/index.ts +49 -49
  154. package/src/core/shared.ts +77 -62
  155. package/src/core/utils/index.ts +148 -148
  156. package/src/devtools/devtools.ts +410 -410
  157. package/src/devtools/index.ts +48 -48
  158. package/src/devtools/types.ts +104 -104
  159. package/src/dnd/draggable.ts +296 -296
  160. package/src/dnd/droppable.ts +228 -228
  161. package/src/dnd/index.ts +62 -62
  162. package/src/dnd/sortable.ts +307 -307
  163. package/src/dnd/types.ts +293 -293
  164. package/src/forms/create-form.ts +320 -278
  165. package/src/forms/index.ts +70 -65
  166. package/src/forms/types.ts +203 -154
  167. package/src/forms/use-field.ts +231 -0
  168. package/src/forms/validators.ts +294 -265
  169. package/src/full.ts +554 -480
  170. package/src/i18n/formatting.ts +67 -67
  171. package/src/i18n/i18n.ts +200 -200
  172. package/src/i18n/index.ts +67 -67
  173. package/src/i18n/translate.ts +182 -182
  174. package/src/i18n/types.ts +171 -171
  175. package/src/index.ts +108 -108
  176. package/src/media/battery.ts +116 -116
  177. package/src/media/breakpoints.ts +129 -131
  178. package/src/media/clipboard.ts +80 -80
  179. package/src/media/device-sensors.ts +158 -158
  180. package/src/media/geolocation.ts +119 -119
  181. package/src/media/index.ts +76 -76
  182. package/src/media/media-query.ts +92 -92
  183. package/src/media/network.ts +115 -115
  184. package/src/media/types.ts +177 -177
  185. package/src/media/viewport.ts +84 -84
  186. package/src/motion/index.ts +57 -57
  187. package/src/motion/morph.ts +151 -151
  188. package/src/motion/parallax.ts +120 -120
  189. package/src/motion/reduced-motion.ts +66 -66
  190. package/src/motion/types.ts +271 -271
  191. package/src/motion/typewriter.ts +164 -164
  192. package/src/plugin/index.ts +37 -37
  193. package/src/plugin/registry.ts +284 -269
  194. package/src/plugin/types.ts +137 -137
  195. package/src/reactive/async-data.ts +250 -29
  196. package/src/reactive/computed.ts +144 -130
  197. package/src/reactive/effect.ts +29 -6
  198. package/src/reactive/http.ts +790 -0
  199. package/src/reactive/index.ts +60 -0
  200. package/src/reactive/pagination.ts +317 -0
  201. package/src/reactive/polling.ts +179 -0
  202. package/src/reactive/readonly.ts +52 -8
  203. package/src/reactive/rest.ts +859 -0
  204. package/src/reactive/scope.ts +276 -0
  205. package/src/reactive/signal.ts +61 -1
  206. package/src/reactive/to-value.ts +71 -0
  207. package/src/reactive/websocket.ts +849 -0
  208. package/src/router/bq-link.ts +279 -279
  209. package/src/router/constraints.ts +204 -201
  210. package/src/router/index.ts +49 -49
  211. package/src/router/match.ts +312 -312
  212. package/src/router/path-pattern.ts +52 -52
  213. package/src/router/query.ts +38 -38
  214. package/src/router/router.ts +421 -402
  215. package/src/router/state.ts +51 -3
  216. package/src/router/types.ts +139 -139
  217. package/src/router/use-route.ts +68 -68
  218. package/src/router/utils.ts +157 -157
  219. package/src/security/index.ts +12 -12
  220. package/src/ssr/hydrate.ts +84 -82
  221. package/src/ssr/index.ts +70 -70
  222. package/src/ssr/render.ts +508 -508
  223. package/src/ssr/serialize.ts +296 -296
  224. package/src/ssr/types.ts +81 -81
  225. package/src/store/create-store.ts +467 -467
  226. package/src/store/index.ts +27 -27
  227. package/src/store/persisted.ts +245 -249
  228. package/src/store/types.ts +247 -247
  229. package/src/store/utils.ts +135 -135
  230. package/src/storybook/index.ts +480 -480
  231. package/src/testing/index.ts +42 -42
  232. package/src/testing/testing.ts +593 -593
  233. package/src/testing/types.ts +170 -170
  234. package/src/view/custom-directives.ts +28 -30
  235. package/src/view/evaluate.ts +292 -292
  236. package/src/view/process.ts +108 -108
  237. package/dist/a11y-C5QOVvRn.js.map +0 -1
  238. package/dist/component-CuuTijA6.js.map +0 -1
  239. package/dist/constraints-3lV9yyBw.js.map +0 -1
  240. package/dist/core-Cjl7GUu8.js.map +0 -1
  241. package/dist/core-DnlyjbF2.js +0 -112
  242. package/dist/core-DnlyjbF2.js.map +0 -1
  243. package/dist/custom-directives-7wAShnnd.js.map +0 -1
  244. package/dist/devtools-D2fQLhDN.js.map +0 -1
  245. package/dist/dnd-B8EgyzaI.js.map +0 -1
  246. package/dist/env-NeVmr4Gf.js.map +0 -1
  247. package/dist/forms-C3yovgH9.js +0 -141
  248. package/dist/forms-C3yovgH9.js.map +0 -1
  249. package/dist/i18n-BnnhTFOS.js.map +0 -1
  250. package/dist/media-Di2Ta22s.js.map +0 -1
  251. package/dist/motion-qPj_TYGv.js.map +0 -1
  252. package/dist/mount-SM07RUa6.js.map +0 -1
  253. package/dist/plugin-cPoOHFLY.js.map +0 -1
  254. package/dist/reactive-Cfv0RK6x.js +0 -233
  255. package/dist/reactive-Cfv0RK6x.js.map +0 -1
  256. package/dist/router-BrthaP_z.js +0 -473
  257. package/dist/router-BrthaP_z.js.map +0 -1
  258. package/dist/ssr-B2qd_WBB.js.map +0 -1
  259. package/dist/store-DWpyH6p5.js.map +0 -1
  260. package/dist/testing-CsqjNUyy.js.map +0 -1
  261. package/dist/untrack-DJVQQ2WM.js +0 -33
  262. package/dist/untrack-DJVQQ2WM.js.map +0 -1
@@ -1,278 +1,320 @@
1
- /**
2
- * Reactive form creation and management.
3
- *
4
- * @module bquery/forms
5
- */
6
-
7
- import { computed, signal } from '../reactive/index';
8
- import { isPromise } from '../core/utils/type-guards';
9
- import type {
10
- CrossFieldValidator,
11
- FieldConfig,
12
- Form,
13
- FormConfig,
14
- FormErrors,
15
- FormField,
16
- FormFields,
17
- ValidationResult,
18
- Validator,
19
- } from './types';
20
-
21
- /**
22
- * Determines whether a validator returned a valid result.
23
- * @internal
24
- */
25
- const isValid = (result: ValidationResult): boolean => result === true || result === undefined;
26
-
27
- /**
28
- * Runs a single validator, normalising sync and async results.
29
- * @internal
30
- */
31
- const runValidator = async <T>(validator: Validator<T>, value: T): Promise<string | undefined> => {
32
- const result = validator(value);
33
- const resolved = isPromise(result) ? await result : result;
34
- return isValid(resolved) ? undefined : (resolved as string);
35
- };
36
-
37
- /**
38
- * Creates a reactive form field from its configuration.
39
- * @internal
40
- */
41
- const createField = <T>(config: FieldConfig<T>): FormField<T> => {
42
- const initial = config.initialValue;
43
- const value = signal<T>(initial);
44
- const error = signal('');
45
- const isTouched = signal(false);
46
-
47
- const isDirty = computed(() => !Object.is(value.value, initial));
48
- const isPristine = computed(() => !isDirty.value);
49
-
50
- return {
51
- value,
52
- error,
53
- isDirty,
54
- isTouched,
55
- isPristine,
56
- touch: () => {
57
- isTouched.value = true;
58
- },
59
- reset: () => {
60
- value.value = initial;
61
- error.value = '';
62
- isTouched.value = false;
63
- },
64
- };
65
- };
66
-
67
- /**
68
- * Validates a single field against its validators.
69
- * Sets the field's error signal.
70
- *
71
- * @returns The first error message, or an empty string if valid.
72
- * @internal
73
- */
74
- const validateSingleField = async <T>(
75
- field: FormField<T>,
76
- validators: Validator<T>[] | undefined
77
- ): Promise<string> => {
78
- if (!validators || validators.length === 0) {
79
- field.error.value = '';
80
- return '';
81
- }
82
-
83
- for (const validator of validators) {
84
- const errorMsg = await runValidator(validator, field.value.value);
85
- if (errorMsg) {
86
- field.error.value = errorMsg;
87
- return errorMsg;
88
- }
89
- }
90
-
91
- field.error.value = '';
92
- return '';
93
- };
94
-
95
- /**
96
- * Creates a fully reactive form with field-level validation,
97
- * dirty/touched tracking, cross-field validation, and submission handling.
98
- *
99
- * Each field's `value`, `error`, `isDirty`, `isTouched`, and `isPristine`
100
- * are reactive signals/computed values that can be used in effects, computed
101
- * values, or directly read/written.
102
- *
103
- * @template T - Shape of the form values (e.g. `{ name: string; age: number }`)
104
- * @param config - Form configuration with field definitions, validators, and submit handler
105
- * @returns A reactive {@link Form} instance
106
- *
107
- * @example
108
- * ```ts
109
- * import { createForm, required, email, min } from '@bquery/bquery/forms';
110
- *
111
- * const form = createForm({
112
- * fields: {
113
- * name: { initialValue: '', validators: [required()] },
114
- * email: { initialValue: '', validators: [required(), email()] },
115
- * age: { initialValue: 0, validators: [min(18, 'Must be 18+')] },
116
- * },
117
- * onSubmit: async (values) => {
118
- * await fetch('/api/register', {
119
- * method: 'POST',
120
- * body: JSON.stringify(values),
121
- * });
122
- * },
123
- * });
124
- *
125
- * // Read reactive state
126
- * console.log(form.isValid.value); // true (initially, before validation runs)
127
- * console.log(form.fields.name.value.value); // ''
128
- *
129
- * // Update a field
130
- * form.fields.name.value.value = 'Ada';
131
- *
132
- * // Validate and submit
133
- * await form.handleSubmit();
134
- * ```
135
- */
136
- export const createForm = <T extends Record<string, unknown>>(config: FormConfig<T>): Form<T> => {
137
- // Build reactive field objects
138
- const fieldEntries = Object.entries(config.fields) as [
139
- keyof T & string,
140
- FieldConfig<T[keyof T]>,
141
- ][];
142
-
143
- const fields = {} as FormFields<T>;
144
- const errors = {} as FormErrors<T>;
145
-
146
- for (const [name, fieldConfig] of fieldEntries) {
147
- const field = createField(fieldConfig as FieldConfig<T[typeof name]>);
148
- (fields as Record<string, FormField>)[name] = field;
149
- (errors as Record<string, typeof field.error>)[name] = field.error;
150
- }
151
-
152
- const isSubmitting = signal(false);
153
-
154
- // Computed: form is valid when all error signals are empty
155
- const isFormValid = computed(() => {
156
- for (const name of Object.keys(fields)) {
157
- if ((fields as Record<string, FormField>)[name].error.value !== '') {
158
- return false;
159
- }
160
- }
161
- return true;
162
- });
163
-
164
- // Computed: form is dirty when any field is dirty
165
- const isFormDirty = computed(() => {
166
- for (const name of Object.keys(fields)) {
167
- if ((fields as Record<string, FormField>)[name].isDirty.value) {
168
- return true;
169
- }
170
- }
171
- return false;
172
- });
173
-
174
- /**
175
- * Validate a single field by name.
176
- */
177
- const validateField = async (name: keyof T & string): Promise<void> => {
178
- const field = (fields as Record<string, FormField>)[name];
179
- const fieldConfig = (config.fields as Record<string, FieldConfig>)[name];
180
- if (!field || !fieldConfig) return;
181
- await validateSingleField(field, fieldConfig.validators);
182
- };
183
-
184
- /**
185
- * Validate all fields (per-field + cross-field).
186
- * Returns `true` if the entire form is valid.
187
- */
188
- const validate = async (): Promise<boolean> => {
189
- let hasError = false;
190
-
191
- // Per-field validation
192
- for (const [name, fieldConfig] of fieldEntries) {
193
- const field = (fields as Record<string, FormField>)[name];
194
- const error = await validateSingleField(field, (fieldConfig as FieldConfig).validators);
195
- if (error) hasError = true;
196
- }
197
-
198
- // Cross-field validation
199
- if (config.crossValidators && config.crossValidators.length > 0) {
200
- const values = getValues();
201
- for (const crossValidator of config.crossValidators as CrossFieldValidator<T>[]) {
202
- const crossErrors = await crossValidator(values);
203
- if (crossErrors) {
204
- for (const [fieldName, errorMsg] of Object.entries(crossErrors) as [
205
- string,
206
- string | undefined,
207
- ][]) {
208
- if (errorMsg) {
209
- const field = (fields as Record<string, FormField>)[fieldName];
210
- if (field) {
211
- // Only set cross-field error if no per-field error exists
212
- if (field.error.value === '') {
213
- field.error.value = errorMsg;
214
- }
215
- hasError = true;
216
- }
217
- }
218
- }
219
- }
220
- }
221
- }
222
-
223
- return !hasError;
224
- };
225
-
226
- /**
227
- * Validate all fields and, if valid, invoke the onSubmit handler.
228
- * Prevents concurrent submissions by setting isSubmitting before validation.
229
- */
230
- const handleSubmit = async (): Promise<void> => {
231
- if (isSubmitting.value) return;
232
- isSubmitting.value = true;
233
-
234
- try {
235
- const valid = await validate();
236
- if (!valid) return;
237
-
238
- if (config.onSubmit) {
239
- await config.onSubmit(getValues());
240
- }
241
- } finally {
242
- isSubmitting.value = false;
243
- }
244
- };
245
-
246
- /**
247
- * Reset every field to its initial value and clear all errors.
248
- */
249
- const reset = (): void => {
250
- for (const name of Object.keys(fields)) {
251
- (fields as Record<string, FormField>)[name].reset();
252
- }
253
- };
254
-
255
- /**
256
- * Return a plain object snapshot of all current field values.
257
- */
258
- const getValues = (): T => {
259
- const values = {} as Record<string, unknown>;
260
- for (const name of Object.keys(fields)) {
261
- values[name] = (fields as Record<string, FormField>)[name].value.value;
262
- }
263
- return values as T;
264
- };
265
-
266
- return {
267
- fields,
268
- errors,
269
- isValid: isFormValid,
270
- isDirty: isFormDirty,
271
- isSubmitting,
272
- handleSubmit,
273
- validateField,
274
- validate,
275
- reset,
276
- getValues,
277
- };
278
- };
1
+ /**
2
+ * Reactive form creation and management.
3
+ *
4
+ * @module bquery/forms
5
+ */
6
+
7
+ import { computed, signal } from '../reactive/index';
8
+ import { isPrototypePollutionKey } from '../core/utils/object';
9
+ import { isPromise } from '../core/utils/type-guards';
10
+ import type {
11
+ CrossFieldValidator,
12
+ FieldConfig,
13
+ Form,
14
+ FormConfig,
15
+ FormErrors,
16
+ FormField,
17
+ FormFields,
18
+ ValidationResult,
19
+ Validator,
20
+ } from './types';
21
+
22
+ /**
23
+ * Determines whether a validator returned a valid result.
24
+ * @internal
25
+ */
26
+ const isValid = (result: ValidationResult): boolean => result === true || result === undefined;
27
+
28
+ /**
29
+ * Runs a single validator, normalising sync and async results.
30
+ * @internal
31
+ */
32
+ const runValidator = async <T>(validator: Validator<T>, value: T): Promise<string | undefined> => {
33
+ const result = validator(value);
34
+ const resolved = isPromise(result) ? await result : result;
35
+ return isValid(resolved) ? undefined : (resolved as string);
36
+ };
37
+
38
+ /**
39
+ * Creates a reactive form field from its configuration.
40
+ * @internal
41
+ */
42
+ const createField = <T>(config: FieldConfig<T>): FormField<T> => {
43
+ const initial = config.initialValue;
44
+ const value = signal<T>(initial);
45
+ const error = signal('');
46
+ const isTouched = signal(false);
47
+
48
+ const isDirty = computed(() => !Object.is(value.value, initial));
49
+ const isPristine = computed(() => !isDirty.value);
50
+
51
+ return {
52
+ value,
53
+ error,
54
+ isDirty,
55
+ isTouched,
56
+ isPristine,
57
+ touch: () => {
58
+ isTouched.value = true;
59
+ },
60
+ reset: () => {
61
+ value.value = initial;
62
+ error.value = '';
63
+ isTouched.value = false;
64
+ },
65
+ };
66
+ };
67
+
68
+ /**
69
+ * Validates a single field against its validators.
70
+ * Sets the field's error signal.
71
+ *
72
+ * @returns The first error message, or an empty string if valid.
73
+ * @internal
74
+ */
75
+ const validateSingleField = async <T>(
76
+ field: FormField<T>,
77
+ validators: Validator<T>[] | undefined
78
+ ): Promise<string> => {
79
+ if (!validators || validators.length === 0) {
80
+ field.error.value = '';
81
+ return '';
82
+ }
83
+
84
+ for (const validator of validators) {
85
+ const errorMsg = await runValidator(validator, field.value.value);
86
+ if (errorMsg) {
87
+ field.error.value = errorMsg;
88
+ return errorMsg;
89
+ }
90
+ }
91
+
92
+ field.error.value = '';
93
+ return '';
94
+ };
95
+
96
+ /**
97
+ * Creates a fully reactive form with field-level validation,
98
+ * dirty/touched tracking, cross-field validation, and submission handling.
99
+ *
100
+ * Each field's `value`, `error`, `isDirty`, `isTouched`, and `isPristine`
101
+ * are reactive signals/computed values that can be used in effects, computed
102
+ * values, or directly read/written.
103
+ *
104
+ * @template T - Shape of the form values (e.g. `{ name: string; age: number }`)
105
+ * @param config - Form configuration with field definitions, validators, and submit handler
106
+ * @returns A reactive {@link Form} instance
107
+ *
108
+ * @example
109
+ * ```ts
110
+ * import { createForm, required, email, min } from '@bquery/bquery/forms';
111
+ *
112
+ * const form = createForm({
113
+ * fields: {
114
+ * name: { initialValue: '', validators: [required()] },
115
+ * email: { initialValue: '', validators: [required(), email()] },
116
+ * age: { initialValue: 0, validators: [min(18, 'Must be 18+')] },
117
+ * },
118
+ * onSubmit: async (values) => {
119
+ * await fetch('/api/register', {
120
+ * method: 'POST',
121
+ * body: JSON.stringify(values),
122
+ * });
123
+ * },
124
+ * });
125
+ *
126
+ * // Read reactive state
127
+ * console.log(form.isValid.value); // true (initially, before validation runs)
128
+ * console.log(form.fields.name.value.value); // ''
129
+ *
130
+ * // Update a field
131
+ * form.fields.name.value.value = 'Ada';
132
+ *
133
+ * // Validate and submit
134
+ * await form.handleSubmit();
135
+ * ```
136
+ */
137
+ export const createForm = <T extends Record<string, unknown>>(config: FormConfig<T>): Form<T> => {
138
+ // Build reactive field objects
139
+ const fieldEntries = Object.entries(config.fields) as [
140
+ keyof T & string,
141
+ FieldConfig<T[keyof T]>,
142
+ ][];
143
+
144
+ const fields = {} as FormFields<T>;
145
+ const errors = {} as FormErrors<T>;
146
+
147
+ for (const [name, fieldConfig] of fieldEntries) {
148
+ const field = createField(fieldConfig as FieldConfig<T[typeof name]>);
149
+ (fields as Record<string, FormField>)[name] = field;
150
+ (errors as Record<string, typeof field.error>)[name] = field.error;
151
+ }
152
+
153
+ const isSubmitting = signal(false);
154
+
155
+ // Computed: form is valid when all error signals are empty
156
+ const isFormValid = computed(() => {
157
+ for (const name of Object.keys(fields)) {
158
+ if ((fields as Record<string, FormField>)[name].error.value !== '') {
159
+ return false;
160
+ }
161
+ }
162
+ return true;
163
+ });
164
+
165
+ // Computed: form is dirty when any field is dirty
166
+ const isFormDirty = computed(() => {
167
+ for (const name of Object.keys(fields)) {
168
+ if ((fields as Record<string, FormField>)[name].isDirty.value) {
169
+ return true;
170
+ }
171
+ }
172
+ return false;
173
+ });
174
+
175
+ /**
176
+ * Validate a single field by name.
177
+ */
178
+ const validateField = async (name: keyof T & string): Promise<void> => {
179
+ const field = (fields as Record<string, FormField>)[name];
180
+ const fieldConfig = (config.fields as Record<string, FieldConfig>)[name];
181
+ if (!field || !fieldConfig) return;
182
+ await validateSingleField(field, fieldConfig.validators);
183
+ };
184
+
185
+ /**
186
+ * Validate all fields (per-field + cross-field).
187
+ * Returns `true` if the entire form is valid.
188
+ */
189
+ const validate = async (): Promise<boolean> => {
190
+ let hasError = false;
191
+
192
+ // Per-field validation
193
+ for (const [name, fieldConfig] of fieldEntries) {
194
+ const field = (fields as Record<string, FormField>)[name];
195
+ const error = await validateSingleField(field, (fieldConfig as FieldConfig).validators);
196
+ if (error) hasError = true;
197
+ }
198
+
199
+ // Cross-field validation
200
+ if (config.crossValidators && config.crossValidators.length > 0) {
201
+ const values = getValues();
202
+ for (const crossValidator of config.crossValidators as CrossFieldValidator<T>[]) {
203
+ const crossErrors = await crossValidator(values);
204
+ if (crossErrors) {
205
+ for (const [fieldName, errorMsg] of Object.entries(crossErrors) as [
206
+ string,
207
+ string | undefined,
208
+ ][]) {
209
+ if (errorMsg) {
210
+ const field = (fields as Record<string, FormField>)[fieldName];
211
+ if (field) {
212
+ // Only set cross-field error if no per-field error exists
213
+ if (field.error.value === '') {
214
+ field.error.value = errorMsg;
215
+ }
216
+ hasError = true;
217
+ }
218
+ }
219
+ }
220
+ }
221
+ }
222
+ }
223
+
224
+ return !hasError;
225
+ };
226
+
227
+ /**
228
+ * Validate all fields and, if valid, invoke the onSubmit handler.
229
+ * Prevents concurrent submissions by setting isSubmitting before validation.
230
+ */
231
+ const handleSubmit = async (): Promise<void> => {
232
+ if (isSubmitting.value) return;
233
+ isSubmitting.value = true;
234
+
235
+ try {
236
+ const valid = await validate();
237
+ if (!valid) return;
238
+
239
+ if (config.onSubmit) {
240
+ await config.onSubmit(getValues());
241
+ }
242
+ } finally {
243
+ isSubmitting.value = false;
244
+ }
245
+ };
246
+
247
+ /**
248
+ * Reset every field to its initial value and clear all errors.
249
+ */
250
+ const reset = (): void => {
251
+ for (const name of Object.keys(fields)) {
252
+ (fields as Record<string, FormField>)[name].reset();
253
+ }
254
+ };
255
+
256
+ /**
257
+ * Return a plain object snapshot of all current field values.
258
+ */
259
+ const getValues = (): T => {
260
+ const values = {} as Record<string, unknown>;
261
+ for (const name of Object.keys(fields)) {
262
+ values[name] = (fields as Record<string, FormField>)[name].value.value;
263
+ }
264
+ return values as T;
265
+ };
266
+
267
+ /**
268
+ * Bulk-set field values from a partial object.
269
+ * Only fields present in the object are updated; missing keys are left unchanged.
270
+ */
271
+ const setValues = (values: Partial<T>): void => {
272
+ for (const [name, val] of Object.entries(values)) {
273
+ // Ignore inherited keys and prototype-pollution vectors before mutating field state.
274
+ if (isPrototypePollutionKey(name) || !Object.prototype.hasOwnProperty.call(fields, name)) {
275
+ continue;
276
+ }
277
+
278
+ const field = (fields as Record<string, FormField>)[name];
279
+ if (!field) {
280
+ continue;
281
+ }
282
+ field.value.value = val;
283
+ }
284
+ };
285
+
286
+ /**
287
+ * Bulk-set field error messages from a partial object.
288
+ * Useful for applying server-side validation errors.
289
+ * Only fields present in the object are updated; missing keys are left unchanged.
290
+ */
291
+ const setErrors = (errorMap: Partial<Record<keyof T & string, string>>): void => {
292
+ for (const [name, msg] of Object.entries(errorMap)) {
293
+ // Ignore inherited keys and prototype-pollution vectors before mutating field state.
294
+ if (isPrototypePollutionKey(name) || !Object.prototype.hasOwnProperty.call(fields, name)) {
295
+ continue;
296
+ }
297
+
298
+ const field = (fields as Record<string, FormField>)[name];
299
+ if (!field) {
300
+ continue;
301
+ }
302
+ field.error.value = (msg as string) ?? '';
303
+ }
304
+ };
305
+
306
+ return {
307
+ fields,
308
+ errors,
309
+ isValid: isFormValid,
310
+ isDirty: isFormDirty,
311
+ isSubmitting,
312
+ handleSubmit,
313
+ validateField,
314
+ validate,
315
+ reset,
316
+ getValues,
317
+ setValues,
318
+ setErrors,
319
+ };
320
+ };