Select Page


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.

A picture with vinyls standing in a row
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:

A screenshot of the final file and the folder structure
(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
A screenshot where the site showing the heading ‘My Record Collection
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><br>
      Launched in <time datetime="{{ report.yr }}">{{ report.yr }}</time> by {{ report.artist }}.
    </li>
    {% endfor %}
  </ol>
</div>
A screenshot with 7 Records listed, each with their title, artist and release date
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><br>
            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;
}
A screenshot with 7 Records listed, each with their title, artist and release date, with links to all pages and a highlighted current page
(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><br>
    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><br>
          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><br>
          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><br>
    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><br>
      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:

  1. repair a bug;
  2. cover the shape;
  3. 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:

  1. 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

  2. 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:

  1. When customers filter the web page, we replace the dwell area, however we don’t transfer focus.
  2. 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:

  1. The pagination might be smarter (most variety of pages, earlier and subsequent hyperlinks, and so forth).
  2. Let customers decide the variety of gadgets per web page.
  3. Sorting can be a pleasant characteristic.
  4. Working with the historical past API can be nice.
  5. Content material shifting might be improved.
  6. 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

Smashing Editorial
(vf, yk, il)