How to create your own search API for a static website with JavaScript and PHP (part 3)
On Tuesday, I shared how I created my own search API for my static website with PHP. And yesterday, I walked you through how I built a custom web component to call the API and render search results.
Today, I wanted to wrap up the series by sharing how I added two features:
- Filtering search results by type.
- Custom URLs for search queries and bookmarking.
Let’s dig in!
Filtering search results by type
For this feature, I first added a createFilterHTML() method.
It accepts an object of types that exist in the search results, and how many results are that type. It might look like this…
let types = {
Articles: 42,
Courses: 3,
Toolkit: 7
};
Inside my function use the Object.keys() method to get the keys from the types object. If there are none, I return an empty string ('').
Otherwise, I return an HTML string with a collection of checkbox inputs, one for each type. I have mine styled as an inline list, but you can style them however you want.
Each input has a name property of search-filter, it’s value is the type, and it’s checked by default.
/**
* Create the filter HTML
* @param {Object} types The content types
* @return {String} The HTML string
*/
createFilterHTML (types) {
let keys = Object.keys(types);
if (keys.length < 1) return '';
return `
<fieldset>
<legend>Filter results by type</legend>
${keys.sort().map(function (type) {
let count = types[type];
return `
<label>
<input type="checkbox" name="search-filter" value="${type}" checked>${type} (${count})
</label>`;
}).join('')}
</fieldset>`;
}
Next, I updated my createResultsHTML() function.
First, I created a types object ({}) to hold my type data. Instead of immediately returning my HTML string, I assign it to a variable.
Inside the Array.prototype.map() callback function, I either add the article.type to my types object with a value of 1, or increase the type count by 1. This is how I track how many of each type are in the results.
Finally, I pass my types object into the createFilterHTML() method, and return the resulting string and my html string for the results.
/**
* Create the markup for results
* @param {Array} results The results to display
* @return {String} The results HTML
*/
createResultsHTML (results) {
let types = {};
let html = results.map(function (article) {
types[article.type] = types[article.type] ? types[article.type] + 1 : 1;
return `
<div>
<aside>
<strong>${article.type}</strong> - <time datetime="${article.datetime}" pubdate>${article.date}</time>
</aside>
<h2>
<a href="${article.url}">${article.title}</a>
</h2>
${article.summary.slice(0, 150)}...
</div>`;
}).join('');
return this.createFilterHTML(types) + html;
}
Now, the filters are displayed in the UI.
Hiding and showing search results when filters change
Next, I updated my handleEvent() method. Instead of just running my search code, I dynamically run an on* method on my Web Component instance.
For example, a submit event would trigger the onsubmit() method to run.
/**
* Handle events
*/
handleEvent (event) {
this[`on${event.type}`](event);
}
Then, I copy/pasted the code that was previously in the handleEvent() method into an onsubmit() method.
/**
* Handle submit events
*/
onsubmit (event) {
event.preventDefault();
this.search(new FormData(this.form));
}
Back in my createResultsHTML() method, I added a [data-search-type] attribute to each search result, with the article.type as its value.
return `
<div data-search-type="${article.type}">
...
</div>`;
Then, I created an oninput() event to handle changes to my filters.
In this method, I use the Element.querySelectorAll() method to get all of the [name="search-filter"] items that are :checked. Then I use the Array.from(), Array.prototype.map(), and Array.prototype.join() methods to create a [data-search-type="*"] selector string, where * is the type of item that should be visible but is currently [hidden].
/**
* Handle checkbox changes
*/
oninput (event) {
// Get the values to show and hide
let show = Array.from(this.results.querySelectorAll('[name="search-filter"]:checked')).map(input => `[data-search-type="${input.value}"][hidden]`).join(',');
}
For example, let’s say Articles and Toolkit are checked, but Courses is not.
The resulting show string would look like this…
let show = '[data-search-type="Articles"][hidden], [data-search-type="Toolkit"][hidden]';
I repeat this process, this time looking for filters that are :not(:checked), and getting the matching search results that are :not([hidden]).
/**
* Handle checkbox changes
*/
oninput (event) {
// Get the values to show and hide
let show = Array.from(this.results.querySelectorAll('[name="search-filter"]:checked')).map(input => `[data-search-type="${input.value}"][hidden]`).join(',');
let hide = Array.from(this.results.querySelectorAll('[name="search-filter"]:not(:checked)')).map(input => `[data-search-type="${input.value}"]:not([hidden])`).join(',');
}
If there are items to show, I pass the select into the Element.querySelectorAll() method. Then, I use a for...of loop to loop through them, and the Element.removeAttribute() method to remove the [hidden] attribute.
I do the same thing for items that I should hide, this time adding the [hidden] attribute with the Element.setAttribute() method.
// Show hidden elements
if (show) {
for (let elem of this.results.querySelectorAll(show)) {
elem.removeAttribute('hidden');
}
}
// Hide visible elements
if (hide) {
for (let elem of this.results.querySelectorAll(hide)) {
elem.setAttribute('hidden', '');
}
}
One final touch to make this work: in my constructor(), I add an input listener on the this.results element.
// Listen for events
this.form.addEventListener('submit', this);
this.results.addEventListener('input', this);
Updating the URL
Inside the search() function, I use the history.pushState() method to update the URL without reloading the page.
I pass in an empty object for the state, though if your app actually uses browser state, you could pass in history.state instead. I pass in an empty string for the deprecated second argument, and the current URL with ?s and the query value as the query string parameter.
/**
* Search the API
* @param {FormData} query The form data to search form
*/
async search (query) {
try {
// Show status message
this.notify.innerHTML = '<p<em>Searching...</em></p>';
this.results.innerHTML = '';
// Update the URL
history.pushState({}, '', window.location.origin + window.location.pathname + '?s=' + query.get('q'));
// ...
} catch (error) {}
}
Now, I have a URL query string parameter I can check for when the page loads to automatically run a search.
I created one last method, onload(). In it, I use the new URLSearchParams() object to get the value of the s query string from the window.location.search property.
If no query exists, I can return to end early.
/**
* If there's a query string search term, search it on page load
*/
onload () {
let query = new URLSearchParams(window.location.search).get('s');
if (!query) return;
}
Otherwise, I create a new FormData() object, and assign my query to the q property (the same as if someone had typed it into the form).
I pass the formData into the search() method to kick off a call to the search API. Then, I get the search input field and update its value to the query.
/**
* If there's a query string search term, search it on page load
*/
onload () {
let query = new URLSearchParams(window.location.search).get('s');
if (!query) return;
let formData = new FormData();
formData.set('q', query);
this.search(formData);
let input = this.form.querySelector('[name="q"]');
input.value = query;
}
Now, when someone visits a search page they had bookmarked, the site automatically displays the saved search query results.
Want to build cool stuff like this?
I can help!
I offer consulting services to help developers and developer teams write code that’s faster, simpler, and easier to maintain.
I also teach developers how to build a simpler web through courses and workshops.
Feel free to reach out with any questions or comments.