pulse-js-framework 1.7.2 → 1.7.3

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.
@@ -0,0 +1,659 @@
1
+ /**
2
+ * Pulse Form Management
3
+ * @module pulse-js-framework/runtime/form
4
+ *
5
+ * Reactive form handling with validation, error management,
6
+ * and touched state tracking.
7
+ */
8
+
9
+ import { pulse, effect, computed, batch } from './pulse.js';
10
+
11
+ /**
12
+ * @typedef {Object} FieldState
13
+ * @property {any} value - Current field value
14
+ * @property {string|null} error - Validation error message
15
+ * @property {boolean} touched - Whether field has been interacted with
16
+ * @property {boolean} dirty - Whether field value differs from initial
17
+ * @property {boolean} valid - Whether field passes validation
18
+ */
19
+
20
+ /**
21
+ * @typedef {Object} ValidationRule
22
+ * @property {function(any, Object): boolean|string} validate - Validation function
23
+ * @property {string} [message] - Default error message
24
+ */
25
+
26
+ /**
27
+ * Built-in validation rules
28
+ */
29
+ export const validators = {
30
+ /**
31
+ * Required field validation
32
+ * @param {string} [message='This field is required']
33
+ */
34
+ required: (message = 'This field is required') => ({
35
+ validate: (value) => {
36
+ if (value === null || value === undefined || value === '') return message;
37
+ if (Array.isArray(value) && value.length === 0) return message;
38
+ return true;
39
+ }
40
+ }),
41
+
42
+ /**
43
+ * Minimum length validation
44
+ * @param {number} length - Minimum length
45
+ * @param {string} [message] - Error message
46
+ */
47
+ minLength: (length, message) => ({
48
+ validate: (value) => {
49
+ if (!value) return true; // Let required handle empty
50
+ if (String(value).length < length) {
51
+ return message || `Must be at least ${length} characters`;
52
+ }
53
+ return true;
54
+ }
55
+ }),
56
+
57
+ /**
58
+ * Maximum length validation
59
+ * @param {number} length - Maximum length
60
+ * @param {string} [message] - Error message
61
+ */
62
+ maxLength: (length, message) => ({
63
+ validate: (value) => {
64
+ if (!value) return true;
65
+ if (String(value).length > length) {
66
+ return message || `Must be at most ${length} characters`;
67
+ }
68
+ return true;
69
+ }
70
+ }),
71
+
72
+ /**
73
+ * Email format validation
74
+ * @param {string} [message='Invalid email address']
75
+ */
76
+ email: (message = 'Invalid email address') => ({
77
+ validate: (value) => {
78
+ if (!value) return true;
79
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
80
+ return emailRegex.test(value) || message;
81
+ }
82
+ }),
83
+
84
+ /**
85
+ * URL format validation
86
+ * @param {string} [message='Invalid URL']
87
+ */
88
+ url: (message = 'Invalid URL') => ({
89
+ validate: (value) => {
90
+ if (!value) return true;
91
+ try {
92
+ new URL(value);
93
+ return true;
94
+ } catch {
95
+ return message;
96
+ }
97
+ }
98
+ }),
99
+
100
+ /**
101
+ * Pattern (regex) validation
102
+ * @param {RegExp} pattern - Regex pattern
103
+ * @param {string} [message='Invalid format']
104
+ */
105
+ pattern: (pattern, message = 'Invalid format') => ({
106
+ validate: (value) => {
107
+ if (!value) return true;
108
+ return pattern.test(String(value)) || message;
109
+ }
110
+ }),
111
+
112
+ /**
113
+ * Minimum value validation (for numbers)
114
+ * @param {number} min - Minimum value
115
+ * @param {string} [message] - Error message
116
+ */
117
+ min: (min, message) => ({
118
+ validate: (value) => {
119
+ if (value === null || value === undefined || value === '') return true;
120
+ const num = Number(value);
121
+ if (isNaN(num) || num < min) {
122
+ return message || `Must be at least ${min}`;
123
+ }
124
+ return true;
125
+ }
126
+ }),
127
+
128
+ /**
129
+ * Maximum value validation (for numbers)
130
+ * @param {number} max - Maximum value
131
+ * @param {string} [message] - Error message
132
+ */
133
+ max: (max, message) => ({
134
+ validate: (value) => {
135
+ if (value === null || value === undefined || value === '') return true;
136
+ const num = Number(value);
137
+ if (isNaN(num) || num > max) {
138
+ return message || `Must be at most ${max}`;
139
+ }
140
+ return true;
141
+ }
142
+ }),
143
+
144
+ /**
145
+ * Custom validation function
146
+ * @param {function(any, Object): boolean|string} fn - Validation function
147
+ */
148
+ custom: (fn) => ({
149
+ validate: fn
150
+ }),
151
+
152
+ /**
153
+ * Match another field value
154
+ * @param {string} fieldName - Name of field to match
155
+ * @param {string} [message] - Error message
156
+ */
157
+ matches: (fieldName, message) => ({
158
+ validate: (value, allValues) => {
159
+ if (value !== allValues[fieldName]) {
160
+ return message || `Must match ${fieldName}`;
161
+ }
162
+ return true;
163
+ }
164
+ })
165
+ };
166
+
167
+ /**
168
+ * @typedef {Object} FormOptions
169
+ * @property {boolean} [validateOnChange=true] - Validate on value change
170
+ * @property {boolean} [validateOnBlur=true] - Validate on blur
171
+ * @property {boolean} [validateOnSubmit=true] - Validate on submit
172
+ * @property {function(Object): void} [onSubmit] - Submit handler
173
+ * @property {function(Object): void} [onError] - Error handler
174
+ * @property {'onChange'|'onBlur'|'onSubmit'} [mode='onChange'] - Validation mode
175
+ */
176
+
177
+ /**
178
+ * Create a reactive form with validation.
179
+ *
180
+ * @template T
181
+ * @param {T} initialValues - Initial form values
182
+ * @param {Object<string, ValidationRule[]>} [validationSchema={}] - Validation rules per field
183
+ * @param {FormOptions} [options={}] - Form configuration
184
+ * @returns {Object} Form state and controls
185
+ *
186
+ * @example
187
+ * const { fields, handleSubmit, isValid, reset } = useForm(
188
+ * { email: '', password: '' },
189
+ * {
190
+ * email: [validators.required(), validators.email()],
191
+ * password: [validators.required(), validators.minLength(8)]
192
+ * },
193
+ * {
194
+ * onSubmit: (values) => console.log('Submit:', values)
195
+ * }
196
+ * );
197
+ *
198
+ * // In view
199
+ * el('input', { value: fields.email.value.get(), onInput: fields.email.onChange });
200
+ * el('span.error', fields.email.error.get());
201
+ */
202
+ export function useForm(initialValues, validationSchema = {}, options = {}) {
203
+ const {
204
+ validateOnChange = true,
205
+ validateOnBlur = true,
206
+ validateOnSubmit = true,
207
+ onSubmit,
208
+ onError,
209
+ mode = 'onChange'
210
+ } = options;
211
+
212
+ // Create field states
213
+ const fields = {};
214
+ const fieldNames = Object.keys(initialValues);
215
+
216
+ for (const name of fieldNames) {
217
+ const initialValue = initialValues[name];
218
+ const rules = validationSchema[name] || [];
219
+
220
+ const value = pulse(initialValue);
221
+ const error = pulse(null);
222
+ const touched = pulse(false);
223
+ const dirty = computed(() => value.get() !== initialValue);
224
+ const valid = computed(() => error.get() === null);
225
+
226
+ // Validate a single field
227
+ const validateField = () => {
228
+ const currentValue = value.get();
229
+ const allValues = getValues();
230
+
231
+ for (const rule of rules) {
232
+ const result = rule.validate(currentValue, allValues);
233
+ if (result !== true) {
234
+ error.set(typeof result === 'string' ? result : rule.message || 'Invalid');
235
+ return false;
236
+ }
237
+ }
238
+ error.set(null);
239
+ return true;
240
+ };
241
+
242
+ // Event handlers
243
+ const onChange = (eventOrValue) => {
244
+ const newValue = eventOrValue?.target
245
+ ? (eventOrValue.target.type === 'checkbox'
246
+ ? eventOrValue.target.checked
247
+ : eventOrValue.target.value)
248
+ : eventOrValue;
249
+
250
+ value.set(newValue);
251
+
252
+ if (validateOnChange && (mode === 'onChange' || touched.get())) {
253
+ validateField();
254
+ }
255
+ };
256
+
257
+ const onBlur = () => {
258
+ touched.set(true);
259
+ if (validateOnBlur) {
260
+ validateField();
261
+ }
262
+ };
263
+
264
+ const onFocus = () => {
265
+ // Could be used for analytics or UI effects
266
+ };
267
+
268
+ fields[name] = {
269
+ value,
270
+ error,
271
+ touched,
272
+ dirty,
273
+ valid,
274
+ validate: validateField,
275
+ onChange,
276
+ onBlur,
277
+ onFocus,
278
+ reset: () => {
279
+ batch(() => {
280
+ value.set(initialValue);
281
+ error.set(null);
282
+ touched.set(false);
283
+ });
284
+ },
285
+ setError: (msg) => error.set(msg),
286
+ clearError: () => error.set(null)
287
+ };
288
+ }
289
+
290
+ // Form-level state
291
+ const isSubmitting = pulse(false);
292
+ const submitCount = pulse(0);
293
+
294
+ // Computed form state
295
+ const isValid = computed(() => {
296
+ return fieldNames.every(name => fields[name].valid.get());
297
+ });
298
+
299
+ const isDirty = computed(() => {
300
+ return fieldNames.some(name => fields[name].dirty.get());
301
+ });
302
+
303
+ const isTouched = computed(() => {
304
+ return fieldNames.some(name => fields[name].touched.get());
305
+ });
306
+
307
+ const errors = computed(() => {
308
+ const result = {};
309
+ for (const name of fieldNames) {
310
+ const err = fields[name].error.get();
311
+ if (err) result[name] = err;
312
+ }
313
+ return result;
314
+ });
315
+
316
+ /**
317
+ * Get current form values
318
+ */
319
+ function getValues() {
320
+ const values = {};
321
+ for (const name of fieldNames) {
322
+ values[name] = fields[name].value.get();
323
+ }
324
+ return values;
325
+ }
326
+
327
+ /**
328
+ * Set multiple values at once
329
+ */
330
+ function setValues(newValues, shouldValidate = false) {
331
+ batch(() => {
332
+ for (const [name, value] of Object.entries(newValues)) {
333
+ if (fields[name]) {
334
+ fields[name].value.set(value);
335
+ }
336
+ }
337
+ });
338
+
339
+ if (shouldValidate) {
340
+ validateAll();
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Set a single field value
346
+ */
347
+ function setValue(name, value, shouldValidate = false) {
348
+ if (fields[name]) {
349
+ fields[name].value.set(value);
350
+ if (shouldValidate) {
351
+ fields[name].validate();
352
+ }
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Validate all fields
358
+ */
359
+ function validateAll() {
360
+ let allValid = true;
361
+ for (const name of fieldNames) {
362
+ if (!fields[name].validate()) {
363
+ allValid = false;
364
+ }
365
+ }
366
+ return allValid;
367
+ }
368
+
369
+ /**
370
+ * Reset form to initial values
371
+ */
372
+ function reset(newValues) {
373
+ batch(() => {
374
+ for (const name of fieldNames) {
375
+ const resetValue = newValues?.[name] ?? initialValues[name];
376
+ fields[name].value.set(resetValue);
377
+ fields[name].error.set(null);
378
+ fields[name].touched.set(false);
379
+ }
380
+ isSubmitting.set(false);
381
+ });
382
+ }
383
+
384
+ /**
385
+ * Handle form submission
386
+ */
387
+ async function handleSubmit(event) {
388
+ if (event?.preventDefault) {
389
+ event.preventDefault();
390
+ }
391
+
392
+ submitCount.update(c => c + 1);
393
+
394
+ // Mark all fields as touched
395
+ batch(() => {
396
+ for (const name of fieldNames) {
397
+ fields[name].touched.set(true);
398
+ }
399
+ });
400
+
401
+ // Validate if required
402
+ if (validateOnSubmit) {
403
+ const valid = validateAll();
404
+ if (!valid) {
405
+ if (onError) {
406
+ onError(errors.get());
407
+ }
408
+ return false;
409
+ }
410
+ }
411
+
412
+ isSubmitting.set(true);
413
+
414
+ try {
415
+ if (onSubmit) {
416
+ await onSubmit(getValues());
417
+ }
418
+ return true;
419
+ } catch (err) {
420
+ if (onError) {
421
+ onError({ _form: err.message || 'Submit failed' });
422
+ }
423
+ return false;
424
+ } finally {
425
+ isSubmitting.set(false);
426
+ }
427
+ }
428
+
429
+ /**
430
+ * Set form-level or field errors
431
+ */
432
+ function setErrors(errorMap) {
433
+ batch(() => {
434
+ for (const [name, message] of Object.entries(errorMap)) {
435
+ if (fields[name]) {
436
+ fields[name].error.set(message);
437
+ }
438
+ }
439
+ });
440
+ }
441
+
442
+ /**
443
+ * Clear all errors
444
+ */
445
+ function clearErrors() {
446
+ batch(() => {
447
+ for (const name of fieldNames) {
448
+ fields[name].error.set(null);
449
+ }
450
+ });
451
+ }
452
+
453
+ return {
454
+ fields,
455
+ isValid,
456
+ isDirty,
457
+ isTouched,
458
+ isSubmitting,
459
+ submitCount,
460
+ errors,
461
+ getValues,
462
+ setValues,
463
+ setValue,
464
+ validateAll,
465
+ reset,
466
+ handleSubmit,
467
+ setErrors,
468
+ clearErrors
469
+ };
470
+ }
471
+
472
+ /**
473
+ * Create a single reactive field (for use outside of useForm)
474
+ *
475
+ * @param {any} initialValue - Initial field value
476
+ * @param {ValidationRule[]} [rules=[]] - Validation rules
477
+ * @returns {Object} Field state and controls
478
+ *
479
+ * @example
480
+ * const email = useField('', [validators.required(), validators.email()]);
481
+ *
482
+ * // Bind to input
483
+ * el('input', { value: email.value.get(), onInput: email.onChange, onBlur: email.onBlur });
484
+ */
485
+ export function useField(initialValue, rules = []) {
486
+ const value = pulse(initialValue);
487
+ const error = pulse(null);
488
+ const touched = pulse(false);
489
+ const dirty = computed(() => value.get() !== initialValue);
490
+ const valid = computed(() => error.get() === null);
491
+
492
+ const validate = () => {
493
+ const currentValue = value.get();
494
+
495
+ for (const rule of rules) {
496
+ const result = rule.validate(currentValue, {});
497
+ if (result !== true) {
498
+ error.set(typeof result === 'string' ? result : rule.message || 'Invalid');
499
+ return false;
500
+ }
501
+ }
502
+ error.set(null);
503
+ return true;
504
+ };
505
+
506
+ const onChange = (eventOrValue) => {
507
+ const newValue = eventOrValue?.target
508
+ ? (eventOrValue.target.type === 'checkbox'
509
+ ? eventOrValue.target.checked
510
+ : eventOrValue.target.value)
511
+ : eventOrValue;
512
+
513
+ value.set(newValue);
514
+
515
+ if (touched.get()) {
516
+ validate();
517
+ }
518
+ };
519
+
520
+ const onBlur = () => {
521
+ touched.set(true);
522
+ validate();
523
+ };
524
+
525
+ const reset = () => {
526
+ batch(() => {
527
+ value.set(initialValue);
528
+ error.set(null);
529
+ touched.set(false);
530
+ });
531
+ };
532
+
533
+ return {
534
+ value,
535
+ error,
536
+ touched,
537
+ dirty,
538
+ valid,
539
+ validate,
540
+ onChange,
541
+ onBlur,
542
+ reset,
543
+ setError: (msg) => error.set(msg),
544
+ clearError: () => error.set(null)
545
+ };
546
+ }
547
+
548
+ /**
549
+ * Create a field array for dynamic lists of fields
550
+ *
551
+ * @template T
552
+ * @param {T[]} initialValues - Initial array of values
553
+ * @param {ValidationRule[]} [itemRules=[]] - Validation rules for each item
554
+ * @returns {Object} Field array state and controls
555
+ *
556
+ * @example
557
+ * const tags = useFieldArray(['tag1', 'tag2'], [validators.required()]);
558
+ *
559
+ * // Add new tag
560
+ * tags.append('tag3');
561
+ *
562
+ * // Remove tag
563
+ * tags.remove(0);
564
+ *
565
+ * // Render items
566
+ * tags.fields.get().forEach((field, index) => {
567
+ * el('input', { value: field.value.get(), onInput: field.onChange });
568
+ * el('button', { onClick: () => tags.remove(index) }, 'Remove');
569
+ * });
570
+ */
571
+ export function useFieldArray(initialValues = [], itemRules = []) {
572
+ // Create field for each initial value
573
+ const createField = (value) => useField(value, itemRules);
574
+
575
+ const fieldsArray = pulse(initialValues.map(createField));
576
+
577
+ // Computed values
578
+ const values = computed(() => fieldsArray.get().map(f => f.value.get()));
579
+ const errors = computed(() => fieldsArray.get().map(f => f.error.get()));
580
+ const isValid = computed(() => fieldsArray.get().every(f => f.valid.get()));
581
+
582
+ const append = (value) => {
583
+ fieldsArray.update(arr => [...arr, createField(value)]);
584
+ };
585
+
586
+ const prepend = (value) => {
587
+ fieldsArray.update(arr => [createField(value), ...arr]);
588
+ };
589
+
590
+ const insert = (index, value) => {
591
+ fieldsArray.update(arr => {
592
+ const newArr = [...arr];
593
+ newArr.splice(index, 0, createField(value));
594
+ return newArr;
595
+ });
596
+ };
597
+
598
+ const remove = (index) => {
599
+ fieldsArray.update(arr => arr.filter((_, i) => i !== index));
600
+ };
601
+
602
+ const move = (from, to) => {
603
+ fieldsArray.update(arr => {
604
+ const newArr = [...arr];
605
+ const [item] = newArr.splice(from, 1);
606
+ newArr.splice(to, 0, item);
607
+ return newArr;
608
+ });
609
+ };
610
+
611
+ const swap = (indexA, indexB) => {
612
+ fieldsArray.update(arr => {
613
+ const newArr = [...arr];
614
+ [newArr[indexA], newArr[indexB]] = [newArr[indexB], newArr[indexA]];
615
+ return newArr;
616
+ });
617
+ };
618
+
619
+ const replace = (index, value) => {
620
+ fieldsArray.update(arr => {
621
+ const newArr = [...arr];
622
+ newArr[index] = createField(value);
623
+ return newArr;
624
+ });
625
+ };
626
+
627
+ const reset = (newValues = initialValues) => {
628
+ fieldsArray.set(newValues.map(createField));
629
+ };
630
+
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());
634
+ return results.every(r => r);
635
+ };
636
+
637
+ return {
638
+ fields: fieldsArray,
639
+ values,
640
+ errors,
641
+ isValid,
642
+ append,
643
+ prepend,
644
+ insert,
645
+ remove,
646
+ move,
647
+ swap,
648
+ replace,
649
+ reset,
650
+ validateAll
651
+ };
652
+ }
653
+
654
+ export default {
655
+ useForm,
656
+ useField,
657
+ useFieldArray,
658
+ validators
659
+ };
package/runtime/pulse.js CHANGED
@@ -524,19 +524,52 @@ function flushEffects() {
524
524
  context.isRunningEffects = true;
525
525
  let iterations = 0;
526
526
 
527
+ // Track effect run counts to identify infinite loop culprits
528
+ const effectRunCounts = new Map();
529
+
527
530
  try {
528
531
  while (context.pendingEffects.size > 0 && iterations < MAX_EFFECT_ITERATIONS) {
529
532
  iterations++;
530
533
  const effects = [...context.pendingEffects];
531
534
  context.pendingEffects.clear();
532
535
 
533
- for (const effect of effects) {
534
- runEffect(effect);
536
+ for (const eff of effects) {
537
+ // Track how many times each effect runs
538
+ const id = eff.id || 'unknown';
539
+ effectRunCounts.set(id, (effectRunCounts.get(id) || 0) + 1);
540
+ runEffect(eff);
535
541
  }
536
542
  }
537
543
 
538
544
  if (iterations >= MAX_EFFECT_ITERATIONS) {
539
- log.warn(`Maximum effect iterations (${MAX_EFFECT_ITERATIONS}) reached. Possible infinite loop.`);
545
+ // Find effects that ran the most (likely causing the loop)
546
+ const sortedByRuns = [...effectRunCounts.entries()]
547
+ .sort((a, b) => b[1] - a[1])
548
+ .slice(0, 10);
549
+
550
+ const culpritDetails = sortedByRuns
551
+ .map(([id, count]) => `${id} (${count} runs)`)
552
+ .join(', ');
553
+
554
+ // Still pending effects
555
+ const stillPending = [...context.pendingEffects]
556
+ .map(e => e.id || 'unknown')
557
+ .slice(0, 5)
558
+ .join(', ');
559
+
560
+ const errorMsg =
561
+ `[Pulse] INFINITE LOOP DETECTED\n` +
562
+ `Maximum effect iterations (${MAX_EFFECT_ITERATIONS}) reached.\n` +
563
+ `Most active effects: [${culpritDetails}]\n` +
564
+ `Still pending: [${stillPending || 'none'}]\n` +
565
+ `Tip: Check for circular dependencies where effects trigger each other.`;
566
+
567
+ // Always use console.error directly to ensure visibility
568
+ console.error(errorMsg);
569
+
570
+ // Also log through the logger for consistency
571
+ log.error(errorMsg);
572
+
540
573
  context.pendingEffects.clear();
541
574
  }
542
575
  } finally {