web dev & more!

Custom "Click Outside" Event with Svelte & TypeScript

Published: February 1, 2024

Background

When building interactive User Interfaces (UI), it’s not uncommon for pieces of a web app to move around. Take for instance the menu or search bar at the top of this site. When clicking or tapping either, portions of the site appear and make new functionality available. This sort of functionality has become so commonplace that end users expect specific behaviors from certain elements. When clicking the menu, the menu appears. Selecting any item within that menu then directs the user to the listed page. When clicking away from the menu, it closes. Simple, right?

This sort of functionality is trivial to implement on any HTML Element with Svelte by using the on:eventname element directive. If we would like a menu to open when a button is clicked, we can quickly trigger that by applying the on:click directive like so:

<script>
 let menuOpen = false;
 const toggle = () => (menuOpen = !menuOpen);
</script>

<span>
  <button title="Menu" class="menu {menuOpen ? 'open' : ''}" on:click={toggle}>
    <span class="hamburger">🍔</span>
  </button>
  <nav class={menuOpen ? 'shown' : ''}>
    <ul>
      <li><a on:click{toggle}>...</a></li>
      <li><a on:click{toggle}>...</a></li>
      <li><a on:click{toggle}>...</a></li>
    </ul>
  </nav>
</span>

In this crude example, menuOpen is used to check whether or not the classes open and shown should be applied the <button> and <nav> tags, respectively. The corresponding CSS classes (not shown) can then apply the styles to the button, hamburger span, and the menu which is subsequently moved into the viewport, thus making it visible to users. When a user clicks a link inside the menu, they’ll be redirected to the appropriate page. The menu closes if a user clicks a link or if the user clicks the menu button again. Fantastic! 🙌

But what happens, or rather, what should happen to this menu if the same user clicks something other than the “hamburger” button or an item within the navigation menu? 🤔

Nothing. There is no change to menuOpen so the menu stays just as it should.

But one of those behaviors users expect is that the menu will close when clicking something outside of it. How is a developer supposed to build functionality in a component that auto-magically 🪄 closes the menu when a click has been detected outside of its scope?

The Old Way

Six years ago, Rich Harris (the creator of Svelte), recommended the way most people would approach this problem; by creating another element that spans the entirety of the screen and intercepts clicks. This “background element” would then know that it could set the value to menuOpen to false, thus closing the menu. This strategy is still common and it works but it creates superfluous elements that clutter the Document Object Model (DOM). Svelte has come a long ways in 6 years, so there has to be a better way, right?

The Better Way

Svelte supports what it calls “actions” which are simply functions that run code when an element is mounted and return a destroy() method to handle unmounting.

Actions from svelte/action are different from SvelteKit's Form actions which are used for processing POST data submitted via a form.

We can harness the power of svelte/action and build our own. This will allow for the creation of a new event directive that can listen for clicks “outside” of our component and fire off a callback function. As a bonus, we can pass an optional parameter that will allow us to set custom locators where we would also like to ignore the “click outside” event. And of course, we’ll examine how to do it in TypeScript because I’ve become frustrated with not knowing what the hell I’m doing. Also, it was trendy four years ago and I’m behind the times! If you’re new to TypeScript, don’t be intimidated as I’ll explain the code after the snippet. To begin, we can create a new file:

clickOutside.ts

export function clickOutside(node: HTMLElement, ignore?: string) {
    const handleClick = (event: Event) => {
        const target = event.target as HTMLElement;
        if (!event.target || (ignore && target.closest(ignore))) {
            return;
        }
        if (node && !node.contains(target) && !event.defaultPrevented) {
            node.dispatchEvent(new CustomEvent('click_outside'));
        }
    };

    document.addEventListener('click', handleClick, true);

    return {
	destroy() {
            document.removeEventListener('click', handleClick, true);
	}
    };
}

Firstly, we’ll need to export our new function aptly named clickOutside. This function is going to accept an HTMLElement that we’ll call ”node" as well as a string named ”ignore” that provides an optional locator for another element we may want to ignore the “outside click” on. We’ll then create another function inside of that named ”handleClick”. This handleClick is going to be attached to the standard click event later on so we’ll need to add our logic that listens specifically for clicks outside of our target element next.

Of course, we don’t want this function to run on every single click that happens whenever our component is mounted so we’ll immediately return if the click wasn’t triggered from our component, it’s happening on our component (double clicks), or if the click is happening inside an element we specifically want to ignore.

After that first if, we know the click is happening to our target node. We then check this event has something to attach to, that the click was not triggered by a child element within our provided node, and its behavior not being prevented. Only then should we fire off a new CustomEvent named click_outside. Notice that the event is named click_outside and the action is named clickOutside. This just helps differentiate one from the other. The event will run the callback function we provide to our component when we implement on:click_outside, telling it to change the state of our menu.

After all of that, we’ve finished our custom handleClick function so we can add it as an event listener to the DOM. Notice how it’s simply registering the with standard click event. This one is simply adding our own special filtering to it.

Finally, we ensure that we undo what we did to the DOM when the component is unmounted by removing the custom event listener. But remember how we’re doing this in TypeScript? To ensure the TypeScript compiler doesn’t get all huffy 😡 with us, we’ll need to make sure we’ve got a couple configurations set up.

tsconfig.json

{
  ...
  "include": ["src/**/*"],
  ...
}

We’ll need to ensure that TypeScript and our linter can see the next file by adding the include option. You may already have this set but if not, be sure the next file is included in someway as TypeScript will be upset about click_outside not being a valid attribute for an HTMLElement.

src/app.d.ts

declare namespace svelteHTML {
    interface HTMLAttributes<T> {
        'on:click_outside'?: CompositionEventHandler
    }
}

As it stands, we won’t be able to implement our current actions on an HTMLElement because TypeScript won’t recognize it as a valid attribute. To get around this, we simply extend the available HTMLAttributes with our click_outside directive.

The New Menu

Remember our hamburger button from earlier? This is how we implement our new click_outside directive:

<script>
 import { clickOutside} from 'clickOutside';
 let menuOpen = false;
 const toggle = () => (menuOpen = !menuOpen);
</script>

<span use:clickOutside on:click_outside{() => menuOpen = false}>
  <button title="Menu" class="menu {menuOpen ? 'open' : ''}" on:click={toggle}>
    <span class="hamburger">🍔</span>
  </button>
  <nav class={menuOpen ? 'shown' : ''}>
    <ul>
      <li><a on:click{toggle}>...</a></li>
      <li><a on:click{toggle}>...</a></li>
      <li><a on:click{toggle}>...</a></li>
    </ul>
  </nav>
</span>

The surrounding <span> tag is retrofitted with the functionality imported from clickOutside by telling it to use:clickOutside and setting the node parameter there. We then have the event on:click_outside available on our element and can provide it a callback function. In this case, we simply close the menu by setting menuOpen = false.

If you’d like to ignore another element, specify that in the use:action step to ensure the locator is correctly passed to the clickOutside action.

<span use:clickOutside={'#ignoreMe'} on:click_outside{() => menuOpen = false}>

Now the element #ignoreMe will also ignore the click_outside event which simply allows for a little more control.

Quick and Dirty Way (NPM packages)

Isn’t there a package out there that does this? Yes, there are several actually. If you’re not interested in implementing this yourself and don’t care about falling into dependency hell, by all means, use one of these:

In my experimentation, I only gave the svelte-put/clickoutside a try but I found it lacked the ability to ignore elements by locators. I also wanted the excuse to play around with custom actions since I’ve never had the chance to!

Sources and Resources

I wish I could pass this off as something I came up with on my own but I wouldn’t want to anger and awaken H. Bomberguy from his year-long slumber and suffer his wrath. Instead, I’ll point you to the thread in the Svelte GitHub issues where this solution is lifted from. Specifically, see this comment from jdgamble555. Various other comments in the thread also helped me troubleshoot TypeScript errors.

Hopefully, this post can serve as a yet another resource for folks looking to implement their own custom actions with Svelte!

Archived Comments

These comments have been imported from a previous commenting system, for the sake of posterity. If you left a comment using the old system and would like to have it removed, please get in touch with me using this form.