diff --git a/.changeset/tiny-badgers-swim.md b/.changeset/tiny-badgers-swim.md new file mode 100644 index 000000000..d2a39c0ff --- /dev/null +++ b/.changeset/tiny-badgers-swim.md @@ -0,0 +1,5 @@ +--- +'@tanstack/svelte-form': minor +--- + +Add createFormCreator API diff --git a/docs/framework/svelte/guides/form-composition.md b/docs/framework/svelte/guides/form-composition.md new file mode 100644 index 000000000..f9fbc0dda --- /dev/null +++ b/docs/framework/svelte/guides/form-composition.md @@ -0,0 +1,507 @@ +--- +id: form-composition +title: Form Composition +--- + +A common criticism of TanStack Form is its verbosity out-of-the-box. While this _can_ be useful for educational purposes - helping enforce understanding our APIs - it's not ideal in production use cases. + +As a result, while `form.Field` enables the most powerful and flexible usage of TanStack Form, we provide APIs that wrap it and make your application code less verbose. + +## Custom Form Runes + +The most powerful way to compose forms is to create custom form runes. This allows you to create a form rune that is tailored to your application's needs, including pre-bound custom UI components and more. + +At its most basic, `createFormCreator` is a function that returns a `createAppForm` rune. + +> This un-customized `createAppForm` rune is identical to `createForm`, but that will quickly change as we add more options to `createFormCreator`. + +```ts +// form-context.ts +import { createFormCreatorContexts } from '@tanstack/svelte-form' + +// export useFieldContext and useFormContext for use in your custom components +export const { useFieldContext, useFormContext } = createFormCreatorContexts() +``` + +```ts +// form.ts +import { createFormCreator } from '@tanstack/svelte-form' + +export const { createAppForm } = createFormCreator({ + // We'll learn more about these options later + fieldComponents: {}, + formComponents: {}, +}) +``` + +```svelte + + + + + + +``` + +### Pre-bound Field Components + +Once this scaffolding is in place, you can start adding custom field and form components to your form rune. + +> Note: the `useFieldContext` must be the same one exported from your custom form context + +```svelte + + + + +``` + +You're then able to register this component with your form rune. + +```ts +import { createFormCreator } from '@tanstack/svelte-form' +import TextField from './text-field.svelte' + +export const { createAppForm } = createFormCreator({ + fieldComponents: { + TextField, + }, + formComponents: {}, +}) +``` + +And use it in your form: + +```svelte + + + + + {#snippet children(field)} + + {/snippet} + +``` + +This not only allows you to reuse the UI of your shared component, but retains the type-safety you'd expect from TanStack Form: Typo `name` and get a TypeScript error. + +### Pre-bound Form Components + +While `form.AppField` solves many of the problems with Field boilerplate and reusability, it doesn't solve the problem of _form_ boilerplate and reusability. + +In particular, being able to share instances of `form.Subscribe` for, say, a reactive form submission button is a common usecase. + +```svelte + + + + state.isSubmitting}> + {#snippet children(isSubmitting)} + + {/snippet} + +``` + +```ts +// form.ts +import { createFormCreator } from '@tanstack/svelte-form' +import TextField from './text-field.svelte' +import SubscribeButton from './subscribe-button.svelte' + +export const { createAppForm } = createFormCreator({ + fieldComponents: { + TextField, + }, + formComponents: { + SubscribeButton, + }, +}) +``` + +```svelte + + + + + + {#snippet children()} + + {/snippet} + +``` + +## Breaking big forms into smaller pieces + +Sometimes forms get very large; it's just how it goes sometimes. While TanStack Form supports large forms well, it's never fun to work with hundreds or thousands of lines of code long files. + +To solve this, you can break forms into smaller Svelte components that accept the form as a prop. + +```ts +// shared-form.ts +import { formOptions } from '@tanstack/svelte-form' + +export const peopleFormOpts = formOptions({ + defaultValues: { + firstName: 'John', + lastName: 'Doe', + }, +}) +``` + +```svelte + + + +
+

{title}

+ + {#snippet children(field)} + + {/snippet} + + + {#snippet children()} + + {/snippet} + +
+``` + +```svelte + + + + +``` + +## Reusing groups of fields in multiple forms + +Sometimes, a pair of fields are so closely related that it makes sense to group and reuse them — like the password example listed in the [linked fields guide](../linked-fields.md). Instead of repeating this logic across multiple forms, you can create reusable field group components. + +> Unlike form-level components, validators in field groups cannot be strictly typed and could be any value. +> Ensure that your fields can accept unknown error types. + +Rewriting the passwords example as a reusable component would look like this: + +```svelte + + + +
+

{title}

+ + {#snippet children(field)} + + {/snippet} + + { + if (value !== fieldApi.form.getFieldValue('password')) { + return 'Passwords do not match' + } + return undefined + }, + }} + > + {#snippet children(field)} +
+ + {#each field.state.meta.errors as error} +
{error}
+ {/each} +
+ {/snippet} +
+
+``` + +We can now use these grouped fields in any form that implements the required fields: + +```svelte + + + + + {#snippet children()} + + {#snippet children(field)} + + {/snippet} + + + + {/snippet} + +``` + +## Tree-shaking form and field components + +While the above examples are great for getting started, they're not ideal for certain use-cases where you might have hundreds of form and field components. +In particular, you may not want to include all of your form and field components in the bundle of every file that uses your form rune. + +To solve this, you can use dynamic imports with Svelte's component loading: + +```ts +// src/runes/form-context.ts +import { createFormCreatorContexts } from '@tanstack/svelte-form' + +export const { useFieldContext, useFormContext } = createFormCreatorContexts() +``` + +```svelte + + + + +``` + +```ts +// src/runes/form.ts +import { createFormCreator } from '@tanstack/svelte-form' +import TextField from '../components/text-field.svelte' +import SubscribeButton from '../components/subscribe-button.svelte' + +export const { createAppForm } = createFormCreator({ + fieldComponents: { + TextField, + }, + formComponents: { + SubscribeButton, + }, +}) +``` + +```svelte + + + + +``` + +## Putting it all together + +Now that we've covered the basics of creating custom form runes, let's put it all together in a single example. + +```ts +// /src/runes/form-context.ts, to be used across the entire app +import { createFormCreatorContexts } from '@tanstack/svelte-form' + +export const { useFieldContext, useFormContext } = createFormCreatorContexts() +``` + +```svelte + + + + +``` + +```svelte + + + + state.isSubmitting}> + {#snippet children(isSubmitting)} + + {/snippet} + +``` + +```ts +// /src/runes/form.ts +import { createFormCreator } from '@tanstack/svelte-form' +import TextField from '../components/text-field.svelte' +import SubscribeButton from '../components/subscribe-button.svelte' + +export const { createAppForm } = createFormCreator({ + fieldComponents: { + TextField, + }, + formComponents: { + SubscribeButton, + }, +}) +``` + +```ts +// /src/features/people/shared-form.ts, to be used across `people` features +import { formOptions } from '@tanstack/svelte-form' + +export const peopleFormOpts = formOptions({ + defaultValues: { + firstName: 'John', + lastName: 'Doe', + }, +}) +``` + +```svelte + + + +
+

{title}

+ + {#snippet children(field)} + + {/snippet} + + + {#snippet children()} + + {/snippet} + +
+``` + +```svelte + + + + +``` + +## API Usage Guidance + +Here's a chart to help you decide what APIs you should be using: + +![](https://raw.githubusercontent.com/TanStack/form/main/docs/assets/svelte_form_composability.svg) diff --git a/examples/svelte/large-form/.gitignore b/examples/svelte/large-form/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/examples/svelte/large-form/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/examples/svelte/large-form/README.md b/examples/svelte/large-form/README.md new file mode 100644 index 000000000..1cf889265 --- /dev/null +++ b/examples/svelte/large-form/README.md @@ -0,0 +1,6 @@ +# Example + +To run this example: + +- `npm install` +- `npm run dev` diff --git a/examples/svelte/large-form/index.html b/examples/svelte/large-form/index.html new file mode 100644 index 000000000..b6c5f0afa --- /dev/null +++ b/examples/svelte/large-form/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + Svelte + TS + + +
+ + + diff --git a/examples/svelte/large-form/package.json b/examples/svelte/large-form/package.json new file mode 100644 index 000000000..78674fb15 --- /dev/null +++ b/examples/svelte/large-form/package.json @@ -0,0 +1,21 @@ +{ + "name": "@tanstack/form-example-svelte-large-form", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/svelte-form": "^1.23.0" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.1.1", + "@tsconfig/svelte": "^5.0.5", + "svelte": "^5.39.4", + "typescript": "5.8.2", + "vite": "^7.2.2" + } +} diff --git a/examples/svelte/large-form/src/App.svelte b/examples/svelte/large-form/src/App.svelte new file mode 100644 index 000000000..3d5f2699a --- /dev/null +++ b/examples/svelte/large-form/src/App.svelte @@ -0,0 +1,5 @@ + + + diff --git a/examples/svelte/large-form/src/components/subscribe-button.svelte b/examples/svelte/large-form/src/components/subscribe-button.svelte new file mode 100644 index 000000000..b1970abb0 --- /dev/null +++ b/examples/svelte/large-form/src/components/subscribe-button.svelte @@ -0,0 +1,13 @@ + + + state.isSubmitting}> + {#snippet children(isSubmitting)} + + {/snippet} + diff --git a/examples/svelte/large-form/src/components/text-field.svelte b/examples/svelte/large-form/src/components/text-field.svelte new file mode 100644 index 000000000..411ce840f --- /dev/null +++ b/examples/svelte/large-form/src/components/text-field.svelte @@ -0,0 +1,21 @@ + + +
+ + {#each field.state.meta.errors as error} +
{error}
+ {/each} +
diff --git a/examples/svelte/large-form/src/features/people/address-fields.svelte b/examples/svelte/large-form/src/features/people/address-fields.svelte new file mode 100644 index 000000000..5807e7e44 --- /dev/null +++ b/examples/svelte/large-form/src/features/people/address-fields.svelte @@ -0,0 +1,37 @@ + + +
+

Address

+ + {#snippet children(field)} + + {/snippet} + + + {#snippet children(field)} + + {/snippet} + + + {#snippet children(field)} + + {/snippet} + + + {#snippet children(field)} + + {/snippet} + + + {#snippet children(field)} + + {/snippet} + +
diff --git a/examples/svelte/large-form/src/features/people/emergency-contact.svelte b/examples/svelte/large-form/src/features/people/emergency-contact.svelte new file mode 100644 index 000000000..132c73e79 --- /dev/null +++ b/examples/svelte/large-form/src/features/people/emergency-contact.svelte @@ -0,0 +1,19 @@ + + + + {#snippet children(field)} + + {/snippet} + + + {#snippet children(field)} + + {/snippet} + diff --git a/examples/svelte/large-form/src/features/people/page.svelte b/examples/svelte/large-form/src/features/people/page.svelte new file mode 100644 index 000000000..a72e514d3 --- /dev/null +++ b/examples/svelte/large-form/src/features/people/page.svelte @@ -0,0 +1,70 @@ + + +
{ + e.preventDefault() + form.handleSubmit() + }} +> +

Personal Information

+ + {#snippet children(field)} + + {/snippet} + + + {#snippet children(field)} + + {/snippet} + + + {#snippet children(field)} + + {/snippet} + + +

Emergency Contact

+ + + {#snippet children()} + + {/snippet} + + diff --git a/examples/svelte/large-form/src/features/people/shared-form.ts b/examples/svelte/large-form/src/features/people/shared-form.ts new file mode 100644 index 000000000..05d697aed --- /dev/null +++ b/examples/svelte/large-form/src/features/people/shared-form.ts @@ -0,0 +1,20 @@ +import { formOptions } from '@tanstack/svelte-form' + +export const peopleFormOpts = formOptions({ + defaultValues: { + fullName: '', + email: '', + phone: '', + address: { + line1: '', + line2: '', + city: '', + state: '', + zip: '', + }, + emergencyContact: { + fullName: '', + phone: '', + }, + }, +}) diff --git a/examples/svelte/large-form/src/main.ts b/examples/svelte/large-form/src/main.ts new file mode 100644 index 000000000..928b6c527 --- /dev/null +++ b/examples/svelte/large-form/src/main.ts @@ -0,0 +1,8 @@ +import { mount } from 'svelte' +import App from './App.svelte' + +const app = mount(App, { + target: document.getElementById('app')!, +}) + +export default app diff --git a/examples/svelte/large-form/src/runes/form-context.ts b/examples/svelte/large-form/src/runes/form-context.ts new file mode 100644 index 000000000..40989db7f --- /dev/null +++ b/examples/svelte/large-form/src/runes/form-context.ts @@ -0,0 +1,3 @@ +import { createFormCreatorContexts } from '@tanstack/svelte-form' + +export const { useFieldContext, useFormContext } = createFormCreatorContexts() diff --git a/examples/svelte/large-form/src/runes/form.ts b/examples/svelte/large-form/src/runes/form.ts new file mode 100644 index 000000000..fe4e06b39 --- /dev/null +++ b/examples/svelte/large-form/src/runes/form.ts @@ -0,0 +1,12 @@ +import { createFormCreator } from '@tanstack/svelte-form' +import TextField from '../components/text-field.svelte' +import SubscribeButton from '../components/subscribe-button.svelte' + +export const { createAppForm } = createFormCreator({ + fieldComponents: { + TextField, + }, + formComponents: { + SubscribeButton, + }, +}) diff --git a/examples/svelte/large-form/src/vite-env.d.ts b/examples/svelte/large-form/src/vite-env.d.ts new file mode 100644 index 000000000..4078e7476 --- /dev/null +++ b/examples/svelte/large-form/src/vite-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/examples/svelte/large-form/svelte.config.js b/examples/svelte/large-form/svelte.config.js new file mode 100644 index 000000000..b0683fd24 --- /dev/null +++ b/examples/svelte/large-form/svelte.config.js @@ -0,0 +1,7 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' + +export default { + // Consult https://svelte.dev/docs#compile-time-svelte-preprocess + // for more information about preprocessors + preprocess: vitePreprocess(), +} diff --git a/examples/svelte/large-form/tsconfig.json b/examples/svelte/large-form/tsconfig.json new file mode 100644 index 000000000..55a2f9b65 --- /dev/null +++ b/examples/svelte/large-form/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "@tsconfig/svelte/tsconfig.json", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "resolveJsonModule": true, + /** + * Typecheck JS in `.svelte` and `.js` files by default. + * Disable checkJs if you'd like to use dynamic types in JS. + * Note that setting allowJs false does not prevent the use + * of JS in `.svelte` files. + */ + "allowJs": true, + "checkJs": true, + "isolatedModules": true, + "moduleDetection": "force" + }, + "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"] +} diff --git a/examples/svelte/large-form/vite.config.ts b/examples/svelte/large-form/vite.config.ts new file mode 100644 index 000000000..d32eba1d6 --- /dev/null +++ b/examples/svelte/large-form/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import { svelte } from '@sveltejs/vite-plugin-svelte' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [svelte()], +}) diff --git a/packages/svelte-form/src/AppField.svelte b/packages/svelte-form/src/AppField.svelte new file mode 100644 index 000000000..fb9ab6abe --- /dev/null +++ b/packages/svelte-form/src/AppField.svelte @@ -0,0 +1,19 @@ + + + + + {#snippet children(field: any)} + + {/snippet} + diff --git a/packages/svelte-form/src/AppForm.svelte b/packages/svelte-form/src/AppForm.svelte new file mode 100644 index 000000000..1ec4833dc --- /dev/null +++ b/packages/svelte-form/src/AppForm.svelte @@ -0,0 +1,16 @@ + + + +{@render children?.()} diff --git a/packages/svelte-form/src/InnerAppField.svelte b/packages/svelte-form/src/InnerAppField.svelte new file mode 100644 index 000000000..655e7ecd3 --- /dev/null +++ b/packages/svelte-form/src/InnerAppField.svelte @@ -0,0 +1,17 @@ + + + +{@render children?.(Object.assign(field, fieldComponents))} diff --git a/packages/svelte-form/src/context-keys.ts b/packages/svelte-form/src/context-keys.ts new file mode 100644 index 000000000..7903532e9 --- /dev/null +++ b/packages/svelte-form/src/context-keys.ts @@ -0,0 +1,2 @@ +export const fieldContextKey = '__tanstack_field_context_key' +export const formContextKey = '__tanstack_form_context_key' diff --git a/packages/svelte-form/src/createForm.svelte.ts b/packages/svelte-form/src/createForm.svelte.ts index 4de61273f..5bd92181c 100644 --- a/packages/svelte-form/src/createForm.svelte.ts +++ b/packages/svelte-form/src/createForm.svelte.ts @@ -179,6 +179,51 @@ export interface SvelteFormApi< WithoutFunction } +/** + * An extended version of the `FormApi` class that includes Svelte-specific functionalities from `SvelteFormApi` + */ +export type SvelteFormExtendedApi< + TFormData, + TOnMount extends undefined | FormValidateOrFn, + TOnChange extends undefined | FormValidateOrFn, + TOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TOnBlur extends undefined | FormValidateOrFn, + TOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TOnSubmit extends undefined | FormValidateOrFn, + TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TOnDynamic extends undefined | FormValidateOrFn, + TOnDynamicAsync extends undefined | FormAsyncValidateOrFn, + TOnServer extends undefined | FormAsyncValidateOrFn, + TSubmitMeta, +> = FormApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TOnServer, + TSubmitMeta +> & + SvelteFormApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TOnServer, + TSubmitMeta + > + export function createForm< TParentData, TFormOnMount extends undefined | FormValidateOrFn, @@ -241,10 +286,10 @@ export function createForm< // @ts-expect-error constructor definition exists only on a type level extendedApi.Field = (internal, props) => - Field(internal, { ...props, form: api }) + Field(internal, { ...props, form: api as never } as never) extendedApi.createField = (props) => createField(() => { - return { ...props(), form: api } + return { ...props(), form: api } as never }) as never // Type cast because else "Error: Type instantiation is excessively deep and possibly infinite." extendedApi.useStore = (selector) => useStore(api.store, selector) // @ts-expect-error constructor definition exists only on a type level diff --git a/packages/svelte-form/src/createFormCreator.svelte.ts b/packages/svelte-form/src/createFormCreator.svelte.ts new file mode 100644 index 000000000..0637168f9 --- /dev/null +++ b/packages/svelte-form/src/createFormCreator.svelte.ts @@ -0,0 +1,273 @@ +import { getContext } from 'svelte' +import { createForm } from './createForm.svelte' +import AppFormSvelte from './AppForm.svelte' +import AppFieldSvelte from './AppField.svelte' +import { fieldContextKey, formContextKey } from './context-keys.js' +import type { + AnyFieldApi, + AnyFormApi, + FieldApi, + FormAsyncValidateOrFn, + FormOptions, + FormValidateOrFn, +} from '@tanstack/form-core' +import type { FieldComponent } from './types.js' +import type { SvelteFormExtendedApi } from './createForm.svelte' +import type { Component, Snippet, SvelteComponent } from 'svelte' + +/** + * TypeScript inferencing is weird. + * + * If you have: + * + * @example + * + * interface Args { + * arg?: T + * } + * + * function test(arg?: Partial>): T { + * return 0 as any; + * } + * + * const a = test({}); + * + * Then `T` will default to `unknown`. + * + * However, if we change `test` to be: + * + * @example + * + * function test(arg?: Partial>): T; + * + * Then `T` becomes `undefined`. + * + * Here, we are checking if the passed type `T` extends `DefaultT` and **only** + * `DefaultT`, as if that's the case we assume that inferencing has not occured. + */ +type UnwrapOrAny = [unknown] extends [T] ? any : T +type UnwrapDefaultOrAny = [DefaultT] extends [T] + ? [T] extends [DefaultT] + ? any + : T + : T + +export function createFormCreatorContexts() { + function useFieldContext() { + const field = getContext(fieldContextKey) as AnyFieldApi + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!field) { + throw new Error( + '`fieldContext` only works when within a `fieldComponent` passed to `createFormCreator`', + ) + } + + return field as FieldApi< + any, + string, + TData, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any + > + } + + function useFormContext() { + const form = getContext(formContextKey) as AnyFormApi + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!form) { + throw new Error( + '`formContext` only works when within a `formComponent` passed to `createFormCreator`', + ) + } + + return form as SvelteFormExtendedApi< + // If you need access to the form data, you need to use `withForm` instead + Record, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any + > + } + + return { useFieldContext, useFormContext } +} + +interface CreateFormRuneProps< + TFieldComponents extends Record>, + TFormComponents extends Record>, +> { + fieldComponents: TFieldComponents + formComponents: TFormComponents +} + +/** + * @private + */ +type AppFieldExtendedSvelteFormApi< + TFormData, + TOnMount extends undefined | FormValidateOrFn, + TOnChange extends undefined | FormValidateOrFn, + TOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TOnBlur extends undefined | FormValidateOrFn, + TOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TOnSubmit extends undefined | FormValidateOrFn, + TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TOnDynamic extends undefined | FormValidateOrFn, + TOnDynamicAsync extends undefined | FormAsyncValidateOrFn, + TOnServer extends undefined | FormAsyncValidateOrFn, + TSubmitMeta, + TFieldComponents extends Record>, + TFormComponents extends Record>, +> = SvelteFormExtendedApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TOnServer, + TSubmitMeta +> & + NoInfer & { + AppField: FieldComponent< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TOnServer, + TSubmitMeta, + NoInfer + > + AppForm: Component<{ children: Snippet }> + } + +export function createFormCreator< + const TComponents extends Record>, + const TFormComponents extends Record>, +>({ + fieldComponents, + formComponents, +}: CreateFormRuneProps) { + function createAppForm< + TFormData, + TOnMount extends undefined | FormValidateOrFn, + TOnChange extends undefined | FormValidateOrFn, + TOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TOnBlur extends undefined | FormValidateOrFn, + TOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TOnSubmit extends undefined | FormValidateOrFn, + TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TOnDynamic extends undefined | FormValidateOrFn, + TOnDynamicAsync extends undefined | FormAsyncValidateOrFn, + TOnServer extends undefined | FormAsyncValidateOrFn, + TSubmitMeta, + >( + props: () => FormOptions< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TOnServer, + TSubmitMeta + >, + ): AppFieldExtendedSvelteFormApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TOnServer, + TSubmitMeta, + TComponents, + TFormComponents + > { + const form = createForm(props) + + const AppForm = ((internal, props) => { + return AppFormSvelte(internal, { ...props, form }) + }) as Component<{ children: Snippet }> + + const AppField = ((internal, { children, ...fieldProps }) => + AppFieldSvelte(internal, { + fieldProps, + form, + fieldComponents, + children, + } as never)) as FieldComponent< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TOnServer, + TSubmitMeta, + TComponents + > + + const extendedForm = Object.assign(form, { + AppField, + AppForm, + ...formComponents, + }) + + return extendedForm + } + + return { + createAppForm, + } +} diff --git a/packages/svelte-form/src/index.ts b/packages/svelte-form/src/index.ts index a76cbb7f2..5540f0b85 100644 --- a/packages/svelte-form/src/index.ts +++ b/packages/svelte-form/src/index.ts @@ -7,3 +7,8 @@ export { createForm, type SvelteFormApi } from './createForm.svelte.js' export { default as Field, createField } from './Field.svelte' export type { CreateField, FieldComponent } from './types.js' + +export { + createFormCreator, + createFormCreatorContexts, +} from './createFormCreator.svelte.js' diff --git a/packages/svelte-form/src/types.ts b/packages/svelte-form/src/types.ts index cfb9d587c..853834d0a 100644 --- a/packages/svelte-form/src/types.ts +++ b/packages/svelte-form/src/types.ts @@ -123,6 +123,7 @@ export type FieldComponent< TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, TFormOnServer extends undefined | FormAsyncValidateOrFn, TParentSubmitMeta, + ExtendedApi = {}, > = // This giant type allows the type // - to be used as a function (which they are now in Svelte 5) @@ -178,7 +179,8 @@ export type FieldComponent< TFormOnDynamic, TFormOnDynamicAsync, TFormOnServer, - TParentSubmitMeta + TParentSubmitMeta, + ExtendedApi >, 'form' >, @@ -235,7 +237,8 @@ export type FieldComponent< TFormOnDynamic, TFormOnDynamicAsync, TFormOnServer, - TParentSubmitMeta + TParentSubmitMeta, + ExtendedApi >, 'form' > @@ -275,6 +278,7 @@ type FieldComponentProps< TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, TFormOnServer extends undefined | FormAsyncValidateOrFn, TParentSubmitMeta, + ExtendedApi = {}, > = { children: Snippet< [ @@ -302,7 +306,8 @@ type FieldComponentProps< TFormOnDynamicAsync, TFormOnServer, TParentSubmitMeta - >, + > & + ExtendedApi, ] > } & Omit< diff --git a/packages/svelte-form/tests/large-components/context.ts b/packages/svelte-form/tests/large-components/context.ts new file mode 100644 index 000000000..944f134a6 --- /dev/null +++ b/packages/svelte-form/tests/large-components/context.ts @@ -0,0 +1,3 @@ +import { createFormCreatorContexts } from '../../src/index.js' + +export const { useFieldContext } = createFormCreatorContexts() diff --git a/packages/svelte-form/tests/large-components/rune.ts b/packages/svelte-form/tests/large-components/rune.ts new file mode 100644 index 000000000..2b96a7346 --- /dev/null +++ b/packages/svelte-form/tests/large-components/rune.ts @@ -0,0 +1,9 @@ +import { createFormCreator } from '../../src/createFormCreator.svelte.js' +import TextField from './text-field.svelte' + +export const { createAppForm } = createFormCreator({ + fieldComponents: { + TextField, + }, + formComponents: {}, +}) diff --git a/packages/svelte-form/tests/large-components/text-field.svelte b/packages/svelte-form/tests/large-components/text-field.svelte new file mode 100644 index 000000000..267569203 --- /dev/null +++ b/packages/svelte-form/tests/large-components/text-field.svelte @@ -0,0 +1,16 @@ + + + diff --git a/packages/svelte-form/tests/large.svelte b/packages/svelte-form/tests/large.svelte new file mode 100644 index 000000000..30ee0f575 --- /dev/null +++ b/packages/svelte-form/tests/large.svelte @@ -0,0 +1,42 @@ + + + + + + +
{ + e.preventDefault() + e.stopPropagation() + form.handleSubmit() + }} +> +

TanStack Form - Svelte Demo

+ + + {#snippet children(field)} + + {/snippet} + +
+ +
{JSON.stringify(formState.current, null, 2)}
diff --git a/packages/svelte-form/tests/large.test.ts b/packages/svelte-form/tests/large.test.ts new file mode 100644 index 000000000..9ccdc0a0c --- /dev/null +++ b/packages/svelte-form/tests/large.test.ts @@ -0,0 +1,38 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { userEvent } from '@testing-library/user-event' +import { mount, unmount } from 'svelte' +import TestForm, { getSampleData } from './large.svelte' + +describe('Svelte Tests', () => { + let element: HTMLDivElement + let instance: any + beforeEach(async () => { + element = document.createElement('div') + document.body.appendChild(element) + instance = mount(TestForm, { + target: element, + }) + }) + + afterEach(() => { + unmount(instance) + element.remove() + }) + + it('should have initial values', async () => { + expect(element.querySelector('#firstName')).toHaveValue( + getSampleData().firstName, + ) + }) + + it('should mirror user input', async () => { + const firstName = element.querySelector('#firstName')! + const firstNameValue = 'Jobs' + await userEvent.type(firstName, firstNameValue) + + const form = JSON.parse(element.querySelector('pre')!.textContent!) + expect(form.values.firstName).toBe( + getSampleData().firstName + firstNameValue, + ) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1629f5386..52748070a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1014,6 +1014,28 @@ importers: specifier: ^7.2.2 version: 7.2.2(@types/node@24.1.0)(jiti@2.6.1)(less@4.4.0)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.43.1)(tsx@4.19.4)(yaml@2.8.1) + examples/svelte/large-form: + dependencies: + '@tanstack/svelte-form': + specifier: ^1.23.0 + version: link:../../../packages/svelte-form + devDependencies: + '@sveltejs/vite-plugin-svelte': + specifier: ^5.1.1 + version: 5.1.1(svelte@5.41.1)(vite@7.2.2(@types/node@24.1.0)(jiti@2.6.1)(less@4.4.0)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.43.1)(tsx@4.19.4)(yaml@2.8.1)) + '@tsconfig/svelte': + specifier: ^5.0.5 + version: 5.0.5 + svelte: + specifier: ^5.39.4 + version: 5.41.1 + typescript: + specifier: 5.8.2 + version: 5.8.2 + vite: + specifier: ^7.2.2 + version: 7.2.2(@types/node@24.1.0)(jiti@2.6.1)(less@4.4.0)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.43.1)(tsx@4.19.4)(yaml@2.8.1) + examples/svelte/simple: dependencies: '@tanstack/svelte-form':