sveltekit-discriminated-fields 0.1.2 → 0.2.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 +181 -98
- package/dist/UnionVariants.svelte +361 -0
- package/dist/UnionVariants.svelte.d.ts +115 -0
- package/dist/discriminated-fields.d.ts +70 -0
- package/dist/discriminated-fields.js +35 -0
- package/dist/index.d.ts +4 -39
- package/dist/index.js +4 -25
- package/dist/infer-discriminator.d.ts +11 -0
- package/dist/infer-discriminator.js +5 -0
- package/package.json +19 -6
package/README.md
CHANGED
|
@@ -2,175 +2,258 @@
|
|
|
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
|
+
- **`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)
|
|
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
|
+
## UnionVariants Component
|
|
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.
|
|
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
|
-
z.object({ kind: z.literal("circle"), radius: z.number() }),
|
|
18
|
-
z.object({
|
|
19
|
-
kind: z.literal("rectangle"),
|
|
20
|
-
width: z.number(),
|
|
21
|
-
height: z.number(),
|
|
22
|
-
}),
|
|
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() }),
|
|
23
39
|
z.object({ kind: z.literal("point") }),
|
|
24
40
|
]);
|
|
25
41
|
|
|
26
42
|
export const shapeForm = form(shapeSchema, async (data) => {
|
|
27
|
-
// handle
|
|
43
|
+
// handle submission
|
|
28
44
|
});
|
|
29
45
|
```
|
|
30
46
|
|
|
47
|
+
Use `UnionVariants` to render variant-specific fields:
|
|
48
|
+
|
|
31
49
|
```svelte
|
|
32
|
-
<!-- +page.svelte (without this library) -->
|
|
33
50
|
<script lang="ts">
|
|
34
|
-
import { shapeForm } from './data.remote
|
|
35
|
-
|
|
36
|
-
const form = shapeForm();
|
|
37
|
-
const shape = form.fields;
|
|
51
|
+
import { shapeForm } from './data.remote';
|
|
52
|
+
import { UnionVariants } from 'sveltekit-discriminated-fields';
|
|
38
53
|
</script>
|
|
39
54
|
|
|
40
|
-
<
|
|
41
|
-
<
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
</
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
<
|
|
49
|
-
{
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
55
|
+
<form {...shapeForm}>
|
|
56
|
+
<select {...shapeForm.fields.kind.as('select')}>
|
|
57
|
+
<option value="">Select a shape...</option>
|
|
58
|
+
<option value="circle">Circle</option>
|
|
59
|
+
<option value="rectangle">Rectangle</option>
|
|
60
|
+
<option value="point">Point</option>
|
|
61
|
+
</select>
|
|
62
|
+
|
|
63
|
+
<UnionVariants fields={shapeForm.fields} key="kind">
|
|
64
|
+
{#snippet fallback()}
|
|
65
|
+
<p>Please select a shape type above.</p>
|
|
66
|
+
{/snippet}
|
|
67
|
+
|
|
68
|
+
{#snippet circle(s)}
|
|
69
|
+
<input {...s.radius.as('number')} placeholder="Radius" />
|
|
70
|
+
{/snippet}
|
|
71
|
+
|
|
72
|
+
{#snippet rectangle(s)}
|
|
73
|
+
<input {...s.width.as('number')} placeholder="Width" />
|
|
74
|
+
<input {...s.height.as('number')} placeholder="Height" />
|
|
75
|
+
{/snippet}
|
|
76
|
+
|
|
77
|
+
{#snippet point(_s)}
|
|
78
|
+
<p>Point has no additional fields.</p>
|
|
79
|
+
{/snippet}
|
|
80
|
+
</UnionVariants>
|
|
81
|
+
|
|
82
|
+
<button type="submit">Submit</button>
|
|
83
|
+
</form>
|
|
53
84
|
```
|
|
54
85
|
|
|
55
|
-
The
|
|
86
|
+
The optional `fallback` snippet is displayed when no variant is currently selected (i.e., when the discriminator field is empty).
|
|
56
87
|
|
|
57
|
-
|
|
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.
|
|
58
89
|
|
|
59
|
-
|
|
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.
|
|
60
91
|
|
|
61
|
-
|
|
62
|
-
|
|
92
|
+
### Radio Buttons
|
|
93
|
+
|
|
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:
|
|
63
95
|
|
|
64
96
|
```svelte
|
|
65
|
-
<!-- +page.svelte (with this library) -->
|
|
66
97
|
<script lang="ts">
|
|
67
|
-
import { shapeForm } from './data.remote
|
|
68
|
-
import { discriminatedFields } from 'sveltekit-discriminated-fields';
|
|
98
|
+
import { shapeForm } from './data.remote';
|
|
99
|
+
import { discriminatedFields, UnionVariants } from 'sveltekit-discriminated-fields';
|
|
69
100
|
|
|
70
|
-
const
|
|
71
|
-
const shape = discriminatedFields('kind', form.fields); // changed
|
|
101
|
+
const shape = $derived(discriminatedFields('kind', shapeForm.fields));
|
|
72
102
|
</script>
|
|
73
103
|
|
|
74
|
-
<
|
|
75
|
-
<
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
</
|
|
104
|
+
<form {...shapeForm}>
|
|
105
|
+
<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>
|
|
109
|
+
</fieldset>
|
|
110
|
+
|
|
111
|
+
<UnionVariants fields={shape} key="kind">
|
|
112
|
+
<!-- snippets work the same way -->
|
|
113
|
+
</UnionVariants>
|
|
114
|
+
</form>
|
|
115
|
+
```
|
|
79
116
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
117
|
+
See the [radio-form example](./test/src/routes/radio-form) for a complete working example.
|
|
118
|
+
|
|
119
|
+
### Non-Sibling Layouts with `selector`
|
|
120
|
+
|
|
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:
|
|
122
|
+
|
|
123
|
+
```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>
|
|
88
136
|
```
|
|
89
137
|
|
|
90
|
-
|
|
138
|
+
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.
|
|
91
139
|
|
|
92
|
-
|
|
93
|
-
2. Wrap `form.fields` with `discriminatedFields('kind', ...)`
|
|
94
|
-
3. Use `shape.kindValue` instead of `shape.kind.value()` for narrowing
|
|
140
|
+
### Nested and Multiple Unions
|
|
95
141
|
|
|
96
|
-
|
|
142
|
+
The discriminated union doesn't need to be the top-level schema. It can be nested within a larger object:
|
|
97
143
|
|
|
98
|
-
```
|
|
99
|
-
|
|
144
|
+
```typescript
|
|
145
|
+
const orderSchema = z.object({
|
|
146
|
+
orderId: z.string(),
|
|
147
|
+
shipping: z.discriminatedUnion("method", [
|
|
148
|
+
z.object({ method: z.literal("pickup"), store: z.string() }),
|
|
149
|
+
z.object({ method: z.literal("delivery"), address: z.string() }),
|
|
150
|
+
]),
|
|
151
|
+
});
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
```svelte
|
|
155
|
+
<UnionVariants fields={orderForm.fields.shipping} key="method">
|
|
156
|
+
<!-- snippets for pickup and delivery -->
|
|
157
|
+
</UnionVariants>
|
|
100
158
|
```
|
|
101
159
|
|
|
102
|
-
|
|
160
|
+
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
|
+
|
|
162
|
+
### Partial Variants
|
|
163
|
+
|
|
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}`:
|
|
103
165
|
|
|
104
166
|
```svelte
|
|
105
|
-
<
|
|
106
|
-
|
|
107
|
-
|
|
167
|
+
<UnionVariants fields={shape} key="kind" partial={true}>
|
|
168
|
+
{#snippet circle(s)}
|
|
169
|
+
<input {...s.radius.as('number')} />
|
|
170
|
+
{/snippet}
|
|
108
171
|
|
|
109
|
-
|
|
172
|
+
{#snippet rectangle(s)}
|
|
173
|
+
<input {...s.width.as('number')} />
|
|
174
|
+
{/snippet}
|
|
110
175
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
176
|
+
<!-- point snippet omitted - fallback shown when point selected -->
|
|
177
|
+
</UnionVariants>
|
|
178
|
+
```
|
|
114
179
|
|
|
115
|
-
|
|
116
|
-
// set() is type-safe - TypeScript enforces correct fields for each variant
|
|
117
|
-
payment.set({ type: 'card', cardNumber: '', cvv: '' });
|
|
118
|
-
}
|
|
119
|
-
</script>
|
|
180
|
+
## discriminatedFields Function
|
|
120
181
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
182
|
+
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
|
+
|
|
184
|
+
The `discriminatedFields()` function wraps your form fields to:
|
|
185
|
+
|
|
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
|
|
189
|
+
4. Fix `.as("radio", value)` to accept only valid discriminator values
|
|
190
|
+
|
|
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):
|
|
129
192
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
193
|
+
```svelte
|
|
194
|
+
<script lang="ts">
|
|
195
|
+
import { shapeForm } from './data.remote';
|
|
196
|
+
import { discriminatedFields } from 'sveltekit-discriminated-fields';
|
|
197
|
+
|
|
198
|
+
const shape = $derived(discriminatedFields('kind', shapeForm.fields));
|
|
199
|
+
</script>
|
|
200
|
+
|
|
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')} />
|
|
138
207
|
{/if}
|
|
139
208
|
```
|
|
140
209
|
|
|
210
|
+
See the [programmatic-form example](./test/src/routes/programmatic-form) for usage of `set()` and other programmatic features.
|
|
211
|
+
|
|
141
212
|
## API
|
|
142
213
|
|
|
214
|
+
### `UnionVariants`
|
|
215
|
+
|
|
216
|
+
A component for rendering variant-specific form sections with CSS-only visibility.
|
|
217
|
+
|
|
218
|
+
**Props:**
|
|
219
|
+
|
|
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 |
|
|
226
|
+
|
|
227
|
+
**Snippets:**
|
|
228
|
+
|
|
229
|
+
- `fallback` - Rendered when no variant is selected
|
|
230
|
+
- `{variantName}(fields)` - One snippet per variant, receives narrowed fields
|
|
231
|
+
|
|
143
232
|
### `discriminatedFields(key, fields)`
|
|
144
233
|
|
|
145
234
|
Wraps discriminated union form fields for type-safe narrowing.
|
|
146
235
|
|
|
147
236
|
**Parameters:**
|
|
148
237
|
|
|
149
|
-
- `key` - The discriminator key (
|
|
238
|
+
- `key` - The discriminator key (must exist as a field in all variants)
|
|
150
239
|
- `fields` - Form fields from a discriminated union schema
|
|
151
240
|
|
|
152
241
|
**Returns:** A proxy object with:
|
|
153
242
|
|
|
154
243
|
- All original fields passed through unchanged
|
|
155
244
|
- `${key}Value` - The current discriminator value (for narrowing)
|
|
156
|
-
- `set()` - Type-safe setter that infers variant from discriminator
|
|
245
|
+
- `set(data)` - Type-safe setter that infers variant from discriminator
|
|
157
246
|
|
|
158
247
|
### `DiscriminatedData<T>`
|
|
159
248
|
|
|
160
249
|
Type helper that extracts the underlying data type from wrapped fields:
|
|
161
250
|
|
|
162
251
|
```typescript
|
|
163
|
-
const payment = discriminatedFields(
|
|
252
|
+
const payment = discriminatedFields("type", form.fields);
|
|
164
253
|
type Payment = DiscriminatedData<typeof payment>;
|
|
165
|
-
// { type: 'card'; cardNumber: string; cvv: string } | { type: 'bank';
|
|
254
|
+
// { type: 'card'; cardNumber: string; cvv: string } | { type: 'bank'; ... }
|
|
166
255
|
```
|
|
167
256
|
|
|
168
|
-
Useful when you need to reference the data type elsewhere, such as defining default values or passing data to other functions.
|
|
169
|
-
|
|
170
|
-
## Note on Runtime Behavior
|
|
171
|
-
|
|
172
|
-
Type narrowing is compile-time only. At runtime, `'width' in shape` will return `true` even when `kindValue === 'circle'`, because all variant fields exist on the underlying object.
|
|
173
|
-
|
|
174
257
|
## License
|
|
175
258
|
|
|
176
259
|
MIT
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
import type { InferDiscriminator } from "./infer-discriminator.js";
|
|
4
|
+
|
|
5
|
+
// Extract discriminator values from a union of field objects
|
|
6
|
+
type DiscriminatorValues<K extends string, T> =
|
|
7
|
+
T extends Record<K, { value(): infer V }>
|
|
8
|
+
? V extends string
|
|
9
|
+
? V
|
|
10
|
+
: never
|
|
11
|
+
: never;
|
|
12
|
+
|
|
13
|
+
// Narrow T to the variant where discriminator equals V
|
|
14
|
+
type NarrowToVariant<K extends string, T, V> =
|
|
15
|
+
T extends Record<K, { value(): V }> ? T : never;
|
|
16
|
+
|
|
17
|
+
// Reserved prop names that can't be variant names
|
|
18
|
+
type ReservedProps = "fields" | "key" | "fallback" | "partial";
|
|
19
|
+
|
|
20
|
+
// =============================================================================
|
|
21
|
+
// Shared field name detection
|
|
22
|
+
// Fields can be:
|
|
23
|
+
// - Unique to one variant: OK
|
|
24
|
+
// - Common to ALL variants: OK (accessible at top level, outside UnionVariants)
|
|
25
|
+
// - Shared by SOME but not ALL: ERROR (would cause duplicate form submissions)
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
// Keys that look like form fields (have a value() method) - excludes methods like set/allIssues
|
|
29
|
+
type FieldKeys<T> = {
|
|
30
|
+
[P in keyof T]: T[P] extends { value(): unknown } ? P : never;
|
|
31
|
+
}[keyof T];
|
|
32
|
+
|
|
33
|
+
// Get field keys for a specific variant (excluding discriminator)
|
|
34
|
+
type VariantFieldKeys<K extends string, T, V extends string> = Exclude<
|
|
35
|
+
FieldKeys<NarrowToVariant<K, T, V>>,
|
|
36
|
+
K
|
|
37
|
+
>;
|
|
38
|
+
|
|
39
|
+
// Get union of field keys from all variants EXCEPT V (distributes over the Exclude result)
|
|
40
|
+
type OtherVariantsFieldKeys<
|
|
41
|
+
K extends string,
|
|
42
|
+
T,
|
|
43
|
+
V extends string,
|
|
44
|
+
OtherV extends string = Exclude<DiscriminatorValues<K, T>, V>,
|
|
45
|
+
> = OtherV extends OtherV ? VariantFieldKeys<K, T, OtherV> : never;
|
|
46
|
+
|
|
47
|
+
// Keys that variant V shares with at least one other variant
|
|
48
|
+
type SharedFieldKeys<
|
|
49
|
+
K extends string,
|
|
50
|
+
T,
|
|
51
|
+
V extends string,
|
|
52
|
+
> = VariantFieldKeys<K, T, V> & OtherVariantsFieldKeys<K, T, V>;
|
|
53
|
+
|
|
54
|
+
// Union of all shared keys across all variants (distributes over V)
|
|
55
|
+
type AllSharedFieldKeys<
|
|
56
|
+
K extends string,
|
|
57
|
+
T,
|
|
58
|
+
V extends string = DiscriminatorValues<K, T>,
|
|
59
|
+
> = V extends V ? SharedFieldKeys<K, T, V> : never;
|
|
60
|
+
|
|
61
|
+
// Check if field P exists in variant V
|
|
62
|
+
type HasField<T, P> = P extends keyof T
|
|
63
|
+
? T[P] extends { value(): unknown }
|
|
64
|
+
? true
|
|
65
|
+
: false
|
|
66
|
+
: false;
|
|
67
|
+
|
|
68
|
+
// Check if field P exists in ALL variants (distributes, so result is union of true/false)
|
|
69
|
+
type FieldExistsInAll<
|
|
70
|
+
K extends string,
|
|
71
|
+
T,
|
|
72
|
+
P extends string,
|
|
73
|
+
V extends string = DiscriminatorValues<K, T>,
|
|
74
|
+
> = V extends V ? HasField<NarrowToVariant<K, T, V>, P> : never;
|
|
75
|
+
|
|
76
|
+
// A field is common if it exists in all variants (no false in the result)
|
|
77
|
+
type IsCommonField<K extends string, T, P extends string> = false extends FieldExistsInAll<K, T, P>
|
|
78
|
+
? false
|
|
79
|
+
: true;
|
|
80
|
+
|
|
81
|
+
// =============================================================================
|
|
82
|
+
// Type uniformity check - fields shared across variants must have the same type
|
|
83
|
+
// =============================================================================
|
|
84
|
+
|
|
85
|
+
// Get the value type of field P in variant V
|
|
86
|
+
type FieldValueType<K extends string, T, V extends string, P extends string> = NarrowToVariant<
|
|
87
|
+
K,
|
|
88
|
+
T,
|
|
89
|
+
V
|
|
90
|
+
>[P & keyof NarrowToVariant<K, T, V>] extends { value(): infer R }
|
|
91
|
+
? R
|
|
92
|
+
: never;
|
|
93
|
+
|
|
94
|
+
// Union of all value types for field P across all variants
|
|
95
|
+
type AllFieldValueTypes<
|
|
96
|
+
K extends string,
|
|
97
|
+
T,
|
|
98
|
+
P extends string,
|
|
99
|
+
V extends string = DiscriminatorValues<K, T>,
|
|
100
|
+
> = V extends V ? FieldValueType<K, T, V, P> : never;
|
|
101
|
+
|
|
102
|
+
// Check if the union of all types equals each individual type (i.e., all types are the same)
|
|
103
|
+
// If types differ: (string | number) extends string = false
|
|
104
|
+
// If types same: string extends string = true
|
|
105
|
+
type FieldTypeMatchesInVariant<
|
|
106
|
+
K extends string,
|
|
107
|
+
T,
|
|
108
|
+
P extends string,
|
|
109
|
+
V extends string,
|
|
110
|
+
Union = AllFieldValueTypes<K, T, P>,
|
|
111
|
+
> = [Union] extends [FieldValueType<K, T, V, P>] ? true : false;
|
|
112
|
+
|
|
113
|
+
// Check if field P has the same type in ALL variants
|
|
114
|
+
type FieldHasSameTypeInAll<
|
|
115
|
+
K extends string,
|
|
116
|
+
T,
|
|
117
|
+
P extends string,
|
|
118
|
+
V extends string = DiscriminatorValues<K, T>,
|
|
119
|
+
> = V extends V ? FieldTypeMatchesInVariant<K, T, P, V> : never;
|
|
120
|
+
|
|
121
|
+
// A field has uniform type if no variant returns false
|
|
122
|
+
type IsUniformTypeField<K extends string, T, P extends string> = false extends FieldHasSameTypeInAll<
|
|
123
|
+
K,
|
|
124
|
+
T,
|
|
125
|
+
P
|
|
126
|
+
>
|
|
127
|
+
? false
|
|
128
|
+
: true;
|
|
129
|
+
|
|
130
|
+
// =============================================================================
|
|
131
|
+
// Field classification
|
|
132
|
+
// =============================================================================
|
|
133
|
+
|
|
134
|
+
// Fields shared by SOME but not ALL variants (problematic - would cause duplicate submissions)
|
|
135
|
+
type PartiallySharedFieldKeys<K extends string, T> = {
|
|
136
|
+
[P in AllSharedFieldKeys<K, T>]: IsCommonField<K, T, P & string> extends true ? never : P;
|
|
137
|
+
}[AllSharedFieldKeys<K, T>];
|
|
138
|
+
|
|
139
|
+
// Fields in ALL variants but with DIFFERENT types (problematic - type mismatch)
|
|
140
|
+
type MixedTypeFieldKeys<K extends string, T> = {
|
|
141
|
+
[P in AllSharedFieldKeys<K, T>]: IsCommonField<K, T, P & string> extends true
|
|
142
|
+
? IsUniformTypeField<K, T, P & string> extends true
|
|
143
|
+
? never
|
|
144
|
+
: P
|
|
145
|
+
: never;
|
|
146
|
+
}[AllSharedFieldKeys<K, T>];
|
|
147
|
+
|
|
148
|
+
// Fields common to ALL variants WITH the same type (safe to use outside UnionVariants)
|
|
149
|
+
type CommonFieldKeys<K extends string, T> = {
|
|
150
|
+
[P in AllSharedFieldKeys<K, T>]: IsCommonField<K, T, P & string> extends true
|
|
151
|
+
? IsUniformTypeField<K, T, P & string> extends true
|
|
152
|
+
? P
|
|
153
|
+
: never
|
|
154
|
+
: never;
|
|
155
|
+
}[AllSharedFieldKeys<K, T>];
|
|
156
|
+
|
|
157
|
+
// =============================================================================
|
|
158
|
+
// Validation - produces error messages for invalid field configurations
|
|
159
|
+
// =============================================================================
|
|
160
|
+
|
|
161
|
+
// Validates that T has no partially shared field keys
|
|
162
|
+
type ValidateNoPartiallySharedFields<K extends string, T> = [
|
|
163
|
+
PartiallySharedFieldKeys<K, T>,
|
|
164
|
+
] extends [never]
|
|
165
|
+
? T
|
|
166
|
+
: `Error: Field '${PartiallySharedFieldKeys<K, T> & string}' exists in some variants but not all. Fields must be either unique to one variant or common to all variants.`;
|
|
167
|
+
|
|
168
|
+
// Validates that T has no mixed-type field keys
|
|
169
|
+
type ValidateNoMixedTypeFields<K extends string, T> = [MixedTypeFieldKeys<K, T>] extends [never]
|
|
170
|
+
? T
|
|
171
|
+
: `Error: Field '${MixedTypeFieldKeys<K, T> & string}' exists in all variants but with different types. Shared fields must have the same type across all variants.`;
|
|
172
|
+
|
|
173
|
+
// Combined validation
|
|
174
|
+
type ValidateFields<K extends string, T> = [PartiallySharedFieldKeys<K, T>] extends [never]
|
|
175
|
+
? [MixedTypeFieldKeys<K, T>] extends [never]
|
|
176
|
+
? T
|
|
177
|
+
: ValidateNoMixedTypeFields<K, T>
|
|
178
|
+
: ValidateNoPartiallySharedFields<K, T>;
|
|
179
|
+
|
|
180
|
+
// Variant type with common fields removed (for snippet parameters)
|
|
181
|
+
// This prevents accidentally rendering common fields twice
|
|
182
|
+
type VariantOnlyFields<K extends string, T, V extends string> = Omit<
|
|
183
|
+
NarrowToVariant<K, T, V>,
|
|
184
|
+
CommonFieldKeys<K, T>
|
|
185
|
+
>;
|
|
186
|
+
|
|
187
|
+
// Snippet props mapped from variant values - optionality controlled by Partial flag
|
|
188
|
+
// Note: snippets receive variant-only fields (common fields are excluded to prevent duplicates)
|
|
189
|
+
type VariantSnippets<
|
|
190
|
+
K extends string,
|
|
191
|
+
T,
|
|
192
|
+
V extends string,
|
|
193
|
+
IsPartial extends boolean,
|
|
194
|
+
> = IsPartial extends true
|
|
195
|
+
? { [P in Exclude<V, ReservedProps>]?: Snippet<[VariantOnlyFields<K, T, P>]> }
|
|
196
|
+
: { [P in Exclude<V, ReservedProps>]: Snippet<[VariantOnlyFields<K, T, P>]> };
|
|
197
|
+
|
|
198
|
+
// Full props type with explicit key
|
|
199
|
+
export type UnionVariantsPropsWithKey<
|
|
200
|
+
K extends string,
|
|
201
|
+
T extends Record<K, { value(): unknown }>,
|
|
202
|
+
V extends string = DiscriminatorValues<K, T>,
|
|
203
|
+
IsPartial extends boolean = false,
|
|
204
|
+
> = {
|
|
205
|
+
fields: ValidateFields<K, T>;
|
|
206
|
+
key: K;
|
|
207
|
+
/** Optional snippet shown when no variant is selected */
|
|
208
|
+
fallback?: Snippet;
|
|
209
|
+
/** When true, variant snippets are optional (default: false) */
|
|
210
|
+
partial?: IsPartial;
|
|
211
|
+
/**
|
|
212
|
+
* CSS selector for the discriminator input element (select or radio container).
|
|
213
|
+
* When provided, uses this selector instead of the default name-based lookup.
|
|
214
|
+
* Example: "#method-select" for a select, or "#radio-group" for radios
|
|
215
|
+
*/
|
|
216
|
+
selector?: string;
|
|
217
|
+
} & VariantSnippets<K, T, V, IsPartial>;
|
|
218
|
+
|
|
219
|
+
// Props type with auto-inferred key (key is optional)
|
|
220
|
+
export type UnionVariantsPropsInferred<
|
|
221
|
+
T extends Record<string, { value(): unknown }>,
|
|
222
|
+
K extends string = InferDiscriminator<T> extends infer I ? (I extends string ? I : never) : never,
|
|
223
|
+
V extends string = DiscriminatorValues<K, T>,
|
|
224
|
+
IsPartial extends boolean = false,
|
|
225
|
+
> = InferDiscriminator<T> extends string
|
|
226
|
+
? {
|
|
227
|
+
fields: ValidateFields<K, T>;
|
|
228
|
+
key?: K;
|
|
229
|
+
fallback?: Snippet;
|
|
230
|
+
partial?: IsPartial;
|
|
231
|
+
selector?: string;
|
|
232
|
+
} & VariantSnippets<K, T, V, IsPartial>
|
|
233
|
+
: {
|
|
234
|
+
fields: InferDiscriminator<T>; // This will be the error message
|
|
235
|
+
key: never;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
// Combined props type - key can be explicit or inferred
|
|
239
|
+
export type UnionVariantsProps<
|
|
240
|
+
K extends string,
|
|
241
|
+
T extends Record<K, { value(): unknown }>,
|
|
242
|
+
V extends string = DiscriminatorValues<K, T>,
|
|
243
|
+
IsPartial extends boolean = false,
|
|
244
|
+
> = UnionVariantsPropsWithKey<K, T, V, IsPartial>;
|
|
245
|
+
</script>
|
|
246
|
+
|
|
247
|
+
<script
|
|
248
|
+
lang="ts"
|
|
249
|
+
generics="K extends string, T extends Record<K, { value(): unknown }>, V extends string = DiscriminatorValues<K, T>, IsPartial extends boolean = false"
|
|
250
|
+
>
|
|
251
|
+
type Props = UnionVariantsProps<K, T, V, IsPartial>;
|
|
252
|
+
|
|
253
|
+
let { fields, key: keyProp, fallback, partial, selector, ...snippets }: Props = $props();
|
|
254
|
+
|
|
255
|
+
// Auto-detect discriminator key if not provided
|
|
256
|
+
function findDiscriminatorKey(f: Record<string, { value(): unknown }>): string {
|
|
257
|
+
const fieldKeys = Object.keys(f).filter((k) => {
|
|
258
|
+
const field = f[k];
|
|
259
|
+
return field && typeof field === "object" && "value" in field && typeof field.value === "function";
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const candidates = fieldKeys.filter((k) => {
|
|
263
|
+
const field = f[k];
|
|
264
|
+
const value = field.value();
|
|
265
|
+
return typeof value === "string" || value === undefined;
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
if (candidates.length === 0) {
|
|
269
|
+
throw new Error("No valid discriminator key found in fields");
|
|
270
|
+
}
|
|
271
|
+
return candidates[0];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Use provided key or auto-detect
|
|
275
|
+
const key = $derived(keyProp ?? findDiscriminatorKey(fields as Record<string, { value(): unknown }>)) as K;
|
|
276
|
+
|
|
277
|
+
// Get all variant values from the snippet names (excluding fallback)
|
|
278
|
+
const variantValues = Object.keys(snippets) as V[];
|
|
279
|
+
|
|
280
|
+
// Get the actual field name from the discriminator field (handles nested paths like "shipping.method")
|
|
281
|
+
const fieldName = $derived.by(() => {
|
|
282
|
+
const discriminatorField = (fields as Record<K, { as(type: "select"): { name: string } }>)[key];
|
|
283
|
+
return discriminatorField?.as("select")?.name ?? key;
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Generate CSS for showing/hiding variants based on select or radio value
|
|
287
|
+
const css = $derived.by(() => {
|
|
288
|
+
const attr = `data-union-${key}`;
|
|
289
|
+
const name = fieldName;
|
|
290
|
+
|
|
291
|
+
if (selector) {
|
|
292
|
+
// Input-based: use :has() with the provided selector to find any ancestor
|
|
293
|
+
// Supports: select elements, radio inputs directly, or radio containers
|
|
294
|
+
const selOpt = (v: string) => `*:has(${selector} option[value="${v}"]:checked)`;
|
|
295
|
+
const radDirect = (v: string) => `*:has(${selector}[value="${v}"]:checked)`;
|
|
296
|
+
const radContainer = (v: string) => `*:has(${selector} input[value="${v}"]:checked)`;
|
|
297
|
+
|
|
298
|
+
// Hide all variant sections by default
|
|
299
|
+
const hideAll = `*:has(${selector}) [${attr}]:not([${attr}="fallback"]) { display: none; }\n`;
|
|
300
|
+
|
|
301
|
+
// Show the variant section that matches the selected value
|
|
302
|
+
const showVariants = variantValues
|
|
303
|
+
.map((v) => `${selOpt(v)} [${attr}="${v}"],
|
|
304
|
+
${radDirect(v)} [${attr}="${v}"],
|
|
305
|
+
${radContainer(v)} [${attr}="${v}"] { display: contents; }`)
|
|
306
|
+
.join("\n");
|
|
307
|
+
|
|
308
|
+
// Hide fallback when any variant is selected
|
|
309
|
+
const hideFallback =
|
|
310
|
+
fallback && variantValues.length
|
|
311
|
+
? `*:has(${selector}:checked) [${attr}="fallback"],
|
|
312
|
+
*:has(${selector} input:checked) [${attr}="fallback"],
|
|
313
|
+
${variantValues.map((v) => `${selOpt(v)} [${attr}="fallback"]`).join(",\n")} { display: none; }\n`
|
|
314
|
+
: "";
|
|
315
|
+
|
|
316
|
+
return hideAll + showVariants + hideFallback;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Sibling-based (default): look for sibling elements containing the discriminator
|
|
320
|
+
const sel = (s: string) => `*:has(select[name="${name}"]${s}) ~`;
|
|
321
|
+
const rad = (s: string) => `*:has(input[name="${name}"]${s}) ~`;
|
|
322
|
+
|
|
323
|
+
// Hide all variant sections by default (but not fallback)
|
|
324
|
+
const hideAll = `${sel("")} [${attr}]:not([${attr}="fallback"]),
|
|
325
|
+
${rad("")} [${attr}]:not([${attr}="fallback"]) { display: none; }\n`;
|
|
326
|
+
|
|
327
|
+
// Show the variant section that matches the selected value
|
|
328
|
+
const showVariants = variantValues
|
|
329
|
+
.map((v) => `${sel(` option[value="${v}"]:checked`)} [${attr}="${v}"],
|
|
330
|
+
${rad(`[value="${v}"]:checked`)} [${attr}="${v}"] { display: contents; }`)
|
|
331
|
+
.join("\n");
|
|
332
|
+
|
|
333
|
+
// Hide fallback when any variant is selected
|
|
334
|
+
const hideFallback =
|
|
335
|
+
fallback && variantValues.length
|
|
336
|
+
? `${rad(":checked")} [${attr}="fallback"],
|
|
337
|
+
${variantValues.map((v) => `${sel(` option[value="${v}"]:checked`)} [${attr}="fallback"]`).join(",\n")} { display: none; }\n`
|
|
338
|
+
: "";
|
|
339
|
+
|
|
340
|
+
return hideAll + showVariants + hideFallback;
|
|
341
|
+
});
|
|
342
|
+
</script>
|
|
343
|
+
|
|
344
|
+
<svelte:head>
|
|
345
|
+
{@html `<style>${css}</style>`}
|
|
346
|
+
</svelte:head>
|
|
347
|
+
|
|
348
|
+
{#if fallback}
|
|
349
|
+
{@const attrs = { [`data-union-${key}`]: "fallback" }}
|
|
350
|
+
<div {...attrs}>
|
|
351
|
+
{@render fallback()}
|
|
352
|
+
</div>
|
|
353
|
+
{/if}
|
|
354
|
+
|
|
355
|
+
{#each variantValues as v (v)}
|
|
356
|
+
{@const snippet = (snippets as unknown as Record<V, Snippet<[T]>>)[v]}
|
|
357
|
+
{@const attrs = { [`data-union-${key}`]: v }}
|
|
358
|
+
<div {...attrs}>
|
|
359
|
+
{@render snippet(fields as T)}
|
|
360
|
+
</div>
|
|
361
|
+
{/each}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { Snippet } from "svelte";
|
|
2
|
+
import type { InferDiscriminator } from "./infer-discriminator.js";
|
|
3
|
+
type DiscriminatorValues<K extends string, T> = T extends Record<K, {
|
|
4
|
+
value(): infer V;
|
|
5
|
+
}> ? V extends string ? V : never : never;
|
|
6
|
+
type NarrowToVariant<K extends string, T, V> = T extends Record<K, {
|
|
7
|
+
value(): V;
|
|
8
|
+
}> ? T : never;
|
|
9
|
+
type ReservedProps = "fields" | "key" | "fallback" | "partial";
|
|
10
|
+
type FieldKeys<T> = {
|
|
11
|
+
[P in keyof T]: T[P] extends {
|
|
12
|
+
value(): unknown;
|
|
13
|
+
} ? P : never;
|
|
14
|
+
}[keyof T];
|
|
15
|
+
type VariantFieldKeys<K extends string, T, V extends string> = Exclude<FieldKeys<NarrowToVariant<K, T, V>>, K>;
|
|
16
|
+
type OtherVariantsFieldKeys<K extends string, T, V extends string, OtherV extends string = Exclude<DiscriminatorValues<K, T>, V>> = OtherV extends OtherV ? VariantFieldKeys<K, T, OtherV> : never;
|
|
17
|
+
type SharedFieldKeys<K extends string, T, V extends string> = VariantFieldKeys<K, T, V> & OtherVariantsFieldKeys<K, T, V>;
|
|
18
|
+
type AllSharedFieldKeys<K extends string, T, V extends string = DiscriminatorValues<K, T>> = V extends V ? SharedFieldKeys<K, T, V> : never;
|
|
19
|
+
type HasField<T, P> = P extends keyof T ? T[P] extends {
|
|
20
|
+
value(): unknown;
|
|
21
|
+
} ? true : false : false;
|
|
22
|
+
type FieldExistsInAll<K extends string, T, P extends string, V extends string = DiscriminatorValues<K, T>> = V extends V ? HasField<NarrowToVariant<K, T, V>, P> : never;
|
|
23
|
+
type IsCommonField<K extends string, T, P extends string> = false extends FieldExistsInAll<K, T, P> ? false : true;
|
|
24
|
+
type FieldValueType<K extends string, T, V extends string, P extends string> = NarrowToVariant<K, T, V>[P & keyof NarrowToVariant<K, T, V>] extends {
|
|
25
|
+
value(): infer R;
|
|
26
|
+
} ? R : never;
|
|
27
|
+
type AllFieldValueTypes<K extends string, T, P extends string, V extends string = DiscriminatorValues<K, T>> = V extends V ? FieldValueType<K, T, V, P> : never;
|
|
28
|
+
type FieldTypeMatchesInVariant<K extends string, T, P extends string, V extends string, Union = AllFieldValueTypes<K, T, P>> = [Union] extends [FieldValueType<K, T, V, P>] ? true : false;
|
|
29
|
+
type FieldHasSameTypeInAll<K extends string, T, P extends string, V extends string = DiscriminatorValues<K, T>> = V extends V ? FieldTypeMatchesInVariant<K, T, P, V> : never;
|
|
30
|
+
type IsUniformTypeField<K extends string, T, P extends string> = false extends FieldHasSameTypeInAll<K, T, P> ? false : true;
|
|
31
|
+
type PartiallySharedFieldKeys<K extends string, T> = {
|
|
32
|
+
[P in AllSharedFieldKeys<K, T>]: IsCommonField<K, T, P & string> extends true ? never : P;
|
|
33
|
+
}[AllSharedFieldKeys<K, T>];
|
|
34
|
+
type MixedTypeFieldKeys<K extends string, T> = {
|
|
35
|
+
[P in AllSharedFieldKeys<K, T>]: IsCommonField<K, T, P & string> extends true ? IsUniformTypeField<K, T, P & string> extends true ? never : P : never;
|
|
36
|
+
}[AllSharedFieldKeys<K, T>];
|
|
37
|
+
type CommonFieldKeys<K extends string, T> = {
|
|
38
|
+
[P in AllSharedFieldKeys<K, T>]: IsCommonField<K, T, P & string> extends true ? IsUniformTypeField<K, T, P & string> extends true ? P : never : never;
|
|
39
|
+
}[AllSharedFieldKeys<K, T>];
|
|
40
|
+
type ValidateNoPartiallySharedFields<K extends string, T> = [
|
|
41
|
+
PartiallySharedFieldKeys<K, T>
|
|
42
|
+
] extends [never] ? T : `Error: Field '${PartiallySharedFieldKeys<K, T> & string}' exists in some variants but not all. Fields must be either unique to one variant or common to all variants.`;
|
|
43
|
+
type ValidateNoMixedTypeFields<K extends string, T> = [MixedTypeFieldKeys<K, T>] extends [never] ? T : `Error: Field '${MixedTypeFieldKeys<K, T> & string}' exists in all variants but with different types. Shared fields must have the same type across all variants.`;
|
|
44
|
+
type ValidateFields<K extends string, T> = [PartiallySharedFieldKeys<K, T>] extends [never] ? [MixedTypeFieldKeys<K, T>] extends [never] ? T : ValidateNoMixedTypeFields<K, T> : ValidateNoPartiallySharedFields<K, T>;
|
|
45
|
+
type VariantOnlyFields<K extends string, T, V extends string> = Omit<NarrowToVariant<K, T, V>, CommonFieldKeys<K, T>>;
|
|
46
|
+
type VariantSnippets<K extends string, T, V extends string, IsPartial extends boolean> = IsPartial extends true ? {
|
|
47
|
+
[P in Exclude<V, ReservedProps>]?: Snippet<[VariantOnlyFields<K, T, P>]>;
|
|
48
|
+
} : {
|
|
49
|
+
[P in Exclude<V, ReservedProps>]: Snippet<[VariantOnlyFields<K, T, P>]>;
|
|
50
|
+
};
|
|
51
|
+
export type UnionVariantsPropsWithKey<K extends string, T extends Record<K, {
|
|
52
|
+
value(): unknown;
|
|
53
|
+
}>, V extends string = DiscriminatorValues<K, T>, IsPartial extends boolean = false> = {
|
|
54
|
+
fields: ValidateFields<K, T>;
|
|
55
|
+
key: K;
|
|
56
|
+
/** Optional snippet shown when no variant is selected */
|
|
57
|
+
fallback?: Snippet;
|
|
58
|
+
/** When true, variant snippets are optional (default: false) */
|
|
59
|
+
partial?: IsPartial;
|
|
60
|
+
/**
|
|
61
|
+
* CSS selector for the discriminator input element (select or radio container).
|
|
62
|
+
* When provided, uses this selector instead of the default name-based lookup.
|
|
63
|
+
* Example: "#method-select" for a select, or "#radio-group" for radios
|
|
64
|
+
*/
|
|
65
|
+
selector?: string;
|
|
66
|
+
} & VariantSnippets<K, T, V, IsPartial>;
|
|
67
|
+
export type UnionVariantsPropsInferred<T extends Record<string, {
|
|
68
|
+
value(): unknown;
|
|
69
|
+
}>, K extends string = InferDiscriminator<T> extends infer I ? (I extends string ? I : never) : never, V extends string = DiscriminatorValues<K, T>, IsPartial extends boolean = false> = InferDiscriminator<T> extends string ? {
|
|
70
|
+
fields: ValidateFields<K, T>;
|
|
71
|
+
key?: K;
|
|
72
|
+
fallback?: Snippet;
|
|
73
|
+
partial?: IsPartial;
|
|
74
|
+
selector?: string;
|
|
75
|
+
} & VariantSnippets<K, T, V, IsPartial> : {
|
|
76
|
+
fields: InferDiscriminator<T>;
|
|
77
|
+
key: never;
|
|
78
|
+
};
|
|
79
|
+
export type UnionVariantsProps<K extends string, T extends Record<K, {
|
|
80
|
+
value(): unknown;
|
|
81
|
+
}>, V extends string = DiscriminatorValues<K, T>, IsPartial extends boolean = false> = UnionVariantsPropsWithKey<K, T, V, IsPartial>;
|
|
82
|
+
declare function $$render<K extends string, T extends Record<K, {
|
|
83
|
+
value(): unknown;
|
|
84
|
+
}>, V extends string = DiscriminatorValues<K, T>, IsPartial extends boolean = false>(): {
|
|
85
|
+
props: UnionVariantsProps<K, T, V, IsPartial>;
|
|
86
|
+
exports: {};
|
|
87
|
+
bindings: "";
|
|
88
|
+
slots: {};
|
|
89
|
+
events: {};
|
|
90
|
+
};
|
|
91
|
+
declare class __sveltets_Render<K extends string, T extends Record<K, {
|
|
92
|
+
value(): unknown;
|
|
93
|
+
}>, V extends string = DiscriminatorValues<K, T>, IsPartial extends boolean = false> {
|
|
94
|
+
props(): ReturnType<typeof $$render<K, T, V, IsPartial>>['props'];
|
|
95
|
+
events(): ReturnType<typeof $$render<K, T, V, IsPartial>>['events'];
|
|
96
|
+
slots(): ReturnType<typeof $$render<K, T, V, IsPartial>>['slots'];
|
|
97
|
+
bindings(): "";
|
|
98
|
+
exports(): {};
|
|
99
|
+
}
|
|
100
|
+
interface $$IsomorphicComponent {
|
|
101
|
+
new <K extends string, T extends Record<K, {
|
|
102
|
+
value(): unknown;
|
|
103
|
+
}>, V extends string = DiscriminatorValues<K, 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']>> & {
|
|
104
|
+
$$bindings?: ReturnType<__sveltets_Render<K, T, V, IsPartial>['bindings']>;
|
|
105
|
+
} & ReturnType<__sveltets_Render<K, T, V, IsPartial>['exports']>;
|
|
106
|
+
<K extends string, T extends Record<K, {
|
|
107
|
+
value(): unknown;
|
|
108
|
+
}>, V extends string = DiscriminatorValues<K, T>, IsPartial extends boolean = false>(internal: unknown, props: ReturnType<__sveltets_Render<K, T, V, IsPartial>['props']> & {}): ReturnType<__sveltets_Render<K, T, V, IsPartial>['exports']>;
|
|
109
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any, any, any, any>['bindings']>;
|
|
110
|
+
}
|
|
111
|
+
declare const UnionVariants: $$IsomorphicComponent;
|
|
112
|
+
type UnionVariants<K extends string, T extends Record<K, {
|
|
113
|
+
value(): unknown;
|
|
114
|
+
}>, V extends string = DiscriminatorValues<K, T>, IsPartial extends boolean = false> = InstanceType<typeof UnionVariants<K, T, V, IsPartial>>;
|
|
115
|
+
export default UnionVariants;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { RemoteFormField, RemoteFormFieldType } from '@sveltejs/kit';
|
|
2
|
+
export type DiscriminatedData<T> = T extends {
|
|
3
|
+
set: (v: infer D) => unknown;
|
|
4
|
+
} ? D : never;
|
|
5
|
+
type ExtractFieldValue<F> = F extends RemoteFormField<infer V> ? V : never;
|
|
6
|
+
type RadioProps = {
|
|
7
|
+
name: string;
|
|
8
|
+
type: 'radio';
|
|
9
|
+
value?: string;
|
|
10
|
+
'aria-invalid': boolean | 'false' | 'true' | undefined;
|
|
11
|
+
get checked(): boolean;
|
|
12
|
+
set checked(value: boolean);
|
|
13
|
+
};
|
|
14
|
+
type NonRadioAsArgs<Type extends string, Value> = Type extends 'checkbox' ? Value extends string[] ? [type: Type, value: Value[number] | (string & {})] : [type: Type] : Type extends 'submit' | 'hidden' ? [type: Type, value: Value | (string & {})] : [type: Type];
|
|
15
|
+
type StrictDiscriminatorField<Value extends string, BaseField extends RemoteFormField<Value>> = Omit<BaseField, 'as'> & {
|
|
16
|
+
as(type: 'radio', value: Value): RadioProps;
|
|
17
|
+
as<T extends Exclude<RemoteFormFieldType<Value>, 'radio'>>(...args: NonRadioAsArgs<T, Value>): ReturnType<BaseField['as']>;
|
|
18
|
+
};
|
|
19
|
+
type UnifyField<F, V extends string = ExtractFieldValue<F> & string> = StrictDiscriminatorField<V, RemoteFormField<V>>;
|
|
20
|
+
type Setter<K extends string, T, D = DiscriminatedData<T>> = {
|
|
21
|
+
set: <V extends D[K & keyof D]>(data: Extract<D, {
|
|
22
|
+
[P in K]: V;
|
|
23
|
+
}>) => void;
|
|
24
|
+
};
|
|
25
|
+
type Keys<T> = T extends unknown ? keyof T : never;
|
|
26
|
+
type KeyValue<K extends string, V> = {
|
|
27
|
+
[P in `${K}Value`]: V;
|
|
28
|
+
};
|
|
29
|
+
type Fields<T> = {
|
|
30
|
+
[P in Keys<T>]: T extends Record<P, infer V> ? V : never;
|
|
31
|
+
};
|
|
32
|
+
type Variant<K extends string, T, V> = KeyValue<K, V> & Fields<T>;
|
|
33
|
+
type Narrowed<K extends string, T> = T extends {
|
|
34
|
+
[P in K]: {
|
|
35
|
+
value(): infer V;
|
|
36
|
+
};
|
|
37
|
+
} ? Variant<K, T, V> : never;
|
|
38
|
+
type Union<K extends string, T> = Narrowed<K, T> | Variant<K, T, undefined>;
|
|
39
|
+
type FieldUnion<K extends string, T> = T extends Record<K, infer F> ? F : never;
|
|
40
|
+
type UnifiedDiscriminator<K extends string, T, F = FieldUnion<K, T>> = {
|
|
41
|
+
readonly [P in K]: [F] extends [never] ? never : UnifyField<F>;
|
|
42
|
+
};
|
|
43
|
+
type DiscriminatedFields<K extends string, T> = Setter<K, T> & Union<K, T> & UnifiedDiscriminator<K, T>;
|
|
44
|
+
/**
|
|
45
|
+
* Wraps discriminated union form fields for type-safe access.
|
|
46
|
+
* - All original fields pass through unchanged (type, issues, allIssues, etc.)
|
|
47
|
+
* - `set` is overridden with type-safe version
|
|
48
|
+
* - `${key}Value` is added for discriminator value (e.g., `reward.typeValue`)
|
|
49
|
+
* - Discriminator field `.as("radio", value)` is type-safe (only valid values allowed)
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```svelte
|
|
53
|
+
* <script>
|
|
54
|
+
* const priority = $derived(discriminatedFields("level", priorityForm.fields));
|
|
55
|
+
* </script>
|
|
56
|
+
*
|
|
57
|
+
* <input {...priority.level.as("radio", "high")} /> <!-- type-safe: only valid values allowed -->
|
|
58
|
+
* ```
|
|
59
|
+
*
|
|
60
|
+
* @param key - Discriminator key (e.g. 'type')
|
|
61
|
+
* @param fields - Form fields from a discriminated union schema
|
|
62
|
+
* @returns Passthrough object with type-safe set(), ${key}Value, and .as("radio", value)
|
|
63
|
+
*/
|
|
64
|
+
export declare function discriminatedFields<K extends string, T extends {
|
|
65
|
+
set: (v: never) => unknown;
|
|
66
|
+
} & Record<K, {
|
|
67
|
+
value(): unknown;
|
|
68
|
+
as(type: 'radio', value: string): object;
|
|
69
|
+
}>>(key: K, fields: T): DiscriminatedFields<K, T>;
|
|
70
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wraps discriminated union form fields for type-safe access.
|
|
3
|
+
* - All original fields pass through unchanged (type, issues, allIssues, etc.)
|
|
4
|
+
* - `set` is overridden with type-safe version
|
|
5
|
+
* - `${key}Value` is added for discriminator value (e.g., `reward.typeValue`)
|
|
6
|
+
* - Discriminator field `.as("radio", value)` is type-safe (only valid values allowed)
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```svelte
|
|
10
|
+
* <script>
|
|
11
|
+
* const priority = $derived(discriminatedFields("level", priorityForm.fields));
|
|
12
|
+
* </script>
|
|
13
|
+
*
|
|
14
|
+
* <input {...priority.level.as("radio", "high")} /> <!-- type-safe: only valid values allowed -->
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* @param key - Discriminator key (e.g. 'type')
|
|
18
|
+
* @param fields - Form fields from a discriminated union schema
|
|
19
|
+
* @returns Passthrough object with type-safe set(), ${key}Value, and .as("radio", value)
|
|
20
|
+
*/
|
|
21
|
+
export function discriminatedFields(key, fields) {
|
|
22
|
+
const proxy = new Proxy(fields, {
|
|
23
|
+
get(target, prop) {
|
|
24
|
+
if (prop === `${key}Value`)
|
|
25
|
+
return target[key].value();
|
|
26
|
+
if (prop === 'set')
|
|
27
|
+
return (data) => target.set(data);
|
|
28
|
+
return Reflect.get(target, prop);
|
|
29
|
+
},
|
|
30
|
+
has(target, prop) {
|
|
31
|
+
return prop === `${key}Value` || prop in target;
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
return proxy;
|
|
35
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,39 +1,4 @@
|
|
|
1
|
-
export type DiscriminatedData
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
type
|
|
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 { discriminatedFields, type DiscriminatedData } from './discriminated-fields.js';
|
|
2
|
+
export type { InferDiscriminator } from './infer-discriminator.js';
|
|
3
|
+
export { default as UnionVariants } from './UnionVariants.svelte';
|
|
4
|
+
export type { UnionVariantsProps } from './UnionVariants.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 discriminatedFields function and types
|
|
2
|
+
export { discriminatedFields } from './discriminated-fields.js';
|
|
3
|
+
// Re-export UnionVariants component and its types
|
|
4
|
+
export { default as UnionVariants } from './UnionVariants.svelte';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
type AllKeys<T> = T extends T ? keyof T : never;
|
|
2
|
+
type HasLiteralValue<T, K> = K extends keyof T ? T[K] extends {
|
|
3
|
+
value(): infer V;
|
|
4
|
+
} ? V extends string ? string extends V ? false : true : false : false : false;
|
|
5
|
+
type IsDiscriminatorInAll<T, K> = T extends T ? HasLiteralValue<T, K> : never;
|
|
6
|
+
type IsDiscriminator<T, K> = [IsDiscriminatorInAll<T, K>] extends [true] ? true : false;
|
|
7
|
+
type DiscriminatorKeys<T, K = AllKeys<T>> = K extends K ? IsDiscriminator<T, K> extends true ? K : never : never;
|
|
8
|
+
type IsUnion<T, U = T> = T extends T ? ([U] extends [T] ? false : true) : never;
|
|
9
|
+
export type InferDiscriminator<T> = DiscriminatorKeys<T> extends infer Keys ? [Keys] extends [never] ? "Error: No valid discriminator found. A discriminator must exist in all variants with distinct string literal values." : IsUnion<Keys> extends true ? `Error: Multiple discriminator candidates found: ${Keys & string}. Please specify the key explicitly.` : Keys : never;
|
|
10
|
+
export type IsValidInference<T> = InferDiscriminator<T> extends `Error: ${string}` ? false : true;
|
|
11
|
+
export type { DiscriminatorKeys };
|
package/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sveltekit-discriminated-fields",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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
|
}
|