solid-tiny-form 0.1.0 → 0.3.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 CHANGED
@@ -1,96 +1,144 @@
1
- # solid-tiny-form
2
-
3
- A tiny SolidJS form state manager inspired by `react-hook-form`, but designed around Solid's fine-grained reactivity and `createStore`.
4
-
5
- ## Features
6
-
7
- - `createForm` API for registering fields, setting values, submitting, resetting, and validating.
8
- - Form state is stored in a Solid store from `solid-js/store`.
9
- - Pluggable resolver interface for schema validation libraries.
10
- - Built-in ArkType resolver via `arktypeResolver`.
11
- - Lightweight field-level rules for common cases.
12
-
13
- ## Installation
14
-
15
- ```sh
16
- pnpm add solid-tiny-form solid-js
17
- ```
18
-
19
- Install ArkType when using the optional ArkType resolver:
20
-
21
- ```sh
22
- pnpm add arktype
23
- ```
24
-
25
- The ArkType adapter supports `arktype@^2.1.27` and is exposed from `solid-tiny-form/resolvers/arktype`.
26
-
27
- ## Usage
28
-
29
- ```tsx
30
- import { type } from "arktype";
31
- import { createForm } from "solid-tiny-form";
32
- import { arktypeResolver } from "solid-tiny-form/resolvers/arktype";
33
-
34
- const schema = type({
35
- email: "string.email",
36
- age: "number >= 18",
37
- });
38
-
39
- function Example() {
40
- const form = createForm({
41
- defaultValues: {
42
- email: "",
43
- age: 18,
44
- },
45
- resolver: arktypeResolver(schema),
46
- mode: "onBlur",
47
- });
48
-
49
- const onSubmit = form.handleSubmit((values) => {
50
- console.log(values);
51
- });
52
-
53
- return (
54
- <form onSubmit={onSubmit}>
55
- <input {...form.register("email")} />
56
- <input type="number" {...form.register("age", { valueAsNumber: true })} />
57
- <button disabled={form.formState.isSubmitting}>Submit</button>
58
- </form>
59
- );
60
- }
61
- ```
62
-
63
- ## API
64
-
65
- ### `createForm(options)`
66
-
67
- Creates a form controller.
68
-
69
- ```ts
70
- const form = createForm({
71
- defaultValues,
72
- resolver,
73
- context,
74
- mode: "onSubmit",
75
- });
76
- ```
77
-
78
- Validation modes are `onSubmit`, `onBlur`, `onChange`, and `all`.
79
-
80
- ### Resolver interface
81
-
82
- Resolvers receive current values and return parsed values plus field errors.
83
-
84
- ```ts
85
- type Resolver<TValues, TContext> = (
86
- values: TValues,
87
- context: {
88
- name?: string;
89
- context?: TContext;
90
- }
91
- ) =>
92
- | { values: TValues; errors: Record<string, FieldError> }
93
- | Promise<{ values: TValues; errors: Record<string, FieldError> }>;
94
- ```
95
-
96
- This keeps the core form independent from any validation library.
1
+ # solid-tiny-form
2
+
3
+ A tiny SolidJS form state manager designed around Solid's fine-grained reactivity and `createStore`.
4
+
5
+ ## Features
6
+
7
+ - `createForm` API for creating fields, setting values, submitting, resetting, and validating.
8
+ - Form state is stored in a Solid store from `solid-js/store`.
9
+ - Pluggable resolver interface for schema validation libraries.
10
+ - Built-in ArkType resolver via `arktypeResolver`.
11
+ - Lightweight field-level rules for common cases.
12
+
13
+ ## Installation
14
+
15
+ ```sh
16
+ pnpm add solid-tiny-form solid-js
17
+ ```
18
+
19
+ Install ArkType when using the optional ArkType resolver:
20
+
21
+ ```sh
22
+ pnpm add arktype
23
+ ```
24
+
25
+ The ArkType adapter supports `arktype@^2.1.27` and is exposed from `solid-tiny-form/resolvers/arktype`.
26
+
27
+ ## Usage
28
+
29
+ ```tsx
30
+ import { type } from "arktype";
31
+ import { createForm } from "solid-tiny-form";
32
+ import { arktypeResolver } from "solid-tiny-form/resolvers/arktype";
33
+
34
+ const schema = type({
35
+ email: "string.email",
36
+ age: "number >= 18",
37
+ });
38
+
39
+ type FormValues = { email: string; age: number | undefined };
40
+
41
+ function Example() {
42
+ const form = createForm<FormValues>({
43
+ defaultValues: {
44
+ email: "",
45
+ age: 18,
46
+ },
47
+ resolver: arktypeResolver<FormValues>(schema),
48
+ mode: "onBlur",
49
+ });
50
+
51
+ const email = form.createField("email");
52
+ const age = form.createField("age");
53
+
54
+ const onSubmit = form.handleSubmit((values) => {
55
+ console.log(values);
56
+ });
57
+
58
+ return (
59
+ <form onSubmit={onSubmit}>
60
+ <input
61
+ name={email.name}
62
+ value={email.value}
63
+ onInput={(event) => email.handleChange(event.currentTarget.value)}
64
+ onBlur={email.handleBlur}
65
+ />
66
+ <input
67
+ type="number"
68
+ name={age.name}
69
+ value={age.value ?? ""}
70
+ onInput={(event) => {
71
+ const value = event.currentTarget.value;
72
+ age.handleChange(value === "" ? undefined : Number(value));
73
+ }}
74
+ onBlur={age.handleBlur}
75
+ />
76
+ <button disabled={form.formState.isSubmitting}>Submit</button>
77
+ </form>
78
+ );
79
+ }
80
+ ```
81
+
82
+ ## API
83
+
84
+ ### `createForm(options)`
85
+
86
+ Creates a form controller.
87
+
88
+ ```ts
89
+ const form = createForm({
90
+ defaultValues,
91
+ resolver,
92
+ context,
93
+ mode: "onSubmit",
94
+ });
95
+ ```
96
+
97
+ Validation modes are `onSubmit`, `onBlur`, `onChange`, and `all`.
98
+
99
+ ### `form.createField(name, options?)`
100
+
101
+ Use value callbacks with third-party or custom components:
102
+
103
+ ```tsx
104
+ const form = createForm<{ country: string }>({
105
+ defaultValues: { country: "" },
106
+ mode: "onBlur",
107
+ });
108
+ const country = form.createField("country", { required: "Choose a country" });
109
+
110
+ // Select is a third-party component with a value callback.
111
+ <Select
112
+ name={country.name}
113
+ value={country.value}
114
+ onValueChange={country.handleChange}
115
+ onBlur={country.handleBlur}
116
+ />
117
+ <span>{country.error?.message}</span>
118
+ ```
119
+
120
+ Paths and values are typed with `FieldPath` and `FieldPathValue`. `value`, `error`, `touched`, and `dirty` are reactive getters: read them in JSX or another reactive scope; **do not destructure them**. They also reflect programmatic updates and reset.
121
+
122
+ Both handlers return `void`. Changes update dirty state; blur marks touched. Validation follows the form's mode. Rules persist across reset and subsequent bindings created without options. `handleChange` accepts the final value, including booleans and objects with a `target` property. It performs no event extraction or value conversion.
123
+
124
+ `FieldOptions<TValue, TValues>` supports `required`, `min`, `max`, `pattern`, and `validate`. Convert values explicitly at the component boundary, as shown above for numbers (empty input becomes `undefined`). For checkboxes, bind `checked={field.value}` and pass `event.currentTarget.checked` to `handleChange`. Bind `name` explicitly. Do not spread a controller into input props or destructure its getters.
125
+
126
+ This is a breaking API change: `createField` is the only field-binding API; no compatibility aliases are provided.
127
+
128
+ ### Resolver interface
129
+
130
+ Resolvers receive current values and return parsed values plus field errors.
131
+
132
+ ```ts
133
+ type Resolver<TValues, TContext> = (
134
+ values: TValues,
135
+ context: {
136
+ name?: string;
137
+ context?: TContext;
138
+ }
139
+ ) =>
140
+ | { values: TValues; errors: Record<string, FieldError> }
141
+ | Promise<{ values: TValues; errors: Record<string, FieldError> }>;
142
+ ```
143
+
144
+ This keeps the core form independent from any validation library.
package/dist/index.d.mts CHANGED
@@ -1,7 +1,4 @@
1
- import { a as FieldPath, c as FieldValues, d as RegisterOptions, f as Resolver, g as ValidationMode, h as SetValueOptions, i as FieldFlags, l as Form, m as ResolverResult, n as FieldError, o as FieldPathValue, p as ResolverContext, r as FieldErrors, s as FieldRegistration, t as CreateFormOptions, u as FormState } from "./types-CzHtMISe.mjs";
2
-
3
- //#region src/create-form.d.ts
1
+ import { a as FieldFlags, c as FieldPathValue, d as FormState, f as Resolver, g as ValidationMode, h as SetValueOptions, i as FieldErrors, l as FieldValues, m as ResolverResult, n as FieldController, o as FieldOptions, p as ResolverContext, r as FieldError, s as FieldPath, t as CreateFormOptions, u as Form } from "./types-xBmBF3iK.mjs";
4
2
  /** Create a Solid-first form controller backed by createStore. */
5
- declare function createForm<TValues extends FieldValues = FieldValues, TContext = unknown>(options?: CreateFormOptions<TValues, TContext>): Form<TValues>;
6
- //#endregion
7
- export { type CreateFormOptions, type FieldError, type FieldErrors, type FieldFlags, type FieldPath, type FieldPathValue, type FieldRegistration, type FieldValues, type Form, type FormState, type RegisterOptions, type Resolver, type ResolverContext, type ResolverResult, type SetValueOptions, type ValidationMode, createForm };
3
+ export declare function createForm<TValues extends FieldValues = FieldValues, TContext = unknown>(options?: CreateFormOptions<TValues, TContext>): Form<TValues>;
4
+ export type { CreateFormOptions, FieldController, FieldError, FieldErrors, FieldFlags, FieldOptions, FieldPath, FieldPathValue, FieldValues, Form, FormState, Resolver, ResolverContext, ResolverResult, SetValueOptions, ValidationMode };
package/dist/index.mjs CHANGED
@@ -1,5 +1,6 @@
1
+ import { a as setPath, i as isEqual, n as getPath, r as isEmptyObject, t as cloneValue } from "./utils-0Ihz8msV.mjs";
1
2
  import { createStore, produce, reconcile, unwrap } from "solid-js/store";
2
- //#region src/create-form/async.ts
3
+ // src/create-form/async.ts
3
4
  /** Run a promise from a synchronous event handler and surface failures globally. */
4
5
  function runAsync(task) {
5
6
  task.catch((error) => {
@@ -8,67 +9,7 @@ function runAsync(task) {
8
9
  });
9
10
  });
10
11
  }
11
- //#endregion
12
- //#region src/utils.ts
13
- /** Return a dot path split into store path segments. */
14
- function toPath(name) {
15
- return name.split(".").filter(Boolean);
16
- }
17
- /** Read a nested value from an object by dot path. */
18
- function getPath(source, name) {
19
- let value = source;
20
- for (const key of toPath(name)) {
21
- if (value == null || typeof value !== "object") return;
22
- value = value[key];
23
- }
24
- return value;
25
- }
26
- /** Set a nested value on a cloned object by dot path. */
27
- function setPath(source, name, value) {
28
- const root = cloneValue(source);
29
- const path = toPath(name);
30
- let cursor = root;
31
- for (const [index, key] of path.entries()) {
32
- if (index === path.length - 1) {
33
- cursor[key] = value;
34
- break;
35
- }
36
- const next = cursor[key];
37
- if (next == null || typeof next !== "object" || Array.isArray(next)) cursor[key] = {};
38
- cursor = cursor[key];
39
- }
40
- return root;
41
- }
42
- /** Create a JSON-compatible deep clone. */
43
- function cloneValue(value) {
44
- if (value == null || typeof value !== "object") return value;
45
- return JSON.parse(JSON.stringify(value));
46
- }
47
- /** Compare two JSON-compatible values. */
48
- function isEqual(left, right) {
49
- return JSON.stringify(left) === JSON.stringify(right);
50
- }
51
- /** Return true when an object has no own enumerable keys. */
52
- function isEmptyObject(value) {
53
- return Object.keys(value).length === 0;
54
- }
55
- /** Extract a form value from a DOM event or raw value. */
56
- function getEventValue(valueOrEvent) {
57
- if (!isEventLike(valueOrEvent)) return valueOrEvent;
58
- const target = valueOrEvent.currentTarget ?? valueOrEvent.target;
59
- if (!isValueTarget(target)) return;
60
- if (target.type === "checkbox") return Boolean(target.checked);
61
- if (target.type === "number") return target.value === "" ? void 0 : Number(target.value);
62
- return target.value;
63
- }
64
- function isEventLike(value) {
65
- return value != null && typeof value === "object" && ("target" in value || "currentTarget" in value);
66
- }
67
- function isValueTarget(target) {
68
- return target != null && typeof target === "object" && ("value" in target || "checked" in target);
69
- }
70
- //#endregion
71
- //#region src/create-form/state.ts
12
+ // src/create-form/state.ts
72
13
  /** Create the initial form state object. */
73
14
  function createInitialFormState(defaultValues) {
74
15
  return {
@@ -92,24 +33,22 @@ function mergeValidationErrors(currentErrors, name, errors) {
92
33
  ...unwrap(currentErrors),
93
34
  [name]: errors[name]
94
35
  };
95
- if (!errors[name]) delete nextErrors[name];
36
+ if (!errors[name]) Reflect.deleteProperty(nextErrors, name);
96
37
  return nextErrors;
97
38
  }
98
39
  /** Calculate the next dirty field map after one field value changes. */
99
40
  function createNextDirtyFields(dirtyFields, values, defaultValues, name) {
100
41
  const nextDirtyFields = { ...unwrap(dirtyFields) };
101
42
  if (!isEqual(getPath(values, name), getPath(defaultValues, name))) nextDirtyFields[name] = true;
102
- else delete nextDirtyFields[name];
43
+ else Reflect.deleteProperty(nextDirtyFields, name);
103
44
  return {
104
45
  dirtyFields: nextDirtyFields,
105
46
  isDirty: !isEmptyObject(nextDirtyFields)
106
47
  };
107
48
  }
108
- //#endregion
109
- //#region src/validation/rules.ts
110
- /** Validate one field with built-in registration rules. */
111
- async function validateField(name, value, values, options) {
112
- return validateRequired(name, value, options) ?? validateMin(name, value, options) ?? validateMax(name, value, options) ?? validatePattern(name, value, options) ?? await validateCustom(name, value, values, options);
49
+ // src/validation/rules.ts
50
+ function isEmpty(value) {
51
+ return value === null || value === "" || Array.isArray(value) && value.length === 0;
113
52
  }
114
53
  function validateRequired(name, value, options) {
115
54
  if (!(options.required && isEmpty(value))) return;
@@ -118,6 +57,13 @@ function validateRequired(name, value, options) {
118
57
  type: "required"
119
58
  };
120
59
  }
60
+ function readRule(rule) {
61
+ if (typeof rule === "number") return {
62
+ message: void 0,
63
+ value: rule
64
+ };
65
+ return rule;
66
+ }
121
67
  function validateMin(name, value, options) {
122
68
  const min = readRule(options.min);
123
69
  if (!min || isEmpty(value) || Number(value) >= min.value) return;
@@ -134,6 +80,13 @@ function validateMax(name, value, options) {
134
80
  type: "max"
135
81
  };
136
82
  }
83
+ function readPattern(rule) {
84
+ if (rule instanceof RegExp) return {
85
+ message: void 0,
86
+ value: rule
87
+ };
88
+ return rule;
89
+ }
137
90
  function validatePattern(name, value, options) {
138
91
  const pattern = readPattern(options.pattern);
139
92
  if (!pattern || typeof value !== "string" || pattern.value.test(value)) return;
@@ -156,59 +109,62 @@ async function validateCustom(name, value, values, options) {
156
109
  };
157
110
  return result;
158
111
  }
159
- function isEmpty(value) {
160
- return value == null || value === "" || Array.isArray(value) && value.length === 0;
161
- }
162
- function readRule(rule) {
163
- if (typeof rule === "number") return {
164
- value: rule,
165
- message: void 0
166
- };
167
- return rule;
112
+ /** Validate one field with built-in field rules. */
113
+ async function validateField(name, value, values, options) {
114
+ return validateRequired(name, value, options) ?? validateMin(name, value, options) ?? validateMax(name, value, options) ?? validatePattern(name, value, options) ?? await validateCustom(name, value, values, options);
168
115
  }
169
- function readPattern(rule) {
170
- if (rule instanceof RegExp) return {
171
- value: rule,
172
- message: void 0
173
- };
174
- return rule;
116
+ // src/create-form.ts
117
+ function pickFieldErrors(errors, name) {
118
+ if (!errors[name]) return {};
119
+ return { [name]: errors[name] };
175
120
  }
176
- //#endregion
177
- //#region src/create-form.ts
178
121
  /** Create a Solid-first form controller backed by createStore. */
179
122
  function createForm(options = {}) {
180
123
  const mode = options.mode ?? "onSubmit";
181
124
  const fieldOptions = /* @__PURE__ */ new Map();
182
- const [formState, setFormState] = createStore(createInitialFormState(cloneValue(options.defaultValues ?? {})));
183
- function register(name, options) {
184
- if (options) fieldOptions.set(name, options);
185
- return {
186
- name,
187
- get value() {
188
- return getValue(name);
189
- },
190
- get checked() {
191
- return Boolean(getValue(name));
192
- },
193
- onInput(event) {
194
- runAsync(setValue(name, readInputValue(name, event), { shouldValidate: mode === "onChange" || mode === "all" }));
195
- },
196
- onChange(event) {
197
- runAsync(setValue(name, readInputValue(name, event), { shouldValidate: mode === "onChange" || mode === "all" }));
198
- },
199
- onBlur() {
200
- setFormState("touchedFields", produce((touchedFields) => {
201
- touchedFields[name] = true;
202
- }));
203
- if (mode === "onBlur" || mode === "all") runAsync(trigger(name));
204
- }
205
- };
125
+ const defaultValues = cloneValue(options.defaultValues ?? {});
126
+ const [formState, setFormState] = createStore(createInitialFormState(defaultValues));
127
+ function getValue(name) {
128
+ return getPath(formState.values, name);
129
+ }
130
+ async function collectFieldErrors(errors, name, values) {
131
+ for (const [fieldName, rules] of fieldOptions) {
132
+ if (name && fieldName !== name) continue;
133
+ const error = await validateField(fieldName, getPath(values, fieldName), values, rules);
134
+ if (error) errors[fieldName] = error;
135
+ }
136
+ }
137
+ async function collectResolverErrors(errors, name, values) {
138
+ if (!options.resolver) return;
139
+ const result = await options.resolver(values, {
140
+ context: options.context,
141
+ name
142
+ });
143
+ Object.assign(errors, name ? pickFieldErrors(result.errors, name) : result.errors);
144
+ if (isEmptyObject(result.errors)) setFormState("values", cloneValue(result.values));
145
+ }
146
+ async function collectErrors(name, values) {
147
+ const errors = {};
148
+ await collectFieldErrors(errors, name, values);
149
+ await collectResolverErrors(errors, name, values);
150
+ return errors;
206
151
  }
207
152
  function getValues() {
208
153
  return unwrap(formState.values);
209
154
  }
210
- function getValue(name) {
211
- return getPath(formState.values, name);
155
+ async function trigger(name) {
156
+ setFormState("isValidating", true);
157
+ const errors = await collectErrors(name, getValues());
158
+ const nextErrors = mergeValidationErrors(formState.errors, name, errors);
159
+ setFormState("errors", reconcile(nextErrors));
160
+ setFormState("isValid", isEmptyObject(nextErrors));
161
+ setFormState("isValidating", false);
162
+ return name ? !nextErrors[name] : isEmptyObject(errors);
163
+ }
164
+ function updateDirtyField(name) {
165
+ const nextDirty = createNextDirtyFields(formState.dirtyFields, formState.values, formState.defaultValues, name);
166
+ setFormState("dirtyFields", reconcile(nextDirty.dirtyFields));
167
+ setFormState("isDirty", nextDirty.isDirty);
212
168
  }
213
169
  async function setValue(name, value, setValueOptions = {}) {
214
170
  setFormState("values", setPath(getValues(), name, value));
@@ -218,6 +174,33 @@ function createForm(options = {}) {
218
174
  if (setValueOptions.shouldDirty ?? true) updateDirtyField(name);
219
175
  if (setValueOptions.shouldValidate) await trigger(name);
220
176
  }
177
+ function createField(name, options) {
178
+ if (options) fieldOptions.set(name, options);
179
+ return {
180
+ get dirty() {
181
+ return Boolean(formState.dirtyFields[name]);
182
+ },
183
+ get error() {
184
+ return formState.errors[name];
185
+ },
186
+ handleBlur() {
187
+ setFormState("touchedFields", produce((touchedFields) => {
188
+ touchedFields[name] = true;
189
+ }));
190
+ if (mode === "onBlur" || mode === "all") runAsync(trigger(name));
191
+ },
192
+ handleChange(value) {
193
+ runAsync(setValue(name, value, { shouldValidate: mode === "onChange" || mode === "all" }));
194
+ },
195
+ name,
196
+ get touched() {
197
+ return Boolean(formState.touchedFields[name]);
198
+ },
199
+ get value() {
200
+ return getValue(name);
201
+ }
202
+ };
203
+ }
221
204
  function setError(name, error) {
222
205
  setFormState("errors", produce((errors) => {
223
206
  errors[name] = error;
@@ -226,56 +209,25 @@ function createForm(options = {}) {
226
209
  }
227
210
  function clearErrors(name) {
228
211
  if (name) setFormState("errors", produce((errors) => {
229
- delete errors[name];
212
+ Reflect.deleteProperty(errors, name);
230
213
  }));
231
214
  else setFormState("errors", reconcile({}));
232
215
  setFormState("isValid", isEmptyObject(formState.errors));
233
216
  }
234
- async function trigger(name) {
235
- setFormState("isValidating", true);
236
- const errors = await collectErrors(name, getValues());
237
- const nextErrors = mergeValidationErrors(formState.errors, name, errors);
238
- setFormState("errors", reconcile(nextErrors));
239
- setFormState("isValid", isEmptyObject(nextErrors));
240
- setFormState("isValidating", false);
241
- return name ? !nextErrors[name] : isEmptyObject(errors);
242
- }
243
- async function collectErrors(name, values) {
244
- const errors = {};
245
- await collectRegisteredErrors(errors, name, values);
246
- await collectResolverErrors(errors, name, values);
247
- return errors;
248
- }
249
- async function collectRegisteredErrors(errors, name, values) {
250
- for (const [fieldName, registerOptions] of fieldOptions) {
251
- if (name && fieldName !== name) continue;
252
- const error = await validateField(fieldName, getPath(values, fieldName), values, registerOptions);
253
- if (error) errors[fieldName] = error;
254
- }
255
- }
256
- async function collectResolverErrors(errors, name, values) {
257
- if (!options.resolver) return;
258
- const result = await options.resolver(values, {
259
- name,
260
- context: options.context
261
- });
262
- Object.assign(errors, name ? pickFieldErrors(result.errors, name) : result.errors);
263
- if (isEmptyObject(result.errors)) setFormState("values", cloneValue(result.values));
264
- }
265
217
  function reset(values) {
266
218
  const nextDefaultValues = cloneValue(values ?? options.defaultValues ?? {});
267
219
  setFormState({
268
- values: cloneValue(nextDefaultValues),
269
220
  defaultValues: nextDefaultValues,
270
- errors: {},
271
- touchedFields: {},
272
221
  dirtyFields: {},
222
+ errors: {},
273
223
  isDirty: false,
224
+ isSubmitted: false,
225
+ isSubmitting: false,
274
226
  isValid: true,
275
227
  isValidating: false,
276
- isSubmitting: false,
277
- isSubmitted: false,
278
- submitCount: 0
228
+ submitCount: 0,
229
+ touchedFields: {},
230
+ values: cloneValue(nextDefaultValues)
279
231
  });
280
232
  }
281
233
  function handleSubmit(onValid, onInvalid) {
@@ -286,44 +238,24 @@ function createForm(options = {}) {
286
238
  setFormState("isSubmitting", true);
287
239
  const isValid = await trigger();
288
240
  try {
289
- if (isValid) await onValid(getValues(), event);
290
- else await onInvalid?.(formState.errors, event);
241
+ await (isValid ? onValid(getValues(), event) : onInvalid?.(formState.errors, event));
291
242
  } finally {
292
243
  setFormState("isSubmitting", false);
293
244
  }
294
245
  };
295
246
  }
296
- function updateDirtyField(name) {
297
- const nextDirty = createNextDirtyFields(formState.dirtyFields, formState.values, formState.defaultValues, name);
298
- setFormState("dirtyFields", reconcile(nextDirty.dirtyFields));
299
- setFormState("isDirty", nextDirty.isDirty);
300
- }
301
- function readInputValue(name, event) {
302
- return transformValue(name, getEventValue(event));
303
- }
304
- function transformValue(name, value) {
305
- const options = fieldOptions.get(name);
306
- if (options?.setValueAs) return options.setValueAs(value);
307
- if (options?.valueAsNumber) return value === "" || value == null ? void 0 : Number(value);
308
- return value;
309
- }
310
247
  return {
248
+ clearErrors,
249
+ createField,
311
250
  formState,
312
- setFormState,
313
- register,
314
- getValues,
315
251
  getValue,
316
- setValue,
317
- setError,
318
- clearErrors,
319
- trigger,
252
+ getValues,
253
+ handleSubmit,
320
254
  reset,
321
- handleSubmit
255
+ setError,
256
+ setFormState,
257
+ setValue,
258
+ trigger
322
259
  };
323
260
  }
324
- function pickFieldErrors(errors, name) {
325
- if (!errors[name]) return {};
326
- return { [name]: errors[name] };
327
- }
328
- //#endregion
329
261
  export { createForm };
@@ -1,10 +1,4 @@
1
- import { c as FieldValues, f as Resolver } from "../types-CzHtMISe.mjs";
1
+ import { f as Resolver, l as FieldValues } from "../types-xBmBF3iK.mjs";
2
2
  import { Type } from "arktype";
3
-
4
- //#region src/resolvers/arktype.d.ts
5
- /** A supported ArkType schema. */
6
- type ArkTypeSchema<TValues extends FieldValues = FieldValues> = Type<TValues>;
7
3
  /** Create a resolver for ArkType schemas. */
8
- declare function arktypeResolver<TValues extends FieldValues>(schema: ArkTypeSchema<TValues>): Resolver<TValues>;
9
- //#endregion
10
- export { ArkTypeSchema, arktypeResolver };
4
+ export declare function arktypeResolver<TValues extends FieldValues>(schema: Type<TValues>): Resolver<TValues>;
@@ -1,13 +1,20 @@
1
- //#region src/resolvers/arktype.ts
2
- /** Create a resolver for ArkType schemas. */
3
- function arktypeResolver(schema) {
4
- return (values) => {
5
- const result = schema(values);
6
- const errors = readArkTypeErrors(result);
7
- return {
8
- errors: errors ?? {},
9
- values: errors ? values : result
10
- };
1
+ import { n as getPath } from "../utils-0Ihz8msV.mjs";
2
+ // src/resolvers/arktype.ts
3
+ function isArkErrors(value) {
4
+ return Array.isArray(value) && "byPath" in value && typeof value.summary === "string";
5
+ }
6
+ function readMessage(value) {
7
+ if (typeof value === "string") return value;
8
+ if (value && typeof value === "object") {
9
+ const candidate = value;
10
+ return candidate.message ?? candidate.summary ?? candidate.toString?.() ?? "Invalid field value";
11
+ }
12
+ return "Invalid field value";
13
+ }
14
+ function collectByPath(errors, byPath) {
15
+ for (const [path, value] of Object.entries(byPath)) errors[path || "root"] = {
16
+ message: readMessage(value),
17
+ type: "arktype"
11
18
  };
12
19
  }
13
20
  function readArkTypeErrors(result) {
@@ -20,22 +27,39 @@ function readArkTypeErrors(result) {
20
27
  };
21
28
  return errors;
22
29
  }
23
- function isArkErrors(value) {
24
- return Array.isArray(value) && "byPath" in value && typeof value.summary === "string";
30
+ function joinPath(name, path) {
31
+ if (!path || path === "root") return name;
32
+ return `${name}.${path}`;
25
33
  }
26
- function collectByPath(errors, byPath) {
27
- for (const [path, value] of Object.entries(byPath)) errors[path || "root"] = {
28
- message: readMessage(value),
29
- type: "arktype"
30
- };
34
+ function prefixFieldErrors(name, errors) {
35
+ if (errors.root) return { [name]: errors.root };
36
+ return Object.fromEntries(Object.entries(errors).map(([path, error]) => [joinPath(name, path), error]));
31
37
  }
32
- function readMessage(value) {
33
- if (typeof value === "string") return value;
34
- if (value && typeof value === "object") {
35
- const candidate = value;
36
- return candidate.message ?? candidate.summary ?? candidate.toString?.() ?? "Invalid field value";
38
+ function validateField(schema, values, name) {
39
+ try {
40
+ const errors = readArkTypeErrors(schema.get(...name.split("."))(getPath(values, name)));
41
+ return {
42
+ errors: errors ? prefixFieldErrors(name, errors) : {},
43
+ values
44
+ };
45
+ } catch (error) {
46
+ console.error(error);
47
+ return {
48
+ errors: {},
49
+ values
50
+ };
37
51
  }
38
- return "Invalid field value";
39
52
  }
40
- //#endregion
53
+ /** Create a resolver for ArkType schemas. */
54
+ function arktypeResolver(schema) {
55
+ return (values, ctx) => {
56
+ if (ctx.name) return validateField(schema, values, ctx.name);
57
+ const result = schema(values);
58
+ const errors = readArkTypeErrors(result);
59
+ return {
60
+ errors: errors ?? {},
61
+ values: errors ? values : result
62
+ };
63
+ };
64
+ }
41
65
  export { arktypeResolver };
@@ -1,13 +1,11 @@
1
1
  import { SetStoreFunction, Store } from "solid-js/store";
2
-
3
- //#region src/types.d.ts
4
2
  /** The object shape managed by a form. */
5
3
  type FieldValues = object;
6
4
  /** A dot-separated field path. */
7
5
  type FieldPath<TValues extends FieldValues = FieldValues> = keyof TValues extends never ? string : ObjectPath<TValues>;
8
6
  /** The value type represented by a dot-separated field path. */
9
7
  type FieldPathValue<TValues extends FieldValues, TName extends FieldPath<TValues>> = PathValue<TValues, TName>;
10
- type ObjectPath<TValue> = TValue extends FieldValues ? { [TKey in Extract<keyof TValue, string>]: TValue[TKey] extends FieldValues ? TKey | `${TKey}.${ObjectPath<TValue[TKey]>}` : TKey }[Extract<keyof TValue, string>] : never;
8
+ type ObjectPath<TValue> = TValue extends FieldValues ? { [TKey in Extract<keyof TValue, string>]: TValue[TKey] extends FieldValues ? TKey | `${TKey}.${ObjectPath<TValue[TKey]>}` : TKey; }[Extract<keyof TValue, string>] : never;
11
9
  type PathValue<TValue, TName> = TName extends `${infer TKey}.${infer TRest}` ? TKey extends keyof TValue ? PathValue<TValue[TKey], TRest> : unknown : TName extends keyof TValue ? TValue[TName] : unknown;
12
10
  /** A validation error associated with a field path. */
13
11
  interface FieldError {
@@ -62,7 +60,7 @@ interface ResolverContext<TValues extends FieldValues = FieldValues, TContext =
62
60
  /** Pluggable validation function for schema libraries or custom logic. */
63
61
  type Resolver<TValues extends FieldValues = FieldValues, TContext = unknown> = (values: TValues, context: ResolverContext<TValues, TContext>) => ResolverResult<TValues> | Promise<ResolverResult<TValues>>;
64
62
  /** Built-in field-level validation rules. */
65
- interface RegisterOptions<TValue = unknown, TValues extends FieldValues = FieldValues> {
63
+ interface FieldOptions<TValue = unknown, TValues extends FieldValues = FieldValues> {
66
64
  /** Maximum string length or numeric value. */
67
65
  max?: number | {
68
66
  value: number;
@@ -80,26 +78,24 @@ interface RegisterOptions<TValue = unknown, TValues extends FieldValues = FieldV
80
78
  };
81
79
  /** Whether the field is required. */
82
80
  required?: boolean | string;
83
- /** Custom value transform before storing. */
84
- setValueAs?: (value: unknown) => TValue;
85
81
  /** Custom synchronous or asynchronous validation rule. */
86
82
  validate?: (value: TValue, values: TValues) => boolean | string | FieldError | Promise<boolean | string | FieldError>;
87
- /** Convert input values to numbers before storing them. */
88
- valueAsNumber?: boolean;
89
83
  }
90
- /** Props and helpers returned by register. */
91
- interface FieldRegistration<TValue = unknown> {
92
- /** Reactive checked getter for checkbox inputs. */
93
- readonly checked: boolean;
94
- /** Field name. */
95
- name: string;
96
- /** Solid blur handler that marks the field as touched. */
97
- onBlur: () => void;
98
- /** Solid change handler that updates form state. */
99
- onChange: (event: Event) => void;
100
- /** Solid input handler that updates form state. */
101
- onInput: (event: Event) => void;
102
- /** Reactive value getter suitable for spreading into Solid JSX. */
84
+ /** Value-driven custom component bindings. Read getters in a reactive scope. */
85
+ interface FieldController<TValue = unknown, TName extends string = string> {
86
+ /** Whether the field differs from its default. */
87
+ readonly dirty: boolean;
88
+ /** Current validation error. */
89
+ readonly error: FieldError | undefined;
90
+ /** Mark touched and validate according to mode. */
91
+ handleBlur: () => void;
92
+ /** Store a final value without DOM extraction or conversion. */
93
+ handleChange: (value: TValue) => void;
94
+ /** Field path. */
95
+ readonly name: TName;
96
+ /** Whether the field has been blurred. */
97
+ readonly touched: boolean;
98
+ /** Current field value. */
103
99
  readonly value: TValue;
104
100
  }
105
101
  /** Validation trigger mode. */
@@ -109,7 +105,7 @@ interface CreateFormOptions<TValues extends FieldValues = FieldValues, TContext
109
105
  /** Extra context passed to the resolver. */
110
106
  context?: TContext;
111
107
  /** Initial form values. */
112
- defaultValues?: Partial<TValues>;
108
+ defaultValues?: Partial<NoInfer<TValues>>;
113
109
  /** When validation should run automatically. Defaults to onSubmit. */
114
110
  mode?: ValidationMode;
115
111
  /** Schema or custom validation resolver. */
@@ -119,6 +115,8 @@ interface CreateFormOptions<TValues extends FieldValues = FieldValues, TContext
119
115
  interface Form<TValues extends FieldValues = FieldValues> {
120
116
  /** Clear all errors or one field error. */
121
117
  clearErrors: (name?: FieldPath<TValues>) => void;
118
+ /** Configure rules and receive value-driven component bindings. */
119
+ createField: <TName extends FieldPath<TValues>>(name: TName, options?: FieldOptions<FieldPathValue<TValues, TName>, TValues>) => FieldController<FieldPathValue<TValues, TName>, TName>;
122
120
  /** Reactive form state. */
123
121
  formState: Store<FormState<TValues>>;
124
122
  /** Read a single field value. */
@@ -127,8 +125,6 @@ interface Form<TValues extends FieldValues = FieldValues> {
127
125
  getValues: () => TValues;
128
126
  /** Submit helper that validates before calling the valid handler. */
129
127
  handleSubmit: (onValid: (values: TValues, event?: Event) => void | Promise<void>, onInvalid?: (errors: FieldErrors<TValues>, event?: Event) => void | Promise<void>) => (event?: Event) => Promise<void>;
130
- /** Register a field and receive Solid-friendly input props. */
131
- register: <TName extends FieldPath<TValues>>(name: TName, options?: RegisterOptions<FieldPathValue<TValues, TName>, TValues>) => FieldRegistration<FieldPathValue<TValues, TName>>;
132
128
  /** Reset values and form state. */
133
129
  reset: (values?: Partial<TValues>) => void;
134
130
  /** Set a field error. */
@@ -149,5 +145,4 @@ interface SetValueOptions {
149
145
  /** Trigger validation after the value is set. */
150
146
  shouldValidate?: boolean;
151
147
  }
152
- //#endregion
153
- export { FieldPath as a, FieldValues as c, RegisterOptions as d, Resolver as f, ValidationMode as g, SetValueOptions as h, FieldFlags as i, Form as l, ResolverResult as m, FieldError as n, FieldPathValue as o, ResolverContext as p, FieldErrors as r, FieldRegistration as s, CreateFormOptions as t, FormState as u };
148
+ export { FieldFlags as a, FieldPathValue as c, FormState as d, Resolver as f, ValidationMode as g, SetValueOptions as h, FieldErrors as i, FieldValues as l, ResolverResult as m, FieldController as n, FieldOptions as o, ResolverContext as p, FieldError as r, FieldPath as s, CreateFormOptions as t, Form as u };
@@ -0,0 +1,44 @@
1
+ // src/utils.ts
2
+ /** Return a dot path split into store path segments. */
3
+ function toPath(name) {
4
+ return name.split(".").filter(Boolean);
5
+ }
6
+ /** Read a nested value from an object by dot path. */
7
+ function getPath(source, name) {
8
+ let value = source;
9
+ for (const key of toPath(name)) {
10
+ if (value === null || typeof value !== "object") return;
11
+ value = value[key];
12
+ }
13
+ return value;
14
+ }
15
+ /** Create a JSON-compatible deep clone. */
16
+ function cloneValue(value) {
17
+ if (value === null || typeof value !== "object") return value;
18
+ return JSON.parse(JSON.stringify(value));
19
+ }
20
+ /** Set a nested value on a cloned object by dot path. */
21
+ function setPath(source, name, value) {
22
+ const root = cloneValue(source);
23
+ const path = toPath(name);
24
+ let cursor = root;
25
+ for (const [index, key] of path.entries()) {
26
+ if (index === path.length - 1) {
27
+ cursor[key] = value;
28
+ break;
29
+ }
30
+ const next = cursor[key];
31
+ if (next === null || typeof next !== "object" || Array.isArray(next)) cursor[key] = {};
32
+ cursor = cursor[key];
33
+ }
34
+ return root;
35
+ }
36
+ /** Compare two JSON-compatible values. */
37
+ function isEqual(left, right) {
38
+ return JSON.stringify(left) === JSON.stringify(right);
39
+ }
40
+ /** Return true when an object has no own enumerable keys. */
41
+ function isEmptyObject(value) {
42
+ return Object.keys(value).length === 0;
43
+ }
44
+ export { setPath as a, isEqual as i, getPath as n, isEmptyObject as r, cloneValue as t };
package/package.json CHANGED
@@ -1,72 +1,71 @@
1
1
  {
2
- "devDependencies": {
3
- "@biomejs/biome": "2.4.16",
4
- "@solidjs/testing-library": "^0.8.10",
5
- "@testing-library/jest-dom": "^6.9.1",
6
- "@types/node": "^25.9.2",
7
- "@typescript/native-preview": "7.0.0-dev.20260608.1",
8
- "@vitest/ui": "^4.1.8",
9
- "arktype": "^2.2.0",
10
- "bumpp": "^11.1.0",
11
- "jsdom": "^29.1.1",
12
- "tsdown": "^0.22.2",
13
- "typescript": "^5.9.3",
14
- "ultracite": "7.8.2",
15
- "vite": "^8.0.16",
16
- "vite-plugin-solid": "^2.11.12",
17
- "vitest": "^4.1.8"
18
- },
19
2
  "name": "solid-tiny-form",
20
- "peerDependencies": {
21
- "arktype": "^2.1.27",
22
- "solid-js": "^1.9.7"
23
- },
24
- "type": "module",
25
- "version": "0.1.0",
26
- "peerDependenciesMeta": {
27
- "arktype": {
28
- "optional": true
29
- }
30
- },
3
+ "version": "0.3.0",
31
4
  "description": "A tiny SolidJS form state manager with pluggable validation",
32
- "author": "solid tiny",
5
+ "keywords": [
6
+ "arktype",
7
+ "form",
8
+ "reactive",
9
+ "solid",
10
+ "solidjs",
11
+ "validation"
12
+ ],
13
+ "homepage": "https://codeberg.org/solid-tiny/solid-tiny-form#readme",
14
+ "bugs": {
15
+ "url": "https://codeberg.org/solid-tiny/solid-tiny-form/issues"
16
+ },
33
17
  "license": "MIT",
34
- "homepage": "https://github.com/sxl-cc/solid-tiny-form#readme",
18
+ "author": "solid tiny",
35
19
  "repository": {
36
20
  "type": "git",
37
- "url": "git+https://github.com/sxl-cc/solid-tiny-form.git"
38
- },
39
- "bugs": {
40
- "url": "https://github.com/sxl-cc/solid-tiny-form/issues"
21
+ "url": "git+https://codeberg.org/solid-tiny/solid-tiny-form.git"
41
22
  },
42
23
  "files": [
43
24
  "dist"
44
25
  ],
26
+ "type": "module",
45
27
  "sideEffects": false,
46
- "keywords": [
47
- "solidjs",
48
- "solid",
49
- "form",
50
- "reactive",
51
- "validation",
52
- "arktype"
53
- ],
54
28
  "types": "./dist/index.d.mts",
55
29
  "exports": {
56
30
  ".": "./dist/index.mjs",
57
31
  "./resolvers/arktype": "./dist/resolvers/arktype.mjs",
58
32
  "./package.json": "./package.json"
59
33
  },
34
+ "devDependencies": {
35
+ "@solidjs/testing-library": "^0.8.10",
36
+ "@testing-library/jest-dom": "^6.9.1",
37
+ "@types/node": "^25.9.9",
38
+ "arktype": "^2.2.9",
39
+ "bumpp": "^12.3.0",
40
+ "jsdom": "^29.1.1",
41
+ "oxfmt": "^0.75.0",
42
+ "oxlint": "^1.93.0",
43
+ "tsdown": "^0.23.7",
44
+ "typescript": "^6.0.3",
45
+ "ultracite": "8.1.1",
46
+ "vite": "^8.3.7",
47
+ "vite-plugin-solid": "^2.11.15",
48
+ "vitest": "^5.1.0"
49
+ },
50
+ "peerDependencies": {
51
+ "arktype": "^2.2.3",
52
+ "solid-js": "^1.9.7"
53
+ },
54
+ "peerDependenciesMeta": {
55
+ "arktype": {
56
+ "optional": true
57
+ }
58
+ },
60
59
  "scripts": {
61
60
  "build": "tsdown",
62
- "dev": "vite -c ./playground/vite.config.ts",
63
- "test": "vitest run",
64
- "test:ui": "vitest --ui",
65
- "tc": "tsgo --noEmit --skipLibCheck",
66
61
  "bump": "pnpm bumpp --no-push",
67
- "prepublish": "pnpm build",
68
62
  "check": "ultracite check",
69
63
  "check:error": "ultracite check --diagnostic-level error",
70
- "fix": "ultracite fix"
64
+ "dev": "vite -c ./playground/vite.config.ts",
65
+ "fix": "ultracite fix",
66
+ "prepublish": "pnpm build",
67
+ "tc": "tsc --noEmit --skipLibCheck",
68
+ "test": "vitest run",
69
+ "test:ui": "vitest --ui"
71
70
  }
72
71
  }