sveltelean Query
Version v0.2.0

Field errors

src/routes/members/[id]/edit/+page.svelte
<!-- src/routes/members/[id]/edit/+page.svelte -->
<script lang="ts">
	import { createMutation } from '@svelte-lean/query';
	import { fieldErrors, issues } from '@svelte-lean/query/forms';
	import { updateMember } from '$api/members';
	import type { Member } from '$api/types';
	import { messageOf } from './messages';

	let { member }: { member: Member } = $props();

	const save = createMutation(updateMember);

	function submit(event: SubmitEvent & { currentTarget: HTMLFormElement }) {
		event.preventDefault();
		const form = new FormData(event.currentTarget);
		save.run({ id: member.id, name: String(form.get('name')), email: String(form.get('email')) });
	}
</script>

<form {@attach fieldErrors(() => save.error)} onsubmit={submit} novalidate>
	<div data-slean="field">
		<label for="member-name">Name</label>
		<input id="member-name" name="name" value={member.name} required />
		<p id="member-name-error" data-slean-part="error">{issues(save.error, 'name').join(' ')}</p>
	</div>
	<div data-slean="field">
		<label for="member-email">Email</label>
		<input id="member-email" name="email" type="email" value={member.email} required />
		<p id="member-email-error" data-slean-part="error">{issues(save.error, 'email').join(' ')}</p>
	</div>

	{#if save.error && save.error.kind !== 'validation'}
		<p role="alert">{messageOf(save.error)}</p>
	{/if}
	<button disabled={save.busy}>Save</button>
</form>

The form keeps its own values. A 422 answer (or a refusal with a field map) becomes save.error with kind: 'validation'; the attachment reads it and writes:

WrittenWhere
aria-invalid="true"on each control a server message names
aria-describedbythe id of the field’s error part, added to the ids already there
setCustomValidity()the first message, so :invalid styles and the validation bubble apply
focusthe first invalid control, unless the user is typing in another one

The error part is the data-slean-part="error" element inside the same data-slean="field". Editing a marked control removes exactly what was written and hides its outdated message; a new error, reset() or a form reset clears every mark.

Messages

issues(error, path) returns the messages of one field: the exact key, then its wildcard form, so issues(error, 'lines.0.sku') also finds lines.*.sku. Without a path it returns every issue in server order.

The error is typed by the request body: save.error?.fields?.fields.email exists only when the body has email, and fields.at('lines', 0, 'sku') walks nested keys.

Translation

src/lib/messages.ts
// Translating errors: errorKey() gives a stable reason; the words are the application's.
import type { QueryError } from '@svelte-lean/query';
import { errorKey, type ErrorKey } from '@svelte-lean/query/forms';

const MESSAGES: Record<ErrorKey, string> = {
	network: 'You are offline. Try again when the connection is back.',
	timeout: 'The server took too long to answer.',
	server: 'Something failed on the server.',
	'rate-limited': 'Too many requests. Wait a moment.',
	'not-found': 'This item no longer exists.',
	conflict: 'Someone else changed this item. Reload it.',
	http: 'The request failed.',
	forbidden: 'You do not have access to this.',
	validation: 'Check the marked fields.',
	refused: 'The server refused the change.',
	parse: 'The answer could not be read.'
};

export const messageOf = (error: QueryError) => error.message || MESSAGES[errorKey(error)];

The package shows no text of its own. errorKey() turns an error into a stable reason (http errors become server, rate-limited, not-found or conflict by status); the words come from the application.

Options

fieldErrors options
fieldErrors(error, {
	name: (path) => path.replace(/^address\./, 'billing_'), // server path → control name
	text: (message) => t(message), // translate field messages
	unmatched: (rest) => showBanner(rest.map((issue) => issue.message)), // no control for these
	focus: true, // focus the first invalid control (default)
	validity: true // setCustomValidity(first message) (default)
});
OptionTypeDefaultEffect
name(path) => stringdot path, bracket form, then name[]Maps a server path to a control name.
text(message, path) => stringidentityTranslates field messages.
unmatched(issues) => void–Receives the issues with no control in this form.
focusbooleantrueFocus the first invalid control.
validitybooleantrueSet custom validity on each marked control.

Guarantees

  • Validation errors and refusals are never retried.
  • The attachment writes attributes and custom validity only; no element is created.
  • A message that looks like an untranslated key (validation.required) is reported in development by devtools.

Reference