diff --git a/README.md b/README.md index a73ab18..bcd01c0 100755 --- a/README.md +++ b/README.md @@ -40,9 +40,29 @@ const swup = new Swup({ ## Server response +The server response must be a valid page with all containers to be replaced by swup. + +## Form attributes + Action, method and encoding type attributes set on the form are respected. -The server response must be a valid page with all containers to be replaced by swup. +[Externally associated inputs](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#form) are +supported. Inputs and submit buttons that live outside the form but reference it via `form="form-id"` are +included in the submission. + +Submitter overrides are supported. If the submitter is a button, its `formaction`, `formmethod`, `formenctype` +and `formtarget` attributes override the form's corresponding attributes, matching native browser behavior. + +```html + + + + + + + + +``` ## Custom animations @@ -84,6 +104,10 @@ If you give a form an additional attribute `[data-swup-inline-form]`, swup will: } ``` +> **Note** Inline forms only replace the form's own container. Controls associated with an +> inline form via the `form` attribute but placed outside of it are **not** +> re-rendered on submit. + ## Options ### `formSelector` diff --git a/tests/unit/forms.test.ts b/tests/unit/forms.test.ts index 05a5f0f..2a129de 100644 --- a/tests/unit/forms.test.ts +++ b/tests/unit/forms.test.ts @@ -15,6 +15,8 @@ const createButton = (html: string) => { return new window.DOMParser().parseFromString(html, 'text/html').querySelector('button')!; }; +const parseDoc = (html: string) => new window.DOMParser().parseFromString(html, 'text/html'); + describe('appendQueryParams', () => { const empty = new FormData(); const data = new FormData(); @@ -225,3 +227,60 @@ describe('getFormInfo', () => { }); }); }); + +describe('getFormInfo with the form attribute', () => { + it('includes inputs associated via the form attribute in GET params', () => { + const doc = parseDoc( + '
' + + '' + ); + const form = doc.querySelector('form')!; + expect(getFormInfo(form)).toMatchObject({ + href: 'http://localhost:3000/path?a=b#anchor', + url: '/path?a=b', + hash: '#anchor', + method: 'GET' + }); + }); + + it('includes inputs associated via the form attribute in POST body', () => { + const doc = parseDoc( + '
' + + '' + ); + const form = doc.querySelector('form')!; + expect(getFormInfo(form)).toMatchObject({ + url: '/path', + method: 'POST', + body: new URLSearchParams({ a: 'b' }) + }); + }); + + it('includes the value of an externally associated submitter', () => { + const doc = parseDoc( + '
' + + '' + ); + const form = doc.querySelector('form')!; + const submitter = doc.querySelector('button'); + expect(getFormInfo(form, submitter)).toMatchObject({ + url: '/path', + method: 'POST', + body: new URLSearchParams({ btn: 'go' }) + }); + }); + + it('reads formaction/formmethod from an externally associated submitter', () => { + const doc = parseDoc( + '
' + + '' + ); + const form = doc.querySelector('form')!; + const submitter = doc.querySelector('button'); + expect(getFormInfo(form, submitter)).toMatchObject({ + action: 'http://localhost:3000/other', + url: '/other', + method: 'POST' + }); + }); +}); diff --git a/tests/unit/plugin.test.ts b/tests/unit/plugin.test.ts index 0706080..bfc71ea 100644 --- a/tests/unit/plugin.test.ts +++ b/tests/unit/plugin.test.ts @@ -11,6 +11,14 @@ const createForm = (html: string) => { return form; }; +const appendToBody = (html: string) => { + const wrapper = document.createElement('div'); + wrapper.innerHTML = html; + const el = wrapper.firstElementChild as T; + document.body.appendChild(el); + return el; +}; + const submitForm = (form: HTMLFormElement, submitter?: HTMLButtonElement | null, key?: string) => { if (key) { document.dispatchEvent(new KeyboardEvent('keydown', { key })); @@ -45,6 +53,7 @@ describe('SwupFormsPlugin', () => { afterEach(() => { swup.unuse(plugin); swup.destroy(); + document.body.innerHTML = ''; }); it('does not call beforeFormSubmit for ignored forms', async () => { @@ -79,6 +88,52 @@ describe('SwupFormsPlugin', () => { expect(spy).toHaveBeenCalledWith(expect.objectContaining({ submitter })); }); + it('calls beforeFormSubmit for an externally associated submit button', async () => { + const spy = vitest.spyOn(plugin, 'beforeFormSubmit').mockImplementation(() => {}); + swup.use(plugin); + + const form = createForm('
'); + const submitter = appendToBody(''); + submitForm(form, submitter); + + expect(spy).toHaveBeenCalledWith(expect.objectContaining({ delegateTarget: form, submitter })); + }); + + it('navigates to the formaction of an externally associated submitter', async () => { + swup.use(plugin); + + const navigateSpy = vitest.spyOn(swup, 'navigate').mockImplementation(() => {}); + + const form = createForm('
'); + const submitter = appendToBody( + '' + ); + submitForm(form, submitter); + + expect(navigateSpy).toHaveBeenCalledWith( + 'http://localhost:3000/other', + expect.objectContaining({ method: 'GET', cache: { read: false, write: true } }), + expect.objectContaining({ el: form }) + ); + }); + + it('includes externally associated inputs when navigating', async () => { + swup.use(plugin); + + const navigateSpy = vitest.spyOn(swup, 'navigate').mockImplementation(() => {}); + + const form = createForm('
'); + appendToBody(''); + const submitter = appendToBody(''); + submitForm(form, submitter); + + expect(navigateSpy).toHaveBeenCalledWith( + 'http://localhost:3000/path?a=b', + expect.objectContaining({ method: 'GET' }), + expect.objectContaining({ el: form }) + ); + }); + it('calls the form:submit hook', async () => { swup.use(plugin);