@pyreon/form 0.0.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.
@@ -0,0 +1,436 @@
1
+ import { onUnmount } from '@pyreon/core'
2
+ import { signal, computed, effect } from '@pyreon/reactivity'
3
+ import type { Signal } from '@pyreon/reactivity'
4
+ import type {
5
+ FieldRegisterProps,
6
+ FieldState,
7
+ FormState,
8
+ UseFormOptions,
9
+ ValidationError,
10
+ } from './types'
11
+
12
+ /**
13
+ * Create a signal-based form. Returns reactive field states, form-level
14
+ * signals, and handlers for submit/reset/validate.
15
+ *
16
+ * @example
17
+ * const form = useForm({
18
+ * initialValues: { email: '', password: '', remember: false },
19
+ * validators: {
20
+ * email: (v) => (!v ? 'Required' : undefined),
21
+ * password: (v, all) => (v.length < 8 ? 'Too short' : undefined),
22
+ * },
23
+ * onSubmit: async (values) => { await login(values) },
24
+ * })
25
+ *
26
+ * // Bind with register():
27
+ * // h('input', form.register('email'))
28
+ * // h('input', { type: 'checkbox', ...form.register('remember', { type: 'checkbox' }) })
29
+ */
30
+ export function useForm<TValues extends Record<string, unknown>>(
31
+ options: UseFormOptions<TValues>,
32
+ ): FormState<TValues> {
33
+ const {
34
+ initialValues,
35
+ onSubmit,
36
+ validators,
37
+ schema,
38
+ validateOn = 'blur',
39
+ debounceMs,
40
+ } = options
41
+
42
+ // Build field states
43
+ const fieldEntries = Object.entries(initialValues) as [
44
+ keyof TValues & string,
45
+ TValues[keyof TValues],
46
+ ][]
47
+
48
+ const fields = {} as { [K in keyof TValues]: FieldState<TValues[K]> }
49
+
50
+ // Debounce timers per field (only allocated when debounceMs is set)
51
+ const debounceTimers: Partial<
52
+ Record<keyof TValues, ReturnType<typeof setTimeout>>
53
+ > = {}
54
+
55
+ // Validation version per field — used to discard stale async results
56
+ const validationVersions: Partial<Record<keyof TValues, number>> = {}
57
+
58
+ // Helper to get all current values (used by cross-field validators)
59
+ const getValues = (): TValues => {
60
+ const values = {} as TValues
61
+ for (const [name] of fieldEntries) {
62
+ ;(values as Record<string, unknown>)[name] =
63
+ fields[name]?.value.peek() ??
64
+ (initialValues as Record<string, unknown>)[name]
65
+ }
66
+ return values
67
+ }
68
+
69
+ // Clear all pending debounce timers
70
+ const clearAllTimers = () => {
71
+ for (const key of Object.keys(debounceTimers)) {
72
+ clearTimeout(debounceTimers[key as keyof TValues])
73
+ delete debounceTimers[key as keyof TValues]
74
+ }
75
+ }
76
+
77
+ const isValidating = signal(false)
78
+ const submitError = signal<unknown>(undefined)
79
+
80
+ // Track whether the form has been disposed (unmounted)
81
+ let disposed = false
82
+
83
+ for (const [name, initial] of fieldEntries) {
84
+ const valueSig = signal(initial) as Signal<TValues[typeof name]>
85
+ const errorSig = signal<ValidationError>(undefined)
86
+ const touchedSig = signal(false)
87
+ const dirtySig = signal(false)
88
+
89
+ // Initialize validation version
90
+ validationVersions[name] = 0
91
+
92
+ const runValidation = async (value: TValues[typeof name]) => {
93
+ const fieldValidator = validators?.[name]
94
+ if (fieldValidator) {
95
+ // Bump version to track this validation run
96
+ validationVersions[name] = (validationVersions[name] ?? 0) + 1
97
+ const currentVersion = validationVersions[name]
98
+ try {
99
+ const result = await fieldValidator(value, getValues())
100
+ // Only apply result if this is still the latest validation and not disposed
101
+ if (!disposed && validationVersions[name] === currentVersion) {
102
+ errorSig.set(result)
103
+ }
104
+ return result
105
+ } catch (err) {
106
+ // Validator threw — treat as error string if possible
107
+ if (!disposed && validationVersions[name] === currentVersion) {
108
+ const message = err instanceof Error ? err.message : String(err)
109
+ errorSig.set(message)
110
+ }
111
+ return err instanceof Error ? err.message : String(err)
112
+ }
113
+ }
114
+ errorSig.set(undefined)
115
+ return undefined
116
+ }
117
+
118
+ const validateField = debounceMs
119
+ ? (value: TValues[typeof name]) => {
120
+ clearTimeout(debounceTimers[name])
121
+ return new Promise<ValidationError>((resolve) => {
122
+ debounceTimers[name] = setTimeout(async () => {
123
+ resolve(await runValidation(value))
124
+ }, debounceMs)
125
+ })
126
+ }
127
+ : runValidation
128
+
129
+ // Auto-validate on change if configured
130
+ if (validateOn === 'change') {
131
+ effect(() => {
132
+ const v = valueSig()
133
+ validateField(v)
134
+ })
135
+ }
136
+
137
+ fields[name] = {
138
+ value: valueSig,
139
+ error: errorSig,
140
+ touched: touchedSig,
141
+ dirty: dirtySig,
142
+ setValue: (value: TValues[typeof name]) => {
143
+ valueSig.set(value)
144
+ // Deep comparison for objects/arrays, reference for primitives
145
+ dirtySig.set(!structuredEqual(value, initial))
146
+ },
147
+ setTouched: () => {
148
+ touchedSig.set(true)
149
+ if (validateOn === 'blur') {
150
+ validateField(valueSig.peek())
151
+ }
152
+ },
153
+ reset: () => {
154
+ valueSig.set(initial as TValues[typeof name])
155
+ errorSig.set(undefined)
156
+ touchedSig.set(false)
157
+ dirtySig.set(false)
158
+ clearTimeout(debounceTimers[name])
159
+ },
160
+ } as FieldState<TValues[typeof name]>
161
+ }
162
+
163
+ // Clean up debounce timers and cancel in-flight validators on unmount
164
+ onUnmount(() => {
165
+ disposed = true
166
+ clearAllTimers()
167
+ })
168
+
169
+ const isSubmitting = signal(false)
170
+ const submitCount = signal(0)
171
+
172
+ // Form-level computed signals
173
+ const isValid = computed(() => {
174
+ for (const name of fieldEntries.map(([n]) => n)) {
175
+ if (fields[name].error() !== undefined) return false
176
+ }
177
+ return true
178
+ })
179
+
180
+ const isDirty = computed(() => {
181
+ for (const name of fieldEntries.map(([n]) => n)) {
182
+ if (fields[name].dirty()) return true
183
+ }
184
+ return false
185
+ })
186
+
187
+ const getErrors = (): Partial<Record<keyof TValues, ValidationError>> => {
188
+ const errors = {} as Partial<Record<keyof TValues, ValidationError>>
189
+ for (const [name] of fieldEntries) {
190
+ const err = fields[name].error.peek()
191
+ if (err !== undefined) errors[name] = err
192
+ }
193
+ return errors
194
+ }
195
+
196
+ const validate = async (): Promise<boolean> => {
197
+ // Cancel any pending debounced validations
198
+ clearAllTimers()
199
+
200
+ isValidating.set(true)
201
+
202
+ try {
203
+ const allValues = getValues()
204
+
205
+ // Clear all errors before re-validating
206
+ for (const [name] of fieldEntries) {
207
+ fields[name].error.set(undefined)
208
+ }
209
+
210
+ // Run field-level validators with all values for cross-field support
211
+ await Promise.all(
212
+ fieldEntries.map(async ([name]) => {
213
+ const fieldValidator = validators?.[name]
214
+ if (fieldValidator) {
215
+ // Bump version so any in-flight debounced validation is discarded
216
+ validationVersions[name] = (validationVersions[name] ?? 0) + 1
217
+ const currentVersion = validationVersions[name]
218
+ try {
219
+ const error = await fieldValidator(
220
+ fields[name].value.peek(),
221
+ allValues,
222
+ )
223
+ if (validationVersions[name] === currentVersion) {
224
+ fields[name].error.set(error)
225
+ }
226
+ } catch (err) {
227
+ if (validationVersions[name] === currentVersion) {
228
+ fields[name].error.set(
229
+ err instanceof Error ? err.message : String(err),
230
+ )
231
+ }
232
+ }
233
+ }
234
+ }),
235
+ )
236
+
237
+ // Run schema-level validator — only set schema errors for fields
238
+ // that don't already have a field-level error (field-level wins)
239
+ if (schema) {
240
+ try {
241
+ const schemaErrors = await schema(allValues)
242
+ for (const [name] of fieldEntries) {
243
+ const schemaError = schemaErrors[name]
244
+ if (
245
+ schemaError !== undefined &&
246
+ fields[name].error.peek() === undefined
247
+ ) {
248
+ fields[name].error.set(schemaError)
249
+ }
250
+ }
251
+ } catch (err) {
252
+ // Schema validator threw — set as submitError rather than losing it
253
+ submitError.set(err)
254
+ return false
255
+ }
256
+ }
257
+
258
+ // Re-check: any field with an error means invalid
259
+ for (const [name] of fieldEntries) {
260
+ if (fields[name].error.peek() !== undefined) return false
261
+ }
262
+ return true
263
+ } finally {
264
+ isValidating.set(false)
265
+ }
266
+ }
267
+
268
+ const handleSubmit = async (e?: Event) => {
269
+ if (e && typeof e.preventDefault === 'function') {
270
+ e.preventDefault()
271
+ }
272
+
273
+ submitError.set(undefined)
274
+ submitCount.update((n) => n + 1)
275
+
276
+ // Mark all fields as touched
277
+ for (const [name] of fieldEntries) {
278
+ fields[name].touched.set(true)
279
+ }
280
+
281
+ const valid = await validate()
282
+ if (!valid) return
283
+
284
+ isSubmitting.set(true)
285
+ try {
286
+ await onSubmit(getValues())
287
+ } catch (err) {
288
+ submitError.set(err)
289
+ throw err
290
+ } finally {
291
+ isSubmitting.set(false)
292
+ }
293
+ }
294
+
295
+ const reset = () => {
296
+ clearAllTimers()
297
+ for (const [name] of fieldEntries) {
298
+ fields[name].reset()
299
+ }
300
+ submitCount.set(0)
301
+ submitError.set(undefined)
302
+ }
303
+
304
+ const setFieldValue = <K extends keyof TValues>(
305
+ field: K,
306
+ value: TValues[K],
307
+ ) => {
308
+ if (!fields[field]) {
309
+ throw new Error(
310
+ `[@pyreon/form] Field "${String(field)}" does not exist. Available fields: ${fieldEntries.map(([n]) => n).join(', ')}`,
311
+ )
312
+ }
313
+ fields[field].setValue(value)
314
+ }
315
+
316
+ const setFieldError = (field: keyof TValues, error: ValidationError) => {
317
+ if (!fields[field]) {
318
+ throw new Error(
319
+ `[@pyreon/form] Field "${String(field)}" does not exist. Available fields: ${fieldEntries.map(([n]) => n).join(', ')}`,
320
+ )
321
+ }
322
+ fields[field].error.set(error)
323
+ }
324
+
325
+ const setErrors = (
326
+ errors: Partial<Record<keyof TValues, ValidationError>>,
327
+ ) => {
328
+ for (const [name, error] of Object.entries(errors)) {
329
+ setFieldError(name as keyof TValues, error as ValidationError)
330
+ }
331
+ }
332
+
333
+ const clearErrors = () => {
334
+ for (const [name] of fieldEntries) {
335
+ fields[name].error.set(undefined)
336
+ }
337
+ }
338
+
339
+ const resetField = (field: keyof TValues) => {
340
+ if (fields[field]) {
341
+ fields[field].reset()
342
+ }
343
+ }
344
+
345
+ // Memoized register props per field+type combo
346
+ const registerCache = new Map<string, FieldRegisterProps<unknown>>()
347
+
348
+ const register = <K extends keyof TValues & string>(
349
+ field: K,
350
+ opts?: { type?: 'checkbox' | 'number' },
351
+ ): FieldRegisterProps<TValues[K]> => {
352
+ const cacheKey = `${field}:${opts?.type ?? 'text'}`
353
+ const cached = registerCache.get(cacheKey)
354
+ if (cached) return cached as FieldRegisterProps<TValues[K]>
355
+
356
+ const fieldState = fields[field]
357
+ const props: FieldRegisterProps<TValues[K]> = {
358
+ value: fieldState.value,
359
+ onInput: (e: Event) => {
360
+ const target = e.target as HTMLInputElement
361
+ if (opts?.type === 'checkbox') {
362
+ fieldState.setValue(target.checked as TValues[K])
363
+ } else if (opts?.type === 'number') {
364
+ const num = target.valueAsNumber
365
+ fieldState.setValue(
366
+ (Number.isNaN(num) ? target.value : num) as TValues[K],
367
+ )
368
+ } else {
369
+ fieldState.setValue(target.value as TValues[K])
370
+ }
371
+ },
372
+ onBlur: () => {
373
+ fieldState.setTouched()
374
+ },
375
+ }
376
+
377
+ if (opts?.type === 'checkbox') {
378
+ props.checked = computed(() => Boolean(fieldState.value()))
379
+ }
380
+
381
+ registerCache.set(cacheKey, props as FieldRegisterProps<unknown>)
382
+ return props
383
+ }
384
+
385
+ return {
386
+ fields,
387
+ isSubmitting,
388
+ isValidating,
389
+ isValid,
390
+ isDirty,
391
+ submitCount,
392
+ submitError,
393
+ values: getValues,
394
+ errors: getErrors,
395
+ setFieldValue,
396
+ setFieldError,
397
+ setErrors,
398
+ clearErrors,
399
+ resetField,
400
+ register,
401
+ handleSubmit,
402
+ reset,
403
+ validate,
404
+ }
405
+ }
406
+
407
+ /** Deep structural equality with depth limit to guard against circular references. */
408
+ function structuredEqual(a: unknown, b: unknown, depth = 0): boolean {
409
+ if (Object.is(a, b)) return true
410
+ if (a == null || b == null) return false
411
+ if (typeof a !== typeof b) return false
412
+ // Bail at depth 10 — treat as not equal to avoid infinite recursion
413
+ if (depth > 10) return false
414
+
415
+ if (Array.isArray(a) && Array.isArray(b)) {
416
+ if (a.length !== b.length) return false
417
+ for (let i = 0; i < a.length; i++) {
418
+ if (!structuredEqual(a[i], b[i], depth + 1)) return false
419
+ }
420
+ return true
421
+ }
422
+
423
+ if (typeof a === 'object' && typeof b === 'object') {
424
+ const aObj = a as Record<string, unknown>
425
+ const bObj = b as Record<string, unknown>
426
+ const aKeys = Object.keys(aObj)
427
+ const bKeys = Object.keys(bObj)
428
+ if (aKeys.length !== bKeys.length) return false
429
+ for (const key of aKeys) {
430
+ if (!structuredEqual(aObj[key], bObj[key], depth + 1)) return false
431
+ }
432
+ return true
433
+ }
434
+
435
+ return false
436
+ }
@@ -0,0 +1,69 @@
1
+ import { computed } from '@pyreon/reactivity'
2
+ import type { Signal, Computed } from '@pyreon/reactivity'
3
+ import type { FormState } from './types'
4
+
5
+ /**
6
+ * Watch specific field values reactively. Returns a computed signal
7
+ * that re-evaluates when any of the watched fields change.
8
+ *
9
+ * @example
10
+ * // Watch a single field
11
+ * const email = useWatch(form, 'email')
12
+ * // email() => current email value
13
+ *
14
+ * @example
15
+ * // Watch multiple fields
16
+ * const [first, last] = useWatch(form, ['firstName', 'lastName'])
17
+ * // first() => firstName value, last() => lastName value
18
+ *
19
+ * @example
20
+ * // Watch all fields
21
+ * const all = useWatch(form)
22
+ * // all() => { email: '...', password: '...' }
23
+ */
24
+ export function useWatch<
25
+ TValues extends Record<string, unknown>,
26
+ K extends keyof TValues & string,
27
+ >(form: FormState<TValues>, name: K): Signal<TValues[K]>
28
+
29
+ export function useWatch<
30
+ TValues extends Record<string, unknown>,
31
+ K extends (keyof TValues & string)[],
32
+ >(
33
+ form: FormState<TValues>,
34
+ names: K,
35
+ ): { [I in keyof K]: Signal<TValues[K[I] & keyof TValues]> }
36
+
37
+ export function useWatch<TValues extends Record<string, unknown>>(
38
+ form: FormState<TValues>,
39
+ ): Computed<TValues>
40
+
41
+ export function useWatch<
42
+ TValues extends Record<string, unknown>,
43
+ K extends keyof TValues & string,
44
+ >(
45
+ form: FormState<TValues>,
46
+ nameOrNames?: K | K[],
47
+ ): Signal<TValues[K]> | Signal<TValues[K]>[] | Computed<TValues> {
48
+ // Watch all fields
49
+ if (nameOrNames === undefined) {
50
+ return computed(() => {
51
+ const result = {} as TValues
52
+ for (const key of Object.keys(form.fields) as (keyof TValues &
53
+ string)[]) {
54
+ ;(result as Record<string, unknown>)[key] = form.fields[key].value()
55
+ }
56
+ return result
57
+ })
58
+ }
59
+
60
+ // Watch multiple fields
61
+ if (Array.isArray(nameOrNames)) {
62
+ return nameOrNames.map((name) => form.fields[name].value) as Signal<
63
+ TValues[K]
64
+ >[]
65
+ }
66
+
67
+ // Watch single field
68
+ return form.fields[nameOrNames].value
69
+ }