<ajax-form>
Progressively add Ajax support to <form>
elements.
Basic Usage
Wrap <form>
elements in the <ajax-form>
custom element. The <form>
must have method
and action
attributes for the <ajax-form>
component to work properly.
<ajax-form>
<form method="post" action="/newsletter">
<!-- Form fields... -->
</form>
</ajax-form>
By default, the <ajax-form>
Web Component will add an accessible status notification element, show a Submitting...
message, and prevent the user from submitting the form twice while waiting for a server response.
Options & Settings
The <ajax-form>
element can be customized with a handful of attributes…
prevent-default
- Stop the form from redirecting/reloading the page. Use this if you want to keep the user on the current page after the form submits.msg-submitting
- Customize the “submitting” message.msg-success
- Customize the “success” message.msg-error
- Customize the “error” message.target
- If the server returns HTML and you want to update the current UI after the form successfully submits, provide one or more selector strings (comma-separated) for the element to update. Each selector should be identical in the current UI and the returned HTML from the server.
With the msg-submitting
, msg-success
, and msg-error
attributes, you can include ${field_name}
in the attribute string to include the value of the matching field in your message.
<!--
This form includes some customizations.
After submitting, it will update the [data-list="todos"] element with new HTML.
-->
<ajax-form
prevent-default
msg-submitting='Adding the "${todo}" todo item...'
msg-success='The "${todo}" todo item was added.'
target='[data-list="todos"]'
>
<form method="post" action="/add-todo">
<label for="todo">Add a todo item</label>
<input id="todo" name="todo" type="text" required>
<button>Add Todo</button>
</form>
</ajax-form>
<!--
This gets updated after the form submits.
It expects HTML to be returned by the server.
-->
<div data-list="todos">
<h2>Your Todos</h2>
<p>You don't have any todos yet.</p>
</div>
Styling
The <ajax-form>
element receives a [form-submitting]
attribute when the form is actively awaiting a response from the server.
You can hook into the attribute in your CSS to style your form and visually indicate submitting status.
/**
* Decrease button opacity when submitting
*/
[form-submitting] button {
opacity: 0.8;
}
Events
The <ajax-form>
component emits a few custom events to let you extend it’s functionality beyond what it offers out-of-the-box.
ajax-form:submit
is emitted when the form is submitted, but before it sends data to the server. Theevent.detail
property is an object of form field values. Runningevent.preventDefault()
will cancel the form submission.ajax-form:success
is emitted when the form successfully returns data from the server. Theevent.detail
property is theResponse
object from the server.ajax-form:error
is emitted when the server returns an error. Theevent.detail
property is theResponse
object from the server.
You can listen for events with the Element.addEventListener()
method.
document.addEventListener('ajax-form:success', function (event) {
// The `<ajax-form>` element
console.log(event.target);
// The event details
console.log(event.detail);
});
The Web Component
customElements.define('ajax-form', class extends HTMLElement {
/**
* The class constructor object
*/
constructor () {
// Always call super first in constructor
super();
// Add a form status element
let announce = document.createElement('div');
announce.setAttribute('role', 'status');
this.append(announce);
// Set base properties
this.announce = announce;
this.form = this.querySelector('form');
this.handler = this.createSubmitHandler();
// Define options
this.preventDefault = this.hasAttribute('prevent-default');
this.msgSubmitting = this.getAttribute('msg-submitting') ?? 'Submitting...';
this.msgSuccess = this.getAttribute('msg-success') ?? 'Success!';
this.msgError = this.getAttribute('msg-error') ?? 'Something went wrong. Please try again.';
let target = this.getAttribute('target');
this.targets = target ? target.split(',').map(target => target.trim()) : null;
}
/**
* Create a submit handler with the instance bound to the callback
* @return {Function} The callback function
*/
createSubmitHandler () {
return async (event) => {
// If the form is already submitting,
// OR if default should be prevented
// Stop form from reloading the page
if (this.isDisabled() || this.preventDefault) {
event.preventDefault();
}
// If the form is already submitting, do nothing
// Otherwise, disable future submissions
if (this.isDisabled()) return;
this.disable();
// Emit a submit event (useful for validations)
if (!this.emit('submit', this.getData())) return;
try {
// Show status message
this.showStatus(this.msgSubmitting);
// If not preventing default behavior, end early
if (!this.preventDefault) return;
// Call the API
let {action, method} = event.target;
let response = await fetch(action, {
method,
body: this.serialize(),
headers: {
'Content-type': 'application/x-www-form-urlencoded'
}
});
// If there's an error, throw
if (!response.ok) throw response;
// If UI should be updated, do so
if (this.targets) {
let str = await response.clone().text();
this.render(str);
}
// Emit a success event
this.emit('success', response.clone());
// Show success URL
this.showStatus(this.msgSuccess);
// Clear the form
this.reset();
} catch (error) {
console.warn(error);
this.showStatus(this.msgError);
this.emit('error', error.clone());
} finally {
this.enable();
}
};
}
/**
* Listen for form submissions when the form is attached in the DOM
*/
connectedCallback () {
this.form.addEventListener('submit', this.handler);
}
/**
* Stop listening for form submissions when the form is attached in the DOM
*/
disconnectedCallback () {
this.form.removeEventListener('submit', this.handler);
}
/**
* Emit a custom event
* @param {String} type The event type
* @param {Object} detail Any details to pass along with the event
*/
emit (type, detail = {}) {
// Create a new event
let event = new CustomEvent(`ajax-form:${type}`, {
bubbles: true,
cancelable: true,
detail: detail
});
// Dispatch the event
return this.dispatchEvent(event);
}
/**
* Disable a form so I can't be submitted while waiting for the API
*/
disable () {
this.setAttribute('form-submitting', '');
}
/**
* Enable a form after the API returns
*/
enable () {
this.removeAttribute('form-submitting');
}
/**
* Check if a form is submitting to the API
* @return {Boolean} If true, the form is submitting
*/
isDisabled () {
return this.hasAttribute('form-submitting');
}
/**
* Get the value of a form field by its [name]
* @param {String} id The field name
* @return {String} The value
*/
getFieldValue (id) {
// Get the field
let field = this.form.querySelector(`[name="${id}"]`);
if (!field) return;
// If select element, get selected element text
if (field.tagName.toLowerCase() === 'select') {
return field.options[field.selectedIndex].textContent;
}
// Otherwise, return value
return field.value;
}
/**
* Replace placeholders in message with field values
* @param {String} msg The message text
* @return {String} The message text with placeholders replaced
*/
getMessageText (msg) {
let instance = this;
return msg.replace(/\$\{([^}]+)\}/g, function (match) {
// Remove the wrapping curly braces
match = match.slice(2, -1);
// Get the field value
let value = instance.getFieldValue(match);
// Return the string
if (!value) return '{{' + match + '}}';
return value;
});
}
/**
* Update the form status in a field
* @param {String} msg The message to display
*/
showStatus (msg) {
this.announce.innerHTML = this.getMessageText(msg);
}
/**
* Serialize all form data into an encoded query string
* @return {String} The serialized form data
*/
serialize () {
let data = new FormData(this.form);
let params = new URLSearchParams();
for (let [key, val] of data) {
params.append(key, val);
}
return params.toString();
}
/**
* Serialize all form data into an object
* @return {Object} The serialized form data
*/
getData () {
let data = new FormData(this.form);
let obj = {};
for (let [key, value] of data) {
if (obj[key] !== undefined) {
if (!Array.isArray(obj[key])) {
obj[key] = [obj[key]];
}
obj[key].push(value);
} else {
obj[key] = value;
}
}
return obj;
}
/**
* Render the updated UI into the DOM
* @param {String} str The HTML string for the updated UI
*/
render (str) {
// Parse returned string into HTML
let parser = new DOMParser();
let doc = parser.parseFromString(str, 'text/html');
if (!doc.body) return;
// Render each target
for (let selector of this.targets) {
// Find target element in the DOM
let target = document.querySelector(selector);
if (!target) continue;
// Get the target element from the returned HTML
let updated = doc.body.querySelector(selector);
if (!updated) continue;
// Update the UI
target.replaceWith(updated);
}
}
/**
* Reset the form element values
*/
reset () {
this.form.reset();
}
});