@classic-homes/theme-svelte 0.1.8 → 0.1.10
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/dist/lib/components/Alert.svelte.d.ts +1 -1
- package/dist/lib/components/Checkbox.svelte +66 -27
- package/dist/lib/components/Checkbox.svelte.d.ts +3 -0
- package/dist/lib/components/Toast.svelte.d.ts +1 -1
- package/dist/lib/components/layout/FormPageLayout.svelte +1 -1
- package/dist/lib/composables/useForm.svelte.d.ts +6 -0
- package/dist/lib/composables/useForm.svelte.js +31 -4
- package/dist/lib/composables/usePersistedForm.svelte.d.ts +6 -54
- package/dist/lib/composables/usePersistedForm.svelte.js +109 -245
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
2
|
declare const Alert: import("svelte").Component<{
|
|
3
3
|
[key: string]: unknown;
|
|
4
|
-
variant?: "default" | "destructive" | "
|
|
4
|
+
variant?: "default" | "destructive" | "error" | "warning" | "success" | "info" | undefined;
|
|
5
5
|
class?: string;
|
|
6
6
|
children: Snippet;
|
|
7
7
|
}, {}, "">;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
2
3
|
import { Checkbox as CheckboxPrimitive } from 'bits-ui';
|
|
3
4
|
import { cn } from '../utils.js';
|
|
4
5
|
|
|
@@ -10,7 +11,9 @@
|
|
|
10
11
|
value?: string;
|
|
11
12
|
id?: string;
|
|
12
13
|
class?: string;
|
|
14
|
+
error?: string;
|
|
13
15
|
onCheckedChange?: (checked: boolean) => void;
|
|
16
|
+
children?: Snippet;
|
|
14
17
|
[key: string]: unknown;
|
|
15
18
|
}
|
|
16
19
|
|
|
@@ -22,44 +25,80 @@
|
|
|
22
25
|
value,
|
|
23
26
|
id,
|
|
24
27
|
class: className,
|
|
28
|
+
error,
|
|
25
29
|
onCheckedChange,
|
|
30
|
+
children,
|
|
26
31
|
...restProps
|
|
27
32
|
}: Props = $props();
|
|
28
33
|
|
|
34
|
+
// Generate a stable unique ID if none provided (for accessibility)
|
|
35
|
+
const generatedId = `checkbox-${Math.random().toString(36).substring(2, 11)}`;
|
|
36
|
+
const effectiveId = $derived(id ?? generatedId);
|
|
37
|
+
|
|
29
38
|
function handleCheckedChange(newChecked: boolean | 'indeterminate') {
|
|
30
39
|
if (typeof newChecked === 'boolean') {
|
|
31
40
|
checked = newChecked;
|
|
32
41
|
onCheckedChange?.(newChecked);
|
|
33
42
|
}
|
|
34
43
|
}
|
|
44
|
+
|
|
45
|
+
const hasError = $derived(!!error);
|
|
46
|
+
const errorId = $derived(`${effectiveId}-error`);
|
|
35
47
|
</script>
|
|
36
48
|
|
|
37
|
-
<
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
class="h-5 w-5"
|
|
56
|
-
fill="none"
|
|
57
|
-
stroke="currentColor"
|
|
58
|
-
stroke-width="2"
|
|
59
|
-
viewBox="0 0 24 24"
|
|
60
|
-
aria-hidden="true"
|
|
49
|
+
<div class="space-y-1">
|
|
50
|
+
<div class="flex items-start gap-3">
|
|
51
|
+
<CheckboxPrimitive.Root
|
|
52
|
+
bind:checked
|
|
53
|
+
{disabled}
|
|
54
|
+
{required}
|
|
55
|
+
{name}
|
|
56
|
+
{value}
|
|
57
|
+
id={effectiveId}
|
|
58
|
+
onCheckedChange={handleCheckedChange}
|
|
59
|
+
aria-invalid={hasError ? 'true' : undefined}
|
|
60
|
+
aria-describedby={hasError ? errorId : undefined}
|
|
61
|
+
class={cn(
|
|
62
|
+
'peer relative h-5 w-5 shrink-0 rounded-sm border ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground flex items-center justify-center mt-0.5',
|
|
63
|
+
hasError ? 'border-destructive' : 'border-primary',
|
|
64
|
+
className
|
|
65
|
+
)}
|
|
66
|
+
{...restProps}
|
|
61
67
|
>
|
|
62
|
-
|
|
63
|
-
|
|
68
|
+
<!-- Touch target expansion (invisible, extends clickable area to 44x44px) -->
|
|
69
|
+
<span class="absolute inset-0 -m-3" aria-hidden="true"></span>
|
|
70
|
+
{#if checked}
|
|
71
|
+
<svg
|
|
72
|
+
class="h-5 w-5"
|
|
73
|
+
fill="none"
|
|
74
|
+
stroke="currentColor"
|
|
75
|
+
stroke-width="2"
|
|
76
|
+
viewBox="0 0 24 24"
|
|
77
|
+
aria-hidden="true"
|
|
78
|
+
>
|
|
79
|
+
<polyline points="20 6 9 17 4 12" />
|
|
80
|
+
</svg>
|
|
81
|
+
{/if}
|
|
82
|
+
</CheckboxPrimitive.Root>
|
|
83
|
+
{#if children}
|
|
84
|
+
<label
|
|
85
|
+
for={effectiveId}
|
|
86
|
+
class={cn(
|
|
87
|
+
'text-sm leading-relaxed cursor-pointer select-none',
|
|
88
|
+
disabled && 'cursor-not-allowed opacity-50'
|
|
89
|
+
)}
|
|
90
|
+
>
|
|
91
|
+
{@render children()}
|
|
92
|
+
{#if required}
|
|
93
|
+
<span class="text-destructive ml-1" aria-hidden="true">*</span>
|
|
94
|
+
<span class="sr-only">(required)</span>
|
|
95
|
+
{/if}
|
|
96
|
+
</label>
|
|
97
|
+
{/if}
|
|
98
|
+
</div>
|
|
99
|
+
{#if error}
|
|
100
|
+
<p id={errorId} class="text-sm text-destructive ml-8" role="alert" aria-live="polite">
|
|
101
|
+
{error}
|
|
102
|
+
</p>
|
|
64
103
|
{/if}
|
|
65
|
-
</
|
|
104
|
+
</div>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
1
2
|
interface Props {
|
|
2
3
|
checked?: boolean;
|
|
3
4
|
disabled?: boolean;
|
|
@@ -6,7 +7,9 @@ interface Props {
|
|
|
6
7
|
value?: string;
|
|
7
8
|
id?: string;
|
|
8
9
|
class?: string;
|
|
10
|
+
error?: string;
|
|
9
11
|
onCheckedChange?: (checked: boolean) => void;
|
|
12
|
+
children?: Snippet;
|
|
10
13
|
[key: string]: unknown;
|
|
11
14
|
}
|
|
12
15
|
declare const Checkbox: import("svelte").Component<Props, {}, "checked">;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
2
|
declare const Toast: import("svelte").Component<{
|
|
3
3
|
[key: string]: unknown;
|
|
4
|
-
type?: "
|
|
4
|
+
type?: "error" | "warning" | "success" | "info" | undefined;
|
|
5
5
|
title?: string;
|
|
6
6
|
message: string;
|
|
7
7
|
class?: string;
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
<div class={cn('gap-8', hasSidebar ? 'grid lg:grid-cols-[1fr,320px]' : 'mx-auto max-w-2xl')}>
|
|
68
68
|
<!-- Sidebar (shows first on mobile when present) -->
|
|
69
69
|
{#if hasSidebar}
|
|
70
|
-
<aside class="order-first lg:order-last">
|
|
70
|
+
<aside class="order-first lg:order-last space-y-6">
|
|
71
71
|
{@render sidebar!()}
|
|
72
72
|
</aside>
|
|
73
73
|
{/if}
|
|
@@ -84,6 +84,8 @@ export interface UseFormReturn<T extends z.ZodObject<z.ZodRawShape>> {
|
|
|
84
84
|
readonly data: z.infer<T>;
|
|
85
85
|
/** Field errors (reactive) */
|
|
86
86
|
readonly errors: Record<string, string>;
|
|
87
|
+
/** Global error message for API/submission errors (reactive) */
|
|
88
|
+
readonly globalError: string;
|
|
87
89
|
/** Whether form is submitting (reactive) */
|
|
88
90
|
readonly isSubmitting: boolean;
|
|
89
91
|
/** Whether form has been submitted (reactive) */
|
|
@@ -110,8 +112,12 @@ export interface UseFormReturn<T extends z.ZodObject<z.ZodRawShape>> {
|
|
|
110
112
|
clearErrors: () => void;
|
|
111
113
|
/** Set a specific error */
|
|
112
114
|
setError: (field: string, message: string) => void;
|
|
115
|
+
/** Set the global error message */
|
|
116
|
+
setGlobalError: (message: string) => void;
|
|
113
117
|
/** Mark a field as dirty */
|
|
114
118
|
markDirty: (field: string) => void;
|
|
119
|
+
/** Handle field blur - validates the field and marks dirty */
|
|
120
|
+
handleBlur: (field: keyof z.infer<T>) => void;
|
|
115
121
|
}
|
|
116
122
|
/**
|
|
117
123
|
* Create a form handler with Zod validation and Svelte 5 runes
|
|
@@ -51,12 +51,13 @@ export function useForm(options) {
|
|
|
51
51
|
// Reactive state using Svelte 5 runes
|
|
52
52
|
let data = $state(cloneInitialValues());
|
|
53
53
|
let errors = $state({});
|
|
54
|
+
let globalError = $state('');
|
|
54
55
|
let isSubmitting = $state(false);
|
|
55
56
|
let isSubmitted = $state(false);
|
|
56
57
|
let dirtyFields = $state(new Set());
|
|
57
58
|
// Derived state
|
|
58
59
|
const isDirty = $derived(dirtyFields.size > 0);
|
|
59
|
-
const isValid = $derived(Object.keys(errors).length === 0);
|
|
60
|
+
const isValid = $derived(Object.keys(errors).length === 0 && globalError === '');
|
|
60
61
|
/**
|
|
61
62
|
* Set nested value in object using dot notation path
|
|
62
63
|
*/
|
|
@@ -159,6 +160,9 @@ export function useForm(options) {
|
|
|
159
160
|
*/
|
|
160
161
|
async function handleSubmit(event) {
|
|
161
162
|
event?.preventDefault();
|
|
163
|
+
// Clear previous errors
|
|
164
|
+
errors = {};
|
|
165
|
+
globalError = '';
|
|
162
166
|
isSubmitted = true;
|
|
163
167
|
// Validate all fields
|
|
164
168
|
if (!validate()) {
|
|
@@ -180,38 +184,56 @@ export function useForm(options) {
|
|
|
180
184
|
}
|
|
181
185
|
catch (error) {
|
|
182
186
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
187
|
+
const message = errorMessage || err.message || 'An error occurred';
|
|
188
|
+
// Set global error for display in form
|
|
189
|
+
globalError = message;
|
|
183
190
|
if (onError) {
|
|
184
191
|
onError(err);
|
|
185
192
|
}
|
|
186
193
|
if (showToastOnError) {
|
|
187
|
-
toastStore.error(
|
|
194
|
+
toastStore.error(message);
|
|
188
195
|
}
|
|
189
196
|
}
|
|
190
197
|
finally {
|
|
191
198
|
isSubmitting = false;
|
|
192
199
|
}
|
|
193
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* Handle field blur - validates the field and marks dirty
|
|
203
|
+
*/
|
|
204
|
+
function handleBlur(field) {
|
|
205
|
+
markDirty(field);
|
|
206
|
+
validateField(field);
|
|
207
|
+
}
|
|
194
208
|
/**
|
|
195
209
|
* Reset form to initial values
|
|
196
210
|
*/
|
|
197
211
|
function reset() {
|
|
198
212
|
data = cloneInitialValues();
|
|
199
213
|
errors = {};
|
|
214
|
+
globalError = '';
|
|
200
215
|
isSubmitted = false;
|
|
201
216
|
dirtyFields = new Set();
|
|
202
217
|
}
|
|
203
218
|
/**
|
|
204
|
-
* Clear all errors
|
|
219
|
+
* Clear all errors (field and global)
|
|
205
220
|
*/
|
|
206
221
|
function clearErrors() {
|
|
207
222
|
errors = {};
|
|
223
|
+
globalError = '';
|
|
208
224
|
}
|
|
209
225
|
/**
|
|
210
|
-
* Set a specific error manually
|
|
226
|
+
* Set a specific field error manually
|
|
211
227
|
*/
|
|
212
228
|
function setError(field, message) {
|
|
213
229
|
errors = { ...errors, [field]: message };
|
|
214
230
|
}
|
|
231
|
+
/**
|
|
232
|
+
* Set the global error message
|
|
233
|
+
*/
|
|
234
|
+
function setGlobalError(message) {
|
|
235
|
+
globalError = message;
|
|
236
|
+
}
|
|
215
237
|
return {
|
|
216
238
|
get data() {
|
|
217
239
|
return data;
|
|
@@ -219,6 +241,9 @@ export function useForm(options) {
|
|
|
219
241
|
get errors() {
|
|
220
242
|
return errors;
|
|
221
243
|
},
|
|
244
|
+
get globalError() {
|
|
245
|
+
return globalError;
|
|
246
|
+
},
|
|
222
247
|
get isSubmitting() {
|
|
223
248
|
return isSubmitting;
|
|
224
249
|
},
|
|
@@ -240,6 +265,8 @@ export function useForm(options) {
|
|
|
240
265
|
reset,
|
|
241
266
|
clearErrors,
|
|
242
267
|
setError,
|
|
268
|
+
setGlobalError,
|
|
243
269
|
markDirty,
|
|
270
|
+
handleBlur,
|
|
244
271
|
};
|
|
245
272
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* usePersistedForm - Form state management with localStorage persistence
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Composes with useForm to add:
|
|
5
5
|
* - Automatic draft saving to localStorage with debounce
|
|
6
6
|
* - Draft restoration on mount
|
|
7
7
|
* - Configurable expiration time
|
|
8
8
|
* - Field exclusion from persistence (e.g., consent fields)
|
|
9
|
-
* - Duplicate submission prevention
|
|
9
|
+
* - Duplicate submission prevention with cooldown
|
|
10
10
|
*
|
|
11
11
|
* @example
|
|
12
12
|
* ```svelte
|
|
@@ -44,25 +44,10 @@
|
|
|
44
44
|
* ```
|
|
45
45
|
*/
|
|
46
46
|
import { z } from 'zod';
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
schema: T;
|
|
50
|
-
/** Initial form values */
|
|
51
|
-
initialValues: z.infer<T>;
|
|
52
|
-
/** Called on successful validation and submission */
|
|
53
|
-
onSubmit: (data: z.infer<T>) => Promise<void> | void;
|
|
47
|
+
import { type UseFormOptions, type UseFormReturn } from './useForm.svelte.js';
|
|
48
|
+
export interface UsePersistedFormOptions<T extends z.ZodObject<z.ZodRawShape>> extends UseFormOptions<T> {
|
|
54
49
|
/** localStorage key for draft persistence */
|
|
55
50
|
storageKey: string;
|
|
56
|
-
/** Called when validation or submission fails */
|
|
57
|
-
onError?: (error: Error | z.ZodError) => void;
|
|
58
|
-
/** Whether to show toast on error (default: true) */
|
|
59
|
-
showToastOnError?: boolean;
|
|
60
|
-
/** Whether to reset form after successful submission (default: false) */
|
|
61
|
-
resetOnSuccess?: boolean;
|
|
62
|
-
/** Custom error message for toast */
|
|
63
|
-
errorMessage?: string;
|
|
64
|
-
/** Custom success message for toast (if set, shows toast on success) */
|
|
65
|
-
successMessage?: string;
|
|
66
51
|
/** Fields to exclude from persistence (e.g., consent fields that require re-confirmation) */
|
|
67
52
|
excludeFields?: (keyof z.infer<T>)[];
|
|
68
53
|
/** Expiration time in milliseconds (default: 7 days) */
|
|
@@ -74,43 +59,9 @@ export interface UsePersistedFormOptions<T extends z.ZodObject<z.ZodRawShape>> {
|
|
|
74
59
|
/** Cooldown time between submissions in milliseconds (default: 5000ms) */
|
|
75
60
|
submitCooldownMs?: number;
|
|
76
61
|
}
|
|
77
|
-
export interface UsePersistedFormReturn<T extends z.ZodObject<z.ZodRawShape>> {
|
|
78
|
-
/** Current form data (reactive) */
|
|
79
|
-
readonly data: z.infer<T>;
|
|
80
|
-
/** Field errors (reactive) */
|
|
81
|
-
readonly errors: Record<string, string>;
|
|
82
|
-
/** Whether form is submitting (reactive) */
|
|
83
|
-
readonly isSubmitting: boolean;
|
|
84
|
-
/** Whether form has been submitted (reactive) */
|
|
85
|
-
readonly isSubmitted: boolean;
|
|
86
|
-
/** Whether any field is dirty (reactive) */
|
|
87
|
-
readonly isDirty: boolean;
|
|
88
|
-
/** Whether form is currently valid (reactive) */
|
|
89
|
-
readonly isValid: boolean;
|
|
62
|
+
export interface UsePersistedFormReturn<T extends z.ZodObject<z.ZodRawShape>> extends UseFormReturn<T> {
|
|
90
63
|
/** Whether a draft was restored on mount (reactive) */
|
|
91
64
|
readonly draftRestored: boolean;
|
|
92
|
-
/** Set a field value */
|
|
93
|
-
setField: <K extends keyof z.infer<T>>(field: K, value: z.infer<T>[K]) => void;
|
|
94
|
-
/** Set multiple field values */
|
|
95
|
-
setFields: (values: Partial<z.infer<T>>) => void;
|
|
96
|
-
/** Set a nested field value using dot notation */
|
|
97
|
-
setNestedField: (path: string, value: unknown) => void;
|
|
98
|
-
/** Validate a single field */
|
|
99
|
-
validateField: (field: keyof z.infer<T>) => boolean;
|
|
100
|
-
/** Validate the entire form */
|
|
101
|
-
validate: () => boolean;
|
|
102
|
-
/** Handle form submission */
|
|
103
|
-
handleSubmit: (event?: Event) => Promise<void>;
|
|
104
|
-
/** Reset form to initial values and clear draft */
|
|
105
|
-
reset: () => void;
|
|
106
|
-
/** Clear all errors */
|
|
107
|
-
clearErrors: () => void;
|
|
108
|
-
/** Set a specific error */
|
|
109
|
-
setError: (field: string, message: string) => void;
|
|
110
|
-
/** Mark a field as dirty */
|
|
111
|
-
markDirty: (field: string) => void;
|
|
112
|
-
/** Handle field blur - validates the field */
|
|
113
|
-
handleBlur: (field: keyof z.infer<T>) => void;
|
|
114
65
|
/** Clear the stored draft */
|
|
115
66
|
clearDraft: () => void;
|
|
116
67
|
/** Dismiss the draft restored notification */
|
|
@@ -118,6 +69,7 @@ export interface UsePersistedFormReturn<T extends z.ZodObject<z.ZodRawShape>> {
|
|
|
118
69
|
}
|
|
119
70
|
/**
|
|
120
71
|
* Create a form handler with localStorage persistence
|
|
72
|
+
* Composes with useForm for all base functionality
|
|
121
73
|
*/
|
|
122
74
|
export declare function usePersistedForm<T extends z.ZodObject<z.ZodRawShape>>(options: UsePersistedFormOptions<T>): UsePersistedFormReturn<T>;
|
|
123
75
|
/**
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* usePersistedForm - Form state management with localStorage persistence
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Composes with useForm to add:
|
|
5
5
|
* - Automatic draft saving to localStorage with debounce
|
|
6
6
|
* - Draft restoration on mount
|
|
7
7
|
* - Configurable expiration time
|
|
8
8
|
* - Field exclusion from persistence (e.g., consent fields)
|
|
9
|
-
* - Duplicate submission prevention
|
|
9
|
+
* - Duplicate submission prevention with cooldown
|
|
10
10
|
*
|
|
11
11
|
* @example
|
|
12
12
|
* ```svelte
|
|
@@ -43,79 +43,38 @@
|
|
|
43
43
|
* </form>
|
|
44
44
|
* ```
|
|
45
45
|
*/
|
|
46
|
+
import { useForm } from './useForm.svelte.js';
|
|
46
47
|
import { toastStore } from '../stores/toast.svelte.js';
|
|
47
48
|
const DEFAULT_EXPIRATION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
48
49
|
const DEFAULT_DEBOUNCE_MS = 500;
|
|
49
50
|
const DEFAULT_SUBMIT_COOLDOWN_MS = 5000;
|
|
51
|
+
/**
|
|
52
|
+
* Check if localStorage is available (also checks for browser environment)
|
|
53
|
+
*/
|
|
54
|
+
function isStorageAvailable() {
|
|
55
|
+
if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const test = '__storage_test__';
|
|
60
|
+
localStorage.setItem(test, test);
|
|
61
|
+
localStorage.removeItem(test);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
50
68
|
/**
|
|
51
69
|
* Create a form handler with localStorage persistence
|
|
70
|
+
* Composes with useForm for all base functionality
|
|
52
71
|
*/
|
|
53
72
|
export function usePersistedForm(options) {
|
|
54
|
-
const {
|
|
55
|
-
//
|
|
56
|
-
const cloneInitialValues = () => JSON.parse(JSON.stringify(initialValues));
|
|
57
|
-
// Reactive state using Svelte 5 runes
|
|
58
|
-
let data = $state(cloneInitialValues());
|
|
59
|
-
let errors = $state({});
|
|
60
|
-
let isSubmitting = $state(false);
|
|
61
|
-
let isSubmitted = $state(false);
|
|
62
|
-
let dirtyFields = $state(new Set());
|
|
73
|
+
const { storageKey, excludeFields = [], expirationMs = DEFAULT_EXPIRATION_MS, onDraftRestored, debounceMs = DEFAULT_DEBOUNCE_MS, submitCooldownMs = DEFAULT_SUBMIT_COOLDOWN_MS, onSubmit, initialValues, ...baseOptions } = options;
|
|
74
|
+
// Persistence state
|
|
63
75
|
let draftRestored = $state(false);
|
|
64
76
|
let lastSubmitTime = $state(0);
|
|
65
77
|
let debounceTimer = null;
|
|
66
|
-
// Derived state
|
|
67
|
-
const isDirty = $derived(dirtyFields.size > 0);
|
|
68
|
-
const isValid = $derived(Object.keys(errors).length === 0);
|
|
69
|
-
/**
|
|
70
|
-
* Check if localStorage is available (also checks for browser environment)
|
|
71
|
-
*/
|
|
72
|
-
function isStorageAvailable() {
|
|
73
|
-
if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
|
|
74
|
-
return false;
|
|
75
|
-
}
|
|
76
|
-
try {
|
|
77
|
-
const test = '__storage_test__';
|
|
78
|
-
localStorage.setItem(test, test);
|
|
79
|
-
localStorage.removeItem(test);
|
|
80
|
-
return true;
|
|
81
|
-
}
|
|
82
|
-
catch {
|
|
83
|
-
return false;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
/**
|
|
87
|
-
* Get data to persist (excludes specified fields)
|
|
88
|
-
*/
|
|
89
|
-
function getDataToPersist() {
|
|
90
|
-
const dataCopy = { ...data };
|
|
91
|
-
for (const field of excludeFields) {
|
|
92
|
-
delete dataCopy[field];
|
|
93
|
-
}
|
|
94
|
-
return dataCopy;
|
|
95
|
-
}
|
|
96
|
-
/**
|
|
97
|
-
* Save draft to localStorage with debounce
|
|
98
|
-
*/
|
|
99
|
-
function saveDraft() {
|
|
100
|
-
if (!isStorageAvailable())
|
|
101
|
-
return;
|
|
102
|
-
if (debounceTimer) {
|
|
103
|
-
clearTimeout(debounceTimer);
|
|
104
|
-
}
|
|
105
|
-
debounceTimer = setTimeout(() => {
|
|
106
|
-
try {
|
|
107
|
-
const draft = {
|
|
108
|
-
data: getDataToPersist(),
|
|
109
|
-
timestamp: Date.now(),
|
|
110
|
-
};
|
|
111
|
-
localStorage.setItem(storageKey, JSON.stringify(draft));
|
|
112
|
-
}
|
|
113
|
-
catch (error) {
|
|
114
|
-
// Silently fail - localStorage may be full or unavailable
|
|
115
|
-
console.warn('Failed to save form draft', error);
|
|
116
|
-
}
|
|
117
|
-
}, debounceMs);
|
|
118
|
-
}
|
|
119
78
|
/**
|
|
120
79
|
* Load draft from localStorage
|
|
121
80
|
*/
|
|
@@ -153,203 +112,99 @@ export function usePersistedForm(options) {
|
|
|
153
112
|
}
|
|
154
113
|
}
|
|
155
114
|
/**
|
|
156
|
-
*
|
|
115
|
+
* Get the initial values, merging with any restored draft
|
|
157
116
|
*/
|
|
158
|
-
function
|
|
117
|
+
function getInitialValues() {
|
|
159
118
|
const draft = loadDraft();
|
|
160
119
|
if (draft && Object.keys(draft).length > 0) {
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
// Restore draft on initialization (only in browser)
|
|
168
|
-
if (typeof window !== 'undefined') {
|
|
169
|
-
restoreDraft();
|
|
170
|
-
}
|
|
171
|
-
// Watch for form changes and save draft
|
|
172
|
-
$effect(() => {
|
|
173
|
-
// Access data to create dependency
|
|
174
|
-
JSON.stringify(data);
|
|
175
|
-
if (dirtyFields.size > 0) {
|
|
176
|
-
saveDraft();
|
|
177
|
-
}
|
|
178
|
-
});
|
|
179
|
-
/**
|
|
180
|
-
* Set nested value in object using dot notation path
|
|
181
|
-
*/
|
|
182
|
-
function setNestedValue(obj, path, value) {
|
|
183
|
-
const keys = path.split('.');
|
|
184
|
-
let current = obj;
|
|
185
|
-
for (let i = 0; i < keys.length - 1; i++) {
|
|
186
|
-
const key = keys[i];
|
|
187
|
-
if (current[key] === undefined || current[key] === null) {
|
|
188
|
-
current[key] = {};
|
|
120
|
+
// Schedule callback for after initialization
|
|
121
|
+
if (typeof window !== 'undefined') {
|
|
122
|
+
queueMicrotask(() => {
|
|
123
|
+
draftRestored = true;
|
|
124
|
+
onDraftRestored?.();
|
|
125
|
+
});
|
|
189
126
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
current[keys[keys.length - 1]] = value;
|
|
193
|
-
}
|
|
194
|
-
/**
|
|
195
|
-
* Parse Zod errors into field-keyed error map
|
|
196
|
-
*/
|
|
197
|
-
function parseZodErrors(zodError) {
|
|
198
|
-
const fieldErrors = {};
|
|
199
|
-
for (const issue of zodError.issues) {
|
|
200
|
-
const path = issue.path.join('.');
|
|
201
|
-
// Only keep the first error for each field
|
|
202
|
-
if (!fieldErrors[path]) {
|
|
203
|
-
fieldErrors[path] = issue.message;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
return fieldErrors;
|
|
207
|
-
}
|
|
208
|
-
/**
|
|
209
|
-
* Set a single field value
|
|
210
|
-
*/
|
|
211
|
-
function setField(field, value) {
|
|
212
|
-
data[field] = value;
|
|
213
|
-
markDirty(field);
|
|
214
|
-
}
|
|
215
|
-
/**
|
|
216
|
-
* Set multiple field values
|
|
217
|
-
*/
|
|
218
|
-
function setFields(values) {
|
|
219
|
-
for (const [key, value] of Object.entries(values)) {
|
|
220
|
-
data[key] = value;
|
|
221
|
-
markDirty(key);
|
|
127
|
+
// Merge draft with initial values (keeps excluded fields at defaults)
|
|
128
|
+
return { ...JSON.parse(JSON.stringify(initialValues)), ...draft };
|
|
222
129
|
}
|
|
130
|
+
return JSON.parse(JSON.stringify(initialValues));
|
|
223
131
|
}
|
|
224
132
|
/**
|
|
225
|
-
*
|
|
133
|
+
* Wrapped onSubmit with cooldown and draft clearing
|
|
226
134
|
*/
|
|
227
|
-
function
|
|
228
|
-
setNestedValue(data, path, value);
|
|
229
|
-
markDirty(path);
|
|
230
|
-
}
|
|
231
|
-
/**
|
|
232
|
-
* Mark a field as dirty
|
|
233
|
-
*/
|
|
234
|
-
function markDirty(field) {
|
|
235
|
-
dirtyFields = new Set([...dirtyFields, field]);
|
|
236
|
-
}
|
|
237
|
-
/**
|
|
238
|
-
* Validate a single field against the schema
|
|
239
|
-
*/
|
|
240
|
-
function validateField(field) {
|
|
241
|
-
const fieldSchema = schema.shape[field];
|
|
242
|
-
if (!fieldSchema)
|
|
243
|
-
return true;
|
|
244
|
-
const fieldValue = data[field];
|
|
245
|
-
const result = fieldSchema.safeParse(fieldValue);
|
|
246
|
-
if (result.success) {
|
|
247
|
-
// Clear error for this field
|
|
248
|
-
const newErrors = { ...errors };
|
|
249
|
-
delete newErrors[field];
|
|
250
|
-
errors = newErrors;
|
|
251
|
-
return true;
|
|
252
|
-
}
|
|
253
|
-
else {
|
|
254
|
-
// Set error for this field
|
|
255
|
-
errors = {
|
|
256
|
-
...errors,
|
|
257
|
-
[field]: result.error.issues[0]?.message || 'Invalid value',
|
|
258
|
-
};
|
|
259
|
-
return false;
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
/**
|
|
263
|
-
* Validate the entire form
|
|
264
|
-
*/
|
|
265
|
-
function validate() {
|
|
266
|
-
const result = schema.safeParse(data);
|
|
267
|
-
if (result.success) {
|
|
268
|
-
errors = {};
|
|
269
|
-
return true;
|
|
270
|
-
}
|
|
271
|
-
else {
|
|
272
|
-
errors = parseZodErrors(result.error);
|
|
273
|
-
return false;
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
/**
|
|
277
|
-
* Handle field blur - validates the field
|
|
278
|
-
*/
|
|
279
|
-
function handleBlur(field) {
|
|
280
|
-
markDirty(field);
|
|
281
|
-
validateField(field);
|
|
282
|
-
}
|
|
283
|
-
/**
|
|
284
|
-
* Handle form submission
|
|
285
|
-
*/
|
|
286
|
-
async function handleSubmit(event) {
|
|
287
|
-
event?.preventDefault();
|
|
135
|
+
async function wrappedOnSubmit(data) {
|
|
288
136
|
// Check for duplicate submission
|
|
289
137
|
const now = Date.now();
|
|
290
138
|
if (now - lastSubmitTime < submitCooldownMs) {
|
|
291
139
|
toastStore.warning('Please wait before submitting again');
|
|
292
|
-
|
|
140
|
+
throw new Error('Submission cooldown active');
|
|
293
141
|
}
|
|
294
|
-
isSubmitted = true;
|
|
295
|
-
// Validate all fields
|
|
296
|
-
if (!validate()) {
|
|
297
|
-
const errorCount = Object.keys(errors).length;
|
|
298
|
-
if (showToastOnError) {
|
|
299
|
-
toastStore.error(errorMessage || `Please fix ${errorCount} validation error${errorCount > 1 ? 's' : ''}`);
|
|
300
|
-
}
|
|
301
|
-
return;
|
|
302
|
-
}
|
|
303
|
-
isSubmitting = true;
|
|
304
142
|
try {
|
|
305
143
|
await onSubmit(data);
|
|
306
144
|
lastSubmitTime = now;
|
|
307
145
|
// Clear draft on successful submission
|
|
308
146
|
clearDraft();
|
|
309
|
-
if (successMessage) {
|
|
310
|
-
toastStore.success(successMessage);
|
|
311
|
-
}
|
|
312
|
-
if (resetOnSuccess) {
|
|
313
|
-
reset();
|
|
314
|
-
}
|
|
315
147
|
}
|
|
316
148
|
catch (error) {
|
|
317
149
|
// Reset cooldown on error to allow retry
|
|
318
150
|
lastSubmitTime = 0;
|
|
319
|
-
|
|
320
|
-
if (onError) {
|
|
321
|
-
onError(err);
|
|
322
|
-
}
|
|
323
|
-
if (showToastOnError) {
|
|
324
|
-
toastStore.error(errorMessage || err.message || 'An error occurred');
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
finally {
|
|
328
|
-
isSubmitting = false;
|
|
151
|
+
throw error;
|
|
329
152
|
}
|
|
330
153
|
}
|
|
154
|
+
// Create base form with potentially restored initial values
|
|
155
|
+
const baseForm = useForm({
|
|
156
|
+
...baseOptions,
|
|
157
|
+
initialValues: getInitialValues(),
|
|
158
|
+
onSubmit: wrappedOnSubmit,
|
|
159
|
+
});
|
|
331
160
|
/**
|
|
332
|
-
*
|
|
161
|
+
* Get data to persist (excludes specified fields)
|
|
333
162
|
*/
|
|
334
|
-
function
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
clearDraft();
|
|
163
|
+
function getDataToPersist() {
|
|
164
|
+
const dataCopy = { ...baseForm.data };
|
|
165
|
+
for (const field of excludeFields) {
|
|
166
|
+
delete dataCopy[field];
|
|
167
|
+
}
|
|
168
|
+
return dataCopy;
|
|
341
169
|
}
|
|
342
170
|
/**
|
|
343
|
-
*
|
|
171
|
+
* Save draft to localStorage with debounce
|
|
344
172
|
*/
|
|
345
|
-
function
|
|
346
|
-
|
|
173
|
+
function saveDraft() {
|
|
174
|
+
if (!isStorageAvailable())
|
|
175
|
+
return;
|
|
176
|
+
if (debounceTimer) {
|
|
177
|
+
clearTimeout(debounceTimer);
|
|
178
|
+
}
|
|
179
|
+
debounceTimer = setTimeout(() => {
|
|
180
|
+
try {
|
|
181
|
+
const draft = {
|
|
182
|
+
data: getDataToPersist(),
|
|
183
|
+
timestamp: Date.now(),
|
|
184
|
+
};
|
|
185
|
+
localStorage.setItem(storageKey, JSON.stringify(draft));
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
// Silently fail - localStorage may be full or unavailable
|
|
189
|
+
console.warn('Failed to save form draft', error);
|
|
190
|
+
}
|
|
191
|
+
}, debounceMs);
|
|
347
192
|
}
|
|
193
|
+
// Watch for form changes and save draft
|
|
194
|
+
$effect(() => {
|
|
195
|
+
// Access data to create dependency
|
|
196
|
+
JSON.stringify(baseForm.data);
|
|
197
|
+
if (baseForm.isDirty) {
|
|
198
|
+
saveDraft();
|
|
199
|
+
}
|
|
200
|
+
});
|
|
348
201
|
/**
|
|
349
|
-
*
|
|
202
|
+
* Extended reset that also clears the draft
|
|
350
203
|
*/
|
|
351
|
-
function
|
|
352
|
-
|
|
204
|
+
function reset() {
|
|
205
|
+
baseForm.reset();
|
|
206
|
+
draftRestored = false;
|
|
207
|
+
clearDraft();
|
|
353
208
|
}
|
|
354
209
|
/**
|
|
355
210
|
* Dismiss the draft restored notification
|
|
@@ -358,38 +213,47 @@ export function usePersistedForm(options) {
|
|
|
358
213
|
draftRestored = false;
|
|
359
214
|
}
|
|
360
215
|
return {
|
|
216
|
+
// Delegate all base form properties
|
|
361
217
|
get data() {
|
|
362
|
-
return data;
|
|
218
|
+
return baseForm.data;
|
|
363
219
|
},
|
|
364
220
|
get errors() {
|
|
365
|
-
return errors;
|
|
221
|
+
return baseForm.errors;
|
|
222
|
+
},
|
|
223
|
+
get globalError() {
|
|
224
|
+
return baseForm.globalError;
|
|
366
225
|
},
|
|
367
226
|
get isSubmitting() {
|
|
368
|
-
return isSubmitting;
|
|
227
|
+
return baseForm.isSubmitting;
|
|
369
228
|
},
|
|
370
229
|
get isSubmitted() {
|
|
371
|
-
return isSubmitted;
|
|
230
|
+
return baseForm.isSubmitted;
|
|
372
231
|
},
|
|
373
232
|
get isDirty() {
|
|
374
|
-
return isDirty;
|
|
233
|
+
return baseForm.isDirty;
|
|
375
234
|
},
|
|
376
235
|
get isValid() {
|
|
377
|
-
return isValid;
|
|
236
|
+
return baseForm.isValid;
|
|
378
237
|
},
|
|
238
|
+
// Persistence-specific properties
|
|
379
239
|
get draftRestored() {
|
|
380
240
|
return draftRestored;
|
|
381
241
|
},
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
242
|
+
// Delegate base form methods
|
|
243
|
+
setField: baseForm.setField,
|
|
244
|
+
setFields: baseForm.setFields,
|
|
245
|
+
setNestedField: baseForm.setNestedField,
|
|
246
|
+
validateField: baseForm.validateField,
|
|
247
|
+
validate: baseForm.validate,
|
|
248
|
+
handleSubmit: baseForm.handleSubmit,
|
|
249
|
+
clearErrors: baseForm.clearErrors,
|
|
250
|
+
setError: baseForm.setError,
|
|
251
|
+
setGlobalError: baseForm.setGlobalError,
|
|
252
|
+
markDirty: baseForm.markDirty,
|
|
253
|
+
handleBlur: baseForm.handleBlur,
|
|
254
|
+
// Override reset to also clear draft
|
|
388
255
|
reset,
|
|
389
|
-
|
|
390
|
-
setError,
|
|
391
|
-
markDirty,
|
|
392
|
-
handleBlur,
|
|
256
|
+
// Persistence-specific methods
|
|
393
257
|
clearDraft,
|
|
394
258
|
dismissDraftNotification,
|
|
395
259
|
};
|