@native-form/core 0.1.1 → 0.1.3

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
@@ -160,19 +160,76 @@ Attach `@native-form/core` via Svelte's `onMount` or action directive:
160
160
 
161
161
  ---
162
162
 
163
+ ### 4. React (React 18 & 19)
164
+
165
+ For React applications, use the official [`@native-form/react`](../react/README.md) package:
166
+
167
+ ```bash
168
+ npm install @native-form/react
169
+ ```
170
+
171
+ ```tsx
172
+ import React from "react";
173
+ import { useForm } from "@native-form/react";
174
+ import { zodResolver } from "@native-form/zod";
175
+ import { z } from "zod";
176
+
177
+ const schema = z.object({
178
+ email: z.string().email("Enter a valid email"),
179
+ age: z.number().min(18, "Must be at least 18"),
180
+ });
181
+
182
+ export function LoginForm() {
183
+ const { formRef, formState } = useForm({
184
+ schema: zodResolver(schema),
185
+ serializationMode: "typed", // automatically coerces type="number" and range to numbers
186
+ onSubmit: (values) => console.log("Submitted:", values),
187
+ });
188
+
189
+ return (
190
+ <form ref={formRef} noValidate>
191
+ <div>
192
+ <label>Email</label>
193
+ <input name="email" type="email" placeholder="jane@example.com" />
194
+ {formState.errors.email && (
195
+ <p style={{ color: "red" }}>{formState.errors.email}</p>
196
+ )}
197
+ </div>
198
+
199
+ <div>
200
+ <label>Age</label>
201
+ <input name="age" type="number" placeholder="25" />
202
+ {formState.errors.age && (
203
+ <p style={{ color: "red" }}>{formState.errors.age}</p>
204
+ )}
205
+ </div>
206
+
207
+ <button type="submit" disabled={formState.submitting}>
208
+ {formState.submitting ? "Submitting..." : "Submit"}
209
+ </button>
210
+ </form>
211
+ );
212
+ }
213
+ ```
214
+
215
+ ---
216
+
163
217
  ## Configuration Options
164
218
 
165
219
  When instantiating `new Runtime(options)`, the following options are available:
166
220
 
167
221
  | Option | Type | Default | Description |
168
222
  | :--- | :--- | :--- | :--- |
169
- | `mode` | `"onSubmit" \| "onBlur" \| "onChange" \| "onTouched" \| "all"` | `"onSubmit"` | Validation trigger strategy. |
170
- | `serializationMode` | `"native" \| "typed"` | `"native"` | Whether values are serialized as raw strings/FormData or coerced typed values (numbers, booleans). |
223
+ | `mode` | `"onSubmit" \| "onBlur" \| "onChange" \| "onTouched" \| "all"` | `"onSubmit"` | Initial validation trigger strategy. |
224
+ | `reValidateMode` | `"onChange" \| "onBlur" \| "onSubmit"` | `"onChange"` | Re-validation strategy for fields with active errors. Automatically re-validates discrete controls (`checkbox`, `radio`, `select`) on change. |
225
+ | `renderDomErrors` | `boolean` | `true` | Whether the runtime automatically injects DOM `<div data-native-form-error="true">` elements next to invalid inputs. Set to `false` when rendering custom UI errors (defaults to `false` in `@native-form/react`). |
226
+ | `serializationMode` | `"native" \| "typed"` | `"native"` | Whether values are serialized as raw strings/FormData or coerced typed values (numbers, booleans, arrays). |
171
227
  | `nesting` | `"flat" \| "nested"` | `"flat"` | Whether dot/bracket notation paths are converted into deep nested objects (e.g. `user.email` -> `{ user: { email } }`). |
172
228
  | `preserveUnmountedValues` | `boolean` | `false` | When true, caches values from fields that unmount from the DOM (e.g. multi-step wizard views). |
173
- | `debounceMs` | `number` | `300` | Debounce delay for async field validators. |
229
+ | `debounceMs` | `number` | `300` | Debounce delay in milliseconds for async field validators. |
174
230
  | `asyncValidators` | `Record<string, AsyncValidator>` | `undefined` | Key-value mapping of field names to asynchronous validation functions with `AbortSignal` cancellation. |
175
231
  | `schema` | `SchemaLike` | `undefined` | Schema adapter (e.g. from `@native-form/zod`, `@native-form/yup`, `@native-form/valibot`). |
232
+ | `validator` | `Validator` | `undefined` | Custom validator object with a `validate(values, context)` method. |
176
233
  | `onSubmit` | `(values, event?) => void` | `undefined` | Callback invoked when submission succeeds and passes validation. |
177
234
  | `onInvalid` | `(errors, values, event?) => void` | `undefined` | Callback invoked when submission is blocked due to validation failures. |
178
235
 
package/dist/index.cjs CHANGED
@@ -329,7 +329,8 @@ var FieldNode = class {
329
329
  case "radio":
330
330
  return this.element.checked ? this.element.value : void 0;
331
331
  case "number":
332
- return this.element.value === "" ? void 0 : this.element.valueAsNumber;
332
+ case "range":
333
+ return this.element.value === "" || Number.isNaN(this.element.valueAsNumber) ? void 0 : this.element.valueAsNumber;
333
334
  case "file":
334
335
  return this.element.files;
335
336
  default:
@@ -489,9 +490,9 @@ var FieldErrorRenderer = class _FieldErrorRenderer {
489
490
  static nextErrorId = 0;
490
491
  errorIds = /* @__PURE__ */ new WeakMap();
491
492
  errorElements = /* @__PURE__ */ new WeakMap();
492
- applyErrors(fields, errors) {
493
+ applyErrors(fields, errors, renderDomErrors = true) {
493
494
  for (const field of fields) {
494
- this.applyFieldError(field, errors[field.name]);
495
+ this.applyFieldError(field, errors[field.name], renderDomErrors);
495
496
  }
496
497
  }
497
498
  normalizeFields(fields) {
@@ -499,12 +500,17 @@ var FieldErrorRenderer = class _FieldErrorRenderer {
499
500
  this.normalizeField(field);
500
501
  }
501
502
  }
502
- applyFieldError(field, message) {
503
+ applyFieldError(field, message, renderDomErrors = true) {
503
504
  const element = field.element;
504
505
  try {
505
506
  element.setCustomValidity(message ?? "");
506
507
  } catch (_e) {
507
508
  }
509
+ if (!renderDomErrors) {
510
+ this.removeErrorElements(this.findExistingErrorElements(element));
511
+ this.errorElements.delete(element);
512
+ return;
513
+ }
508
514
  if (!message) {
509
515
  this.removeErrorElements(this.findExistingErrorElements(element));
510
516
  this.errorElements.delete(element);
@@ -1283,7 +1289,11 @@ var Runtime = class {
1283
1289
  const { node, values, value } = snapshot;
1284
1290
  node.handleInput(value);
1285
1291
  const mode = this.options.mode ?? "onChange";
1286
- const shouldValidate = mode === "onChange" || mode === "all" || mode === "onTouched" && node.isTouched;
1292
+ const reValidateMode = this.options.reValidateMode ?? "onChange";
1293
+ const hasExistingError = Boolean(
1294
+ resolveFieldError(this.errors, element.name)
1295
+ );
1296
+ const shouldValidate = mode === "onChange" || mode === "all" || hasExistingError && reValidateMode === "onChange" || mode === "onTouched" && node.isTouched;
1287
1297
  if (shouldValidate) {
1288
1298
  this.triggerFieldValidation(node, values);
1289
1299
  }
@@ -1308,7 +1318,12 @@ var Runtime = class {
1308
1318
  const { node, values, value } = snapshot;
1309
1319
  node.handleChange(value);
1310
1320
  const mode = this.options.mode ?? "onChange";
1311
- const shouldValidate = mode === "onChange" || mode === "all" || mode === "onTouched" && node.isTouched;
1321
+ const reValidateMode = this.options.reValidateMode ?? "onChange";
1322
+ const hasExistingError = Boolean(
1323
+ resolveFieldError(this.errors, element.name)
1324
+ );
1325
+ const isDiscreteControl = element instanceof HTMLSelectElement || element instanceof HTMLInputElement && (element.type === "checkbox" || element.type === "radio");
1326
+ const shouldValidate = mode === "onChange" || mode === "all" || isDiscreteControl || hasExistingError && reValidateMode === "onChange" || mode === "onTouched" && node.isTouched;
1312
1327
  if (shouldValidate) {
1313
1328
  this.triggerFieldValidation(node, values);
1314
1329
  }
@@ -1470,8 +1485,12 @@ var Runtime = class {
1470
1485
  const node = this.registry.get(element.name);
1471
1486
  node?.handleBlur();
1472
1487
  const mode = this.options.mode ?? "onChange";
1488
+ const reValidateMode = this.options.reValidateMode ?? "onChange";
1489
+ const hasExistingError = Boolean(
1490
+ resolveFieldError(this.errors, element.name)
1491
+ );
1473
1492
  const hasPendingDebounce = this.debounceTimers.has(element.name);
1474
- const shouldValidate = mode === "onBlur" || mode === "onTouched" || mode === "all" || hasPendingDebounce;
1493
+ const shouldValidate = mode === "onBlur" || mode === "onTouched" || mode === "all" || hasExistingError && reValidateMode === "onBlur" || hasPendingDebounce;
1475
1494
  if (shouldValidate && node) {
1476
1495
  this.triggerFieldValidation(node, values, true);
1477
1496
  }
@@ -2017,7 +2036,12 @@ var Runtime = class {
2017
2036
  if (observer) {
2018
2037
  observer.disconnect();
2019
2038
  }
2020
- this.fieldErrorRenderer.applyErrors(this.registry.values(), this.errors);
2039
+ const renderDomErrors = this.options.renderDomErrors ?? true;
2040
+ this.fieldErrorRenderer.applyErrors(
2041
+ this.registry.values(),
2042
+ this.errors,
2043
+ renderDomErrors
2044
+ );
2021
2045
  if (observer && this.form) {
2022
2046
  observer.observe(this.form, runtimeMutationObserverOptions);
2023
2047
  }
package/dist/index.d.cts CHANGED
@@ -8,6 +8,14 @@
8
8
  * @public
9
9
  */
10
10
  type ValidationMode = "onChange" | "onBlur" | "onSubmit" | "onTouched" | "all";
11
+ /**
12
+ * Supported re-validation modes for fields that currently contain errors.
13
+ * - onChange: re-validates on every input/change event once an error is present
14
+ * - onBlur: re-validates only when the input loses focus
15
+ * - onSubmit: re-validates only on subsequent form submissions
16
+ * @public
17
+ */
18
+ type ReValidateMode = "onChange" | "onBlur" | "onSubmit";
11
19
  /** @public */
12
20
  type NativeFormElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
13
21
  /**
@@ -284,6 +292,20 @@ declare function createValidationPipeline<TValues extends NativeFormValues = Nat
284
292
  /** @public */
285
293
  interface RuntimeOptions<TValues extends NativeFormValues = NativeFormValues> {
286
294
  mode?: ValidationMode;
295
+ /**
296
+ * Validation strategy used after the form has encountered errors.
297
+ * - "onChange": field re-validates immediately on input/change when it has an error
298
+ * - "onBlur": field re-validates when blurred
299
+ * - "onSubmit": field re-validates only on subsequent submits
300
+ * @defaultValue "onChange"
301
+ */
302
+ reValidateMode?: ReValidateMode;
303
+ /**
304
+ * Whether to inject native DOM error elements after inputs.
305
+ * Defaults to `true` in core, but `false` in `@native-form/react`.
306
+ * @defaultValue true
307
+ */
308
+ renderDomErrors?: boolean;
287
309
  serializationMode?: SerializationMode;
288
310
  serializeFieldsOptions?: SerializeFieldsOptions;
289
311
  nesting?: "flat" | "nested";
@@ -492,4 +514,4 @@ declare function setByPath(target: Record<string, unknown>, path: string | reado
492
514
  declare function toNestedValues<TOutput extends Record<string, unknown> = Record<string, unknown>>(flatValues: Record<string, unknown>): TOutput;
493
515
 
494
516
  export { FieldNode, Runtime, Serializer, arrayItemPath, createValidationPipeline, createValidator, formPath, getByPath, isArrayItemPath, matchArrayItemPath, normalizePath, parseArrayItemPath, remapArrayItemIndex, setByPath, shiftArrayItemIndex, splitPath, toNestedValues };
495
- export type { ArrayItemPathInfo, AsyncFieldValidator, FieldEvents, FieldPathSegment, FieldState, FormEvents, InferSchemaOutput, NativeFormElement, NativeFormFlatValues, NativeFormNestedValue, NativeFormValue, NativeFormValues, RuntimeEvent, RuntimeEventType, RuntimeFormState, RuntimeListener, RuntimeOptions, RuntimePublicApi, SchemaLike, SerializationMode, SerializeFieldsOptions, StandardSchemaIssue, StandardSchemaResult, StandardSchemaV1, ValidationContext, ValidationEvents, ValidationFieldMeta, ValidationIssue, ValidationMode, ValidationPipeline, ValidationPipelineCapabilities, ValidationPipelineContext, ValidationPipelineOptions, ValidationResult, Validator };
517
+ export type { ArrayItemPathInfo, AsyncFieldValidator, FieldEvents, FieldPathSegment, FieldState, FormEvents, InferSchemaOutput, NativeFormElement, NativeFormFlatValues, NativeFormNestedValue, NativeFormValue, NativeFormValues, ReValidateMode, RuntimeEvent, RuntimeEventType, RuntimeFormState, RuntimeListener, RuntimeOptions, RuntimePublicApi, SchemaLike, SerializationMode, SerializeFieldsOptions, StandardSchemaIssue, StandardSchemaResult, StandardSchemaV1, ValidationContext, ValidationEvents, ValidationFieldMeta, ValidationIssue, ValidationMode, ValidationPipeline, ValidationPipelineCapabilities, ValidationPipelineContext, ValidationPipelineOptions, ValidationResult, Validator };
package/dist/index.d.ts CHANGED
@@ -8,6 +8,14 @@
8
8
  * @public
9
9
  */
10
10
  type ValidationMode = "onChange" | "onBlur" | "onSubmit" | "onTouched" | "all";
11
+ /**
12
+ * Supported re-validation modes for fields that currently contain errors.
13
+ * - onChange: re-validates on every input/change event once an error is present
14
+ * - onBlur: re-validates only when the input loses focus
15
+ * - onSubmit: re-validates only on subsequent form submissions
16
+ * @public
17
+ */
18
+ type ReValidateMode = "onChange" | "onBlur" | "onSubmit";
11
19
  /** @public */
12
20
  type NativeFormElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
13
21
  /**
@@ -284,6 +292,20 @@ declare function createValidationPipeline<TValues extends NativeFormValues = Nat
284
292
  /** @public */
285
293
  interface RuntimeOptions<TValues extends NativeFormValues = NativeFormValues> {
286
294
  mode?: ValidationMode;
295
+ /**
296
+ * Validation strategy used after the form has encountered errors.
297
+ * - "onChange": field re-validates immediately on input/change when it has an error
298
+ * - "onBlur": field re-validates when blurred
299
+ * - "onSubmit": field re-validates only on subsequent submits
300
+ * @defaultValue "onChange"
301
+ */
302
+ reValidateMode?: ReValidateMode;
303
+ /**
304
+ * Whether to inject native DOM error elements after inputs.
305
+ * Defaults to `true` in core, but `false` in `@native-form/react`.
306
+ * @defaultValue true
307
+ */
308
+ renderDomErrors?: boolean;
287
309
  serializationMode?: SerializationMode;
288
310
  serializeFieldsOptions?: SerializeFieldsOptions;
289
311
  nesting?: "flat" | "nested";
@@ -492,4 +514,4 @@ declare function setByPath(target: Record<string, unknown>, path: string | reado
492
514
  declare function toNestedValues<TOutput extends Record<string, unknown> = Record<string, unknown>>(flatValues: Record<string, unknown>): TOutput;
493
515
 
494
516
  export { FieldNode, Runtime, Serializer, arrayItemPath, createValidationPipeline, createValidator, formPath, getByPath, isArrayItemPath, matchArrayItemPath, normalizePath, parseArrayItemPath, remapArrayItemIndex, setByPath, shiftArrayItemIndex, splitPath, toNestedValues };
495
- export type { ArrayItemPathInfo, AsyncFieldValidator, FieldEvents, FieldPathSegment, FieldState, FormEvents, InferSchemaOutput, NativeFormElement, NativeFormFlatValues, NativeFormNestedValue, NativeFormValue, NativeFormValues, RuntimeEvent, RuntimeEventType, RuntimeFormState, RuntimeListener, RuntimeOptions, RuntimePublicApi, SchemaLike, SerializationMode, SerializeFieldsOptions, StandardSchemaIssue, StandardSchemaResult, StandardSchemaV1, ValidationContext, ValidationEvents, ValidationFieldMeta, ValidationIssue, ValidationMode, ValidationPipeline, ValidationPipelineCapabilities, ValidationPipelineContext, ValidationPipelineOptions, ValidationResult, Validator };
517
+ export type { ArrayItemPathInfo, AsyncFieldValidator, FieldEvents, FieldPathSegment, FieldState, FormEvents, InferSchemaOutput, NativeFormElement, NativeFormFlatValues, NativeFormNestedValue, NativeFormValue, NativeFormValues, ReValidateMode, RuntimeEvent, RuntimeEventType, RuntimeFormState, RuntimeListener, RuntimeOptions, RuntimePublicApi, SchemaLike, SerializationMode, SerializeFieldsOptions, StandardSchemaIssue, StandardSchemaResult, StandardSchemaV1, ValidationContext, ValidationEvents, ValidationFieldMeta, ValidationIssue, ValidationMode, ValidationPipeline, ValidationPipelineCapabilities, ValidationPipelineContext, ValidationPipelineOptions, ValidationResult, Validator };
package/dist/index.js CHANGED
@@ -287,7 +287,8 @@ var FieldNode = class {
287
287
  case "radio":
288
288
  return this.element.checked ? this.element.value : void 0;
289
289
  case "number":
290
- return this.element.value === "" ? void 0 : this.element.valueAsNumber;
290
+ case "range":
291
+ return this.element.value === "" || Number.isNaN(this.element.valueAsNumber) ? void 0 : this.element.valueAsNumber;
291
292
  case "file":
292
293
  return this.element.files;
293
294
  default:
@@ -447,9 +448,9 @@ var FieldErrorRenderer = class _FieldErrorRenderer {
447
448
  static nextErrorId = 0;
448
449
  errorIds = /* @__PURE__ */ new WeakMap();
449
450
  errorElements = /* @__PURE__ */ new WeakMap();
450
- applyErrors(fields, errors) {
451
+ applyErrors(fields, errors, renderDomErrors = true) {
451
452
  for (const field of fields) {
452
- this.applyFieldError(field, errors[field.name]);
453
+ this.applyFieldError(field, errors[field.name], renderDomErrors);
453
454
  }
454
455
  }
455
456
  normalizeFields(fields) {
@@ -457,12 +458,17 @@ var FieldErrorRenderer = class _FieldErrorRenderer {
457
458
  this.normalizeField(field);
458
459
  }
459
460
  }
460
- applyFieldError(field, message) {
461
+ applyFieldError(field, message, renderDomErrors = true) {
461
462
  const element = field.element;
462
463
  try {
463
464
  element.setCustomValidity(message ?? "");
464
465
  } catch (_e) {
465
466
  }
467
+ if (!renderDomErrors) {
468
+ this.removeErrorElements(this.findExistingErrorElements(element));
469
+ this.errorElements.delete(element);
470
+ return;
471
+ }
466
472
  if (!message) {
467
473
  this.removeErrorElements(this.findExistingErrorElements(element));
468
474
  this.errorElements.delete(element);
@@ -1241,7 +1247,11 @@ var Runtime = class {
1241
1247
  const { node, values, value } = snapshot;
1242
1248
  node.handleInput(value);
1243
1249
  const mode = this.options.mode ?? "onChange";
1244
- const shouldValidate = mode === "onChange" || mode === "all" || mode === "onTouched" && node.isTouched;
1250
+ const reValidateMode = this.options.reValidateMode ?? "onChange";
1251
+ const hasExistingError = Boolean(
1252
+ resolveFieldError(this.errors, element.name)
1253
+ );
1254
+ const shouldValidate = mode === "onChange" || mode === "all" || hasExistingError && reValidateMode === "onChange" || mode === "onTouched" && node.isTouched;
1245
1255
  if (shouldValidate) {
1246
1256
  this.triggerFieldValidation(node, values);
1247
1257
  }
@@ -1266,7 +1276,12 @@ var Runtime = class {
1266
1276
  const { node, values, value } = snapshot;
1267
1277
  node.handleChange(value);
1268
1278
  const mode = this.options.mode ?? "onChange";
1269
- const shouldValidate = mode === "onChange" || mode === "all" || mode === "onTouched" && node.isTouched;
1279
+ const reValidateMode = this.options.reValidateMode ?? "onChange";
1280
+ const hasExistingError = Boolean(
1281
+ resolveFieldError(this.errors, element.name)
1282
+ );
1283
+ const isDiscreteControl = element instanceof HTMLSelectElement || element instanceof HTMLInputElement && (element.type === "checkbox" || element.type === "radio");
1284
+ const shouldValidate = mode === "onChange" || mode === "all" || isDiscreteControl || hasExistingError && reValidateMode === "onChange" || mode === "onTouched" && node.isTouched;
1270
1285
  if (shouldValidate) {
1271
1286
  this.triggerFieldValidation(node, values);
1272
1287
  }
@@ -1428,8 +1443,12 @@ var Runtime = class {
1428
1443
  const node = this.registry.get(element.name);
1429
1444
  node?.handleBlur();
1430
1445
  const mode = this.options.mode ?? "onChange";
1446
+ const reValidateMode = this.options.reValidateMode ?? "onChange";
1447
+ const hasExistingError = Boolean(
1448
+ resolveFieldError(this.errors, element.name)
1449
+ );
1431
1450
  const hasPendingDebounce = this.debounceTimers.has(element.name);
1432
- const shouldValidate = mode === "onBlur" || mode === "onTouched" || mode === "all" || hasPendingDebounce;
1451
+ const shouldValidate = mode === "onBlur" || mode === "onTouched" || mode === "all" || hasExistingError && reValidateMode === "onBlur" || hasPendingDebounce;
1433
1452
  if (shouldValidate && node) {
1434
1453
  this.triggerFieldValidation(node, values, true);
1435
1454
  }
@@ -1975,7 +1994,12 @@ var Runtime = class {
1975
1994
  if (observer) {
1976
1995
  observer.disconnect();
1977
1996
  }
1978
- this.fieldErrorRenderer.applyErrors(this.registry.values(), this.errors);
1997
+ const renderDomErrors = this.options.renderDomErrors ?? true;
1998
+ this.fieldErrorRenderer.applyErrors(
1999
+ this.registry.values(),
2000
+ this.errors,
2001
+ renderDomErrors
2002
+ );
1979
2003
  if (observer && this.form) {
1980
2004
  observer.observe(this.form, runtimeMutationObserverOptions);
1981
2005
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@native-form/core",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Framework-agnostic native HTML-first form runtime, discovery, and validation contracts.",
5
5
  "license": "MIT",
6
6
  "type": "module",