From 98807be7afffb0c17b2b74b72dc58b3ce9d4d5fc Mon Sep 17 00:00:00 2001 From: BCsabaEngine Date: Fri, 30 Jan 2026 19:37:21 +0100 Subject: [PATCH 1/3] feat: async validator --- src/index.ts | 3 + src/state.svelte.ts | 171 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 171 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index fcb8334..9485a4d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,7 @@ export { + type AsyncErrors, + type AsyncValidator, + type AsyncValidatorFunction, createSvState, type EffectContext, type Snapshot, diff --git a/src/state.svelte.ts b/src/state.svelte.ts index 5ea9281..39cb210 100644 --- a/src/state.svelte.ts +++ b/src/state.svelte.ts @@ -22,11 +22,23 @@ export type EffectContext = { oldValue: unknown; }; +// Async validation types +export type AsyncValidatorFunction = (value: unknown, source: T, signal: AbortSignal) => Promise; + +export type AsyncValidator = { + [propertyPath: string]: AsyncValidatorFunction; +}; + +export type AsyncErrors = { + [propertyPath: string]: string; +}; + type Actuators, V extends Validator, P extends object> = { validator?: (source: T) => V; effect?: (context: EffectContext) => void; action?: Action

; actionCompleted?: (error?: unknown) => void | Promise; + asyncValidator?: AsyncValidator; }; type StateResult = { @@ -36,6 +48,10 @@ type StateResult = { actionInProgress: Readable; actionError: Readable; snapshots: Readable[]>; + asyncErrors: Readable; + hasAsyncErrors: Readable; + asyncValidating: Readable; + hasCombinedErrors: Readable; }; // Helpers @@ -52,18 +68,58 @@ const deepClone = (object: T): T => { return cloned; }; +// Async validation helpers +const getValueAtPath = (source: T, path: string): unknown => { + const parts = path.split('.'); + let current: unknown = source; + for (const part of parts) { + if (current === null || current === undefined) return undefined; + current = (current as Record)[part]; + } + return current; +}; + +const getSyncErrorForPath = (errors: Validator | undefined, path: string): string => { + if (!errors) return ''; + const parts = path.split('.'); + let current: string | Validator = errors; + for (const part of parts) { + if (typeof current === 'string') return ''; + if (current[part] === undefined) return ''; + current = current[part]; + } + return typeof current === 'string' ? current : ''; +}; + +const getMatchingAsyncValidatorPaths = (asyncValidator: AsyncValidator, changedPath: string): string[] => { + const matches: string[] = []; + for (const registeredPath of Object.keys(asyncValidator)) + // Exact match or changed path is a prefix of registered path + if (registeredPath === changedPath || registeredPath.startsWith(changedPath + '.')) matches.push(registeredPath); + // Changed path is nested within registered path (e.g., validator for 'user', changed 'user.name') + else if (changedPath.startsWith(registeredPath + '.')) matches.push(registeredPath); + + return matches; +}; + // Options export type SvStateOptions = { resetDirtyOnAction: boolean; debounceValidation: number; allowConcurrentActions: boolean; persistActionError: boolean; + debounceAsyncValidation: number; + runAsyncValidationOnInit: boolean; + clearAsyncErrorsOnChange: boolean; }; const defaultOptions: SvStateOptions = { resetDirtyOnAction: true, debounceValidation: 0, allowConcurrentActions: false, - persistActionError: false + persistActionError: false, + debounceAsyncValidation: 300, + runAsyncValidationOnInit: false, + clearAsyncErrorsOnChange: true }; // createSvState @@ -74,7 +130,7 @@ export function createSvState, V extends Valid ) { const usedOptions: SvStateOptions = { ...defaultOptions, ...options }; - const { validator, effect } = actuators ?? {}; + const { validator, effect, asyncValidator } = actuators ?? {}; const errors = writable(); const hasErrors = derived(errors, hasAnyErrors); @@ -83,6 +139,24 @@ export function createSvState, V extends Valid const actionError = writable(); const snapshots = writable[]>([{ title: 'Initial', data: deepClone(init) }]); + // Async validation stores + const asyncErrorsStore = writable({}); + const asyncValidatingSet = writable>(new Set()); + const asyncValidating = derived(asyncValidatingSet, ($set) => [...$set]); + const hasAsyncErrors = derived(asyncErrorsStore, ($asyncErrors) => + Object.values($asyncErrors).some((error) => !!error) + ); + const hasCombinedErrors = derived( + [hasErrors, hasAsyncErrors], + ([$hasErrors, $hasAsyncErrors]) => $hasErrors || $hasAsyncErrors + ); + + // Async validation trackers for cancellation + const asyncValidationTrackers = new Map< + string, + { controller: AbortController; timeoutId: ReturnType } + >(); + const stateObject = $state(init); const createSnapshot: SnapshotFunction = (title: string, replace = true) => { @@ -116,6 +190,86 @@ export function createSvState, V extends Valid } }; + // Async validation functions + const cancelAsyncValidation = (path: string) => { + const tracker = asyncValidationTrackers.get(path); + if (tracker) { + clearTimeout(tracker.timeoutId); + tracker.controller.abort(); + asyncValidationTrackers.delete(path); + asyncValidatingSet.update(($set) => { + $set.delete(path); + return new Set($set); + }); + } + }; + + const cancelAllAsyncValidations = () => { + for (const path of asyncValidationTrackers.keys()) cancelAsyncValidation(path); + asyncErrorsStore.set({}); + }; + + const scheduleAsyncValidation = (path: string) => { + if (!asyncValidator || !asyncValidator[path]) return; + + // Cancel any existing validation for this path + cancelAsyncValidation(path); + + // Clear async error if configured + if (usedOptions.clearAsyncErrorsOnChange) + asyncErrorsStore.update(($asyncErrors) => { + const updated = { ...$asyncErrors }; + delete updated[path]; + return updated; + }); + + const controller = new AbortController(); + const timeoutId = setTimeout(async () => { + // Check sync error for this path - skip if sync fails + const syncError = getSyncErrorForPath(get(errors), path); + if (syncError) { + asyncValidationTrackers.delete(path); + return; + } + + // Mark as validating + asyncValidatingSet.update(($set) => new Set([...$set, path])); + + try { + const value = getValueAtPath(data, path); + const asyncValidatorForPath = asyncValidator[path]; + if (!asyncValidatorForPath) return; + + const error = await asyncValidatorForPath(value, data, controller.signal); + + // Only update if not aborted + if (!controller.signal.aborted) + asyncErrorsStore.update(($asyncErrors) => ({ + ...$asyncErrors, + [path]: error + })); + } catch (error) { + // Ignore abort errors, re-throw others + if (error instanceof Error && error.name !== 'AbortError') throw error; + } finally { + asyncValidationTrackers.delete(path); + asyncValidatingSet.update(($set) => { + $set.delete(path); + return new Set($set); + }); + } + }, usedOptions.debounceAsyncValidation); + + asyncValidationTrackers.set(path, { controller, timeoutId }); + }; + + const scheduleAsyncValidationsForPath = (changedPath: string) => { + if (!asyncValidator) return; + + const matchingPaths = getMatchingAsyncValidatorPaths(asyncValidator, changedPath); + for (const path of matchingPaths) scheduleAsyncValidation(path); + }; + const data = ChangeProxy(stateObject, (target: T, property: string, currentValue: unknown, oldValue: unknown) => { if (!usedOptions.persistActionError) actionError.set(undefined); isDirty.set(true); @@ -123,10 +277,15 @@ export function createSvState, V extends Valid if (effectResult instanceof Promise) throw new Error('svstate: effect callback must be synchronous. Use action for async operations.'); scheduleValidation(); + scheduleAsyncValidationsForPath(property); }); if (validator) errors.set(validator(data)); + // Run async validation on init if configured + if (asyncValidator && usedOptions.runAsyncValidationOnInit) + for (const path of Object.keys(asyncValidator)) scheduleAsyncValidation(path); + const execute = async (parameters?: P) => { if (!usedOptions.allowConcurrentActions && get(actionInProgress)) return; @@ -153,6 +312,7 @@ export function createSvState, V extends Valid const targetSnapshot = currentSnapshots[targetIndex]; if (!targetSnapshot) return; + cancelAllAsyncValidations(); Object.assign(stateObject, deepClone(targetSnapshot.data)); snapshots.set(currentSnapshots.slice(0, targetIndex + 1)); @@ -164,6 +324,7 @@ export function createSvState, V extends Valid const initialSnapshot = currentSnapshots[0]; if (!initialSnapshot) return; + cancelAllAsyncValidations(); Object.assign(stateObject, deepClone(initialSnapshot.data)); snapshots.set([initialSnapshot]); @@ -176,7 +337,11 @@ export function createSvState, V extends Valid isDirty, actionInProgress, actionError, - snapshots + snapshots, + asyncErrors: asyncErrorsStore, + hasAsyncErrors, + asyncValidating, + hasCombinedErrors }; return { data, execute, state, rollback, reset }; From 76911caec192bc881ab15dc371dcefa45d6f450b Mon Sep 17 00:00:00 2001 From: BCsabaEngine Date: Fri, 30 Jan 2026 19:37:25 +0100 Subject: [PATCH 2/3] feat: add test --- test/async-validation.test.svelte.ts | 591 +++++++++++++++++++++++++++ 1 file changed, 591 insertions(+) create mode 100644 test/async-validation.test.svelte.ts diff --git a/test/async-validation.test.svelte.ts b/test/async-validation.test.svelte.ts new file mode 100644 index 0000000..204d430 --- /dev/null +++ b/test/async-validation.test.svelte.ts @@ -0,0 +1,591 @@ +import { get } from 'svelte/store'; + +import { createSvState, stringValidator } from '../src/index'; + +describe('async validation - basic functionality', () => { + it('should run async validator after sync passes', async () => { + let asyncValidatorCalled = false; + + const { data, state } = createSvState( + { username: '' }, + { + validator: (source) => ({ + username: stringValidator(source.username).required().minLength(3).getError() + }), + asyncValidator: { + username: async (value) => { + asyncValidatorCalled = true; + return value === 'taken' ? 'Username already taken' : ''; + } + } + }, + { debounceAsyncValidation: 10 } + ); + + // Set valid username + data.username = 'validuser'; + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(asyncValidatorCalled).toBe(true); + expect(get(state.asyncErrors)).toEqual({ username: '' }); + }); + + it('should skip async validator when sync fails', async () => { + let asyncValidatorCalled = false; + + const { data, state } = createSvState( + { username: '' }, + { + validator: (source) => ({ + username: stringValidator(source.username).required().minLength(3).getError() + }), + asyncValidator: { + username: async () => { + asyncValidatorCalled = true; + return ''; + } + } + }, + { debounceAsyncValidation: 10 } + ); + + // Set invalid username (too short) + data.username = 'ab'; + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(asyncValidatorCalled).toBe(false); + expect(get(state.hasErrors)).toBe(true); + }); + + it('should return async error when validation fails', async () => { + const { data, state } = createSvState( + { username: '' }, + { + validator: (source) => ({ + username: stringValidator(source.username).required().minLength(3).getError() + }), + asyncValidator: { + username: async (value) => (value === 'taken' ? 'Username already taken' : '') + } + }, + { debounceAsyncValidation: 10 } + ); + + data.username = 'taken'; + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(get(state.asyncErrors)).toEqual({ username: 'Username already taken' }); + expect(get(state.hasAsyncErrors)).toBe(true); + }); +}); + +describe('async validation - debouncing', () => { + it('should debounce rapid changes', async () => { + let callCount = 0; + + const { data } = createSvState( + { username: '' }, + { + asyncValidator: { + username: async () => { + callCount++; + return ''; + } + } + }, + { debounceAsyncValidation: 50 } + ); + + // Rapid changes + data.username = 'a'; + data.username = 'ab'; + data.username = 'abc'; + data.username = 'abcd'; + data.username = 'abcde'; + + await new Promise((resolve) => setTimeout(resolve, 150)); + + // Should only call once after debounce + expect(callCount).toBe(1); + }); +}); + +describe('async validation - cancellation', () => { + it('should cancel in-flight validation when field changes', async () => { + let abortedCount = 0; + let completedCount = 0; + + const { data } = createSvState( + { username: '' }, + { + asyncValidator: { + username: async (_value, _source, signal) => { + await new Promise((resolve, reject) => { + const timeout = setTimeout(resolve, 100); + signal.addEventListener('abort', () => { + clearTimeout(timeout); + abortedCount++; + reject(new DOMException('Aborted', 'AbortError')); + }); + }); + completedCount++; + return ''; + } + } + }, + { debounceAsyncValidation: 10 } + ); + + data.username = 'first'; + await new Promise((resolve) => setTimeout(resolve, 30)); + + // Change while first validation is in progress + data.username = 'second'; + await new Promise((resolve) => setTimeout(resolve, 200)); + + // First should be aborted, second should complete + expect(abortedCount).toBe(1); + expect(completedCount).toBe(1); + }); + + it('should provide AbortSignal to async validator', async () => { + let receivedSignal: AbortSignal | undefined; + + const { data } = createSvState( + { username: '' }, + { + asyncValidator: { + username: async (_value, _source, signal) => { + receivedSignal = signal; + return ''; + } + } + }, + { debounceAsyncValidation: 10 } + ); + + data.username = 'test'; + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(receivedSignal).toBeInstanceOf(AbortSignal); + }); +}); + +describe('async validation - asyncValidating store', () => { + it('should update asyncValidating store during validation', async () => { + const { data, state } = createSvState( + { username: '' }, + { + asyncValidator: { + username: async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + return ''; + } + } + }, + { debounceAsyncValidation: 10 } + ); + + data.username = 'test'; + await new Promise((resolve) => setTimeout(resolve, 20)); + + // Should be validating + expect(get(state.asyncValidating)).toContain('username'); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Should no longer be validating + expect(get(state.asyncValidating)).not.toContain('username'); + }); +}); + +describe('async validation - hasCombinedErrors', () => { + it('should be true when only sync errors exist', async () => { + const { state } = createSvState( + { username: '' }, + { + validator: (source) => ({ + username: stringValidator(source.username).required().getError() + }), + asyncValidator: { + username: async () => '' + } + } + ); + + expect(get(state.hasErrors)).toBe(true); + expect(get(state.hasAsyncErrors)).toBe(false); + expect(get(state.hasCombinedErrors)).toBe(true); + }); + + it('should be true when only async errors exist', async () => { + const { data, state } = createSvState( + { username: '' }, + { + validator: (source) => ({ + username: stringValidator(source.username).required().getError() + }), + asyncValidator: { + username: async (value) => (value === 'taken' ? 'Already taken' : '') + } + }, + { debounceAsyncValidation: 10 } + ); + + data.username = 'taken'; + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(get(state.hasErrors)).toBe(false); + expect(get(state.hasAsyncErrors)).toBe(true); + expect(get(state.hasCombinedErrors)).toBe(true); + }); + + it('should be false when no errors exist', async () => { + const { data, state } = createSvState( + { username: '' }, + { + validator: (source) => ({ + username: stringValidator(source.username).required().getError() + }), + asyncValidator: { + username: async () => '' + } + }, + { debounceAsyncValidation: 10 } + ); + + data.username = 'validuser'; + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(get(state.hasErrors)).toBe(false); + expect(get(state.hasAsyncErrors)).toBe(false); + expect(get(state.hasCombinedErrors)).toBe(false); + }); +}); + +describe('async validation - rollback and reset', () => { + it('should clear async errors on rollback', async () => { + const { data, rollback, state } = createSvState( + { username: 'initial' }, + { + effect: ({ snapshot }) => { + snapshot('Changed'); + }, + asyncValidator: { + username: async (value) => (value === 'taken' ? 'Already taken' : '') + } + }, + { debounceAsyncValidation: 10 } + ); + + data.username = 'taken'; + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(get(state.asyncErrors)).toEqual({ username: 'Already taken' }); + + rollback(); + + expect(get(state.asyncErrors)).toEqual({}); + expect(data.username).toBe('initial'); + }); + + it('should clear async errors on reset', async () => { + const { data, reset, state } = createSvState( + { username: 'initial' }, + { + effect: ({ snapshot }) => { + snapshot('Changed'); + }, + asyncValidator: { + username: async (value) => (value === 'taken' ? 'Already taken' : '') + } + }, + { debounceAsyncValidation: 10 } + ); + + data.username = 'taken'; + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(get(state.asyncErrors)).toEqual({ username: 'Already taken' }); + + reset(); + + expect(get(state.asyncErrors)).toEqual({}); + expect(data.username).toBe('initial'); + }); + + it('should cancel in-flight async validation on rollback', async () => { + let completedCount = 0; + + const { data, rollback, state } = createSvState( + { username: 'initial' }, + { + effect: ({ snapshot }) => { + snapshot('Changed'); + }, + asyncValidator: { + username: async (_value, _source, signal) => { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + completedCount++; + resolve(); + }, 100); + signal.addEventListener('abort', () => { + clearTimeout(timeout); + reject(new DOMException('Aborted', 'AbortError')); + }); + }); + return ''; + } + } + }, + { debounceAsyncValidation: 10 } + ); + + data.username = 'newvalue'; + await new Promise((resolve) => setTimeout(resolve, 30)); + + // Rollback while validation is in progress + rollback(); + await new Promise((resolve) => setTimeout(resolve, 150)); + + expect(completedCount).toBe(0); + expect(get(state.asyncValidating)).toEqual([]); + }); +}); + +describe('async validation - runAsyncValidationOnInit', () => { + it('should run async validation on init when configured', async () => { + let asyncValidatorCalled = false; + + createSvState( + { username: 'testuser' }, + { + asyncValidator: { + username: async () => { + asyncValidatorCalled = true; + return ''; + } + } + }, + { runAsyncValidationOnInit: true, debounceAsyncValidation: 10 } + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(asyncValidatorCalled).toBe(true); + }); + + it('should not run async validation on init by default', async () => { + let asyncValidatorCalled = false; + + createSvState( + { username: 'testuser' }, + { + asyncValidator: { + username: async () => { + asyncValidatorCalled = true; + return ''; + } + } + }, + { debounceAsyncValidation: 10 } + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(asyncValidatorCalled).toBe(false); + }); +}); + +describe('async validation - clearAsyncErrorsOnChange', () => { + it('should clear async error when field changes by default', async () => { + const { data, state } = createSvState( + { username: '' }, + { + asyncValidator: { + username: async (value) => (value === 'taken' ? 'Already taken' : '') + } + }, + { debounceAsyncValidation: 10 } + ); + + data.username = 'taken'; + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(get(state.asyncErrors)).toEqual({ username: 'Already taken' }); + + // Change the field + data.username = 'different'; + + // Error should be cleared immediately + expect(get(state.asyncErrors)).toEqual({}); + }); + + it('should keep async error when clearAsyncErrorsOnChange is false', async () => { + const { data, state } = createSvState( + { username: '' }, + { + asyncValidator: { + username: async (value) => (value === 'taken' ? 'Already taken' : '') + } + }, + { debounceAsyncValidation: 10, clearAsyncErrorsOnChange: false } + ); + + data.username = 'taken'; + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(get(state.asyncErrors)).toEqual({ username: 'Already taken' }); + + // Change the field + data.username = 'different'; + + // Error should still be there + expect(get(state.asyncErrors)).toEqual({ username: 'Already taken' }); + + // Wait for new validation to complete + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Now it should be cleared + expect(get(state.asyncErrors)).toEqual({ username: '' }); + }); +}); + +describe('async validation - multiple fields', () => { + it('should handle multiple async validators independently', async () => { + const callOrder: string[] = []; + + const { data, state } = createSvState( + { username: '', email: '' }, + { + asyncValidator: { + username: async () => { + await new Promise((resolve) => setTimeout(resolve, 30)); + callOrder.push('username'); + return ''; + }, + email: async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + callOrder.push('email'); + return ''; + } + } + }, + { debounceAsyncValidation: 10 } + ); + + data.username = 'user'; + data.email = 'user@example.com'; + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(callOrder).toEqual(['email', 'username']); + expect(get(state.asyncValidating)).toEqual([]); + }); + + it('should track multiple fields validating', async () => { + const { data, state } = createSvState( + { username: '', email: '' }, + { + asyncValidator: { + username: async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + return ''; + }, + email: async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + return ''; + } + } + }, + { debounceAsyncValidation: 10 } + ); + + data.username = 'user'; + data.email = 'user@example.com'; + await new Promise((resolve) => setTimeout(resolve, 30)); + + const validating = get(state.asyncValidating); + expect(validating).toContain('username'); + expect(validating).toContain('email'); + }); +}); + +describe('async validation - nested paths', () => { + it('should trigger async validator when nested property changes', async () => { + let asyncValidatorCalled = false; + let receivedValue: unknown; + + const { data, state } = createSvState( + { user: { name: '' } }, + { + asyncValidator: { + 'user.name': async (value) => { + asyncValidatorCalled = true; + receivedValue = value; + return value === 'taken' ? 'Name taken' : ''; + } + } + }, + { debounceAsyncValidation: 10 } + ); + + data.user.name = 'testname'; + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(asyncValidatorCalled).toBe(true); + expect(receivedValue).toBe('testname'); + expect(get(state.asyncErrors)).toEqual({ 'user.name': '' }); + }); + + it('should trigger async validator when parent property changes', async () => { + let asyncValidatorCalled = false; + + const { data, state } = createSvState( + { user: { name: '' } }, + { + asyncValidator: { + user: async (value) => { + asyncValidatorCalled = true; + const user = value as { name: string }; + return user.name === 'taken' ? 'User invalid' : ''; + } + } + }, + { debounceAsyncValidation: 10 } + ); + + data.user.name = 'taken'; + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(asyncValidatorCalled).toBe(true); + expect(get(state.asyncErrors)).toEqual({ user: 'User invalid' }); + }); +}); + +describe('async validation - receives full source', () => { + it('should receive full state object in async validator', async () => { + let receivedSource: unknown; + + const { data } = createSvState( + { username: '', email: '' }, + { + asyncValidator: { + username: async (_value, source) => { + receivedSource = source; + return ''; + } + } + }, + { debounceAsyncValidation: 10 } + ); + + data.email = 'test@example.com'; + data.username = 'testuser'; + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(receivedSource).toBeDefined(); + expect((receivedSource as { username: string; email: string }).username).toBe('testuser'); + expect((receivedSource as { username: string; email: string }).email).toBe('test@example.com'); + }); +}); From 795690f9d236b8f8ca21723b9d0377a324a39f96 Mon Sep 17 00:00:00 2001 From: BCsabaEngine Date: Fri, 30 Jan 2026 19:37:31 +0100 Subject: [PATCH 3/3] feat: demo page --- demo/src/App.svelte | 5 + demo/src/pages/AsyncValidation.svelte | 261 ++++++++++++++++++++++++++ 2 files changed, 266 insertions(+) create mode 100644 demo/src/pages/AsyncValidation.svelte diff --git a/demo/src/App.svelte b/demo/src/App.svelte index 12be800..045d4a7 100644 --- a/demo/src/App.svelte +++ b/demo/src/App.svelte @@ -3,6 +3,7 @@ import ActionDemo from './pages/ActionDemo.svelte'; import ArrayProperty from './pages/ArrayProperty.svelte'; + import AsyncValidation from './pages/AsyncValidation.svelte'; import BasicValidation from './pages/BasicValidation.svelte'; import CalculatedClass from './pages/CalculatedClass.svelte'; import CalculatedFields from './pages/CalculatedFields.svelte'; @@ -20,6 +21,7 @@ | 'reset-demo' | 'snapshot-demo' | 'action-demo' + | 'async-validation' | 'options-demo'; const demoModes: { value: DemoMode; name: string }[] = [ @@ -31,6 +33,7 @@ { value: 'reset-demo', name: 'Reset' }, { value: 'snapshot-demo', name: 'Snapshot & Rollback' }, { value: 'action-demo', name: 'Action & Error' }, + { value: 'async-validation', name: 'Async Validation' }, { value: 'options-demo', name: 'Options' } ]; @@ -137,6 +140,8 @@ {:else if selectedMode === 'action-demo'} + {:else if selectedMode === 'async-validation'} + {:else if selectedMode === 'options-demo'} {/if} diff --git a/demo/src/pages/AsyncValidation.svelte b/demo/src/pages/AsyncValidation.svelte new file mode 100644 index 0000000..b76146b --- /dev/null +++ b/demo/src/pages/AsyncValidation.svelte @@ -0,0 +1,261 @@ + + + + {#snippet main()} + + +

+ + Async Errors: {$hasAsyncErrors ? 'Yes' : 'No'} + + + Combined: {$hasCombinedErrors ? 'Has Errors' : 'Valid'} + +
+ +
+
+ + {#if $asyncValidating.includes('username')} +
+ + + + +
+ {/if} +
+ +
+ + {#if $asyncValidating.includes('email')} +
+ + + + +
+ {/if} +
+
+ +
+
+ Taken usernames: {takenUsernames.join(', ')} +
+
+ Taken emails: {takenEmails.join(', ')} +
+
+ +
+ +
+ {/snippet} + + {#snippet sidebar()} +
+ + +
+
Quick Fill
+ +
+ +
+
Async Validation State
+
+
asyncValidating: [{$asyncValidating.join(', ')}]
+
hasAsyncErrors: {$hasAsyncErrors}
+
hasCombinedErrors: {$hasCombinedErrors}
+
+
+ +
+
Async Errors
+
{JSON.stringify($asyncErrors, null, 2)}
+
+
+ {/snippet} + + {#snippet sourceCode()} + + + + + + {/snippet} +