Last active
October 21, 2025 07:56
-
-
Save jbarnat/fc29f0eb4d35055534cf348afd61dc71 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <script lang="ts"> | |
| import { SvelteSet } from 'svelte/reactivity'; | |
| import type { FlattenedIssues } from '$lib/components/forms/types.js'; | |
| import Form from '$lib/components/forms/Form.svelte'; | |
| import ValidationIssues from '$lib/components/forms/ValidationIssues.svelte'; | |
| import { commentForm } from './commentForm.remote.js'; | |
| import { commentSchema } from './schema.js'; | |
| let liveIssues: FlattenedIssues<typeof commentSchema> = $state({}); | |
| const { nickname, age, content } = $derived(commentForm.fields); | |
| /** | |
| Initial state | |
| all fields: untouched | |
| User interacts | |
| user types in field → field becomes "touched" | |
| On submit | |
| 1. All fields → "untouched" | |
| 2. Server validates | |
| 3. Server errors visible on ALL fields (because all are untouched) | |
| User starts fixing | |
| user touches field A → field A becomes "touched" | |
| → field A's server error HIDES (even if not fixed yet) | |
| → other untouched fields still show their errors | |
| On next submit | |
| 1. All fields → "untouched" again | |
| 2. Fresh server validation | |
| 3. New set of errors visible | |
| */ | |
| let touchedFields = $state<Set<string>>(new SvelteSet()); | |
| $effect(() => { | |
| // binds to submit / preflight event | |
| commentForm.fields.allIssues()?.length; | |
| touchedFields.clear(); | |
| }); | |
| </script> | |
| <Form | |
| schema={commentSchema} | |
| remoteForm={commentForm} | |
| bind:liveIssues | |
| bind:touchedFields | |
| class="flex flex-col gap-5" | |
| > | |
| <div class="flex"> | |
| <input {...nickname.as('text')} /> | |
| <ValidationIssues | |
| isTouched={touchedFields.has('nickname')} | |
| serverIssues={nickname.issues()} | |
| clientIssues={liveIssues.nickname} | |
| /> | |
| </div> | |
| <div class="flex"> | |
| <input {...age.as('number')} /> | |
| <ValidationIssues | |
| isTouched={touchedFields.has('age')} | |
| serverIssues={age.issues()} | |
| clientIssues={liveIssues.age} | |
| /> | |
| </div> | |
| <div class="flex"> | |
| <textarea {...content.as('text')} rows="4"></textarea> | |
| <ValidationIssues | |
| isTouched={touchedFields.has('content')} | |
| serverIssues={content.issues()} | |
| clientIssues={liveIssues.content} | |
| /> | |
| </div> | |
| <button type="submit">Save</button> | |
| </Form> | |
| {#if commentForm.result} | |
| {@const { id, nickname, age, content } = commentForm.result.newComment} | |
| <div>{id}</div> | |
| <div>{nickname} ({age})</div> | |
| <div>{content}</div> | |
| {/if} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| //@ts-nocheck | |
| export function convert_formdata(data: FormData) { | |
| let result = Object.create(null); | |
| for (let key of data.keys()) { | |
| if (key.startsWith('sveltekit:')) { | |
| continue; | |
| } | |
| const is_array = key.endsWith('[]'); | |
| let values = data.getAll(key); | |
| if (is_array) key = key.slice(0, -2); | |
| if (values.length > 1 && !is_array) { | |
| throw new Error(`Form cannot contain duplicated keys — "${key}" has ${values.length} values`); | |
| } | |
| values = values.filter( | |
| (entry) => typeof entry === 'string' || entry.name !== '' || entry.size > 0 | |
| ); | |
| if (key.startsWith('n:')) { | |
| key = key.slice(2); | |
| values = values.map((v) => (v === '' ? undefined : parseFloat(v))); | |
| } else if (key.startsWith('b:')) { | |
| key = key.slice(2); | |
| values = values.map((v) => v === 'on'); | |
| } | |
| result = set_nested_value(result, key, is_array ? values : values[0]); | |
| } | |
| return result; | |
| } | |
| export function set_nested_value(object, path_string, value) { | |
| if (path_string.startsWith('n:')) { | |
| path_string = path_string.slice(2); | |
| value = value === '' ? undefined : parseFloat(value); | |
| } else if (path_string.startsWith('b:')) { | |
| path_string = path_string.slice(2); | |
| value = value === 'on'; | |
| } | |
| return deep_set(object, split_path(path_string), value); | |
| } | |
| const path_regex = /^[a-zA-Z_$]\w*(\.[a-zA-Z_$]\w*|\[\d+\])*$/; | |
| export function split_path(path) { | |
| if (!path_regex.test(path)) { | |
| throw new Error(`Invalid path ${path}`); | |
| } | |
| return path.split(/\.|\[|\]/).filter(Boolean); | |
| } | |
| export function deep_set<T extends Record<string, any>>( | |
| object: T, | |
| keys: string[], | |
| value: unknown | |
| ): T { | |
| const result = Object.assign(Object.create(null), object); | |
| let current = result; | |
| for (let i = 0; i < keys.length - 1; i += 1) { | |
| const key = keys[i]; | |
| const is_array = /^\d+$/.test(keys[i + 1]); | |
| const exists = key in current; | |
| const inner = current[key]; | |
| if (exists && is_array !== Array.isArray(inner)) { | |
| throw new Error(`Invalid array key ${keys[i + 1]}`); | |
| } | |
| current[key] = is_array | |
| ? exists | |
| ? [...inner] | |
| : [] | |
| : Object.assign(Object.create(null), inner); | |
| current = current[key]; | |
| } | |
| current[keys[keys.length - 1]] = value; | |
| return result; | |
| } | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import type { InferIssue, BaseSchema } from 'valibot'; | |
| import type { FlattenedIssues, NormalizedIssue } from '../types'; | |
| export function flatten_issues<TSchema extends BaseSchema<any, any, any>>( | |
| issues: InferIssue<TSchema>[], | |
| server = false, | |
| skipUntouched = true, | |
| touchedFields?: Set<string> | |
| ): FlattenedIssues<TSchema> { | |
| const result: Record<string, NormalizedIssue[]> = {}; | |
| for (const issue of issues) { | |
| const normalized: NormalizedIssue = { | |
| name: '', | |
| path: [], | |
| message: issue.message, | |
| server | |
| }; | |
| (result.$ ??= []).push(normalized); | |
| let name = ''; | |
| if (issue.path !== undefined) { | |
| for (const segment of issue.path) { | |
| const key = typeof segment === 'object' ? segment.key : segment; | |
| normalized.path.push(key); | |
| if (typeof key === 'number') { | |
| name += `[${key}]`; | |
| } else if (typeof key === 'string') { | |
| name += name === '' ? key : '.' + key; | |
| } | |
| if (skipUntouched && touchedFields && !touchedFields.has(name)) { | |
| continue; | |
| } | |
| (result[name] ??= []).push(normalized); | |
| } | |
| normalized.name = name; | |
| } | |
| } | |
| return result as FlattenedIssues<TSchema>; | |
| } | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <script lang="ts" generics="TSchema extends ObjectSchema<any, any>"> | |
| import type { RemoteForm } from '@sveltejs/kit'; | |
| import type { Snippet } from 'svelte'; | |
| import type { InferInput, ObjectSchema, InferIssue } from 'valibot'; | |
| import { debounce } from '$lib/utils'; | |
| import { convert_formdata } from './svelte-form-utils/convert_formdata'; | |
| import { flatten_issues } from './svelte-form-utils/flatten_issues'; | |
| import type { FlattenedIssues } from './types'; | |
| type SchemaIssue = InferIssue<TSchema>; | |
| type FormIssues = FlattenedIssues<TSchema>; | |
| interface IProps { | |
| remoteForm: RemoteForm<InferInput<TSchema>, unknown>; | |
| schema: TSchema; | |
| children: Snippet; | |
| liveIssues: FormIssues; | |
| touchedFields: Set<string>; | |
| class?: string; | |
| skipUntouched?: boolean; | |
| scrollIntoView?: boolean; | |
| resetOnSubmit?: boolean; | |
| onSubmitSuccess?: () => void; | |
| } | |
| let { | |
| remoteForm, | |
| schema, | |
| children, | |
| liveIssues = $bindable(), | |
| touchedFields = $bindable(), | |
| class: className, | |
| skipUntouched = true, | |
| scrollIntoView = true, | |
| resetOnSubmit = true, | |
| onSubmitSuccess | |
| }: IProps = $props(); | |
| let formElement: HTMLFormElement; | |
| async function handleInput(event: Event) { | |
| const target = event.target as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; | |
| if (target.name) { | |
| touchedFields.add(target.name.replace(/^[nb]:/, '')); | |
| } | |
| if (!formElement) return; | |
| const data = convert_formdata(new FormData(formElement)); | |
| const validationResult = (await schema['~standard'].validate(data)) as SchemaIssue; | |
| if (!validationResult.issues) { | |
| liveIssues = {}; | |
| return; | |
| } | |
| liveIssues = flatten_issues<TSchema>( | |
| validationResult.issues, | |
| false, | |
| skipUntouched, | |
| touchedFields | |
| ); | |
| } | |
| function handleBlur(event: FocusEvent) { | |
| const target = event.target as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; | |
| if (target.name) { | |
| touchedFields.add(target.name); | |
| // Trigger validation after blur | |
| handleInput(event); | |
| } | |
| } | |
| export function resetTouched() { | |
| touchedFields = new Set(); | |
| liveIssues = {}; | |
| } | |
| $effect(() => { | |
| if (scrollIntoView) { | |
| formElement?.scrollIntoView({ | |
| behavior: 'instant', | |
| block: 'start', | |
| inline: 'nearest' | |
| }); | |
| } | |
| }); | |
| </script> | |
| <form | |
| class={className} | |
| bind:this={formElement} | |
| {...remoteForm.preflight(schema).enhance(async ({ submit, form }) => { | |
| try { | |
| await submit(); | |
| resetTouched(); | |
| if (resetOnSubmit) { | |
| form.reset(); | |
| } | |
| if (onSubmitSuccess) { | |
| onSubmitSuccess(); | |
| } | |
| } catch {} | |
| })} | |
| oninput={debounce(handleInput, 400)} | |
| onblur={handleBlur} | |
| > | |
| {@render children()} | |
| </form> | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import * as v from 'valibot'; | |
| export const commentSchema = v.object({ | |
| content: v.pipe(v.string(), v.minLength(10, 'Too short'), v.maxLength(500, 'Too long')), | |
| nickname: v.pipe(v.string(), v.minLength(5, 'Too short'), v.maxLength(500, 'Too long')), | |
| age: v.pipe(v.number(), v.minValue(3, 'Too young')) | |
| }); | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import type { BaseSchema, InferInput } from 'valibot'; | |
| export interface NormalizedIssue { | |
| name: string; | |
| path: (string | number)[]; | |
| message: string; | |
| server: boolean; | |
| } | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| export type FlattenedIssues<TSchema extends BaseSchema<any, any, any>> = { | |
| [Key in keyof InferInput<TSchema> | '$' | string]?: NormalizedIssue[]; | |
| }; | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <script lang="ts"> | |
| import type { RemoteFormIssue } from '@sveltejs/kit'; | |
| import type { NormalizedIssue } from './types'; | |
| interface IProps { | |
| isTouched: boolean; | |
| serverIssues?: RemoteFormIssue[]; | |
| clientIssues?: NormalizedIssue[]; | |
| } | |
| const { isTouched, serverIssues = [], clientIssues = [] }: IProps = $props(); | |
| let uniqueClientIssues = $derived( | |
| isTouched | |
| ? clientIssues | |
| : clientIssues.filter( | |
| (clientIssue) => | |
| !serverIssues.find((serverIssue) => serverIssue.message === clientIssue.message) | |
| ) // 🚨 Using message for deduplication is brittle. | |
| ); | |
| </script> | |
| <section class="min-h-10"> | |
| {#if !isTouched} | |
| {#each serverIssues ?? [] as issue} | |
| <div class="text-red-400">{issue.message}</div> | |
| {/each} | |
| {/if} | |
| {#each uniqueClientIssues ?? [] as { message } (message)} | |
| <div class="text-indigo-400">{message}</div> | |
| {/each} | |
| </section> | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment