Triggering HTMX from option tags

March 3, 2023

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:

  1. AlpineJS will listen when the select element changes and call the tellHTMXOptionChanged function with the JavaScript change event.
  2. 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 the getChosenOption function.
  3. 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 the renderXPathSelector function.
  4. That function creates the XPath expression that looks for an option element that contains the value the select 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)
  }
})

© 2024 Everyday Superpowers

LINKS
About | Articles | Resources

Free! Four simple steps to solid python projects.

Reduce bugs, expand capabilities, and increase your confidence by building on these four foundational items.

Get the Guide

Join the Everyday Superpowers community!

We're building a community to help all of us grow and meet other Pythonistas. Join us!

Join

Subscribe for email updates.