Ever wondered how to build a paginated list that works with and without JavaScript? In this article, Manuel explains how you can leverage the power of Progressive Enhancement and do just that with Eleventy and Alpine.js.
Most sites I build are static sites with HTML files generated by a static site generator or pages served on a server by a CMS like Wordpress or CraftCMS. I use JavaScript only on top to enhance the user experience. I use it for things like disclosure widgets, accordions, fly-out navigations, or modals.
The requirements for most of these features are simple, so using a library or framework would be overkill. Recently, however, I found myself in a situation where writing a component from scratch in Vanilla JS without the help of a framework would’ve been too complicated and messy.
Lightweight Frameworks
My task was to add multiple filters, sorting and pagination to an existing list of items. I didn’t want to use a JavaScript Framework like Vue or React, only because I needed help in some places on my site, and I didn’t want to change my stack. I consulted Twitter, and people suggested minimal frameworks like lit, petite-vue, hyperscript, htmx or Alpine.js. I went with Alpine because it sounded like it was exactly what I was looking for:
“Alpine is a rugged, minimal tool for composing behavior directly in your markup. Think of it like jQuery for the modern web. Plop in a script tag and get going.”
Alpine.js
Alpine is a lightweight (~7KB) collection of 15 attributes, 6 properties, and 2 methods. I won’t go into the basics of it (check out this article about Alpine by Hugo Di Francesco or read the Alpine docs), but let me quickly introduce you to Alpine:
Note: You can skip this intro and go straight to the main content of the article if you’re already familiar with Alpine.js.
Let’s say we want to turn a simple list with many items into a disclosure widget. You could use the native HTML elements: details and summary for that, but for this exercise, I’ll use Alpine.
By default, with JavaScript disabled, we show the list, but we want to hide it and allow users to open and close it by pressing a button if JavaScript is enabled:
First, we include Alpine using a script
tag. Then we wrap the list in a div
and use the x-data
directive to pass data into the component. The open
property inside the object we passed is available to all children of the div
:
We can use the open
property for the x-show
directive, which determines whether or not an element is visible:
Since we set open
to false
, the list is hidden now.
Next, we need a button that toggles the value of the open
property. We can add events by using the x-on:click
directive or the shorter @-Syntax @click
:
Pressing the button, open
now switches between false
and true
and x-show
reactively watches these changes, showing and hiding the list accordingly.
While
this works for keyboard and mouse users, it’s useless to screen reader
users, as we need to communicate the state of our widget. We can do that
by toggling the value of the aria-expanded
attribute:
We can also create a semantic connection between the button and the list using aria-controls
for screen readers that support the attribute:
Here’s the final result:
Pretty neat! You can enhance existing static content with JavaScript without having to write a single line of JS. Of course, you may need to write some JavaScript, especially if you’re working on more complex components.
A Static, Paginated List
Okay, now that we know the basics of Alpine.js, I’d say it’s time to build a more complex component.
Note: You can take a look at the final result before we get started.
I want to build a paginated list of my vinyl records that works without JavaScript. We’ll use the static site generator eleventy (or short “11ty”) for that and Alpine.js to enhance it by making the list filterable.
Setup
Before we get started, let’s set up our site. We need:
- a project folder for our site,
- 11ty to generate HTML files,
- an input file for our HTML,
- a data file that contains the list of records.
On your command line, navigate to the folder where you want to save the project, create a folder, and cd
into it:
Then create a package.json
file and install eleventy:
Next, create an index.njk
file (.njk
means this is a Nunjucks file; more about that below) and a folder _data
with a records.json
:
You don’t have to do all these steps on the command line. You can also create folders and files in any user interface. The final file and folder structure looks like this:
Adding Content
11ty allows you to write content directly into an HTML file (or Markdown, Nunjucks, and other template languages). You can even store data in the front matter or in a JSON file. I don’t want to manage hundreds of entries manually, so I’ll store them in the JSON file we just created. Let’s add some data to the file:
Finally, let’s add a basic HTML structure to the index.njk
file and start eleventy:
By running the following command you should be able to access the site at http://localhost:8080
:
Displaying Content
Now let’s take the data from our JSON file and turn it into HTML. We can access it by looping over the records
object in nunjucks:
Pagination
Eleventy
supports pagination out of the box. All we have to do is add a
frontmatter block to our page, tell 11ty which dataset it should use for
pagination, and finally, we have to adapt our for
loop to use the paginated list instead of all records:
If you access the page again, the list only contains 5 items. You can also see that I’ve added a status message (ignore the output
element for now), wrapped the list in a div
with the role
“region”, and that I’ve labelled it by creating a reference to #message
using aria-labelledby
. I did that to turn it into a landmark and allow screen reader users to access the list of results directly using keyboard shortcuts.
Next, we’ll add a navigation with links to all pages created by the static site generator. The pagination
object holds an array
that contains all pages. We use aria-current="page"
to highlight the current page:
Finally, let’s add some basic CSS to improve the styling:
You can see it in action in the live demo and you can check out the code on GitHub.
This works fairly well with 7 records. It might even work with 10, 20, or 50, but I have over 400 records. We can make browsing the list easier by adding filters.
A Dynamic Paginated And Filterable List
I like JavaScript, but I also believe that the core content and functionality of a website should be accessible without it. This doesn’t mean that you can’t use JavaScript at all, it just means that you start with a basic server-rendered foundation of your component or site, and you add functionality layer by layer. This is called progressive enhancement.
Our foundation in this example is the static list created with 11ty, and now we add a layer of functionality with Alpine.
First, right before the closing body
tag, we reference the latest version (as of writing 3.9.1) of Alpine.js:
Note: Be
careful using a third-party CDN, this can have all kinds of negative
implications (performance, privacy, security). Consider referencing the
file locally or importing it as a module.
In case you’re wondering why you don’t see the Subresource Integrity hash in the official docs, it’s because I’ve created and added it manually.
Since
we’re moving into JavaScript-world, we need to make our records
available to Alpine.js. Probably not the best, but the quickest solution
is to create a .eleventy.js
file in your root folder and add the following lines:
This ensures that eleventy doesn’t just generate HTML files, but it also copies the contents of the _data
folder into our destination folder, making it accessible to our scripts.
Fetching Data
Just like in the previous example, we’ll add the x-data
directive to our component to pass data:
We don’t have any data, so we need to fetch it as the component initialises. The x-init
directive allows us to hook into the initialisation phase of any element and perform tasks:
If we output the results directly, we see a list of [object Object]
s, because we’re fetching and receiving an array
. Instead, we should iterate over the list using the x-for
directive on a template
tag and output the data using x-text
:
The<template>
HTML element is a mechanism for holding HTML that is not to be rendered immediately when a page is loaded but may be instantiated subsequently during runtime using JavaScript.
MDN:<template>
: The Content Template Element
Here’s how the whole list looks like now:
Isn’t it amazing how quickly we were able to fetch and output data? Check out the demo below to see how Alpine populates the list with results.
You can achieve a lot by using Alpine’s directives, but at some point relying only on attributes can get messy. That’s why I’ve decided to move the data and some of the logic into a separate Alpine component object.
Here’s how that works: Instead of passing data directly, we now reference a component using x-data
.
The rest is pretty much identical: Define a variable to hold our data,
then fetch our JSON file in the initialization phase. However, we don’t
do that inside an attribute, but inside a script
tag or file instead:
Looking
at the previous CodePen, you’ve probably noticed that we now have a
duplicate set of data. That’s because our static 11ty list is still
there. Alpine has a directive that tells it to ignore certain DOM
elements. I don’t know if this is actually necessary here, but it’s a
nice way of marking these unwanted elements. So, we add the x-ignore
directive on our 11ty list items, and we add a class to the html
element when the data has loaded and then use the class and the attribute to hide those list items in CSS:
11ty data is hidden, results are coming from Alpine, but the pagination is not functional at the moment:
Pagination #
Before we add filters, let’s paginate our data. 11ty did us the favor of handling all the logic for us, but now we have to do it on our own. In order to split our data across multiple pages, we need the following:
- the number of items per page (
itemsPerPage
), - the current page (
currentPage
), - the total number of pages (
numOfPages
), - a dynamic, paged subset of the whole data (
page
).
The number of items per page is a fixed value (5)
, and the current page starts with 0
. We get the number of pages by dividing the total number of items by the number of items per page:
The easiest way for me to get the items per page was to use the slice()
method in JavaScript and take out the slice of the dataset that I need for the current page:
To only display the items for the current page, we have to adapt the for
loop to iterate over page
instead of records
:
We now have a page, but no links that allow us to jump from page to page. Just like earlier, we use the template
element and the x-for
directive to display our page links:
Since we don’t want to reload the whole page anymore, we put a click
event on each link, prevent the default click behavior, and change the current page number on click:
Here’s what that looks like in the browser. (I’ve added more entries to the JSON file. You can download it on GitHub.)
Filtering #
I want to be able to filter the list by artist and by decade.
We add two select elements wrapped in a fieldset
to our component, and we put a x-model
directive on each of them. x-model
allows us to bind the value of an input element to Alpine data:
Of course, we also have to create these data fields in our Alpine component:
If we change the selected value in each select
, filters.artist
and filters.year
will update automatically. You can try it here with some dummy data I’ve added manually:
Now we have select
elements, and we’ve bound the data to our component. The next step is to populate each select
dynamically with artists and decades respectively. For that we take our records
array and manipulate the data a bit:
This
looks wild, and I’m sure that I’ll forget what’s going on here real
soon, but what this code does is that it takes the array of objects and
turns it into an array of strings (map()
), it makes sure that each entry is unique (that’s what [...new Set()]
does here) and sorts the array alphabetically (sort()
).
For the decade’s array, I’m additionally slicing off the last digit of
the year because I don’t want this filter to be too granular. Filtering
by decade is good enough.
Next, we populate the artist and decade select
elements, again using the template
element and the x-for
directive:
Try it yourself in demo 5 on Codepen.
We’ve successfully populated the select elements with data from our JSON file. To finally filter the data, we go through all records, we check whether a filter is set. If that’s the case, we check that the respective field of the record corresponds to the selected value of the filter. If not, we filter this record out. We’re left with a filtered array that matches the criteria:
For this to take effect we have to adapt our numOfPages()
and page()
functions to use only the filtered records:
Three things left to do:
- fix a bug;
- hide the form;
- update the status message.
Bug Fix: Watching a Component Property #
When
you open the first page, click on page 6, then select “1990” — you
don’t see any results. That’s because our filter thinks that we’re still
on page 6, but 1) we’re actually on page 1, and 2) there is no page 6
with “1990” active. We can fix that by resetting the currentPage
when the user changes one of the filters. To watch changes in the filter
object, we can use a so-called magic method:
Every time the filter
property changes, the currentPage will be set to 0
.
Hiding the Form #
Since
the filters only work with JavaScript enabled and functioning, we
should hide the whole form when that’s not the case. We can use the .alpine
class we created earlier for that:
I’m using visibility: hidden
instead of hidden
only to avoid content shifting while Alpine is still loading.
Communicating Changes
The status message at the beginning of our list still reads “Showing 7 records”, but this doesn’t change when the user changes the page or filters the list. There are two things we have to do to make the paragraph dynamic: bind data to it and communicate changes to assistive technology (a screen reader, e.g.).
First, we bind data to the output
element in the paragraph that changes based on the current page and filter:
Next, we want to communicate to screen readers that the content on the page has changed. There are at least two ways of doing that:
- We could turn an element into a so-called live region using the
aria-live
attribute. A live region is an element that announces its content to screen readers every time it changes.
output
element (remember?) which is an implicit live region by default.We can reference the region using the
x-ref
directive.I’ve decided to do both:
- When users filter the page, we update the live region, but we don’t move focus.
- When they change the page, we move focus to the list.
That’s it. Here’s the final result:
Note: When
you filter by artist, and the status message shows “1 records”, and you
filter again by another artist, also with just one record, the content
of the output
element doesn’t change, and nothing is
reported to screen readers. This can be seen as a bug or as a feature to
reduce redundant announcements. You’ll have to test this with users.
What’s Next?
What I did here might seem redundant, but if you’re like me, and you don’t have enough trust in JavaScript, it’s worth the effort. And if you look at the final CodePen or the complete code on GitHub, it actually wasn’t that much extra work. Minimal frameworks like Alpine.js make it really easy to progressively enhance static components and make them reactive.
I’m pretty happy with the result, but there are a few more things that could be improved:
- The pagination could be smarter (maximum number of pages, previous and next links, and so on).
- Let users pick the number of items per page.
- Sorting would be a nice feature.
- Working with the history API would be great.
- Content shifting can be improved.
- The solution needs user testing and browser/screen reader testing.
P.S. Yes, I know, Alpine produces invalid HTML with its custom x-
attribute syntax. That hurts me as much as it hurts you, but as long as it doesn’t affect users, I can live with that. :)
P.S.S. Special thanks to Scott, Søren, Thain, David, Saptak and Christian for their feedback.
Further Resources #
- “How To Build A Filterable List Of Things”, Søren Birkemeyer
- “Considering Dynamic Search Results And Content”, Scott O’Hara
“The<output>
HTML element is a container element into which a site or app can inject the results of a calculation or the outcome of a user action.”
Source:<output>
: The Output Element, MDN Web Docs
No comments:
Post a Comment