Writing a custom search for leaflet maps
The Problem
One of the issues with using open source web projects is that you depend on the maintainers/creators to fix them if anything goes wrong. In moving my water report search from my Flask website to a more modern JS/node set up, I found the search had broken.
The demos were also broken, even after a bit of tweaking with the basic JS.
After sinking quite enough time into trying to get this working, I decided enough was enough and started writing my own.
The Solution
The fix was to write my own, but sometimes things we use every day aren’t as simple as they seem.
The Search Box
The simplest way to go about doing this was to take an HTML input, and make a div with the search results appear below it when the user clicks onto the input. Unfortunately, this is a little harder than it might seem with HTML. If the element is in the normal document flow, as soon as it appears, it will push the elements below it down the page. I didn’t want this to happen, so I’ve used absolute positioning. This is not ideal, as it means if I change the other elements on the page, the search result position needs tweaking. There’s a GitHub issue on the private repo that I’ll get round to at some point.
<form class="search-box" use:clickOutside={'#water-search'} on:click_outside={closeDropdown}>
<div class="input-group mb-3" style="z-index: 1;">
<input type="text" id="water-search" on:click={toggleDropdown} autocomplete="off" />
</div>
{#if isDropdownOpen}
<div class="dropdown-search-results open">
<ul>
<button>Click outside this to hide this dropdown</button>
</ul>
</div>
{/if}
</form>
Displaying the Dropdown
As with all these projects, you start with the simple stuff, and build on top.
Clicking on the input runs a function that changes the isDropdownOpen
variable to true
, declared false
at the start. We use the conditional {#if isDropdownOpen}
to provide readable code.
let isDropdownOpen = false;
function toggleDropdown() {
isDropdownOpen = !isDropdownOpen;
}
function closeDropdown() {
if (isDropdownOpen) {
isDropdownOpen = false;
}
}
Displaying the div is done with the CSS:
.dropdown-search-results {
position: absolute;
width: calc(100% - 2rem);
max-height: 0;
overflow-y: hidden;
text-align: left;
border-radius: 5px;
display: none;
ul {
padding: 0;
}
button {
background: none;
color: var(--slate);
border: none;
padding: 0.5rem;
width: 100%;
text-align: left;
&:hover {
background: var(--lightAccent);
}
}
}
.search-box {
position: relative;
}
.dropdown-search-results.open {
display: block; /* Display changes from none to block */
background-color: var(--lighterAccent);
max-height: 32rem;
overflow-y: auto;
z-index: 500;
border-radius: 0.5rem;
margin-top: 0.5rem;
top: calc(100% + -1.5rem);
left: 0.7rem;
}
Click Outside Function
When the dropdown is displayed, we want the user to click outside the element and for it to disappear.
We do this by creating a clickOutside function, passing the HTML element into the function. The function sets up an event listener that checks for clicks outside the node, dispatching a custom event when this happens.
function clickOutside(node: HTMLElement, ignore?: string) {
const handleClick = (event: Event) => {
const target = event.target as HTMLElement;
if (!event.target || (ignore && target.closest(ignore))) {
return;
}
if (node && !node.contains(target) && !event.defaultPrevented) {
node.dispatchEvent(new CustomEvent("click_outside"));
}
};
document.addEventListener("click", handleClick, true);
return {
destroy() {
document.removeEventListener("click", handleClick, true);
},
};
}
use:clickOutside={'#water-search'}
on:click_outside={resultsClassClose}
We invoke this with the code: use:clickOutside={"#water-search"}
- that specifies the div we want to remove. The code on:click_outside={resultsClassClose}
is listening for the “clickoutside” event, and handles the change in class - as seen in the function above. Quite advanced stuff here, just to get our _basic functionality!
Here’s a demo:
Initiating the search
The search is a little simpler. We want to do this with a button, or have the user press enter. We then want to run a function with the search string as an input variable.
We bind the value of the text input to the waterSearchString
variable.
let waterSearchString
function onKeyDown(event) {
if (event.which === 13 || event.keyCode === 13 || event.key === "Enter") {
searchResults(waterSearchString);
}
}
on:keydown={onKeyDown}
bind:value={waterSearchString}
We monitor for the user pressing the enter key using the onKeyDown()
function. Unfortunately, there’s a few ways to do that, we have to do all of them!
<button
id="send-search"
class="btn btn-primary"
type="submit"
on:click={searchResults(waterSearchString)}
>
We add a button to monitor for the search string, if the user clicks the button, the searchResults()
function is run with the search input string.
Here’s a demo:
Search for your a geographic location!
Grabbing the data
// imported function
export async function grabWater(searchString) {
// Ping the OSM api for the data.
let mapUrl =
"https://nominatim.openstreetmap.org/search?format=json&q=" + searchString;
let response = await fetch(mapUrl);
let data = await response.json();
return data;
}
// in the main svelte page
let waterResults = [];
async function searchResults(searchString) {
waterResults = await grabWater(searchString);
}
Here, I’m using the OpenStreetMap api to grab some data using the search string in the previous step.
The code is fairly self-explanatory, using JS fetch, I’m grabbing some JSON data and returning it. I’m then setting the waterResults array variable.
Displaying the Results
{#if waterResults.length > 0}
{#each waterResults as water}
<button
on:click={() => (
setLocation(water.lat, water.lon, circle, myMap, kmRadius, water.display_name),
console.log('Location set in search results!')
)}>{water.display_name}</button
>
{/each}
{:else}
<ul>
<button> Press enter or click search when you're ready </button>
</ul>
{/if}
I don’t want the dropdown to display any results if there’s none, but I’m going to use it to help the user understand what to do, so there’s a text prompt telling them to click or press enter to perform the search - see the Initiating the Search section.
Here I take the waterResults array, and use it to populate the dropdown list with a svelte loop. I also use it to populate the on:click event variables. This runs the setLocation()
function which changes the map location. I won’t go into details about the setLocation()
function here as it’s beyond the scope of this little description.
Here’s a demo: