Skip to main content Accessibility Feedback

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:

  1. Filtering search results by type.
  2. 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.