Introducing Hansel, runner of handlers and enhancers

For years we've been using a system of handlers and enhancers to bind behavior to our HTML pages.
In order to standardize we published Hansel, to allow for easy installation and distribution.

You can find Hansel on GitHub.


Credit where it's due

First of all: this system is based on the article “Progressive enhancement with handlers and enhancers” by Hidde de Vries.

We've been using this method for years, only minimally tweaking as time went by.
At GRRR we like to package things for reusability though, so after copying and pasting this to a bunch of projects, the time was right for an npm package.

Usage

For an in-depth look at the whys and hows, read Hidde's article linked above.
But let's outline the core concepts:

  1. An enhancer is a behavior that should happen on page load.
  2. A handler is a behavior that should happen on click.

You bind either to an HTML element by adding a corresponding data-attribute:

<nav data-enhancer="stickyNavigation">
  <!-- ... -->
</nav>

<a href="/stuff" data-handler="confirmPrompt">Delete my profile</a>

In order to enrich these elements with behavior, install Hansel using npm or yarn:

$ npm install @grrr/hansel

Now import Hansel into your main Javascript file, and bootstrap using the enhance and handle functions:

import { enhance, handle } from '@grrr/hansel';

enhance(document.documentElement, {
    stickyNavigation(elm) {
        // ...
    }
});

handle(document.documentElement, {
    confirmPrompt(elm, event) {
        // ...
    }
});

Enhancers

enhance will look for DOM nodes containing the data-enhancer attribute inside the root node specified as the first argument. The second argument is a lookup table for enhancer functions.
The value of the data-enhancer attribute will be matched with the table and if found, executed.

The callback function will receive a single argument: the element being enhanced.

Let's apply this to our stickyNavigation example above:

function stickyNavigation(elm) {
    // elm == <nav data-enhancer="stickyNavigation">
} 

enhance(
    document.documentElement,
    {
        stickyNavigation
    }
);

Inside the enhancer you're free to do whatever you want – bind more event listeners, manipulate the DOM, log some analytics.

Multiple enhancers are possible by comma-separating them:

<section data-enhancer="foo,bar">
</section>

Handlers

Handlers are called on click, using a global event listener on the document. Meta-clicks (Ctrl-click, ⌘-click) are caught and not passed on to the handler.

Because it's a single event listener, you don't have to re-attach it when the DOM changes. Any new element with a data-handler attribute will be automatically picked up.

handle will look for DOM nodes containing the data-handler attribute inside the root node specified as the first argument. The second argument is a lookup table for handler functions.

A handler callback function receives the element that's being handled as the first argument, and the event object as the second.

Let's apply this to our confirmPrompt example above:

function confirmPrompt(elm, e) {
    if (!confirm('Are you sure?')) {
        e.preventDefault();
    }
}


handle(
    document.documentElement,
    {
        confirmPrompt
    }
);

As you can see, the event object is yours to do with whatever you like. Preventing default happens inside the handler.

Multiple handlers are possible by comma-separating them:

<a data-handler="foo,bar" href="/">Do the thing</a>

File organization

The way we usually organize this is a single module per file. Sometimes a module exposes an enhancer, sometimes a handler, sometimes both. We then import all these files in a main JS file, in which we include the calls to enhance and handle:

import { handle, enhance } from "@grrr/hansel";
import { enhancer as stickyNavigation } from "./modules/sticky-navigation";
import { handler as confirmPrompt } from "./modules/confirm-prompt";

enhance(document.documentElement, {
    stickyNavigation
});

handle(document.documentElement, {
    confirmPrompt
});

Techniques

Binding data

How would one pass arguments to the behavior? Since the element is the sole scope of a handler or enhancer, it must be the vessel carrying our parameters:

<input data-enhancer="livesearch" data-remote-endpoint="/search">
function livesearch(input) {
    input.addEventListener('keydown', () => {
        fetch(input.dataset.remoteEndpoint)
            .then( // etc..
            );
    });
}

It's of course possible to traverse the DOM, either using the element as an orientation point or simply using the globally available document.querySelector, but we try to keep things locally scoped and avoid looking outside the enhanced node too much.
This keeps the behavior portable and the code easy to understand.

Re-enhancing elements

Enhancers are run on page load. You might want to run them again, for instance when loading new DOM nodes in response to an AJAX call. The first argument to enhance is the container element within which nodes are searched. Therefore, you can pass the parent to the newly created nodes as reference to enhance all its children:


fetch('/foo')
    .then((results) => {
        const newNode = document.createElement('div');
        newNode.setAttribute('data-enhancer', 'myEnhancer');
        element.appendChild(newNode);

        enhance(element, { 
            myEnhancer
        });
    }); 

Mature, robust vanilla Javascript

To wrap up: we've been using this system for years, very successfully. We tend to shy away from full-blown front-end frameworks, but this setup allows us, with minimal effort, to maintain a lot of structure and reliability in our vanilla Javascript implementations.

It's also very accessible to developers of all levels and disciplines, ensuring rapid development when building templates.

Head over to Github, and take it for a spin!