🛠️ Dota Heroes: Filtering heroes (Part 1)

Hey ,

I'm thrilled to help you learn JavaScript. Unfortunately, you've landed on a page where you cannot access with your current purchase.

Please upgrade (use this link) access this content.

I'm super eager to help you learn more!

🛠️ Dota Heroes: Filtering heroes (Part 1)

We want to let users filter the list of heroes according to three categories:

  1. Attack type
  2. Primary attribute
  3. Role
Picture of the filters.

Let’s talk about the HTML for the filters before we continue.

Structure for the filters

All filters are placed in a <div> with a .filter class. This lets us use event delegation to listen to the change in all filters at the same time.

Each category of filters is wrapped in another <div> with a .filter-group class. We’ll give each .filter-group an id that corresponds to its category so it’s easier for us to locate it.

<div class="filters">
  <div class="filter-group" id="attack-type"> ... </div>
  <div class="filter-group" id="primary-attribute"> ... </div>
  <div class="filter-group" id="role"> ... </div>
</div>

In each .filter-group, there are checkboxes.

<div class="filter-group" id="attack-type">
  <div class="checkbox"> ... </div>
  <div class="checkbox"> ... </div>
  <div class="checkbox"> ... </div>
</div>

Here, we use a checkbox div to wrap the checkbox input element to create a custom input style. We need this because browsers limit us from styling a real checkbox.

<div class="checkbox">
  <input type="checkbox" id="melee" />
  <label for="melee">
    <span class="checkbox__fakebox"></span>
    <svg height="1em" viewBox="0 0 20 15">
      <use xlink:href="#checkmark"></use>
    </svg>
    <span>Melee</span>
  </label>
</div>

The .checkbox__fakebox is the squarish-looking checkbox container while the svg is a tick. If you click on a checkbox, the tick should show up.

(I already taught you how to write this kind of CSS in the Todolist, so I’m skipping the CSS in here).

Clicks the checkboxes

Filtering

We want to let users filter the list of heroes according to three categories:

  1. Attack type
  2. Primary attribute
  3. Role

Here, we can listen for a change event on .filters. (Event delegation pattern).

const filtersDiv = document.querySelector('.filters')

zlFetch(`${dotaApi}/heroStats`)
  .then(response => {
    // ...
    filtersDiv.addEventListener('change', event => {
      // ...
    })
  })

change will only trigger when an input element gets changed. In this case, it only triggers when a checkbox gets checked (or unchecked).

How checkbox filters work

Checkboxes are different from radio buttons.

Radios let you choose one option from a list of options, while checkboxes let you select many options from the list.

When used in a filtering system, checkboxes have this trait:

  1. If no checkboxes are checked, show everything.
  2. If one checkbox is checked, show results from that one selection.
  3. If two checkboxes are checked, show results that match either selection.

Here’s an example. Say you’re trying to book a place to stay on Airbnb. There are four possible types of rooms:

  1. Entire house
  2. Private room
  3. Hotel room
  4. Shared room

If you select nothing, it means you’re open to viewing all possible options.

If you select Private room, you expect to see private rooms in your search results.

If you select both private and hotel rooms, you expect to see both private and hotel rooms in the search results.

Types of rooms on airbnb.

Filtering by attack type

Every hero in Dota is classified into two attack types:

  1. Melee
  2. Ranged

To filter heroes by their attack types, we need to know which attack types are checked.

Here’s one way to do this:

  1. We can use the id to find the checkbox for one attack type.
  2. Then, can use checked to test if the checkbox is checked.

We can do the same for ranged. (And also the same for EVERY filter).

filtersDiv.addEventListener('change', event => {
  const melee = document.querySelector('#melee')
  const isMeleeChecked = melee.checked

  const ranged = document.querySelector('#ranged')
  const isRangedChecked = ranged.checked

  // ...
})

You can imagine the amount of code to grab all checkboxes.

There’s a shorter, cleaner way.

Selecting the checked checkboxes

First, we know the attack type .filter-group has an id of #attack-type. We can use querySelector to select this group.

Then, we use input:checked to select input elements that are checked

filtersDiv.addEventListener('change', event => {
  const attackTypeDiv = document.querySelector('#attack-type')
  const selectedCheckboxes = [...attackTypeDiv.querySelectorAll('input:checked')]
  console.log(selectedCheckboxes)
})
Logs the selected checkboxes

You can combine these two lines into a single querySelectorAll statement. The selector will look a little more complicated.

filtersDiv.addEventListener('change', event => {
  const selectedCheckboxes = [...document.querySelectorAll('#attack-type input:checked')]
})

Getting the attack types

If you look at each hero’s object, you’ll notice the attack_type property contains either Melee or Ranged.

The first hero is called Anti-Mage and has an attack type of 'Melee'.

If you look at the HTML, you’ll notice I chose to add the attack type as an id to each checkbox. Here’s the example for melee.

<div class="checkbox">
  <input type="checkbox" id="melee" />
  <label for="melee"> ... </div>
</div>

When we match attack types, we don’t need the <input> element. We just need the id of each selected attack type. We can use map to return an array that contains all selected attack types.

filtersDiv.addEventListener('change', event => {
  const selectedAttackTypes = [...document.querySelectorAll('#attack-type input:checked')]
    .map(checkbox => checkbox.id)
  console.log(selectedAttackTypes)
})
Logs selectedAttackTypes

Filtering the heroes

We want to get a list of heroes that has an attack type that was selected. This means we need to filter the heroes.

filtersDiv.addEventListener('change', event => {
  // ...
  const filtered = heroes.filter(hero => {
    // ...
  })
})

Each hero’s attackType property is written in title case. But the entries in selectedAttackTypes are lowercase. Their cases don’t match. (Melee vs melee).

We need to match their cases. We can do this by making everything lowercase.

filtersDiv.addEventListener('change', event => {
  // ...
  const filtered = heroes.filter(hero => {
    const attackType = hero.attack_type.toLowerCase()
  })
})

If the hero’s attackType is found in selectedAttackTypes we return a truthy expression to include the hero.

filtersDiv.addEventListener('change', event => {
  // ...
  const filtered = heroes.filter(hero => {
    const attackType = hero.attack_type.toLowerCase()
    return selectedAttackTypes.includes(attackType)
  })
})

Updating the DOM

After filtering the heroes, we need to update the DOM with the new list of filtered heroes.

To do this, we remove the current list of heroes by setting .heroes-list's innerHTML to ''.

filtersDiv.addEventListener('change', event => {
  // ...
  heroesList.innerHTML = ''
})

Then, we recreate the list of heroes from the filtered selection. After creating the list of heroes, we add them back into the DOM.

filtersDiv.addEventListener('change', event => {
  // ...
  const filtered = heroes.filter(/*...*/)

  heroesList.innerHTML = ''
  filtered.forEach(hero => {
    const li = document.createElement('li')
    li.classList.add('hero')
    li.innerHTML = `
      <a href="#">
        <span class="hero__name"> ${hero.localized_name} </span>
        <img src="https://api.opendota.com${hero.img}" alt="${hero.localized_name} image">
      </a>
    `
    heroesList.appendChild(li)
  })
})
Filtered heroes by attack type

Unchecked filters

When you uncheck the filters, you’ll notice there are no more heroes in the list.

Here’s why:

  1. selectedAttackType returns an empty array when no filters are checked
  2. [].includes will always return false (since the array is empty to begin with!).

But as you read above, if no filters are checked, we want to show all heroes. The easy way out is to return all heroes if no attack types were selected.

filtersDiv.addEventListener('change', event => {
  // ...

  const filtered = heroes.filter(hero => {
    if (selectedAttackTypes.length === 0) return true
    const attackType = hero.attack_type.toLowerCase()
    return selectedAttackTypes.includes(attackType)
  })
})
Shows all heroes when no filters are checked.

We’ll continue to filter the other two categories in the next lesson.