Creating An hx-event Extension For Triggering Events In HTMX
I'm still very deep in the "learning phase" of using HTMX, trying to understand how things are wired together and where the jagged edges might be. As a code kata, I wanted to see if I could create an HTMX extension that would trigger an event instead of an AJAX call. This way, I might be able to click on a button and bubble a synthetic event up the DOM (Document Object Model) tree to another element that is using an hx-on:{event}
binding.
To be clear, event binding is already very doable in a variety of ways in HTML and JavaScript. But, I wanted to explore the HTMX extension path because I'm enthralled by the developer experience (DX) of the hx-trigger
attribute. For example, I can declaratively bind a keyboard command to the Up Arrow by using:
hx-trigger="keydown[key === 'ArrowUp'] from:body"
The nice thing about this is that HTMX will automatically bind and unbind this event listener as it initializes and tears-down the DOM, respectively. Of course, in the native HTMX, this is only ever done to power an AJAX request (via hx-get
, hx-post
, etc). But, I wanted to see if I could co-opt the hx-trigger
attribute and have it emit an event if no "verb" was present on the element.
It turns out, this is a little trickier than I anticipated (which is the whole point of doing a code kata!)—HTMX doesn't want the hx-trigger
attribute to be used with anything else. So, if it detects a "naked trigger", it will simply bind it to a no-op event handler and prevent other handlers from being bound.
After some trial and error, I figured out that I could move the hx-trigger
attribute in the htmx:beforeProcessNode
event; and then move it back in the htmx:afterProcessNode
event. Then, I could use the exposed extensions API to parse the hx-trigger
attribute and bind the necessary event handlers.
To explore this extension, I created a list of 10 buttons that emit a woot
event. The event can be triggered either by clicking the button or by hitting the corresponding number key on the keyboard. When the woot
event is emitted, it will use the hx-vals
attribute as the event.detail
data.
<cfoutput>
<body hx-ext="hx-event">
<div hx-on:woot="console.log( 'woot', event.detail )">
<cfloop index="i" from="0" to="9">
<button
hx-event="woot"
hx-vals='{ "i": "#i#" }'
hx-trigger="
click,
keydown[key === '#i#'] from:body
">
Trigger From #i#
</button>
</cfloop>
</div>
</body>
</cfoutput>
In this ColdFusion code, notice that each button is emitting the same woot
event; but, each events can be differentiated because the hx-vals
attribute is including the CFLoop
index variable.
The hx-trigger
attribute is defining a list of events to bind. This isn't doing anything special here—this is just how HTMX works. When each event is emitted, I'm using an hx-on:woot
event-binding on the parent element to catch the synthetic / custom event and log it to the console.
Since the hx-event
can be triggered in two ways, let's first try the MouseEvent
approach:

When I click on each of the buttons, the hx-event
extension detects the click
event and emits the given event name with the hx-vals
data.
Now, let's try it with the keydown
event:

This time, when I press the number keys on the keyboard, the hx-event
extension detects the keydown
event on the body
and emits the given event name with the hx-vals
data.
Here's the code that powers the hx-event
extension. It makes use of the extensions API that's injected into the extension via the init()
method:
addTriggerHandler()
- binds thehx-trigger
specification (such asclick
andkeydown from:body
to the given node.getAttributeValue()
- gets the attribute value, checking for the attribute both with and without thedata-
prefix.getExpressionVars()
- parses and returns thehx-vals
(and deprecatedhx-vars
) attribute payloads.getInternalData()
- retrieves the expando property that HTMX attaches to each observed DOM node.getTarget()
- gets the element referenced by thehx-target
attribute (or the given node if no attribute is defined).getTriggerSpecs()
- parses thehx-trigger
attribute, returning an array of events to be bound.triggerEvent()
- same ashtmx.trigger()
, emits a custom event.
I'm attempting to lean on the HTMX framework as much as possible in order to keep the code light-weight.
(() => {
// Caution: we have to assign this variable BEFORE we call defineExtension() because
// HTMX will invoke the init() method immediately. As such, if attempt to call the
// defineExtension() method as the first thing we do, we could end up overwriting the
// API reference that we store inside the init() method body.
var api = null;
htmx.defineExtension(
"hx-event",
{
init,
getSelectors,
onEvent
}
);
// ---
// PUBLIC METHODS.
// ---
/**
* I initialize the extension and store a reference to extensions API.
*/
function init ( extensionsApi ) {
api = extensionsApi;
}
/**
* I tell HTMX which selectors are relevant for this extension.
*/
function getSelectors () {
return [ "[hx-event]", "[data-hx-event]" ];
}
/**
* I handle HTMX life-cycle events.
*/
function onEvent ( name, event ) {
if (
( name !== "htmx:beforeProcessNode" ) &&
( name !== "htmx:afterProcessNode" )
) {
return;
}
var node = event.detail.elt;
if ( ! api.getAttributeValue( node, "hx-event" ) ) {
return;
}
// HTMX only wants to pair hx-trigger with VERBS (such as hx-get, hx-post). If it
// sees a "naked trigger", it will bind it to a no-op event handler, which will
// prevent any other event handler from being bound via hx-trigger (HTMX seems to
// only allow a single trigger-based event handler per node/type - though, I can't
// quite figure out why from reading the code). As such, we're going to
// temporarily hide the hx-trigger so that HTMX doesn't see it during the
// node initialization.
if ( name === "htmx:beforeProcessNode" ) {
moveAttribute( node, "hx-trigger", "hidden-hx-trigger" );
return;
}
// Once HTMX is done processing the node, we can put the hx-trigger attribute back
// in place so that we can process it and setup our own event bindings.
moveAttribute( node, "hidden-hx-trigger", "hx-trigger" );
// Parse the hx-trigger attribute and attach event handlers.
for ( var triggerSpec of api.getTriggerSpecs( node ) ) {
api.addTriggerHandler(
node,
triggerSpec,
api.getInternalData( node ),
emitEvent
);
}
}
// ---
// PRIVATE METHODS.
// ---
/**
* I handle the triggered event and translate it into a synthetic hx-event that bubbles
* up the DOM.
*/
function emitEvent ( node, event ) {
var hxEventNames = api.getAttributeValue( node, "hx-event" );
var hxEventDetail = api.getExpressionVars( node );
var hxTarget = api.getTarget( node );
// Monkey-patch original event into synthetic event.
hxEventDetail.originalEvent = event;
// Allow the event name to be a list of event names.
for ( var hxEventName of fromList( hxEventNames ) ) {
api.triggerEvent( hxTarget, hxEventName, hxEventDetail );
}
}
/**
* I split the delimited list into an array.
*/
function fromList ( value ) {
return ( value || "" ).split( /[\s,]+/g );
}
/**
* I move a node attribute from one name to the another name.
*/
function moveAttribute ( node, fromName, toName ) {
var value = api.getAttributeValue( node, fromName );
node.setAttribute( toName, value );
// Since HTMX allows its attributes to be define with or without the `data-`
// attribute, let's remove both - misses don't cause an error.
node.removeAttribute( fromName );
node.removeAttribute( `data-${ fromName }` );
}
})();
The reason this code kata came to mind is because I've been exploring the use of modal windows in HTMX. I was considering creating some sort of extension that would manage the modal windows; and, I thought it might be helpful to have it listen for a modalclose
event. Then, the [x]
button that lives in the corner of the modal window would simply emit that event on click:
<button hx-event="modalclose"> X </button>
Again, you could just hack this yourself via hx-on
:
<button hx-on:click="htmx.trigger( event.currentTarget, 'modalclose' )">
But, the hx-on
route (essentially) uses eval()
internally within HTMX, which may not be available based on the site's CSP (Content Security Policy). The benefit of using the hx-event
declarative approach is that it doesn't use eval()
and therefore would also work in a stricter CSP context.
Want to use code from this post? Check out the license.
Short link: https://bennadel.com/4803
Reader Comments
I've gone full bore into using HTMX with CF. I find using HTMX just makes sense and gives me exactly the amount of functionality I need.
If you haven't already, take a look at hyperscript (hyperscript.org). I find it to be a wonderful tool for dealing with events and it comes from the same world as htmx.
For modals (I strictly use the dialog element now), I have found setting a HX-Trigger-After-Swap to open or close the dialog in my fragment response to be a nice way to make the dialog do what I need it to do.
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →