
Better event handling with Observers
If you have ever worked with a library like RxJS, you will know how well its declarative API works for composing event handlers. Without such a library, something like drag-and-drop can be complicated because it requires nested event handlers and hard-to-follow callback chains.
For example, if you want to implement drag-and-drop, you need to initialize it like this:
define a
mousedown
event handlerinside this
mousedown
event handler, define amousemove
event handler and amouseup
event handlerwhen the
mouseup
event handler is invoked, cancel themousemove
event handler
In code, it would look something like this:
document.body.addEventHandler('mousedown', e => {
// function to call when the mouse moves
const mouseMoveHandler = e => {...});
document.body.addEventHandler('mousemove', mouseMoveHandler);
// remove the "mousemove" event handler
document.body.addEventHandler('mouseup', e => {
document.body.removeEventHandler('mousemove', mouseMoveHandler);
});
});
With the new Observable API, we can write simpler, declarative code that’s much easier to understand. This API is now fully supported in Chrome and partially in Safari Tech Preview.
The main use case for Observables
is declarative event handling, and for that, the when()
method is added to EventTarget
. With it, you can set a click
event handler like this:
document.body.when('click')
This returns a new Observable
that has array-like methods like map
, filter,
and takeUntil
among others. You can use filter
to determine if the click
event took place on a certain element.
In the next example, only clicks that take place on an element with class
“foo” will be emitted:
document.body
.when('click')
.filter((e) => e.target.matches('.foo'))
You can then use map
to transform the stream of events, for example, to only return the clientX
and clientY
properties of the event:
document.body
.when('click')
.filter((e) => e.target.matches('.foo'))
.map((e) => ({ x: e.clientX, y: e.clientY }))
This way, you can chain these methods together in a declarative way to transform the stream of events. If you want to handle the event at the end of the chain, you call the subscribe
method of Observable
. This method takes an object with a next
method that receives the (transformed) event:
document.body
.when('click')
.filter((e) => e.target.matches('.foo'))
.map((e) => ({ x: e.clientX, y: e.clientY }))
.subscribe({next: doSomethingWithTheEvent })
Here’s a demo where the color of the header changes when the mouse is hovered over the red or the blue box:
The code to implement this is declarative and easy to follow since it just describes the steps that are taken:
const header = document.querySelector('h1');
document.body
.when('mouseover')
.filter(e => e.target.matches('.red') || e.target.matches('.blue'))
.subscribe({
next: e => {
header.style.color = e.target.className;
}
});
When the mouseover
event is fired, filter only the events that take place on the elements with class
“red” or “blue” and then change the color of the header by setting it to the class
of the box that was hovered (the target
of the event).
Declarative drag-and-drop
The takeUntil
method I mentioned earlier can be used to unsubscribe from the event in a declarative way. In the next example, the mousemove
event is fired until the mouseup
event takes place:
document.body
.when('mousemove')
.takeUntil(document.when('mouseup'))
We can use this to implement the drag-and-drop from the earlier example in a declarative way.
We’ll start with a red box that we can drag around:
<div class="box"></div>
Then, we’ll declaratively add a mousedown
event handler and filter the events on the ones that are fired on the <div>
with class=”box”
:
const box = document.querySelector('.box');
document.body
.when('mousedown')
.filter(e => e.target === box)
At this point, we’ll need to subscribe to this event to get the starting position of the box relative to the coordinates of the mousedown event. We need to move around the box by setting its position with top
and left
according to the coordinates of the mouse. But the mouse is usually not exactly in the top-left corner of the box when we click the button down and start dragging, so we need to account for the offset between the top-left corner of the box and the mouse position. If we don’t, the box will not stay in the same position “under” the mouse and its top-left corner will suddenly move to the position of the mouse.
We need to save the x and y-coordinates of this offset in two variables startX
and startY
and subtract those from the x and y-coordinates of the mouse when it’s moving around. That way, the box will stay in the same position under the mouse when it’s moving:
document.body
.when('mousedown')
.filter(e => e.target === box)
.subscribe({
next: e => {
// here we get the difference between the mouse position
// and the top-left corner of the box
const startX = e.clientX - box.offsetLeft;
const startY = e.clientY - box.offsetTop;
...
});
We can’t fully remove the nesting, so inside the subscribe callback, we listen for the mousemove
event until a mouseup
event takes place and then map
over each event to get the mouse coordinates minus startX
and startY
. Then, we call subscribe
again and finally position the box. Here’s the finished code:
const box = document.querySelector('.box');
document.body
.when('mousedown')
.filter(e => e.target === box)
.subscribe({
next: e => {
const startX = e.clientX - box.offsetLeft;
const startY = e.clientY - box.offsetTop;
document.body
.when('mousemove')
.takeUntil(document.when('mouseup'))
.map((e) => ({
x: e.clientX - startX,
y: e.clientY - startY
}))
.subscribe({
next: ({x, y}) => {
box.style.left = `${x}px`;
box.style.top = `${y}px`;
}
});
}
});
And here’s the codepen:
While we still had to nest mousemove
inside mousedown
, we now have code that clearly communicates its steps and is much easier to follow.
Other notable methods
Observable
has other handy methods to make your code simpler. For example, if you only want to log the first three click events, you can use take()
:
document.body
.when('click')
// take only 3 events
.take(3)
.subscribe({
next: e => {
console.log(e);
}
});
You can also skip the first 3 events, and then log all the other ones after that with drop()
:
document.body
.when('click')
// skip the first 3 events
.drop(3)
.subscribe({
next: e => {
console.log(e);
}
});
You can also combine take
and drop
, for example, to drop the first three events and then only log three events after that:
document.body
.when('click')
// skip the first 3 events
.drop(3)
// then take only 3 events
.take(3)
.subscribe({
next: e => {
console.log(e);
}
});
When you have a finite stream of events, you can use first()
and last()
to get the first and last one respectively. These methods return a Promise
that resolves to the first or last event respectively:
const firstEvent = await document.body
.when('click')
.first();
const lastEvent = await document.body
.when('click')
.last();
Similarly, you can use toArray()
to return an array of events, for example to get the first three events as an array with take()
:
const arrayOfEvents = await document.body
.when('click')
.take(3)
.toArray();
This also makes it easy to determine if an element has been clicked an x amount of times. For example, if you want to do something after an element has been clicked three times, you can do this:
await document.body
.when('click')
.take(3)
.last();
console.log('clicked three times');
Compare this with the code you would have to write without Observables
:
let timesClicked = 0;
const clickHandler = e => {
timesClicked++;
if(timesClicked === 3) {
console.log('clicked three times');
document.body.removeEventListener('click', clickHandler);
}
};
document.body.addEventListener('click', clickHandler);
In addition to the when()
method of EventTarget
that returns an Observable
, you can also create one with the Observable
constructor, which I will explain in the next edition of Modern Web Weekly.
Mastering Web Components
Mastering Web Components is a course that will take you from beginner to expert in Web Components by teaching you how can create your own reusable Web Components and integrate them into any web app.
In the course, you will build an image gallery component to apply and test your knowledge of the concepts you have learned.
The course contains many interactive code examples you can study and modify to test and sharpen your skills.
You will learn:
how to create and register a Web Component
how to effectively use the lifecycle methods of Web Components
how to encapsulate the HTML and CSS of your Web Component using Shadow DOM
how to extend native HTML elements
how to compose Web Components with user-defined content
how to test Web Components
how to theme and share styling between Web Components
how to integrate Web Components into forms and validate them
how to server-side render Web Components
how to implement data-binding for Web Components
how to compose Web Components using the mixin pattern
how to build Web Components with a library
You get:
257 page PDF
45+ interactive code examples
Scoped view transitions in Chrome Canary
Chrome Canary now supports scoped view transitions that enable web apps to perform transitions within the scope of any DOM element, not just document
.
This means that you can now call startViewTransition
on any HTML element, not just document,
and that also means that the pseudo-element tree will then be added to that HTML element instead of <html>
.
This approach has a couple of important benefits:
you can now run multiple view transitions at the same time instead of a single one on
document
view transitions can now render inside a container that applies a clip, transform, or animation to it.
While a view transitions is running, the DOM has to pause rendering. Scoped view transitions allow web apps to pause rendering on only part of the page
content outside the scoped transition root can now paint on top of the transitioning content, which was previously not possible as the view transition would be painted on top of everything else in the page.
The first use case I thought of was that this enables developers to perform view transitions inside web components (Custom Elements).
Unfortunately, I noticed this is not possible inside Shadow DOM. Maybe this will be enabled in the future, but for now it’s not possible.
I created a web component demo anyway with only light DOM and scoped view transitions. It’s a recreation of the tags demo I built earlier that looks like this:
Scoped view transitions enable the creation of a web component with the transitions contained inside it without influencing any other transitions in the document or other components.
Hopefully, scoped view transitions will be enabled in Shadow DOM as well, allowing for further encapsulation.
I created a codepen demo that you can find below:
notificationclick event now works in Safari on iOS
I don’t know yet when this was implemented, but contrary to what MDN and caniuse.com report, the notificationclick
event now works in Safari on iOS! 🎉
The event has a notification
property that in turn has a data
property that can contain arbitrary data, for example, the URL users need to navigate to when the notification is clicked. This feature was always sorely missed on iOS, but luckily, it has been (silently) implemented.
Thanks to Milan Milutinovic for reporting this.
You can check out the service worker of What PWA Can Do Today to see how it all works.
The handler for the push
event is invoked when the push notification arrives. It gets the JSON data from the push event's data
property, and then the title and message for the notification from this data
property:
const pushHandler = async e => {
const data = e.data.json();
const {title, message, interaction} = data;
...
};
It then constructs an options
object that contains all data for the notification, like the icon, vibration pattern, and the URL the user should be directed to when the notification is clicked (the url
property of data
in options
):
const options = {
body: message,
icon: '/src/img/icons/icon-512x512.png',
vibrate: [100, 50, 100],
data: {
dateOfArrival: Date.now(),
// the URL the user is redirected when the notification
// is clicked
url: 'https://whatpwacando.today/view-transitions'
},
actions: [
{
action: 'confirm',
title: 'OK'
},
{
action: 'close',
title: 'Close notification'
},
],
requireInteraction: interaction
};
In this case, the URL is set in the event handler, but this data will usually come from the server.
The handler for the notificationclick
event can then access this data
property on the notification
property of the event, and then open the URL in the PWA:
const notificationClickHandler = async (e) => {
const {notification} = e;
const {data} = notification;
notification.close();
e.waitUntil(clients.openWindow(data.url))
};
On iOS, the URL is consistently opened inside the PWA instead of Safari, which is exactly what you want.