pulse-js-framework 1.8.3 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +3 -1
  2. package/runtime/form.js +604 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pulse-js-framework",
3
- "version": "1.8.3",
3
+ "version": "1.9.0",
4
4
  "description": "A declarative DOM framework with CSS selector-based structure and reactive pulsations",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -149,6 +149,7 @@
149
149
  "test:async": "node test/async.test.js",
150
150
  "test:async-coverage": "node test/async-coverage.test.js",
151
151
  "test:form": "node test/form.test.js",
152
+ "test:form-v2": "node test/form-v2.test.js",
152
153
  "test:http": "node test/http.test.js",
153
154
  "test:devtools": "node test/devtools.test.js",
154
155
  "test:native": "node test/native.test.js",
@@ -168,6 +169,7 @@
168
169
  "test:integration": "node test/integration.test.js",
169
170
  "test:context-stress": "node test/context-stress.test.js",
170
171
  "test:form-edge-cases": "node test/form-edge-cases.test.js",
172
+ "test:form-coverage": "node test/form-coverage.test.js",
171
173
  "test:graphql-subscriptions": "node test/graphql-subscriptions.test.js",
172
174
  "test:http-edge-cases": "node test/http-edge-cases.test.js",
173
175
  "test:integration-advanced": "node test/integration-advanced.test.js",
package/runtime/form.js CHANGED
@@ -32,6 +32,18 @@ function isAsyncValidator(rule) {
32
32
  return rule.async === true;
33
33
  }
34
34
 
35
+ /**
36
+ * Check if localStorage is available (SSR-safe)
37
+ * @returns {boolean}
38
+ */
39
+ function isLocalStorageAvailable() {
40
+ try {
41
+ return typeof localStorage !== 'undefined' && localStorage !== null;
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
35
47
  /**
36
48
  * Built-in validation rules
37
49
  */
@@ -172,6 +184,74 @@ export const validators = {
172
184
  }
173
185
  }),
174
186
 
187
+ // ============================================================================
188
+ // Conditional Validators (#30)
189
+ // ============================================================================
190
+
191
+ /**
192
+ * Conditional validation - run rules only when condition is met
193
+ * @param {function(any, Object): boolean} condition - Condition function (value, allValues) => boolean
194
+ * @param {ValidationRule[]} rules - Validation rules to apply when condition is true
195
+ * @returns {ValidationRule}
196
+ *
197
+ * @example
198
+ * // Only validate shipping address when "different shipping" is checked
199
+ * validators.when(
200
+ * (value, allValues) => allValues.differentShipping,
201
+ * [validators.required(), validators.minLength(5)]
202
+ * )
203
+ */
204
+ when: (condition, rules) => {
205
+ const hasAsync = rules.some(r => isAsyncValidator(r));
206
+ const firstAsyncRule = rules.find(r => isAsyncValidator(r));
207
+
208
+ const rule = {
209
+ validate: hasAsync
210
+ ? async (value, allValues) => {
211
+ if (!condition(value, allValues)) return true;
212
+ for (const r of rules) {
213
+ const result = isAsyncValidator(r)
214
+ ? await r.validate(value, allValues)
215
+ : r.validate(value, allValues);
216
+ if (result !== true) return result;
217
+ }
218
+ return true;
219
+ }
220
+ : (value, allValues) => {
221
+ if (!condition(value, allValues)) return true;
222
+ for (const r of rules) {
223
+ const result = r.validate(value, allValues);
224
+ if (result !== true) return result;
225
+ }
226
+ return true;
227
+ }
228
+ };
229
+
230
+ if (hasAsync) {
231
+ rule.async = true;
232
+ rule.debounce = firstAsyncRule.debounce ?? 300;
233
+ }
234
+
235
+ return rule;
236
+ },
237
+
238
+ /**
239
+ * Inverse conditional validation - run rules unless condition is met
240
+ * @param {function(any, Object): boolean} condition - Condition function (value, allValues) => boolean
241
+ * @param {ValidationRule[]} rules - Validation rules to apply when condition is false
242
+ * @returns {ValidationRule}
243
+ *
244
+ * @example
245
+ * // Skip email validation when field is empty
246
+ * validators.unless(
247
+ * (value) => !value,
248
+ * [validators.email()]
249
+ * )
250
+ */
251
+ unless: (condition, rules) => {
252
+ return validators.when((value, allValues) => !condition(value, allValues), rules);
253
+ },
254
+
175
255
  // ============================================================================
176
256
  // Async Validators
177
257
  // ============================================================================
@@ -287,6 +367,11 @@ export const validators = {
287
367
  * @property {function(Object): void} [onSubmit] - Submit handler
288
368
  * @property {function(Object): void} [onError] - Error handler
289
369
  * @property {'onChange'|'onBlur'|'onSubmit'} [mode='onChange'] - Validation mode
370
+ * @property {function(Object): Object} [validate] - Form-level validation function
371
+ * @property {boolean} [persist=false] - Enable draft persistence to localStorage
372
+ * @property {string} [persistKey='pulse-form-draft'] - localStorage key for drafts
373
+ * @property {number} [persistDebounce=300] - Debounce delay for saving drafts (ms)
374
+ * @property {string[]} [persistExclude=[]] - Field names to exclude from persistence
290
375
  */
291
376
 
292
377
  /**
@@ -321,9 +406,39 @@ export function useForm(initialValues, validationSchema = {}, options = {}) {
321
406
  validateOnSubmit = true,
322
407
  onSubmit,
323
408
  onError,
324
- mode = 'onChange'
409
+ mode = 'onChange',
410
+ validate: formValidate,
411
+ persist = false,
412
+ persistKey = 'pulse-form-draft',
413
+ persistDebounce = 300,
414
+ persistExclude = []
325
415
  } = options;
326
416
 
417
+ // ========================================================================
418
+ // Draft Persistence: Restore saved draft (#36)
419
+ // ========================================================================
420
+
421
+ let restoredValues = { ...initialValues };
422
+ const canPersist = persist && isLocalStorageAvailable();
423
+
424
+ if (canPersist) {
425
+ try {
426
+ const savedDraft = localStorage.getItem(persistKey);
427
+ if (savedDraft) {
428
+ const parsed = JSON.parse(savedDraft);
429
+ if (parsed && typeof parsed === 'object') {
430
+ for (const key of Object.keys(initialValues)) {
431
+ if (key in parsed && !persistExclude.includes(key)) {
432
+ restoredValues[key] = parsed[key];
433
+ }
434
+ }
435
+ }
436
+ }
437
+ } catch {
438
+ // Ignore parse errors — use initial values
439
+ }
440
+ }
441
+
327
442
  // Create field states
328
443
  const fields = {};
329
444
  const fieldNames = Object.keys(initialValues);
@@ -334,13 +449,14 @@ export function useForm(initialValues, validationSchema = {}, options = {}) {
334
449
 
335
450
  for (const name of fieldNames) {
336
451
  const initialValue = initialValues[name];
452
+ const startValue = restoredValues[name];
337
453
  const rules = validationSchema[name] || [];
338
454
 
339
455
  // Separate sync and async rules
340
456
  const syncRules = rules.filter(r => !isAsyncValidator(r));
341
457
  const asyncRules = rules.filter(r => isAsyncValidator(r));
342
458
 
343
- const value = pulse(initialValue);
459
+ const value = pulse(startValue);
344
460
  const error = pulse(null);
345
461
  const touched = pulse(false);
346
462
  const validating = pulse(false);
@@ -508,13 +624,34 @@ export function useForm(initialValues, validationSchema = {}, options = {}) {
508
624
  };
509
625
  }
510
626
 
627
+ // ========================================================================
511
628
  // Form-level state
629
+ // ========================================================================
630
+
512
631
  const isSubmitting = pulse(false);
513
632
  const submitCount = pulse(0);
633
+ const submitError = pulse(null); // #29: Server-side submit error
634
+ const formError = pulse(null); // #32: Form-level validation error
635
+
636
+ // ========================================================================
637
+ // Draft Persistence: hasDraft state (#36)
638
+ // ========================================================================
514
639
 
640
+ const hasDraft = pulse(canPersist && (() => {
641
+ try {
642
+ return localStorage.getItem(persistKey) !== null;
643
+ } catch {
644
+ return false;
645
+ }
646
+ })());
647
+
648
+ // ========================================================================
515
649
  // Computed form state
650
+ // ========================================================================
651
+
516
652
  const isValid = computed(() => {
517
- return fieldNames.every(name => fields[name].valid.get());
653
+ // All fields must be valid AND no form-level error
654
+ return fieldNames.every(name => fields[name].valid.get()) && formError.get() === null;
518
655
  });
519
656
 
520
657
  const isValidating = computed(() => {
@@ -535,6 +672,9 @@ export function useForm(initialValues, validationSchema = {}, options = {}) {
535
672
  const err = fields[name].error.get();
536
673
  if (err) result[name] = err;
537
674
  }
675
+ // Include form-level error under _form key
676
+ const fErr = formError.get();
677
+ if (fErr) result._form = fErr;
538
678
  return result;
539
679
  });
540
680
 
@@ -578,6 +718,56 @@ export function useForm(initialValues, validationSchema = {}, options = {}) {
578
718
  }
579
719
  }
580
720
 
721
+ // ========================================================================
722
+ // Form-Level Validation (#32)
723
+ // ========================================================================
724
+
725
+ /**
726
+ * Run form-level validation (cross-field)
727
+ * @returns {Promise<boolean>} true if valid
728
+ */
729
+ async function runFormLevelValidation() {
730
+ if (!formValidate) return true;
731
+
732
+ const allValues = getValues();
733
+ let result;
734
+ try {
735
+ result = formValidate(allValues);
736
+ // Support async validate functions
737
+ if (result && typeof result.then === 'function') {
738
+ result = await result;
739
+ }
740
+ } catch (err) {
741
+ formError.set(err.message || 'Form validation failed');
742
+ return false;
743
+ }
744
+
745
+ if (!result || typeof result !== 'object') {
746
+ formError.set(null);
747
+ return true;
748
+ }
749
+
750
+ // Check if any errors were returned
751
+ const errorKeys = Object.keys(result);
752
+ if (errorKeys.length === 0) {
753
+ formError.set(null);
754
+ return true;
755
+ }
756
+
757
+ // Apply errors to fields and form
758
+ batch(() => {
759
+ for (const [key, message] of Object.entries(result)) {
760
+ if (key === '_form') {
761
+ formError.set(message);
762
+ } else if (fields[key]) {
763
+ fields[key].error.set(message);
764
+ }
765
+ }
766
+ });
767
+
768
+ return false;
769
+ }
770
+
581
771
  /**
582
772
  * Validate all fields (sync only, for immediate check)
583
773
  */
@@ -592,10 +782,13 @@ export function useForm(initialValues, validationSchema = {}, options = {}) {
592
782
  }
593
783
 
594
784
  /**
595
- * Validate all fields (sync + async)
785
+ * Validate all fields (sync + async) + form-level validation
596
786
  * @returns {Promise<boolean>}
597
787
  */
598
788
  async function validateAll() {
789
+ // Clear form-level error before re-validating
790
+ formError.set(null);
791
+
599
792
  // First run all sync validations
600
793
  let allValid = validateAllSync();
601
794
 
@@ -611,9 +804,90 @@ export function useForm(initialValues, validationSchema = {}, options = {}) {
611
804
  }
612
805
  }
613
806
 
807
+ // Run form-level validation if field-level passed
808
+ if (allValid && formValidate) {
809
+ allValid = await runFormLevelValidation();
810
+ }
811
+
614
812
  return allValid;
615
813
  }
616
814
 
815
+ // ========================================================================
816
+ // Draft Persistence Helpers (#36)
817
+ // ========================================================================
818
+
819
+ let persistTimer = null;
820
+
821
+ /**
822
+ * Save current form values to localStorage
823
+ */
824
+ function saveDraft() {
825
+ if (!canPersist) return;
826
+ try {
827
+ const values = getValues();
828
+ const filtered = {};
829
+ for (const [key, val] of Object.entries(values)) {
830
+ if (!persistExclude.includes(key)) {
831
+ // Only persist JSON-serializable values
832
+ if (val !== undefined && typeof val !== 'function' && !(typeof val === 'object' && val !== null && typeof val.then === 'function')) {
833
+ filtered[key] = val;
834
+ }
835
+ }
836
+ }
837
+ localStorage.setItem(persistKey, JSON.stringify(filtered));
838
+ hasDraft.set(true);
839
+ } catch {
840
+ // Ignore storage errors (quota exceeded, etc.)
841
+ }
842
+ }
843
+
844
+ /**
845
+ * Clear saved draft from localStorage
846
+ */
847
+ function clearDraft() {
848
+ if (persistTimer) {
849
+ clearTimeout(persistTimer);
850
+ persistTimer = null;
851
+ }
852
+ if (isLocalStorageAvailable()) {
853
+ try {
854
+ localStorage.removeItem(persistKey);
855
+ } catch {
856
+ // Ignore
857
+ }
858
+ }
859
+ hasDraft.set(false);
860
+ }
861
+
862
+ /**
863
+ * Debounced draft save - schedules a save after persistDebounce ms
864
+ */
865
+ function scheduleDraftSave() {
866
+ if (!canPersist) return;
867
+ if (persistTimer) {
868
+ clearTimeout(persistTimer);
869
+ }
870
+ persistTimer = setTimeout(saveDraft, persistDebounce);
871
+ }
872
+
873
+ // Set up draft persistence effect
874
+ if (canPersist) {
875
+ // Watch for field value changes and debounce-save
876
+ const disposeEffect = effect(() => {
877
+ // Track all field values
878
+ for (const name of fieldNames) {
879
+ fields[name].value.get();
880
+ }
881
+ // Schedule save (debounced)
882
+ scheduleDraftSave();
883
+ });
884
+ onCleanup(disposeEffect);
885
+ }
886
+
887
+ // ========================================================================
888
+ // Form Control Methods
889
+ // ========================================================================
890
+
617
891
  /**
618
892
  * Reset form to initial values
619
893
  */
@@ -626,7 +900,14 @@ export function useForm(initialValues, validationSchema = {}, options = {}) {
626
900
  fields[name].touched.set(false);
627
901
  }
628
902
  isSubmitting.set(false);
903
+ submitError.set(null);
904
+ formError.set(null);
629
905
  });
906
+
907
+ // Clear draft on reset
908
+ if (canPersist) {
909
+ clearDraft();
910
+ }
630
911
  }
631
912
 
632
913
  /**
@@ -638,6 +919,7 @@ export function useForm(initialValues, validationSchema = {}, options = {}) {
638
919
  }
639
920
 
640
921
  submitCount.update(c => c + 1);
922
+ submitError.set(null); // #29: Clear previous submit error
641
923
 
642
924
  // Mark all fields as touched
643
925
  batch(() => {
@@ -663,10 +945,18 @@ export function useForm(initialValues, validationSchema = {}, options = {}) {
663
945
  if (onSubmit) {
664
946
  await onSubmit(getValues());
665
947
  }
948
+
949
+ // Clear draft on successful submit
950
+ if (canPersist) {
951
+ clearDraft();
952
+ }
953
+
666
954
  return true;
667
955
  } catch (err) {
956
+ const errorMessage = err.message || 'Submit failed';
957
+ submitError.set(errorMessage); // #29: Set reactive submit error
668
958
  if (onError) {
669
- onError({ _form: err.message || 'Submit failed' });
959
+ onError({ _form: errorMessage });
670
960
  }
671
961
  return false;
672
962
  } finally {
@@ -680,7 +970,9 @@ export function useForm(initialValues, validationSchema = {}, options = {}) {
680
970
  function setErrors(errorMap) {
681
971
  batch(() => {
682
972
  for (const [name, message] of Object.entries(errorMap)) {
683
- if (fields[name]) {
973
+ if (name === '_form') {
974
+ formError.set(message);
975
+ } else if (fields[name]) {
684
976
  fields[name].error.set(message);
685
977
  }
686
978
  }
@@ -688,17 +980,32 @@ export function useForm(initialValues, validationSchema = {}, options = {}) {
688
980
  }
689
981
 
690
982
  /**
691
- * Clear all errors
983
+ * Clear all errors (field-level and form-level)
692
984
  */
693
985
  function clearErrors() {
694
986
  batch(() => {
695
987
  for (const name of fieldNames) {
696
988
  fields[name].error.set(null);
697
989
  }
990
+ formError.set(null);
991
+ submitError.set(null);
698
992
  });
699
993
  }
700
994
 
995
+ /**
996
+ * Clear form-level error only
997
+ */
998
+ function clearFormError() {
999
+ formError.set(null);
1000
+ }
1001
+
701
1002
  const dispose = () => {
1003
+ // Clear persist timer
1004
+ if (persistTimer) {
1005
+ clearTimeout(persistTimer);
1006
+ persistTimer = null;
1007
+ }
1008
+ // Clear debounce timers
702
1009
  for (const name of fieldNames) {
703
1010
  const timers = debounceTimers[name];
704
1011
  if (timers) {
@@ -719,6 +1026,8 @@ export function useForm(initialValues, validationSchema = {}, options = {}) {
719
1026
  isTouched,
720
1027
  isSubmitting,
721
1028
  submitCount,
1029
+ submitError, // #29: Reactive submit error
1030
+ formError, // #32: Form-level validation error
722
1031
  errors,
723
1032
  getValues,
724
1033
  setValues,
@@ -729,6 +1038,9 @@ export function useForm(initialValues, validationSchema = {}, options = {}) {
729
1038
  handleSubmit,
730
1039
  setErrors,
731
1040
  clearErrors,
1041
+ clearFormError, // #32: Clear form-level error
1042
+ hasDraft, // #36: Whether a saved draft exists
1043
+ clearDraft, // #36: Clear saved draft
732
1044
  dispose
733
1045
  };
734
1046
  }
@@ -1077,9 +1389,294 @@ export function useFieldArray(initialValues = [], itemRules = []) {
1077
1389
  };
1078
1390
  }
1079
1391
 
1392
+ // ============================================================================
1393
+ // File Upload Field (#34)
1394
+ // ============================================================================
1395
+
1396
+ /**
1397
+ * Create a reactive file upload field with drag-and-drop, preview, and validation.
1398
+ *
1399
+ * @param {Object} [options={}] - File field options
1400
+ * @param {string[]} [options.accept] - Allowed MIME types (e.g. ['image/png', 'image/jpeg'])
1401
+ * @param {number} [options.maxSize] - Maximum file size in bytes
1402
+ * @param {boolean} [options.multiple=false] - Allow multiple files
1403
+ * @param {number} [options.maxFiles=Infinity] - Maximum number of files (when multiple=true)
1404
+ * @param {boolean} [options.preview=false] - Generate preview URLs for image files
1405
+ * @param {function(File[]): boolean|string} [options.validate] - Custom validation function
1406
+ * @returns {Object} File field state and controls
1407
+ *
1408
+ * @example
1409
+ * const avatar = useFileField({
1410
+ * accept: ['image/png', 'image/jpeg'],
1411
+ * maxSize: 5 * 1024 * 1024,
1412
+ * preview: true
1413
+ * });
1414
+ *
1415
+ * // Input binding
1416
+ * el('input[type=file]', { onchange: avatar.onChange, accept: 'image/*' });
1417
+ *
1418
+ * // Drop zone
1419
+ * el('.dropzone', {
1420
+ * ondragenter: avatar.onDragEnter,
1421
+ * ondragover: avatar.onDragOver,
1422
+ * ondragleave: avatar.onDragLeave,
1423
+ * ondrop: avatar.onDrop,
1424
+ * class: () => avatar.isDragging.get() ? 'drag-over' : ''
1425
+ * });
1426
+ */
1427
+ export function useFileField(options = {}) {
1428
+ const {
1429
+ accept,
1430
+ maxSize,
1431
+ multiple = false,
1432
+ maxFiles = Infinity,
1433
+ preview = false,
1434
+ validate: customValidate
1435
+ } = options;
1436
+
1437
+ // Reactive state
1438
+ const files = pulse([]);
1439
+ const previews = pulse([]);
1440
+ const error = pulse(null);
1441
+ const touched = pulse(false);
1442
+ const isDragging = pulse(false);
1443
+ const valid = computed(() => error.get() === null);
1444
+
1445
+ // Track object URLs for cleanup
1446
+ let previewUrls = [];
1447
+
1448
+ /**
1449
+ * Check if URL.createObjectURL is available (SSR-safe)
1450
+ */
1451
+ function canCreateObjectURL() {
1452
+ return typeof URL !== 'undefined' && typeof URL.createObjectURL === 'function';
1453
+ }
1454
+
1455
+ /**
1456
+ * Revoke all existing preview URLs to prevent memory leaks
1457
+ */
1458
+ function revokePreviewUrls() {
1459
+ if (canCreateObjectURL()) {
1460
+ for (const url of previewUrls) {
1461
+ try {
1462
+ URL.revokeObjectURL(url);
1463
+ } catch {
1464
+ // Ignore revoke errors
1465
+ }
1466
+ }
1467
+ }
1468
+ previewUrls = [];
1469
+ previews.set([]);
1470
+ }
1471
+
1472
+ /**
1473
+ * Generate preview URLs for image files
1474
+ */
1475
+ function generatePreviews(fileList) {
1476
+ if (!preview || !canCreateObjectURL()) return;
1477
+
1478
+ revokePreviewUrls();
1479
+
1480
+ const urls = [];
1481
+ for (const file of fileList) {
1482
+ if (file.type && file.type.startsWith('image/')) {
1483
+ const url = URL.createObjectURL(file);
1484
+ urls.push(url);
1485
+ previewUrls.push(url);
1486
+ } else {
1487
+ urls.push(null);
1488
+ }
1489
+ }
1490
+ previews.set(urls);
1491
+ }
1492
+
1493
+ /**
1494
+ * Validate files against constraints
1495
+ * @param {File[]} fileList - Files to validate
1496
+ * @returns {string|null} Error message or null
1497
+ */
1498
+ function validateFiles(fileList) {
1499
+ if (fileList.length === 0) return null;
1500
+
1501
+ // Check file count
1502
+ if (!multiple && fileList.length > 1) {
1503
+ return 'Only one file is allowed';
1504
+ }
1505
+ if (multiple && fileList.length > maxFiles) {
1506
+ return `Maximum ${maxFiles} files allowed`;
1507
+ }
1508
+
1509
+ // Check each file
1510
+ for (const file of fileList) {
1511
+ // Type validation
1512
+ if (accept && accept.length > 0) {
1513
+ const fileType = file.type || '';
1514
+ const matched = accept.some(type => {
1515
+ if (type.endsWith('/*')) {
1516
+ // Wildcard match: image/* matches image/png
1517
+ const prefix = type.slice(0, -2);
1518
+ return fileType.startsWith(prefix);
1519
+ }
1520
+ return fileType === type;
1521
+ });
1522
+ if (!matched) {
1523
+ return `File type "${fileType || 'unknown'}" is not allowed`;
1524
+ }
1525
+ }
1526
+
1527
+ // Size validation
1528
+ if (maxSize && file.size > maxSize) {
1529
+ const sizeMB = (maxSize / (1024 * 1024)).toFixed(1);
1530
+ return `File "${file.name}" exceeds maximum size of ${sizeMB}MB`;
1531
+ }
1532
+ }
1533
+
1534
+ // Custom validation
1535
+ if (customValidate) {
1536
+ const result = customValidate(fileList);
1537
+ if (result !== true && typeof result === 'string') {
1538
+ return result;
1539
+ }
1540
+ }
1541
+
1542
+ return null;
1543
+ }
1544
+
1545
+ /**
1546
+ * Process and set files
1547
+ * @param {File[]} fileList - Files to set
1548
+ */
1549
+ function setFiles(fileList) {
1550
+ touched.set(true);
1551
+
1552
+ const fileArray = Array.from(fileList);
1553
+ const validationError = validateFiles(fileArray);
1554
+
1555
+ if (validationError) {
1556
+ error.set(validationError);
1557
+ return;
1558
+ }
1559
+
1560
+ error.set(null);
1561
+ files.set(fileArray);
1562
+ generatePreviews(fileArray);
1563
+ }
1564
+
1565
+ // Event handlers
1566
+ const onChange = (event) => {
1567
+ const inputFiles = event?.target?.files;
1568
+ if (inputFiles) {
1569
+ setFiles(inputFiles);
1570
+ }
1571
+ };
1572
+
1573
+ const onDragEnter = (event) => {
1574
+ event.preventDefault();
1575
+ event.stopPropagation();
1576
+ isDragging.set(true);
1577
+ };
1578
+
1579
+ const onDragOver = (event) => {
1580
+ event.preventDefault();
1581
+ event.stopPropagation();
1582
+ isDragging.set(true);
1583
+ };
1584
+
1585
+ const onDragLeave = (event) => {
1586
+ event.preventDefault();
1587
+ event.stopPropagation();
1588
+ isDragging.set(false);
1589
+ };
1590
+
1591
+ const onDrop = (event) => {
1592
+ event.preventDefault();
1593
+ event.stopPropagation();
1594
+ isDragging.set(false);
1595
+
1596
+ const droppedFiles = event?.dataTransfer?.files;
1597
+ if (droppedFiles && droppedFiles.length > 0) {
1598
+ setFiles(droppedFiles);
1599
+ }
1600
+ };
1601
+
1602
+ const clear = () => {
1603
+ revokePreviewUrls();
1604
+ batch(() => {
1605
+ files.set([]);
1606
+ error.set(null);
1607
+ });
1608
+ };
1609
+
1610
+ const removeFile = (index) => {
1611
+ const currentFiles = files.get();
1612
+ if (index < 0 || index >= currentFiles.length) return;
1613
+
1614
+ const newFiles = currentFiles.filter((_, i) => i !== index);
1615
+
1616
+ // Revoke removed preview URL
1617
+ if (preview && canCreateObjectURL()) {
1618
+ const currentPreviews = previews.get();
1619
+ if (currentPreviews[index]) {
1620
+ try {
1621
+ URL.revokeObjectURL(currentPreviews[index]);
1622
+ } catch {
1623
+ // Ignore
1624
+ }
1625
+ }
1626
+ const newPreviews = currentPreviews.filter((_, i) => i !== index);
1627
+ previewUrls = previewUrls.filter((_, i) => i !== index);
1628
+ previews.set(newPreviews);
1629
+ }
1630
+
1631
+ files.set(newFiles);
1632
+
1633
+ // Re-validate remaining files
1634
+ if (newFiles.length > 0) {
1635
+ const validationError = validateFiles(newFiles);
1636
+ error.set(validationError);
1637
+ } else {
1638
+ error.set(null);
1639
+ }
1640
+ };
1641
+
1642
+ const reset = () => {
1643
+ revokePreviewUrls();
1644
+ batch(() => {
1645
+ files.set([]);
1646
+ error.set(null);
1647
+ touched.set(false);
1648
+ isDragging.set(false);
1649
+ });
1650
+ };
1651
+
1652
+ const dispose = () => {
1653
+ revokePreviewUrls();
1654
+ };
1655
+ onCleanup(dispose);
1656
+
1657
+ return {
1658
+ files,
1659
+ previews,
1660
+ error,
1661
+ touched,
1662
+ valid,
1663
+ isDragging,
1664
+ onChange,
1665
+ onDragEnter,
1666
+ onDragOver,
1667
+ onDragLeave,
1668
+ onDrop,
1669
+ clear,
1670
+ removeFile,
1671
+ reset,
1672
+ dispose
1673
+ };
1674
+ }
1675
+
1080
1676
  export default {
1081
1677
  useForm,
1082
1678
  useField,
1083
1679
  useFieldArray,
1680
+ useFileField,
1084
1681
  validators
1085
1682
  };