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 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
- - **`discriminatedFields()`** - A wrapper function that enables proper TypeScript type narrowing on form field objects
8
- - **`UnionVariants`** - A component that renders variant-specific form sections with CSS-only visibility, supporting progressive enhancement (works without JavaScript)
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
- ## UnionVariants Component
25
+ ## FieldVariants Component
26
26
 
27
- The `UnionVariants` component provides declarative variant rendering with **full progressive enhancement support**. It uses CSS-only visibility toggling via `:has()` selectors, so forms work identically with or without JavaScript enabled.
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.coerce.number() }),
38
- z.object({ kind: z.literal("rectangle"), width: z.coerce.number(), height: z.coerce.number() }),
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 `UnionVariants` to render variant-specific fields:
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 { UnionVariants } from 'sveltekit-discriminated-fields';
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
- <UnionVariants fields={shapeForm.fields} key="kind">
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(s)}
69
- <input {...s.radius.as('number')} placeholder="Radius" />
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(s)}
73
- <input {...s.width.as('number')} placeholder="Width" />
74
- <input {...s.height.as('number')} placeholder="Height" />
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(_s)}
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
- </UnionVariants>
88
+ </FieldVariants>
81
89
 
82
90
  <button type="submit">Submit</button>
83
91
  </form>
84
92
  ```
85
93
 
86
- The optional `fallback` snippet is displayed when no variant is currently selected (i.e., when the discriminator field is empty).
94
+ ### Snippet Parameters
87
95
 
88
- Each snippet receives correctly narrowed fields for that variant - TypeScript knows `s.radius` exists in the `circle` snippet but not in `rectangle`. Only valid discriminator values are accepted.
96
+ Each variant snippet receives a single argument that mirrors how forms work in SvelteKit:
89
97
 
90
- Snippets only receive fields **specific to that variant**. Fields common to all variants (same name and type) should be rendered outside `UnionVariants` to prevent accidental duplicate inputs. Fields shared by some but not all variants, or with differing types across variants, produce compile-time errors.
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 `discriminatedFields()` 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:
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 { discriminatedFields, UnionVariants } from 'sveltekit-discriminated-fields';
116
+ import { discriminated, FieldVariants } from 'sveltekit-discriminated-fields';
100
117
 
101
- const shape = $derived(discriminatedFields('kind', shapeForm.fields));
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
- <UnionVariants fields={shape} key="kind">
112
- <!-- snippets work the same way -->
113
- </UnionVariants>
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
- ### Non-Sibling Layouts with `selector`
146
+ ### Select Options
120
147
 
121
- By default, `UnionVariants` expects the discriminator input (select or radios) to be a sibling element. For complex layouts where this isn't possible, use the `selector` prop:
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
- <div class="header">
125
- <select {...shape.kind.as('select')} id="shape-select">
126
- <!-- options -->
127
- </select>
128
- </div>
129
-
130
- <div class="body">
131
- <!-- Not a sibling of the select! -->
132
- <UnionVariants fields={shape} key="kind" selector="#shape-select">
133
- <!-- snippets -->
134
- </UnionVariants>
135
- </div>
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
- <UnionVariants fields={orderForm.fields.shipping} key="method">
156
- <!-- snippets for pickup and delivery -->
157
- </UnionVariants>
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, `UnionVariants` 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}`:
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
- <UnionVariants fields={shape} key="kind" partial={true}>
168
- {#snippet circle(s)}
169
- <input {...s.radius.as('number')} />
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
- {#snippet rectangle(s)}
173
- <input {...s.width.as('number')} />
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
- <!-- point snippet omitted - fallback shown when point selected -->
177
- </UnionVariants>
264
+ ```svelte
265
+ <FieldVariants fields={shapeForm.fields} key="kind" css={false}>
266
+ <!-- snippets -->
267
+ </FieldVariants>
178
268
  ```
179
269
 
180
- ## discriminatedFields Function
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 `discriminatedFields()` function wraps your form fields to:
274
+ The `discriminated()` function wraps your form fields to:
185
275
 
186
- 1. Make all variant fields accessible
187
- 2. Add a `${key}Value` property that enables TypeScript narrowing
188
- 3. Provide a type-safe `set()` method for programmatic updates
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 `UnionVariants`. This approach requires JavaScript (unlike `UnionVariants` which works without JS):
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 { discriminatedFields } from 'sveltekit-discriminated-fields';
286
+ import { discriminated } from 'sveltekit-discriminated-fields';
197
287
 
198
- const shape = $derived(discriminatedFields('kind', shapeForm.fields));
288
+ const shape = $derived(discriminated(shapeForm.fields, 'kind'));
199
289
  </script>
200
290
 
201
- <!-- Use kindValue (not kind.value()) for type narrowing -->
202
- {#if shape.kindValue === 'circle'}
203
- <input {...shape.radius.as('number')} /> <!-- TypeScript knows radius exists -->
204
- {:else if shape.kindValue === 'rectangle'}
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
- ### `UnionVariants`
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 | Type | Description |
221
- | ---------- | --------------------- | ----------------------------------------------------------- |
222
- | `fields` | `DiscriminatedFields` | The wrapped form fields from `discriminatedFields()` |
223
- | `key` | `string` | The discriminator key (must match a field in the schema) |
224
- | `selector` | `string` (optional) | CSS selector for the discriminator input when not a sibling |
225
- | `partial` | `boolean` (optional) | Allow missing snippets for some variants |
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}(fields)` - One snippet per variant, receives narrowed fields
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
- ### `discriminatedFields(key, fields)`
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
- - All original fields passed through unchanged
244
- - `${key}Value` - The current discriminator value (for narrowing)
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 = discriminatedFields("type", form.fields);
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}