Svelte does an excellent job of blurring the lines between frontend frameworks and plain old HTML/JS. Leaning on Svelte’s reactivity model, property binding, and event directives will get you 99% of the way to a finished app, but every once in a while you just really need to work directly with the HTMLElement
.
This can be done in a one-off way with the bind:this
approach to getting a local reference to a DOM element, but there’s a better way. The use:
directive allows for reusable logic to be pulled out of the component itself.
Let’s take a look at a really common use for this - listening for clicks/taps outside an area of the UI. I used this recently for Kamfly’s modals, instead of adding a one-off click listener in the Modal
component I created a clickOutside
action that can be reused elsewhere. This can also help make testing a whole lot easier, testing the async nature of a full blown Modal component can be tricky, testing just the action itself is much less complicated!
tl;dr; Check out the final example in this Live REPL demo.
Svelte actions
Svelte’s official tutorial has an excellent walk through of what actions are, but basically you can think of actions as lifecycle hooks for DOM elements themselves. An action works much like onMount
or onDestroy
, but actions are tied to DOM elements themselves rather than entire Svelte components.
Why bother with actions?
It’s easier to reach for bind:this
when the component you’re working on needs to interact with a DOM element. I won’t jump into the DRY design principles debate, instead just look at the testing challenges.
Many projects end up needing a modal at some point, and they’re almost always ignored in automated testing. When the component itself is responsible for hiding/showing the modal, managing a stack of multiple open modals, etc. the logic gets tricky fast.
Testing asynchronous behavior like animations or modals hiding/showing based on user input can be a chore. What if you could move some of that logic out to a reusable function?
Anatomy of an action
action = (node: HTMLElement, parameters: any) => {
update?: (parameters: any) => void,
destroy?: () => void
}
That’s really all there is to it! An action in Svelte takes in the DOM element and (optionally) user defined parameters. The action
function can run initialization logic on the element before returning update
and destroy
handlers (both optional).
use:
clickOutside
Let’s look at a really common UX pattern - a modal is open on screen and you want it to hide when the user clicks/taps outside the modal (often on some kind of grayed out background).
This isn’t a big deal to do in the Modal component itself - add an extra DOM element for the grayed out background and add a click handler to it. But what if you want a similar interaction for your mobile menu? That component is almost certainly different, if you copy the code over now you have duplicated JS in your site and you need complicated test coverage testing the close functionality for both components.
The clickOutside action
export default (node, _options = {}) => {
const options = { onClickOutside: () => {}, ..._options }
function detect({ target }) {
if (!node.contains(target)) {
options.onClickOutside()
}
}
document.addEventListener('click', detect, { passive: true, capture: true })
return {
destroy() {
document.removeEventListener('click', detect)
},
}
}
There’s a few things going on here, let’s break that down.
First, the action is taking in optional parameters (_options
). The action expects options.onClickOutside
to be a callback function, if one wasn’t provided it’s defaulted to a noop function.
The detect
function does the real work of checking to see if a click event on the page was inside the original DOM element. The event listener is using passive
to avoid scrolling performance issues, and capture
to make sure that all clicks bubble up to the listener.
Finally, the action returns a destroy
callback that will clean up the event listener when the element is being removed from the DOM.
Allow components to whitelist elements to ignore
export default (node, _options = {}) => {
const options = { include: [], onClickOutside: () => {}, ..._options }
function detect({ target }) {
if (
!node.contains(target) ||
options.include.some((i) => target.isSameNode(i))
) {
options.onClickOutside()
}
}
/** Same as above */
}
A little extra functionality here allows components to pass in other DOM elements that should be considered as “inside” the action (rather than “outside”).
How to test it
UI testing will be a topic for another day, but you can see how much easier this will be to test. Instead of having to write tests that can reliably wait for a Modal component to animate into view before testing the click away scenario, tests could work more closely to the REPL demo (below). DOM elements for a test wouldn’t need to animate at all, they just need to accept a click
event so the test can count how many times the onClickOutside
handler was called!
Live REPL demo
That’s all, folks! Check out the Live REPL demo for a working example.