diff --git a/packages/svelte-form/src/AppField.svelte b/packages/svelte-form/src/AppField.svelte new file mode 100644 index 000000000..41daccd13 --- /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..ce0bf3798 --- /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..de0f34873 --- /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/createFormRune.svelte.ts b/packages/svelte-form/src/createFormRune.svelte.ts new file mode 100644 index 000000000..2946584db --- /dev/null +++ b/packages/svelte-form/src/createFormRune.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 createFormRuneContexts() { + 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 `createFormRune`', + ) + } + + 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 `createFormRune`', + ) + } + + 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 createFormRune< + 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..ed692af69 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 { + createFormRune, + createFormRuneContexts, +} from './createFormRune.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..3e20b01d6 --- /dev/null +++ b/packages/svelte-form/tests/large-components/context.ts @@ -0,0 +1,3 @@ +import { createFormRuneContexts } from '../../src/index.js' + +export const { useFieldContext } = createFormRuneContexts() 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..727526ae8 --- /dev/null +++ b/packages/svelte-form/tests/large-components/rune.ts @@ -0,0 +1,9 @@ +import { createFormRune } from '../../src/createFormRune.svelte.js' +import TextField from './text-field.svelte' + +export const { createAppForm } = createFormRune({ + 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, + ) + }) +})