Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<!-- The form itself, handled by swup -->
<form id="search" action="/search" data-swup-form></form>

<!-- External input associated with the form above -->
<input name="q" form="search" />

<!-- External submitter associated with the form above, overriding the form action -->
<button type="submit" form="search" formaction="/search/advanced">Search</button>
```

## Custom animations

Expand Down Expand Up @@ -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`
Expand Down
59 changes: 59 additions & 0 deletions tests/unit/forms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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(
'<form id="f" action="/path#anchor"></form>' +
'<input type="hidden" name="a" value="b" form="f">'
);
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(
'<form id="f" action="/path" method="post"></form>' +
'<input type="hidden" name="a" value="b" form="f">'
);
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(
'<form id="f" action="/path" method="post"></form>' +
'<button type="submit" form="f" name="btn" value="go">go</button>'
);
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(
'<form id="f" action="/path"></form>' +
'<button type="submit" form="f" formaction="/other" formmethod="post">go</button>'
);
const form = doc.querySelector('form')!;
const submitter = doc.querySelector('button');
expect(getFormInfo(form, submitter)).toMatchObject({
action: 'http://localhost:3000/other',
url: '/other',
method: 'POST'
});
});
});
55 changes: 55 additions & 0 deletions tests/unit/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ const createForm = (html: string) => {
return form;
};

const appendToBody = <T extends Element = HTMLElement>(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 }));
Expand Down Expand Up @@ -45,6 +53,7 @@ describe('SwupFormsPlugin', () => {
afterEach(() => {
swup.unuse(plugin);
swup.destroy();
document.body.innerHTML = '';
});

it('does not call beforeFormSubmit for ignored forms', async () => {
Expand Down Expand Up @@ -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('<form id="ext-form" action="/path" data-swup-form></form>');
const submitter = appendToBody<HTMLButtonElement>('<button type="submit" form="ext-form"></button>');
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('<form id="ext-form" action="/path" data-swup-form></form>');
const submitter = appendToBody<HTMLButtonElement>(
'<button type="submit" form="ext-form" formaction="/other"></button>'
);
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('<form id="ext-form" action="/path" data-swup-form></form>');
appendToBody('<input type="hidden" name="a" value="b" form="ext-form">');
const submitter = appendToBody<HTMLButtonElement>('<button type="submit" form="ext-form"></button>');
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);

Expand Down