@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 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"}
@@ -0,0 +1,4 @@
1
+ export { createForm } from "./create-form";
2
+ export { validateSchema, validateField } from "./validate";
3
+ export type { CreateFormOptions, FormReturn, FieldReturn, ValidateOn, } from "./types";
4
+ //# sourceMappingURL=index.d.ts.map
@@ -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
+ };
@@ -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
@@ -0,0 +1,8 @@
1
+ export { createForm } from "./create-form";
2
+ export { validateSchema, validateField } from "./validate";
3
+ export type {
4
+ CreateFormOptions,
5
+ FormReturn,
6
+ FieldReturn,
7
+ ValidateOn,
8
+ } from "./types";
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
+ }
@@ -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
+ }