HTML Web Components Re-Use Logic, Which is What You Want

Custom elements that wrap HTML (AKA ���HTML Web Components���) can be extremely useful for re-using logic without requiring the user of the custom element to adopt any particular UI or styling. And this is usually the sort of re-use you actually want.

Let me demonstrate by creating a way to sort and filter any HTML table. Sorry, this is a bit long.

HTML Web Components used in this way are extremely powerful because they work with the HTML you already have, nomatter how that HTML generated. Unlike a sortable/filterable table made with React, the HTML Web Componentwe���ll create doesn���t require that the HTML be generated on the client, or from any particular server process. Itworks with static HTML served from a CDN.

Semantic Markup

This example was inspired by a Vue Example, shared with me on Mastodon, but I���ve done everything from scratch. Here is the most basic HTML needed for this task:

Name Power Chuck Norris Infinite Bruce Lee 9001 Jet Li 8000 Jackie Chan 7000

Pretty standard stuff. Next, we need the search form, which can be created with, again, basic HTML:

Search type="search" name="filter-terms"> Search

You can see this on CodePen, along with some basic styles thatdon���t affect behavior (though we will be using CSS as part of this).

The requirements for our table are:

Clicking on the header elements will sort the table by that column, alternating ascending and descending. There should be an indicator of what column is the sort column and which direction. Entering text in the form filters the rows to only those with a cell containing the matching text.Custom Elements Augment Regular Elements

We���ll do this by creating a few custom elements, each of which will bestow behavior on the markup they contain:

will do most of the work. Any it containswill be sorted based on the sort-column in the direction specified by sort-direction, filtering based onfilter-terms. When this attributes change, the table will update itself to conform to the new attributes will manage the state of the sort of the and respond to clickevents of a it contains to trigger attribute changes that will cause sorting. will wrap the and, when submitted, update the filter-terms attribute of the, thus filtering the table.

Let���s look at the table first, as it���s the most complex.

First, we���ll surround our with :��� ���

To create our custom element, we���ll need a bit of boilerplate. We need to extend HTMLElement and declare theattributes we wish to observe. I also like to create a static property tagName that can be used inquerySelector/querySelectorAll to reduce duplication.

We���ll then need to call define on window.customElements to tell the browser about our custom element. I���mdoing this inside the DOMContentLoaded callback because I don���t want the element to be initialized until theentire DOM has been loaded. If the element is initialized before that, it won���t find the element itwraps (see end notes for some nuance around this).class FancyTable extends HTMLElement { static tagName = "fancy-table" static observedAttributes = [ "sort-column", "sort-direction", "filter-terms", ]}document.addEventListener("DOMContentLoaded", () => { window.customElements.define(FancyTable.tagName,FancyTable)})

The approach we���ll take is to have a single method called #update that examines the element���s attributes andcontents and re-arranges the contents as needed. Of note, this approach will not generate any HTML and itwill not blow away its innards to accomplish its goals.

The #update method will be called by two callbacks that are part of the custom element spec: connectedCallback and attributeChangedCallback. This will allow the element to react to changes (again, don���t skip the end notes for some further discussion).

class FancyTable extends HTMLElement { static tagName = "fancy-table" static observedAttributes = [ "sort-column", "sort-direction", "filter-terms", ] ��� #sortColumn = NaN��� #sortDirection = "ascending"��� #filterTerms = null������ attributeChangedCallback(name, oldValue, newValue) {��� if (name == "sort-column") {��� this.#sortColumn = parseInt(newValue)��� } else if (name == "sort-direction") {��� this.#sortDirection = newValue��� } else if (name == "filter-terms") {��� this.#filterTerms = newValue ? newValue.toLowerCase() : null��� }��� this.#update()��� }������ connectedCallback() {��� this.#update()��� } }

I like to use attributeChangedCallback as a place to normalize the values for the attributes. When anattribute is removed, the newValue will be the empty string which, while falsey, is annoying to deal with.

Next, we���ll sketch #update:

class FancyTable extends HTMLElement { // ...��� #update() {��� this.#sortTable()��� this.#filter()��� } }Sorting

#sortTable() will sort each based on the value for this.#sortColumn and this.#sortDirection. Thisis where we���ll examine the contents of our custom element, and there will need to be a fair bit of defensivecoding to handle cases where we don���t find the elements we expect.

First, locate the body and return if we don���t find it:

// Member of the FancyTable class#sortTable() { const tbody = this.querySelector("table tbody") if (!tbody) { return }

(Note: I���m not console.warning in this case because I don���t think custom elements should emit warnings unless really necessary. See my previous post on how I set up debugging mistaken use of elements)

Next, we���ll sort the rows based on the content of the cells in the selected column. This is gnarly, but it should be rock solid:

// Still inside #sortTable const rows = Array.from(tbody.querySelectorAll("tr")) rows.sort((a, b) => { let sortColumnA = a.querySelectorAll("td")[this.#sortColumn] let sortColumnB = b.querySelectorAll("td")[this.#sortColumn] if (this.#sortDirection == "descending") { const swap = sortColumnA sortColumnA = sortColumnB sortColumnB = swap } if (sortColumnA) { if (sortColumnB) { return sortColumnA.textContent.localeCompare( sortColumnB.textContent ) } else { return 1 } } else if (sortColumnB) { return -1 } else { return 0 } })

Now that the rows are sorted, we can take advantage of the behavior of appendChild:

If the given child is a reference to an existing node in the document, appendChild() moves it from its current position to the new position


rows.forEach((row) => tbody.appendChild(row))}// end of #sortTable method

Check out the implementation on CodePen. You can set thesort-column and sort-direction attributes in the HTML pane and the table will sort itself. You can also dothis in the console with setAttribute and the same thing will happen.

Filtering

To filter rows, we���ll use the hidden attribute on any filtered-out row. This should prevent the row from being rendered as well as read out by screen readers.

We���ll go through each and, if we have a value for this.#filterTerms, set hidden on the bydefault, then removing it if the term is a substring of any ���s textContent (case insensitively). Ifthere���s no value to filter on, we���ll remove hidden if it was there. class FancyTable extends HTMLElement { // ...��� #filter() {��� this.querySelectorAll("tbody tr").forEach((tr) => {��� if (this.#filterTerms) {��� tr.setAttribute("hidden", true)��� tr.querySelectorAll("td").forEach((td) => {��� const lowerContent = td.textContent.toLowerCase()��� if (lowerContent.indexOf(this.#filterTerms) != -1) {��� tr.removeAttribute("hidden")��� }��� })��� } else {��� tr.removeAttribute("hidden")��� }��� })��� } }

You can see this on CodePen. Set the filter-terms attribute on and table will filter its elements.

Review of

This is the bulk of it. does whatever its attributes tell it to do and updates as thoseattributes change. The rest of the requirements can be met by connecting user actions to changes in thoseattributes:

When the form is submitted, filter-terms is updated to match. When the user clicks on a header, sort-column and sort-direction are updated.

Let���s tackle filtering first, as it���s a bit simpler.

As with , let���s surround our with :

��� Search type="search" name="filter-terms"> Search ���

As before, we���ll need a class for the custom element. We���ll use the same #update method pattern we usedbefore. Note that there are no observedAttributes.

��� class FancyTableFilter extends HTMLElement {��� static tagName = "fancy-table-filter"��� connectedCallback() {��� this.#update()��� }��� } document.addEventListener("DOMContentLoaded", () => { window.customElements.define(FancyTable.tagName, FancyTable)��� window.customElements.define(FancyTableFilter.tagName, FancyTableFilter) })

Inside #update, we���ll need to setup an event listener for the form. This is a critical piece here, because alot of Web Components blog posts I have read do this sort of set up inside the constructor. The thing aboutcustom elements is that the callbacks can be called multiple times (especially if we were to have observedAttributes), so you have to write any setup code to be idempotent.

This means that any setup code has to be written in a way that it���s safe for it to be called over and over.Generally, ���safe��� means that we won���t add an infinite number of event listeners.

To do that, we���ll take advantage of the behavior of addEventListener that will not add the same listener morethan once. That means we need to create our event listener as a member of the class and not as an anonymousfunction.

Given that, let���s see #update first:

// Inside FancyTableFilter#update() { const form = this.querySelector("form") if (form) { form.addEventListener("submit", this.#formSubmitted) }}

Note again, we have to check that there is a available. If there is, we add our listener to the"submit" event.

Our event listener will disable submitting the form to the server, then locate a in the page(see end notes for a better, but more complicated, way to do this). Once the table is located, it will access the FormData for theform, extract the filter-terms and set that on the table:

// Inside FancyTableFilter#formSubmitted = (event) => { event.preventDefault() const fancyTable = document.querySelector(FancyTable.tagName) if (!fancyTable) { return } const formData = new FormData(event.target) const filterTerms = formData.get("filter-terms") if (filterTerms) { fancyTable.setAttribute("filter-terms", filterTerms) } else { fancyTable.removeAttribute("filter-terms") }}

As we saw, merely setting the filter-terms attribute on the will cause it to filter.

You can try this now on CodePen.

Next, let���s implement user-initiated sorting.

There are several ways to accomplish sorting the table based on a user click. The details of the requirementswe want to meet are:

Clicking the header indicates that column should be used for sorting. If the table is already sorted by that column, flip the ordering of the sort. There should be a visual indicator of the sort (e.g. an up or down arrow). Appropriate aria- attributes should be set and consistent with the sort of the table.

Here is the approach I took, that I���ll show below:

The elements will contain a that itself contains a regular thatwill be styled to fill the entire header and appear clickable but not look like a button. The current state of the sorting will be reflected in the aria-sort attribute set on the . CSS will be used to place an up or down arrow in the right place. The will listen for a click of its and set sort-column andsort-direction on the containing it, thus sorting the table.

As before, here���s the basics of the custom element:

��� class FancyTableSortButton extends HTMLElement {��� static tagName = "fancy-table-sort-button"������ connectedCallback() {��� this.#update()��� }��� } document.addEventListener("DOMContentLoaded", () => { window.customElements.define(FancyTable.tagName, FancyTable) window.customElements.define(FancyTableFilter.tagName, FancyTableFilter)��� window.customElements.define(��� FancyTableSortButton.tagName,��� FancyTableSortButton��� ) })

#update will set up an event listener, again using a member of the class:

// Inside FancyTableSortButton#update() { const button = this.querySelector("button") if (!button) { return } button.addEventListener("click", this.#sort)}

All the work is done in this.#sort. It���s a bit tricky, because we need to use closest to figure out where we are in the DOM. Namely, we���ll find the that contains us, find the that contains us, and then figure out which index we are. And, we���ll look at the that contains us���s aria-sort attribute to figure out if we are already being sorted.

Once we have that, we can set the new values for aria-sort, sort-direction, and sort-column:

#sort = (event) => { const fancyTable = this.closest(FancyTable.tagName) if (!fancyTable) { return } const th = this.closest("th") if (!th) { return } const tr = th.closest("tr") if (!tr) { return } const direction = th.getAttribute("aria-sort") let myIndex = -1 tr.querySelectorAll("th").forEach((th, index) => { if (th.querySelector(FancyTableSortButton.tagName) == this) { myIndex = index } th.removeAttribute("aria-sort") }) if (myIndex == -1) { return } const newDirection = direction == "ascending" ? "descending" : "ascending" th.setAttribute("aria-sort" ,newDirection) fancyTable.setAttribute("sort-direction" ,newDirection) fancyTable.setAttribute("sort-column" ,myIndex)}

The last bit is CSS. If you���ve looked at the CodePens, I put a small amount of CSS there just to make thingslook decent. I���ll just focus on the table���s header.

First, the inside the is styled so it fills the entire space and doesn���t look like a button, but generally indicates that it is clickable and indicates it���s been clicked:fancy-table-sort-button button { width: 100%; display: block; border: none; padding-left: 1rem; padding-right: 1rem; padding-top: 0.5rem; padding-bottom: 0.5rem; cursor: pointer; background-color: #004400; color: white; font-size: 1.25rem;}fancy-table-sort-button button:active { background-color: #006600;}

None of this was needed to make this feature work. Now, we use the aria-sort attribute and the content:property to show a sort indicator:

fancy-table-sort-button button:after { content: " ";}th[aria-sort="ascending"] fancy-table-sort-button button:after { content: "\2191"; /* Up arrow */}th[aria-sort="descending"] fancy-table-sort-button button:after { content: "\2193"; /* Down arrow */}

You can see this all working on CodePen.

Of note, a designer could style this table and the sorting indicators however they wanted without worrying aboutbreaking the functionality.

Review: Neat!

All in all, this is around 150 lines of JavaScript, and only a few extra lines of HTML beyond what is needed tomarkup the form and table. The Vue version of this is a bit smaller, however it requires HTML generation in theclient (though perhaps there is some way to generate this on the server first?).

Here is what I find interesting about the HTML Web Components version:

It is completely agnostic of any look and feel or styling. This logic could be applied to any markup, no matter how it���s generated. In theory, this would allow developers to focus on styling only, and notworry about sorting tables (though see end notes for some nuanced discussion). This agnostic of any framework as well! You could create a React component that generated this markup andit would work! The HTML and CSS are 100% standard. There���s not even the need for a data- element! By starting with an accessible approach using semantic markup and necessary aria- attributes, the logic andstyling can hang off of that (again, see end notes). Although this doesn���t do any HTML generation, it���s not hard to imagine fetching data from an AJAX request and inserting it into the . A MutationObserver could be used by FancyTable to detect this and call #update, thus maintaining the sort. I honestly don���t know if this is ���reactive���, but it feels like it either is or is close. None of the codewe���ve seen tells any other object what to do. The code either sets attributes on other elements or reacts tothose attributes having been changed. Although there is coupling between the various custom elements having toknow about their attributes, this could be abstracted if the overall state on the page is more complex. I���m not sure how to think about the performance, but a version with 1,000entries seems to work well enough. I don���t think an HTML pagewith a 1,000 row table is very useful, but it seems fast enough.

Despite this, here is what is annoying and I wish could be made to go away without having to have someframework:

Tons of defensive coding around elements potentially not being where they are expected. This is the mainadvantage to frameworks like React and Vue. Since they are generating the markup, they don���t have to worry thatwhatever they expect isn���t there. I don���t think this advantage outweighs the downsides, but it���s still annoyingto have to check if elements exist before executing logic. It would be nice to have a callback that amounted to ���the DOM inside you has changed���. MutationObserver isso complicated, I just don���t want to deal, but it would vastly improve the behavior of custom elements. We didn���t see any or elements, but those leave some room for improvement.End Notes

I am not sure if I have properly or completely handled all accessibility concerns. I continue to findit really hard to know what is the right way to handle this stuff, and generally my process is to use the properelements for things and to peruse the aria- attributes and roles to see if anything jumps out that I mightneed to use. Please get in touch with any feedback on this.

Another consideration with this implementation is that the just plucks the first it finds and operates on that. The way I have handled this in the past requires a bit morecode, but basically, what I would do is:

Allow to observe an attribute like fancy-table that is intended to be the id of the it���s supposed to interact with. Allow this attribute to be optional only if there is one or zero elements on the page.

You could also imagine the setting every attribute from its FormData onto thefancy-table. That would make the coupling between the two elements even lighter.

Also, the use of may not really be necessary. I could see a case being made that can locate elements inside its elements and assuming those exist to sort thetable. That may be a cleaner implementation.

Further, the sorting could be made more convenient by respecting an attribute or other custom element thatindicates the sortable value:

Charles Norris Chuck Norris !!!!!!! Infinity

Lastly, to make the component truly bullet-proof would require using the aforementioned MutationObserver toensure that any changes to the DOM inside the element triggered a re-sort and re-filter.

To make a truly universal custom element that sorts a table would require thinking through a lot more edgecases.

 •  0 comments  •  flag
Share on Twitter
Published on September 30, 2024 04:00
No comments have been added yet.