mirror of
https://github.com/bigskysoftware/hypermedia-systems.git
synced 2025-11-28 00:04:56 -05:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
f6d90351ca
@ -337,6 +337,69 @@ That's enough fun, however, let's get to work on ContactApp.
|
||||
|
||||
==== Vanilla JavaScript in action: A confirmation dialog
|
||||
|
||||
Right now, clicking the `Delete` link on a contact instantly deletes it, making it prone to accidents. We'll write some JavaScript to add confirmation dialogs to elements and use it on the delete button.
|
||||
|
||||
We'll write the JavaScript first before adding anything to our markup.
|
||||
|
||||
.Confirmation dialog with Vanilla JS & RSJS
|
||||
[source,js]
|
||||
----
|
||||
document.querySelectorAll("[data-confirm]")<1>.forEach(el => {
|
||||
// ...
|
||||
})
|
||||
----
|
||||
<1> Find relevant elements. Our attribute is `data-confirm`, so we'll write this code in a file named `confirm.js`.
|
||||
|
||||
We need to show a confirmation dialog. There are libraries that let us show styled, rich alert dialogs, but let's just use `confirm()` for now. Adding in a library later will be a good test of how maintainable our code is.
|
||||
|
||||
[source,js,highlight=2..4]
|
||||
----
|
||||
document.querySelectorAll("[data-confirm]")<1>.forEach(el => {
|
||||
el.addEventListener("..."<1>, e => {
|
||||
const didConfirm = confirm()
|
||||
if (!didConfirm) {
|
||||
event.stopImmediatePropagation(); <2>
|
||||
event.stopPropagation(); <3>
|
||||
}
|
||||
})
|
||||
})
|
||||
----
|
||||
<1> **What event?**
|
||||
<2> Prevent listeners on this element from running
|
||||
<3> Prevent listeners on parent elements from running
|
||||
|
||||
We need to decide what event we need to listen to:
|
||||
|
||||
* Hardcode `"click"`. It's simple and it covers most cases. However, there's not a clear escape hatch if you need a different event.
|
||||
* Try to sniff what event you need to listen to based on the element. Complex and fragile (but I repeat myself).
|
||||
* Let the author specify in the attribute. This is what we'll do.
|
||||
|
||||
[source,js]
|
||||
----
|
||||
el.addEventListener(el.dataset<1>.confirm || "click"<2>, e => {
|
||||
// ...
|
||||
})
|
||||
----
|
||||
<1> The dataset property is a shorthand way to access and modify `data-` attributes. Attribute names are automatically mapped to camel case: `data-state-slice-reducer` becomes `dataset.stateSliceReducer`.
|
||||
<2> Specify a default for convenience.
|
||||
|
||||
In 9 lines of code, we have a generic confirmation library that we can use for any element as follows.
|
||||
|
||||
[source,html]
|
||||
----
|
||||
<button type="submit" data-confirm>Delete</button>
|
||||
<input type="radio" name="volume" value="100" data-confirm="input">
|
||||
----
|
||||
|
||||
// TODO: talk about how this is overengineered
|
||||
|
||||
.Async ruins everything
|
||||
****
|
||||
In the confirmation dialog code we wrote, we use `confirm()`, which is convenient, but displays a barebones dialog that cannot contain rich text. Can we write a similar script using a fancy alert dialog library, like SweetAlert2?
|
||||
|
||||
// TODO: talk about why we can't
|
||||
****
|
||||
|
||||
|
||||
=== Alpine.js
|
||||
|
||||
@ -385,15 +448,23 @@ To attach event listeners, we use `x-on`:
|
||||
----
|
||||
<1> With `x-on`, we specify the attribute in the attribute _name_.
|
||||
|
||||
Would you look at that, we're done already. (It's almost as though we wrote a trivial example). What we created is, incidentally, nearly identical to the first code example in Alpine's documentation --- available at https://alpinejs.dev/start-here[].
|
||||
Would you look at that, we're done already. (It's almost as though we wrote a trivial example). What we created is, incidentally, nearly identical to the second code example in Alpine's documentation --- available at https://alpinejs.dev/start-here[].
|
||||
|
||||
|
||||
==== `@click` vs. `onclick`
|
||||
==== `x-on:click` vs. `onclick`
|
||||
|
||||
The `x-on:click` attribute (or its shorthand `@click`) differs from the browser built-in `onclick` attribute in significant ways that make it much 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, or
|
||||
** attach passive listeners.
|
||||
* You can listen to custom events, such as those dispatched by htmx.
|
||||
|
||||
|
||||
==== Reactivity and templating
|
||||
|
||||
|
||||
As you can see, this code is much tighter than the VanillaJS implementation. It helps that AlpineJS supports a notion
|
||||
of variables, allowing you to bind the visibility of the `span` element to a variable that both it and the button
|
||||
can access. Alpine allows for much more elaborate data bindings as well, it is an excellent general purpose client-side
|
||||
@ -402,6 +473,129 @@ scripting library.
|
||||
|
||||
==== Alpine in action: an overflow menu
|
||||
|
||||
An overflow menu only has one bit of state: whether it is open.
|
||||
|
||||
[source,html]
|
||||
----
|
||||
<div x-data="{ open: false <1> }">
|
||||
<button>Options</button> <2>
|
||||
<div>
|
||||
<a href="/contacts/{{ contact.id }}/edit">Edit</a>
|
||||
<a href="/contacts/{{ contact.id }}">View</a>
|
||||
</div>
|
||||
</div>
|
||||
----
|
||||
<1> Define the initial state
|
||||
<2> We'll hook this button up to open and close our menu
|
||||
|
||||
While we have only one bit of state, we have many parts that depend on it. This is where _reactivity_ shines:
|
||||
|
||||
[source,html]
|
||||
----
|
||||
<div x-data="{ open: false }">
|
||||
<button
|
||||
aria-haspopup="menu" <1>
|
||||
aria-controls="contents" <2>
|
||||
x-bind:aria-expanded="open" <3>
|
||||
>Options</button>
|
||||
<template x-if="open"<4>>
|
||||
<div id="contents"<5>>
|
||||
<a href="/contacts/{{ contact.id }}/edit">Edit</a>
|
||||
<a href="/contacts/{{ contact.id }}">View</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
----
|
||||
<1> Declare that this button will cause a menu to open,
|
||||
<2> and that the menu that this button _controls_ is the one with ID `contents`
|
||||
<3> Indicate the current open state of the menu, using x-bind to reference our data
|
||||
<4> Only show the menu if it is open
|
||||
<5> Add an ID to the menu, so that we can reference it in the aria-controls attribute
|
||||
|
||||
This is based on the https://www.w3.org/WAI/ARIA/apg/patterns/menubutton/[Menu Button] example from the cite:[ARIA Authoring Practices Guide]. We haven't made the menu work yet, just the button that opens it.
|
||||
|
||||
The use `x-bind` means that as we change the open state, the `aria-expanded` attribute will update to match. The same holds for the `x-show` on the div with the contents, and indeed for most of Alpine. In order to see this in action, let's actually change that state:
|
||||
|
||||
.HTML ID Soup
|
||||
****
|
||||
// TODO
|
||||
****
|
||||
|
||||
[source,html]
|
||||
----
|
||||
<div x-data="{ open: false }">
|
||||
<button
|
||||
aria-haspopup="menu"
|
||||
aria-controls="contents"
|
||||
x-bind:aria-expanded="open"
|
||||
x-on:click="open = !open" <1>
|
||||
>Options</button>
|
||||
<template x-if="open">
|
||||
<div id="contents" x-on:click.outside="open = false"<2>>
|
||||
<a href="/contacts/{{ contact.id }}/edit">Edit</a>
|
||||
<a href="/contacts/{{ contact.id }}">View</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
----
|
||||
<1> Toggle the open state when the button is clicked
|
||||
<2> Dismiss the menu by clicking away
|
||||
|
||||
You should be able to open the menu now, and may be tempted to ship this code to production. Don't! We're not done because our menu fails many requirements for menu interactions:
|
||||
|
||||
* It does not have the `menu` or `menuitem` roles applied properly, which makes life harder for users of assistive software
|
||||
* You can't navigate between menu items using arrow keys
|
||||
* You can't activate a menu item with the Space key
|
||||
|
||||
These factors make our menu annoying and possibly unusable for many people. Let's fix it using guidance from the venerable cite:[ARIA Authoring Practices Guide]:
|
||||
|
||||
[source,html]
|
||||
----
|
||||
<div x-data="{ open: false }">
|
||||
<button
|
||||
aria-haspopup="menu"
|
||||
aria-controls="contents"
|
||||
x-bind:aria-expanded="open"
|
||||
x-on:click="open = !open"
|
||||
>Options</button>
|
||||
<div role="menu<1>" id="contents" x-show="open"
|
||||
x-on:click.outside="open = false"
|
||||
x-on:keydown.up="document.activeElement.previousElementSibling?.focus()<2>"
|
||||
x-on:keydown.down="document.activeElement.nextElementSibling?.focus()<3>"
|
||||
x-on:keydown.space="document.activeElement.click()<4>"
|
||||
x-effect="show ? $el<5>.firstElementChild().focus()<6>"
|
||||
x-on:keydown="key === 'Home'
|
||||
? $el.firstChild.focus()
|
||||
: key === 'End'
|
||||
? $el.lastChild.focus()
|
||||
: null <7>"
|
||||
>
|
||||
<a role="menuitem<8>" tabindex="-1<9>" href="/contacts/{{ contact.id }}/edit">Edit</a>
|
||||
<a role="menuitem" tabindex="-1" href="/contacts/{{ contact.id }}">View</a>
|
||||
</div>
|
||||
</div>
|
||||
----
|
||||
<1> Put the `menu` role on the menu root
|
||||
<2> Move focus to the previous element when the up arrow key is pressed
|
||||
<3> Move focus to the next element when the down arrow key is pressed
|
||||
<4> Click the currently focused element when the space key is pressed
|
||||
<5> Access the div itself through the Alpine-supplied `$el` variable
|
||||
<6> Focus the first item when `show` changes
|
||||
<7> Handle the remaining cases that Alpine doesn't have modifiers for
|
||||
<8> Put the `menuitem` role on the individual items
|
||||
<9> Make the menu items non-tabbable
|
||||
|
||||
`x-effect` is a cool attribute that lets you perform side-effects when a piece of element state changes. It automatically detects which state is accessed in the effect.
|
||||
|
||||
That's a lot! And we still made some assumptions to make our code shorter:
|
||||
|
||||
* All children are menu items with no wrappers, dividers, etc.
|
||||
* No submenus
|
||||
|
||||
As we need more features, it might make more sense to use a library such as GitHub's https://github.com/github/details-menu-element[`details-menu-element`].
|
||||
|
||||
// TODO: talk about https://alpinejs.dev/directives/bind#bind-directives
|
||||
|
||||
|
||||
=== _hyperscript
|
||||
|
||||
|
||||
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user