kit
kit copied to clipboard
`enhance` should progressively enhance `<form method="GET">`
Describe the problem
Currently, enhance
only works on forms where method="POST"
. I would also like to progressively enhance a form where method="GET"
, e.g. a search form.
For of an example of the kind of form where this would be useful, see the REPL search form on svelte.dev. We currently have to write the submit
callback ourselves.
It would be nice if we could use:enhance
here as well to get the same behavior. This would also let us encourage people to further #useThePlatform by teaching progressive enhancement of <form method="GET">
and <form method="POST">
.
<form
on:submit|preventDefault={(e) => {
const search = new FormData(e.target).get('search');
goto(search ? `/apps?search=${encodeURIComponent(search)}` : '/apps');
}}
>
<input
type="search"
placeholder="Search"
aria-label="Search"
name="search"
value={data.search}
/>
</form>
Describe the proposed solution
Instead of calling fetch
, when method === 'GET'
the action would construct the query string from the form data and call goto(action)
, i.e. call the load
function for the given action. Since this is a navigation and no data is returned, applyAction
would not be called and the form
property would not be updated.
So on submit, the following form would call goto('/search?q=cats')
, the same as the browser would do if JS was not available. We call any +page.js
or +page.server.js
load
function and navigate to the page at that route (what goto
normally does).
<form method="GET" action="/search" use:enhance>
<input name="q" value="cats">
<button>Submit</button>
</form>
enhance
should also be updated to take <button formmethod="GET">
into account, likely using event.submitter
.
I don't think a post-submit callback makes sense in this case, since it would trigger navigation. Any customizations could be implemented with $navigating
, beforeNavigate
, and afterNavigate
.
One open question is do we allow customizing the options passed to goto
? For instance, if the user wants to keep focus on the form with goto(action, { keepfocus: true })
. Or do we have them implement the action themselves in that case?
Alternatives considered
Do nothing and have people implement it themselves.
Importance
nice to have
Additional Information
No response
One thing I'm asking myself is if this should be part of the enhance
action, because it's essentially two very separate things, and having it both in one means unnecessary code for people using only one of those variants.
One thing I'm asking myself is if this should be part of the enhance
action, because it's essentially two very separate things, and having it both in one means unnecessary code for people using only one of those variants.
I can see it both ways.
Pros of splitting out:
- we don't include unnecessary code (though I don't think it's that much code)
- don't have to explain what happens with POST and what happens with GET when using the same action
Cons:
- what do we call
enhanceGet
? This could be either an awkward name or a breaking change if we want to renmae the POSTenhance
to align with what we choose - user has to keep track of which action to use with GET and which one to use with POST. If you change the form method you have to import a completely different action instead of keeping
<form use:enhance>
- I don't think we could enhance a multi-method form (e.g. main form is
method="POST"
but there's a<button formmethod="GET">
. ifenhance
handles both methods we could.
It's worth nothing that there is a good chunk of overlap in the code:
https://github.com/sveltejs/kit/blob/0f2e0d36b0ba5f3f06eff8be91998a566e1802aa/packages/kit/src/runtime/app/forms.js#L45-L59
I can see it both ways.
Pros of splitting out:
- we don't include unnecessary code (though I don't think it's that much code)
- don't have to explain what happens with POST and what happens with GET when using the same action
Cons:
- what do we call
enhanceGet
? This could be either an awkward name or a breaking change if we want to renmae the POSTenhance
to align with what we choose - user has to keep track of which action to use with GET and which one to use with POST. If you change the form method you have to import a completely different action instead of keeping
<form use:enhance>
- I don't think we could enhance a multi-method form (e.g. main form is
method="POST"
but there's a<button formmethod="GET">
. ifenhance
handles both methods we could.
It's worth nothing that there is a good chunk of overlap in the code:
https://github.com/sveltejs/kit/blob/0f2e0d36b0ba5f3f06eff8be91998a566e1802aa/packages/kit/src/runtime/app/forms.js#L45-L59
I think it should be in one (not splitted)... there are two reasons:
1st, we already tell if it's get or post via method=
attribute, and telling it twice is weird.
2nd, we can have dynamic forms, e.g.
<script>
export let forms = [];
</script>
{#each forms as form}
<form method={form.method} action={form.action} use:enhance>
{#each form.inputs as input}
{input.text}<input type={input.type}>
{/each}
<button type="submit">Submit</button>
</form>
{/each}
now imagine how would you do use:{form.method === 'get' ? enhanceGet : enhancePost}
and even pass function to it, or some other things... it would be too complicated. In case of combined, it would be just use:enhance={form.handler}
I think it should be in one (not splitted)... there are two reasons:
1st, we already tell if it's get or post via method=
attribute, and telling it twice is weird.
2nd, we can have dynamic forms, e.g.
<script>
export let forms = [];
</script>
{#each forms as form}
<form method={form.method} action={form.action} use:enhance>
{#each form.inputs as input}
{input.text}<input type={input.type}>
{/each}
<button type="submit">Submit</button>
</form>
{/each}
now imagine how would you do use:{form.method === 'get' ? enhanceGet : enhancePost}
and even pass function to it, or some other things... it would be too complicated. In case of combined, it would be just use:enhance={form.handler}
I agree with @dummdidumm:
it's essentially two very separate things
Submitting a GET form is just like clicking on a link. The difference between ?search=foobarbaz
and ?page=2
is nil. A GET form submission is just a regular page navigation, handled by the relevant load
function, not an Action
. In the javascript-enabled case, the client side router handles each of these links just as it handles every other navigation. It already does what @geoffrich proposes. It intercepts the request and fetch
es the data rather than doing a full page reload.
I agree with @dummdidumm:
it's essentially two very separate things
Submitting a GET form is just like clicking on a link. The difference between ?search=foobarbaz
and ?page=2
is nil. A GET form submission is just a regular page navigation, handled by the relevant load
function, not an Action
. In the javascript-enabled case, the client side router handles each of these links just as it handles every other navigation. It already does what @geoffrich proposes. It intercepts the request and fetch
es the data rather than doing a full page reload.
I like to use GET forms for requests with complex query parameters, such as filtering data tables.
This is simple GET forms that I implement my own GET enhance action since the brilliant idea of the form enhance action came up:
https://user-images.githubusercontent.com/25121822/195618138-6eed0bf0-30d5-456e-a14f-583f094412bf.mp4
and the form code is just:
transforming FormData
to query search params can be done by passing it to URLSearchParams
constructor and set value back to form controls can be easy using form.elements
.
It's very convenient to use since form control elements can be anywhere even outside the form tag.
There is things to consider such as auto submit on select element changes that not default form behavior, but using flag like data-submitonchange
per element is fine and for the keepfocus
, data-keepfocus
in form tag is also fine, I think.
Unfortunately, set value back to form controls is required Javascript-enabled, I hope this can be possible in SSR, on framework implementation.
I like to use GET forms for requests with complex query parameters, such as filtering data tables.
This is simple GET forms that I implement my own GET enhance action since the brilliant idea of the form enhance action came up:
https://user-images.githubusercontent.com/25121822/195618138-6eed0bf0-30d5-456e-a14f-583f094412bf.mp4
and the form code is just:
transforming FormData
to query search params can be done by passing it to URLSearchParams
constructor and set value back to form controls can be easy using form.elements
.
It's very convenient to use since form control elements can be anywhere even outside the form tag.
There is things to consider such as auto submit on select element changes that not default form behavior, but using flag like data-submitonchange
per element is fine and for the keepfocus
, data-keepfocus
in form tag is also fine, I think.
Unfortunately, set value back to form controls is required Javascript-enabled, I hope this can be possible in SSR, on framework implementation.
There is things to consider such as auto submit on select element changes that not default form behavior, but using flag like
data-submitonchange
per element is fine
you can use another action to submit on change or any other event
<script lang="ts">
function submitNearestForm(this: HTMLElement) {
this.closest('form')?.dispatchEvent(new SubmitEvent('submit', { bubbles: true }));
}
</script>
<form use:enhance_get>
<select name="test" on:input={submitNearestForm}>
<option value="1">one</option>
<option value="2">two</option>
<option value="3">three</option>
</select>
<button type="submit">Submit</button>
</form>
There is things to consider such as auto submit on select element changes that not default form behavior, but using flag like
data-submitonchange
per element is fine
you can use another action to submit on change or any other event
<script lang="ts">
function submitNearestForm(this: HTMLElement) {
this.closest('form')?.dispatchEvent(new SubmitEvent('submit', { bubbles: true }));
}
</script>
<form use:enhance_get>
<select name="test" on:input={submitNearestForm}>
<option value="1">one</option>
<option value="2">two</option>
<option value="3">three</option>
</select>
<button type="submit">Submit</button>
</form>
Submitting a GET form is just like clicking on a link. The difference between
?search=foobarbaz
and?page=2
is nil. A GET form submission is just a regular page navigation, handled by the relevantload
function, not anAction
. In the javascript-enabled case, the client side router handles each of these links just as it handles every other navigation. It already does what @geoffrich proposes. It intercepts the request andfetch
es the data rather than doing a full page reload.
That´s true, GET´s are very different. But they still belong to forms and the enhance
helper does not tell you, atleast not by it´s name, that it´s only meant to be used with POST requests.
Submitting a GET form is just like clicking on a link. The difference between
?search=foobarbaz
and?page=2
is nil. A GET form submission is just a regular page navigation, handled by the relevantload
function, not anAction
. In the javascript-enabled case, the client side router handles each of these links just as it handles every other navigation. It already does what @geoffrich proposes. It intercepts the request andfetch
es the data rather than doing a full page reload.
That´s true, GET´s are very different. But they still belong to forms and the enhance
helper does not tell you, atleast not by it´s name, that it´s only meant to be used with POST requests.
In the javascript-enabled case, the client side router handles each of these links just as it handles every other navigation
That made me think... Since we are dealing with basic navigation, should the router intercept form get requests?
With this bit of code i get global form get requests working just like anchor tags
// src/routes/+layout.svelte
onMount(() => {
function handleSubmit(event: SubmitEvent) {
const form = event.target;
if (!(form instanceof HTMLFormElement)) {
return;
}
const submitter = event.submitter as HTMLButtonElement | HTMLInputElement | null;
const actionUrl = new URL(
submitter?.hasAttribute('formaction') ? submitter.formAction : form.action
);
const reload =
(submitter && submitter.getAttribute('data-sveltekit-reload') !== null) ||
form.getAttribute('data-sveltekit-reload') !== null;
// only handle get requests and same origin urls
if (reload || form.method !== 'get' || actionUrl.origin !== location.origin) {
return;
}
event.preventDefault();
event.stopPropagation();
const formData = new FormData(form);
const query = new URLSearchParams(formData as any);
goto(`?${query}`);
}
addEventListener('submit', handleSubmit);
return () => {
removeEventListener('submit', handleSubmit);
};
});
We can opt out of this behavior just like with the links using data-sveltekit-reload
In the javascript-enabled case, the client side router handles each of these links just as it handles every other navigation
That made me think... Since we are dealing with basic navigation, should the router intercept form get requests?
With this bit of code i get global form get requests working just like anchor tags
// src/routes/+layout.svelte
onMount(() => {
function handleSubmit(event: SubmitEvent) {
const form = event.target;
if (!(form instanceof HTMLFormElement)) {
return;
}
const submitter = event.submitter as HTMLButtonElement | HTMLInputElement | null;
const actionUrl = new URL(
submitter?.hasAttribute('formaction') ? submitter.formAction : form.action
);
const reload =
(submitter && submitter.getAttribute('data-sveltekit-reload') !== null) ||
form.getAttribute('data-sveltekit-reload') !== null;
// only handle get requests and same origin urls
if (reload || form.method !== 'get' || actionUrl.origin !== location.origin) {
return;
}
event.preventDefault();
event.stopPropagation();
const formData = new FormData(form);
const query = new URLSearchParams(formData as any);
goto(`?${query}`);
}
addEventListener('submit', handleSubmit);
return () => {
removeEventListener('submit', handleSubmit);
};
});
We can opt out of this behavior just like with the links using data-sveltekit-reload
Yeah, I rather like the idea of treating <form>
the same as <a>
if there's no method="POST"
. A few tweaks to @david-plugge's code, to avoid the pollution issues we saw previously with enhance
— we could just add this to client.js
alongside the other event handling code, and voila:
target.addEventListener('submit', (event) => {
const form = /** @type {HTMLFormElement} */ (
HTMLFormElement.prototype.cloneNode.call(event.target)
);
const submitter = /** @type {HTMLButtonElement | HTMLInputElement} */ (event.submitter);
const method = event.submitter?.hasAttribute('formmethod')
? submitter.formMethod
: form.method;
if (method !== 'get') return;
const action = new URL(
event.submitter?.hasAttribute('formaction') ? submitter.formAction : form.action
);
if (is_external_url(action)) return;
event.preventDefault();
event.stopPropagation();
action.search = new URLSearchParams(new FormData(event.target));
goto(action, {});
});
Not pictured: taking account of data-sveltekit-reload
Yeah, I rather like the idea of treating <form>
the same as <a>
if there's no method="POST"
. A few tweaks to @david-plugge's code, to avoid the pollution issues we saw previously with enhance
— we could just add this to client.js
alongside the other event handling code, and voila:
target.addEventListener('submit', (event) => {
const form = /** @type {HTMLFormElement} */ (
HTMLFormElement.prototype.cloneNode.call(event.target)
);
const submitter = /** @type {HTMLButtonElement | HTMLInputElement} */ (event.submitter);
const method = event.submitter?.hasAttribute('formmethod')
? submitter.formMethod
: form.method;
if (method !== 'get') return;
const action = new URL(
event.submitter?.hasAttribute('formaction') ? submitter.formAction : form.action
);
if (is_external_url(action)) return;
event.preventDefault();
event.stopPropagation();
action.search = new URLSearchParams(new FormData(event.target));
goto(action, {});
});
Not pictured: taking account of data-sveltekit-reload
I think that should solve for everything in the original issue except this part:
One open question is do we allow customizing the options passed to goto? For instance, if the user wants to keep focus on the form with goto(action, { keepfocus: true }). Or do we have them implement the action themselves in that case?
Any ideas/API proposals on how we could allow passing those options? keepFocus
seems like the most important, since I could easily see someone wanting a progressively enhanced search form that keeps focus on the search input after submission instead of resetting to the top of the page.
I think that should solve for everything in the original issue except this part:
One open question is do we allow customizing the options passed to goto? For instance, if the user wants to keep focus on the form with goto(action, { keepfocus: true }). Or do we have them implement the action themselves in that case?
Any ideas/API proposals on how we could allow passing those options? keepFocus
seems like the most important, since I could easily see someone wanting a progressively enhanced search form that keeps focus on the search input after submission instead of resetting to the top of the page.
I don't know if this is the best possible approach, but you could do that with an autofocus
on the search page. Or — if you only wanted it to apply after the form had been submitted — something like this:
afterNavigate(({ from, to }) => {
if (from?.route.id === to.route.id) {
input.focus();
}
});
Better still, we add a new navigation type (which I think we'd need anyway):
afterNavigate(({ type }) => {
if (type === 'form') { // or 'submit', or something else?
input.focus();
}
});
I don't know if this is the best possible approach, but you could do that with an autofocus
on the search page. Or — if you only wanted it to apply after the form had been submitted — something like this:
afterNavigate(({ from, to }) => {
if (from?.route.id === to.route.id) {
input.focus();
}
});
Better still, we add a new navigation type (which I think we'd need anyway):
afterNavigate(({ type }) => {
if (type === 'form') { // or 'submit', or something else?
input.focus();
}
});
As for keeping the current scroll position, we could reuse data-sveltekit-noscroll
.
As for keeping the current scroll position, we could reuse data-sveltekit-noscroll
.
can you still process/validate for example search parameters when using the approach outlined by david/rich above?
it seems to my untrained eyes that the final URL is determined/used by:
const action = new URL(
event.submitter?.hasAttribute('formaction') ? submitter.formAction : form.action
);
...
goto(action, {});
if I need to validate the form input before I want to goto
, how would I do that?
can you still process/validate for example search parameters when using the approach outlined by david/rich above?
it seems to my untrained eyes that the final URL is determined/used by:
const action = new URL(
event.submitter?.hasAttribute('formaction') ? submitter.formAction : form.action
);
...
goto(action, {});
if I need to validate the form input before I want to goto
, how would I do that?
to give some more context - right now I have disabled the default action in my search/filter form and have something along the lines of this:
<script lang="ts>
...
async function submitForm(form: HTMLFormElement) {
const url = $page.url;
const name = new FormData(form).get('name');
const nameParam = name?.toString();
if (nameParam) {
url.searchParams.set('name', nameParam);
} else {
url.searchParams.delete('name');
}
const venue = new FormData(form).get('venue');
const venueParam = venue?.toString();
if (venueParam) {
url.searchParams.set('venue', venueParam);
} else {
url.searchParams.delete('venue');
}
url.searchParams.delete('page');
await prefetch(url.href);
await goto(url.href, { noscroll: true, keepfocus: true });
}
let form: HTMLFormElement;
let nameValue = $page.url.searchParams.get('name');
let venueValue = $page.url.searchParams.get('venue');
</script>
<form on:submit|preventDefault={handleSubmit} bind:this={form}>
...
would something like this also work with the progressive GET form enhancement proposed earlier?
to give some more context - right now I have disabled the default action in my search/filter form and have something along the lines of this:
<script lang="ts>
...
async function submitForm(form: HTMLFormElement) {
const url = $page.url;
const name = new FormData(form).get('name');
const nameParam = name?.toString();
if (nameParam) {
url.searchParams.set('name', nameParam);
} else {
url.searchParams.delete('name');
}
const venue = new FormData(form).get('venue');
const venueParam = venue?.toString();
if (venueParam) {
url.searchParams.set('venue', venueParam);
} else {
url.searchParams.delete('venue');
}
url.searchParams.delete('page');
await prefetch(url.href);
await goto(url.href, { noscroll: true, keepfocus: true });
}
let form: HTMLFormElement;
let nameValue = $page.url.searchParams.get('name');
let venueValue = $page.url.searchParams.get('venue');
</script>
<form on:submit|preventDefault={handleSubmit} bind:this={form}>
...
would something like this also work with the progressive GET form enhancement proposed earlier?
Yes, anything like this should work:
<form
on:submit|preventDefault={event => {
const data = new FormData(event.target);
try {
validate(data);
const action = new URL(e.target.action);
action.search = new URLSearchParams(data);
goto(action);
} catch(error) {
doSomethingWith(error);
}
}}
>
SvelteKit's handler would need to bail on event.defaultPrevented
.
By the way: there's no need to await prefetch(...)
before calling goto(...)
, you can just use goto(...)
.
Yes, anything like this should work:
<form
on:submit|preventDefault={event => {
const data = new FormData(event.target);
try {
validate(data);
const action = new URL(e.target.action);
action.search = new URLSearchParams(data);
goto(action);
} catch(error) {
doSomethingWith(error);
}
}}
>
SvelteKit's handler would need to bail on event.defaultPrevented
.
By the way: there's no need to await prefetch(...)
before calling goto(...)
, you can just use goto(...)
.
(I guess the question is whether we'd need that pattern often enough that it makes sense to use a GETified enhance
after all)
(I guess the question is whether we'd need that pattern often enough that it makes sense to use a GETified enhance
after all)