Introduction

One of the projects I'm involved with at work had an interesting set of problems and I thought I would share how I solved one them. One of the sites we're building has an A - Z index page. It's basically a massive link far (over 750 links and counting) that are listed alphabetically in groups like a dictionary.

My job was to:

  • add an input field that would filter the list
  • display only the results and their headings
  • have text to show the total number of results
  • be able to reset the list by hitting the escape key
  • make it all accessible

So, let's start with some markup!

Markup

The markup for this is pretty straightforward. Basically it's this

<main>
  
  <label for="search">Search the list
    <input type="search" id="search" name="search" />
  </label>
  <p class="results"><span class="results-count">0</span> item<span class="is-plural">s</span> found.</p>
  
  <!-- begin lists -->
  <section class="search-contiain">
    <!-- id's allow linking directly -->
    <p id="a">A</p>
    <ul>
      <!-- all the "A" links as <li>'s -->
    </ul>
  </section>
  
  <!-- repeat the section for B - Z... -->
  
  <!-- end lists -->
</main>

This way, each letter gets its own section and list of items.

Next, I wanted to be able to be able to hide or show items as I wanted. That means it's CSS time!

CSS

The results text is going to get visibility: hidden to start with. It won't be visible, but it will take up space on the page. That way, the elements around the search box won't shift when it becomes visible. I also wanted a utility class called .hidden which would add display: none; to any elements it was applied to. This will also remove the space they take up in the document collapsing the list to just the items that are found.

.results {
  visibility: hidden;
}

.hidden {
  display: none;
}

Why use two different approaches to showing and hiding? Well aside from the page layout issues, there are web accessibility issues! If I used visibility or height: 0, people search the list and using a screen reader would still have to hear all the results. Using display: none removes the elements (and their descendants) from what's called the accessibility tree. That way, they'll get an equal experience of just hearing the results they've searched for.

Now for the javascript!

JavaScript

Basically we need to create three sections

  • a bunch of variables
  • a utility function to manage the hidden class
  • an event handler for the input

One other thing to note is how to handle collections of DOM elements. The way I want to treat all of the sections with the search-contain class is as arrays. That way, I can use filter, map, etc... However the element.querySelectorAll() method returns a NodeList which is a type of iterable that's similar to an array, but doesn't have all the array methods.

To create an array from a NodeList, there are two basic ways depending on whether you need to support older browsers or you can polyfill your script. The "old" way is to use

const myArray = Array.prototype.slice.call(myNodeList)

Not exactly concise, but it'll run anywhere and you don't have to polyfill it. Otherwise, you can use

const myArray = Array.from(myNodeList)

Honestly, if you need to support Internet Explorer, go with the first one. It's guaranteed to work.

Variables

Next, here are some variables!

const input = document.querySelector('input')
const containers = Array.from(document.querySelectorAll('.search-contain'))
const items = Array.from(document.querySelectorAll('ul li'))
const results = document.querySelector('.results')
const resultsCount = document.querySelector('.results-count')
const isPlural = document.querySelector('.is-plural')
const count = items.length

These give me enough to start working with. Next, I want to create a small utility function to add or remove the hidden class.

Utility Function

As a function, it doesn't do much. It takes two arguments.

  • an element to act on
  • a boolean (should the element be hidden or not)
const toggleClass = (el, isHidden) => {
  isHidden ? el.classList.add('hidden') : el.classList.remove('hidden')
}

Why not use classList.toggle()? Because then on every keypress all the elements will keep toggling between visible and hidden. This way, I can be more declarative about what should happen.

Next, the meat of the thing. An event listener!

Event Listener

In order to make the filter feel dynamic, I want to use the keyup event on the input.

const input = document.querySelector('input')
const containers = Array.from(document.querySelectorAll('.search-contain'))
const items = Array.from(document.querySelectorAll('ul li'))
const results = document.querySelector('.results')
const resultsCount = document.querySelector('.results-count')
const isPlural = document.querySelector('.is-plural')
const count = items.length

// utility
const toggleClass = (el, isHidden) => {
  isHidden ? el.classList.add('hidden') : el.classList.remove('hidden')
}

// begin the event listener
input.addEventListener('keyup', evt => {})

Before going any further, let's think about what needs to happen.

  • This is a search bar, if the user hits the escape key, the search should clear and all results show
  • If there's nothing in the search bar (the user hit delete until it was empty) all the results should show
  • The search should be case insensitive
  • The search should not search for empty strings
  • We have no searches of just one character. That wouldn't make sense for a links page.
  • Elements should appear or disappear as needed
  • An accurate count of the results should be kept

Let's deal with the escape key first. When the user's in the search box, if they hit escape, clear the search box...

input.addEventListener('keyup', evt => {
  if (evt.which === 27) {
    evt.taret.value = ''
    // prevent the keyup from bubbling to any other elements that happening to be listening...
    evt.stopImmediatePropagation()
  }
})

Next, I want an if/else block that I can use depending on the length of the search term. If it's greater than 1, do the filtering, otherwise show all the items.

input.addEventListener('keyup', evt => {
  if (evt.which === 27) {
    evt.taret.value = ''
    // prevent the keyup from bubbling to any other elements that happening to be listening...
    evt.stopImmediatePropagation()
  }
  
  // handle filtering
  if (evt.target.value.length > 1) {
    // filter items
  } else {
    // show everything
  }
})


The search should be case insensitive and strip out "extra" white space. From there, I can use the array filter method on all the items.

input.addEventListener('keyup', evt => {
  if (evt.which === 27) {
    evt.taret.value = ''
    // prevent the keyup from bubbling to any other elements that happening to be listening...
    evt.stopImmediatePropagation()
  }
  
  if (evt.target.value.length > 1) {
    const searchTerm = evt.target.value.trim().toLowerCase()
    const filteredItems = items.filter(item => {
      
      // does the item text include the search term?
      if (!item.innerText.toLowerCase().includes(searchTerm)) {
     
        // hide the item and return it so we keep an count
        toggleClass(item, true)
        return item
      } else {
        // keep the item visible
        toggleClass(item, false)
      }
    })
  } else {
  // show everything
  }
})

Why is else block in the filter necessary? Good question! Without it, if the user changes their mind and searches for something else, the items will still be hidden from the previous search.

Next, I want to get rid of the section containers if all their items are hidden. To do that, I'll compare the number of items in the container to the number that are hidden. If those numbers match, I'll hide the container.

input.addEventListener('keyup', evt => {
  if (evt.which === 27) {
    evt.taret.value = ''
    // prevent the keyup from bubbling to any other elements that happening to be listening...
    evt.stopImmediatePropagation()
  }
  
  if (evt.target.value.length > 1) {
    const searchTerm = evt.target.value.trim().toLowerCase()
    const filteredItems = items.filter(item => {
      // does the item text include the search term?
      if (!item.innerText.toLowerCase().includes(searchTerm)) {
        // hide the item and return it so we keep an count
        toggleClass(item, true)
        return item
      } else {
        // keep the item visible
        toggleClass(item, false)
      }
    })
 
    containers.map(container => {
      const items = container.querySelectorAll('li')
      const hiddenItems = container.querySelectorAll('li.hidden')

      // ternary operator time!
      items.length === hiddenItems.length ?
        toggleClass(container, true) :
        toggleClass(container, false)
    })

  } else {
    // show everything
  }
})

Next, it's time to display the results and make the text grammatical. Either that or reset everything...

input.addEventListener('keyup', evt => {
  if (evt.which === 27) {
    evt.taret.value = ''
    // prevent the keyup from bubbling to any other elements that happening to be listening...
    evt.stopImmediatePropagation()
  }
  
  if (evt.target.value.length > 1) {
    const searchTerm = evt.target.value.trim().toLowerCase()
 
    const filteredItems = items.filter(item => {
   
      // does the item text include the search term?
      if (!item.innerText.toLowerCase().includes(searchTerm)) {
     
        // hide the item and return it so we keep an count
        toggleClass(item, true)
        return item
      } else {
        // keep the item visible
        toggleClass(item, false)
      }
    })
 
    containers.map(container => {
      const items = container.querySelectorAll('li')
      const hiddenItems = container.querySelectorAll('li.hidden')

      // ternary operator time!
      items.length === hiddenItems.length ?
        toggleClass(container, true) :
        toggleClass(container, false)
    })

    // handle the results text
  
    // should the text be plural or not?
    document.querySelector('.is-plural').innerText = count -       filteredItems.length === 1 ? '' : 's'
  
    // show the text
    results.style.visibility = 'visible'
  
    // show the count
    resultsCount.innerText = count - filteredItems.length
  
  } else {
    // show everything
    results.style.visibility = 'hidden'
    resultsCount.innerText = '0'
    items.map(item => toggleClass(item, false))
    containers.map(container => toggleClass(container, false))
  }
})

Conclusion

If you want to see a working demo, check out this codepen.

This may have been a fairly straightforward problem to solve, but it was still fun to work on.