Skip to main content
Ben Nadel at CFUNITED 2009 (Lansdowne, VA) with: Andy Allan
Ben Nadel at CFUNITED 2009 (Lansdowne, VA) with: Andy Allan

Keyboard Command Extension In HTMX And ColdFusion

By
Published in ,

Earlier this week, I took a look at using extensions in an HTMX and ColdFusion application. Extensions allow us to tap into the event life-cycle of nodes withing the document object model (DOM); which, in turn, allows us to augment the document behavior as HTMX swaps content into and out of the rendered page. For this follow-up post, I take inspiration from the Docket app by Mark Story. Mark's HTMX and PHP app allows keyboards events to trigger DOM interaction. I wanted to try building something similar for a ColdFusion demo.

The premise of this ColdFusion demo is super simple. It's a single page that renders an id value. There are two links: Previous and Next, which will re-render the page with the given id value decremented or incremented, respectively. We will use an HTMX extension to augment these links with LeftArrow and RightArrow key events, respectively.

I'm calling this extension, key-commands. This is the identifier that will be passed into the hx-ext attribute, telling HTMX that this extension will be active in certain parts of the DOM tree. The ColdFusion part of this demo is fairly straight-forward - it's just a CFML template that links back to itself:

<cfscript>

	param name="url.id" type="numeric" default=1;

	minID = 1;
	maxID = 10;

	// Increment / decrement id, but loop to the other end of range when we're at the
	// edge. We can live life on the edge, but we can't stay there for long.
	prevID = ( url.id > minID )
		? ( url.id - 1 )
		: maxID
	;
	nextID = ( url.id < maxID )
		? ( url.id + 1 )
		: minID
	;

</cfscript>
<cfoutput>

	<h1>
		Key Command Extension In HTMX
	</h1>

	<section class="experience">
		<p>
			#encodeForHtml( url.id )#
		</p>
		<nav hx-boost="true" hx-ext="key-commands">
			<a
				href="index.cfm?id=#encodeForUrl( prevID )#"
				data-keyboard-shortcut="
					ArrowLeft,
					Shift.P
				">
				&larr; Prev
			</a>
			<a
				href="index.cfm?id=#encodeForUrl( nextID )#"
				data-keyboard-shortcut="
					ArrowRight,
					Shift.N
				">
				Next &rarr;
			</a>
		</nav>
	</section>

</cfoutput>

In this ColdFusion code, there are several things to notice:

First, we've added hx-boost to the <nav> element. This means that instead of a full-page re-render, HTMX will intercept the navigation links and use AJAX to swap the content of the body. This isn't strictly needed for this demo; but, I'm going to be logging messages to the console; and, the boosting allows the console to persist across navigation.

Second, we've added the hx-ext="key-commands" to the <nav> element which tells HTMX that our key-commands extension should be notified of events that happen in this portion of the DOM. HTMX treats the DOM tree as an event bus. As such, when HTMX events are triggered on a given element, they will naturally propagate up the DOM branch, and will eventually be intercepted by our extension's onEvent() callback.

Third, we've added the [data-keyboard-shortcut] attribute to the Prev/Next links. This attribute will be consumed by our key-commands extension; and will bind the given keyboard keys to the .click() invocation of these links. In this demo, I'm using the comma to allow multiple keyboard combinations to be bound to a single element:

  • Prev is bound to both ArrowLeft and Shift.P.
  • Next is bound to both ArrowRight and Shift.N.

All of these keyboard combinations will come up during natural interactions with the webpage (such as typing into an input field). As such, our HTMX extension will make sure to only intercept they keyboard events when:

  • They are triggered on the document.body element.
  • No other handler has called .preventDefault() on the given event.

If we now run this ColdFusion and HTMX demo and use our keyboard events, we get the following output - the keyboard usage will logged to the console:

Console logging showing that the prev and next links have been triggered by the keyboard commands (via our HTMX extension).

As you can see, the prev and next links respond to the mouse clicks as you would expect. But, I'm also able to trigger the prev and next navigation by using the keyboard shortcuts defined in the [data-keyboard-shortcut] attribute.

There's quite a bit of code in our HTMX extension; so, let's start by looking at the public API - the part that HTMX consumes as the interface to the extension. This will include three methods:

  1. init() - HTMX will call this once per page, regardless of how many hx-ext attributes exist, to allow a centralized initialization of our extension.

  2. getSelectors() - HTMX will always let our extension know about the nodes that have hx-ext on them. But, this method allows us to define additional nodes that HTMX should process. In our case, this method will tell HTMX about the [data-keyboard-shortcut] attribute.

  3. onEvent() - HTMX will notify our extension of all events that happen under the relevant nodes in the DOM tree. Within this callback, we have to introspect both the event name and the node state to see if the given event is relevant to our extension.

(() => {

	htmx.defineExtension(
		"key-commands",
		{
			init,
			getSelectors,
			onEvent
		}
	);

	// The mappings create the association between the normalized keyboard event and the
	// elements that they reference [ shortcut => element ]. As the DOM is processed,
	// mappings will be added to and removed from this collection.
	var mappings = new Map();

	// ---
	// PUBLIC METHODS.
	// ---

	/**
	* I initialize the extension. I get called once per page load, regardless of how many
	* hx-ext attributes there are.
	*/
	function init () {

		// All shortcut events will be processed by a centralized handler.
		window.addEventListener( "keydown", processRootKeyboardEvent );

	}

	/**
	* I tell HTMX which elements need to be processed (ie, receive the HTMX treatment) in
	* order for this extension to work properly. This is in addition to elements that may
	* already have the [hx-ext] attribute.
	*/
	function getSelectors () {

		return [ "[data-keyboard-shortcut]" ];

	}

	/**
	* I hook into the HTMX event bus, responding to events as needed.
	*/
	function onEvent ( name, event ) {

		// HTMX will tell us about ALL events, not just the events that are relevant to
		// this extension. As such, we have to look at the event name and inspect the
		// target node to see if it's relevant to our key commands.
		switch ( name ) {
			case "htmx:afterProcessNode":

				if ( event.detail.elt.dataset.keyboardShortcut ) {

					setupShortcut( event.detail.elt );

				}

			break;
			case "htmx:beforeCleanupElement":

				if ( event.detail.elt.dataset.keyboardShortcut ) {

					teardownShortcut( event.detail.elt );

				}

			break;
		}

	}

	// .... private methods truncated ....

})();

What you can see from the public API of the HTMX extension is that our keyboard shortcuts work by setting up an event-listener on the root of the document. As keyboard events bubble-up through the DOM tree, they will eventually reach the root and be intercepted by our event listener.

The heavy lifting of event interception is done by the aforementioned event listener. The onEvent() callback is simply a means by which we can add and remove events to and from the centralized mappings collection, respectively. Notice that our onEvent() logic cannot assume anything about the given event. Or rather, it has to assume that it may receive many irrelevant events; and, that it must programmatically narrow-down the events that it wants to process further.

This is a byproduct of the way that events work in the DOM tree - it's not an HTMX-specific mechanic. Since HTMX uses the DOM tree as the natural event bus, the onEvent() callback naturally has to know that it will receive all events that bubble up through the DOM tree.

The rest of the HTMX extension logic is predominantly concerned with normalizing events into a consistent string format so that they can be compared to the mappings collection. I'm using an Angular-inspired technique in which aspects of the event are mapped to English words, and then sorted alphabetically, creating a canonical representation.

With that said, here's the full HTMX extension code without any additional explanation:

(() => {

	htmx.defineExtension(
		"key-commands",
		{
			init,
			getSelectors,
			onEvent
		}
	);

	// The mappings create the association between the normalized keyboard event and the
	// elements that they reference [ shortcut => element ]. As the DOM is processed,
	// mappings will be added to and removed from this collection.
	var mappings = new Map();

	// ---
	// PUBLIC METHODS.
	// ---

	/**
	* I initialize the extension. I get called once per page load, regardless of how many
	* hx-ext attributes there are.
	*/
	function init () {

		// All shortcut events will be processed by a centralized handler.
		window.addEventListener( "keydown", processRootKeyboardEvent );

	}

	/**
	* I tell HTMX which elements need to be processed (ie, receive the HTMX treatment) in
	* order for this extension to work properly. This is in addition to elements that may
	* already have the [hx-ext] attribute.
	*/
	function getSelectors () {

		return [ "[data-keyboard-shortcut]" ];

	}

	/**
	* I hook into the HTMX event bus, responding to events as needed.
	*/
	function onEvent ( name, event ) {

		// HTMX will tell us about ALL events, not just the events that are relevant to
		// this extension. As such, we have to look at the event name and inspect the
		// target node to see if it's relevant to our key commands.
		switch ( name ) {
			case "htmx:afterProcessNode":

				if ( event.detail.elt.dataset.keyboardShortcut ) {

					setupShortcut( event.detail.elt );

				}

			break;
			case "htmx:beforeCleanupElement":

				if ( event.detail.elt.dataset.keyboardShortcut ) {

					teardownShortcut( event.detail.elt );

				}

			break;
		}

	}

	// ---
	// PRIVATE METHODS.
	// ---

	/**
	* I return the normalized shortcut from the given keyboard event.
	*/
	function getShortcutFromEvent ( event ) {

		var parts = [ normalizeEventKey( event.key ) ];

		if ( event.altKey ) parts.push( "alt" );
		if ( event.ctrlKey ) parts.push( "ctrl" );
		if ( event.metaKey ) parts.push( "meta" );
		if ( event.shiftKey ) parts.push( "shift" );

		return parts.sort().join( "." );

	}

	/**
	* I return the normalized keyboard event key. This helps us deal with some special
	* characters that might be harder to read or have alternate meanings.
	*/
	function normalizeEventKey ( key ) {

		switch ( key ) {
			case " ":
				return "space";
			break;
			case ".":
				return "dot";
			break;
			default:
				 return key.toLowerCase();
			break;			
		}

	}

	/**
	* I process the given keyboard event that has bubbled up to the document root.
	*/
	function processRootKeyboardEvent ( event ) {

		// If we have no bound mappings, no point in inspecting the event.
		if ( ! mappings.size ) {

			return;

		}

		// If the user is interacting with a specific element (such as an input element),
		// the shortcut might not be relevant.
		if ( event.target !== document.body ) {

			return;

		}

		// If the event has been modified by another handler, assume we shouldn't mess
		// with it any further.
		if ( event.defaultPrevented ) {

			return;

		}

		var shortcut = getShortcutFromEvent( event );

		if ( mappings.has( shortcut ) ) {

			event.preventDefault();
			mappings.get( shortcut ).click();

			console.log( `%cProcessed: %c${ shortcut }`, "color: darkcyan ; font-weight: bold ;", "color: black ;" );

		}

	}

	/**
	* I add the shortcut mapping(s) for the given node.
	*/
	function setupShortcut ( node ) {

		// Multiple shortcuts can be defined on a single element by separating them with
		// a comma.
		var shortcuts = node.dataset.keyboardShortcut.split( "," ).map(
			( segment ) => {

				return segment
					.trim()
					.toLowerCase()
					.split( "." )
					.sort()
					.join( "." )
				;

			}
		);

		// Store the normalized data attribute back into the dataset so that the teardown
		// process doesn't have to deal with normalization. It can simply read the data
		// value and assume it matches the internal mapping key.
		node.dataset.keyboardShortcut = shortcuts.join( "," );

		for ( var shortcut of shortcuts ) {

			mappings.set( shortcut, node );

		}

	}

	/**
	* I remove the shortcut mapping for the given node.
	*/
	function teardownShortcut ( node ) {

		var shortcuts = node.dataset.keyboardShortcut.split( "," );

		for ( var shortcut of shortcuts ) {

			mappings.delete( shortcut );

		}

	}

})();

One thing that I really like about the way Mark Story approached key commands in his Docket app (which I've also done here) is to tie each key command to an actual DOM node. In the past, when I've looked at key commands in Angular, the commands were always bound to a view, not to a particular node. But, what I like about the node-specific approach is that it forces you to always have a DOM-representation of a given pathway. Essentially, this approach forces us to create a consistent experience whether the user is using the mouse (to click the node) or the keyboard (to "click" the node).

Want to use code from this post? Check out the license.

Reader Comments

Post A Comment — I'd Love To Hear From You!

Post a Comment

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel