How to create filters with IsotopeJS in Bricks (Part 3): apply multiple filters

Schermata 2022-08-13 alle 06.32.30

This tutorial will review how to apply multiple filters to an isotope container using the IsotopeJS library‘s features in Bricks.

Table of Contents

Requirements

Custom fields

For this tutorial, I created a Custom type called Tutorials (but feel free to choose whatever you want) and attached three customs fields to it: Type, Estimated Read, and Difficulty.

Then, in bricks, I built my isotope container using the native query loop element and added all my elements to it. My isotope container looks like this:

Of course, this is just an example to illustrate the structure needed to create a correct isotope container – feel free to add any element that you want inside your query loop.

Add custom inputs

The whole idea is to insert some custom HTML inputs (such as checkboxes, radio buttons, or a range slider) and execute the filter function once any of the inputs get triggered.

So we’ll basically need 3 elements for each filter:

  • the HTML code inside bricks to create the input
  • a JavaScript code ( an EventListener) that will trigger the filter function
  • the filter function that will return the filtered items if the criteria are met.

Let’s review the most used HTML inputs one by one.

Input buttons

The buttons have been covered in PART 1 and PART 2 so I won’t spend too much time on how to set them inside Bricks. Let’s directly jump into our JavaScript code.

First of all, we need to save all the buttons inside a variable:

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"');
}

And create a new EventListener in order to trigger the filter every time a button is clicked:

// 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();

	}));
};

Now let’s create the filter function inside the isotope options:

// Default variable
var filterRes = true;
var filterSelector = "*";

// init Isotope

var isotopeOptions = {
	itemSelector: '.isotope-selector',
	layoutMode: isotopeLayoutHelper,
	filter: (itemElem1, itemElem2) => {
		const itemElem = itemElem1 || itemElem2;

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

		return filterRes;
	}
};

A bit of explanation:

  • we set a default filterRes variable to true, so even if there are no filter buttons on your page, the filter function will return true and the other filters will still work as expected.
  • we set a default filterSelector variable to * in order to avoid any filtering on load.
  • in the isotopeOptions variable, we added a new filter object that will take care of the filtering logic.
  • The main filter condition is: if the filterSelector is set to *, it will return true for all the items – thus no filtering will be applied, otherwise it will filter the items that contain the same data-filter value than the button we just clicked.

Input text

Let’s create a quick search input field that will filter our isotope selectors based on the keywords inserted in the text field.

First of all, let’s add a new code element inside our isotope-wrapper and paste this HTML code in it:

<fieldset>
  <legend>Search:</legend>
  <input type="text" id="quicksearch" placeholder="Type a keyword" />
</fieldset>

As you can see, we set the ID of the text input to quicksearch in order to be easily queryable by JavaScript. Let’s assign that input field inside a variable called quickSearch:

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"');
}

The next step is to build our Event Listener to trigger the filter function that we’re going to create in a moment.

// 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));
}

Our trigger is listening for any input from the keyboard and registering the value (which is the typed text) of our field inside the qsRegex variable that will later be processed by our filter function.

Note that we are using a debounce function to avoid triggering the filter every single time we type a letter – but only after a delay of 200ms. Here is the debounce function:

// 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);
	};
};

The final step is to create the filter function inside the isotopeOptions variable:

// Default variable
var filterSearch = true;
var qsRegex;

// init Isotope
var isotopeOptions = {
	itemSelector: '.isotope-selector',
	layoutMode: isotopeLayoutHelper,
	filter: (itemElem1, itemElem2) => {
		const itemElem = itemElem1 || itemElem2;

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

		return filterSearch;
	}
};

Explanation:

  • we set a default filterSearch variable to true, so even if there is no quick search input on your page, the filter function will return true and the other filters will still work as expected.
  • we added a filter object inside the isotopeOptions variable that will take care of the filtering logic.
  • The main filter condition is: if the text typed inside the search field matches the text of the isotope selector, the function returns true and the isotope selector will be visible inside the isotope container. If the text doesn’t match the isotope selector content, the function returns false the and the isotope selector gets filtered.

Input range slider

Let’s replicate the same logic with an HTML slider input. But before that, we need to add a new data-attribute called data-range to our isotope selectors that will dynamically return the value of our custom field. In this example, I’m using the range slider to filter my selectors by the Estimated Read Time field we set earlier:

Now, let’s create our HTML slider inside the isotope-wrapper. Copy/paste the code inside a block element:

<fieldset>
  <legend>Estimated Read:</legend>
    <input type="range" id="range" min="0" max="30" value="30" oninput="this.nextElementSibling.value = this.value + ' minute(s)'">
    <output>30 minutes</output>
</fieldset>

Note that I added a custom JavaScript function to add the value of the slider and the units to the output. This is totally optional, feel free to skip it if you don’t need it.

Let’s jump back to our JavaScript code and register our range element inside a variable through its ID:

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"');
}

Now we want to create an Event listener that will trigger the filter function every time the slider is being moved. Actually, we’ll create two different events: one when the slider is moved by the mouse and another one when it’s moved by the keyboard keys. In order to avoid repeating the same code two times, let’s create a function rangeFilterFN that we will use on both Event Listeners:

// 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));
}

Since the value of the HTML slider is a string – and we want to apply some numerical logic to our filter function – we need to convert it to be a valid integer using the parseInt() function.

Once our Event Listeners are correctly set, we need to create the filter function inside our isotopeOptions variable. The idea is to check whether the data-attribute value of each selector is less or equal to the value of the slider that we are listening to:

// Default variable

var filterRange = true;
var filterRangeValue = '*';

// init Isotope

var isotopeOptions = {
	itemSelector: '.isotope-selector',
	layoutMode: isotopeLayoutHelper,
	filter: (itemElem1, itemElem2) => {
		const itemElem = itemElem1 || itemElem2;

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

		return filterRange;
	}
};

Again, the data-attribute value is a string, so we need to convert it to an integer in order to apply numerical conditions such as “less than”.

If the data-attribute value is less or equal to the slider value, the function will return a true value and the isotope selector will be visible inside the isotope container. If the data-attribute value is higher than the slider value, the function will return false and the selector will be filtered.

Input checkbox

Just like we did earlier with the input range, we need to add a data-attribute called data-checkbox to our isotope selectors:

The returned value for each selector will be the value of the Difficulty field we set earlier.

Let’s add a new code element inside our isotope-wrapper with the following HTML code:

<fieldset>
  <legend>Difficulty:</legend>
  <div class="checkbox-wrapper">
    <input type="checkbox" id="easy" name="difficulty" value="easy" class="checkbox" checked>
    <label for="easy">Easy</label>
  </div>
  <div class="checkbox-wrapper">
    <input type="checkbox" id="medium" name="difficulty" value="medium" class="checkbox" checked>
    <label for="medium">Medium</label>
  </div>
  <div class="checkbox-wrapper">
    <input type="checkbox" id="hard" name="difficulty" value="hard" class="checkbox" checked>
    <label for="hard">Hard</label>
  </div>
</fieldset>

And register all the checkboxes inside a variable called checkboxes:

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"');
}

Now the logic is slightly trickier, so bear with me! We need to listen to each checkbox for any click event and, once any of them gets clicked, we need to loop into each checkbox again and see which ones are checked and – if they are – store their value inside an array that we will use later in our filter function.

// 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));
	})
}

Let’s now create the filter function. Here is the logic: we need to see if the data-attribute value of each selector is included in the list of the active checkboxes. This list is the array with all the values of the active checkboxes we created earlier inside our Event Listener function. So basically, we need to check if the data-attribute value is included inside our array:

// Default variable

var filterCheckbox = true;
var filterCheckboxValue = '*';

// init Isotope

var isotopeOptions = {
	itemSelector: '.isotope-selector',
	layoutMode: isotopeLayoutHelper,
	filter: (itemElem1, itemElem2) => {
		const itemElem = itemElem1 || itemElem2;

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

		return filterCheckbox;
	}
};

If the data-attribute value is included in the array, the function returns true and the selector will be visible inside the isotope container. If the data-attribute value is not included in the array, the function returns false and the selector will be filtered.

Input radio buttons

Let’s replicate the same logic that we applied earlier and create a data-attribute called data-radio to our isotope selectors:

This data-attribute will return the value of our Type ACF field that we set at the beginning.

Now let’s create a new code element inside our isotope-wrapper and paste the following HTML code:

<fieldset>
  <legend>Type:</legend>
  <div class="radio-wrapper">
    <input type="radio" id="single" name="type" value="single" class="radio">
    <label for="single">Single Article</label>
  </div>
  <div class="radio-wrapper">
    <input type="radio" id="series" name="type" value="series" class="radio">
    <label for="series">Series</label>
  </div>
</fieldset>

Just like we did before, let’s assign all the radio buttons to a radios variable:

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"');
}

And let’s apply the exact same logic we used for checkboxes:

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

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

			})

			filterRadioValue = arrRadio;

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

		}, 200));
	})
}

The same goes for the filter function:

// Default variable

var filterRadio = true;
var filterRadioValue = '*';

// init Isotope

var isotopeOptions = {
	itemSelector: '.isotope-selector',
	layoutMode: isotopeLayoutHelper,
	filter: (itemElem1, itemElem2) => {
		const itemElem = itemElem1 || itemElem2;
		
		// radio
		if (radios.length > 0) {
			filterRadio = filterRadioValue != '*' ? filterRadioValue.includes(itemElem.dataset.radio) : true;
		}

		return filterRadio;
	}
};

And that’s it!

Final code

Here is the final DOM tree of our filters in Bricks:

And that’s final Javascript code that you can copy/paste inside your 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"');
		}

		// 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,
			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));
			})
		}

		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);
		};
	});
});

Conclusion

If everything worked as expected, you should see this result on frontend:

Now you have a super lightweight fully-custom bricks-compatible facet function that you can add to your website. Cool isn’t it? ☺

  • Gerard Halligan

    Hi Maxime,

    Thank you for these tutorials. They are exactly what I needed for a project.
    I have the filtering working but the issue I have is that I have 2 post feeds on my home page that need to be filtered independently. Each is on their own tab. But, when I click on the filter for one, it filters the other as well.

    I tired to fix it by adding the wrapper’s parent ID to the isotopeOptions itemSelector
    itemSelector: ‘#’ + wrapper.parentElement.id + ‘ .isotope-selector’
    But it no joy.
    Do you know how I can do this?
    The website, so you can see whay I mean is https://explore.trupe.com/

    Thanks,
    Ger

    • Gerard Halligan

      I was able to fix this. It wasn’t an issue with the selector but with the global variables. When iso.arrange() is called, it applies the filterSelector to all wrapper selectors. So these global vars need to be saved as a key and value.

      var filterRes = true;
      var filterSelector = “*”;

      So to have it work for muiltiple wrappers on the same page, I added in a global Map() object. Then each wrapper will use a unique key to access the correct data.

      var map = new Map();

      You need to set the default values for the map in the wrapper for loop

      isotopeWrappers.forEach((wrapper) => {

      if(!map.has(wrapper.parentElement.id + ‘filterSelector’)){
      map.set(wrapper.parentElement.id + ‘filterRes’, true);
      map.set(wrapper.parentElement.id + ‘filterSelector’, ‘*’);
      }

      You then call the map.get in the filter when initialising isotopeOptions.

      if (filtersElem.length > 0) {
      filterRes = map.get(wrapper.parentElement.id + ‘filterRes’);
      filterSelector = map.get(wrapper.parentElement.id + ‘filterSelector’);

      You need to then save the filterRes before the return, on the filter

      map.set(wrapper.parentElement.id + ‘filterRes’, filterRes);

      And finally, you need to save the filterSelector at the end of the click event, just before you call iso.arrange()

      map.set(wrapper.parentElement.id + ‘filterSelector’, filterSelector);

      I’m only using the category filter, but you’d need to do the same if you have the other filters added. THere is probably an easier way to achieve this but it works for me.

  • Konstantin

    Hello, search, filter by reading time and tag buttons work, but checkboxes and radio buttons do not 🙁 when you click on any checkbox or radio button, all articles disappear.
    (translator)

    • Hi Konstantin, when the filter show no results, it means that the listener works correctly but the filter function return false for each item, which is often caused by the data-attribute not being the same as the filter value. In your case – after checking the website – the conflict was caused by capitalized terms in the data-attribute vs non capitalized values in the radio/checkbox inputs. Since it’s case sensitive, it returns no results. By making sure the terms are equals, it should work as expected.

      Cheers!

  • Hi Maxime,

    Not yet for “Sorting” or “load more/infinite scroll” or both ?

  • Hello,

    Really nice serie, what about “Sorting” ?

    From what I understand, for the moment there is no pagination or “load more” button or infinite scroll, is that correct ?

    Thanks and Regards,

    Nicolas.

Leave a Reply to Maxime Beguin (Cancel Reply)