@samuel-charpentier/sform 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,587 +1,697 @@
1
- # Sform
2
-
3
- A type-safe form library for **Svelte 5** with **SvelteKit remote functions**.
4
-
5
- ## Table of Contents
6
-
7
- - [Features](#features)
8
- - [Requirements](#requirements)
9
- - [Installation](#installation)
10
- - [Quick Start](#quick-start)
11
- - [Create a Remote Form](#1-create-a-remote-form)
12
- - [Create Your Form Component](#2-create-your-form-component)
13
- - [Components](#components)
14
- - [`<Sform>`](#sform)
15
- - [`<Sfield>`](#sfield)
16
- - [Common Props (all types)](#common-props-all-types)
17
- - [Text Inputs](#text-inputs)
18
- - [Password Input](#password-input)
19
- - [Number Input](#number-input)
20
- - [Textarea](#textarea)
21
- - [Select](#select)
22
- - [Checkbox](#checkbox)
23
- - [Radio](#radio)
24
- - [Range](#range)
25
- - [Toggle](#toggle)
26
- - [Masked Input](#masked-input)
27
- - [Hidden Input](#hidden-input)
28
- - [`<Sbutton>`](#sbutton)
29
- - [`<SIssues>`](#sissues)
30
- - [`<SResult>`](#sresult)
31
- - [Styling](#styling)
32
- - [Validation](#validation)
33
- - [Type Safety](#type-safety)
34
- - [Development](#development)
35
- - [License](#license)
36
-
37
- ## Features
38
-
39
- - ✅ **Type-safe** - Discriminated union types for each input type
40
- - ✅ **Preflight validation** - All errors shown on submit, not one at a time
41
- - ✅ **Validate modes** - `blur`, `change`, or `submit`
42
- - ✅ **Password toggle** - Eye icon to show/hide password
43
- - ✅ **Masked inputs** - Phone, credit card, SSN formatting
44
- - ✅ **Range slider** - With optional value display
45
- - ✅ **Toggle switch** - Modern on/off control
46
- - ✅ **Stateful button** - Shows pending state during submission
47
-
48
- ## Requirements
49
-
50
- - Svelte 5
51
- - SvelteKit with `remoteFunctions: true` in config
52
- - Valibot for schema validation
53
-
54
- ## Installation
55
-
56
- ```bash
57
- npm install @samuel-charpentier/sform
58
- ```
59
-
60
- Enable remote functions in `svelte.config.js`:
61
-
62
- ```javascript
63
- export default {
64
- kit: {
65
- experimental: {
66
- remoteFunctions: true
67
- }
68
- }
69
- };
70
- ```
71
-
72
- ## Quick Start
73
-
74
- ### 1. Create a Remote Form
75
-
76
- Create a `.remote.ts` file with your form schema and handler:
77
-
78
- ```typescript
79
- // src/routes/auth.remote.ts
80
- import * as v from 'valibot';
81
- import { form } from '@sveltejs/kit/remote';
82
-
83
- const loginSchema = v.object({
84
- username: v.pipe(v.string(), v.minLength(3, 'Username must be at least 3 characters')),
85
- _password: v.pipe(v.string(), v.minLength(8, 'Password must be at least 8 characters'))
86
- });
87
-
88
- export const login = form(loginSchema, async ({ username, _password }) => {
89
- // Your authentication logic here
90
- return { success: true, message: 'Welcome!' };
91
- });
92
- ```
93
-
94
- ### 2. Create Your Form Component
95
-
96
- ```svelte
97
- <script lang="ts">
98
- import { Sform, Sfield, Sbutton } from '@samuel-charpentier/sform';
99
- import { login } from './auth.remote.ts';
100
- </script>
101
-
102
- <Sform form={login} validateOn="blur">
103
- {#snippet children(fields)}
104
- <Sfield field={fields.username} type="text" label="Username" />
105
- <Sfield field={fields._password} type="password" label="Password" />
106
-
107
- <Sbutton form={login} label="Login" />
108
- {/snippet}
109
- </Sform>
110
- ```
111
-
112
- ## Components
113
-
114
- ### `<Sform>`
115
-
116
- Wrapper component that provides form context to all child fields.
117
-
118
- ```svelte
119
- <Sform form={remoteForm} validateOn="blur" class="my-form">
120
- {#snippet children(fields)}
121
- <!-- Sfield components here -->
122
- {/snippet}
123
- </Sform>
124
- ```
125
-
126
- | Prop | Type | Default | Description |
127
- | --------------- | -------------------------------- | ----------- | ------------------------------------------------- |
128
- | `form` | `RemoteForm` | required | Remote form object from `form()` API |
129
- | `validateOn` | `'blur' \| 'change' \| 'submit'` | `'blur'` | When to validate and show errors |
130
- | `class` | `string` | `undefined` | CSS class for form element |
131
- | `preflightOnly` | `boolean` | `false` | If true, client side validation is preflight only |
132
-
133
- **Validate Modes:**
134
-
135
- - `blur` - Validate and show errors after leaving field (default)
136
- - `change` - Validate and show errors as soon as value changes
137
- - `submit` - Validate and show all errors only after submit attempt
138
-
139
- ### `<Sfield>`
140
-
141
- Smart field component with type-safe props based on input type.
142
-
143
- #### Common Props (all types)
144
-
145
- | Prop | Type | Default | Description |
146
- | ------------- | ------------------------- | ----------- | ------------------------------------- |
147
- | `field` | `RemoteFormField` | required | Field from `fields` snippet parameter |
148
- | `type` | `InputType` | required | Input type |
149
- | `label` | `string` | `undefined` | Field label |
150
- | `placeholder` | `string` | `undefined` | Placeholder text (text/password/etc) |
151
- | `disabled` | `boolean` | `false` | Disable the field |
152
- | `readonly` | `boolean` | `false` | Make field readonly |
153
- | `validateOn` | `ValidateOn` | inherited | Override form validateOn |
154
- | `class` | `SfieldClasses \| string` | `undefined` | CSS classes |
155
- | `hint` | `string \| Snippet` | `undefined` | Help text shown below the field |
156
-
157
- #### Text Inputs
158
-
159
- ```svelte
160
- <Sfield field={fields.email} type="email" label="Email" placeholder="you@example.com" />
161
- <Sfield field={fields.search} type="search" label="Search" />
162
- <Sfield field={fields.phone} type="tel" label="Phone" />
163
- <Sfield field={fields.website} type="url" label="Website" prefix="https://" />
164
- ```
165
-
166
- Supported text types: `text`, `email`, `tel`, `url`, `search`, `date`, `datetime-local`, `time`, `month`, `week`, `color`, `file`
167
-
168
- | Prop | Type | Default | Description |
169
- | -------- | ------------------- | ----------- | -------------------- |
170
- | `prefix` | `string \| Snippet` | `undefined` | Content before input |
171
- | `suffix` | `string \| Snippet` | `undefined` | Content after input |
172
-
173
- #### Password Input
174
-
175
- ```svelte
176
- <Sfield field={fields._password} type="password" label="Password" />
177
- <Sfield field={fields._password} type="password" label="Password" showToggle={false} />
178
- <Sfield field={fields._password} type="password" label="Password">
179
- {#snippet showToggleIcon(passwordShown)}
180
- {#if passwordShown}
181
- 🙈
182
- {:else}
183
- 👁️
184
- {/if}
185
- {/snippet}
186
- </Sfield>
187
- ```
188
-
189
- | Prop | Type | Default | Description |
190
- | ---------------- | ----------------------------------- | ----------- | ---------------------------------- |
191
- | `showToggle` | `boolean` | `true` | Show eye icon to toggle visibility |
192
- | `showToggleIcon` | `Snippet<[passwordShown: boolean]>` | `undefined` | Custom toggle icon snippet |
193
- | `prefix` | `string \| Snippet` | `undefined` | Content before input |
194
- | `suffix` | `string \| Snippet` | `undefined` | Content after input |
195
- | `autocomplete` | `string` | `undefined` | HTML autocomplete attribute |
196
-
197
- #### Number Input
198
-
199
- ```svelte
200
- <Sfield field={fields.age} type="number" label="Age" min={0} max={150} step={1} />
201
- <Sfield field={fields.price} type="number" label="Price" prefix="$" suffix="USD" align="end" />
202
- <Sfield field={fields.quantity} type="number" label="Qty" showControls={false} maxDecimals={0} />
203
- ```
204
-
205
- | Prop | Type | Default | Description |
206
- | -------------- | ------------------- | ----------- | -------------------------------------- |
207
- | `min` | `number \| string` | `undefined` | Minimum value |
208
- | `max` | `number \| string` | `undefined` | Maximum value |
209
- | `step` | `number \| string` | `undefined` | Step increment |
210
- | `prefix` | `string \| Snippet` | `undefined` | Content before input (e.g., "$") |
211
- | `suffix` | `string \| Snippet` | `undefined` | Content after input (e.g., "USD") |
212
- | `showControls` | `boolean` | `true` | Show spinner controls |
213
- | `align` | `'start' \| 'end'` | `'start'` | Text alignment |
214
- | `maxDecimals` | `number` | `undefined` | Max decimal places (0 = integers only) |
215
- | `autocomplete` | `string` | `undefined` | HTML autocomplete attribute |
216
-
217
- #### Textarea
218
-
219
- ```svelte
220
- <Sfield field={fields.bio} type="textarea" label="Bio" placeholder="Tell us about yourself" />
221
- <Sfield field={fields.notes} type="textarea" label="Notes" prefix="📝" suffix="(max 500 chars)" />
222
- ```
223
-
224
- | Prop | Type | Default | Description |
225
- | -------- | ------------------- | ----------- | -------------------- |
226
- | `prefix` | `string \| Snippet` | `undefined` | Content before input |
227
- | `suffix` | `string \| Snippet` | `undefined` | Content after input |
228
-
229
- #### Select
230
-
231
- ```svelte
232
- <Sfield
233
- field={fields.country}
234
- type="select"
235
- label="Country"
236
- options={[
237
- { value: 'us', label: 'United States' },
238
- { value: 'uk', label: 'United Kingdom' },
239
- { value: 'ca', label: 'Canada' }
240
- ]}
241
- />
242
- ```
243
-
244
- | Prop | Type | Default | Description |
245
- | -------------- | ---------------------------- | ----------- | --------------------------- |
246
- | `options` | `SelectOption[] \| string[]` | required | Select options |
247
- | `autocomplete` | `string` | `undefined` | HTML autocomplete attribute |
248
-
249
- #### Checkbox
250
-
251
- ```svelte
252
- <Sfield field={fields.subscribe} type="checkbox" label="Subscribe to newsletter" />
253
- ```
254
-
255
- #### Radio
256
-
257
- ```svelte
258
- <Sfield
259
- field={fields.plan}
260
- type="radio"
261
- label="Plan"
262
- options={[
263
- { value: 'free', label: 'Free' },
264
- { value: 'pro', label: 'Pro' },
265
- { value: 'enterprise', label: 'Enterprise' }
266
- ]}
267
- />
268
- ```
269
-
270
- | Prop | Type | Default | Description |
271
- | --------- | ---------------------------- | ----------- | ------------------------ |
272
- | `options` | `SelectOption[] \| string[]` | `undefined` | Radio options for groups |
273
-
274
- #### Range
275
-
276
- ```svelte
277
- <Sfield field={fields.volume} type="range" label="Volume" min={0} max={100} step={5} showValue />
278
- <Sfield
279
- field={fields.brightness}
280
- type="range"
281
- label="Brightness"
282
- min={0}
283
- max={100}
284
- formatValue={(v) => `${v}%`}
285
- showValue
286
- />
287
- ```
288
-
289
- | Prop | Type | Default | Description |
290
- | -------------- | --------------------------- | ----------- | --------------------------- |
291
- | `min` | `number \| string` | `0` | Minimum value |
292
- | `max` | `number \| string` | `100` | Maximum value |
293
- | `step` | `number \| string` | `1` | Step increment |
294
- | `showValue` | `boolean` | `false` | Show current value |
295
- | `formatValue` | `(value: number) => string` | `undefined` | Format displayed value |
296
- | `autocomplete` | `string` | `undefined` | HTML autocomplete attribute |
297
-
298
- #### Toggle
299
-
300
- ```svelte
301
- <Sfield field={fields.notifications} type="toggle" label="Enable Notifications" />
302
- <Sfield field={fields.darkMode} type="toggle" label="Theme" onLabel="Dark" offLabel="Light" />
303
- ```
304
-
305
- | Prop | Type | Default | Description |
306
- | ---------------- | -------- | ----------- | -------------------- |
307
- | `onLabel` | `string` | `undefined` | Label when on |
308
- | `offLabel` | `string` | `undefined` | Label when off |
309
- | `checkedValue` | `string` | `'true'` | Value when checked |
310
- | `uncheckedValue` | `string` | `'false'` | Value when unchecked |
311
-
312
- #### Masked Input
313
-
314
- ```svelte
315
- <Sfield field={fields.phone} type="masked" label="Phone" mask="(###) ###-####" />
316
- <Sfield field={fields.creditCard} type="masked" label="Credit Card" mask="#### #### #### ####" />
317
- <Sfield field={fields.ssn} type="masked" label="SSN" mask="###-##-####" />
318
- <!-- Custom tokens -->
319
- <Sfield
320
- field={fields.code}
321
- type="masked"
322
- label="Code"
323
- mask="AAAA-99-LL"
324
- tokens={{ A: /[A-Z]/, L: /[a-z]/ }}
325
- />
326
- ```
327
-
328
- | Prop | Type | Default | Description |
329
- | --------------------- | ------------------------ | ----------- | -------------------------------- |
330
- | `mask` | `string` | required | Mask pattern |
331
- | `tokens` | `Record<string, RegExp>` | `undefined` | Custom token definitions |
332
- | `maskPlaceholder` | `string` | `'_'` | Placeholder character |
333
- | `showMaskPlaceholder` | `boolean` | `false` | Show full mask with placeholders |
334
- | `unmaskValue` | `boolean` | `true` | Store unmasked value |
335
- | `prefix` | `string \| Snippet` | `undefined` | Content before input |
336
- | `suffix` | `string \| Snippet` | `undefined` | Content after input |
337
- | `autocomplete` | `string` | `undefined` | HTML autocomplete attribute |
338
-
339
- **Mask Tokens:**
340
-
341
- - `#` or `9` - Numeric (0-9)
342
- - `a` - Alphabetic (a-z, A-Z)
343
- - `A` - Alphabetic uppercase
344
- - `*` - Alphanumeric
345
-
346
- #### Hidden Input
347
-
348
- ```svelte
349
- <Sfield field={fields.token} type="hidden" value={authToken} />
350
- <Sfield field={fields.userId} type="hidden" value="12345" />
351
- ```
352
-
353
- | Prop | Type | Default | Description |
354
- | ------- | -------- | ------- | ------------------------------------------------ |
355
- | `value` | `string` | `''` | The value for the hidden field (can be reactive) |
356
-
357
- 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:
358
-
359
- ```svelte
360
- <script lang="ts">
361
- let token = $state(initialToken);
362
-
363
- async function refreshToken() {
364
- token = await getNewToken();
365
- }
366
- </script>
367
-
368
- <Sfield field={fields.token} type="hidden" value={token} />
369
- ```
370
-
371
- ### `<Sbutton>`
372
-
373
- Stateful submit button that reacts to form state. Pass the `form` prop to enable typed result access.
374
-
375
- ```svelte
376
- <Sbutton form={myForm} label="Submit" class="my-button" />
377
-
378
- <!-- With custom state rendering -->
379
- <Sbutton form={myForm} class="submit-btn">
380
- {#snippet children(state)}
381
- {#if state.state === 'pending'}
382
- Submitting...
383
- {:else if state.state === 'success'}
384
- {state.result.message}
385
- {:else if state.state === 'hasIssues'}
386
- Fix Errors
387
- {:else}
388
- Submit Form
389
- {/if}
390
- {/snippet}
391
- </Sbutton>
392
- ```
393
-
394
- The `state` parameter is a discriminated union of type `ButtonState<T>` where `T` is inferred from the form's result type:
395
-
396
- ```typescript
397
- type ButtonState<T = unknown> =
398
- | { state: 'default'; pending: false; success: false; hasIssues: false; result: undefined }
399
- | { state: 'pending'; pending: true; success: false; hasIssues: false; result: undefined }
400
- | { state: 'success'; pending: false; success: true; hasIssues: false; result: T }
401
- | { state: 'hasIssues'; pending: false; success: false; hasIssues: true; result: undefined };
402
- ```
403
-
404
- #### Typed Result Access
405
-
406
- The result type is automatically inferred from the `form` prop. When your remote function returns a typed result, you can access it directly:
407
-
408
- ```svelte
409
- <script lang="ts">
410
- import { login } from './auth.remote'; // Returns { success: boolean; message: string }
411
- </script>
412
-
413
- <Sbutton form={login} class="submit-btn">
414
- {#snippet children(state)}
415
- {#if state.state === 'success'}
416
- {state.result.message} <!-- TypeScript knows this is string -->
417
- {:else if state.state === 'pending'}
418
- Logging in...
419
- {:else}
420
- Login
421
- {/if}
422
- {/snippet}
423
- </Sbutton>
424
- ```
425
-
426
- | Prop | Type | Default | Description |
427
- | ------------ | --------------------------------- | ----------- | --------------------------------- |
428
- | `form` | `RemoteForm` | required | Remote form for type inference |
429
- | `label` | `string` | `'Submit'` | Button text (when no children) |
430
- | `buttonType` | `'submit' \| 'reset' \| 'button'` | `'submit'` | Button type |
431
- | `class` | `string` | `undefined` | CSS class |
432
- | `disabled` | `boolean` | `false` | Disable button |
433
- | `children` | `Snippet<[ButtonState<T>]>` | `undefined` | Custom content with typed state |
434
- | `onsubmit` | `() => void \| Promise<void>` | `undefined` | Callback before validation/submit |
435
-
436
- ### `<SIssues>`
437
-
438
- Displays form-level issues and issues not shown by any Sfield component (e.g., hidden field issues or programmatic validation via `invalid()`).
439
-
440
- ```svelte
441
- <SIssues message="There are some issues with your form:" />
442
-
443
- <!-- With custom message snippet -->
444
- <SIssues>
445
- {#snippet message()}
446
- <strong>⚠️ Please fix the following issues:</strong>
447
- {/snippet}
448
- </SIssues>
449
- ```
450
-
451
- | Prop | Type | Default | Description |
452
- | ----------- | ------------------- | --------------------- | --------------------------------- |
453
- | `message` | `string \| Snippet` | `undefined` | General message shown when issues |
454
- | `class` | `string` | `'sform-issues'` | CSS class for wrapper |
455
- | `listClass` | `string` | `'sform-issues-list'` | CSS class for issues list |
456
-
457
- The component filters issues to only show:
458
-
459
- - Form-level issues (from `invalid("message")`)
460
- - Field issues for hidden inputs (no Sfield displays them)
461
- - Issues for fields without a corresponding Sfield
462
-
463
- ### `<SResult>`
464
-
465
- 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.
466
-
467
- ```svelte
468
- <SResult form={myLogin} class="sform-result sform-result-success">
469
- {#snippet children(result)}
470
- {result.message}
471
- {/snippet}
472
- </SResult>
473
- ```
474
-
475
- The `result` parameter is typed based on your remote function's return type:
476
-
477
- ```svelte
478
- <script lang="ts">
479
- import { login } from './auth.remote'; // Returns { success: boolean; message: string }
480
- </script>
481
-
482
- <SResult form={login} class="success-message">
483
- {#snippet children(result)}
484
- <!-- TypeScript knows result is { success: boolean; message: string } -->
485
- <h2>Welcome!</h2>
486
- <p>{result.message}</p>
487
- {/snippet}
488
- </SResult>
489
- ```
490
-
491
- | Prop | Type | Default | Description |
492
- | ---------- | -------------- | ----------- | ------------------------------ |
493
- | `form` | `RemoteForm` | required | Remote form for type inference |
494
- | `children` | `Snippet<[T]>` | required | Content with typed result |
495
- | `class` | `string` | `undefined` | CSS class for wrapper |
496
-
497
- The component only renders when `form.result !== undefined`, so the `result` parameter in the children snippet is guaranteed to be defined.
498
-
499
- ## Styling
500
-
501
- ### CSS Classes
502
-
503
- Sfield adds these classes automatically:
504
-
505
- - `.sform-field` - Wrapper element
506
- - `.sform-label` - Label element
507
- - `.sform-input` - Input element
508
- - `.sform-messages` - Error messages container
509
- - `.sform-field-error` - Added to wrapper when field has errors
510
-
511
- ### Custom Classes
512
-
513
- ```svelte
514
- <!-- String class applies to wrapper -->
515
- <Sfield field={fields.email} type="email" class="my-field" />
516
-
517
- <!-- Object for granular control -->
518
- <Sfield
519
- field={fields.email}
520
- type="email"
521
- class={{
522
- wrapper: 'field-wrapper',
523
- label: 'field-label',
524
- input: 'field-input',
525
- messages: 'field-errors'
526
- }}
527
- />
528
- ```
529
-
530
- ## Validation
531
-
532
- Sform uses preflight validation with Valibot schemas. Native browser validation (required, minlength, pattern) is disabled to allow showing all errors at once on submit.
533
-
534
- ### Schema Example
535
-
536
- ```typescript
537
- import * as v from 'valibot';
538
-
539
- const signupSchema = v.object({
540
- email: v.pipe(v.string(), v.email('Please enter a valid email')),
541
- _password: v.pipe(
542
- v.string(),
543
- v.minLength(8, 'Password must be at least 8 characters'),
544
- v.regex(/[A-Z]/, 'Password must contain an uppercase letter'),
545
- v.regex(/[0-9]/, 'Password must contain a number')
546
- ),
547
- age: v.pipe(v.number(), v.minValue(18, 'Must be at least 18 years old'))
548
- });
549
- ```
550
-
551
- ## Type Safety
552
-
553
- Sform uses TypeScript discriminated unions to provide type-safe props for each input type:
554
-
555
- ```typescript
556
- // TypeScript knows 'showToggle' is only valid for password type
557
- <Sfield field={fields._password} type="password" showToggle={false} />
558
-
559
- // ✅ TypeScript knows 'options' is required for select type
560
- <Sfield field={fields.country} type="select" options={countries} />
561
-
562
- // TypeScript knows 'min', 'max', 'step' are valid for number type
563
- <Sfield field={fields.age} type="number" min={0} max={150} />
564
-
565
- // TypeScript error: 'showToggle' doesn't exist on text type
566
- <Sfield field={fields.username} type="text" showToggle />
567
- ```
568
-
569
- ## Development
570
-
571
- ```bash
572
- # Install dependencies
573
- npm install
574
-
575
- # Start dev server
576
- npm run dev
577
-
578
- # Run tests
579
- npm test
580
-
581
- # Build library
582
- npm run package
583
- ```
584
-
585
- ## License
586
-
587
- MIT
1
+ # Sform
2
+
3
+ A type-safe form library for **Svelte 5** with **SvelteKit remote functions**.
4
+
5
+ ## Table of Contents
6
+
7
+ - [Features](#features)
8
+ - [Requirements](#requirements)
9
+ - [Installation](#installation)
10
+ - [Quick Start](#quick-start)
11
+ - [Create a Remote Form](#1-create-a-remote-form)
12
+ - [Create Your Form Component](#2-create-your-form-component)
13
+ - [Components](#components)
14
+ - [`<Sform>`](#sform)
15
+ - [`<Sfield>`](#sfield)
16
+ - [Common Props (all types)](#common-props-all-types)
17
+ - [Text Inputs](#text-inputs)
18
+ - [Password Input](#password-input)
19
+ - [Number Input](#number-input)
20
+ - [Textarea](#textarea)
21
+ - [Select](#select)
22
+ - [Checkbox](#checkbox)
23
+ - [Radio](#radio)
24
+ - [Range](#range)
25
+ - [Toggle](#toggle)
26
+ - [Masked Input](#masked-input)
27
+ - [Hidden Input](#hidden-input)
28
+ - [`<Sbutton>`](#sbutton)
29
+ - [`<SIssues>`](#sissues)
30
+ - [`<SResult>`](#sresult)
31
+ - [Styling](#styling)
32
+ - [Validation](#validation)
33
+ - [Type Safety](#type-safety)
34
+ - [Development](#development)
35
+ - [License](#license)
36
+
37
+ ## Features
38
+
39
+ - ✅ **Type-safe** - Discriminated union types for each input type
40
+ - ✅ **Preflight validation** - All errors shown on submit, not one at a time
41
+ - ✅ **Validate modes** - `blur`, `change`, or `submit`
42
+ - ✅ **Password toggle** - Eye icon to show/hide password
43
+ - ✅ **Masked inputs** - Phone, credit card, SSN formatting
44
+ - ✅ **Range slider** - With optional value display
45
+ - ✅ **Toggle switch** - Modern on/off control
46
+ - ✅ **Stateful button** - Shows pending state during submission
47
+
48
+ ## Requirements
49
+
50
+ - Svelte 5
51
+ - SvelteKit with `remoteFunctions: true` in config
52
+ - Valibot for schema validation
53
+
54
+ ## Installation
55
+
56
+ ```bash
57
+ npm install @samuel-charpentier/sform
58
+ ```
59
+
60
+ Enable remote functions in `svelte.config.js`:
61
+
62
+ ```javascript
63
+ export default {
64
+ kit: {
65
+ experimental: {
66
+ remoteFunctions: true
67
+ }
68
+ }
69
+ };
70
+ ```
71
+
72
+ ## Quick Start
73
+
74
+ ### 1. Create a Remote Form
75
+
76
+ Create a `.remote.ts` file with your form schema and handler:
77
+
78
+ ```typescript
79
+ // src/routes/auth.remote.ts
80
+ import * as v from 'valibot';
81
+ import { form } from '@sveltejs/kit/remote';
82
+
83
+ const loginSchema = v.object({
84
+ username: v.pipe(v.string(), v.minLength(3, 'Username must be at least 3 characters')),
85
+ _password: v.pipe(v.string(), v.minLength(8, 'Password must be at least 8 characters'))
86
+ });
87
+
88
+ export const login = form(loginSchema, async ({ username, _password }) => {
89
+ // Your authentication logic here
90
+ return { success: true, message: 'Welcome!' };
91
+ });
92
+ ```
93
+
94
+ ### 2. Create Your Form Component
95
+
96
+ ```svelte
97
+ <script lang="ts">
98
+ import { Sform, Sfield, Sbutton } from '@samuel-charpentier/sform';
99
+ import { login } from './auth.remote.ts';
100
+ </script>
101
+
102
+ <Sform form={login} validateOn="blur">
103
+ {#snippet children(fields)}
104
+ <Sfield field={fields.username} type="text" label="Username" />
105
+ <Sfield field={fields._password} type="password" label="Password" />
106
+
107
+ <Sbutton form={login} label="Login" />
108
+ {/snippet}
109
+ </Sform>
110
+ ```
111
+
112
+ ## Components
113
+
114
+ ### `<Sform>`
115
+
116
+ Wrapper component that provides form context to all child fields.
117
+
118
+ ```svelte
119
+ <Sform form={remoteForm} validateOn="blur" class="my-form">
120
+ {#snippet children(fields)}
121
+ <!-- Sfield components here -->
122
+ {/snippet}
123
+ </Sform>
124
+ ```
125
+
126
+ | Prop | Type | Default | Description |
127
+ | ---------------- | -------------------------------- | ----------- | -------------------------------------------------------------------- |
128
+ | `form` | `RemoteForm` | required | Remote form object from `form()` API |
129
+ | `validateOn` | `'blur' \| 'change' \| 'submit'` | `'blur'` | When to validate and show errors |
130
+ | `class` | `string` | `undefined` | CSS class for form element |
131
+ | `preflightOnly` | `boolean` | `false` | If true, client side validation is preflight only |
132
+ | `resetOnSuccess` | `boolean` | `true` | If false, keep touched/dirty/submitted state after successful submit |
133
+ | `lifecycle` | `SformLifecycleHooks` | `undefined` | Register lifecycle hooks for submit/validate flow |
134
+ | `disabled` | `boolean` | `false` | Disable the entire form: no validation, no submission, all fields disabled |
135
+
136
+ **Validate Modes:**
137
+
138
+ - `blur` - Validate and show errors after leaving field (default)
139
+ - `change` - Validate and show errors as soon as value changes
140
+ - `submit` - Validate and show all errors only after submit attempt
141
+
142
+ **Disabled Forms:**
143
+
144
+ Setting `disabled` on `<Sform>` freezes the whole form:
145
+
146
+ - All local (preflight) and remote validation is suppressed — no `form.validate()` calls, and the `beforeValidate`/`afterValidateCalled`/`afterValidateSettled` lifecycle hooks don't run.
147
+ - Submission is blocked, even from a custom `<button type="submit">` inside the form; `Sbutton` is automatically disabled.
148
+ - Every child `Sfield` (including `type="hidden"` and the hidden value input backing `type="masked"`) receives the HTML `disabled` attribute and is excluded from the submitted `FormData`. Form-level `disabled` always wins over a field's own `disabled` prop.
149
+ - Validation messages (field-level and `SIssues`) are hidden while disabled. `touched`/`dirty`/`submitted` state is preserved, so previously shown messages reappear unchanged once the form is re-enabled — they are **not** cleared while disabled.
150
+ - The `<form>` element gets `aria-disabled="true"` and `data-disabled` attributes for styling/AT hooks. `inert` is intentionally not applied, since it would also remove links and static content from the accessibility tree.
151
+
152
+ **Lifecycle Hooks:**
153
+
154
+ Use `lifecycle` to run sync/async functions at key points in the form lifecycle:
155
+
156
+ ```svelte
157
+ <Sform
158
+ form={login}
159
+ lifecycle={{
160
+ beforeSubmit: async () => {
161
+ // Mutate values right before Sbutton triggers submit
162
+ login.fields.username.set(login.fields.username.value().trim());
163
+ },
164
+ afterSubmitTriggered: () => {
165
+ console.log('submit requested');
166
+ },
167
+ afterSubmitResponse: () => {
168
+ console.log('submit response received');
169
+ },
170
+ beforeValidate: () => {
171
+ console.log('before validate');
172
+ },
173
+ afterValidateCalled: () => {
174
+ console.log('validate called');
175
+ },
176
+ afterValidateSettled: () => {
177
+ console.log('validate settled');
178
+ }
179
+ }}
180
+ >
181
+ {#snippet children(fields)}
182
+ <Sfield field={fields.username} type="text" />
183
+ {/snippet}
184
+ </Sform>
185
+ ```
186
+
187
+ Hook event names:
188
+
189
+ - `beforeSubmit` - right before submit is triggered by `<Sbutton>` click
190
+ - `afterSubmitTriggered` - right after submit is triggered
191
+ - `afterSubmitResponse` - once pending resolves back to idle
192
+ - `beforeValidate` - immediately before `form.validate()`
193
+ - `afterValidateCalled` - immediately after `form.validate()` is called (validation may still be in flight)
194
+ - `afterValidateSettled` - when `form.validate()` settles (resolved or rejected)
195
+
196
+ Hooks registered for the same event run in parallel. Execution order is not guaranteed, especially when fields mount dynamically.
197
+
198
+ - Keep same-event hooks independent and idempotent.
199
+ - Do not rely on one same-event hook mutating state before another reads it.
200
+
201
+ Validation lifecycle hooks are intended for UX and instrumentation, not security decisions.
202
+
203
+ - Good uses: loading indicators, tracing/metrics, validation timing analytics.
204
+ - Avoid: relying on client-only validation hooks for authorization, policy enforcement, or bypass patterns.
205
+ - Keep authoritative checks on server submit handlers and schemas.
206
+
207
+ If you need to keep field touched/dirty/submitted state after a successful submit response:
208
+
209
+ ```svelte
210
+ <Sform form={login} resetOnSuccess={false}>
211
+ {#snippet children(fields)}
212
+ <Sfield field={fields.username} type="text" />
213
+ {/snippet}
214
+ </Sform>
215
+ ```
216
+
217
+ ### `<Sfield>`
218
+
219
+ Smart field component with type-safe props based on input type.
220
+
221
+ #### Common Props (all types)
222
+
223
+ | Prop | Type | Default | Description |
224
+ | ------------- | ------------------------- | ----------- | ------------------------------------- |
225
+ | `field` | `RemoteFormField` | required | Field from `fields` snippet parameter |
226
+ | `type` | `InputType` | required | Input type |
227
+ | `label` | `string` | `undefined` | Field label |
228
+ | `placeholder` | `string` | `undefined` | Placeholder text (text/password/etc) |
229
+ | `disabled` | `boolean` | `false` | Disable the field |
230
+ | `readonly` | `boolean` | `false` | Make field readonly |
231
+ | `validateOn` | `ValidateOn` | inherited | Override form validateOn |
232
+ | `class` | `SfieldClasses \| string` | `undefined` | CSS classes |
233
+ | `hint` | `string \| Snippet` | `undefined` | Help text shown below the field |
234
+ | `lifecycle` | `SformLifecycleHooks` | `undefined` | Register field-scoped lifecycle hooks |
235
+
236
+ #### Text Inputs
237
+
238
+ ```svelte
239
+ <Sfield field={fields.email} type="email" label="Email" placeholder="you@example.com" />
240
+ <Sfield field={fields.search} type="search" label="Search" />
241
+ <Sfield field={fields.phone} type="tel" label="Phone" />
242
+ <Sfield field={fields.website} type="url" label="Website" prefix="https://" />
243
+ ```
244
+
245
+ Supported text types: `text`, `email`, `tel`, `url`, `search`, `date`, `datetime-local`, `time`, `month`, `week`, `color`, `file`
246
+
247
+ | Prop | Type | Default | Description |
248
+ | -------- | ------------------- | ----------- | -------------------- |
249
+ | `prefix` | `string \| Snippet` | `undefined` | Content before input |
250
+ | `suffix` | `string \| Snippet` | `undefined` | Content after input |
251
+
252
+ #### Password Input
253
+
254
+ ```svelte
255
+ <Sfield field={fields._password} type="password" label="Password" />
256
+ <Sfield field={fields._password} type="password" label="Password" showToggle={false} />
257
+ <Sfield field={fields._password} type="password" label="Password">
258
+ {#snippet showToggleIcon(passwordShown)}
259
+ {#if passwordShown}
260
+ 🙈
261
+ {:else}
262
+ 👁️
263
+ {/if}
264
+ {/snippet}
265
+ </Sfield>
266
+ ```
267
+
268
+ | Prop | Type | Default | Description |
269
+ | ---------------- | ----------------------------------- | ----------- | ---------------------------------- |
270
+ | `showToggle` | `boolean` | `true` | Show eye icon to toggle visibility |
271
+ | `showToggleIcon` | `Snippet<[passwordShown: boolean]>` | `undefined` | Custom toggle icon snippet |
272
+ | `prefix` | `string \| Snippet` | `undefined` | Content before input |
273
+ | `suffix` | `string \| Snippet` | `undefined` | Content after input |
274
+ | `autocomplete` | `string` | `undefined` | HTML autocomplete attribute |
275
+
276
+ #### Number Input
277
+
278
+ ```svelte
279
+ <Sfield field={fields.age} type="number" label="Age" min={0} max={150} step={1} />
280
+ <Sfield field={fields.price} type="number" label="Price" prefix="$" suffix="USD" align="end" />
281
+ <Sfield field={fields.quantity} type="number" label="Qty" showControls={false} maxDecimals={0} />
282
+ ```
283
+
284
+ | Prop | Type | Default | Description |
285
+ | -------------- | ------------------- | ----------- | -------------------------------------- |
286
+ | `min` | `number \| string` | `undefined` | Minimum value |
287
+ | `max` | `number \| string` | `undefined` | Maximum value |
288
+ | `step` | `number \| string` | `undefined` | Step increment |
289
+ | `prefix` | `string \| Snippet` | `undefined` | Content before input (e.g., "$") |
290
+ | `suffix` | `string \| Snippet` | `undefined` | Content after input (e.g., "USD") |
291
+ | `showControls` | `boolean` | `true` | Show spinner controls |
292
+ | `align` | `'start' \| 'end'` | `'start'` | Text alignment |
293
+ | `maxDecimals` | `number` | `undefined` | Max decimal places (0 = integers only) |
294
+ | `autocomplete` | `string` | `undefined` | HTML autocomplete attribute |
295
+
296
+ #### Textarea
297
+
298
+ ```svelte
299
+ <Sfield field={fields.bio} type="textarea" label="Bio" placeholder="Tell us about yourself" />
300
+ <Sfield field={fields.notes} type="textarea" label="Notes" prefix="📝" suffix="(max 500 chars)" />
301
+ ```
302
+
303
+ | Prop | Type | Default | Description |
304
+ | -------- | ------------------- | ----------- | -------------------- |
305
+ | `prefix` | `string \| Snippet` | `undefined` | Content before input |
306
+ | `suffix` | `string \| Snippet` | `undefined` | Content after input |
307
+
308
+ #### Select
309
+
310
+ ```svelte
311
+ <Sfield
312
+ field={fields.country}
313
+ type="select"
314
+ label="Country"
315
+ options={[
316
+ { value: 'us', label: 'United States' },
317
+ { value: 'uk', label: 'United Kingdom' },
318
+ { value: 'ca', label: 'Canada' }
319
+ ]}
320
+ />
321
+ ```
322
+
323
+ | Prop | Type | Default | Description |
324
+ | -------------- | ---------------------------- | ----------- | --------------------------- |
325
+ | `options` | `SelectOption[] \| string[]` | required | Select options |
326
+ | `autocomplete` | `string` | `undefined` | HTML autocomplete attribute |
327
+
328
+ #### Checkbox
329
+
330
+ ```svelte
331
+ <Sfield field={fields.subscribe} type="checkbox" label="Subscribe to newsletter" />
332
+ ```
333
+
334
+ #### Radio
335
+
336
+ ```svelte
337
+ <Sfield
338
+ field={fields.plan}
339
+ type="radio"
340
+ label="Plan"
341
+ options={[
342
+ { value: 'free', label: 'Free' },
343
+ { value: 'pro', label: 'Pro' },
344
+ { value: 'enterprise', label: 'Enterprise' }
345
+ ]}
346
+ />
347
+ ```
348
+
349
+ | Prop | Type | Default | Description |
350
+ | --------- | ---------------------------- | ----------- | ------------------------ |
351
+ | `options` | `SelectOption[] \| string[]` | `undefined` | Radio options for groups |
352
+
353
+ #### Range
354
+
355
+ ```svelte
356
+ <Sfield field={fields.volume} type="range" label="Volume" min={0} max={100} step={5} showValue />
357
+ <Sfield
358
+ field={fields.brightness}
359
+ type="range"
360
+ label="Brightness"
361
+ min={0}
362
+ max={100}
363
+ formatValue={(v) => `${v}%`}
364
+ showValue
365
+ />
366
+ ```
367
+
368
+ | Prop | Type | Default | Description |
369
+ | -------------- | --------------------------- | ----------- | --------------------------- |
370
+ | `min` | `number \| string` | `0` | Minimum value |
371
+ | `max` | `number \| string` | `100` | Maximum value |
372
+ | `step` | `number \| string` | `1` | Step increment |
373
+ | `showValue` | `boolean` | `false` | Show current value |
374
+ | `formatValue` | `(value: number) => string` | `undefined` | Format displayed value |
375
+ | `autocomplete` | `string` | `undefined` | HTML autocomplete attribute |
376
+
377
+ #### Toggle
378
+
379
+ ```svelte
380
+ <Sfield field={fields.notifications} type="toggle" label="Enable Notifications" />
381
+ <Sfield field={fields.darkMode} type="toggle" label="Theme" onLabel="Dark" offLabel="Light" />
382
+ ```
383
+
384
+ | Prop | Type | Default | Description |
385
+ | ---------------- | -------- | ----------- | -------------------- |
386
+ | `onLabel` | `string` | `undefined` | Label when on |
387
+ | `offLabel` | `string` | `undefined` | Label when off |
388
+ | `checkedValue` | `string` | `'true'` | Value when checked |
389
+ | `uncheckedValue` | `string` | `'false'` | Value when unchecked |
390
+
391
+ #### Masked Input
392
+
393
+ ```svelte
394
+ <Sfield field={fields.phone} type="masked" label="Phone" mask="(###) ###-####" />
395
+ <Sfield field={fields.creditCard} type="masked" label="Credit Card" mask="#### #### #### ####" />
396
+ <Sfield field={fields.ssn} type="masked" label="SSN" mask="###-##-####" />
397
+ <!-- Custom tokens -->
398
+ <Sfield
399
+ field={fields.code}
400
+ type="masked"
401
+ label="Code"
402
+ mask="AAAA-99-LL"
403
+ tokens={{ A: /[A-Z]/, L: /[a-z]/ }}
404
+ />
405
+ ```
406
+
407
+ | Prop | Type | Default | Description |
408
+ | --------------------- | ------------------------ | ----------- | -------------------------------- |
409
+ | `mask` | `string` | required | Mask pattern |
410
+ | `tokens` | `Record<string, RegExp>` | `undefined` | Custom token definitions |
411
+ | `maskPlaceholder` | `string` | `'_'` | Placeholder character |
412
+ | `showMaskPlaceholder` | `boolean` | `false` | Show full mask with placeholders |
413
+ | `unmaskValue` | `boolean` | `true` | Store unmasked value |
414
+ | `prefix` | `string \| Snippet` | `undefined` | Content before input |
415
+ | `suffix` | `string \| Snippet` | `undefined` | Content after input |
416
+ | `autocomplete` | `string` | `undefined` | HTML autocomplete attribute |
417
+
418
+ **Mask Tokens:**
419
+
420
+ - `#` or `9` - Numeric (0-9)
421
+ - `a` - Alphabetic (a-z, A-Z)
422
+ - `A` - Alphabetic uppercase
423
+ - `*` - Alphanumeric
424
+
425
+ #### Hidden Input
426
+
427
+ ```svelte
428
+ <Sfield field={fields.token} type="hidden" value={authToken} />
429
+ <Sfield field={fields.userId} type="hidden" value="12345" />
430
+ ```
431
+
432
+ | Prop | Type | Default | Description |
433
+ | ---------- | --------- | ------- | ------------------------------------------------ |
434
+ | `value` | `string` | `''` | The value for the hidden field (can be reactive) |
435
+ | `disabled` | `boolean` | `false` | Disables the hidden input so it is not submitted |
436
+
437
+ 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:
438
+
439
+ ```svelte
440
+ <script lang="ts">
441
+ let token = $state(initialToken);
442
+
443
+ async function refreshToken() {
444
+ token = await getNewToken();
445
+ }
446
+ </script>
447
+
448
+ <Sfield field={fields.token} type="hidden" value={token} />
449
+ ```
450
+
451
+ You can disable a hidden field until it is ready to be submitted. This is useful for client-only tokens that should not be sent by an SSR/no-JS form:
452
+
453
+ ```svelte
454
+ <Sfield field={fields.recaptcha} type="hidden" value={token} disabled={!clientReady} />
455
+ ```
456
+
457
+ #### Field issue ownership
458
+
459
+ By default, visible fields render their own issues and hidden fields leave their issues to `<SIssues>`. Use `issueDisplay` when a field needs different ownership:
460
+
461
+ ```svelte
462
+ <!-- Render issues next to this field, even if it is hidden -->
463
+ <Sfield field={fields.token} type="hidden" value={token} issueDisplay="field" />
464
+
465
+ <!-- Leave this field's issues for <SIssues> -->
466
+ <Sfield field={fields.email} type="email" label="Email" issueDisplay="form" />
467
+
468
+ <!-- Mark issues as handled without rendering them -->
469
+ <Sfield field={fields.recaptcha} type="hidden" value={token} issueDisplay="none" />
470
+ ```
471
+
472
+ | Value | Behavior |
473
+ | ------- | --------------------------------------------------------------------------------------- |
474
+ | `auto` | Default. Visible fields render their issues; hidden fields leave issues to `<SIssues>`. |
475
+ | `field` | The field renders its own issues. |
476
+ | `form` | The field leaves its issues to `<SIssues>`. |
477
+ | `none` | The field marks issues as handled without rendering them anywhere. |
478
+
479
+ ### `<Sbutton>`
480
+
481
+ Stateful submit button that reacts to form state. Pass the `form` prop to enable typed result access.
482
+
483
+ ```svelte
484
+ <Sbutton form={myForm} label="Submit" class="my-button" />
485
+
486
+ <!-- With custom state rendering -->
487
+ <Sbutton form={myForm} class="submit-btn">
488
+ {#snippet children(state)}
489
+ {#if state.state === 'pending'}
490
+ Submitting...
491
+ {:else if state.state === 'success'}
492
+ {state.result.message}
493
+ {:else if state.state === 'hasIssues'}
494
+ Fix Errors
495
+ {:else}
496
+ Submit Form
497
+ {/if}
498
+ {/snippet}
499
+ </Sbutton>
500
+ ```
501
+
502
+ The `state` parameter is a discriminated union of type `ButtonState<T>` where `T` is inferred from the form's result type:
503
+
504
+ ```typescript
505
+ type ButtonState<T = unknown> =
506
+ | { state: 'default'; pending: false; success: false; hasIssues: false; result: undefined }
507
+ | { state: 'pending'; pending: true; success: false; hasIssues: false; result: undefined }
508
+ | { state: 'success'; pending: false; success: true; hasIssues: false; result: T }
509
+ | { state: 'hasIssues'; pending: false; success: false; hasIssues: true; result: undefined };
510
+ ```
511
+
512
+ #### Typed Result Access
513
+
514
+ The result type is automatically inferred from the `form` prop. When your remote function returns a typed result, you can access it directly:
515
+
516
+ ```svelte
517
+ <script lang="ts">
518
+ import { login } from './auth.remote'; // Returns { success: boolean; message: string }
519
+ </script>
520
+
521
+ <Sbutton form={login} class="submit-btn">
522
+ {#snippet children(state)}
523
+ {#if state.state === 'success'}
524
+ {state.result.message} <!-- TypeScript knows this is string -->
525
+ {:else if state.state === 'pending'}
526
+ Logging in...
527
+ {:else}
528
+ Login
529
+ {/if}
530
+ {/snippet}
531
+ </Sbutton>
532
+ ```
533
+
534
+ | Prop | Type | Default | Description |
535
+ | ------------ | ------------------------------------------- | ----------- | ----------------------------------------------------- |
536
+ | `form` | `RemoteForm` | required | Remote form for type inference |
537
+ | `label` | `string \| Snippet<[ButtonState<T>]>` | `'Submit'` | Button text, or a snippet with typed state |
538
+ | `buttonType` | `'submit' \| 'reset' \| 'button'` | `'submit'` | Button type |
539
+ | `class` | `string` | `undefined` | CSS class |
540
+ | `disabled` | `boolean` | `false` | Disable button |
541
+ | `children` | `Snippet<[ButtonState<T>]>` | `undefined` | Custom content with typed state; takes precedence over `label` |
542
+ | `onsubmit` | `() => void \| Promise<void>` | `undefined` | Callback before lifecycle `beforeSubmit` and submit |
543
+
544
+ `children` and `label` (as a snippet) both receive the same typed `ButtonState<T>` and render identically; `children` wins if both are provided.
545
+
546
+ ### `<SIssues>`
547
+
548
+ Displays form-level issues and issues not shown by any Sfield component (e.g., hidden field issues or programmatic validation via `invalid()`).
549
+
550
+ ```svelte
551
+ <SIssues message="There are some issues with your form:" />
552
+
553
+ <!-- With custom message snippet -->
554
+ <SIssues>
555
+ {#snippet message()}
556
+ <strong>⚠️ Please fix the following issues:</strong>
557
+ {/snippet}
558
+ </SIssues>
559
+ ```
560
+
561
+ | Prop | Type | Default | Description |
562
+ | ----------- | ------------------- | --------------------- | --------------------------------- |
563
+ | `message` | `string \| Snippet` | `undefined` | General message shown when issues |
564
+ | `class` | `string` | `'sform-issues'` | CSS class for wrapper |
565
+ | `listClass` | `string` | `'sform-issues-list'` | CSS class for issues list |
566
+
567
+ The component filters issues to only show:
568
+
569
+ - Form-level issues (from `invalid("message")`)
570
+ - Field issues for hidden inputs (no Sfield displays them)
571
+ - Issues for fields without a corresponding Sfield
572
+
573
+ ### `<SResult>`
574
+
575
+ 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.
576
+
577
+ ```svelte
578
+ <SResult form={myLogin} class="sform-result sform-result-success">
579
+ {#snippet children(result)}
580
+ {result.message}
581
+ {/snippet}
582
+ </SResult>
583
+ ```
584
+
585
+ The `result` parameter is typed based on your remote function's return type:
586
+
587
+ ```svelte
588
+ <script lang="ts">
589
+ import { login } from './auth.remote'; // Returns { success: boolean; message: string }
590
+ </script>
591
+
592
+ <SResult form={login} class="success-message">
593
+ {#snippet children(result)}
594
+ <!-- TypeScript knows result is { success: boolean; message: string } -->
595
+ <h2>Welcome!</h2>
596
+ <p>{result.message}</p>
597
+ {/snippet}
598
+ </SResult>
599
+ ```
600
+
601
+ | Prop | Type | Default | Description |
602
+ | ---------- | -------------- | ----------- | ------------------------------ |
603
+ | `form` | `RemoteForm` | required | Remote form for type inference |
604
+ | `children` | `Snippet<[T]>` | required | Content with typed result |
605
+ | `class` | `string` | `undefined` | CSS class for wrapper |
606
+
607
+ The component only renders when `form.result !== undefined`, so the `result` parameter in the children snippet is guaranteed to be defined.
608
+
609
+ ## Styling
610
+
611
+ ### CSS Classes
612
+
613
+ Sfield adds these classes automatically:
614
+
615
+ - `.sform-field` - Wrapper element
616
+ - `.sform-label` - Label element
617
+ - `.sform-input` - Input element
618
+ - `.sform-messages` - Error messages container
619
+ - `.sform-field-error` - Added to wrapper when field has errors
620
+
621
+ ### Custom Classes
622
+
623
+ ```svelte
624
+ <!-- String class applies to wrapper -->
625
+ <Sfield field={fields.email} type="email" class="my-field" />
626
+
627
+ <!-- Object for granular control -->
628
+ <Sfield
629
+ field={fields.email}
630
+ type="email"
631
+ class={{
632
+ wrapper: 'field-wrapper',
633
+ label: 'field-label',
634
+ input: 'field-input',
635
+ messages: 'field-errors'
636
+ }}
637
+ />
638
+ ```
639
+
640
+ ## Validation
641
+
642
+ Sform uses preflight validation with Valibot schemas. Native browser validation (required, minlength, pattern) is disabled to allow showing all errors at once on submit.
643
+
644
+ ### Schema Example
645
+
646
+ ```typescript
647
+ import * as v from 'valibot';
648
+
649
+ const signupSchema = v.object({
650
+ email: v.pipe(v.string(), v.email('Please enter a valid email')),
651
+ _password: v.pipe(
652
+ v.string(),
653
+ v.minLength(8, 'Password must be at least 8 characters'),
654
+ v.regex(/[A-Z]/, 'Password must contain an uppercase letter'),
655
+ v.regex(/[0-9]/, 'Password must contain a number')
656
+ ),
657
+ age: v.pipe(v.number(), v.minValue(18, 'Must be at least 18 years old'))
658
+ });
659
+ ```
660
+
661
+ ## Type Safety
662
+
663
+ Sform uses TypeScript discriminated unions to provide type-safe props for each input type:
664
+
665
+ ```typescript
666
+ // ✅ TypeScript knows 'showToggle' is only valid for password type
667
+ <Sfield field={fields._password} type="password" showToggle={false} />
668
+
669
+ // ✅ TypeScript knows 'options' is required for select type
670
+ <Sfield field={fields.country} type="select" options={countries} />
671
+
672
+ // ✅ TypeScript knows 'min', 'max', 'step' are valid for number type
673
+ <Sfield field={fields.age} type="number" min={0} max={150} />
674
+
675
+ // ❌ TypeScript error: 'showToggle' doesn't exist on text type
676
+ <Sfield field={fields.username} type="text" showToggle />
677
+ ```
678
+
679
+ ## Development
680
+
681
+ ```bash
682
+ # Install dependencies
683
+ npm install
684
+
685
+ # Start dev server
686
+ npm run dev
687
+
688
+ # Run tests
689
+ npm test
690
+
691
+ # Build library
692
+ npm run package
693
+ ```
694
+
695
+ ## License
696
+
697
+ MIT