@thomasfosterau/effect-forms-svelte 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +115 -0
- package/dist/ClientValidate.d.ts +18 -0
- package/dist/ClientValidate.js +37 -0
- package/dist/ClientValidate.js.map +1 -0
- package/dist/EnhanceCore.d.ts +61 -0
- package/dist/EnhanceCore.js +123 -0
- package/dist/EnhanceCore.js.map +1 -0
- package/dist/EntityForm.d.ts +158 -0
- package/dist/EntityForm.js +103 -0
- package/dist/EntityForm.js.map +1 -0
- package/dist/FormStore.svelte.d.ts +381 -0
- package/dist/FormStore.svelte.js +986 -0
- package/dist/FormStore.svelte.js.map +1 -0
- package/dist/Listeners.d.ts +57 -0
- package/dist/Listeners.js +38 -0
- package/dist/Listeners.js.map +1 -0
- package/dist/SvelteKit.d.ts +22 -0
- package/dist/SvelteKit.js +18 -0
- package/dist/SvelteKit.js.map +1 -0
- package/dist/Tainted.d.ts +32 -0
- package/dist/Tainted.js +81 -0
- package/dist/Tainted.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/package.json +59 -0
|
@@ -0,0 +1,986 @@
|
|
|
1
|
+
import { validateData, validateField } from "./ClientValidate.js";
|
|
2
|
+
import { computeDataApplication, objectToFormData, parseActionResult, resultToUpdate } from "./EnhanceCore.js";
|
|
3
|
+
import { computeTainted, isAnyTainted, isTaintedAt, taintedLeafPaths } from "./Tainted.js";
|
|
4
|
+
import { dispatchChange } from "./Listeners.js";
|
|
5
|
+
import { Constraints, Errors, Field, Form, FormBuilder, Mode, Path, Validate } from "@thomasfosterau/effect-forms";
|
|
6
|
+
import * as Duration from "effect/Duration";
|
|
7
|
+
import * as Effect from "effect/Effect";
|
|
8
|
+
import * as Fiber from "effect/Fiber";
|
|
9
|
+
import * as Layer from "effect/Layer";
|
|
10
|
+
import * as ManagedRuntime from "effect/ManagedRuntime";
|
|
11
|
+
import { createAttachmentKey } from "svelte/attachments";
|
|
12
|
+
//#region src/FormStore.svelte.ts
|
|
13
|
+
/**
|
|
14
|
+
* @copyright Thomas Foster
|
|
15
|
+
*
|
|
16
|
+
* The reactive Svelte 5 form store.
|
|
17
|
+
*
|
|
18
|
+
* {@link createForm} binds a schema to a reactive, rune-backed {@link FormStore}.
|
|
19
|
+
* Give it either `{ schema, defaults }` (it wraps the core's `makeForm` to seed
|
|
20
|
+
* an empty form) or a server-produced {@link FormState} to hydrate from. The
|
|
21
|
+
* store exposes mutable `data` (bind inputs to it), typed `errors`
|
|
22
|
+
* (`form.errors.name`), `submitting` / `delayed` / `message` / `valid`, tainted
|
|
23
|
+
* tracking, and a spreadable {@link FormStore.props} for `<form {...form.props}>`
|
|
24
|
+
* that wires progressive-enhancement submission (a Svelte attachment).
|
|
25
|
+
*
|
|
26
|
+
* Submission is handled one of two ways: an `onSubmit` handler the page
|
|
27
|
+
* provides (full client-side / JS handling, with `cancel()` and an optional
|
|
28
|
+
* returned result), or — by default — a POST to the form's action (e.g. a
|
|
29
|
+
* derived SvelteKit action) whose result is applied back to the store.
|
|
30
|
+
*
|
|
31
|
+
* ## Validation engine v2
|
|
32
|
+
*
|
|
33
|
+
* `errors` merges two parallel trees (see {@link Errors.SourcedErrors}):
|
|
34
|
+
* `server` (the last submission's response) and `client` (browser-side
|
|
35
|
+
* validation — the schema, per-field validators, a builder's `refineEffect`
|
|
36
|
+
* refinements). A field's value changing clears *only that field's* server
|
|
37
|
+
* error — a `State.withError` uniqueness failure the user hasn't touched
|
|
38
|
+
* survives a client revalidation of some other field. `clientErrors` /
|
|
39
|
+
* `serverErrors` expose the per-source trees for a UI that wants to style
|
|
40
|
+
* them differently.
|
|
41
|
+
*
|
|
42
|
+
* `validators` (keyed by {@link Path.toString}) attach per-field sync/async
|
|
43
|
+
* validators (the `FieldValidators` shape — TanStack Form's `onChange(Async)`
|
|
44
|
+
* / `onBlur(Async)` vocabulary). Async validators run as Effect fibers: a new
|
|
45
|
+
* value interrupts the field's in-flight fiber before forking the next run —
|
|
46
|
+
* debounce + "latest wins" cancellation fall out of that alone (see
|
|
47
|
+
* {@link Validate.runAsyncFieldValidator}). `isValidating` /
|
|
48
|
+
* {@link FormStore.isValidatingAt} reflect any fiber still running; supply
|
|
49
|
+
* `layer` when a validator (or a builder's `refineEffect`) needs services.
|
|
50
|
+
*
|
|
51
|
+
* `Mode.RevalidateMode` (`options.mode.revalidate`, default `"onChange"`)
|
|
52
|
+
* governs what happens *after* a failed post in `onSubmit` mode: fixing a
|
|
53
|
+
* field re-validates it (clearing its error) without a resubmit.
|
|
54
|
+
*
|
|
55
|
+
* @module effect-forms-svelte/FormStore
|
|
56
|
+
*/
|
|
57
|
+
const { clearSourceAt, emptySourced, hasErrors, mergeSourced, noErrors, replaceAt, setSourceAt } = Errors;
|
|
58
|
+
const { getAt, setAt } = Path;
|
|
59
|
+
const makeForm = Form.make;
|
|
60
|
+
/**
|
|
61
|
+
* A typed, reactive handle to one field, returned by {@link FormStore.field}.
|
|
62
|
+
* Bundles everything a single input needs — `bind:value={field.value}`,
|
|
63
|
+
* `name={field.name}`, `onblur={field.handleBlur}`, `{...field.constraints}`,
|
|
64
|
+
* `field.visibleErrors`, `field.meta` — instead of wiring each by hand against
|
|
65
|
+
* an untyped {@link FormPath}. See {@link Path.FieldPath} for how the path is
|
|
66
|
+
* type-checked.
|
|
67
|
+
*/
|
|
68
|
+
var FieldHandle = class {
|
|
69
|
+
#store;
|
|
70
|
+
#path;
|
|
71
|
+
/** The path in flat field-name notation — use as the input's `name` attribute. */
|
|
72
|
+
name;
|
|
73
|
+
constructor(store, path) {
|
|
74
|
+
this.#store = store;
|
|
75
|
+
this.#path = path;
|
|
76
|
+
this.name = Path.toString(path);
|
|
77
|
+
}
|
|
78
|
+
/** The field's current value. Assignable — `bind:value={field.value}` works. */
|
|
79
|
+
get value() {
|
|
80
|
+
return this.#store.valueAt(this.#path);
|
|
81
|
+
}
|
|
82
|
+
set value(next) {
|
|
83
|
+
this.#store.setValue(this.#path, next);
|
|
84
|
+
}
|
|
85
|
+
/** All messages currently attached to this field. */
|
|
86
|
+
get errors() {
|
|
87
|
+
return this.#store.errorsAt(this.#path);
|
|
88
|
+
}
|
|
89
|
+
/** This field's messages, gated by the form's validation mode (see {@link FormStore.shouldShowError}). */
|
|
90
|
+
get visibleErrors() {
|
|
91
|
+
return this.#store.visibleErrorsAt(this.#path);
|
|
92
|
+
}
|
|
93
|
+
/** Native validation attributes for this field — spread onto the `<input>`. */
|
|
94
|
+
get constraints() {
|
|
95
|
+
return this.#store.constraintAt(this.#path);
|
|
96
|
+
}
|
|
97
|
+
/** This field's {@link FieldMeta}. */
|
|
98
|
+
get meta() {
|
|
99
|
+
const isBlurred = this.#store.isTouchedAt(this.#path);
|
|
100
|
+
const isDirty = this.#store.isDirtyAt(this.#path);
|
|
101
|
+
return {
|
|
102
|
+
isTouched: isBlurred || isDirty,
|
|
103
|
+
isBlurred,
|
|
104
|
+
isDirty,
|
|
105
|
+
isDefaultValue: this.#store.isDefaultValueAt(this.#path)
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/** Mark this field blurred — see {@link FormStore.handleBlur}. */
|
|
109
|
+
handleBlur = () => {
|
|
110
|
+
this.#store.handleBlur(this.#path);
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
const defaultSubmit = async (body, action) => {
|
|
114
|
+
const response = await fetch(action, {
|
|
115
|
+
method: "POST",
|
|
116
|
+
body,
|
|
117
|
+
headers: { accept: "application/json" }
|
|
118
|
+
});
|
|
119
|
+
let parsed;
|
|
120
|
+
try {
|
|
121
|
+
parsed = await response.json();
|
|
122
|
+
} catch {
|
|
123
|
+
return {
|
|
124
|
+
_tag: "Error",
|
|
125
|
+
status: response.status,
|
|
126
|
+
message: response.statusText || "Request failed"
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return parseActionResult(parsed, response.status);
|
|
130
|
+
};
|
|
131
|
+
/** Whether `index` is a valid position in an array of `length`. */
|
|
132
|
+
const inRange = (index, length) => index >= 0 && index < length;
|
|
133
|
+
/** A reactive form, returned by {@link createForm}. */
|
|
134
|
+
var FormStore = class {
|
|
135
|
+
/** The reactive form data — bind inputs to its fields. */
|
|
136
|
+
data = $state();
|
|
137
|
+
/** The current status message, if any. */
|
|
138
|
+
message = $state();
|
|
139
|
+
/** Native input constraints derived server-side. */
|
|
140
|
+
constraints = $state({});
|
|
141
|
+
/** Whether a submission is in flight. */
|
|
142
|
+
submitting = $state(false);
|
|
143
|
+
/** Whether the in-flight submission has passed `delayMs`. */
|
|
144
|
+
delayed = $state(false);
|
|
145
|
+
/** Whether the current state came from a submission. */
|
|
146
|
+
posted = $state(false);
|
|
147
|
+
/** Whether the last validation/submit was valid. */
|
|
148
|
+
valid = $state(false);
|
|
149
|
+
/** How many times {@link FormStore.enhance}'s submit handler has actually attempted a submission. */
|
|
150
|
+
submissionAttempts = $state(0);
|
|
151
|
+
/** The values sent by the last submission (for revert / changed-since-submit). */
|
|
152
|
+
#lastSubmitted = $state();
|
|
153
|
+
/**
|
|
154
|
+
* Per-field blurred flags, keyed by {@link Path.toString}. Drives `onBlur`
|
|
155
|
+
* display (via {@link shouldShowError}) and {@link FieldMeta.isBlurred}.
|
|
156
|
+
*/
|
|
157
|
+
#touched = $state({});
|
|
158
|
+
/**
|
|
159
|
+
* Per-field persistent dirty flags, keyed by {@link Path.toString}. Set once
|
|
160
|
+
* a field's value is changed away from its default via {@link setValue} or
|
|
161
|
+
* an array mutator (`append` / `removeItem` / `swap` / `move`), and never
|
|
162
|
+
* cleared except by {@link reset} — unlike {@link taintedAt}, a revert to the
|
|
163
|
+
* default value does not clear it. Backs {@link FieldMeta.isDirty}. Only
|
|
164
|
+
* tracks writes made through those methods — mutating `data` directly
|
|
165
|
+
* (`bind:value={form.data.x}`, bypassing {@link FormStore.field}) never marks
|
|
166
|
+
* a field dirty.
|
|
167
|
+
*/
|
|
168
|
+
#dirtied = $state({});
|
|
169
|
+
/** Parallel error trees — see {@link Errors.SourcedErrors}. `errors` is their merged view. */
|
|
170
|
+
#sourced = $state(emptySourced);
|
|
171
|
+
/** Whether *any* validation (whole-form or per-field) has run yet — see {@link canSubmit}. */
|
|
172
|
+
#hasValidated = $state(false);
|
|
173
|
+
/** Per-field / whole-form ("") async-validation-in-flight flags, keyed by `"<path>:<trigger>"` or `""`. */
|
|
174
|
+
#validating = $state({});
|
|
175
|
+
/** Per-key run counter guarding `#validating` writes — see {@link FormStore.#runValidatorFiber}. */
|
|
176
|
+
#validatingGeneration = {};
|
|
177
|
+
/** The outcome of the last completed submission, for {@link isSubmitSuccessful}. */
|
|
178
|
+
#lastSubmitOutcome = $state(void 0);
|
|
179
|
+
id;
|
|
180
|
+
/** The tainted baseline `tainted`/`isTainted` compare against — rebased on every successful submit. */
|
|
181
|
+
#initial;
|
|
182
|
+
#options;
|
|
183
|
+
#mode;
|
|
184
|
+
#attachmentKey = createAttachmentKey();
|
|
185
|
+
#runtime;
|
|
186
|
+
#autoSubmitNode;
|
|
187
|
+
#validateTimer;
|
|
188
|
+
/** Per-field-listener debounce timers, keyed by `"change:<path>"` / `"blur:<path>"`. */
|
|
189
|
+
#listenerTimers = /* @__PURE__ */ new Map();
|
|
190
|
+
/** Per-field validators, keyed by {@link Path.toString}; seeded from `options.validators`. */
|
|
191
|
+
#fieldValidators;
|
|
192
|
+
/** In-flight async-validator fibers, keyed by `"<path>:<trigger>"` (fields) or `""` (whole-form `refineEffect`). */
|
|
193
|
+
#fibers = {};
|
|
194
|
+
/** The error paths the last `refineEffect` run set, so the next run can clear exactly those before merging fresh ones. */
|
|
195
|
+
#lastRefinementPaths = [];
|
|
196
|
+
/** The data snapshot the field-tracking watcher last diffed against. */
|
|
197
|
+
#previousData;
|
|
198
|
+
/** The current nested error tree (server errors merged with client errors), typed to the form data's shape. */
|
|
199
|
+
errors = $derived.by(() => mergeSourced(this.#sourced));
|
|
200
|
+
/** Client-side-sourced errors only (schema, per-field validators, `refineEffect`). */
|
|
201
|
+
clientErrors = $derived.by(() => this.#sourced.client);
|
|
202
|
+
/** Server-sourced errors only (the last submission's response). */
|
|
203
|
+
serverErrors = $derived.by(() => this.#sourced.server);
|
|
204
|
+
/** Whether any async field validator or `refineEffect` run is currently in flight. */
|
|
205
|
+
isValidating = $derived(Object.values(this.#validating).some(Boolean));
|
|
206
|
+
/** Whether the form has ever been submitted (attempted). */
|
|
207
|
+
isSubmitted = $derived(this.submissionAttempts > 0);
|
|
208
|
+
/** Whether the most recently *completed* submission succeeded. `false` before any submit completes. */
|
|
209
|
+
isSubmitSuccessful = $derived(this.#lastSubmitOutcome === "success");
|
|
210
|
+
/**
|
|
211
|
+
* Whether the submit button should be enabled: not mid-submit, not
|
|
212
|
+
* mid-validation, and either nothing has been validated yet (a pristine
|
|
213
|
+
* form isn't presumptively invalid — the server gets the first look) or the
|
|
214
|
+
* last validation run said valid.
|
|
215
|
+
*/
|
|
216
|
+
canSubmit = $derived(!this.submitting && !this.isValidating && (!this.#hasValidated || this.valid));
|
|
217
|
+
/** The tainted-field tree, re-derived as `data` changes. */
|
|
218
|
+
tainted = $derived.by(() => computeTainted(this.#initial, this.data));
|
|
219
|
+
/** Whether any field has been changed since the form was seeded. */
|
|
220
|
+
isTainted = $derived(isAnyTainted(this.tainted));
|
|
221
|
+
/** The tree of fields changed since the last submission (empty before any submit). */
|
|
222
|
+
changedSinceSubmit = $derived.by(() => this.#lastSubmitted === void 0 ? {} : computeTainted(this.#lastSubmitted, this.data));
|
|
223
|
+
/** Whether anything has changed since the last submission. */
|
|
224
|
+
hasChangedSinceSubmit = $derived(isAnyTainted(this.changedSinceSubmit));
|
|
225
|
+
constructor(initial, options = {}) {
|
|
226
|
+
this.id = initial.id;
|
|
227
|
+
this.#initial = structuredClone(initial.data);
|
|
228
|
+
this.#previousData = structuredClone(initial.data);
|
|
229
|
+
this.#options = options;
|
|
230
|
+
this.#mode = Mode.parse(options.mode);
|
|
231
|
+
this.#runtime = ManagedRuntime.make(options.layer ?? Layer.empty);
|
|
232
|
+
this.#fieldValidators = { ...options.validators };
|
|
233
|
+
this.data = structuredClone(initial.data);
|
|
234
|
+
this.#sourced = {
|
|
235
|
+
client: noErrors,
|
|
236
|
+
server: initial.errors ?? noErrors
|
|
237
|
+
};
|
|
238
|
+
this.message = initial.message;
|
|
239
|
+
this.constraints = initial.constraints ?? (options.schema ? Constraints.constraintsFromSchema(options.schema) : {});
|
|
240
|
+
this.posted = initial.posted;
|
|
241
|
+
this.valid = initial.valid;
|
|
242
|
+
this.#hasValidated = initial.posted;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Spreadable attributes for the `<form>` element: `<form {...form.props}>`.
|
|
246
|
+
* Sets `method="post"` (and `action`, if configured) and attaches the
|
|
247
|
+
* progressive-enhancement submit handler via a Svelte attachment.
|
|
248
|
+
*/
|
|
249
|
+
get props() {
|
|
250
|
+
return {
|
|
251
|
+
method: "post",
|
|
252
|
+
...this.#options.action !== void 0 ? { action: this.#options.action } : {},
|
|
253
|
+
[this.#attachmentKey]: this.#enhanceAttachment
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
/** Whether the field at `path` (or anything beneath it) is tainted. */
|
|
257
|
+
taintedAt(path) {
|
|
258
|
+
return isTaintedAt(this.tainted, path);
|
|
259
|
+
}
|
|
260
|
+
/** The messages currently attached to `path` (for dynamic / nested paths). */
|
|
261
|
+
errorsAt(path) {
|
|
262
|
+
return Errors.getError(this.errors, path) ?? [];
|
|
263
|
+
}
|
|
264
|
+
/** Whether the async validator(s) at `path` are currently running. */
|
|
265
|
+
isValidatingAt(path) {
|
|
266
|
+
const key = Path.toString(path);
|
|
267
|
+
return this.#validating[`${key}:change`] === true || this.#validating[`${key}:blur`] === true;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Register (or replace) `path`'s validators — the option-driven equivalent
|
|
271
|
+
* of attaching `validators` at {@link createForm} time, for fields that
|
|
272
|
+
* don't exist until later (e.g. an appended array item).
|
|
273
|
+
*/
|
|
274
|
+
setFieldValidators(path, validators) {
|
|
275
|
+
this.#fieldValidators = {
|
|
276
|
+
...this.#fieldValidators,
|
|
277
|
+
[Path.toString(path)]: validators
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
/** Re-validate the whole form against the schema (if one was supplied), plus a builder's `refineEffect` refinements. */
|
|
281
|
+
validate() {
|
|
282
|
+
if (this.#options.schema) {
|
|
283
|
+
const clientErrors = validateData(this.#options.schema, this.data, this.#options.validationFormat);
|
|
284
|
+
this.#sourced = {
|
|
285
|
+
...this.#sourced,
|
|
286
|
+
client: clientErrors
|
|
287
|
+
};
|
|
288
|
+
this.#hasValidated = true;
|
|
289
|
+
this.#recomputeValid();
|
|
290
|
+
}
|
|
291
|
+
this.#runRefineEffect();
|
|
292
|
+
return this.valid;
|
|
293
|
+
}
|
|
294
|
+
/** Validate a single field against the schema, returning that field's messages. */
|
|
295
|
+
validateField(path) {
|
|
296
|
+
if (!this.#options.schema) return this.errorsAt(path);
|
|
297
|
+
return validateField(this.#options.schema, this.data, path, this.#options.validationFormat);
|
|
298
|
+
}
|
|
299
|
+
/** Set the value at `path` (path-addressed, immutable). Bind directly to `data` otherwise. */
|
|
300
|
+
setValue(path, value) {
|
|
301
|
+
this.data = setAt(this.data, path, value);
|
|
302
|
+
this.#markDirty(path);
|
|
303
|
+
this.#previousData = this.#detachedData();
|
|
304
|
+
this.#onFieldChanged(path);
|
|
305
|
+
}
|
|
306
|
+
/** Read the value at `path`. */
|
|
307
|
+
valueAt(path) {
|
|
308
|
+
return getAt(this.data, path);
|
|
309
|
+
}
|
|
310
|
+
/** Mark the field at `path` touched (defaults to `true`) — drives `onBlur` display. */
|
|
311
|
+
setTouched(path, touched = true) {
|
|
312
|
+
this.#touched = {
|
|
313
|
+
...this.#touched,
|
|
314
|
+
[Path.toString(path)]: touched
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
/** Whether the field at `path` has been touched. */
|
|
318
|
+
isTouchedAt(path) {
|
|
319
|
+
return this.#touched[Path.toString(path)] === true;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Once a change away from the default at `path` is seen (per the current
|
|
323
|
+
* `data`, against `taintedAt`), sticks until {@link reset}. Called after
|
|
324
|
+
* `data` has already been updated, by every method that writes it
|
|
325
|
+
* (`setValue` and the array mutators below) — writing `data` directly
|
|
326
|
+
* (`bind:value={form.data.x}`) bypasses this, same as it bypasses `tainted`
|
|
327
|
+
* recomputing off anything but `data` itself would.
|
|
328
|
+
*/
|
|
329
|
+
#markDirty(path) {
|
|
330
|
+
const key = Path.toString(path);
|
|
331
|
+
if (!this.#dirtied[key] && this.taintedAt(path)) this.#dirtied = {
|
|
332
|
+
...this.#dirtied,
|
|
333
|
+
[key]: true
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
/** Whether the field at `path` was ever changed away from its default — see {@link FieldMeta.isDirty}. */
|
|
337
|
+
isDirtyAt(path) {
|
|
338
|
+
return this.#dirtied[Path.toString(path)] === true;
|
|
339
|
+
}
|
|
340
|
+
/** Whether the field at `path` currently holds its default value — the negation of {@link taintedAt}. */
|
|
341
|
+
isDefaultValueAt(path) {
|
|
342
|
+
return !this.taintedAt(path);
|
|
343
|
+
}
|
|
344
|
+
/** The native validation attributes for `path` (a leaf of {@link constraints}). */
|
|
345
|
+
constraintAt(path) {
|
|
346
|
+
return Constraints.constraintAt(this.constraints, path);
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* A typed, reactive handle to the field at `path` — see {@link FieldHandle}.
|
|
350
|
+
* `path` is type-checked against the form data's shape (see {@link
|
|
351
|
+
* Path.FieldPath}); a typo'd segment or wrong-shaped path is a compile error.
|
|
352
|
+
*/
|
|
353
|
+
field(path) {
|
|
354
|
+
return new FieldHandle(this, Path.fromString(path));
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Handle a field losing focus: mark it touched, run its `onBlur` /
|
|
358
|
+
* `onBlurAsync` validators (or, once posted, the revalidate-on-blur mode),
|
|
359
|
+
* fire that field's `onBlur` listener (if any, debounced per its config),
|
|
360
|
+
* and — in `onBlur` validation mode — validate the whole form
|
|
361
|
+
* (auto-submitting if configured and something changed since the last
|
|
362
|
+
* submit).
|
|
363
|
+
*/
|
|
364
|
+
handleBlur(path) {
|
|
365
|
+
this.setTouched(path, true);
|
|
366
|
+
this.#onFieldBlurred(path);
|
|
367
|
+
const key = Path.toString(path);
|
|
368
|
+
const field = this.#options.listeners?.fields?.[key];
|
|
369
|
+
if (field?.onBlur) {
|
|
370
|
+
const value = this.valueAt(path);
|
|
371
|
+
this.#scheduleListener(`blur:${key}`, field.debounce, () => field.onBlur?.({
|
|
372
|
+
path,
|
|
373
|
+
value
|
|
374
|
+
}));
|
|
375
|
+
}
|
|
376
|
+
if (this.#mode.validation === "onBlur") {
|
|
377
|
+
this.validate();
|
|
378
|
+
if (this.#mode.autoSubmit && this.valid && this.#hasUnsubmittedChanges()) this.#autoSubmit();
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/** Run `run` now, or after `debounce` (per-key timer, replacing any pending run for that key). */
|
|
382
|
+
#scheduleListener(key, debounce, run) {
|
|
383
|
+
if (debounce === void 0) {
|
|
384
|
+
run();
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
clearTimeout(this.#listenerTimers.get(key));
|
|
388
|
+
this.#listenerTimers.set(key, setTimeout(() => {
|
|
389
|
+
this.#listenerTimers.delete(key);
|
|
390
|
+
run();
|
|
391
|
+
}, Duration.toMillis(debounce)));
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* The listeners table to dispatch change events against, with each field's
|
|
395
|
+
* `onChange` wrapped in this store's own debounce timer — or `undefined` if
|
|
396
|
+
* nothing is configured to consume a change (an `onMount`-only config, say),
|
|
397
|
+
* so the deep watcher can skip diffing the data tree entirely. Built once
|
|
398
|
+
* per {@link enhance} call rather than per change, since the listener table
|
|
399
|
+
* itself doesn't change across the mount's lifetime.
|
|
400
|
+
*/
|
|
401
|
+
#buildDispatchListeners() {
|
|
402
|
+
const listeners = this.#options.listeners;
|
|
403
|
+
if (!listeners || listeners.onChange === void 0 && !listeners.fields) return void 0;
|
|
404
|
+
if (!listeners.fields) return listeners;
|
|
405
|
+
const fields = {};
|
|
406
|
+
for (const [key, field] of Object.entries(listeners.fields)) fields[key] = field.onChange ? {
|
|
407
|
+
...field,
|
|
408
|
+
onChange: (input) => this.#scheduleListener(`change:${key}`, field.debounce, () => field.onChange?.(input))
|
|
409
|
+
} : field;
|
|
410
|
+
return {
|
|
411
|
+
...listeners,
|
|
412
|
+
fields
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Whether there are edits worth auto-submitting: before any submit, any
|
|
417
|
+
* change from the seeded values; afterwards, any change since the last
|
|
418
|
+
* submission. Stops `autoSubmit` from re-posting server-echoed data in a loop.
|
|
419
|
+
*/
|
|
420
|
+
#hasUnsubmittedChanges() {
|
|
421
|
+
return this.#lastSubmitted === void 0 ? this.isTainted : this.hasChangedSinceSubmit;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Whether an error at `path` should be shown yet, per the validation mode:
|
|
425
|
+
* always after a submit; in `onChange` once the field is tainted; in `onBlur`
|
|
426
|
+
* once it is touched; never in `onSubmit` before posting.
|
|
427
|
+
*/
|
|
428
|
+
shouldShowError(path) {
|
|
429
|
+
if (this.posted) return true;
|
|
430
|
+
if (this.#mode.validation === "onChange") return this.taintedAt(path);
|
|
431
|
+
if (this.#mode.validation === "onBlur") return this.isTouchedAt(path);
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
/** The messages at `path`, but only when {@link shouldShowError} allows it. */
|
|
435
|
+
visibleErrorsAt(path) {
|
|
436
|
+
return this.shouldShowError(path) ? this.errorsAt(path) : [];
|
|
437
|
+
}
|
|
438
|
+
/** Read the array at `path` (empty when absent). */
|
|
439
|
+
#arrayAt(path) {
|
|
440
|
+
return getAt(this.data, path) ?? [];
|
|
441
|
+
}
|
|
442
|
+
/** Append an item to the array at `path` (a blank item is derived when omitted). */
|
|
443
|
+
append(path, value) {
|
|
444
|
+
const current = this.#arrayAt(path);
|
|
445
|
+
const item = value !== void 0 ? value : this.#defaultItem(path);
|
|
446
|
+
this.data = setAt(this.data, path, [...current, item]);
|
|
447
|
+
this.#markDirty(path);
|
|
448
|
+
this.#previousData = this.#detachedData();
|
|
449
|
+
this.#onFieldChanged(path);
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Insert an item into the array at `path` at `index` (clamped to the
|
|
453
|
+
* array's bounds; a non-finite `index` inserts at the end), shifting later
|
|
454
|
+
* items — and their per-index touched flags / errors — up by one. A blank
|
|
455
|
+
* item is derived when `value` is omitted.
|
|
456
|
+
*/
|
|
457
|
+
insertItem(path, index, value) {
|
|
458
|
+
const current = this.#arrayAt(path);
|
|
459
|
+
const at = Math.max(0, Math.min(Number.isFinite(index) ? index : current.length, current.length));
|
|
460
|
+
const item = value !== void 0 ? value : this.#defaultItem(path);
|
|
461
|
+
this.data = setAt(this.data, path, [
|
|
462
|
+
...current.slice(0, at),
|
|
463
|
+
item,
|
|
464
|
+
...current.slice(at)
|
|
465
|
+
]);
|
|
466
|
+
this.#markDirty(path);
|
|
467
|
+
this.#remapArrayIndices(path, Path.insertAt(at));
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Replace the item at `index` in the array at `path` in place. Drops that
|
|
471
|
+
* index's own touched flag / errors (they described the old value); other
|
|
472
|
+
* indices are untouched.
|
|
473
|
+
*/
|
|
474
|
+
replaceItem(path, index, value) {
|
|
475
|
+
const current = this.#arrayAt(path);
|
|
476
|
+
if (!inRange(index, current.length)) return;
|
|
477
|
+
const next = [...current];
|
|
478
|
+
next[index] = value;
|
|
479
|
+
this.data = setAt(this.data, path, next);
|
|
480
|
+
this.#markDirty(path);
|
|
481
|
+
this.#remapArrayIndices(path, Path.replaceAt(index));
|
|
482
|
+
}
|
|
483
|
+
/** Remove every item from the array at `path`, dropping all of its per-index state. */
|
|
484
|
+
clearItems(path) {
|
|
485
|
+
this.data = setAt(this.data, path, []);
|
|
486
|
+
this.#markDirty(path);
|
|
487
|
+
this.#remapArrayIndices(path, Path.clearAll);
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Remove the item at `index` from the array at `path`, shifting later
|
|
491
|
+
* items' per-index touched flags / errors down by one so they keep
|
|
492
|
+
* following their row.
|
|
493
|
+
*/
|
|
494
|
+
removeItem(path, index) {
|
|
495
|
+
const current = this.#arrayAt(path);
|
|
496
|
+
if (!inRange(index, current.length)) return;
|
|
497
|
+
this.data = setAt(this.data, path, current.filter((_, i) => i !== index));
|
|
498
|
+
this.#markDirty(path);
|
|
499
|
+
this.#remapArrayIndices(path, Path.removeAt(index));
|
|
500
|
+
this.#previousData = this.#detachedData();
|
|
501
|
+
this.#onFieldChanged(path);
|
|
502
|
+
}
|
|
503
|
+
/** Swap two items in the array at `path`, swapping their per-index touched flags / errors too. */
|
|
504
|
+
swap(path, indexA, indexB) {
|
|
505
|
+
const current = this.#arrayAt(path);
|
|
506
|
+
if (!inRange(indexA, current.length) || !inRange(indexB, current.length) || indexA === indexB) return;
|
|
507
|
+
const next = [...current];
|
|
508
|
+
[next[indexA], next[indexB]] = [next[indexB], next[indexA]];
|
|
509
|
+
this.data = setAt(this.data, path, next);
|
|
510
|
+
this.#markDirty(path);
|
|
511
|
+
this.#remapArrayIndices(path, Path.swapAt(indexA, indexB));
|
|
512
|
+
this.#previousData = this.#detachedData();
|
|
513
|
+
this.#onFieldChanged(path);
|
|
514
|
+
}
|
|
515
|
+
/** Move the item at `from` to `to` within the array at `path`, carrying its per-index state along. */
|
|
516
|
+
move(path, from, to) {
|
|
517
|
+
const current = this.#arrayAt(path);
|
|
518
|
+
if (!inRange(from, current.length) || to < 0 || to >= current.length || from === to) return;
|
|
519
|
+
const next = [...current];
|
|
520
|
+
const [item] = next.splice(from, 1);
|
|
521
|
+
next.splice(to, 0, item);
|
|
522
|
+
this.data = setAt(this.data, path, next);
|
|
523
|
+
this.#markDirty(path);
|
|
524
|
+
this.#remapArrayIndices(path, Path.moveAt(from, to));
|
|
525
|
+
this.#previousData = this.#detachedData();
|
|
526
|
+
this.#onFieldChanged(path);
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Remap the per-index touched flags and errors under the array at `path`
|
|
530
|
+
* through `map`, so state recorded against a row follows it through an
|
|
531
|
+
* index-shifting mutation. See {@link Path.IndexMap}.
|
|
532
|
+
*/
|
|
533
|
+
#remapArrayIndices(path, map) {
|
|
534
|
+
const sourced = this.#sourcedSnapshot();
|
|
535
|
+
this.#sourced = {
|
|
536
|
+
client: Errors.remapIndices(sourced.client, path, map),
|
|
537
|
+
server: Errors.remapIndices(sourced.server, path, map)
|
|
538
|
+
};
|
|
539
|
+
this.#touched = Path.remapKeyedRecord(this.#touched, path, map);
|
|
540
|
+
}
|
|
541
|
+
/** Revert `data` to the values sent by the last submission (no-op before any submit). */
|
|
542
|
+
revertToLastSubmit() {
|
|
543
|
+
if (this.#lastSubmitted === void 0) return;
|
|
544
|
+
this.data = structuredClone($state.snapshot(this.#lastSubmitted));
|
|
545
|
+
this.#previousData = this.#detachedData();
|
|
546
|
+
this.#sourced = emptySourced;
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Derive a blank item for the array field at `path`, at any nesting depth
|
|
550
|
+
* (e.g. `authors[0].emails`) — by walking the bound schema's AST there when
|
|
551
|
+
* one is available, falling back to a top-level {@link FormOptions.fields}
|
|
552
|
+
* lookup, else `""`.
|
|
553
|
+
*/
|
|
554
|
+
#defaultItem(path) {
|
|
555
|
+
if (this.#options.schema) return Field.defaultItemAt(this.#options.schema, path);
|
|
556
|
+
const fields = this.#options.fields;
|
|
557
|
+
if (fields && path.length === 1 && typeof path[0] === "string") {
|
|
558
|
+
const def = fields[path[0]];
|
|
559
|
+
if (def && Field.isArrayFieldDef(def)) return Field.defaultValue(def.itemSchema);
|
|
560
|
+
}
|
|
561
|
+
return "";
|
|
562
|
+
}
|
|
563
|
+
/** Submit via the enhanced node, if one is mounted and not already in flight. */
|
|
564
|
+
#autoSubmit() {
|
|
565
|
+
if (this.submitting || this.#autoSubmitNode === void 0) return;
|
|
566
|
+
this.#runSubmit(this.#autoSubmitNode);
|
|
567
|
+
}
|
|
568
|
+
/** Debounced (in `onChange`) whole-form validation, auto-submitting when valid. */
|
|
569
|
+
#scheduleValidation() {
|
|
570
|
+
const run = () => {
|
|
571
|
+
this.validate();
|
|
572
|
+
if (this.#mode.autoSubmit && this.valid && this.#hasUnsubmittedChanges()) this.#autoSubmit();
|
|
573
|
+
};
|
|
574
|
+
const ms = this.#mode.debounce;
|
|
575
|
+
if (ms !== null && ms > 0) {
|
|
576
|
+
clearTimeout(this.#validateTimer);
|
|
577
|
+
this.#validateTimer = setTimeout(run, ms);
|
|
578
|
+
} else run();
|
|
579
|
+
}
|
|
580
|
+
#recomputeValid() {
|
|
581
|
+
this.valid = !hasErrors(mergeSourced(this.#sourced));
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* A plain (non-`$state`-proxy) snapshot of `#sourced`. `clearSourceAt` /
|
|
585
|
+
* `setSourceAt` / `replaceAt` in the framework-neutral core `structuredClone`
|
|
586
|
+
* their `errors` argument — which throws `DataCloneError` on a live Svelte
|
|
587
|
+
* `$state` proxy — so anywhere `#sourced` (or one of its two branches) feeds
|
|
588
|
+
* one of those calls, snapshot it first.
|
|
589
|
+
*/
|
|
590
|
+
#sourcedSnapshot() {
|
|
591
|
+
return $state.snapshot(this.#sourced);
|
|
592
|
+
}
|
|
593
|
+
/** Whether {@link Mode.ParsedMode.revalidate} names this field trigger. */
|
|
594
|
+
#revalidatesOn(trigger) {
|
|
595
|
+
if (this.#mode.revalidate === "never") return false;
|
|
596
|
+
return this.#mode.revalidate === "onChange" ? trigger === "change" : trigger === "blur";
|
|
597
|
+
}
|
|
598
|
+
/** Interrupt (fire-and-forget) any fiber running under `key`, if one is in flight. */
|
|
599
|
+
#interruptFiber(key) {
|
|
600
|
+
const previous = this.#fibers[key];
|
|
601
|
+
if (previous) Effect.runFork(Fiber.interrupt(previous));
|
|
602
|
+
delete this.#fibers[key];
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Fork `effect` under `key` as an interrupt-on-supersede fiber, toggling
|
|
606
|
+
* `#validating[key]` for its duration: any fiber already running under
|
|
607
|
+
* `key` is interrupted first (this — not a timer — is what gives "latest
|
|
608
|
+
* wins"; see {@link Validate.runAsyncFieldValidator}). The old and new
|
|
609
|
+
* fiber's `#validating` writes are guarded by a per-key generation counter
|
|
610
|
+
* — `Effect.runFork(Fiber.interrupt(previous))` is fire-and-forget, so the
|
|
611
|
+
* *old* fiber's `Effect.ensuring` finalizer can run after the *new* one has
|
|
612
|
+
* already flipped `#validating[key]` back to `true`; without the guard that
|
|
613
|
+
* finalizer would clear it while the new run is still genuinely in flight.
|
|
614
|
+
*/
|
|
615
|
+
#runValidatorFiber(key, effect) {
|
|
616
|
+
this.#interruptFiber(key);
|
|
617
|
+
const generation = (this.#validatingGeneration[key] ?? 0) + 1;
|
|
618
|
+
this.#validatingGeneration[key] = generation;
|
|
619
|
+
this.#validating = {
|
|
620
|
+
...this.#validating,
|
|
621
|
+
[key]: true
|
|
622
|
+
};
|
|
623
|
+
const program = effect.pipe(Effect.ensuring(Effect.sync(() => {
|
|
624
|
+
if (this.#validatingGeneration[key] === generation) this.#validating = {
|
|
625
|
+
...this.#validating,
|
|
626
|
+
[key]: false
|
|
627
|
+
};
|
|
628
|
+
})));
|
|
629
|
+
this.#fibers[key] = this.#runtime.runFork(program);
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Handle a field's value actually changing: the field's server-sourced
|
|
633
|
+
* error (if any) is superseded — clear it — then run whichever validators
|
|
634
|
+
* apply for the `"change"` trigger.
|
|
635
|
+
*/
|
|
636
|
+
#onFieldChanged(path) {
|
|
637
|
+
this.#sourced = clearSourceAt(this.#sourcedSnapshot(), "server", path);
|
|
638
|
+
this.#runFieldTrigger(path, "change");
|
|
639
|
+
this.#recomputeValid();
|
|
640
|
+
}
|
|
641
|
+
/** Handle a field losing focus: run whichever validators apply for the `"blur"` trigger. */
|
|
642
|
+
#onFieldBlurred(path) {
|
|
643
|
+
this.#runFieldTrigger(path, "blur");
|
|
644
|
+
this.#recomputeValid();
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Run `path`'s sync + async validators for `trigger` (resolved by {@link
|
|
648
|
+
* Validate.resolveFieldTrigger}). When neither is registered, falls back to
|
|
649
|
+
* a whole-schema {@link validateField} re-run once the form has been
|
|
650
|
+
* posted and {@link Mode.ParsedMode.revalidate} names this trigger — the
|
|
651
|
+
* "fixing a field clears its error without a resubmit" behaviour.
|
|
652
|
+
*/
|
|
653
|
+
#runFieldTrigger(path, trigger) {
|
|
654
|
+
const validators = this.#fieldValidators[Path.toString(path)];
|
|
655
|
+
const value = getAt(this.data, path);
|
|
656
|
+
const resolved = Validate.resolveFieldTrigger(validators, trigger);
|
|
657
|
+
if (resolved.sync) this.#applySyncFieldValidator(path, resolved.sync, value);
|
|
658
|
+
else if (this.#shouldFallBackToRevalidate(trigger)) this.#applyRevalidateFallback(path);
|
|
659
|
+
if (resolved.async) {
|
|
660
|
+
this.#hasValidated = true;
|
|
661
|
+
this.#runAsyncFieldValidator(path, trigger, resolved.async, value, resolved.asyncDebounceMs);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
/** Run a field's registered sync validator and set its client messages. */
|
|
665
|
+
#applySyncFieldValidator(path, validator, value) {
|
|
666
|
+
this.#setClientMessagesAt(path, Validate.runSyncFieldValidator(validator, value));
|
|
667
|
+
this.#hasValidated = true;
|
|
668
|
+
}
|
|
669
|
+
/** Whether, absent a registered validator, a whole-schema fallback revalidation applies for `trigger`. */
|
|
670
|
+
#shouldFallBackToRevalidate(trigger) {
|
|
671
|
+
return this.posted && this.#revalidatesOn(trigger) && this.#options.schema !== void 0;
|
|
672
|
+
}
|
|
673
|
+
/** Re-run the whole-schema client validation and adopt just `path`'s messages. */
|
|
674
|
+
#applyRevalidateFallback(path) {
|
|
675
|
+
this.#sourced = setSourceAt(this.#sourcedSnapshot(), "client", path, this.validateField(path));
|
|
676
|
+
this.#hasValidated = true;
|
|
677
|
+
}
|
|
678
|
+
/** Replace `path`'s client messages with `issues`' messages (folding {@link FormIssue}s at the field's own path). */
|
|
679
|
+
#setClientMessagesAt(path, issues) {
|
|
680
|
+
this.#sourced = setSourceAt(this.#sourcedSnapshot(), "client", path, issues.map((issue) => issue.message));
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Run `path`'s `trigger` async validator as an interrupt-on-supersede
|
|
684
|
+
* fiber (via {@link FormStore.#runValidatorFiber}) — the result (unless
|
|
685
|
+
* the fiber is itself later interrupted) replaces the field's client
|
|
686
|
+
* messages.
|
|
687
|
+
*/
|
|
688
|
+
#runAsyncFieldValidator(path, trigger, validator, value, debounceMs) {
|
|
689
|
+
const fiberKey = `${Path.toString(path)}:${trigger}`;
|
|
690
|
+
this.#runValidatorFiber(fiberKey, Validate.runAsyncFieldValidator(validator, value, debounceMs).pipe(Effect.tap((issues) => Effect.sync(() => {
|
|
691
|
+
this.#setClientMessagesAt(path, issues);
|
|
692
|
+
this.#recomputeValid();
|
|
693
|
+
}))));
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Run the builder's `refineEffect` refinements (client-side, when the form
|
|
697
|
+
* was seeded from a {@link FormBuilder}) as a whole-form async validator —
|
|
698
|
+
* interrupt-on-supersede, same as a field's async validator (see {@link
|
|
699
|
+
* FormStore.#runValidatorFiber}). The previous run's issue *paths* are
|
|
700
|
+
* cleared before the fresh ones are merged in, so a superseded run can't
|
|
701
|
+
* leave stale messages behind without disturbing schema-derived client
|
|
702
|
+
* errors set by {@link validate} in between.
|
|
703
|
+
*/
|
|
704
|
+
#runRefineEffect() {
|
|
705
|
+
const builder = this.#options.builder;
|
|
706
|
+
if (!builder || FormBuilder.asyncRefinements(builder).length === 0) return;
|
|
707
|
+
this.#runValidatorFiber("", FormBuilder.runAsyncRefinements(builder, this.data).pipe(Effect.tap((issues) => Effect.sync(() => {
|
|
708
|
+
let client = this.#sourcedSnapshot().client;
|
|
709
|
+
for (const path of this.#lastRefinementPaths) client = replaceAt(client, path, []);
|
|
710
|
+
client = Errors.mergeErrors(client, Errors.issuesToErrors(issues));
|
|
711
|
+
this.#lastRefinementPaths = issues.map((issue) => issue.path);
|
|
712
|
+
this.#sourced = {
|
|
713
|
+
...this.#sourced,
|
|
714
|
+
client
|
|
715
|
+
};
|
|
716
|
+
this.#recomputeValid();
|
|
717
|
+
}))));
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Reset `data`, `errors`, and `message` back to the tainted baseline. Pass
|
|
721
|
+
* `values` to re-seed that baseline first (e.g. adopting a different
|
|
722
|
+
* entity's values) instead of returning to the original seed.
|
|
723
|
+
*/
|
|
724
|
+
reset(values) {
|
|
725
|
+
if (values !== void 0) this.#initial = structuredClone(values);
|
|
726
|
+
for (const key of Object.keys(this.#fibers)) this.#interruptFiber(key);
|
|
727
|
+
this.data = structuredClone(this.#initial);
|
|
728
|
+
this.#previousData = this.#detachedData();
|
|
729
|
+
this.#sourced = emptySourced;
|
|
730
|
+
this.message = void 0;
|
|
731
|
+
this.valid = false;
|
|
732
|
+
this.posted = false;
|
|
733
|
+
this.#touched = {};
|
|
734
|
+
this.#dirtied = {};
|
|
735
|
+
this.#lastSubmitted = void 0;
|
|
736
|
+
this.#hasValidated = false;
|
|
737
|
+
this.#validating = {};
|
|
738
|
+
this.#lastRefinementPaths = [];
|
|
739
|
+
this.submissionAttempts = 0;
|
|
740
|
+
this.#lastSubmitOutcome = void 0;
|
|
741
|
+
}
|
|
742
|
+
/** A serialisable snapshot for SvelteKit's `snapshot` capture/restore. */
|
|
743
|
+
capture() {
|
|
744
|
+
return {
|
|
745
|
+
id: this.id,
|
|
746
|
+
valid: this.valid,
|
|
747
|
+
posted: this.posted,
|
|
748
|
+
data: this.#detachedData(),
|
|
749
|
+
errors: this.errors,
|
|
750
|
+
message: this.message,
|
|
751
|
+
constraints: this.constraints
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* An independent deep copy of `data`, detached from the live `$state`
|
|
756
|
+
* proxy. `structuredClone` can't clone the proxy directly (Node's
|
|
757
|
+
* structured-clone algorithm rejects it); take a plain snapshot first, per
|
|
758
|
+
* Svelte's own guidance for this exact case.
|
|
759
|
+
*/
|
|
760
|
+
#detachedData() {
|
|
761
|
+
return structuredClone($state.snapshot(this.data));
|
|
762
|
+
}
|
|
763
|
+
/** Restore from a {@link capture} snapshot (its merged tree becomes the server tree; the client tree clears). */
|
|
764
|
+
restore(snapshot) {
|
|
765
|
+
this.data = structuredClone(snapshot.data);
|
|
766
|
+
this.#previousData = this.#detachedData();
|
|
767
|
+
this.#sourced = {
|
|
768
|
+
client: noErrors,
|
|
769
|
+
server: snapshot.errors
|
|
770
|
+
};
|
|
771
|
+
this.message = snapshot.message;
|
|
772
|
+
this.valid = snapshot.valid;
|
|
773
|
+
this.posted = snapshot.posted;
|
|
774
|
+
this.constraints = snapshot.constraints ?? {};
|
|
775
|
+
this.#hasValidated = snapshot.posted;
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* Apply server-sourced `data` + `errors` directly, bypassing a submit
|
|
779
|
+
* round-trip — for glue code that repopulates the store from a derived
|
|
780
|
+
* result it received some other way (e.g.
|
|
781
|
+
* {@link module:effect-forms-svelte/SvelteKit}'s `applyActionResult`, from
|
|
782
|
+
* a SvelteKit form action's failure). Sets `posted` so
|
|
783
|
+
* {@link shouldShowError} shows the new errors immediately; `valid` is
|
|
784
|
+
* always `false` (the caller only reaches for this on a failure result).
|
|
785
|
+
*/
|
|
786
|
+
setServerState(data, errors) {
|
|
787
|
+
this.data = data;
|
|
788
|
+
this.#previousData = structuredClone(data);
|
|
789
|
+
this.#sourced = {
|
|
790
|
+
client: noErrors,
|
|
791
|
+
server: errors
|
|
792
|
+
};
|
|
793
|
+
this.valid = false;
|
|
794
|
+
this.posted = true;
|
|
795
|
+
this.#hasValidated = true;
|
|
796
|
+
}
|
|
797
|
+
#applyUpdate(update, result) {
|
|
798
|
+
this.#options.onUpdate?.({
|
|
799
|
+
update,
|
|
800
|
+
result
|
|
801
|
+
});
|
|
802
|
+
if (update.redirect !== void 0) return this.#applyRedirect(update.redirect);
|
|
803
|
+
if (update.error !== void 0) return this.#applyError(update.error);
|
|
804
|
+
if (update.data !== void 0) this.#applySubmittedData(update.data, result);
|
|
805
|
+
this.#sourced = {
|
|
806
|
+
client: noErrors,
|
|
807
|
+
server: update.errors ?? noErrors
|
|
808
|
+
};
|
|
809
|
+
this.#hasValidated = true;
|
|
810
|
+
this.message = update.message;
|
|
811
|
+
if (update.valid !== void 0) this.valid = update.valid;
|
|
812
|
+
this.#lastSubmitOutcome = this.valid ? "success" : "failure";
|
|
813
|
+
this.posted = true;
|
|
814
|
+
this.#options.onUpdated?.({ form: {
|
|
815
|
+
data: this.data,
|
|
816
|
+
errors: this.errors,
|
|
817
|
+
valid: this.valid
|
|
818
|
+
} });
|
|
819
|
+
}
|
|
820
|
+
#applyRedirect(location) {
|
|
821
|
+
this.#lastSubmitOutcome = "success";
|
|
822
|
+
if (this.#options.onRedirect) this.#options.onRedirect({ location });
|
|
823
|
+
else if (typeof window !== "undefined") window.location.assign(location);
|
|
824
|
+
}
|
|
825
|
+
#applyError(error) {
|
|
826
|
+
this.#lastSubmitOutcome = "failure";
|
|
827
|
+
this.#options.onError?.({ error });
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Adopt (or discard) the submission's echoed data via {@link
|
|
831
|
+
* computeDataApplication} and rebase the tainted baseline. An
|
|
832
|
+
* auto-submitting (live-save) form keeps its local data as the source of
|
|
833
|
+
* truth: adopting the server's echo would discard keystrokes made during
|
|
834
|
+
* the round-trip and, when the echo isn't byte-identical (a trimmed or
|
|
835
|
+
* server-stamped field), retrigger the change watcher — an extra submit,
|
|
836
|
+
* or an endless loop for a non-idempotent echo. On a successful submit the
|
|
837
|
+
* tainted baseline still rebases to whatever `data` ends up holding, so
|
|
838
|
+
* `isTainted` (and the `beforeunload` guard it drives) reflects what was
|
|
839
|
+
* actually just saved, not the original seed.
|
|
840
|
+
*/
|
|
841
|
+
#applySubmittedData(data, result) {
|
|
842
|
+
const applied = computeDataApplication({
|
|
843
|
+
isSuccess: result._tag === "Success",
|
|
844
|
+
appliedData: data,
|
|
845
|
+
initial: this.#initial,
|
|
846
|
+
submittedData: $state.snapshot(this.#lastSubmitted ?? this.data),
|
|
847
|
+
resetForm: this.#options.resetForm ?? false,
|
|
848
|
+
autoSubmit: this.#mode.autoSubmit
|
|
849
|
+
});
|
|
850
|
+
if (applied.data !== void 0) this.data = structuredClone(applied.data);
|
|
851
|
+
if (applied.rebaseTo !== void 0) {
|
|
852
|
+
this.#initial = structuredClone(applied.rebaseTo);
|
|
853
|
+
this.#touched = {};
|
|
854
|
+
}
|
|
855
|
+
this.#previousData = this.#detachedData();
|
|
856
|
+
}
|
|
857
|
+
async #runSubmit(node, submitter = null) {
|
|
858
|
+
const formData = new FormData(node, submitter);
|
|
859
|
+
let cancelled = false;
|
|
860
|
+
let resolved;
|
|
861
|
+
await this.#options.onSubmit?.({
|
|
862
|
+
data: this.data,
|
|
863
|
+
formData,
|
|
864
|
+
form: node,
|
|
865
|
+
submitter,
|
|
866
|
+
cancel: () => {
|
|
867
|
+
cancelled = true;
|
|
868
|
+
},
|
|
869
|
+
resolve: (result) => {
|
|
870
|
+
resolved = result;
|
|
871
|
+
}
|
|
872
|
+
});
|
|
873
|
+
if (cancelled) return;
|
|
874
|
+
this.#lastSubmitted = this.#detachedData();
|
|
875
|
+
this.submissionAttempts++;
|
|
876
|
+
this.submitting = true;
|
|
877
|
+
const delayMs = this.#options.delayMs ?? 500;
|
|
878
|
+
const delayTimer = setTimeout(() => {
|
|
879
|
+
this.delayed = true;
|
|
880
|
+
}, delayMs);
|
|
881
|
+
try {
|
|
882
|
+
const submit = this.#options.submit ?? defaultSubmit;
|
|
883
|
+
const action = node.action || (typeof location !== "undefined" ? location.href : "");
|
|
884
|
+
let result;
|
|
885
|
+
if (resolved !== void 0) result = resolved;
|
|
886
|
+
else if (this.#options.handler) result = await this.#options.handler(this.data);
|
|
887
|
+
else result = await submit(formData, action, node);
|
|
888
|
+
this.#options.onResult?.({ result });
|
|
889
|
+
this.#applyUpdate(resultToUpdate(result), result);
|
|
890
|
+
} catch (cause) {
|
|
891
|
+
const message = cause instanceof Error ? cause.message : "Submission failed";
|
|
892
|
+
this.#lastSubmitOutcome = "failure";
|
|
893
|
+
this.#options.onError?.({ error: message });
|
|
894
|
+
} finally {
|
|
895
|
+
clearTimeout(delayTimer);
|
|
896
|
+
this.submitting = false;
|
|
897
|
+
this.delayed = false;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Progressive-enhancement form action. Use as `<form use:form.enhance>` (or
|
|
902
|
+
* spread {@link FormStore.props}). Intercepts submit, runs the `onSubmit`
|
|
903
|
+
* handler, then sends the body and applies the result. Without JavaScript the
|
|
904
|
+
* form posts normally.
|
|
905
|
+
*/
|
|
906
|
+
enhance = (node) => {
|
|
907
|
+
const handleSubmit = (event) => {
|
|
908
|
+
event.preventDefault();
|
|
909
|
+
this.#runSubmit(node, event.submitter);
|
|
910
|
+
};
|
|
911
|
+
node.addEventListener("submit", handleSubmit);
|
|
912
|
+
const beforeUnload = (event) => {
|
|
913
|
+
if (this.#options.taintedMessage && this.isTainted) event.preventDefault();
|
|
914
|
+
};
|
|
915
|
+
if (this.#options.taintedMessage && typeof window !== "undefined") window.addEventListener("beforeunload", beforeUnload);
|
|
916
|
+
this.#autoSubmitNode = node;
|
|
917
|
+
this.#options.listeners?.onMount?.({ data: $state.snapshot(this.data) });
|
|
918
|
+
const dispatchListeners = this.#buildDispatchListeners();
|
|
919
|
+
let stopWatching;
|
|
920
|
+
if (this.#mode.validation === "onChange" || dispatchListeners !== void 0) {
|
|
921
|
+
let previous = $state.snapshot(this.data);
|
|
922
|
+
stopWatching = $effect.root(() => {
|
|
923
|
+
$effect(() => {
|
|
924
|
+
const snapshot = $state.snapshot(this.data);
|
|
925
|
+
if (this.#mode.validation === "onChange" && (this.isTainted || this.posted)) this.#scheduleValidation();
|
|
926
|
+
if (dispatchListeners !== void 0) dispatchChange(previous, snapshot, dispatchListeners);
|
|
927
|
+
previous = snapshot;
|
|
928
|
+
});
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
const stopFieldWatching = $effect.root(() => {
|
|
932
|
+
$effect(() => {
|
|
933
|
+
const snapshot = $state.snapshot(this.data);
|
|
934
|
+
const changed = taintedLeafPaths(computeTainted(this.#previousData, snapshot));
|
|
935
|
+
this.#previousData = snapshot;
|
|
936
|
+
for (const path of changed) this.#onFieldChanged(path);
|
|
937
|
+
});
|
|
938
|
+
});
|
|
939
|
+
return { destroy: () => {
|
|
940
|
+
node.removeEventListener("submit", handleSubmit);
|
|
941
|
+
if (typeof window !== "undefined") window.removeEventListener("beforeunload", beforeUnload);
|
|
942
|
+
stopWatching?.();
|
|
943
|
+
stopFieldWatching?.();
|
|
944
|
+
clearTimeout(this.#validateTimer);
|
|
945
|
+
for (const timer of this.#listenerTimers.values()) clearTimeout(timer);
|
|
946
|
+
this.#listenerTimers.clear();
|
|
947
|
+
for (const key of Object.keys(this.#fibers)) this.#interruptFiber(key);
|
|
948
|
+
if (this.#autoSubmitNode === node) this.#autoSubmitNode = void 0;
|
|
949
|
+
} };
|
|
950
|
+
};
|
|
951
|
+
/** Attachment form of {@link FormStore.enhance} for {@link FormStore.props}. */
|
|
952
|
+
#enhanceAttachment = (node) => this.enhance(node).destroy;
|
|
953
|
+
/** Build a {@link FormData} body from the current data (SPA / manual submit). */
|
|
954
|
+
toFormData() {
|
|
955
|
+
return objectToFormData(this.data);
|
|
956
|
+
}
|
|
957
|
+
};
|
|
958
|
+
function createForm(source, options = {}) {
|
|
959
|
+
if (FormBuilder.isFormBuilder(source)) {
|
|
960
|
+
const schema = FormBuilder.buildSchema(source);
|
|
961
|
+
return new FormStore(Form.fromBuilder(source, {
|
|
962
|
+
...options.defaults !== void 0 ? { defaults: options.defaults } : {},
|
|
963
|
+
...options.id !== void 0 ? { id: options.id } : {}
|
|
964
|
+
}).empty(), {
|
|
965
|
+
...options,
|
|
966
|
+
schema,
|
|
967
|
+
fields: source.fields,
|
|
968
|
+
builder: source
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
if ("defaults" in source && "schema" in source) {
|
|
972
|
+
const seed = source;
|
|
973
|
+
return new FormStore(makeForm(seed.schema, {
|
|
974
|
+
defaults: seed.defaults,
|
|
975
|
+
...seed.id !== void 0 ? { id: seed.id } : {}
|
|
976
|
+
}).empty(), {
|
|
977
|
+
...options,
|
|
978
|
+
schema: seed.schema
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
return new FormStore(source, options);
|
|
982
|
+
}
|
|
983
|
+
//#endregion
|
|
984
|
+
export { FieldHandle, FormStore, createForm };
|
|
985
|
+
|
|
986
|
+
//# sourceMappingURL=FormStore.svelte.js.map
|