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.