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 totrue
, so even if there are no filter buttons on your page, the filter function will returntrue
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 newfilter
object that will take care of the filtering logic. - The main filter condition is: if the
filterSelector
is set to*
, it will returntrue
for all the items – thus no filtering will be applied, otherwise it will filter the items that contain the samedata-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 totrue
, so even if there is no quick search input on your page, the filter function will returntrue
and the other filters will still work as expected. - we added a
filter
object inside theisotopeOptions
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 returnsfalse
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? ☺
13 comments
David Demastus
Is there a way to use a dropdown as a filter?
David Demastus
Agh sorry, it doesn't like when I use carets.... a "select" dropdown field.
David Demastus
by using the // // field?
Daniele
Thanks Maxime for these awesome tutorials. Your code is working well but I'd like to filter like this exemple ( https://codepen.io/desandro/pen/MebyMR ), without jQuery, with buttons (not checkboxes). I have a cpt with several custom taxonomies to use as filters. How have I to modify your code to achieve that? I've tried in many ways but with no luck. Thanks
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.
Maxime Beguin
Hey Gerard, sorry I missed your first comment. Happy to hear you get it sorted!
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)
Maxime Beguin
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!
nickwe
Hi Maxime,
Not yet for "Sorting" or "load more/infinite scroll" or both ?
Maxime Beguin
Both and more. Check it out: https://brickslabs.com/how-to-create-filters-with-isotopejs-in-bricks-part-4-ajax-filter-and-infinite-loops-sorting-list-view-and-disable-animations/
nickwe
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.
Maxime Beguin
Hey Nicolas,
Not yet… ????