sveltekit-discriminated-fields 0.3.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
@@ -69,21 +69,21 @@ Use `FieldVariants` to render variant-specific fields:
69
69
  <p {...props}>Please select a shape type above.</p>
70
70
  {/snippet}
71
71
 
72
- {#snippet circle(v)}
73
- <label {...v}>
74
- Radius: <input {...v.fields.radius.as('number')} />
72
+ {#snippet circle(shape)}
73
+ <label {...shape}>
74
+ Radius: <input {...shape.fields.radius.as('number')} />
75
75
  </label>
76
76
  {/snippet}
77
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" />
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
82
  </div>
83
83
  {/snippet}
84
84
 
85
- {#snippet point(v)}
86
- <p {...v}>Point has no additional fields.</p>
85
+ {#snippet point(shape)}
86
+ <p {...shape}>Point has no additional fields.</p>
87
87
  {/snippet}
88
88
  </FieldVariants>
89
89
 
@@ -95,14 +95,14 @@ Use `FieldVariants` to render variant-specific fields:
95
95
 
96
96
  Each variant snippet receives a single argument that mirrors how forms work in SvelteKit:
97
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
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
100
 
101
101
  This pattern is consistent with how you use forms: `<form {...form}>` + `form.fields.x`.
102
102
 
103
103
  ### Snippets and Fields
104
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.
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
106
 
107
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
108
 
@@ -115,24 +115,24 @@ For radio button discriminators, you must use the `discriminated()` wrapper. The
115
115
  import { shapeForm } from './data.remote';
116
116
  import { discriminated, FieldVariants } from 'sveltekit-discriminated-fields';
117
117
 
118
- const shape = $derived(discriminated('kind', shapeForm.fields));
118
+ const shape = $derived(discriminated(shapeForm.fields, 'kind'));
119
119
  </script>
120
120
 
121
121
  <form {...shapeForm}>
122
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>
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>
126
126
  </fieldset>
127
127
 
128
- <FieldVariants fields={shape} key="kind">
128
+ <FieldVariants fields={shapeForm.fields} key="kind">
129
129
  {#snippet fallback(props)}
130
130
  <p {...props}>Select a shape type</p>
131
131
  {/snippet}
132
132
 
133
- {#snippet circle(v)}
134
- <label {...v}>
135
- Radius: <input {...v.fields.radius.as('number')} />
133
+ {#snippet circle(shape)}
134
+ <label {...shape}>
135
+ Radius: <input {...shape.fields.radius.as('number')} />
136
136
  </label>
137
137
  {/snippet}
138
138
 
@@ -148,11 +148,11 @@ See the [radio-form example](./test/src/routes/radio-form) for a complete workin
148
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
149
 
150
150
  ```svelte
151
- <select {...shape.kind.as("select")}>
151
+ <select {...shape.fields.kind.as("select")}>
152
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>
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
156
 
157
157
  <!-- Also works: standard HTML (no type checking) -->
158
158
  <option value="point">Point</option>
@@ -203,16 +203,16 @@ const orderSchema = z.object({
203
203
  <script lang="ts">
204
204
  import { discriminated, FieldVariants } from 'sveltekit-discriminated-fields';
205
205
 
206
- const shipping = $derived(discriminated('method', orderForm.fields.shipping));
206
+ const shipping = $derived(discriminated(orderForm.fields.shipping, 'method'));
207
207
  </script>
208
208
 
209
- <FieldVariants fields={shipping} key="method">
210
- {#snippet pickup(v)}
211
- <input {...v} {...v.fields.store.as('text')} />
209
+ <FieldVariants fields={orderForm.fields.shipping} key="method">
210
+ {#snippet pickup(shipping)}
211
+ <input {...shipping} {...shipping.fields.store.as('text')} />
212
212
  {/snippet}
213
213
 
214
- {#snippet delivery(v)}
215
- <input {...v} {...v.fields.address.as('text')} />
214
+ {#snippet delivery(shipping)}
215
+ <input {...shipping} {...shipping.fields.address.as('text')} />
216
216
  {/snippet}
217
217
  </FieldVariants>
218
218
  ```
@@ -224,13 +224,13 @@ You can also have multiple discriminated unions in the same form, or even a disc
224
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}`:
225
225
 
226
226
  ```svelte
227
- <FieldVariants fields={shape} key="kind" partial={true}>
228
- {#snippet circle(v)}
229
- <input {...v} {...v.fields.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
230
  {/snippet}
231
231
 
232
- {#snippet rectangle(v)}
233
- <input {...v} {...v.fields.width.as('number')} />
232
+ {#snippet rectangle(shape)}
233
+ <input {...shape} {...shape.fields.width.as('number')} />
234
234
  {/snippet}
235
235
 
236
236
  <!-- point snippet omitted - nothing shown when point selected -->
@@ -247,11 +247,11 @@ By default, `FieldVariants` requires a snippet for every variant - a compile err
247
247
  This means forms work without JavaScript, but once JS loads, you get full Svelte features:
248
248
 
249
249
  ```svelte
250
- <FieldVariants fields={shape} key="kind">
251
- {#snippet circle(v)}
250
+ <FieldVariants fields={shapeForm.fields} key="kind">
251
+ {#snippet circle(shape)}
252
252
  <!-- Svelte transitions work after hydration -->
253
- <div {...v} transition:slide>
254
- <input {...v.fields.radius.as('number')} />
253
+ <div {...shape} transition:slide>
254
+ <input {...shape.fields.radius.as('number')} />
255
255
  </div>
256
256
  {/snippet}
257
257
  </FieldVariants>
@@ -262,7 +262,7 @@ This means forms work without JavaScript, but once JS loads, you get full Svelte
262
262
  If you want to handle visibility yourself, disable CSS generation:
263
263
 
264
264
  ```svelte
265
- <FieldVariants fields={shape} key="kind" css={false}>
265
+ <FieldVariants fields={shapeForm.fields} key="kind" css={false}>
266
266
  <!-- snippets -->
267
267
  </FieldVariants>
268
268
  ```
@@ -273,9 +273,9 @@ When using SvelteKit's remote function `form()` with discriminated union schemas
273
273
 
274
274
  The `discriminated()` function wraps your form fields to:
275
275
 
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
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
279
279
  4. Fix `.as("radio", value)` to accept only valid discriminator values
280
280
 
281
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):
@@ -285,15 +285,15 @@ The following example demonstrates conditionally rendering variant-specific fiel
285
285
  import { shapeForm } from './data.remote';
286
286
  import { discriminated } from 'sveltekit-discriminated-fields';
287
287
 
288
- const shape = $derived(discriminated('kind', shapeForm.fields));
288
+ const shape = $derived(discriminated(shapeForm.fields, 'kind'));
289
289
  </script>
290
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')} />
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')} />
297
297
  {/if}
298
298
  ```
299
299
 
@@ -307,31 +307,31 @@ A component for rendering variant-specific form sections with CSS-only visibilit
307
307
 
308
308
  **Props:**
309
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) |
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) |
316
316
 
317
317
  **Snippets:**
318
318
 
319
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`.
320
+ - `{variantName}(variant)` - One snippet per variant. Spread `variant` onto your container, access fields via `variant.fields`.
321
321
 
322
- ### `discriminated(key, fields)`
322
+ ### `discriminated(fields, key)`
323
323
 
324
324
  Wraps discriminated union form fields for type-safe narrowing.
325
325
 
326
326
  **Parameters:**
327
327
 
328
- - `key` - The discriminator key (must exist as a field in all variants)
329
328
  - `fields` - Form fields from a discriminated union schema
329
+ - `key` - The discriminator key (must exist as a field in all variants)
330
330
 
331
331
  **Returns:** A proxy object with:
332
332
 
333
- - All original fields passed through unchanged
334
- - `${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
335
335
  - `set(data)` - Type-safe setter that infers variant from discriminator
336
336
  - `allIssues()` - All validation issues for the discriminated fields
337
337
 
@@ -340,7 +340,7 @@ Wraps discriminated union form fields for type-safe narrowing.
340
340
  Type helper that extracts the underlying data type from wrapped fields:
341
341
 
342
342
  ```typescript
343
- const payment = discriminated("type", form.fields);
343
+ const payment = discriminated(form.fields, "type");
344
344
  type Payment = DiscriminatedData<typeof payment>;
345
345
  // { type: 'card'; cardNumber: string; cvv: string } | { type: 'bank'; ... }
346
346
  ```
@@ -350,22 +350,10 @@ type Payment = DiscriminatedData<typeof payment>;
350
350
  Type for the argument passed to variant snippets:
351
351
 
352
352
  ```typescript
353
- // v can be spread onto elements and has a .fields property
353
+ // variant can be spread onto elements and has a .fields property
354
354
  type VariantSnippetArg<T> = VariantProps & { readonly fields: T };
355
355
  ```
356
356
 
357
- ## Migration from v0.1
358
-
359
- If upgrading from v0.1:
360
-
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>`
368
-
369
357
  ## License
370
358
 
371
359
  MIT
@@ -16,19 +16,22 @@ type VariantDiscriminatorField<V extends string, AllV extends string> = Omit<Rem
16
16
  as(type: Exclude<RemoteFormFieldType<V>, 'radio'>, ...args: unknown[]): ReturnType<RemoteFormField<V>['as']>;
17
17
  };
18
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>;
19
+ type FieldsObject<K extends string, D, V extends string, AllV extends string> = {
20
+ readonly [P in K]: VariantDiscriminatorField<V, AllV>;
23
21
  } & {
24
22
  readonly [P in Exclude<keyof D, K>]: NestedField<D[P & keyof D]>;
23
+ };
24
+ type VariantFields<K extends string, D, AllV extends string> = D extends Record<K, infer V> ? {
25
+ readonly type: V;
26
+ readonly fields: FieldsObject<K, D, V & string, AllV>;
25
27
  } : never;
26
28
  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]>;
29
+ readonly type: undefined;
30
+ readonly fields: {
31
+ readonly [P in K]: VariantDiscriminatorField<AllV, AllV>;
32
+ } & {
33
+ readonly [P in Exclude<keyof D, K>]: NestedField<D[P & keyof D]>;
34
+ };
32
35
  };
33
36
  type SetMethod<K extends string, D> = {
34
37
  set: <V extends DiscriminatorValues<K, D>>(data: Extract<D, Record<K, V>>) => void;
@@ -40,35 +43,32 @@ type CommonMethods = RemoteFormFields<unknown> extends {
40
43
  } : never;
41
44
  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
45
  /**
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
46
+ * Wraps discriminated union form fields for type-safe narrowing.
49
47
  *
50
48
  * @example
51
49
  * ```svelte
52
50
  * <script>
53
- * const priority = $derived(discriminated("level", priorityForm.fields));
51
+ * const priority = $derived(discriminated(priorityForm.fields, "level"));
54
52
  * </script>
55
53
  *
56
- * <input {...priority.level.as("radio", "high")} /> <!-- type-safe: only valid values allowed -->
54
+ * {#if priority.type === "high"}
55
+ * <input {...priority.fields.urgency.as("number")} />
56
+ * {/if}
57
57
  *
58
- * <select {...priority.level.as("select")}>
59
- * <option {...priority.level.as("option")}>Select...</option>
60
- * <option {...priority.level.as("option", "high")}>High</option>
58
+ * <select {...priority.fields.level.as("select")}>
59
+ * <option {...priority.fields.level.as("option")}>Select...</option>
60
+ * <option {...priority.fields.level.as("option", "high")}>High</option>
61
61
  * </select>
62
62
  * ```
63
63
  *
64
- * @param key - Discriminator key (e.g. 'type')
65
64
  * @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?)
65
+ * @param key - Discriminator key (e.g. 'type', 'kind')
66
+ * @returns Object with `.type` (discriminator value), `.fields` (all form fields), `.set()`, `.allIssues()`
67
67
  */
68
68
  export declare function discriminated<K extends string, T extends {
69
69
  set: (v: never) => unknown;
70
70
  } & Record<K, {
71
71
  value(): unknown;
72
72
  as(type: 'radio', value: string): object;
73
- }>>(key: K, fields: T): DiscriminatedFields<K, DiscriminatedData<T>>;
73
+ }>>(fields: T, key: K): DiscriminatedFields<K, DiscriminatedData<T>>;
74
74
  export {};
@@ -2,32 +2,29 @@
2
2
  // Main function
3
3
  // =============================================================================
4
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
5
+ * Wraps discriminated union form fields for type-safe narrowing.
11
6
  *
12
7
  * @example
13
8
  * ```svelte
14
9
  * <script>
15
- * const priority = $derived(discriminated("level", priorityForm.fields));
10
+ * const priority = $derived(discriminated(priorityForm.fields, "level"));
16
11
  * </script>
17
12
  *
18
- * <input {...priority.level.as("radio", "high")} /> <!-- type-safe: only valid values allowed -->
13
+ * {#if priority.type === "high"}
14
+ * <input {...priority.fields.urgency.as("number")} />
15
+ * {/if}
19
16
  *
20
- * <select {...priority.level.as("select")}>
21
- * <option {...priority.level.as("option")}>Select...</option>
22
- * <option {...priority.level.as("option", "high")}>High</option>
17
+ * <select {...priority.fields.level.as("select")}>
18
+ * <option {...priority.fields.level.as("option")}>Select...</option>
19
+ * <option {...priority.fields.level.as("option", "high")}>High</option>
23
20
  * </select>
24
21
  * ```
25
22
  *
26
- * @param key - Discriminator key (e.g. 'type')
27
23
  * @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?)
24
+ * @param key - Discriminator key (e.g. 'type', 'kind')
25
+ * @returns Object with `.type` (discriminator value), `.fields` (all form fields), `.set()`, `.allIssues()`
29
26
  */
30
- export function discriminated(key, fields) {
27
+ export function discriminated(fields, key) {
31
28
  // Wrap the discriminator field to intercept as("option", value?) calls
32
29
  const wrapDiscriminatorField = (field) => {
33
30
  return new Proxy(field, {
@@ -44,18 +41,29 @@ export function discriminated(key, fields) {
44
41
  }
45
42
  });
46
43
  };
44
+ // Create the fields proxy that wraps the discriminator field
45
+ const fieldsProxy = new Proxy(fields, {
46
+ get(target, prop) {
47
+ if (prop === key)
48
+ return wrapDiscriminatorField(target[key]);
49
+ return Reflect.get(target, prop);
50
+ }
51
+ });
52
+ // Create the main proxy with .type, .fields, .set, .allIssues
47
53
  const proxy = new Proxy(fields, {
48
54
  get(target, prop) {
49
- if (prop === `${key}Value`)
55
+ if (prop === 'type')
50
56
  return target[key].value();
57
+ if (prop === 'fields')
58
+ return fieldsProxy;
51
59
  if (prop === 'set')
52
60
  return (data) => target.set(data);
53
- if (prop === key)
54
- return wrapDiscriminatorField(target[key]);
55
- return Reflect.get(target, prop);
61
+ if (prop === 'allIssues')
62
+ return () => target.allIssues?.();
63
+ return undefined;
56
64
  },
57
- has(target, prop) {
58
- return prop === `${key}Value` || prop in target;
65
+ has(_target, prop) {
66
+ return prop === 'type' || prop === 'fields' || prop === 'set' || prop === 'allIssues';
59
67
  }
60
68
  });
61
69
  return proxy;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sveltekit-discriminated-fields",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Type-safe discriminated union support for SvelteKit form fields",
5
5
  "type": "module",
6
6
  "svelte": "./dist/index.js",