@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.
- package/LICENSE +21 -0
- package/README.md +405 -0
- package/dist/Sform/Sfield.svelte +164 -0
- package/dist/Sform/Sfield.svelte.d.ts +4 -0
- package/dist/Sform/Sform.svelte +101 -0
- package/dist/Sform/Sform.svelte.d.ts +51 -0
- package/dist/Sform/context.svelte.d.ts +3 -0
- package/dist/Sform/context.svelte.js +65 -0
- package/dist/Sform/index.d.ts +5 -0
- package/dist/Sform/index.js +7 -0
- package/dist/Sform/inputs/ButtonInput.svelte +75 -0
- package/dist/Sform/inputs/ButtonInput.svelte.d.ts +32 -0
- package/dist/Sform/inputs/CheckboxGroupInput.svelte +81 -0
- package/dist/Sform/inputs/CheckboxGroupInput.svelte.d.ts +4 -0
- package/dist/Sform/inputs/CheckboxInput.svelte +47 -0
- package/dist/Sform/inputs/CheckboxInput.svelte.d.ts +4 -0
- package/dist/Sform/inputs/MaskedInput.svelte +220 -0
- package/dist/Sform/inputs/MaskedInput.svelte.d.ts +4 -0
- package/dist/Sform/inputs/NumberInput.svelte +116 -0
- package/dist/Sform/inputs/NumberInput.svelte.d.ts +4 -0
- package/dist/Sform/inputs/PasswordInput.svelte +142 -0
- package/dist/Sform/inputs/PasswordInput.svelte.d.ts +4 -0
- package/dist/Sform/inputs/RadioInput.svelte +81 -0
- package/dist/Sform/inputs/RadioInput.svelte.d.ts +4 -0
- package/dist/Sform/inputs/RangeInput.svelte +67 -0
- package/dist/Sform/inputs/RangeInput.svelte.d.ts +4 -0
- package/dist/Sform/inputs/SelectInput.svelte +36 -0
- package/dist/Sform/inputs/SelectInput.svelte.d.ts +4 -0
- package/dist/Sform/inputs/TextInput.svelte +61 -0
- package/dist/Sform/inputs/TextInput.svelte.d.ts +4 -0
- package/dist/Sform/inputs/TextareaInput.svelte +56 -0
- package/dist/Sform/inputs/TextareaInput.svelte.d.ts +4 -0
- package/dist/Sform/inputs/ToggleInput.svelte +101 -0
- package/dist/Sform/inputs/ToggleInput.svelte.d.ts +4 -0
- package/dist/Sform/inputs/ToggleOptionsInput.svelte +118 -0
- package/dist/Sform/inputs/ToggleOptionsInput.svelte.d.ts +4 -0
- package/dist/Sform/sform.css +628 -0
- package/dist/Sform/types.d.ts +610 -0
- package/dist/Sform/types.js +5 -0
- package/dist/Sform/utils/mask.d.ts +68 -0
- package/dist/Sform/utils/mask.js +155 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- 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,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>
|