Skip to content

Instantly share code, notes, and snippets.

@jbarnat
Last active October 21, 2025 07:56
Show Gist options
  • Select an option

  • Save jbarnat/fc29f0eb4d35055534cf348afd61dc71 to your computer and use it in GitHub Desktop.

Select an option

Save jbarnat/fc29f0eb4d35055534cf348afd61dc71 to your computer and use it in GitHub Desktop.
<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}
//@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;
}
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>;
}
<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>
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'))
});
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[];
};
<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