From 4bbb7aa1917eab0e312e8e9674a846b93a586947 Mon Sep 17 00:00:00 2001 From: Bohdan Sviripa Date: Thu, 2 May 2024 22:04:08 -0400 Subject: [PATCH] feat: add useClickOutside --- .changeset/bright-rabbits-obey.md | 5 ++ packages/runed/src/lib/functions/index.ts | 1 + .../lib/functions/useClickOutside/index.ts | 1 + .../useClickOutside/useClickOutside.svelte.ts | 52 ++++++++++++++++++ .../useClickOutside.test.svelte.ts | 55 +++++++++++++++++++ 5 files changed, 114 insertions(+) create mode 100644 .changeset/bright-rabbits-obey.md create mode 100644 packages/runed/src/lib/functions/useClickOutside/index.ts create mode 100644 packages/runed/src/lib/functions/useClickOutside/useClickOutside.svelte.ts create mode 100644 packages/runed/src/lib/functions/useClickOutside/useClickOutside.test.svelte.ts diff --git a/.changeset/bright-rabbits-obey.md b/.changeset/bright-rabbits-obey.md new file mode 100644 index 00000000..e9640e3d --- /dev/null +++ b/.changeset/bright-rabbits-obey.md @@ -0,0 +1,5 @@ +--- +"runed": minor +--- + +feat: `useClickOutside` diff --git a/packages/runed/src/lib/functions/index.ts b/packages/runed/src/lib/functions/index.ts index 22e6b62a..6de85d09 100644 --- a/packages/runed/src/lib/functions/index.ts +++ b/packages/runed/src/lib/functions/index.ts @@ -1,5 +1,6 @@ export * from "./box/index.js"; export * from "./useActiveElement/index.js"; +export * from "./useClickOutside/index.js"; export * from "./useDebounce/index.js"; export * from "./useElementSize/index.js"; export * from "./useEventListener/index.js"; diff --git a/packages/runed/src/lib/functions/useClickOutside/index.ts b/packages/runed/src/lib/functions/useClickOutside/index.ts new file mode 100644 index 00000000..a3239803 --- /dev/null +++ b/packages/runed/src/lib/functions/useClickOutside/index.ts @@ -0,0 +1 @@ +export * from "./useClickOutside.svelte.js"; diff --git a/packages/runed/src/lib/functions/useClickOutside/useClickOutside.svelte.ts b/packages/runed/src/lib/functions/useClickOutside/useClickOutside.svelte.ts new file mode 100644 index 00000000..9f5d5de2 --- /dev/null +++ b/packages/runed/src/lib/functions/useClickOutside/useClickOutside.svelte.ts @@ -0,0 +1,52 @@ +import { box, type WritableBox } from "$lib/functions/box/box.svelte.js"; +import { watch } from "$lib/functions/watch/watch.svelte.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} + */ +export function useClickOutside( + container: WritableBox, + fn: () => void +): ClickOutside { + const isEnabled = box(true); + + function start() { + isEnabled.value = true; + } + + function stop() { + isEnabled.value = false; + } + + function handleClick(event: MouseEvent) { + if (event.target && !container.value?.contains(event.target as Node)) { + fn(); + } + } + + watch([container, isEnabled], ([currentContainer, currentIsEnabled]) => { + if (currentContainer && currentIsEnabled) { + window.addEventListener("click", handleClick); + } + + return () => { + window.removeEventListener("click", handleClick); + }; + }); + + return { + start, + stop, + }; +} diff --git a/packages/runed/src/lib/functions/useClickOutside/useClickOutside.test.svelte.ts b/packages/runed/src/lib/functions/useClickOutside/useClickOutside.test.svelte.ts new file mode 100644 index 00000000..de7d168b --- /dev/null +++ b/packages/runed/src/lib/functions/useClickOutside/useClickOutside.test.svelte.ts @@ -0,0 +1,55 @@ +import { describe, expect, vi } from "vitest"; +import { tick } from "svelte"; +import { testWithEffect } from "$lib/test/util.svelte.js"; +import { box } from "$lib/functions/box/box.svelte.js"; +import { useClickOutside } from "./useClickOutside.svelte.js"; + +describe("useClickOutside", () => { + testWithEffect("calls a given callback when 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(box.from(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(box.from(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(); + }); +});