Fix Your Annoying Popups with the CloseWatcher API
So my product designer came to me the other day and asked me to implement something to cancel an action whenever the user hits the Esc key. I was like âSure, no problemâ and I went on my way to implement it but then I noticed the way I was doing it was fundamentally flawed.
The CloseWatcher is a new API that has been recently introduced in Chrome 120. The main job of this API is for listening and responding to âclose requestsâ, but what are they and why is this even a problem? And why is it related to my short story earlier?
What is a close request?
A close request is when the user using your web app wants to close something, just about anything. This can range from sidebars, menus, popovers, modals, an accordion, or whatever UI that has an open/close interaction.
The proposal defines it as:
platform-mediated interaction thatâs intended to close an in-page component. This is distinct from page-mediated interactions, such as clicking on an âxâ or âDoneâ button, or clicking on the backdrop outside of the modal.
Usually, those close requests are triggered by hitting the Esc key, specifically âpressing downâ the key. But thatâs just for the desktop. On mobile and specifically on Android, the âbackâ key is also another source for triggering those close requests. On iOS there is no such way to do that without the VoiceOver assistive âZâ gesture.
Why is this a problem?
Weâve been closing modals and opening menus for more than a decade on the web now, so why is this suddenly a problem? One reason is we are in the golden area of web apps and the platform is trying to normalize, standardize, and offer as much help as possible to the developers to write simpler and better web apps through more standard APIs for complex web UI needs.
But also recently, the popover
API was introduced, and not so recently the dialog
element was also introduced, and both defined some interesting interaction and accessibility behaviors when it comes to closing stuff that has been well established in the web for a while now across multiple UI frameworks and libraries, but never standardized.
Some of those behaviors are:
- Clicking outside the popover closes it automatically.
- Pressing the Esc key closes the popover automatically.
- Pressing the Esc closes the last open popover, if there are multiple popovers open.
- Continually pressing the Esc will close the popovers one by one, starting from the last opened one until none are left.
Weâve been doing click-outside detection for a while now, also detecting a key press on the Esc key is not that hard, but the last two behaviors are a bit tricky to implement and it is not always clear how to do it.
Letâs say you are building a floating component with any framework of your choice. That can be a menu or a modal or whatever, and so you want to support the last two behaviors I just listed, how would you do it?
Better yet, letâs go through a few examples.
Example: Sidebar
This is a simple sidebar that shows up when you press the button, the sidebar happens to have an input of type search
.
The examples here are using
- Vue.js, but the API is framework agnostic and can be used with any framework or even vanilla JS.
@vueuse/core
for theonClickOutside
andonKeyDown
functions.tailwindcss
for styling.
If you open the sidebar, click outside, or press Esc it closes as expected.
However, there is a thing about input[type="search"]
that you may not be aware of. When pressing the Esc key, the input will clear itself, so if you have some text in the input and you press Esc it should clear it.
But here, we close the sidebar altogether so that may be unexpected to the user, especially if they prefer to use the keyboard to navigate around the UI, this is the first problem here. Merely listing to the Esc key is not enough, we need to consider if other parts of the UI are also interested in the same key-down event.
You can try handling it yourself by checking the currently active element and if it happens to be an input text of type search, then you ignore the event until it is safe to close the sidebar.
tsonKeyDown('Escape', (e) => {
// Skip if the active element is a search input
if (
document.activeElement?.tagName === 'INPUT' &&
document.activeElement?.type === 'search'
) {
return;
}
isOpen.value = false;
});
This would work nicely, but I donât think you would be building a web app with just a sidebar and a search input. You would have modals, other inputs, pickers, menus, and more stuff that could be interested in that precious Esc keystroke, so are you going to conditionally filter out every other thing?
Say you are building another component, are you going to check if the sidebar is open before you decide to close that other component? Wait, that means all components that âopenâ, need to be aware of each other somehow so they can manage their close behavior.
Example: Context Menu
Here is another example with a different kind of issue. We have this context menu component that has a list of items. If the user wants to close the current open item then they need to hit the Esc key or click outside the menu.
That is all straightforward, but let me throw a wrench in there and add another requirement. The menu can be nested.
Now immediately you would ask a few questions about the behavior here, one of them is âDoes the Esc key close the currently open menu item or all the menu items currently open?â.
We can argue whether that is an expected behavior or not. But this is the same behavior you can observe with nested popovers or even multiple popovers at the same time and if the menu is to be fully keyboard-navigable then it is reasonable to implement that behavior.
How can we go about doing that? A shared state or a store could help those to become aware of one anotherâs state or if it should be closing on that Esc keystroke or not.
Or if you like Vue.jsâs provide/inject
then the components can inform one another of their hierarchy and their current âopenâ state, I use that latter approach in my work.
Going with the shared state idea, you could have a state that behaves like a stack, the last item is the last one to respond to the keydown
event.
jsexport const closeStack = shallowRef([]);
vue<script setup>
import { closeStack } from './closeStack.js';
// ...
const menuId = 'menuId';
function onOpenToggle(e) {
// ...
if (isOpen.value) {
closeStack.value.unshift(menuId);
} else {
closeStack.value.shift();
}
}
// check if we should close
onKeyDown('Escape', () => {
if (closeStack.value[0] === menuId) {
isOpen.value = false;
closeStack.value.shift();
}
});
</script>
This is a bit clever, but again. These menus will NOT be the only closable component to be be present in your app, you want to have a way to uniformly and reliably close any kind of closable UI element in order. This is how I implement the desired behavior today, but remembering to add the closeStack
to every component that can be closed is a bit of a hassle could be a major UX problem.
This is where the CloseWatcher
API comes in.
The CloseWatcher API
So all this presentation on the issues and we have not yet talked about the API itself. I will try my best to summarize the API:
jslet watcher = new CloseWatcher();
// Listen for close request
watcher.onclose = () => {
// Add your close logic
};
// Trigger the close event and handler
watcher.close();
// Dispose of the watcher instance
watcher.destroy();
There are some other things that the watcher can do but letâs leave that till later when we are more familiar with the API.
The close watcher API takes into consideration many things, all of which are done for you. That includes native UI elements with pickers, special behaviors with full-screen, dialogs, popovers, and inputs and it even cleans up our code by centralizing the closure logic of the UI component into a single place.
This is a significant shift in mindset when handling these kinds of things, you no longer think about key presses, or back button presses. Those are now âclose requestsâ and you are handling or issuing close requests.
One implication of this mindset is your UI component close logic is no longer scattered around, it should only exist in the close
event handler on the watcher instance.
This also means we no longer need to listen for the Esc key or the back button press, we just need to listen for the onclose
event and handle it accordingly.
Here is a quick rundown of a typical implementation of a component that opens/closes.
jslet watcher;
function onOpen() {
// Change state or however your component is meant to be "open".
// ...
// Initialize a new instance every time we open the component
watcher = new CloseWatcher();
watcher.onclose = () => {
// Close the component or however your component is meant to be "closed".
// Dispose of the watcher instance
watcher.destroy();
};
}
function onCloseClick() {
// Close the component by triggering the handler.
watcher?.close();
}
This may look a bit confusing at first, but it will all make sense once you see how it can âfixâ the examples earlier.
Sidebar - Revisited
The changes are minor, we just need to create a new instance of the CloseWatcher
and listen for the onclose
event, here is a snippet of the logic.
jsimport { ref } from 'vue';
import { onClickOutside } from '@vueuse/core';
let watcher;
const isOpen = ref(false);
function onOpenClick() {
isOpen.value = true;
// Initialize a new instance every time we open the sidebar
watcher = new CloseWatcher();
// Closure logic is handled by the watcher
watcher.onclose = () => {
// Dispose of the instance
watcher.destroy();
// Close the sidebar
isOpen.value = false;
};
}
onClickOutside(sidebar, () => {
// Close that sidebar by the watcher
watcher?.close();
});
That looks simple enough, but one weird part that I didnât wrap my head around initially was the need to create a new watcher every time the component is âopenedâ.
This is because the watcher is a one-time use instance, once it receives a close
event it cannot be used again. So we need to create a new one every time we open the sidebar. This makes sense because of a couple of things:
- You cannot close a closed UI, the
onclose
shouldnât trigger by then. - What if you have multiple things that can be opened and closed, you cannot share watchers here and you want to only close the last opened one.
- No need to tell the watcher itself that something has opened. Creating the instance means âIâve opened something, be on the lookout when it should be closed.â
Here is the full example with the CloseWatcher
API.
Notice that now if you have a search input with a value, pressing the Esc key will clear it first without closing the sidebar. The next Esc keystroke will close the sidebar and we didnât have to code in any kind of exceptions or filtration of the events.
Nested Menus - Revisited
Similarly with the nested menu, we do not need to do a lot of work to make it work as expected. We just need to create a new instance of the CloseWatcher
every time we open a menu item and close it when we close it.
We donât need to maintain a state of stacks for nested items or any other UI component. It is one of those things that truly âjust worksâ.
The amazing thing here to me is this ContextMenu
component is a recursive nested component and they donât care about which one is open and which one is whose child. They just open and close themselves in order all thanks to the CloseWatcher
API which routes the close request to only one watcher instance at a time, the last one typically.
Interrupting Close Requests
There is one more thing that the CloseWatcher
API can do for us, and that is interrupting close requests. This is useful when you want to prevent the close request from happening, maybe you want to show a confirmation dialog or the user has forgotten to save their work in a closeable UI and you want to make sure they know what they are doing.
This is where the cancel
and requestClose
come in, you want to ask nicely if the current UI can close and if it cannot then you can cancel the close request.
jswatcher.oncancel = (e) => {
e.preventDefault();
// decide to close or not
if (something) {
watcher.close();
}
};
// Ask nicely to trigger the "cancel" event
watcher.requestClose();
Here is a rather complicated example. This example has a toggle form that can be opened and closed, but if closed then the user will lose any changes done to their text input. So we want to ask them nicely if they are sure that they want to close the form or not.
Take your time to go through the code and understand the moving pieces and play around with the example, here are a few scenarios to try:
- Open the form, type a value, and click âSaveâ The form closes and the value is saved.
- Open the form, change the value and click âCancelâ and then click confirm: Form closes and the value is lost.
- Open the form, change the value and click âCancelâ and then click cancel: Form stays open.
- Open the form, change the value and press Esc: Confirmation dialog appears, press Esc again: Form stays open.
Things to note
- We are exposing a Vue component through a composable so we get a nice async API for showing/closing the confirmation.
- We have two close watchers at play here, one for the form and one for the confirmation dialog.
- All the closure logic is handled by the watchers
- We are using
requestClose
to trigger the confirmation dialog to show up. - We are using
close
to forcibly close the form if the user confirms or if they save their changes. - We are listening for
oncancel
to prevent the form from closing until the user confirms their action.
- We are using
Behavior on mobile
The CloseWatcher
API is not only for the desktop, it also works on mobile. On Android, the back button is the main way to trigger close requests, and on iOS it is the VoiceOver âZâ gesture.
Letâs focus on the Android use case because it does have a few interesting behaviors that are worth mentioning.
Consider the last sidebar example, on the mobile, a sliding menu like that could potentially cover most of the screen. So to the user, it may as well be a new page. So clicking the back button is expected to close the menu or the sidebar.
An issue I faced in my full-time work while working on a hybrid web app version using Capacitor, is that the back button doubles as a navigation command to âgo backâ. With modern frameworks that use the history API and client-side routing, you can easily confuse the browser into navigating to the previous page rather than just closing the current open UI. Unless you are using native popovers or dialog elements, the browser will navigate to the previous page which is frustrating.
I handled it by listening to the back button press and preventing the default behavior, then triggering a global event that would propagate to the router and all components that were meant to be closed. Once a component handles it, it is marked as âhandledâ and the back navigation is skipped.
So doesnât work well especially if there are a lot of UIs interested in that back button press, and it is not a very elegant solution. Consider the nested menu on mobile, it would typically cover most of the screen and with each nested item opening, it would slide over covering the previous one.
Review
You can use the CloseWatcher
API with any kind of closable UI, and not only popovers and menus. Here is an API summary:
ts// initialize a new instance, usually in the UI open handler
let watcher = new CloseWatcher();
// listen for close requests
// All the logic for closure and cleanup belongs here
watcher.onclose = () => {
// close the UI by toggling the state or whatever
// Dispose of the watcher instance.
watcher.destroy();
};
// Listen for cancel requests that precede close requests
// Use this if you want to interrupt the close request
watcher.oncancel = (e) => {
// You can prevent the close request by calling `preventDefault`
e.preventDefault();
// decide to close or not...
if (something) {
watcher.close();
}
};
// Trigger a close request manually
// This will go through the cancel handler first if there is one
// Then the close handler.
watcher.requestClose();
// Triggers the close handler, bypassing the cancel handler.
watcher.close();
This API is still experimental and is subject to change, it is only available in Chrome 120+. However, it is a good time to start thinking about how you can use it in your apps and how it can help you build better components.
The standardization of this behavior and being able to hook into it is a big deal, weâve seen a lot of recently introduced APIs and new web platform features that can take advantage of this API. The proposal menu mentions a few of them:
- a
<dialog>
element, especially the showModal() API. - a sidebar menu.
- a lightbox.
- a custom picker input (e.g. date picker).
- a custom context menu.
- fullscreen mode.
I know I will make use of it once it gets enough browser support or as I like to call it: let it âmarinateâ for a little while.
Adding Typings
The API being fresh means TypeScript wonât like seeing you use it.
Until it is added to the dom
standard typings, you can add it yourself like so:
shnpm i -D @types/dom-close-watcher
And add it to your tsconfig.json
file:
json5{
compilerOptions: {
//...
types: ['@types/dom-close-watcher'],
},
}
Conclusion
The CloseWatcher
API can help us build better and more accessible UIs.
It is a small API that can have a big impact on the web platform and how we build web apps.
It standardizes an area that has been left to the developers to figure out on their own for a very long time and brings order to the chaos there.