@samuel-charpentier/sform
v0.0.12
Published
A Svelte 5 form library for SvelteKit remote forms
Downloads
1,212
Maintainers
Readme
Sform
A type-safe form library for Svelte 5 with SvelteKit remote functions.
Table of Contents
Features
- ✅ Type-safe - Discriminated union types for each input type
- ✅ Preflight validation - All errors shown on submit, not one at a time
- ✅ Validate modes -
blur,change, orsubmit - ✅ Password toggle - Eye icon to show/hide password
- ✅ Masked inputs - Phone, credit card, SSN formatting
- ✅ Range slider - With optional value display
- ✅ Toggle switch - Modern on/off control
- ✅ Toggle options - Segmented control for mutually exclusive options
- ✅ Stateful button - Shows pending state during submission
Requirements
- Svelte 5
- SvelteKit with
remoteFunctions: truein config - Valibot for schema validation
Installation
npm install @samuel-charpentier/sformEnable remote functions in svelte.config.js:
export default {
kit: {
experimental: {
remoteFunctions: true
}
}
};Quick Start
1. Create a Remote Form
Create a .remote.ts file with your form schema and handler:
// src/routes/auth.remote.ts
import * as v from 'valibot';
import { form } from '@sveltejs/kit/remote';
const loginSchema = v.object({
username: v.pipe(v.string(), v.minLength(3, 'Username must be at least 3 characters')),
_password: v.pipe(v.string(), v.minLength(8, 'Password must be at least 8 characters'))
});
export const login = form(loginSchema, async ({ username, _password }) => {
// Your authentication logic here
return { success: true, message: 'Welcome!' };
});2. Create Your Form Component
<script lang="ts">
import { Sform, Sfield, Sbutton } from '@samuel-charpentier/sform';
import { login } from './auth.remote.ts';
</script>
<Sform form={login} validateOn="blur">
{#snippet children(fields)}
<Sfield field={fields.username} type="text" label="Username" />
<Sfield field={fields._password} type="password" label="Password" />
<Sbutton form={login} label="Login" />
{/snippet}
</Sform>Components
<Sform>
Wrapper component that provides form context to all child fields.
<Sform form={remoteForm} validateOn="blur" class="my-form">
{#snippet children(fields)}
<!-- Sfield components here -->
{/snippet}
</Sform>| Prop | Type | Default | Description |
| --------------- | -------------------------------- | ----------- | ------------------------------------------------- |
| form | RemoteForm | required | Remote form object from form() API |
| validateOn | 'blur' \| 'change' \| 'submit' | 'blur' | When to validate and show errors |
| class | string | undefined | CSS class for form element |
| preflightOnly | boolean | false | If true, client side validation is preflight only |
Validate Modes:
blur- Validate and show errors after leaving field (default)change- Validate and show errors as soon as value changessubmit- Validate and show all errors only after submit attempt
<Sfield>
Smart field component with type-safe props based on input type.
Common Props (all types)
| Prop | Type | Default | Description |
| ------------- | ------------------------- | ----------- | ------------------------------------- |
| field | RemoteFormField | required | Field from fields snippet parameter |
| type | InputType | required | Input type |
| label | string | undefined | Field label |
| placeholder | string | undefined | Placeholder text (text/password/etc) |
| disabled | boolean | false | Disable the field |
| readonly | boolean | false | Make field readonly |
| validateOn | ValidateOn | inherited | Override form validateOn |
| class | SfieldClasses \| string | undefined | CSS classes |
| hint | string \| Snippet | undefined | Help text shown below the field |
Text Inputs
<Sfield field={fields.email} type="email" label="Email" placeholder="[email protected]" />
<Sfield field={fields.search} type="search" label="Search" />
<Sfield field={fields.phone} type="tel" label="Phone" />
<Sfield field={fields.website} type="url" label="Website" prefix="https://" />Supported text types: text, email, tel, url, search, date, datetime-local, time, month, week, color, file
| Prop | Type | Default | Description |
| -------- | ------------------- | ----------- | -------------------- |
| prefix | string \| Snippet | undefined | Content before input |
| suffix | string \| Snippet | undefined | Content after input |
Password Input
<Sfield field={fields._password} type="password" label="Password" />
<Sfield field={fields._password} type="password" label="Password" showToggle={false} />
<Sfield field={fields._password} type="password" label="Password">
{#snippet showToggleIcon(passwordShown)}
{#if passwordShown}
🙈
{:else}
👁️
{/if}
{/snippet}
</Sfield>| Prop | Type | Default | Description |
| ---------------- | ----------------------------------- | ----------- | ---------------------------------- |
| showToggle | boolean | true | Show eye icon to toggle visibility |
| showToggleIcon | Snippet<[passwordShown: boolean]> | undefined | Custom toggle icon snippet |
| prefix | string \| Snippet | undefined | Content before input |
| suffix | string \| Snippet | undefined | Content after input |
| autocomplete | string | undefined | HTML autocomplete attribute |
Number Input
<Sfield field={fields.age} type="number" label="Age" min={0} max={150} step={1} />
<Sfield field={fields.price} type="number" label="Price" prefix="$" suffix="USD" align="end" />
<Sfield field={fields.quantity} type="number" label="Qty" showControls={false} maxDecimals={0} />| Prop | Type | Default | Description |
| -------------- | ------------------- | ----------- | -------------------------------------- |
| min | number \| string | undefined | Minimum value |
| max | number \| string | undefined | Maximum value |
| step | number \| string | undefined | Step increment |
| prefix | string \| Snippet | undefined | Content before input (e.g., "$") |
| suffix | string \| Snippet | undefined | Content after input (e.g., "USD") |
| showControls | boolean | true | Show spinner controls |
| align | 'start' \| 'end' | 'start' | Text alignment |
| maxDecimals | number | undefined | Max decimal places (0 = integers only) |
| autocomplete | string | undefined | HTML autocomplete attribute |
Textarea
<Sfield field={fields.bio} type="textarea" label="Bio" placeholder="Tell us about yourself" />
<Sfield field={fields.notes} type="textarea" label="Notes" prefix="📝" suffix="(max 500 chars)" />| Prop | Type | Default | Description |
| -------- | ------------------- | ----------- | -------------------- |
| prefix | string \| Snippet | undefined | Content before input |
| suffix | string \| Snippet | undefined | Content after input |
Select
<Sfield
field={fields.country}
type="select"
label="Country"
options={[
{ value: 'us', label: 'United States' },
{ value: 'uk', label: 'United Kingdom' },
{ value: 'ca', label: 'Canada' }
]}
/>| Prop | Type | Default | Description |
| -------------- | ---------------------------- | ----------- | --------------------------- |
| options | SelectOption[] \| string[] | required | Select options |
| autocomplete | string | undefined | HTML autocomplete attribute |
Checkbox
<Sfield field={fields.subscribe} type="checkbox" label="Subscribe to newsletter" />Radio
<Sfield
field={fields.plan}
type="radio"
label="Plan"
options={[
{ value: 'free', label: 'Free' },
{ value: 'pro', label: 'Pro' },
{ value: 'enterprise', label: 'Enterprise' }
]}
/>| Prop | Type | Default | Description |
| --------- | ---------------------------- | ----------- | ------------------------ |
| options | SelectOption[] \| string[] | undefined | Radio options for groups |
Range
<Sfield field={fields.volume} type="range" label="Volume" min={0} max={100} step={5} showValue />
<Sfield
field={fields.brightness}
type="range"
label="Brightness"
min={0}
max={100}
formatValue={(v) => `${v}%`}
showValue
/>| Prop | Type | Default | Description |
| -------------- | --------------------------- | ----------- | --------------------------- |
| min | number \| string | 0 | Minimum value |
| max | number \| string | 100 | Maximum value |
| step | number \| string | 1 | Step increment |
| showValue | boolean | false | Show current value |
| formatValue | (value: number) => string | undefined | Format displayed value |
| autocomplete | string | undefined | HTML autocomplete attribute |
Toggle
<Sfield field={fields.notifications} type="toggle" label="Enable Notifications" />
<Sfield field={fields.darkMode} type="toggle" label="Theme" onLabel="Dark" offLabel="Light" />| Prop | Type | Default | Description |
| ---------------- | -------- | ----------- | -------------------- |
| onLabel | string | undefined | Label when on |
| offLabel | string | undefined | Label when off |
| checkedValue | string | 'true' | Value when checked |
| uncheckedValue | string | 'false' | Value when unchecked |
Toggle Options
<Sfield
field={fields.theme}
type="toggle-options"
label="Theme"
options={[
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'auto', label: 'Auto' }
]}
/>
<!-- Multiple selection -->
<Sfield
field={fields.features}
type="toggle-options"
label="Features"
multiple={true}
options={[
{ value: 'push', label: 'Push Notifications' },
{ value: 'email', label: 'Email' },
{ value: 'sms', label: 'SMS' }
]}
/>| Prop | Type | Default | Description |
| ---------- | ---------------------------- | -------- | ------------------------- |
| options | ToggleOption[] \| string[] | required | Toggle options |
| multiple | boolean | false | Allow multiple selections |
Masked Input
<Sfield field={fields.phone} type="masked" label="Phone" mask="(###) ###-####" />
<Sfield field={fields.creditCard} type="masked" label="Credit Card" mask="#### #### #### ####" />
<Sfield field={fields.ssn} type="masked" label="SSN" mask="###-##-####" />
<!-- Custom tokens -->
<Sfield
field={fields.code}
type="masked"
label="Code"
mask="AAAA-99-LL"
tokens={{ A: /[A-Z]/, L: /[a-z]/ }}
/>| Prop | Type | Default | Description |
| --------------------- | ------------------------ | ----------- | -------------------------------- |
| mask | string | required | Mask pattern |
| tokens | Record<string, RegExp> | undefined | Custom token definitions |
| maskPlaceholder | string | '_' | Placeholder character |
| showMaskPlaceholder | boolean | false | Show full mask with placeholders |
| unmaskValue | boolean | true | Store unmasked value |
| prefix | string \| Snippet | undefined | Content before input |
| suffix | string \| Snippet | undefined | Content after input |
| autocomplete | string | undefined | HTML autocomplete attribute |
Mask Tokens:
#or9- Numeric (0-9)a- Alphabetic (a-z, A-Z)A- Alphabetic uppercase*- Alphanumeric
Hidden Input
<Sfield field={fields.token} type="hidden" value={authToken} />
<Sfield field={fields.userId} type="hidden" value="12345" />| Prop | Type | Default | Description |
| ------- | -------- | ------- | ------------------------------------------------ |
| value | string | '' | The value for the hidden field (can be reactive) |
Hidden inputs are useful for including data in form submissions without displaying it to the user. The value prop is reactive, so you can update it programmatically:
<script lang="ts">
let token = $state(initialToken);
async function refreshToken() {
token = await getNewToken();
}
</script>
<Sfield field={fields.token} type="hidden" value={token} /><Sbutton>
Stateful submit button that reacts to form state. Pass the form prop to enable typed result access.
<Sbutton form={myForm} label="Submit" class="my-button" />
<!-- With custom state rendering -->
<Sbutton form={myForm} class="submit-btn">
{#snippet children(state)}
{#if state.state === 'pending'}
Submitting...
{:else if state.state === 'success'}
✓ {state.result.message}
{:else if state.state === 'hasIssues'}
Fix Errors
{:else}
Submit Form
{/if}
{/snippet}
</Sbutton>The state parameter is a discriminated union of type ButtonState<T> where T is inferred from the form's result type:
type ButtonState<T = unknown> =
| { state: 'default'; pending: false; success: false; hasIssues: false; result: undefined }
| { state: 'pending'; pending: true; success: false; hasIssues: false; result: undefined }
| { state: 'success'; pending: false; success: true; hasIssues: false; result: T }
| { state: 'hasIssues'; pending: false; success: false; hasIssues: true; result: undefined };Typed Result Access
The result type is automatically inferred from the form prop. When your remote function returns a typed result, you can access it directly:
<script lang="ts">
import { login } from './auth.remote'; // Returns { success: boolean; message: string }
</script>
<Sbutton form={login} class="submit-btn">
{#snippet children(state)}
{#if state.state === 'success'}
{state.result.message} <!-- TypeScript knows this is string -->
{:else if state.state === 'pending'}
Logging in...
{:else}
Login
{/if}
{/snippet}
</Sbutton>| Prop | Type | Default | Description |
| ------------ | --------------------------------- | ----------- | --------------------------------- |
| form | RemoteForm | required | Remote form for type inference |
| label | string | 'Submit' | Button text (when no children) |
| buttonType | 'submit' \| 'reset' \| 'button' | 'submit' | Button type |
| class | string | undefined | CSS class |
| disabled | boolean | false | Disable button |
| children | Snippet<[ButtonState<T>]> | undefined | Custom content with typed state |
| onsubmit | () => void \| Promise<void> | undefined | Callback before validation/submit |
<SIssues>
Displays form-level issues and issues not shown by any Sfield component (e.g., hidden field issues or programmatic validation via invalid()).
<SIssues message="There are some issues with your form:" />
<!-- With custom message snippet -->
<SIssues>
{#snippet message()}
<strong>⚠️ Please fix the following issues:</strong>
{/snippet}
</SIssues>| Prop | Type | Default | Description |
| ----------- | ------------------- | --------------------- | --------------------------------- |
| message | string \| Snippet | undefined | General message shown when issues |
| class | string | 'sform-issues' | CSS class for wrapper |
| listClass | string | 'sform-issues-list' | CSS class for issues list |
The component filters issues to only show:
- Form-level issues (from
invalid("message")) - Field issues for hidden inputs (no Sfield displays them)
- Issues for fields without a corresponding Sfield
<SResult>
Displays form result with typed access. Only renders when the form has a result. Pass the form prop to enable typed result access in the children snippet.
<SResult form={myLogin} class="sform-result sform-result-success">
{#snippet children(result)}
{result.message}
{/snippet}
</SResult>The result parameter is typed based on your remote function's return type:
<script lang="ts">
import { login } from './auth.remote'; // Returns { success: boolean; message: string }
</script>
<SResult form={login} class="success-message">
{#snippet children(result)}
<!-- TypeScript knows result is { success: boolean; message: string } -->
<h2>Welcome!</h2>
<p>{result.message}</p>
{/snippet}
</SResult>| Prop | Type | Default | Description |
| ---------- | -------------- | ----------- | ------------------------------ |
| form | RemoteForm | required | Remote form for type inference |
| children | Snippet<[T]> | required | Content with typed result |
| class | string | undefined | CSS class for wrapper |
The component only renders when form.result !== undefined, so the result parameter in the children snippet is guaranteed to be defined.
Styling
CSS Classes
Sfield adds these classes automatically:
.sform-field- Wrapper element.sform-label- Label element.sform-input- Input element.sform-messages- Error messages container.sform-field-error- Added to wrapper when field has errors
Custom Classes
<!-- String class applies to wrapper -->
<Sfield field={fields.email} type="email" class="my-field" />
<!-- Object for granular control -->
<Sfield
field={fields.email}
type="email"
class={{
wrapper: 'field-wrapper',
label: 'field-label',
input: 'field-input',
messages: 'field-errors'
}}
/>Validation
Sform uses preflight validation with Valibot schemas. Native browser validation (required, minlength, pattern) is disabled to allow showing all errors at once on submit.
Schema Example
import * as v from 'valibot';
const signupSchema = v.object({
email: v.pipe(v.string(), v.email('Please enter a valid email')),
_password: v.pipe(
v.string(),
v.minLength(8, 'Password must be at least 8 characters'),
v.regex(/[A-Z]/, 'Password must contain an uppercase letter'),
v.regex(/[0-9]/, 'Password must contain a number')
),
age: v.pipe(v.number(), v.minValue(18, 'Must be at least 18 years old'))
});Type Safety
Sform uses TypeScript discriminated unions to provide type-safe props for each input type:
// ✅ TypeScript knows 'showToggle' is only valid for password type
<Sfield field={fields._password} type="password" showToggle={false} />
// ✅ TypeScript knows 'options' is required for select type
<Sfield field={fields.country} type="select" options={countries} />
// ✅ TypeScript knows 'min', 'max', 'step' are valid for number type
<Sfield field={fields.age} type="number" min={0} max={150} />
// ❌ TypeScript error: 'showToggle' doesn't exist on text type
<Sfield field={fields.username} type="text" showToggle />Development
# Install dependencies
npm install
# Start dev server
npm run dev
# Run tests
npm test
# Build library
npm run packageLicense
MIT
