formstand 0.8.0 → 0.9.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 +154 -0
- package/README.md +2 -1
- package/dist/index.cjs +232 -19
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +61 -1
- package/dist/index.d.ts +61 -1
- package/dist/index.js +227 -20
- package/dist/index.js.map +1 -1
- package/package.json +11 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,152 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.9.0 — 2026-07-10
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- `validateFields` no longer fabricates a "required" error for a field
|
|
10
|
+
whose required ancestor is `undefined` (the field-scoped fast path parsed
|
|
11
|
+
the leaf against undefined where the full-form parse keys the error at the
|
|
12
|
+
ancestor) — sync and async agreed again. `slotAtPath` now checks the
|
|
13
|
+
container at each step.
|
|
14
|
+
- `useVariantField` keeps its type guard for **optional/nullable**
|
|
15
|
+
discriminated unions (a missing `NonNullable` let the nullish member
|
|
16
|
+
collapse `keyof` to `never` and leak every key, including the
|
|
17
|
+
discriminant).
|
|
18
|
+
- `devtools` is active only in non-production builds
|
|
19
|
+
(`NODE_ENV !== "production"`), so opting in during development never
|
|
20
|
+
streams a shipped form's state to an end user with the extension.
|
|
21
|
+
- `parseDateText` accepts calendar years under 100 (the Date constructor
|
|
22
|
+
maps 0–99 to 1900–1999; the literal year is forced back on).
|
|
23
|
+
- `DateField` / `dateInputProps` preserve the existing value's time-of-day,
|
|
24
|
+
so re-picking the same day on a timestamped value is no longer spuriously
|
|
25
|
+
dirty (and changing the day keeps the time).
|
|
26
|
+
- `parsePath`'s cached segment array is frozen — it is shared across callers
|
|
27
|
+
now, so external mutation throws instead of corrupting the cache.
|
|
28
|
+
- `useVariantField` docs the write-gating contract (gate variant-field
|
|
29
|
+
writes on the discriminant, like rendering).
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- `useVariantField(form, unionPath, field)`: typed access to the
|
|
34
|
+
variant-specific fields of a `z.discriminatedUnion` — the ones
|
|
35
|
+
`FieldPath` omits because they exist in only some union members (the
|
|
36
|
+
discriminant stays a common key, bound with plain `useField`). Typed as
|
|
37
|
+
the field's value across the variants that declare it, widened with
|
|
38
|
+
`| undefined`; a non-variant field or the discriminant itself is a
|
|
39
|
+
compile error. `createFormHooks` gains a bound `use{Name}VariantField`.
|
|
40
|
+
- `persistForm(form, { key })`: the autosave recipe as a first-class
|
|
41
|
+
helper — debounced JSON drafts to any `{ getItem, setItem, removeItem }`
|
|
42
|
+
storage, draft auto-apply on start (`adopt` rebases clean, `restore`
|
|
43
|
+
loads dirty, `manual` waits for you), `clear()` that also cancels
|
|
44
|
+
pending writes, and guarded storage access throughout.
|
|
45
|
+
- Redux DevTools: `createForm(schema, { devtools: "checkout" })` connects
|
|
46
|
+
the form's store to the extension via zustand's middleware — every write
|
|
47
|
+
named, inspectable, and time-travelable. Off by default and inert
|
|
48
|
+
without the extension.
|
|
49
|
+
- Hot-path performance (the 2026-07 review's efficiency findings, no API
|
|
50
|
+
changes): `parsePath` is memoized (every field subscription re-parsed
|
|
51
|
+
its path per store notification); `validateFields` parses just the
|
|
52
|
+
requested subschemas when they're extractable — cross-field rules still
|
|
53
|
+
force the full parse, because extraction bails on refined levels — so a
|
|
54
|
+
wizard step stops paying for the whole schema per click; `diff()`/
|
|
55
|
+
`dirtyFields()` deep-compare each node once instead of once per ancestor
|
|
56
|
+
level; `focusFirstError` runs its focusability DOM walks only on
|
|
57
|
+
name-matched candidates instead of every named control in the document.
|
|
58
|
+
- Dates are a first-class field kind: `DateField` (an `<input type="date">`
|
|
59
|
+
bound to Date-typed paths) joins the shipped components, with
|
|
60
|
+
`dateInputProps` / `dateToInputText` / `parseDateText` exported for
|
|
61
|
+
custom markup and UI-kit adapters. Local calendar-date semantics
|
|
62
|
+
throughout (never `toISOString()` — June 1 stays June 1 west of UTC),
|
|
63
|
+
the `emptyValue` round-trip on clear, and rollover rejection
|
|
64
|
+
(`2026-02-31` is invalid, not March 3).
|
|
65
|
+
|
|
66
|
+
### CI (no package changes)
|
|
67
|
+
|
|
68
|
+
- Dependabot triage: esbuild pinned to the patched 0.28.1 line via npm
|
|
69
|
+
overrides in the root and cli (dev-server advisories), scoped to spare
|
|
70
|
+
vitepress 1.x's nested vite-5 toolchain (esbuild 0.28 can't downlevel to
|
|
71
|
+
vite 5's browser targets). The remaining vitepress-nested alerts are
|
|
72
|
+
dismissed as tolerable risk — local docs dev/build only — with the
|
|
73
|
+
VitePress 2 migration tracked on the roadmap as the real fix.
|
|
74
|
+
- Workflows declare least-privilege GITHUB_TOKEN permissions (CodeQL:
|
|
75
|
+
actions/missing-workflow-permissions).
|
|
76
|
+
|
|
77
|
+
### Docs & examples (no package changes)
|
|
78
|
+
|
|
79
|
+
- The Schema builder gains a **Paste a TypeScript type** mode: paste an
|
|
80
|
+
interface or type alias and the same in-browser emitters generate the
|
|
81
|
+
form — no TypeScript compiler in the bundle, a focused ~250-line parser
|
|
82
|
+
covers the CLI's type-mode subset (string/number/boolean/Date, arrays,
|
|
83
|
+
nested objects, string-literal unions, optional/nullable) and degrades
|
|
84
|
+
the rest to a text field with a TODO. Both input modes share one emit
|
|
85
|
+
path.
|
|
86
|
+
- ROADMAP.md: a living plan for the library and the CLI — the 0.9/cli-0.6
|
|
87
|
+
cycle (dates as a first-class field kind, hot-path performance,
|
|
88
|
+
DevTools, CLI config + watch), the next tier, the parking lot, and the
|
|
89
|
+
review's deferred cleanup items.
|
|
90
|
+
- The theme is one preference across the docs site and the playground:
|
|
91
|
+
the playground stores its light/dark choice under VitePress's own
|
|
92
|
+
localStorage key (same origin), so flipping either surface's toggle
|
|
93
|
+
follows you to the other.
|
|
94
|
+
- The demo body caps at a readable 760px measure; legitimately wide demos
|
|
95
|
+
(Perf's grid, the Schema builder, the Invoice table) opt out via an
|
|
96
|
+
explicit WIDE_DEMOS set.
|
|
97
|
+
- The demo card gets a real header: group eyebrow, title, a one-line
|
|
98
|
+
description of what each demo shows (exhaustive over the tab keys, like
|
|
99
|
+
the group map), and the View state / View code toggles plus a copy-link
|
|
100
|
+
button as a right-aligned action cluster — shell chrome separated from
|
|
101
|
+
demo content by a hairline. The nav groups show demo counts, and the
|
|
102
|
+
mobile bottom sheet gains its own Close button (the header toggles sit
|
|
103
|
+
behind it).
|
|
104
|
+
|
|
105
|
+
- Mobile playground shell: a top app bar (menu / title / controls), the
|
|
106
|
+
demo list as an off-canvas drawer that closes on pick, and the View
|
|
107
|
+
state / View code panel as a bottom sheet — all CSS-driven, desktop
|
|
108
|
+
layout unchanged.
|
|
109
|
+
- Light theme: a toggle in the app bar (and sidebar on desktop), defaulting
|
|
110
|
+
to the OS preference and persisted per browser. One variable palette in
|
|
111
|
+
styles.css drives the shell; the MUI bridge and the shadcn scope follow
|
|
112
|
+
the same html[data-theme] switch. Terminal/code surfaces deliberately
|
|
113
|
+
stay dark in both themes.
|
|
114
|
+
- The GitHub link in the shell shows the repo's star count (best-effort,
|
|
115
|
+
cached per session, skipped under tests).
|
|
116
|
+
|
|
117
|
+
## formstand-cli 0.6.0 — 2026-07-10
|
|
118
|
+
|
|
119
|
+
- Discriminated unions (`z.discriminatedUnion`) at a field position: the
|
|
120
|
+
generator emits a discriminant select plus per-variant conditional
|
|
121
|
+
blocks, binding variant fields through the library's `useVariantField`
|
|
122
|
+
(single-file) or bound `use{Name}VariantField` (module layout) — fully
|
|
123
|
+
typed, no casts. All six emit paths (plain/mui/shadcn × single-file/
|
|
124
|
+
module) typecheck against the real helper. Initial values pick the
|
|
125
|
+
first variant; the schema round-trips. Nested-in-section unions,
|
|
126
|
+
unions in array rows, and non-scalar variant fields keep a TODO.
|
|
127
|
+
- Nested-array row extraction (`--layout module`): an array inside a
|
|
128
|
+
section object, or an array inside an array row, now generates its own
|
|
129
|
+
Row/Rows component pair with a bound `useFieldArray` on the template
|
|
130
|
+
path (two numeric holes for arrays-in-rows,
|
|
131
|
+
`contacts.${parentIndex}.phones.${index}.number`) instead of a TODO
|
|
132
|
+
comment. One extraction level per array — deeper nesting keeps a TODO.
|
|
133
|
+
- `formstand.config.ts` (also `.mts`/`.js`/`.mjs`, or `--config <file>`):
|
|
134
|
+
project defaults for `ui`/`layout`/`sections`/`columns`, written with the
|
|
135
|
+
typed `defineConfig` export. Precedence is flags > config > built-ins,
|
|
136
|
+
and config typos fail as loudly as flag typos.
|
|
137
|
+
- `--watch`: regenerate whenever the input file changes (requires
|
|
138
|
+
`--out`; reruns overwrite their own output). Watches the parent
|
|
139
|
+
directory so editors that save via rename don't kill the watcher.
|
|
140
|
+
- A kind × optional × nullable matrix test pins `emitInitialValues` and
|
|
141
|
+
`blankNeedsCast` agreement in both directions: the no-cast combos must
|
|
142
|
+
typecheck with the checked annotation, and every cast combo is proven
|
|
143
|
+
to genuinely need it.
|
|
144
|
+
- Date fields emit real bindings instead of a text input with a cast and
|
|
145
|
+
a TODO: `DateField` (plain), `muiDateFieldProps` (MUI `type="date"`
|
|
146
|
+
TextField with a floated label), `shadcnDateInputProps` (shadcn Input).
|
|
147
|
+
`DATE_CAST` and the per-backend date TODOs are gone from generated
|
|
148
|
+
output — forms with dates submit successfully out of the box. Requires
|
|
149
|
+
formstand ≥ 0.9 for date fields.
|
|
150
|
+
|
|
5
151
|
## 0.8.0 — 2026-07-10
|
|
6
152
|
|
|
7
153
|
#### Fixed (2026-07 full-repo review)
|
|
@@ -105,6 +251,14 @@
|
|
|
105
251
|
The benchmark writes `field0` instead of the mid-grid field, so the
|
|
106
252
|
change is visible without scrolling.
|
|
107
253
|
|
|
254
|
+
## formstand-cli 0.5.3 — 2026-07-10
|
|
255
|
+
|
|
256
|
+
### Fixed
|
|
257
|
+
|
|
258
|
+
- Emitted string literals escape U+2028/U+2029 (JSON.stringify leaves the
|
|
259
|
+
JS line separators raw — a syntax error for pre-ES2019 parsers of the
|
|
260
|
+
GENERATED file, and CodeQL's js/bad-code-sanitization).
|
|
261
|
+
|
|
108
262
|
## formstand-cli 0.5.2 — 2026-07-10
|
|
109
263
|
|
|
110
264
|
#### Fixed (2026-07 full-repo review)
|
package/README.md
CHANGED
|
@@ -429,7 +429,8 @@ form.handleSubmit(async (data) => {
|
|
|
429
429
|
|
|
430
430
|
See [CONTRIBUTING.md](./CONTRIBUTING.md) — note the repo has three
|
|
431
431
|
independent npm roots (`.`, `examples/`, `cli/`), and the root test suite
|
|
432
|
-
needs `examples/` installed.
|
|
432
|
+
needs `examples/` installed. Where the project is headed lives in
|
|
433
|
+
[ROADMAP.md](./ROADMAP.md).
|
|
433
434
|
|
|
434
435
|
## License
|
|
435
436
|
|
package/dist/index.cjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
var vanilla = require('zustand/vanilla');
|
|
4
|
+
var middleware = require('zustand/middleware');
|
|
4
5
|
var zod = require('zod');
|
|
5
6
|
var react = require('react');
|
|
6
7
|
var jsxRuntime = require('react/jsx-runtime');
|
|
@@ -87,7 +88,18 @@ var isIndexSegment = (s) => {
|
|
|
87
88
|
const n = Number(s);
|
|
88
89
|
return Number.isInteger(n) && n >= 0 && String(n) === s;
|
|
89
90
|
};
|
|
90
|
-
var
|
|
91
|
+
var PARSE_CACHE_MAX = 4096;
|
|
92
|
+
var parseCache = /* @__PURE__ */ new Map();
|
|
93
|
+
var parsePath = (path) => {
|
|
94
|
+
const cached = parseCache.get(path);
|
|
95
|
+
if (cached !== void 0) return cached;
|
|
96
|
+
const segments = Object.freeze(
|
|
97
|
+
path === "" ? [] : path.split(".").map((s) => isIndexSegment(s) ? Number(s) : s)
|
|
98
|
+
);
|
|
99
|
+
if (parseCache.size >= PARSE_CACHE_MAX) parseCache.clear();
|
|
100
|
+
parseCache.set(path, segments);
|
|
101
|
+
return segments;
|
|
102
|
+
};
|
|
91
103
|
var isPlainRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
92
104
|
var readSegment = (obj, segment) => {
|
|
93
105
|
if (obj == null) return void 0;
|
|
@@ -100,8 +112,9 @@ var getAtPath = (obj, path) => parsePath(path).reduce(readSegment, obj);
|
|
|
100
112
|
var slotAtPath = (obj, path) => {
|
|
101
113
|
const walked = parsePath(path).reduce(
|
|
102
114
|
(acc, segment) => {
|
|
103
|
-
const
|
|
104
|
-
|
|
115
|
+
const container = acc.current;
|
|
116
|
+
const addressable = typeof segment === "number" ? Array.isArray(container) ? segment < container.length : isPlainRecord(container) : isPlainRecord(container);
|
|
117
|
+
return !acc.ok || !addressable ? { ok: false, current: void 0 } : { ok: true, current: readSegment(container, segment) };
|
|
105
118
|
},
|
|
106
119
|
{ ok: true, current: obj }
|
|
107
120
|
);
|
|
@@ -265,7 +278,7 @@ var releaseAncestorEntries = (map, path) => {
|
|
|
265
278
|
return kept.length === entries.length ? map : Object.fromEntries(kept);
|
|
266
279
|
};
|
|
267
280
|
var dirtyPathsOf = (values, initial, prefix) => {
|
|
268
|
-
if (
|
|
281
|
+
if (Object.is(values, initial)) return [];
|
|
269
282
|
if (isPlainObject(values) && isPlainObject(initial)) {
|
|
270
283
|
const keys = /* @__PURE__ */ new Set([...Object.keys(values), ...Object.keys(initial)]);
|
|
271
284
|
const childPaths = [...keys].flatMap(
|
|
@@ -275,9 +288,11 @@ var dirtyPathsOf = (values, initial, prefix) => {
|
|
|
275
288
|
prefix === "" ? k : `${prefix}.${k}`
|
|
276
289
|
)
|
|
277
290
|
);
|
|
278
|
-
|
|
291
|
+
if (childPaths.length > 0) return childPaths;
|
|
292
|
+
const sameKeySet = Object.keys(values).length === Object.keys(initial).length && Object.keys(values).every((k) => Object.hasOwn(initial, k));
|
|
293
|
+
return sameKeySet ? [] : [prefix];
|
|
279
294
|
}
|
|
280
|
-
return [prefix];
|
|
295
|
+
return valuesEqual(values, initial) ? [] : [prefix];
|
|
281
296
|
};
|
|
282
297
|
var FORM_SEQ_KEY = "__form__";
|
|
283
298
|
var SUBMIT_SEQ_KEY = "__submit__";
|
|
@@ -339,7 +354,18 @@ var createForm = (schema, options) => {
|
|
|
339
354
|
mode: initialMode,
|
|
340
355
|
reValidateMode: initialReValidateMode
|
|
341
356
|
};
|
|
342
|
-
const store = vanilla.createStore(() => initial)
|
|
357
|
+
const store = options.devtools === void 0 || options.devtools === false ? vanilla.createStore(() => initial) : vanilla.createStore()(
|
|
358
|
+
middleware.devtools(() => initial, {
|
|
359
|
+
name: typeof options.devtools === "string" ? options.devtools : "formstand",
|
|
360
|
+
// Non-production only, matching zustand's own devtools default:
|
|
361
|
+
// opting in during development must NOT stream every keystroke to
|
|
362
|
+
// an end user who happens to have the extension installed, where
|
|
363
|
+
// a module-singleton form's unbounded time-travel history is a
|
|
364
|
+
// real memory + serialization cost. Guarded for environments
|
|
365
|
+
// without process (a bundler defines NODE_ENV in real dev).
|
|
366
|
+
enabled: typeof process !== "undefined" && process.env?.["NODE_ENV"] !== "production"
|
|
367
|
+
})
|
|
368
|
+
);
|
|
343
369
|
const commitFullPass = (state, next) => errorChannels(state, {
|
|
344
370
|
schemaErrors: reuseErrorRefs(next, state.schemaErrors)
|
|
345
371
|
});
|
|
@@ -368,7 +394,38 @@ var createForm = (schema, options) => {
|
|
|
368
394
|
return result.kind === "pending" ? result.promise.then((r) => r.kind === "valid") : result.kind === "valid";
|
|
369
395
|
}
|
|
370
396
|
try {
|
|
371
|
-
const
|
|
397
|
+
const values = store.getState().values;
|
|
398
|
+
const scopes = paths.map((path) => ({
|
|
399
|
+
path,
|
|
400
|
+
scope: fieldScopeFor(path, values)
|
|
401
|
+
}));
|
|
402
|
+
if (scopes.every(
|
|
403
|
+
({ scope }) => scope.kind === "skip" || scope.kind === "parse" && scope.viaSubschema
|
|
404
|
+
)) {
|
|
405
|
+
const merged = scopes.reduce(
|
|
406
|
+
(acc, { path, scope }) => scope.kind === "skip" ? acc : {
|
|
407
|
+
...acc,
|
|
408
|
+
...scopeSettledResult(
|
|
409
|
+
validateSync(scope.schema, scope.value),
|
|
410
|
+
path,
|
|
411
|
+
true
|
|
412
|
+
)
|
|
413
|
+
},
|
|
414
|
+
{}
|
|
415
|
+
);
|
|
416
|
+
store.setState((state) => ({
|
|
417
|
+
...state,
|
|
418
|
+
...errorChannels(state, {
|
|
419
|
+
schemaErrors: reuseErrorRefs(
|
|
420
|
+
mergeScopedErrors(state.schemaErrors, merged, paths),
|
|
421
|
+
state.schemaErrors
|
|
422
|
+
),
|
|
423
|
+
serverErrors: omitScope(state.serverErrors, paths)
|
|
424
|
+
})
|
|
425
|
+
}));
|
|
426
|
+
return Object.values(merged).flat().length === 0;
|
|
427
|
+
}
|
|
428
|
+
const result = validateSync(schema, values);
|
|
372
429
|
const fullErrors = result.kind === "invalid" ? result.errors : emptyErrors;
|
|
373
430
|
store.setState((state) => ({
|
|
374
431
|
...state,
|
|
@@ -968,6 +1025,74 @@ var shouldValidateOn = (trigger, mode, reValidateMode, submitAttempted, touched
|
|
|
968
1025
|
return true;
|
|
969
1026
|
}
|
|
970
1027
|
};
|
|
1028
|
+
|
|
1029
|
+
// src/core/persist.ts
|
|
1030
|
+
var defaultStorage = () => {
|
|
1031
|
+
try {
|
|
1032
|
+
return typeof globalThis.localStorage === "undefined" ? null : globalThis.localStorage;
|
|
1033
|
+
} catch {
|
|
1034
|
+
return null;
|
|
1035
|
+
}
|
|
1036
|
+
};
|
|
1037
|
+
var persistForm = (form, options) => {
|
|
1038
|
+
const storage = options.storage ?? defaultStorage();
|
|
1039
|
+
const debounceMs = options.debounceMs ?? 300;
|
|
1040
|
+
const apply = options.apply ?? "adopt";
|
|
1041
|
+
const applyDraft = (values) => apply === "restore" ? form.setValues(values) : form.adoptValues(values);
|
|
1042
|
+
const restore = () => {
|
|
1043
|
+
try {
|
|
1044
|
+
const raw = storage === null ? null : storage.getItem(options.key);
|
|
1045
|
+
if (raw === null) return false;
|
|
1046
|
+
applyDraft(JSON.parse(raw));
|
|
1047
|
+
return true;
|
|
1048
|
+
} catch {
|
|
1049
|
+
return false;
|
|
1050
|
+
}
|
|
1051
|
+
};
|
|
1052
|
+
const writeDraft = (values) => {
|
|
1053
|
+
try {
|
|
1054
|
+
storage?.setItem(options.key, JSON.stringify(values));
|
|
1055
|
+
} catch {
|
|
1056
|
+
}
|
|
1057
|
+
};
|
|
1058
|
+
const timerRef = {
|
|
1059
|
+
current: null
|
|
1060
|
+
};
|
|
1061
|
+
const cancelPending = () => {
|
|
1062
|
+
if (timerRef.current !== null) {
|
|
1063
|
+
clearTimeout(timerRef.current);
|
|
1064
|
+
timerRef.current = null;
|
|
1065
|
+
}
|
|
1066
|
+
};
|
|
1067
|
+
if (apply !== "manual") {
|
|
1068
|
+
restore();
|
|
1069
|
+
}
|
|
1070
|
+
const unsubscribe = form.watchValues((values) => {
|
|
1071
|
+
cancelPending();
|
|
1072
|
+
if (debounceMs === 0) {
|
|
1073
|
+
writeDraft(values);
|
|
1074
|
+
} else {
|
|
1075
|
+
timerRef.current = setTimeout(() => {
|
|
1076
|
+
timerRef.current = null;
|
|
1077
|
+
writeDraft(values);
|
|
1078
|
+
}, debounceMs);
|
|
1079
|
+
}
|
|
1080
|
+
});
|
|
1081
|
+
return Object.freeze({
|
|
1082
|
+
restore,
|
|
1083
|
+
clear: () => {
|
|
1084
|
+
cancelPending();
|
|
1085
|
+
try {
|
|
1086
|
+
storage?.removeItem(options.key);
|
|
1087
|
+
} catch {
|
|
1088
|
+
}
|
|
1089
|
+
},
|
|
1090
|
+
dispose: () => {
|
|
1091
|
+
cancelPending();
|
|
1092
|
+
unsubscribe();
|
|
1093
|
+
}
|
|
1094
|
+
});
|
|
1095
|
+
};
|
|
971
1096
|
var useForm = (schema, options) => {
|
|
972
1097
|
const formRef = react.useRef(null);
|
|
973
1098
|
if (formRef.current === null) {
|
|
@@ -1130,6 +1255,14 @@ function useField(form, pathArg, options) {
|
|
|
1130
1255
|
[slice, emptyValue, setValue, setTouched, setError, clearError, validate, validateAsync2, onBlur]
|
|
1131
1256
|
);
|
|
1132
1257
|
}
|
|
1258
|
+
|
|
1259
|
+
// src/react/useVariantField.ts
|
|
1260
|
+
function useVariantField(form, unionPath, field) {
|
|
1261
|
+
return useField(
|
|
1262
|
+
form,
|
|
1263
|
+
`${unionPath}.${field}`
|
|
1264
|
+
);
|
|
1265
|
+
}
|
|
1133
1266
|
var EMPTY_ID_STATE = { items: [], ids: [], counter: 0 };
|
|
1134
1267
|
var EMPTY_ITEMS = [];
|
|
1135
1268
|
var reconcileIds = (prev, nextItems) => {
|
|
@@ -1271,6 +1404,11 @@ var capitalize = (name) => name.length === 0 ? name : name.charAt(0).toUpperCase
|
|
|
1271
1404
|
var createFormHooks = (form, name) => {
|
|
1272
1405
|
const structural = form;
|
|
1273
1406
|
const useBoundField = (path, options) => useField(structural, path, options);
|
|
1407
|
+
const useBoundVariantField = (unionPath, field) => useVariantField(
|
|
1408
|
+
structural,
|
|
1409
|
+
unionPath,
|
|
1410
|
+
field
|
|
1411
|
+
);
|
|
1274
1412
|
const useBoundFieldArray = (path) => useFieldArray(structural, path);
|
|
1275
1413
|
const useBoundSelector = (selector) => useFormSelector(structural, selector);
|
|
1276
1414
|
const useBoundSelectorShallow = (selector) => useFormSelectorShallow(structural, selector);
|
|
@@ -1282,6 +1420,7 @@ var createFormHooks = (form, name) => {
|
|
|
1282
1420
|
const prefix = capitalize(name ?? "");
|
|
1283
1421
|
return {
|
|
1284
1422
|
[`use${prefix}Field`]: useBoundField,
|
|
1423
|
+
[`use${prefix}VariantField`]: useBoundVariantField,
|
|
1285
1424
|
[`use${prefix}FieldArray`]: useBoundFieldArray,
|
|
1286
1425
|
[`use${prefix}Selector`]: useBoundSelector,
|
|
1287
1426
|
[`use${prefix}SelectorShallow`]: useBoundSelectorShallow,
|
|
@@ -1326,6 +1465,48 @@ var numberInputProps = (field) => ({
|
|
|
1326
1465
|
},
|
|
1327
1466
|
onBlur: field.onBlur
|
|
1328
1467
|
});
|
|
1468
|
+
var dateToInputText = (value) => {
|
|
1469
|
+
if (value === void 0 || value === null || Number.isNaN(value.getTime())) {
|
|
1470
|
+
return "";
|
|
1471
|
+
}
|
|
1472
|
+
const pad = (part) => String(part).padStart(2, "0");
|
|
1473
|
+
return `${String(value.getFullYear()).padStart(4, "0")}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`;
|
|
1474
|
+
};
|
|
1475
|
+
var parseDateText = (text) => {
|
|
1476
|
+
if (text.trim() === "") return { kind: "empty" };
|
|
1477
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
|
|
1478
|
+
if (match === null) return { kind: "invalid" };
|
|
1479
|
+
const [, year, month, day] = match;
|
|
1480
|
+
const y = Number(year);
|
|
1481
|
+
const date = new Date(y, Number(month) - 1, Number(day));
|
|
1482
|
+
if (y < 100) date.setFullYear(y);
|
|
1483
|
+
return date.getFullYear() === y && date.getMonth() === Number(month) - 1 && date.getDate() === Number(day) ? { kind: "date", value: date } : { kind: "invalid" };
|
|
1484
|
+
};
|
|
1485
|
+
var dateInputProps = (field) => ({
|
|
1486
|
+
type: "date",
|
|
1487
|
+
name: field.path,
|
|
1488
|
+
value: dateToInputText(field.value),
|
|
1489
|
+
"aria-invalid": ariaInvalid(field),
|
|
1490
|
+
onChange: (e) => {
|
|
1491
|
+
const parsed = parseDateText(e.target.value);
|
|
1492
|
+
if (parsed.kind !== "date") {
|
|
1493
|
+
field.setValue(field.emptyValue);
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
1496
|
+
const current = field.value;
|
|
1497
|
+
const next = current instanceof Date && !Number.isNaN(current.getTime()) ? new Date(
|
|
1498
|
+
parsed.value.getFullYear(),
|
|
1499
|
+
parsed.value.getMonth(),
|
|
1500
|
+
parsed.value.getDate(),
|
|
1501
|
+
current.getHours(),
|
|
1502
|
+
current.getMinutes(),
|
|
1503
|
+
current.getSeconds(),
|
|
1504
|
+
current.getMilliseconds()
|
|
1505
|
+
) : parsed.value;
|
|
1506
|
+
field.setValue(next);
|
|
1507
|
+
},
|
|
1508
|
+
onBlur: field.onBlur
|
|
1509
|
+
});
|
|
1329
1510
|
var checkboxProps = (field) => ({
|
|
1330
1511
|
type: "checkbox",
|
|
1331
1512
|
name: field.path,
|
|
@@ -1380,6 +1561,33 @@ var TextField = ({
|
|
|
1380
1561
|
/* @__PURE__ */ jsxRuntime.jsx(ErrorText, { id: errorId, error: field.error })
|
|
1381
1562
|
] });
|
|
1382
1563
|
};
|
|
1564
|
+
var DateField = ({
|
|
1565
|
+
form,
|
|
1566
|
+
path,
|
|
1567
|
+
label,
|
|
1568
|
+
min,
|
|
1569
|
+
max,
|
|
1570
|
+
ref
|
|
1571
|
+
}) => {
|
|
1572
|
+
const id = react.useId();
|
|
1573
|
+
const errorId = `${id}-error`;
|
|
1574
|
+
const field = useField(form, path);
|
|
1575
|
+
return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "zf-field", children: [
|
|
1576
|
+
label !== void 0 ? /* @__PURE__ */ jsxRuntime.jsx("label", { htmlFor: id, className: "zf-label", children: label }) : null,
|
|
1577
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
1578
|
+
"input",
|
|
1579
|
+
{
|
|
1580
|
+
id,
|
|
1581
|
+
ref,
|
|
1582
|
+
...dateInputProps(field),
|
|
1583
|
+
min,
|
|
1584
|
+
max,
|
|
1585
|
+
"aria-describedby": hasError(field.error) ? errorId : void 0
|
|
1586
|
+
}
|
|
1587
|
+
),
|
|
1588
|
+
/* @__PURE__ */ jsxRuntime.jsx(ErrorText, { id: errorId, error: field.error })
|
|
1589
|
+
] });
|
|
1590
|
+
};
|
|
1383
1591
|
var IDLE_EDIT = { raw: null, pushed: void 0 };
|
|
1384
1592
|
var useNumberInput = (field) => {
|
|
1385
1593
|
const [edit, setEdit] = react.useState(IDLE_EDIT);
|
|
@@ -1509,13 +1717,12 @@ var SelectField = ({
|
|
|
1509
1717
|
};
|
|
1510
1718
|
|
|
1511
1719
|
// src/react/focusError.ts
|
|
1512
|
-
var
|
|
1720
|
+
var namedControls = (scope) => [
|
|
1513
1721
|
...scope.querySelectorAll(
|
|
1514
1722
|
"input[name], select[name], textarea[name]"
|
|
1515
1723
|
)
|
|
1516
|
-
]
|
|
1517
|
-
|
|
1518
|
-
);
|
|
1724
|
+
];
|
|
1725
|
+
var isFocusCandidate = (el) => !el.matches(':disabled, [type="hidden"]') && el.closest("dialog:not([open])") === null;
|
|
1519
1726
|
var holdsFocus = (el) => {
|
|
1520
1727
|
const rootNode = el.getRootNode();
|
|
1521
1728
|
const active = rootNode instanceof Document || rootNode instanceof ShadowRoot ? rootNode.activeElement : document.activeElement;
|
|
@@ -1535,20 +1742,21 @@ var focusFirstError = (errors, root) => {
|
|
|
1535
1742
|
(k) => k !== "" && (errors[k]?.length ?? 0) > 0
|
|
1536
1743
|
);
|
|
1537
1744
|
const hasRootError = (errors[""]?.length ?? 0) > 0;
|
|
1538
|
-
const controls =
|
|
1539
|
-
const fieldMatches = controls.filter(
|
|
1540
|
-
(el) => nameMatchesAny(el, erroredPaths)
|
|
1541
|
-
);
|
|
1745
|
+
const controls = namedControls(scope);
|
|
1746
|
+
const fieldMatches = controls.filter((el) => nameMatchesAny(el, erroredPaths)).filter(isFocusCandidate);
|
|
1542
1747
|
const rootFallbackApplies = hasRootError && (root !== void 0 || document.querySelectorAll("form").length <= 1);
|
|
1543
|
-
const candidates = fieldMatches.length > 0 ? fieldMatches : rootFallbackApplies ? controls : [];
|
|
1748
|
+
const candidates = fieldMatches.length > 0 ? fieldMatches : rootFallbackApplies ? controls.filter(isFocusCandidate) : [];
|
|
1544
1749
|
return focusFirstOf(candidates);
|
|
1545
1750
|
};
|
|
1546
1751
|
var focusField = (path, root) => {
|
|
1547
|
-
const controls =
|
|
1548
|
-
return path === "" ? (root !== void 0 || document.querySelectorAll("form").length <= 1) && focusFirstOf(controls
|
|
1752
|
+
const controls = namedControls(root ?? document);
|
|
1753
|
+
return path === "" ? (root !== void 0 || document.querySelectorAll("form").length <= 1) && focusFirstOf(controls.filter(isFocusCandidate)) : focusFirstOf(
|
|
1754
|
+
controls.filter((el) => nameMatchesAny(el, [path])).filter(isFocusCandidate)
|
|
1755
|
+
);
|
|
1549
1756
|
};
|
|
1550
1757
|
|
|
1551
1758
|
exports.CheckboxField = CheckboxField;
|
|
1759
|
+
exports.DateField = DateField;
|
|
1552
1760
|
exports.NumberField = NumberField;
|
|
1553
1761
|
exports.SelectField = SelectField;
|
|
1554
1762
|
exports.TextField = TextField;
|
|
@@ -1556,6 +1764,8 @@ exports.checkboxProps = checkboxProps;
|
|
|
1556
1764
|
exports.createForm = createForm;
|
|
1557
1765
|
exports.createFormContext = createFormContext;
|
|
1558
1766
|
exports.createFormHooks = createFormHooks;
|
|
1767
|
+
exports.dateInputProps = dateInputProps;
|
|
1768
|
+
exports.dateToInputText = dateToInputText;
|
|
1559
1769
|
exports.emptyValueForSchema = emptyValueForSchema;
|
|
1560
1770
|
exports.flattenIssues = flattenIssues;
|
|
1561
1771
|
exports.focusField = focusField;
|
|
@@ -1564,8 +1774,10 @@ exports.getAtPath = getAtPath;
|
|
|
1564
1774
|
exports.isAsyncRequiredError = isAsyncRequiredError;
|
|
1565
1775
|
exports.numberInputProps = numberInputProps;
|
|
1566
1776
|
exports.numberToInputText = numberToInputText;
|
|
1777
|
+
exports.parseDateText = parseDateText;
|
|
1567
1778
|
exports.parseNumberText = parseNumberText;
|
|
1568
1779
|
exports.parsePath = parsePath;
|
|
1780
|
+
exports.persistForm = persistForm;
|
|
1569
1781
|
exports.selectProps = selectProps;
|
|
1570
1782
|
exports.setAtPath = setAtPath;
|
|
1571
1783
|
exports.shouldValidateOn = shouldValidateOn;
|
|
@@ -1580,6 +1792,7 @@ exports.useIsDirty = useIsDirty;
|
|
|
1580
1792
|
exports.useIsSubmitting = useIsSubmitting;
|
|
1581
1793
|
exports.useIsValid = useIsValid;
|
|
1582
1794
|
exports.useSubmitCount = useSubmitCount;
|
|
1795
|
+
exports.useVariantField = useVariantField;
|
|
1583
1796
|
exports.validateAsync = validateAsync;
|
|
1584
1797
|
exports.validateSync = validateSync;
|
|
1585
1798
|
//# sourceMappingURL=index.cjs.map
|