Read time: 7 mins
HTMX Search
Out of the Box
The htmx docs have a basic example of active search. It's inticing to have a feature like that with just so few lines of declarative html. Let's look at it real quick before we get into my more involved example.
<h3>
Search Contacts<span class="htmx-indicator">
<img src="/img/bars.svg"/> Searching...
</span>
</h3>
<input class="form-control" type="search"
name="search" placeholder="Begin Typing To Search Users..."
hx-post="/search"
hx-trigger="input changed delay:500ms, search"
hx-target="#search-results"
hx-indicator=".htmx-indicator">
<table class="table">
<thead>
<tr>
<th>First Name</th>
<th>Last Name</th>
<th>Email</th>
</tr>
</thead>
<tbody id="search-results">
</tbody>
</table>
This is pretty straightforward. The input triggers on change and
posts to /search
with it's value.
Then it pulls out the #search-results
and replaces
tbody
element with it.
Easy peasy … but what if you want to add a checkbox for some kind of filter? What if you want the search inputs preserved in the url for sharing/refreshing support?
That's the issue I ran into. That functionality complicates the
process but I think I've figured out a minimal way to accomplish this
while still staying true to HATEOAS
and not adding too much
overhead.
Expanded
I'm building a personal insights app with Clojure, Biff, XTDB, Tailwindcss, and HTMX. Part of that app includes habit tracking. This implementation of typeahead search focuses on just the page that lists the habits for editing and review.
Other Inputs
For my habits tracker I needed to add a boolean attribute called
sensitive
. This acts as a flag to hide the habit from
general views. To see sensitive habits the user has to explicitly
indicate they should present. I tend to demo my apps to friends and
sometimes I don't want all of my habits to be on display.
So now I have two inputs, a text input and a checkbox. In the future I might add more inputs for different filtering options. So I need to include all of that input data in a single request to the server.
The best way I could think to do that was with a form component. Here
is some code in Clojure/Hiccup that shows the form.
biff/form
is a framework convenience that outputs
[:form]
element with some magic for hidden elements and a
csrf token.
defn habits-search-component [{:keys [sensitive search]}]
(:div.my-2
[
(biff/form:id "habit-search"
{:hx-post "/app/habits"
:hx-swap "outerHTML"
:hx-trigger "search delay:500ms"
:hx-select "#habits-list"
:hx-target "#habits-list"}
:div.flex.flex-col.justify-center.my-6
[
:input.form-control.w-full.md:w-96.mb-2
[:type "search"
{:name "search"
:script "on keyup htmx.trigger('#habit-search', 'search', {})"
:placeholder "Begin Typing To Search Habits..."}]
:div.flex.flex-row.justify-start.items-center
[:label.mr-4 {:for "sensitive"} "Sensitive"]
[:input.rounded.mr-2
[:type "checkbox"
{:name "sensitive"
:script "on change htmx.trigger('#habit-search', 'search', {})"
:autocomplete "off"
:checked sensitive}]]])])
This works for keeping all the inputs bundled together with each
request. On any action the inputs fire off a custom event that triggers
the form. There is no submit button. The :script
attribute
accomplishes this with a touch of hyperscript.
The habits page backend endpoint needs to also accept a post request and utilize the input data to filter the results. In my backend I have the same clojure function that serves the initial GET request also server the POST request.
defn habits-page
("Accepts GET and POST. POST is for search form."
:keys [session biff/db params]}]
[{let [user-id (:uid session)
(:user/keys [email time-zone]} (xt/entity db user-id)
{
habits (habits-query (pot/map-of db user-id)):sensitive checkbox-true?)
sensitive (some-> params :search search-str-xform)]
search (some-> params
(ui/page
{}:div
[
(header (pot/map-of email)):button.bg-blue-500.hover:bg-blue-700.text-white.font-bold.py-2.px-4.rounded.w-full.md:w-96.mt-6
[;; TODO not implemented yet
"Add habit"]
:sensitive sensitive :search search})
(habits-search-component {:div {:id "habits-list"}
[->> habits
(filter (fn [{:habit/keys [name notes]
(:habit/sensitive
this-habit-is-sensitive :xt/id}]
id let [matches-name (str/includes? (str/lower-case name) search)
(
matches-notes (str/includes? (str/lower-case notes) search)]and (or sensitive
(-> id (= edit-id))
(not this-habit-is-sensitive))
(or matches-name
(
matches-notes)))))map habits-list-item))]]))) (
This works well, and we could stop there. However, I want one more thing. I want the search paramters to be synced to query parameters. Then if the user shares a link or refreshes the page the search results are preserved.
Query Params Too
The most minimally viable way I could think to sync the state of search to the query parameters of the url was to use a single javascript function and to alter the backend a little bit.
Let's start with the js function. Biff has a static js file called
main.js
in a resources directory that is for one off
functions like this. It has no build step of any kind and so far, this
is the only function I've added to that file.
function setURLParameter(paramName, value) {
console.log("setting url param: ", paramName, value)
const url = new URL(window.location);
// if the value is an empty string or null remove it otherwise set it
if (value === '' || value === null) {
.searchParams.delete(paramName);
urlelse {
} .searchParams.set(paramName, value.toString());
url
}// keep the url bar in sync
window.history.pushState({}, null, url.toString());
}
All it does is take in a name and a value. It uses
URL.searchParams
and window.history.pushState
to add and remove values and keep the browser in sync. Pretty
simple.
Now how does our search component call this? With a smidge more
hypserscript. Below is the full definition of my current search
component function. Notice the changes to the :script
attribute of each input.
defn habits-search-component [{:keys [sensitive search]}]
(:div.my-2
[
(biff/form:id "habit-search"
{:hx-post "/app/habits"
:hx-swap "outerHTML"
:hx-trigger "search delay:500ms"
:hx-select "#habits-list"
:hx-target "#habits-list"}
:div.flex.flex-col.justify-center.my-6
[
:input.form-control.w-full.md:w-96.mb-2
[merge {:type "search"
(:name "search"
:placeholder "Begin Typing To Search Habits..."
:script "on keyup setURLParameter(me.name, me.value) then htmx.trigger('#habit-search', 'search', {})"}
when (not (str/blank? search))
(:value search}))]
{
:div.flex.flex-row.justify-start.items-center
[:label.mr-4 {:for "sensitive"} "Sensitive"]
[:input.rounded.mr-2
[:type "checkbox"
{:name "sensitive"
:script "on change setURLParameter(me.name, me.checked) then htmx.trigger('#habit-search', 'search', {})"
:autocomplete "off"
:checked sensitive}]]])])
Now the :script
attribute calls the
setURLParameter
function with the name and value of the
input the attribute is on. me
is a reserved symbol in
hyperscript for this purpose.
Changing the backend endpoint to accommodate query params and form
params was pretty straightfoward. Below is the full
habits-page
component. The important change is within the
let
block and the assignment of the sensitive
and search
attributes.
defn habits-page
("Accepts GET and POST. POST is for search form as body."
:keys [session biff/db params query-params]}]
[{let [user-id (:uid session)
(:user/keys [email time-zone]} (xt/entity db user-id)
{
habits (habits-query (pot/map-of db user-id)):edit (java.util.UUID/fromString))
edit-id (some-> params or (some-> params :sensitive checkbox-true?)
sensitive (:sensitive checkbox-true?))
(some-> query-params or (some-> params :search search-str-xform)
search (:search search-str-xform)
(some-> query-params "")]
(ui/page
{}:div
[
(header (pot/map-of email)):button.bg-blue-500.hover:bg-blue-700.text-white.font-bold.py-2.px-4.rounded.w-full.md:w-96.mt-6
["Add habit"]
:sensitive sensitive :search search})
(habits-search-component {:div {:id "habits-list"}
[->> habits
(filter (fn [{:habit/keys [name notes]
(:habit/sensitive
this-habit-is-sensitive :xt/id}]
id let [matches-name (str/includes? (str/lower-case name) search)
(
matches-notes (str/includes? (str/lower-case notes) search)]and (or sensitive
(-> id (= edit-id))
(not this-habit-is-sensitive))
(or matches-name
(
matches-notes)))))map (fn [z] (habit-list-item (-> z (assoc :edit-id edit-id))))))]]))) (
Basically this changes is saying let the search
and
sensitive
symbols be the form params if present or the
query params. If neither is present then use a default value – an empty
string and false in this case.
Now as the user types a search string or checks a box htmx will post for new habits list content and also keep the url in sync. If the user bookmarks the url and comes back to it the backend will act on those search inputs and return the exact same page.
Overview
To wrap it up here is a sequence diagram of how this flows.
sequenceDiagram browser ->> server: GET /habits server ->> browser: Habbits page + scripts/stylesheets note over browser: Start typing in search box or press filter toggle browser --> browser: setURLParameter(n,v) & 'search' event browser ->> server: POST /habits server ->> browser: Habbits page (hx-target habits-list) browser --> browser: replace habits-list
To recap this accomplishes the typeahead search functionality with extra search inputs beyond just a string of text to search on. It also keeps all search state encoded in the url to allow deep linking.