mirror of
https://github.com/bigskysoftware/hypermedia-systems.git
synced 2025-12-05 00:03:55 -05:00
1337 lines
69 KiB
Plaintext
1337 lines
69 KiB
Plaintext
|
|
= Client-Side Scripting
|
|
:chapter: 09
|
|
:url: ./client-side-scripting/
|
|
|
|
[quote, Roy Fielding, Architectural Styles and the Design of Network-based Software Architectures]
|
|
REST allows client functionality to be extended by downloading and executing code in the form of applets or scripts.
|
|
This simplifies clients by reducing the number of features required to be pre-implemented.
|
|
|
|
Thus far we have (mostly) avoided writing any JavaScript (or +_hyperscript+) in Contact.app, mainly because the functionality
|
|
we implemented has not required it. In this chapter we are going to look at scripting and, in particular, hypermedia-friendly
|
|
scripting within the context of a Hypermedia-Driven Application.
|
|
|
|
== Is Scripting Allowed?
|
|
|
|
A common criticism of the web is that it's being misused. There is a narrative that WWW was created as a delivery system
|
|
for "`documents`", and only came to be used for "`applications`" by way of an accident or bizarre circumstances.
|
|
|
|
However, the concept of hypermedia challenges the split of document and application. Hypermedia systems like HyperCard, which preceded the web, featured rich capabilities for active
|
|
and interactive experiences, including scripting.
|
|
|
|
HTML, as specified and implemented, does lack affordances needed to build highly interactive applications. This doesn't mean, however, that hypermedia's _purpose_ is "`documents`"
|
|
over "`applications.`"
|
|
|
|
Rather, while the theoretical foundation is there, the implementation is underdeveloped. With JavaScript being the
|
|
only extension point and hypermedia controls not being well integrated to JavaScript (why can't one click a link without
|
|
halting the program?), developers have not internalized hypermedia and have instead used the web as a dumb pipe for apps
|
|
that imitate "`native`" ones.
|
|
|
|
A goal of this book is to show that it is possible to build sophisticated web applications using the original technology
|
|
of the web, hypermedia, without the application developer needing to reach for the abstractions provided by the large,
|
|
popular JavaScript frameworks.
|
|
|
|
Htmx itself is, of course, written in JavaScript, and one of its advantages is that hypermedia interactions that go
|
|
through htmx expose a rich interface to JavaScript code with configuration, events, and htmx's own extension support.
|
|
|
|
Htmx expands the expressiveness of HTML enough that it removes the need for scripting in many situations.
|
|
This makes htmx attractive to people who don't want to write JavaScript, and there are many of those sorts of developers,
|
|
wary of the complexity of Single Page Application frameworks.
|
|
|
|
However, dunking on JavaScript is not the aim of the htmx project.
|
|
// adds okay here?
|
|
The goal of htmx is not less JavaScript, but less code, more readable and hypermedia-friendly code.
|
|
|
|
Scripting has been a massive force multiplier for the web. Using scripting, web application developers are not only able
|
|
to enhance their HTML websites, but also create full-fledged client-side applications that can often compete with
|
|
native, thick client applications.
|
|
|
|
This JavaScript-centric approach to building web applications is a testament to the power of the web and to the sophistication
|
|
of web browsers in particular. It has its place in web development: there are situations where the hypermedia approach
|
|
simply can't provide the level of interaction that an SPA can.
|
|
|
|
However, in addition to this more JavaScript-centric style, we want to develop a style of scripting more compatible and
|
|
consistent with Hypermedia-Driven Applications.
|
|
|
|
|
|
== Scripting for Hypermedia
|
|
|
|
Borrowing from Roy Fielding's notion of "`constraints`" defining REST, we offer two constraints of hypermedia-friendly
|
|
scripting. You are scripting in an HDA-compatible manner if the following two constraints are adhered to:
|
|
|
|
* The main data format exchanged between server and client must be hypermedia, the same as it would be without scripting.
|
|
* Client-side state, outside the DOM itself, is kept to a minimum.
|
|
|
|
The goal of these constraints is to confine scripting to where it shines best and where nothing else comes close:
|
|
_interaction design_. Business logic and presentation logic are the responsibility of the server, where we can pick
|
|
whichever languages or tools are appropriate for our business domain.
|
|
|
|
.The Server
|
|
[note]
|
|
****
|
|
Keeping business logic and presentation logic both "`on the server`" does not mean these two "`concerns`" are mixed or
|
|
coupled. They can be modularized on the server. In fact, they _should_ be modularized on the server, along with all the
|
|
other concerns of our application.
|
|
|
|
Note also that, especially in web development parlance, the humble "`server`" is usually a whole fleet of racks, virtual
|
|
machines, containers and more. Even a worldwide network of datacenters is reduced to "`the server`" when discussing
|
|
the server-side of a Hypermedia-Driven Application.
|
|
****
|
|
|
|
Satisfying these two constraints sometimes requires us to diverge from what is typically considered best practice for
|
|
JavaScript. Keep in mind that the cultural wisdom of JavaScript was largely developed in JavaScript-centric SPA applications.
|
|
|
|
The Hypermedia-Driven Application cannot as comfortably fall back on this tradition. This chapter is our contribution to the
|
|
development of a new style and best practices for what we are calling Hypermedia-Driven Applications.
|
|
|
|
Unfortunately, simply listing "`best practices`" is rarely convincing or edifying. To be honest, it's boring.
|
|
|
|
Instead, we will demonstrate these best practices by implementing client-side features in Contact.app. To cover different aspects of hypermedia-friendly scripting, we will
|
|
implement three different features:
|
|
|
|
* An overflow menu to hold the _Edit_, _View_ and _Delete_ actions, to clean up visual clutter in our list of contacts.
|
|
* An improved interface for bulk deletion.
|
|
* A keyboard shortcut for focusing the search box.
|
|
|
|
The important takeaway in the implementation of each of these features is that, while they are implemented entirely on
|
|
the client-side using scripting, they _don't exchange information with the server_ via a non-hypermedia format, such
|
|
as JSON, and that they don't store a significant amount of state outside of the DOM itself.
|
|
|
|
== Scripting Tools for the Web
|
|
|
|
The primary scripting language for the web is, of course, JavaScript, which is ubiquitous in web development today.
|
|
|
|
A bit of interesting internet lore, however, is that JavaScript was not always the only built-in option.
|
|
As the quote from Roy Fielding at the start of this chapter hints, "`applets`" written in other languages such as Java were considered to be
|
|
part of the scripting infrastructure of the web. In addition, there was a time period when Internet Explorer supported VBScript,
|
|
a scripting language based on Visual Basic.
|
|
|
|
Today, we have a variety of _transcompilers_ (often shortened to _transpilers_) that convert many languages to JavaScript,
|
|
such as TypeScript, Dart, Kotlin, ClojureScript, F# and more. There is also the WebAssembly (WASM) bytecode format, which
|
|
is supported as a compilation target for C, Rust, and the WASM-first language AssemblyScript.
|
|
|
|
However, most of these options are not geared towards a hypermedia-friendly style of scripting. Compile-to-JS languages
|
|
are often paired with SPA-oriented libraries (Dart and AngularDart, ClojureScript and Reagent, F# and Elm), and WASM is
|
|
currently mainly geared toward linking to C/C++ libraries from JavaScript.
|
|
|
|
We will instead focus on three client-side scripting technologies that _are_ hypermedia-friendly:
|
|
|
|
* VanillaJS, that is, using JavaScript without depending on any framework.
|
|
|
|
* Alpine.js, a JavaScript library for adding behavior directly in HTML.
|
|
|
|
* +_hyperscript+, a non-JavaScript scripting language created alongside htmx. Like AlpineJS, +_hyperscript+ is usually embedded in HTML.
|
|
|
|
Let's take a quick look at each of these scripting options, so we know what we are dealing with.
|
|
|
|
Note that, as with CSS, we are going to show you just enough of each of these options to give a flavor of how they work and, we hope, spark your interest in looking into any of them more extensively.
|
|
|
|
== Vanilla JavaScript
|
|
|
|
[quote,Merb]
|
|
No code is faster than no code.
|
|
|
|
Vanilla JavaScript is simply using plain JavaScript in your application, without any intermediate layers.
|
|
The term "`Vanilla`" entered frontend web dev parlance as it became assumed that any sufficiently "`advanced`" web app would
|
|
use some library with a name ending in "`.js`". As JavaScript matured as a scripting language, however, standardized across browsers and
|
|
provided more and more functionality, these frameworks and libraries became less important.
|
|
|
|
Somewhat ironically though, as JavaScript became more powerful and removed the need for the first generation of
|
|
JavaScript libraries such as jQuery, it also enabled people to build complex SPA libraries. These SPA libraries are often
|
|
even more elaborate than the original first generation of JavaScript libraries.
|
|
|
|
A quote from the website http://vanilla-js.com, which is well worth visiting even though it's slightly out of date,
|
|
captures the situation well:
|
|
|
|
[quote,http://vanilla-js.com]
|
|
VanillaJS is the lowest-overhead, most comprehensive framework I've ever used.
|
|
|
|
With JavaScript having matured as a scripting language, this is certainly the case for many applications. It is
|
|
especially true in the case of HDAs, since, by using hypermedia, your application will not need many of the features
|
|
typically provided by more elaborate Single Page Application JavaScript frameworks:
|
|
|
|
* Client-side routing
|
|
* An abstraction over DOM manipulation (i.e., templates that automatically update when referenced variables change)
|
|
* Server side rendering footnote:[Rendering here refers to HTML generation. Framework support for server-side rendering
|
|
is not needed in a HDA because generating HTML on the server is the default.]
|
|
* Attaching dynamic behavior to server-rendered tags on load (i.e., "`hydration`")
|
|
* Network requests
|
|
|
|
Without all this complexity being handled in JavaScript, your framework needs are dramatically reduced.
|
|
|
|
One of the best things about VanillaJS is how you install it: you don't have to!
|
|
|
|
You can just start writing JavaScript in your web application, and it will simply work.
|
|
|
|
That's the good news. The bad news is that, despite improvements over the last decade, JavaScript has some significant
|
|
limitations as a scripting language that can make it less than ideal as a stand-alone scripting technology for
|
|
Hypermedia-Driven Applications:
|
|
|
|
* Being as established as it is, it has accreted a lot of features and warts.
|
|
* It has a complicated and confusing set of features for working with asynchronous code.
|
|
* Working with events is surprisingly difficult.
|
|
* DOM APIs (a large portion of which were originally designed for Java, yes _Java_)
|
|
are verbose and don't have a habit of making common functionality easy to use.
|
|
|
|
None of these limitations are deal-breakers, of course. Many of them are gradually being fixed and many people prefer
|
|
the "`close to the metal`" (for lack of a better term) nature of vanilla JavaScript over more elaborate client-side scripting approaches.
|
|
|
|
=== A Simple Counter
|
|
|
|
To dive into vanilla JavaScript as a front end scripting option, let's create a simple counter widget.
|
|
|
|
Counter widgets are a common "`Hello World`" example for JavaScript frameworks, so looking at how it can be done in
|
|
vanilla JavaScript (as well as the other options we are going to look at) will be instructive.
|
|
|
|
Our counter widget will be very simple: it will have a number, shown as text, and a button that increments the number.
|
|
|
|
One problem with tackling this problem in vanilla JavaScript is that it lacks one thing that most JavaScript frameworks
|
|
provide: a default code and architectural style.
|
|
|
|
With vanilla JavaScript, there are no rules!
|
|
|
|
This isn't all bad. It presents a great opportunity to take a small journey through various styles that people have
|
|
developed for writing their JavaScript.
|
|
|
|
==== An inline implementation
|
|
|
|
To begin, let's start with the simplest thing imaginable: all of our JavaScript will be written inline, directly in the
|
|
HTML. When the button is clicked, we will look up the `output` element holding the number, and increment the number
|
|
contained within it.
|
|
|
|
.Counter in vanilla JavaScript, inline version
|
|
[source,html]
|
|
----
|
|
<section class="counter">
|
|
<output id="my-output">0</output> <1>
|
|
<button
|
|
onclick=" <2>
|
|
document.querySelector('#my-output') <3>
|
|
.textContent++ <4>
|
|
"
|
|
>Increment</button>
|
|
</section>
|
|
----
|
|
<1> Our output element has an ID to help us find it.
|
|
<2> We use the `onclick` attribute to add an event listener.
|
|
<3> Find the output via a querySelector() call.
|
|
<4> JavaScript allows us use the `++` operator on strings.
|
|
|
|
Not too bad.
|
|
|
|
It's not the most beautiful code, and can be irritating especially if you aren't used to the DOM APIs.
|
|
|
|
It's a little annoying that we needed to add an `id` to the `output` element. The `document.querySelector()` function
|
|
is a bit verbose compared with, say, the `$` function, as provided by jQuery.
|
|
|
|
But it works. It's also easy enough to understand, and crucially it doesn't require any other JavaScript libraries.
|
|
|
|
So that's the simple, inline approach with VanillaJS.
|
|
|
|
==== Separating our scripting out
|
|
|
|
While the inline implementation is simple in some sense, a more standard way to write this would be to move the code
|
|
into a separate JavaScript file. This JavaScript file would then either be linked to via a `<script src>` tag or
|
|
placed into an inline `<script>` tag by a build process.
|
|
|
|
Here we see the HTML and JavaScript _separated out_ from one another, in different files. The HTML is now "`cleaner`" in
|
|
that there is no JavaScript in it.
|
|
|
|
The JavaScript is a bit more complex than in our inline version: we need to look up the button using a query selector
|
|
and add an _event listener_ to handle the click event and increment the counter.
|
|
|
|
.Counter HTML
|
|
[source,html]
|
|
----
|
|
<section class="counter">
|
|
<output id="my-output">0</output>
|
|
<button class="increment-btn">Increment</button>
|
|
</section>
|
|
----
|
|
|
|
.Counter JavaScript
|
|
[source,js]
|
|
----
|
|
const counterOutput = document.querySelector("#my-output") <1>
|
|
const incrementBtn = document.querySelector(".counter .increment-btn") <2>
|
|
|
|
incrementBtn.addEventListener("click", e => { <3>
|
|
counterOutput.innerHTML++ <4>
|
|
})
|
|
----
|
|
<1> Find the output element.
|
|
<2> Find the button.
|
|
<3> We use `addEventListener`, which is preferable to `onclick` for many reasons.
|
|
<4> The logic stays the same, only the structure around it changes.
|
|
|
|
In moving the JavaScript out to another file, we are following a software design principle known as _Separation of Concerns (SoC)._
|
|
|
|
Separation of Concerns posits that the various "`concerns`" (or aspects) of a software project should be divided up into
|
|
multiple files, so that they don't "`pollute`" one another. JavaScript isn't markup, so it shouldn't be in your HTML,
|
|
it should be _elsewhere_. Styling information, similarly, isn't markup, and so it belongs in a separate file as well
|
|
(A CSS file, for example.)
|
|
|
|
For quite some time, this Separation of Concerns was considered the "`orthodox`" way to build web applications.
|
|
|
|
A stated goal of Separation of Concerns is that we should be able to modify and evolve each concern independently, with
|
|
confidence that we won't break any of the other concerns.
|
|
|
|
However, let's look at exactly how this principle has worked out in our simple counter example. If you look closely
|
|
at the new HTML, it turns out that we've had to add a class to the button. We added this class so that we could look the button
|
|
up in JavaScript and add in an event handler for the "`click`" event.
|
|
|
|
Now, in both the HTML and the JavaScript, this class name is just a string and there isn't any process to _verify_ that
|
|
the button has the right classes on it or its parents to ensure that the event handler is actually added to the right element.
|
|
|
|
Unfortunately, it has turned out that the careless use of CSS selectors in JavaScript can cause what is known as
|
|
_jQuery soup_. jQuery soup is a situation where:
|
|
|
|
* The JavaScript that attaches a given behavior to a given element is difficult to find.
|
|
* Code reuse is difficult.
|
|
* The code ends up wildly disorganized and "`flat`", with lots of unrelated event handlers mixed together.
|
|
|
|
The name "`jQuery soup`" comes from the fact that most JavaScript-heavy applications used to be built in jQuery (many still are),
|
|
which, perhaps inadvertently, tended to encourage this style of JavaScript.
|
|
|
|
So, you can see that the notion of Separation of Concerns doesn't always work as well as promised: our concerns
|
|
end up intertwined or coupled pretty deeply, even when we separate them into different files.
|
|
|
|
image::diagram/separation-of-concerns.svg["Expectation: HTML concern, CSS concern, JS concern. Reality: HTML Co co co CSS nc nc nc JS ern ern ern"]
|
|
|
|
To show that it isn't just naming between concerns that can get you into trouble, consider another small change to our HTML
|
|
that demonstrates the problems with our separation of concerns: imagine that we decide to change the number field from
|
|
an `<output>` tag to an `<input type="number">`.
|
|
|
|
This small change to our HTML will break our JavaScript, despite the fact we have "`separated`" our concerns.
|
|
|
|
The fix for this issue is simple enough (we would need to change the `.textContent` property to `.value` property), but
|
|
it demonstrates the burden of synchronizing markup changes and code changes across multiple files. Keeping everything
|
|
in sync can become increasingly difficult as your application size increases.
|
|
|
|
The fact that small changes to our HTML can break our scripting indicates that the two are _tightly coupled_, despite being
|
|
broken up into multiple files. This tight coupling suggests that separation between HTML and JavaScript (and CSS) is often
|
|
an illusory separation of concerns: the concerns are sufficiently related to one another that they aren't easily separated.
|
|
|
|
In Contact.app we are not _concerned_ with "`structure,`" "`styling`" or "`behavior`"; we are concerned with collecting contact
|
|
info and presenting it to users. SoC, in the way it's formulated in web development orthodoxy, is not really an inviolate
|
|
architectural guideline, but rather a stylistic choice that, as we can see, can even become a hindrance.
|
|
|
|
==== Locality of Behavior
|
|
|
|
It turns out that there is a burgeoning reaction _against_ the Separation of Concerns design principle. Consider the
|
|
following web technologies and techniques:
|
|
|
|
* JSX
|
|
* LitHTML
|
|
* CSS-in-JS
|
|
* Single-File Components
|
|
* Filesystem based routing
|
|
|
|
Each of these technologies _colocate_ code in various languages that address a single _feature_ (typically a UI widget).
|
|
|
|
All of them mix _implementation_ concerns together in order to present a unified abstraction to the end-user. Separating
|
|
technical detail concerns just isn't as much of an, ahem, concern.
|
|
|
|
Locality of Behavior (LoB) is an alternative software design principle that we coined, in opposition to Separation of Concerns.
|
|
It describes the following characteristic of a piece of software:
|
|
|
|
[quote, https://htmx.org/essays/locality-of-behaviour/]
|
|
The behavior of a unit of code should be as obvious as possible by looking only at that unit of code.
|
|
|
|
In simple terms: you should be able to tell what a button does by simply looking at the code or markup that creates that button.
|
|
This does not mean you need to inline the entire implementation, but that you shouldn't need to hunt for it or require prior knowledge of the codebase to find it.
|
|
|
|
We will demonstrate Locality of Behavior in all of our examples, both the counter demos and the features we add to Contact.app.
|
|
Locality of behavior is an explicit design goal of both +_hyperscript+ and Alpine.js (which we will cover later) as well as htmx.
|
|
|
|
All of these tools achieve Locality of Behavior by having you embed attributes directly within your HTML, as opposed to
|
|
having code look up elements in a document through CSS selectors in order to add event listeners onto them.
|
|
|
|
In a Hypermedia-Driven Application, we feel that the Locality of Behavior design principle is often more important than
|
|
the more traditional Separation of Concerns design principle.
|
|
|
|
==== What to do with our counter?
|
|
|
|
So, should we go back to the `onclick` attribute way of doing things? That approach certainly wins in Locality of
|
|
Behavior, and has the additional benefit that it is baked into HTML.
|
|
|
|
Unfortunately, however, the `on*` JavaScript attributes also come with some drawbacks:
|
|
|
|
* They don't support custom events.
|
|
* There is no good mechanism for associating long-lasting variables with an element -- all variables are discarded when an event listener completes executing.
|
|
* If you have multiple instances of an element, you will need to repeat the listener code on each, or use something more clever like event delegation.
|
|
* JavaScript code that directly manipulates the DOM gets verbose, and clutters the markup.
|
|
* An element cannot listen for events on another element.
|
|
|
|
Consider this common situation: you have a popup, and you want it to be dismissed when a user clicks outside of it. The
|
|
listener will need to be on the body element in this situation, far away from the actual popup markup. This means that
|
|
the body element would need to have listeners attached to it that deal with many unrelated components. Some of these
|
|
components may not even be on the page when it was first rendered, if they are added dynamically after the initial
|
|
HTML page is rendered.
|
|
|
|
So vanilla JavaScript and Locality of Behavior don't seem to mesh _quite_ as well as we would like them to.
|
|
|
|
The situation is not hopeless, however: it's important to understand that LoB does not require behavior to be _implemented_
|
|
at a use site, but merely _invoked_ there. That is, we don't need to write all our code on a given element, we just
|
|
need to make it clear that a given element is _invoking_ some code, which can be located elsewhere.
|
|
|
|
Keeping this in mind, it _is_ possible to improve LoB while writing JavaScript in a separate file, provided we have a
|
|
reasonable system for structuring our JavaScript.
|
|
|
|
=== RSJS
|
|
|
|
RSJS (the "`Reasonable System for JavaScript Structure,`" https://ricostacruz.com/rsjs/) is a set of guidelines for
|
|
JavaScript architecture targeted at "`a typical non-SPA website.`" RSJS provides a solution to the lack of a standard code
|
|
style for vanilla JavaScript that we mentioned earlier.
|
|
|
|
Here are the RSJS guidelines most relevant for our counter widget:
|
|
|
|
* "`Use `data-` attributes`" in HTML: invoking behavior via adding data attributes makes it obvious there is JavaScript happening, as opposed to using random classes or IDs that may be mistakenly removed or changed.
|
|
* "`One component per file`": the name of the file should match the data attribute so that it can be found easily, a win for LoB.
|
|
|
|
To follow the RSJS guidelines, let's restructure our current HTML and JavaScript files. First, we will use _data attributes_,
|
|
that is, HTML attributes that begin with `data-`, a standard feature of HTML, to indicate that our HTML is a counter
|
|
component. We will then update our JavaScript to use an attribute selector that looks for the `data-counter` attribute
|
|
as the root element in our counter component and wires in the appropriate event handlers and logic. Additionally, let's
|
|
rework the code to use `querySelectorAll()` and add the counter functionality to _all_ counter components found on the
|
|
page. (You never know how many counters you might want!)
|
|
|
|
Here is what our code looks like now:
|
|
|
|
.Counter in vanilla JavaScript, with RSJS
|
|
--
|
|
[source,html]
|
|
----
|
|
<section class="counter" data-counter> <1>
|
|
<output id="my-output" data-counter-output>0</output> <2>
|
|
<button class="increment-btn" data-counter-increment>Increment</button>
|
|
</section>
|
|
----
|
|
<1> Invoke a JavaScript behavior with a data attribute.
|
|
<2> Mark relevant descendant elements.
|
|
|
|
[source,js]
|
|
----
|
|
// counter.js <1>
|
|
document.querySelectorAll("[data-counter]") <2>
|
|
.forEach(el => {
|
|
const
|
|
output = el.querySelector("[data-counter-output]"),
|
|
increment = el.querySelector("[data-counter-increment]"); <3>
|
|
|
|
increment.addEventListener("click", e => output.textContent++); <4>
|
|
});
|
|
----
|
|
<1> File should have the same name as the data attribute, so that we can locate it easily.
|
|
<2> Get all elements that invoke this behavior.
|
|
<3> Get any child elements we need.
|
|
<4> Register event handlers.
|
|
--
|
|
|
|
Using RSJS solves, or at least alleviates, many of the problems we pointed out with our first, unstructured example of VanillaJS being
|
|
split out to a separate file:
|
|
|
|
* The JS that attaches behavior to a given element is _clear_ (though only through naming conventions).
|
|
* Reuse is _easy_ -- you can create another counter component on the page and it will just work.
|
|
* The code is _well-organized_ -- one behavior per file.
|
|
|
|
All in all, RSJS is a good way to structure your vanilla JavaScript in a Hypermedia-Driven Application. So long as the
|
|
JavaScript isn't communicating with a server via a plain data JSON API, or holding a bunch of internal state outside of
|
|
the DOM, this is perfectly compatible with the HDA approach.
|
|
|
|
Let's implement a feature in Contact.app using the RSJS/vanilla JavaScript approach.
|
|
|
|
=== VanillaJS in Action: An Overflow Menu
|
|
|
|
Our homepage has "`Edit`", "`View`" and "`Delete`" links for every contact in our table. This uses a lot of space and creates
|
|
visual clutter. Let's fix that by placing these actions inside a drop-down menu with a button to open it.
|
|
|
|
If you're less familiar with JavaScript and the code here starts to feel too complicated, don't worry; the Alpine.js and +_hyperscript+ examples -- which we'll look at next -- are easier to follow.
|
|
|
|
Let's begin by sketching the markup we want for our dropdown menu. First, we need an element, we'll use a `<div>`, to enclose the
|
|
entire widget and mark it as a menu component. Within this div, we will have a standard `<button>` that will function
|
|
as the mechanism that shows and hides our menu items. Finally, we'll have another `<div>` that holds the menu items
|
|
that we are going to show.
|
|
|
|
These menu items will be simple anchor tags, as they are in the current contacts table.
|
|
|
|
Here is what our updated, RSJS-structured HTML looks like:
|
|
|
|
[source,html]
|
|
----
|
|
<div data-overflow-menu> <1>
|
|
<button type="button" aria-haspopup="menu"
|
|
aria-controls="contact-menu-{{ contact.id }}"
|
|
>Options</button> <2>
|
|
<div role="menu" hidden id="contact-menu-{{ contact.id }}"> <3>
|
|
<a role="menuitem" href="/contacts/{{ contact.id }}/edit">Edit</a> <4>
|
|
<a role="menuitem" href="/contacts/{{ contact.id }}">View</a>
|
|
<!-- ... -->
|
|
</div>
|
|
</div>
|
|
----
|
|
<1> Mark the root element of the menu component
|
|
<2> This button will open and close our menu
|
|
<3> A container for our menu items
|
|
<4> Menu items
|
|
|
|
The roles and ARIA attributes are based on the Menu and Menu Button patterns from the ARIA Authoring Practices Guide.
|
|
|
|
.What is ARIA?
|
|
****
|
|
As we web developers create more interactive, app-like websites, HTML's repertoire of elements won't have all we need.
|
|
As we have seen, using CSS and JavaScript, we can endow existing elements with extended behavior and appearances, rivaling
|
|
those of native controls.
|
|
|
|
However, there is one thing web apps used to be unable to replicate. While these widgets are similar enough in appearance
|
|
for most users to operate, assistive technology (e.g., screen readers) can only report the underlying HTML elements.
|
|
|
|
Even if you take the time to get all the keyboard interactions right, some users often are unable to work with these custom
|
|
elements easily.
|
|
|
|
ARIA was created by W3C's Web Accessibility Initiative (WAI) in 2008 to address this problem. At a surface level, it is
|
|
a set of attributes you can add to HTML to make it meaningful to assistive software such as a screen reader.
|
|
|
|
ARIA has two main components that interact with one another:
|
|
|
|
The first is the `role` attribute. This attribute has a predefined set of possible values:
|
|
|
|
* `menu`
|
|
* `dialog`
|
|
* `radiogroup`
|
|
* etc.
|
|
|
|
The `role` attribute _does not add any behavior_ to HTML elements. Rather, it is a promise you make to the user. When
|
|
you annotate an element as `role='menu'`, you are saying: _I will make this element work like a menu._
|
|
|
|
Because this is a promise you are making, if you add the `role` attribute to an element but you _don't_ uphold
|
|
the promise, the experience for many users will be _worse_ than if the element had no `role` annotation on it at all.
|
|
|
|
Because of this, it is written:
|
|
|
|
[quote, W3C, Read Me First | APG https://www.w3.org/WAI/ARIA/apg/practices/read-me-first/]
|
|
No ARIA is better than Bad ARIA.
|
|
|
|
The second component of ARIA is a whole range of attributes, all sharing the `aria-` prefix:
|
|
|
|
* `aria-expanded`
|
|
* `aria-controls`
|
|
* `aria-label`
|
|
* etc.
|
|
|
|
These attributes can specify various things such as the state of a widget, the relationships between components, or
|
|
additional semantics.
|
|
|
|
Once again, these attributes are _promises_, not implementations.
|
|
|
|
Rather than learn all the roles and attributes and try to combine them into a usable widget,
|
|
the best course of action for most developers is to rely on the ARIA Authoring Practices Guide (APG),
|
|
a web resource with practical information aimed directly at web developers.
|
|
|
|
If you're new to ARIA, check out the following W3C resources:
|
|
|
|
- https://www.w3.org/WAI/ARIA/apg/practices/read-me-first/[ARIA Read Me First]
|
|
- https://www.w3.org/WAI/ARIA/apg/patterns/[ARIA UI patterns]
|
|
- https://www.w3.org/WAI/ARIA/apg/practices/[ARIA Good Practices]
|
|
|
|
Always remember to test your website for accessibility to ensure a maximum number of users can interact with it
|
|
easily and effectively.
|
|
****
|
|
|
|
With this brief introduction to ARIA, let's return to our VanillaJS drop down menu. We'll begin with the RSJS
|
|
boilerplate: query for all elements with some data attribute, iterate over them, get any relevant descendants.
|
|
|
|
Note that, below, we've modified the RSJS boilerplate a bit to integrate with htmx; we load the
|
|
overflow menu when htmx loads new content.
|
|
|
|
[source,js]
|
|
----
|
|
|
|
function overflowMenu(subtree = document) {
|
|
document.querySelectorAll("[data-overflow-menu]").forEach(menuRoot => { <1>
|
|
const
|
|
button = menuRoot.querySelector("[aria-haspopup]"), <2>
|
|
menu = menuRoot.querySelector("[role=menu]"), <2>
|
|
items = [...menu.querySelectorAll("[role=menuitem]")]; <3>
|
|
});
|
|
}
|
|
|
|
addEventListener("htmx:load", e => overflowMenu(e.target)); <4>
|
|
----
|
|
<1> With RSJS, you'll be writing `document.querySelectorAll(...).forEach` a lot.
|
|
<2> To keep the HTML clean, we use ARIA attributes rather than custom data attributes here.
|
|
<3> Use the spread operator to convert a `NodeList` into a normal `Array`.
|
|
<4> Initialize all overflow menus when the page is loaded or content is inserted by htmx.
|
|
|
|
Conventionally, we would keep track of whether the menu is open using a JavaScript variable or a property in a JavaScript
|
|
state object. This approach is common in large, JavaScript-heavy web applications.
|
|
|
|
However, this approach has some drawback:
|
|
|
|
* We would need to keep the DOM in sync with the state (harder without a framework).
|
|
* We would lose the ability to serialize the HTML (as this open state isn't stored in the DOM, but rather in JavaScript).
|
|
|
|
Instead of taking this approach, we will use the DOM to store our state. We'll lean on the `hidden` attribute on the
|
|
menu element to tell us it's closed. If the HTML of the page is snapshotted and restored, the menu can be restored as
|
|
well by simply re-running the JS.
|
|
|
|
[source,js,highlight=3]
|
|
----
|
|
items = [...menu.querySelectorAll("[role=menuitem]")];
|
|
|
|
const isOpen = () => !menu.hidden; <1>
|
|
|
|
});
|
|
----
|
|
<1> The `hidden` attribute is helpfully reflected as a `hidden` _property_, so we don't need to use `getAttribute`.
|
|
|
|
We'll also make the menu items non-tabbable, so we can manage their focus ourselves.
|
|
|
|
[source,js,highlight=3]
|
|
----
|
|
const isOpen = () => !menu.hidden; <1>
|
|
|
|
items.forEach(item => item.setAttribute("tabindex", "-1"));
|
|
|
|
});
|
|
----
|
|
|
|
Now let's implement toggling the menu in JavaScript:
|
|
|
|
[source,js,highlight=3..16]
|
|
----
|
|
items.forEach(item => item.setAttribute("tabindex", "-1"));
|
|
|
|
function toggleMenu(open = !isOpen()) { <1>
|
|
if (open) {
|
|
menu.hidden = false;
|
|
button.setAttribute("aria-expanded", "true");
|
|
items[0].focus(); <2>
|
|
} else {
|
|
menu.hidden = true;
|
|
button.setAttribute("aria-expanded", "false");
|
|
}
|
|
}
|
|
|
|
toggleMenu(isOpen()); <3>
|
|
button.addEventListener("click", () => toggleMenu()); <4>
|
|
menuRoot.addEventListener("blur", e => toggleMenu(false)); <5>
|
|
|
|
})
|
|
----
|
|
<1> Optional parameter to specify desired state. This allows us to use one function to open, close, or toggle the menu.
|
|
<2> Focus first item of menu when opened.
|
|
<3> Call `toggleMenu` with current state, to initialize element attributes.
|
|
<4> Toggle menu when button is clicked.
|
|
<5> Close menu when focus moves away.
|
|
|
|
Let's also make the menu close when we click outside it, a nice behavior that mimics how native drop-down menus work. This
|
|
will require an event listener on the whole window.
|
|
|
|
Note that we need to be careful with this kind of listener: you may find that listeners accumulate as components add
|
|
listeners and fail to remove them when the component is removed from the DOM. This, unfortunately, leads to difficult
|
|
to track down memory leaks.
|
|
|
|
There is not an easy way in JavaScript to execute logic when an element is removed. The best option is what is known
|
|
as the `MutationObserver` API. A `MutationObserver` is very useful, but the API is quite heavy and a bit arcane, so we
|
|
won't be using it for our example.
|
|
|
|
Instead, we will use a simple pattern to avoid leaking event listeners: when our event listener runs, we will check if the
|
|
attaching component is still in the DOM, and, if the element is no longer in the DOM, we will remove the listener and
|
|
exit.
|
|
|
|
This is a somewhat hacky, manual form of _garbage collection_. As is (usually) the case with other garbage collection
|
|
algorithms, our strategy removes listeners in a nondeterministic amount of time after they are no longer needed. Fortunately
|
|
for us, With a frequent event like "`the user clicks anywhere in the page`" driving the collection, it should work well
|
|
enough for our system.
|
|
|
|
[source,js,highlight=3..6]
|
|
----
|
|
menuRoot.addEventListener("blur", e => toggleMenu(false));
|
|
|
|
window.addEventListener("click", function clickAway(event) {
|
|
if (!menuRoot.isConnected) window.removeEventListener("click", clickAway); <1>
|
|
if (!menuRoot.contains(event.target)) toggleMenu(false); <2>
|
|
});
|
|
});
|
|
----
|
|
<1> This line is the garbage collection.
|
|
<2> If the click is outside the menu, close the menu.
|
|
|
|
Now, let's move on to the keyboard interactions for our dropdown menu. The keyboard handlers turn out to all be pretty
|
|
similar to one another and not particularly intricate, so let's knock them all out in one go:
|
|
|
|
[source,js,highlight=4..30]
|
|
----
|
|
if (!menuRoot.contains(event.target)) toggleMenu(false); <2>
|
|
});
|
|
|
|
const currentIndex = () => { <1>
|
|
const idx = items.indexOf(document.activeElement);
|
|
if (idx === -1) return 0;
|
|
return idx;
|
|
}
|
|
|
|
menu.addEventListener("keydown", e => {
|
|
if (e.key === "ArrowUp") {
|
|
items[currentIndex() - 1]?.focus(); <2>
|
|
|
|
} else if (e.key === "ArrowDown") {
|
|
items[currentIndex() + 1]?.focus(); <3>
|
|
|
|
} else if (e.key === "Space") {
|
|
items[currentIndex()].click(); <4>
|
|
|
|
} else if (e.key === "Home") {
|
|
items[0].focus(); <5>
|
|
|
|
} else if (e.key === "End") {
|
|
items[items.length - 1].focus(); <6>
|
|
|
|
} else if (e.key === "Escape") {
|
|
toggleMenu(false); <7>
|
|
button.focus(); <8>
|
|
}
|
|
});
|
|
});
|
|
----
|
|
<1> Helper: Get the index in the items array of the currently focused menu item (0 if none).
|
|
<2> Move focus to the previous menu item when the up arrow key is pressed.
|
|
<3> Move focus to the next menu item when the down arrow key is pressed.
|
|
<4> Activate the currently focused element when the space key is pressed.
|
|
<5> Move focus to the first menu item when Home is pressed.
|
|
<6> Move focus to the last menu item when End is pressed.
|
|
<7> Close menu when Escape is pressed.
|
|
<8> Return focus to menu button when closing menu.
|
|
|
|
That should cover all our bases, and we'll admit that's a lot of code. But, in fairness, it's code that encodes a
|
|
lot of behavior.
|
|
|
|
Now, our drop-down menu isn't perfect, and it doesn't handle a lot of things. For example, we don't support submenus,
|
|
or menu items being added or removed dynamically to the menu. If we needed more menu features like this, it might make
|
|
more sense to use an off-the-shelf library, such as GitHub's https://github.com/github/details-menu-element[`details-menu-element`].
|
|
|
|
But, for our relatively simple use case, vanilla JavaScript does a fine job, and we got to explore ARIA and RSJS while
|
|
implementing it.
|
|
|
|
|
|
== Alpine.js
|
|
|
|
OK, so that's an in-depth look at how to structure plain VanillaJS-style JavaScript. Let's turn our attention to an
|
|
actual JavaScript framework that enables a different approach for adding dynamic behavior to your application,
|
|
https://alpinejs.dev[Alpine.js].
|
|
|
|
Alpine is a relatively new JavaScript library that allows developers to embed JavaScript code directly in HTML, akin to
|
|
the `on*` attributes available in plain HTML and JavaScript. However, Alpine takes this concept of embedded scripting
|
|
much further than `on*` attributes.
|
|
|
|
Alpine bills itself as a modern replacement for jQuery, the widely used, older JavaScript library. As you will see, it
|
|
definitely lives up to this promise.
|
|
|
|
Installing Alpine is very easy: it is a single file and is dependency-free, so you can simply include it via a CDN:
|
|
|
|
.Installing Alpine
|
|
[source,html]
|
|
----
|
|
<script src="https://unpkg.com/alpinejs"></script>
|
|
----
|
|
|
|
You can also install it via a package manager such as NPM, or vendor it from your own server.
|
|
|
|
Alpine provides a set of HTML attributes, all of which begin with the `x-` prefix, the main one of which is `x-data`.
|
|
The content of `x-data` is a JavaScript expression which evaluates to an object. The properties of this object can, then,
|
|
be accessed within the element that the `x-data` attribute is located.
|
|
|
|
To get a flavor of AlpineJS, let's look at how to implement our counter example using it.
|
|
|
|
For the counter, the only state we need to keep track of is the current number, so let's declare a JavaScript object
|
|
with one property, `count`, in an `x-data` attribute on the div for our counter:
|
|
|
|
// TODO: check: removed class="counter" to avoid confusion
|
|
|
|
.Counter with Alpine, line 1
|
|
[source,html]
|
|
----
|
|
<div x-data="{ count: 0 }">
|
|
----
|
|
|
|
This defines our state, that is, the data we are going to be using to drive dynamic updates to the DOM. With the state
|
|
declared like this, we can now use it _within_ the div element it is declared on. Let's add an `output` element with
|
|
an `x-text` attribute.
|
|
|
|
Next, we will _bind_ the `x-text` attribute to the `count` attribute we declared in the `x-data` attribute
|
|
on the parent `div` element. This will have the effect of setting the text of the `output` element to whatever the
|
|
value of `count` is: if `count` is updated, so will the text of the `output`. This is "`reactive`" programming, in that
|
|
the DOM will "`react`" to changes to the backing data.
|
|
|
|
.Counter with Alpine, lines 1-2
|
|
[source,html,highlight=2]
|
|
----
|
|
<div x-data="{ count: 0 }">
|
|
<output x-text="count"></output> <1>
|
|
----
|
|
<1> The `x-text` attribute.
|
|
|
|
Next, we need to update the count, using a button. Alpine allows you to attach event listeners with the `x-on` attribute.
|
|
|
|
To specify the event to listen for, you add a colon and then the event name after the `x-on` attribute name. Then, the
|
|
value of the attribute is the JavaScript you wish to execute. This is similar to the plain `on*` attributes we discussed
|
|
earlier, but it turns out to be much more flexible.
|
|
|
|
We want to listen for a `click` event, and we want to increment `count` when a click occurs, so here is what the Alpine
|
|
code will look like:
|
|
|
|
.Counter with Alpine, the full thing
|
|
[source,html,highlight=4]
|
|
----
|
|
<div x-data="{ count: 0 }">
|
|
<output x-text="count"></output>
|
|
|
|
<button x-on:click="count++">Increment</button> <1>
|
|
</div>
|
|
----
|
|
|
|
<1> With `x-on`, we specify the attribute in the attribute _name_.
|
|
|
|
And that's all it takes. A simple component like a counter should be simple to code, and Alpine delivers.
|
|
|
|
=== "`x-on:click`" vs. "`onclick`"
|
|
|
|
As we said, the Alpine `x-on:click` attribute (or its shorthand, the `@click` attribute) is similar to the built-in
|
|
`onclick` attribute. However, it has additional features that make it significantly more useful:
|
|
|
|
* You can listen for events from other elements. For example, the `.outside` modifier lets you listen to any click event that is _not_ within the element.
|
|
* You can use other modifiers to:
|
|
** throttle or debounce event listeners
|
|
** ignore events that are bubbled up from descendant elements
|
|
** attach passive listeners
|
|
* You can listen to custom events. For example, if you wanted to listen for the `htmx:after-request` event you could write
|
|
`x-on:htmx:after-request="doSomething()"`.
|
|
|
|
=== Reactivity and Templating
|
|
|
|
We hope you'll agree that the AlpineJS version of the counter widget is better, in general, than the VanillaJS
|
|
implementation, which was either somewhat hacky or spread out over multiple files.
|
|
|
|
A big part of the power of AlpineJS is that it supports a notion of "`reactive`" variables, allowing you to bind the count
|
|
of the `div` element to a variable that both the `output` and the `button` can reference, and properly updating all the
|
|
dependencies when a mutation occurs. Alpine allows for much more elaborate data bindings than we have demonstrated
|
|
here, and it is an excellent general purpose client-side scripting library.
|
|
|
|
=== Alpine.js in Action: A Bulk Action Toolbar
|
|
|
|
Let's implement a feature in Contact.app with Alpine. As it stands currently, Contact.app has a "`Delete Selected
|
|
Contacts`" button at the very bottom of the page. This button has a long name, is not easy to find and takes up a
|
|
lot of room. If we wanted to add additional "`bulk`" actions, this wouldn't scale well visually.
|
|
|
|
In this section, we'll replace this single button with a toolbar. Furthermore, the toolbar will only appear when the
|
|
user starts selecting contacts. Finally, it will show how many contacts are selected and let you select all contacts in
|
|
one go.
|
|
|
|
The first thing we will need to add is an `x-data` attribute, to hold the state that we will use to determine if the
|
|
toolbar is visible or not. We will need to place this on a parent element of both the toolbar that we are going to
|
|
add, as well as the checkboxes, which will be updating the state when they are checked and unchecked. The best
|
|
option given our current HTML is to place the attribute on the `form` element that surrounds the contacts table. We
|
|
will declare a property, `selected`, which will be an array that holds the selected contact ids, based on the checkboxes
|
|
that are selected.
|
|
|
|
Here is what our form tag will look like:
|
|
|
|
[source,html]
|
|
----
|
|
<form x-data="{ selected: [] }"> <1>
|
|
----
|
|
<1> This form wraps around the contacts table.
|
|
|
|
Next, at the top of the contacts table, we are going to add a `template` tag. A template tag is _not_ rendered by a
|
|
browser, by default, so you might be surprised that we are using it. However, by adding an Alpine `x-if` attribute,
|
|
we can tell Alpine: if a condition is true, show the HTML within this template.
|
|
|
|
Recall that we want to show the toolbar if and only if one or more contacts are selected. But we know that we will
|
|
have the ids of the selected contacts in the `selected` property. Therefore, we can check the _length_ of that array
|
|
to see if there are any selected contacts, quite easily:
|
|
|
|
// TODO: were we going to have a selected count in the toolbar too?
|
|
[source,html]
|
|
----
|
|
<template x-if="selected.length > 0"> <1>
|
|
<div class="box info tool-bar">
|
|
<slot x-text="selected.length"></slot>
|
|
contacts selected
|
|
|
|
<button type="button" class="bad bg color border">Delete</button> <2>
|
|
<hr aria-orientation="vertical">
|
|
<button type="button">Cancel</button>
|
|
</div>
|
|
</template>
|
|
----
|
|
<1> Show this HTML if there are 1 or more selected contacts.
|
|
<2> We will implement these buttons in just a moment.
|
|
// remove or explain missing.css class styles?
|
|
The next step is to ensure that toggling a checkbox for a given contact adds (or removes) a given contact's id from the
|
|
`selected` property. To do this, we will need to use a new Alpine attribute, `x-model`. The `x-model` attribute allows
|
|
you to _bind_ a given element to some underlying data, or its "`model.`"
|
|
|
|
In this case, we want to bind the value of the checkbox inputs to the `selected` property. This is how we do this:
|
|
|
|
[source,html]
|
|
----
|
|
<td>
|
|
<input type="checkbox" name="selected_contact_ids" value="{{ contact.id }}" x-model="selected"> <1>
|
|
</td>
|
|
----
|
|
<1> The `x-model` attribute binds the `value` of this input to the `selected` property
|
|
|
|
Now, when a checkbox is checked or unchecked, the `selected` array will be updated with the given row's contact id.
|
|
Furthermore, mutations we make to the `selected` array will similarly be reflected in the checkboxes' state. This is
|
|
known as a _two-way_ binding.
|
|
|
|
With this code written, we can make the toolbar appear and disappear, based on whether contact checkboxes are selected.
|
|
|
|
Very slick.
|
|
|
|
// useful? or cut css?
|
|
Before we move on, you may have noticed our code here includes some "`class=`" references. These are for css styling, and are not part of Alpine.js. We've included them only as a reminder that the menu bar we're building will require css to work well. The classes in the code above refer to a minimal css library called Missing.css. If you use other css libraries, such as Bootstrap, Tailwind, Bulma, Pico.css, etc., your styling code will be different.
|
|
|
|
==== Implementing actions
|
|
|
|
Now that we have the mechanics of showing and hiding the toolbar, let's look at how to implement the buttons within
|
|
the toolbar.
|
|
|
|
Let's first implement the "`Clear`" button, because it is quite easy. All we need to do is, when the button is clicked,
|
|
clear out the `selected` array. Because of the two-way binding that Alpine provides, this will uncheck all the selected
|
|
contacts (and then hide the toolbar)!
|
|
|
|
For the _Cancel_ button, our job is simple:
|
|
[source,html]
|
|
----
|
|
<button type="button" @click="selected = []">Cancel</button><1>
|
|
----
|
|
<1> Reset the `selected` array.
|
|
|
|
Once again, AlpineJS makes this very easy.
|
|
|
|
The "`Delete`" button, however, will be a bit more complicated. It will need to do two things: first it will confirm
|
|
if the user indeed intends to delete the contacts selected. Then, if the user confirms the action, it will use the htmx JavaScript API to issue a `DELETE` request.
|
|
|
|
[source,html]
|
|
----
|
|
<button type="button" class="bad bg color border"
|
|
@click="confirm(`Delete ${selected.length} contacts?`) && <1>
|
|
htmx.ajax('DELETE', '/contacts', { source: $root, target: document.body })" <2>
|
|
>Delete</button>
|
|
----
|
|
<1> Confirm the user wishes to delete the selected number of contacts.
|
|
<2> Issue a `DELETE` using the htmx JavaScript API.
|
|
|
|
Note that we are using the short-circuiting behavior of the `&&` operator in JavaScript to avoid the call to
|
|
`htmx.ajax()` if the `confirm()` call returns false.
|
|
|
|
The `htmx.ajax()` function is just a way to access the normal, HTML-driven hypermedia exchange that htmx's
|
|
HTML attributes give you directly from JavaScript.
|
|
|
|
Looking at how we call `htmx.ajax`, we first pass in that we want to issue a `DELETE` to `/contacts`. We then pass in
|
|
two additional pieces of information: `source` and `target`. The `source` property is the element from which htmx will
|
|
collect data to include in the request. We set this to `$root`, which is a special symbol in Alpine that will be
|
|
the element that has the `x-data` attribute declared on it. In this case, it will be the form containing all of our
|
|
contacts. The `target`, or where the response HTML will be placed, is just the entire document's body, since the
|
|
`DELETE` handler returns a whole page when it completes.
|
|
|
|
Note that we are using Alpine here in a Hypermedia-Driven Application compatible manner. We _could_ have issued an
|
|
AJAX request directly from Alpine and perhaps updated an `x-data` property depending on the results of that request.
|
|
But, instead, we delegated to htmx's JavaScript API, which made a _hypermedia exchange_ with the server.
|
|
|
|
This is the key to scripting in a hypermedia-friendly manner within a Hypermedia-Driven Application.
|
|
|
|
So, with all of this in place, we now have a much improved experience for performing bulk actions on contacts: less
|
|
visual clutter and the toolbar can be extended with more options without creating bloat in the main interface of our app.
|
|
|
|
== +_hyperscript+
|
|
|
|
The final scripting technology we are going to look at is a bit further afield: https://hyperscript.org[+_hyperscript+]. The authors of this book initially created +_hyperscript+ as a sibling project to htmx. We felt that JavaScript wasn't
|
|
event-oriented enough, which made adding small scripting enhancements to htmx applications cumbersome.
|
|
|
|
While the previous two examples are JavaScript-oriented, +_hyperscript+ has a completely different syntax than JavaScript, based on an older language called HyperTalk.
|
|
HyperTalk was the scripting language for a technology called HyperCard, an old hypermedia system available on early
|
|
Macintosh Computers.
|
|
|
|
The most noticeable thing about +_hyperscript+ is that it resembles English prose more than it resembles other programming
|
|
languages.
|
|
|
|
Like Alpine,
|
|
+_hyperscript+ is a modern jQuery replacement. Also like Alpine, +_hyperscript+ allows you to write your scripting inline, in HTML.
|
|
|
|
Unlike Alpine, however, +_hyperscript+ is _not_ reactive. It instead focuses on making DOM manipulations in response to events
|
|
easy to write and easy to read. It has built-in language constructs for many DOM operations, preventing you from needing
|
|
to navigate the sometimes-verbose JavaScript DOM APIs.
|
|
|
|
We will give a small taste of what scripting in the
|
|
+_hyperscript+ language is like, so you can pursue the language in more depth later if you find it interesting.
|
|
|
|
Like htmx and AlpineJS, +_hyperscript+ can be installed via a CDN or from npm (package name `hyperscript.org`):
|
|
|
|
.Installing +_hyperscript+ via CDN
|
|
[source,html]
|
|
----
|
|
<script src="//unpkg.com/hyperscript.org"></script>
|
|
----
|
|
|
|
+_hyperscript+ uses the `\_` (underscore) attribute for putting scripting on DOM elements. You may also use the `script`
|
|
or `data-script` attributes, depending on your HTML validation needs.
|
|
|
|
Let's look at how to implement the simple counter component we have been looking at using +_hyperscript+. We will place
|
|
an `output` element and a `button` inside of a `div`. To implement the counter, we will need to add a small bit of
|
|
+_hyperscript+ to the button. On a click, the button should increment the text of the previous `output` tag.
|
|
|
|
As you'll see, that last sentence is close to the actual +_hyperscript+ code:
|
|
|
|
[source,html]
|
|
----
|
|
<div class="counter">
|
|
<output>0</output>
|
|
<button _="on click increment the textContent of the previous <output/>">Increment</button> <1>
|
|
</div>
|
|
----
|
|
<1> The +_hyperscript+ code added inline to the button.
|
|
|
|
Let's go through each component of this script:
|
|
|
|
* `on click` is an event listener, telling the button to listen for a `click` event and then executing the remaining code.
|
|
* `increment` is a "`command`" in +_hyperscript+ that "`increments`" things, similar to the `++` operator in JavaScript.
|
|
* `the` doesn't have any semantic meaning in +_hyperscript+, but can used to make scripts more readable.
|
|
* `textContent of` is one form of _property access_ in +_hyperscript+. You are probably familiar with the JavaScript syntax `a.b`, meaning "Get the property `b` on object `a.`" +_hyperscript+ supports this syntax, but _also_ supports
|
|
the forms `b of a` and `a's b`. Which one you use should depend on which one is most readable.
|
|
* `previous` is an expression in +_hyperscript+ that finds the previous element in the DOM that matches some condition.
|
|
* `<output />` is a _query literal_, which is a CSS selector wrapped between `<` and `/>`.
|
|
|
|
In this code, the `previous` keyword (and the accompanying `next` keyword) is an example of how +_hyperscript+ makes DOM operations
|
|
easier: there is no such native functionality to be found in the standard DOM API, and implementing this in VanillaJS is trickier
|
|
than you might think!
|
|
|
|
So, you can see, +_hyperscript+ is very expressive, particularly when it comes to DOM manipulations. This makes it
|
|
easier to embed scripts directly in HTML: since the scripting language is more powerful, scripts written in it tend
|
|
to be shorter and easier to read.
|
|
|
|
.Natural Language Programming?
|
|
****
|
|
Seasoned programmers may be suspicious of +_hyperscript+: There have been many "natural language programming" (NLP)
|
|
projects that target non-programmers and beginner programmers, assuming that being able to read code in their
|
|
"natural language" will give them the ability to write it as well. This has lead to some badly written and
|
|
structured code and has failed to live up to the (often over the top) hype.
|
|
|
|
+_hyperscript+ is _not_ an NLP programming language. Yes, its syntax is inspired in many places
|
|
by the speech patterns of web developers. But +_hyperscript+'s readability is achieved not through complex
|
|
heuristics or fuzzy NLP processing, but rather through judicious use of common parsing tricks, coupled with a culture
|
|
of readability.
|
|
|
|
As you can see in the above example, with the use of a _query reference_, `<output/>`, +_hyperscript+ does not shy away
|
|
from using DOM-specific, non-natural language when appropriate.
|
|
****
|
|
|
|
=== +_hyperscript+ in Action: A Keyboard Shortcut
|
|
|
|
// TODO: alt-S instead? shift-S too aggressive?
|
|
|
|
While the counter demo is a good way to compare various approaches to scripting, the rubber meets the road when
|
|
you try to actually implement a useful feature with an approach. For +_hyperscript+, let's add a keyboard shortcut
|
|
to Contact.app: when a user hits Shift-S in our app, we will focus the search field.
|
|
|
|
Since our keyboard shortcut focuses the search input, let's put the code for it on that search input, satisfying
|
|
locality.
|
|
|
|
Here is the original HTML for the search input:
|
|
|
|
[source,html]
|
|
----
|
|
<input id="search" name="q" type="search" placeholder="Search Contacts">
|
|
----
|
|
|
|
We will add an event listener using the `on keydown` syntax, which will fire whenever a keydown occurs. Further, we
|
|
can use an _event filter_ syntax in +_hyperscript+ using square brackets after the event. In the square brackets we
|
|
can place a _filter expression_ that will filter out `keydown` events we aren't interested in. In our case, we only
|
|
want to consider events where the shift key is held down and where the "`S`" key is being pressed. We can create a
|
|
boolean expression that inspects the `shiftKey` property (to see if it is `true`) and the `code` property (to see if
|
|
it is `"KeyS"`) of the event to achieve this.
|
|
|
|
So far our +_hyperscript+ looks like this:
|
|
|
|
.A start on our keyboard shortcut
|
|
[source, hyperscript]
|
|
----
|
|
on keydown[shiftKey and code is 'KeyS'] ...
|
|
----
|
|
|
|
Now, by default, +_hyperscript+ will listen for a given event _on the element where it is declared_. So, with the script we have, we would only get `keydown` events if the search box is already focused. That's not what
|
|
we want! We want to have this key work _globally_, no matter which element has focus.
|
|
|
|
Not a problem! We can listen for the `keyDown` event elsewhere by using a `from` clause in our event handler. In this
|
|
case we want to listen for the `keyDown` from the window, and our code ends up looking, naturally, like this:
|
|
|
|
.Listening globally
|
|
[source, hyperscript]
|
|
----
|
|
on keydown[shiftKey and code is 'KeyS'] from window ...
|
|
----
|
|
|
|
Using the `from` clause, we can attach the listener to the window while, at the same time, keeping the code on the
|
|
element it logically relates to.
|
|
|
|
Now that we've picked out the event we want to use to focus the search box, let's implement the actual focusing by
|
|
calling the standard `.focus()` method.
|
|
|
|
Here is the entire script, embedded in HTML:
|
|
|
|
.Our final script
|
|
[source,html]
|
|
----
|
|
<input id="search" name="q" type="search" placeholder="Search Contacts"
|
|
_="on keydown[shiftKey and code is 'KeyS'] from the window
|
|
me.focus()"> <1>
|
|
----
|
|
<1> "`me`" refers to the element that the script is written on.
|
|
|
|
Given all the functionality, this is surprisingly terse, and, as an English-like programming language, pretty easy to
|
|
read.
|
|
|
|
=== Why a New Programming Language?
|
|
|
|
This is all well and good, but you may be thinking "`An entirely new scripting language? That seems excessive.`" And,
|
|
at some level, you are right: JavaScript is a decent scripting language, is very well optimized and is widely understood
|
|
in web development. On the other hand, by creating an entirely new front end scripting language, we had the freedom
|
|
to address some problems that we saw generating ugly and verbose code in JavaScript:
|
|
|
|
Async transparency:: In +_hyperscript+, asynchronous functions (i.e., functions that return `Promise` instances) can be
|
|
invoked _as if they were synchronous_. Changing a function from sync to async does not break any +_hyperscript+ code that
|
|
calls it. This is achieved by checking for a Promise when evaluating any expression, and suspending the running script
|
|
if one exists (only the current event handler is suspended and the main thread is not blocked). JavaScript, instead, requires
|
|
either the explicit use of callbacks _or_ the use of explicit `async` annotations (which can't be mixed with synchronous
|
|
code).
|
|
|
|
Array property access:: In +_hyperscript+, accessing a property on an array (other than `length` or a number) will return
|
|
an array of the values of property on each member of that array, making array property access act like a flat-map operation.
|
|
jQuery has a similar feature, but only for its own data structure.
|
|
|
|
Native CSS Syntax:: In +_hyperscript+, you can use things like CSS class and ID literals, or CSS query literals, directly
|
|
in the language, rather than needing to call out to a wordy DOM API, as you do in JavaScript.
|
|
|
|
Deep Event Support:: Working with events in +_hyperscript+ is far more pleasant than working with them in JavaScript, with
|
|
native support for responding to and sending events, as well as for common event-handling patterns such as "`debouncing`"
|
|
or rate limiting events. +_hyperscript+ also provides declarative mechanisms for synchronizing events within a given element
|
|
and across multiple elements.
|
|
|
|
Again we wish to stress that, in this example, we are not stepping outside the lines of a Hypermedia-Driven
|
|
Application: we are only adding frontend, client-side functionality with our scripting. We are not creating and
|
|
managing a large amount of state outside of the DOM itself, or communicating with the server in a non-hypermedia
|
|
exchange.
|
|
|
|
Additionally, since +_hyperscript+ embeds so well in HTML, it keeps the focus _on the hypermedia_, rather than on the
|
|
scripting logic.
|
|
|
|
It may not fit all scripting styles or needs, but +_hyperscript+ can provide an
|
|
excellent scripting experience for Hypermedia-Driven Applications. It is a small and obscure programming language worth a look to understand what it is trying to achieve.
|
|
|
|
== Using Off-the-Shelf Components
|
|
|
|
That concludes our look at three different options for _your_ scripting infrastructure, that is, the code that _you_ write
|
|
to enhance your Hypermedia-Driven Application. However, there is another major area to consider when discussing client
|
|
side scripting: "`off the shelf`" components. That is, JavaScript libraries that other people have created that offer
|
|
some sort of functionality, such as showing modal dialogs.
|
|
|
|
Components have become very popular in the web development works, with libraries like https://datatables.net/[DataTables]
|
|
providing rich user experiences with very little JavaScript code on the part of a user. Unfortunately, if these libraries
|
|
aren't integrated well into a website, they can begin to make an application feel "`patched together.`" Furthermore, some
|
|
libraries go beyond simple DOM manipulation, and require that you integrate with a server endpoint, almost invariably
|
|
with a JSON data API. This means you are no longer building a Hypermedia-Driven Application, simply because a particular
|
|
widget demands something different. A shame!
|
|
|
|
=== Integration Options
|
|
|
|
The best JavaScript libraries to work with when you are building a Hypermedia-Driven Application are ones that:
|
|
|
|
* Mutate the DOM but don't communicate with a server over JSON
|
|
* Respect HTML norms (e.g., using `input` elements to store values)
|
|
* Trigger many custom events as the library updates things
|
|
|
|
The last point, triggering many custom events (over the alternative of using lots of methods and callbacks) is especially
|
|
important, as these custom events can be dispatched or listened to without additional glue code written in a scripting language.
|
|
|
|
Let's take a look at two different approaches to scripting, one using JavaScript call backs, and one using events.
|
|
|
|
To make things concrete, let's implement a better confirmation dialog for the `DELETE` button we created in Alpine in the
|
|
previous section. In the original example we used the `confirm()` function built in to JavaScript, which shows a
|
|
pretty bare-bones system confirmation dialog. We will replace this function with a popular JavaScript library,
|
|
SweetAlert2, that shows a much nicer looking confirmation dialog. Unlike the `confirm()` function, which blocks
|
|
and returns a boolean (`true` if the user confirmed, `false` otherwise), SweetAlert2 returns a `Promise` object, which
|
|
is a JavaScript mechanism for hooking in a callback once an asynchronous action (such as waiting for a user to confirm
|
|
or deny an action) completes.
|
|
|
|
==== Integrating using callbacks
|
|
|
|
With SweetAlert2 installed as a library, you have access to the `Swal` object, which has a `fire()` function on it to
|
|
trigger showing an alert. You can pass in arguments to the `fire()` method to configure exactly what the buttons
|
|
on the confirmation dialog look like, what the title of the dialog is, and so forth. We won't get into these details
|
|
too much, but you will see what a dialog looks like in a bit.
|
|
|
|
So, given we have installed the SweetAlert2 library, we can swap it in place of the `confirm()` function call. We then
|
|
need to restructure the code to pass a _callback_ to the `then()` method on the `Promise` that `Swal.fire()` returns. A
|
|
deep dive into Promises is beyond the scope of this chapter, but suffice to say that this callback will be called when
|
|
a user confirms or denies the action. If the user confirmed the action, then the `result.isConfirmed` property will be
|
|
`true`.
|
|
|
|
Given all that, our updated code will look like this:
|
|
|
|
.A callback-based confirmation dialog
|
|
[source,html]
|
|
----
|
|
<button type="button" class="bad bg color border"
|
|
@click="Swal.fire({ <1>
|
|
title: 'Delete these contacts?', <2>
|
|
showCancelButton: true,
|
|
confirmButtonText: 'Delete'
|
|
}).then((result) => { <3>
|
|
if (result.isConfirmed) {
|
|
htmx.ajax('DELETE', '/contacts', { source: $root, target: document.body })
|
|
}
|
|
});"
|
|
>Delete</button>
|
|
----
|
|
<1> Invoke the `Swal.fire()` function
|
|
<2> Configure the dialog
|
|
<3> Handle the result of the user's selection
|
|
|
|
And now, when this button is clicked, we get a nice looking dialog in our web application:
|
|
|
|
image::screenshot_sweet_alert.png[Modal dialog: "Delete these contacts?" with a colorful delete button and gray cancel button.]
|
|
|
|
Much nicer than the system confirmation dialog. Still, this feels a little wrong. This is a lot of code to write
|
|
just to trigger a slightly nicer `confirm()`, isn't it? And the htmx JavaScript code we are using here feels
|
|
awkward. It would be more natural to move the htmx out to attributes on the button, as we have been doing, and then
|
|
trigger the request via events.
|
|
|
|
So let's take a different approach and see how that looks.
|
|
|
|
==== Integrating using events
|
|
|
|
To clean this code up, we will pull the `Swal.fire()` code out to a custom JavaScript function we will create called
|
|
`sweetConfirm()`. `sweetConfirm()` will take the dialog options that are passed into the `fire()` method, as well as
|
|
the element that is confirming an action. The big difference here is
|
|
that the new `sweetConfirm()` function, rather than calling some htmx directly, will instead trigger a `confirmed` event on the
|
|
button when the user confirms they wish to delete.
|
|
|
|
Here is what our JavaScript function looks like:
|
|
|
|
.An event-based confirmation dialog
|
|
[source,javascript]
|
|
----
|
|
function sweetConfirm(elt, config) {
|
|
Swal.fire(config) <1>
|
|
.then((result) => {
|
|
if (result.isConfirmed) {
|
|
elt.dispatchEvent(new Event('confirmed')); <2>
|
|
}
|
|
});
|
|
}
|
|
----
|
|
<1> Pass the config through to the `fire()` function.
|
|
<2> If the user confirmed the action, trigger a `confirmed` event.
|
|
|
|
With this method available, we can now tighten up our delete button quite a bit. We can remove all the SweetAlert2
|
|
code that we had in the `@click` Alpine attribute, and simply call this new `sweetConfirm()` method, passing in the
|
|
arguments `$el`, which is the Alpine syntax for getting `"the current element`" that the script is on, and then
|
|
the exact configuration we want for our dialog.
|
|
|
|
If the user confirms the action, a `confirmed` event will be triggered on the button. This means that we can go back
|
|
to using our trusty htmx attributes! Namely, we can move `DELETE` to an `hx-delete` attribute, and we can we can use
|
|
`hx-target` to target the body. And then, and here is the crucial step, we can use the `confirmed` event that is
|
|
triggered in the `sweetConfirm()` function, to trigger the request, but adding an `hx-trigger` for it.
|
|
|
|
Here is what our code looks like:
|
|
|
|
.An Event-based Confirmation Dialog
|
|
[source,html]
|
|
----
|
|
<button type="button" class="bad bg color border"
|
|
hx-delete="/contacts" hx-target="body" hx-trigger="confirmed" <1>
|
|
@click="sweetConfirm($el, <2>
|
|
{ title: 'Delete these contacts?', <3>
|
|
showCancelButton: true,
|
|
confirmButtonText: 'Delete'})">
|
|
----
|
|
<1> Our htmx attributes are back.
|
|
<2> We pass the button in to the function, so an event can be triggered on it.
|
|
<3> We pass through the SweetAlert2 configuration information.
|
|
|
|
As you can see, this event-based code is much cleaner and certainly more "`HTML-ish.`" The key to this cleaner
|
|
implementation is that our new `sweetConfirm()` function fires an event that htmx is able to listen for.
|
|
|
|
This is why a rich event model is important to look for when choosing a library to work with, both with htmx and with
|
|
Hypermedia-Driven Applications in general.
|
|
|
|
Unfortunately, due to the prevalence and dominance of the JavaScript-first mindset today, many libraries are like
|
|
SweetAlert2: they expect you to pass a callback in the first style. In these cases you can use the technique we
|
|
have demonstrated here, wrapping the library in a function that triggers events in a callback, to make the library more
|
|
hypermedia and htmx-friendly.
|
|
|
|
== Pragmatic Scripting
|
|
|
|
[quote,W3C,HTML Design Principles § 3.2 Priority of Constituencies]
|
|
____
|
|
In case of conflict, consider users over authors over implementors over specifiers over theoretical purity.
|
|
____
|
|
|
|
We have looked at several tools and techniques for scripting in a Hypermedia-Driven Application. How should you
|
|
pick between them? The sad truth is that there will never be a single, always correct answer to this question.
|
|
|
|
Are you committed to vanilla JavaScript-only, perhaps due to company policy? Well, you can use vanilla JavaScript effectively
|
|
to script your Hypermedia-Driven Application.
|
|
|
|
Do you have more leeway and like the look of Alpine.js? That's a great way to add more structured, localized JavaScript
|
|
to your application, and offers some nice reactive features as well.
|
|
|
|
Are you a bit more bold in your technical choices? Maybe +_hyperscript+ is worth a look. (We certainly think so.)
|
|
|
|
Sometimes you might even consider picking two (or more) of these approaches within an application. Each has its own
|
|
strengths and weaknesses, and all of them are relatively small and self-contained, so picking the right tool for the job
|
|
at hand might be the best approach.
|
|
|
|
In general, we encourage a _pragmatic_ approach to scripting: whatever feels right is probably right (or, at least,
|
|
right _enough_) for you. Rather than being concerned about which particular approach is taken for your scripting,
|
|
we would focus on these more general concerns:
|
|
|
|
* Avoid communicating with the server via JSON data APIs.
|
|
* Avoid storing large amounts of state outside of the DOM.
|
|
* Favor using events, rather than hard-coded callbacks or method calls.
|
|
|
|
And even on these topics, sometimes a web developer has to do what a web developer has to do. If the perfect widget for your application exists but uses a JSON data API? That's OK.
|
|
|
|
Just don't make it a habit.
|
|
|
|
|
|
[.design-note]
|
|
.HTML Notes: Markdown soup
|
|
****
|
|
[.dfn]_Markdown soup_ is the lesser known sibling of `<div>` soup.
|
|
This is the result of web developers limiting themselves to the set of elements that the Markdown language provides shorthand for,
|
|
even when these elements are incorrect.
|
|
Consider the following example of an IEEE-style citation:
|
|
|
|
[source,markdown]
|
|
----
|
|
[1] C.H. Gross, A. Stepinski, and D. Akşimşek, <1>
|
|
_Hypermedia Systems_, <2>
|
|
Bozeman, MT, USA: Big Sky Software.
|
|
Available: <https://hypermedia.systems/>
|
|
----
|
|
<1> The reference number is written in brackets.
|
|
<2> Underscores around the book title creates an <em> element.
|
|
|
|
Here, <em> is used because it's the only Markdown element that is presented in italics by default.
|
|
This indicates that the book title is being stressed, but the purpose is to mark it as the title of a work.
|
|
HTML has the `<cite>` element that's intended for this exact purpose.
|
|
|
|
Furthermore, even though this is a numbered list perfect for the `<ol>` element, which Markdown supports, plain text is used for the reference numbers instead.
|
|
Why could this be?
|
|
The IEEE citation style requires that these numbers are presented in square brackets.
|
|
This could be achieved on an `<ol>` with CSS,
|
|
but Markdown doesn't have a way to add a class to elements meaning the square brackets would apply to all ordered lists.
|
|
**** |