@samuel-charpentier/sform 0.0.2 → 0.0.3

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
@@ -2,6 +2,39 @@
2
2
 
3
3
  A type-safe form library for **Svelte 5** with **SvelteKit remote functions**.
4
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
+ - [Toggle Options](#toggle-options)
27
+ - [Masked Input](#masked-input)
28
+ - [Hidden Input](#hidden-input)
29
+ - [`<Sbutton>`](#sbutton)
30
+ - [`<SIssues>`](#sissues)
31
+ - [`<SResult>`](#sresult)
32
+ - [Styling](#styling)
33
+ - [Validation](#validation)
34
+ - [Type Safety](#type-safety)
35
+ - [Development](#development)
36
+ - [License](#license)
37
+
5
38
  ## Features
6
39
 
7
40
  - ✅ **Type-safe** - Discriminated union types for each input type
@@ -69,10 +102,12 @@ export const login = form(loginSchema, async ({ username, _password }) => {
69
102
  </script>
70
103
 
71
104
  <Sform form={login} validateOn="blur">
72
- <Sfield name="username" type="text" label="Username" />
73
- <Sfield name="_password" type="password" label="Password" />
105
+ {#snippet children(fields)}
106
+ <Sfield field={fields.username} type="text" label="Username" />
107
+ <Sfield field={fields._password} type="password" label="Password" />
74
108
 
75
- <Sbutton label="Login" />
109
+ <Sbutton form={login} label="Login" />
110
+ {/snippet}
76
111
  </Sform>
77
112
  ```
78
113
 
@@ -84,7 +119,9 @@ Wrapper component that provides form context to all child fields.
84
119
 
85
120
  ```svelte
86
121
  <Sform form={remoteForm} validateOn="blur" class="my-form">
87
- <!-- Sfield components here -->
122
+ {#snippet children(fields)}
123
+ <!-- Sfield components here -->
124
+ {/snippet}
88
125
  </Sform>
89
126
  ```
90
127
 
@@ -106,27 +143,28 @@ Smart field component with type-safe props based on input type.
106
143
 
107
144
  #### Common Props (all types)
108
145
 
109
- | Prop | Type | Default | Description |
110
- | ------------- | ------------------------- | ----------- | ------------------------------ |
111
- | `name` | `string` | required | Field name (must match schema) |
112
- | `type` | `InputType` | required | Input type |
113
- | `label` | `string` | `undefined` | Field label |
114
- | `placeholder` | `string` | `undefined` | Placeholder text |
115
- | `disabled` | `boolean` | `false` | Disable the field |
116
- | `readonly` | `boolean` | `false` | Make field readonly |
117
- | `validateOn` | `ValidateOn` | inherited | Override form validateOn |
118
- | `class` | `SfieldClasses \| string` | `undefined` | CSS classes |
146
+ | Prop | Type | Default | Description |
147
+ | ------------- | ------------------------- | ----------- | ------------------------------------- |
148
+ | `field` | `RemoteFormField` | required | Field from `fields` snippet parameter |
149
+ | `type` | `InputType` | required | Input type |
150
+ | `label` | `string` | `undefined` | Field label |
151
+ | `placeholder` | `string` | `undefined` | Placeholder text (text/password/etc) |
152
+ | `disabled` | `boolean` | `false` | Disable the field |
153
+ | `readonly` | `boolean` | `false` | Make field readonly |
154
+ | `validateOn` | `ValidateOn` | inherited | Override form validateOn |
155
+ | `class` | `SfieldClasses \| string` | `undefined` | CSS classes |
156
+ | `hint` | `string \| Snippet` | `undefined` | Help text shown below the field |
119
157
 
120
158
  #### Text Inputs
121
159
 
122
160
  ```svelte
123
- <Sfield name="email" type="email" label="Email" placeholder="you@example.com" />
124
- <Sfield name="search" type="search" label="Search" />
125
- <Sfield name="phone" type="tel" label="Phone" />
126
- <Sfield name="website" type="url" label="Website" prefix="https://" />
161
+ <Sfield field={fields.email} type="email" label="Email" placeholder="you@example.com" />
162
+ <Sfield field={fields.search} type="search" label="Search" />
163
+ <Sfield field={fields.phone} type="tel" label="Phone" />
164
+ <Sfield field={fields.website} type="url" label="Website" prefix="https://" />
127
165
  ```
128
166
 
129
- Supported text types: `text`, `email`, `tel`, `url`, `search`, `date`, `datetime-local`, `time`, `month`, `week`, `color`, `file`, `hidden`
167
+ Supported text types: `text`, `email`, `tel`, `url`, `search`, `date`, `datetime-local`, `time`, `month`, `week`, `color`, `file`
130
168
 
131
169
  | Prop | Type | Default | Description |
132
170
  | -------- | ------------------- | ----------- | -------------------- |
@@ -136,20 +174,32 @@ Supported text types: `text`, `email`, `tel`, `url`, `search`, `date`, `datetime
136
174
  #### Password Input
137
175
 
138
176
  ```svelte
139
- <Sfield name="_password" type="password" label="Password" />
140
- <Sfield name="_password" type="password" label="Password" showToggle={false} />
177
+ <Sfield field={fields._password} type="password" label="Password" />
178
+ <Sfield field={fields._password} type="password" label="Password" showToggle={false} />
179
+ <Sfield field={fields._password} type="password" label="Password">
180
+ {#snippet showToggleIcon(passwordShown)}
181
+ {#if passwordShown}
182
+ 🙈
183
+ {:else}
184
+ 👁️
185
+ {/if}
186
+ {/snippet}
187
+ </Sfield>
141
188
  ```
142
189
 
143
- | Prop | Type | Default | Description |
144
- | ------------ | --------- | ------- | ---------------------------------- |
145
- | `showToggle` | `boolean` | `true` | Show eye icon to toggle visibility |
190
+ | Prop | Type | Default | Description |
191
+ | ---------------- | ----------------------------------- | ----------- | ---------------------------------- |
192
+ | `showToggle` | `boolean` | `true` | Show eye icon to toggle visibility |
193
+ | `showToggleIcon` | `Snippet<[passwordShown: boolean]>` | `undefined` | Custom toggle icon snippet |
194
+ | `prefix` | `string \| Snippet` | `undefined` | Content before input |
195
+ | `suffix` | `string \| Snippet` | `undefined` | Content after input |
146
196
 
147
197
  #### Number Input
148
198
 
149
199
  ```svelte
150
- <Sfield name="age" type="number" label="Age" min={0} max={150} step={1} />
151
- <Sfield name="price" type="number" label="Price" prefix="$" suffix="USD" align="end" />
152
- <Sfield name="quantity" type="number" label="Qty" showControls={false} maxDecimals={0} />
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} />
153
203
  ```
154
204
 
155
205
  | Prop | Type | Default | Description |
@@ -162,18 +212,26 @@ Supported text types: `text`, `email`, `tel`, `url`, `search`, `date`, `datetime
162
212
  | `showControls` | `boolean` | `true` | Show spinner controls |
163
213
  | `align` | `'start' \| 'end'` | `'start'` | Text alignment |
164
214
  | `maxDecimals` | `number` | `undefined` | Max decimal places (0 = integers only) |
215
+ | `autocomplete` | `string` | `undefined` | HTML autocomplete attribute |
165
216
 
166
217
  #### Textarea
167
218
 
168
219
  ```svelte
169
- <Sfield name="bio" type="textarea" label="Bio" placeholder="Tell us about yourself" />
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)" />
170
222
  ```
171
223
 
224
+ | Prop | Type | Default | Description |
225
+ | -------------- | ------------------- | ----------- | --------------------------- |
226
+ | `prefix` | `string \| Snippet` | `undefined` | Content before input |
227
+ | `suffix` | `string \| Snippet` | `undefined` | Content after input |
228
+ | `autocomplete` | `string` | `undefined` | HTML autocomplete attribute |
229
+
172
230
  #### Select
173
231
 
174
232
  ```svelte
175
233
  <Sfield
176
- name="country"
234
+ field={fields.country}
177
235
  type="select"
178
236
  label="Country"
179
237
  options={[
@@ -191,14 +249,14 @@ Supported text types: `text`, `email`, `tel`, `url`, `search`, `date`, `datetime
191
249
  #### Checkbox
192
250
 
193
251
  ```svelte
194
- <Sfield name="subscribe" type="checkbox" label="Subscribe to newsletter" />
252
+ <Sfield field={fields.subscribe} type="checkbox" label="Subscribe to newsletter" />
195
253
  ```
196
254
 
197
255
  #### Radio
198
256
 
199
257
  ```svelte
200
258
  <Sfield
201
- name="plan"
259
+ field={fields.plan}
202
260
  type="radio"
203
261
  label="Plan"
204
262
  options={[
@@ -216,7 +274,16 @@ Supported text types: `text`, `email`, `tel`, `url`, `search`, `date`, `datetime
216
274
  #### Range
217
275
 
218
276
  ```svelte
219
- <Sfield name="volume" type="range" label="Volume" min={0} max={100} step={5} showValue />
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
+ />
220
287
  ```
221
288
 
222
289
  | Prop | Type | Default | Description |
@@ -230,8 +297,8 @@ Supported text types: `text`, `email`, `tel`, `url`, `search`, `date`, `datetime
230
297
  #### Toggle
231
298
 
232
299
  ```svelte
233
- <Sfield name="notifications" type="toggle" label="Enable Notifications" />
234
- <Sfield name="darkMode" type="toggle" label="Theme" onLabel="Dark" offLabel="Light" />
300
+ <Sfield field={fields.notifications} type="toggle" label="Enable Notifications" />
301
+ <Sfield field={fields.darkMode} type="toggle" label="Theme" onLabel="Dark" offLabel="Light" />
235
302
  ```
236
303
 
237
304
  | Prop | Type | Default | Description |
@@ -245,7 +312,7 @@ Supported text types: `text`, `email`, `tel`, `url`, `search`, `date`, `datetime
245
312
 
246
313
  ```svelte
247
314
  <Sfield
248
- name="theme"
315
+ field={fields.theme}
249
316
  type="toggle-options"
250
317
  label="Theme"
251
318
  options={[
@@ -254,6 +321,18 @@ Supported text types: `text`, `email`, `tel`, `url`, `search`, `date`, `datetime
254
321
  { value: 'auto', label: 'Auto' }
255
322
  ]}
256
323
  />
324
+ <!-- Multiple selection -->
325
+ <Sfield
326
+ field={fields.features}
327
+ type="toggle-options"
328
+ label="Features"
329
+ multiple={true}
330
+ options={[
331
+ { value: 'push', label: 'Push Notifications' },
332
+ { value: 'email', label: 'Email' },
333
+ { value: 'sms', label: 'SMS' }
334
+ ]}
335
+ />
257
336
  ```
258
337
 
259
338
  | Prop | Type | Default | Description |
@@ -264,19 +343,28 @@ Supported text types: `text`, `email`, `tel`, `url`, `search`, `date`, `datetime
264
343
  #### Masked Input
265
344
 
266
345
  ```svelte
267
- <Sfield name="phone" type="masked" label="Phone" mask="(###) ###-####" />
268
- <Sfield name="creditCard" type="masked" label="Credit Card" mask="#### #### #### ####" />
269
- <Sfield name="ssn" type="masked" label="SSN" mask="###-##-####" />
346
+ <Sfield field={fields.phone} type="masked" label="Phone" mask="(###) ###-####" />
347
+ <Sfield field={fields.creditCard} type="masked" label="Credit Card" mask="#### #### #### ####" />
348
+ <Sfield field={fields.ssn} type="masked" label="SSN" mask="###-##-####" />
349
+ <!-- Custom tokens -->
350
+ <Sfield
351
+ field={fields.code}
352
+ type="masked"
353
+ label="Code"
354
+ mask="AAAA-99-LL"
355
+ tokens={{ A: /[A-Z]/, L: /[a-z]/ }}
356
+ />
270
357
  ```
271
358
 
272
- | Prop | Type | Default | Description |
273
- | --------------------- | ------------------- | ----------- | -------------------------------- |
274
- | `mask` | `string` | required | Mask pattern |
275
- | `maskPlaceholder` | `string` | `'_'` | Placeholder character |
276
- | `showMaskPlaceholder` | `boolean` | `false` | Show full mask with placeholders |
277
- | `unmaskValue` | `boolean` | `true` | Store unmasked value |
278
- | `prefix` | `string \| Snippet` | `undefined` | Content before input |
279
- | `suffix` | `string \| Snippet` | `undefined` | Content after input |
359
+ | Prop | Type | Default | Description |
360
+ | --------------------- | ------------------------ | ----------- | -------------------------------- |
361
+ | `mask` | `string` | required | Mask pattern |
362
+ | `tokens` | `Record<string, RegExp>` | `undefined` | Custom token definitions |
363
+ | `maskPlaceholder` | `string` | `'_'` | Placeholder character |
364
+ | `showMaskPlaceholder` | `boolean` | `false` | Show full mask with placeholders |
365
+ | `unmaskValue` | `boolean` | `true` | Store unmasked value |
366
+ | `prefix` | `string \| Snippet` | `undefined` | Content before input |
367
+ | `suffix` | `string \| Snippet` | `undefined` | Content after input |
280
368
 
281
369
  **Mask Tokens:**
282
370
 
@@ -285,41 +373,158 @@ Supported text types: `text`, `email`, `tel`, `url`, `search`, `date`, `datetime
285
373
  - `A` - Alphabetic uppercase
286
374
  - `*` - Alphanumeric
287
375
 
376
+ #### Hidden Input
377
+
378
+ ```svelte
379
+ <Sfield field={fields.token} type="hidden" value={authToken} />
380
+ <Sfield field={fields.userId} type="hidden" value="12345" />
381
+ ```
382
+
383
+ | Prop | Type | Default | Description |
384
+ | ------- | -------- | ------- | ------------------------------------------------ |
385
+ | `value` | `string` | `''` | The value for the hidden field (can be reactive) |
386
+
387
+ 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:
388
+
389
+ ```svelte
390
+ <script lang="ts">
391
+ let token = $state(initialToken);
392
+
393
+ async function refreshToken() {
394
+ token = await getNewToken();
395
+ }
396
+ </script>
397
+
398
+ <Sfield field={fields.token} type="hidden" value={token} />
399
+ ```
400
+
288
401
  ### `<Sbutton>`
289
402
 
290
- Stateful submit button that reacts to form state.
403
+ Stateful submit button that reacts to form state. Pass the `form` prop to enable typed result access.
404
+
405
+ ```svelte
406
+ <Sbutton form={myForm} label="Submit" class="my-button" />
407
+
408
+ <!-- With custom state rendering -->
409
+ <Sbutton form={myForm} class="submit-btn">
410
+ {#snippet children(state)}
411
+ {#if state.state === 'pending'}
412
+ Submitting...
413
+ {:else if state.state === 'success'}
414
+ ✓ {state.result.message}
415
+ {:else if state.state === 'hasIssues'}
416
+ Fix Errors
417
+ {:else}
418
+ Submit Form
419
+ {/if}
420
+ {/snippet}
421
+ </Sbutton>
422
+ ```
423
+
424
+ The `state` parameter is a discriminated union of type `ButtonState<T>` where `T` is inferred from the form's result type:
425
+
426
+ ```typescript
427
+ type ButtonState<T = unknown> =
428
+ | { state: 'default'; pending: false; success: false; hasIssues: false; result: undefined }
429
+ | { state: 'pending'; pending: true; success: false; hasIssues: false; result: undefined }
430
+ | { state: 'success'; pending: false; success: true; hasIssues: false; result: T }
431
+ | { state: 'hasIssues'; pending: false; success: false; hasIssues: true; result: undefined };
432
+ ```
433
+
434
+ #### Typed Result Access
435
+
436
+ The result type is automatically inferred from the `form` prop. When your remote function returns a typed result, you can access it directly:
291
437
 
292
438
  ```svelte
293
- <Sbutton label="Submit" class="my-button" />
439
+ <script lang="ts">
440
+ import { login } from './auth.remote'; // Returns { success: boolean; message: string }
441
+ </script>
294
442
 
295
- <!-- With custom state snippets -->
296
- <Sbutton class="submit-btn">
297
- {#snippet defaultState(state)}
298
- Submit Form
443
+ <Sbutton form={login} class="submit-btn">
444
+ {#snippet children(state)}
445
+ {#if state.state === 'success'}
446
+ {state.result.message} <!-- TypeScript knows this is string -->
447
+ {:else if state.state === 'pending'}
448
+ Logging in...
449
+ {:else}
450
+ Login
451
+ {/if}
299
452
  {/snippet}
300
- {#snippet pendingState(state)}
301
- Submitting...
453
+ </Sbutton>
454
+ ```
455
+
456
+ | Prop | Type | Default | Description |
457
+ | ------------ | --------------------------------- | ----------- | --------------------------------- |
458
+ | `form` | `RemoteForm` | required | Remote form for type inference |
459
+ | `label` | `string` | `'Submit'` | Button text (when no children) |
460
+ | `buttonType` | `'submit' \| 'reset' \| 'button'` | `'submit'` | Button type |
461
+ | `class` | `string` | `undefined` | CSS class |
462
+ | `disabled` | `boolean` | `false` | Disable button |
463
+ | `children` | `Snippet<[ButtonState<T>]>` | `undefined` | Custom content with typed state |
464
+ | `onsubmit` | `() => void \| Promise<void>` | `undefined` | Callback before validation/submit |
465
+
466
+ ### `<SIssues>`
467
+
468
+ Displays form-level issues and issues not shown by any Sfield component (e.g., hidden field issues or programmatic validation via `invalid()`).
469
+
470
+ ```svelte
471
+ <SIssues message="There are some issues with your form:" />
472
+
473
+ <!-- With custom message snippet -->
474
+ <SIssues>
475
+ {#snippet message()}
476
+ <strong>⚠️ Please fix the following issues:</strong>
302
477
  {/snippet}
303
- {#snippet successState(state)}
304
- ✓ Success!
478
+ </SIssues>
479
+ ```
480
+
481
+ | Prop | Type | Default | Description |
482
+ | ----------- | ------------------- | --------------------- | --------------------------------- |
483
+ | `message` | `string \| Snippet` | `undefined` | General message shown when issues |
484
+ | `class` | `string` | `'sform-issues'` | CSS class for wrapper |
485
+ | `listClass` | `string` | `'sform-issues-list'` | CSS class for issues list |
486
+
487
+ The component filters issues to only show:
488
+
489
+ - Form-level issues (from `invalid("message")`)
490
+ - Field issues for hidden inputs (no Sfield displays them)
491
+ - Issues for fields without a corresponding Sfield
492
+
493
+ ### `<SResult>`
494
+
495
+ 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.
496
+
497
+ ```svelte
498
+ <SResult form={myLogin} class="sform-result sform-result-success">
499
+ {#snippet children(result)}
500
+ {result.message}
305
501
  {/snippet}
306
- {#snippet errorState(state)}
307
- Fix Errors
502
+ </SResult>
503
+ ```
504
+
505
+ The `result` parameter is typed based on your remote function's return type:
506
+
507
+ ```svelte
508
+ <script lang="ts">
509
+ import { login } from './auth.remote'; // Returns { success: boolean; message: string }
510
+ </script>
511
+
512
+ <SResult form={login} class="success-message">
513
+ {#snippet children(result)}
514
+ <!-- TypeScript knows result is { success: boolean; message: string } -->
515
+ <h2>Welcome!</h2>
516
+ <p>{result.message}</p>
308
517
  {/snippet}
309
- </Sbutton>
518
+ </SResult>
310
519
  ```
311
520
 
312
- | Prop | Type | Default | Description |
313
- | -------------- | --------------------------------- | ----------- | --------------------------------- |
314
- | `label` | `string` | `'Submit'` | Button text |
315
- | `buttonType` | `'submit' \| 'reset' \| 'button'` | `'submit'` | Button type |
316
- | `class` | `string` | `undefined` | CSS class |
317
- | `disabled` | `boolean` | `false` | Disable button |
318
- | `onsubmit` | `() => void \| Promise<void>` | `undefined` | Callback before validation/submit |
319
- | `defaultState` | `Snippet` | `undefined` | Default state snippet |
320
- | `pendingState` | `Snippet` | `undefined` | Pending state snippet |
321
- | `successState` | `Snippet` | `undefined` | Success state snippet |
322
- | `errorState` | `Snippet` | `undefined` | Error state snippet |
521
+ | Prop | Type | Default | Description |
522
+ | ---------- | -------------- | ----------- | ------------------------------ |
523
+ | `form` | `RemoteForm` | required | Remote form for type inference |
524
+ | `children` | `Snippet<[T]>` | required | Content with typed result |
525
+ | `class` | `string` | `undefined` | CSS class for wrapper |
526
+
527
+ The component only renders when `form.result !== undefined`, so the `result` parameter in the children snippet is guaranteed to be defined.
323
528
 
324
529
  ## Styling
325
530
 
@@ -337,11 +542,11 @@ Sfield adds these classes automatically:
337
542
 
338
543
  ```svelte
339
544
  <!-- String class applies to wrapper -->
340
- <Sfield name="email" type="email" class="my-field" />
545
+ <Sfield field={fields.email} type="email" class="my-field" />
341
546
 
342
547
  <!-- Object for granular control -->
343
548
  <Sfield
344
- name="email"
549
+ field={fields.email}
345
550
  type="email"
346
551
  class={{
347
552
  wrapper: 'field-wrapper',
@@ -379,16 +584,16 @@ Sform uses TypeScript discriminated unions to provide type-safe props for each i
379
584
 
380
585
  ```typescript
381
586
  // ✅ TypeScript knows 'showToggle' is only valid for password type
382
- <Sfield name="_password" type="password" showToggle={false} />
587
+ <Sfield field={fields._password} type="password" showToggle={false} />
383
588
 
384
589
  // ✅ TypeScript knows 'options' is required for select type
385
- <Sfield name="country" type="select" options={countries} />
590
+ <Sfield field={fields.country} type="select" options={countries} />
386
591
 
387
592
  // ✅ TypeScript knows 'min', 'max', 'step' are valid for number type
388
- <Sfield name="age" type="number" min={0} max={150} />
593
+ <Sfield field={fields.age} type="number" min={0} max={150} />
389
594
 
390
595
  // ❌ TypeScript error: 'showToggle' doesn't exist on text type
391
- <Sfield name="username" type="text" showToggle />
596
+ <Sfield field={fields.username} type="text" showToggle />
392
597
  ```
393
598
 
394
599
  ## Development
@@ -0,0 +1,49 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { getSformContext } from './context.svelte.js';
4
+
5
+ interface Props {
6
+ /** General message shown when there are any issues (string or snippet) */
7
+ message?: string | Snippet;
8
+ /** Display message only if */
9
+ showMessageIf?: 'noUnhandledIssue' | 'hasUnhandledIssue' | 'hasAnyIssue';
10
+ /** CSS class for the wrapper */
11
+ class?: string;
12
+ /** CSS class for the issues list */
13
+ listClass?: string;
14
+ }
15
+
16
+ let { message, class: className, listClass, showMessageIf = 'hasAnyIssue' }: Props = $props();
17
+
18
+ const context = getSformContext();
19
+
20
+ // Get unhandled issues from context (issues not displayed by any Sfield)
21
+ const unhandledIssues = $derived(context.getUnhandledIssues());
22
+ const formState = $derived(context.getFormState());
23
+
24
+ // Show issues only after form submission and when there are issues
25
+ const shouldShow = $derived(context.submitted && formState.hasIssues);
26
+ const hasUnhandledIssues = $derived(unhandledIssues.length > 0);
27
+ </script>
28
+
29
+ {#if shouldShow}
30
+ <div class={className ?? 'sform-issues'}>
31
+ {#if message && (showMessageIf === 'hasAnyIssue' || (showMessageIf === 'noUnhandledIssue' && !hasUnhandledIssues) || (showMessageIf === 'hasUnhandledIssue' && hasUnhandledIssues))}
32
+ <div class="sform-issues-message">
33
+ {#if typeof message === 'string'}
34
+ {message}
35
+ {:else}
36
+ {@render message()}
37
+ {/if}
38
+ </div>
39
+ {/if}
40
+
41
+ {#if hasUnhandledIssues}
42
+ <ul class={listClass ?? 'sform-issues-list'}>
43
+ {#each unhandledIssues as issue, i (i)}
44
+ <li>{issue.message}</li>
45
+ {/each}
46
+ </ul>
47
+ {/if}
48
+ </div>
49
+ {/if}
@@ -0,0 +1,14 @@
1
+ import type { Snippet } from 'svelte';
2
+ interface Props {
3
+ /** General message shown when there are any issues (string or snippet) */
4
+ message?: string | Snippet;
5
+ /** Display message only if */
6
+ showMessageIf?: 'noUnhandledIssue' | 'hasUnhandledIssue' | 'hasAnyIssue';
7
+ /** CSS class for the wrapper */
8
+ class?: string;
9
+ /** CSS class for the issues list */
10
+ listClass?: string;
11
+ }
12
+ declare const SIssues: import("svelte").Component<Props, {}, "">;
13
+ type SIssues = ReturnType<typeof SIssues>;
14
+ export default SIssues;
@@ -0,0 +1,36 @@
1
+ <script lang="ts" generics="T = unknown">
2
+ import type { Snippet } from 'svelte';
3
+ import type { RemoteFormIssue } from './types.js';
4
+
5
+ /**
6
+ * Minimal form shape needed for type inference.
7
+ * This allows the component to infer T from the form's result type.
8
+ */
9
+ interface FormLike<Output> {
10
+ result?: Output;
11
+ fields: {
12
+ allIssues?: () => RemoteFormIssue[] | undefined;
13
+ [key: string]: unknown;
14
+ };
15
+ }
16
+
17
+ interface Props {
18
+ /** The remote form - used to infer the result type T */
19
+ form: FormLike<T>;
20
+ /** Children snippet receives the result (guaranteed to be defined) */
21
+ children: Snippet<[T]>;
22
+ /** CSS class for the wrapper */
23
+ class?: string;
24
+ }
25
+
26
+ let { form, children, class: className }: Props = $props();
27
+
28
+ // Only show when there's a result
29
+ const hasResult = $derived(form.result !== undefined);
30
+ </script>
31
+
32
+ {#if hasResult}
33
+ <div class={className}>
34
+ {@render children(form.result as T)}
35
+ </div>
36
+ {/if}
@@ -0,0 +1,39 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { RemoteFormIssue } from './types.js';
3
+ declare function $$render<T = unknown>(): {
4
+ props: {
5
+ /** The remote form - used to infer the result type T */
6
+ form: {
7
+ result?: T | undefined;
8
+ fields: {
9
+ allIssues?: () => RemoteFormIssue[] | undefined;
10
+ [key: string]: unknown;
11
+ };
12
+ };
13
+ /** Children snippet receives the result (guaranteed to be defined) */
14
+ children: Snippet<[T]>;
15
+ /** CSS class for the wrapper */
16
+ class?: string;
17
+ };
18
+ exports: {};
19
+ bindings: "";
20
+ slots: {};
21
+ events: {};
22
+ };
23
+ declare class __sveltets_Render<T = unknown> {
24
+ props(): ReturnType<typeof $$render<T>>['props'];
25
+ events(): ReturnType<typeof $$render<T>>['events'];
26
+ slots(): ReturnType<typeof $$render<T>>['slots'];
27
+ bindings(): "";
28
+ exports(): {};
29
+ }
30
+ interface $$IsomorphicComponent {
31
+ new <T = unknown>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
32
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
33
+ } & ReturnType<__sveltets_Render<T>['exports']>;
34
+ <T = unknown>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
35
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
36
+ }
37
+ declare const SResult: $$IsomorphicComponent;
38
+ type SResult<T = unknown> = InstanceType<typeof SResult<T>>;
39
+ export default SResult;
@@ -18,6 +18,7 @@
18
18
  import ToggleOptionsInput from './inputs/ToggleOptionsInput.svelte';
19
19
  import MaskedInput from './inputs/MaskedInput.svelte';
20
20
  import PasswordInput from './inputs/PasswordInput.svelte';
21
+ import HiddenInput from './inputs/HiddenInput.svelte';
21
22
 
22
23
  /**
23
24
  * Sfield - Type-safe form field component.
@@ -41,6 +42,10 @@
41
42
  // Register this field with the context on mount
42
43
  $effect(() => {
43
44
  context.registerField(name);
45
+ // Register for issue display tracking (all types except hidden display their issues)
46
+ if (props.type !== 'hidden') {
47
+ context.registerFieldWithIssueDisplay(name);
48
+ }
44
49
  });
45
50
 
46
51
  const classes: SfieldClasses = $derived(
@@ -112,7 +117,6 @@
112
117
  'month',
113
118
  'week',
114
119
  'color',
115
- 'hidden',
116
120
  'file'
117
121
  ].includes(props.type)
118
122
  );
@@ -147,6 +151,8 @@
147
151
  <ToggleOptionsInput {...passthroughProps()} {...internalProps} />
148
152
  {:else if props.type === 'masked'}
149
153
  <MaskedInput {...passthroughProps()} {...internalProps} />
154
+ {:else if props.type === 'hidden'}
155
+ <HiddenInput {...passthroughProps()} {...internalProps} />
150
156
  {/if}
151
157
 
152
158
  {#if props.hint}
@@ -60,7 +60,8 @@
60
60
  () => {
61
61
  // Use setTimeout to ensure we're outside the current event loop
62
62
  setTimeout(() => formElement?.requestSubmit(), 0);
63
- }
63
+ },
64
+ () => form
64
65
  );
65
66
 
66
67
  // Apply preflight schema if provided
@@ -1,3 +1,12 @@
1
- import type { SformContext, ValidateOn } from './types.js';
2
- export declare function createSformContext(getValidateOn: () => ValidateOn, getFieldNames: () => string[], triggerValidation: () => void, submitForm: () => void): SformContext;
1
+ import type { RemoteFormIssue, SformContext, ValidateOn } from './types.js';
2
+ interface FormLike {
3
+ pending?: number;
4
+ result?: unknown;
5
+ fields: {
6
+ allIssues?: () => RemoteFormIssue[] | undefined;
7
+ [key: string]: unknown;
8
+ };
9
+ }
10
+ export declare function createSformContext(getValidateOn: () => ValidateOn, getFieldNames: () => string[], triggerValidation: () => void, submitForm: () => void, getForm: () => FormLike): SformContext;
3
11
  export declare function getSformContext(): SformContext;
12
+ export {};
@@ -1,10 +1,11 @@
1
1
  import { getContext, setContext } from 'svelte';
2
2
  import { SvelteSet } from 'svelte/reactivity';
3
3
  const SFORM_CONTEXT_KEY = Symbol('sform-context');
4
- export function createSformContext(getValidateOn, getFieldNames, triggerValidation, submitForm) {
4
+ export function createSformContext(getValidateOn, getFieldNames, triggerValidation, submitForm, getForm) {
5
5
  const touched = new SvelteSet();
6
6
  const dirty = new SvelteSet();
7
7
  const registeredFields = new SvelteSet();
8
+ const fieldsWithIssueDisplay = new SvelteSet();
8
9
  let submitted = $state(false);
9
10
  const context = {
10
11
  get validateOn() {
@@ -51,12 +52,71 @@ export function createSformContext(getValidateOn, getFieldNames, triggerValidati
51
52
  registerField: (name) => {
52
53
  registeredFields.add(name);
53
54
  },
55
+ registerFieldWithIssueDisplay: (name) => {
56
+ fieldsWithIssueDisplay.add(name);
57
+ },
54
58
  resetFieldStates: () => {
55
59
  touched.clear();
56
60
  dirty.clear();
57
61
  submitted = false;
58
62
  },
59
- submitForm
63
+ submitForm,
64
+ getFormState: () => {
65
+ const form = getForm();
66
+ const pending = (form.pending ?? 0) !== 0;
67
+ const hasResult = form.result !== undefined;
68
+ const issues = form.fields.allIssues?.() ?? [];
69
+ const hasIssues = issues.length > 0;
70
+ if (pending) {
71
+ return {
72
+ state: 'pending',
73
+ pending: true,
74
+ success: false,
75
+ hasIssues: false,
76
+ result: undefined
77
+ };
78
+ }
79
+ if (hasResult) {
80
+ // If there's a result, it's success (issues come via invalid() which throws, no result)
81
+ return {
82
+ state: 'success',
83
+ pending: false,
84
+ success: true,
85
+ hasIssues: false,
86
+ result: form.result
87
+ };
88
+ }
89
+ if (hasIssues) {
90
+ return {
91
+ state: 'hasIssues',
92
+ pending: false,
93
+ success: false,
94
+ hasIssues: true,
95
+ result: undefined
96
+ };
97
+ }
98
+ return {
99
+ state: 'default',
100
+ pending: false,
101
+ success: false,
102
+ hasIssues: false,
103
+ result: undefined
104
+ };
105
+ },
106
+ getUnhandledIssues: () => {
107
+ const form = getForm();
108
+ const allIssues = form.fields.allIssues?.() ?? [];
109
+ // Filter out issues that are linked to fields with issue display
110
+ return allIssues.filter((issue) => {
111
+ // If issue has no path, it's a form-level issue (from invalid("message"))
112
+ if (!issue.path || issue.path.length === 0) {
113
+ return true;
114
+ }
115
+ // Check if the first path segment is a field that displays issues
116
+ const fieldName = String(issue.path[0]);
117
+ return !fieldsWithIssueDisplay.has(fieldName);
118
+ });
119
+ }
60
120
  };
61
121
  setContext(SFORM_CONTEXT_KEY, context);
62
122
  return context;
@@ -1,5 +1,7 @@
1
1
  export { default as Sform } from './Sform.svelte';
2
2
  export { default as Sfield } from './Sfield.svelte';
3
3
  export { default as Sbutton } from './inputs/ButtonInput.svelte';
4
+ export { default as SIssues } from './SIssues.svelte';
5
+ export { default as SResult } from './SResult.svelte';
4
6
  export { applyMask, unmask, MASK_PATTERNS, DEFAULT_TOKENS, type MaskOptions, type MaskResult, type MaskToken, type MaskPattern } from './utils/mask.js';
5
- export type { ValidateOn, FieldState, SfieldClasses, InputType, SelectOption, SformContext, SformProps, ButtonFormState, ButtonInputProps, InputAffixProps, RangeInputProps, ToggleInputProps, ToggleOption, ToggleOptionsInputProps, SfieldTypeMap, AllowedSfieldType, TypedSfieldProps, SfieldBaseProps, TypedBaseSfieldProps, SfieldTextProps, SfieldPasswordProps, SfieldNumberProps, SfieldTextareaProps, SfieldSelectProps, SfieldCheckboxProps, SfieldCheckboxGroupProps, SfieldRadioProps, SfieldRangeProps, SfieldToggleProps, SfieldToggleOptionsProps, SfieldMaskedProps, RemoteForm, RemoteFormField, RemoteFormFields, RemoteFormFieldValue, RemoteFormInput, RemoteFormIssue } from './types.js';
7
+ export type { ValidateOn, FieldState, SfieldClasses, InputType, SelectOption, SformContext, SformProps, ButtonState, ButtonFormState, ButtonInputProps, SIssuesProps, InputAffixProps, RangeInputProps, ToggleInputProps, ToggleOption, ToggleOptionsInputProps, SfieldTypeMap, AllowedSfieldType, TypedSfieldProps, SfieldBaseProps, TypedBaseSfieldProps, SfieldTextProps, SfieldPasswordProps, SfieldNumberProps, SfieldTextareaProps, SfieldSelectProps, SfieldCheckboxProps, SfieldCheckboxGroupProps, SfieldRadioProps, SfieldRangeProps, SfieldToggleProps, SfieldToggleOptionsProps, SfieldMaskedProps, RemoteForm, RemoteFormField, RemoteFormFields, RemoteFormFieldValue, RemoteFormInput, RemoteFormIssue } from './types.js';
@@ -3,5 +3,7 @@ export { default as Sform } from './Sform.svelte';
3
3
  export { default as Sfield } from './Sfield.svelte';
4
4
  // Standalone components (not routed through Sfield)
5
5
  export { default as Sbutton } from './inputs/ButtonInput.svelte';
6
+ export { default as SIssues } from './SIssues.svelte';
7
+ export { default as SResult } from './SResult.svelte';
6
8
  // Utilities
7
9
  export { applyMask, unmask, MASK_PATTERNS, DEFAULT_TOKENS } from './utils/mask.js';
@@ -1,20 +1,25 @@
1
- <script lang="ts">
1
+ <script lang="ts" generics="T = unknown">
2
2
  import type { Snippet } from 'svelte';
3
- import type { ButtonFormState, RemoteFormIssue } from '../types.js';
3
+ import type { ButtonState, RemoteFormIssue } from '../types.js';
4
4
  import { getSformContext } from '../context.svelte.js';
5
5
 
6
- interface FormLike {
6
+ /**
7
+ * Minimal form shape needed for type inference.
8
+ * This allows the component to infer T from the form's result type.
9
+ */
10
+ interface FormLike<Output> {
11
+ result?: Output;
7
12
  pending?: number;
8
- result?: unknown;
9
13
  fields: {
10
14
  allIssues?: () => RemoteFormIssue[] | undefined;
15
+ [key: string]: unknown;
11
16
  };
12
17
  }
13
18
 
14
19
  interface Props {
15
- /** The remote form - required for button state */
16
- form: FormLike;
17
- /** Button text (used if no snippets provided) */
20
+ /** The remote form - used to infer the result type T */
21
+ form: FormLike<T>;
22
+ /** Button text (used if no children snippet provided) */
18
23
  label?: string;
19
24
  /** Button type */
20
25
  buttonType?: 'submit' | 'reset' | 'button';
@@ -22,55 +27,37 @@
22
27
  class?: string;
23
28
  /** Whether button is disabled */
24
29
  disabled?: boolean;
30
+ /** Children snippet receives ButtonState<T> for custom rendering with typed result */
31
+ children?: Snippet<[ButtonState<T>]>;
25
32
  /** Callback that runs before validation/submission (can be async) */
26
33
  onsubmit?: () => void | Promise<void>;
27
- /** Snippet for default state */
28
- defaultState?: Snippet<[ButtonFormState]>;
29
- /** Snippet for pending state */
30
- pendingState?: Snippet<[ButtonFormState]>;
31
- /** Snippet for success state */
32
- successState?: Snippet<[ButtonFormState]>;
33
- /** Snippet for error/issues state */
34
- errorState?: Snippet<[ButtonFormState]>;
35
34
  }
36
35
 
37
36
  let {
38
- form,
39
37
  label = 'Submit',
40
38
  buttonType = 'submit',
41
39
  class: className,
42
40
  disabled = false,
43
- onsubmit,
44
- defaultState,
45
- pendingState,
46
- successState,
47
- errorState
41
+ children,
42
+ onsubmit
48
43
  }: Props = $props();
49
44
 
50
- // Get Sform context to trigger validation display
45
+ // Get Sform context for form state and actions
51
46
  const sformContext = getSformContext();
52
47
 
53
- const formState: ButtonFormState = $derived.by(() => {
54
- const pending = (form.pending ?? 0) !== 0;
55
- const hasResult = form.result !== undefined;
56
- const issues = form.fields.allIssues?.() ?? [];
57
- const hasIssues = issues.length > 0;
58
-
59
- return {
60
- pending,
61
- success: hasResult && !hasIssues && !pending,
62
- hasIssues,
63
- result: form.result
64
- };
65
- });
48
+ // Get form state from context with the generic type
49
+ const formState = $derived.by(sformContext.getFormState<T>);
66
50
 
67
51
  const isDisabled = $derived(disabled || formState.pending);
68
52
 
69
53
  async function handleClick(event: MouseEvent) {
70
- if (buttonType !== 'submit' || !onsubmit) return;
54
+ if (buttonType !== 'submit') return;
71
55
 
72
56
  event.preventDefault();
73
- await onsubmit();
57
+
58
+ if (onsubmit) {
59
+ await onsubmit();
60
+ }
74
61
 
75
62
  // Mark form as submitted and all fields dirty so issues display when server responds
76
63
  sformContext.markSubmitted();
@@ -82,14 +69,8 @@
82
69
  </script>
83
70
 
84
71
  <button type={buttonType} class={className} disabled={isDisabled} onclick={handleClick}>
85
- {#if formState.pending && pendingState}
86
- {@render pendingState(formState)}
87
- {:else if formState.success && successState}
88
- {@render successState(formState)}
89
- {:else if formState.hasIssues && errorState}
90
- {@render errorState(formState)}
91
- {:else if defaultState}
92
- {@render defaultState(formState)}
72
+ {#if children}
73
+ {@render children(formState)}
93
74
  {:else}
94
75
  {label}
95
76
  {/if}
@@ -1,34 +1,48 @@
1
1
  import type { Snippet } from 'svelte';
2
- import type { ButtonFormState, RemoteFormIssue } from '../types.js';
3
- interface FormLike {
4
- pending?: number;
5
- result?: unknown;
6
- fields: {
7
- allIssues?: () => RemoteFormIssue[] | undefined;
2
+ import type { ButtonState, RemoteFormIssue } from '../types.js';
3
+ declare function $$render<T = unknown>(): {
4
+ props: {
5
+ /** The remote form - used to infer the result type T */
6
+ form: {
7
+ result?: T | undefined;
8
+ pending?: number;
9
+ fields: {
10
+ allIssues?: () => RemoteFormIssue[] | undefined;
11
+ [key: string]: unknown;
12
+ };
13
+ };
14
+ /** Button text (used if no children snippet provided) */
15
+ label?: string;
16
+ /** Button type */
17
+ buttonType?: "submit" | "reset" | "button";
18
+ /** Button class */
19
+ class?: string;
20
+ /** Whether button is disabled */
21
+ disabled?: boolean;
22
+ /** Children snippet receives ButtonState<T> for custom rendering with typed result */
23
+ children?: Snippet<[ButtonState<T>]>;
24
+ /** Callback that runs before validation/submission (can be async) */
25
+ onsubmit?: () => void | Promise<void>;
8
26
  };
27
+ exports: {};
28
+ bindings: "";
29
+ slots: {};
30
+ events: {};
31
+ };
32
+ declare class __sveltets_Render<T = unknown> {
33
+ props(): ReturnType<typeof $$render<T>>['props'];
34
+ events(): ReturnType<typeof $$render<T>>['events'];
35
+ slots(): ReturnType<typeof $$render<T>>['slots'];
36
+ bindings(): "";
37
+ exports(): {};
9
38
  }
10
- interface Props {
11
- /** The remote form - required for button state */
12
- form: FormLike;
13
- /** Button text (used if no snippets provided) */
14
- label?: string;
15
- /** Button type */
16
- buttonType?: 'submit' | 'reset' | 'button';
17
- /** Button class */
18
- class?: string;
19
- /** Whether button is disabled */
20
- disabled?: boolean;
21
- /** Callback that runs before validation/submission (can be async) */
22
- onsubmit?: () => void | Promise<void>;
23
- /** Snippet for default state */
24
- defaultState?: Snippet<[ButtonFormState]>;
25
- /** Snippet for pending state */
26
- pendingState?: Snippet<[ButtonFormState]>;
27
- /** Snippet for success state */
28
- successState?: Snippet<[ButtonFormState]>;
29
- /** Snippet for error/issues state */
30
- errorState?: Snippet<[ButtonFormState]>;
39
+ interface $$IsomorphicComponent {
40
+ new <T = unknown>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
41
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
42
+ } & ReturnType<__sveltets_Render<T>['exports']>;
43
+ <T = unknown>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
44
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
31
45
  }
32
- declare const ButtonInput: import("svelte").Component<Props, {}, "">;
33
- type ButtonInput = ReturnType<typeof ButtonInput>;
46
+ declare const ButtonInput: $$IsomorphicComponent;
47
+ type ButtonInput<T = unknown> = InstanceType<typeof ButtonInput<T>>;
34
48
  export default ButtonInput;
@@ -0,0 +1,10 @@
1
+ <script lang="ts">
2
+ import type { HiddenInputProps } from '../types.js';
3
+
4
+ let { field, name, value }: HiddenInputProps = $props();
5
+
6
+ // Hidden fields require the value to be passed to field.as('hidden', value)
7
+ const fieldAttrs = $derived(field.as('hidden', value ?? ''));
8
+ </script>
9
+
10
+ <input {...fieldAttrs} type="hidden" id={name} />
@@ -0,0 +1,4 @@
1
+ import type { HiddenInputProps } from '../types.js';
2
+ declare const HiddenInput: import("svelte").Component<HiddenInputProps, {}, "">;
3
+ type HiddenInput = ReturnType<typeof HiddenInput>;
4
+ export default HiddenInput;
@@ -28,34 +28,30 @@
28
28
  let inputElement: HTMLInputElement | undefined = $state();
29
29
  </script>
30
30
 
31
- {#if type !== 'hidden'}
32
- {#if label}
33
- <label class={labelClass} for={name}>{label}</label>
34
- {/if}
35
- <div class="sform-input-wrapper {wrapperClass ?? ''}">
36
- {#if prefix}
37
- <div class="sform-prefix" onclick={() => inputElement?.focus()} role="presentation">
38
- {#if typeof prefix === 'function'}{@render prefix()}{:else}{prefix}{/if}
39
- </div>
40
- {/if}
41
- <input
42
- bind:this={inputElement}
43
- {...fieldAttrs}
44
- id={name}
45
- class={className}
46
- {placeholder}
47
- {disabled}
48
- {readonly}
49
- {autocomplete}
50
- {onblur}
51
- {oninput}
52
- />
53
- {#if suffix}
54
- <div class="sform-suffix" onclick={() => inputElement?.focus()} role="presentation">
55
- {#if typeof suffix === 'function'}{@render suffix()}{:else}{suffix}{/if}
56
- </div>
57
- {/if}
58
- </div>
59
- {:else}
60
- <input {...fieldAttrs} type="hidden" id={name} />
31
+ {#if label}
32
+ <label class={labelClass} for={name}>{label}</label>
61
33
  {/if}
34
+ <div class="sform-input-wrapper {wrapperClass ?? ''}">
35
+ {#if prefix}
36
+ <div class="sform-prefix" onclick={() => inputElement?.focus()} role="presentation">
37
+ {#if typeof prefix === 'function'}{@render prefix()}{:else}{prefix}{/if}
38
+ </div>
39
+ {/if}
40
+ <input
41
+ bind:this={inputElement}
42
+ {...fieldAttrs}
43
+ id={name}
44
+ class={className}
45
+ {placeholder}
46
+ {disabled}
47
+ {readonly}
48
+ {autocomplete}
49
+ {onblur}
50
+ {oninput}
51
+ />
52
+ {#if suffix}
53
+ <div class="sform-suffix" onclick={() => inputElement?.focus()} role="presentation">
54
+ {#if typeof suffix === 'function'}{@render suffix()}{:else}{suffix}{/if}
55
+ </div>
56
+ {/if}
57
+ </div>
@@ -600,6 +600,7 @@ select.sform-input {
600
600
  .sform-result-success {
601
601
  color: #065f46;
602
602
  background: var(--sform-success-bg);
603
+ margin-block-start: 1rem;
603
604
  }
604
605
 
605
606
  .sform-result-error {
@@ -612,6 +613,19 @@ select.sform-input {
612
613
  background: var(--sform-warning-bg);
613
614
  }
614
615
 
616
+ /* ===========================================
617
+ Issues List
618
+ =========================================== */
619
+
620
+ .sform-issues-message {
621
+ font-weight: bold;
622
+ margin-bottom: 0.25rem;
623
+ }
624
+ .sform-issues-list {
625
+ margin: 0;
626
+ padding-inline-start: 1.25rem;
627
+ }
628
+
615
629
  /* ===========================================
616
630
  Utilities
617
631
  =========================================== */
@@ -82,7 +82,7 @@ export type AllowedSfieldType<T> = {
82
82
  */
83
83
  export type InputType = 'text' | 'email' | 'password' | 'number' | 'tel' | 'url' | 'search' | 'date' | 'datetime-local' | 'time' | 'month' | 'week' | 'color' | 'textarea' | 'select' | 'checkbox' | 'checkbox-group' | 'radio' | 'file' | 'hidden' | 'range' | 'toggle' | 'toggle-options' | 'masked';
84
84
  /** Text-like input types */
85
- export type TextInputType = 'text' | 'email' | 'tel' | 'url' | 'search' | 'date' | 'datetime-local' | 'time' | 'month' | 'week' | 'color' | 'file' | 'hidden';
85
+ export type TextInputType = 'text' | 'email' | 'tel' | 'url' | 'search' | 'date' | 'datetime-local' | 'time' | 'month' | 'week' | 'color' | 'file';
86
86
  /** Numeric input types */
87
87
  export type NumericInputType = 'number';
88
88
  /**
@@ -105,6 +105,37 @@ export type RemoteForm<Input extends RemoteFormInput | void = RemoteFormInput, O
105
105
  * The .for(id) method returns Omit<RemoteForm, 'for'>, so this type accepts both.
106
106
  */
107
107
  export type RemoteFormInstance<Input extends RemoteFormInput | void = RemoteFormInput, Output = unknown> = SvelteKitRemoteForm<Input, Output> | Omit<SvelteKitRemoteForm<Input, Output>, 'for'>;
108
+ /**
109
+ * Form state for buttons and state-aware components.
110
+ * State is derived from context - no result means default or pending,
111
+ * result with no issues means success, issues (via invalid()) means hasIssues.
112
+ * @template T - The type of the form result (defaults to unknown)
113
+ */
114
+ export type ButtonState<T = unknown> = {
115
+ state: 'default';
116
+ pending: false;
117
+ success: false;
118
+ hasIssues: false;
119
+ result: undefined;
120
+ } | {
121
+ state: 'pending';
122
+ pending: true;
123
+ success: false;
124
+ hasIssues: false;
125
+ result: undefined;
126
+ } | {
127
+ state: 'success';
128
+ pending: false;
129
+ success: true;
130
+ hasIssues: false;
131
+ result: T;
132
+ } | {
133
+ state: 'hasIssues';
134
+ pending: false;
135
+ success: false;
136
+ hasIssues: true;
137
+ result: undefined;
138
+ };
108
139
  /**
109
140
  * Form context provided by Sform to children.
110
141
  * Manages field state (touched, dirty, submitted) for validation display.
@@ -132,8 +163,14 @@ export interface SformContext {
132
163
  resetFieldStates: () => void;
133
164
  /** Register a field name (called by Sfield on mount) */
134
165
  registerField: (name: string) => void;
166
+ /** Register a field that displays its own issues (all except hidden) */
167
+ registerFieldWithIssueDisplay: (name: string) => void;
135
168
  /** Programmatically submit the form */
136
169
  submitForm: () => void;
170
+ /** Get the current form state (for buttons and state-aware components) */
171
+ getFormState: <T = unknown>() => ButtonState<T>;
172
+ /** Get issues that are not displayed by any Sfield component */
173
+ getUnhandledIssues: () => RemoteFormIssue[];
137
174
  }
138
175
  /**
139
176
  * Enhance callback options - typed based on form Input type.
@@ -365,8 +402,10 @@ export type SfieldToggleProps = SfieldPropsFrom<ToggleInputProps, boolean, 'togg
365
402
  export type SfieldToggleOptionsProps = SfieldPropsFrom<ToggleOptionsInputProps, string, 'toggle-options'>;
366
403
  /** Props for masked input */
367
404
  export type SfieldMaskedProps = SfieldPropsFrom<MaskedInputProps, string, 'masked'>;
405
+ /** Props for hidden input */
406
+ export type SfieldHiddenProps = SfieldPropsFrom<HiddenInputProps, string, 'hidden'>;
368
407
  /** All possible Sfield props as a discriminated union */
369
- type AllSfieldProps = SfieldTextProps | SfieldPasswordProps | SfieldNumberProps | SfieldTextareaProps | SfieldSelectProps | SfieldCheckboxProps | SfieldCheckboxGroupProps | SfieldRadioProps | SfieldRangeProps | SfieldToggleProps | SfieldToggleOptionsProps | SfieldMaskedProps;
408
+ type AllSfieldProps = SfieldTextProps | SfieldPasswordProps | SfieldNumberProps | SfieldTextareaProps | SfieldSelectProps | SfieldCheckboxProps | SfieldCheckboxGroupProps | SfieldRadioProps | SfieldRangeProps | SfieldToggleProps | SfieldToggleOptionsProps | SfieldMaskedProps | SfieldHiddenProps;
370
409
  /**
371
410
  * Extract props that match a specific field value type.
372
411
  * This ensures type="number" is only valid for number fields, etc.
@@ -515,7 +554,18 @@ export interface RadioInputProps extends BaseInputComponentProps {
515
554
  options: SelectOption[] | string[];
516
555
  }
517
556
  /**
518
- * Form state for button snippets
557
+ * Props for hidden input component
558
+ */
559
+ export interface HiddenInputProps {
560
+ /** Remote field from form */
561
+ field: RemoteFormField<RemoteFormFieldValue>;
562
+ /** Field name */
563
+ name: string;
564
+ /** The value for the hidden field - required for hidden inputs */
565
+ value?: string;
566
+ }
567
+ /**
568
+ * @deprecated Use ButtonState instead
519
569
  */
520
570
  export interface ButtonFormState {
521
571
  /** Whether form is submitting */
@@ -527,11 +577,26 @@ export interface ButtonFormState {
527
577
  /** The form result if available */
528
578
  result: unknown;
529
579
  }
580
+ /**
581
+ * Minimal form shape for Sbutton type inference.
582
+ * Allows the button to infer the result type T from the form.
583
+ */
584
+ export interface ButtonFormLike<T = unknown> {
585
+ result?: T;
586
+ pending?: number;
587
+ fields: {
588
+ allIssues?: () => RemoteFormIssue[] | undefined;
589
+ [key: string]: unknown;
590
+ };
591
+ }
530
592
  /**
531
593
  * Props for button input component
594
+ * @template T - The type of the form result (inferred from form prop)
532
595
  */
533
- export interface ButtonInputProps {
534
- /** Button text (used if no snippets provided) */
596
+ export interface ButtonInputProps<T = unknown> {
597
+ /** The remote form - used to infer the result type T */
598
+ form: ButtonFormLike<T>;
599
+ /** Button text (used if no children snippet provided) */
535
600
  label?: string;
536
601
  /** Button type */
537
602
  buttonType?: 'submit' | 'reset' | 'button';
@@ -539,16 +604,21 @@ export interface ButtonInputProps {
539
604
  class?: string;
540
605
  /** Whether button is disabled */
541
606
  disabled?: boolean;
542
- /** Snippet for default state */
543
- defaultState?: Snippet;
544
- /** Snippet for pending state */
545
- pendingState?: Snippet;
546
- /** Snippet for success state */
547
- successState?: Snippet;
548
- /** Snippet for error/issues state */
549
- errorState?: Snippet;
550
- /** Access to form state for custom rendering */
551
- formState?: ButtonFormState;
607
+ /** Children snippet receives ButtonState<T> for custom rendering with typed result */
608
+ children?: Snippet<[ButtonState<T>]>;
609
+ /** Callback that runs before validation/submission (can be async) */
610
+ onsubmit?: () => void | Promise<void>;
611
+ }
612
+ /**
613
+ * Props for SIssues component
614
+ */
615
+ export interface SIssuesProps {
616
+ /** General message shown when there are any issues */
617
+ message?: string | Snippet;
618
+ /** CSS class for the wrapper */
619
+ class?: string;
620
+ /** CSS class for the issues list */
621
+ listClass?: string;
552
622
  }
553
623
  /**
554
624
  * Props for range input component
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export { Sform, Sfield, Sbutton } from './Sform/index.js';
1
+ export { Sform, Sfield, Sbutton, SIssues, SResult } from './Sform/index.js';
2
2
  export type { ValidateOn, FieldState, SfieldClasses, InputType, SelectOption, SformContext, SformProps, ButtonFormState, ButtonInputProps, InputAffixProps, SfieldTypeMap, AllowedSfieldType, TypedSfieldProps, SfieldBaseProps, TypedBaseSfieldProps, SfieldTextProps, SfieldPasswordProps, SfieldNumberProps, SfieldTextareaProps, SfieldSelectProps, SfieldCheckboxProps, SfieldCheckboxGroupProps, SfieldRadioProps, SfieldRangeProps, SfieldToggleProps, SfieldToggleOptionsProps, SfieldMaskedProps, RemoteForm, RemoteFormField, RemoteFormFields, RemoteFormFieldValue, RemoteFormInput, RemoteFormIssue } from './Sform/index.js';
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export { Sform, Sfield, Sbutton } from './Sform/index.js';
1
+ export { Sform, Sfield, Sbutton, SIssues, SResult } from './Sform/index.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@samuel-charpentier/sform",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "A Svelte 5 form library for SvelteKit remote forms",
5
5
  "keywords": [
6
6
  "svelte",