@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-present Vit Bokisch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,259 @@
1
+ # @pyreon/form
2
+
3
+ Signal-based form management for Pyreon. Fields, validation, submission, arrays, and context.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ bun add @pyreon/form
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```tsx
14
+ import { useForm } from "@pyreon/form"
15
+
16
+ function LoginForm() {
17
+ const form = useForm({
18
+ initialValues: { email: "", password: "" },
19
+ validators: {
20
+ email: (v) => (!v.includes("@") ? "Invalid email" : undefined),
21
+ password: (v) => (v.length < 8 ? "Too short" : undefined),
22
+ },
23
+ validateOn: "blur",
24
+ onSubmit: async (values) => {
25
+ await fetch("/api/login", { method: "POST", body: JSON.stringify(values) })
26
+ },
27
+ })
28
+
29
+ return () => (
30
+ <form onSubmit={form.handleSubmit}>
31
+ <input type="email" {...form.register("email")} />
32
+ <input type="password" {...form.register("password")} />
33
+ <button type="submit">Login</button>
34
+ </form>
35
+ )
36
+ }
37
+ ```
38
+
39
+ ## API
40
+
41
+ ### `useForm(options)`
42
+
43
+ Create a reactive form instance with field states, validation, and submission handling.
44
+
45
+ | Parameter | Type | Description |
46
+ | --- | --- | --- |
47
+ | `options.initialValues` | `TValues` | Initial values for each field |
48
+ | `options.onSubmit` | `(values: TValues) => void \| Promise<void>` | Called with validated values on submit |
49
+ | `options.validators` | `Partial<Record<keyof TValues, ValidateFn>>` | Per-field validators (receives value + all values) |
50
+ | `options.schema` | `SchemaValidateFn<TValues>` | Schema-level validator (runs after field validators) |
51
+ | `options.validateOn` | `"blur" \| "change" \| "submit"` | When to trigger validation (default: `"blur"`) |
52
+ | `options.debounceMs` | `number` | Debounce delay for validators in ms |
53
+
54
+ **Returns:** `FormState<TValues>` with these properties:
55
+
56
+ | Property | Type | Description |
57
+ | --- | --- | --- |
58
+ | `fields` | `Record<keyof TValues, FieldState>` | Individual field states |
59
+ | `isSubmitting` | `Signal<boolean>` | Whether the form is being submitted |
60
+ | `isValidating` | `Signal<boolean>` | Whether async validation is running |
61
+ | `isValid` | `Accessor<boolean>` | Whether all fields pass validation |
62
+ | `isDirty` | `Accessor<boolean>` | Whether any field differs from initial |
63
+ | `submitCount` | `Signal<number>` | Number of submission attempts |
64
+ | `submitError` | `Signal<unknown>` | Error thrown by `onSubmit` |
65
+ | `values()` | `() => TValues` | Get all current values |
66
+ | `errors()` | `() => Record<keyof TValues, string>` | Get all current errors |
67
+ | `register(field, opts?)` | `Function` | Bind an input to a field |
68
+ | `handleSubmit(e?)` | `(e?: Event) => Promise<void>` | Submit handler |
69
+ | `validate()` | `() => Promise<boolean>` | Validate all fields |
70
+ | `reset()` | `() => void` | Reset all fields to initial values |
71
+ | `setFieldValue(field, value)` | `Function` | Set a single field's value |
72
+ | `setFieldError(field, error)` | `Function` | Set a single field's error |
73
+ | `setErrors(errors)` | `Function` | Set multiple field errors |
74
+ | `clearErrors()` | `() => void` | Clear all errors |
75
+ | `resetField(field)` | `Function` | Reset a single field |
76
+
77
+ ```tsx
78
+ const form = useForm({
79
+ initialValues: { email: "", remember: false },
80
+ onSubmit: async (values) => console.log(values),
81
+ })
82
+
83
+ // Bind text input:
84
+ <input {...form.register("email")} />
85
+
86
+ // Bind checkbox:
87
+ <input type="checkbox" {...form.register("remember", { type: "checkbox" })} />
88
+
89
+ // Bind number input:
90
+ <input type="number" {...form.register("age", { type: "number" })} />
91
+ ```
92
+
93
+ ### `useField(form, name)`
94
+
95
+ Extract a single field's state with computed helpers. Useful for building isolated field components.
96
+
97
+ | Parameter | Type | Description |
98
+ | --- | --- | --- |
99
+ | `form` | `FormState<TValues>` | Form instance from `useForm` |
100
+ | `name` | `keyof TValues & string` | Field name |
101
+
102
+ **Returns:** `UseFieldResult<T>` with `value`, `error`, `touched`, `dirty`, `setValue`, `setTouched`, `reset`, `register`, `hasError` (Computed), `showError` (Computed: touched AND has error).
103
+
104
+ ```tsx
105
+ function EmailField({ form }) {
106
+ const field = useField(form, "email")
107
+ return () => (
108
+ <div>
109
+ <input {...field.register()} />
110
+ {() => field.showError() ? <span>{field.error()}</span> : null}
111
+ </div>
112
+ )
113
+ }
114
+ ```
115
+
116
+ ### `useFieldArray(initial?)`
117
+
118
+ Manage a dynamic array of form fields with stable keys for keyed rendering.
119
+
120
+ | Parameter | Type | Description |
121
+ | --- | --- | --- |
122
+ | `initial` | `T[]` | Initial array values (default: `[]`) |
123
+
124
+ **Returns:** `UseFieldArrayResult<T>` with:
125
+
126
+ | Property | Type | Description |
127
+ | --- | --- | --- |
128
+ | `items` | `Signal<FieldArrayItem<T>[]>` | Reactive list with `{ key, value }` items |
129
+ | `length` | `Computed<number>` | Number of items |
130
+ | `append(value)` | `(value: T) => void` | Add to end |
131
+ | `prepend(value)` | `(value: T) => void` | Add to start |
132
+ | `insert(index, value)` | `Function` | Insert at index |
133
+ | `remove(index)` | `(index: number) => void` | Remove at index |
134
+ | `update(index, value)` | `Function` | Update item at index |
135
+ | `move(from, to)` | `Function` | Move item between indices |
136
+ | `swap(a, b)` | `Function` | Swap two items |
137
+ | `replace(values)` | `(values: T[]) => void` | Replace all items |
138
+ | `values()` | `() => T[]` | Get current values as plain array |
139
+
140
+ ```ts
141
+ const tags = useFieldArray<string>(["typescript"])
142
+ tags.append("pyreon")
143
+ tags.items() // [{ key: 0, value: Signal("typescript") }, { key: 1, value: Signal("pyreon") }]
144
+ ```
145
+
146
+ ### `useWatch(form, name?)`
147
+
148
+ Watch specific field values reactively.
149
+
150
+ | Signature | Returns |
151
+ | --- | --- |
152
+ | `useWatch(form, "email")` | `Signal<string>` — single field value |
153
+ | `useWatch(form, ["first", "last"])` | `[Signal, Signal]` — tuple of field signals |
154
+ | `useWatch(form)` | `Computed<TValues>` — all fields as object |
155
+
156
+ ```ts
157
+ const email = useWatch(form, "email")
158
+ // email() re-evaluates reactively when the email field changes
159
+
160
+ const all = useWatch(form)
161
+ // all() => { email: "...", password: "..." }
162
+ ```
163
+
164
+ ### `useFormState(form, selector?)`
165
+
166
+ Subscribe to the full form state as a computed signal. Optionally pass a selector for fine-grained reactivity.
167
+
168
+ | Parameter | Type | Description |
169
+ | --- | --- | --- |
170
+ | `form` | `FormState<TValues>` | Form instance |
171
+ | `selector` | `(state: FormStateSummary) => R` | Optional projection function |
172
+
173
+ **Returns:** `Computed<FormStateSummary>` or `Computed<R>` when using a selector.
174
+
175
+ `FormStateSummary` contains: `isSubmitting`, `isValidating`, `isValid`, `isDirty`, `submitCount`, `submitError`, `touchedFields`, `dirtyFields`, `errors`.
176
+
177
+ ```ts
178
+ const state = useFormState(form)
179
+ state().isValid // boolean
180
+
181
+ const canSubmit = useFormState(form, (s) => s.isValid && !s.isSubmitting)
182
+ canSubmit() // boolean
183
+ ```
184
+
185
+ ### `FormProvider` / `useFormContext()`
186
+
187
+ Context pattern for sharing a form instance with nested components.
188
+
189
+ ```tsx
190
+ // Parent:
191
+ <FormProvider form={form}>
192
+ <EmailField />
193
+ </FormProvider>
194
+
195
+ // Child:
196
+ function EmailField() {
197
+ const form = useFormContext<{ email: string }>()
198
+ return () => <input {...form.register("email")} />
199
+ }
200
+ ```
201
+
202
+ ## Patterns
203
+
204
+ ### Server-Side Validation
205
+
206
+ Use `setErrors()` to apply errors returned from your API.
207
+
208
+ ```ts
209
+ const form = useForm({
210
+ initialValues: { email: "" },
211
+ onSubmit: async (values) => {
212
+ const res = await fetch("/api/register", { method: "POST", body: JSON.stringify(values) })
213
+ if (!res.ok) {
214
+ const errors = await res.json()
215
+ form.setErrors(errors) // { email: "Already taken" }
216
+ }
217
+ },
218
+ })
219
+ ```
220
+
221
+ ### Schema Validation
222
+
223
+ Use `@pyreon/validation` adapters for schema-level validation.
224
+
225
+ ```ts
226
+ import { zodSchema } from "@pyreon/validation"
227
+ import { z } from "zod"
228
+
229
+ const form = useForm({
230
+ initialValues: { email: "", age: 0 },
231
+ schema: zodSchema(z.object({ email: z.string().email(), age: z.number().min(13) })),
232
+ onSubmit: async (values) => { ... },
233
+ })
234
+ ```
235
+
236
+ ## Types
237
+
238
+ | Type | Description |
239
+ | --- | --- |
240
+ | `Accessor<T>` | `Signal<T> \| Computed<T>` — a readable reactive value |
241
+ | `FormState<TValues>` | Full form instance returned by `useForm` |
242
+ | `FieldState<T>` | Per-field state: `value`, `error`, `touched`, `dirty`, `setValue`, `setTouched`, `reset` |
243
+ | `FieldRegisterProps<T>` | Props returned by `register()`: `value`, `onInput`, `onBlur`, `checked?` |
244
+ | `UseFormOptions<TValues>` | Options for `useForm` |
245
+ | `ValidateFn<T, TValues>` | `(value: T, allValues: TValues) => ValidationError \| Promise<ValidationError>` |
246
+ | `SchemaValidateFn<TValues>` | `(values: TValues) => Record<keyof TValues, string>` |
247
+ | `ValidationError` | `string \| undefined` |
248
+ | `FieldArrayItem<T>` | `{ key: number, value: Signal<T> }` |
249
+ | `UseFieldArrayResult<T>` | Return type of `useFieldArray` |
250
+ | `UseFieldResult<T>` | Return type of `useField` |
251
+ | `FormStateSummary<TValues>` | Snapshot object returned by `useFormState` |
252
+
253
+ ## Gotchas
254
+
255
+ - `register()` results are memoized per field+type combo. Calling `register("email")` twice returns the same object.
256
+ - `validateOn: "change"` creates an `effect()` per field — for large forms, prefer `"blur"` or `"submit"`.
257
+ - Field validators receive all current form values as the second argument for cross-field validation.
258
+ - `handleSubmit` marks all fields as touched before validating, so error messages appear on submit.
259
+ - Debounce timers and in-flight validators are automatically cleaned up on component unmount.