How to create filters with IsotopeJS in Bricks (Part 4): AJAX filter and infinite loops, Sorting, List view and Disable animations

Schermata 2022-08-16 alle 07.14.53

This tutorial will explain how to enable the AJAX filter with an infinite scroll query loop container, how to add a sorting function, how to add a list view selector and how to disable the CSS animation when filtering the items using Isotope in Bricks builder.

Table of Contents

Requirements

  • Everything working from PART 3.

AJAX filter on infinite scroll query loops container

It sounds hard to implement but it’s way easier than it looks. All the credits for this go to Jenn Lee who explained to me how to use the AJAX endpoint created by bricks and found the correct AJAX function to trigger isotope when new content is being loaded.

The first step is to enable the infinite scroll setting inside your query loop container and set a limit to your posts_per_page:

I had a few errors when I set this setting on my server – somehow the loop wasn’t querying the correct post_id for some unknown reason and the infinite loop returned erros, so make sure the infinite loop work without isotope enabled before jumping on the next steps.

Paste the following code to enable the AJAX filter in your init.js file (see the final code to see exactly where it should be pasted):

const open = window.XMLHttpRequest.prototype.open;

function XMLOpenReplacement() {
   this.addEventListener("load", function () {
      let current_url = new URL(this.responseURL);
      if (current_url.pathname.includes("/load_query_page")) {

         //reload the items
         iso.reloadItems();

         //set the padding
         if (wrapper.dataset.gutter) {
            isotopeSelector = wrapper.querySelectorAll('.isotope-selector');
            isotopeSelector.forEach(elm => elm.style.paddingBottom = isotopeGutter + 'px');
         }

         //rearrange the container
         iso.arrange();
      }
   });
   return open.apply(this, arguments);
}
window.XMLHttpRequest.prototype.open = XMLOpenReplacement;

Without going into too much details, the function tasks advantage of the endpoint created by Bricks called load_query_page. We created an EventListener function that triggers our isotope functions every time the AJAX endpoint is being loaded.

The isotope-related functions are pretty basic: first, we reload all the items so the isotope container includes all the selectors – including the new ones, then we apply the gutter padding to all of them if the gutter was set, and finally, we rearrange the isotope container to reorder/fix the layout. And that’s pretty much it.

Disable the CSS animation

One thing bothered me though: each time we load new items through AJAX, the new items have a transform animation like they are flying from the top to their new bottom position.

If this annoys you, you can simply deactivate the animations on all the selectors by adding one line in our isotopeOptions variable:

var isotopeOptions = {
   transitionDuration: 0,
};

Sorting function

A few people asked for a sorting function, so let’s do that now.

This time we’ll add a select dropdown input field with the sorting value that we want to apply: sort by newest, oldest, A to Z and Z to A.

Add this code in a code element:

<select name="sorting" id="sorting">
  <option value="newest">Newest</option>
  <option value="oldest">Oldest</option>
  <option value="a_to_z">A to Z</option>
  <option value="z_to_a">Z to A</option>
</select>

Note that I slightly changed my DOM structure in order to put the dropdown (and grid/list views buttons) on top of the isotope container:

Now let’s jump into the init.js file and store the query of new input:

var sorting = wrapper.querySelector('#sorting');
if (!sorting) {
   console.log('No Sorting input found. Make sure your sorting dropdown input has the ID "#sorting"');
}

We have to declare the sorting criteria in our isotopeOptions variable. In our case, we’ll add name (“A to Z” and “Z to A”) and date (“Newest” and “Oldest”):

var isotopeOptions = {
   getSortData: {
      name: (el) => {
         return el.querySelector('.post-title').textContent;
      },
      date: (el) => {
         return el.querySelector('.post-date').textContent;
      }
   },
};

You can see that in both functions we query an inner element inside our element and grab the text content: the Post Title for name and the Post Date for date. Make sure to add the classes .post-title and .post-date to your bricks elements.

The last step is to create an EventListener function that will trigger our sorting function each time the value of the dropdown is changed:

// Event Listener for sorting 
if (sorting) {
   sorting.addEventListener('change', (e) => {

      switch (e.target.value) {
         case "newest":
            iso.arrange({
               sortBy: 'date',
               sortAscending: false
            });
            break;
         case "oldest":
            iso.arrange({
               sortBy: 'date',
               sortAscending: true
            });
            break;
         case "a_to_z":
            iso.arrange({
               sortBy: 'name',
               sortAscending: true
            });
            break;
         case "z_to_a":
            iso.arrange({
               sortBy: 'name',
               sortAscending: false
            });
            break;
         default:
            return;
      }
   })
}

Every time the value of the dropdown changes, we will run a switch function and check for the corresponding cases. If the value matches one of our cases, it’ll rearrange the isotope container with the sortBy condition and the correct Ascending/Descending order we assigned through sortAscending.

List view

This is pretty straightforward. Let’s add two buttons in our new topbar container and assign grid-view and list-view as their respective IDs.

In the init.js file, let’s query the buttons:

var gridView = wrapper.querySelector('#grid-view');
// Show a message in console if no grid-view buttons have been found
if (!gridView) {
   console.log('No grid-view button found. Make sure that your grid-view button has the ID "#grid-view"')
}

var listView = wrapper.querySelector('#list-view');
// Show a message in console if no list-view buttons have been found
if (!listView) {
   console.log('No list-view button found. Make sure that your list-view button has the ID "#grid-view"')
}

And add their respective EventListener functions:

//Event Listerner for grid-view
if (gridView) {
   gridView.addEventListener('click', (e) => {
      e.preventDefault();
      gridView.classList.add('filterbtn--active');
      listView.classList.remove('filterbtn--active');
      isotopeContainer.classList.remove('list');
      iso.arrange();
   })
}

//Event Listerner for list-view
if (listView) {
   listView.addEventListener('click', (e) => {
      e.preventDefault();
      listView.classList.add('filterbtn--active');
      gridView.classList.remove('filterbtn--active');
      isotopeContainer.classList.add('list');
      iso.arrange();
   })
}

The script will toggle the filterbtn--active class if the button is clicked. Note that we are reusing the active class we created earlier in PART 2 but feel free to create your own specific class and style it inside the Bricks builder.

But the most important: we toggle a list class on the isotope container. Now the only thing to do is to insert the following CSS in your page settings:

.isotope-container.list {
    --col: 1;
}

By just changing the CSS variable --col we recalculate all the dimensions of our isotope container.

Optionally, you can style your list view as you wish by targeting the .isotope-container.list class. Unfortunately, you’ll have to write your CSS manually as Bricks doesn’t support this feature yet.

Here is the CSS I added in my example:

@media screen and (min-width: 1200px) {

	/* ARTICLE */
	.isotope-container.list article {
		flex-direction: row !important;
		align-items: stretch;
		gap: 20px;
	}

	/* LEFT COL */
	.isotope-container.list article>a {
		max-width: 45%;
		align-self: stretch !important;
		flex-basis: 100%;
	}

	/*RIGHT COL */
	.isotope-container.list article>div {
		flex-basis: 100%;
	}

	/* FEATURE IMAGE */
	.isotope-container.list article>a>img {
		aspect-ratio: unset;
		position: absolute;
		top: 50%;
		left: 50%;
		bottom: 0;
		right: 0;
		width: 100%;
		height: 100%;
		transform: translate(-50%, -50%);
	}

	/* READ MORE BUTTONS */
	.isotope-container.list article>div>a {
		margin: 0;
		align-self: end;
	}

	.isotope-container.list article>div>a>div {
		border-radius: 10px 0 10px 0 !important;
	}
}

And that’s it!

Final Code

Here is the final code of our init.js file:

window.addEventListener('DOMContentLoaded', () => {

   const isotopeWrappers = document.querySelectorAll('.isotope-wrapper');

   // Stop the function if there is no isotope wrapper detected
   if (isotopeWrappers.length < 1) {
      return console.log('No isotope wrapper found. Make sure to add the ".isotope-wrapper" class to the main isotope wrapper');
   };

   // Default variable

   // buttons
   var filterRes = true;
   var filterSelector = "*";
   // search
   var filterSearch = true;
   var qsRegex
   // range
   var filterRange = true;
   var filterRangeValue = '*';
   // checkbox
   var filterCheckbox = true;
   var filterCheckboxValue = '*';
   // radio
   var filterRadio = true;
   var filterRadioValue = '*';


   // Loop inside each isotope wrapper
   isotopeWrappers.forEach(wrapper => {

      // Set variable and Error Handling

      var isotopeContainer = wrapper.querySelector('.isotope-container');
      if (!isotopeContainer) {
         return console.log('No isotope container found. Make sure to add the ".isotope-container" class to the container of your selectors');
      }

      var isotopeSelector = isotopeContainer.querySelectorAll('.isotope-container .isotope-selector');
      if (isotopeSelector.length < 1) {
         return console.log('No isotope selector found. Make sure to add the ".isotope-selector" class to all your selector');
      }

      var buttons = wrapper.querySelectorAll(".filterbtn-container .filterbtn");
      // Show a message in console if no buttons have been found
      if (buttons.length < 1) {
         console.log('No filter wrapper or filter buttons found. Make sure your filter wrapper has the class ".filterbtn-wrapper" and all your filter buttons have the class ".filterbtn"');
      }

      var quickSearch = wrapper.querySelector('#quicksearch');
      // Show a message in console if no search input have been found
      if (!quickSearch) {
         console.log('No QuickSearch found. Make sure your search input has the ID "#quicksearch"');
      }

      var range = wrapper.querySelector('#range');
      // Show a message in console if no range slider have been found
      if (!range) {
         console.log('No Range found. Make sure your range input has the ID "#range"');
      }

      var checkboxes = wrapper.querySelectorAll('.checkbox');
      // Show a message in console if no checkboxes have been found
      if (checkboxes.length < 1) {
         console.log('No checkbox found. Make sure your checkbox input has the class ".checkbox"');
      }

      var radios = wrapper.querySelectorAll('.radio');
      // Show a message in console if no radio buttons have been found
      if (checkboxes.length < 1) {
         console.log('No radio found. Make sure your radio input has the class ".radio"');
      }

      var sorting = wrapper.querySelector('#sorting');
      if (!sorting) {
         console.log('No Sorting found. Make sure your sorting dropdown input has the ID "#sorting"');
      }

      var gridView = wrapper.querySelector('#grid-view');
      // Show a message in console if no grid-view buttons have been found
      if (!gridView) {
         console.log('No grid-view button found. Make sure that your grid-view button has the ID "#grid-view"')
      }

      var listView = wrapper.querySelector('#list-view');
      // Show a message in console if no list-view buttons have been found
      if (!listView) {
         console.log('No list-view button found. Make sure that your list-view button has the ID "#grid-view"')
      }

      // Gutter Settings through data-gutter
      if (wrapper.dataset.gutter) {
         var isotopeGutter = parseInt(wrapper.dataset.gutter);
         wrapper.style.setProperty('--gutter', isotopeGutter + 'px');
         isotopeSelector.forEach(elm => elm.style.paddingBottom = isotopeGutter + 'px');
      } else {
         // Default option
         var isotopeGutter = 0;
         console.log('No data-gutter attribute has been found on your isotope container. Default set to 0.');
      };


      // Layout Settings through data-filter-layout
      if (wrapper.dataset.filterLayout) {
         var isotopeLayoutHelper = wrapper.dataset.filterLayout;
      } else {
         // Default option
         var isotopeLayoutHelper = 'fitRows';
         console.log('No data-filter-layout attribute has been found on your isotope container. Default set to "fitRows".');
      };

      // init Isotope

      var isotopeOptions = {
         itemSelector: '.isotope-selector',
         layoutMode: isotopeLayoutHelper,
         //transitionDuration: 0, /Uncomment to disable animations
         getSortData: {
            name: (el) => {
               return el.querySelector('.post-title').textContent;
            },
            date: (el) => {
               return el.querySelector('.post-date').textContent;
            }
         },
         filter: (itemElem1, itemElem2) => {
            const itemElem = itemElem1 || itemElem2;

            // tags/buttons
            if (buttons.length > 0) {
               filterRes = filterSelector != '*' ? itemElem.dataset.filter.includes(filterSelector) : true;
            }

            // quicksearch
            if (quickSearch) {
               filterSearch = qsRegex ? itemElem.textContent.match(qsRegex) : true;
            }

            // range
            if (range) {
               filterRange = filterRangeValue != '*' ? parseInt(itemElem.dataset.range) <= filterRangeValue : true;
            }

            // checkboxes
            if (checkboxes.length > 0) {
               filterCheckbox = filterCheckboxValue != '*' ? filterCheckboxValue.includes(itemElem.dataset.checkbox) : true;
            }

            // radio
            if (radios.length > 0) {
               filterRadio = filterRadioValue != '*' ? filterRadioValue.includes(itemElem.dataset.radio) : true;
            }


            return filterRes && filterSearch && filterRange && filterCheckbox && filterRadio;
         }
      };


      // Set the correct layout
      switch (isotopeLayoutHelper) {
         case 'fitRows':
            isotopeOptions.fitRows = {
               gutter: isotopeGutter
            };
            break;
         case 'masonry':
            isotopeOptions.masonry = {
               gutter: isotopeGutter
            };
            break;
      }

      var iso = new Isotope(isotopeContainer, isotopeOptions);

      // debounce so filtering doesn't happen every millisecond
      const debounce = (fn, threshold) => {
         var timeout;
         threshold = threshold || 200;
         return function debounced() {
            clearTimeout(timeout);
            var args = arguments;
            var _this = this;

            function delayed() {
               fn.apply(_this, args);
            }
            timeout = setTimeout(delayed, threshold);
         };
      };

      // Event Listener for buttons
      if (buttons.length > 0) {

         buttons.forEach(elem => elem.addEventListener("click", (event) => {

            event.preventDefault();

            // get the data-filter attribute from the filter button
            var filterValue = event.target.getAttribute("data-filter");
            filterSelector = filterValue;

            // filter results
            iso.arrange();
         }));
      };

      // Event Listener for search input
      if (quickSearch) {
         quickSearch.addEventListener('keyup', debounce((event) => {

            qsRegex = new RegExp(quickSearch.value, 'gi');

            //filter the store list
            iso.arrange();

         }, 200));
      }

      // Event Listener for range slider
      if (range) {
         const rangeFilterFN = (e) => {
            filterRangeValue = parseInt(e.target.value);

            //filter the store list
            iso.arrange();
         }

         range.addEventListener('input', debounce((e) => {

            rangeFilterFN(e);

         }, 200));

         range.addEventListener('keyup', debounce((e) => {

            rangeFilterFN(e);

         }, 200));
      }

      // Event Listener for checkboxes
      if (checkboxes) {
         checkboxes.forEach(checkbox => {
            checkbox.addEventListener('click', debounce(() => {
               arrCheckbox = [];

               checkboxes.forEach(cb => {
                  if (!cb.checked) {
                     return;
                  }
                  arrCheckbox.push(cb.value);

               })

               filterCheckboxValue = arrCheckbox;

               //filter the store list
               iso.arrange();

            }, 200));
         })
      }

      // Event Listener for radio buttons
      if (radios) {
         radios.forEach(radio => {
            radio.addEventListener('change', debounce(() => {
               arrRadio = [];

               radios.forEach(radio => {
                  if (!radio.checked) {
                     return;
                  }
                  arrRadio.push(radio.value);

               })

               filterRadioValue = arrRadio;

               //filter the store list
               iso.arrange();

            }, 200));
         })
      }

      // Event Listener for sorting 
      if (sorting) {
         sorting.addEventListener('change', (e) => {

            switch (e.target.value) {
               case "newest":
                  iso.arrange({
                     sortBy: 'date',
                     sortAscending: false
                  });
                  break;
               case "oldest":
                  iso.arrange({
                     sortBy: 'date',
                     sortAscending: true
                  });
                  break;
               case "a_to_z":
                  iso.arrange({
                     sortBy: 'name',
                     sortAscending: true
                  });
                  break;
               case "z_to_a":
                  iso.arrange({
                     sortBy: 'name',
                     sortAscending: false
                  });
                  break;
               default:
                  return;
            }
         })
      }

      //Event Listener for grid-view
      if (gridView) {
         gridView.addEventListener('click', (e) => {
            e.preventDefault();
            gridView.classList.add('filterbtn--active');
            listView.classList.remove('filterbtn--active');
            isotopeContainer.classList.remove('list');
            iso.arrange();
         })
      }

      // Event Listener for list-view
      if (listView) {
         listView.addEventListener('click', (e) => {
            e.preventDefault();
            listView.classList.add('filterbtn--active');
            gridView.classList.remove('filterbtn--active');
            isotopeContainer.classList.add('list');
            iso.arrange();
         })
      }

      // Event Listener for filter buttons
      const radioButtonGroup = (buttonGroup) => {

         buttonGroup.addEventListener("click", (event) => {
            buttons.forEach(btn => btn.classList.remove("filterbtn--active"));
            event.target.classList.add("filterbtn--active");
         });
      };

      // change is-checked class on buttons
      for (var i = 0, len = buttons.length; i < len; i++) {

         var buttonGroup = buttons[i];
         radioButtonGroup(buttonGroup);
      };
      setTimeout(() => {
         iso.arrange()
      }, 300);

      // AJAX FILTER
      const open = window.XMLHttpRequest.prototype.open;

      function XMLOpenReplacement() {
         this.addEventListener("load", function () {
            let current_url = new URL(this.responseURL);
            if (current_url.pathname.includes("/load_query_page")) {

               //reload the items
               iso.reloadItems();

               //set the padding
               if (wrapper.dataset.gutter) {
                  isotopeSelector = wrapper.querySelectorAll('.isotope-selector');
                  isotopeSelector.forEach(elm => elm.style.paddingBottom = isotopeGutter + 'px');
               }

               //rearrange the container
               iso.arrange();
            }
         });
         return open.apply(this, arguments);
      }
      window.XMLHttpRequest.prototype.open = XMLOpenReplacement;
   });

});

Conclusion

Unless there are some specific requests popping up from the community, this tutorial closes the Isotope series on how to integrate smart filters into your next Bricks project without adding any third-party plugin. Hope you enjoyed it!

  • Ken Mundell

    Hi there Maxime,

    First of all, thank-you for this awesome four part tutorial. As a novice, this was great to dig into and learn more about. I had a couple questions I was not sure about.

    1. Can you apply multiple button filters at the same time? I have two different taxonomies applied to my products post, safety category and variations, I’d like to be able to filter a term from each if that’s possible? Or should I create one of them as a checkbox or radio and manually add each term for that input type?

    2. Can the filtering handle a larger number of posts, more than 1000? It seems that when I use AJAX and infinite loop from this tutorial, it doesn’t filter through all of them and won’t return anything in the loop even though I know that one item does have the term? And related to the loop, if there are no options, is there a way for it to show a custom message in the loop for no results?

    • Hey Ken, thanks for your kind words!

      1) I’d probably create two different radio groups in that case and duplicate the filter logic for each one. You’ll have to adapt the variables and the filter conditions to be unique (radio1 / filterRadioValue1 / filterRadio1, radio2 / filterRadioValue2 / filterRadio2 etc…) otherwise you’ll get a js error, but if you understand the logic behind it, it should be pretty straightforward.

      2) I never tested such a large amount of posts, but I can’t see a reason why it wouldn’t work as expected. Do you see any console errors?

      3) To show a no result message, isotope provides an option to show the number of filtered items: iso.filteredItems.length. You could create a function like:
      if(iso.filteredItems.length == 0) {
      some action (like adding an active class to a text element that would show no results text)
      }

      I hope it helps!

      • 1. I’ll have to see if I can figure that out, my safety category has 5 terms right now, will have about 20-25 when I’m done, but the variations taxonomy has over 100 so would definitely take a while to put all those in the html. That’s why I like the terms loop with buttons.

        2. I added the link to the site so you can look at what it is doing but I did not see any errors in the console.

        3. I understand the concept, I just have to figure out how to turn that into code, more learning and research. Thanks for the starting point.

        • Ken Mundell

          Oops, forgot to put link in the comment itself. https://staging.srusigns.ca/products/

          • 1) In this case, a radio button element would be really handy. I wish it was part of the Bricks core elements – as well as the other inputs – but right now you could try this hacky trick: insert a form element, then create a radio group and hide the submit button with CSS. In the list of options, you should put an echo:custom_function to list all your terms. That should output all your terms dynamically. Not tested tho ๐Ÿ™‚

            2) I had a quick look. It looks like everything is working correctly. Take into consideration that – right now – isotope will only filter the loaded items. So if there are items in some categories that are not AJAX loaded yet, it will return an empty result. If you need to call the database when the filters are clicked, this is probably not the best solution for you – or it would require some additional work on the AJAX calls.

            • Ken Mundell

              Okay, thank-you very much for the info. Appreciate your time on this with me. This is a very cool method but I guess it is not the best option for my project at the moment as I don’t understand AJAX calls. I might circle back to this once I can learn more about AJAX calls. Thanks again!

  • Sebastian Albert

    Thanks a lot! It is absolutely perfect that it is now working with infinite scroll!
    I have some more questions:

    – When having multiple pages with different isotope filters, should all be put in the one .init file (child-theme-folder), or would it be better to have multiple init files (e.g. in Advanced Scripts I can dynamically adjust on which page a specific script should be fired.)

    – Is it possible to create a “reset” button, which resets all filters at once?

    – If a sticky header is on the page, how can it be achieved, when a filter is clicked or text-input is selected, => move the page position to top of the page (offset sticky header).

    – would it be possible to create a url_hash of the filter-selection & the page positing (infinite scroll [not sure if bricks is using pagination behind the infinite scroll])

    Thanks again ๐Ÿ™‚

    • Maxime Beguin

      Hey Sebastian,

      1) Ideally, you shouldn’t need multiple init.js files. One should work for all your pages. To be honest, I didn’t deeply test all the different scenarios with multiple isotope containers on the same page. There are some variables that query unique ID’s, so that should be modified in case there are multiple elements on the same page.

      2) For the reset button, you could create a EventListener on a button, and use iso.arrange(filterSelector = “*”, filterRangeValue = ‘*’, etc…), not tested but that should work at least for the isotope container, the HTML input would need to be reset separately. Even iso.arrange(filter: ‘*’) should work. To be tested!

      3) You could add in each EventListener function something like:
      document.querySelector(‘#the_id_of_the_section_you_want_to_scroll’).scrollIntoView({behavior:”smooth”}

      4) I never tested it, but here is a working codepen with url_hash activated: https://codepen.io/desandro/pen/vErxXj. It’s using jquery instead of javascript, but the logic should be the same.

      Hope it’s helpful ๐Ÿ™‚

Leave a Reply to Maxime Beguin (Cancel Reply)