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.
- package/package.json +3 -1
- 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.
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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 (
|
|
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
|
};
|