Skip to content

Commit f5af652

Browse files
committed
Added customValidity option to superForm.
1 parent 9ed822c commit f5af652

File tree

6 files changed

+208
-8
lines changed

6 files changed

+208
-8
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414

1515
### Added
1616

17+
- Added `customValidity` option to `superForm`, which will use the browser's validation message reporting for validation errors.
1718
- Added `emptyIfZero` option to `numberProxy` and `intProxy`.
1819

1920
## [1.4.0] - 2023-07-20

src/lib/client/formEnhance.ts

Lines changed: 105 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import { browser } from '$app/environment';
77
import {
88
SuperFormError,
99
type TaintedFields,
10-
type SuperValidated
10+
type SuperValidated,
11+
type ZodValidation
1112
} from '../index.js';
1213
import type { z, AnyZodObject } from 'zod';
1314
import { stringify } from 'devalue';
@@ -16,6 +17,8 @@ import type { FormOptions, SuperForm } from './index.js';
1617
import { clientValidation, validateField } from './clientValidation.js';
1718
import { Form } from './form.js';
1819
import { onDestroy } from 'svelte';
20+
import { traversePath } from '$lib/traversal.js';
21+
import { mergePath, splitPath } from '$lib/stringPath.js';
1922

2023
export type FormUpdate = (
2124
result: Exclude<ActionResult, { type: 'error' }>,
@@ -61,6 +64,34 @@ export function shouldSyncFlash<T extends AnyZodObject, M>(
6164
return options.syncFlashMessage;
6265
}
6366

67+
///// Custom validity /////
68+
69+
const noCustomValidityDataAttribute = 'noCustomValidity';
70+
71+
function setCustomValidity(
72+
el: HTMLInputElement,
73+
errors: string[] | undefined
74+
) {
75+
const message = errors && errors.length ? errors.join('\n') : '';
76+
el.setCustomValidity(message);
77+
if (message) el.reportValidity();
78+
}
79+
80+
function setCustomValidityForm<T extends AnyZodObject, M>(
81+
formEl: HTMLFormElement,
82+
errors: SuperValidated<ZodValidation<T>, M>['errors']
83+
) {
84+
for (const el of formEl.querySelectorAll('input')) {
85+
if (noCustomValidityDataAttribute in el.dataset) continue;
86+
87+
const error = traversePath(errors, splitPath(el.name));
88+
setCustomValidity(el, error?.value);
89+
if (error?.value) return;
90+
}
91+
}
92+
93+
//////////////////////////////////
94+
6495
/**
6596
* Custom use:enhance version. Flash message support, friendly error messages, for usage with initializeForm.
6697
* @param formEl Form element from the use:formEnhance default parameter.
@@ -92,15 +123,47 @@ export function formEnhance<T extends AnyZodObject, M>(
92123
// Using this type in the function argument causes a type recursion error.
93124
const errors = errs as SuperForm<T, M>['errors'];
94125

95-
async function validateChange(change: string[]) {
96-
await validateField(change, options, data, errors, tainted);
126+
async function validateChange(
127+
change: string[],
128+
event: 'blur' | 'input',
129+
validityEl: HTMLElement | null
130+
) {
131+
if (options.customValidity && validityEl) {
132+
// Always reset validity, in case it has been validated on the server.
133+
if ('setCustomValidity' in validityEl) {
134+
(validityEl as HTMLInputElement).setCustomValidity('');
135+
}
136+
137+
// If event is input but element shouldn't use custom validity,
138+
// return immediately since validateField don't have to be called
139+
// in this case, validation is happening elsewhere.
140+
if (noCustomValidityDataAttribute in validityEl.dataset)
141+
if (event == 'input') return;
142+
else validityEl = null;
143+
}
144+
145+
const newErrors = await validateField(
146+
change,
147+
options,
148+
data,
149+
errors,
150+
tainted
151+
);
152+
153+
if (validityEl) {
154+
setCustomValidity(validityEl as any, newErrors);
155+
}
97156
}
98157

158+
/**
159+
* Some input fields have timing issues with the stores, need to wait in that case.
160+
*/
99161
function timingIssue(el: EventTarget | null) {
100162
return (
101163
el &&
102164
(el instanceof HTMLSelectElement ||
103-
(el instanceof HTMLInputElement && el.type == 'radio'))
165+
(el instanceof HTMLInputElement &&
166+
(el.type == 'radio' || el.type == 'checkbox')))
104167
);
105168
}
106169

@@ -113,26 +176,56 @@ export function formEnhance<T extends AnyZodObject, M>(
113176
return;
114177
}
115178

116-
// Some form fields have some timing issue, need to wait
117179
if (timingIssue(e.target)) {
118180
await new Promise((r) => setTimeout(r, 0));
119181
}
120182

121183
for (const change of get(lastChanges)) {
122-
//console.log('🚀 ~ file: index.ts:905 ~ BLUR:', change);
123-
validateChange(change);
184+
let validityEl: HTMLElement | null = null;
185+
186+
if (options.customValidity) {
187+
const name = CSS.escape(mergePath(change));
188+
validityEl = formEl.querySelector<HTMLElement>(`[name="${name}"]`);
189+
}
190+
191+
validateChange(change, 'blur', validityEl);
124192
}
125193
// Clear last changes after blur (not after input)
126194
lastChanges.set([]);
127195
}
128196
formEl.addEventListener('focusout', checkBlur);
129197

130-
const htmlForm = Form(formEl, { submitting, delayed, timeout }, options);
198+
// Add input event, for custom validity
199+
async function checkCustomValidity(e: Event) {
200+
if (timingIssue(e.target)) {
201+
await new Promise((r) => setTimeout(r, 0));
202+
}
203+
204+
for (const change of get(lastChanges)) {
205+
const name = CSS.escape(mergePath(change));
206+
const validityEl = formEl.querySelector<HTMLElement>(
207+
`[name="${name}"]`
208+
);
209+
if (!validityEl) continue;
210+
211+
const hadErrors = traversePath(get(errors), change as any);
212+
if (hadErrors && hadErrors.key in hadErrors.parent) {
213+
// Problem - store hasn't updated here with new value yet.
214+
setTimeout(() => validateChange(change, 'input', validityEl), 0);
215+
}
216+
}
217+
}
218+
if (options.customValidity) {
219+
formEl.addEventListener('input', checkCustomValidity);
220+
}
131221

132222
onDestroy(() => {
133223
formEl.removeEventListener('focusout', checkBlur);
224+
formEl.removeEventListener('input', checkCustomValidity);
134225
});
135226

227+
const htmlForm = Form(formEl, { submitting, delayed, timeout }, options);
228+
136229
let currentRequest: AbortController | null;
137230

138231
return enhance(formEl, async (submit) => {
@@ -319,6 +412,10 @@ export function formEnhance<T extends AnyZodObject, M>(
319412
for (const event of formEvents.onUpdate) {
320413
await event(data);
321414
}
415+
416+
if (!cancelled && options.customValidity) {
417+
setCustomValidityForm(formEl, data.form.errors);
418+
}
322419
}
323420
}
324421

src/lib/client/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ export type FormOptions<T extends ZodValidation<AnyZodObject>, M> = Partial<{
119119
| ZodValidation<UnwrapEffects<T>>;
120120
validationMethod: 'auto' | 'oninput' | 'onblur' | 'submit-only';
121121
defaultValidator: 'keep' | 'clear';
122+
customValidity: boolean;
122123
clearOnSubmit: 'errors' | 'message' | 'errors-and-message' | 'none';
123124
delayMs: number;
124125
timeoutMs: number;
@@ -173,6 +174,7 @@ const defaultFormOptions = {
173174
dataType: 'form',
174175
validators: undefined,
175176
defaultValidator: 'keep',
177+
customValidity: false,
176178
clearOnSubmit: 'errors-and-message',
177179
delayMs: 500,
178180
timeoutMs: 8000,
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { message, superValidate } from '$lib/server';
2+
import { schema } from './schema';
3+
import { fail } from '@sveltejs/kit';
4+
5+
export const load = async () => {
6+
const form = await superValidate(schema);
7+
return { form };
8+
};
9+
10+
export const actions = {
11+
default: async ({ request }) => {
12+
const formData = await request.formData();
13+
console.log(formData);
14+
15+
const form = await superValidate(formData, schema);
16+
console.log('POST', form);
17+
18+
if (!form.valid) return fail(400, { form });
19+
20+
return message(form, 'Posted OK!');
21+
}
22+
};
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<script lang="ts">
2+
import { superForm } from '$lib/client';
3+
import type { PageData } from './$types';
4+
import SuperDebug from '$lib/client/SuperDebug.svelte';
5+
import { schema } from './schema';
6+
7+
export let data: PageData;
8+
9+
const { form, errors, tainted, message, enhance, constraints } = superForm(
10+
data.form,
11+
{
12+
customValidity: true,
13+
validators: schema
14+
}
15+
);
16+
</script>
17+
18+
{#if $message}<h4>{$message}</h4>{/if}
19+
20+
<form method="POST" use:enhance>
21+
<label>
22+
Name: <input name="name" bind:value={$form.name} />
23+
</label>
24+
25+
<label>
26+
Email: <input type="email" name="email" bind:value={$form.email} />
27+
</label>
28+
29+
<label>
30+
Number: <input type="number" name="number" bind:value={$form.number} />
31+
</label>
32+
33+
<label>
34+
Info: <input
35+
type="TEXt"
36+
name="info"
37+
data-no-custom-validity
38+
bind:value={$form.info}
39+
/>
40+
{#if $errors.info}<span class="invalid">{$errors.info}</span>{/if}
41+
</label>
42+
43+
<label>
44+
Accept terms: <input
45+
type="checkbox"
46+
name="accept"
47+
bind:checked={$form.accept}
48+
/>
49+
</label>
50+
<div>
51+
<button>Submit</button>
52+
</div>
53+
</form>
54+
55+
<SuperDebug data={{ $form, $errors }} />
56+
57+
<style lang="scss">
58+
form {
59+
margin: 2rem 0;
60+
61+
input {
62+
background-color: #dedede;
63+
}
64+
65+
.invalid {
66+
color: crimson;
67+
}
68+
}
69+
</style>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { z } from 'zod';
2+
3+
export const schema = z.object({
4+
name: z.string().min(1),
5+
email: z.string().email(),
6+
number: z.number().min(10),
7+
info: z.string().min(1),
8+
accept: z.literal(true).default(false as true)
9+
});

0 commit comments

Comments
 (0)