pulse-js-framework 1.7.5 → 1.7.8

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/runtime/form.js CHANGED
@@ -23,6 +23,15 @@ import { pulse, effect, computed, batch } from './pulse.js';
23
23
  * @property {string} [message] - Default error message
24
24
  */
25
25
 
26
+ /**
27
+ * Check if a validator is async
28
+ * @param {ValidationRule} rule - Validation rule
29
+ * @returns {boolean}
30
+ */
31
+ function isAsyncValidator(rule) {
32
+ return rule.async === true;
33
+ }
34
+
26
35
  /**
27
36
  * Built-in validation rules
28
37
  */
@@ -161,6 +170,112 @@ export const validators = {
161
170
  }
162
171
  return true;
163
172
  }
173
+ }),
174
+
175
+ // ============================================================================
176
+ // Async Validators
177
+ // ============================================================================
178
+
179
+ /**
180
+ * Async custom validation function
181
+ * @param {function(any, Object): Promise<boolean|string>} fn - Async validation function
182
+ * @param {Object} [options] - Options
183
+ * @param {number} [options.debounce=300] - Debounce delay in ms
184
+ *
185
+ * @example
186
+ * validators.asyncCustom(async (value) => {
187
+ * const exists = await checkUsername(value);
188
+ * return exists ? 'Username already taken' : true;
189
+ * })
190
+ */
191
+ asyncCustom: (fn, options = {}) => ({
192
+ async: true,
193
+ debounce: options.debounce ?? 300,
194
+ validate: fn
195
+ }),
196
+
197
+ /**
198
+ * Async email validation (check availability via API)
199
+ * @param {function(string): Promise<boolean>} checkFn - Returns true if email is available
200
+ * @param {string} [message='Email is already taken']
201
+ * @param {Object} [options] - Options
202
+ * @param {number} [options.debounce=300] - Debounce delay in ms
203
+ *
204
+ * @example
205
+ * validators.asyncEmail(
206
+ * async (email) => {
207
+ * const res = await fetch(`/api/check-email?email=${email}`);
208
+ * const { available } = await res.json();
209
+ * return available;
210
+ * },
211
+ * 'This email is already registered'
212
+ * )
213
+ */
214
+ asyncEmail: (checkFn, message = 'Email is already taken', options = {}) => ({
215
+ async: true,
216
+ debounce: options.debounce ?? 300,
217
+ validate: async (value, allValues) => {
218
+ if (!value) return true;
219
+ // First check format synchronously
220
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
221
+ if (!emailRegex.test(value)) {
222
+ return 'Invalid email address';
223
+ }
224
+ // Then check availability
225
+ const available = await checkFn(value, allValues);
226
+ return available ? true : message;
227
+ }
228
+ }),
229
+
230
+ /**
231
+ * Async unique validation (check uniqueness via API)
232
+ * @param {function(any, Object): Promise<boolean>} checkFn - Returns true if value is unique
233
+ * @param {string} [message='This value is already taken']
234
+ * @param {Object} [options] - Options
235
+ * @param {number} [options.debounce=300] - Debounce delay in ms
236
+ *
237
+ * @example
238
+ * validators.asyncUnique(
239
+ * async (username) => {
240
+ * const res = await fetch(`/api/check-username?q=${username}`);
241
+ * return (await res.json()).available;
242
+ * },
243
+ * 'Username is already taken'
244
+ * )
245
+ */
246
+ asyncUnique: (checkFn, message = 'This value is already taken', options = {}) => ({
247
+ async: true,
248
+ debounce: options.debounce ?? 300,
249
+ validate: async (value, allValues) => {
250
+ if (!value) return true;
251
+ const unique = await checkFn(value, allValues);
252
+ return unique ? true : message;
253
+ }
254
+ }),
255
+
256
+ /**
257
+ * Async server-side validation
258
+ * @param {function(any, Object): Promise<string|null>} validateFn - Returns error message or null
259
+ * @param {Object} [options] - Options
260
+ * @param {number} [options.debounce=300] - Debounce delay in ms
261
+ *
262
+ * @example
263
+ * validators.asyncServer(async (value) => {
264
+ * const res = await fetch('/api/validate', {
265
+ * method: 'POST',
266
+ * body: JSON.stringify({ value })
267
+ * });
268
+ * const { error } = await res.json();
269
+ * return error; // null if valid, error message if invalid
270
+ * })
271
+ */
272
+ asyncServer: (validateFn, options = {}) => ({
273
+ async: true,
274
+ debounce: options.debounce ?? 300,
275
+ validate: async (value, allValues) => {
276
+ const error = await validateFn(value, allValues);
277
+ return error === null ? true : error;
278
+ }
164
279
  })
165
280
  };
166
281
 
@@ -213,28 +328,123 @@ export function useForm(initialValues, validationSchema = {}, options = {}) {
213
328
  const fields = {};
214
329
  const fieldNames = Object.keys(initialValues);
215
330
 
331
+ // Version counters for async validation race condition handling
332
+ const validationVersions = {};
333
+ const debounceTimers = {};
334
+
216
335
  for (const name of fieldNames) {
217
336
  const initialValue = initialValues[name];
218
337
  const rules = validationSchema[name] || [];
219
338
 
339
+ // Separate sync and async rules
340
+ const syncRules = rules.filter(r => !isAsyncValidator(r));
341
+ const asyncRules = rules.filter(r => isAsyncValidator(r));
342
+
220
343
  const value = pulse(initialValue);
221
344
  const error = pulse(null);
222
345
  const touched = pulse(false);
346
+ const validating = pulse(false);
223
347
  const dirty = computed(() => value.get() !== initialValue);
224
- const valid = computed(() => error.get() === null);
348
+ const valid = computed(() => error.get() === null && !validating.get());
225
349
 
226
- // Validate a single field
227
- const validateField = () => {
350
+ // Initialize version counter
351
+ validationVersions[name] = 0;
352
+ debounceTimers[name] = new Map();
353
+
354
+ /**
355
+ * Run sync validators only
356
+ */
357
+ const validateFieldSync = () => {
228
358
  const currentValue = value.get();
229
359
  const allValues = getValues();
230
360
 
231
- for (const rule of rules) {
361
+ for (const rule of syncRules) {
232
362
  const result = rule.validate(currentValue, allValues);
233
363
  if (result !== true) {
234
364
  error.set(typeof result === 'string' ? result : rule.message || 'Invalid');
235
365
  return false;
236
366
  }
237
367
  }
368
+ return true;
369
+ };
370
+
371
+ /**
372
+ * Run async validators with debouncing
373
+ */
374
+ const validateFieldAsync = async () => {
375
+ if (asyncRules.length === 0) return true;
376
+
377
+ const version = ++validationVersions[name];
378
+ validating.set(true);
379
+
380
+ try {
381
+ const currentValue = value.get();
382
+ const allValues = getValues();
383
+
384
+ for (const rule of asyncRules) {
385
+ // Cancel previous debounce timer for this rule
386
+ const existingTimer = debounceTimers[name].get(rule);
387
+ if (existingTimer) {
388
+ clearTimeout(existingTimer);
389
+ }
390
+
391
+ // Debounce async validation
392
+ await new Promise((resolve) => {
393
+ const timer = setTimeout(resolve, rule.debounce || 300);
394
+ debounceTimers[name].set(rule, timer);
395
+ });
396
+
397
+ // Check if this validation is still current
398
+ if (version !== validationVersions[name]) {
399
+ return null; // Cancelled
400
+ }
401
+
402
+ const result = await rule.validate(currentValue, allValues);
403
+
404
+ // Check again after async operation
405
+ if (version !== validationVersions[name]) {
406
+ return null; // Cancelled
407
+ }
408
+
409
+ if (result !== true) {
410
+ error.set(typeof result === 'string' ? result : rule.message || 'Invalid');
411
+ validating.set(false);
412
+ return false;
413
+ }
414
+ }
415
+
416
+ // All async validations passed
417
+ if (version === validationVersions[name]) {
418
+ error.set(null);
419
+ validating.set(false);
420
+ }
421
+ return true;
422
+ } catch (err) {
423
+ if (version === validationVersions[name]) {
424
+ error.set(err.message || 'Validation failed');
425
+ validating.set(false);
426
+ }
427
+ return false;
428
+ }
429
+ };
430
+
431
+ /**
432
+ * Full validation (sync + async)
433
+ */
434
+ const validateField = async () => {
435
+ // First run sync validators
436
+ if (!validateFieldSync()) {
437
+ validating.set(false);
438
+ return false;
439
+ }
440
+
441
+ // Then run async validators
442
+ if (asyncRules.length > 0) {
443
+ const result = await validateFieldAsync();
444
+ if (result === null) return null; // Cancelled
445
+ return result;
446
+ }
447
+
238
448
  error.set(null);
239
449
  return true;
240
450
  };
@@ -250,7 +460,14 @@ export function useForm(initialValues, validationSchema = {}, options = {}) {
250
460
  value.set(newValue);
251
461
 
252
462
  if (validateOnChange && (mode === 'onChange' || touched.get())) {
253
- validateField();
463
+ // Run sync validation immediately
464
+ validateFieldSync();
465
+ // Trigger async validation (debounced)
466
+ if (asyncRules.length > 0) {
467
+ validateField();
468
+ } else {
469
+ error.set(null);
470
+ }
254
471
  }
255
472
  };
256
473
 
@@ -271,15 +488,19 @@ export function useForm(initialValues, validationSchema = {}, options = {}) {
271
488
  touched,
272
489
  dirty,
273
490
  valid,
491
+ validating,
274
492
  validate: validateField,
493
+ validateSync: validateFieldSync,
275
494
  onChange,
276
495
  onBlur,
277
496
  onFocus,
278
497
  reset: () => {
498
+ validationVersions[name]++; // Cancel pending async validation
279
499
  batch(() => {
280
500
  value.set(initialValue);
281
501
  error.set(null);
282
502
  touched.set(false);
503
+ validating.set(false);
283
504
  });
284
505
  },
285
506
  setError: (msg) => error.set(msg),
@@ -296,6 +517,10 @@ export function useForm(initialValues, validationSchema = {}, options = {}) {
296
517
  return fieldNames.every(name => fields[name].valid.get());
297
518
  });
298
519
 
520
+ const isValidating = computed(() => {
521
+ return fieldNames.some(name => fields[name].validating.get());
522
+ });
523
+
299
524
  const isDirty = computed(() => {
300
525
  return fieldNames.some(name => fields[name].dirty.get());
301
526
  });
@@ -354,18 +579,41 @@ export function useForm(initialValues, validationSchema = {}, options = {}) {
354
579
  }
355
580
 
356
581
  /**
357
- * Validate all fields
582
+ * Validate all fields (sync only, for immediate check)
358
583
  */
359
- function validateAll() {
584
+ function validateAllSync() {
360
585
  let allValid = true;
361
586
  for (const name of fieldNames) {
362
- if (!fields[name].validate()) {
587
+ if (!fields[name].validateSync()) {
363
588
  allValid = false;
364
589
  }
365
590
  }
366
591
  return allValid;
367
592
  }
368
593
 
594
+ /**
595
+ * Validate all fields (sync + async)
596
+ * @returns {Promise<boolean>}
597
+ */
598
+ async function validateAll() {
599
+ // First run all sync validations
600
+ let allValid = validateAllSync();
601
+
602
+ // Then run async validations in parallel
603
+ const asyncResults = await Promise.all(
604
+ fieldNames.map(name => fields[name].validate())
605
+ );
606
+
607
+ // Check results (null means cancelled, which we treat as valid for now)
608
+ for (const result of asyncResults) {
609
+ if (result === false) {
610
+ allValid = false;
611
+ }
612
+ }
613
+
614
+ return allValid;
615
+ }
616
+
369
617
  /**
370
618
  * Reset form to initial values
371
619
  */
@@ -400,7 +648,7 @@ export function useForm(initialValues, validationSchema = {}, options = {}) {
400
648
 
401
649
  // Validate if required
402
650
  if (validateOnSubmit) {
403
- const valid = validateAll();
651
+ const valid = await validateAll();
404
652
  if (!valid) {
405
653
  if (onError) {
406
654
  onError(errors.get());
@@ -453,6 +701,7 @@ export function useForm(initialValues, validationSchema = {}, options = {}) {
453
701
  return {
454
702
  fields,
455
703
  isValid,
704
+ isValidating,
456
705
  isDirty,
457
706
  isTouched,
458
707
  isSubmitting,
@@ -462,6 +711,7 @@ export function useForm(initialValues, validationSchema = {}, options = {}) {
462
711
  setValues,
463
712
  setValue,
464
713
  validateAll,
714
+ validateAllSync,
465
715
  reset,
466
716
  handleSubmit,
467
717
  setErrors,
@@ -474,35 +724,151 @@ export function useForm(initialValues, validationSchema = {}, options = {}) {
474
724
  *
475
725
  * @param {any} initialValue - Initial field value
476
726
  * @param {ValidationRule[]} [rules=[]] - Validation rules
727
+ * @param {Object} [options={}] - Field options
728
+ * @param {boolean} [options.validateOnChange=true] - Validate on change (after touched)
729
+ * @param {boolean} [options.validateOnBlur=true] - Validate on blur
477
730
  * @returns {Object} Field state and controls
478
731
  *
479
732
  * @example
480
733
  * const email = useField('', [validators.required(), validators.email()]);
481
734
  *
735
+ * // With async validation
736
+ * const username = useField('', [
737
+ * validators.required(),
738
+ * validators.asyncUnique(async (value) => checkUsernameAvailable(value))
739
+ * ]);
740
+ *
482
741
  * // Bind to input
483
742
  * el('input', { value: email.value.get(), onInput: email.onChange, onBlur: email.onBlur });
484
743
  */
485
- export function useField(initialValue, rules = []) {
744
+ export function useField(initialValue, rules = [], options = {}) {
745
+ const { validateOnChange = true, validateOnBlur = true } = options;
746
+
486
747
  const value = pulse(initialValue);
487
748
  const error = pulse(null);
488
749
  const touched = pulse(false);
750
+ const validating = pulse(false);
489
751
  const dirty = computed(() => value.get() !== initialValue);
490
- const valid = computed(() => error.get() === null);
752
+ const valid = computed(() => error.get() === null && !validating.get());
491
753
 
492
- const validate = () => {
493
- const currentValue = value.get();
754
+ // Separate sync and async rules
755
+ const syncRules = rules.filter(r => !isAsyncValidator(r));
756
+ const asyncRules = rules.filter(r => isAsyncValidator(r));
757
+
758
+ // Version counter for race condition handling
759
+ let validationVersion = 0;
760
+
761
+ // Debounce timers per async rule
762
+ const debounceTimers = new Map();
494
763
 
495
- for (const rule of rules) {
496
- const result = rule.validate(currentValue, {});
764
+ /**
765
+ * Run synchronous validators
766
+ */
767
+ const validateSync = (currentValue, allValues = {}) => {
768
+ for (const rule of syncRules) {
769
+ const result = rule.validate(currentValue, allValues);
497
770
  if (result !== true) {
498
771
  error.set(typeof result === 'string' ? result : rule.message || 'Invalid');
499
772
  return false;
500
773
  }
501
774
  }
775
+ return true;
776
+ };
777
+
778
+ /**
779
+ * Run async validators with debouncing and race condition handling
780
+ */
781
+ const validateAsync = async (currentValue, allValues = {}) => {
782
+ if (asyncRules.length === 0) return true;
783
+
784
+ const version = ++validationVersion;
785
+ validating.set(true);
786
+
787
+ try {
788
+ for (const rule of asyncRules) {
789
+ // Cancel previous debounce timer for this rule
790
+ const existingTimer = debounceTimers.get(rule);
791
+ if (existingTimer) {
792
+ clearTimeout(existingTimer);
793
+ }
794
+
795
+ // Debounce async validation
796
+ await new Promise((resolve) => {
797
+ const timer = setTimeout(resolve, rule.debounce || 300);
798
+ debounceTimers.set(rule, timer);
799
+ });
800
+
801
+ // Check if this validation is still current
802
+ if (version !== validationVersion) {
803
+ return null; // Cancelled
804
+ }
805
+
806
+ const result = await rule.validate(currentValue, allValues);
807
+
808
+ // Check again after async operation
809
+ if (version !== validationVersion) {
810
+ return null; // Cancelled
811
+ }
812
+
813
+ if (result !== true) {
814
+ error.set(typeof result === 'string' ? result : rule.message || 'Invalid');
815
+ validating.set(false);
816
+ return false;
817
+ }
818
+ }
819
+
820
+ // All async validations passed
821
+ if (version === validationVersion) {
822
+ error.set(null);
823
+ validating.set(false);
824
+ }
825
+ return true;
826
+ } catch (err) {
827
+ if (version === validationVersion) {
828
+ error.set(err.message || 'Validation failed');
829
+ validating.set(false);
830
+ }
831
+ return false;
832
+ }
833
+ };
834
+
835
+ /**
836
+ * Full validation (sync + async)
837
+ */
838
+ const validate = async (allValues = {}) => {
839
+ const currentValue = value.get();
840
+
841
+ // First run sync validators
842
+ if (!validateSync(currentValue, allValues)) {
843
+ validating.set(false);
844
+ return false;
845
+ }
846
+
847
+ // Then run async validators
848
+ if (asyncRules.length > 0) {
849
+ const result = await validateAsync(currentValue, allValues);
850
+ if (result === null) return null; // Cancelled
851
+ return result;
852
+ }
853
+
502
854
  error.set(null);
503
855
  return true;
504
856
  };
505
857
 
858
+ /**
859
+ * Sync-only validation (for immediate feedback)
860
+ */
861
+ const validateSyncOnly = (allValues = {}) => {
862
+ const currentValue = value.get();
863
+ if (!validateSync(currentValue, allValues)) {
864
+ return false;
865
+ }
866
+ if (syncRules.length > 0 && asyncRules.length === 0) {
867
+ error.set(null);
868
+ }
869
+ return true;
870
+ };
871
+
506
872
  const onChange = (eventOrValue) => {
507
873
  const newValue = eventOrValue?.target
508
874
  ? (eventOrValue.target.type === 'checkbox'
@@ -512,21 +878,30 @@ export function useField(initialValue, rules = []) {
512
878
 
513
879
  value.set(newValue);
514
880
 
515
- if (touched.get()) {
516
- validate();
881
+ if (validateOnChange && touched.get()) {
882
+ // Run sync validation immediately
883
+ validateSyncOnly();
884
+ // Trigger async validation (debounced)
885
+ if (asyncRules.length > 0) {
886
+ validate();
887
+ }
517
888
  }
518
889
  };
519
890
 
520
891
  const onBlur = () => {
521
892
  touched.set(true);
522
- validate();
893
+ if (validateOnBlur) {
894
+ validate();
895
+ }
523
896
  };
524
897
 
525
898
  const reset = () => {
899
+ validationVersion++; // Cancel any pending async validation
526
900
  batch(() => {
527
901
  value.set(initialValue);
528
902
  error.set(null);
529
903
  touched.set(false);
904
+ validating.set(false);
530
905
  });
531
906
  };
532
907
 
@@ -536,7 +911,9 @@ export function useField(initialValue, rules = []) {
536
911
  touched,
537
912
  dirty,
538
913
  valid,
914
+ validating,
539
915
  validate,
916
+ validateSync: validateSyncOnly,
540
917
  onChange,
541
918
  onBlur,
542
919
  reset,
@@ -628,12 +1005,29 @@ export function useFieldArray(initialValues = [], itemRules = []) {
628
1005
  fieldsArray.set(newValues.map(createField));
629
1006
  };
630
1007
 
631
- const validateAll = () => {
632
- // Use map instead of every to validate ALL fields (every short-circuits)
633
- const results = fieldsArray.get().map(f => f.validate());
1008
+ /**
1009
+ * Validate all fields synchronously only
1010
+ */
1011
+ const validateAllSync = () => {
1012
+ const results = fieldsArray.get().map(f => f.validateSync());
634
1013
  return results.every(r => r);
635
1014
  };
636
1015
 
1016
+ /**
1017
+ * Validate all fields (sync + async)
1018
+ */
1019
+ const validateAll = async () => {
1020
+ // First run sync validation on all
1021
+ const syncResults = validateAllSync();
1022
+
1023
+ // Then run async validation on all
1024
+ const asyncResults = await Promise.all(
1025
+ fieldsArray.get().map(f => f.validate())
1026
+ );
1027
+
1028
+ return asyncResults.every(r => r === true);
1029
+ };
1030
+
637
1031
  return {
638
1032
  fields: fieldsArray,
639
1033
  values,
@@ -647,7 +1041,8 @@ export function useFieldArray(initialValues = [], itemRules = []) {
647
1041
  swap,
648
1042
  replace,
649
1043
  reset,
650
- validateAll
1044
+ validateAll,
1045
+ validateAllSync
651
1046
  };
652
1047
  }
653
1048