sveltekit-discriminated-fields 0.2.0 → 0.4.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 +184 -84
- 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 +70 -0
- package/dist/index.d.ts +3 -4
- package/dist/index.js +4 -4
- package/package.json +1 -1
- package/dist/UnionVariants.svelte +0 -361
- package/dist/UnionVariants.svelte.d.ts +0 -115
- package/dist/discriminated-fields.d.ts +0 -70
- package/dist/discriminated-fields.js +0 -35
- package/dist/infer-discriminator.d.ts +0 -11
- package/dist/infer-discriminator.js +0 -5
package/README.md
CHANGED
|
@@ -4,8 +4,8 @@ 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
|
-
- **`
|
|
8
|
-
- **`
|
|
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)
|
|
9
9
|
|
|
10
10
|
The implementation prioritises:
|
|
11
11
|
|
|
@@ -22,9 +22,9 @@ This library exposes existing SvelteKit form behaviour with improved typing for
|
|
|
22
22
|
npm install sveltekit-discriminated-fields
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
##
|
|
25
|
+
## FieldVariants Component
|
|
26
26
|
|
|
27
|
-
The `
|
|
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
28
|
|
|
29
29
|
Given a SvelteKit remote function using Zod (this library also works with [Valibot](./test/src/routes/valibot-form) or other validation libraries):
|
|
30
30
|
|
|
@@ -34,8 +34,12 @@ import { z } from "zod";
|
|
|
34
34
|
import { form } from "$app/server";
|
|
35
35
|
|
|
36
36
|
const shapeSchema = z.discriminatedUnion("kind", [
|
|
37
|
-
z.object({ kind: z.literal("circle"), radius: z.
|
|
38
|
-
z.object({
|
|
37
|
+
z.object({ kind: z.literal("circle"), radius: z.number() }),
|
|
38
|
+
z.object({
|
|
39
|
+
kind: z.literal("rectangle"),
|
|
40
|
+
width: z.number(),
|
|
41
|
+
height: z.number(),
|
|
42
|
+
}),
|
|
39
43
|
z.object({ kind: z.literal("point") }),
|
|
40
44
|
]);
|
|
41
45
|
|
|
@@ -44,12 +48,12 @@ export const shapeForm = form(shapeSchema, async (data) => {
|
|
|
44
48
|
});
|
|
45
49
|
```
|
|
46
50
|
|
|
47
|
-
Use `
|
|
51
|
+
Use `FieldVariants` to render variant-specific fields:
|
|
48
52
|
|
|
49
53
|
```svelte
|
|
50
54
|
<script lang="ts">
|
|
51
55
|
import { shapeForm } from './data.remote';
|
|
52
|
-
import {
|
|
56
|
+
import { FieldVariants } from 'sveltekit-discriminated-fields';
|
|
53
57
|
</script>
|
|
54
58
|
|
|
55
59
|
<form {...shapeForm}>
|
|
@@ -60,79 +64,123 @@ Use `UnionVariants` to render variant-specific fields:
|
|
|
60
64
|
<option value="point">Point</option>
|
|
61
65
|
</select>
|
|
62
66
|
|
|
63
|
-
<
|
|
64
|
-
{#snippet fallback()}
|
|
65
|
-
<p>Please select a shape type above.</p>
|
|
67
|
+
<FieldVariants fields={shapeForm.fields} key="kind">
|
|
68
|
+
{#snippet fallback(props)}
|
|
69
|
+
<p {...props}>Please select a shape type above.</p>
|
|
66
70
|
{/snippet}
|
|
67
71
|
|
|
68
|
-
{#snippet circle(
|
|
69
|
-
<
|
|
72
|
+
{#snippet circle(shape)}
|
|
73
|
+
<label {...shape}>
|
|
74
|
+
Radius: <input {...shape.fields.radius.as('number')} />
|
|
75
|
+
</label>
|
|
70
76
|
{/snippet}
|
|
71
77
|
|
|
72
|
-
{#snippet rectangle(
|
|
73
|
-
<
|
|
74
|
-
|
|
78
|
+
{#snippet rectangle(shape)}
|
|
79
|
+
<div {...shape}>
|
|
80
|
+
<input {...shape.fields.width.as('number')} placeholder="Width" />
|
|
81
|
+
<input {...shape.fields.height.as('number')} placeholder="Height" />
|
|
82
|
+
</div>
|
|
75
83
|
{/snippet}
|
|
76
84
|
|
|
77
|
-
{#snippet point(
|
|
78
|
-
<p>Point has no additional fields.</p>
|
|
85
|
+
{#snippet point(shape)}
|
|
86
|
+
<p {...shape}>Point has no additional fields.</p>
|
|
79
87
|
{/snippet}
|
|
80
|
-
</
|
|
88
|
+
</FieldVariants>
|
|
81
89
|
|
|
82
90
|
<button type="submit">Submit</button>
|
|
83
91
|
</form>
|
|
84
92
|
```
|
|
85
93
|
|
|
86
|
-
|
|
94
|
+
### Snippet Parameters
|
|
87
95
|
|
|
88
|
-
Each snippet receives
|
|
96
|
+
Each variant snippet receives a single argument that mirrors how forms work in SvelteKit:
|
|
89
97
|
|
|
90
|
-
|
|
98
|
+
- **Spread for CSS targeting**: `{...shape}` - Adds the `data-fv` attribute for CSS visibility
|
|
99
|
+
- **Access fields**: `shape.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 `shape.fields.radius` exists in the `circle` snippet but not in `rectangle`. Only valid discriminator values are accepted.
|
|
106
|
+
|
|
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.
|
|
91
108
|
|
|
92
109
|
### Radio Buttons
|
|
93
110
|
|
|
94
|
-
For radio button discriminators, you must use the `
|
|
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:
|
|
95
112
|
|
|
96
113
|
```svelte
|
|
97
114
|
<script lang="ts">
|
|
98
115
|
import { shapeForm } from './data.remote';
|
|
99
|
-
import {
|
|
116
|
+
import { discriminated, FieldVariants } from 'sveltekit-discriminated-fields';
|
|
100
117
|
|
|
101
|
-
const shape = $derived(
|
|
118
|
+
const shape = $derived(discriminated(shapeForm.fields, 'kind'));
|
|
102
119
|
</script>
|
|
103
120
|
|
|
104
121
|
<form {...shapeForm}>
|
|
105
122
|
<fieldset>
|
|
106
|
-
<label><input {...shape.kind.as("radio", "circle")} /> Circle</label>
|
|
107
|
-
<label><input {...shape.kind.as("radio", "rectangle")} /> Rectangle</label>
|
|
108
|
-
<label><input {...shape.kind.as("radio", "point")} /> Point</label>
|
|
123
|
+
<label><input {...shape.fields.kind.as("radio", "circle")} /> Circle</label>
|
|
124
|
+
<label><input {...shape.fields.kind.as("radio", "rectangle")} /> Rectangle</label>
|
|
125
|
+
<label><input {...shape.fields.kind.as("radio", "point")} /> Point</label>
|
|
109
126
|
</fieldset>
|
|
110
127
|
|
|
111
|
-
<
|
|
112
|
-
|
|
113
|
-
|
|
128
|
+
<FieldVariants fields={shapeForm.fields} key="kind">
|
|
129
|
+
{#snippet fallback(props)}
|
|
130
|
+
<p {...props}>Select a shape type</p>
|
|
131
|
+
{/snippet}
|
|
132
|
+
|
|
133
|
+
{#snippet circle(shape)}
|
|
134
|
+
<label {...shape}>
|
|
135
|
+
Radius: <input {...shape.fields.radius.as('number')} />
|
|
136
|
+
</label>
|
|
137
|
+
{/snippet}
|
|
138
|
+
|
|
139
|
+
<!-- other snippets -->
|
|
140
|
+
</FieldVariants>
|
|
114
141
|
</form>
|
|
115
142
|
```
|
|
116
143
|
|
|
117
144
|
See the [radio-form example](./test/src/routes/radio-form) for a complete working example.
|
|
118
145
|
|
|
119
|
-
###
|
|
146
|
+
### Select Options
|
|
120
147
|
|
|
121
|
-
|
|
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:
|
|
122
149
|
|
|
123
150
|
```svelte
|
|
124
|
-
<
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
</
|
|
128
|
-
</
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
151
|
+
<select {...shape.fields.kind.as("select")}>
|
|
152
|
+
<!-- Type-safe: typos caught at compile time -->
|
|
153
|
+
<option {...shape.fields.kind.as("option")}>Select a shape...</option>
|
|
154
|
+
<option {...shape.fields.kind.as("option", "circle")}>Circle</option>
|
|
155
|
+
<option {...shape.fields.kind.as("option", "rectangle")}>Rectangle</option>
|
|
156
|
+
|
|
157
|
+
<!-- Also works: standard HTML (no type checking) -->
|
|
158
|
+
<option value="point">Point</option>
|
|
159
|
+
</select>
|
|
160
|
+
```
|
|
161
|
+
|
|
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>
|
|
136
184
|
```
|
|
137
185
|
|
|
138
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.
|
|
@@ -152,58 +200,100 @@ const orderSchema = z.object({
|
|
|
152
200
|
```
|
|
153
201
|
|
|
154
202
|
```svelte
|
|
155
|
-
<
|
|
156
|
-
|
|
157
|
-
|
|
203
|
+
<script lang="ts">
|
|
204
|
+
import { discriminated, FieldVariants } from 'sveltekit-discriminated-fields';
|
|
205
|
+
|
|
206
|
+
const shipping = $derived(discriminated(orderForm.fields.shipping, 'method'));
|
|
207
|
+
</script>
|
|
208
|
+
|
|
209
|
+
<FieldVariants fields={orderForm.fields.shipping} key="method">
|
|
210
|
+
{#snippet pickup(shipping)}
|
|
211
|
+
<input {...shipping} {...shipping.fields.store.as('text')} />
|
|
212
|
+
{/snippet}
|
|
213
|
+
|
|
214
|
+
{#snippet delivery(shipping)}
|
|
215
|
+
<input {...shipping} {...shipping.fields.address.as('text')} />
|
|
216
|
+
{/snippet}
|
|
217
|
+
</FieldVariants>
|
|
158
218
|
```
|
|
159
219
|
|
|
160
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.
|
|
161
221
|
|
|
162
222
|
### Partial Variants
|
|
163
223
|
|
|
164
|
-
By default, `
|
|
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}`:
|
|
165
225
|
|
|
166
226
|
```svelte
|
|
167
|
-
<
|
|
168
|
-
{#snippet circle(
|
|
169
|
-
<input {...
|
|
227
|
+
<FieldVariants fields={shapeForm.fields} key="kind" partial={true}>
|
|
228
|
+
{#snippet circle(shape)}
|
|
229
|
+
<input {...shape} {...shape.fields.radius.as('number')} />
|
|
230
|
+
{/snippet}
|
|
231
|
+
|
|
232
|
+
{#snippet rectangle(shape)}
|
|
233
|
+
<input {...shape} {...shape.fields.width.as('number')} />
|
|
170
234
|
{/snippet}
|
|
171
235
|
|
|
172
|
-
|
|
173
|
-
|
|
236
|
+
<!-- point snippet omitted - nothing shown when point selected -->
|
|
237
|
+
</FieldVariants>
|
|
238
|
+
```
|
|
239
|
+
|
|
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:
|
|
248
|
+
|
|
249
|
+
```svelte
|
|
250
|
+
<FieldVariants fields={shapeForm.fields} key="kind">
|
|
251
|
+
{#snippet circle(shape)}
|
|
252
|
+
<!-- Svelte transitions work after hydration -->
|
|
253
|
+
<div {...shape} transition:slide>
|
|
254
|
+
<input {...shape.fields.radius.as('number')} />
|
|
255
|
+
</div>
|
|
174
256
|
{/snippet}
|
|
257
|
+
</FieldVariants>
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### Disabling CSS
|
|
261
|
+
|
|
262
|
+
If you want to handle visibility yourself, disable CSS generation:
|
|
175
263
|
|
|
176
|
-
|
|
177
|
-
|
|
264
|
+
```svelte
|
|
265
|
+
<FieldVariants fields={shapeForm.fields} key="kind" css={false}>
|
|
266
|
+
<!-- snippets -->
|
|
267
|
+
</FieldVariants>
|
|
178
268
|
```
|
|
179
269
|
|
|
180
|
-
##
|
|
270
|
+
## discriminated Function
|
|
181
271
|
|
|
182
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.
|
|
183
273
|
|
|
184
|
-
The `
|
|
274
|
+
The `discriminated()` function wraps your form fields to:
|
|
185
275
|
|
|
186
|
-
1.
|
|
187
|
-
2.
|
|
188
|
-
3. Provide a type-safe
|
|
276
|
+
1. Provide `.type` - the current discriminator value for TypeScript narrowing
|
|
277
|
+
2. Provide `.fields` - all variant fields accessible with proper typing
|
|
278
|
+
3. Provide a type-safe `.set()` method for programmatic updates
|
|
189
279
|
4. Fix `.as("radio", value)` to accept only valid discriminator values
|
|
190
280
|
|
|
191
|
-
The following example demonstrates conditionally rendering variant-specific fields with type-safe narrowing, without using `
|
|
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):
|
|
192
282
|
|
|
193
283
|
```svelte
|
|
194
284
|
<script lang="ts">
|
|
195
285
|
import { shapeForm } from './data.remote';
|
|
196
|
-
import {
|
|
286
|
+
import { discriminated } from 'sveltekit-discriminated-fields';
|
|
197
287
|
|
|
198
|
-
const shape = $derived(
|
|
288
|
+
const shape = $derived(discriminated(shapeForm.fields, 'kind'));
|
|
199
289
|
</script>
|
|
200
290
|
|
|
201
|
-
<!-- Use
|
|
202
|
-
{#if shape.
|
|
203
|
-
<input {...shape.radius.as('number')} /> <!-- TypeScript knows radius exists -->
|
|
204
|
-
{:else if shape.
|
|
205
|
-
<input {...shape.width.as('number')} /> <!-- TypeScript knows width exists -->
|
|
206
|
-
<input {...shape.height.as('number')} />
|
|
291
|
+
<!-- Use .type for narrowing, .fields for field access -->
|
|
292
|
+
{#if shape.type === 'circle'}
|
|
293
|
+
<input {...shape.fields.radius.as('number')} /> <!-- TypeScript knows radius exists -->
|
|
294
|
+
{:else if shape.type === 'rectangle'}
|
|
295
|
+
<input {...shape.fields.width.as('number')} /> <!-- TypeScript knows width exists -->
|
|
296
|
+
<input {...shape.fields.height.as('number')} />
|
|
207
297
|
{/if}
|
|
208
298
|
```
|
|
209
299
|
|
|
@@ -211,49 +301,59 @@ See the [programmatic-form example](./test/src/routes/programmatic-form) for usa
|
|
|
211
301
|
|
|
212
302
|
## API
|
|
213
303
|
|
|
214
|
-
### `
|
|
304
|
+
### `FieldVariants`
|
|
215
305
|
|
|
216
306
|
A component for rendering variant-specific form sections with CSS-only visibility.
|
|
217
307
|
|
|
218
308
|
**Props:**
|
|
219
309
|
|
|
220
|
-
| Prop
|
|
221
|
-
|
|
|
222
|
-
| `fields`
|
|
223
|
-
| `key`
|
|
224
|
-
| `
|
|
225
|
-
| `
|
|
310
|
+
| Prop | Type | Description |
|
|
311
|
+
| --------- | -------------------- | --------------------------------------------------------- |
|
|
312
|
+
| `fields` | `RemoteFormFields` | Raw form fields from `form.fields` (not wrapped) |
|
|
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) |
|
|
226
316
|
|
|
227
317
|
**Snippets:**
|
|
228
318
|
|
|
229
|
-
- `fallback` - Rendered when no variant is selected
|
|
230
|
-
- `{variantName}(
|
|
319
|
+
- `fallback(props)` - Rendered when no variant is selected. Spread `props` onto your element.
|
|
320
|
+
- `{variantName}(variant)` - One snippet per variant. Spread `variant` onto your container, access fields via `variant.fields`.
|
|
231
321
|
|
|
232
|
-
### `
|
|
322
|
+
### `discriminated(fields, key)`
|
|
233
323
|
|
|
234
324
|
Wraps discriminated union form fields for type-safe narrowing.
|
|
235
325
|
|
|
236
326
|
**Parameters:**
|
|
237
327
|
|
|
238
|
-
- `key` - The discriminator key (must exist as a field in all variants)
|
|
239
328
|
- `fields` - Form fields from a discriminated union schema
|
|
329
|
+
- `key` - The discriminator key (must exist as a field in all variants)
|
|
240
330
|
|
|
241
331
|
**Returns:** A proxy object with:
|
|
242
332
|
|
|
243
|
-
-
|
|
244
|
-
-
|
|
333
|
+
- `type` - The current discriminator value (for narrowing)
|
|
334
|
+
- `fields` - All form fields with proper variant typing
|
|
245
335
|
- `set(data)` - Type-safe setter that infers variant from discriminator
|
|
336
|
+
- `allIssues()` - All validation issues for the discriminated fields
|
|
246
337
|
|
|
247
338
|
### `DiscriminatedData<T>`
|
|
248
339
|
|
|
249
340
|
Type helper that extracts the underlying data type from wrapped fields:
|
|
250
341
|
|
|
251
342
|
```typescript
|
|
252
|
-
const payment =
|
|
343
|
+
const payment = discriminated(form.fields, "type");
|
|
253
344
|
type Payment = DiscriminatedData<typeof payment>;
|
|
254
345
|
// { type: 'card'; cardNumber: string; cvv: string } | { type: 'bank'; ... }
|
|
255
346
|
```
|
|
256
347
|
|
|
348
|
+
### `VariantSnippetArg<T>`
|
|
349
|
+
|
|
350
|
+
Type for the argument passed to variant snippets:
|
|
351
|
+
|
|
352
|
+
```typescript
|
|
353
|
+
// variant can be spread onto elements and has a .fields property
|
|
354
|
+
type VariantSnippetArg<T> = VariantProps & { readonly fields: T };
|
|
355
|
+
```
|
|
356
|
+
|
|
257
357
|
## License
|
|
258
358
|
|
|
259
359
|
MIT
|
|
@@ -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}
|