sveltekit-discriminated-fields 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +281 -86
- package/dist/FieldVariants.svelte +234 -0
- package/dist/FieldVariants.svelte.d.ts +95 -0
- package/dist/discriminated.d.ts +74 -0
- package/dist/discriminated.js +62 -0
- package/dist/index.d.ts +3 -39
- package/dist/index.js +4 -25
- package/package.json +19 -6
package/README.md
CHANGED
|
@@ -2,16 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
Type-safe discriminated union support for SvelteKit remote function form fields.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
This library provides two complementary tools for working with discriminated unions in SvelteKit forms:
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
- **`discriminated()`** - A wrapper function that enables proper TypeScript type narrowing on form field objects
|
|
8
|
+
- **`FieldVariants`** - A component that renders variant-specific form sections with CSS-only visibility, supporting progressive enhancement (works without JavaScript)
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
The implementation prioritises:
|
|
11
|
+
|
|
12
|
+
- **Static type safety** - Catch errors at compile time with clear error messages
|
|
13
|
+
- **Progressive enhancement** - Forms work identically with or without JavaScript
|
|
14
|
+
- **Minimal boilerplate** - Simple API that stays out of your way
|
|
15
|
+
- **Flexibility** - Use as much or as little as you need
|
|
16
|
+
|
|
17
|
+
This library exposes existing SvelteKit form behaviour with improved typing for discriminated unions. Runtime overhead is minimal - just a thin proxy wrapper.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install sveltekit-discriminated-fields
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## FieldVariants Component
|
|
26
|
+
|
|
27
|
+
The `FieldVariants` component provides declarative variant rendering with **full progressive enhancement support**. It uses CSS-only visibility toggling via `form:has()` selectors, so forms work identically with or without JavaScript enabled.
|
|
28
|
+
|
|
29
|
+
Given a SvelteKit remote function using Zod (this library also works with [Valibot](./test/src/routes/valibot-form) or other validation libraries):
|
|
10
30
|
|
|
11
31
|
```typescript
|
|
12
32
|
// data.remote.ts
|
|
13
33
|
import { z } from "zod";
|
|
14
|
-
import { form } from "$app/server
|
|
34
|
+
import { form } from "$app/server";
|
|
15
35
|
|
|
16
36
|
const shapeSchema = z.discriminatedUnion("kind", [
|
|
17
37
|
z.object({ kind: z.literal("circle"), radius: z.number() }),
|
|
@@ -24,152 +44,327 @@ const shapeSchema = z.discriminatedUnion("kind", [
|
|
|
24
44
|
]);
|
|
25
45
|
|
|
26
46
|
export const shapeForm = form(shapeSchema, async (data) => {
|
|
27
|
-
// handle
|
|
47
|
+
// handle submission
|
|
28
48
|
});
|
|
29
49
|
```
|
|
30
50
|
|
|
51
|
+
Use `FieldVariants` to render variant-specific fields:
|
|
52
|
+
|
|
31
53
|
```svelte
|
|
32
|
-
<!-- +page.svelte (without this library) -->
|
|
33
54
|
<script lang="ts">
|
|
34
|
-
import { shapeForm } from './data.remote
|
|
55
|
+
import { shapeForm } from './data.remote';
|
|
56
|
+
import { FieldVariants } from 'sveltekit-discriminated-fields';
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<form {...shapeForm}>
|
|
60
|
+
<select {...shapeForm.fields.kind.as('select')}>
|
|
61
|
+
<option value="">Select a shape...</option>
|
|
62
|
+
<option value="circle">Circle</option>
|
|
63
|
+
<option value="rectangle">Rectangle</option>
|
|
64
|
+
<option value="point">Point</option>
|
|
65
|
+
</select>
|
|
66
|
+
|
|
67
|
+
<FieldVariants fields={shapeForm.fields} key="kind">
|
|
68
|
+
{#snippet fallback(props)}
|
|
69
|
+
<p {...props}>Please select a shape type above.</p>
|
|
70
|
+
{/snippet}
|
|
71
|
+
|
|
72
|
+
{#snippet circle(v)}
|
|
73
|
+
<label {...v}>
|
|
74
|
+
Radius: <input {...v.fields.radius.as('number')} />
|
|
75
|
+
</label>
|
|
76
|
+
{/snippet}
|
|
77
|
+
|
|
78
|
+
{#snippet rectangle(v)}
|
|
79
|
+
<div {...v}>
|
|
80
|
+
<input {...v.fields.width.as('number')} placeholder="Width" />
|
|
81
|
+
<input {...v.fields.height.as('number')} placeholder="Height" />
|
|
82
|
+
</div>
|
|
83
|
+
{/snippet}
|
|
84
|
+
|
|
85
|
+
{#snippet point(v)}
|
|
86
|
+
<p {...v}>Point has no additional fields.</p>
|
|
87
|
+
{/snippet}
|
|
88
|
+
</FieldVariants>
|
|
89
|
+
|
|
90
|
+
<button type="submit">Submit</button>
|
|
91
|
+
</form>
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Snippet Parameters
|
|
95
|
+
|
|
96
|
+
Each variant snippet receives a single argument that mirrors how forms work in SvelteKit:
|
|
97
|
+
|
|
98
|
+
- **Spread for CSS targeting**: `{...v}` - Adds the `data-fv` attribute for CSS visibility
|
|
99
|
+
- **Access fields**: `v.fields.radius` - Provides the variant-specific form fields
|
|
100
|
+
|
|
101
|
+
This pattern is consistent with how you use forms: `<form {...form}>` + `form.fields.x`.
|
|
102
|
+
|
|
103
|
+
### Snippets and Fields
|
|
104
|
+
|
|
105
|
+
Each snippet receives correctly narrowed fields for that variant - TypeScript knows `v.fields.radius` exists in the `circle` snippet but not in `rectangle`. Only valid discriminator values are accepted.
|
|
35
106
|
|
|
36
|
-
|
|
37
|
-
|
|
107
|
+
Snippets only receive fields **specific to that variant**. Fields common to all variants (same name and type) should be rendered outside `FieldVariants` to prevent accidental duplicate inputs. Fields shared by some but not all variants, or with differing types across variants, produce compile-time errors.
|
|
108
|
+
|
|
109
|
+
### Radio Buttons
|
|
110
|
+
|
|
111
|
+
For radio button discriminators, you must use the `discriminated()` wrapper. The raw SvelteKit field object's `.as("radio", value)` method doesn't work with discriminated unions (causes a static error). The wrapped version is type-safe - only valid discriminator values are accepted:
|
|
112
|
+
|
|
113
|
+
```svelte
|
|
114
|
+
<script lang="ts">
|
|
115
|
+
import { shapeForm } from './data.remote';
|
|
116
|
+
import { discriminated, FieldVariants } from 'sveltekit-discriminated-fields';
|
|
117
|
+
|
|
118
|
+
const shape = $derived(discriminated('kind', shapeForm.fields));
|
|
38
119
|
</script>
|
|
39
120
|
|
|
40
|
-
<
|
|
41
|
-
<
|
|
42
|
-
|
|
121
|
+
<form {...shapeForm}>
|
|
122
|
+
<fieldset>
|
|
123
|
+
<label><input {...shape.kind.as("radio", "circle")} /> Circle</label>
|
|
124
|
+
<label><input {...shape.kind.as("radio", "rectangle")} /> Rectangle</label>
|
|
125
|
+
<label><input {...shape.kind.as("radio", "point")} /> Point</label>
|
|
126
|
+
</fieldset>
|
|
127
|
+
|
|
128
|
+
<FieldVariants fields={shape} key="kind">
|
|
129
|
+
{#snippet fallback(props)}
|
|
130
|
+
<p {...props}>Select a shape type</p>
|
|
131
|
+
{/snippet}
|
|
132
|
+
|
|
133
|
+
{#snippet circle(v)}
|
|
134
|
+
<label {...v}>
|
|
135
|
+
Radius: <input {...v.fields.radius.as('number')} />
|
|
136
|
+
</label>
|
|
137
|
+
{/snippet}
|
|
138
|
+
|
|
139
|
+
<!-- other snippets -->
|
|
140
|
+
</FieldVariants>
|
|
141
|
+
</form>
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
See the [radio-form example](./test/src/routes/radio-form) for a complete working example.
|
|
145
|
+
|
|
146
|
+
### Select Options
|
|
147
|
+
|
|
148
|
+
For select elements, you can use `.as("option", value)` for type-safe option values. This is optional - you can still use `value="..."` directly if you prefer:
|
|
149
|
+
|
|
150
|
+
```svelte
|
|
151
|
+
<select {...shape.kind.as("select")}>
|
|
152
|
+
<!-- Type-safe: typos caught at compile time -->
|
|
153
|
+
<option {...shape.kind.as("option")}>Select a shape...</option>
|
|
154
|
+
<option {...shape.kind.as("option", "circle")}>Circle</option>
|
|
155
|
+
<option {...shape.kind.as("option", "rectangle")}>Rectangle</option>
|
|
156
|
+
|
|
157
|
+
<!-- Also works: standard HTML (no type checking) -->
|
|
43
158
|
<option value="point">Point</option>
|
|
44
159
|
</select>
|
|
160
|
+
```
|
|
45
161
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
162
|
+
- `as("option")` returns `{ value: "" }` for the placeholder option
|
|
163
|
+
- `as("option", "circle")` returns `{ value: "circle" }` with type checking
|
|
164
|
+
|
|
165
|
+
### CSS-Based Visibility
|
|
166
|
+
|
|
167
|
+
`FieldVariants` uses `form:has()` CSS selectors to show/hide variant sections based on the selected discriminator value. This works automatically for any layout - the discriminator input and variant sections can be anywhere within the same `<form>`.
|
|
168
|
+
|
|
169
|
+
```svelte
|
|
170
|
+
<form {...shapeForm}>
|
|
171
|
+
<div class="header">
|
|
172
|
+
<select {...shapeForm.fields.kind.as('select')}>
|
|
173
|
+
<!-- options -->
|
|
174
|
+
</select>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
<div class="body">
|
|
178
|
+
<!-- Works regardless of DOM structure -->
|
|
179
|
+
<FieldVariants fields={shapeForm.fields} key="kind">
|
|
180
|
+
<!-- snippets -->
|
|
181
|
+
</FieldVariants>
|
|
182
|
+
</div>
|
|
183
|
+
</form>
|
|
53
184
|
```
|
|
54
185
|
|
|
55
|
-
|
|
186
|
+
See the [selector-form example](./test/src/routes/selector-form) for select elements or [selector-radio-form example](./test/src/routes/selector-radio-form) for radio buttons.
|
|
56
187
|
|
|
57
|
-
|
|
188
|
+
### Nested and Multiple Unions
|
|
58
189
|
|
|
59
|
-
|
|
190
|
+
The discriminated union doesn't need to be the top-level schema. It can be nested within a larger object:
|
|
60
191
|
|
|
61
|
-
|
|
62
|
-
|
|
192
|
+
```typescript
|
|
193
|
+
const orderSchema = z.object({
|
|
194
|
+
orderId: z.string(),
|
|
195
|
+
shipping: z.discriminatedUnion("method", [
|
|
196
|
+
z.object({ method: z.literal("pickup"), store: z.string() }),
|
|
197
|
+
z.object({ method: z.literal("delivery"), address: z.string() }),
|
|
198
|
+
]),
|
|
199
|
+
});
|
|
200
|
+
```
|
|
63
201
|
|
|
64
202
|
```svelte
|
|
65
|
-
<!-- +page.svelte (with this library) -->
|
|
66
203
|
<script lang="ts">
|
|
67
|
-
import {
|
|
68
|
-
import { discriminatedFields } from 'sveltekit-discriminated-fields'; // +
|
|
204
|
+
import { discriminated, FieldVariants } from 'sveltekit-discriminated-fields';
|
|
69
205
|
|
|
70
|
-
const
|
|
71
|
-
const shape = discriminatedFields('kind', form.fields); // changed
|
|
206
|
+
const shipping = $derived(discriminated('method', orderForm.fields.shipping));
|
|
72
207
|
</script>
|
|
73
208
|
|
|
74
|
-
<
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
</select>
|
|
209
|
+
<FieldVariants fields={shipping} key="method">
|
|
210
|
+
{#snippet pickup(v)}
|
|
211
|
+
<input {...v} {...v.fields.store.as('text')} />
|
|
212
|
+
{/snippet}
|
|
79
213
|
|
|
80
|
-
{#
|
|
81
|
-
|
|
82
|
-
{
|
|
83
|
-
|
|
84
|
-
<input {...shape.height.as('number')} />
|
|
85
|
-
<!-- ERROR: Property 'radius' does not exist - correctly rejected -->
|
|
86
|
-
<input {...shape.radius.as('number')} />
|
|
87
|
-
{/if}
|
|
214
|
+
{#snippet delivery(v)}
|
|
215
|
+
<input {...v} {...v.fields.address.as('text')} />
|
|
216
|
+
{/snippet}
|
|
217
|
+
</FieldVariants>
|
|
88
218
|
```
|
|
89
219
|
|
|
90
|
-
|
|
220
|
+
You can also have multiple discriminated unions in the same form, or even a discriminated union nested within another discriminated union. See the [nested-form example](./test/src/routes/nested-form) for nested unions within objects, or [nested-union-form example](./test/src/routes/nested-union-form) for a union inside a union.
|
|
91
221
|
|
|
92
|
-
|
|
93
|
-
2. Wrap `form.fields` with `discriminatedFields('kind', ...)`
|
|
94
|
-
3. Use `shape.kindValue` instead of `shape.kind.value()` for narrowing
|
|
222
|
+
### Partial Variants
|
|
95
223
|
|
|
96
|
-
|
|
224
|
+
By default, `FieldVariants` requires a snippet for every variant - a compile error appears if one is missing, helping you avoid omissions. When you intentionally want to handle only some variants, use `partial={true}`:
|
|
97
225
|
|
|
98
|
-
```
|
|
99
|
-
|
|
226
|
+
```svelte
|
|
227
|
+
<FieldVariants fields={shape} key="kind" partial={true}>
|
|
228
|
+
{#snippet circle(v)}
|
|
229
|
+
<input {...v} {...v.fields.radius.as('number')} />
|
|
230
|
+
{/snippet}
|
|
231
|
+
|
|
232
|
+
{#snippet rectangle(v)}
|
|
233
|
+
<input {...v} {...v.fields.width.as('number')} />
|
|
234
|
+
{/snippet}
|
|
235
|
+
|
|
236
|
+
<!-- point snippet omitted - nothing shown when point selected -->
|
|
237
|
+
</FieldVariants>
|
|
100
238
|
```
|
|
101
239
|
|
|
102
|
-
|
|
240
|
+
### Progressive Enhancement
|
|
241
|
+
|
|
242
|
+
`FieldVariants` provides true progressive enhancement:
|
|
243
|
+
|
|
244
|
+
1. **Before JavaScript loads**: All variant snippets are rendered, CSS handles visibility
|
|
245
|
+
2. **After JavaScript hydrates**: Switches to conditional rendering, enabling Svelte transitions
|
|
246
|
+
|
|
247
|
+
This means forms work without JavaScript, but once JS loads, you get full Svelte features:
|
|
103
248
|
|
|
104
249
|
```svelte
|
|
105
|
-
<
|
|
106
|
-
|
|
107
|
-
|
|
250
|
+
<FieldVariants fields={shape} key="kind">
|
|
251
|
+
{#snippet circle(v)}
|
|
252
|
+
<!-- Svelte transitions work after hydration -->
|
|
253
|
+
<div {...v} transition:slide>
|
|
254
|
+
<input {...v.fields.radius.as('number')} />
|
|
255
|
+
</div>
|
|
256
|
+
{/snippet}
|
|
257
|
+
</FieldVariants>
|
|
258
|
+
```
|
|
108
259
|
|
|
109
|
-
|
|
260
|
+
### Disabling CSS
|
|
110
261
|
|
|
111
|
-
|
|
112
|
-
// The first argument is your discriminator key (e.g., 'type', 'kind', 'channel')
|
|
113
|
-
const payment = $derived(discriminatedFields('type', form.fields));
|
|
262
|
+
If you want to handle visibility yourself, disable CSS generation:
|
|
114
263
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
264
|
+
```svelte
|
|
265
|
+
<FieldVariants fields={shape} key="kind" css={false}>
|
|
266
|
+
<!-- snippets -->
|
|
267
|
+
</FieldVariants>
|
|
268
|
+
```
|
|
120
269
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
<option value="card">Card</option>
|
|
127
|
-
<option value="bank">Bank Transfer</option>
|
|
128
|
-
</select>
|
|
270
|
+
## discriminated Function
|
|
271
|
+
|
|
272
|
+
When using SvelteKit's remote function `form()` with discriminated union schemas, the generated `fields` object is a union of field objects. TypeScript only allows access to properties that exist on ALL variants - meaning variant-specific fields are inaccessible, and `.as("radio", value)` doesn't work.
|
|
273
|
+
|
|
274
|
+
The `discriminated()` function wraps your form fields to:
|
|
129
275
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
276
|
+
1. Make all variant fields accessible
|
|
277
|
+
2. Add a `${key}Value` property that enables TypeScript narrowing
|
|
278
|
+
3. Provide a type-safe `set()` method for programmatic updates
|
|
279
|
+
4. Fix `.as("radio", value)` to accept only valid discriminator values
|
|
280
|
+
|
|
281
|
+
The following example demonstrates conditionally rendering variant-specific fields with type-safe narrowing, without using `FieldVariants`. This approach requires JavaScript (unlike `FieldVariants` which works without JS):
|
|
282
|
+
|
|
283
|
+
```svelte
|
|
284
|
+
<script lang="ts">
|
|
285
|
+
import { shapeForm } from './data.remote';
|
|
286
|
+
import { discriminated } from 'sveltekit-discriminated-fields';
|
|
287
|
+
|
|
288
|
+
const shape = $derived(discriminated('kind', shapeForm.fields));
|
|
289
|
+
</script>
|
|
290
|
+
|
|
291
|
+
<!-- Use kindValue (not kind.value()) for type narrowing -->
|
|
292
|
+
{#if shape.kindValue === 'circle'}
|
|
293
|
+
<input {...shape.radius.as('number')} /> <!-- TypeScript knows radius exists -->
|
|
294
|
+
{:else if shape.kindValue === 'rectangle'}
|
|
295
|
+
<input {...shape.width.as('number')} /> <!-- TypeScript knows width exists -->
|
|
296
|
+
<input {...shape.height.as('number')} />
|
|
138
297
|
{/if}
|
|
139
298
|
```
|
|
140
299
|
|
|
300
|
+
See the [programmatic-form example](./test/src/routes/programmatic-form) for usage of `set()` and other programmatic features.
|
|
301
|
+
|
|
141
302
|
## API
|
|
142
303
|
|
|
143
|
-
### `
|
|
304
|
+
### `FieldVariants`
|
|
305
|
+
|
|
306
|
+
A component for rendering variant-specific form sections with CSS-only visibility.
|
|
307
|
+
|
|
308
|
+
**Props:**
|
|
309
|
+
|
|
310
|
+
| Prop | Type | Description |
|
|
311
|
+
| --------- | --------------------- | --------------------------------------------------------- |
|
|
312
|
+
| `fields` | `DiscriminatedFields` | Form fields (raw or wrapped with `discriminated()`) |
|
|
313
|
+
| `key` | `string` | The discriminator key (must match a field in the schema) |
|
|
314
|
+
| `partial` | `boolean` (optional) | Allow missing snippets for some variants (default: false) |
|
|
315
|
+
| `css` | `boolean` (optional) | Enable CSS visibility generation (default: true) |
|
|
316
|
+
|
|
317
|
+
**Snippets:**
|
|
318
|
+
|
|
319
|
+
- `fallback(props)` - Rendered when no variant is selected. Spread `props` onto your element.
|
|
320
|
+
- `{variantName}(v)` - One snippet per variant. Spread `v` onto your container, access fields via `v.fields`.
|
|
321
|
+
|
|
322
|
+
### `discriminated(key, fields)`
|
|
144
323
|
|
|
145
324
|
Wraps discriminated union form fields for type-safe narrowing.
|
|
146
325
|
|
|
147
326
|
**Parameters:**
|
|
148
327
|
|
|
149
|
-
- `key` - The discriminator key (
|
|
328
|
+
- `key` - The discriminator key (must exist as a field in all variants)
|
|
150
329
|
- `fields` - Form fields from a discriminated union schema
|
|
151
330
|
|
|
152
331
|
**Returns:** A proxy object with:
|
|
153
332
|
|
|
154
333
|
- All original fields passed through unchanged
|
|
155
334
|
- `${key}Value` - The current discriminator value (for narrowing)
|
|
156
|
-
- `set()` - Type-safe setter that infers variant from discriminator
|
|
335
|
+
- `set(data)` - Type-safe setter that infers variant from discriminator
|
|
336
|
+
- `allIssues()` - All validation issues for the discriminated fields
|
|
157
337
|
|
|
158
338
|
### `DiscriminatedData<T>`
|
|
159
339
|
|
|
160
340
|
Type helper that extracts the underlying data type from wrapped fields:
|
|
161
341
|
|
|
162
342
|
```typescript
|
|
163
|
-
const payment =
|
|
343
|
+
const payment = discriminated("type", form.fields);
|
|
164
344
|
type Payment = DiscriminatedData<typeof payment>;
|
|
165
|
-
// { type: 'card'; cardNumber: string; cvv: string } | { type: 'bank';
|
|
345
|
+
// { type: 'card'; cardNumber: string; cvv: string } | { type: 'bank'; ... }
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
### `VariantSnippetArg<T>`
|
|
349
|
+
|
|
350
|
+
Type for the argument passed to variant snippets:
|
|
351
|
+
|
|
352
|
+
```typescript
|
|
353
|
+
// v can be spread onto elements and has a .fields property
|
|
354
|
+
type VariantSnippetArg<T> = VariantProps & { readonly fields: T };
|
|
166
355
|
```
|
|
167
356
|
|
|
168
|
-
|
|
357
|
+
## Migration from v0.1
|
|
169
358
|
|
|
170
|
-
|
|
359
|
+
If upgrading from v0.1:
|
|
171
360
|
|
|
172
|
-
|
|
361
|
+
1. Rename `UnionVariants` to `FieldVariants`
|
|
362
|
+
2. Rename `discriminatedFields()` to `discriminated()`
|
|
363
|
+
3. Remove the `selector` prop (no longer needed - `form:has()` handles all layouts)
|
|
364
|
+
4. Update snippet signatures:
|
|
365
|
+
- Old: `{#snippet circle(fields)} <input {...fields.radius.as('number')} />`
|
|
366
|
+
- New: `{#snippet circle(v)} <div {...v}><input {...v.fields.radius.as('number')} /></div>`
|
|
367
|
+
5. Add `props` parameter to fallback and spread it: `{#snippet fallback(props)} <p {...props}>...</p>`
|
|
173
368
|
|
|
174
369
|
## License
|
|
175
370
|
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
import type { RemoteFormField, RemoteFormFieldValue, RemoteFormFields } from "@sveltejs/kit";
|
|
4
|
+
|
|
5
|
+
// =============================================================================
|
|
6
|
+
// Core types - work with data type D instead of field object type T
|
|
7
|
+
// =============================================================================
|
|
8
|
+
|
|
9
|
+
// Extract data type from fields' set method
|
|
10
|
+
type Data<T> = T extends { set: (v: infer D) => unknown } ? D : never;
|
|
11
|
+
|
|
12
|
+
// Discriminator values from data (distributes over D)
|
|
13
|
+
type Values<K extends string, D> = D extends Record<K, infer V> ? (V extends string ? V : never) : never;
|
|
14
|
+
|
|
15
|
+
// Narrow D to variant where discriminator equals V
|
|
16
|
+
type NarrowData<K extends string, D, V> = D extends Record<K, V> ? D : never;
|
|
17
|
+
|
|
18
|
+
// Common keys (in EVERY variant) - for a union, keyof gives intersection
|
|
19
|
+
type CommonKeys<D> = keyof D;
|
|
20
|
+
|
|
21
|
+
// =============================================================================
|
|
22
|
+
// Validation - detect problematic field configurations
|
|
23
|
+
// =============================================================================
|
|
24
|
+
|
|
25
|
+
// Get all keys from ALL variants (distributes over union to collect all keys)
|
|
26
|
+
type AllDataKeys<D> = D extends D ? keyof D : never;
|
|
27
|
+
|
|
28
|
+
// Variants containing key P (distributes to filter union members)
|
|
29
|
+
// Checks if P is in keyof D (works for both required and optional properties)
|
|
30
|
+
type VariantsWithKey<D, P> = D extends D ? (P extends keyof D ? D : never) : never;
|
|
31
|
+
|
|
32
|
+
// Check if T is a union type (multiple members vs single type)
|
|
33
|
+
type IsUnion<T, U = T> = T extends T ? ([U] extends [T] ? false : true) : never;
|
|
34
|
+
|
|
35
|
+
// Key P is "partially shared" if:
|
|
36
|
+
// - NOT in all variants (not in CommonKeys)
|
|
37
|
+
// - In MORE THAN ONE variant (VariantsWithKey is a union)
|
|
38
|
+
// Keys in exactly 1 variant are fine (variant-specific)
|
|
39
|
+
type IsPartiallyShared<D, P> = P extends CommonKeys<D>
|
|
40
|
+
? false // In all variants - OK (common field)
|
|
41
|
+
: IsUnion<VariantsWithKey<D, P>>; // true if 2+ variants have this key
|
|
42
|
+
|
|
43
|
+
// Find all partially shared keys (in 2 to N-1 variants, not variant-specific)
|
|
44
|
+
type PartiallySharedKeys<K extends string, D> = {
|
|
45
|
+
[P in Exclude<AllDataKeys<D>, K>]: IsPartiallyShared<D, P> extends true ? P : never;
|
|
46
|
+
}[Exclude<AllDataKeys<D>, K>];
|
|
47
|
+
|
|
48
|
+
// Check if types match across variants for a given key
|
|
49
|
+
type TypeAt<D, P extends PropertyKey> = D extends Record<P, infer V> ? V : never;
|
|
50
|
+
|
|
51
|
+
// For mixed type detection, check if the type varies across variants
|
|
52
|
+
type HasMixedTypes<D, P extends PropertyKey> = IsUnion<TypeAt<D, P>>;
|
|
53
|
+
|
|
54
|
+
// Common keys with different types across variants
|
|
55
|
+
type MixedTypeKeys<K extends string, D, CK extends PropertyKey = Exclude<CommonKeys<D>, K>> = CK extends CK
|
|
56
|
+
? HasMixedTypes<D, CK> extends true
|
|
57
|
+
? CK
|
|
58
|
+
: never
|
|
59
|
+
: never;
|
|
60
|
+
|
|
61
|
+
// Validation - check for both partially shared keys AND mixed types
|
|
62
|
+
type Validate<K extends string, D, T> = [PartiallySharedKeys<K, D>] extends [never]
|
|
63
|
+
? [MixedTypeKeys<K, D>] extends [never]
|
|
64
|
+
? T
|
|
65
|
+
: `Error: Field '${MixedTypeKeys<K, D> & string}' has different types across variants. Common fields must have the same type in all variants.`
|
|
66
|
+
: `Error: Field '${PartiallySharedKeys<K, D> & string}' exists in some variants but not all. Fields must be either unique to one variant or common to all variants.`;
|
|
67
|
+
|
|
68
|
+
// =============================================================================
|
|
69
|
+
// Snippet types - build field objects from data
|
|
70
|
+
// =============================================================================
|
|
71
|
+
|
|
72
|
+
// Reserved prop names
|
|
73
|
+
type Reserved = "fields" | "key" | "fallback" | "partial" | "css";
|
|
74
|
+
|
|
75
|
+
// Build field type - uses SvelteKit's RemoteFormFields for nested objects
|
|
76
|
+
// This correctly includes set(), value(), issues(), allIssues() on nested containers
|
|
77
|
+
type Field<V> = [V] extends [object]
|
|
78
|
+
? RemoteFormFields<V>
|
|
79
|
+
: RemoteFormField<V & RemoteFormFieldValue>;
|
|
80
|
+
|
|
81
|
+
// Variant snippet fields: narrowed data → field object, excluding common fields
|
|
82
|
+
type SnippetFields<K extends string, D, V extends string, Narrow = NarrowData<K, D, V>> = {
|
|
83
|
+
readonly [P in Exclude<keyof Narrow, K | CommonKeys<D>>]: Field<Narrow[P & keyof Narrow]>;
|
|
84
|
+
} & {
|
|
85
|
+
readonly [P in K]: Field<V>;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Props passed to variant snippets for CSS targeting (spread onto wrapper element)
|
|
89
|
+
export type VariantProps = { "data-fv"?: string };
|
|
90
|
+
|
|
91
|
+
// Combined variant snippet argument - spread for props, access .fields for variant data
|
|
92
|
+
// Mirrors form pattern: <form {...form}> + form.fields.x → <div {...v}> + v.fields.x
|
|
93
|
+
export type VariantSnippetArg<F> = VariantProps & { readonly fields: F };
|
|
94
|
+
|
|
95
|
+
// Snippet props - one snippet per variant, receives single arg with .fields property
|
|
96
|
+
type Snippets<K extends string, D, V extends string, IsPartial extends boolean> = IsPartial extends true
|
|
97
|
+
? { [P in Exclude<V, Reserved>]?: Snippet<[VariantSnippetArg<SnippetFields<K, D, P>>]> }
|
|
98
|
+
: { [P in Exclude<V, Reserved>]: Snippet<[VariantSnippetArg<SnippetFields<K, D, P>>]> };
|
|
99
|
+
|
|
100
|
+
// =============================================================================
|
|
101
|
+
// Component props
|
|
102
|
+
// =============================================================================
|
|
103
|
+
|
|
104
|
+
export type FieldVariantsProps<
|
|
105
|
+
K extends string,
|
|
106
|
+
T extends { set: (v: never) => unknown } & Record<K, { value(): unknown }>,
|
|
107
|
+
V extends string = Values<K, Data<T>>,
|
|
108
|
+
IsPartial extends boolean = false,
|
|
109
|
+
> = {
|
|
110
|
+
fields: Validate<K, Data<T>, T>;
|
|
111
|
+
key: K;
|
|
112
|
+
/** Optional snippet shown when no variant is selected. Receives (props) for CSS targeting. */
|
|
113
|
+
fallback?: Snippet<[VariantProps]>;
|
|
114
|
+
/** When true, variant snippets are optional (default: false) */
|
|
115
|
+
partial?: IsPartial;
|
|
116
|
+
/** Set to false to disable CSS generation (default: true) */
|
|
117
|
+
css?: boolean;
|
|
118
|
+
} & Snippets<K, Data<T>, V, IsPartial>;
|
|
119
|
+
</script>
|
|
120
|
+
|
|
121
|
+
<script
|
|
122
|
+
lang="ts"
|
|
123
|
+
generics="K extends string, T extends { set: (v: never) => unknown } & Record<K, { value(): unknown }>, V extends string = Values<K, Data<T>>, IsPartial extends boolean = false"
|
|
124
|
+
>
|
|
125
|
+
import { onMount } from "svelte";
|
|
126
|
+
|
|
127
|
+
type Props = FieldVariantsProps<K, T, V, IsPartial>;
|
|
128
|
+
|
|
129
|
+
let { fields, key, fallback, partial, css: cssEnabled = true, ...snippets }: Props = $props();
|
|
130
|
+
|
|
131
|
+
// Get all variant values from the snippet names
|
|
132
|
+
const variantValues = Object.keys(snippets) as V[];
|
|
133
|
+
|
|
134
|
+
// After hydration, switch from CSS-only to JS-based conditional rendering
|
|
135
|
+
// This enables Svelte transitions and other JS-dependent features
|
|
136
|
+
let hydrated = $state(false);
|
|
137
|
+
onMount(() => {
|
|
138
|
+
hydrated = true;
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Get the current discriminator value
|
|
142
|
+
const currentValue = $derived.by(() => {
|
|
143
|
+
const discriminatorField = (fields as Record<K, { value(): unknown }>)[key];
|
|
144
|
+
return discriminatorField?.value() as V | "" | undefined;
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Get the actual field name from the discriminator field (used for CSS selectors)
|
|
148
|
+
const fieldName = $derived.by(() => {
|
|
149
|
+
const discriminatorField = (fields as Record<K, { as(type: "select"): { name: string } }>)[key];
|
|
150
|
+
return discriminatorField?.as("select")?.name ?? key;
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Generate CSS for showing/hiding variants based on select or radio value
|
|
154
|
+
// Uses form:has() to scope to the containing form - works regardless of DOM structure
|
|
155
|
+
const css = $derived.by(() => {
|
|
156
|
+
if (!cssEnabled) return "";
|
|
157
|
+
|
|
158
|
+
const name = fieldName;
|
|
159
|
+
// Use .fieldName.variant format - the leading dot prevents collisions
|
|
160
|
+
const fv = (v: string) => `[data-fv=".${name}.${v}"]`;
|
|
161
|
+
const fvPrefix = `[data-fv^=".${name}."]`;
|
|
162
|
+
|
|
163
|
+
// form:has() scopes to the containing form, name-based targeting is precise
|
|
164
|
+
const sel = (v: string) => `form:has(select[name="${name}"] option[value="${v}"]:checked)`;
|
|
165
|
+
const rad = (v: string) => `form:has(input[name="${name}"][value="${v}"]:checked)`;
|
|
166
|
+
|
|
167
|
+
// Hide all variant sections by default (starts-with selector matches all variants for this field)
|
|
168
|
+
const hideAll = `form:has([name="${name}"]) ${fvPrefix}:not(${fv("fallback")}) { display: none; }\n`;
|
|
169
|
+
|
|
170
|
+
// Show the variant section that matches the selected value
|
|
171
|
+
const showVariants = variantValues
|
|
172
|
+
.map((v) => `${sel(v)} ${fv(v)},
|
|
173
|
+
${rad(v)} ${fv(v)} { display: contents; }`)
|
|
174
|
+
.join("\n");
|
|
175
|
+
|
|
176
|
+
// Hide fallback when ANY value is selected (not just known variants)
|
|
177
|
+
// This checks for any non-empty option selected or any radio checked
|
|
178
|
+
const hideFallback = fallback
|
|
179
|
+
? `form:has(select[name="${name}"] option:checked:not([value=""])) ${fv("fallback")},
|
|
180
|
+
form:has(input[name="${name}"]:checked) ${fv("fallback")} { display: none; }\n`
|
|
181
|
+
: "";
|
|
182
|
+
|
|
183
|
+
return hideAll + showVariants + hideFallback;
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Props to add to wrapper elements when CSS is enabled - uses .fieldName.variant format
|
|
187
|
+
const wrapperProps = (variant: string): VariantProps =>
|
|
188
|
+
cssEnabled ? { "data-fv": `.${fieldName}.${variant}` } : {};
|
|
189
|
+
|
|
190
|
+
// Create snippet argument with non-enumerable fields property
|
|
191
|
+
// This allows {...v} to only spread the CSS props, while v.fields is still accessible
|
|
192
|
+
const createSnippetArg = (variant: string, variantFields: T): VariantSnippetArg<T> => {
|
|
193
|
+
const arg = { ...wrapperProps(variant) };
|
|
194
|
+
Object.defineProperty(arg, "fields", {
|
|
195
|
+
value: variantFields,
|
|
196
|
+
enumerable: false,
|
|
197
|
+
configurable: false,
|
|
198
|
+
writable: false,
|
|
199
|
+
});
|
|
200
|
+
return arg as VariantSnippetArg<T>;
|
|
201
|
+
};
|
|
202
|
+
</script>
|
|
203
|
+
|
|
204
|
+
<svelte:head>
|
|
205
|
+
{#if css && !hydrated}
|
|
206
|
+
<!-- CSS-only visibility before JS hydrates -->
|
|
207
|
+
{@html `<style>${css}</style>`}
|
|
208
|
+
{/if}
|
|
209
|
+
</svelte:head>
|
|
210
|
+
|
|
211
|
+
{#if hydrated}
|
|
212
|
+
<!-- After hydration: JS-based conditional rendering (enables transitions) -->
|
|
213
|
+
<!-- Fallback shows when no value is selected (empty string or undefined) -->
|
|
214
|
+
{#if !currentValue && fallback}
|
|
215
|
+
{@render fallback(wrapperProps("fallback"))}
|
|
216
|
+
{/if}
|
|
217
|
+
|
|
218
|
+
{#each variantValues as v (v)}
|
|
219
|
+
{#if currentValue === v}
|
|
220
|
+
{@const snippet = (snippets as unknown as Record<V, Snippet<[VariantSnippetArg<T>]>>)[v]}
|
|
221
|
+
{@render snippet(createSnippetArg(v, fields as T))}
|
|
222
|
+
{/if}
|
|
223
|
+
{/each}
|
|
224
|
+
{:else}
|
|
225
|
+
<!-- Before hydration: render all, CSS handles visibility -->
|
|
226
|
+
{#if fallback}
|
|
227
|
+
{@render fallback(wrapperProps("fallback"))}
|
|
228
|
+
{/if}
|
|
229
|
+
|
|
230
|
+
{#each variantValues as v (v)}
|
|
231
|
+
{@const snippet = (snippets as unknown as Record<V, Snippet<[VariantSnippetArg<T>]>>)[v]}
|
|
232
|
+
{@render snippet(createSnippetArg(v, fields as T))}
|
|
233
|
+
{/each}
|
|
234
|
+
{/if}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { Snippet } from "svelte";
|
|
2
|
+
import type { RemoteFormField, RemoteFormFieldValue, RemoteFormFields } from "@sveltejs/kit";
|
|
3
|
+
type Data<T> = T extends {
|
|
4
|
+
set: (v: infer D) => unknown;
|
|
5
|
+
} ? D : never;
|
|
6
|
+
type Values<K extends string, D> = D extends Record<K, infer V> ? (V extends string ? V : never) : never;
|
|
7
|
+
type NarrowData<K extends string, D, V> = D extends Record<K, V> ? D : never;
|
|
8
|
+
type CommonKeys<D> = keyof D;
|
|
9
|
+
type AllDataKeys<D> = D extends D ? keyof D : never;
|
|
10
|
+
type VariantsWithKey<D, P> = D extends D ? (P extends keyof D ? D : never) : never;
|
|
11
|
+
type IsUnion<T, U = T> = T extends T ? ([U] extends [T] ? false : true) : never;
|
|
12
|
+
type IsPartiallyShared<D, P> = P extends CommonKeys<D> ? false : IsUnion<VariantsWithKey<D, P>>;
|
|
13
|
+
type PartiallySharedKeys<K extends string, D> = {
|
|
14
|
+
[P in Exclude<AllDataKeys<D>, K>]: IsPartiallyShared<D, P> extends true ? P : never;
|
|
15
|
+
}[Exclude<AllDataKeys<D>, K>];
|
|
16
|
+
type TypeAt<D, P extends PropertyKey> = D extends Record<P, infer V> ? V : never;
|
|
17
|
+
type HasMixedTypes<D, P extends PropertyKey> = IsUnion<TypeAt<D, P>>;
|
|
18
|
+
type MixedTypeKeys<K extends string, D, CK extends PropertyKey = Exclude<CommonKeys<D>, K>> = CK extends CK ? HasMixedTypes<D, CK> extends true ? CK : never : never;
|
|
19
|
+
type Validate<K extends string, D, T> = [PartiallySharedKeys<K, D>] extends [never] ? [MixedTypeKeys<K, D>] extends [never] ? T : `Error: Field '${MixedTypeKeys<K, D> & string}' has different types across variants. Common fields must have the same type in all variants.` : `Error: Field '${PartiallySharedKeys<K, D> & string}' exists in some variants but not all. Fields must be either unique to one variant or common to all variants.`;
|
|
20
|
+
type Reserved = "fields" | "key" | "fallback" | "partial" | "css";
|
|
21
|
+
type Field<V> = [V] extends [object] ? RemoteFormFields<V> : RemoteFormField<V & RemoteFormFieldValue>;
|
|
22
|
+
type SnippetFields<K extends string, D, V extends string, Narrow = NarrowData<K, D, V>> = {
|
|
23
|
+
readonly [P in Exclude<keyof Narrow, K | CommonKeys<D>>]: Field<Narrow[P & keyof Narrow]>;
|
|
24
|
+
} & {
|
|
25
|
+
readonly [P in K]: Field<V>;
|
|
26
|
+
};
|
|
27
|
+
export type VariantProps = {
|
|
28
|
+
"data-fv"?: string;
|
|
29
|
+
};
|
|
30
|
+
export type VariantSnippetArg<F> = VariantProps & {
|
|
31
|
+
readonly fields: F;
|
|
32
|
+
};
|
|
33
|
+
type Snippets<K extends string, D, V extends string, IsPartial extends boolean> = IsPartial extends true ? {
|
|
34
|
+
[P in Exclude<V, Reserved>]?: Snippet<[VariantSnippetArg<SnippetFields<K, D, P>>]>;
|
|
35
|
+
} : {
|
|
36
|
+
[P in Exclude<V, Reserved>]: Snippet<[VariantSnippetArg<SnippetFields<K, D, P>>]>;
|
|
37
|
+
};
|
|
38
|
+
export type FieldVariantsProps<K extends string, T extends {
|
|
39
|
+
set: (v: never) => unknown;
|
|
40
|
+
} & Record<K, {
|
|
41
|
+
value(): unknown;
|
|
42
|
+
}>, V extends string = Values<K, Data<T>>, IsPartial extends boolean = false> = {
|
|
43
|
+
fields: Validate<K, Data<T>, T>;
|
|
44
|
+
key: K;
|
|
45
|
+
/** Optional snippet shown when no variant is selected. Receives (props) for CSS targeting. */
|
|
46
|
+
fallback?: Snippet<[VariantProps]>;
|
|
47
|
+
/** When true, variant snippets are optional (default: false) */
|
|
48
|
+
partial?: IsPartial;
|
|
49
|
+
/** Set to false to disable CSS generation (default: true) */
|
|
50
|
+
css?: boolean;
|
|
51
|
+
} & Snippets<K, Data<T>, V, IsPartial>;
|
|
52
|
+
declare function $$render<K extends string, T extends {
|
|
53
|
+
set: (v: never) => unknown;
|
|
54
|
+
} & Record<K, {
|
|
55
|
+
value(): unknown;
|
|
56
|
+
}>, V extends string = Values<K, Data<T>>, IsPartial extends boolean = false>(): {
|
|
57
|
+
props: FieldVariantsProps<K, T, V, IsPartial>;
|
|
58
|
+
exports: {};
|
|
59
|
+
bindings: "";
|
|
60
|
+
slots: {};
|
|
61
|
+
events: {};
|
|
62
|
+
};
|
|
63
|
+
declare class __sveltets_Render<K extends string, T extends {
|
|
64
|
+
set: (v: never) => unknown;
|
|
65
|
+
} & Record<K, {
|
|
66
|
+
value(): unknown;
|
|
67
|
+
}>, V extends string = Values<K, Data<T>>, IsPartial extends boolean = false> {
|
|
68
|
+
props(): ReturnType<typeof $$render<K, T, V, IsPartial>>['props'];
|
|
69
|
+
events(): ReturnType<typeof $$render<K, T, V, IsPartial>>['events'];
|
|
70
|
+
slots(): ReturnType<typeof $$render<K, T, V, IsPartial>>['slots'];
|
|
71
|
+
bindings(): "";
|
|
72
|
+
exports(): {};
|
|
73
|
+
}
|
|
74
|
+
interface $$IsomorphicComponent {
|
|
75
|
+
new <K extends string, T extends {
|
|
76
|
+
set: (v: never) => unknown;
|
|
77
|
+
} & Record<K, {
|
|
78
|
+
value(): unknown;
|
|
79
|
+
}>, V extends string = Values<K, Data<T>>, IsPartial extends boolean = false>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<K, T, V, IsPartial>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<K, T, V, IsPartial>['props']>, ReturnType<__sveltets_Render<K, T, V, IsPartial>['events']>, ReturnType<__sveltets_Render<K, T, V, IsPartial>['slots']>> & {
|
|
80
|
+
$$bindings?: ReturnType<__sveltets_Render<K, T, V, IsPartial>['bindings']>;
|
|
81
|
+
} & ReturnType<__sveltets_Render<K, T, V, IsPartial>['exports']>;
|
|
82
|
+
<K extends string, T extends {
|
|
83
|
+
set: (v: never) => unknown;
|
|
84
|
+
} & Record<K, {
|
|
85
|
+
value(): unknown;
|
|
86
|
+
}>, V extends string = Values<K, Data<T>>, IsPartial extends boolean = false>(internal: unknown, props: ReturnType<__sveltets_Render<K, T, V, IsPartial>['props']> & {}): ReturnType<__sveltets_Render<K, T, V, IsPartial>['exports']>;
|
|
87
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any, any, any, any>['bindings']>;
|
|
88
|
+
}
|
|
89
|
+
declare const FieldVariants: $$IsomorphicComponent;
|
|
90
|
+
type FieldVariants<K extends string, T extends {
|
|
91
|
+
set: (v: never) => unknown;
|
|
92
|
+
} & Record<K, {
|
|
93
|
+
value(): unknown;
|
|
94
|
+
}>, V extends string = Values<K, Data<T>>, IsPartial extends boolean = false> = InstanceType<typeof FieldVariants<K, T, V, IsPartial>>;
|
|
95
|
+
export default FieldVariants;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { RemoteFormField, RemoteFormFields, RemoteFormFieldType, RemoteFormFieldValue } from '@sveltejs/kit';
|
|
2
|
+
export type DiscriminatedData<T> = T extends {
|
|
3
|
+
set: (v: infer D) => unknown;
|
|
4
|
+
} ? D : never;
|
|
5
|
+
type DiscriminatorValues<K extends string, D> = D extends Record<K, infer V> ? Exclude<V, undefined> : never;
|
|
6
|
+
type RadioProps = RemoteFormField<string> extends {
|
|
7
|
+
as(type: 'radio', value: string): infer R;
|
|
8
|
+
} ? R : never;
|
|
9
|
+
type OptionProps = {
|
|
10
|
+
value: string;
|
|
11
|
+
};
|
|
12
|
+
type VariantDiscriminatorField<V extends string, AllV extends string> = Omit<RemoteFormField<V>, 'as'> & {
|
|
13
|
+
as(type: 'radio', value: AllV): RadioProps;
|
|
14
|
+
as(type: 'option'): OptionProps;
|
|
15
|
+
as(type: 'option', value: AllV): OptionProps;
|
|
16
|
+
as(type: Exclude<RemoteFormFieldType<V>, 'radio'>, ...args: unknown[]): ReturnType<RemoteFormField<V>['as']>;
|
|
17
|
+
};
|
|
18
|
+
type NestedField<V> = [V] extends [object] ? RemoteFormFields<V> : RemoteFormField<V & RemoteFormFieldValue>;
|
|
19
|
+
type VariantFields<K extends string, D, AllV extends string> = D extends Record<K, infer V> ? {
|
|
20
|
+
readonly [P in `${K}Value`]: V;
|
|
21
|
+
} & {
|
|
22
|
+
readonly [P in K]: VariantDiscriminatorField<V & string, AllV>;
|
|
23
|
+
} & {
|
|
24
|
+
readonly [P in Exclude<keyof D, K>]: NestedField<D[P & keyof D]>;
|
|
25
|
+
} : never;
|
|
26
|
+
type UndefinedVariant<K extends string, D, AllV extends string> = {
|
|
27
|
+
readonly [P in `${K}Value`]: undefined;
|
|
28
|
+
} & {
|
|
29
|
+
readonly [P in K]: VariantDiscriminatorField<AllV, AllV>;
|
|
30
|
+
} & {
|
|
31
|
+
readonly [P in Exclude<keyof D, K>]: NestedField<D[P & keyof D]>;
|
|
32
|
+
};
|
|
33
|
+
type SetMethod<K extends string, D> = {
|
|
34
|
+
set: <V extends DiscriminatorValues<K, D>>(data: Extract<D, Record<K, V>>) => void;
|
|
35
|
+
};
|
|
36
|
+
type CommonMethods = RemoteFormFields<unknown> extends {
|
|
37
|
+
allIssues: infer M;
|
|
38
|
+
} ? {
|
|
39
|
+
allIssues: M;
|
|
40
|
+
} : never;
|
|
41
|
+
type DiscriminatedFields<K extends string, D, AllV extends string = DiscriminatorValues<K, D> & string> = (VariantFields<K, D, AllV> | UndefinedVariant<K, D, AllV>) & SetMethod<K, D> & CommonMethods;
|
|
42
|
+
/**
|
|
43
|
+
* Marks discriminated union form fields for type-safe narrowing.
|
|
44
|
+
* - All original fields pass through unchanged (type, issues, allIssues, etc.)
|
|
45
|
+
* - `set` is overridden with type-safe version
|
|
46
|
+
* - `${key}Value` is added for discriminator value (e.g., `reward.typeValue`)
|
|
47
|
+
* - Discriminator field `.as("radio", value)` is type-safe (only valid values allowed)
|
|
48
|
+
* - Discriminator field `.as("option", value?)` is type-safe for select options
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```svelte
|
|
52
|
+
* <script>
|
|
53
|
+
* const priority = $derived(discriminated("level", priorityForm.fields));
|
|
54
|
+
* </script>
|
|
55
|
+
*
|
|
56
|
+
* <input {...priority.level.as("radio", "high")} /> <!-- type-safe: only valid values allowed -->
|
|
57
|
+
*
|
|
58
|
+
* <select {...priority.level.as("select")}>
|
|
59
|
+
* <option {...priority.level.as("option")}>Select...</option>
|
|
60
|
+
* <option {...priority.level.as("option", "high")}>High</option>
|
|
61
|
+
* </select>
|
|
62
|
+
* ```
|
|
63
|
+
*
|
|
64
|
+
* @param key - Discriminator key (e.g. 'type')
|
|
65
|
+
* @param fields - Form fields from a discriminated union schema
|
|
66
|
+
* @returns Passthrough object with type-safe set(), ${key}Value, .as("radio", value), and .as("option", value?)
|
|
67
|
+
*/
|
|
68
|
+
export declare function discriminated<K extends string, T extends {
|
|
69
|
+
set: (v: never) => unknown;
|
|
70
|
+
} & Record<K, {
|
|
71
|
+
value(): unknown;
|
|
72
|
+
as(type: 'radio', value: string): object;
|
|
73
|
+
}>>(key: K, fields: T): DiscriminatedFields<K, DiscriminatedData<T>>;
|
|
74
|
+
export {};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Main function
|
|
3
|
+
// =============================================================================
|
|
4
|
+
/**
|
|
5
|
+
* Marks discriminated union form fields for type-safe narrowing.
|
|
6
|
+
* - All original fields pass through unchanged (type, issues, allIssues, etc.)
|
|
7
|
+
* - `set` is overridden with type-safe version
|
|
8
|
+
* - `${key}Value` is added for discriminator value (e.g., `reward.typeValue`)
|
|
9
|
+
* - Discriminator field `.as("radio", value)` is type-safe (only valid values allowed)
|
|
10
|
+
* - Discriminator field `.as("option", value?)` is type-safe for select options
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```svelte
|
|
14
|
+
* <script>
|
|
15
|
+
* const priority = $derived(discriminated("level", priorityForm.fields));
|
|
16
|
+
* </script>
|
|
17
|
+
*
|
|
18
|
+
* <input {...priority.level.as("radio", "high")} /> <!-- type-safe: only valid values allowed -->
|
|
19
|
+
*
|
|
20
|
+
* <select {...priority.level.as("select")}>
|
|
21
|
+
* <option {...priority.level.as("option")}>Select...</option>
|
|
22
|
+
* <option {...priority.level.as("option", "high")}>High</option>
|
|
23
|
+
* </select>
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* @param key - Discriminator key (e.g. 'type')
|
|
27
|
+
* @param fields - Form fields from a discriminated union schema
|
|
28
|
+
* @returns Passthrough object with type-safe set(), ${key}Value, .as("radio", value), and .as("option", value?)
|
|
29
|
+
*/
|
|
30
|
+
export function discriminated(key, fields) {
|
|
31
|
+
// Wrap the discriminator field to intercept as("option", value?) calls
|
|
32
|
+
const wrapDiscriminatorField = (field) => {
|
|
33
|
+
return new Proxy(field, {
|
|
34
|
+
get(fieldTarget, fieldProp) {
|
|
35
|
+
if (fieldProp === 'as') {
|
|
36
|
+
return (type, value) => {
|
|
37
|
+
if (type === 'option') {
|
|
38
|
+
return { value: value ?? '' };
|
|
39
|
+
}
|
|
40
|
+
return fieldTarget.as(type, value);
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return Reflect.get(fieldTarget, fieldProp);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
const proxy = new Proxy(fields, {
|
|
48
|
+
get(target, prop) {
|
|
49
|
+
if (prop === `${key}Value`)
|
|
50
|
+
return target[key].value();
|
|
51
|
+
if (prop === 'set')
|
|
52
|
+
return (data) => target.set(data);
|
|
53
|
+
if (prop === key)
|
|
54
|
+
return wrapDiscriminatorField(target[key]);
|
|
55
|
+
return Reflect.get(target, prop);
|
|
56
|
+
},
|
|
57
|
+
has(target, prop) {
|
|
58
|
+
return prop === `${key}Value` || prop in target;
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
return proxy;
|
|
62
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,39 +1,3 @@
|
|
|
1
|
-
export type DiscriminatedData
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
type Setter<K extends string, T, D = DiscriminatedData<T>> = {
|
|
5
|
-
set: <V extends D[K & keyof D]>(data: Extract<D, {
|
|
6
|
-
[P in K]: V;
|
|
7
|
-
}>) => void;
|
|
8
|
-
};
|
|
9
|
-
type Keys<T> = T extends unknown ? keyof T : never;
|
|
10
|
-
type KeyValue<K extends string, V> = {
|
|
11
|
-
[P in `${K}Value`]: V;
|
|
12
|
-
};
|
|
13
|
-
type Fields<T> = {
|
|
14
|
-
[P in Keys<T>]: T extends Record<P, infer V> ? V : never;
|
|
15
|
-
};
|
|
16
|
-
type Variant<K extends string, T, V> = KeyValue<K, V> & Fields<T>;
|
|
17
|
-
type Narrowed<K extends string, T> = T extends {
|
|
18
|
-
[P in K]: {
|
|
19
|
-
value(): infer V;
|
|
20
|
-
};
|
|
21
|
-
} ? Variant<K, T, V> : never;
|
|
22
|
-
type Union<K extends string, T> = Narrowed<K, T> | Variant<K, T, undefined>;
|
|
23
|
-
type DiscriminatedFields<K extends string, T> = Setter<K, T> & Union<K, T>;
|
|
24
|
-
/**
|
|
25
|
-
* Wraps discriminated union form fields, adding ${key}Value for type narrowing.
|
|
26
|
-
* - All original fields pass through unchanged (type, issues, allIssues, etc.)
|
|
27
|
-
* - `set` is overridden with type-safe version
|
|
28
|
-
* - `${key}Value` is added for discriminator value (e.g., `reward.typeValue`)
|
|
29
|
-
*
|
|
30
|
-
* @param key - Discriminator key (e.g. 'type')
|
|
31
|
-
* @param fields - Form fields from a discriminated union schema
|
|
32
|
-
* @returns Passthrough object with type-safe set() and ${key}Value for narrowing
|
|
33
|
-
*/
|
|
34
|
-
export declare function discriminatedFields<K extends string, T extends {
|
|
35
|
-
set: (v: never) => unknown;
|
|
36
|
-
} & Record<K, {
|
|
37
|
-
value(): unknown;
|
|
38
|
-
}>>(key: K, fields: T): DiscriminatedFields<K, T>;
|
|
39
|
-
export {};
|
|
1
|
+
export { discriminated, type DiscriminatedData } from './discriminated.js';
|
|
2
|
+
export { default as FieldVariants } from './FieldVariants.svelte';
|
|
3
|
+
export type { FieldVariantsProps, VariantProps, VariantSnippetArg } from './FieldVariants.svelte';
|
package/dist/index.js
CHANGED
|
@@ -1,25 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
* - `${key}Value` is added for discriminator value (e.g., `reward.typeValue`)
|
|
6
|
-
*
|
|
7
|
-
* @param key - Discriminator key (e.g. 'type')
|
|
8
|
-
* @param fields - Form fields from a discriminated union schema
|
|
9
|
-
* @returns Passthrough object with type-safe set() and ${key}Value for narrowing
|
|
10
|
-
*/
|
|
11
|
-
export function discriminatedFields(key, fields) {
|
|
12
|
-
const proxy = new Proxy(fields, {
|
|
13
|
-
get(target, prop) {
|
|
14
|
-
if (prop === `${key}Value`)
|
|
15
|
-
return target[key].value();
|
|
16
|
-
if (prop === 'set')
|
|
17
|
-
return (data) => target.set(data);
|
|
18
|
-
return Reflect.get(target, prop);
|
|
19
|
-
},
|
|
20
|
-
has(target, prop) {
|
|
21
|
-
return prop === `${key}Value` || prop in target;
|
|
22
|
-
}
|
|
23
|
-
});
|
|
24
|
-
return proxy;
|
|
25
|
-
}
|
|
1
|
+
// Re-export discriminated function and types
|
|
2
|
+
export { discriminated } from './discriminated.js';
|
|
3
|
+
// Re-export FieldVariants component and its types
|
|
4
|
+
export { default as FieldVariants } from './FieldVariants.svelte';
|
package/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sveltekit-discriminated-fields",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Type-safe discriminated union support for SvelteKit form fields",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"svelte": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
6
8
|
"exports": {
|
|
7
9
|
".": {
|
|
8
10
|
"types": "./dist/index.d.ts",
|
|
11
|
+
"svelte": "./dist/index.js",
|
|
9
12
|
"default": "./dist/index.js"
|
|
10
13
|
}
|
|
11
14
|
},
|
|
@@ -13,11 +16,10 @@
|
|
|
13
16
|
"dist"
|
|
14
17
|
],
|
|
15
18
|
"scripts": {
|
|
16
|
-
"build": "
|
|
17
|
-
"check": "
|
|
18
|
-
"test:types": "
|
|
19
|
-
"test
|
|
20
|
-
"test": "npm run test:types && npm run test:runtime",
|
|
19
|
+
"build": "svelte-package -i src -o dist",
|
|
20
|
+
"check": "svelte-check --tsconfig ./tsconfig.json",
|
|
21
|
+
"test:types": "npm run --prefix test build && svelte-check --tsconfig ./test/tsconfig.json",
|
|
22
|
+
"test": "npm run test:types && playwright test",
|
|
21
23
|
"prepublishOnly": "npm run test && npm run build"
|
|
22
24
|
},
|
|
23
25
|
"keywords": [
|
|
@@ -33,7 +35,18 @@
|
|
|
33
35
|
"type": "git",
|
|
34
36
|
"url": "https://github.com/robertadamsonsmith/sveltekit-discriminated-fields"
|
|
35
37
|
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@sveltejs/kit": "^2.27.0",
|
|
40
|
+
"svelte": "^5.0.0"
|
|
41
|
+
},
|
|
36
42
|
"devDependencies": {
|
|
43
|
+
"@playwright/test": "^1.57.0",
|
|
44
|
+
"@sveltejs/package": "^2.0.0",
|
|
45
|
+
"@types/node": "^25.0.3",
|
|
46
|
+
"@typescript-eslint/types": "^8.51.0",
|
|
47
|
+
"playwright": "^1.57.0",
|
|
48
|
+
"svelte": "^5.0.0",
|
|
49
|
+
"svelte-check": "^4.0.0",
|
|
37
50
|
"typescript": "^5.0.0"
|
|
38
51
|
}
|
|
39
52
|
}
|