@barefootjs/form 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +180 -0
- package/dist/create-form.d.ts +4 -0
- package/dist/create-form.d.ts.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +317 -0
- package/dist/types.d.ts +45 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/validate.d.ts +11 -0
- package/dist/validate.d.ts.map +1 -0
- package/package.json +49 -0
- package/src/create-form.ts +238 -0
- package/src/index.ts +8 -0
- package/src/types.ts +67 -0
- package/src/validate.ts +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# @barefootjs/form
|
|
2
|
+
|
|
3
|
+
Signal-based form management for [BarefootJS](https://github.com/piconic-ai/barefootjs). Provides reactive per-field state (value, error, touched, dirty), configurable validation timing, and [Standard Schema](https://github.com/standard-schema/standard-schema) integration for library-agnostic validation.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @barefootjs/form @barefootjs/client
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
You also need a Standard Schema–compatible validation library (e.g. [Zod](https://zod.dev/), [Valibot](https://valibot.dev/), [ArkType](https://arktype.io/)):
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bun add zod
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
"use client"
|
|
21
|
+
|
|
22
|
+
import { createForm } from "@barefootjs/form"
|
|
23
|
+
import { z } from "zod"
|
|
24
|
+
|
|
25
|
+
const schema = z.object({
|
|
26
|
+
email: z.string().email("Invalid email"),
|
|
27
|
+
password: z.string().min(8, "At least 8 characters"),
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
function LoginForm() {
|
|
31
|
+
const form = createForm({
|
|
32
|
+
schema,
|
|
33
|
+
defaultValues: { email: "", password: "" },
|
|
34
|
+
onSubmit: async (data) => {
|
|
35
|
+
// `data` is fully typed and validated
|
|
36
|
+
await fetch("/api/login", {
|
|
37
|
+
method: "POST",
|
|
38
|
+
body: JSON.stringify(data),
|
|
39
|
+
})
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const email = form.field("email")
|
|
44
|
+
const password = form.field("password")
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<form onSubmit={form.handleSubmit}>
|
|
48
|
+
<input
|
|
49
|
+
type="email"
|
|
50
|
+
value={email.value()}
|
|
51
|
+
onInput={email.handleInput}
|
|
52
|
+
onBlur={email.handleBlur}
|
|
53
|
+
/>
|
|
54
|
+
{email.error() && <span>{email.error()}</span>}
|
|
55
|
+
|
|
56
|
+
<input
|
|
57
|
+
type="password"
|
|
58
|
+
value={password.value()}
|
|
59
|
+
onInput={password.handleInput}
|
|
60
|
+
onBlur={password.handleBlur}
|
|
61
|
+
/>
|
|
62
|
+
{password.error() && <span>{password.error()}</span>}
|
|
63
|
+
|
|
64
|
+
<button type="submit" disabled={form.isSubmitting()}>
|
|
65
|
+
{form.isSubmitting() ? "Submitting..." : "Log in"}
|
|
66
|
+
</button>
|
|
67
|
+
</form>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## API
|
|
73
|
+
|
|
74
|
+
### `createForm(options)`
|
|
75
|
+
|
|
76
|
+
Creates a form instance with reactive state management.
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
const form = createForm({
|
|
80
|
+
schema, // Standard Schema compliant
|
|
81
|
+
defaultValues: { email: "", password: "" },
|
|
82
|
+
validateOn: "blur", // "input" | "blur" | "submit" (default: "submit")
|
|
83
|
+
revalidateOn: "input", // validation after first error (default: "input")
|
|
84
|
+
onSubmit: async (data) => {}, // called with validated data
|
|
85
|
+
})
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
#### Options
|
|
89
|
+
|
|
90
|
+
| Option | Type | Default | Description |
|
|
91
|
+
|--------|------|---------|-------------|
|
|
92
|
+
| `schema` | `StandardSchemaV1` | required | Validation schema (Zod, Valibot, ArkType, etc.) |
|
|
93
|
+
| `defaultValues` | `InferInput<TSchema>` | required | Initial field values |
|
|
94
|
+
| `validateOn` | `"input" \| "blur" \| "submit"` | `"submit"` | When to run first validation |
|
|
95
|
+
| `revalidateOn` | `"input" \| "blur" \| "submit"` | `"input"` | When to revalidate after first error |
|
|
96
|
+
| `onSubmit` | `(data) => void \| Promise<void>` | — | Called with validated data on successful submit |
|
|
97
|
+
|
|
98
|
+
### Form Return
|
|
99
|
+
|
|
100
|
+
| Property | Type | Description |
|
|
101
|
+
|----------|------|-------------|
|
|
102
|
+
| `field(name)` | `(name) => FieldReturn` | Get a field controller (memoized) |
|
|
103
|
+
| `isSubmitting()` | `() => boolean` | Whether submission is in progress |
|
|
104
|
+
| `isDirty` | `Memo<boolean>` | Whether any field differs from defaults |
|
|
105
|
+
| `isValid` | `Memo<boolean>` | Whether all fields pass validation |
|
|
106
|
+
| `errors` | `Memo<Record<string, string>>` | All current errors by field name |
|
|
107
|
+
| `handleSubmit(e)` | `(e: Event) => Promise<void>` | Form submit handler |
|
|
108
|
+
| `reset()` | `() => void` | Reset all fields to defaults |
|
|
109
|
+
| `setError(name, msg)` | `(name, message) => void` | Manually set a field error |
|
|
110
|
+
|
|
111
|
+
### Field Return
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
const email = form.field("email")
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
| Property | Type | Description |
|
|
118
|
+
|----------|------|-------------|
|
|
119
|
+
| `value()` | `() => V` | Current value (signal getter) |
|
|
120
|
+
| `error()` | `() => string` | Validation error message |
|
|
121
|
+
| `touched()` | `() => boolean` | Whether field has been blurred |
|
|
122
|
+
| `dirty()` | `() => boolean` | Whether value differs from default |
|
|
123
|
+
| `setValue(value)` | `(value: V) => void` | Set value directly |
|
|
124
|
+
| `handleInput(e)` | `(e: Event) => void` | Input event handler (reads `e.target.value`) |
|
|
125
|
+
| `handleBlur()` | `() => void` | Blur event handler |
|
|
126
|
+
|
|
127
|
+
## Validation Timing
|
|
128
|
+
|
|
129
|
+
The `validateOn` / `revalidateOn` options control when validation runs:
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
// Validate on blur, revalidate on input (good UX default)
|
|
133
|
+
createForm({ validateOn: "blur", revalidateOn: "input", ... })
|
|
134
|
+
|
|
135
|
+
// Validate only on submit
|
|
136
|
+
createForm({ validateOn: "submit", ... })
|
|
137
|
+
|
|
138
|
+
// Validate on every keystroke
|
|
139
|
+
createForm({ validateOn: "input", ... })
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
After `reset()`, the timing reverts to `validateOn` (the `revalidateOn` state is cleared).
|
|
143
|
+
|
|
144
|
+
## Server-Side Errors
|
|
145
|
+
|
|
146
|
+
Use `setError` to apply errors returned from a server:
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
const form = createForm({
|
|
150
|
+
schema,
|
|
151
|
+
defaultValues: { email: "" },
|
|
152
|
+
onSubmit: async (data) => {
|
|
153
|
+
const res = await fetch("/api/register", {
|
|
154
|
+
method: "POST",
|
|
155
|
+
body: JSON.stringify(data),
|
|
156
|
+
})
|
|
157
|
+
if (!res.ok) {
|
|
158
|
+
const body = await res.json()
|
|
159
|
+
form.setError("email", body.message)
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
})
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Custom Components
|
|
166
|
+
|
|
167
|
+
For components that don't use `e.target.value` (e.g. checkboxes, selects, custom widgets), use `setValue` directly:
|
|
168
|
+
|
|
169
|
+
```tsx
|
|
170
|
+
const active = form.field("active")
|
|
171
|
+
|
|
172
|
+
<Switch
|
|
173
|
+
checked={active.value()}
|
|
174
|
+
onCheckedChange={(checked) => active.setValue(checked)}
|
|
175
|
+
/>
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## License
|
|
179
|
+
|
|
180
|
+
MIT
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
2
|
+
import type { CreateFormOptions, FormReturn } from "./types";
|
|
3
|
+
export declare function createForm<TSchema extends StandardSchemaV1<Record<string, unknown>>>(options: CreateFormOptions<TSchema>): FormReturn<TSchema>;
|
|
4
|
+
//# sourceMappingURL=create-form.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"create-form.d.ts","sourceRoot":"","sources":["../src/create-form.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAI9D,OAAO,KAAK,EACV,iBAAiB,EACjB,UAAU,EAGX,MAAM,SAAS,CAAC;AAajB,wBAAgB,UAAU,CACxB,OAAO,SAAS,gBAAgB,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,EACzD,OAAO,EAAE,iBAAiB,CAAC,OAAO,CAAC,GAAG,UAAU,CAAC,OAAO,CAAC,CAqN1D"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAC3C,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAC3D,YAAY,EACV,iBAAiB,EACjB,UAAU,EACV,WAAW,EACX,UAAU,GACX,MAAM,SAAS,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// ../client/src/reactive.ts
|
|
2
|
+
var Owner = null;
|
|
3
|
+
var Listener = null;
|
|
4
|
+
var MAX_EFFECT_RUNS = 100;
|
|
5
|
+
var BatchDepth = 0;
|
|
6
|
+
var PendingEffects = new Set;
|
|
7
|
+
function createSignal(initialValue) {
|
|
8
|
+
let value = initialValue;
|
|
9
|
+
const subscribers = new Set;
|
|
10
|
+
const get = () => {
|
|
11
|
+
if (Listener) {
|
|
12
|
+
subscribers.add(Listener);
|
|
13
|
+
Listener.dependencies.add(subscribers);
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
};
|
|
17
|
+
const set = (valueOrFn) => {
|
|
18
|
+
const newValue = typeof valueOrFn === "function" ? valueOrFn(value) : valueOrFn;
|
|
19
|
+
if (Object.is(value, newValue)) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
value = newValue;
|
|
23
|
+
if (BatchDepth > 0) {
|
|
24
|
+
for (const effect of subscribers) {
|
|
25
|
+
PendingEffects.add(effect);
|
|
26
|
+
}
|
|
27
|
+
} else {
|
|
28
|
+
const effectsToRun = [...subscribers];
|
|
29
|
+
for (const effect of effectsToRun) {
|
|
30
|
+
runEffect(effect);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
return [get, set];
|
|
35
|
+
}
|
|
36
|
+
function createEffect(fn) {
|
|
37
|
+
const effect = {
|
|
38
|
+
fn,
|
|
39
|
+
cleanup: null,
|
|
40
|
+
dependencies: new Set,
|
|
41
|
+
owner: Owner,
|
|
42
|
+
children: [],
|
|
43
|
+
disposed: false,
|
|
44
|
+
runCount: 0
|
|
45
|
+
};
|
|
46
|
+
if (Owner)
|
|
47
|
+
Owner.children.push(effect);
|
|
48
|
+
runEffect(effect);
|
|
49
|
+
}
|
|
50
|
+
function runEffect(effect) {
|
|
51
|
+
if (effect.disposed)
|
|
52
|
+
return;
|
|
53
|
+
effect.runCount++;
|
|
54
|
+
if (effect.runCount > MAX_EFFECT_RUNS) {
|
|
55
|
+
effect.runCount = 0;
|
|
56
|
+
throw new Error(`Circular dependency detected: effect re-entered itself ${MAX_EFFECT_RUNS} times.`);
|
|
57
|
+
}
|
|
58
|
+
if (effect.cleanup) {
|
|
59
|
+
effect.cleanup();
|
|
60
|
+
effect.cleanup = null;
|
|
61
|
+
}
|
|
62
|
+
for (const dep of effect.dependencies) {
|
|
63
|
+
dep.delete(effect);
|
|
64
|
+
}
|
|
65
|
+
effect.dependencies.clear();
|
|
66
|
+
const prevOwner = Owner;
|
|
67
|
+
const prevListener = Listener;
|
|
68
|
+
Owner = effect;
|
|
69
|
+
Listener = effect;
|
|
70
|
+
try {
|
|
71
|
+
const result = effect.fn();
|
|
72
|
+
if (typeof result === "function") {
|
|
73
|
+
effect.cleanup = result;
|
|
74
|
+
}
|
|
75
|
+
} finally {
|
|
76
|
+
Owner = prevOwner;
|
|
77
|
+
Listener = prevListener;
|
|
78
|
+
effect.runCount--;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function untrack(fn) {
|
|
82
|
+
const prevListener = Listener;
|
|
83
|
+
Listener = null;
|
|
84
|
+
try {
|
|
85
|
+
return fn();
|
|
86
|
+
} finally {
|
|
87
|
+
Listener = prevListener;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function createMemo(fn) {
|
|
91
|
+
const [value, setValue] = createSignal(undefined);
|
|
92
|
+
createEffect(() => {
|
|
93
|
+
const result = fn();
|
|
94
|
+
setValue(() => result);
|
|
95
|
+
});
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ../../node_modules/.bun/@standard-schema+utils@0.3.0/node_modules/@standard-schema/utils/dist/index.js
|
|
100
|
+
function getDotPath(issue) {
|
|
101
|
+
if (issue.path?.length) {
|
|
102
|
+
let dotPath = "";
|
|
103
|
+
for (const item of issue.path) {
|
|
104
|
+
const key = typeof item === "object" ? item.key : item;
|
|
105
|
+
if (typeof key === "string" || typeof key === "number") {
|
|
106
|
+
if (dotPath) {
|
|
107
|
+
dotPath += `.${key}`;
|
|
108
|
+
} else {
|
|
109
|
+
dotPath += key;
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return dotPath;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// src/validate.ts
|
|
121
|
+
async function validateSchema(schema, values) {
|
|
122
|
+
const result = await schema["~standard"].validate(values);
|
|
123
|
+
if (!result.issues) {
|
|
124
|
+
return {};
|
|
125
|
+
}
|
|
126
|
+
const errors = {};
|
|
127
|
+
for (const issue of result.issues) {
|
|
128
|
+
const path = getDotPath(issue);
|
|
129
|
+
if (path && !errors[path]) {
|
|
130
|
+
errors[path] = issue.message;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return errors;
|
|
134
|
+
}
|
|
135
|
+
async function validateField(schema, values, fieldName) {
|
|
136
|
+
const errors = await validateSchema(schema, values);
|
|
137
|
+
return errors[fieldName] ?? "";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// src/create-form.ts
|
|
141
|
+
function isPrimitive(v) {
|
|
142
|
+
return v === null || typeof v !== "object";
|
|
143
|
+
}
|
|
144
|
+
function createForm(options) {
|
|
145
|
+
const {
|
|
146
|
+
schema,
|
|
147
|
+
defaultValues,
|
|
148
|
+
validateOn = "submit",
|
|
149
|
+
revalidateOn = "input",
|
|
150
|
+
onSubmit
|
|
151
|
+
} = options;
|
|
152
|
+
const defaults = defaultValues;
|
|
153
|
+
const defaultKeys = Object.keys(defaults);
|
|
154
|
+
const fieldSignals = new Map;
|
|
155
|
+
const fieldCache = new Map;
|
|
156
|
+
const validatedFields = new Set;
|
|
157
|
+
const [isSubmitting, setIsSubmitting] = createSignal(false);
|
|
158
|
+
const [fieldVersion, setFieldVersion] = createSignal(0);
|
|
159
|
+
function getOrCreateFieldSignals(name) {
|
|
160
|
+
let signals = fieldSignals.get(name);
|
|
161
|
+
if (!signals) {
|
|
162
|
+
signals = {
|
|
163
|
+
value: createSignal(defaults[name] ?? ""),
|
|
164
|
+
error: createSignal(""),
|
|
165
|
+
touched: createSignal(false),
|
|
166
|
+
dirty: createSignal(false)
|
|
167
|
+
};
|
|
168
|
+
fieldSignals.set(name, signals);
|
|
169
|
+
setFieldVersion((v) => v + 1);
|
|
170
|
+
}
|
|
171
|
+
return signals;
|
|
172
|
+
}
|
|
173
|
+
function getCurrentValues() {
|
|
174
|
+
return untrack(() => {
|
|
175
|
+
const values = {};
|
|
176
|
+
for (const key of defaultKeys) {
|
|
177
|
+
const signals = fieldSignals.get(key);
|
|
178
|
+
values[key] = signals ? signals.value[0]() : defaults[key];
|
|
179
|
+
}
|
|
180
|
+
return values;
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
function shouldValidate(name, trigger) {
|
|
184
|
+
const timing = validatedFields.has(name) ? revalidateOn : validateOn;
|
|
185
|
+
return timing === trigger;
|
|
186
|
+
}
|
|
187
|
+
async function runFieldValidation(name) {
|
|
188
|
+
const values = getCurrentValues();
|
|
189
|
+
const error = await validateField(schema, values, name);
|
|
190
|
+
const signals = fieldSignals.get(name);
|
|
191
|
+
if (signals) {
|
|
192
|
+
signals.error[1](error);
|
|
193
|
+
}
|
|
194
|
+
validatedFields.add(name);
|
|
195
|
+
}
|
|
196
|
+
function field(name) {
|
|
197
|
+
const cached = fieldCache.get(name);
|
|
198
|
+
if (cached)
|
|
199
|
+
return cached;
|
|
200
|
+
const signals = getOrCreateFieldSignals(name);
|
|
201
|
+
const defaultValue = defaults[name] ?? "";
|
|
202
|
+
const defaultIsPrimitive = isPrimitive(defaultValue);
|
|
203
|
+
const serializedDefault = defaultIsPrimitive ? undefined : JSON.stringify(defaultValue);
|
|
204
|
+
function checkDirty(value) {
|
|
205
|
+
if (defaultIsPrimitive)
|
|
206
|
+
return value !== defaultValue;
|
|
207
|
+
return JSON.stringify(value) !== serializedDefault;
|
|
208
|
+
}
|
|
209
|
+
const fieldReturn = {
|
|
210
|
+
value: signals.value[0],
|
|
211
|
+
error: signals.error[0],
|
|
212
|
+
touched: signals.touched[0],
|
|
213
|
+
dirty: signals.dirty[0],
|
|
214
|
+
setValue(value) {
|
|
215
|
+
signals.value[1](value);
|
|
216
|
+
signals.dirty[1](checkDirty(value));
|
|
217
|
+
if (shouldValidate(name, "input")) {
|
|
218
|
+
runFieldValidation(name);
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
handleInput(e) {
|
|
222
|
+
const target = e.target;
|
|
223
|
+
const value = target.value;
|
|
224
|
+
fieldReturn.setValue(value);
|
|
225
|
+
},
|
|
226
|
+
handleBlur() {
|
|
227
|
+
signals.touched[1](true);
|
|
228
|
+
if (shouldValidate(name, "blur")) {
|
|
229
|
+
runFieldValidation(name);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
fieldCache.set(name, fieldReturn);
|
|
234
|
+
return fieldReturn;
|
|
235
|
+
}
|
|
236
|
+
const isDirty = createMemo(() => {
|
|
237
|
+
fieldVersion();
|
|
238
|
+
for (const [, signals] of fieldSignals) {
|
|
239
|
+
if (signals.dirty[0]())
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
return false;
|
|
243
|
+
});
|
|
244
|
+
const errors = createMemo(() => {
|
|
245
|
+
fieldVersion();
|
|
246
|
+
const result = {};
|
|
247
|
+
for (const [name, signals] of fieldSignals) {
|
|
248
|
+
const err = signals.error[0]();
|
|
249
|
+
if (err)
|
|
250
|
+
result[name] = err;
|
|
251
|
+
}
|
|
252
|
+
return result;
|
|
253
|
+
});
|
|
254
|
+
const isValid = createMemo(() => {
|
|
255
|
+
const e = errors();
|
|
256
|
+
for (const _ in e)
|
|
257
|
+
return false;
|
|
258
|
+
return true;
|
|
259
|
+
});
|
|
260
|
+
async function handleSubmit(e) {
|
|
261
|
+
e.preventDefault();
|
|
262
|
+
const values = getCurrentValues();
|
|
263
|
+
setIsSubmitting(true);
|
|
264
|
+
try {
|
|
265
|
+
const validationErrors = await validateSchema(schema, values);
|
|
266
|
+
const hasErrors = Object.keys(validationErrors).length > 0;
|
|
267
|
+
if (hasErrors) {
|
|
268
|
+
for (const [name, message] of Object.entries(validationErrors)) {
|
|
269
|
+
const signals = getOrCreateFieldSignals(name);
|
|
270
|
+
signals.error[1](message);
|
|
271
|
+
validatedFields.add(name);
|
|
272
|
+
}
|
|
273
|
+
setIsSubmitting(false);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
for (const [, signals] of fieldSignals) {
|
|
277
|
+
signals.error[1]("");
|
|
278
|
+
}
|
|
279
|
+
if (onSubmit) {
|
|
280
|
+
try {
|
|
281
|
+
await onSubmit(values);
|
|
282
|
+
} catch {}
|
|
283
|
+
}
|
|
284
|
+
} finally {
|
|
285
|
+
setIsSubmitting(false);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
function reset() {
|
|
289
|
+
for (const [name, signals] of fieldSignals) {
|
|
290
|
+
signals.value[1](defaults[name] ?? "");
|
|
291
|
+
signals.error[1]("");
|
|
292
|
+
signals.touched[1](false);
|
|
293
|
+
signals.dirty[1](false);
|
|
294
|
+
}
|
|
295
|
+
validatedFields.clear();
|
|
296
|
+
}
|
|
297
|
+
function setError(name, message) {
|
|
298
|
+
const signals = getOrCreateFieldSignals(name);
|
|
299
|
+
signals.error[1](message);
|
|
300
|
+
validatedFields.add(name);
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
field,
|
|
304
|
+
isSubmitting,
|
|
305
|
+
isDirty,
|
|
306
|
+
isValid,
|
|
307
|
+
errors,
|
|
308
|
+
handleSubmit,
|
|
309
|
+
reset,
|
|
310
|
+
setError
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
export {
|
|
314
|
+
validateSchema,
|
|
315
|
+
validateField,
|
|
316
|
+
createForm
|
|
317
|
+
};
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
2
|
+
import type { Reactive, Memo } from "@barefootjs/client";
|
|
3
|
+
export type ValidateOn = "input" | "blur" | "submit";
|
|
4
|
+
export interface CreateFormOptions<TSchema extends StandardSchemaV1<Record<string, unknown>>> {
|
|
5
|
+
schema: TSchema;
|
|
6
|
+
defaultValues: StandardSchemaV1.InferInput<TSchema>;
|
|
7
|
+
validateOn?: ValidateOn;
|
|
8
|
+
revalidateOn?: ValidateOn;
|
|
9
|
+
onSubmit?: (data: StandardSchemaV1.InferOutput<TSchema>) => void | Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export interface FieldReturn<V> {
|
|
12
|
+
/** Current field value (signal getter) */
|
|
13
|
+
value: Reactive<() => V>;
|
|
14
|
+
/** Current validation error message (signal getter) */
|
|
15
|
+
error: Reactive<() => string>;
|
|
16
|
+
/** Whether the field has been touched (signal getter) */
|
|
17
|
+
touched: Reactive<() => boolean>;
|
|
18
|
+
/** Whether the field value differs from defaultValue (signal getter) */
|
|
19
|
+
dirty: Reactive<() => boolean>;
|
|
20
|
+
/** Set field value directly */
|
|
21
|
+
setValue: (value: V) => void;
|
|
22
|
+
/** Input event handler — reads e.target.value */
|
|
23
|
+
handleInput: (e: Event) => void;
|
|
24
|
+
/** Blur event handler — marks touched and may trigger validation */
|
|
25
|
+
handleBlur: () => void;
|
|
26
|
+
}
|
|
27
|
+
export interface FormReturn<TSchema extends StandardSchemaV1<Record<string, unknown>>> {
|
|
28
|
+
/** Get a field controller by name (memoized) */
|
|
29
|
+
field: <K extends string & keyof StandardSchemaV1.InferInput<TSchema>>(name: K) => FieldReturn<StandardSchemaV1.InferInput<TSchema>[K]>;
|
|
30
|
+
/** Whether a submission is in progress (signal getter) */
|
|
31
|
+
isSubmitting: Reactive<() => boolean>;
|
|
32
|
+
/** Whether any field value differs from defaults (memo) */
|
|
33
|
+
isDirty: Memo<boolean>;
|
|
34
|
+
/** Whether all fields pass validation (memo) */
|
|
35
|
+
isValid: Memo<boolean>;
|
|
36
|
+
/** All current errors keyed by field name (memo) */
|
|
37
|
+
errors: Memo<Record<string, string>>;
|
|
38
|
+
/** Form submit handler — call with the submit event */
|
|
39
|
+
handleSubmit: (e: Event) => Promise<void>;
|
|
40
|
+
/** Reset all fields to default values and clear errors */
|
|
41
|
+
reset: () => void;
|
|
42
|
+
/** Manually set an error on a field (e.g. server-side errors) */
|
|
43
|
+
setError: (name: string & keyof StandardSchemaV1.InferInput<TSchema>, message: string) => void;
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAC9D,OAAO,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,oBAAoB,CAAC;AAIzD,MAAM,MAAM,UAAU,GAAG,OAAO,GAAG,MAAM,GAAG,QAAQ,CAAC;AAIrD,MAAM,WAAW,iBAAiB,CAChC,OAAO,SAAS,gBAAgB,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAEzD,MAAM,EAAE,OAAO,CAAC;IAChB,aAAa,EAAE,gBAAgB,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC;IACpD,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,YAAY,CAAC,EAAE,UAAU,CAAC;IAC1B,QAAQ,CAAC,EAAE,CACT,IAAI,EAAE,gBAAgB,CAAC,WAAW,CAAC,OAAO,CAAC,KACxC,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3B;AAID,MAAM,WAAW,WAAW,CAAC,CAAC;IAC5B,0CAA0C;IAC1C,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC;IACzB,uDAAuD;IACvD,KAAK,EAAE,QAAQ,CAAC,MAAM,MAAM,CAAC,CAAC;IAC9B,yDAAyD;IACzD,OAAO,EAAE,QAAQ,CAAC,MAAM,OAAO,CAAC,CAAC;IACjC,wEAAwE;IACxE,KAAK,EAAE,QAAQ,CAAC,MAAM,OAAO,CAAC,CAAC;IAC/B,+BAA+B;IAC/B,QAAQ,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,IAAI,CAAC;IAC7B,iDAAiD;IACjD,WAAW,EAAE,CAAC,CAAC,EAAE,KAAK,KAAK,IAAI,CAAC;IAChC,oEAAoE;IACpE,UAAU,EAAE,MAAM,IAAI,CAAC;CACxB;AAID,MAAM,WAAW,UAAU,CACzB,OAAO,SAAS,gBAAgB,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAEzD,gDAAgD;IAChD,KAAK,EAAE,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,gBAAgB,CAAC,UAAU,CAAC,OAAO,CAAC,EACnE,IAAI,EAAE,CAAC,KACJ,WAAW,CAAC,gBAAgB,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAC1D,0DAA0D;IAC1D,YAAY,EAAE,QAAQ,CAAC,MAAM,OAAO,CAAC,CAAC;IACtC,2DAA2D;IAC3D,OAAO,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC;IACvB,gDAAgD;IAChD,OAAO,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC;IACvB,oDAAoD;IACpD,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACrC,uDAAuD;IACvD,YAAY,EAAE,CAAC,CAAC,EAAE,KAAK,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1C,0DAA0D;IAC1D,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,iEAAiE;IACjE,QAAQ,EAAE,CACR,IAAI,EAAE,MAAM,GAAG,MAAM,gBAAgB,CAAC,UAAU,CAAC,OAAO,CAAC,EACzD,OAAO,EAAE,MAAM,KACZ,IAAI,CAAC;CACX"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
2
|
+
/**
|
|
3
|
+
* Validate all fields against the schema and return a map of field name → error message.
|
|
4
|
+
*/
|
|
5
|
+
export declare function validateSchema(schema: StandardSchemaV1<Record<string, unknown>>, values: Record<string, unknown>): Promise<Record<string, string>>;
|
|
6
|
+
/**
|
|
7
|
+
* Validate the full schema and extract the error for a specific field.
|
|
8
|
+
* Returns empty string if the field has no error.
|
|
9
|
+
*/
|
|
10
|
+
export declare function validateField(schema: StandardSchemaV1<Record<string, unknown>>, values: Record<string, unknown>, fieldName: string): Promise<string>;
|
|
11
|
+
//# sourceMappingURL=validate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAG9D;;GAEG;AACH,wBAAsB,cAAc,CAClC,MAAM,EAAE,gBAAgB,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,EACjD,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC9B,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAcjC;AAED;;;GAGG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,gBAAgB,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,EACjD,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,MAAM,CAAC,CAGjB"}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@barefootjs/form",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Signal-based form management for BarefootJS",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"src"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "bun run build:js && bun run build:types",
|
|
20
|
+
"build:js": "bun build ./src/index.ts --outdir ./dist --format esm",
|
|
21
|
+
"build:types": "tsgo --emitDeclarationOnly --outDir ./dist",
|
|
22
|
+
"test": "bun test",
|
|
23
|
+
"clean": "rm -rf dist"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"form",
|
|
27
|
+
"signals",
|
|
28
|
+
"reactive",
|
|
29
|
+
"barefoot"
|
|
30
|
+
],
|
|
31
|
+
"author": "kobaken <kentafly88@gmail.com>",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "https://github.com/piconic-ai/barefootjs",
|
|
36
|
+
"directory": "packages/form"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@barefootjs/client": ">=0.0.1",
|
|
40
|
+
"@standard-schema/spec": "^1.0.0"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@standard-schema/utils": "^0.3.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"typescript": "^5.0.0",
|
|
47
|
+
"zod": "^3.24.0"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
2
|
+
import { createSignal, createMemo, untrack } from "@barefootjs/client";
|
|
3
|
+
import type { Signal, Memo } from "@barefootjs/client";
|
|
4
|
+
import { validateSchema, validateField } from "./validate";
|
|
5
|
+
import type {
|
|
6
|
+
CreateFormOptions,
|
|
7
|
+
FormReturn,
|
|
8
|
+
FieldReturn,
|
|
9
|
+
ValidateOn,
|
|
10
|
+
} from "./types";
|
|
11
|
+
|
|
12
|
+
interface FieldSignals {
|
|
13
|
+
value: Signal<unknown>;
|
|
14
|
+
error: Signal<string>;
|
|
15
|
+
touched: Signal<boolean>;
|
|
16
|
+
dirty: Signal<boolean>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isPrimitive(v: unknown): boolean {
|
|
20
|
+
return v === null || typeof v !== "object";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createForm<
|
|
24
|
+
TSchema extends StandardSchemaV1<Record<string, unknown>>,
|
|
25
|
+
>(options: CreateFormOptions<TSchema>): FormReturn<TSchema> {
|
|
26
|
+
type Input = StandardSchemaV1.InferInput<TSchema>;
|
|
27
|
+
|
|
28
|
+
const {
|
|
29
|
+
schema,
|
|
30
|
+
defaultValues,
|
|
31
|
+
validateOn = "submit",
|
|
32
|
+
revalidateOn = "input",
|
|
33
|
+
onSubmit,
|
|
34
|
+
} = options;
|
|
35
|
+
|
|
36
|
+
// --- Internal state ---
|
|
37
|
+
|
|
38
|
+
const defaults = defaultValues as Record<string, unknown>;
|
|
39
|
+
const defaultKeys = Object.keys(defaults);
|
|
40
|
+
const fieldSignals = new Map<string, FieldSignals>();
|
|
41
|
+
const fieldCache = new Map<string, FieldReturn<unknown>>();
|
|
42
|
+
const validatedFields = new Set<string>();
|
|
43
|
+
const [isSubmitting, setIsSubmitting] = createSignal(false);
|
|
44
|
+
// Bump when fields are added so memos that iterate fieldSignals re-run
|
|
45
|
+
const [fieldVersion, setFieldVersion] = createSignal(0);
|
|
46
|
+
|
|
47
|
+
// --- Helpers ---
|
|
48
|
+
|
|
49
|
+
function getOrCreateFieldSignals(name: string): FieldSignals {
|
|
50
|
+
let signals = fieldSignals.get(name);
|
|
51
|
+
if (!signals) {
|
|
52
|
+
signals = {
|
|
53
|
+
value: createSignal<unknown>(defaults[name] ?? ""),
|
|
54
|
+
error: createSignal(""),
|
|
55
|
+
touched: createSignal(false),
|
|
56
|
+
dirty: createSignal(false),
|
|
57
|
+
};
|
|
58
|
+
fieldSignals.set(name, signals);
|
|
59
|
+
setFieldVersion((v) => v + 1);
|
|
60
|
+
}
|
|
61
|
+
return signals;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getCurrentValues(): Record<string, unknown> {
|
|
65
|
+
return untrack(() => {
|
|
66
|
+
const values: Record<string, unknown> = {};
|
|
67
|
+
for (const key of defaultKeys) {
|
|
68
|
+
const signals = fieldSignals.get(key);
|
|
69
|
+
values[key] = signals ? signals.value[0]() : defaults[key];
|
|
70
|
+
}
|
|
71
|
+
return values;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function shouldValidate(name: string, trigger: ValidateOn): boolean {
|
|
76
|
+
const timing = validatedFields.has(name) ? revalidateOn : validateOn;
|
|
77
|
+
return timing === trigger;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function runFieldValidation(name: string): Promise<void> {
|
|
81
|
+
const values = getCurrentValues();
|
|
82
|
+
const error = await validateField(schema, values, name);
|
|
83
|
+
const signals = fieldSignals.get(name);
|
|
84
|
+
if (signals) {
|
|
85
|
+
signals.error[1](error);
|
|
86
|
+
}
|
|
87
|
+
validatedFields.add(name);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// --- Field API ---
|
|
91
|
+
|
|
92
|
+
function field<K extends string & keyof Input>(
|
|
93
|
+
name: K,
|
|
94
|
+
): FieldReturn<Input[K]> {
|
|
95
|
+
const cached = fieldCache.get(name);
|
|
96
|
+
if (cached) return cached as FieldReturn<Input[K]>;
|
|
97
|
+
|
|
98
|
+
const signals = getOrCreateFieldSignals(name);
|
|
99
|
+
const defaultValue = defaults[name] ?? "";
|
|
100
|
+
const defaultIsPrimitive = isPrimitive(defaultValue);
|
|
101
|
+
const serializedDefault = defaultIsPrimitive
|
|
102
|
+
? undefined
|
|
103
|
+
: JSON.stringify(defaultValue);
|
|
104
|
+
|
|
105
|
+
function checkDirty(value: unknown): boolean {
|
|
106
|
+
if (defaultIsPrimitive) return value !== defaultValue;
|
|
107
|
+
return JSON.stringify(value) !== serializedDefault;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const fieldReturn: FieldReturn<Input[K]> = {
|
|
111
|
+
value: signals.value[0] as FieldReturn<Input[K]>['value'],
|
|
112
|
+
error: signals.error[0],
|
|
113
|
+
touched: signals.touched[0],
|
|
114
|
+
dirty: signals.dirty[0],
|
|
115
|
+
|
|
116
|
+
setValue(value: Input[K]) {
|
|
117
|
+
signals.value[1](value);
|
|
118
|
+
signals.dirty[1](checkDirty(value));
|
|
119
|
+
if (shouldValidate(name, "input")) {
|
|
120
|
+
runFieldValidation(name);
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
handleInput(e: Event) {
|
|
125
|
+
const target = e.target as HTMLInputElement;
|
|
126
|
+
const value = target.value as Input[K];
|
|
127
|
+
fieldReturn.setValue(value);
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
handleBlur() {
|
|
131
|
+
signals.touched[1](true);
|
|
132
|
+
if (shouldValidate(name, "blur")) {
|
|
133
|
+
runFieldValidation(name);
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
fieldCache.set(name, fieldReturn as FieldReturn<unknown>);
|
|
139
|
+
return fieldReturn;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --- Derived state ---
|
|
143
|
+
|
|
144
|
+
const isDirty: Memo<boolean> = createMemo(() => {
|
|
145
|
+
fieldVersion(); // track field additions
|
|
146
|
+
for (const [, signals] of fieldSignals) {
|
|
147
|
+
if (signals.dirty[0]()) return true;
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const errors: Memo<Record<string, string>> = createMemo(() => {
|
|
153
|
+
fieldVersion(); // track field additions
|
|
154
|
+
const result: Record<string, string> = {};
|
|
155
|
+
for (const [name, signals] of fieldSignals) {
|
|
156
|
+
const err = signals.error[0]();
|
|
157
|
+
if (err) result[name] = err;
|
|
158
|
+
}
|
|
159
|
+
return result;
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const isValid: Memo<boolean> = createMemo(() => {
|
|
163
|
+
const e = errors();
|
|
164
|
+
for (const _ in e) return false;
|
|
165
|
+
return true;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// --- Form actions ---
|
|
169
|
+
|
|
170
|
+
async function handleSubmit(e: Event): Promise<void> {
|
|
171
|
+
e.preventDefault();
|
|
172
|
+
|
|
173
|
+
const values = getCurrentValues();
|
|
174
|
+
|
|
175
|
+
setIsSubmitting(true);
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
const validationErrors = await validateSchema(schema, values);
|
|
179
|
+
const hasErrors = Object.keys(validationErrors).length > 0;
|
|
180
|
+
|
|
181
|
+
if (hasErrors) {
|
|
182
|
+
for (const [name, message] of Object.entries(validationErrors)) {
|
|
183
|
+
const signals = getOrCreateFieldSignals(name);
|
|
184
|
+
signals.error[1](message);
|
|
185
|
+
validatedFields.add(name);
|
|
186
|
+
}
|
|
187
|
+
setIsSubmitting(false);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Clear all errors on success
|
|
192
|
+
for (const [, signals] of fieldSignals) {
|
|
193
|
+
signals.error[1]("");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (onSubmit) {
|
|
197
|
+
try {
|
|
198
|
+
await onSubmit(values as StandardSchemaV1.InferOutput<TSchema>);
|
|
199
|
+
} catch {
|
|
200
|
+
// onSubmit errors are silently caught to prevent unhandled rejections.
|
|
201
|
+
// Use onSubmit's own try/catch to handle errors explicitly.
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
} finally {
|
|
205
|
+
setIsSubmitting(false);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function reset(): void {
|
|
210
|
+
for (const [name, signals] of fieldSignals) {
|
|
211
|
+
signals.value[1](defaults[name] ?? "" as unknown);
|
|
212
|
+
signals.error[1]("");
|
|
213
|
+
signals.touched[1](false);
|
|
214
|
+
signals.dirty[1](false);
|
|
215
|
+
}
|
|
216
|
+
validatedFields.clear();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function setError(
|
|
220
|
+
name: string & keyof Input,
|
|
221
|
+
message: string,
|
|
222
|
+
): void {
|
|
223
|
+
const signals = getOrCreateFieldSignals(name);
|
|
224
|
+
signals.error[1](message);
|
|
225
|
+
validatedFields.add(name);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
field,
|
|
230
|
+
isSubmitting,
|
|
231
|
+
isDirty,
|
|
232
|
+
isValid,
|
|
233
|
+
errors,
|
|
234
|
+
handleSubmit,
|
|
235
|
+
reset,
|
|
236
|
+
setError,
|
|
237
|
+
};
|
|
238
|
+
}
|
package/src/index.ts
ADDED
package/src/types.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
2
|
+
import type { Reactive, Memo } from "@barefootjs/client";
|
|
3
|
+
|
|
4
|
+
// --- Validation timing ---
|
|
5
|
+
|
|
6
|
+
export type ValidateOn = "input" | "blur" | "submit";
|
|
7
|
+
|
|
8
|
+
// --- Form options ---
|
|
9
|
+
|
|
10
|
+
export interface CreateFormOptions<
|
|
11
|
+
TSchema extends StandardSchemaV1<Record<string, unknown>>,
|
|
12
|
+
> {
|
|
13
|
+
schema: TSchema;
|
|
14
|
+
defaultValues: StandardSchemaV1.InferInput<TSchema>;
|
|
15
|
+
validateOn?: ValidateOn;
|
|
16
|
+
revalidateOn?: ValidateOn;
|
|
17
|
+
onSubmit?: (
|
|
18
|
+
data: StandardSchemaV1.InferOutput<TSchema>,
|
|
19
|
+
) => void | Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// --- Field return ---
|
|
23
|
+
|
|
24
|
+
export interface FieldReturn<V> {
|
|
25
|
+
/** Current field value (signal getter) */
|
|
26
|
+
value: Reactive<() => V>;
|
|
27
|
+
/** Current validation error message (signal getter) */
|
|
28
|
+
error: Reactive<() => string>;
|
|
29
|
+
/** Whether the field has been touched (signal getter) */
|
|
30
|
+
touched: Reactive<() => boolean>;
|
|
31
|
+
/** Whether the field value differs from defaultValue (signal getter) */
|
|
32
|
+
dirty: Reactive<() => boolean>;
|
|
33
|
+
/** Set field value directly */
|
|
34
|
+
setValue: (value: V) => void;
|
|
35
|
+
/** Input event handler — reads e.target.value */
|
|
36
|
+
handleInput: (e: Event) => void;
|
|
37
|
+
/** Blur event handler — marks touched and may trigger validation */
|
|
38
|
+
handleBlur: () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// --- Form return ---
|
|
42
|
+
|
|
43
|
+
export interface FormReturn<
|
|
44
|
+
TSchema extends StandardSchemaV1<Record<string, unknown>>,
|
|
45
|
+
> {
|
|
46
|
+
/** Get a field controller by name (memoized) */
|
|
47
|
+
field: <K extends string & keyof StandardSchemaV1.InferInput<TSchema>>(
|
|
48
|
+
name: K,
|
|
49
|
+
) => FieldReturn<StandardSchemaV1.InferInput<TSchema>[K]>;
|
|
50
|
+
/** Whether a submission is in progress (signal getter) */
|
|
51
|
+
isSubmitting: Reactive<() => boolean>;
|
|
52
|
+
/** Whether any field value differs from defaults (memo) */
|
|
53
|
+
isDirty: Memo<boolean>;
|
|
54
|
+
/** Whether all fields pass validation (memo) */
|
|
55
|
+
isValid: Memo<boolean>;
|
|
56
|
+
/** All current errors keyed by field name (memo) */
|
|
57
|
+
errors: Memo<Record<string, string>>;
|
|
58
|
+
/** Form submit handler — call with the submit event */
|
|
59
|
+
handleSubmit: (e: Event) => Promise<void>;
|
|
60
|
+
/** Reset all fields to default values and clear errors */
|
|
61
|
+
reset: () => void;
|
|
62
|
+
/** Manually set an error on a field (e.g. server-side errors) */
|
|
63
|
+
setError: (
|
|
64
|
+
name: string & keyof StandardSchemaV1.InferInput<TSchema>,
|
|
65
|
+
message: string,
|
|
66
|
+
) => void;
|
|
67
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
2
|
+
import { getDotPath } from "@standard-schema/utils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validate all fields against the schema and return a map of field name → error message.
|
|
6
|
+
*/
|
|
7
|
+
export async function validateSchema(
|
|
8
|
+
schema: StandardSchemaV1<Record<string, unknown>>,
|
|
9
|
+
values: Record<string, unknown>,
|
|
10
|
+
): Promise<Record<string, string>> {
|
|
11
|
+
const result = await schema["~standard"].validate(values);
|
|
12
|
+
if (!result.issues) {
|
|
13
|
+
return {};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const errors: Record<string, string> = {};
|
|
17
|
+
for (const issue of result.issues) {
|
|
18
|
+
const path = getDotPath(issue);
|
|
19
|
+
if (path && !errors[path]) {
|
|
20
|
+
errors[path] = issue.message;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return errors;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Validate the full schema and extract the error for a specific field.
|
|
28
|
+
* Returns empty string if the field has no error.
|
|
29
|
+
*/
|
|
30
|
+
export async function validateField(
|
|
31
|
+
schema: StandardSchemaV1<Record<string, unknown>>,
|
|
32
|
+
values: Record<string, unknown>,
|
|
33
|
+
fieldName: string,
|
|
34
|
+
): Promise<string> {
|
|
35
|
+
const errors = await validateSchema(schema, values);
|
|
36
|
+
return errors[fieldName] ?? "";
|
|
37
|
+
}
|