solid-tiny-form 0.2.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/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 solid tiny
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 solid tiny
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # solid-tiny-form
2
2
 
3
- A tiny SolidJS form state manager inspired by `react-hook-form`, but designed around Solid's fine-grained reactivity and `createStore`.
3
+ A tiny SolidJS form state manager designed around Solid's fine-grained reactivity and `createStore`.
4
4
 
5
5
  ## Features
6
6
 
7
- - `createForm` API for registering fields, setting values, submitting, resetting, and validating.
7
+ - `createForm` API for creating fields, setting values, submitting, resetting, and validating.
8
8
  - Form state is stored in a Solid store from `solid-js/store`.
9
9
  - Pluggable resolver interface for schema validation libraries.
10
10
  - Built-in ArkType resolver via `arktypeResolver`.
@@ -36,24 +36,43 @@ const schema = type({
36
36
  age: "number >= 18",
37
37
  });
38
38
 
39
+ type FormValues = { email: string; age: number | undefined };
40
+
39
41
  function Example() {
40
- const form = createForm({
42
+ const form = createForm<FormValues>({
41
43
  defaultValues: {
42
44
  email: "",
43
45
  age: 18,
44
46
  },
45
- resolver: arktypeResolver(schema),
47
+ resolver: arktypeResolver<FormValues>(schema),
46
48
  mode: "onBlur",
47
49
  });
48
50
 
51
+ const email = form.createField("email");
52
+ const age = form.createField("age");
53
+
49
54
  const onSubmit = form.handleSubmit((values) => {
50
55
  console.log(values);
51
56
  });
52
57
 
53
58
  return (
54
59
  <form onSubmit={onSubmit}>
55
- <input {...form.register("email")} />
56
- <input type="number" {...form.register("age", { valueAsNumber: true })} />
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
+ />
57
76
  <button disabled={form.formState.isSubmitting}>Submit</button>
58
77
  </form>
59
78
  );
@@ -77,6 +96,35 @@ const form = createForm({
77
96
 
78
97
  Validation modes are `onSubmit`, `onBlur`, `onChange`, and `all`.
79
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
+
80
128
  ### Resolver interface
81
129
 
82
130
  Resolvers receive current values and return parsed values plus field errors.
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-DL1ynFcc.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,6 +1,6 @@
1
- import { a as isEqual, i as isEmptyObject, n as getEventValue, o as setPath, r as getPath, t as cloneValue } from "./utils-jVw2-Xts.mjs";
1
+ import { a as setPath, i as isEqual, n as getPath, r as isEmptyObject, t as cloneValue } from "./utils-0Ihz8msV.mjs";
2
2
  import { createStore, produce, reconcile, unwrap } from "solid-js/store";
3
- //#region src/create-form/async.ts
3
+ // src/create-form/async.ts
4
4
  /** Run a promise from a synchronous event handler and surface failures globally. */
5
5
  function runAsync(task) {
6
6
  task.catch((error) => {
@@ -9,8 +9,7 @@ function runAsync(task) {
9
9
  });
10
10
  });
11
11
  }
12
- //#endregion
13
- //#region src/create-form/state.ts
12
+ // src/create-form/state.ts
14
13
  /** Create the initial form state object. */
15
14
  function createInitialFormState(defaultValues) {
16
15
  return {
@@ -34,24 +33,22 @@ function mergeValidationErrors(currentErrors, name, errors) {
34
33
  ...unwrap(currentErrors),
35
34
  [name]: errors[name]
36
35
  };
37
- if (!errors[name]) delete nextErrors[name];
36
+ if (!errors[name]) Reflect.deleteProperty(nextErrors, name);
38
37
  return nextErrors;
39
38
  }
40
39
  /** Calculate the next dirty field map after one field value changes. */
41
40
  function createNextDirtyFields(dirtyFields, values, defaultValues, name) {
42
41
  const nextDirtyFields = { ...unwrap(dirtyFields) };
43
42
  if (!isEqual(getPath(values, name), getPath(defaultValues, name))) nextDirtyFields[name] = true;
44
- else delete nextDirtyFields[name];
43
+ else Reflect.deleteProperty(nextDirtyFields, name);
45
44
  return {
46
45
  dirtyFields: nextDirtyFields,
47
46
  isDirty: !isEmptyObject(nextDirtyFields)
48
47
  };
49
48
  }
50
- //#endregion
51
- //#region src/validation/rules.ts
52
- /** Validate one field with built-in registration rules. */
53
- async function validateField(name, value, values, options) {
54
- 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;
55
52
  }
56
53
  function validateRequired(name, value, options) {
57
54
  if (!(options.required && isEmpty(value))) return;
@@ -60,6 +57,13 @@ function validateRequired(name, value, options) {
60
57
  type: "required"
61
58
  };
62
59
  }
60
+ function readRule(rule) {
61
+ if (typeof rule === "number") return {
62
+ message: void 0,
63
+ value: rule
64
+ };
65
+ return rule;
66
+ }
63
67
  function validateMin(name, value, options) {
64
68
  const min = readRule(options.min);
65
69
  if (!min || isEmpty(value) || Number(value) >= min.value) return;
@@ -76,6 +80,13 @@ function validateMax(name, value, options) {
76
80
  type: "max"
77
81
  };
78
82
  }
83
+ function readPattern(rule) {
84
+ if (rule instanceof RegExp) return {
85
+ message: void 0,
86
+ value: rule
87
+ };
88
+ return rule;
89
+ }
79
90
  function validatePattern(name, value, options) {
80
91
  const pattern = readPattern(options.pattern);
81
92
  if (!pattern || typeof value !== "string" || pattern.value.test(value)) return;
@@ -98,59 +109,62 @@ async function validateCustom(name, value, values, options) {
98
109
  };
99
110
  return result;
100
111
  }
101
- function isEmpty(value) {
102
- return value == null || value === "" || Array.isArray(value) && value.length === 0;
103
- }
104
- function readRule(rule) {
105
- if (typeof rule === "number") return {
106
- value: rule,
107
- message: void 0
108
- };
109
- 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);
110
115
  }
111
- function readPattern(rule) {
112
- if (rule instanceof RegExp) return {
113
- value: rule,
114
- message: void 0
115
- };
116
- return rule;
116
+ // src/create-form.ts
117
+ function pickFieldErrors(errors, name) {
118
+ if (!errors[name]) return {};
119
+ return { [name]: errors[name] };
117
120
  }
118
- //#endregion
119
- //#region src/create-form.ts
120
121
  /** Create a Solid-first form controller backed by createStore. */
121
122
  function createForm(options = {}) {
122
123
  const mode = options.mode ?? "onSubmit";
123
124
  const fieldOptions = /* @__PURE__ */ new Map();
124
- const [formState, setFormState] = createStore(createInitialFormState(cloneValue(options.defaultValues ?? {})));
125
- function register(name, options) {
126
- if (options) fieldOptions.set(name, options);
127
- return {
128
- name,
129
- get value() {
130
- return getValue(name);
131
- },
132
- get checked() {
133
- return Boolean(getValue(name));
134
- },
135
- onInput(event) {
136
- runAsync(setValue(name, readInputValue(name, event), { shouldValidate: mode === "onChange" || mode === "all" }));
137
- },
138
- onChange(event) {
139
- runAsync(setValue(name, readInputValue(name, event), { shouldValidate: mode === "onChange" || mode === "all" }));
140
- },
141
- onBlur() {
142
- setFormState("touchedFields", produce((touchedFields) => {
143
- touchedFields[name] = true;
144
- }));
145
- if (mode === "onBlur" || mode === "all") runAsync(trigger(name));
146
- }
147
- };
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;
148
151
  }
149
152
  function getValues() {
150
153
  return unwrap(formState.values);
151
154
  }
152
- function getValue(name) {
153
- 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);
154
168
  }
155
169
  async function setValue(name, value, setValueOptions = {}) {
156
170
  setFormState("values", setPath(getValues(), name, value));
@@ -160,6 +174,33 @@ function createForm(options = {}) {
160
174
  if (setValueOptions.shouldDirty ?? true) updateDirtyField(name);
161
175
  if (setValueOptions.shouldValidate) await trigger(name);
162
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
+ }
163
204
  function setError(name, error) {
164
205
  setFormState("errors", produce((errors) => {
165
206
  errors[name] = error;
@@ -168,56 +209,25 @@ function createForm(options = {}) {
168
209
  }
169
210
  function clearErrors(name) {
170
211
  if (name) setFormState("errors", produce((errors) => {
171
- delete errors[name];
212
+ Reflect.deleteProperty(errors, name);
172
213
  }));
173
214
  else setFormState("errors", reconcile({}));
174
215
  setFormState("isValid", isEmptyObject(formState.errors));
175
216
  }
176
- async function trigger(name) {
177
- setFormState("isValidating", true);
178
- const errors = await collectErrors(name, getValues());
179
- const nextErrors = mergeValidationErrors(formState.errors, name, errors);
180
- setFormState("errors", reconcile(nextErrors));
181
- setFormState("isValid", isEmptyObject(nextErrors));
182
- setFormState("isValidating", false);
183
- return name ? !nextErrors[name] : isEmptyObject(errors);
184
- }
185
- async function collectErrors(name, values) {
186
- const errors = {};
187
- await collectRegisteredErrors(errors, name, values);
188
- await collectResolverErrors(errors, name, values);
189
- return errors;
190
- }
191
- async function collectRegisteredErrors(errors, name, values) {
192
- for (const [fieldName, registerOptions] of fieldOptions) {
193
- if (name && fieldName !== name) continue;
194
- const error = await validateField(fieldName, getPath(values, fieldName), values, registerOptions);
195
- if (error) errors[fieldName] = error;
196
- }
197
- }
198
- async function collectResolverErrors(errors, name, values) {
199
- if (!options.resolver) return;
200
- const result = await options.resolver(values, {
201
- name,
202
- context: options.context
203
- });
204
- Object.assign(errors, name ? pickFieldErrors(result.errors, name) : result.errors);
205
- if (isEmptyObject(result.errors)) setFormState("values", cloneValue(result.values));
206
- }
207
217
  function reset(values) {
208
218
  const nextDefaultValues = cloneValue(values ?? options.defaultValues ?? {});
209
219
  setFormState({
210
- values: cloneValue(nextDefaultValues),
211
220
  defaultValues: nextDefaultValues,
212
- errors: {},
213
- touchedFields: {},
214
221
  dirtyFields: {},
222
+ errors: {},
215
223
  isDirty: false,
224
+ isSubmitted: false,
225
+ isSubmitting: false,
216
226
  isValid: true,
217
227
  isValidating: false,
218
- isSubmitting: false,
219
- isSubmitted: false,
220
- submitCount: 0
228
+ submitCount: 0,
229
+ touchedFields: {},
230
+ values: cloneValue(nextDefaultValues)
221
231
  });
222
232
  }
223
233
  function handleSubmit(onValid, onInvalid) {
@@ -228,44 +238,24 @@ function createForm(options = {}) {
228
238
  setFormState("isSubmitting", true);
229
239
  const isValid = await trigger();
230
240
  try {
231
- if (isValid) await onValid(getValues(), event);
232
- else await onInvalid?.(formState.errors, event);
241
+ await (isValid ? onValid(getValues(), event) : onInvalid?.(formState.errors, event));
233
242
  } finally {
234
243
  setFormState("isSubmitting", false);
235
244
  }
236
245
  };
237
246
  }
238
- function updateDirtyField(name) {
239
- const nextDirty = createNextDirtyFields(formState.dirtyFields, formState.values, formState.defaultValues, name);
240
- setFormState("dirtyFields", reconcile(nextDirty.dirtyFields));
241
- setFormState("isDirty", nextDirty.isDirty);
242
- }
243
- function readInputValue(name, event) {
244
- return transformValue(name, getEventValue(event));
245
- }
246
- function transformValue(name, value) {
247
- const options = fieldOptions.get(name);
248
- if (options?.setValueAs) return options.setValueAs(value);
249
- if (options?.valueAsNumber) return value === "" || value == null ? void 0 : Number(value);
250
- return value;
251
- }
252
247
  return {
248
+ clearErrors,
249
+ createField,
253
250
  formState,
254
- setFormState,
255
- register,
256
- getValues,
257
251
  getValue,
258
- setValue,
259
- setError,
260
- clearErrors,
261
- trigger,
252
+ getValues,
253
+ handleSubmit,
262
254
  reset,
263
- handleSubmit
255
+ setError,
256
+ setFormState,
257
+ setValue,
258
+ trigger
264
259
  };
265
260
  }
266
- function pickFieldErrors(errors, name) {
267
- if (!errors[name]) return {};
268
- return { [name]: errors[name] };
269
- }
270
- //#endregion
271
261
  export { createForm };
@@ -1,8 +1,4 @@
1
- import { c as FieldValues, f as Resolver } from "../types-DL1ynFcc.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
3
  /** Create a resolver for ArkType schemas. */
6
- declare function arktypeResolver<TValues extends FieldValues>(schema: Type<TValues>): Resolver<TValues>;
7
- //#endregion
8
- export { arktypeResolver };
4
+ export declare function arktypeResolver<TValues extends FieldValues>(schema: Type<TValues>): Resolver<TValues>;
@@ -1,31 +1,21 @@
1
- import { r as getPath } from "../utils-jVw2-Xts.mjs";
2
- //#region src/resolvers/arktype.ts
3
- /** Create a resolver for ArkType schemas. */
4
- function arktypeResolver(schema) {
5
- return (values, ctx) => {
6
- if (ctx.name) return validateField(schema, values, ctx.name);
7
- const result = schema(values);
8
- const errors = readArkTypeErrors(result);
9
- return {
10
- errors: errors ?? {},
11
- values: errors ? values : result
12
- };
13
- };
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";
14
5
  }
15
- function validateField(schema, values, name) {
16
- try {
17
- const errors = readArkTypeErrors(schema.get(...name.split("."))(getPath(values, name)));
18
- return {
19
- errors: errors ? prefixFieldErrors(name, errors) : {},
20
- values
21
- };
22
- } catch (err) {
23
- console.error(err);
24
- return {
25
- errors: {},
26
- values
27
- };
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";
28
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"
18
+ };
29
19
  }
30
20
  function readArkTypeErrors(result) {
31
21
  if (!isArkErrors(result)) return;
@@ -37,30 +27,39 @@ function readArkTypeErrors(result) {
37
27
  };
38
28
  return errors;
39
29
  }
40
- function prefixFieldErrors(name, errors) {
41
- if (errors.root) return { [name]: errors.root };
42
- return Object.fromEntries(Object.entries(errors).map(([path, error]) => [joinPath(name, path), error]));
43
- }
44
30
  function joinPath(name, path) {
45
31
  if (!path || path === "root") return name;
46
32
  return `${name}.${path}`;
47
33
  }
48
- function isArkErrors(value) {
49
- return Array.isArray(value) && "byPath" in value && typeof value.summary === "string";
50
- }
51
- function collectByPath(errors, byPath) {
52
- for (const [path, value] of Object.entries(byPath)) errors[path || "root"] = {
53
- message: readMessage(value),
54
- type: "arktype"
55
- };
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]));
56
37
  }
57
- function readMessage(value) {
58
- if (typeof value === "string") return value;
59
- if (value && typeof value === "object") {
60
- const candidate = value;
61
- 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
+ };
62
51
  }
63
- return "Invalid field value";
64
52
  }
65
- //#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
+ }
66
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. */
@@ -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 };
@@ -1,4 +1,4 @@
1
- //#region src/utils.ts
1
+ // src/utils.ts
2
2
  /** Return a dot path split into store path segments. */
3
3
  function toPath(name) {
4
4
  return name.split(".").filter(Boolean);
@@ -7,11 +7,16 @@ function toPath(name) {
7
7
  function getPath(source, name) {
8
8
  let value = source;
9
9
  for (const key of toPath(name)) {
10
- if (value == null || typeof value !== "object") return;
10
+ if (value === null || typeof value !== "object") return;
11
11
  value = value[key];
12
12
  }
13
13
  return value;
14
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
+ }
15
20
  /** Set a nested value on a cloned object by dot path. */
16
21
  function setPath(source, name, value) {
17
22
  const root = cloneValue(source);
@@ -23,16 +28,11 @@ function setPath(source, name, value) {
23
28
  break;
24
29
  }
25
30
  const next = cursor[key];
26
- if (next == null || typeof next !== "object" || Array.isArray(next)) cursor[key] = {};
31
+ if (next === null || typeof next !== "object" || Array.isArray(next)) cursor[key] = {};
27
32
  cursor = cursor[key];
28
33
  }
29
34
  return root;
30
35
  }
31
- /** Create a JSON-compatible deep clone. */
32
- function cloneValue(value) {
33
- if (value == null || typeof value !== "object") return value;
34
- return JSON.parse(JSON.stringify(value));
35
- }
36
36
  /** Compare two JSON-compatible values. */
37
37
  function isEqual(left, right) {
38
38
  return JSON.stringify(left) === JSON.stringify(right);
@@ -41,20 +41,4 @@ function isEqual(left, right) {
41
41
  function isEmptyObject(value) {
42
42
  return Object.keys(value).length === 0;
43
43
  }
44
- /** Extract a form value from a DOM event or raw value. */
45
- function getEventValue(valueOrEvent) {
46
- if (!isEventLike(valueOrEvent)) return valueOrEvent;
47
- const target = valueOrEvent.currentTarget ?? valueOrEvent.target;
48
- if (!isValueTarget(target)) return;
49
- if (target.type === "checkbox") return Boolean(target.checked);
50
- if (target.type === "number") return target.value === "" ? void 0 : Number(target.value);
51
- return target.value;
52
- }
53
- function isEventLike(value) {
54
- return value != null && typeof value === "object" && ("target" in value || "currentTarget" in value);
55
- }
56
- function isValueTarget(target) {
57
- return target != null && typeof target === "object" && ("value" in target || "checked" in target);
58
- }
59
- //#endregion
60
- export { isEqual as a, isEmptyObject as i, getEventValue as n, setPath as o, getPath as r, cloneValue as t };
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.2.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
  }