@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.
@@ -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" | "warning" | "error" | "success" | "info" | undefined;
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
- <CheckboxPrimitive.Root
38
- bind:checked
39
- {disabled}
40
- {required}
41
- {name}
42
- {value}
43
- {id}
44
- onCheckedChange={handleCheckedChange}
45
- class={cn(
46
- 'peer relative h-5 w-5 shrink-0 rounded-sm border border-primary 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',
47
- className
48
- )}
49
- {...restProps}
50
- >
51
- <!-- Touch target expansion (invisible, extends clickable area to 44x44px) -->
52
- <span class="absolute inset-0 -m-3" aria-hidden="true"></span>
53
- {#if checked}
54
- <svg
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
- <polyline points="20 6 9 17 4 12" />
63
- </svg>
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
- </CheckboxPrimitive.Root>
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?: "warning" | "error" | "success" | "info" | undefined;
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(errorMessage || err.message || 'An error occurred');
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
- * Extends useForm with:
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
- export interface UsePersistedFormOptions<T extends z.ZodObject<z.ZodRawShape>> {
48
- /** Zod schema for validation */
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
- * Extends useForm with:
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 { schema, initialValues, onSubmit, storageKey, onError, showToastOnError = true, resetOnSuccess = false, errorMessage, successMessage, excludeFields = [], expirationMs = DEFAULT_EXPIRATION_MS, onDraftRestored, debounceMs = DEFAULT_DEBOUNCE_MS, submitCooldownMs = DEFAULT_SUBMIT_COOLDOWN_MS, } = options;
55
- // Deep clone to avoid reference issues
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
- * Restore draft on initialization
115
+ * Get the initial values, merging with any restored draft
157
116
  */
158
- function restoreDraft() {
117
+ function getInitialValues() {
159
118
  const draft = loadDraft();
160
119
  if (draft && Object.keys(draft).length > 0) {
161
- // Merge draft with initial data (to keep excluded fields at their defaults)
162
- data = { ...cloneInitialValues(), ...draft };
163
- draftRestored = true;
164
- onDraftRestored?.();
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
- current = current[key];
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
- * Set a nested field value using dot notation
133
+ * Wrapped onSubmit with cooldown and draft clearing
226
134
  */
227
- function setNestedField(path, value) {
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
- return;
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
- const err = error instanceof Error ? error : new Error(String(error));
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
- * Reset form to initial values and clear draft
161
+ * Get data to persist (excludes specified fields)
333
162
  */
334
- function reset() {
335
- data = cloneInitialValues();
336
- errors = {};
337
- isSubmitted = false;
338
- dirtyFields = new Set();
339
- draftRestored = false;
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
- * Clear all errors
171
+ * Save draft to localStorage with debounce
344
172
  */
345
- function clearErrors() {
346
- errors = {};
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
- * Set a specific error manually
202
+ * Extended reset that also clears the draft
350
203
  */
351
- function setError(field, message) {
352
- errors = { ...errors, [field]: message };
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
- setField,
383
- setFields,
384
- setNestedField,
385
- validateField,
386
- validate,
387
- handleSubmit,
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
- clearErrors,
390
- setError,
391
- markDirty,
392
- handleBlur,
256
+ // Persistence-specific methods
393
257
  clearDraft,
394
258
  dismissDraftNotification,
395
259
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/theme-svelte",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Svelte components for the Classic theme system",
5
5
  "type": "module",
6
6
  "svelte": "./dist/lib/index.js",