Yesterday, we looked at how I build JavaScript-free interactive web apps using HTML forms and PHP.
Today, I want to share how I progressively enhance those into asynchronous ajaxy interactions using HTML Web Components and a little JavaScript.
Let's dig in!
The HTML in HTML Web Component
HTML Web Components are an approach to building Web Components where you progressively enhance the HTML that's already there instead of rendering it entirely from JavaScript.
I start by wrapping my <form>
elements in an <ajax-form>
custom element…
<ajax-form> <form action="/path/to/add-item.php" method="POST"> <label for="item">The New Item</label> <input type="text" name="item" id="item" required> <button>Add Item</button> <div role="status"></div> </form> </ajax-form>
Now, we're ready to write some JavaScript!
Instantiating our Web Component
Like any Web Component, this one starts by defining the custom element, passing in a class, and running super()
in the constructor()
method.
(If you're new to Web Components, I've got a short tutorial on writing your first one here.)
customElements.define('ajax-form', class extends HTMLElement { /** * The class constructor object */ constructor () { // Always call super first in constructor super(); } });
Next, I get the [role="status"]
element used for displaying form announcements and save it to the this.announce
property.
If the element doesn't exist yet, I create it. Then, I get this.form
from inside my custom element, and append the this.announce
element into it.
This could be refactored a bit, but how it works is unique to how my apps get built using Hugo, my preferred Static Site Generator (SSG).
/** * The class constructor object */ constructor () { // Always call super first in constructor super(); // Add a form status element this.announce = this.querySelector('[role="status"]') || document.createElement('div'); this.announce.setAttribute('role', 'status'); // Set base properties this.form = this.querySelector('form'); this.form.append(this.announce); }
Options & Settings
This Web Component supports a bunch of options.
I'll talk through what they all do in a bit, but the main one is this.msgSubmitting
. This is the text that gets displayed when the form is submitting, but before I've gotten a response back from the server.
/** * The class constructor object */ constructor () { // ... this.form.append(this.announce); // Define options this.msgSubmitting = this.getAttribute('msg-submitting') ?? 'Submitting...'; this.msgDisappear = this.hasAttribute('msg-disappear'); this.keepFields = this.hasAttribute('keep-fields'); this.updateURL = this.getAttribute('update-url'); this.removeElem = this.getAttribute('remove'); this.delay = this.hasAttribute('delay') ? 6000 : 0; }
With Web Components, all of your options and settings are configured using attributes on the custom element. This one uses the [msg-submitting]
attribute.
For example, on a login form, I'd use Logging in...
as the status message, like this…
<ajax-form msg-submitting="Logging in..."> <form action="/path/to/login.php" method="POST"> <!-- ... --> </form> </ajax-form>
-
msg-submitting
- The message to show when the form is submitting. -
msg-disappear
- If this attribute exists, automatically remove success status messages after 6 seconds. -
keep-fields
- If this attribute exists, do not reset the form fields on success. -
update-url
- A URL to update the current window's browser history to (without reloading the page) on success. -
remove
- The selector for a parent element to remove from the DOM entirely on success (will remove the Web Component, too). -
delay
- The number of milliseconds to wait before re-enabling the form on success (prevents accidental multiple submits).
Listening for submit
events
Now, we can actually listen for submit
events on our form.
I take advantage of the handleEvent()
method, which lets you easily preserve the context of this
when working with events in a class pattern.
/** * The class constructor object */ constructor () { // ... this.delay = this.hasAttribute('delay') ? 6000 : 0; // Listen for events this.form.addEventListener('submit', this); } /** * Handle events on the component * @param {Event} event The event object */ handleEvent(event) { this[`on${event.type}`](event); }
I setup an onsubmit()
method to handle my submit
events. I also use the async
keyword to make it an asynchronous function, since we'll be calling an API.
/** * Handle submit events * @param {Event} event The event object */ async onsubmit (event) { // ... }
Some helper methods
I use a few helper methods for working with my form.
The disable()
method adds a [form-submitting]
attribute, and the enable()
method removes it. The isDisable()
method checks if that attribute exists.
/** * 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'); }
I could instead set this.isSubmitting
as an instance property, but using an HTML attribute let's you hook into it for styling purposes.
This is preferred over the [disabled]
attribute, which can create accessibility issues.
[form-submitting] button { /* Make the button appear disabled */ }
I also have a method to showStatus()
messages in my form.
It accepts the msg
to display, a boolean indicating if the message is a success
or not, and a boolean indicating if the entire form lifecycle is complete
.
It displays the text in the this.announce
element, and adds a success or error class.
If the element was a success
and complete
, and the element should be removed, it displays a toast message instead of an inline form message, since the form element is about to get removed from the UI.
And if the message was set to disappear, it gets automatically removed after 6000
milliseconds (or 6 seconds).
/** * Update the form status in a field * @param {String} msg The message to display * @param {Boolean} success If true, add success class * @param {Boolean} complete If true, form submit is complete */ showStatus (msg, success, complete) { // Show the message this.announce.innerHTML = msg; this.announce.className = success ? 'success-message' : 'error-message'; // If content should be removed, switch to toast notification if (success && complete && this.removeElem) { this.toast(); } // If success and message should disappear if (success && this.msgDisappear) { setTimeout(() => { this.announce.innerHTML = ''; this.announce.className = ''; }, 6000); } }
Why two attributes for what seems like one thing (success
and complete
)? Submitting is not an error, but the form isn't complete
yet either. So that's success = true
but complete = false
.
Handling events
The first thing I do in the onsubmit()
method is run the event.preventDefault()
method to stop the form from reloading the page.
Then, I check if the this.isDisabled()
already. If so, the form is already submitting, so I return
to end the function early. If not, I run this.disable()
to disable it.
Then, I show this.msgSubmitting
using the showStatus()
method, and set success
to true
.
/** * Handle submit events * @param {Event} event The event object */ async onsubmit (event) { // Stop form from reloading the page event.preventDefault(); // If the form is already submitting, do nothing // Otherwise, disable future submissions if (this.isDisabled()) return; this.disable(); // Show status message this.showStatus(this.msgSubmitting, true); }
Now, we're ready to call the API!
Calling the API
I wrap the entire API call in a try-catch-finally
block.
This will handle any errors, and do some cleanup after the response is returned. If there's an error, I console.warn()
it, and use the showStatus()
method to display the error.message
in the UI.
In the finally
block, I enable()
the form again. I run this in a setTimeout()
method, using the this.delay
property for the number of milliseconds. If undefined by an attribute, it has a default value of 0
and will run immediately.
/** * Handle submit events * @param {Event} event The event object */ async onsubmit (event) { // ... this.showStatus(this.msgSubmitting, true); try { // ... } catch (error) { console.warn(error); this.showStatus(error.message); } finally { setTimeout(() => { this.enable(); }, this.delay); } }
To call the API itself, I use object destructuring to get the action
and method
properties from this.form
.
Then, I make a fetch()
request to the API, and await
the response
.
I pass in the method
, use a serialize()
method to get the form data and assign it to the body
, and define some headers
. The X-Requested-With
header is how my PHP backend knows it's an ajax request and not a full page reload.
try { // Call the API let {action, method} = this.form; let response = await fetch(action, { method, body: this.serialize(), headers: { 'Content-type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest' } }); }
The serialize()
method uses the FormData
API to get the form field values, and the URLSearchParams
API to create a URL encoded string (or query string) from those values.
/** * 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(); }
Handling the API response
Once we get a response
back, we can use the Response.json()
method to convert it into a JSON object (this is asynchronous, so I need the await
operator).
If the response.ok
property is false, there was an error, so I throw data
to trigger the catch()
method to run. If not, I use the showStatus()
method to display the data.message
.
This time, I pass in true
for both success
and complete
.
try { // ... // Get the response data let data = await response.json(); // If there's an error, throw if (!response.ok) throw data; // If message, display it if (data.message) { this.showStatus(data.message, true, true); } }
Let's look at some of the extra stuff that might happen after a successful response.
If there's an updateURL
property defined on the instance, I use the history.replaceState()
method to update the current URL without reloading the page.
try { // ... // If updateURL, update it if (this.updateURL) { history.replaceState(history.state, null, this.updateURL); } }
The the data
response has a url
property, that means on success the user should be redirected to that URL.
I use the window.location.href
property to do that.
try { // ... // If URL, redirect if (data.url) { window.location.href = data.url; } }
If the keepFields
instance property is not true
, I use the HTMLFormElement.reset()
method to clear all of the fields.
I have a helper method setup for this, but I probably don't need one.
try { // ... // Clear the form if (!this.keepFields) { this.reset(); } }
I emit()
a custom event that other scripts and Web Components can hook into. This emits an ajax-form
event on the Web Component, with the response data
as an event.detail
.
We'll look at how I use that tomorrow.
try { // ... // Emit custom event this.emit(data); }
And finally, if the this.removeElem
property is defined, I find the matching element, and use the Element.remove()
method to remove it from the DOM.
try { // ... // Optionally remove all HTML if (this.removeElem) { let elemToRemove = this.closest(this.removeElem) || this; elemToRemove.remove(); } }
How toasts work
One thing I didn't cover is the tost()
method.
This relocates to the this.announce
element to a fixed element at the bottom of the page, and adds a unique class for styling the notifications differently.
It's used specifically when the element the this.announce
element was in will no longer exist, but you still need to notify the user of a successful action.
In it, I look for a .toast-wrapper
element. If one doesn't exist, I create it and append it to the end of the document.body
.
/** * Convert status message into a toast */ toast () { // Get the toast wrapper let wrapper = document.querySelector('.toast-wrapper'); // If there is no wrapper, make one if (!wrapper) { wrapper = document.createElement('div'); wrapper.className = 'toast-wrapper'; document.body.append(wrapper); } }
Next, I reset the this.announce.className
to .toast
, and use the Element.prepend()
method to add it as the first element in my wrapper
.
Then, I use setTimeout()
to remove it from the UI after 6 seconds, since toast components are typically short lived and automatically disappear.
/** * Convert status message into a toast */ toast () { // ... // Add the toast class to the notification this.announce.className = 'toast'; wrapper.prepend(this.announce); // Remove after 6 seconds setTimeout(() => { this.announce.remove(); }, 6000); }
Putting it all together…
You can download the entire <ajax-form>
element on GitHub. I'll also be adding it to my members toolkit.
Tomorrow, I'll show you how I use custom events and another HTML Web Component to dynamically update certain parts of the UI after a form is successfully submitted.
Like this? A Go Make Things membership is the best way to support my work and help me create more free content.
Cheers,
Chris
Want to share this with others or read it later? View it in a browser.
0 Komentar untuk "[Go Make Things] Progressively enhancing forms with an HTML Web Component (part 2)"