Updated 2023-06-23: My initial solution started failing in a Chromium browser, so I had to create a more robust workaround. See below.
HTMX has done wonders to help me enjoy my web development tasks.
Recently, I became aware of a cross-browser issue with it and <select>
elements.
Let’s say we wanted to use HTMX to populate parts available for specific makes of cars. We could use a select
element to enable the user to choose a make:
<label>
Choose the make of your car
<select hx-target="#display-parts">
<option></option>
<option hx-get="/parts/make/mercedes">Mercedes</option>
<option hx-get="/parts/make/aston">Aston Martin</option>
<option hx-get="/parts/make/delorian">Delorian</option>
<option hx-get="/parts/make/redbull">Red Bull</option>
</select>
</label>
<ul id="display-parts">
<li>Choose a car manufacturer.</li>
</ul>
If a user chose the Delorian option, HTMX would send an AJAX request to our server with the URL /parts/make/delorian
. Our server-side code could return the parts the database has for that make:
<li>Flux capacitor</li>
<li>Mr. Fusion</li>
<li>Cup holder</li>
<li>Dog</li>
And HTMX will populate the parts in the HTML:
<label>
Choose the make of your car
<select hx-target="#display-parts">
<option></option>
<option hx-get="/parts/make/mercedes">Mercedes</option>
<option hx-get="/parts/make/aston">Aston Martin</option>
<option hx-get="/parts/make/delorian" selected>Delorian</option>
<option hx-get="/parts/make/redbull">Red Bull</option>
</select>
</label>
<ul id="display-parts">
<li>Flux capacitor</li>
<li>Mr. Fusion</li>
<li>Cup holder</li>
<li>Dog</li>
</ul>
…At least, that’s what happens in Firefox.
In Chrome and Safari, nothing happens when an option
is chosen.
A cross-browser solution
To fix this, I leveraged AlpineJS to tell HTMX when an option
was chosen:
<!-- This isn't the final code. It gets better -->
<label>
Choose the make of your car
<select
hx-target="#display-parts"
x-data="{
renderXPathSelector(value) {return `.//option[contains(., '${value}')]`}, <!-- 4 -->
getChosenOption(value) {return document.evaluate(this.renderXPathSelector(value), $el).iterateNext()}, <!-- 3 -->
tellHTMXOptionChanged(event) { htmx.trigger(this.getChosenOption(event.target.value), 'click')} <!-- 2 -->
}"
@change="tellHTMXOptionChanged($event)" <!-- 1 -->
>
<option></option>
<option hx-get="/parts/make/mercedes">Mercedes</option>
<option hx-get="/parts/make/aston">Aston Martin</option>
<option hx-get="/parts/make/delorian">Delorian</option>
<option hx-get="/parts/make/redbull">Red Bull</option>
</select>
</label>
<ul id="display-parts">
<li>Choose a car manufacturer.</li>
</ul>
This is a dense block of HTML. But let’s talk about what it does:
- AlpineJS will listen when the
select
element changes and call thetellHTMXOptionChanged
function with the JavaScriptchange
event. - That function tells HTMX to trigger a click event on the
<option>
element that was selected. To determine which<option>
element was selected, it calls thegetChosenOption
function. - That function uses the browser’s
document.evaluate
function to look for children of the current element ($el
) that match an XPath expression created by therenderXPathSelector
function. - That function creates the XPath expression that looks for an
option
element that contains the value theselect
element has been changed to.
It’s complicated. But it works…
…as long as the value of your option
elements is the text they contain.
Supporting value attributes
But one great feature of option
elements is that you can separate the option’s value from what the user sees. For example, our Python code might have specific codes we use for each manufacturer:
manufacturers = [
('Mercedes', 'merc'),
('Aston Martin', 'astm'),
('Delorian', 'delo'),
('Red Bull', 'bull'),
]
And we might use that to create the select
element:
<select>
<option value="merc" hx-get="/parts/make/mercedes">Mercedes</option>
<option value="astm" hx-get="/parts/make/aston">Aston Martin</option>
<option value="delo" hx-get="/parts/make/delorian">Delorian</option>
<option value="bull" hx-get="/parts/make/redbull">Red Bull</option>
</select>
In that case, if a user chose Red Bull, our XPath selector would fail us, as the select
's value would be bull
, and no option
matches that (case-sensitive) text.
But no fear, we can leverage the browser’s querySelector
function to select the option
with the matching value:
$el.querySelector(`[value]="${$event.target.value}"`)
And we can support both cases by querying for value
attributes first. If no element matches, it returns null
, and JavScript will then run the getChosenOption
function:
const chosenOption = el.querySelector(`[value="${event.target.value}"]`) ||
getChosenOption(event.target.value)
I’m happy this works, but this is a LOT of HTML to write whenever we want to use a select
to trigger an HTMX call. But we can simplify it with Alpine.
Alpine gives us magic
Alpine allows us to register custom functionality with its magic function. This means we can define reusable functionality in one place and use it in any of our Alpine elements.
Let’s leverage that to create a function called tellHTMXOptionChanged
. In our base HTML code, we can add this:
<!-- Part 1 of our final code -->
<script>
document.addEventListener('alpine:init', () => {
Alpine.magic('tellHTMXOptionChanged', (el) => {
{# This is needed for cross-browser compatibility for when a <select> changes #}
return (event) => {
function renderXPathSelector(value) {return `.//option[contains(., '${value}')]`}
function getChosenOption(value) {return document.evaluate(renderXPathSelector(value), el).iterateNext()}
const chosenOption = el.querySelector(`[value="${el.value}"]`) || getChosenOption(event.target.value)
htmx.trigger(chosenOption, 'click')
}
})
})
</script>
Now, any time we want to use a select
tag with HTMX, we can add two attributes:
<label>
Choose the make of your car
<select
hx-target="#display-parts"
x-data
@change="$tellHTMXOptionChanged($event)"
>
<option></option>
<option hx-get="/parts/make/mercedes">Mercedes</option>
<option hx-get="/parts/make/aston">Aston Martin</option>
<option hx-get="/parts/make/delorian">Delorian</option>
<option hx-get="/parts/make/redbull">Red Bull</option>
</select>
</label>
<ul id="display-parts">
<li>Choose a car manufacturer.</li>
</ul>
Update 2023-06-23
The above solution was not robust enough for certain browsers as the html.trigger
function didn’t trigger an Ajax call. Below is a more robust solution, but it won’t solve every use case.
Alpine.magic('tellHTMXOptionChanged', (el) => {
{# This is needed for cross-browser compatability for when a <select> changes #}
return (event) => {
function renderXPathSelector(value) {
return `.//option[contains(., '${value}')]`
}
function getChosenOption(value) {
return document.evaluate(renderXPathSelector(value), el).iterateNext()
}
const selectedOption = el.querySelector(`[value="${el.value}"]`) ||
getChosenOption(event.target.value)
htmx.trigger(selectedOption, 'click')
targetDescription = htmx.closest(selectedOption, '[hx-target]').attributes['hx-target'].value
if (targetDescription === 'this') {
target = htmx.closest(selectedOption, '[hx-target]')
}
else if (targetDescription.startsWith('closest')) {
selector = targetDescription.split(' ')[1]
target = htmx.closest(selector)
}
else {
target = htmx.find(targetDescription)
}
htmx.ajax('GET', selectedOption.attributes['hx-get'].value, target)
}
})