Skip to content

Commit 45a8c52

Browse files
authored
Merge pull request #97 from swup/feat/form-attribute
Test and document advanced form attributes
2 parents a153e0d + 4355e24 commit 45a8c52

3 files changed

Lines changed: 139 additions & 1 deletion

File tree

README.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,29 @@ const swup = new Swup({
4040

4141
## Server response
4242

43+
The server response must be a valid page with all containers to be replaced by swup.
44+
45+
## Form attributes
46+
4347
Action, method and encoding type attributes set on the form are respected.
4448

45-
The server response must be a valid page with all containers to be replaced by swup.
49+
[Externally associated inputs](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#form) are
50+
supported. Inputs and submit buttons that live outside the form but reference it via `form="form-id"` are
51+
included in the submission.
52+
53+
Submitter overrides are supported. If the submitter is a button, its `formaction`, `formmethod`, `formenctype`
54+
and `formtarget` attributes override the form's corresponding attributes, matching native browser behavior.
55+
56+
```html
57+
<!-- The form itself, handled by swup -->
58+
<form id="search" action="/search" data-swup-form></form>
59+
60+
<!-- External input associated with the form above -->
61+
<input name="q" form="search" />
62+
63+
<!-- External submitter associated with the form above, overriding the form action -->
64+
<button type="submit" form="search" formaction="/search/advanced">Search</button>
65+
```
4666

4767
## Custom animations
4868

@@ -84,6 +104,10 @@ If you give a form an additional attribute `[data-swup-inline-form]`, swup will:
84104
}
85105
```
86106

107+
> **Note** Inline forms only replace the form's own container. Controls associated with an
108+
> inline form via the `form` attribute but placed outside of it are **not**
109+
> re-rendered on submit.
110+
87111
## Options
88112

89113
### `formSelector`

tests/unit/forms.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ const createButton = (html: string) => {
1515
return new window.DOMParser().parseFromString(html, 'text/html').querySelector('button')!;
1616
};
1717

18+
const parseDoc = (html: string) => new window.DOMParser().parseFromString(html, 'text/html');
19+
1820
describe('appendQueryParams', () => {
1921
const empty = new FormData();
2022
const data = new FormData();
@@ -225,3 +227,60 @@ describe('getFormInfo', () => {
225227
});
226228
});
227229
});
230+
231+
describe('getFormInfo with the form attribute', () => {
232+
it('includes inputs associated via the form attribute in GET params', () => {
233+
const doc = parseDoc(
234+
'<form id="f" action="/path#anchor"></form>' +
235+
'<input type="hidden" name="a" value="b" form="f">'
236+
);
237+
const form = doc.querySelector('form')!;
238+
expect(getFormInfo(form)).toMatchObject({
239+
href: 'http://localhost:3000/path?a=b#anchor',
240+
url: '/path?a=b',
241+
hash: '#anchor',
242+
method: 'GET'
243+
});
244+
});
245+
246+
it('includes inputs associated via the form attribute in POST body', () => {
247+
const doc = parseDoc(
248+
'<form id="f" action="/path" method="post"></form>' +
249+
'<input type="hidden" name="a" value="b" form="f">'
250+
);
251+
const form = doc.querySelector('form')!;
252+
expect(getFormInfo(form)).toMatchObject({
253+
url: '/path',
254+
method: 'POST',
255+
body: new URLSearchParams({ a: 'b' })
256+
});
257+
});
258+
259+
it('includes the value of an externally associated submitter', () => {
260+
const doc = parseDoc(
261+
'<form id="f" action="/path" method="post"></form>' +
262+
'<button type="submit" form="f" name="btn" value="go">go</button>'
263+
);
264+
const form = doc.querySelector('form')!;
265+
const submitter = doc.querySelector('button');
266+
expect(getFormInfo(form, submitter)).toMatchObject({
267+
url: '/path',
268+
method: 'POST',
269+
body: new URLSearchParams({ btn: 'go' })
270+
});
271+
});
272+
273+
it('reads formaction/formmethod from an externally associated submitter', () => {
274+
const doc = parseDoc(
275+
'<form id="f" action="/path"></form>' +
276+
'<button type="submit" form="f" formaction="/other" formmethod="post">go</button>'
277+
);
278+
const form = doc.querySelector('form')!;
279+
const submitter = doc.querySelector('button');
280+
expect(getFormInfo(form, submitter)).toMatchObject({
281+
action: 'http://localhost:3000/other',
282+
url: '/other',
283+
method: 'POST'
284+
});
285+
});
286+
});

tests/unit/plugin.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ const createForm = (html: string) => {
1111
return form;
1212
};
1313

14+
const appendToBody = <T extends Element = HTMLElement>(html: string) => {
15+
const wrapper = document.createElement('div');
16+
wrapper.innerHTML = html;
17+
const el = wrapper.firstElementChild as T;
18+
document.body.appendChild(el);
19+
return el;
20+
};
21+
1422
const submitForm = (form: HTMLFormElement, submitter?: HTMLButtonElement | null, key?: string) => {
1523
if (key) {
1624
document.dispatchEvent(new KeyboardEvent('keydown', { key }));
@@ -45,6 +53,7 @@ describe('SwupFormsPlugin', () => {
4553
afterEach(() => {
4654
swup.unuse(plugin);
4755
swup.destroy();
56+
document.body.innerHTML = '';
4857
});
4958

5059
it('does not call beforeFormSubmit for ignored forms', async () => {
@@ -79,6 +88,52 @@ describe('SwupFormsPlugin', () => {
7988
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ submitter }));
8089
});
8190

91+
it('calls beforeFormSubmit for an externally associated submit button', async () => {
92+
const spy = vitest.spyOn(plugin, 'beforeFormSubmit').mockImplementation(() => {});
93+
swup.use(plugin);
94+
95+
const form = createForm('<form id="ext-form" action="/path" data-swup-form></form>');
96+
const submitter = appendToBody<HTMLButtonElement>('<button type="submit" form="ext-form"></button>');
97+
submitForm(form, submitter);
98+
99+
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ delegateTarget: form, submitter }));
100+
});
101+
102+
it('navigates to the formaction of an externally associated submitter', async () => {
103+
swup.use(plugin);
104+
105+
const navigateSpy = vitest.spyOn(swup, 'navigate').mockImplementation(() => {});
106+
107+
const form = createForm('<form id="ext-form" action="/path" data-swup-form></form>');
108+
const submitter = appendToBody<HTMLButtonElement>(
109+
'<button type="submit" form="ext-form" formaction="/other"></button>'
110+
);
111+
submitForm(form, submitter);
112+
113+
expect(navigateSpy).toHaveBeenCalledWith(
114+
'http://localhost:3000/other',
115+
expect.objectContaining({ method: 'GET', cache: { read: false, write: true } }),
116+
expect.objectContaining({ el: form })
117+
);
118+
});
119+
120+
it('includes externally associated inputs when navigating', async () => {
121+
swup.use(plugin);
122+
123+
const navigateSpy = vitest.spyOn(swup, 'navigate').mockImplementation(() => {});
124+
125+
const form = createForm('<form id="ext-form" action="/path" data-swup-form></form>');
126+
appendToBody('<input type="hidden" name="a" value="b" form="ext-form">');
127+
const submitter = appendToBody<HTMLButtonElement>('<button type="submit" form="ext-form"></button>');
128+
submitForm(form, submitter);
129+
130+
expect(navigateSpy).toHaveBeenCalledWith(
131+
'http://localhost:3000/path?a=b',
132+
expect.objectContaining({ method: 'GET' }),
133+
expect.objectContaining({ el: form })
134+
);
135+
});
136+
82137
it('calls the form:submit hook', async () => {
83138
swup.use(plugin);
84139

0 commit comments

Comments
 (0)