@samuel-charpentier/sform 0.0.1

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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +405 -0
  3. package/dist/Sform/Sfield.svelte +164 -0
  4. package/dist/Sform/Sfield.svelte.d.ts +4 -0
  5. package/dist/Sform/Sform.svelte +101 -0
  6. package/dist/Sform/Sform.svelte.d.ts +51 -0
  7. package/dist/Sform/context.svelte.d.ts +3 -0
  8. package/dist/Sform/context.svelte.js +65 -0
  9. package/dist/Sform/index.d.ts +5 -0
  10. package/dist/Sform/index.js +7 -0
  11. package/dist/Sform/inputs/ButtonInput.svelte +75 -0
  12. package/dist/Sform/inputs/ButtonInput.svelte.d.ts +32 -0
  13. package/dist/Sform/inputs/CheckboxGroupInput.svelte +81 -0
  14. package/dist/Sform/inputs/CheckboxGroupInput.svelte.d.ts +4 -0
  15. package/dist/Sform/inputs/CheckboxInput.svelte +47 -0
  16. package/dist/Sform/inputs/CheckboxInput.svelte.d.ts +4 -0
  17. package/dist/Sform/inputs/MaskedInput.svelte +220 -0
  18. package/dist/Sform/inputs/MaskedInput.svelte.d.ts +4 -0
  19. package/dist/Sform/inputs/NumberInput.svelte +116 -0
  20. package/dist/Sform/inputs/NumberInput.svelte.d.ts +4 -0
  21. package/dist/Sform/inputs/PasswordInput.svelte +142 -0
  22. package/dist/Sform/inputs/PasswordInput.svelte.d.ts +4 -0
  23. package/dist/Sform/inputs/RadioInput.svelte +81 -0
  24. package/dist/Sform/inputs/RadioInput.svelte.d.ts +4 -0
  25. package/dist/Sform/inputs/RangeInput.svelte +67 -0
  26. package/dist/Sform/inputs/RangeInput.svelte.d.ts +4 -0
  27. package/dist/Sform/inputs/SelectInput.svelte +36 -0
  28. package/dist/Sform/inputs/SelectInput.svelte.d.ts +4 -0
  29. package/dist/Sform/inputs/TextInput.svelte +61 -0
  30. package/dist/Sform/inputs/TextInput.svelte.d.ts +4 -0
  31. package/dist/Sform/inputs/TextareaInput.svelte +56 -0
  32. package/dist/Sform/inputs/TextareaInput.svelte.d.ts +4 -0
  33. package/dist/Sform/inputs/ToggleInput.svelte +101 -0
  34. package/dist/Sform/inputs/ToggleInput.svelte.d.ts +4 -0
  35. package/dist/Sform/inputs/ToggleOptionsInput.svelte +118 -0
  36. package/dist/Sform/inputs/ToggleOptionsInput.svelte.d.ts +4 -0
  37. package/dist/Sform/sform.css +628 -0
  38. package/dist/Sform/types.d.ts +610 -0
  39. package/dist/Sform/types.js +5 -0
  40. package/dist/Sform/utils/mask.d.ts +68 -0
  41. package/dist/Sform/utils/mask.js +155 -0
  42. package/dist/index.d.ts +2 -0
  43. package/dist/index.js +1 -0
  44. package/package.json +78 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Samuel Charpentier
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,405 @@
1
+ # Sform
2
+
3
+ A type-safe form library for **Svelte 5** with **SvelteKit remote functions**.
4
+
5
+ ## Features
6
+
7
+ - ✅ **Type-safe** - Discriminated union types for each input type
8
+ - ✅ **Preflight validation** - All errors shown on submit, not one at a time
9
+ - ✅ **Validate modes** - `blur`, `change`, or `submit`
10
+ - ✅ **Password toggle** - Eye icon to show/hide password
11
+ - ✅ **Masked inputs** - Phone, credit card, SSN formatting
12
+ - ✅ **Range slider** - With optional value display
13
+ - ✅ **Toggle switch** - Modern on/off control
14
+ - ✅ **Toggle options** - Segmented control for mutually exclusive options
15
+ - ✅ **Stateful button** - Shows pending state during submission
16
+
17
+ ## Requirements
18
+
19
+ - Svelte 5
20
+ - SvelteKit with `remoteFunctions: true` in config
21
+ - Valibot for schema validation
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ npm install
27
+ ```
28
+
29
+ Enable remote functions in `svelte.config.js`:
30
+
31
+ ```javascript
32
+ export default {
33
+ kit: {
34
+ experimental: {
35
+ remoteFunctions: true
36
+ }
37
+ }
38
+ };
39
+ ```
40
+
41
+ ## Quick Start
42
+
43
+ ### 1. Create a Remote Form
44
+
45
+ Create a `.remote.ts` file with your form schema and handler:
46
+
47
+ ```typescript
48
+ // src/routes/auth.remote.ts
49
+ import * as v from 'valibot';
50
+ import { form } from '@sveltejs/kit/remote';
51
+
52
+ const loginSchema = v.object({
53
+ username: v.pipe(v.string(), v.minLength(3, 'Username must be at least 3 characters')),
54
+ _password: v.pipe(v.string(), v.minLength(8, 'Password must be at least 8 characters'))
55
+ });
56
+
57
+ export const login = form(loginSchema, async ({ username, _password }) => {
58
+ // Your authentication logic here
59
+ return { success: true, message: 'Welcome!' };
60
+ });
61
+ ```
62
+
63
+ ### 2. Create Your Form Component
64
+
65
+ ```svelte
66
+ <script lang="ts">
67
+ import { Sform, Sfield, Sbutton } from '$lib';
68
+ import { login } from './auth.remote.ts';
69
+ </script>
70
+
71
+ <Sform form={login} validateOn="blur">
72
+ <Sfield name="username" type="text" label="Username" />
73
+ <Sfield name="_password" type="password" label="Password" />
74
+
75
+ <Sbutton label="Login" />
76
+ </Sform>
77
+ ```
78
+
79
+ ## Components
80
+
81
+ ### `<Sform>`
82
+
83
+ Wrapper component that provides form context to all child fields.
84
+
85
+ ```svelte
86
+ <Sform form={remoteForm} validateOn="blur" class="my-form">
87
+ <!-- Sfield components here -->
88
+ </Sform>
89
+ ```
90
+
91
+ | Prop | Type | Default | Description |
92
+ | ------------ | -------------------------------- | ----------- | ------------------------------------ |
93
+ | `form` | `RemoteForm` | required | Remote form object from `form()` API |
94
+ | `validateOn` | `'blur' \| 'change' \| 'submit'` | `'blur'` | When to validate and show errors |
95
+ | `class` | `string` | `undefined` | CSS class for form element |
96
+
97
+ **Validate Modes:**
98
+
99
+ - `blur` - Validate and show errors after leaving field (default)
100
+ - `change` - Validate and show errors as soon as value changes
101
+ - `submit` - Validate and show all errors only after submit attempt
102
+
103
+ ### `<Sfield>`
104
+
105
+ Smart field component with type-safe props based on input type.
106
+
107
+ #### Common Props (all types)
108
+
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 |
119
+
120
+ #### Text Inputs
121
+
122
+ ```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" />
127
+ ```
128
+
129
+ Supported text types: `text`, `email`, `tel`, `url`, `search`, `date`, `datetime-local`, `time`, `month`, `week`, `color`, `file`, `hidden`
130
+
131
+ #### Password Input
132
+
133
+ ```svelte
134
+ <Sfield name="_password" type="password" label="Password" />
135
+ <Sfield name="_password" type="password" label="Password" showToggle={false} />
136
+ ```
137
+
138
+ | Prop | Type | Default | Description |
139
+ | ------------ | --------- | ------- | ---------------------------------- |
140
+ | `showToggle` | `boolean` | `true` | Show eye icon to toggle visibility |
141
+
142
+ #### Number Input
143
+
144
+ ```svelte
145
+ <Sfield name="age" type="number" label="Age" min={0} max={150} step={1} />
146
+ ```
147
+
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 |
153
+
154
+ #### Textarea
155
+
156
+ ```svelte
157
+ <Sfield name="bio" type="textarea" label="Bio" placeholder="Tell us about yourself" />
158
+ ```
159
+
160
+ #### Select
161
+
162
+ ```svelte
163
+ <Sfield
164
+ name="country"
165
+ type="select"
166
+ label="Country"
167
+ options={[
168
+ { value: 'us', label: 'United States' },
169
+ { value: 'uk', label: 'United Kingdom' },
170
+ { value: 'ca', label: 'Canada' }
171
+ ]}
172
+ />
173
+ ```
174
+
175
+ | Prop | Type | Default | Description |
176
+ | --------- | ---------------------------- | -------- | -------------- |
177
+ | `options` | `SelectOption[] \| string[]` | required | Select options |
178
+
179
+ #### Checkbox
180
+
181
+ ```svelte
182
+ <Sfield name="subscribe" type="checkbox" label="Subscribe to newsletter" />
183
+ ```
184
+
185
+ #### Radio
186
+
187
+ ```svelte
188
+ <Sfield
189
+ name="plan"
190
+ type="radio"
191
+ label="Plan"
192
+ options={[
193
+ { value: 'free', label: 'Free' },
194
+ { value: 'pro', label: 'Pro' },
195
+ { value: 'enterprise', label: 'Enterprise' }
196
+ ]}
197
+ />
198
+ ```
199
+
200
+ | Prop | Type | Default | Description |
201
+ | --------- | ---------------------------- | ----------- | ------------------------ |
202
+ | `options` | `SelectOption[] \| string[]` | `undefined` | Radio options for groups |
203
+
204
+ #### Range
205
+
206
+ ```svelte
207
+ <Sfield name="volume" type="range" label="Volume" min={0} max={100} step={5} showValue />
208
+ ```
209
+
210
+ | Prop | Type | Default | Description |
211
+ | ------------- | --------------------------- | ----------- | ---------------------- |
212
+ | `min` | `number \| string` | `0` | Minimum value |
213
+ | `max` | `number \| string` | `100` | Maximum value |
214
+ | `step` | `number \| string` | `1` | Step increment |
215
+ | `showValue` | `boolean` | `false` | Show current value |
216
+ | `formatValue` | `(value: number) => string` | `undefined` | Format displayed value |
217
+
218
+ #### Toggle
219
+
220
+ ```svelte
221
+ <Sfield name="notifications" type="toggle" label="Enable Notifications" />
222
+ <Sfield name="darkMode" type="toggle" label="Theme" onLabel="Dark" offLabel="Light" />
223
+ ```
224
+
225
+ | Prop | Type | Default | Description |
226
+ | ---------------- | -------- | ----------- | -------------------- |
227
+ | `onLabel` | `string` | `undefined` | Label when on |
228
+ | `offLabel` | `string` | `undefined` | Label when off |
229
+ | `checkedValue` | `string` | `'true'` | Value when checked |
230
+ | `uncheckedValue` | `string` | `'false'` | Value when unchecked |
231
+
232
+ #### Toggle Options
233
+
234
+ ```svelte
235
+ <Sfield
236
+ name="theme"
237
+ type="toggle-options"
238
+ label="Theme"
239
+ options={[
240
+ { value: 'light', label: 'Light' },
241
+ { value: 'dark', label: 'Dark' },
242
+ { value: 'auto', label: 'Auto' }
243
+ ]}
244
+ />
245
+ ```
246
+
247
+ | Prop | Type | Default | Description |
248
+ | ---------- | ---------------------------- | -------- | ------------------------- |
249
+ | `options` | `ToggleOption[] \| string[]` | required | Toggle options |
250
+ | `multiple` | `boolean` | `false` | Allow multiple selections |
251
+
252
+ #### Masked Input
253
+
254
+ ```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="###-##-####" />
258
+ ```
259
+
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 |
266
+
267
+ **Mask Tokens:**
268
+
269
+ - `#` or `9` - Numeric (0-9)
270
+ - `a` - Alphabetic (a-z, A-Z)
271
+ - `A` - Alphabetic uppercase
272
+ - `*` - Alphanumeric
273
+
274
+ ### `<Sbutton>`
275
+
276
+ Stateful submit button that reacts to form state.
277
+
278
+ ```svelte
279
+ <Sbutton label="Submit" class="my-button" />
280
+
281
+ <!-- With custom state snippets -->
282
+ <Sbutton class="submit-btn">
283
+ {#snippet defaultState(state)}
284
+ Submit Form
285
+ {/snippet}
286
+ {#snippet pendingState(state)}
287
+ Submitting...
288
+ {/snippet}
289
+ {#snippet successState(state)}
290
+ ✓ Success!
291
+ {/snippet}
292
+ {#snippet errorState(state)}
293
+ Fix Errors
294
+ {/snippet}
295
+ </Sbutton>
296
+ ```
297
+
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 |
308
+
309
+ ## Styling
310
+
311
+ ### CSS Classes
312
+
313
+ Sfield adds these classes automatically:
314
+
315
+ - `.sform-field` - Wrapper element
316
+ - `.sform-label` - Label element
317
+ - `.sform-input` - Input element
318
+ - `.sform-messages` - Error messages container
319
+ - `.sform-field-error` - Added to wrapper when field has errors
320
+
321
+ ### Custom Classes
322
+
323
+ ```svelte
324
+ <!-- String class applies to wrapper -->
325
+ <Sfield name="email" type="email" class="my-field" />
326
+
327
+ <!-- Object for granular control -->
328
+ <Sfield
329
+ name="email"
330
+ type="email"
331
+ class={{
332
+ wrapper: 'field-wrapper',
333
+ label: 'field-label',
334
+ input: 'field-input',
335
+ messages: 'field-errors'
336
+ }}
337
+ />
338
+ ```
339
+
340
+ ## Validation
341
+
342
+ Sform uses preflight validation with Valibot schemas. Native browser validation (required, minlength, pattern) is disabled to allow showing all errors at once on submit.
343
+
344
+ ### Schema Example
345
+
346
+ ```typescript
347
+ import * as v from 'valibot';
348
+
349
+ const signupSchema = v.object({
350
+ email: v.pipe(v.string(), v.email('Please enter a valid email')),
351
+ _password: v.pipe(
352
+ v.string(),
353
+ v.minLength(8, 'Password must be at least 8 characters'),
354
+ v.regex(/[A-Z]/, 'Password must contain an uppercase letter'),
355
+ v.regex(/[0-9]/, 'Password must contain a number')
356
+ ),
357
+ age: v.pipe(v.number(), v.minValue(18, 'Must be at least 18 years old'))
358
+ });
359
+ ```
360
+
361
+ ## Type Safety
362
+
363
+ Sform uses TypeScript discriminated unions to provide type-safe props for each input type:
364
+
365
+ ```typescript
366
+ // ✅ TypeScript knows 'showToggle' is only valid for password type
367
+ <Sfield name="_password" type="password" showToggle={false} />
368
+
369
+ // ✅ TypeScript knows 'options' is required for select type
370
+ <Sfield name="country" type="select" options={countries} />
371
+
372
+ // ✅ TypeScript knows 'min', 'max', 'step' are valid for number type
373
+ <Sfield name="age" type="number" min={0} max={150} />
374
+
375
+ // ❌ TypeScript error: 'showToggle' doesn't exist on text type
376
+ <Sfield name="username" type="text" showToggle />
377
+ ```
378
+
379
+ ## Development
380
+
381
+ ```bash
382
+ # Install dependencies
383
+ npm install
384
+
385
+ # Start dev server
386
+ npm run dev
387
+
388
+ # Run tests
389
+ npm test
390
+
391
+ # Build library
392
+ npm run package
393
+ ```
394
+
395
+ ## License
396
+
397
+ 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,164 @@
1
+ <script lang="ts">
2
+ import type {
3
+ SfieldClasses,
4
+ RemoteFormField,
5
+ RemoteFormFieldValue,
6
+ TypedSfieldProps
7
+ } from './types.js';
8
+ import { getSformContext } from './context.svelte.js';
9
+ import TextInput from './inputs/TextInput.svelte';
10
+ import NumberInput from './inputs/NumberInput.svelte';
11
+ import TextareaInput from './inputs/TextareaInput.svelte';
12
+ import SelectInput from './inputs/SelectInput.svelte';
13
+ import CheckboxInput from './inputs/CheckboxInput.svelte';
14
+ import CheckboxGroupInput from './inputs/CheckboxGroupInput.svelte';
15
+ import RadioInput from './inputs/RadioInput.svelte';
16
+ import RangeInput from './inputs/RangeInput.svelte';
17
+ import ToggleInput from './inputs/ToggleInput.svelte';
18
+ import ToggleOptionsInput from './inputs/ToggleOptionsInput.svelte';
19
+ import MaskedInput from './inputs/MaskedInput.svelte';
20
+ import PasswordInput from './inputs/PasswordInput.svelte';
21
+
22
+ /**
23
+ * Sfield - Type-safe form field component.
24
+ *
25
+ * The `type` prop is constrained based on the field's value type:
26
+ * - string fields: text, email, password, textarea, select, radio, masked, etc.
27
+ * - number fields: number, range
28
+ * - boolean fields: checkbox, toggle
29
+ * - string[] fields: checkbox-group
30
+ */
31
+ let props: TypedSfieldProps<RemoteFormFieldValue> = $props();
32
+
33
+ const context = getSformContext();
34
+
35
+ // Field is directly passed as a prop
36
+ const field = $derived(props.field as RemoteFormField<RemoteFormFieldValue>);
37
+
38
+ // Derive name from the field - all field types include name in their .as() output
39
+ const name = $derived(field.as('text').name);
40
+
41
+ const classes: SfieldClasses = $derived(
42
+ typeof props.class === 'string' ? { wrapper: props.class } : (props.class ?? {})
43
+ );
44
+
45
+ const showIssues = $derived(context.shouldDisplayIssues(name, props.validateOn));
46
+ const issues = $derived(showIssues ? field.issues() : []);
47
+ const hasIssues = $derived(issues && issues instanceof Array && issues.length > 0);
48
+
49
+ function handleBlur() {
50
+ context.markTouched(name);
51
+ // Trigger validation with includeUntouched so blur mode shows issues
52
+ context.triggerValidation();
53
+ }
54
+
55
+ function handleInput() {
56
+ context.markDirty(name);
57
+ }
58
+
59
+ // Props that Sfield manages internally - these are set by Sfield, not passed from parent
60
+ const internalPropKeys = [
61
+ 'field',
62
+ 'name',
63
+ 'showIssues',
64
+ 'onblur',
65
+ 'oninput',
66
+ 'labelClass',
67
+ 'class'
68
+ ];
69
+ // Props that are Sfield-specific and not passed to components
70
+ const sfieldOnlyPropKeys = ['validateOn', 'hint', 'type'];
71
+
72
+ // Passthrough props: everything from parent except internal and sfield-only props
73
+ // This allows new component props to automatically flow through without updating Sfield
74
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
75
+ const passthroughProps = $derived((): any => {
76
+ const result: Record<string, unknown> = {};
77
+ for (const [key, value] of Object.entries(props)) {
78
+ if (!internalPropKeys.includes(key) && !sfieldOnlyPropKeys.includes(key)) {
79
+ result[key] = value;
80
+ }
81
+ }
82
+ return result;
83
+ });
84
+
85
+ // Internal props that Sfield computes/manages
86
+ const internalProps = $derived({
87
+ field,
88
+ name,
89
+ class: hasIssues ? `${classes.input ?? ''} sform-field-error`.trim() : classes.input,
90
+ labelClass: classes.label,
91
+ showIssues,
92
+ onblur: handleBlur,
93
+ oninput: handleInput
94
+ });
95
+
96
+ // Check input type categories
97
+ const isTextType = $derived(
98
+ [
99
+ 'text',
100
+ 'email',
101
+ 'tel',
102
+ 'url',
103
+ 'search',
104
+ 'date',
105
+ 'datetime-local',
106
+ 'time',
107
+ 'month',
108
+ 'week',
109
+ 'color',
110
+ 'hidden',
111
+ 'file'
112
+ ].includes(props.type)
113
+ );
114
+ </script>
115
+
116
+ <div class={classes.wrapper}>
117
+ {#if isTextType}
118
+ <TextInput
119
+ {...passthroughProps()}
120
+ {...internalProps}
121
+ type={props.type as import('./types.js').TextInputType}
122
+ />
123
+ {:else if props.type === 'password'}
124
+ <PasswordInput {...passthroughProps()} {...internalProps} />
125
+ {:else if props.type === 'number'}
126
+ <NumberInput {...passthroughProps()} {...internalProps} />
127
+ {:else if props.type === 'textarea'}
128
+ <TextareaInput {...passthroughProps()} {...internalProps} />
129
+ {:else if props.type === 'select'}
130
+ <SelectInput {...passthroughProps()} {...internalProps} />
131
+ {:else if props.type === 'checkbox'}
132
+ <CheckboxInput {...passthroughProps()} {...internalProps} />
133
+ {:else if props.type === 'checkbox-group'}
134
+ <CheckboxGroupInput {...passthroughProps()} {...internalProps} />
135
+ {:else if props.type === 'radio'}
136
+ <RadioInput {...passthroughProps()} {...internalProps} />
137
+ {:else if props.type === 'range'}
138
+ <RangeInput {...passthroughProps()} {...internalProps} />
139
+ {:else if props.type === 'toggle'}
140
+ <ToggleInput {...passthroughProps()} {...internalProps} />
141
+ {:else if props.type === 'toggle-options'}
142
+ <ToggleOptionsInput {...passthroughProps()} {...internalProps} />
143
+ {:else if props.type === 'masked'}
144
+ <MaskedInput {...passthroughProps()} {...internalProps} />
145
+ {/if}
146
+
147
+ {#if props.hint}
148
+ <div class="sform-hint">
149
+ {#if typeof props.hint === 'string'}
150
+ {props.hint}
151
+ {:else}
152
+ {@render props.hint()}
153
+ {/if}
154
+ </div>
155
+ {/if}
156
+
157
+ {#if hasIssues}
158
+ <div class={classes.messages}>
159
+ {#each issues as issue, i (i)}
160
+ <p>{issue.message}</p>
161
+ {/each}
162
+ </div>
163
+ {/if}
164
+ </div>
@@ -0,0 +1,4 @@
1
+ import type { RemoteFormFieldValue, TypedSfieldProps } from './types.js';
2
+ declare const Sfield: import("svelte").Component<TypedSfieldProps<RemoteFormFieldValue>, {}, "">;
3
+ type Sfield = ReturnType<typeof Sfield>;
4
+ export default Sfield;
@@ -0,0 +1,101 @@
1
+ <script lang="ts" generics="Input extends import('@sveltejs/kit').RemoteFormInput, Output">
2
+ import type { RemoteFormInstance, ValidateOn, EnhanceCallback } from './types.js';
3
+ import type { RemoteFormFields } from '@sveltejs/kit';
4
+ import type { StandardSchemaV1 } from '@standard-schema/spec';
5
+ import { createSformContext } from './context.svelte.js';
6
+ import type { HTMLFormAttributes } from 'svelte/elements';
7
+ import type { Snippet } from 'svelte';
8
+
9
+ /**
10
+ * Form fields accessor type - provides typed access to all form fields
11
+ */
12
+ type FormFields = RemoteFormFields<Input>;
13
+
14
+ let {
15
+ form,
16
+ schema,
17
+ enhance,
18
+ validateOn = 'blur',
19
+ class: className,
20
+ children
21
+ }: {
22
+ /** Remote form object from form() API, or the result of form.for(id) */
23
+ form: RemoteFormInstance<Input, Output>;
24
+ /** Preflight validation schema (Valibot, Zod, or any StandardSchema) */
25
+ schema?: StandardSchemaV1<Input, unknown>;
26
+ /** Enhance callback for custom form submission handling */
27
+ enhance?: EnhanceCallback<Input>;
28
+ /** When to validate and show issues: 'blur' (default), 'change', or 'submit' */
29
+ validateOn?: ValidateOn;
30
+ /** Form element class */
31
+ class?: string;
32
+ /**
33
+ * Children snippet receives typed fields for type-safe field access.
34
+ * @example
35
+ * ```svelte
36
+ * <Sform {form}>
37
+ * {#snippet children(fields)}
38
+ * <Sfield field={fields.username} type="text" />
39
+ * {/snippet}
40
+ * </Sform>
41
+ * ```
42
+ */
43
+ children: Snippet<[FormFields]>;
44
+ } = $props();
45
+
46
+ // Get field names for marking all dirty on submit
47
+ const getFieldNames = () => {
48
+ return Object.keys(form.fields).filter((key) => !['value', 'set', 'allIssues'].includes(key));
49
+ };
50
+
51
+ // Trigger validation including untouched fields (for blur mode)
52
+ const triggerValidation = () => {
53
+ form.validate({ includeUntouched: true });
54
+ };
55
+
56
+ const context = createSformContext(() => validateOn, getFieldNames, triggerValidation);
57
+
58
+ // Apply preflight schema if provided
59
+ const formWithSchema = $derived(schema ? form.preflight(schema) : form);
60
+
61
+ // Apply enhance if provided - returns a minimal object for spreading onto form element
62
+ const formProps = $derived(enhance ? formWithSchema.enhance(enhance) : formWithSchema);
63
+
64
+ // Track previous pending state to detect submission completion
65
+ let wasPending = $state(false);
66
+
67
+ $effect(() => {
68
+ const isPending = form.pending !== 0;
69
+ const hasResult = form.result !== undefined;
70
+ const allIssues =
71
+ (form.fields as { allIssues?: () => unknown[] | undefined }).allIssues?.() ?? [];
72
+ const hasNoIssues = allIssues.length === 0;
73
+
74
+ // Submission just completed successfully (was pending, now not, has result, no issues)
75
+ if (wasPending && !isPending && hasResult && hasNoIssues) {
76
+ context.resetFieldStates();
77
+ }
78
+
79
+ wasPending = isPending;
80
+ });
81
+
82
+ function handleInput() {
83
+ // Always include untouched to preserve issues on fields that were already validated
84
+ form.validate({ includeUntouched: true });
85
+ }
86
+
87
+ function handleSubmit() {
88
+ context.markSubmitted();
89
+ context.markAllFieldsDirty();
90
+ }
91
+ </script>
92
+
93
+ <form
94
+ {...formProps as unknown as HTMLFormAttributes}
95
+ class={className}
96
+ novalidate
97
+ oninput={handleInput}
98
+ onsubmit={handleSubmit}
99
+ >
100
+ {@render (children as Snippet<[FormFields]>)(form.fields)}
101
+ </form>