You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/soma/components/form
dev fb5dd0919c
Advance Eidos demos and form validation docs
5 months ago
..
components Move form core to forms lib 5 months ago
README.md Advance Eidos demos and form validation docs 5 months ago
exports.ts Tighten Soma public component surface 5 months ago
form-provider.svelte.test.ts Add form provider coverage 5 months ago
form-provider.svelte.ts Advance Eidos demos and form validation docs 5 months ago
index.ts soma: 10 new components + reactivity audit + guide rules A30–A33 6 months ago
langs.ts Move form drag translations to morfo 5 months ago
types.ts Advance Eidos demos and form validation docs 5 months ago

README.md

Form

The Svelte 5 form system the ecosystem was missing: runes-native state, UI primitives integrated, schema-first via Standard Schema, SSR-compatible, accessibility-first.

createForm() returns a reactive handle whose values / errors / isDirty / isTouched / isPending are $state proxies — no stores, no subscribe(), no imperative register(). Fields auto-register via Field.Provider's name prop once the Form.Provider context is present. Validation plugs in via any Standard Schema v1 implementor (Zod, Valibot, ArkType, Effect/Schema…).

Strategy & positioning

Every mainstream form library today picks exactly one of these and skips the rest:

Axis Who does it today
Form state management React Hook Form, TanStack Form, Formik
Svelte-first Felte, Superforms
UI primitives Ark UI, Chakra, Bits, Radix
Accessibility top-tier React Aria Form
SSR / progressive enhancement Superforms
Schema-first (any validator) TanStack Form, Superforms

No single library covers all axes. soma/Form is the only one built on Svelte 5 runes with UI primitives already in the same library — ColorField, DateField, NumberField, TimeField etc. plug in without adapter shims.

Quick start

<script lang="ts">
	import { Form, Field } from '$soma/components';
	import { createForm } from '$libs/forms';
	import { z } from 'zod'; // Or valibot, arktype, anything Standard Schema

	const schema = z.object({
		email: z.string().email(),
		age: z.number().min(18, 'Must be 18+')
	});

	const form = createForm({
		schema,
		defaults: { email: '', age: 0 },
		async onValidSubmit(values) {
			await api.save(values);
		}
	});
</script>

<Form.Provider {form}>
	<Form.ErrorSummary />

	<Field.Provider name="email" label="Email">
		<Field.Label>Email</Field.Label>
		<Field.Control>
			<input type="email" bind:value={form.values.email} />
		</Field.Control>
		<Field.ErrorText>{form.errors.email?.[0]}</Field.ErrorText>
	</Field.Provider>

	<Field.Provider name="age" label="Age">
		<Field.Label>Age</Field.Label>
		<Field.Control>
			<input type="number" bind:value={form.values.age} />
		</Field.Control>
		<Field.ErrorText>{form.errors.age?.[0]}</Field.ErrorText>
	</Field.Provider>

	<Form.Reset>Cancel</Form.Reset>
	<Form.Submit>Save</Form.Submit>
</Form.Provider>

That's the entire API surface for a typical form.

Architecture

Two layers:

┌─────────────────────────────────────────────────────────────┐
│  createForm()                                               │
│  ─────────────                                              │
│  Runes-native state:                                        │
│    • values (mutable $state proxy — bind inputs directly)   │
│    • errors, isPending, submitCount  ($state)                │
│    • isValid, isDirty, isTouched, firstInvalidField         │
│      ($derived from values, registry, schema result)        │
│    • field registry (Map<name, entry>)                      │
│    • Standard Schema v1 integration (async-aware)           │
│    • submit / reset / validate methods                      │
└────────────────────────────┬────────────────────────────────┘
                             │ form handle
                             ▼
┌─────────────────────────────────────────────────────────────┐
│  UI primitives                                              │
│  ──────────────                                             │
│    Form.Provider       — <form>, context, submit handler    │
│    Form.Submit         — reacts to isPending, aria-busy     │
│    Form.Reset          — disables when pristine, form.reset()│
│    Form.ErrorSummary   — role="alert", aria-live, focus-*   │
│                                                             │
│    Field.Provider      — extended with `name` prop:         │
│                          auto-registers, OR-merges flags,   │
│                          derives dirty/touched, collects    │
│                          blur events for touched tracking.  │
└─────────────────────────────────────────────────────────────┘

Parts

Part Description
Form.Provider Renders <form novalidate>. Owns the form handle, wires onsubmit, cascades state to Fields via context, focuses first-invalid on submit failure.
Form.Submit <button type="submit">. disabled + aria-busy while form.isPending. i18n-aware default label.
Form.Reset <button type="button"> that calls form.reset(). disabled while pristine (overridable).
Form.ErrorSummary role="alert" with aria-live="polite". Renders a list of field-level and form-level errors. Hidden pre-first-submit (overridable). Snippet-props-friendly for custom layouts.

createForm() API

Options

createForm({
    schema?: StandardSchemaV1,        // Any Standard Schema — Zod/Valibot/ArkType/…
    defaults: Values,                  // Initial values (required)
    onValidSubmit?: (values, e) => void | Promise<void>,
    onInvalidSubmit?: (errors, e) => void,
    validationBehaviour?: 'progressive' | 'onSubmit' | 'onBlur' | 'onChange'  // @default 'progressive'
})

Handle shape

interface Form<Values> {
	// Mutable state (bind inputs to these)
	values: Values;

	// Derived reactive state
	readonly errors: FormErrors; // keyed by dot-path
	readonly isPending: boolean; // async onValidSubmit in flight
	readonly isValid: boolean; // no errors
	readonly isDirty: boolean; // any registered field differs from default
	readonly isTouched: boolean; // any registered field has been blurred
	readonly submitCount: number;
	readonly firstInvalidField: string | undefined;

	// Per-field state lookup
	getFieldState(name: string): FieldState | undefined;

	// Imperative methods
	submit(e?: SubmitEvent): Promise<boolean>;
	reset(): void;
	validate(): void;

	// Registry (called by Field.Provider — no manual use)
	registerField(name, meta): void;
	unregisterField(name): void;
	setFieldTouched(name, touched): void;
	clearFieldTouched(name): void;

	// Attached by Form.Provider
	onsubmit(e: SubmitEvent): void;
}

FieldState

interface FieldState {
	touched: boolean;
	dirty: boolean; // value !== default (JSON-compared)
	errors: string[]; // scoped errors
	isInvalid: boolean; // errors.length > 0
}

Standard Schema integration

soma/Form accepts any validator that implements Standard Schema v1 — the zero-dep contract defined at https://standardschema.dev/. Zod, Valibot, ArkType, Effect/Schema all implement it natively. Yup needs a thin adapter.

// Zod
import { z } from 'zod';
const schema = z.object({ email: z.string().email() });

// Valibot
import * as v from 'valibot';
const schema = v.object({ email: v.pipe(v.string(), v.email()) });

// ArkType
import { type } from 'arktype';
const schema = type({ email: 'email' });

// Any of the above works with:
createForm({ schema, defaults: { email: '' } });

Schema errors are grouped by issue.path into form.errors['fieldName'] arrays. Form-level (pathless) issues land in form.errors[''] and surface via formErrors in ErrorSummary.

Async schemas (e.g. Zod refine with async check) return a Promise — createForm kicks validation off and updates form.errors when it resolves. The submit flow awaits the promise before the onValidSubmit callback fires.

Field.Provider integration

When Field.Provider sits inside Form.Provider and sets a name, three things happen:

  1. Auto-registration on mount → Form tracks the field, its label, its value for dirty calculation.
  2. OR-merge flags — Field.isDisabled = own disabled prop || form pending || form disabled. Same for isReadonly, isInvalid, isRequired.
  3. Dirty / touched / errors — derived from Form's state. Overridable by passing dirty/touched props explicitly.
<!-- Outside any Form: unchanged behaviour -->
<Field.Provider invalid>
	<Field.Label>Email</Field.Label>
	<input bind:value={email} />
</Field.Provider>

<!-- Inside a Form.Provider: name unlocks integration -->
<Form.Provider {form}>
	<Field.Provider name="email" label="Email">
		<Field.Label>Email</Field.Label>
		<input bind:value={form.values.email} />
		<!-- isInvalid / isDirty / isTouched derived from the form automatically -->
	</Field.Provider>
</Form.Provider>

data-field-name is emitted on the root so Form.ErrorSummary links and focus-first-error can locate the element.

Data attributes

Part Attribute Meaning
Form root data-form always
Form root data-pending Async submit in flight
Form root data-dirty Any field dirty
Form root data-touched Any field touched
Form root data-invalid After submit, when errors exist
Form root data-submitted submitCount > 0
Submit data-form-submit always
Submit data-pending During async submit
Reset data-form-reset always
Reset data-dirty Form is dirty
ErrorSummary data-form-error-summary always
ErrorSummary data-hidden Hidden pre-first-submit or empty
AutoFields group data-form-auto-fields-group Object branch path
AutoFields array data-form-auto-fields-array Array branch path
AutoFields array toolbar data-form-auto-fields-array-toolbar Array controls row
AutoFields add data-form-auto-fields-array-add Array branch path
AutoFields item data-form-auto-fields-item Array item path
AutoFields item toolbar data-form-auto-fields-item-toolbar Item controls row
AutoFields remove data-form-auto-fields-array-remove Array item path
AutoFields discriminated data-form-auto-fields-discriminated Discriminated branch path
AutoFields placeholder data-form-auto-fields-placeholder Unsupported schema kind
AutoFields field data-form-auto-fields-field Leaf field path
AutoFields widget data-form-auto-fields-widget Custom tag widget path
AutoFields widget data-form-auto-fields-widget-key Widget resolution key
Field root (new) data-field-name For ErrorSummary links + focus targeting
Field root (new) data-dirty Field dirty
Field root (new) data-touched Field touched

Accessibility

  • <form novalidate> by default — soma controls validation timing (no browser race).
  • aria-busy on the form + submit button while pending.
  • Form.ErrorSummary emits role="alert" + aria-live="polite" so announcements fire automatically.
  • focusFirstError={true} (default) focuses the first invalid field on a failed submit. Uses data-field-name + a tabbable descendant lookup.
  • Field.ErrorText already has aria-describedby wiring via the existing Field primitives — works unchanged.

SSR / SvelteKit integration

By default Form.Provider renders a standard <form> and intercepts onsubmit client-side. For SvelteKit progressive enhancement, pass your own handler:

<script>
	import { enhance } from '$app/forms';
	import { createForm } from '$libs/forms';

	const form = createForm({ defaults: {} });
</script>

<!-- Progressive enhancement: server action if JS fails, soma if JS loads -->
<form use:enhance method="post" action="?/save" onsubmit={form.onsubmit}>
	<Form.Provider {form} onsubmit={form.onsubmit}>
		<!-- fields -->
	</Form.Provider>
</form>

(Stage 2 will bundle this into a helper — see roadmap.)

Comparison

Feature React Hook Form TanStack Form Superforms Felte React Aria Form soma/Form
Framework React React/Vue/Sv SvelteKit Svelte 3/4 React Svelte 5 runes
Reactivity model hooks hooks stores stores hooks runes (no stores)
UI primitives integrated ✗ ✗ ✗ ✗ partial ✓ (Field/DateField/…)
Standard Schema (Zod/Valibot/ArkType) via resolvers via adapters ✓ ✓ ✗ ✓
register() boilerplate imperative imperative implicit imperative N/A implicit (by name)
bind:value on form state ✗ ✗ ~ ~ ✗ ✓
ErrorSummary + focus-first-error ✗ ✗ partial ✗ ✓ ✓
Dirty / touched out of box ✓ ✓ ~ ✓ ✗ ✓
Async validation (schema or custom) ✓ ✓ ✓ ~ ✗ ✓
SvelteKit use:enhance — — ✓ ~ — ✓
Field arrays ✓ ✓ ✓ ✓ ✗ v2
Granular reactivity (per-field subscriptions) ✓ (custom) ✓ ✗ ~ N/A ✓ (runes native)
Zero-setup for simple cases ~ ~ ~ ~ ✓ ✓

The unique columns soma wins: runes-native, UI primitives integrated, implicit registration by name, bind:value directly on form state.

Roadmap

v1 (shipped — this release):

  • ✓ createForm() runes-native state
  • ✓ Standard Schema integration
  • ✓ Form.Provider + Form.Submit + Form.Reset + Form.ErrorSummary
  • ✓ Field.Provider auto-registration + OR-merge
  • ✓ focusFirstError on submit failure

v2 (planned):

  • Form.FieldArray (dynamic add/remove rows)
  • SvelteKit use:enhance helper (form.enhance)
  • Server error merge (form.setErrors({ email: ['Taken'] }) from server response)
  • Debounced async validation with per-field loading state

v3 (future):

  • Granular subscriptions (opt-in <Form.Field name="x"><!-- re-renders only when x changes --></>)
  • DevTools panel for form state introspection
  • Conditional fields (Form.Show when={v => …})

Example — async submit with server error

<script lang="ts">
	import { Form, Field } from '$soma/components';
	import { createForm } from '$libs/forms';
	import { z } from 'zod';

	const schema = z.object({
		email: z.string().email(),
		username: z.string().min(3)
	});

	const form = createForm({
		schema,
		defaults: { email: '', username: '' },
		async onValidSubmit(values) {
			try {
				await fetch('/api/signup', { method: 'POST', body: JSON.stringify(values) });
			} catch (e) {
				// Thrown errors land in form.errors[''] as a form-level error.
				throw new Error('Server error — please retry.');
			}
		}
	});
</script>

<Form.Provider {form}>
	<Form.ErrorSummary />
	<Field.Provider name="email">
		<Field.Label>Email</Field.Label>
		<input type="email" bind:value={form.values.email} />
		<Field.ErrorText>{form.errors.email?.[0]}</Field.ErrorText>
	</Field.Provider>
	<Field.Provider name="username">
		<Field.Label>Username</Field.Label>
		<input bind:value={form.values.username} />
		<Field.ErrorText>{form.errors.username?.[0]}</Field.ErrorText>
	</Field.Provider>
	<Form.Submit>Sign up</Form.Submit>
</Form.Provider>

Powered by TurnKey Linux.