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.