formstand 0.6.0 → 0.8.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 +236 -0
- package/dist/index.cjs +190 -146
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +90 -59
- package/dist/index.d.ts +90 -59
- package/dist/index.js +191 -148
- package/dist/index.js.map +1 -1
- package/package.json +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,241 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.8.0 — 2026-07-10
|
|
6
|
+
|
|
7
|
+
#### Fixed (2026-07 full-repo review)
|
|
8
|
+
|
|
9
|
+
- `valuesEqual` compared only the first object's keys, so two objects with
|
|
10
|
+
equal key counts but different key sets (one holding `undefined`) read as
|
|
11
|
+
equal — dirty flags, `diff()`, and server-error release could all treat a
|
|
12
|
+
real change as clean. Key sets must now match (`Object.hasOwn`), and the
|
|
13
|
+
compare is symmetric.
|
|
14
|
+
- `setAtPath` no longer spreads a `Date`/`Map`/`Set`/class instance at an
|
|
15
|
+
intermediate path into a plain object (silently destroying it); the write
|
|
16
|
+
is refused with a console warning, like other unwritable shapes.
|
|
17
|
+
- Path reads return own properties only: `getAtPath(values,
|
|
18
|
+
"lookup.constructor")` on a `z.record` is `undefined` instead of leaking
|
|
19
|
+
`Object.prototype` members as field values.
|
|
20
|
+
- A throwing `onInvalid` handler now resolves `submit()` with
|
|
21
|
+
`{ kind: "error" }` like a throwing `onValid`, instead of rejecting out of
|
|
22
|
+
a DOM event handler.
|
|
23
|
+
- `restore()` no longer resurrects a snapshotted `isSubmitting` — the flag
|
|
24
|
+
reflects live submit passes, so a mid-submit snapshot restored later can't
|
|
25
|
+
disable submit buttons forever (same rule the validation flags already
|
|
26
|
+
followed).
|
|
27
|
+
- `SelectField` on a nullable field keeps its empty option visible and
|
|
28
|
+
selectable after a choice, so the field can be cleared back to `null`
|
|
29
|
+
through the UI (the `emptyValue` round-trip `selectProps` always
|
|
30
|
+
supported). Non-nullable fields keep the disabled placeholder.
|
|
31
|
+
- `useFieldArray` no longer hands a removed row's id to a row appended in
|
|
32
|
+
the same update (React treated it as a reorder and resurrected the dead
|
|
33
|
+
row's DOM state); the same-index fallback still keeps an edited row's id.
|
|
34
|
+
- `useForm` now also warns when the CONTENT of `initialValues` changes after
|
|
35
|
+
mount (the async-fetch pattern with a module-hoisted schema — previously
|
|
36
|
+
silently ignored with no warning at all). Inline literals with identical
|
|
37
|
+
content stay silent.
|
|
38
|
+
- Docs: `field-arrays.md` claimed array ops re-key `isValidating`; they
|
|
39
|
+
deliberately drop in-flight flags (the pass that set them is stale) — the
|
|
40
|
+
guide now says so. `components.md` documents the `checkboxProps`
|
|
41
|
+
unchecked-writes-`false` tradeoff for nullable booleans.
|
|
42
|
+
|
|
43
|
+
### Docs & examples (no package changes)
|
|
44
|
+
|
|
45
|
+
- The docs nav gains a "Built on" menu and the footer links to
|
|
46
|
+
[zod](https://zod.dev) and [zustand](https://zustand.docs.pmnd.rs).
|
|
47
|
+
|
|
48
|
+
- A "Schema builder" tab: formstand-gen running in the browser. A formstand
|
|
49
|
+
form describes a schema (fields, sections, arrays, enum options — with
|
|
50
|
+
duplicate-name and enum-needs-options cross-field validation), its values
|
|
51
|
+
map straight onto the CLI's IR, and the REAL emitters (imported from
|
|
52
|
+
cli/src — pure string builders, no Node APIs) regenerate the output files
|
|
53
|
+
on every keystroke, shown in a file tree with copy-file / copy-all. All
|
|
54
|
+
the CLI axes are selects: --ui, --layout, --sections, --columns.
|
|
55
|
+
- The Schema builder's "Copy all files" is now "Download .zip" — a
|
|
56
|
+
dependency-free STORE-only zip writer (~50 lines, in the demo's own
|
|
57
|
+
source) bundles the generated files under a folder named for the
|
|
58
|
+
component, exactly the tree `--out` would have written.
|
|
59
|
+
- Every demo has a direct link: hash routes like
|
|
60
|
+
`examples/#/schema-builder` (kebab-cased tab keys), synced both ways so
|
|
61
|
+
back/forward and hand-edited URLs work. Hash routing because GitHub Pages
|
|
62
|
+
is static hosting.
|
|
63
|
+
- The CLI command builder gains Sections and Field columns selects, and the
|
|
64
|
+
Generated Onboarding tab now showcases `--sections panel --columns 2`
|
|
65
|
+
(still CI-verified as the CLI's untouched output).
|
|
66
|
+
|
|
67
|
+
- A "CLI command builder" tab: a formstand form (cross-field superRefine,
|
|
68
|
+
conditional fields) whose live-updating formstand-gen command line is
|
|
69
|
+
useFormSelector-derived state, with a copy button.
|
|
70
|
+
- A "Generated" playground group whose Onboarding tab is formstand-gen's
|
|
71
|
+
untouched `--layout module --ui mui` output for the Onboarding schema —
|
|
72
|
+
CI regenerates it and fails on drift, so the demo is provably what the
|
|
73
|
+
current CLI emits (scripts/generate-cli-demos.mjs).
|
|
74
|
+
|
|
75
|
+
- MUI and shadcn variants of the Onboarding feature module (31 demos now):
|
|
76
|
+
same shared schema, option lists, and blank draft as the plain module
|
|
77
|
+
(re-exported, so the three can't drift), each with its own form instance
|
|
78
|
+
and pre-wired hooks — MUI sections are Accordions with dirty/valid chips,
|
|
79
|
+
shadcn sections are collapsible with badges. The CLI generates the same
|
|
80
|
+
architecture from the same schema (`--layout module --ui mui|shadcn`).
|
|
81
|
+
- The sidebar demo list is a tree view (x-tree-view): brass group folders,
|
|
82
|
+
form-check leaf icons, brass selection.
|
|
83
|
+
|
|
84
|
+
- The playground gets a real app layout: grouped demo navigation in a
|
|
85
|
+
sidebar (Core / Patterns / Material UI / shadcn/ui — the group map is
|
|
86
|
+
exhaustive over the tab keys, so an unassigned demo is a compile error),
|
|
87
|
+
a sticky scrollable sidebar from tablet width up, and responsive tiers
|
|
88
|
+
through HD to 4K; on mobile the nav becomes wrapping chips above the
|
|
89
|
+
content. `/guide/` on the docs site now lands on a guide index instead
|
|
90
|
+
of a 404.
|
|
91
|
+
- Fixed: clicking a checkbox's label made the field and everything below
|
|
92
|
+
it jump down for a frame and snap back (Chrome's label-forwarded
|
|
93
|
+
activation briefly rendered the checkbox as a padded text-input box —
|
|
94
|
+
reproduced and verified fixed in real Chromium via Playwright). The
|
|
95
|
+
playground's text-input chrome now excludes checkboxes, which also gain
|
|
96
|
+
a brass accent-color and a focus-visible ring.
|
|
97
|
+
- View state / View code move to a panel on the demo's right (stacked
|
|
98
|
+
below on narrow screens), toggled by the same two buttons; multi-file
|
|
99
|
+
demos (Onboarding) get a file-tree navigator (@mui/x-tree-view with
|
|
100
|
+
folder/file icons) opening on hooks.ts; the sidebar brand links back to
|
|
101
|
+
the docs and stays pinned while the demo list scrolls.
|
|
102
|
+
- The Perf demo registers its form like every other tab, so View state
|
|
103
|
+
works (the shell only mounts the panel while open, so a closed panel
|
|
104
|
+
costs the benchmark nothing — the copy says to close it while running).
|
|
105
|
+
The benchmark writes `field0` instead of the mid-grid field, so the
|
|
106
|
+
change is visible without scrolling.
|
|
107
|
+
|
|
108
|
+
## formstand-cli 0.5.2 — 2026-07-10
|
|
109
|
+
|
|
110
|
+
#### Fixed (2026-07 full-repo review)
|
|
111
|
+
|
|
112
|
+
- Digit-leading schema keys ("2fa", "2ndOwners") emitted invalid
|
|
113
|
+
identifiers (`const 2ndOwnersArray`); pascalCase/camelCase results are
|
|
114
|
+
now underscore-prefixed when they'd start with a digit.
|
|
115
|
+
- Array-row fields named JS reserved words ("new", "delete") emitted
|
|
116
|
+
reserved-word `const` declarations in module sections; row bindings now
|
|
117
|
+
use an identifier-safe variant (`new_`).
|
|
118
|
+
- A field named after the module prefix (field "contact" in ContactForm)
|
|
119
|
+
collided with the bound `useContactField` hook from `hooks.ts` — a
|
|
120
|
+
duplicate declaration plus self-recursion; component names are now
|
|
121
|
+
deduped against the hook names too (`ContactField2`).
|
|
122
|
+
- Boolean-only schemas with `--ui mui --layout module` emitted an
|
|
123
|
+
`adapter.ts` using `ChangeEvent` without importing it.
|
|
124
|
+
- `--name` is validated as an identifier (reserved words rejected) instead
|
|
125
|
+
of interpolating verbatim into declarations and file names.
|
|
126
|
+
- Cross-drive input/`--out` on Windows produced an unresolvable
|
|
127
|
+
`"./D:/..."` import specifier; the CLI now fails loudly at generation
|
|
128
|
+
time.
|
|
129
|
+
- zod's `.nonoptional()` unwrapped transparently, letting an inner
|
|
130
|
+
`.optional()` win — the field's checked `initialValues` annotation then
|
|
131
|
+
failed to typecheck; `.nonoptional()` now re-requires the field.
|
|
132
|
+
- A field literally named `__proto__` silently vanished from emitted object
|
|
133
|
+
literals (prototype-setter semantics); it's now emitted as a computed key.
|
|
134
|
+
- `--sections panel` with `--ui mui` rendered different chrome per
|
|
135
|
+
`--layout` (single-file used `CardHeader`, module used `Typography` in
|
|
136
|
+
`CardContent`); both emitters now share the module shape, the grid
|
|
137
|
+
strings come from one set of helpers instead of six inline copies, and
|
|
138
|
+
`emitInitialValues`/`blankNeedsCast` read one shared blank-value table so
|
|
139
|
+
they can't drift.
|
|
140
|
+
|
|
141
|
+
## formstand-cli 0.5.1 — 2026-07-09
|
|
142
|
+
|
|
143
|
+
### Fixed
|
|
144
|
+
|
|
145
|
+
- Generated `initialValues` (and array empty-item constants) only use the
|
|
146
|
+
`as unknown as` escape hatch when the blank draft genuinely can't
|
|
147
|
+
typecheck (a required number/date/enum starts `undefined`); every other
|
|
148
|
+
schema gets a checked type annotation, so typos in edited initial values
|
|
149
|
+
are compile errors again.
|
|
150
|
+
- `--layout module`: the generated `index.ts` now re-exports `./schema` and
|
|
151
|
+
`./types` alongside `./hooks`, so consumers can type submit handlers and
|
|
152
|
+
server code (`ProfileValues`, `profileSchema`) off the module's public
|
|
153
|
+
API instead of deep-importing its files.
|
|
154
|
+
|
|
155
|
+
## formstand-cli 0.5.0 — 2026-07-09
|
|
156
|
+
|
|
157
|
+
### Added
|
|
158
|
+
|
|
159
|
+
- `--sections flat|panel|collapsible` and `--columns 1|2|3`: minimal visual
|
|
160
|
+
layout options, working with both `--layout single` and `--layout module`
|
|
161
|
+
and all three uis. Sections render as flat headings (default, unchanged
|
|
162
|
+
output), bordered panels, or collapsible sections; fields inside each
|
|
163
|
+
section flow into 1–3 evenly spaced columns, with nested sections
|
|
164
|
+
spanning the full row. Each ui speaks its own dialect: inline styles for
|
|
165
|
+
`plain` (`<details>`/`<summary>` when collapsible), `Card`/`Accordion` +
|
|
166
|
+
`sx` grids for `mui`, Tailwind classes (`md:grid-cols-2`,
|
|
167
|
+
`bg-card … shadow-sm`, `<details>`) for `shadcn`. The defaults emit
|
|
168
|
+
byte-identical output to 0.4.0.
|
|
169
|
+
|
|
170
|
+
## formstand-cli 0.4.0 — 2026-07-09
|
|
171
|
+
|
|
172
|
+
### Added
|
|
173
|
+
|
|
174
|
+
- `--layout module` works with `--ui mui` and `--ui shadcn`: kit modules
|
|
175
|
+
get a shared `adapter.ts` / `adapter.tsx` exporting the adapter the
|
|
176
|
+
single-file backends inline, and field/section files import it. The
|
|
177
|
+
emitted prop builders are now generic over the field's value type
|
|
178
|
+
(matching the documented example adapters), because the module layout's
|
|
179
|
+
typed hooks surfaced what the single-file layout's widened hooks masked —
|
|
180
|
+
`UseFieldReturn<string>` wasn't assignable to a monomorphic builder.
|
|
181
|
+
Emitted kit modules are typechecked in CI against the MUI stub and both
|
|
182
|
+
the shadcn stub and the repo's real Radix components.
|
|
183
|
+
|
|
184
|
+
### Docs & examples (no package changes)
|
|
185
|
+
|
|
186
|
+
- Brand pass on the docs site and playground: the formstand mark (a form
|
|
187
|
+
with its green check, resting on a music stand) as logo and favicon, a
|
|
188
|
+
brass/ink palette replacing the stock VitePress indigo, hand-drawn SVG
|
|
189
|
+
feature icons replacing the emoji, and restrained motion (hero float,
|
|
190
|
+
card lift, tab/button transitions, demo fade-in) — all disabled under
|
|
191
|
+
prefers-reduced-motion. The playground gains a branded header and
|
|
192
|
+
focus-visible states.
|
|
193
|
+
|
|
194
|
+
## formstand-cli 0.3.0 — 2026-07-09
|
|
195
|
+
|
|
196
|
+
### Added
|
|
197
|
+
|
|
198
|
+
- `--layout module`: emits a feature-module folder instead of one file —
|
|
199
|
+
`schema.ts` (re-exported in zod mode, generated in type mode), `types.ts`,
|
|
200
|
+
`hooks.ts` (`createForm` + `createFormHooks`, so the whole module shares
|
|
201
|
+
one pre-wired hook API), one file per scalar field (props type + field
|
|
202
|
+
hook + component), one file per top-level object/array section (props
|
|
203
|
+
type + section hook built on the path-scoped dirty/valid flags +
|
|
204
|
+
component), the form body, and `index.ts` — the shape of the Onboarding
|
|
205
|
+
playground demo. `--out` names the folder with all destinations
|
|
206
|
+
pre-checked; without it the files stream to stdout with `// --- file:`
|
|
207
|
+
headers. Emitted modules are typechecked in CI against the library
|
|
208
|
+
source, including hostile-name, colliding-name, and leaf-free schemas.
|
|
209
|
+
Requires formstand ≥ 0.7; `--ui plain` only for now.
|
|
210
|
+
- `emitModuleForm` / `joinModuleFiles` join the programmatic API.
|
|
211
|
+
|
|
212
|
+
## 0.7.0 — 2026-07-09
|
|
213
|
+
|
|
214
|
+
### Added
|
|
215
|
+
|
|
216
|
+
- `createFormHooks(form, name?)` — every hook pre-wired to one form, the
|
|
217
|
+
provider-free way to share a module-singleton form. The optional name is
|
|
218
|
+
baked into the hook names at the type level and at runtime
|
|
219
|
+
(`createFormHooks(form, "invoice")` → `useInvoiceField`,
|
|
220
|
+
`useInvoiceFieldArray`, `useInvoiceSelector`, `useInvoiceSelectorShallow`,
|
|
221
|
+
`useInvoiceError`, `useInvoiceIsDirty`, `useInvoiceIsValid`,
|
|
222
|
+
`useInvoiceIsSubmitting`, `useInvoiceSubmitCount`), so a typo'd
|
|
223
|
+
destructure is a compile error; omit the name for unprefixed keys. Every
|
|
224
|
+
bound hook keeps its unbound signature minus the `form` argument — typed
|
|
225
|
+
paths, array item inference, path-scoped flags. Documented alongside
|
|
226
|
+
`createFormContext` with the singleton/SSR caveat; new "Hooks factory"
|
|
227
|
+
playground tab.
|
|
228
|
+
|
|
229
|
+
### Docs & examples (no package changes)
|
|
230
|
+
|
|
231
|
+
- New "Onboarding" playground tab: a 26-field, five-section feature module
|
|
232
|
+
built on `createFormHooks` — `schema.ts` / `types.ts` / `hooks.ts`, one
|
|
233
|
+
file per field, one per section (section headers run the path-scoped
|
|
234
|
+
`useIsDirty`/`useIsValid` flags), with the whole folder shown in the
|
|
235
|
+
View code panel.
|
|
236
|
+
- The playground's View code panel is syntax-highlighted (Prism, ~25KB —
|
|
237
|
+
read-only, so no editor bundle).
|
|
238
|
+
|
|
3
239
|
## 0.6.0 — 2026-07-09
|
|
4
240
|
|
|
5
241
|
### Added
|
package/dist/index.cjs
CHANGED
|
@@ -59,7 +59,7 @@ var valuesEqual = (a, b) => {
|
|
|
59
59
|
if (isPlainObject(a) && isPlainObject(b)) {
|
|
60
60
|
const aKeys = Object.keys(a);
|
|
61
61
|
const bKeys = Object.keys(b);
|
|
62
|
-
return aKeys.length === bKeys.length && aKeys.every((k) => valuesEqual(a[k], b[k]));
|
|
62
|
+
return aKeys.length === bKeys.length && aKeys.every((k) => Object.hasOwn(b, k) && valuesEqual(a[k], b[k]));
|
|
63
63
|
}
|
|
64
64
|
return false;
|
|
65
65
|
};
|
|
@@ -94,7 +94,7 @@ var readSegment = (obj, segment) => {
|
|
|
94
94
|
if (Array.isArray(obj)) {
|
|
95
95
|
return typeof segment === "number" ? obj[segment] : void 0;
|
|
96
96
|
}
|
|
97
|
-
return isPlainRecord(obj) ? obj[String(segment)] : void 0;
|
|
97
|
+
return isPlainRecord(obj) && Object.hasOwn(obj, String(segment)) ? obj[String(segment)] : void 0;
|
|
98
98
|
};
|
|
99
99
|
var getAtPath = (obj, path) => parsePath(path).reduce(readSegment, obj);
|
|
100
100
|
var slotAtPath = (obj, path) => {
|
|
@@ -137,6 +137,12 @@ var writeSegments = (obj, segments, value) => {
|
|
|
137
137
|
return writeIntoArray(obj, head, rest, value);
|
|
138
138
|
}
|
|
139
139
|
if (isPlainRecord(obj)) {
|
|
140
|
+
if (!isPlainObject(obj)) {
|
|
141
|
+
console.warn(
|
|
142
|
+
`[formstand] cannot write key "${String(head)}" through a non-plain object (Date/Map/Set/class instance); value left unchanged.`
|
|
143
|
+
);
|
|
144
|
+
return obj;
|
|
145
|
+
}
|
|
140
146
|
const key = String(head);
|
|
141
147
|
return { ...obj, [key]: writeSegments(obj[key], rest, value) };
|
|
142
148
|
}
|
|
@@ -670,7 +676,11 @@ var createForm = (schema, options) => {
|
|
|
670
676
|
});
|
|
671
677
|
}
|
|
672
678
|
const merged = stale ? result.errors : store.getState().errors;
|
|
673
|
-
|
|
679
|
+
try {
|
|
680
|
+
onInvalid?.(merged);
|
|
681
|
+
} catch (error) {
|
|
682
|
+
return { kind: "error", error };
|
|
683
|
+
}
|
|
674
684
|
return { kind: "invalid", errors: merged };
|
|
675
685
|
}
|
|
676
686
|
if (!stale) {
|
|
@@ -930,9 +940,13 @@ var createForm = (schema, options) => {
|
|
|
930
940
|
// In-flight state is owned by live passes, never by snapshots: a
|
|
931
941
|
// snapshotted flag has no pass left to clear it (the pass that set
|
|
932
942
|
// it cleared its ORIGINAL entry when it settled), so restoring one
|
|
933
|
-
// would stick it forever.
|
|
943
|
+
// would stick it forever. isSubmitting follows the same rule — a
|
|
944
|
+
// mid-submit snapshot restored later must not disable submit
|
|
945
|
+
// buttons forever, so it reflects the LIVE submit count instead of
|
|
946
|
+
// the snapshotted flag (a live pass's finally still resets it).
|
|
934
947
|
isValidating: emptyBools,
|
|
935
|
-
isValidatingForm: false
|
|
948
|
+
isValidatingForm: false,
|
|
949
|
+
isSubmitting: inFlight.count > 0
|
|
936
950
|
};
|
|
937
951
|
})
|
|
938
952
|
});
|
|
@@ -960,15 +974,17 @@ var useForm = (schema, options) => {
|
|
|
960
974
|
formRef.current = createForm(schema, options);
|
|
961
975
|
}
|
|
962
976
|
const form = formRef.current;
|
|
977
|
+
const mountInitialsRef = react.useRef(options.initialValues);
|
|
963
978
|
const warnedRef = react.useRef(false);
|
|
964
979
|
react.useEffect(() => {
|
|
965
|
-
|
|
980
|
+
const changedInitials = !Object.is(options.initialValues, mountInitialsRef.current) && !valuesEqual(options.initialValues, mountInitialsRef.current);
|
|
981
|
+
if (!warnedRef.current && (form.schema !== schema || changedInitials)) {
|
|
966
982
|
warnedRef.current = true;
|
|
967
983
|
console.warn(
|
|
968
|
-
"[formstand] useForm received a different schema
|
|
984
|
+
"[formstand] useForm received a different schema or initial values after the first render; schema and options changes are ignored for the life of the component. If the schema is defined inline this is harmless (but consider hoisting it to module scope). For initial values that arrive later (e.g. from a fetch), call form.adoptValues(values) or form.reset(values) instead."
|
|
969
985
|
);
|
|
970
986
|
}
|
|
971
|
-
}, [form, schema]);
|
|
987
|
+
}, [form, schema, options.initialValues]);
|
|
972
988
|
return form;
|
|
973
989
|
};
|
|
974
990
|
var createFormContext = () => {
|
|
@@ -985,38 +1001,6 @@ var createFormContext = () => {
|
|
|
985
1001
|
};
|
|
986
1002
|
return { Provider, useFormContext };
|
|
987
1003
|
};
|
|
988
|
-
function useFormSelector(form, selector) {
|
|
989
|
-
return react$1.useStore(form.store, selector);
|
|
990
|
-
}
|
|
991
|
-
function useFormSelectorShallow(form, selector) {
|
|
992
|
-
return react$1.useStore(form.store, shallow.useShallow(selector));
|
|
993
|
-
}
|
|
994
|
-
var useFormError = (form) => react$1.useStore(
|
|
995
|
-
form.store,
|
|
996
|
-
shallow.useShallow((state) => state.errors[""])
|
|
997
|
-
);
|
|
998
|
-
function useIsDirty(form, path) {
|
|
999
|
-
return react$1.useStore(
|
|
1000
|
-
form.store,
|
|
1001
|
-
(state) => path === void 0 || path === "" ? isFieldDirty(state.values, state.initialValues) : isFieldDirty(
|
|
1002
|
-
getAtPath(state.values, path),
|
|
1003
|
-
getAtPath(state.initialValues, path)
|
|
1004
|
-
)
|
|
1005
|
-
);
|
|
1006
|
-
}
|
|
1007
|
-
function useIsValid(form, path) {
|
|
1008
|
-
return react$1.useStore(form.store, (state) => {
|
|
1009
|
-
for (const k in state.errors) {
|
|
1010
|
-
const errs = state.errors[k];
|
|
1011
|
-
if (errs !== void 0 && errs.length > 0 && (path === void 0 || path === "" || isPathOrChild(k, path))) {
|
|
1012
|
-
return false;
|
|
1013
|
-
}
|
|
1014
|
-
}
|
|
1015
|
-
return true;
|
|
1016
|
-
});
|
|
1017
|
-
}
|
|
1018
|
-
var useIsSubmitting = (form) => react$1.useStore(form.store, (state) => state.isSubmitting);
|
|
1019
|
-
var useSubmitCount = (form) => react$1.useStore(form.store, (state) => state.submitCount);
|
|
1020
1004
|
function useField(form, pathArg, options) {
|
|
1021
1005
|
const slice = react$1.useStore(
|
|
1022
1006
|
form.store,
|
|
@@ -1146,6 +1130,168 @@ function useField(form, pathArg, options) {
|
|
|
1146
1130
|
[slice, emptyValue, setValue, setTouched, setError, clearError, validate, validateAsync2, onBlur]
|
|
1147
1131
|
);
|
|
1148
1132
|
}
|
|
1133
|
+
var EMPTY_ID_STATE = { items: [], ids: [], counter: 0 };
|
|
1134
|
+
var EMPTY_ITEMS = [];
|
|
1135
|
+
var reconcileIds = (prev, nextItems) => {
|
|
1136
|
+
if (prev.items === nextItems) return prev;
|
|
1137
|
+
if (prev.items.length === nextItems.length && prev.items.every((item, i) => Object.is(item, nextItems[i]))) {
|
|
1138
|
+
return prev;
|
|
1139
|
+
}
|
|
1140
|
+
const buckets = prev.items.reduce((acc, item, i) => {
|
|
1141
|
+
const bucket = acc.get(item);
|
|
1142
|
+
if (bucket === void 0) {
|
|
1143
|
+
acc.set(item, [i]);
|
|
1144
|
+
} else {
|
|
1145
|
+
bucket.push(i);
|
|
1146
|
+
}
|
|
1147
|
+
return acc;
|
|
1148
|
+
}, /* @__PURE__ */ new Map());
|
|
1149
|
+
const matchedIds = nextItems.map((item) => {
|
|
1150
|
+
const prevIndex = buckets.get(item)?.shift();
|
|
1151
|
+
return prevIndex === void 0 ? null : prev.ids[prevIndex] ?? null;
|
|
1152
|
+
});
|
|
1153
|
+
const leftoverAt = new Map(
|
|
1154
|
+
[...buckets.values()].flat().flatMap((i) => {
|
|
1155
|
+
const id = prev.ids[i];
|
|
1156
|
+
return id === void 0 ? [] : [[i, id]];
|
|
1157
|
+
})
|
|
1158
|
+
);
|
|
1159
|
+
const reused = matchedIds.map((id, i) => id ?? leftoverAt.get(i) ?? null);
|
|
1160
|
+
const mintCounts = reused.reduce((acc, id) => {
|
|
1161
|
+
const count = acc[acc.length - 1] ?? 0;
|
|
1162
|
+
return [...acc, id === null ? count + 1 : count];
|
|
1163
|
+
}, []);
|
|
1164
|
+
const minted = mintCounts[mintCounts.length - 1] ?? 0;
|
|
1165
|
+
const ids = reused.map(
|
|
1166
|
+
(id, i) => id ?? `__zfa_${prev.counter + (mintCounts[i] ?? 0)}`
|
|
1167
|
+
);
|
|
1168
|
+
return { items: nextItems, ids, counter: prev.counter + minted };
|
|
1169
|
+
};
|
|
1170
|
+
function useFieldArray(form, pathArg) {
|
|
1171
|
+
const path = react$1.useStore(
|
|
1172
|
+
form.store,
|
|
1173
|
+
(state) => typeof pathArg === "function" ? pathArg(state) : pathArg
|
|
1174
|
+
);
|
|
1175
|
+
const slice = react$1.useStore(
|
|
1176
|
+
form.store,
|
|
1177
|
+
shallow.useShallow((state) => {
|
|
1178
|
+
const value = getAtPath(state.values, path);
|
|
1179
|
+
return {
|
|
1180
|
+
items: Array.isArray(value) ? value : EMPTY_ITEMS,
|
|
1181
|
+
error: state.errors[path]
|
|
1182
|
+
};
|
|
1183
|
+
})
|
|
1184
|
+
);
|
|
1185
|
+
const items = slice.items;
|
|
1186
|
+
const error = slice.error;
|
|
1187
|
+
const [idEntry, setIdEntry] = react.useState({ form, path, state: EMPTY_ID_STATE });
|
|
1188
|
+
const base = idEntry.form === form && idEntry.path === path ? idEntry.state : { ...EMPTY_ID_STATE, counter: idEntry.state.counter };
|
|
1189
|
+
const nextIdState = reconcileIds(base, items);
|
|
1190
|
+
if (idEntry.form !== form || idEntry.path !== path || idEntry.state !== nextIdState) {
|
|
1191
|
+
setIdEntry({ form, path, state: nextIdState });
|
|
1192
|
+
}
|
|
1193
|
+
const ids = nextIdState.ids;
|
|
1194
|
+
const push = react.useCallback(
|
|
1195
|
+
(item) => form.arrayPush(path, item),
|
|
1196
|
+
[form, path]
|
|
1197
|
+
);
|
|
1198
|
+
const remove = react.useCallback(
|
|
1199
|
+
(index) => form.arrayRemove(path, index),
|
|
1200
|
+
[form, path]
|
|
1201
|
+
);
|
|
1202
|
+
const insert = react.useCallback(
|
|
1203
|
+
(index, item) => form.arrayInsert(path, index, item),
|
|
1204
|
+
[form, path]
|
|
1205
|
+
);
|
|
1206
|
+
const move = react.useCallback(
|
|
1207
|
+
(from, to) => form.arrayMove(path, from, to),
|
|
1208
|
+
[form, path]
|
|
1209
|
+
);
|
|
1210
|
+
const swap = react.useCallback(
|
|
1211
|
+
(a, b) => form.arraySwap(path, a, b),
|
|
1212
|
+
[form, path]
|
|
1213
|
+
);
|
|
1214
|
+
const fields = react.useMemo(
|
|
1215
|
+
() => items.map((value, index) => ({
|
|
1216
|
+
id: ids[index] ?? `__zfa_fallback_${index}`,
|
|
1217
|
+
value
|
|
1218
|
+
})),
|
|
1219
|
+
[items, ids]
|
|
1220
|
+
);
|
|
1221
|
+
return react.useMemo(
|
|
1222
|
+
() => ({
|
|
1223
|
+
fields,
|
|
1224
|
+
items,
|
|
1225
|
+
length: items.length,
|
|
1226
|
+
error,
|
|
1227
|
+
push,
|
|
1228
|
+
remove,
|
|
1229
|
+
insert,
|
|
1230
|
+
move,
|
|
1231
|
+
swap
|
|
1232
|
+
}),
|
|
1233
|
+
[fields, items, error, push, remove, insert, move, swap]
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
var useFormError = (form) => react$1.useStore(
|
|
1237
|
+
form.store,
|
|
1238
|
+
shallow.useShallow((state) => state.errors[""])
|
|
1239
|
+
);
|
|
1240
|
+
function useIsDirty(form, path) {
|
|
1241
|
+
return react$1.useStore(
|
|
1242
|
+
form.store,
|
|
1243
|
+
(state) => path === void 0 || path === "" ? isFieldDirty(state.values, state.initialValues) : isFieldDirty(
|
|
1244
|
+
getAtPath(state.values, path),
|
|
1245
|
+
getAtPath(state.initialValues, path)
|
|
1246
|
+
)
|
|
1247
|
+
);
|
|
1248
|
+
}
|
|
1249
|
+
function useIsValid(form, path) {
|
|
1250
|
+
return react$1.useStore(form.store, (state) => {
|
|
1251
|
+
for (const k in state.errors) {
|
|
1252
|
+
const errs = state.errors[k];
|
|
1253
|
+
if (errs !== void 0 && errs.length > 0 && (path === void 0 || path === "" || isPathOrChild(k, path))) {
|
|
1254
|
+
return false;
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
return true;
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
var useIsSubmitting = (form) => react$1.useStore(form.store, (state) => state.isSubmitting);
|
|
1261
|
+
var useSubmitCount = (form) => react$1.useStore(form.store, (state) => state.submitCount);
|
|
1262
|
+
function useFormSelector(form, selector) {
|
|
1263
|
+
return react$1.useStore(form.store, selector);
|
|
1264
|
+
}
|
|
1265
|
+
function useFormSelectorShallow(form, selector) {
|
|
1266
|
+
return react$1.useStore(form.store, shallow.useShallow(selector));
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// src/react/createFormHooks.ts
|
|
1270
|
+
var capitalize = (name) => name.length === 0 ? name : name.charAt(0).toUpperCase() + name.slice(1);
|
|
1271
|
+
var createFormHooks = (form, name) => {
|
|
1272
|
+
const structural = form;
|
|
1273
|
+
const useBoundField = (path, options) => useField(structural, path, options);
|
|
1274
|
+
const useBoundFieldArray = (path) => useFieldArray(structural, path);
|
|
1275
|
+
const useBoundSelector = (selector) => useFormSelector(structural, selector);
|
|
1276
|
+
const useBoundSelectorShallow = (selector) => useFormSelectorShallow(structural, selector);
|
|
1277
|
+
const useBoundError = () => useFormError(structural);
|
|
1278
|
+
const useBoundIsDirty = (path) => useIsDirty(structural, path);
|
|
1279
|
+
const useBoundIsValid = (path) => useIsValid(structural, path);
|
|
1280
|
+
const useBoundIsSubmitting = () => useIsSubmitting(structural);
|
|
1281
|
+
const useBoundSubmitCount = () => useSubmitCount(structural);
|
|
1282
|
+
const prefix = capitalize(name ?? "");
|
|
1283
|
+
return {
|
|
1284
|
+
[`use${prefix}Field`]: useBoundField,
|
|
1285
|
+
[`use${prefix}FieldArray`]: useBoundFieldArray,
|
|
1286
|
+
[`use${prefix}Selector`]: useBoundSelector,
|
|
1287
|
+
[`use${prefix}SelectorShallow`]: useBoundSelectorShallow,
|
|
1288
|
+
[`use${prefix}Error`]: useBoundError,
|
|
1289
|
+
[`use${prefix}IsDirty`]: useBoundIsDirty,
|
|
1290
|
+
[`use${prefix}IsValid`]: useBoundIsValid,
|
|
1291
|
+
[`use${prefix}IsSubmitting`]: useBoundIsSubmitting,
|
|
1292
|
+
[`use${prefix}SubmitCount`]: useBoundSubmitCount
|
|
1293
|
+
};
|
|
1294
|
+
};
|
|
1149
1295
|
|
|
1150
1296
|
// src/react/inputProps.ts
|
|
1151
1297
|
var ariaInvalid = (field) => field.error !== void 0 && field.error.length > 0 ? true : void 0;
|
|
@@ -1336,7 +1482,8 @@ var SelectField = ({
|
|
|
1336
1482
|
const id = react.useId();
|
|
1337
1483
|
const errorId = `${id}-error`;
|
|
1338
1484
|
const field = useField(form, path);
|
|
1339
|
-
const
|
|
1485
|
+
const clearable = field.emptyValue === null;
|
|
1486
|
+
const showEmptyOption = clearable || field.value === void 0 || field.value === null || placeholder !== void 0;
|
|
1340
1487
|
return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "zf-field", children: [
|
|
1341
1488
|
label !== void 0 ? /* @__PURE__ */ jsxRuntime.jsx("label", { htmlFor: id, className: "zf-label", children: label }) : null,
|
|
1342
1489
|
/* @__PURE__ */ jsxRuntime.jsxs(
|
|
@@ -1347,7 +1494,7 @@ var SelectField = ({
|
|
|
1347
1494
|
...selectProps(field),
|
|
1348
1495
|
"aria-describedby": hasError(field.error) ? errorId : void 0,
|
|
1349
1496
|
children: [
|
|
1350
|
-
showEmptyOption ? /* @__PURE__ */ jsxRuntime.jsx("option", { value: "", disabled:
|
|
1497
|
+
showEmptyOption ? /* @__PURE__ */ jsxRuntime.jsx("option", { value: "", disabled: !clearable, children: placeholder ?? "" }) : null,
|
|
1351
1498
|
options.map((opt, index) => (
|
|
1352
1499
|
// Index-qualified: option lists may legitimately repeat a value
|
|
1353
1500
|
// (e.g. differently-labelled aliases), and bare value keys would
|
|
@@ -1400,110 +1547,6 @@ var focusField = (path, root) => {
|
|
|
1400
1547
|
const controls = focusableControls(root ?? document);
|
|
1401
1548
|
return path === "" ? (root !== void 0 || document.querySelectorAll("form").length <= 1) && focusFirstOf(controls) : focusFirstOf(controls.filter((el) => nameMatchesAny(el, [path])));
|
|
1402
1549
|
};
|
|
1403
|
-
var EMPTY_ID_STATE = { items: [], ids: [], counter: 0 };
|
|
1404
|
-
var EMPTY_ITEMS = [];
|
|
1405
|
-
var reconcileIds = (prev, nextItems) => {
|
|
1406
|
-
if (prev.items === nextItems) return prev;
|
|
1407
|
-
if (prev.items.length === nextItems.length && prev.items.every((item, i) => Object.is(item, nextItems[i]))) {
|
|
1408
|
-
return prev;
|
|
1409
|
-
}
|
|
1410
|
-
const buckets = prev.items.reduce((acc, item, i) => {
|
|
1411
|
-
const bucket = acc.get(item);
|
|
1412
|
-
if (bucket === void 0) {
|
|
1413
|
-
acc.set(item, [i]);
|
|
1414
|
-
} else {
|
|
1415
|
-
bucket.push(i);
|
|
1416
|
-
}
|
|
1417
|
-
return acc;
|
|
1418
|
-
}, /* @__PURE__ */ new Map());
|
|
1419
|
-
const matchedIds = nextItems.map((item) => {
|
|
1420
|
-
const prevIndex = buckets.get(item)?.shift();
|
|
1421
|
-
return prevIndex === void 0 ? null : prev.ids[prevIndex] ?? null;
|
|
1422
|
-
});
|
|
1423
|
-
const leftover = [...buckets.values()].flat().sort((a, b) => a - b).map((i) => prev.ids[i]).filter((id) => id !== void 0);
|
|
1424
|
-
const ordinals = matchedIds.reduce((acc, id) => {
|
|
1425
|
-
const count = acc.length === 0 ? 0 : acc[acc.length - 1] ?? 0;
|
|
1426
|
-
acc.push(id === null ? count + 1 : count);
|
|
1427
|
-
return acc;
|
|
1428
|
-
}, []);
|
|
1429
|
-
const unmatched = ordinals.length === 0 ? 0 : ordinals[ordinals.length - 1] ?? 0;
|
|
1430
|
-
const ids = matchedIds.map((id, i) => {
|
|
1431
|
-
if (id !== null) return id;
|
|
1432
|
-
const ordinal = ordinals[i] ?? 0;
|
|
1433
|
-
return leftover[ordinal - 1] ?? `__zfa_${prev.counter + (ordinal - leftover.length)}`;
|
|
1434
|
-
});
|
|
1435
|
-
return {
|
|
1436
|
-
items: nextItems,
|
|
1437
|
-
ids,
|
|
1438
|
-
counter: prev.counter + Math.max(0, unmatched - leftover.length)
|
|
1439
|
-
};
|
|
1440
|
-
};
|
|
1441
|
-
function useFieldArray(form, pathArg) {
|
|
1442
|
-
const path = react$1.useStore(
|
|
1443
|
-
form.store,
|
|
1444
|
-
(state) => typeof pathArg === "function" ? pathArg(state) : pathArg
|
|
1445
|
-
);
|
|
1446
|
-
const slice = react$1.useStore(
|
|
1447
|
-
form.store,
|
|
1448
|
-
shallow.useShallow((state) => {
|
|
1449
|
-
const value = getAtPath(state.values, path);
|
|
1450
|
-
return {
|
|
1451
|
-
items: Array.isArray(value) ? value : EMPTY_ITEMS,
|
|
1452
|
-
error: state.errors[path]
|
|
1453
|
-
};
|
|
1454
|
-
})
|
|
1455
|
-
);
|
|
1456
|
-
const items = slice.items;
|
|
1457
|
-
const error = slice.error;
|
|
1458
|
-
const [idEntry, setIdEntry] = react.useState({ form, path, state: EMPTY_ID_STATE });
|
|
1459
|
-
const base = idEntry.form === form && idEntry.path === path ? idEntry.state : { ...EMPTY_ID_STATE, counter: idEntry.state.counter };
|
|
1460
|
-
const nextIdState = reconcileIds(base, items);
|
|
1461
|
-
if (idEntry.form !== form || idEntry.path !== path || idEntry.state !== nextIdState) {
|
|
1462
|
-
setIdEntry({ form, path, state: nextIdState });
|
|
1463
|
-
}
|
|
1464
|
-
const ids = nextIdState.ids;
|
|
1465
|
-
const push = react.useCallback(
|
|
1466
|
-
(item) => form.arrayPush(path, item),
|
|
1467
|
-
[form, path]
|
|
1468
|
-
);
|
|
1469
|
-
const remove = react.useCallback(
|
|
1470
|
-
(index) => form.arrayRemove(path, index),
|
|
1471
|
-
[form, path]
|
|
1472
|
-
);
|
|
1473
|
-
const insert = react.useCallback(
|
|
1474
|
-
(index, item) => form.arrayInsert(path, index, item),
|
|
1475
|
-
[form, path]
|
|
1476
|
-
);
|
|
1477
|
-
const move = react.useCallback(
|
|
1478
|
-
(from, to) => form.arrayMove(path, from, to),
|
|
1479
|
-
[form, path]
|
|
1480
|
-
);
|
|
1481
|
-
const swap = react.useCallback(
|
|
1482
|
-
(a, b) => form.arraySwap(path, a, b),
|
|
1483
|
-
[form, path]
|
|
1484
|
-
);
|
|
1485
|
-
const fields = react.useMemo(
|
|
1486
|
-
() => items.map((value, index) => ({
|
|
1487
|
-
id: ids[index] ?? `__zfa_fallback_${index}`,
|
|
1488
|
-
value
|
|
1489
|
-
})),
|
|
1490
|
-
[items, ids]
|
|
1491
|
-
);
|
|
1492
|
-
return react.useMemo(
|
|
1493
|
-
() => ({
|
|
1494
|
-
fields,
|
|
1495
|
-
items,
|
|
1496
|
-
length: items.length,
|
|
1497
|
-
error,
|
|
1498
|
-
push,
|
|
1499
|
-
remove,
|
|
1500
|
-
insert,
|
|
1501
|
-
move,
|
|
1502
|
-
swap
|
|
1503
|
-
}),
|
|
1504
|
-
[fields, items, error, push, remove, insert, move, swap]
|
|
1505
|
-
);
|
|
1506
|
-
}
|
|
1507
1550
|
|
|
1508
1551
|
exports.CheckboxField = CheckboxField;
|
|
1509
1552
|
exports.NumberField = NumberField;
|
|
@@ -1512,6 +1555,7 @@ exports.TextField = TextField;
|
|
|
1512
1555
|
exports.checkboxProps = checkboxProps;
|
|
1513
1556
|
exports.createForm = createForm;
|
|
1514
1557
|
exports.createFormContext = createFormContext;
|
|
1558
|
+
exports.createFormHooks = createFormHooks;
|
|
1515
1559
|
exports.emptyValueForSchema = emptyValueForSchema;
|
|
1516
1560
|
exports.flattenIssues = flattenIssues;
|
|
1517
1561
|
exports.focusField = focusField;
|