Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: onClickOutside #46

Closed
wants to merge 19 commits into from
Closed
5 changes: 5 additions & 0 deletions .changeset/bright-rabbits-obey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"runed": minor
---

feat: `useClickOutside`
1 change: 1 addition & 0 deletions packages/runed/src/lib/utilities/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from "./activeElement/index.js";
export * from "./useClickOutside/index.js";
export * from "./useDebounce/index.js";
export * from "./ElementSize/index.js";
export * from "./useEventListener/index.js";
Expand Down
1 change: 1 addition & 0 deletions packages/runed/src/lib/utilities/useClickOutside/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./useClickOutside.svelte.js";
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { MaybeGetter } from "../../internal/types.js";
import { watch } from "../watch/watch.svelte.js";
import { extract } from "../extract/extract.js";

type ClickOutside = {
start: () => void;
stop: () => void;
};

/**
* Accepts a box which holds a container element and callback function.
* Invokes the callback function when the user clicks outside of the
* container.
*
* @returns an object with start and stop functions
*
* @see {@link https://runed.dev/docs/functions/use-click-outside}
sviripa marked this conversation as resolved.
Show resolved Hide resolved
*/
export function useClickOutside<T extends Element>(
container: MaybeGetter<T | undefined>,
callback: () => void
): ClickOutside {
let isEnabled = $state<boolean>(true);
sviripa marked this conversation as resolved.
Show resolved Hide resolved
const el = $derived<T | undefined>(extract(container));
sviripa marked this conversation as resolved.
Show resolved Hide resolved

function start() {
isEnabled = true;
}

function stop() {
isEnabled = false;
}
sviripa marked this conversation as resolved.
Show resolved Hide resolved

function handleClick(event: MouseEvent) {
if (event.target && !el?.contains(event.target as Node)) {
callback();
}
}
sviripa marked this conversation as resolved.
Show resolved Hide resolved

watch([() => el, () => isEnabled], ([currentEl, currentIsEnabled]) => {
if (currentEl && currentIsEnabled) {
window.addEventListener("click", handleClick);
sviripa marked this conversation as resolved.
Show resolved Hide resolved
}

return () => {
window.removeEventListener("click", handleClick);
};
});

return {
start,
stop,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, vi } from "vitest";
import { tick } from "svelte";
import { useClickOutside } from "./useClickOutside.svelte.js";
import { testWithEffect } from "$lib/test/util.svelte.js";

describe("useClickOutside", () => {
testWithEffect("calls a given callback on an outside of container click", async () => {
const container = document.createElement("div");
const innerButton = document.createElement("button");
const button = document.createElement("button");

document.body.appendChild(container);
document.body.appendChild(button);
container.appendChild(innerButton);

const callbackFn = vi.fn();

useClickOutside(() => container, callbackFn);
await tick();

button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(callbackFn).toHaveBeenCalledOnce();

innerButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(callbackFn).toHaveBeenCalledOnce();

container.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(callbackFn).toHaveBeenCalledOnce();
});

testWithEffect("can be paused and resumed", async () => {
const container = document.createElement("div");
const button = document.createElement("button");

document.body.appendChild(container);
document.body.appendChild(button);

const callbackFn = vi.fn();

const clickOutside = useClickOutside(() => container, callbackFn);

clickOutside.stop();
await tick();

button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(callbackFn).not.toHaveBeenCalled();

clickOutside.start();
await tick();

button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(callbackFn).toHaveBeenCalledOnce();
});
});
61 changes: 61 additions & 0 deletions sites/docs/content/utilities/use-click-outside.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
title: useClickOutside
description:
A function that calls a callback when a click event is triggered outside of a given container
element.
category: Browser
---

<script>
import Demo from '$lib/components/demos/use-click-outside.svelte';
</script>

## Demo

<Demo />

## Usage

```svelte
<script lang="ts">
import { useClickOutside } from "runed";

let el = $state<HTMLDivElement | undefined>(undefined);

useClickOutside(
() => el,
() => {
console.log("clicked outside of container");
}
);
</script>

<main>
<div bind:this={el}>Container</div>
<button>Click Me</button>
</main>
```

You can also programmatically pause and resume `useClickOutside` using the `start` and `stop`
functiosn returned by `useClickOutside`.

```svelte
<script lang="ts">
import { useClickOutside } from "runed";

let el = $state<HTMLDivElement | undefined>(undefined);

const outsideClick = useClickOutside(
() => el,
() => {
console.log("clicked outside of container");
}
);
</script>

<main>
<button onclick={outsideClick.stop}>Stop listening for outside clicks</button>
<button onclick={outsideClick.start}>Start listening again</button>
<div bind:this={el}></div>
</main>
```
37 changes: 37 additions & 0 deletions sites/docs/src/lib/components/demos/use-click-outside.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<script lang="ts">
import { useClickOutside } from "runed";

let containerText = $state("Has not clicked yet");
let el = $state<HTMLDivElement | undefined>(undefined);

useClickOutside(
() => el,
() => {
containerText = "Has clicked outside of container";
}
);
</script>

<main>
<div bind:this={el}>
<p>{containerText}</p>

<button onclick={() => (containerText = "Has clicked within container")}>
Button within container
</button>
</div>
<button>Button outside container</button>
</main>

<style>
div {
padding: 40px;
border: 1px solid;
margin-bottom: 16px;
}

button {
border: 1px solid;
padding-inline: 8px;
}
</style>