@formwright/core 0.1.0 → 0.2.2

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/dist/index.cjs CHANGED
@@ -1,165 +1,9 @@
1
1
  'use strict';
2
2
 
3
+ var reactive = require('@formwright/reactive');
3
4
  var schema = require('@formwright/schema');
4
5
 
5
6
  // src/reactive.ts
6
- var activeObserver = null;
7
- var batchDepth = 0;
8
- var pendingEffects = /* @__PURE__ */ new Set();
9
- var flushing = false;
10
- function link(source) {
11
- const obs = activeObserver;
12
- if (obs === null) return;
13
- if (!source.observers.has(obs)) {
14
- source.observers.add(obs);
15
- obs.sources.add(source);
16
- }
17
- }
18
- function clearSources(obs) {
19
- for (const src of obs.sources) src.observers.delete(obs);
20
- obs.sources.clear();
21
- }
22
- function flush() {
23
- if (flushing) return;
24
- flushing = true;
25
- try {
26
- while (pendingEffects.size > 0) {
27
- const next = pendingEffects.values().next().value;
28
- pendingEffects.delete(next);
29
- next.run();
30
- }
31
- } finally {
32
- flushing = false;
33
- }
34
- }
35
- var SignalNode = class {
36
- constructor(value) {
37
- this.value = value;
38
- }
39
- value;
40
- observers = /* @__PURE__ */ new Set();
41
- get() {
42
- link(this);
43
- return this.value;
44
- }
45
- peek() {
46
- return this.value;
47
- }
48
- set(next) {
49
- if (Object.is(next, this.value)) return;
50
- this.value = next;
51
- for (const obs of [...this.observers]) obs.notify();
52
- if (batchDepth === 0) flush();
53
- }
54
- update(fn) {
55
- this.set(fn(this.value));
56
- }
57
- };
58
- var ComputedNode = class {
59
- constructor(fn) {
60
- this.fn = fn;
61
- }
62
- fn;
63
- observers = /* @__PURE__ */ new Set();
64
- sources = /* @__PURE__ */ new Set();
65
- value;
66
- dirty = true;
67
- notify() {
68
- if (this.dirty) return;
69
- this.dirty = true;
70
- for (const obs of [...this.observers]) obs.notify();
71
- }
72
- get() {
73
- link(this);
74
- if (this.dirty) this.recompute();
75
- return this.value;
76
- }
77
- peek() {
78
- if (this.dirty) this.recompute();
79
- return this.value;
80
- }
81
- recompute() {
82
- clearSources(this);
83
- const prev = activeObserver;
84
- activeObserver = this;
85
- try {
86
- this.value = this.fn();
87
- this.dirty = false;
88
- } finally {
89
- activeObserver = prev;
90
- }
91
- }
92
- };
93
- var EffectNode = class {
94
- constructor(fn) {
95
- this.fn = fn;
96
- this.run();
97
- }
98
- fn;
99
- sources = /* @__PURE__ */ new Set();
100
- cleanup = void 0;
101
- disposed = false;
102
- notify() {
103
- if (this.disposed) return;
104
- pendingEffects.add(this);
105
- }
106
- run() {
107
- if (this.disposed) return;
108
- this.runCleanup();
109
- clearSources(this);
110
- const prev = activeObserver;
111
- activeObserver = this;
112
- try {
113
- this.cleanup = this.fn();
114
- } finally {
115
- activeObserver = prev;
116
- }
117
- }
118
- runCleanup() {
119
- if (typeof this.cleanup === "function") {
120
- this.cleanup();
121
- this.cleanup = void 0;
122
- }
123
- }
124
- dispose() {
125
- if (this.disposed) return;
126
- this.disposed = true;
127
- this.runCleanup();
128
- clearSources(this);
129
- pendingEffects.delete(this);
130
- }
131
- };
132
- function signal(initial) {
133
- return new SignalNode(initial);
134
- }
135
- function computed(fn) {
136
- return new ComputedNode(fn);
137
- }
138
- function effect(fn) {
139
- const node = new EffectNode(fn);
140
- return () => node.dispose();
141
- }
142
- function untrack(fn) {
143
- const prev = activeObserver;
144
- activeObserver = null;
145
- try {
146
- return fn();
147
- } finally {
148
- activeObserver = prev;
149
- }
150
- }
151
- function batch(fn) {
152
- batchDepth++;
153
- try {
154
- return fn();
155
- } finally {
156
- batchDepth--;
157
- if (batchDepth === 0) flush();
158
- }
159
- }
160
- function isTracking() {
161
- return activeObserver !== null;
162
- }
163
7
 
164
8
  // src/conditions.ts
165
9
  function isOp(cond, key) {
@@ -249,35 +93,42 @@ function isEmpty(value) {
249
93
  return value === void 0 || value === null || value === "";
250
94
  }
251
95
  function compileValidator(schema) {
96
+ const overrides = schema.messages ?? {};
97
+ const msg = (rule, fallback) => overrides[rule] ?? (typeof schema.message === "string" ? schema.message : fallback);
252
98
  return (value) => {
253
- const msg = (fallback) => typeof schema.message === "string" ? schema.message : fallback;
254
99
  if (isEmpty(value)) {
255
- return schema.required ? msg("This field is required") : null;
100
+ return schema.required ? msg("required", "This field is required") : null;
256
101
  }
257
102
  if (schema.kind === "string" || schema.format) {
258
103
  const str = String(value);
259
104
  if (schema.minLength !== void 0 && str.length < schema.minLength) {
260
- return msg(`Must be at least ${schema.minLength} characters`);
105
+ return msg("minLength", `Must be at least ${schema.minLength} characters`);
261
106
  }
262
107
  if (schema.maxLength !== void 0 && str.length > schema.maxLength) {
263
- return msg(`Must be at most ${schema.maxLength} characters`);
108
+ return msg("maxLength", `Must be at most ${schema.maxLength} characters`);
264
109
  }
265
110
  if (schema.pattern !== void 0 && !new RegExp(schema.pattern).test(str)) {
266
- return msg("Invalid format");
111
+ return msg("pattern", "Invalid format");
267
112
  }
268
- if (schema.format === "email" && !EMAIL.test(str)) return msg("Enter a valid email");
269
- if (schema.format === "url" && !URL.test(str)) return msg("Enter a valid URL");
270
- if (schema.format === "uuid" && !UUID.test(str)) return msg("Enter a valid UUID");
113
+ if (schema.format === "email" && !EMAIL.test(str))
114
+ return msg("format", "Enter a valid email");
115
+ if (schema.format === "url" && !URL.test(str)) return msg("format", "Enter a valid URL");
116
+ if (schema.format === "uuid" && !UUID.test(str)) return msg("format", "Enter a valid UUID");
271
117
  }
272
118
  if (schema.kind === "number") {
273
119
  const num = Number(value);
274
- if (Number.isNaN(num)) return msg("Must be a number");
275
- if (schema.min !== void 0 && num < schema.min) return msg(`Must be \u2265 ${schema.min}`);
276
- if (schema.max !== void 0 && num > schema.max) return msg(`Must be \u2264 ${schema.max}`);
120
+ if (Number.isNaN(num)) return msg("type", "Must be a number");
121
+ if (schema.min !== void 0 && num < schema.min)
122
+ return msg("min", `Must be \u2265 ${schema.min}`);
123
+ if (schema.max !== void 0 && num > schema.max)
124
+ return msg("max", `Must be \u2264 ${schema.max}`);
277
125
  }
278
126
  return null;
279
127
  };
280
128
  }
129
+ function requiredMessage(schema) {
130
+ return schema?.messages?.required ?? (typeof schema?.message === "string" ? schema.message : "This field is required");
131
+ }
281
132
 
282
133
  // src/providers.ts
283
134
  function isProviderRef(value) {
@@ -317,10 +168,14 @@ function defaultValueFor(type) {
317
168
  return "";
318
169
  }
319
170
  }
171
+ var uidSeq = 0;
320
172
  var FieldState = class {
321
173
  /** Discriminant for the {@link FieldNode} union (leaf vs group/collection). */
322
174
  kind = "field";
323
175
  id;
176
+ /** A globally-unique DOM id for the control (collection rows reuse `id`, so this must be unique). */
177
+ domId = `fw-${(uidSeq++).toString(36)}`;
178
+ /** The field's schema — mutable at runtime via {@link patchSchema}. */
324
179
  schema;
325
180
  value;
326
181
  error;
@@ -328,32 +183,55 @@ var FieldState = class {
328
183
  visible;
329
184
  enabled;
330
185
  required;
186
+ /** Bumps whenever the schema is patched — renderers re-render the field on change. */
187
+ revision;
331
188
  validator;
332
- constructor(schema, initial, getValue) {
189
+ rev = reactive.signal(0);
190
+ stepActive;
191
+ constructor(schema, initial, getValue, stepActive) {
333
192
  this.id = schema.id;
334
193
  this.schema = schema;
335
- this.value = signal(initial);
336
- this.error = signal(null);
337
- this.touched = signal(false);
194
+ this.stepActive = stepActive;
195
+ this.value = reactive.signal(initial);
196
+ this.error = reactive.signal(null);
197
+ this.touched = reactive.signal(false);
338
198
  this.validator = schema.validation ? compileValidator(schema.validation) : null;
339
- this.visible = computed(() => evaluateCondition(schema.visibleWhen, getValue, true));
340
- this.enabled = computed(() => evaluateCondition(schema.enabledWhen, getValue, true));
341
- this.required = computed(() => {
342
- if (schema.requiredWhen !== void 0) {
343
- return evaluateCondition(schema.requiredWhen, getValue, false);
199
+ this.revision = this.rev;
200
+ this.visible = reactive.computed(() => {
201
+ this.rev.get();
202
+ return evaluateCondition(this.schema.visibleWhen, getValue, true);
203
+ });
204
+ this.enabled = reactive.computed(() => {
205
+ this.rev.get();
206
+ return evaluateCondition(this.schema.enabledWhen, getValue, true);
207
+ });
208
+ this.required = reactive.computed(() => {
209
+ this.rev.get();
210
+ if (this.schema.requiredWhen !== void 0) {
211
+ return evaluateCondition(this.schema.requiredWhen, getValue, false);
344
212
  }
345
- return schema.validation?.required ?? false;
213
+ return this.schema.validation?.required ?? false;
346
214
  });
347
215
  }
348
- /** Run validation, store and return the error (or null). Hidden fields never error. */
349
- validate() {
216
+ /** Merge a partial schema in at runtime (change type, label, options, validation, …). */
217
+ patchSchema(partial) {
218
+ this.schema = { ...this.schema, ...partial };
219
+ this.validator = this.schema.validation ? compileValidator(this.schema.validation) : null;
220
+ this.rev.update((n) => n + 1);
221
+ }
222
+ /** Run validation, store and return the error (or null). Hidden / inactive-step fields never error. */
223
+ validate(options) {
350
224
  if (!this.visible.peek()) {
351
225
  this.error.set(null);
352
226
  return null;
353
227
  }
228
+ if (!options?.allSteps && this.stepActive && !this.stepActive.peek()) {
229
+ this.error.set(null);
230
+ return null;
231
+ }
354
232
  let result = null;
355
233
  if (this.required.peek() && isEmpty2(this.value.peek())) {
356
- result = "This field is required";
234
+ result = requiredMessage(this.schema.validation);
357
235
  } else if (this.validator) {
358
236
  result = this.validator(this.value.peek());
359
237
  }
@@ -371,26 +249,40 @@ function isEmpty2(value) {
371
249
  }
372
250
 
373
251
  // src/nodes.ts
252
+ var PRESENTATIONAL = /* @__PURE__ */ new Set(["heading", "separator", "paragraph"]);
253
+ function isPresentational(type) {
254
+ return PRESENTATIONAL.has(type);
255
+ }
374
256
  function nodeValue(node) {
375
257
  return node.value.get();
376
258
  }
377
259
  function collectValues(nodes) {
378
260
  const out = {};
379
- for (const node of nodes) out[node.id] = nodeValue(node);
261
+ for (const node of nodes) {
262
+ if (node.schema.omit || isPresentational(node.schema.type)) continue;
263
+ if (!node.visible.get()) continue;
264
+ out[node.id] = nodeValue(node);
265
+ }
380
266
  return out;
381
267
  }
382
- function buildNodes(schemas, scope, initial) {
268
+ function buildNodes(schemas, scope, initial, stepActive) {
383
269
  const nodes = [];
384
270
  const byName = /* @__PURE__ */ new Map();
385
271
  for (const schema of schemas) {
386
272
  let node;
387
273
  if (schema.type === "group") {
388
- node = new GroupNode(schema, scope, asDict(initial[schema.id]));
274
+ node = new GroupNode(schema, scope, asDict(initial[schema.id]), stepActive);
275
+ } else if (schema.type === "step") {
276
+ if (!stepActive)
277
+ throw new Error('Field type "step" must be nested inside a "steps" container');
278
+ node = new StepNode(schema, scope, asDict(initial[schema.id]), stepActive);
389
279
  } else if (schema.type === "collection") {
390
280
  node = new CollectionNode(schema, scope, asArray(initial[schema.id]));
281
+ } else if (schema.type === "steps") {
282
+ node = new StepsNode(schema, scope, asDict(initial[schema.id]));
391
283
  } else {
392
284
  const init = initial[schema.id] ?? schema.defaultValue ?? defaultValueFor(schema.type);
393
- node = new FieldState(schema, init, scope);
285
+ node = new FieldState(schema, init, scope, stepActive);
394
286
  }
395
287
  nodes.push(node);
396
288
  byName.set(schema.id, node);
@@ -408,7 +300,9 @@ function resetNodes(nodes, initial) {
408
300
  if (node.kind === "field") {
409
301
  const init = initial[node.id] ?? node.schema.defaultValue ?? defaultValueFor(node.schema.type);
410
302
  node.reset(init);
411
- } else if (node.kind === "group") {
303
+ } else if (node.kind === "group" || node.kind === "step") {
304
+ node.reset(asDict(initial[node.id]));
305
+ } else if (node.kind === "steps") {
412
306
  node.reset(asDict(initial[node.id]));
413
307
  } else {
414
308
  node.reset(asArray(initial[node.id]));
@@ -426,19 +320,19 @@ var GroupNode = class {
426
320
  enabled;
427
321
  /** The scope a child uses: resolve a name among siblings, else delegate upward. */
428
322
  scope;
429
- constructor(schema, parentScope, initial) {
323
+ constructor(schema, parentScope, initial, stepActive) {
430
324
  this.id = schema.id;
431
325
  this.schema = schema;
432
326
  this.scope = (name) => {
433
327
  const child = this.byName.get(name);
434
328
  return child ? nodeValue(child) : parentScope(name);
435
329
  };
436
- const built = buildNodes(schema.fields ?? [], this.scope, initial);
330
+ const built = buildNodes(schema.fields ?? [], this.scope, initial, stepActive);
437
331
  this.children = built.nodes;
438
332
  this.byName = built.byName;
439
- this.value = computed(() => collectValues(this.children));
440
- this.visible = computed(() => evaluateCondition(schema.visibleWhen, parentScope, true));
441
- this.enabled = computed(() => evaluateCondition(schema.enabledWhen, parentScope, true));
333
+ this.value = reactive.computed(() => collectValues(this.children));
334
+ this.visible = reactive.computed(() => evaluateCondition(schema.visibleWhen, parentScope, true));
335
+ this.enabled = reactive.computed(() => evaluateCondition(schema.enabledWhen, parentScope, true));
442
336
  }
443
337
  reset(initial) {
444
338
  resetNodes(this.children, initial);
@@ -461,10 +355,10 @@ var CollectionNode = class {
461
355
  this.parentScope = parentScope;
462
356
  this.itemSchema = { id: schema.id, type: "group", fields: schema.fields ?? [] };
463
357
  const seed = this.seedRows(initial);
464
- this.rows = signal(seed);
465
- this.value = computed(() => this.rows.get().map((row) => row.group.value.get()));
466
- this.visible = computed(() => evaluateCondition(schema.visibleWhen, parentScope, true));
467
- this.enabled = computed(() => evaluateCondition(schema.enabledWhen, parentScope, true));
358
+ this.rows = reactive.signal(seed);
359
+ this.value = reactive.computed(() => this.rows.get().map((row) => row.group.value.get()));
360
+ this.visible = reactive.computed(() => evaluateCondition(schema.visibleWhen, parentScope, true));
361
+ this.enabled = reactive.computed(() => evaluateCondition(schema.enabledWhen, parentScope, true));
468
362
  }
469
363
  /** Reactive list of rows (subscribes the caller to add/remove). */
470
364
  get items() {
@@ -496,6 +390,109 @@ var CollectionNode = class {
496
390
  this.rows.set(this.seedRows(initial));
497
391
  }
498
392
  };
393
+ var StepNode = class {
394
+ kind = "step";
395
+ id;
396
+ schema;
397
+ children;
398
+ byName;
399
+ value;
400
+ visible;
401
+ enabled;
402
+ scope;
403
+ /** True when this step is the active step in its parent wizard. */
404
+ active;
405
+ constructor(schema, parentScope, initial, active) {
406
+ this.id = schema.id;
407
+ this.schema = schema;
408
+ this.active = active;
409
+ this.scope = (name) => {
410
+ const child = this.byName.get(name);
411
+ return child ? nodeValue(child) : parentScope(name);
412
+ };
413
+ const built = buildNodes(schema.fields ?? [], this.scope, initial, active);
414
+ this.children = built.nodes;
415
+ this.byName = built.byName;
416
+ this.value = reactive.computed(() => collectValues(this.children));
417
+ this.visible = reactive.computed(() => evaluateCondition(schema.visibleWhen, parentScope, true));
418
+ this.enabled = reactive.computed(() => evaluateCondition(schema.enabledWhen, parentScope, true));
419
+ }
420
+ reset(initial) {
421
+ resetNodes(this.children, initial);
422
+ }
423
+ };
424
+ var StepsNode = class {
425
+ kind = "steps";
426
+ id;
427
+ schema;
428
+ steps;
429
+ byName;
430
+ value;
431
+ visible;
432
+ enabled;
433
+ currentStep;
434
+ scope;
435
+ constructor(schema, parentScope, initial) {
436
+ this.id = schema.id;
437
+ this.schema = schema;
438
+ this.currentStep = reactive.signal(0);
439
+ this.byName = /* @__PURE__ */ new Map();
440
+ this.scope = (name) => {
441
+ const step = this.byName.get(name);
442
+ return step ? step.value.get() : parentScope(name);
443
+ };
444
+ const stepSchemas = schema.fields ?? [];
445
+ const steps = stepSchemas.map((stepSchema, index) => {
446
+ const active = reactive.computed(() => this.currentStep.get() === index);
447
+ const stepInitial = asDict(initial[stepSchema.id]);
448
+ const step = new StepNode(stepSchema, this.scope, stepInitial, active);
449
+ this.byName.set(stepSchema.id, step);
450
+ return step;
451
+ });
452
+ this.steps = steps;
453
+ this.value = reactive.computed(() => {
454
+ const out = {};
455
+ for (const step of this.steps) out[step.id] = step.value.get();
456
+ return out;
457
+ });
458
+ this.visible = reactive.computed(() => evaluateCondition(schema.visibleWhen, parentScope, true));
459
+ this.enabled = reactive.computed(() => evaluateCondition(schema.enabledWhen, parentScope, true));
460
+ }
461
+ /** Validate every leaf in the step at `index` (defaults to the current step). */
462
+ validateStep(index) {
463
+ const i = index ?? this.currentStep.peek();
464
+ const step = this.steps[i];
465
+ if (!step) return true;
466
+ let ok = true;
467
+ eachLeaf([step], (leaf) => {
468
+ if (leaf.validate() !== null) ok = false;
469
+ });
470
+ return ok;
471
+ }
472
+ /** Advance to the next step after optionally validating the current one. Returns false if blocked. */
473
+ next() {
474
+ const validate = this.schema.validateOnNext !== false;
475
+ if (validate && !this.validateStep()) return false;
476
+ const cur = this.currentStep.peek();
477
+ if (cur < this.steps.length - 1) {
478
+ this.currentStep.set(cur + 1);
479
+ return true;
480
+ }
481
+ return false;
482
+ }
483
+ /** Go back one step (no validation). */
484
+ prev() {
485
+ this.currentStep.update((i) => Math.max(0, i - 1));
486
+ }
487
+ /** Jump to a step by index (does not validate). */
488
+ goTo(index) {
489
+ if (index >= 0 && index < this.steps.length) this.currentStep.set(index);
490
+ }
491
+ reset(initial) {
492
+ this.currentStep.set(0);
493
+ for (const step of this.steps) step.reset(asDict(initial[step.id]));
494
+ }
495
+ };
499
496
  function buildTree(schemas, initial) {
500
497
  let byName;
501
498
  const scope = (name) => {
@@ -509,8 +506,10 @@ function buildTree(schemas, initial) {
509
506
  function eachLeaf(nodes, visit) {
510
507
  for (const node of nodes) {
511
508
  if (node.kind === "field") visit(node);
512
- else if (node.kind === "group") eachLeaf(node.children, visit);
513
- else for (const row of node.items.peek()) eachLeaf(row.group.children, visit);
509
+ else if (node.kind === "group" || node.kind === "step") eachLeaf(node.children, visit);
510
+ else if (node.kind === "steps") {
511
+ for (const step of node.steps) eachLeaf(step.children, visit);
512
+ } else for (const row of node.items.peek()) eachLeaf(row.group.children, visit);
514
513
  }
515
514
  }
516
515
  var defaultRenderer = null;
@@ -529,24 +528,37 @@ var Form = class {
529
528
  isDirty;
530
529
  /** True when no visible field currently has an error. */
531
530
  isValid;
532
- submitting = signal(false);
531
+ submitting = reactive.signal(false);
533
532
  initialValues;
534
533
  initialSnapshot;
535
534
  rootByName;
536
535
  listeners = /* @__PURE__ */ new Map();
537
536
  disposeRenderer = null;
537
+ disposePersist = null;
538
538
  constructor(schema$1, initialValues = {}, options = {}) {
539
539
  this.schema = schema.parseSchema(schema$1);
540
540
  this.options = options;
541
541
  this.initialValues = initialValues;
542
- const tree = buildTree(this.schema.fields, initialValues);
542
+ const fields = this.schema.locales?.length ? expandLocalized(this.schema.fields, this.schema.locales) : this.schema.fields;
543
+ const seed = loadPersisted(options.persistKey, initialValues);
544
+ const tree = buildTree(fields, seed);
543
545
  this.tree = tree.nodes;
544
546
  this.rootByName = tree.byName;
545
- this.order = this.schema.fields.map((f) => f.id);
546
- this.values = computed(() => collectTree(this.tree));
547
- this.initialSnapshot = JSON.stringify(untrack(() => this.values.peek()));
548
- this.isDirty = computed(() => JSON.stringify(this.values.get()) !== this.initialSnapshot);
549
- this.isValid = computed(() => {
547
+ this.order = fields.map((f) => f.id);
548
+ this.values = reactive.computed(() => collectTree(this.tree));
549
+ if (options.persistKey) {
550
+ const key = options.persistKey;
551
+ this.disposePersist = reactive.effect(() => {
552
+ const v = this.values.get();
553
+ try {
554
+ localStorage.setItem(key, JSON.stringify(v));
555
+ } catch {
556
+ }
557
+ });
558
+ }
559
+ this.initialSnapshot = JSON.stringify(reactive.untrack(() => this.values.peek()));
560
+ this.isDirty = reactive.computed(() => JSON.stringify(this.values.get()) !== this.initialSnapshot);
561
+ this.isValid = reactive.computed(() => {
550
562
  let valid = true;
551
563
  eachLeaf(this.tree, (leaf) => {
552
564
  if (leaf.visible.get() && leaf.error.get() !== null) valid = false;
@@ -574,14 +586,24 @@ var Form = class {
574
586
  setFieldValue(field, value) {
575
587
  field.value.set(value);
576
588
  field.touched.set(true);
577
- if (field.error.peek() !== null) field.validate();
589
+ field.validate();
578
590
  this.emit("change", { id: field.id, value });
579
591
  }
580
592
  setError(id, error) {
581
593
  this.field(id)?.error.set(error);
582
594
  }
595
+ /** Patch one field's schema at runtime (change type, label, options, validation, …). */
596
+ setFieldSchema(path, partial) {
597
+ this.field(path)?.patchSchema(partial);
598
+ }
599
+ /** Patch many fields' schemas at once: `form.patch({ state: { type: "text" }, … })`. */
600
+ patch(updates) {
601
+ reactive.batch(() => {
602
+ for (const [path, partial] of Object.entries(updates)) this.setFieldSchema(path, partial);
603
+ });
604
+ }
583
605
  setErrors(errors) {
584
- batch(() => {
606
+ reactive.batch(() => {
585
607
  for (const [id, error] of Object.entries(errors)) this.setError(id, error);
586
608
  });
587
609
  }
@@ -590,47 +612,70 @@ var Form = class {
590
612
  }
591
613
  // ---- lifecycle ----------------------------------------------------------
592
614
  /** Validate every (visible) leaf field; returns true when the whole form is valid. */
593
- validate() {
594
- return untrack(() => {
615
+ validate(options) {
616
+ const allSteps = options?.allSteps ?? false;
617
+ return reactive.untrack(() => {
595
618
  let ok = true;
596
- batch(() => {
619
+ reactive.batch(() => {
597
620
  eachLeaf(this.tree, (leaf) => {
598
- if (leaf.validate() !== null) ok = false;
621
+ if (leaf.validate({ allSteps }) !== null) ok = false;
599
622
  });
600
623
  });
601
624
  return ok;
602
625
  });
603
626
  }
604
- /** Run the submission pipeline: validate transform send onSuccess/onError. */
605
- async submit() {
606
- if (!this.validate()) {
607
- const error = new FormValidationError(this.collectErrors());
627
+ /** Find the first `steps` container in the field tree (if any). */
628
+ findSteps() {
629
+ return findSteps(this.tree);
630
+ }
631
+ /**
632
+ * Run the submission pipeline: validate → transform → send → onSuccess/onError.
633
+ * Pass an inline `transform` to shape the final payload, e.g.
634
+ * `form.submit((values) => ({ ...values, source: "web" }))`.
635
+ *
636
+ * Always **resolves** with a {@link SubmitResult} — never throws — so you can
637
+ * handle both outcomes from the API in one place:
638
+ * `const res = await form.submit(); res.ok ? res.data : res.error`.
639
+ */
640
+ async submit(transform) {
641
+ if (!this.validate({ allSteps: true })) {
642
+ const errors = this.collectErrors();
643
+ const error = new FormValidationError(errors);
608
644
  this.runErrorHandler(error);
609
645
  this.emit("error", error);
610
- throw error;
646
+ return { ok: false, error, errors };
611
647
  }
612
648
  this.submitting.set(true);
613
- const values = untrack(() => this.values.peek());
614
- const payload = this.applyTransform(values);
649
+ const values = reactive.untrack(() => this.values.peek());
650
+ const named = this.applyTransform(values);
651
+ const payload = transform ? transform(named, this) : named;
615
652
  this.emit("submit", payload);
616
653
  try {
617
- const result = await this.send(payload);
618
- this.runSuccessHandler(result);
619
- this.emit("success", result);
620
- return result;
654
+ const data = await this.send(payload);
655
+ this.clearPersisted();
656
+ this.runSuccessHandler(data);
657
+ this.emit("success", data);
658
+ return { ok: true, data };
621
659
  } catch (error) {
622
660
  this.runErrorHandler(error);
623
661
  this.emit("error", error);
624
- throw error;
662
+ return { ok: false, error };
625
663
  } finally {
626
664
  this.submitting.set(false);
627
665
  }
628
666
  }
629
667
  reset(values = this.initialValues) {
630
- batch(() => {
668
+ reactive.batch(() => {
631
669
  resetNodes(this.tree, values);
632
670
  });
633
671
  }
672
+ /** Trigger a named form action: runs its handler (from options) and emits "action". */
673
+ action(name) {
674
+ const def = this.schema.actions?.find((a) => a.name === name);
675
+ const handler = def?.handler ? this.options.handlers?.[def.handler] : void 0;
676
+ handler?.(this);
677
+ this.emit("action", { name });
678
+ }
634
679
  /** Mount into a host element using the given renderer (or the registered default). */
635
680
  mount(host, renderer = defaultRenderer) {
636
681
  if (!renderer) {
@@ -645,8 +690,19 @@ var Form = class {
645
690
  destroy() {
646
691
  this.disposeRenderer?.();
647
692
  this.disposeRenderer = null;
693
+ this.disposePersist?.();
694
+ this.disposePersist = null;
648
695
  this.listeners.clear();
649
696
  }
697
+ /** Remove the cached draft from `localStorage` (called on a successful submit). */
698
+ clearPersisted() {
699
+ const key = this.options.persistKey;
700
+ if (!key || typeof localStorage === "undefined") return;
701
+ try {
702
+ localStorage.removeItem(key);
703
+ } catch {
704
+ }
705
+ }
650
706
  // ---- events -------------------------------------------------------------
651
707
  on(event, listener) {
652
708
  let set = this.listeners.get(event);
@@ -708,14 +764,64 @@ var FormValidationError = class extends Error {
708
764
  function collectTree(tree) {
709
765
  return collectValues(tree);
710
766
  }
767
+ function loadPersisted(key, initial) {
768
+ if (!key || typeof localStorage === "undefined") return initial;
769
+ try {
770
+ const saved = localStorage.getItem(key);
771
+ if (saved) return { ...initial, ...JSON.parse(saved) };
772
+ } catch {
773
+ }
774
+ return initial;
775
+ }
776
+ function expandLocalized(fields, locales) {
777
+ return fields.map((f) => {
778
+ if (f.localized) {
779
+ const leafType = f.type === "group" || f.type === "collection" ? "text" : f.type;
780
+ const child = (loc) => {
781
+ const c = { id: loc, type: leafType, label: loc };
782
+ if (f.placeholder !== void 0) c["placeholder"] = f.placeholder;
783
+ if (f.validation !== void 0) c["validation"] = f.validation;
784
+ if (f.options !== void 0) c["options"] = f.options;
785
+ if (f.widget !== void 0) c["widget"] = f.widget;
786
+ if (f.tooltip !== void 0) c["tooltip"] = f.tooltip;
787
+ return c;
788
+ };
789
+ const group = {
790
+ id: f.id,
791
+ type: "group",
792
+ // Keep the `localized` flag so the renderer shows ONE input + a language
793
+ // switcher (instead of one input per locale). Value stays `{ en, ar }`.
794
+ localized: true,
795
+ fields: locales.map(child)
796
+ };
797
+ if (f.defaultLocale !== void 0) group["defaultLocale"] = f.defaultLocale;
798
+ for (const key of [
799
+ "label",
800
+ "visibleWhen",
801
+ "enabledWhen",
802
+ "class",
803
+ "classes",
804
+ "help",
805
+ "tooltip"
806
+ ]) {
807
+ if (f[key] !== void 0) group[key] = f[key];
808
+ }
809
+ return group;
810
+ }
811
+ if (f.fields) return { ...f, fields: expandLocalized(f.fields, locales) };
812
+ return f;
813
+ });
814
+ }
711
815
  function collectLeaves(tree) {
712
816
  const out = /* @__PURE__ */ new Map();
713
817
  const walk = (nodes, prefix) => {
714
818
  for (const node of nodes) {
715
819
  const path = prefix ? `${prefix}.${node.id}` : node.id;
716
820
  if (node.kind === "field") out.set(path, node);
717
- else if (node.kind === "group") walk(node.children, path);
718
- else {
821
+ else if (node.kind === "group" || node.kind === "step") walk(node.children, path);
822
+ else if (node.kind === "steps") {
823
+ for (const step of node.steps) walk(step.children, `${path}.${step.id}`);
824
+ } else {
719
825
  node.items.peek().forEach((row, i) => walk(row.group.children, `${path}.${i}`));
720
826
  }
721
827
  }
@@ -728,7 +834,9 @@ function resolveLeaf(tree, rootByName, path) {
728
834
  let node = rootByName.get(parts[0]);
729
835
  for (let i = 1; i < parts.length && node; i++) {
730
836
  const part = parts[i];
731
- if (node instanceof GroupNode) {
837
+ if (node instanceof GroupNode || node instanceof StepNode) {
838
+ node = node.byName.get(part);
839
+ } else if (node instanceof StepsNode) {
732
840
  node = node.byName.get(part);
733
841
  } else if (node instanceof CollectionNode) {
734
842
  node = node.items.peek()[Number(part)]?.group;
@@ -738,27 +846,63 @@ function resolveLeaf(tree, rootByName, path) {
738
846
  }
739
847
  return node && node.kind === "field" ? node : void 0;
740
848
  }
849
+ function findSteps(tree) {
850
+ for (const node of tree) {
851
+ if (node.kind === "steps") return node;
852
+ if (node.kind === "group" || node.kind === "step") {
853
+ const found = findSteps(node.children);
854
+ if (found) return found;
855
+ } else if (node.kind === "collection") {
856
+ for (const row of node.items.peek()) {
857
+ const found = findSteps(row.group.children);
858
+ if (found) return found;
859
+ }
860
+ }
861
+ }
862
+ return void 0;
863
+ }
741
864
 
865
+ Object.defineProperty(exports, "batch", {
866
+ enumerable: true,
867
+ get: function () { return reactive.batch; }
868
+ });
869
+ Object.defineProperty(exports, "computed", {
870
+ enumerable: true,
871
+ get: function () { return reactive.computed; }
872
+ });
873
+ Object.defineProperty(exports, "effect", {
874
+ enumerable: true,
875
+ get: function () { return reactive.effect; }
876
+ });
877
+ Object.defineProperty(exports, "isTracking", {
878
+ enumerable: true,
879
+ get: function () { return reactive.isTracking; }
880
+ });
881
+ Object.defineProperty(exports, "signal", {
882
+ enumerable: true,
883
+ get: function () { return reactive.signal; }
884
+ });
885
+ Object.defineProperty(exports, "untrack", {
886
+ enumerable: true,
887
+ get: function () { return reactive.untrack; }
888
+ });
742
889
  exports.CollectionNode = CollectionNode;
743
890
  exports.FieldState = FieldState;
744
891
  exports.Form = Form;
745
892
  exports.FormValidationError = FormValidationError;
746
893
  exports.GroupNode = GroupNode;
747
- exports.batch = batch;
894
+ exports.StepNode = StepNode;
895
+ exports.StepsNode = StepsNode;
748
896
  exports.buildTree = buildTree;
749
897
  exports.compileValidator = compileValidator;
750
- exports.computed = computed;
751
898
  exports.defaultValueFor = defaultValueFor;
752
899
  exports.eachLeaf = eachLeaf;
753
- exports.effect = effect;
754
900
  exports.evaluateCondition = evaluateCondition;
901
+ exports.isPresentational = isPresentational;
755
902
  exports.isProviderRef = isProviderRef;
756
- exports.isTracking = isTracking;
757
903
  exports.referencedFields = referencedFields;
758
904
  exports.resolve = resolve;
759
905
  exports.resolveQuery = resolveQuery;
760
906
  exports.setDefaultRenderer = setDefaultRenderer;
761
- exports.signal = signal;
762
- exports.untrack = untrack;
763
907
  //# sourceMappingURL=index.cjs.map
764
908
  //# sourceMappingURL=index.cjs.map