Back to "A Smarter Search Dropdown with HTMX"

This is a viewer only at the moment see the article on how this works.

To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk

This is a preview from the server running through my markdig pipeline

Alpine.js HTMX

A Smarter Search Dropdown with HTMX

Monday, 16 September 2024

Introduction

In a previous post I showed you how to create a search dropdown using Alpine.js and HTMX then I showed how we could enable Cross-Site Request Forgery protection using the AntiforgeryRequestToken in ASP.NET Core with JavaScript using HTMX to implement a Donut Hole cache. One outstanding issue was how it loaded pages.

The Problem

The issue was I was using HTMX AJAX to do the requested page loading once you had selected the result from the drop down page. This only KINDA worked.

  selectResult(result) {
            htmx.ajax('get', result.url, {
                target: '#contentcontainer',  // The container to update
                swap: 'innerHTML',            // Replace the content inside the target
            }).then(function() {
                history.pushState(null, '', result.url);
                window.scrollTo({
                    top: 0,
                    behavior: 'smooth'
                });
            });

The issue was that while this would load the right page and update the shown URL with the new one, it messed up the back button. As the page WASN'T REALLY loaded into the history properly.

As with my last article on back button shennanigans this was something I wanted to fix.

The Solution

As with previously the solution was to let HTMX handle this directly. To do this I updated my template I use for search results.

_typeahead.cshtml

<div x-data="window.mostlylucid.typeahead()" class="relative" id="searchelement" x-on:click.outside="results = []">
    @Html.AntiForgeryToken()
    <label class="input input-sm bg-neutral-500 bg-opacity-10 input-bordered flex items-center gap-2">
        <input
            type="text"
            x-model="query"
            x-on:input.debounce.200ms="search"
            x-on:keydown.down.prevent="moveDown"
            x-on:keydown.up.prevent="moveUp"
            x-on:keydown.enter.prevent="selectHighlighted"
            placeholder="Search..."
            class="border-0 grow input-sm text-black dark:text-white bg-transparent w-full"/>
        <i class="bx bx-search"></i>
    </label>
    <!-- Dropdown -->
    <ul x-show="results.length > 0"
        id="searchresults"
        class="absolute z-100 my-2 w-full bg-white dark:bg-custom-dark-bg border border-1 text-black dark:text-white border-neutral-600 rounded-lg shadow-lg">
        <template x-for="(result, index) in results" :key="result.slug">
            <li :class="{
                'dark:bg-blue-dark bg-blue-light': index === highlightedIndex,
                'dark:hover:bg-blue-dark hover:bg-blue-light': true
            }"
                class="cursor-pointer text-sm p-2 m-2">
                <!-- These are the key changes.-->
                <a
                    x-on:click="selectResult(index)"
                    @* :href="result.url" *@
                    :hx-get="result.url"
                    hx-target="#contentcontainer"
                    hx-swap="innerHTML"
                    hx-push-url="true"
                    x-text="result.title"
                   >
                </a>
                <-- End of changes -->
            </li>
        </template>
    </ul>

</div>

You'll see that I now generate proper HTMX links in this code block. Letting us use the correct HTMX behaviour.

typeahead.js

To enable this in my backend JavaScript code I added the following to my search method (shown below). The this.$nextTick is an Alpine.js construct that delays this until Alpine has finished processing the template I showed above.

I then use htmx.process() on the search element which will ensure the HTMX attributes work as expected.


.then(data => {
 this.results = data;
this.highlightedIndex = -1; // Reset index on new search
 this.$nextTick(() => {
    htmx.process(document.getElementById('searchresults'));
 });
})
typeahead.js search
   search() {
            if (this.query.length < 2) {
                this.results = [];
                this.highlightedIndex = -1;
                return;
            }
            let token = document.querySelector('#searchelement input[name="__RequestVerificationToken"]').value;
            console.log(token);
            fetch(`/api/search/${encodeURIComponent(this.query)}`, { // Fixed the backtick and closing bracket
                method: 'GET', // or 'POST' depending on your needs
                headers: {
                    'Content-Type': 'application/json',
                    'X-CSRF-TOKEN': token // Attach the AntiForgery token in the headers
                }
            })
                .then(response => {
                    if(response.ok){
                        return  response.json();
                    }
                    return Promise.reject(response);
                })
                .then(data => {
                    this.results = data;
                    this.highlightedIndex = -1; // Reset index on new search
                    this.$nextTick(() => {
                        htmx.process(document.getElementById('searchresults'));
                    });
                })
                .catch((response) => {
                    console.log(response.status, response.statusText);
                    if(response.status === 400)
                    {
                        console.log('Bad request, reloading page to try to fix it.');
                        window.location.reload();
                    }
                    response.json().then((json) => {
                        console.log(json);
                    })
                    console.log("Error fetching search results");
                });
        }

Later on once a page is selected I handle the code to select the page, click on the link an clear the results (to close the search box).

selectHighlighted() {
            if (this.highlightedIndex >= 0 && this.highlightedIndex < this.results.length) {
                this.selectResult(this.highlightedIndex);
                
            }
        },

        selectResult(selectedIndex) {
       let links = document.querySelectorAll('#searchresults a');
       links[selectedIndex].click();
            this.$nextTick(() => {
                this.results = []; // Clear the results
                this.highlightedIndex = -1; // Reset the highlighted index
                this.query = ''; // Clear the query
            });
        }

This is selected through the onclick of the link in the search results.

 <a
  x-on:click="selectResult(index)"
  :hx-get="result.url"
  hx-target="#contentcontainer"
  hx-swap="innerHTML"
  hx-push-url="true"
  x-text="result.title"
  >
  </a>

Which will then load the page and update the URL correctly.

I also have code in the parent box whick allows you to use the arrow keys and enter.

    <label class="input input-sm bg-neutral-500 bg-opacity-10 input-bordered flex items-center gap-2">
        <input
            type="text"
            x-model="query"
            x-on:input.debounce.200ms="search"
            x-on:keydown.down.prevent="moveDown"
            x-on:keydown.up.prevent="moveUp"
            x-on:keydown.enter.prevent="selectHighlighted"
            placeholder="Search..."
            class="border-0 grow input-sm text-black dark:text-white bg-transparent w-full"/>
        <i class="bx bx-search"></i>
    </label>

You'll see that this has all the code necessary to enable you to just hit enter and navigate to the selected page.

In Conclusion

Just a quick update article to the existing search dropdown to enhance the user experience when using search. Again this is a MINIMAL user facing change but just enhances the user experience; who as a web developer are your primary concern (beyond getting paid :)).

logo

©2024 Scott Galloway