-
Perfection
briefs.video/videos/is-prog…
-
Fast abstract ↬
Ever questioned how one can construct a paginated listing that works with and with out JavaScript? On this article, Manuel explains how one can leverage the facility of Progressive Enhancement and just do that with Eleventy and Alpine.js.Most websites I construct are static websites with HTML recordsdata generated by a static website generator or pages served on a server by a CMS like WordPress or CraftCMS. I exploit JavaScript solely on high to boost the consumer expertise. I exploit it for issues like disclosure widgets, accordions, fly-out navigations, or modals.
The necessities for many of those options are easy, so utilizing a library or framework can be overkill. Not too long ago, nevertheless, I discovered myself in a scenario the place writing a part from scratch in Vanilla JS with out the assistance of a framework would’ve been too difficult and messy.
Light-weight Frameworks
My process was so as to add a number of filters, sorting and pagination to an current listing of things. I didn’t need to use a JavaScript Framework like Vue or React, solely as a result of I wanted assist in some locations on my website, and I didn’t need to change my stack. I consulted Twitter, and other people prompt minimal frameworks like lit, petite-vue, hyperscript, htmx or Alpine.js. I went with Alpine as a result of it sounded prefer it was precisely what I used to be on the lookout for:
“Alpine is a rugged, minimal device for composing habits straight in your markup. Consider it like jQuery for the trendy net. Plop in a script tag and get going.”
Alpine.js
Alpine is a light-weight (~7KB) assortment of 15 attributes, 6 properties, and a couple of strategies. I gained’t go into the fundamentals of it (take a look at this article about Alpine by Hugo Di Francesco or learn the Alpine docs), however let me shortly introduce you to Alpine:
Word: You’ll be able to skip this intro and go straight to the main content of the article should you’re already conversant in Alpine.js.
Let’s say we need to flip a easy listing with many gadgets right into a disclosure widget. You might use the native HTML components: details and summary for that, however for this train, I’ll use Alpine.
By default, with JavaScript disabled, we present the listing, however we need to cover it and permit customers to open and shut it by urgent a button if JavaScript is enabled:
<h2>Beastie Boys Anthology</h2>
<p>The Sounds of Science is the primary anthology album by American rap rock group Beastie Boys composed of best hits, B-sides, and beforehand unreleased tracks.</p>
<ol>
<li>Beastie Boys</li>
<li>Sluggish And Low</li>
<li>Shake Your Rump</li>
<li>Gratitude</li>
<li>Expertise To Pay The Payments</li>
<li>Root Down</li>
<li>Consider Me</li>
…
</ol>
First, we embrace Alpine utilizing a script tag. Then we wrap the listing in a div and use the x-data directive to move knowledge into the part. The open property inside the thing we handed is obtainable to all youngsters of the div:
<div x-data="{ open: false }">
<ol>
<li>Beastie Boys</li>
<li>Sluggish And Low</li>
…
</ol>
</div>
<script src="https://unpkg.com/alpinejs@3.9.1/dist/cdn.min.js" integrity="sha384-mDHH3kdyMS0F6QcfHCxEgPMMjssTurzucc7Jct3g1GOfB4p7PxJuugPP1NOLvE7I" crossorigin="nameless"></script>
We will use the open property for the x-show directive, which determines whether or not or not a component is seen:
<div x-data="{ open: false }">
<ol x-show="open">
<li>Beastie Boys</li>
<li>Sluggish And Low</li>
…
</ol>
</div>
Since we set open to false, the listing is hidden now.
Subsequent, we want a button that toggles the worth of the open property. We will add occasions by utilizing the x-on:click on directive or the shorter @-Syntax @click on:
<div x-data="{ open: false }">
<button @click on="open = !open">Tracklist</button>
<ol x-show="open">
<li>Beastie Boys</li>
<li>Sluggish And Low</li>
…
</ol>
</div>
Urgent the button, open now switches between false and true and x-show reactively watches these modifications, displaying and hiding the listing accordingly.
Whereas this works for keyboard and mouse customers, it’s ineffective to display screen reader customers, as we have to talk the state of our widget. We will do this by toggling the worth of the aria-expanded attribute:
<button @click on="open = !open" :aria-expanded="open">
Tracklist
</button>
We will additionally create a semantic connection between the button and the listing utilizing aria-controls for screen readers that support the attribute:
<button @click on="open = ! open" :aria-expanded="open" aria-controls="tracklist">
Tracklist
</button>
<ol x-show="open" id="tracklist">
…
</ol>
Right here’s the ultimate end result:
See the Pen [Simple disclosure widget with Alpine.js](https://codepen.io/smashingmag/pen/xxpdzNz) by Manuel Matuzovic.
See the Pen Simple disclosure widget with Alpine.js by Manuel Matuzovic.Fairly neat! You’ll be able to improve current static content material with JavaScript with out having to jot down a single line of JS. After all, you might want to jot down some JavaScript, particularly should you’re engaged on extra complicated parts.
A Static, Paginated Record
Okay, now that we all know the fundamentals of Alpine.js, I’d say it’s time to construct a extra complicated part.
Word: You’ll be able to take a look at the final result earlier than we get began.
I need to construct a paginated listing of my vinyl information that works with out JavaScript. We’ll use the static website generator eleventy (or short “11ty”) for that and Alpine.js to boost it by making the listing filterable.
Anybody else right here additionally a fan of vinyl information? 😉 (Large preview)Setup
Earlier than we get began, let’s arrange our website. We want:
a undertaking folder for our website,
11ty to generate HTML recordsdata,
an enter file for our HTML,
an information file that accommodates the listing of information.
In your command line, navigate to the folder the place you need to save the undertaking, create a folder, and cd into it:
cd Websites # or wherever you need to save the undertaking
mkdir myrecordcollection # decide any identify
cd myrecordcollection
Then create a bundle.json file and install eleventy:
npm init -y
npm set up @11ty/eleventy
Subsequent, create an index.njk file (.njk means this can be a Nunjucks file; extra about that beneath) and a folder _data with a information.json:
contact index.njk
mkdir _data
contact _data/information.json
You don’t should do all these steps on the command line. You may also create folders and recordsdata in any consumer interface. The ultimate file and folder construction appears to be like like this:
(Large preview)Including Content material
11ty lets you write content material straight into an HTML file (or Markdown, Nunjucks, and other template languages). You’ll be able to even retailer knowledge within the front matter or in a JSON file. I don’t need to handle a whole bunch of entries manually, so I’ll retailer them within the JSON file we simply created. Let’s add some knowledge to the file:
[
{
"artist": "Akne Kid Joe",
"title": "Die große Palmöllüge",
"year": 2020
},
{
"artist": "Bring me the Horizon",
"title": "Post Human: Survial Horror",
"year": 2020
},
{
"artist": "Idles",
"title": "Joy as an Act of Resistance",
"year": 2018
},
{
"artist": "Beastie Boys",
"title": "Licensed to Ill",
"year": 1986
},
{
"artist": "Beastie Boys",
"title": "Paul's Boutique",
"year": 1989
},
{
"artist": "Beastie Boys",
"title": "Check Your Head",
"year": 1992
},
{
"artist": "Beastie Boys",
"title": "Ill Communication",
"year": 1994
}
]
Lastly, let’s add a fundamental HTML construction to the index.njk file and begin eleventy:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta identify="viewport" content material="width=device-width, initial-scale=1.0">
<title>My File Assortment</title>
</head>
<physique>
<h1>My File Assortment</h1>
</physique>
</html>
By working the next command it is best to be capable to entry the positioning at http://localhost:8080:
eleventy --serve
Eleventy working on port :8080. The location simply reveals the heading ‘My File Assortment’. (Large preview)Displaying Content material
Now let’s take the info from our JSON file and switch it into HTML. We will entry it by looping over the information object in nunjucks:
<div class="assortment">
<ol>
{% for report in information %}
<li>
<sturdy>{{ report.title }}</sturdy>
Launched in <time datetime="{{ report.yr }}">{{ report.yr }}</time> by {{ report.artist }}.
</li>
{% endfor %}
</ol>
</div>
7 Data listed, every with their title, artist and launch date. (Large preview)Eleventy helps pagination out of the field. All we’ve to do is add a frontmatter block to our web page, inform 11ty which dataset it ought to use for pagination, and eventually, we’ve to adapt our for loop to make use of the paginated listing as a substitute of all information:
---
pagination:
knowledge: information
dimension: 5
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta identify="viewport" content material="width=device-width, initial-scale=1.0">
<title>My File Assortment</title>
</head>
<physique>
<h1>My File Assortment</h1>
<div class="assortment">
<p id="message">Exhibiting <output>{{ information.size }} information</output></p>
<div aria-labelledby="message" function="area">
<ol class="information">
{% for report in pagination.gadgets %}
<li>
<sturdy>{{ report.title }}</sturdy>
Launched in <time datetime="{{ report.yr }}">{{ report.yr }}</time> by {{ report.artist }}.
</li>
{% endfor %}
</ol>
</div>
</div>
</physique>
</html>
If you happen to entry the web page once more, the listing solely accommodates 5 gadgets. You may also see that I’ve added a standing message (ignore the output factor for now), wrapped the listing in a div with the function “area”, and that I’ve labelled it by making a reference to #message utilizing aria-labelledby. I did that to show it right into a landmark and permit display screen reader customers to entry the listing of outcomes straight utilizing keyboard shortcuts.
Subsequent, we’ll add a navigation with hyperlinks to all pages created by the static website generator. The pagination object holds an array that accommodates all pages. We use aria-current="web page" to spotlight the present web page:
<nav aria-label="Choose a web page">
<ol class="pages">
{% for page_entry in pagination.pages %}
{%- set page_url = pagination.hrefs[loop.index0] -%}
<li>
<a href="https://smashingmagazine.com/2022/04/accessible-filterable-paginated-list-11ty-alpinejs/{{ page_url }}"{% if web page.url == page_url %} aria-current="web page"{% endif %}>
Web page {{ loop.index }}
</a>
</li>
{% endfor %}
</ol>
</nav>
Lastly, let’s add some fundamental CSS to enhance the styling:
physique {
font-family: sans-serif;
line-height: 1.5;
}
ol {
list-style: none;
margin: 0;
padding: 0;
}
.information > * + * {
margin-top: 2rem;
}
h2 {
margin-bottom: 0;
}
nav {
margin-top: 1.5rem;
}
.pages {
show: flex;
flex-wrap: wrap;
hole: 0.5rem;
}
.pages a {
border: 1px strong #000000;
padding: 0.5rem;
border-radius: 5px;
show: flex;
text-decoration: none;
}
.pages a:the place([aria-current]) {
background-color: #000000;
colour: #ffffff;
}
.pages a:the place(:focus, :hover) {
background-color: #6c6c6c;
colour: #ffffff;
}
(Large preview)You’ll be able to see it in motion within the live demo and you may take a look at the code on GitHub.
This works pretty nicely with 7 information. It’d even work with 10, 20, or 50, however I’ve over 400 information. We will make shopping the listing simpler by including filters.
Extra after soar! Proceed studying beneath ↓
A Dynamic Paginated And Filterable Record
I like JavaScript, however I additionally consider that the core content material and performance of an internet site needs to be accessible with out it. This doesn’t imply you can’t use JavaScript in any respect, it simply signifies that you begin with a fundamental server-rendered basis of your part or website, and also you add performance layer by layer. That is referred to as progressive enhancement.
Our basis on this instance is the static listing created with 11ty, and now we add a layer of performance with Alpine.
First, proper earlier than the closing physique tag, we reference the most recent model (as of writing 3.9.1) of Alpine.js:
<script src="https://unpkg.com/alpinejs@3.9.1/dist/cdn.min.js" integrity="sha384-mDHH3kdyMS0F6QcfHCxEgPMMjssTurzucc7Jct3g1GOfB4p7PxJuugPP1NOLvE7I" crossorigin="nameless"></script>
</physique>
Word: Watch out utilizing a third-party CDN, this could have all types of unfavorable implications (efficiency, privateness, safety). Contemplate referencing the file regionally or importing it as a module.
In case you’re questioning why you don’t see the Subresource Integrity hash within the official docs, it’s as a result of I’ve created and added it manually.
Since we’re transferring into JavaScript-world, we have to make our information out there to Alpine.js. Most likely not the very best, however the quickest resolution is to create a .eleventy.js file in your root folder and add the next strains:
module.exports = operate(eleventyConfig) {
eleventyConfig.addPassthroughCopy("_data");
};
This ensures that eleventy doesn’t simply generate HTML recordsdata, but it surely additionally copies the contents of the _data folder into our vacation spot folder, making it accessible to our scripts.
Fetching Knowledge
Similar to within the earlier instance, we’ll add the x-data directive to our part to move knowledge:
<div class="assortment" x-data="{ information: [] }">
</div>
We don’t have any knowledge, so we have to fetch it because the part initialises. The x-init directive permits us to hook into the initialisation part of any factor and carry out duties:
<div class="assortment" x-init="information = await (await fetch('/_data/information.json')).json()" x-data="{ information: [] }">
<div x-text="information"></div>
[…]
</div>
If we output the outcomes straight, we see a listing of [object Object]s, as a result of we’re fetching and receiving an array. As an alternative, we must always iterate over the listing utilizing the x-for directive on a template tag and output the info utilizing x-text:
<template x-for="report in information">
<li>
<sturdy x-text="report.title"></sturdy>
Launched in <time :datetime="report.yr" x-text="report.yr"></time> by <span x-text="report.artist"></span>.
</li>
</template>
The <template> HTML factor is a mechanism for holding HTML that’s not to be rendered instantly when a web page is loaded however could also be instantiated subsequently throughout runtime utilizing JavaScript.
MDN: <template>: The Content Template Element
Right here’s how the entire listing appears to be like like now:
<div class="assortment" x-init="information = await (await fetch('/_data/information.json')).json()" x-data="{ information: [] }">
<p id="message">Exhibiting <output>{{ information.size }} information</output></p>
<div aria-labelledby="message" function="area">
<ol class="information">
<template x-for="report in information">
<li>
<sturdy x-text="report.title"></sturdy>
Launched in <time :datetime="report.yr" x-text="report.yr"></time> by <span x-text="report.artist"></span>.
</li>
</template>
{%- for report in pagination.gadgets %}
<li>
<sturdy>{{ report.title }}</sturdy>
Launched in <time datetime="{{ report.yr }}">{{ report.yr }}</time> by {{ report.artist }}.
</li>
{%- endfor %}
</ol>
</div>
[…]
</div>
Isn’t it wonderful how shortly we have been in a position to fetch and output knowledge? Try the demo beneath to see how Alpine populates the listing with outcomes.
Trace: You don’t see any Nunjucks code on this CodePen, as a result of 11ty doesn’t run within the browser. I’ve simply copied and pasted the rendered HTML of the primary web page.
See the Pen [Pagination + Filter with Alpine.js Step 1](https://codepen.io/smashingmag/pen/abEWRMY) by Manuel Matuzovic.
See the Pen Pagination + Filter with Alpine.js Step 1 by Manuel Matuzovic.You’ll be able to obtain rather a lot by utilizing Alpine’s directives, however sooner or later relying solely on attributes can get messy. That’s why I’ve determined to maneuver the info and a few of the logic right into a separate Alpine part object.
Right here’s how that works: As an alternative of passing knowledge straight, we now reference a part utilizing x-data. The remainder is just about an identical: Outline a variable to carry our knowledge, then fetch our JSON file within the initialization part. Nevertheless, we don’t do this inside an attribute, however inside a script tag or file as a substitute:
<div class="assortment" x-data="assortment">
[…]
</div>
[…]
<script>
doc.addEventListener('alpine:init', () => {
Alpine.knowledge('assortment', () => ({
information: [],
async getRecords() {
this.information = await (await fetch('/_data/information.json')).json();
},
init() {
this.getRecords();
}
}))
})
</script>
<script src="https://unpkg.com/alpinejs@3.9.1/dist/cdn.min.js" integrity="sha384-mDHH3kdyMS0F6QcfHCxEgPMMjssTurzucc7Jct3g1GOfB4p7PxJuugPP1NOLvE7I" crossorigin="nameless"></script>
Wanting on the earlier CodePen, you’ve in all probability observed that we now have a reproduction set of information. That’s as a result of our static 11ty listing remains to be there. Alpine has a directive that tells it to disregard sure DOM components. I don’t know if that is really essential right here, but it surely’s a pleasant manner of marking these undesirable components. So, we add the x-ignore directive on our 11ty listing gadgets, and we add a category to the html factor when the info has loaded after which use the category and the attribute to cover these listing gadgets in CSS:
<model>
.alpine [x-ignore] {
show: none;
}
</model>
[…]
{%- for report in pagination.gadgets %}
<li x-ignore>
<sturdy>{{ report.title }}</sturdy>
Launched in <time datetime="{{ report.yr }}">{{ report.yr }}</time> by {{ report.artist }}.
</li>
{%- endfor %}
[…]
<script>
doc.addEventListener('alpine:init', () => {
Alpine.knowledge('assortment', () => ({
information: [],
async getRecords() {
this.information = await (await fetch('/_data/information.json')).json();
doc.documentElement.classList.add('alpine');
},
init() {
this.getRecords();
}
}))
})
</script>
11ty knowledge is hidden, outcomes are coming from Alpine, however the pagination is just not useful in the intervening time:
See the Pen [Pagination + Filter with Alpine.js Step 2](https://codepen.io/smashingmag/pen/eYyWQOe) by Manuel Matuzovic.
See the Pen Pagination + Filter with Alpine.js Step 2 by Manuel Matuzovic.Earlier than we add filters, let’s paginate our knowledge. 11ty did us the favor of dealing with all of the logic for us, however now we’ve to do it on our personal. With the intention to cut up our knowledge throughout a number of pages, we want the next:
the variety of gadgets per web page (itemsPerPage),
the present web page (currentPage),
the overall variety of pages (numOfPages),
a dynamic, paged subset of the entire knowledge (web page).
doc.addEventListener('alpine:init', () => {
Alpine.knowledge('assortment', () => ({
information: [],
itemsPerPage: 5,
currentPage: 0,
numOfPages: // complete variety of pages,
web page: // paged gadgets
async getRecords() {
this.information = await (await fetch('/_data/information.json')).json();
doc.documentElement.classList.add('alpine');
},
init() {
this.getRecords();
}
}))
})
The variety of gadgets per web page is a hard and fast worth (5), and the present web page begins with 0. We get the variety of pages by dividing the overall variety of gadgets by the variety of gadgets per web page:
numOfPages() {
return Math.ceil(this.information.size / this.itemsPerPage)
// 7 / 5 = 1.4
// Math.ceil(7 / 5) = 2
},
The simplest manner for me to get the gadgets per web page was to make use of the slice() methodology in JavaScript and take out the slice of the dataset that I would like for the present web page:
web page() {
return this.information.slice(this.currentPage * this.itemsPerPage, (this.currentPage + 1) * this.itemsPerPage)
// this.currentPage * this.itemsPerPage, (this.currentPage + 1) * this.itemsPerPage
// Web page 1: 0 * 5, (0 + 1) * 5 (=> slice(0, 5);)
// Web page 2: 1 * 5, (1 + 1) * 5 (=> slice(5, 10);)
// Web page 3: 2 * 5, (2 + 1) * 5 (=> slice(10, 15);)
}
To solely show the gadgets for the present web page, we’ve to adapt the for loop to iterate over web page as a substitute of information:
<ol class="information">
<template x-for="report in web page">
<li>
<sturdy x-text="report.title"></sturdy>
Launched in <time :datetime="report.yr" x-text="report.yr"></time> by <span x-text="report.artist"></span>.
</li>
</template>
</ol>
We now have a web page, however no hyperlinks that permit us to leap from web page to web page. Similar to earlier, we use the template factor and the x-for directive to show our web page hyperlinks:
<ol class="pages">
<template x-for="idx in numOfPages">
<li>
<a :href="https://smashingmagazine.com/2022/04/accessible-filterable-paginated-list-11ty-alpinejs/`/${idx}`" x-text="`Web page ${idx}`" :aria-current="idx === currentPage + 1 ? 'web page' : false" @click on.stop="currentPage = idx - 1"></a>
</li>
</template>
{% for page_entry in pagination.pages %}
<li x-ignore>
[…]
</li>
{% endfor %}
</ol>
Since we don’t need to reload the entire web page anymore, we put a click on occasion on every hyperlink, stop the default click on habits, and alter the present web page quantity on click on:
<a href="https://smashingmagazine.com/" @click on.stop="currentPage = idx - 1"></a>
Right here’s what that appears like within the browser. (I’ve added extra entries to the JSON file. You’ll be able to download it on GitHub.)
See the Pen [Pagination + Filter with Alpine.js Step 3](https://codepen.io/smashingmag/pen/GRymwjg) by Manuel Matuzovic.
See the Pen Pagination + Filter with Alpine.js Step 3 by Manuel Matuzovic.Filtering
I need to have the ability to filter the listing by artist and by decade.
We add two choose components wrapped in a fieldset to our part, and we put a x-model directive on every of them. x-model permits us to bind the worth of an enter factor to Alpine knowledge:
<fieldset class="filters">
<legend>Filter by</legend>
<label for="artist">Artist</label>
<choose id="artist" x-model="filters.artist">
<choice worth="">All</choice>
</choose>
<label for="decade">Decade</label>
<choose id="decade" x-model="filters.yr">
<choice worth="">All</choice>
</choose>
</fieldset>
After all, we additionally should create these knowledge fields in our Alpine part:
doc.addEventListener('alpine:init', () => {
Alpine.knowledge('assortment', () => ({
filters: {
yr: '',
artist: '',
},
information: [],
itemsPerPage: 5,
currentPage: 0,
numOfPages() {
return Math.ceil(this.information.size / this.itemsPerPage)
},
web page() {
return this.information.slice(this.currentPage * this.itemsPerPage, (this.currentPage + 1) * this.itemsPerPage)
},
async getRecords() {
this.information = await (await fetch('/_data/information.json')).json();
doc.documentElement.classList.add('alpine');
},
init() {
this.getRecords();
}
}))
})
If we alter the chosen worth in every choose, filters.artist and filters.yr will replace mechanically. You’ll be able to attempt it right here with some dummy knowledge I’ve added manually:
See the Pen [Pagination + Filter with Alpine.js Step 4](https://codepen.io/smashingmag/pen/GGRymwEp) by Manuel Matuzovic.
See the Pen Pagination + Filter with Alpine.js Step 4 by Manuel Matuzovic.Now we’ve choose components, and we’ve sure the info to our part. The subsequent step is to populate every choose dynamically with artists and a long time respectively. For that we take our information array and manipulate the info a bit:
doc.addEventListener('alpine:init', () => {
Alpine.knowledge('assortment', () => ({
artists: [],
a long time: [],
// […]
async getRecords() {
this.information = await (await fetch('/_data/information.json')).json();
this.artists = [...new Set(this.records.map(record => record.artist))].type();
this.a long time = [...new Set(this.records.map(record => record.year.toString().slice(0, -1)))].type();
doc.documentElement.classList.add('alpine');
},
// […]
}))
})
This appears to be like wild, and I’m certain that I’ll neglect what’s happening right here actual quickly, however what this code does is that it takes the array of objects and turns it into an array of strings (map()), it makes certain that every entry is exclusive (that’s what [...new Set()] does right here) and types the array alphabetically (type()). For the last decade’s array, I’m moreover slicing off the final digit of the yr as a result of I don’t need this filter to be too granular. Filtering by decade is nice sufficient.
Subsequent, we populate the artist and decade choose components, once more utilizing the template factor and the x-for directive:
<label for="artist">Artist</label>
<choose id="artist" x-model="filters.artist">
<choice worth="">All</choice>
<template x-for="artist in artists">
<choice x-text="artist"></choice>
</template>
</choose>
<label for="decade">Decade</label>
<choose id="decade" x-model="filters.yr">
<choice worth="">All</choice>
<template x-for="yr in a long time">
<choice :worth="yr" x-text="`${yr}0`"></choice>
</template>
</choose>
Strive it your self in demo 5 on Codepen.
See the Pen [Pagination + Filter with Alpine.js Step 5](https://codepen.io/smashingmag/pen/OJzmaZb) by Manuel Matuzovic.
See the Pen Pagination + Filter with Alpine.js Step 5 by Manuel Matuzovic.We’ve efficiently populated the choose components with knowledge from our JSON file. To lastly filter the info, we undergo all information, we verify whether or not a filter is ready. If that’s the case, we verify that the respective discipline of the report corresponds to the chosen worth of the filter. If not, we filter this report out. We’re left with a filtered array that matches the standards:
get filteredRecords() {
const filtered = this.information.filter((merchandise) => {
for (var key on this.filters) {
if (this.filters[key] === '') {
proceed
}
if(!String(merchandise[key]).contains(this.filters[key])) {
return false
}
}
return true
});
return filtered
}
For this to take impact we’ve to adapt our numOfPages() and web page() features to make use of solely the filtered information:
numOfPages() {
return Math.ceil(this.filteredRecords.size / this.itemsPerPage)
},
web page() {
return this.filteredRecords.slice(this.currentPage * this.itemsPerPage, (this.currentPage + 1) * this.itemsPerPage)
},
See the Pen [Pagination + Filter with Alpine.js Step 6](https://codepen.io/smashingmag/pen/GRymwQZ) by Manuel Matuzovic.
See the Pen Pagination + Filter with Alpine.js Step 6 by Manuel Matuzovic.Three issues left to do:
repair a bug;
cover the shape;
replace the standing message.
Bug Repair: Watching a Element Property
While you open the primary web page, click on on web page 6, then choose “1990” — you don’t see any outcomes. That’s as a result of our filter thinks that we’re nonetheless on web page 6, however 1) we’re really on web page 1, and a couple of) there is no such thing as a web page 6 with “1990” lively. We will repair that by resetting the currentPage when the consumer modifications one of many filters. To observe modifications within the filter object, we are able to use a so-called magic methodology:
init() {
this.getRecords();
this.$watch('filters', filter => this.currentPage = 0);
}
Each time the filter property modifications, the currentPage can be set to 0.
Hiding the Kind
Because the filters solely work with JavaScript enabled and functioning, we must always cover the entire type when that’s not the case. We will use the .alpine class we created earlier for that:
<fieldset class="filters" hidden>
[…]
</fieldset>
.filters {
show: block;
}
html:not(.alpine) .filters {
visibility: hidden;
}
I’m utilizing visibility: hidden as a substitute of hidden solely to keep away from content material shifting whereas Alpine remains to be loading.
Speaking Modifications
The standing message at first of our listing nonetheless reads “Exhibiting 7 information”, however this doesn’t change when the consumer modifications the web page or filters the listing. There are two issues we’ve to do to make the paragraph dynamic: bind knowledge to it and talk modifications to assistive know-how (a display screen reader, e.g.).
First, we bind knowledge to the output factor within the paragraph that modifications primarily based on the present web page and filter:
<p id="message">Exhibiting <output x-text="message">{{ information.size }} information</output></p>
Alpine.knowledge('assortment', () => ({
message() {
return `${this.filteredRecords.size} information`;
},
// […]
Subsequent, we need to talk to display screen readers that the content material on the web page has modified. There are no less than two methods of doing that:
We might flip a component right into a so-called live region utilizing the aria-live attribute. A dwell area is a component that asserts its content material to display screen readers each time it modifications.
<div aria-live="well mannered">Dynamic modifications can be introduced</div>
In our case, we don’t should do something, as a result of we’re already utilizing the output factor (bear in mind?) which is an implicit dwell area by default.
<p id="message">Exhibiting <output x-text="message">{{ information.size }} information</output></p>
“The <output> HTML factor is a container factor into which a website or app can inject the outcomes of a calculation or the result of a consumer motion.”
Supply: <output>: The Output Element, MDN Net Docs
We might make the area focusable and transfer the main focus to the area when its content material modifications. Because the area is labelled, its identify and function can be introduced when that occurs.
<div aria-labelledby="message" function="area" tabindex="-1" x-ref="area">
We will reference the area utilizing the x-ref directive.
<a @click on.stop="currentPage = idx - 1; $nextTick(() => { $refs.area.focus(); $refs.area.scrollIntoView(); });" :href="https://smashingmagazine.com/2022/04/accessible-filterable-paginated-list-11ty-alpinejs/`/${idx}`" x-text="`Web page ${idx}`" :aria-current="idx === currentPage + 1 ? 'web page' : false">
I’ve determined to do each:
When customers filter the web page, we replace the dwell area, however we don’t transfer focus.
After they change the web page, we transfer focus to the listing.
That’s it. Right here’s the final result:
See the Pen [Pagination + Filter with Alpine.js Step 7](https://codepen.io/smashingmag/pen/zYpwMXX) by Manuel Matuzovic.
See the Pen Pagination + Filter with Alpine.js Step 7 by Manuel Matuzovic.Word: While you filter by artist, and the standing message reveals “1 information”, and also you filter once more by one other artist, additionally with only one report, the content material of the output factor doesn’t change, and nothing is reported to display screen readers. This may be seen as a bug or as a characteristic to cut back redundant bulletins. You’ll have to check this with customers.
What’s Subsequent?
What I did right here might sound redundant, however should you’re like me, and also you don’t have sufficient belief in JavaScript, it’s well worth the effort. And should you have a look at the final CodePen or the complete code on GitHub, it really wasn’t that a lot additional work. Minimal frameworks like Alpine.js make it very easy to progressively improve static parts and make them reactive.
I’m fairly proud of the end result, however there are a number of extra issues that might be improved:
The pagination might be smarter (most variety of pages, earlier and subsequent hyperlinks, and so forth).
Let customers decide the variety of gadgets per web page.
Sorting can be a pleasant characteristic.
Working with the historical past API can be nice.
Content material shifting might be improved.
The answer wants consumer testing and browser/display screen reader testing.
P.S. Sure, I do know, Alpine produces invalid HTML with its customized x- attribute syntax. That hurts me as a lot because it hurts you, however so long as it doesn’t have an effect on customers, I can dwell with that. 🙂
P.S.S. Particular due to Scott, Søren, Thain, David, Saptak and Christian for his or her suggestions.
Additional Assets
(vf, yk, il)
-
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:
<h2>Beastie Boys Anthology</h2>
<p>The Sounds of Science is the first anthology album by American rap rock group Beastie Boys composed of greatest hits, B-sides, and previously unreleased tracks.</p>
<ol> <li>Beastie Boys</li> <li>Slow And Low</li> <li>Shake Your Rump</li> <li>Gratitude</li> <li>Skills To Pay The Bills</li> <li>Root Down</li> <li>Believe Me</li> …
</ol>
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:
<div x-data="{ open: false }"> <ol> <li>Beastie Boys</li> <li>Slow And Low</li> … </ol>
</div> <script src="https://unpkg.com/alpinejs@3.9.1/dist/cdn.min.js" integrity="sha384-mDHH3kdyMS0F6QcfHCxEgPMMjssTurzucc7Jct3g1GOfB4p7PxJuugPP1NOLvE7I" crossorigin="anonymous"></script>
We can use the open property for the x-show directive, which determines whether or not an element is visible:
<div x-data="{ open: false }"> <ol x-show="open"> <li>Beastie Boys</li> <li>Slow And Low</li> … </ol>
</div>
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:
<div x-data="{ open: false }"> <button @click="open = !open">Tracklist</button> <ol x-show="open"> <li>Beastie Boys</li> <li>Slow And Low</li> … </ol>
</div>
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:
<button @click="open = !open" :aria-expanded="open"> Tracklist
</button>
We can also create a semantic connection between the button and the list using aria-controls for screen readers that support the attribute:
<button @click="open = ! open" :aria-expanded="open" aria-controls="tracklist"> Tracklist
</button>
<ol x-show="open" id="tracklist"> …
</ol>
Here’s the final result:
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:
cd Sites # or wherever you want to save the project
mkdir myrecordcollection # pick any name
cd myrecordcollection
Then create a package.json file and install eleventy:
npm init -y
npm install @11ty/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:
touch index.njk
mkdir _data
touch _data/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:
[ { "artist": "Akne Kid Joe", "title": "Die große Palmöllüge", "year": 2020 }, { "artist": "Bring me the Horizon", "title": "Post Human: Survial Horror", "year": 2020 }, { "artist": "Idles", "title": "Joy as an Act of Resistance", "year": 2018 }, { "artist": "Beastie Boys", "title": "Licensed to Ill", "year": 1986 }, { "artist": "Beastie Boys", "title": "Paul's Boutique", "year": 1989 }, { "artist": "Beastie Boys", "title": "Check Your Head", "year": 1992 }, { "artist": "Beastie Boys", "title": "Ill Communication", "year": 1994 }
]
Finally, let’s add a basic HTML structure to the index.njk file and start eleventy:
<!DOCTYPE html>
<html lang="en">
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>My Record Collection</title>
</head>
<body> <h1>My Record Collection</h1> </body>
</html>
By running the following command you should be able to access the site at http://localhost:8080:
eleventy --serve
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:
<div class="collection"> <ol> {% for record in records %} <li> <strong>{{ record.title }}</strong>
Released in <time datetime="{{ record.year }}">{{ record.year }}</time> by {{ record.artist }}. </li> {% endfor %} </ol>
</div>
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:
---
pagination: data: records size: 5
---
<!DOCTYPE html>
<html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>My Record Collection</title> </head> <body> <h1>My Record Collection</h1> <div class="collection"> <p id="message">Showing <output>{{ records.length }} records</output></p> <div aria-labelledby="message" role="region"> <ol class="records"> {% for record in pagination.items %} <li> <strong>{{ record.title }}</strong>
Released in <time datetime="{{ record.year }}">{{ record.year }}</time> by {{ record.artist }}. </li> {% endfor %} </ol> </div> </div> </body>
</html>
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:
<nav aria-label="Select a page"> <ol class="pages"> {% for page_entry in pagination.pages %} {%- set page_url = pagination.hrefs[loop.index0] -%} <li> <a href="{{ page_url }}"{% if page.url == page_url %} aria-current="page"{% endif %}> Page {{ loop.index }} </a> </li> {% endfor %} </ol>
</nav>
Finally, let’s add some basic CSS to improve the styling:
body { font-family: sans-serif; line-height: 1.5;
} ol { list-style: none; margin: 0; padding: 0;
} .records > * + * { margin-top: 2rem;
} h2 { margin-bottom: 0;
} nav { margin-top: 1.5rem;
} .pages { display: flex; flex-wrap: wrap; gap: 0.5rem;
} .pages a { border: 1px solid #000000; padding: 0.5rem; border-radius: 5px; display: flex; text-decoration: none;
} .pages a:where([aria-current]) { background-color: #000000; color: #ffffff;
} .pages a:where(:focus, :hover) { background-color: #6c6c6c; color: #ffffff;
}
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:
<script src="https://unpkg.com/alpinejs@3.9.1/dist/cdn.min.js" integrity="sha384-mDHH3kdyMS0F6QcfHCxEgPMMjssTurzucc7Jct3g1GOfB4p7PxJuugPP1NOLvE7I" crossorigin="anonymous"></script>
</body>
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:
module.exports = function(eleventyConfig) { eleventyConfig.addPassthroughCopy("_data");
};
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:
<div class="collection" x-data="{ records: [] }">
</div>
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:
<div class="collection" x-init="records = await (await fetch('/_data/records.json')).json()" x-data="{ records: [] }"> <div x-text="records"></div> […]
</div>
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:
<template x-for="record in records"> <li> <strong x-text="record.title"></strong>
Released in <time :datetime="record.year" x-text="record.year"></time> by <span x-text="record.artist"></span>. </li>
</template>
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:
<div class="collection" x-init="records = await (await fetch('/_data/records.json')).json()" x-data="{ records: [] }"> <p id="message">Showing <output>{{ records.length }} records</output></p> <div aria-labelledby="message" role="region"> <ol class="records"> <template x-for="record in records"> <li> <strong x-text="record.title"></strong>
Released in <time :datetime="record.year" x-text="record.year"></time> by <span x-text="record.artist"></span>. </li> </template> {%- for record in pagination.items %} <li> <strong>{{ record.title }}</strong>
Released in <time datetime="{{ record.year }}">{{ record.year }}</time> by {{ record.artist }}. </li> {%- endfor %} </ol> </div> […]
</div>
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.
Hint: You don’t see any Nunjucks code in this CodePen, because 11ty doesn’t run in the browser. I’ve just copied and pasted the rendered HTML of the first page.
See the Pen Pagination + Filter with Alpine.js Step 1 by Manuel Matuzovic.
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:
<div class="collection" x-data="collection"> […]
</div> […] <script> document.addEventListener('alpine:init', () => { Alpine.data('collection', () => ({ records: [], async getRecords() { this.records = await (await fetch('/_data/records.json')).json(); }, init() { this.getRecords(); } })) })
</script> <script src="https://unpkg.com/alpinejs@3.9.1/dist/cdn.min.js" integrity="sha384-mDHH3kdyMS0F6QcfHCxEgPMMjssTurzucc7Jct3g1GOfB4p7PxJuugPP1NOLvE7I" crossorigin="anonymous"></script>
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:
<style> .alpine [x-ignore] { display: none; }
</style> […]
{%- for record in pagination.items %} <li x-ignore> <strong>{{ record.title }}</strong>
Released in <time datetime="{{ record.year }}">{{ record.year }}</time> by {{ record.artist }}. </li>
{%- endfor %}
[…]
<script> document.addEventListener('alpine:init', () => { Alpine.data('collection', () => ({ records: [], async getRecords() { this.records = await (await fetch('/_data/records.json')).json(); document.documentElement.classList.add('alpine'); }, init() { this.getRecords(); } })) })
</script>
11ty data is hidden, results are coming from Alpine, but the pagination is not functional at the moment:
See the Pen Pagination + Filter with Alpine.js Step 2 by Manuel Matuzovic.
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).
document.addEventListener('alpine:init', () => { Alpine.data('collection', () => ({ records: [], itemsPerPage: 5, currentPage: 0, numOfPages: // total number of pages, page: // paged items async getRecords() { this.records = await (await fetch('/_data/records.json')).json(); document.documentElement.classList.add('alpine'); }, init() { this.getRecords(); } }))
})
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:
numOfPages() { return Math.ceil(this.records.length / this.itemsPerPage) // 7 / 5 = 1.4 // Math.ceil(7 / 5) = 2
},
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:
page() { return this.records.slice(this.currentPage * this.itemsPerPage, (this.currentPage + 1) * this.itemsPerPage) // this.currentPage * this.itemsPerPage, (this.currentPage + 1) * this.itemsPerPage // Page 1: 0 * 5, (0 + 1) * 5 (=> slice(0, 5);) // Page 2: 1 * 5, (1 + 1) * 5 (=> slice(5, 10);) // Page 3: 2 * 5, (2 + 1) * 5 (=> slice(10, 15);)
}
To only display the items for the current page, we have to adapt the for loop to iterate over page instead of records:
<ol class="records"> <template x-for="record in page"> <li> <strong x-text="record.title"></strong>
Released in <time :datetime="record.year" x-text="record.year"></time> by <span x-text="record.artist"></span>. </li> </template>
</ol>
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:
<ol class="pages"> <template x-for="idx in numOfPages"> <li> <a :href="`/${idx}`" x-text="`Page ${idx}`" :aria-current="idx === currentPage + 1 ? 'page' : false" @click.prevent="currentPage = idx - 1"></a> </li> </template> {% for page_entry in pagination.pages %} <li x-ignore> […] </li> {% endfor %}
</ol>
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:
<a href="/" @click.prevent="currentPage = idx - 1"></a>
Here’s what that looks like in the browser. (I’ve added more entries to the JSON file. You can download it on GitHub.)
See the Pen Pagination + Filter with Alpine.js Step 3 by Manuel Matuzovic.
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:
<fieldset class="filters"> <legend>Filter by</legend> <label for="artist">Artist</label> <select id="artist" x-model="filters.artist"> <option value="">All</option> </select> <label for="decade">Decade</label> <select id="decade" x-model="filters.year"> <option value="">All</option> </select>
</fieldset>
Of course, we also have to create these data fields in our Alpine component:
document.addEventListener('alpine:init', () => { Alpine.data('collection', () => ({ filters: { year: '', artist: '', }, records: [], itemsPerPage: 5, currentPage: 0, numOfPages() { return Math.ceil(this.records.length / this.itemsPerPage) }, page() { return this.records.slice(this.currentPage * this.itemsPerPage, (this.currentPage + 1) * this.itemsPerPage) }, async getRecords() { this.records = await (await fetch('/_data/records.json')).json(); document.documentElement.classList.add('alpine'); }, init() { this.getRecords(); } }))
})
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:
See the Pen Pagination + Filter with Alpine.js Step 4 by Manuel Matuzovic.
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:
document.addEventListener('alpine:init', () => { Alpine.data('collection', () => ({ artists: [], decades: [], // […] async getRecords() { this.records = await (await fetch('/_data/records.json')).json(); this.artists = [...new Set(this.records.map(record => record.artist))].sort(); this.decades = [...new Set(this.records.map(record => record.year.toString().slice(0, -1)))].sort(); document.documentElement.classList.add('alpine'); }, // […] }))
})
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:
<label for="artist">Artist</label>
<select id="artist" x-model="filters.artist"> <option value="">All</option> <template x-for="artist in artists"> <option x-text="artist"></option> </template>
</select> <label for="decade">Decade</label>
<select id="decade" x-model="filters.year"> <option value="">All</option> <template x-for="year in decades"> <option :value="year" x-text="`${year}0`"></option> </template>
</select>
Try it yourself in demo 5 on Codepen.
See the Pen Pagination + Filter with Alpine.js Step 5 by Manuel Matuzovic.
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:
get filteredRecords() { const filtered = this.records.filter((item) => { for (var key in this.filters) { if (this.filters[key] === '') { continue } if(!String(item[key]).includes(this.filters[key])) { return false } } return true }); return filtered
}
For this to take effect we have to adapt our numOfPages() and page() functions to use only the filtered records:
numOfPages() { return Math.ceil(this.filteredRecords.length / this.itemsPerPage)
},
page() { return this.filteredRecords.slice(this.currentPage * this.itemsPerPage, (this.currentPage + 1) * this.itemsPerPage)
},
See the Pen Pagination + Filter with Alpine.js Step 6 by Manuel Matuzovic.
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:
init() { this.getRecords(); this.$watch('filters', filter => this.currentPage = 0);
}
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:
<fieldset class="filters" hidden> […]
</fieldset>
.filters { display: block;
} html:not(.alpine) .filters { visibility: hidden;
}
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:
<p id="message">Showing <output x-text="message">{{ records.length }} records</output></p>
Alpine.data('collection', () => ({ message() { return `${this.filteredRecords.length} records`; },
// […]
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.
<div aria-live="polite">Dynamic changes will be announced</div>
In our case, we don’t have to do anything, because we’re already using the output element (remember?) which is an implicit live region by default.
<p id="message">Showing <output x-text="message">{{ records.length }} records</output></p>
“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
We could make the region focusable and move the focus to the region when its content changes. Since the region is labelled, its name and role will be announced when that happens.
<div aria-labelledby="message" role="region" tabindex="-1" x-ref="region">
We can reference the region using the x-ref directive.
<a @click.prevent="currentPage = idx - 1; $nextTick(() => { $refs.region.focus(); $refs.region.scrollIntoView(); });" :href="/${idx}" x-text="Page ${idx}" :aria-current="idx === currentPage + 1 ? 'page' : false">
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:
See the Pen Pagination + Filter with Alpine.js Step 7 by Manuel Matuzovic.
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
-
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:
<h2>Beastie Boys Anthology</h2>
<p>The Sounds of Science is the first anthology album by American rap rock group Beastie Boys composed of greatest hits, B-sides, and previously unreleased tracks.</p>
<ol>
<li>Beastie Boys</li>
<li>Slow And Low</li>
<li>Shake Your Rump</li>
<li>Gratitude</li>
<li>Skills To Pay The Bills</li>
<li>Root Down</li>
<li>Believe Me</li>
…
</ol>
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:
<div x-data="{ open: false }">
<ol>
<li>Beastie Boys</li>
<li>Slow And Low</li>
…
</ol>
</div>
<script src="https://unpkg.com/alpinejs@3.9.1/dist/cdn.min.js" integrity="sha384-mDHH3kdyMS0F6QcfHCxEgPMMjssTurzucc7Jct3g1GOfB4p7PxJuugPP1NOLvE7I" crossorigin="anonymous"></script>
We can use the open property for the x-show directive, which determines whether or not an element is visible:
<div x-data="{ open: false }">
<ol x-show="open">
<li>Beastie Boys</li>
<li>Slow And Low</li>
…
</ol>
</div>
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:
<div x-data="{ open: false }">
<button @click="open = !open">Tracklist</button>
<ol x-show="open">
<li>Beastie Boys</li>
<li>Slow And Low</li>
…
</ol>
</div>
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:
<button @click="open = !open" :aria-expanded="open">
Tracklist
</button>
We can also create a semantic connection between the button and the list using aria-controls for screen readers that support the attribute:
<button @click="open = ! open" :aria-expanded="open" aria-controls="tracklist">
Tracklist
</button>
<ol x-show="open" id="tracklist">
…
</ol>
Here’s the final result:
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:
cd Sites # or wherever you want to save the project
mkdir myrecordcollection # pick any name
cd myrecordcollection
Then create a package.json file and install eleventy:
npm init -y
npm install @11ty/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:
touch index.njk
mkdir _data
touch _data/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:
[
{
"artist": "Akne Kid Joe",
"title": "Die große Palmöllüge",
"year": 2020
},
{
"artist": "Bring me the Horizon",
"title": "Post Human: Survial Horror",
"year": 2020
},
{
"artist": "Idles",
"title": "Joy as an Act of Resistance",
"year": 2018
},
{
"artist": "Beastie Boys",
"title": "Licensed to Ill",
"year": 1986
},
{
"artist": "Beastie Boys",
"title": "Paul's Boutique",
"year": 1989
},
{
"artist": "Beastie Boys",
"title": "Check Your Head",
"year": 1992
},
{
"artist": "Beastie Boys",
"title": "Ill Communication",
"year": 1994
}
]
Finally, let’s add a basic HTML structure to the index.njk file and start eleventy:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Record Collection</title>
</head>
<body>
<h1>My Record Collection</h1>
</body>
</html>
By running the following command you should be able to access the site at http://localhost:8080:
eleventy --serve
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:
<div class="collection">
<ol>
{% for record in records %}
<li>
<strong>{{ record.title }}</strong>
Released in <time datetime="{{ record.year }}">{{ record.year }}</time> by {{ record.artist }}.
</li>
{% endfor %}
</ol>
</div>
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:
---
pagination:
data: records
size: 5
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Record Collection</title>
</head>
<body>
<h1>My Record Collection</h1>
<div class="collection">
<p id="message">Showing <output>{{ records.length }} records</output></p>
<div aria-labelledby="message" role="region">
<ol class="records">
{% for record in pagination.items %}
<li>
<strong>{{ record.title }}</strong>
Released in <time datetime="{{ record.year }}">{{ record.year }}</time> by {{ record.artist }}.
</li>
{% endfor %}
</ol>
</div>
</div>
</body>
</html>
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:
<nav aria-label="Select a page">
<ol class="pages">
{% for page_entry in pagination.pages %}
{%- set page_url = pagination.hrefs[loop.index0] -%}
<li>
<a href="{{ page_url }}"{% if page.url == page_url %} aria-current="page"{% endif %}>
Page {{ loop.index }}
</a>
</li>
{% endfor %}
</ol>
</nav>
Finally, let’s add some basic CSS to improve the styling:
body {
font-family: sans-serif;
line-height: 1.5;
}
ol {
list-style: none;
margin: 0;
padding: 0;
}
.records > * + * {
margin-top: 2rem;
}
h2 {
margin-bottom: 0;
}
nav {
margin-top: 1.5rem;
}
.pages {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.pages a {
border: 1px solid #000000;
padding: 0.5rem;
border-radius: 5px;
display: flex;
text-decoration: none;
}
.pages a:where([aria-current]) {
background-color: #000000;
color: #ffffff;
}
.pages a:where(:focus, :hover) {
background-color: #6c6c6c;
color: #ffffff;
}
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:
<script src="https://unpkg.com/alpinejs@3.9.1/dist/cdn.min.js" integrity="sha384-mDHH3kdyMS0F6QcfHCxEgPMMjssTurzucc7Jct3g1GOfB4p7PxJuugPP1NOLvE7I" crossorigin="anonymous"></script>
</body>
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:
module.exports = function(eleventyConfig) {
eleventyConfig.addPassthroughCopy("_data");
};
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:
<div class="collection" x-data="{ records: [] }">
</div>
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:
<div class="collection" x-init="records = await (await fetch('/_data/records.json')).json()" x-data="{ records: [] }">
<div x-text="records"></div>
[…]
</div>
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:
<template x-for="record in records">
<li>
<strong x-text="record.title"></strong>
Released in <time :datetime="record.year" x-text="record.year"></time> by <span x-text="record.artist"></span>.
</li>
</template>
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:
<div class="collection" x-init="records = await (await fetch('/_data/records.json')).json()" x-data="{ records: [] }">
<p id="message">Showing <output>{{ records.length }} records</output></p>
<div aria-labelledby="message" role="region">
<ol class="records">
<template x-for="record in records">
<li>
<strong x-text="record.title"></strong>
Released in <time :datetime="record.year" x-text="record.year"></time> by <span x-text="record.artist"></span>.
</li>
</template>
{%- for record in pagination.items %}
<li>
<strong>{{ record.title }}</strong>
Released in <time datetime="{{ record.year }}">{{ record.year }}</time> by {{ record.artist }}.
</li>
{%- endfor %}
</ol>
</div>
[…]
</div>
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.
Hint: You don’t see any Nunjucks code in this CodePen, because 11ty doesn’t run in the browser. I’ve just copied and pasted the rendered HTML of the first page.
See the Pen Pagination + Filter with Alpine.js Step 1 by Manuel Matuzovic.
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:
<div class="collection" x-data="collection">
[…]
</div>
[…]
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('collection', () => ({
records: [],
async getRecords() {
this.records = await (await fetch('/_data/records.json')).json();
},
init() {
this.getRecords();
}
}))
})
</script>
<script src="https://unpkg.com/alpinejs@3.9.1/dist/cdn.min.js" integrity="sha384-mDHH3kdyMS0F6QcfHCxEgPMMjssTurzucc7Jct3g1GOfB4p7PxJuugPP1NOLvE7I" crossorigin="anonymous"></script>
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:
<style>
.alpine [x-ignore] {
display: none;
}
</style>
[…]
{%- for record in pagination.items %}
<li x-ignore>
<strong>{{ record.title }}</strong>
Released in <time datetime="{{ record.year }}">{{ record.year }}</time> by {{ record.artist }}.
</li>
{%- endfor %}
[…]
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('collection', () => ({
records: [],
async getRecords() {
this.records = await (await fetch('/_data/records.json')).json();
document.documentElement.classList.add('alpine');
},
init() {
this.getRecords();
}
}))
})
</script>
11ty data is hidden, results are coming from Alpine, but the pagination is not functional at the moment:
See the Pen Pagination + Filter with Alpine.js Step 2 by Manuel Matuzovic.
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).
document.addEventListener('alpine:init', () => {
Alpine.data('collection', () => ({
records: [],
itemsPerPage: 5,
currentPage: 0,
numOfPages: // total number of pages,
page: // paged items
async getRecords() {
this.records = await (await fetch('/_data/records.json')).json();
document.documentElement.classList.add('alpine');
},
init() {
this.getRecords();
}
}))
})
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:
numOfPages() {
return Math.ceil(this.records.length / this.itemsPerPage)
// 7 / 5 = 1.4
// Math.ceil(7 / 5) = 2
},
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:
page() {
return this.records.slice(this.currentPage * this.itemsPerPage, (this.currentPage + 1) * this.itemsPerPage)
// this.currentPage * this.itemsPerPage, (this.currentPage + 1) * this.itemsPerPage
// Page 1: 0 * 5, (0 + 1) * 5 (=> slice(0, 5);)
// Page 2: 1 * 5, (1 + 1) * 5 (=> slice(5, 10);)
// Page 3: 2 * 5, (2 + 1) * 5 (=> slice(10, 15);)
}
To only display the items for the current page, we have to adapt the for loop to iterate over page instead of records:
<ol class="records">
<template x-for="record in page">
<li>
<strong x-text="record.title"></strong>
Released in <time :datetime="record.year" x-text="record.year"></time> by <span x-text="record.artist"></span>.
</li>
</template>
</ol>
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:
<ol class="pages">
<template x-for="idx in numOfPages">
<li>
<a :href="`/${idx}`" x-text="`Page ${idx}`" :aria-current="idx === currentPage + 1 ? 'page' : false" @click.prevent="currentPage = idx - 1"></a>
</li>
</template>
{% for page_entry in pagination.pages %}
<li x-ignore>
[…]
</li>
{% endfor %}
</ol>
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:
<a href="/" @click.prevent="currentPage = idx - 1"></a>
Here’s what that looks like in the browser. (I’ve added more entries to the JSON file. You can download it on GitHub.)
See the Pen Pagination + Filter with Alpine.js Step 3 by Manuel Matuzovic.
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:
<fieldset class="filters">
<legend>Filter by</legend>
<label for="artist">Artist</label>
<select id="artist" x-model="filters.artist">
<option value="">All</option>
</select>
<label for="decade">Decade</label>
<select id="decade" x-model="filters.year">
<option value="">All</option>
</select>
</fieldset>
Of course, we also have to create these data fields in our Alpine component:
document.addEventListener('alpine:init', () => {
Alpine.data('collection', () => ({
filters: {
year: '',
artist: '',
},
records: [],
itemsPerPage: 5,
currentPage: 0,
numOfPages() {
return Math.ceil(this.records.length / this.itemsPerPage)
},
page() {
return this.records.slice(this.currentPage * this.itemsPerPage, (this.currentPage + 1) * this.itemsPerPage)
},
async getRecords() {
this.records = await (await fetch('/_data/records.json')).json();
document.documentElement.classList.add('alpine');
},
init() {
this.getRecords();
}
}))
})
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:
See the Pen Pagination + Filter with Alpine.js Step 4 by Manuel Matuzovic.
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:
document.addEventListener('alpine:init', () => {
Alpine.data('collection', () => ({
artists: [],
decades: [],
// […]
async getRecords() {
this.records = await (await fetch('/_data/records.json')).json();
this.artists = [...new Set(this.records.map(record => record.artist))].sort();
this.decades = [...new Set(this.records.map(record => record.year.toString().slice(0, -1)))].sort();
document.documentElement.classList.add('alpine');
},
// […]
}))
})
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:
<label for="artist">Artist</label>
<select id="artist" x-model="filters.artist">
<option value="">All</option>
<template x-for="artist in artists">
<option x-text="artist"></option>
</template>
</select>
<label for="decade">Decade</label>
<select id="decade" x-model="filters.year">
<option value="">All</option>
<template x-for="year in decades">
<option :value="year" x-text="`${year}0`"></option>
</template>
</select>
Try it yourself in demo 5 on Codepen.
See the Pen Pagination + Filter with Alpine.js Step 5 by Manuel Matuzovic.
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:
get filteredRecords() {
const filtered = this.records.filter((item) => {
for (var key in this.filters) {
if (this.filters[key] === '') {
continue
}
if(!String(item[key]).includes(this.filters[key])) {
return false
}
}
return true
});
return filtered
}
For this to take effect we have to adapt our numOfPages() and page() functions to use only the filtered records:
numOfPages() {
return Math.ceil(this.filteredRecords.length / this.itemsPerPage)
},
page() {
return this.filteredRecords.slice(this.currentPage * this.itemsPerPage, (this.currentPage + 1) * this.itemsPerPage)
},
See the Pen Pagination + Filter with Alpine.js Step 6 by Manuel Matuzovic.
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:
init() {
this.getRecords();
this.$watch('filters', filter => this.currentPage = 0);
}
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:
<fieldset class="filters" hidden>
[…]
</fieldset>
.filters {
display: block;
}
html:not(.alpine) .filters {
visibility: hidden;
}
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:
<p id="message">Showing <output x-text="message">{{ records.length }} records</output></p>
Alpine.data('collection', () => ({
message() {
return `${this.filteredRecords.length} records`;
},
// […]
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.
<div aria-live="polite">Dynamic changes will be announced</div>
In our case, we don’t have to do anything, because we’re already using the output element (remember?) which is an implicit live region by default.
<p id="message">Showing <output x-text="message">{{ records.length }} records</output></p>
“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
We could make the region focusable and move the focus to the region when its content changes. Since the region is labelled, its name and role will be announced when that happens.
<div aria-labelledby="message" role="region" tabindex="-1" x-ref="region">
We can reference the region using the x-ref directive.
<a @click.prevent="currentPage = idx - 1; $nextTick(() => { $refs.region.focus(); $refs.region.scrollIntoView(); });" :href="/${idx}" x-text="Page ${idx}" :aria-current="idx === currentPage + 1 ? 'page' : false">
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:
See the Pen Pagination + Filter with Alpine.js Step 7 by Manuel Matuzovic.
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
Go to Source of this post
Author Of this post:
Title Of post: How To Build A Progressively Enhanced, Accessible, Filterable And Paginated List
Author Link: {authorlink}
-
Quick summary ↬
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:
<h2>Beastie Boys Anthology</h2>
<p>The Sounds of Science is the first anthology album by American rap rock group Beastie Boys composed of greatest hits, B-sides, and previously unreleased tracks.</p>
<ol>
<li>Beastie Boys</li>
<li>Slow And Low</li>
<li>Shake Your Rump</li>
<li>Gratitude</li>
<li>Skills To Pay The Bills</li>
<li>Root Down</li>
<li>Believe Me</li>
…
</ol>
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:
<div x-data="{ open: false }">
<ol>
<li>Beastie Boys</li>
<li>Slow And Low</li>
…
</ol>
</div>
<script src="https://unpkg.com/alpinejs@3.9.1/dist/cdn.min.js" integrity="sha384-mDHH3kdyMS0F6QcfHCxEgPMMjssTurzucc7Jct3g1GOfB4p7PxJuugPP1NOLvE7I" crossorigin="anonymous"></script>
We can use the open property for the x-show directive, which determines whether or not an element is visible:
<div x-data="{ open: false }">
<ol x-show="open">
<li>Beastie Boys</li>
<li>Slow And Low</li>
…
</ol>
</div>
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:
<div x-data="{ open: false }">
<button @click="open = !open">Tracklist</button>
<ol x-show="open">
<li>Beastie Boys</li>
<li>Slow And Low</li>
…
</ol>
</div>
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:
<button @click="open = !open" :aria-expanded="open">
Tracklist
</button>
We can also create a semantic connection between the button and the list using aria-controls for screen readers that support the attribute:
<button @click="open = ! open" :aria-expanded="open" aria-controls="tracklist">
Tracklist
</button>
<ol x-show="open" id="tracklist">
…
</ol>
Here’s the final result:
See the Pen [Simple disclosure widget with Alpine.js](https://codepen.io/smashingmag/pen/xxpdzNz) by Manuel Matuzovic.
See the Pen Simple disclosure widget with Alpine.js by Manuel Matuzovic.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.
Anyone else here also a fan of vinyl records? 😉 (Large preview)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:
cd Sites # or wherever you want to save the project
mkdir myrecordcollection # pick any name
cd myrecordcollection
Then create a package.json file and install eleventy:
npm init -y
npm install @11ty/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:
touch index.njk
mkdir _data
touch _data/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:
(Large preview)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:
[
{
"artist": "Akne Kid Joe",
"title": "Die große Palmöllüge",
"year": 2020
},
{
"artist": "Bring me the Horizon",
"title": "Post Human: Survial Horror",
"year": 2020
},
{
"artist": "Idles",
"title": "Joy as an Act of Resistance",
"year": 2018
},
{
"artist": "Beastie Boys",
"title": "Licensed to Ill",
"year": 1986
},
{
"artist": "Beastie Boys",
"title": "Paul's Boutique",
"year": 1989
},
{
"artist": "Beastie Boys",
"title": "Check Your Head",
"year": 1992
},
{
"artist": "Beastie Boys",
"title": "Ill Communication",
"year": 1994
}
]
Finally, let’s add a basic HTML structure to the index.njk file and start eleventy:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Record Collection</title>
</head>
<body>
<h1>My Record Collection</h1>
</body>
</html>
By running the following command you should be able to access the site at http://localhost:8080:
eleventy --serve
Eleventy running on port :8080. The site just shows the heading ‘My Record Collection’. (Large preview)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:
<div class="collection">
<ol>
{% for record in records %}
<li>
<strong>{{ record.title }}</strong>
Released in <time datetime="{{ record.year }}">{{ record.year }}</time> by {{ record.artist }}.
</li>
{% endfor %}
</ol>
</div>
7 Records listed, each with their title, artist and release date. (Large preview)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:
---
pagination:
data: records
size: 5
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Record Collection</title>
</head>
<body>
<h1>My Record Collection</h1>
<div class="collection">
<p id="message">Showing <output>{{ records.length }} records</output></p>
<div aria-labelledby="message" role="region">
<ol class="records">
{% for record in pagination.items %}
<li>
<strong>{{ record.title }}</strong>
Released in <time datetime="{{ record.year }}">{{ record.year }}</time> by {{ record.artist }}.
</li>
{% endfor %}
</ol>
</div>
</div>
</body>
</html>
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:
<nav aria-label="Select a page">
<ol class="pages">
{% for page_entry in pagination.pages %}
{%- set page_url = pagination.hrefs[loop.index0] -%}
<li>
<a href="https://smashingmagazine.com/2022/04/accessible-filterable-paginated-list-11ty-alpinejs/{{ page_url }}"{% if page.url == page_url %} aria-current="page"{% endif %}>
Page {{ loop.index }}
</a>
</li>
{% endfor %}
</ol>
</nav>
Finally, let’s add some basic CSS to improve the styling:
body {
font-family: sans-serif;
line-height: 1.5;
}
ol {
list-style: none;
margin: 0;
padding: 0;
}
.records > * + * {
margin-top: 2rem;
}
h2 {
margin-bottom: 0;
}
nav {
margin-top: 1.5rem;
}
.pages {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.pages a {
border: 1px solid #000000;
padding: 0.5rem;
border-radius: 5px;
display: flex;
text-decoration: none;
}
.pages a:where([aria-current]) {
background-color: #000000;
color: #ffffff;
}
.pages a:where(:focus, :hover) {
background-color: #6c6c6c;
color: #ffffff;
}
(Large preview)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.
More after jump! Continue reading below ↓
Meet Smashing Email Newsletter with useful tips on front-end, design & UX. Subscribe and get “Smart Interface Design Checklists” — a free PDF deck with 150+ questions to ask yourself when designing and building almost anything.
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:
<script src="https://unpkg.com/alpinejs@3.9.1/dist/cdn.min.js" integrity="sha384-mDHH3kdyMS0F6QcfHCxEgPMMjssTurzucc7Jct3g1GOfB4p7PxJuugPP1NOLvE7I" crossorigin="anonymous"></script>
</body>
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:
module.exports = function(eleventyConfig) {
eleventyConfig.addPassthroughCopy("_data");
};
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:
<div class="collection" x-data="{ records: [] }">
</div>
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:
<div class="collection" x-init="records = await (await fetch('/_data/records.json')).json()" x-data="{ records: [] }">
<div x-text="records"></div>
[…]
</div>
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:
<template x-for="record in records">
<li>
<strong x-text="record.title"></strong>
Released in <time :datetime="record.year" x-text="record.year"></time> by <span x-text="record.artist"></span>.
</li>
</template>
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:
<div class="collection" x-init="records = await (await fetch('/_data/records.json')).json()" x-data="{ records: [] }">
<p id="message">Showing <output>{{ records.length }} records</output></p>
<div aria-labelledby="message" role="region">
<ol class="records">
<template x-for="record in records">
<li>
<strong x-text="record.title"></strong>
Released in <time :datetime="record.year" x-text="record.year"></time> by <span x-text="record.artist"></span>.
</li>
</template>
{%- for record in pagination.items %}
<li>
<strong>{{ record.title }}</strong>
Released in <time datetime="{{ record.year }}">{{ record.year }}</time> by {{ record.artist }}.
</li>
{%- endfor %}
</ol>
</div>
[…]
</div>
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.
Hint: You don’t see any Nunjucks code in this CodePen, because 11ty doesn’t run in the browser. I’ve just copied and pasted the rendered HTML of the first page.
See the Pen [Pagination + Filter with Alpine.js Step 1](https://codepen.io/smashingmag/pen/abEWRMY) by Manuel Matuzovic.
See the Pen Pagination + Filter with Alpine.js Step 1 by Manuel Matuzovic.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:
<div class="collection" x-data="collection">
[…]
</div>
[…]
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('collection', () => ({
records: [],
async getRecords() {
this.records = await (await fetch('/_data/records.json')).json();
},
init() {
this.getRecords();
}
}))
})
</script>
<script src="https://unpkg.com/alpinejs@3.9.1/dist/cdn.min.js" integrity="sha384-mDHH3kdyMS0F6QcfHCxEgPMMjssTurzucc7Jct3g1GOfB4p7PxJuugPP1NOLvE7I" crossorigin="anonymous"></script>
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:
<style>
.alpine [x-ignore] {
display: none;
}
</style>
[…]
{%- for record in pagination.items %}
<li x-ignore>
<strong>{{ record.title }}</strong>
Released in <time datetime="{{ record.year }}">{{ record.year }}</time> by {{ record.artist }}.
</li>
{%- endfor %}
[…]
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('collection', () => ({
records: [],
async getRecords() {
this.records = await (await fetch('/_data/records.json')).json();
document.documentElement.classList.add('alpine');
},
init() {
this.getRecords();
}
}))
})
</script>
11ty data is hidden, results are coming from Alpine, but the pagination is not functional at the moment:
See the Pen [Pagination + Filter with Alpine.js Step 2](https://codepen.io/smashingmag/pen/eYyWQOe) by Manuel Matuzovic.
See the Pen Pagination + Filter with Alpine.js Step 2 by Manuel Matuzovic.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).
document.addEventListener('alpine:init', () => {
Alpine.data('collection', () => ({
records: [],
itemsPerPage: 5,
currentPage: 0,
numOfPages: // total number of pages,
page: // paged items
async getRecords() {
this.records = await (await fetch('/_data/records.json')).json();
document.documentElement.classList.add('alpine');
},
init() {
this.getRecords();
}
}))
})
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:
numOfPages() {
return Math.ceil(this.records.length / this.itemsPerPage)
// 7 / 5 = 1.4
// Math.ceil(7 / 5) = 2
},
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:
page() {
return this.records.slice(this.currentPage * this.itemsPerPage, (this.currentPage + 1) * this.itemsPerPage)
// this.currentPage * this.itemsPerPage, (this.currentPage + 1) * this.itemsPerPage
// Page 1: 0 * 5, (0 + 1) * 5 (=> slice(0, 5);)
// Page 2: 1 * 5, (1 + 1) * 5 (=> slice(5, 10);)
// Page 3: 2 * 5, (2 + 1) * 5 (=> slice(10, 15);)
}
To only display the items for the current page, we have to adapt the for loop to iterate over page instead of records:
<ol class="records">
<template x-for="record in page">
<li>
<strong x-text="record.title"></strong>
Released in <time :datetime="record.year" x-text="record.year"></time> by <span x-text="record.artist"></span>.
</li>
</template>
</ol>
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:
<ol class="pages">
<template x-for="idx in numOfPages">
<li>
<a :href="https://smashingmagazine.com/2022/04/accessible-filterable-paginated-list-11ty-alpinejs/`/${idx}`" x-text="`Page ${idx}`" :aria-current="idx === currentPage + 1 ? 'page' : false" @click.prevent="currentPage = idx - 1"></a>
</li>
</template>
{% for page_entry in pagination.pages %}
<li x-ignore>
[…]
</li>
{% endfor %}
</ol>
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:
<a href="https://smashingmagazine.com/" @click.prevent="currentPage = idx - 1"></a>
Here’s what that looks like in the browser. (I’ve added more entries to the JSON file. You can download it on GitHub.)
See the Pen [Pagination + Filter with Alpine.js Step 3](https://codepen.io/smashingmag/pen/GRymwjg) by Manuel Matuzovic.
See the Pen Pagination + Filter with Alpine.js Step 3 by Manuel Matuzovic.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:
<fieldset class="filters">
<legend>Filter by</legend>
<label for="artist">Artist</label>
<select id="artist" x-model="filters.artist">
<option value="">All</option>
</select>
<label for="decade">Decade</label>
<select id="decade" x-model="filters.year">
<option value="">All</option>
</select>
</fieldset>
Of course, we also have to create these data fields in our Alpine component:
document.addEventListener('alpine:init', () => {
Alpine.data('collection', () => ({
filters: {
year: '',
artist: '',
},
records: [],
itemsPerPage: 5,
currentPage: 0,
numOfPages() {
return Math.ceil(this.records.length / this.itemsPerPage)
},
page() {
return this.records.slice(this.currentPage * this.itemsPerPage, (this.currentPage + 1) * this.itemsPerPage)
},
async getRecords() {
this.records = await (await fetch('/_data/records.json')).json();
document.documentElement.classList.add('alpine');
},
init() {
this.getRecords();
}
}))
})
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:
See the Pen [Pagination + Filter with Alpine.js Step 4](https://codepen.io/smashingmag/pen/GGRymwEp) by Manuel Matuzovic.
See the Pen Pagination + Filter with Alpine.js Step 4 by Manuel Matuzovic.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:
document.addEventListener('alpine:init', () => {
Alpine.data('collection', () => ({
artists: [],
decades: [],
// […]
async getRecords() {
this.records = await (await fetch('/_data/records.json')).json();
this.artists = [...new Set(this.records.map(record => record.artist))].sort();
this.decades = [...new Set(this.records.map(record => record.year.toString().slice(0, -1)))].sort();
document.documentElement.classList.add('alpine');
},
// […]
}))
})
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:
<label for="artist">Artist</label>
<select id="artist" x-model="filters.artist">
<option value="">All</option>
<template x-for="artist in artists">
<option x-text="artist"></option>
</template>
</select>
<label for="decade">Decade</label>
<select id="decade" x-model="filters.year">
<option value="">All</option>
<template x-for="year in decades">
<option :value="year" x-text="`${year}0`"></option>
</template>
</select>
Try it yourself in demo 5 on Codepen.
See the Pen [Pagination + Filter with Alpine.js Step 5](https://codepen.io/smashingmag/pen/OJzmaZb) by Manuel Matuzovic.
See the Pen Pagination + Filter with Alpine.js Step 5 by Manuel Matuzovic.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:
get filteredRecords() {
const filtered = this.records.filter((item) => {
for (var key in this.filters) {
if (this.filters[key] === '') {
continue
}
if(!String(item[key]).includes(this.filters[key])) {
return false
}
}
return true
});
return filtered
}
For this to take effect we have to adapt our numOfPages() and page() functions to use only the filtered records:
numOfPages() {
return Math.ceil(this.filteredRecords.length / this.itemsPerPage)
},
page() {
return this.filteredRecords.slice(this.currentPage * this.itemsPerPage, (this.currentPage + 1) * this.itemsPerPage)
},
See the Pen [Pagination + Filter with Alpine.js Step 6](https://codepen.io/smashingmag/pen/GRymwQZ) by Manuel Matuzovic.
See the Pen Pagination + Filter with Alpine.js Step 6 by Manuel Matuzovic.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:
init() {
this.getRecords();
this.$watch('filters', filter => this.currentPage = 0);
}
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:
<fieldset class="filters" hidden>
[…]
</fieldset>
.filters {
display: block;
}
html:not(.alpine) .filters {
visibility: hidden;
}
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:
<p id="message">Showing <output x-text="message">{{ records.length }} records</output></p>
Alpine.data('collection', () => ({
message() {
return `${this.filteredRecords.length} records`;
},
// […]
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.
<div aria-live="polite">Dynamic changes will be announced</div>
In our case, we don’t have to do anything, because we’re already using the output element (remember?) which is an implicit live region by default.
<p id="message">Showing <output x-text="message">{{ records.length }} records</output></p>
“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
We could make the region focusable and move the focus to the region when its content changes. Since the region is labelled, its name and role will be announced when that happens.
<div aria-labelledby="message" role="region" tabindex="-1" x-ref="region">
We can reference the region using the x-ref directive.
<a @click.prevent="currentPage = idx - 1; $nextTick(() => { $refs.region.focus(); $refs.region.scrollIntoView(); });" :href="https://smashingmagazine.com/2022/04/accessible-filterable-paginated-list-11ty-alpinejs/`/${idx}`" x-text="`Page ${idx}`" :aria-current="idx === currentPage + 1 ? 'page' : false">
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:
See the Pen [Pagination + Filter with Alpine.js Step 7](https://codepen.io/smashingmag/pen/zYpwMXX) by Manuel Matuzovic.
See the Pen Pagination + Filter with Alpine.js Step 7 by Manuel Matuzovic.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
(vf, yk, il)
Source link
-
briefs.video/videos/is-prog…
-
JengaScript, a hilarious metaphor to make a point about Progressive Enhancement. briefs.video/videos/is-prog…
-
heydon continues to be the greatest person on earth briefs.video/videos/is-prog…
-
Can't remember the last time I both laughed a lot and learned things 👏 👏 👏
-
I missed @heydonworks vlog on #ProgressiveEnhancement - briefs.video/videos/is-prog… - worth the watch.
-
The basic layout is not a broken layout } Bis
#progressive_enhancement is not dead !
briefs.video/videos/is-prog…
Merci @heydonworks
-
I just heard about @heydonworks new vlog and gave it a quick view: great like always. I think it’s the most fun way to tell people how to make their webpages f**ing work.
Here an example:
briefs.video/videos/is-prog…
-
So much to unpack here from @heydonworks
1. Any Progressive Enhancement video w/mention of a fez has my attention.
2. See 1. but replace “fez” with @tonyhawk “Tony Hawk”
3. Hell yes.
4. Clarification of hawkses. Someone had to do it. briefs.video/videos/is-prog…
-
Is Progressive Enhancement Dead Yet? briefs.video/videos/is-prog…
-
Progressive enhancement is most likely MUCH more relevant today than it ever was. And here's why: briefs.video/videos/is-prog… #progressiveenhancement #nojavascript #setyourexpectations #bestpractices
-
This is literally the best thing I've seen in my life. And the content is so good.
briefs.video/videos/is-prog…
-
This needs no further comment other that "watch this, it's fabulous". briefs.video/videos/is-prog…
-
Is Progressive Enhancement Dead Yet? briefs.video/videos/is-prog… by @heydonworks #webdev
-
Is Progressive Enhancement Dead Yet? by @heydonworks briefs.video/videos/is-prog…
-
The production value of this @heydonworks is simply amazing. This is what it means to stand out and infuse your things with personality. Also, the basic layout is not a broken layout.
briefs.video/videos/is-prog…
-
As usual @heydonworks provides and continues to inspire me with his ridiculously well crafted content. Accessible, inspirational and *FUN*.
Is Progressive Enhancement Dead Yet?
briefs.video/videos/is-prog…
🧵Here's what I learned.
-
Is Progressive Enhancement Dead Yet? via @heydonworks
briefs.video/videos/is-prog…
#web #webdev #webdevelopment #ProgressiveEnhancement
-
love this - Thanks!
-
I just watched the amazing: briefs.video/videos/is-prog…, which lead me to check out @heydonworks profile, which lead me to buy "Death Garage #1" album.link/i/1538491998, which lead me to a day of Garage Rock. Thanks Heydon! Best rabbit hole ever.
-
Hilaaarious: "Is Progressive Enhancement Dead Yet?" by @heydonworks.
Man, I just love your work. Thank you!
briefs.video/videos/is-prog…
-
Is Progressive Enhancement Dead Yet? briefs.video/videos/is-prog…
-
このひと多才だな / Is Progressive Enhancement Dead Yet? (Webbed Briefs) briefs.video/videos/is-prog…
-
Unterhaltsame Erinnerung über die Bedeutung von Progressive Enhancement: briefs.video/videos/is-prog…
-
Is Progressive Enhancement Dead Yet? Quality despatch from @heydonworks
briefs.video/videos/is-prog…
-
"Browsers are able to scroll"
"The Basic Layout is not a broken layout"
Some real great statements in this.
briefs.video/videos/is-prog…
-
Is Progressive Enhancement Dead Yet? Definitely not, but it may be not what you're thinking of. Let @heydonworks show you: briefs.video/videos/is-prog…
-
Hear about progressive enhancement but don't really know what people mean by it, or are you hoping one day it won't be a thing to even consider anymore?
@heydonworks has it covered in this excellent video: briefs.video/videos/is-prog…
-
Is Progressive Enhancement Dead Yet? briefs.video/videos/is-prog…
-
Every Heydon's video made me grin violently, but I'm afraid this particular one caused irreversible damage to my face muscles. briefs.video/videos/is-prog…
-
I quite enjoyed @heydonworks's Webbed Briefs entry on Progressive Enhancement, but I can't get over his voice. When I read his tweets, I would NEVER have imagined an accent that sounded so...fancy.
I felt a chill hearing it say "hawks" so many times.
briefs.video/videos/is-prog…
-
Is Progressive Enhancement Dead Yet? briefs.video/videos/is-prog…
-
I just had a blast laughing at this: "Is Progressive Enhancement Dead Yet?" by @heydonworks
briefs.video/videos/is-prog…
-
Is this the best progressive enhancement presentation ever made? Yes it is. briefs.video/videos/is-prog…
-
briefs.video/videos/is-prog…
-
briefs.video/videos/is-prog…
-
Brilliant & funny overview of Progressive Enhancement in web (and by extension web app) design:
via @heydonworks
briefs.video/videos/is-prog…
-
This bit by @heydonworks on progressive enhancement is fabulous! briefs.video/videos/is-prog…
-
“Progressive enhancement is Scary Mary™ to some but there is no need to cry or pass out. Let me use a shark in a fez, Tony Hawk, and a couple of zombie lads to explain.” briefs.video/videos/is-prog…
-
Wow - I was really surprised to see @heydonworks advocating for this CSS build process, run in asynchronous cloud Jupyter Notebook servers, but he's the expert, so... just watch it*.
briefs.video/videos/is-prog…
* don't click the goat button
-
Made my das :) The Basic Layout is Not a Broken Layout #webdev thx @heydonworks
briefs.video/videos/is-prog…
-
Frontend Devs, interested in something else than your everyday react-angular-vue-ember-comparison or I-made-120k-after-this-4-hours-html-workshop medium newsletter?
briefs.video/videos/is-prog…
-
Is Progressive Enhancement Dead Yet? (Webbed Briefs) briefs.video/videos/is-prog…
via @heydonworks
-
That @heydonworks, the small bearded unicorn of hope, has produced a video that I must attach directly to colleagues' optic nerves.
briefs.video/videos/is-prog…
-
Brilliant! 🧟🦈🤯🔝
briefs.video/videos/is-prog…
-
#Web #ProgressiveEnhancement
"Is Progressive Enhancement Dead Yet?", Heydon ickering (@heydonworks ) uses shark in a fez, Tony Hawk, and zombies to explain progressive enhancement in a fun educational black and white video. Also, JengaScript
briefs.video/videos/is-prog…
-
Good, as always: briefs.video/videos/is-prog…
-
Is Progressive Enhancement Dead Yet? (Webbed Briefs). So funny and true. 😆 briefs.video/videos/is-prog…
-
Another beautiful, funny and informative “webbed briefs” video by @heydonworks.
Is Progressive Enhancement Dead Yet?
briefs.video/videos/is-prog…
-
Is Progressive Enhancement Dead Yet? (Webbed Briefs) briefs.video/videos/is-prog…
-
The Basic Layout is Not a Broken Layout
The Basic Layout is Not a Broken Layout
The Basic Layout is Not a Broken Layout
The Basic Layout is Not a Broken Layout
The Basic Layout is Not a Broken Layout
The Basic Layout is Not a Broken Layout
briefs.video/videos/is-prog…
-
Is Progressive Enhancement Dead Yet? by Heydon @heydonworks
#progressiveenhancement #css #webdev
briefs.video/videos/is-prog…
-
So good, @heydonworks 🔥
briefs.video/videos/is-prog…
-
briefs.video/videos/is-prog…
-
One of the best things I've seen in the weird web…
Another wonderfully video in this series briefs.video/videos/is-prog…
briefs.video
-
Awesome video from @heydonworks, the guy is a legend
briefs.video/videos/is-prog…
-
Is Progressive Enhancement Dead Yet?
Or, how many Hawks do you know?? briefs.video/videos/is-prog…
-
Today I learnt: progressive enhancement is NOT a f**kton of JavaScript 🤯🎉
Another superbly entertaining video from @heydonworks which will trick you into learning things.
-
Once again @heydonworks has produced a funny, yet educational look at an important #accessibility issue. This time #ProgressiveEnhancement is the focus of his sharp wit. It's almost like watching Monty Python, but with better graphics & real content!
-
This informative video by @heydonworks is a bit of genius and I'm going to make all the devs I know watch it. briefs.video/videos/is-prog…
-
The metaphor with @tonyhawk is fantastic. I’m wondering if any other legend out there fits well to describe good web practice
briefs.video/videos/is-prog…
Feat @heydonworks
-
For those of a progressively challenged nature:
briefs.video/videos/is-prog…
-
I haven't had that much fun on the web for quite some time: briefs.video/videos/is-prog… - thank you @heydonworks
-
I love these so much briefs.video/videos/is-prog…
-
Here's someone explaining "Progressive enhancement" like you would be 5 years old aka why you don't use JavaScript aka JengaScript as first-class client-first citizen.
briefs.video/videos/is-prog…