formstand 0.2.0 → 0.4.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/CHANGELOG.md +83 -1
- package/README.md +26 -4
- package/dist/index.cjs +146 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +36 -28
- package/dist/index.d.ts +36 -28
- package/dist/index.js +143 -46
- package/dist/index.js.map +1 -1
- package/package.json +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,90 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 — 2026-07-08
|
|
4
|
+
|
|
5
|
+
### Breaking
|
|
6
|
+
|
|
7
|
+
- The deprecated `useFormState` / `useFormStateShallow` aliases are removed —
|
|
8
|
+
use `useFormSelector` / `useFormSelectorShallow` (renamed in 0.2.0 because
|
|
9
|
+
React DOM ships its own, deprecated, `useFormState`).
|
|
10
|
+
- Bound components' `path` prop is schema-typed when `form` is a
|
|
11
|
+
`Form<TSchema>` (`PathsOf<F>`): typo'd paths are now compile errors, and
|
|
12
|
+
runtime-built strings need the documented cast. A structural `FieldFormApi`
|
|
13
|
+
form keeps plain `string`.
|
|
14
|
+
- `focusFirstError`'s root-`""` fallback no longer fires under the default
|
|
15
|
+
`document` scope when the page holds more than one `<form>` — "first
|
|
16
|
+
control" would be a guess, so it returns `false`. Pass the form element
|
|
17
|
+
(e.g. via a ref) as `root`.
|
|
18
|
+
- `SubmitResult` gained `{ kind: "error", error }`: when `onValid` throws or
|
|
19
|
+
rejects, `submit` now **resolves** with that result instead of rejecting
|
|
20
|
+
(so `handleSubmit` never leaves an unhandled rejection). Code that relied
|
|
21
|
+
on catching the rejection must check `result.kind === "error"`.
|
|
22
|
+
- `adoptValues` now clears the in-flight validation flags
|
|
23
|
+
(`isValidating` / `isValidatingForm`) along with the errors it already
|
|
24
|
+
cleared — the rebase disowns in-flight passes.
|
|
25
|
+
- `submit` skips its error/touched state writes when `values` changed while
|
|
26
|
+
validation was in flight (a `reset` during an in-flight submit no longer
|
|
27
|
+
gets stale errors committed over the fresh form); `onValid`/`onInvalid`
|
|
28
|
+
still run and the result still reports the outcome.
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- `focusField(path, root?)` — imperative focus by path (the `setFocus` of
|
|
33
|
+
react-hook-form), sharing `focusFirstError`'s focusability rules.
|
|
34
|
+
- `emptyValueForSchema(schema)` is exported — the schema-introspection rule
|
|
35
|
+
behind `useField().emptyValue`, alongside its adapter siblings
|
|
36
|
+
`numberToInputText` / `parseNumberText`.
|
|
37
|
+
- `field.setError` (from `useField`) accepts a single string, matching
|
|
38
|
+
`form.setError`.
|
|
39
|
+
- Dev-mode warning (once per path) when `validateField` /
|
|
40
|
+
`validateFieldAsync` target a path the schema provably cannot contain —
|
|
41
|
+
protects the docs-sanctioned dynamic-path casts from silent always-valid
|
|
42
|
+
results.
|
|
43
|
+
- Docs: a migrating-from-react-hook-form guide with the full API mapping
|
|
44
|
+
table.
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- Array ops no longer strand in-flight `isValidating` flags: flags under the
|
|
49
|
+
path are dropped rather than re-keyed (the completing pass clears the
|
|
50
|
+
original key, so a re-keyed flag could never be cleared).
|
|
51
|
+
- The validation sequence counter is globally monotonic and never resets, so
|
|
52
|
+
a superseded pre-reset validation pass can no longer collide with a
|
|
53
|
+
post-reset one and clobber its state.
|
|
54
|
+
- `validateFieldAsync("")` delegates to the whole-form pass, so its pending
|
|
55
|
+
state lives in `isValidatingForm` instead of `isValidating[""]`.
|
|
56
|
+
- `focusFirstError` / `focusField` report success only when a control
|
|
57
|
+
actually **holds** focus — hidden, disabled, closed-`<dialog>`, and
|
|
58
|
+
focus-refusing matches are passed over for the next candidate in DOM
|
|
59
|
+
order.
|
|
60
|
+
- `dirtyFields()` / `diff()` report an object that diverges only by an
|
|
61
|
+
`undefined`-valued key, agreeing with `useIsDirty`.
|
|
62
|
+
- `SelectField` tolerates duplicate option values (React keys no longer
|
|
63
|
+
collide).
|
|
64
|
+
- `useField` typo errors blame the path argument against the full
|
|
65
|
+
`FieldPath` union instead of blaming the form argument.
|
|
66
|
+
|
|
67
|
+
## 0.3.0 — 2026-07-04
|
|
68
|
+
|
|
69
|
+
### Added
|
|
70
|
+
|
|
71
|
+
- `numberToInputText`, `parseNumberText` (and the `ParsedNumberText` type)
|
|
72
|
+
are exported — the number-text rules the built-in bindings use, so
|
|
73
|
+
adapters for third-party UI kits can share them instead of re-deriving.
|
|
74
|
+
This is the minimum formstand version for `formstand-gen --ui mui` output.
|
|
75
|
+
|
|
76
|
+
### Docs & examples (no package changes)
|
|
77
|
+
|
|
78
|
+
- Five Material UI 9 playground demos plus the ~60-line formstand→MUI
|
|
79
|
+
adapter pattern they showcase.
|
|
80
|
+
- `formstand-cli` (`formstand-gen`) lives in the repo: generates form
|
|
81
|
+
components from a zod schema or TypeScript type. Published separately.
|
|
82
|
+
|
|
3
83
|
## 0.2.0 — 2026-07-02
|
|
4
84
|
|
|
5
|
-
The result of a full-repo review pass (
|
|
85
|
+
The result of a full-repo review pass (37 items across correctness, API
|
|
86
|
+
gaps, robustness, testing, tooling, and docs — the working log lived in
|
|
87
|
+
`TODO.md`, retired after release; see the git history for the play-by-play).
|
|
6
88
|
|
|
7
89
|
### Breaking
|
|
8
90
|
|
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# formstand
|
|
2
2
|
|
|
3
|
+
[](https://github.com/Scrumrot/formstand/actions/workflows/ci.yml) [](https://www.npmjs.com/package/formstand) [](https://github.com/Scrumrot/formstand/blob/main/LICENSE)
|
|
4
|
+
|
|
3
5
|
Zod-schema-first form state for React 19, backed by zustand.
|
|
4
6
|
|
|
5
7
|
**[Documentation](https://scrumrot.github.io/formstand/)** · **[Live playground](https://scrumrot.github.io/formstand/examples/)** · **[API reference](https://scrumrot.github.io/formstand/api/)**
|
|
@@ -84,11 +86,11 @@ const form = createForm(schema, {
|
|
|
84
86
|
| `setMode` / `setReValidateMode` | switch modes at runtime |
|
|
85
87
|
| `reset(nextInitial?, options?)` | reset to initial; optional partial overrides (shallow-merged for record roots, replaced wholesale otherwise) and `{ keepErrors, keepTouched, keepSubmitCount }` (no `keepDirty` — dirtiness derives from values vs initial, and reset makes them equal) |
|
|
86
88
|
| `resetField(path)` | reset one field to its initial value, clearing its (and descendants') error/touched state (dirtiness clears by definition — the value now equals initial) |
|
|
87
|
-
| `adoptValues(values)` | mid-session rebase: replaces `values` + `initialValues` and clears `errors
|
|
89
|
+
| `adoptValues(values)` | mid-session rebase: replaces `values` + `initialValues` and clears `errors` and in-flight validation flags (`isValidating`/`isValidatingForm` — the rebase disowns in-flight passes), but **preserves** interaction state (`touched`, `submitCount`, `isSubmitting`, `mode`). Use `reset()` for a full wipe |
|
|
88
90
|
| `updateState(updater)` | atomic multi-field patch; `errors` is derived from `schemaErrors`/`serverErrors`, so patch the channels — the patch type omits `errors` entirely, and a plain-JS `errors` patch is warned about and ignored |
|
|
89
91
|
| `validate()` / `validateField(path)` / `validateFields(paths)` | sync validation; on an async schema they transparently start the async pass instead (`validate`/`validateField` return `{ kind: "pending", promise }`, `validateFields` returns the `Promise<boolean>` itself) |
|
|
90
92
|
| `validateAsync()` / `validateFieldAsync(path)` / `validateFieldsAsync(paths)` | async; supports `async .refine` |
|
|
91
|
-
| `submit(onValid, onInvalid?, { force? })` → `Promise<SubmitResult>` | full submit flow; resolves `{ kind: "valid", data }`, `{ kind: "invalid", errors }` (errored fields are also marked touched),
|
|
93
|
+
| `submit(onValid, onInvalid?, { force? })` → `Promise<SubmitResult>` | full submit flow; resolves `{ kind: "valid", data }`, `{ kind: "invalid", errors }` (errored fields are also marked touched), `{ kind: "skipped" }` when another submit is in flight, or `{ kind: "error", error }` when `onValid` throws/rejects — submit resolves instead of rejecting, so `handleSubmit` never produces an unhandled rejection from an `onValid` throw |
|
|
92
94
|
| `handleSubmit(onValid, onInvalid?)(event?)` | event handler wrapper that calls `preventDefault` |
|
|
93
95
|
| `getField(path)` | typed one-shot value read |
|
|
94
96
|
| `getFieldState(path)` | typed one-shot read of a field's full slice (value/error/touched/dirty/isValidating) |
|
|
@@ -127,7 +129,7 @@ name.dirty;
|
|
|
127
129
|
name.isValidating;
|
|
128
130
|
name.setValue(v);
|
|
129
131
|
name.setTouched();
|
|
130
|
-
name.setError([
|
|
132
|
+
name.setError("taken"); // or ["taken", "reserved"]
|
|
131
133
|
name.clearError();
|
|
132
134
|
name.validate();
|
|
133
135
|
name.validateAsync();
|
|
@@ -176,7 +178,7 @@ Selector-style subscription. Use `useFormSelectorShallow` for selectors that ret
|
|
|
176
178
|
|
|
177
179
|
> Formerly `useFormState` / `useFormStateShallow` — renamed because React DOM
|
|
178
180
|
> ships its own (deprecated) `useFormState` and auto-imports regularly grabbed
|
|
179
|
-
> the wrong one. The old names
|
|
181
|
+
> the wrong one. The old names were removed in 0.4.0.
|
|
180
182
|
|
|
181
183
|
### `useFormError(form)`
|
|
182
184
|
|
|
@@ -256,6 +258,20 @@ works out of the box. Wire it into the invalid-submit handler:
|
|
|
256
258
|
<form onSubmit={form.handleSubmit(onValid, (errors) => focusFirstError(errors))}>
|
|
257
259
|
```
|
|
258
260
|
|
|
261
|
+
On a page with several forms, pass the form element (e.g. via a ref) as `root`
|
|
262
|
+
so the search stays inside your form — without it the whole document is
|
|
263
|
+
scanned, and a root-`""` error's first-control fallback refuses to guess
|
|
264
|
+
between forms (it returns `false` when the document holds more than one
|
|
265
|
+
`<form>`):
|
|
266
|
+
|
|
267
|
+
```tsx
|
|
268
|
+
<form ref={formRef} onSubmit={form.handleSubmit(onValid, (errors) => focusFirstError(errors, formRef.current ?? undefined))}>
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
It returns `true` only when a control actually received focus — disabled or
|
|
272
|
+
hidden controls, controls inside a closed `<dialog>`, and controls that refuse
|
|
273
|
+
focus (e.g. `display: none`) are passed over for the next match.
|
|
274
|
+
|
|
259
275
|
For custom focus logic, every bound component also takes a `ref` to its input.
|
|
260
276
|
|
|
261
277
|
## Sharing a form across components
|
|
@@ -409,6 +425,12 @@ form.handleSubmit(async (data) => {
|
|
|
409
425
|
});
|
|
410
426
|
```
|
|
411
427
|
|
|
428
|
+
## Contributing
|
|
429
|
+
|
|
430
|
+
See [CONTRIBUTING.md](./CONTRIBUTING.md) — note the repo has three
|
|
431
|
+
independent npm roots (`.`, `examples/`, `cli/`), and the root test suite
|
|
432
|
+
needs `examples/` installed.
|
|
433
|
+
|
|
412
434
|
## License
|
|
413
435
|
|
|
414
436
|
MIT
|
package/dist/index.cjs
CHANGED
|
@@ -188,6 +188,39 @@ var emptyValueForSchema = (schema) => {
|
|
|
188
188
|
};
|
|
189
189
|
return walk(schema, false);
|
|
190
190
|
};
|
|
191
|
+
var unwrapForWalk = (s) => {
|
|
192
|
+
if (s instanceof zod.z.ZodOptional || s instanceof zod.z.ZodNullable) {
|
|
193
|
+
return s.unwrap();
|
|
194
|
+
}
|
|
195
|
+
if (s instanceof zod.z.ZodDefault || s instanceof zod.z.ZodPrefault || s instanceof zod.z.ZodCatch || s instanceof zod.z.ZodReadonly || s instanceof zod.z.ZodNonOptional) {
|
|
196
|
+
return s.def.innerType;
|
|
197
|
+
}
|
|
198
|
+
if (s instanceof zod.z.ZodPipe) return s.def.in;
|
|
199
|
+
return null;
|
|
200
|
+
};
|
|
201
|
+
var isLeafSchema = (s) => s instanceof zod.z.ZodString || s instanceof zod.z.ZodNumber || s instanceof zod.z.ZodBoolean || s instanceof zod.z.ZodBigInt || s instanceof zod.z.ZodDate || s instanceof zod.z.ZodEnum || s instanceof zod.z.ZodLiteral || s instanceof zod.z.ZodNull || s instanceof zod.z.ZodUndefined || s instanceof zod.z.ZodVoid || s instanceof zod.z.ZodNaN || s instanceof zod.z.ZodSymbol;
|
|
202
|
+
var SCHEMA_WALK_BUDGET = 64;
|
|
203
|
+
var schemaHasPath = (schema, path) => {
|
|
204
|
+
const walk = (s, segments, budget) => {
|
|
205
|
+
if (budget <= 0) return true;
|
|
206
|
+
const inner = unwrapForWalk(s);
|
|
207
|
+
if (inner !== null) return walk(inner, segments, budget - 1);
|
|
208
|
+
const [head, ...rest] = segments;
|
|
209
|
+
if (head === void 0) return true;
|
|
210
|
+
if (s instanceof zod.z.ZodObject) {
|
|
211
|
+
const shape = s.shape;
|
|
212
|
+
const child = shape[String(head)];
|
|
213
|
+
if (child !== void 0) return walk(child, rest, budget - 1);
|
|
214
|
+
const catchall = s.def.catchall;
|
|
215
|
+
return catchall !== void 0 && !(catchall instanceof zod.z.ZodNever);
|
|
216
|
+
}
|
|
217
|
+
if (s instanceof zod.z.ZodArray) {
|
|
218
|
+
return typeof head === "number" ? walk(s.element, rest, budget - 1) : false;
|
|
219
|
+
}
|
|
220
|
+
return !isLeafSchema(s);
|
|
221
|
+
};
|
|
222
|
+
return walk(schema, parsePath(path), SCHEMA_WALK_BUDGET);
|
|
223
|
+
};
|
|
191
224
|
var hasChecks = (s) => {
|
|
192
225
|
const checks = s.def.checks;
|
|
193
226
|
return checks !== void 0 && checks.length > 0;
|
|
@@ -229,13 +262,14 @@ var dirtyPathsOf = (values, initial, prefix) => {
|
|
|
229
262
|
if (valuesEqual(values, initial)) return [];
|
|
230
263
|
if (isPlainObject(values) && isPlainObject(initial)) {
|
|
231
264
|
const keys = /* @__PURE__ */ new Set([...Object.keys(values), ...Object.keys(initial)]);
|
|
232
|
-
|
|
265
|
+
const childPaths = [...keys].flatMap(
|
|
233
266
|
(k) => dirtyPathsOf(
|
|
234
267
|
values[k],
|
|
235
268
|
initial[k],
|
|
236
269
|
prefix === "" ? k : `${prefix}.${k}`
|
|
237
270
|
)
|
|
238
271
|
);
|
|
272
|
+
return childPaths.length > 0 ? childPaths : [prefix];
|
|
239
273
|
}
|
|
240
274
|
return [prefix];
|
|
241
275
|
};
|
|
@@ -359,6 +393,16 @@ var createForm = (schema, options) => {
|
|
|
359
393
|
subschemaCache.set(path, sub);
|
|
360
394
|
return sub;
|
|
361
395
|
};
|
|
396
|
+
const checkedSchemaPaths = /* @__PURE__ */ new Set();
|
|
397
|
+
const warnMissingSchemaPath = (fn, path) => {
|
|
398
|
+
if (path === "" || checkedSchemaPaths.has(path)) return;
|
|
399
|
+
checkedSchemaPaths.add(path);
|
|
400
|
+
if (!schemaHasPath(schema, path)) {
|
|
401
|
+
console.warn(
|
|
402
|
+
`[formstand] ${fn}("${path}"): path does not exist in the schema \u2014 result will always be valid.`
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
};
|
|
362
406
|
const fieldScopeFor = (path, values) => {
|
|
363
407
|
const sub = path === "" ? null : subschemaAt(path);
|
|
364
408
|
if (sub === null) {
|
|
@@ -396,6 +440,7 @@ var createForm = (schema, options) => {
|
|
|
396
440
|
serverErrors: omitScope(state.serverErrors, [path])
|
|
397
441
|
});
|
|
398
442
|
const validateField = (path) => {
|
|
443
|
+
warnMissingSchemaPath("validateField", path);
|
|
399
444
|
try {
|
|
400
445
|
const scoped = scopedFieldErrors(path);
|
|
401
446
|
store.setState((state) => ({
|
|
@@ -421,10 +466,11 @@ var createForm = (schema, options) => {
|
|
|
421
466
|
};
|
|
422
467
|
};
|
|
423
468
|
const sequences = /* @__PURE__ */ new Map();
|
|
469
|
+
const seqRef = { current: 0 };
|
|
424
470
|
const nextSeq = (key) => {
|
|
425
|
-
|
|
426
|
-
sequences.set(key,
|
|
427
|
-
return
|
|
471
|
+
seqRef.current += 1;
|
|
472
|
+
sequences.set(key, seqRef.current);
|
|
473
|
+
return seqRef.current;
|
|
428
474
|
};
|
|
429
475
|
const validateAsyncOnForm = async () => {
|
|
430
476
|
const seq = nextSeq(FORM_SEQ_KEY);
|
|
@@ -504,6 +550,13 @@ var createForm = (schema, options) => {
|
|
|
504
550
|
return requestedValid;
|
|
505
551
|
};
|
|
506
552
|
const validateFieldAsync = async (path) => {
|
|
553
|
+
warnMissingSchemaPath("validateFieldAsync", path);
|
|
554
|
+
if (path === "") {
|
|
555
|
+
const result = await validateAsyncOnForm();
|
|
556
|
+
return fieldResult(
|
|
557
|
+
result.kind === "invalid" ? result.errors : emptyErrors
|
|
558
|
+
);
|
|
559
|
+
}
|
|
507
560
|
const seq = nextSeq(path);
|
|
508
561
|
const valuesAtStart = store.getState().values;
|
|
509
562
|
store.setState((state) => ({
|
|
@@ -563,7 +616,13 @@ var createForm = (schema, options) => {
|
|
|
563
616
|
)
|
|
564
617
|
}),
|
|
565
618
|
touched: reKeyByArrayPath(state.touched, path, mapper),
|
|
566
|
-
|
|
619
|
+
// Drop (don't re-key) in-flight flags under the array: the op changes
|
|
620
|
+
// `values`, so every pass they belong to fails its values-changed
|
|
621
|
+
// guard and never commits — but each pass's cleanup omits its
|
|
622
|
+
// ORIGINAL key, so a re-keyed entry would be orphaned and stick
|
|
623
|
+
// forever. Dropped, the flag is gone now and the cleanup is a
|
|
624
|
+
// harmless no-op.
|
|
625
|
+
isValidating: omitScope(state.isValidating, [path])
|
|
567
626
|
};
|
|
568
627
|
});
|
|
569
628
|
};
|
|
@@ -585,33 +644,43 @@ var createForm = (schema, options) => {
|
|
|
585
644
|
submitCount: state.submitCount + 1
|
|
586
645
|
}));
|
|
587
646
|
try {
|
|
588
|
-
const
|
|
647
|
+
const valuesAtStart = store.getState().values;
|
|
648
|
+
const result = await validateAsync(schema, valuesAtStart);
|
|
649
|
+
const staleValues = store.getState().values !== valuesAtStart;
|
|
589
650
|
if (result.kind === "invalid") {
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
Object.
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
651
|
+
if (!staleValues) {
|
|
652
|
+
store.setState((state) => {
|
|
653
|
+
const committed = commitFullPass(state, result.errors);
|
|
654
|
+
return {
|
|
655
|
+
...state,
|
|
656
|
+
...committed,
|
|
657
|
+
// Mark every errored field touched so touched-gated error UIs
|
|
658
|
+
// show something after the canonical first failed submit. The
|
|
659
|
+
// "" key (schema-level refine) has no field to touch.
|
|
660
|
+
touched: {
|
|
661
|
+
...state.touched,
|
|
662
|
+
...Object.fromEntries(
|
|
663
|
+
Object.keys(committed.errors).filter((k) => k !== "").map((k) => [k, true])
|
|
664
|
+
)
|
|
665
|
+
}
|
|
666
|
+
};
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
const merged = staleValues ? result.errors : store.getState().errors;
|
|
607
670
|
onInvalid?.(merged);
|
|
608
671
|
return { kind: "invalid", errors: merged };
|
|
609
672
|
}
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
673
|
+
if (!staleValues) {
|
|
674
|
+
store.setState((state) => ({
|
|
675
|
+
...state,
|
|
676
|
+
...commitFullPass(state, emptyErrors)
|
|
677
|
+
}));
|
|
678
|
+
}
|
|
679
|
+
try {
|
|
680
|
+
await onValid(result.data);
|
|
681
|
+
} catch (error) {
|
|
682
|
+
return { kind: "error", error };
|
|
683
|
+
}
|
|
615
684
|
return { kind: "valid", data: result.data };
|
|
616
685
|
} finally {
|
|
617
686
|
inFlight.count -= 1;
|
|
@@ -743,7 +812,12 @@ var createForm = (schema, options) => {
|
|
|
743
812
|
...errorChannels(state, {
|
|
744
813
|
schemaErrors: emptyErrors,
|
|
745
814
|
serverErrors: emptyErrors
|
|
746
|
-
})
|
|
815
|
+
}),
|
|
816
|
+
// Clearing `sequences` disowns every in-flight pass, including its
|
|
817
|
+
// flag cleanup (the ownership check fails, so it never writes) —
|
|
818
|
+
// clear the flags here too, mirroring reset(), or they'd stick.
|
|
819
|
+
isValidating: emptyBools,
|
|
820
|
+
isValidatingForm: false
|
|
747
821
|
}));
|
|
748
822
|
},
|
|
749
823
|
reset: (nextInitial, resetOptions) => {
|
|
@@ -908,8 +982,6 @@ function useFormSelector(form, selector) {
|
|
|
908
982
|
function useFormSelectorShallow(form, selector) {
|
|
909
983
|
return react$1.useStore(form.store, shallow.useShallow(selector));
|
|
910
984
|
}
|
|
911
|
-
var useFormState = useFormSelector;
|
|
912
|
-
var useFormStateShallow = useFormSelectorShallow;
|
|
913
985
|
var useFormError = (form) => react$1.useStore(
|
|
914
986
|
form.store,
|
|
915
987
|
shallow.useShallow((state) => state.errors[""])
|
|
@@ -1206,7 +1278,12 @@ var NumberField = ({
|
|
|
1206
1278
|
/* @__PURE__ */ jsxRuntime.jsx(ErrorText, { id: errorId, error: field.error })
|
|
1207
1279
|
] });
|
|
1208
1280
|
};
|
|
1209
|
-
var CheckboxField = ({
|
|
1281
|
+
var CheckboxField = ({
|
|
1282
|
+
form,
|
|
1283
|
+
path,
|
|
1284
|
+
label,
|
|
1285
|
+
ref
|
|
1286
|
+
}) => {
|
|
1210
1287
|
const id = react.useId();
|
|
1211
1288
|
const errorId = `${id}-error`;
|
|
1212
1289
|
const field = useField(form, path);
|
|
@@ -1250,7 +1327,12 @@ var SelectField = ({
|
|
|
1250
1327
|
"aria-describedby": hasError(field.error) ? errorId : void 0,
|
|
1251
1328
|
children: [
|
|
1252
1329
|
showEmptyOption ? /* @__PURE__ */ jsxRuntime.jsx("option", { value: "", disabled: true, children: placeholder ?? "" }) : null,
|
|
1253
|
-
options.map((opt) =>
|
|
1330
|
+
options.map((opt, index) => (
|
|
1331
|
+
// Index-qualified: option lists may legitimately repeat a value
|
|
1332
|
+
// (e.g. differently-labelled aliases), and bare value keys would
|
|
1333
|
+
// collide.
|
|
1334
|
+
/* @__PURE__ */ jsxRuntime.jsx("option", { value: opt.value, children: opt.label }, `${index}-${opt.value}`)
|
|
1335
|
+
))
|
|
1254
1336
|
]
|
|
1255
1337
|
}
|
|
1256
1338
|
),
|
|
@@ -1259,25 +1341,40 @@ var SelectField = ({
|
|
|
1259
1341
|
};
|
|
1260
1342
|
|
|
1261
1343
|
// src/react/focusError.ts
|
|
1344
|
+
var focusableControls = (scope) => [
|
|
1345
|
+
...scope.querySelectorAll(
|
|
1346
|
+
"input[name], select[name], textarea[name]"
|
|
1347
|
+
)
|
|
1348
|
+
].filter(
|
|
1349
|
+
(el) => !el.matches(':disabled, [type="hidden"]') && el.closest("dialog:not([open])") === null
|
|
1350
|
+
);
|
|
1351
|
+
var focusFirstOf = (candidates) => candidates.some((el) => {
|
|
1352
|
+
el.focus();
|
|
1353
|
+
return document.activeElement === el;
|
|
1354
|
+
});
|
|
1355
|
+
var nameMatchesAny = (el, paths) => {
|
|
1356
|
+
const name = el.getAttribute("name");
|
|
1357
|
+
return name !== null && paths.some((p) => isPathOrChild(name, p));
|
|
1358
|
+
};
|
|
1262
1359
|
var focusFirstError = (errors, root) => {
|
|
1263
1360
|
const scope = root ?? document;
|
|
1264
1361
|
const erroredPaths = Object.keys(errors).filter(
|
|
1265
1362
|
(k) => k !== "" && (errors[k]?.length ?? 0) > 0
|
|
1266
1363
|
);
|
|
1267
1364
|
const hasRootError = (errors[""]?.length ?? 0) > 0;
|
|
1268
|
-
const controls =
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
const
|
|
1274
|
-
|
|
1275
|
-
return name !== null && erroredPaths.some((k) => isPathOrChild(name, k));
|
|
1276
|
-
});
|
|
1277
|
-
const target = fieldMatch ?? (hasRootError ? controls[0] : void 0);
|
|
1278
|
-
target?.focus();
|
|
1279
|
-
return target !== void 0;
|
|
1365
|
+
const controls = focusableControls(scope);
|
|
1366
|
+
const fieldMatches = controls.filter(
|
|
1367
|
+
(el) => nameMatchesAny(el, erroredPaths)
|
|
1368
|
+
);
|
|
1369
|
+
const rootFallbackApplies = hasRootError && (root !== void 0 || document.querySelectorAll("form").length <= 1);
|
|
1370
|
+
const candidates = fieldMatches.length > 0 ? fieldMatches : rootFallbackApplies ? controls : [];
|
|
1371
|
+
return focusFirstOf(candidates);
|
|
1280
1372
|
};
|
|
1373
|
+
var focusField = (path, root) => focusFirstOf(
|
|
1374
|
+
focusableControls(root ?? document).filter(
|
|
1375
|
+
(el) => nameMatchesAny(el, [path])
|
|
1376
|
+
)
|
|
1377
|
+
);
|
|
1281
1378
|
var EMPTY_ID_STATE = { items: [], ids: [], counter: 0 };
|
|
1282
1379
|
var EMPTY_ITEMS = [];
|
|
1283
1380
|
var reconcileIds = (prev, nextItems) => {
|
|
@@ -1390,11 +1487,15 @@ exports.TextField = TextField;
|
|
|
1390
1487
|
exports.checkboxProps = checkboxProps;
|
|
1391
1488
|
exports.createForm = createForm;
|
|
1392
1489
|
exports.createFormContext = createFormContext;
|
|
1490
|
+
exports.emptyValueForSchema = emptyValueForSchema;
|
|
1393
1491
|
exports.flattenIssues = flattenIssues;
|
|
1492
|
+
exports.focusField = focusField;
|
|
1394
1493
|
exports.focusFirstError = focusFirstError;
|
|
1395
1494
|
exports.getAtPath = getAtPath;
|
|
1396
1495
|
exports.isAsyncRequiredError = isAsyncRequiredError;
|
|
1397
1496
|
exports.numberInputProps = numberInputProps;
|
|
1497
|
+
exports.numberToInputText = numberToInputText;
|
|
1498
|
+
exports.parseNumberText = parseNumberText;
|
|
1398
1499
|
exports.parsePath = parsePath;
|
|
1399
1500
|
exports.selectProps = selectProps;
|
|
1400
1501
|
exports.setAtPath = setAtPath;
|
|
@@ -1406,8 +1507,6 @@ exports.useForm = useForm;
|
|
|
1406
1507
|
exports.useFormError = useFormError;
|
|
1407
1508
|
exports.useFormSelector = useFormSelector;
|
|
1408
1509
|
exports.useFormSelectorShallow = useFormSelectorShallow;
|
|
1409
|
-
exports.useFormState = useFormState;
|
|
1410
|
-
exports.useFormStateShallow = useFormStateShallow;
|
|
1411
1510
|
exports.useIsDirty = useIsDirty;
|
|
1412
1511
|
exports.useIsSubmitting = useIsSubmitting;
|
|
1413
1512
|
exports.useIsValid = useIsValid;
|