@samuel-charpentier/sform 0.0.1 → 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
@@ -23,7 +56,7 @@ A type-safe form library for **Svelte 5** with **SvelteKit remote functions**.
23
56
  ## Installation
24
57
 
25
58
  ```bash
26
- npm install
59
+ npm install @samuel-charpentier/sform
27
60
  ```
28
61
 
29
62
  Enable remote functions in `svelte.config.js`:
@@ -64,15 +97,17 @@ export const login = form(loginSchema, async ({ username, _password }) => {
64
97
 
65
98
  ```svelte
66
99
  <script lang="ts">
67
- import { Sform, Sfield, Sbutton } from '$lib';
100
+ import { Sform, Sfield, Sbutton } from '@samuel-charpentier/sform';
68
101
  import { login } from './auth.remote.ts';
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,62 +143,95 @@ 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" />
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`
168
+
169
+ | Prop | Type | Default | Description |
170
+ | -------- | ------------------- | ----------- | -------------------- |
171
+ | `prefix` | `string \| Snippet` | `undefined` | Content before input |
172
+ | `suffix` | `string \| Snippet` | `undefined` | Content after input |
130
173
 
131
174
  #### Password Input
132
175
 
133
176
  ```svelte
134
- <Sfield name="_password" type="password" label="Password" />
135
- <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>
136
188
  ```
137
189
 
138
- | Prop | Type | Default | Description |
139
- | ------------ | --------- | ------- | ---------------------------------- |
140
- | `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 |
141
196
 
142
197
  #### Number Input
143
198
 
144
199
  ```svelte
145
- <Sfield name="age" type="number" label="Age" min={0} max={150} step={1} />
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} />
146
203
  ```
147
204
 
148
- | Prop | Type | Default | Description |
149
- | ------ | ------------------ | ----------- | -------------- |
150
- | `min` | `number \| string` | `undefined` | Minimum value |
151
- | `max` | `number \| string` | `undefined` | Maximum value |
152
- | `step` | `number \| string` | `undefined` | Step increment |
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 |
153
216
 
154
217
  #### Textarea
155
218
 
156
219
  ```svelte
157
- <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)" />
158
222
  ```
159
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
+
160
230
  #### Select
161
231
 
162
232
  ```svelte
163
233
  <Sfield
164
- name="country"
234
+ field={fields.country}
165
235
  type="select"
166
236
  label="Country"
167
237
  options={[
@@ -179,14 +249,14 @@ Supported text types: `text`, `email`, `tel`, `url`, `search`, `date`, `datetime
179
249
  #### Checkbox
180
250
 
181
251
  ```svelte
182
- <Sfield name="subscribe" type="checkbox" label="Subscribe to newsletter" />
252
+ <Sfield field={fields.subscribe} type="checkbox" label="Subscribe to newsletter" />
183
253
  ```
184
254
 
185
255
  #### Radio
186
256
 
187
257
  ```svelte
188
258
  <Sfield
189
- name="plan"
259
+ field={fields.plan}
190
260
  type="radio"
191
261
  label="Plan"
192
262
  options={[
@@ -204,7 +274,16 @@ Supported text types: `text`, `email`, `tel`, `url`, `search`, `date`, `datetime
204
274
  #### Range
205
275
 
206
276
  ```svelte
207
- <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
+ />
208
287
  ```
209
288
 
210
289
  | Prop | Type | Default | Description |
@@ -218,8 +297,8 @@ Supported text types: `text`, `email`, `tel`, `url`, `search`, `date`, `datetime
218
297
  #### Toggle
219
298
 
220
299
  ```svelte
221
- <Sfield name="notifications" type="toggle" label="Enable Notifications" />
222
- <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" />
223
302
  ```
224
303
 
225
304
  | Prop | Type | Default | Description |
@@ -233,7 +312,7 @@ Supported text types: `text`, `email`, `tel`, `url`, `search`, `date`, `datetime
233
312
 
234
313
  ```svelte
235
314
  <Sfield
236
- name="theme"
315
+ field={fields.theme}
237
316
  type="toggle-options"
238
317
  label="Theme"
239
318
  options={[
@@ -242,6 +321,18 @@ Supported text types: `text`, `email`, `tel`, `url`, `search`, `date`, `datetime
242
321
  { value: 'auto', label: 'Auto' }
243
322
  ]}
244
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
+ />
245
336
  ```
246
337
 
247
338
  | Prop | Type | Default | Description |
@@ -252,17 +343,28 @@ Supported text types: `text`, `email`, `tel`, `url`, `search`, `date`, `datetime
252
343
  #### Masked Input
253
344
 
254
345
  ```svelte
255
- <Sfield name="phone" type="masked" label="Phone" mask="(###) ###-####" />
256
- <Sfield name="creditCard" type="masked" label="Credit Card" mask="#### #### #### ####" />
257
- <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
+ />
258
357
  ```
259
358
 
260
- | Prop | Type | Default | Description |
261
- | --------------------- | --------- | -------- | -------------------------------- |
262
- | `mask` | `string` | required | Mask pattern |
263
- | `maskPlaceholder` | `string` | `'_'` | Placeholder character |
264
- | `showMaskPlaceholder` | `boolean` | `false` | Show full mask with placeholders |
265
- | `storeRaw` | `boolean` | `true` | Store unmasked value |
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 |
266
368
 
267
369
  **Mask Tokens:**
268
370
 
@@ -271,40 +373,158 @@ Supported text types: `text`, `email`, `tel`, `url`, `search`, `date`, `datetime
271
373
  - `A` - Alphabetic uppercase
272
374
  - `*` - Alphanumeric
273
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
+
274
401
  ### `<Sbutton>`
275
402
 
276
- 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:
277
437
 
278
438
  ```svelte
279
- <Sbutton label="Submit" class="my-button" />
439
+ <script lang="ts">
440
+ import { login } from './auth.remote'; // Returns { success: boolean; message: string }
441
+ </script>
280
442
 
281
- <!-- With custom state snippets -->
282
- <Sbutton class="submit-btn">
283
- {#snippet defaultState(state)}
284
- 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}
285
452
  {/snippet}
286
- {#snippet pendingState(state)}
287
- 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>
288
477
  {/snippet}
289
- {#snippet successState(state)}
290
- ✓ 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}
291
501
  {/snippet}
292
- {#snippet errorState(state)}
293
- 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>
294
517
  {/snippet}
295
- </Sbutton>
518
+ </SResult>
296
519
  ```
297
520
 
298
- | Prop | Type | Default | Description |
299
- | -------------- | --------------------------------- | ----------- | --------------------- |
300
- | `label` | `string` | `'Submit'` | Button text |
301
- | `buttonType` | `'submit' \| 'reset' \| 'button'` | `'submit'` | Button type |
302
- | `class` | `string` | `undefined` | CSS class |
303
- | `disabled` | `boolean` | `false` | Disable button |
304
- | `defaultState` | `Snippet` | `undefined` | Default state snippet |
305
- | `pendingState` | `Snippet` | `undefined` | Pending state snippet |
306
- | `successState` | `Snippet` | `undefined` | Success state snippet |
307
- | `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.
308
528
 
309
529
  ## Styling
310
530
 
@@ -322,11 +542,11 @@ Sfield adds these classes automatically:
322
542
 
323
543
  ```svelte
324
544
  <!-- String class applies to wrapper -->
325
- <Sfield name="email" type="email" class="my-field" />
545
+ <Sfield field={fields.email} type="email" class="my-field" />
326
546
 
327
547
  <!-- Object for granular control -->
328
548
  <Sfield
329
- name="email"
549
+ field={fields.email}
330
550
  type="email"
331
551
  class={{
332
552
  wrapper: 'field-wrapper',
@@ -364,16 +584,16 @@ Sform uses TypeScript discriminated unions to provide type-safe props for each i
364
584
 
365
585
  ```typescript
366
586
  // ✅ TypeScript knows 'showToggle' is only valid for password type
367
- <Sfield name="_password" type="password" showToggle={false} />
587
+ <Sfield field={fields._password} type="password" showToggle={false} />
368
588
 
369
589
  // ✅ TypeScript knows 'options' is required for select type
370
- <Sfield name="country" type="select" options={countries} />
590
+ <Sfield field={fields.country} type="select" options={countries} />
371
591
 
372
592
  // ✅ TypeScript knows 'min', 'max', 'step' are valid for number type
373
- <Sfield name="age" type="number" min={0} max={150} />
593
+ <Sfield field={fields.age} type="number" min={0} max={150} />
374
594
 
375
595
  // ❌ TypeScript error: 'showToggle' doesn't exist on text type
376
- <Sfield name="username" type="text" showToggle />
596
+ <Sfield field={fields.username} type="text" showToggle />
377
597
  ```
378
598
 
379
599
  ## Development
@@ -395,11 +615,3 @@ npm run package
395
615
  ## License
396
616
 
397
617
  MIT
398
-
399
- Go into the `package.json` and give your package the desired name through the `"name"` option. Also consider adding a `"license"` field and point it to a `LICENSE` file which you can create from a template (one popular option is the [MIT license](https://opensource.org/license/mit/)).
400
-
401
- To publish your library to [npm](https://www.npmjs.com):
402
-
403
- ```sh
404
- npm publish
405
- ```
@@ -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}