|
|
5 months ago | |
|---|---|---|
| .. | ||
| components | 5 months ago | |
| README.md | 5 months ago | |
| exports.ts | 5 months ago | |
| form-provider.svelte.test.ts | 5 months ago | |
| form-provider.svelte.ts | 5 months ago | |
| index.ts | 6 months ago | |
| langs.ts | 5 months ago | |
| types.ts | 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:
- Auto-registration on mount → Form tracks the field, its label, its value for dirty calculation.
- OR-merge flags —
Field.isDisabled= owndisabledprop || form pending || form disabled. Same forisReadonly,isInvalid,isRequired. - Dirty / touched / errors — derived from Form's state. Overridable by passing
dirty/touchedprops 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-busyon the form + submit button while pending.Form.ErrorSummaryemitsrole="alert"+aria-live="polite"so announcements fire automatically.focusFirstError={true}(default) focuses the first invalid field on a failed submit. Usesdata-field-name+ a tabbable descendant lookup.Field.ErrorTextalready hasaria-describedbywiring 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.Providerauto-registration + OR-merge - ✓
focusFirstErroron submit failure
v2 (planned):
Form.FieldArray(dynamic add/remove rows)- SvelteKit
use:enhancehelper (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>
Related
Field— per-field Provider (label / control / helper / error). Extended to auto-register with Form whennameis set.DateField,TimeField,NumberField,ColorField,ColorPicker— all form-capable primitives that OR-merge flags with an ancestor Field + Form.