dalila 1.9.13 → 1.9.14

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.
@@ -2,6 +2,31 @@
2
2
  * Type utilities for path-based access
3
3
  */
4
4
  export type FieldErrors = Record<string, string>;
5
+ export interface SchemaValidationIssue {
6
+ path?: string;
7
+ message: string;
8
+ }
9
+ export interface SchemaValidationResult<T = unknown> {
10
+ value?: T;
11
+ issues?: SchemaValidationIssue[];
12
+ formError?: string;
13
+ }
14
+ export interface FormSchemaAdapter<T = unknown> {
15
+ /**
16
+ * Validate full form data.
17
+ */
18
+ validate(data: unknown): SchemaValidationResult<T> | Promise<SchemaValidationResult<T>>;
19
+ /**
20
+ * Optional field-level validation. Useful for validateOn blur/change.
21
+ */
22
+ validateField?(path: string, value: unknown, data: unknown): SchemaValidationResult<T> | Promise<SchemaValidationResult<T>>;
23
+ /**
24
+ * Optional fallback mapper for unknown schema errors.
25
+ */
26
+ mapErrors?(error: unknown): SchemaValidationIssue[] | {
27
+ formError?: string;
28
+ };
29
+ }
5
30
  export interface FormSubmitContext {
6
31
  signal: AbortSignal;
7
32
  }
@@ -23,7 +48,14 @@ export interface FormOptions<T> {
23
48
  validate?: (data: T) => FieldErrors | {
24
49
  fieldErrors?: FieldErrors;
25
50
  formError?: string;
26
- } | void;
51
+ } | void | Promise<FieldErrors | {
52
+ fieldErrors?: FieldErrors;
53
+ formError?: string;
54
+ } | void>;
55
+ /**
56
+ * Optional schema adapter (zod/valibot/yup/custom).
57
+ */
58
+ schema?: FormSchemaAdapter<T>;
27
59
  /**
28
60
  * When to run validation:
29
61
  * - "submit" (default): only on submit
@@ -114,6 +146,11 @@ export interface Form<T> {
114
146
  * Create or get a field array
115
147
  */
116
148
  fieldArray<TItem = unknown>(path: string): FieldArray<TItem>;
149
+ /**
150
+ * Watch a specific path and run callback when its value changes.
151
+ * Returns an idempotent unsubscribe function.
152
+ */
153
+ watch(path: string, fn: (next: unknown, prev: unknown) => void): () => void;
117
154
  }
118
155
  export interface FieldArrayItem<T = unknown> {
119
156
  key: string;
package/dist/form/form.js CHANGED
@@ -275,6 +275,7 @@ export function createForm(options = {}) {
275
275
  // Registry
276
276
  const fieldRegistry = new Map();
277
277
  const fieldArrayRegistry = new Map();
278
+ const pathWatchers = new Set();
278
279
  // Form element reference
279
280
  let formElement = null;
280
281
  // Submit abort controller
@@ -282,6 +283,94 @@ export function createForm(options = {}) {
282
283
  // Default values
283
284
  let defaultValues = {};
284
285
  let defaultsInitialized = false;
286
+ function getCurrentSnapshot(preferFieldArrayPaths) {
287
+ const snapshot = formElement
288
+ ? (options.parse ?? parseFormData)(formElement, new FormData(formElement))
289
+ : {};
290
+ // Field arrays may exist before DOM render; overlay them when DOM snapshot
291
+ // has no value for that path, or when callers explicitly prefer array state
292
+ // (useful during reorder mutations before DOM patches apply).
293
+ for (const [path, array] of fieldArrayRegistry) {
294
+ const hasDomValue = getNestedValue(snapshot, path) !== undefined;
295
+ const shouldPreferArrayState = preferFieldArrayPaths?.has(path) === true;
296
+ if (hasDomValue && !shouldPreferArrayState)
297
+ continue;
298
+ const rows = array.fields().map((item) => item.value);
299
+ setNestedValue(snapshot, path, rows);
300
+ }
301
+ return snapshot;
302
+ }
303
+ function readPathValue(path, preferFieldArrayPaths) {
304
+ return getNestedValue(getCurrentSnapshot(preferFieldArrayPaths), path);
305
+ }
306
+ function isPathRelated(pathA, pathB) {
307
+ if (pathA === pathB)
308
+ return true;
309
+ if (pathA.startsWith(`${pathB}.`) || pathA.startsWith(`${pathB}[`))
310
+ return true;
311
+ if (pathB.startsWith(`${pathA}.`) || pathB.startsWith(`${pathA}[`))
312
+ return true;
313
+ return false;
314
+ }
315
+ function emitWatchCallback(callback, next, prev) {
316
+ try {
317
+ callback(next, prev);
318
+ }
319
+ catch (err) {
320
+ if (typeof console !== 'undefined') {
321
+ console.error('[Dalila] Error in form.watch callback:', err);
322
+ }
323
+ }
324
+ }
325
+ function isPlainObject(value) {
326
+ return Object.prototype.toString.call(value) === '[object Object]';
327
+ }
328
+ function areValuesEqual(a, b) {
329
+ if (Object.is(a, b))
330
+ return true;
331
+ if (Array.isArray(a) && Array.isArray(b)) {
332
+ if (a.length !== b.length)
333
+ return false;
334
+ for (let i = 0; i < a.length; i++) {
335
+ if (!areValuesEqual(a[i], b[i]))
336
+ return false;
337
+ }
338
+ return true;
339
+ }
340
+ if (isPlainObject(a) && isPlainObject(b)) {
341
+ const aKeys = Object.keys(a);
342
+ const bKeys = Object.keys(b);
343
+ if (aKeys.length !== bKeys.length)
344
+ return false;
345
+ for (const key of aKeys) {
346
+ if (!(key in b))
347
+ return false;
348
+ if (!areValuesEqual(a[key], b[key]))
349
+ return false;
350
+ }
351
+ return true;
352
+ }
353
+ return false;
354
+ }
355
+ function notifyWatchers(changedPath, opts = {}) {
356
+ if (pathWatchers.size === 0)
357
+ return;
358
+ const preferFieldArrayPaths = opts.preferFieldArrayPaths != null
359
+ ? new Set(opts.preferFieldArrayPaths)
360
+ : opts.preferFieldArrayPath
361
+ ? new Set([opts.preferFieldArrayPath])
362
+ : undefined;
363
+ for (const watcher of pathWatchers) {
364
+ if (changedPath && !isPathRelated(changedPath, watcher.path))
365
+ continue;
366
+ const next = readPathValue(watcher.path, preferFieldArrayPaths);
367
+ if (areValuesEqual(next, watcher.lastValue))
368
+ continue;
369
+ const prev = watcher.lastValue;
370
+ watcher.lastValue = next;
371
+ emitWatchCallback(watcher.callback, next, prev);
372
+ }
373
+ }
285
374
  // Initialize defaults
286
375
  (async () => {
287
376
  try {
@@ -317,6 +406,189 @@ export function createForm(options = {}) {
317
406
  // Validation mode
318
407
  const validateOn = options.validateOn ?? 'submit';
319
408
  let hasSubmitted = false;
409
+ let validationRunSeq = 0;
410
+ let formValidationRunId = 0;
411
+ let formInvalidationId = 0;
412
+ const latestFieldValidationByPath = new Map();
413
+ function beginValidation(path) {
414
+ if (path) {
415
+ // Field-level validation should invalidate pending full-form validations,
416
+ // but keep other field paths independent.
417
+ formInvalidationId += 1;
418
+ const runId = ++validationRunSeq;
419
+ latestFieldValidationByPath.set(path, runId);
420
+ return {
421
+ kind: 'path',
422
+ path,
423
+ runId,
424
+ formRunIdAtStart: formValidationRunId,
425
+ formInvalidationIdAtStart: formInvalidationId,
426
+ };
427
+ }
428
+ // Full-form validation invalidates previous full-form and field-level runs.
429
+ formValidationRunId += 1;
430
+ latestFieldValidationByPath.clear();
431
+ const runId = ++validationRunSeq;
432
+ return {
433
+ kind: 'form',
434
+ runId,
435
+ formRunIdAtStart: formValidationRunId,
436
+ formInvalidationIdAtStart: formInvalidationId,
437
+ };
438
+ }
439
+ function isValidationCurrent(token) {
440
+ if (token.kind === 'form') {
441
+ return token.formRunIdAtStart === formValidationRunId &&
442
+ token.formInvalidationIdAtStart === formInvalidationId;
443
+ }
444
+ if (!token.path)
445
+ return false;
446
+ return token.formRunIdAtStart === formValidationRunId &&
447
+ latestFieldValidationByPath.get(token.path) === token.runId;
448
+ }
449
+ function isPromiseLike(value) {
450
+ return !!value && typeof value.then === 'function';
451
+ }
452
+ function mergeValidationOutcomes(outcomes) {
453
+ const mergedFieldErrors = {};
454
+ let formError = undefined;
455
+ let value;
456
+ let hasValue = false;
457
+ for (const outcome of outcomes) {
458
+ for (const [path, message] of Object.entries(outcome.fieldErrors)) {
459
+ if (!(path in mergedFieldErrors)) {
460
+ mergedFieldErrors[path] = message;
461
+ }
462
+ }
463
+ if (formError == null && typeof outcome.formError === 'string' && outcome.formError.length > 0) {
464
+ formError = outcome.formError;
465
+ }
466
+ if (outcome.hasValue) {
467
+ hasValue = true;
468
+ value = outcome.value;
469
+ }
470
+ }
471
+ return { fieldErrors: mergedFieldErrors, formError, value, hasValue };
472
+ }
473
+ function normalizeLegacyValidationResult(result) {
474
+ if (!result || typeof result !== 'object') {
475
+ return { fieldErrors: {} };
476
+ }
477
+ if ('fieldErrors' in result || 'formError' in result) {
478
+ const typedResult = result;
479
+ return {
480
+ fieldErrors: typedResult.fieldErrors ?? {},
481
+ formError: typedResult.formError,
482
+ };
483
+ }
484
+ return { fieldErrors: result };
485
+ }
486
+ function normalizeSchemaValidationResult(result) {
487
+ const fieldErrors = {};
488
+ let formError = result.formError;
489
+ const hasValue = Object.prototype.hasOwnProperty.call(result, 'value');
490
+ for (const issue of result.issues ?? []) {
491
+ if (issue.path && !(issue.path in fieldErrors)) {
492
+ fieldErrors[issue.path] = issue.message;
493
+ continue;
494
+ }
495
+ if (!issue.path && formError == null) {
496
+ formError = issue.message;
497
+ }
498
+ }
499
+ return { fieldErrors, formError, value: result.value, hasValue };
500
+ }
501
+ function runLegacyValidation(data) {
502
+ if (!options.validate)
503
+ return { fieldErrors: {} };
504
+ const result = options.validate(data);
505
+ if (isPromiseLike(result)) {
506
+ return result
507
+ .then((resolved) => normalizeLegacyValidationResult(resolved))
508
+ .catch((error) => ({
509
+ fieldErrors: {},
510
+ formError: error instanceof Error ? error.message : 'Validation failed',
511
+ }));
512
+ }
513
+ return normalizeLegacyValidationResult(result);
514
+ }
515
+ function runSchemaValidation(data, path) {
516
+ if (!options.schema)
517
+ return { fieldErrors: {} };
518
+ const schema = options.schema;
519
+ const execute = () => {
520
+ if (path && schema.validateField) {
521
+ return schema.validateField(path, getNestedValue(data, path), data);
522
+ }
523
+ return schema.validate(data);
524
+ };
525
+ const normalizeMappedError = (error) => {
526
+ const mapped = schema.mapErrors?.(error);
527
+ if (Array.isArray(mapped)) {
528
+ if (mapped.length === 0) {
529
+ return {
530
+ fieldErrors: {},
531
+ formError: error instanceof Error ? error.message : 'Schema validation failed',
532
+ };
533
+ }
534
+ return normalizeSchemaValidationResult({ issues: mapped });
535
+ }
536
+ if (mapped && typeof mapped === 'object') {
537
+ const mappedFormError = mapped.formError;
538
+ if (typeof mappedFormError === 'string' && mappedFormError.length > 0) {
539
+ return {
540
+ fieldErrors: {},
541
+ formError: mappedFormError,
542
+ };
543
+ }
544
+ return {
545
+ fieldErrors: {},
546
+ formError: error instanceof Error ? error.message : 'Schema validation failed',
547
+ };
548
+ }
549
+ return {
550
+ fieldErrors: {},
551
+ formError: error instanceof Error ? error.message : 'Schema validation failed',
552
+ };
553
+ };
554
+ try {
555
+ const result = execute();
556
+ if (isPromiseLike(result)) {
557
+ return result
558
+ .then((resolved) => normalizeSchemaValidationResult(resolved))
559
+ .catch((error) => normalizeMappedError(error));
560
+ }
561
+ return normalizeSchemaValidationResult(result);
562
+ }
563
+ catch (error) {
564
+ return normalizeMappedError(error);
565
+ }
566
+ }
567
+ function applyValidationOutcome(outcome, path) {
568
+ if (path && options.schema?.validateField) {
569
+ errors.update((prev) => {
570
+ const next = {};
571
+ for (const [key, message] of Object.entries(prev)) {
572
+ if (!isPathRelated(key, path)) {
573
+ next[key] = message;
574
+ }
575
+ }
576
+ for (const [key, message] of Object.entries(outcome.fieldErrors)) {
577
+ if (isPathRelated(key, path)) {
578
+ next[key] = message;
579
+ }
580
+ }
581
+ return next;
582
+ });
583
+ formErrorSignal.set(outcome.formError ?? null);
584
+ }
585
+ else {
586
+ errors.set(outcome.fieldErrors);
587
+ formErrorSignal.set(outcome.formError ?? null);
588
+ }
589
+ return Object.keys(outcome.fieldErrors).length === 0 &&
590
+ !(typeof outcome.formError === 'string' && outcome.formError.length > 0);
591
+ }
320
592
  // Error Management
321
593
  function setError(path, message) {
322
594
  errors.update((prev) => ({ ...prev, [path]: message }));
@@ -346,6 +618,26 @@ export function createForm(options = {}) {
346
618
  function formError() {
347
619
  return formErrorSignal();
348
620
  }
621
+ function watch(path, fn) {
622
+ const watcher = {
623
+ path,
624
+ callback: fn,
625
+ lastValue: readPathValue(path),
626
+ };
627
+ pathWatchers.add(watcher);
628
+ let active = true;
629
+ const unsubscribe = () => {
630
+ if (!active)
631
+ return;
632
+ active = false;
633
+ pathWatchers.delete(watcher);
634
+ };
635
+ const ownerScope = getCurrentScope();
636
+ if (ownerScope) {
637
+ ownerScope.onCleanup(unsubscribe);
638
+ }
639
+ return unsubscribe;
640
+ }
349
641
  // Touched / Dirty Management
350
642
  function touched(path) {
351
643
  return touchedSet().has(path);
@@ -372,38 +664,53 @@ export function createForm(options = {}) {
372
664
  return next;
373
665
  });
374
666
  }
375
- // Validation
376
- function validate(data) {
377
- if (!options.validate)
378
- return true;
379
- clearErrors();
380
- const result = options.validate(data);
381
- if (!result)
382
- return true;
383
- // Check if result is object with fieldErrors/formError
384
- if (typeof result === 'object' && result !== null) {
385
- if ('fieldErrors' in result || 'formError' in result) {
386
- const typedResult = result;
387
- if (typedResult.fieldErrors) {
388
- errors.set(typedResult.fieldErrors);
389
- }
390
- if (typedResult.formError) {
391
- formErrorSignal.set(typedResult.formError);
392
- }
393
- // Check if fieldErrors is empty (not just truthy)
394
- // Validator can return { fieldErrors: {}, formError: null } which is valid
395
- const hasFieldErrors = typedResult.fieldErrors && Object.keys(typedResult.fieldErrors).length > 0;
396
- const hasFormError = typedResult.formError && typedResult.formError.length > 0;
397
- return !hasFieldErrors && !hasFormError;
667
+ function resolveValidationOutcome(data, opts = {}) {
668
+ if (!options.schema && !options.validate) {
669
+ return { fieldErrors: {}, value: data, hasValue: true };
670
+ }
671
+ const runLegacyAfterSchema = (schemaOutcome) => {
672
+ if (!options.validate)
673
+ return schemaOutcome;
674
+ const legacyInput = schemaOutcome.hasValue ? schemaOutcome.value : data;
675
+ const legacyOutcome = runLegacyValidation(legacyInput);
676
+ if (!isPromiseLike(legacyOutcome)) {
677
+ return mergeValidationOutcomes([schemaOutcome, legacyOutcome]);
398
678
  }
399
- else {
400
- // Assume it's FieldErrors
401
- const fieldErrors = result;
402
- errors.set(fieldErrors);
403
- return Object.keys(fieldErrors).length === 0;
679
+ return Promise.resolve(legacyOutcome).then((resolvedLegacy) => mergeValidationOutcomes([schemaOutcome, resolvedLegacy]));
680
+ };
681
+ if (options.schema) {
682
+ const schemaOutcome = runSchemaValidation(data, opts.path);
683
+ if (!isPromiseLike(schemaOutcome)) {
684
+ return runLegacyAfterSchema(schemaOutcome);
404
685
  }
686
+ return Promise.resolve(schemaOutcome).then((resolvedSchema) => runLegacyAfterSchema(resolvedSchema));
687
+ }
688
+ return runLegacyValidation(data);
689
+ }
690
+ function finalizeValidation(token, sourceData, outcome, opts = {}) {
691
+ if (!isValidationCurrent(token)) {
692
+ return { isValid: false, data: sourceData };
693
+ }
694
+ const isValid = applyValidationOutcome(outcome, opts.path);
695
+ const data = outcome.hasValue ? outcome.value : sourceData;
696
+ return { isValid, data };
697
+ }
698
+ // Validation
699
+ function validate(data, opts = {}) {
700
+ const token = beginValidation(opts.path);
701
+ const outcome = resolveValidationOutcome(data, opts);
702
+ if (!isPromiseLike(outcome)) {
703
+ return finalizeValidation(token, data, outcome, opts).isValid;
704
+ }
705
+ return outcome.then((resolved) => finalizeValidation(token, data, resolved, opts).isValid);
706
+ }
707
+ function validateForSubmit(data) {
708
+ const token = beginValidation();
709
+ const outcome = resolveValidationOutcome(data);
710
+ if (!isPromiseLike(outcome)) {
711
+ return finalizeValidation(token, data, outcome);
405
712
  }
406
- return true;
713
+ return outcome.then((resolved) => finalizeValidation(token, data, resolved));
407
714
  }
408
715
  // Field Registry
409
716
  function _registerField(path, element) {
@@ -423,16 +730,25 @@ export function createForm(options = {}) {
423
730
  const fd = new FormData(formElement);
424
731
  const parser = options.parse ?? parseFormData;
425
732
  const data = parser(formElement, fd);
426
- validate(data);
733
+ const result = validate(data, { path: currentPath });
734
+ if (isPromiseLike(result)) {
735
+ void result.catch((err) => {
736
+ if (typeof console !== 'undefined') {
737
+ console.error('[Dalila] Field validation failed:', err);
738
+ }
739
+ });
740
+ }
427
741
  }
428
742
  };
429
743
  element.addEventListener('blur', handleBlur);
430
744
  // Setup change handler for dirty + validation
431
745
  const handleChange = () => {
432
- if (!defaultsInitialized)
433
- return;
434
746
  // Use dynamic path lookup instead of captured path
435
747
  const currentPath = getCurrentPath();
748
+ if (!defaultsInitialized) {
749
+ notifyWatchers(currentPath);
750
+ return;
751
+ }
436
752
  const input = element;
437
753
  const defaultValue = getNestedValue(defaultValues, currentPath);
438
754
  let currentValue;
@@ -487,8 +803,16 @@ export function createForm(options = {}) {
487
803
  const fd = new FormData(formElement);
488
804
  const parser = options.parse ?? parseFormData;
489
805
  const data = parser(formElement, fd);
490
- validate(data);
806
+ const result = validate(data, { path: currentPath });
807
+ if (isPromiseLike(result)) {
808
+ void result.catch((err) => {
809
+ if (typeof console !== 'undefined') {
810
+ console.error('[Dalila] Field validation failed:', err);
811
+ }
812
+ });
813
+ }
491
814
  }
815
+ notifyWatchers(currentPath);
492
816
  };
493
817
  element.addEventListener('change', handleChange);
494
818
  element.addEventListener('input', handleChange);
@@ -525,13 +849,14 @@ export function createForm(options = {}) {
525
849
  const parser = options.parse ?? parseFormData;
526
850
  const data = parser(form, fd);
527
851
  // Validate
528
- const isValid = validate(data);
852
+ const validation = await Promise.resolve(validateForSubmit(data));
853
+ const isValid = validation.isValid;
529
854
  if (!isValid) {
530
855
  focus(); // Focus first error
531
856
  return;
532
857
  }
533
858
  // Call handler
534
- await handler(data, { signal });
859
+ await handler(validation.data, { signal });
535
860
  // If aborted, don't do anything
536
861
  if (signal.aborted)
537
862
  return;
@@ -672,6 +997,7 @@ export function createForm(options = {}) {
672
997
  submitController?.abort();
673
998
  submitController = null;
674
999
  submittingSignal.set(false);
1000
+ notifyWatchers(undefined, { preferFieldArrayPaths: fieldArrayRegistry.keys() });
675
1001
  }
676
1002
  // Field Arrays
677
1003
  function fieldArray(path) {
@@ -683,11 +1009,14 @@ export function createForm(options = {}) {
683
1009
  const array = createFieldArray(path, {
684
1010
  form: formElement,
685
1011
  scope,
1012
+ onMutate: () => notifyWatchers(path, { preferFieldArrayPath: path }),
686
1013
  // Pass meta-state signals for remapping on reorder
687
1014
  errors,
688
1015
  touchedSet,
689
1016
  dirtySet,
690
1017
  });
1018
+ // Register before hydration so watch reads can see this array immediately.
1019
+ fieldArrayRegistry.set(path, array);
691
1020
  // Initialize from defaultValues if available
692
1021
  // When d-array is first rendered, it should start with values from defaultValues
693
1022
  if (defaultsInitialized) {
@@ -696,7 +1025,6 @@ export function createForm(options = {}) {
696
1025
  array.replace(initialValue);
697
1026
  }
698
1027
  }
699
- fieldArrayRegistry.set(path, array);
700
1028
  return array;
701
1029
  }
702
1030
  // Getters
@@ -711,6 +1039,7 @@ export function createForm(options = {}) {
711
1039
  }
712
1040
  function _setFormElement(form) {
713
1041
  formElement = form;
1042
+ notifyWatchers();
714
1043
  }
715
1044
  // Cleanup on scope disposal
716
1045
  if (scope) {
@@ -718,6 +1047,7 @@ export function createForm(options = {}) {
718
1047
  submitController?.abort();
719
1048
  fieldRegistry.clear();
720
1049
  fieldArrayRegistry.clear();
1050
+ pathWatchers.clear();
721
1051
  });
722
1052
  }
723
1053
  return {
@@ -733,6 +1063,7 @@ export function createForm(options = {}) {
733
1063
  submitting,
734
1064
  submitCount,
735
1065
  focus,
1066
+ watch,
736
1067
  _registerField,
737
1068
  _getFormElement,
738
1069
  _setFormElement,
@@ -844,6 +1175,7 @@ function createFieldArray(basePath, options) {
844
1175
  newKeys.forEach((key, i) => next.set(key, items[i]));
845
1176
  return next;
846
1177
  });
1178
+ options.onMutate?.();
847
1179
  }
848
1180
  function remove(key) {
849
1181
  const removeIndex = _getIndex(key);
@@ -905,6 +1237,7 @@ function createFieldArray(basePath, options) {
905
1237
  remapMetaState(oldIndices, newIndices);
906
1238
  }
907
1239
  }
1240
+ options.onMutate?.();
908
1241
  }
909
1242
  function removeAt(index) {
910
1243
  if (index < 0 || index >= keys().length)
@@ -939,6 +1272,7 @@ function createFieldArray(basePath, options) {
939
1272
  next.set(key, value);
940
1273
  return next;
941
1274
  });
1275
+ options.onMutate?.();
942
1276
  }
943
1277
  function move(fromIndex, toIndex) {
944
1278
  const len = keys().length;
@@ -974,6 +1308,7 @@ function createFieldArray(basePath, options) {
974
1308
  next.splice(toIndex, 0, item);
975
1309
  return next;
976
1310
  });
1311
+ options.onMutate?.();
977
1312
  }
978
1313
  function swap(indexA, indexB) {
979
1314
  const len = keys().length;
@@ -988,6 +1323,7 @@ function createFieldArray(basePath, options) {
988
1323
  [next[indexA], next[indexB]] = [next[indexB], next[indexA]];
989
1324
  return next;
990
1325
  });
1326
+ options.onMutate?.();
991
1327
  }
992
1328
  function replace(newValues) {
993
1329
  const newKeys = newValues.map(() => generateKey());
@@ -1027,6 +1363,7 @@ function createFieldArray(basePath, options) {
1027
1363
  }
1028
1364
  keys.set(newKeys);
1029
1365
  values.set(new Map(newKeys.map((key, i) => [key, newValues[i]])));
1366
+ options.onMutate?.();
1030
1367
  }
1031
1368
  function update(key, value) {
1032
1369
  values.update((prev) => {
@@ -1034,6 +1371,7 @@ function createFieldArray(basePath, options) {
1034
1371
  next.set(key, value);
1035
1372
  return next;
1036
1373
  });
1374
+ options.onMutate?.();
1037
1375
  }
1038
1376
  function updateAt(index, value) {
1039
1377
  if (index < 0 || index >= keys().length)
@@ -1,2 +1,3 @@
1
1
  export * from "./form-types.js";
2
2
  export { createForm, parseFormData } from "./form.js";
3
+ export { zodAdapter, valibotAdapter, yupAdapter } from "./schema-adapters.js";
@@ -1,2 +1,3 @@
1
1
  export * from "./form-types.js";
2
2
  export { createForm, parseFormData } from "./form.js";
3
+ export { zodAdapter, valibotAdapter, yupAdapter } from "./schema-adapters.js";
@@ -0,0 +1,14 @@
1
+ import type { FormSchemaAdapter } from "./form-types.js";
2
+ interface ValibotRuntime {
3
+ safeParseAsync?: (schema: unknown, input: unknown) => Promise<unknown>;
4
+ safeParse?: (schema: unknown, input: unknown) => unknown;
5
+ parseAsync?: (schema: unknown, input: unknown) => Promise<unknown>;
6
+ parse?: (schema: unknown, input: unknown) => unknown;
7
+ }
8
+ declare global {
9
+ var valibot: ValibotRuntime | undefined;
10
+ }
11
+ export declare function zodAdapter<T = unknown>(schema: unknown): FormSchemaAdapter<T>;
12
+ export declare function valibotAdapter<T = unknown>(schema: unknown, runtime?: ValibotRuntime): FormSchemaAdapter<T>;
13
+ export declare function yupAdapter<T = unknown>(schema: unknown): FormSchemaAdapter<T>;
14
+ export {};
@@ -0,0 +1,304 @@
1
+ let resolvedValibotRuntime;
2
+ let pendingValibotRuntime = null;
3
+ let attemptedValibotModuleImport = false;
4
+ function isPathRelated(pathA, pathB) {
5
+ if (pathA === pathB)
6
+ return true;
7
+ if (pathA.startsWith(`${pathB}.`) || pathA.startsWith(`${pathB}[`))
8
+ return true;
9
+ if (pathB.startsWith(`${pathA}.`) || pathB.startsWith(`${pathA}[`))
10
+ return true;
11
+ return false;
12
+ }
13
+ function normalizePathSegment(segment) {
14
+ if (typeof segment === "string" || typeof segment === "number")
15
+ return segment;
16
+ if (!segment || typeof segment !== "object")
17
+ return null;
18
+ if ("key" in segment) {
19
+ const key = segment.key;
20
+ if (typeof key === "string" || typeof key === "number")
21
+ return key;
22
+ }
23
+ if ("path" in segment) {
24
+ const path = segment.path;
25
+ if (typeof path === "string" || typeof path === "number")
26
+ return path;
27
+ }
28
+ return null;
29
+ }
30
+ function normalizePath(path) {
31
+ if (typeof path === "string")
32
+ return path;
33
+ if (typeof path === "number")
34
+ return `[${path}]`;
35
+ if (!Array.isArray(path))
36
+ return undefined;
37
+ let out = "";
38
+ for (const segment of path) {
39
+ const normalized = normalizePathSegment(segment);
40
+ if (normalized == null)
41
+ continue;
42
+ if (typeof normalized === "number") {
43
+ out += `[${normalized}]`;
44
+ }
45
+ else if (out.length === 0) {
46
+ out += normalized;
47
+ }
48
+ else {
49
+ out += `.${normalized}`;
50
+ }
51
+ }
52
+ return out.length > 0 ? out : undefined;
53
+ }
54
+ function filterIssuesByPath(issues, path) {
55
+ if (!issues || issues.length === 0)
56
+ return [];
57
+ return issues.filter((issue) => issue.path && isPathRelated(issue.path, path));
58
+ }
59
+ function normalizeAdapterFailure(error, map) {
60
+ const issues = map(error);
61
+ if (issues.length > 0) {
62
+ return { issues };
63
+ }
64
+ return {
65
+ issues: [],
66
+ formError: error instanceof Error ? error.message : "Schema validation failed",
67
+ };
68
+ }
69
+ function mapZodIssues(error) {
70
+ const issues = error?.issues;
71
+ if (!Array.isArray(issues))
72
+ return [];
73
+ const normalized = [];
74
+ for (const issue of issues) {
75
+ if (!issue || typeof issue !== "object")
76
+ continue;
77
+ const message = issue.message;
78
+ if (typeof message !== "string" || message.length === 0)
79
+ continue;
80
+ normalized.push({
81
+ path: normalizePath(issue.path),
82
+ message,
83
+ });
84
+ }
85
+ return normalized;
86
+ }
87
+ function mapValibotIssues(error) {
88
+ const issues = error?.issues;
89
+ if (!Array.isArray(issues))
90
+ return [];
91
+ const normalized = [];
92
+ for (const issue of issues) {
93
+ if (!issue || typeof issue !== "object")
94
+ continue;
95
+ const message = issue.message;
96
+ if (typeof message !== "string" || message.length === 0)
97
+ continue;
98
+ normalized.push({
99
+ path: normalizePath(issue.path),
100
+ message,
101
+ });
102
+ }
103
+ return normalized;
104
+ }
105
+ async function runSafeParseSchema(schema, data) {
106
+ const s = schema;
107
+ if (typeof s.safeParseAsync === "function") {
108
+ const result = await s.safeParseAsync(data);
109
+ const success = result?.success === true;
110
+ if (success) {
111
+ return { ok: true, value: result.data };
112
+ }
113
+ return { ok: false, error: result.error };
114
+ }
115
+ if (typeof s.safeParse === "function") {
116
+ const result = s.safeParse(data);
117
+ const success = result?.success === true;
118
+ if (success) {
119
+ return { ok: true, value: result.data };
120
+ }
121
+ return { ok: false, error: result.error };
122
+ }
123
+ try {
124
+ if (typeof s.parseAsync === "function") {
125
+ return { ok: true, value: await s.parseAsync(data) };
126
+ }
127
+ if (typeof s.parse === "function") {
128
+ return { ok: true, value: s.parse(data) };
129
+ }
130
+ }
131
+ catch (error) {
132
+ return { ok: false, error };
133
+ }
134
+ throw new Error("Invalid schema adapter input: expected safeParse/parse methods.");
135
+ }
136
+ async function resolveValibotRuntime(runtime) {
137
+ if (runtime)
138
+ return runtime;
139
+ const globalRuntime = globalThis.valibot;
140
+ if (globalRuntime) {
141
+ resolvedValibotRuntime = globalRuntime;
142
+ return globalRuntime;
143
+ }
144
+ if (resolvedValibotRuntime)
145
+ return resolvedValibotRuntime;
146
+ if (attemptedValibotModuleImport)
147
+ return null;
148
+ if (!pendingValibotRuntime) {
149
+ attemptedValibotModuleImport = true;
150
+ pendingValibotRuntime = (async () => {
151
+ try {
152
+ const modName = "valibot";
153
+ const imported = await import(modName);
154
+ const moduleRuntime = imported;
155
+ resolvedValibotRuntime = moduleRuntime;
156
+ return moduleRuntime;
157
+ }
158
+ catch {
159
+ return null;
160
+ }
161
+ finally {
162
+ pendingValibotRuntime = null;
163
+ }
164
+ })();
165
+ }
166
+ return pendingValibotRuntime;
167
+ }
168
+ async function runValibotParse(schema, data, runtime) {
169
+ const resolvedRuntime = await resolveValibotRuntime(runtime);
170
+ if (resolvedRuntime) {
171
+ if (typeof resolvedRuntime.safeParseAsync === "function") {
172
+ const result = await resolvedRuntime.safeParseAsync(schema, data);
173
+ const success = result?.success === true;
174
+ if (success) {
175
+ return { ok: true, value: result.output ?? result.data };
176
+ }
177
+ return { ok: false, error: result.issues ? { issues: result.issues } : result.error };
178
+ }
179
+ if (typeof resolvedRuntime.safeParse === "function") {
180
+ const result = resolvedRuntime.safeParse(schema, data);
181
+ const success = result?.success === true;
182
+ if (success) {
183
+ return { ok: true, value: result.output ?? result.data };
184
+ }
185
+ return { ok: false, error: result.issues ? { issues: result.issues } : result.error };
186
+ }
187
+ try {
188
+ if (typeof resolvedRuntime.parseAsync === "function") {
189
+ return { ok: true, value: await resolvedRuntime.parseAsync(schema, data) };
190
+ }
191
+ if (typeof resolvedRuntime.parse === "function") {
192
+ return { ok: true, value: resolvedRuntime.parse(schema, data) };
193
+ }
194
+ }
195
+ catch (error) {
196
+ return { ok: false, error };
197
+ }
198
+ }
199
+ try {
200
+ return await runSafeParseSchema(schema, data);
201
+ }
202
+ catch {
203
+ throw new Error("Invalid valibot adapter input: expected valibot safeParse(schema, input) runtime or schema parse methods.");
204
+ }
205
+ }
206
+ export function zodAdapter(schema) {
207
+ function mapErrors(error) {
208
+ return mapZodIssues(error);
209
+ }
210
+ const runValidate = async (data) => {
211
+ const result = await runSafeParseSchema(schema, data);
212
+ if (result.ok) {
213
+ return { value: result.value, issues: [] };
214
+ }
215
+ return normalizeAdapterFailure(result.error, mapErrors);
216
+ };
217
+ return {
218
+ validate: runValidate,
219
+ async validateField(path, _value, data) {
220
+ const full = await runValidate(data);
221
+ return { ...full, issues: filterIssuesByPath(full.issues, path) };
222
+ },
223
+ mapErrors,
224
+ };
225
+ }
226
+ export function valibotAdapter(schema, runtime) {
227
+ function mapErrors(error) {
228
+ return mapValibotIssues(error);
229
+ }
230
+ const runValidate = async (data) => {
231
+ const result = await runValibotParse(schema, data, runtime);
232
+ if (result.ok) {
233
+ return { value: result.value, issues: [] };
234
+ }
235
+ return normalizeAdapterFailure(result.error, mapErrors);
236
+ };
237
+ return {
238
+ validate: runValidate,
239
+ async validateField(path, _value, data) {
240
+ const full = await runValidate(data);
241
+ return { ...full, issues: filterIssuesByPath(full.issues, path) };
242
+ },
243
+ mapErrors,
244
+ };
245
+ }
246
+ function mapYupError(error) {
247
+ const err = error;
248
+ const mapped = [];
249
+ if (Array.isArray(err.inner) && err.inner.length > 0) {
250
+ for (const item of err.inner) {
251
+ if (!item || typeof item.message !== "string")
252
+ continue;
253
+ mapped.push({ path: typeof item.path === "string" ? item.path : undefined, message: item.message });
254
+ }
255
+ return mapped;
256
+ }
257
+ if (typeof err.message === "string" && err.message.length > 0) {
258
+ mapped.push({
259
+ path: typeof err.path === "string" ? err.path : undefined,
260
+ message: err.message,
261
+ });
262
+ }
263
+ return mapped;
264
+ }
265
+ export function yupAdapter(schema) {
266
+ const s = schema;
267
+ function mapErrors(error) {
268
+ return mapYupError(error);
269
+ }
270
+ const runValidate = async (data) => {
271
+ if (typeof s.validate !== "function") {
272
+ throw new Error("Invalid yup schema adapter input: expected validate().");
273
+ }
274
+ try {
275
+ const value = await s.validate(data, { abortEarly: false });
276
+ return { value: value, issues: [] };
277
+ }
278
+ catch (error) {
279
+ return normalizeAdapterFailure(error, mapErrors);
280
+ }
281
+ };
282
+ return {
283
+ validate: runValidate,
284
+ async validateField(path, _value, data) {
285
+ if (typeof s.validateAt === "function") {
286
+ try {
287
+ await s.validateAt(path, data, { abortEarly: false });
288
+ return { issues: [] };
289
+ }
290
+ catch (error) {
291
+ const normalized = normalizeAdapterFailure(error, mapErrors);
292
+ const formLevelIssue = (normalized.issues ?? []).find((issue) => !issue.path);
293
+ return {
294
+ formError: normalized.formError ?? formLevelIssue?.message,
295
+ issues: filterIssuesByPath(normalized.issues, path),
296
+ };
297
+ }
298
+ }
299
+ const full = await runValidate(data);
300
+ return { ...full, issues: filterIssuesByPath(full.issues, path) };
301
+ },
302
+ mapErrors,
303
+ };
304
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dalila",
3
- "version": "1.9.13",
3
+ "version": "1.9.14",
4
4
  "description": "DOM-first reactive framework based on signals",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",