From 0e6b9028c7667afd7d56cb2bd159d149fb795e30 Mon Sep 17 00:00:00 2001 From: Bohdan Sviripa Date: Thu, 2 May 2024 22:04:08 -0400 Subject: [PATCH 01/18] feat: add useClickOutside --- .changeset/bright-rabbits-obey.md | 5 ++ .../lib/functions/useClickOutside/index.ts | 1 + .../useClickOutside/useClickOutside.svelte.ts | 52 ++++++++++++++++++ .../useClickOutside.test.svelte.ts | 55 +++++++++++++++++++ 4 files changed, 113 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/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..1c06d207 --- /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 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(); + }); +}); From f77e853e4c62670c2817b097644590bcc25d3ef0 Mon Sep 17 00:00:00 2001 From: Bohdan Sviripa Date: Tue, 7 May 2024 20:48:47 -0400 Subject: [PATCH 02/18] add docs page --- .../content/utilities/use-click-outside.md | 55 +++++++++++++++++++ .../components/demos/use-click-outside.svelte | 35 ++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 sites/docs/content/utilities/use-click-outside.md create mode 100644 sites/docs/src/lib/components/demos/use-click-outside.svelte diff --git a/sites/docs/content/utilities/use-click-outside.md b/sites/docs/content/utilities/use-click-outside.md new file mode 100644 index 00000000..1b177735 --- /dev/null +++ b/sites/docs/content/utilities/use-click-outside.md @@ -0,0 +1,55 @@ +--- +title: useClickOutside +description: + A function that calls a callback when a click event is triggered outside of a given container + element. +category: Elements +--- + + + +## Demo + + + +## Usage + +```svelte + + +
+
Container
+ +
+``` + +You can also programmatically pause and resume `useClickOutside` using the `start` and `stop` +functiosn returned by `useClickOutside`. + +```svelte + + +
+ + +
+
+``` diff --git a/sites/docs/src/lib/components/demos/use-click-outside.svelte b/sites/docs/src/lib/components/demos/use-click-outside.svelte new file mode 100644 index 00000000..3be1eb62 --- /dev/null +++ b/sites/docs/src/lib/components/demos/use-click-outside.svelte @@ -0,0 +1,35 @@ + + +
+
+

{containerText}

+ + +
+ +
+ + From 4acf14f4b8608d7bcf7e3bcc5d4b9b1690d158b7 Mon Sep 17 00:00:00 2001 From: Bohdan Sviripa Date: Mon, 1 Jul 2024 21:35:04 -0400 Subject: [PATCH 03/18] update to use new internals --- packages/runed/src/lib/utilities/index.ts | 1 + .../useClickOutside/index.ts | 0 .../useClickOutside/useClickOutside.svelte.ts | 24 +++++++------- .../useClickOutside.test.svelte.ts | 7 ++-- .../content/utilities/use-click-outside.md | 32 +++++++++++-------- .../components/demos/use-click-outside.svelte | 18 ++++++----- 6 files changed, 46 insertions(+), 36 deletions(-) rename packages/runed/src/lib/{functions => utilities}/useClickOutside/index.ts (100%) rename packages/runed/src/lib/{functions => utilities}/useClickOutside/useClickOutside.svelte.ts (55%) rename packages/runed/src/lib/{functions => utilities}/useClickOutside/useClickOutside.test.svelte.ts (89%) diff --git a/packages/runed/src/lib/utilities/index.ts b/packages/runed/src/lib/utilities/index.ts index 495c09cf..8372cffa 100644 --- a/packages/runed/src/lib/utilities/index.ts +++ b/packages/runed/src/lib/utilities/index.ts @@ -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"; diff --git a/packages/runed/src/lib/functions/useClickOutside/index.ts b/packages/runed/src/lib/utilities/useClickOutside/index.ts similarity index 100% rename from packages/runed/src/lib/functions/useClickOutside/index.ts rename to packages/runed/src/lib/utilities/useClickOutside/index.ts diff --git a/packages/runed/src/lib/functions/useClickOutside/useClickOutside.svelte.ts b/packages/runed/src/lib/utilities/useClickOutside/useClickOutside.svelte.ts similarity index 55% rename from packages/runed/src/lib/functions/useClickOutside/useClickOutside.svelte.ts rename to packages/runed/src/lib/utilities/useClickOutside/useClickOutside.svelte.ts index 9f5d5de2..5df23d51 100644 --- a/packages/runed/src/lib/functions/useClickOutside/useClickOutside.svelte.ts +++ b/packages/runed/src/lib/utilities/useClickOutside/useClickOutside.svelte.ts @@ -1,5 +1,6 @@ -import { box, type WritableBox } from "$lib/functions/box/box.svelte.js"; -import { watch } from "$lib/functions/watch/watch.svelte.js"; +import type { MaybeGetter } from "../../internal/types.js"; +import { watch } from "../watch/watch.svelte.js"; +import { extract } from "../extract/extract.js"; type ClickOutside = { start: () => void; @@ -16,27 +17,28 @@ type ClickOutside = { * @see {@link https://runed.dev/docs/functions/use-click-outside} */ export function useClickOutside( - container: WritableBox, - fn: () => void + container: MaybeGetter, + callback: () => void ): ClickOutside { - const isEnabled = box(true); + let isEnabled = $state(true); + const el = $derived(extract(container)); function start() { - isEnabled.value = true; + isEnabled = true; } function stop() { - isEnabled.value = false; + isEnabled = false; } function handleClick(event: MouseEvent) { - if (event.target && !container.value?.contains(event.target as Node)) { - fn(); + if (event.target && !el?.contains(event.target as Node)) { + callback(); } } - watch([container, isEnabled], ([currentContainer, currentIsEnabled]) => { - if (currentContainer && currentIsEnabled) { + watch([() => el, () => isEnabled], ([currentEl, currentIsEnabled]) => { + if (currentEl && currentIsEnabled) { window.addEventListener("click", handleClick); } diff --git a/packages/runed/src/lib/functions/useClickOutside/useClickOutside.test.svelte.ts b/packages/runed/src/lib/utilities/useClickOutside/useClickOutside.test.svelte.ts similarity index 89% rename from packages/runed/src/lib/functions/useClickOutside/useClickOutside.test.svelte.ts rename to packages/runed/src/lib/utilities/useClickOutside/useClickOutside.test.svelte.ts index 1c06d207..a3f05788 100644 --- a/packages/runed/src/lib/functions/useClickOutside/useClickOutside.test.svelte.ts +++ b/packages/runed/src/lib/utilities/useClickOutside/useClickOutside.test.svelte.ts @@ -1,8 +1,7 @@ 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"; +import { testWithEffect } from "$lib/test/util.svelte.js"; describe("useClickOutside", () => { testWithEffect("calls a given callback on an outside of container click", async () => { @@ -16,7 +15,7 @@ describe("useClickOutside", () => { const callbackFn = vi.fn(); - useClickOutside(box.from(container), callbackFn); + useClickOutside(() => container, callbackFn); await tick(); button.dispatchEvent(new MouseEvent("click", { bubbles: true })); @@ -38,7 +37,7 @@ describe("useClickOutside", () => { const callbackFn = vi.fn(); - const clickOutside = useClickOutside(box.from(container), callbackFn); + const clickOutside = useClickOutside(() => container, callbackFn); clickOutside.stop(); await tick(); diff --git a/sites/docs/content/utilities/use-click-outside.md b/sites/docs/content/utilities/use-click-outside.md index 1b177735..65eea08d 100644 --- a/sites/docs/content/utilities/use-click-outside.md +++ b/sites/docs/content/utilities/use-click-outside.md @@ -3,7 +3,7 @@ title: useClickOutside description: A function that calls a callback when a click event is triggered outside of a given container element. -category: Elements +category: Browser ---
-
Container
+
Container
``` @@ -38,18 +41,21 @@ functiosn returned by `useClickOutside`. ```svelte
-
+
``` diff --git a/sites/docs/src/lib/components/demos/use-click-outside.svelte b/sites/docs/src/lib/components/demos/use-click-outside.svelte index 3be1eb62..36ad1c23 100644 --- a/sites/docs/src/lib/components/demos/use-click-outside.svelte +++ b/sites/docs/src/lib/components/demos/use-click-outside.svelte @@ -1,17 +1,19 @@
-
+

{containerText}

-
+
From b37659a1cef67f80dd8f75f2c524ab2778a8e20d Mon Sep 17 00:00:00 2001 From: Bohdan Sviripa Date: Sat, 20 Jul 2024 20:59:17 -0400 Subject: [PATCH 05/18] use elements bounding client rect to detect inside clicks --- .../useClickOutside/useClickOutside.svelte.ts | 15 +++++++++++++-- .../useClickOutside.test.svelte.ts | 18 +++++++++++++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/runed/src/lib/utilities/useClickOutside/useClickOutside.svelte.ts b/packages/runed/src/lib/utilities/useClickOutside/useClickOutside.svelte.ts index a52b4e5f..b328c03d 100644 --- a/packages/runed/src/lib/utilities/useClickOutside/useClickOutside.svelte.ts +++ b/packages/runed/src/lib/utilities/useClickOutside/useClickOutside.svelte.ts @@ -16,9 +16,20 @@ export function useClickOutside( const el = $derived(extract(container)); function handleClick(event: MouseEvent) { - if (!event.target || el?.contains(event.target as Node)) return; + if (!event.target || !el) { + return; + } - callback(); + const rect = el.getBoundingClientRect(); + const clickedInside = + rect.top <= event.clientY && + event.clientY <= rect.top + rect.height && + rect.left <= event.clientX && + event.clientX <= rect.left + rect.width; + + if (!clickedInside) { + callback(); + } } useEventListener(() => document, "click", handleClick); diff --git a/packages/runed/src/lib/utilities/useClickOutside/useClickOutside.test.svelte.ts b/packages/runed/src/lib/utilities/useClickOutside/useClickOutside.test.svelte.ts index 459da377..f85b3b52 100644 --- a/packages/runed/src/lib/utilities/useClickOutside/useClickOutside.test.svelte.ts +++ b/packages/runed/src/lib/utilities/useClickOutside/useClickOutside.test.svelte.ts @@ -13,18 +13,30 @@ describe("useClickOutside", () => { document.body.appendChild(button); container.appendChild(innerButton); + container.getBoundingClientRect = vi.fn(() => ({ + height: 100, + width: 100, + top: 50, + left: 50, + bottom: 0, + right: 0, + x: 50, + y: 50, + toJSON: vi.fn() + })) + const callbackFn = vi.fn(); useClickOutside(() => container, callbackFn); await tick(); - button.dispatchEvent(new MouseEvent("click", { bubbles: true })); + button.dispatchEvent(new MouseEvent("click", { bubbles: true, clientX: 10, clientY: 10 })); expect(callbackFn).toHaveBeenCalledOnce(); - innerButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); + innerButton.dispatchEvent(new MouseEvent("click", { bubbles: true, clientX: 100, clientY: 100 })); expect(callbackFn).toHaveBeenCalledOnce(); - container.dispatchEvent(new MouseEvent("click", { bubbles: true })); + container.dispatchEvent(new MouseEvent("click", { bubbles: true, clientX: 50, clientY: 50 })); expect(callbackFn).toHaveBeenCalledOnce(); }); }); From 59f5016b755c86d6d03a735799c0aa0a307e467c Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Fri, 20 Dec 2024 21:23:19 -0500 Subject: [PATCH 06/18] work --- packages/runed/src/lib/internal/types.ts | 2 + .../runed/src/lib/internal/utils/platform.ts | 44 +++++++ packages/runed/src/lib/utilities/index.ts | 2 +- .../src/lib/utilities/onClickOutside/index.ts | 1 + .../onClickOutside/onClickOutside.svelte.ts | 76 ++++++++++++ .../onClickOutside.test.svelte.ts | 111 ++++++++++++++++++ .../lib/utilities/useClickOutside/index.ts | 1 - .../useClickOutside/useClickOutside.svelte.ts | 36 ------ .../useClickOutside.test.svelte.ts | 42 ------- .../src/content/utilities/on-click-outside.md | 65 ++++++++++ .../content/utilities/use-click-outside.md | 61 ---------- .../components/demos/on-click-outside.svelte | 28 +++++ .../components/demos/use-click-outside.svelte | 37 ------ .../src/routes/api/search.json/search.json | 2 +- 14 files changed, 329 insertions(+), 179 deletions(-) create mode 100644 packages/runed/src/lib/internal/utils/platform.ts create mode 100644 packages/runed/src/lib/utilities/onClickOutside/index.ts create mode 100644 packages/runed/src/lib/utilities/onClickOutside/onClickOutside.svelte.ts create mode 100644 packages/runed/src/lib/utilities/onClickOutside/onClickOutside.test.svelte.ts delete mode 100644 packages/runed/src/lib/utilities/useClickOutside/index.ts delete mode 100644 packages/runed/src/lib/utilities/useClickOutside/useClickOutside.svelte.ts delete mode 100644 packages/runed/src/lib/utilities/useClickOutside/useClickOutside.test.svelte.ts create mode 100644 sites/docs/src/content/utilities/on-click-outside.md delete mode 100644 sites/docs/src/content/utilities/use-click-outside.md create mode 100644 sites/docs/src/lib/components/demos/on-click-outside.svelte delete mode 100644 sites/docs/src/lib/components/demos/use-click-outside.svelte diff --git a/packages/runed/src/lib/internal/types.ts b/packages/runed/src/lib/internal/types.ts index e04785f1..fc8b283b 100644 --- a/packages/runed/src/lib/internal/types.ts +++ b/packages/runed/src/lib/internal/types.ts @@ -1,6 +1,8 @@ export type Getter = () => T; export type MaybeGetter = T | Getter; export type MaybeElementGetter = MaybeGetter; +export type MaybeElement = HTMLElement | SVGElement | undefined | null; + export type Setter = (value: T) => void; export type Expand = T extends infer U ? { [K in keyof U]: U[K] } : never; export type WritableProperties = { diff --git a/packages/runed/src/lib/internal/utils/platform.ts b/packages/runed/src/lib/internal/utils/platform.ts new file mode 100644 index 00000000..c16d8311 --- /dev/null +++ b/packages/runed/src/lib/internal/utils/platform.ts @@ -0,0 +1,44 @@ +type UserAgentBrand = { + brand: string; + version: string; +}; + +interface NavigatorWithUserAgentData extends Navigator { + userAgentData?: { + brands: UserAgentBrand[]; + platform: string; + }; +} + +/** + * Tests if a given RegExp pattern matches the platform identifier + */ +function testPlatform(pattern: RegExp): boolean { + if (typeof window === "undefined" || !window.navigator) return false; + + const nav = window.navigator as NavigatorWithUserAgentData; + const platform = nav.userAgentData?.platform || nav.platform; + return pattern.test(platform); +} + +export function getIsMac(): boolean { + return testPlatform(/^Mac/i); +} + +export function getIsIPhone(): boolean { + return testPlatform(/^iPhone/i); +} + +export function getIsIPad(): boolean { + const isPlatformIPad = testPlatform(/^iPad/i); + const isTouchCapableMac = + getIsMac() && typeof navigator !== "undefined" && navigator.maxTouchPoints > 1; + + return isPlatformIPad || isTouchCapableMac; +} + +export function getIsIOS(): boolean { + return getIsIPhone() || getIsIPad(); +} + +export const isIOS = /* #__PURE__ */ getIsIOS(); diff --git a/packages/runed/src/lib/utilities/index.ts b/packages/runed/src/lib/utilities/index.ts index 82feb100..ad65facf 100644 --- a/packages/runed/src/lib/utilities/index.ts +++ b/packages/runed/src/lib/utilities/index.ts @@ -1,5 +1,5 @@ export * from "./activeElement/index.js"; -export * from "./useClickOutside/index.js"; +export * from "./onClickOutside/index.js"; export * from "./useDebounce/index.js"; export * from "./ElementSize/index.js"; export * from "./useEventListener/index.js"; diff --git a/packages/runed/src/lib/utilities/onClickOutside/index.ts b/packages/runed/src/lib/utilities/onClickOutside/index.ts new file mode 100644 index 00000000..e5dbf1be --- /dev/null +++ b/packages/runed/src/lib/utilities/onClickOutside/index.ts @@ -0,0 +1 @@ +export * from "./onClickOutside.svelte.js"; diff --git a/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.svelte.ts b/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.svelte.ts new file mode 100644 index 00000000..9eeaf4d2 --- /dev/null +++ b/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.svelte.ts @@ -0,0 +1,76 @@ +import { defaultDocument, type ConfigurableDocument } from "$lib/internal/configurable-globals.js"; +import type { MaybeElement, MaybeElementGetter, MaybeGetter } from "$lib/internal/types.js"; +import { extract } from "../extract/extract.svelte.js"; +import { useEventListener } from "../useEventListener/useEventListener.svelte.js"; + +export type OnClickOutsideOptions = ConfigurableDocument & { + /** + * A list of elements and/or selectors to ignore when determining if a click + * event occurred outside of the container. + */ + ignore?: MaybeGetter>; +}; + +/** + * A utility that calls a given callback when a click event occurs outside of + * a specified container element. + * + * @template T - The type of the container element, defaults to HTMLElement. + * @param {MaybeElementGetter} container - The container element or a getter function that returns the container element. + * @param {() => void} callback - The callback function to call when a click event occurs outside of the container. + * @param {OnClickOutsideOptions} [opts={}] - Optional configuration object. + * @param {ConfigurableDocument} [opts.document=defaultDocument] - The document object to use, defaults to the global document. + * + * @example + * ```svelte + * + * + *
+ * Inside + *
+ * + *``` + * @see {@link https://runed.dev/docs/utilities/on-click-outside} + */ +export function onClickOutside( + container: MaybeElementGetter, + callback: () => void, + opts: OnClickOutsideOptions = {} +): void { + const { document = defaultDocument } = opts; + + /** + * WIP - need to handle cases where a pointerdown starts in the container + * but is released outside the container. This would result in a click event + * occurring outside the container, but we shouldn't trigger the callback + * unless the _complete_ click event occurred outside. + * + * Additionally, we should _really_ only doing the rect comparison if the event target + * is the same as the container or a descendant of the container which should cover the + * cases of pseudo elements being clicked. + */ + + function handleClick(e: MouseEvent) { + if (!e.target) return; + const node = extract(container); + if (!node) return; + + const rect = node.getBoundingClientRect(); + const wasInsideClick = + rect.top <= e.clientY && + e.clientY <= rect.top + rect.height && + rect.left <= e.clientX && + e.clientX <= rect.left + rect.width; + + if (!wasInsideClick) callback(); + } + + useEventListener(() => document, "click", handleClick); +} diff --git a/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.test.svelte.ts b/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.test.svelte.ts new file mode 100644 index 00000000..61810575 --- /dev/null +++ b/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.test.svelte.ts @@ -0,0 +1,111 @@ +import { describe, expect, vi, beforeEach, afterEach } from "vitest"; +import { tick } from "svelte"; +import { onClickOutside } from "./onClickOutside.svelte.js"; +import { testWithEffect } from "$lib/test/util.svelte.js"; + +describe("onClickOutside", () => { + let container: HTMLDivElement; + let innerButton: HTMLButtonElement; + let outsideButton: HTMLButtonElement; + let callbackFn: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + innerButton = document.createElement("button"); + outsideButton = document.createElement("button"); + + document.body.appendChild(container); + document.body.appendChild(outsideButton); + container.appendChild(innerButton); + + // we need to mock getBoundingClientRect + container.getBoundingClientRect = vi.fn(() => ({ + height: 100, + width: 100, + top: 50, + left: 50, + bottom: 150, + right: 150, + x: 50, + y: 50, + toJSON: vi.fn(), + })); + + callbackFn = vi.fn(); + }); + + afterEach(() => { + document.body.removeChild(container); + document.body.removeChild(outsideButton); + vi.clearAllMocks(); + }); + + const createMouseEvent = (x: number, y: number) => + new MouseEvent("click", { + bubbles: true, + clientX: x, + clientY: y, + }); + + testWithEffect("calls callback on click outside container", async () => { + onClickOutside(() => container, callbackFn); + await tick(); + + outsideButton.dispatchEvent(createMouseEvent(10, 10)); + expect(callbackFn).toHaveBeenCalledOnce(); + }); + + testWithEffect("doesn't call callback on click inside container", async () => { + onClickOutside(() => container, callbackFn); + await tick(); + + innerButton.dispatchEvent(createMouseEvent(75, 75)); + expect(callbackFn).not.toHaveBeenCalled(); + + container.dispatchEvent(createMouseEvent(60, 60)); + expect(callbackFn).not.toHaveBeenCalled(); + }); + + testWithEffect("handles edge cases of container boundaries", async () => { + onClickOutside(() => container, callbackFn); + await tick(); + + // Click exactly on boundaries + outsideButton.dispatchEvent(createMouseEvent(50, 50)); // Top-left corner + expect(callbackFn).not.toHaveBeenCalled(); + + outsideButton.dispatchEvent(createMouseEvent(150, 150)); // Bottom-right corner + expect(callbackFn).not.toHaveBeenCalled(); + + // Click just outside boundaries + outsideButton.dispatchEvent(createMouseEvent(49, 50)); + expect(callbackFn).toHaveBeenCalledTimes(1); + + outsideButton.dispatchEvent(createMouseEvent(151, 150)); + expect(callbackFn).toHaveBeenCalledTimes(2); + }); + + testWithEffect("handles null container gracefully", async () => { + onClickOutside(() => null, callbackFn); + await tick(); + + outsideButton.dispatchEvent(createMouseEvent(0, 0)); + expect(callbackFn).not.toHaveBeenCalled(); + }); + + testWithEffect("handles clicks when target is null", async () => { + onClickOutside(() => container, callbackFn); + await tick(); + + // Simulate a click event with null target + const nullTargetEvent = new MouseEvent("click", { + bubbles: true, + clientX: 0, + clientY: 0, + }); + Object.defineProperty(nullTargetEvent, "target", { value: null }); + + document.dispatchEvent(nullTargetEvent); + expect(callbackFn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/runed/src/lib/utilities/useClickOutside/index.ts b/packages/runed/src/lib/utilities/useClickOutside/index.ts deleted file mode 100644 index a3239803..00000000 --- a/packages/runed/src/lib/utilities/useClickOutside/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./useClickOutside.svelte.js"; diff --git a/packages/runed/src/lib/utilities/useClickOutside/useClickOutside.svelte.ts b/packages/runed/src/lib/utilities/useClickOutside/useClickOutside.svelte.ts deleted file mode 100644 index b328c03d..00000000 --- a/packages/runed/src/lib/utilities/useClickOutside/useClickOutside.svelte.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { MaybeGetter } from "../../internal/types.js"; -import { extract } from "../extract/extract.js"; -import { useEventListener } from "../useEventListener/useEventListener.svelte.js"; - -/** - * Accepts a box which holds a container element and callback function. - * Invokes the callback function when the user clicks outside of the - * container. - * - * @see {@link https://runed.dev/docs/utilities/use-click-outside} - */ -export function useClickOutside( - container: MaybeGetter, - callback: () => void -) { - const el = $derived(extract(container)); - - function handleClick(event: MouseEvent) { - if (!event.target || !el) { - return; - } - - const rect = el.getBoundingClientRect(); - const clickedInside = - rect.top <= event.clientY && - event.clientY <= rect.top + rect.height && - rect.left <= event.clientX && - event.clientX <= rect.left + rect.width; - - if (!clickedInside) { - callback(); - } - } - - useEventListener(() => document, "click", handleClick); -} diff --git a/packages/runed/src/lib/utilities/useClickOutside/useClickOutside.test.svelte.ts b/packages/runed/src/lib/utilities/useClickOutside/useClickOutside.test.svelte.ts deleted file mode 100644 index f85b3b52..00000000 --- a/packages/runed/src/lib/utilities/useClickOutside/useClickOutside.test.svelte.ts +++ /dev/null @@ -1,42 +0,0 @@ -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); - - container.getBoundingClientRect = vi.fn(() => ({ - height: 100, - width: 100, - top: 50, - left: 50, - bottom: 0, - right: 0, - x: 50, - y: 50, - toJSON: vi.fn() - })) - - const callbackFn = vi.fn(); - - useClickOutside(() => container, callbackFn); - await tick(); - - button.dispatchEvent(new MouseEvent("click", { bubbles: true, clientX: 10, clientY: 10 })); - expect(callbackFn).toHaveBeenCalledOnce(); - - innerButton.dispatchEvent(new MouseEvent("click", { bubbles: true, clientX: 100, clientY: 100 })); - expect(callbackFn).toHaveBeenCalledOnce(); - - container.dispatchEvent(new MouseEvent("click", { bubbles: true, clientX: 50, clientY: 50 })); - expect(callbackFn).toHaveBeenCalledOnce(); - }); -}); diff --git a/sites/docs/src/content/utilities/on-click-outside.md b/sites/docs/src/content/utilities/on-click-outside.md new file mode 100644 index 00000000..3cfa71a2 --- /dev/null +++ b/sites/docs/src/content/utilities/on-click-outside.md @@ -0,0 +1,65 @@ +--- +title: onClickOutside +description: Handle clicks outside of a specified element. +category: Sensors +--- + + + +`onClickOutside` detects clicks that occur outside a specified element's boundaries and executes a +callback function. It's commonly used for dismissible dropdowns, modals, and other interactive +components. + +## Demo + + + +## Basic Usage + +```svelte + + +
+ +
+ +``` + +## Advanced Usage + +### Controlled Listener + +The function returns control methods to programmatically manage the listener, `start` and `stop`: + +```svelte + + +
+ + +
+ +
+ +
+``` diff --git a/sites/docs/src/content/utilities/use-click-outside.md b/sites/docs/src/content/utilities/use-click-outside.md deleted file mode 100644 index 65eea08d..00000000 --- a/sites/docs/src/content/utilities/use-click-outside.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -title: useClickOutside -description: - A function that calls a callback when a click event is triggered outside of a given container - element. -category: Browser ---- - - - -## Demo - - - -## Usage - -```svelte - - -
-
Container
- -
-``` - -You can also programmatically pause and resume `useClickOutside` using the `start` and `stop` -functiosn returned by `useClickOutside`. - -```svelte - - -
- - -
-
-``` diff --git a/sites/docs/src/lib/components/demos/on-click-outside.svelte b/sites/docs/src/lib/components/demos/on-click-outside.svelte new file mode 100644 index 00000000..afabc26b --- /dev/null +++ b/sites/docs/src/lib/components/demos/on-click-outside.svelte @@ -0,0 +1,28 @@ + + + +
+ + container + +

{containerText}

+ +
+
diff --git a/sites/docs/src/lib/components/demos/use-click-outside.svelte b/sites/docs/src/lib/components/demos/use-click-outside.svelte deleted file mode 100644 index 92f41604..00000000 --- a/sites/docs/src/lib/components/demos/use-click-outside.svelte +++ /dev/null @@ -1,37 +0,0 @@ - - -
- -

{containerText}

- - -
- -
- - diff --git a/sites/docs/src/routes/api/search.json/search.json b/sites/docs/src/routes/api/search.json/search.json index edc7ac12..03c1196c 100644 --- a/sites/docs/src/routes/api/search.json/search.json +++ b/sites/docs/src/routes/api/search.json/search.json @@ -1 +1 @@ -[{"title":"Getting Started","href":"/docs/getting-started","description":"Learn how to install and use Runed in your projects.","content":"Installation Install Runed using your favorite package manager: npm install runed Usage Import one of the utilities you need to either a .svelte or .svelte.js|ts file and start using it: import { activeElement } from \"runed\"; let inputElement = $state(); {#if activeElement.current === inputElement} The input element is active! {/if} or import { activeElement } from \"runed\"; function logActiveElement() { $effect(() => { console.log(\"Active element is \", activeElement.current); }); } logActiveElement(); `"},{"title":"Introduction","href":"/docs/index","description":"Runes are magic, but what good is magic if you don't have a wand?","content":"Runed is a collection of utilities for Svelte 5 that make composing powerful applications and libraries a breeze, leveraging the power of $2. Why Runed? Svelte 5 Runes unlock immense power by providing a set of primitives that allow us to build impressive applications and libraries with ease. However, building complex applications often requires more than just the primitives provided by Svelte Runes. Runed takes those primitives to the next level by providing: Powerful Utilities**: A set of carefully crafted utility functions and classes that simplify common tasks and reduce boilerplate. Collective Efforts**: We often find ourselves writing the same utility functions over and over again. Runed aims to provide a single source of truth for these utilities, allowing the community to contribute, test, and benefit from them. Consistency**: A consistent set of APIs and behaviors across all utilities, so you can focus on building your projects instead of constantly learning new APIs. Reactivity First**: Powered by Svelte 5's new reactivity system, Runed utilities are designed to handle reactive state and side effects with ease. Type Safety**: Full TypeScript support to catch errors early and provide a better developer experience. Ideas and Principles Embrace the Magic of Runes Svelte Runes are a powerful new paradigm. Runed fully embraces this concept and explores its potential. Our goal is to make working with Runes feel as natural and intuitive as possible. Enhance, Don't Replace Runed is not here to replace Svelte's core functionality, but to enhance and extend it. Our utilities should feel like a natural extension of Svelte, not a separate framework. Progressive Complexity Simple things should be simple, complex things should be possible. Runed provides easy-to-use defaults while allowing for advanced customization when needed. Open Source and Community Collaboration Runed is an open-source, MIT licensed project that welcomes all forms of contributions from the community. Whether it's bug reports, feature requests, or code contributions, your input will help make Runed the best it can be."},{"title":"activeElement","href":"/docs/utilities/active-element","description":"Track and access the currently focused DOM element","content":" import Demo from '$lib/components/demos/active-element.svelte'; activeElement provides reactive access to the currently focused DOM element in your application, similar to document.activeElement but with reactive updates. Updates synchronously with DOM focus changes Returns null when no element is focused Safe to use with SSR (Server-Side Rendering) Lightweight alternative to manual focus tracking If you need to provide a custom document / shadowRoot, you can use the $2 utility instead, which provides a more flexible API. Demo Usage import { activeElement } from \"runed\"; Currently active element: {activeElement.current?.localName ?? \"No active element found\"} Type Definition interface ActiveElement { readonly current: Element | null; } `"},{"title":"AnimationFrames","href":"/docs/utilities/animation-frames","description":"A wrapper for requestAnimationFrame with FPS control and frame metrics","content":" import Demo from '$lib/components/demos/animation-frames.svelte'; AnimationFrames provides a declarative API over the browser's $2, offering FPS limiting capabilities and frame metrics while handling cleanup automatically. Demo Usage import { AnimationFrames } from \"runed\"; import { Slider } from \"../ui/slider\"; // Check out shadcn-svelte! let frames = $state(0); let fpsLimit = $state(10); let delta = $state(0); const animation = new AnimationFrames( (args) => { frames++; delta = args.delta; }, { fpsLimit: () => fpsLimit } ); const stats = $derived( Frames: ${frames}\\nFPS: ${animation.fps.toFixed(0)}\\nDelta: ${delta.toFixed(0)}ms ); {stats} {animation.running ? \"Stop\" : \"Start\"} FPS limit: {fpsLimit}{fpsLimit === 0 ? \" (not limited)\" : \"\"} (fpsLimit = value[0] ?? 0)} min={0} max={144} /> `"},{"title":"Context","href":"/docs/utilities/context","description":"A wrapper around Svelte's Context API that provides type safety and improved ergonomics for sharing data between components.","content":" import { Steps, Step, Callout } from '@svecodocs/kit'; Context allows you to pass data through the component tree without explicitly passing props through every level. It's useful for sharing data that many components need, like themes, authentication state, or localization preferences. The Context class provides a type-safe way to define, set, and retrieve context values. Usage Creating a Context First, create a Context instance with the type of value it will hold: import { Context } from \"runed\"; export const myTheme = new Context(\"theme\"); Creating a Context instance only defines the context - it doesn't actually set any value. The value passed to the constructor (\"theme\" in this example) is just an identifier used for debugging and error messages. Think of this step as creating a \"container\" that will later hold your context value. The container is typed (in this case to only accept \"light\" or \"dark\" as values) but remains empty until you explicitly call myTheme.set() during component initialization. This separation between defining and setting context allows you to: Keep context definitions in separate files Reuse the same context definition across different parts of your app Maintain type safety throughout your application Set different values for the same context in different component trees Setting Context Values Set the context value in a parent component during initialization. import { myTheme } from \"./context\"; let { data, children } = $props(); myTheme.set(data.theme); {@render children?.()} Context must be set during component initialization, similar to lifecycle functions like onMount. You cannot set context inside event handlers or callbacks. Reading Context Values Child components can access the context using get() or getOr() import { myTheme } from \"./context\"; const theme = myTheme.get(); // or with a fallback value if the context is not set const theme = myTheme.getOr(\"light\"); Type Definition class Context { /** @param name The name of the context. This is used for generating the context key and error messages. */ constructor(name: string) {} /** The key used to get and set the context. * It is not recommended to use this value directly. Instead, use the methods provided by this class. */ get key(): symbol; /** Checks whether this has been set in the context of a parent component. * Must be called during component initialization. */ exists(): boolean; /** Retrieves the context that belongs to the closest parent component. * Must be called during component initialization. * @throws An error if the context does not exist. */ get(): TContext; /** Retrieves the context that belongs to the closest parent component, or the given fallback value if the context does not exist. * Must be called during component initialization. */ getOr(fallback: TFallback): TContext | TFallback; /** Associates the given value with the current component and returns it. * Must be called during component initialization. */ set(context: TContext): TContext; } `"},{"title":"Debounced","href":"/docs/utilities/debounced","description":"A wrapper over `useDebounce` that returns a debounced state.","content":" import Demo from '$lib/components/demos/debounced.svelte'; Demo Usage This is a simple wrapper over $2 that returns a debounced state. import { Debounced } from \"runed\"; let search = $state(\"\"); const debounced = new Debounced(() => search, 500); You searched for: {debounced.current} You may cancel the pending update, run it immediately, or set a new value. Setting a new value immediately also cancels any pending updates. let count = $state(0); const debounced = new Debounced(() => count, 500); count = 1; debounced.cancel(); // after a while... console.log(debounced.current); // Still 0! count = 2; console.log(debounced.current); // Still 0! debounced.setImmediately(count); console.log(debounced.current); // 2 count = 3; console.log(debounced.current); // 2 await debounced.updateImmediately(); console.log(debounced.current); // 3 `"},{"title":"ElementRect","href":"/docs/utilities/element-rect","description":"Track element dimensions and position reactively","content":" import Demo from '$lib/components/demos/element-rect.svelte'; ElementRect provides reactive access to an element's dimensions and position information, automatically updating when the element's size or position changes. Demo Usage import { ElementRect } from \"runed\"; let el = $state(); const rect = new ElementRect(() => el); Width: {rect.width} Height: {rect.height} {JSON.stringify(rect.current, null, 2)} Type Definition type Rect = Omit; interface ElementRectOptions { initialRect?: DOMRect; } class ElementRect { constructor(node: MaybeGetter, options?: ElementRectOptions); readonly current: Rect; readonly width: number; readonly height: number; readonly top: number; readonly left: number; readonly right: number; readonly bottom: number; readonly x: number; readonly y: number; } `"},{"title":"ElementSize","href":"/docs/utilities/element-size","description":"Track element dimensions reactively","content":" import Demo from '$lib/components/demos/element-size.svelte'; ElementSize provides reactive access to an element's width and height, automatically updating when the element's dimensions change. Similar to ElementRect but focused only on size measurements. Demo Usage import { ElementSize } from \"runed\"; let el = $state() as HTMLElement; const size = new ElementSize(() => el); Width: {size.width} Height: {size.height} Type Definition interface ElementSize { readonly width: number; readonly height: number; } `"},{"title":"FiniteStateMachine","href":"/docs/utilities/finite-state-machine","description":"Defines a strongly-typed finite state machine.","content":" import Demo from '$lib/components/demos/finite-state-machine.svelte'; Demo type MyStates = \"disabled\" | \"idle\" | \"running\"; type MyEvents = \"toggleEnabled\" | \"start\" | \"stop\"; const f = new FiniteStateMachine(\"disabled\", { disabled: { toggleEnabled: \"idle\" }, idle: { toggleEnabled: \"disabled\", start: \"running\" }, running: { _enter: () => { f.debounce(2000, \"stop\"); }, stop: \"idle\", toggleEnabled: \"disabled\" } }); Usage Finite state machines (often abbreviated as \"FSMs\") are useful for tracking and manipulating something that could be in one of many different states. It centralizes the definition of every possible state and the events that might trigger a transition from one state to another. Here is a state machine describing a simple toggle switch: import { FiniteStateMachine } from \"runed\"; type MyStates = \"on\" | \"off\"; type MyEvents = \"toggle\"; const f = new FiniteStateMachine(\"off\", { off: { toggle: \"on\" }, on: { toggle: \"off\" } }); The first argument to the FiniteStateMachine constructor is the initial state. The second argument is an object with one key for each state. Each state then describes which events are valid for that state, and which state that event should lead to. In the above example of a simple switch, there are two states (on and off). The toggle event in either state leads to the other state. You send events to the FSM using f.send. To send the toggle event, invoke f.send('toggle'). Actions Maybe you want fancier logic for an event handler, or you want to conditionally transition into another state. Instead of strings, you can use actions. An action is a function that returns a state. An action can receive parameters, and it can use those parameters to dynamically choose which state should come next. It can also prevent a state transition by returning nothing. type MyStates = \"on\" | \"off\" | \"cooldown\"; const f = new FiniteStateMachine(\"off\", { off: { toggle: () => { if (isTuesday) { // Switch can only turn on during Tuesdays return \"on\"; } // All other days, nothing is returned and state is unchanged. } }, on: { toggle: (heldMillis: number) => { // You can also dynamically return the next state! // Only turn off if switch is depressed for 3 seconds if (heldMillis > 3000) { return \"off\"; } } } }); Lifecycle methods You can define special handlers that are invoked whenever a state is entered or exited: const f = new FiniteStateMachine('off', { off: { toggle: 'on' _enter: (meta) => { console.log('switch is off') } _exit: (meta) => { console.log('switch is no longer off') } }, on: { toggle: 'off' _enter: (meta) => { console.log('switch is on') } _exit: (meta) => { console.log('switch is no longer on') } } }); The lifecycle methods are invoked with a metadata object containing some useful information: from: the name of the event that is being exited to: the name of the event that is being entered event: the name of the event which has triggered the transition args: (optional) you may pass additional metadata when invoking an action with f.send('theAction', additional, params, as, args) The _enter handler for the initial state is called upon creation of the FSM. It is invoked with both the from and event fields set to null. Wildcard handlers There is one special state used as a fallback: *. If you have the fallback state, and you attempt to send() an event that is not handled by the current state, then it will try to find a handler for that event on the * state before discarding the event: const f = new FiniteStateMachine('off', { off: { toggle: 'on' }, on: { toggle: 'off' } '*': { emergency: 'off' } }); // will always result in the switch turning off. f.send('emergency'); Debouncing Frequently, you want to transition to another state after some time has elapsed. To do this, use the debounce method: f.send(\"toggle\"); // turn on immediately f.debounce(5000, \"toggle\"); // turn off in 5000 milliseconds If you re-invoke debounce with the same event, it will cancel the existing timer and start the countdown over: // schedule a toggle in five seconds f.debounce(5000, \"toggle\"); // ... less than 5000ms elapses ... f.debounce(5000, \"toggle\"); // The second call cancels the original timer, and starts a new one You can also use debounce in both actions and lifecycle methods. In both of the following examples, the lightswitch will turn itself off five seconds after it was turned on: const f = new FiniteStateMachine(\"off\", { off: { toggle: () => { f.debounce(5000, \"toggle\"); return \"on\"; } }, on: { toggle: \"off\" } }); const f = new FiniteStateMachine(\"off\", { off: { toggle: \"on\" }, on: { toggle: \"off\", _enter: () => { f.debounce(5000, \"toggle\"); } } }); Notes FiniteStateMachine is a loving rewrite of $2. FSMs are ideal for representing many different kinds of systems and interaction patterns. FiniteStateMachine is an intentionally minimalistic implementation. If you're looking for a more powerful FSM library, $2 is an excellent library with more features — and a steeper learning curve."},{"title":"IsFocusWithin","href":"/docs/utilities/is-focus-within","description":"A utility that tracks whether any descendant element has focus within a specified container element.","content":" import Demo from '$lib/components/demos/is-focus-within.svelte'; IsFocusWithin reactively tracks focus state within a container element, updating automatically when focus changes. Demo Usage import { IsFocusWithin } from \"runed\"; let formElement = $state(); const focusWithinForm = new IsFocusWithin(() => formElement); Focus within form: {focusWithinForm.current} Submit Type Definition class IsFocusWithin { constructor(node: MaybeGetter); readonly current: boolean; } `"},{"title":"IsIdle","href":"/docs/utilities/is-idle","description":"Track if a user is idle and the last time they were active.","content":" import Demo from '$lib/components/demos/is-idle.svelte'; IsIdle tracks user activity and determines if they're idle based on a configurable timeout. It monitors mouse movement, keyboard input, and touch events to detect user interaction. Demo Usage import { AnimationFrames, IsIdle } from \"runed\"; const idle = new IsIdle({ timeout: 1000 }); Idle: {idle.current} Last active: {new Date(idle.lastActive).toLocaleTimeString()} Type Definitions interface IsIdleOptions { /** The events that should set the idle state to true * @default ['mousemove', 'mousedown', 'resize', 'keydown', 'touchstart', 'wheel'] */ events?: MaybeGetter; /** The timeout in milliseconds before the idle state is set to true. Defaults to 60 seconds. * @default 60000 */ timeout?: MaybeGetter; /** Detect document visibility changes * @default true */ detectVisibilityChanges?: MaybeGetter; /** The initial state of the idle property * @default false */ initialState?: boolean; } class IsIdle { constructor(options?: IsIdleOptions); readonly current: boolean; readonly lastActive: number; } `"},{"title":"IsInViewport","href":"/docs/utilities/is-in-viewport","description":"Track if an element is visible within the current viewport.","content":" import Demo from '$lib/components/demos/is-in-viewport.svelte'; IsInViewport uses the $2 utility to track if an element is visible within the current viewport. It accepts an element or getter that returns an element and an optional options object that aligns with the $2 utility options. Demo Usage import { IsInViewport } from \"runed\"; let targetNode = $state()!; const inViewport = new IsInViewport(() => targetNode); Target node Target node in viewport: {inViewport.current} Type Definition import { type UseIntersectionObserverOptions } from \"runed\"; export type IsInViewportOptions = UseIntersectionObserverOptions; export declare class IsInViewport { constructor(node: MaybeGetter, options?: IsInViewportOptions); get current(): boolean; } "},{"title":"IsMounted","href":"/docs/utilities/is-mounted","description":"A class that returns the mounted state of the component it's called in.","content":" import Demo from '$lib/components/demos/is-mounted.svelte'; Demo Usage import { IsMounted } from \"runed\"; const isMounted = new IsMounted(); Which is a shorthand for one of the following: import { onMount } from \"svelte\"; const isMounted = $state({ current: false }); onMount(() => { isMounted.current = true; }); or import { untrack } from \"svelte\"; const isMounted = $state({ current: false }); $effect(() => { untrack(() => (isMounted.current = true)); }); `"},{"title":"IsSupported","href":"/docs/utilities/is-supported","description":"Determine if a feature is supported by the environment before using it.","content":"Usage import { IsSupported } from \"runed\"; const isSupported = new IsSupported(() => navigator && \"geolocation\" in navigator); if (isSupported.current) { // Do something with the geolocation API } Type Definition class IsSupported { readonly current: boolean; } `"},{"title":"PersistedState","href":"/docs/utilities/persisted-state","description":"A reactive state manager that persists and synchronizes state across browser sessions and tabs using Web Storage APIs.","content":" import Demo from '$lib/components/demos/persisted-state.svelte'; import { Callout } from '@svecodocs/kit' PersistedState provides a reactive state container that automatically persists data to browser storage and optionally synchronizes changes across browser tabs in real-time. Demo You can refresh this page and/or open it in another tab to see the count state being persisted and synchronized across sessions and tabs. Usage Initialize PersistedState by providing a unique key and an initial value for the state. import { PersistedState } from \"runed\"; const count = new PersistedState(\"count\", 0); count.current++}>Increment count.current--}>Decrement (count.current = 0)}>Reset Count: {count.current} Configuration Options PersistedState includes an options object that allows you to customize the behavior of the state manager. const state = new PersistedState(\"user-preferences\", initialValue, { // Use sessionStorage instead of localStorage (default: 'local') storage: \"session\", // Disable cross-tab synchronization (default: true) syncTabs: false, // Custom serialization handlers serializer: { serialize: superjson.stringify, deserialize: superjson.parse } }); Storage Options 'local': Data persists until explicitly cleared 'session': Data persists until the browser session ends Cross-Tab Synchronization When syncTabs is enabled (default), changes are automatically synchronized across all browser tabs using the storage event. Custom Serialization Provide custom serialize and deserialize functions to handle complex data types: import superjson from \"superjson\"; // Example with Date objects const lastAccessed = new PersistedState(\"last-accessed\", new Date(), { serializer: { serialize: superjson.stringify, deserialize: superjson.parse } }); `"},{"title":"PressedKeys","href":"/docs/utilities/pressed-keys","description":"Tracks which keys are currently pressed","content":" import Demo from '$lib/components/demos/pressed-keys.svelte'; Demo Usage With an instance of PressedKeys, you can use the has method. const keys = new PressedKeys(); const isArrowDownPressed = $derived(keys.has(\"ArrowDown\")); const isCtrlAPressed = $derived(keys.has(\"Control\", \"a\")); Or get all of the currently pressed keys: const keys = new PressedKeys(); console.log(keys.all()); `"},{"title":"Previous","href":"/docs/utilities/previous","description":"A utility that tracks and provides access to the previous value of a reactive getter.","content":" import Demo from '$lib/components/demos/previous.svelte'; The Previous utility creates a reactive wrapper that maintains the previous value of a getter function. This is particularly useful when you need to compare state changes or implement transition effects. Demo Usage import { Previous } from \"runed\"; let count = $state(0); const previous = new Previous(() => count); count++}>Count: {count} Previous: {${previous.current}} Type Definition class Previous { constructor(getter: () => T); readonly current: T; // Previous value } `"},{"title":"StateHistory","href":"/docs/utilities/state-history","description":"Track state changes with undo/redo capabilities","content":" import Demo from '$lib/components/demos/state-history.svelte'; Demo Usage StateHistory tracks a getter's return value, logging each change into an array. A setter is also required to use the undo and redo functions. import { StateHistory } from \"runed\"; let count = $state(0); const history = new StateHistory(() => count, (c) => (count = c)); history.log[0]; // { snapshot: 0, timestamp: ... } Besides log, the returned object contains undo and redo functionality. import { useStateHistory } from \"runed\"; let count = $state(0); const history = new StateHistory(() => count, (c) => (count = c)); function format(ts: number) { return new Date(ts).toLocaleString(); } {count} count++}>Increment count--}>Decrement Undo Redo `"},{"title":"useActiveElement","href":"/docs/utilities/use-active-element","description":"Get a reactive reference to the currently focused element in the document.","content":" import Demo from '$lib/components/demos/use-active-element.svelte'; import { PropField } from '@svecodocs/kit' useActiveElement is used to get the currently focused element in the document. If you don't need to provide a custom document / shadowRoot, you can use the $2 state instead, as it provides a simpler API. This utility behaves similarly to document.activeElement but with additional features such as: Updates synchronously with DOM focus changes Returns null when no element is focused Safe to use with SSR (Server-Side Rendering) Lightweight alternative to manual focus tracking Demo Usage import { useActiveElement } from \"runed\"; const activeElement = useActiveElement(); {#if activeElement.current} The active element is: {activeElement.current.localName} {:else} No active element found {/if} Options The following options can be passed via the first argument to useActiveElement: The document or shadow root to track focus within. The window to use for focus tracking. "},{"title":"useDebounce","href":"/docs/utilities/use-debounce","description":"A higher-order function that debounces the execution of a function.","content":" import Demo from '$lib/components/demos/use-debounce.svelte'; useDebounce is a utility function that creates a debounced version of a callback function. Debouncing prevents a function from being called too frequently by delaying its execution until after a specified duration of inactivity. Demo Usage import { useDebounce } from \"runed\"; let count = $state(0); let logged = $state(\"\"); let isFirstTime = $state(true); let debounceDuration = $state(1000); const logCount = useDebounce( () => { if (isFirstTime) { isFirstTime = false; logged = You pressed the button ${count} times!; } else { logged = You pressed the button ${count} times since last time!; } count = 0; }, () => debounceDuration ); function ding() { count++; logCount(); } DING DING DING Run now Cancel message {logged || \"Press the button!\"} `"},{"title":"useEventListener","href":"/docs/utilities/use-event-listener","description":"A function that attaches an automatically disposed event listener.","content":" import Demo from '$lib/components/demos/use-event-listener.svelte'; Demo Usage The useEventListener function is particularly useful for attaching event listeners to elements you don't directly control. For instance, if you need to listen for events on the document body or window and can't use ``, or if you receive an element reference from a parent component. Example: Tracking Clicks on the Document // ClickLogger.ts import { useEventListener } from \"runed\"; export class ClickLogger { #clicks = $state(0); constructor() { useEventListener( () => document.body, \"click\", () => this.#clicks++ ); } get clicks() { return this.#clicks; } } This ClickLogger class tracks the number of clicks on the document body using the useEventListener function. Each time a click occurs, the internal counter increments. Svelte Component Usage import { ClickLogger } from \"./ClickLogger.ts\"; const logger = new ClickLogger(); You've clicked the document {logger.clicks} {logger.clicks === 1 ? \"time\" : \"times\"} In the component above, we create an instance of the ClickLogger class to monitor clicks on the document. The displayed text updates dynamically based on the recorded click count. Key Points Automatic Cleanup:** The event listener is removed automatically when the component is destroyed or when the element reference changes. Lazy Initialization:** The target element can be defined using a function, enabling flexible and dynamic behavior. Convenient for Global Listeners:** Ideal for scenarios where attaching event listeners directly to the DOM elements is cumbersome or impractical."},{"title":"useGeolocation","href":"/docs/utilities/use-geolocation","description":"Reactive access to the browser's Geolocation API.","content":" import Demo from '$lib/components/demos/use-geolocation.svelte'; useGeolocation is a reactive wrapper around the browser's $2. Demo Usage import { useGeolocation } from \"runed\"; const location = useGeolocation(); Coords: {JSON.stringify(location.coords, null, 2)} Located at: {location.locatedAt} Error: {JSON.stringify(location.error, null, 2)} Is Supported: {location.isSupported} Pause Resume Type Definitions type UseGeolocationOptions = Partial & { /** Whether to start the watcher immediately upon creation. If set to false, the watcher will only start tracking the position when resume() is called. * @defaultValue true */ immediate?: boolean; }; type UseGeolocationReturn = { readonly isSupported: boolean; readonly coords: Omit; readonly locatedAt: number | null; readonly error: GeolocationPositionError | null; readonly isPaused: boolean; pause: () => void; resume: () => void; }; `"},{"title":"useIntersectionObserver","href":"/docs/utilities/use-intersection-observer","description":"Watch for intersection changes of a target element.","content":" import Demo from '$lib/components/demos/use-intersection-observer.svelte'; import { Callout } from '@svecodocs/kit' Demo Usage With a reference to an element, you can use the useIntersectionObserver utility to watch for intersection changes of the target element. import { useIntersectionObserver } from \"runed\"; let target = $state(null); let root = $state(null); let isIntersecting = $state(false); useIntersectionObserver( () => target, (entries) => { const entry = entries[0]; if (!entry) return; isIntersecting = entry.isIntersecting; }, { root: () => root } ); {#if isIntersecting} Target is intersecting {:else} Target is not intersecting {/if} Pause You can pause the intersection observer at any point by calling the pause method. const observer = useIntersectionObserver(/* ... */); observer.pause(); Resume You can resume the intersection observer at any point by calling the resume method. const observer = useIntersectionObserver(/* ... */); observer.resume(); Stop You can stop the intersection observer at any point by calling the stop method. const observer = useIntersectionObserver(/* ... */); observer.stop(); isActive You can check if the intersection observer is active by checking the isActive property. This property cannot be destructured as it is a getter. You must access it directly from the observer. const observer = useIntersectionObserver(/* ... */); if (observer.isActive) { // do something } `"},{"title":"useMutationObserver","href":"/docs/utilities/use-mutation-observer","description":"Observe changes in an element","content":" import Demo from '$lib/components/demos/use-mutation-observer.svelte'; Demo Usage With a reference to an element, you can use the useMutationObserver hook to observe changes in the element. import { useMutationObserver } from \"runed\"; let el = $state(null); const messages = $state([]); let className = $state(\"\"); let style = $state(\"\"); useMutationObserver( () => el, (mutations) => { const mutation = mutations[0]; if (!mutation) return; messages.push(mutation.attributeName!); }, { attributes: true } ); setTimeout(() => { className = \"text-brand\"; }, 1000); setTimeout(() => { style = \"font-style: italic;\"; }, 1500); {#each messages as text} Mutation Attribute: {text} {:else} No mutations yet {/each} You can stop the mutation observer at any point by calling the stop method. const { stop } = useMutationObserver(/* ... */); stop(); `"},{"title":"useResizeObserver","href":"/docs/utilities/use-resize-observer","description":"Detects changes in the size of an element","content":" import Demo from '$lib/components/demos/use-resize-observer.svelte'; Demo Usage With a reference to an element, you can use the useResizeObserver utility to detect changes in the size of an element. import { useResizeObserver } from \"runed\"; let el = $state(null); let text = $state(\"\"); useResizeObserver( () => el, (entries) => { const entry = entries[0]; if (!entry) return; const { width, height } = entry.contentRect; text = width: ${width};\\nheight: ${height};; } ); You can stop the resize observer at any point by calling the stop method. const { stop } = useResizeObserver(/* ... */); stop(); `"},{"title":"watch","href":"/docs/utilities/watch","description":"Watch for changes and run a callback","content":"Runes provide a handy way of running a callback when reactive values change: $2. It automatically detects when inner values change, and re-runs the callback. $effect is great, but sometimes you want to manually specify which values should trigger the callback. Svelte provides an untrack function, allowing you to specify that a dependency shouldn't be tracked, but it doesn't provide a way to say that only certain values should be tracked. watch does exactly that. It accepts a getter function, which returns the dependencies of the effect callback. Usage watch Runs a callback whenever one of the sources change. import { watch } from \"runed\"; let count = $state(0); watch(() => count, () => { console.log(count); } ); The callback receives two arguments: The current value of the sources, and the previous value. let count = $state(0); watch(() => count, (curr, prev) => { console.log(count is ${curr}, was ${prev}); } ); You can also send in an array of sources: let age = $state(20); let name = $state(\"bob\"); watch([() => age, () => name], ([age, name], [prevAge, prevName]) => { // ... } watch also accepts an options object. watch(sources, callback, { // First run will only happen after sources change when set to true. // By default, its false. lazy: true }); watch.pre watch.pre is similar to watch, but it uses $2 under the hood. watchOnce In case you want to run the callback only once, you can use watchOnce and watchOnce.pre. It functions identically to the watch and watch.pre otherwise, but it does not accept any options object."}] \ No newline at end of file +[{"title":"Getting Started","href":"/docs/getting-started","description":"Learn how to install and use Runed in your projects.","content":"Installation Install Runed using your favorite package manager: npm install runed Usage Import one of the utilities you need to either a .svelte or .svelte.js|ts file and start using it: import { activeElement } from \"runed\"; let inputElement = $state(); {#if activeElement.current === inputElement} The input element is active! {/if} or import { activeElement } from \"runed\"; function logActiveElement() { $effect(() => { console.log(\"Active element is \", activeElement.current); }); } logActiveElement(); `"},{"title":"Introduction","href":"/docs/index","description":"Runes are magic, but what good is magic if you don't have a wand?","content":"Runed is a collection of utilities for Svelte 5 that make composing powerful applications and libraries a breeze, leveraging the power of $2. Why Runed? Svelte 5 Runes unlock immense power by providing a set of primitives that allow us to build impressive applications and libraries with ease. However, building complex applications often requires more than just the primitives provided by Svelte Runes. Runed takes those primitives to the next level by providing: Powerful Utilities**: A set of carefully crafted utility functions and classes that simplify common tasks and reduce boilerplate. Collective Efforts**: We often find ourselves writing the same utility functions over and over again. Runed aims to provide a single source of truth for these utilities, allowing the community to contribute, test, and benefit from them. Consistency**: A consistent set of APIs and behaviors across all utilities, so you can focus on building your projects instead of constantly learning new APIs. Reactivity First**: Powered by Svelte 5's new reactivity system, Runed utilities are designed to handle reactive state and side effects with ease. Type Safety**: Full TypeScript support to catch errors early and provide a better developer experience. Ideas and Principles Embrace the Magic of Runes Svelte Runes are a powerful new paradigm. Runed fully embraces this concept and explores its potential. Our goal is to make working with Runes feel as natural and intuitive as possible. Enhance, Don't Replace Runed is not here to replace Svelte's core functionality, but to enhance and extend it. Our utilities should feel like a natural extension of Svelte, not a separate framework. Progressive Complexity Simple things should be simple, complex things should be possible. Runed provides easy-to-use defaults while allowing for advanced customization when needed. Open Source and Community Collaboration Runed is an open-source, MIT licensed project that welcomes all forms of contributions from the community. Whether it's bug reports, feature requests, or code contributions, your input will help make Runed the best it can be."},{"title":"activeElement","href":"/docs/utilities/active-element","description":"Track and access the currently focused DOM element","content":" import Demo from '$lib/components/demos/active-element.svelte'; activeElement provides reactive access to the currently focused DOM element in your application, similar to document.activeElement but with reactive updates. Updates synchronously with DOM focus changes Returns null when no element is focused Safe to use with SSR (Server-Side Rendering) Lightweight alternative to manual focus tracking If you need to provide a custom document / shadowRoot, you can use the $2 utility instead, which provides a more flexible API. Demo Usage import { activeElement } from \"runed\"; Currently active element: {activeElement.current?.localName ?? \"No active element found\"} Type Definition interface ActiveElement { readonly current: Element | null; } `"},{"title":"AnimationFrames","href":"/docs/utilities/animation-frames","description":"A wrapper for requestAnimationFrame with FPS control and frame metrics","content":" import Demo from '$lib/components/demos/animation-frames.svelte'; AnimationFrames provides a declarative API over the browser's $2, offering FPS limiting capabilities and frame metrics while handling cleanup automatically. Demo Usage import { AnimationFrames } from \"runed\"; import { Slider } from \"../ui/slider\"; // Check out shadcn-svelte! let frames = $state(0); let fpsLimit = $state(10); let delta = $state(0); const animation = new AnimationFrames( (args) => { frames++; delta = args.delta; }, { fpsLimit: () => fpsLimit } ); const stats = $derived( Frames: ${frames}\\nFPS: ${animation.fps.toFixed(0)}\\nDelta: ${delta.toFixed(0)}ms ); {stats} {animation.running ? \"Stop\" : \"Start\"} FPS limit: {fpsLimit}{fpsLimit === 0 ? \" (not limited)\" : \"\"} (fpsLimit = value[0] ?? 0)} min={0} max={144} /> `"},{"title":"Context","href":"/docs/utilities/context","description":"A wrapper around Svelte's Context API that provides type safety and improved ergonomics for sharing data between components.","content":" import { Steps, Step, Callout } from '@svecodocs/kit'; Context allows you to pass data through the component tree without explicitly passing props through every level. It's useful for sharing data that many components need, like themes, authentication state, or localization preferences. The Context class provides a type-safe way to define, set, and retrieve context values. Usage Creating a Context First, create a Context instance with the type of value it will hold: import { Context } from \"runed\"; export const myTheme = new Context(\"theme\"); Creating a Context instance only defines the context - it doesn't actually set any value. The value passed to the constructor (\"theme\" in this example) is just an identifier used for debugging and error messages. Think of this step as creating a \"container\" that will later hold your context value. The container is typed (in this case to only accept \"light\" or \"dark\" as values) but remains empty until you explicitly call myTheme.set() during component initialization. This separation between defining and setting context allows you to: Keep context definitions in separate files Reuse the same context definition across different parts of your app Maintain type safety throughout your application Set different values for the same context in different component trees Setting Context Values Set the context value in a parent component during initialization. import { myTheme } from \"./context\"; let { data, children } = $props(); myTheme.set(data.theme); {@render children?.()} Context must be set during component initialization, similar to lifecycle functions like onMount. You cannot set context inside event handlers or callbacks. Reading Context Values Child components can access the context using get() or getOr() import { myTheme } from \"./context\"; const theme = myTheme.get(); // or with a fallback value if the context is not set const theme = myTheme.getOr(\"light\"); Type Definition class Context { /** @param name The name of the context. This is used for generating the context key and error messages. */ constructor(name: string) {} /** The key used to get and set the context. * It is not recommended to use this value directly. Instead, use the methods provided by this class. */ get key(): symbol; /** Checks whether this has been set in the context of a parent component. * Must be called during component initialization. */ exists(): boolean; /** Retrieves the context that belongs to the closest parent component. * Must be called during component initialization. * @throws An error if the context does not exist. */ get(): TContext; /** Retrieves the context that belongs to the closest parent component, or the given fallback value if the context does not exist. * Must be called during component initialization. */ getOr(fallback: TFallback): TContext | TFallback; /** Associates the given value with the current component and returns it. * Must be called during component initialization. */ set(context: TContext): TContext; } `"},{"title":"Debounced","href":"/docs/utilities/debounced","description":"A wrapper over `useDebounce` that returns a debounced state.","content":" import Demo from '$lib/components/demos/debounced.svelte'; Demo Usage This is a simple wrapper over $2 that returns a debounced state. import { Debounced } from \"runed\"; let search = $state(\"\"); const debounced = new Debounced(() => search, 500); You searched for: {debounced.current} You may cancel the pending update, run it immediately, or set a new value. Setting a new value immediately also cancels any pending updates. let count = $state(0); const debounced = new Debounced(() => count, 500); count = 1; debounced.cancel(); // after a while... console.log(debounced.current); // Still 0! count = 2; console.log(debounced.current); // Still 0! debounced.setImmediately(count); console.log(debounced.current); // 2 count = 3; console.log(debounced.current); // 2 await debounced.updateImmediately(); console.log(debounced.current); // 3 `"},{"title":"ElementRect","href":"/docs/utilities/element-rect","description":"Track element dimensions and position reactively","content":" import Demo from '$lib/components/demos/element-rect.svelte'; ElementRect provides reactive access to an element's dimensions and position information, automatically updating when the element's size or position changes. Demo Usage import { ElementRect } from \"runed\"; let el = $state(); const rect = new ElementRect(() => el); Width: {rect.width} Height: {rect.height} {JSON.stringify(rect.current, null, 2)} Type Definition type Rect = Omit; interface ElementRectOptions { initialRect?: DOMRect; } class ElementRect { constructor(node: MaybeGetter, options?: ElementRectOptions); readonly current: Rect; readonly width: number; readonly height: number; readonly top: number; readonly left: number; readonly right: number; readonly bottom: number; readonly x: number; readonly y: number; } `"},{"title":"ElementSize","href":"/docs/utilities/element-size","description":"Track element dimensions reactively","content":" import Demo from '$lib/components/demos/element-size.svelte'; ElementSize provides reactive access to an element's width and height, automatically updating when the element's dimensions change. Similar to ElementRect but focused only on size measurements. Demo Usage import { ElementSize } from \"runed\"; let el = $state() as HTMLElement; const size = new ElementSize(() => el); Width: {size.width} Height: {size.height} Type Definition interface ElementSize { readonly width: number; readonly height: number; } `"},{"title":"FiniteStateMachine","href":"/docs/utilities/finite-state-machine","description":"Defines a strongly-typed finite state machine.","content":" import Demo from '$lib/components/demos/finite-state-machine.svelte'; Demo type MyStates = \"disabled\" | \"idle\" | \"running\"; type MyEvents = \"toggleEnabled\" | \"start\" | \"stop\"; const f = new FiniteStateMachine(\"disabled\", { disabled: { toggleEnabled: \"idle\" }, idle: { toggleEnabled: \"disabled\", start: \"running\" }, running: { _enter: () => { f.debounce(2000, \"stop\"); }, stop: \"idle\", toggleEnabled: \"disabled\" } }); Usage Finite state machines (often abbreviated as \"FSMs\") are useful for tracking and manipulating something that could be in one of many different states. It centralizes the definition of every possible state and the events that might trigger a transition from one state to another. Here is a state machine describing a simple toggle switch: import { FiniteStateMachine } from \"runed\"; type MyStates = \"on\" | \"off\"; type MyEvents = \"toggle\"; const f = new FiniteStateMachine(\"off\", { off: { toggle: \"on\" }, on: { toggle: \"off\" } }); The first argument to the FiniteStateMachine constructor is the initial state. The second argument is an object with one key for each state. Each state then describes which events are valid for that state, and which state that event should lead to. In the above example of a simple switch, there are two states (on and off). The toggle event in either state leads to the other state. You send events to the FSM using f.send. To send the toggle event, invoke f.send('toggle'). Actions Maybe you want fancier logic for an event handler, or you want to conditionally transition into another state. Instead of strings, you can use actions. An action is a function that returns a state. An action can receive parameters, and it can use those parameters to dynamically choose which state should come next. It can also prevent a state transition by returning nothing. type MyStates = \"on\" | \"off\" | \"cooldown\"; const f = new FiniteStateMachine(\"off\", { off: { toggle: () => { if (isTuesday) { // Switch can only turn on during Tuesdays return \"on\"; } // All other days, nothing is returned and state is unchanged. } }, on: { toggle: (heldMillis: number) => { // You can also dynamically return the next state! // Only turn off if switch is depressed for 3 seconds if (heldMillis > 3000) { return \"off\"; } } } }); Lifecycle methods You can define special handlers that are invoked whenever a state is entered or exited: const f = new FiniteStateMachine('off', { off: { toggle: 'on' _enter: (meta) => { console.log('switch is off') } _exit: (meta) => { console.log('switch is no longer off') } }, on: { toggle: 'off' _enter: (meta) => { console.log('switch is on') } _exit: (meta) => { console.log('switch is no longer on') } } }); The lifecycle methods are invoked with a metadata object containing some useful information: from: the name of the event that is being exited to: the name of the event that is being entered event: the name of the event which has triggered the transition args: (optional) you may pass additional metadata when invoking an action with f.send('theAction', additional, params, as, args) The _enter handler for the initial state is called upon creation of the FSM. It is invoked with both the from and event fields set to null. Wildcard handlers There is one special state used as a fallback: *. If you have the fallback state, and you attempt to send() an event that is not handled by the current state, then it will try to find a handler for that event on the * state before discarding the event: const f = new FiniteStateMachine('off', { off: { toggle: 'on' }, on: { toggle: 'off' } '*': { emergency: 'off' } }); // will always result in the switch turning off. f.send('emergency'); Debouncing Frequently, you want to transition to another state after some time has elapsed. To do this, use the debounce method: f.send(\"toggle\"); // turn on immediately f.debounce(5000, \"toggle\"); // turn off in 5000 milliseconds If you re-invoke debounce with the same event, it will cancel the existing timer and start the countdown over: // schedule a toggle in five seconds f.debounce(5000, \"toggle\"); // ... less than 5000ms elapses ... f.debounce(5000, \"toggle\"); // The second call cancels the original timer, and starts a new one You can also use debounce in both actions and lifecycle methods. In both of the following examples, the lightswitch will turn itself off five seconds after it was turned on: const f = new FiniteStateMachine(\"off\", { off: { toggle: () => { f.debounce(5000, \"toggle\"); return \"on\"; } }, on: { toggle: \"off\" } }); const f = new FiniteStateMachine(\"off\", { off: { toggle: \"on\" }, on: { toggle: \"off\", _enter: () => { f.debounce(5000, \"toggle\"); } } }); Notes FiniteStateMachine is a loving rewrite of $2. FSMs are ideal for representing many different kinds of systems and interaction patterns. FiniteStateMachine is an intentionally minimalistic implementation. If you're looking for a more powerful FSM library, $2 is an excellent library with more features — and a steeper learning curve."},{"title":"IsFocusWithin","href":"/docs/utilities/is-focus-within","description":"A utility that tracks whether any descendant element has focus within a specified container element.","content":" import Demo from '$lib/components/demos/is-focus-within.svelte'; IsFocusWithin reactively tracks focus state within a container element, updating automatically when focus changes. Demo Usage import { IsFocusWithin } from \"runed\"; let formElement = $state(); const focusWithinForm = new IsFocusWithin(() => formElement); Focus within form: {focusWithinForm.current} Submit Type Definition class IsFocusWithin { constructor(node: MaybeGetter); readonly current: boolean; } `"},{"title":"IsIdle","href":"/docs/utilities/is-idle","description":"Track if a user is idle and the last time they were active.","content":" import Demo from '$lib/components/demos/is-idle.svelte'; IsIdle tracks user activity and determines if they're idle based on a configurable timeout. It monitors mouse movement, keyboard input, and touch events to detect user interaction. Demo Usage import { AnimationFrames, IsIdle } from \"runed\"; const idle = new IsIdle({ timeout: 1000 }); Idle: {idle.current} Last active: {new Date(idle.lastActive).toLocaleTimeString()} Type Definitions interface IsIdleOptions { /** The events that should set the idle state to true * @default ['mousemove', 'mousedown', 'resize', 'keydown', 'touchstart', 'wheel'] */ events?: MaybeGetter; /** The timeout in milliseconds before the idle state is set to true. Defaults to 60 seconds. * @default 60000 */ timeout?: MaybeGetter; /** Detect document visibility changes * @default true */ detectVisibilityChanges?: MaybeGetter; /** The initial state of the idle property * @default false */ initialState?: boolean; } class IsIdle { constructor(options?: IsIdleOptions); readonly current: boolean; readonly lastActive: number; } `"},{"title":"IsInViewport","href":"/docs/utilities/is-in-viewport","description":"Track if an element is visible within the current viewport.","content":" import Demo from '$lib/components/demos/is-in-viewport.svelte'; IsInViewport uses the $2 utility to track if an element is visible within the current viewport. It accepts an element or getter that returns an element and an optional options object that aligns with the $2 utility options. Demo Usage import { IsInViewport } from \"runed\"; let targetNode = $state()!; const inViewport = new IsInViewport(() => targetNode); Target node Target node in viewport: {inViewport.current} Type Definition import { type UseIntersectionObserverOptions } from \"runed\"; export type IsInViewportOptions = UseIntersectionObserverOptions; export declare class IsInViewport { constructor(node: MaybeGetter, options?: IsInViewportOptions); get current(): boolean; } "},{"title":"IsMounted","href":"/docs/utilities/is-mounted","description":"A class that returns the mounted state of the component it's called in.","content":" import Demo from '$lib/components/demos/is-mounted.svelte'; Demo Usage import { IsMounted } from \"runed\"; const isMounted = new IsMounted(); Which is a shorthand for one of the following: import { onMount } from \"svelte\"; const isMounted = $state({ current: false }); onMount(() => { isMounted.current = true; }); or import { untrack } from \"svelte\"; const isMounted = $state({ current: false }); $effect(() => { untrack(() => (isMounted.current = true)); }); `"},{"title":"IsSupported","href":"/docs/utilities/is-supported","description":"Determine if a feature is supported by the environment before using it.","content":"Usage import { IsSupported } from \"runed\"; const isSupported = new IsSupported(() => navigator && \"geolocation\" in navigator); if (isSupported.current) { // Do something with the geolocation API } Type Definition class IsSupported { readonly current: boolean; } `"},{"title":"onClickOutside","href":"/docs/utilities/on-click-outside","description":"Call a function when a user clicks outside of a container.","content":" import Demo from '$lib/components/demos/on-click-outside.svelte'; Demo Usage import { onClickOutside } from \"runed\"; let el = $state(undefined); onClickOutside( () => el, () => { console.log(\"clicked outside of container\"); } ); Container Click Me You can also programmatically pause and resume onClickOutside using the start and stop functions returned by onClickOutside. import { onClickOutside } from \"runed\"; let el = $state(undefined); const outsideClick = onClickOutside( () => el, () => { console.log(\"clicked outside of container\"); } ); Stop listening for outside clicks Start listening again `"},{"title":"PersistedState","href":"/docs/utilities/persisted-state","description":"A reactive state manager that persists and synchronizes state across browser sessions and tabs using Web Storage APIs.","content":" import Demo from '$lib/components/demos/persisted-state.svelte'; import { Callout } from '@svecodocs/kit' PersistedState provides a reactive state container that automatically persists data to browser storage and optionally synchronizes changes across browser tabs in real-time. Demo You can refresh this page and/or open it in another tab to see the count state being persisted and synchronized across sessions and tabs. Usage Initialize PersistedState by providing a unique key and an initial value for the state. import { PersistedState } from \"runed\"; const count = new PersistedState(\"count\", 0); count.current++}>Increment count.current--}>Decrement (count.current = 0)}>Reset Count: {count.current} Configuration Options PersistedState includes an options object that allows you to customize the behavior of the state manager. const state = new PersistedState(\"user-preferences\", initialValue, { // Use sessionStorage instead of localStorage (default: 'local') storage: \"session\", // Disable cross-tab synchronization (default: true) syncTabs: false, // Custom serialization handlers serializer: { serialize: superjson.stringify, deserialize: superjson.parse } }); Storage Options 'local': Data persists until explicitly cleared 'session': Data persists until the browser session ends Cross-Tab Synchronization When syncTabs is enabled (default), changes are automatically synchronized across all browser tabs using the storage event. Custom Serialization Provide custom serialize and deserialize functions to handle complex data types: import superjson from \"superjson\"; // Example with Date objects const lastAccessed = new PersistedState(\"last-accessed\", new Date(), { serializer: { serialize: superjson.stringify, deserialize: superjson.parse } }); `"},{"title":"PressedKeys","href":"/docs/utilities/pressed-keys","description":"Tracks which keys are currently pressed","content":" import Demo from '$lib/components/demos/pressed-keys.svelte'; Demo Usage With an instance of PressedKeys, you can use the has method. const keys = new PressedKeys(); const isArrowDownPressed = $derived(keys.has(\"ArrowDown\")); const isCtrlAPressed = $derived(keys.has(\"Control\", \"a\")); Or get all of the currently pressed keys: const keys = new PressedKeys(); console.log(keys.all()); `"},{"title":"Previous","href":"/docs/utilities/previous","description":"A utility that tracks and provides access to the previous value of a reactive getter.","content":" import Demo from '$lib/components/demos/previous.svelte'; The Previous utility creates a reactive wrapper that maintains the previous value of a getter function. This is particularly useful when you need to compare state changes or implement transition effects. Demo Usage import { Previous } from \"runed\"; let count = $state(0); const previous = new Previous(() => count); count++}>Count: {count} Previous: {${previous.current}} Type Definition class Previous { constructor(getter: () => T); readonly current: T; // Previous value } `"},{"title":"StateHistory","href":"/docs/utilities/state-history","description":"Track state changes with undo/redo capabilities","content":" import Demo from '$lib/components/demos/state-history.svelte'; Demo Usage StateHistory tracks a getter's return value, logging each change into an array. A setter is also required to use the undo and redo functions. import { StateHistory } from \"runed\"; let count = $state(0); const history = new StateHistory(() => count, (c) => (count = c)); history.log[0]; // { snapshot: 0, timestamp: ... } Besides log, the returned object contains undo and redo functionality. import { useStateHistory } from \"runed\"; let count = $state(0); const history = new StateHistory(() => count, (c) => (count = c)); function format(ts: number) { return new Date(ts).toLocaleString(); } {count} count++}>Increment count--}>Decrement Undo Redo `"},{"title":"useActiveElement","href":"/docs/utilities/use-active-element","description":"Get a reactive reference to the currently focused element in the document.","content":" import Demo from '$lib/components/demos/use-active-element.svelte'; import { PropField } from '@svecodocs/kit' useActiveElement is used to get the currently focused element in the document. If you don't need to provide a custom document / shadowRoot, you can use the $2 state instead, as it provides a simpler API. This utility behaves similarly to document.activeElement but with additional features such as: Updates synchronously with DOM focus changes Returns null when no element is focused Safe to use with SSR (Server-Side Rendering) Lightweight alternative to manual focus tracking Demo Usage import { useActiveElement } from \"runed\"; const activeElement = useActiveElement(); {#if activeElement.current} The active element is: {activeElement.current.localName} {:else} No active element found {/if} Options The following options can be passed via the first argument to useActiveElement: The document or shadow root to track focus within. The window to use for focus tracking. "},{"title":"useDebounce","href":"/docs/utilities/use-debounce","description":"A higher-order function that debounces the execution of a function.","content":" import Demo from '$lib/components/demos/use-debounce.svelte'; useDebounce is a utility function that creates a debounced version of a callback function. Debouncing prevents a function from being called too frequently by delaying its execution until after a specified duration of inactivity. Demo Usage import { useDebounce } from \"runed\"; let count = $state(0); let logged = $state(\"\"); let isFirstTime = $state(true); let debounceDuration = $state(1000); const logCount = useDebounce( () => { if (isFirstTime) { isFirstTime = false; logged = You pressed the button ${count} times!; } else { logged = You pressed the button ${count} times since last time!; } count = 0; }, () => debounceDuration ); function ding() { count++; logCount(); } DING DING DING Run now Cancel message {logged || \"Press the button!\"} `"},{"title":"useEventListener","href":"/docs/utilities/use-event-listener","description":"A function that attaches an automatically disposed event listener.","content":" import Demo from '$lib/components/demos/use-event-listener.svelte'; Demo Usage The useEventListener function is particularly useful for attaching event listeners to elements you don't directly control. For instance, if you need to listen for events on the document body or window and can't use ``, or if you receive an element reference from a parent component. Example: Tracking Clicks on the Document // ClickLogger.ts import { useEventListener } from \"runed\"; export class ClickLogger { #clicks = $state(0); constructor() { useEventListener( () => document.body, \"click\", () => this.#clicks++ ); } get clicks() { return this.#clicks; } } This ClickLogger class tracks the number of clicks on the document body using the useEventListener function. Each time a click occurs, the internal counter increments. Svelte Component Usage import { ClickLogger } from \"./ClickLogger.ts\"; const logger = new ClickLogger(); You've clicked the document {logger.clicks} {logger.clicks === 1 ? \"time\" : \"times\"} In the component above, we create an instance of the ClickLogger class to monitor clicks on the document. The displayed text updates dynamically based on the recorded click count. Key Points Automatic Cleanup:** The event listener is removed automatically when the component is destroyed or when the element reference changes. Lazy Initialization:** The target element can be defined using a function, enabling flexible and dynamic behavior. Convenient for Global Listeners:** Ideal for scenarios where attaching event listeners directly to the DOM elements is cumbersome or impractical."},{"title":"useGeolocation","href":"/docs/utilities/use-geolocation","description":"Reactive access to the browser's Geolocation API.","content":" import Demo from '$lib/components/demos/use-geolocation.svelte'; useGeolocation is a reactive wrapper around the browser's $2. Demo Usage import { useGeolocation } from \"runed\"; const location = useGeolocation(); Coords: {JSON.stringify(location.position.coords, null, 2)} Located at: {location.position.timestamp} Error: {JSON.stringify(location.error, null, 2)} Is Supported: {location.isSupported} Pause Resume Type Definitions type UseGeolocationOptions = Partial & { /** Whether to start the watcher immediately upon creation. If set to false, the watcher will only start tracking the position when resume() is called. * @defaultValue true */ immediate?: boolean; }; type UseGeolocationReturn = { readonly isSupported: boolean; readonly position: Omit; readonly error: GeolocationPositionError | null; readonly isPaused: boolean; pause: () => void; resume: () => void; }; `"},{"title":"useIntersectionObserver","href":"/docs/utilities/use-intersection-observer","description":"Watch for intersection changes of a target element.","content":" import Demo from '$lib/components/demos/use-intersection-observer.svelte'; import { Callout } from '@svecodocs/kit' Demo Usage With a reference to an element, you can use the useIntersectionObserver utility to watch for intersection changes of the target element. import { useIntersectionObserver } from \"runed\"; let target = $state(null); let root = $state(null); let isIntersecting = $state(false); useIntersectionObserver( () => target, (entries) => { const entry = entries[0]; if (!entry) return; isIntersecting = entry.isIntersecting; }, { root: () => root } ); {#if isIntersecting} Target is intersecting {:else} Target is not intersecting {/if} Pause You can pause the intersection observer at any point by calling the pause method. const observer = useIntersectionObserver(/* ... */); observer.pause(); Resume You can resume the intersection observer at any point by calling the resume method. const observer = useIntersectionObserver(/* ... */); observer.resume(); Stop You can stop the intersection observer at any point by calling the stop method. const observer = useIntersectionObserver(/* ... */); observer.stop(); isActive You can check if the intersection observer is active by checking the isActive property. This property cannot be destructured as it is a getter. You must access it directly from the observer. const observer = useIntersectionObserver(/* ... */); if (observer.isActive) { // do something } `"},{"title":"useMutationObserver","href":"/docs/utilities/use-mutation-observer","description":"Observe changes in an element","content":" import Demo from '$lib/components/demos/use-mutation-observer.svelte'; Demo Usage With a reference to an element, you can use the useMutationObserver hook to observe changes in the element. import { useMutationObserver } from \"runed\"; let el = $state(null); const messages = $state([]); let className = $state(\"\"); let style = $state(\"\"); useMutationObserver( () => el, (mutations) => { const mutation = mutations[0]; if (!mutation) return; messages.push(mutation.attributeName!); }, { attributes: true } ); setTimeout(() => { className = \"text-brand\"; }, 1000); setTimeout(() => { style = \"font-style: italic;\"; }, 1500); {#each messages as text} Mutation Attribute: {text} {:else} No mutations yet {/each} You can stop the mutation observer at any point by calling the stop method. const { stop } = useMutationObserver(/* ... */); stop(); `"},{"title":"useResizeObserver","href":"/docs/utilities/use-resize-observer","description":"Detects changes in the size of an element","content":" import Demo from '$lib/components/demos/use-resize-observer.svelte'; Demo Usage With a reference to an element, you can use the useResizeObserver utility to detect changes in the size of an element. import { useResizeObserver } from \"runed\"; let el = $state(null); let text = $state(\"\"); useResizeObserver( () => el, (entries) => { const entry = entries[0]; if (!entry) return; const { width, height } = entry.contentRect; text = width: ${width};\\nheight: ${height};; } ); You can stop the resize observer at any point by calling the stop method. const { stop } = useResizeObserver(/* ... */); stop(); `"},{"title":"watch","href":"/docs/utilities/watch","description":"Watch for changes and run a callback","content":"Runes provide a handy way of running a callback when reactive values change: $2. It automatically detects when inner values change, and re-runs the callback. $effect is great, but sometimes you want to manually specify which values should trigger the callback. Svelte provides an untrack function, allowing you to specify that a dependency shouldn't be tracked, but it doesn't provide a way to say that only certain values should be tracked. watch does exactly that. It accepts a getter function, which returns the dependencies of the effect callback. Usage watch Runs a callback whenever one of the sources change. import { watch } from \"runed\"; let count = $state(0); watch(() => count, () => { console.log(count); } ); The callback receives two arguments: The current value of the sources, and the previous value. let count = $state(0); watch(() => count, (curr, prev) => { console.log(count is ${curr}, was ${prev}); } ); You can also send in an array of sources: let age = $state(20); let name = $state(\"bob\"); watch([() => age, () => name], ([age, name], [prevAge, prevName]) => { // ... } watch also accepts an options object. watch(sources, callback, { // First run will only happen after sources change when set to true. // By default, its false. lazy: true }); watch.pre watch.pre is similar to watch, but it uses $2 under the hood. watchOnce In case you want to run the callback only once, you can use watchOnce and watchOnce.pre. It functions identically to the watch and watch.pre otherwise, but it does not accept any options object."}] \ No newline at end of file From 223b10b030502f43862e7d5b746560934ec5b81b Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Fri, 20 Dec 2024 21:23:58 -0500 Subject: [PATCH 07/18] more --- .../lib/utilities/onClickOutside/onClickOutside.svelte.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.svelte.ts b/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.svelte.ts index 9eeaf4d2..0cef4e6f 100644 --- a/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.svelte.ts +++ b/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.svelte.ts @@ -45,6 +45,7 @@ export function onClickOutside( opts: OnClickOutsideOptions = {} ): void { const { document = defaultDocument } = opts; + const node = $derived(extract(container)); /** * WIP - need to handle cases where a pointerdown starts in the container @@ -58,9 +59,7 @@ export function onClickOutside( */ function handleClick(e: MouseEvent) { - if (!e.target) return; - const node = extract(container); - if (!node) return; + if (!e.target || !node) return; const rect = node.getBoundingClientRect(); const wasInsideClick = From 9891cda6c46805ba46af325ded736286aa7572f4 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 21 Dec 2024 12:29:52 -0500 Subject: [PATCH 08/18] click outside work --- packages/runed/src/lib/internal/utils/dom.ts | 26 +++ packages/runed/src/lib/internal/utils/is.ts | 4 + .../onClickOutside/onClickOutside.svelte.ts | 164 +++++++++++++++--- .../onClickOutside.test.svelte.ts | 151 ++++++++++++---- .../src/content/utilities/on-click-outside.md | 20 ++- .../components/demos/on-click-outside.svelte | 17 +- .../src/routes/api/search.json/search.json | 2 +- 7 files changed, 322 insertions(+), 62 deletions(-) diff --git a/packages/runed/src/lib/internal/utils/dom.ts b/packages/runed/src/lib/internal/utils/dom.ts index 442e16df..130b09e9 100644 --- a/packages/runed/src/lib/internal/utils/dom.ts +++ b/packages/runed/src/lib/internal/utils/dom.ts @@ -1,3 +1,5 @@ +import { defaultDocument } from "../configurable-globals.js"; + /** * Handles getting the active element in a document or shadow root. * If the active element is within a shadow root, it will traverse the shadow root @@ -18,3 +20,27 @@ export function getActiveElement(document: DocumentOrShadowRoot): HTMLElement | return activeElement; } + +/** + * Returns the owner document of a given element. + * + * @param node The element to get the owner document from. + * @returns + */ +export function getOwnerDocument( + node: Element | null | undefined, + fallback = defaultDocument +): Document | undefined { + return node?.ownerDocument ?? fallback; +} + +/** + * Checks if an element is or is contained by another element. + * + * @param node The element to check if it or its descendants contain the target element. + * @param target The element to check if it is contained by the node. + * @returns + */ +export function isOrContainsTarget(node: Element, target: Element) { + return node === target || node.contains(target); +} diff --git a/packages/runed/src/lib/internal/utils/is.ts b/packages/runed/src/lib/internal/utils/is.ts index 3adbbf09..11e6d965 100644 --- a/packages/runed/src/lib/internal/utils/is.ts +++ b/packages/runed/src/lib/internal/utils/is.ts @@ -5,3 +5,7 @@ export function isFunction(value: unknown): value is (...args: unknown[]) => unk export function isObject(value: unknown): value is Record { return value !== null && typeof value === "object"; } + +export function isElement(value: unknown): value is Element { + return value instanceof Element; +} diff --git a/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.svelte.ts b/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.svelte.ts index 0cef4e6f..24cfe3fb 100644 --- a/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.svelte.ts +++ b/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.svelte.ts @@ -1,14 +1,22 @@ import { defaultDocument, type ConfigurableDocument } from "$lib/internal/configurable-globals.js"; -import type { MaybeElement, MaybeElementGetter, MaybeGetter } from "$lib/internal/types.js"; +import type { MaybeElementGetter } from "$lib/internal/types.js"; +import { getOwnerDocument, isOrContainsTarget } from "$lib/internal/utils/dom.js"; +import { addEventListener } from "$lib/internal/utils/event.js"; +import { noop } from "$lib/internal/utils/function.js"; +import { isElement } from "$lib/internal/utils/is.js"; import { extract } from "../extract/extract.svelte.js"; -import { useEventListener } from "../useEventListener/useEventListener.svelte.js"; +import { useDebounce } from "../useDebounce/useDebounce.svelte.js"; +import { watch } from "../watch/watch.svelte.js"; export type OnClickOutsideOptions = ConfigurableDocument & { /** - * A list of elements and/or selectors to ignore when determining if a click - * event occurred outside of the container. + * Whether the click outside handler is enabled by default or not. + * If set to false, the handler will not be active until enabled by + * calling the returned `start` function + * + * @default true */ - ignore?: MaybeGetter>; + immediate?: boolean; }; /** @@ -41,35 +49,145 @@ export type OnClickOutsideOptions = ConfigurableDocument & { */ export function onClickOutside( container: MaybeElementGetter, - callback: () => void, + callback: (event: PointerEvent) => void, opts: OnClickOutsideOptions = {} -): void { - const { document = defaultDocument } = opts; +) { + const { document = defaultDocument, immediate = true } = opts; const node = $derived(extract(container)); + const nodeOwnerDocument = $derived(getOwnerDocument(node, document)); + + let enabled = $state(immediate); + let pointerDownIntercepted = false; + let removeClickListener = noop; + let removePointerListeners = noop; + + const handleClickOutside = useDebounce((e: PointerEvent) => { + if (!node || !nodeOwnerDocument) { + removeClickListener(); + return; + } + + if (pointerDownIntercepted === true || !isValidEvent(e, node)) { + removeClickListener(); + return; + } + + if (e.pointerType === "touch") { + /** + * If the pointer type is touch, we add a listener to wait for the click + * event that will follow the pointerdown event if the user interacts in a way + * that would trigger a click event. + * + * This prevents us from prematurely calling the callback if the user is simply + * scrolling or dragging the page. + */ + removeClickListener(); + removeClickListener = addEventListener(nodeOwnerDocument, "click", () => callback(e), { + once: true, + }); + } else { + /** + * I + */ + callback(e); + } + }, 10); + + function addPointerDownListeners() { + if (!nodeOwnerDocument) return noop; + const events = [ + /** + * CAPTURE INTERACTION START + * mark the pointerdown event as intercepted + */ + addEventListener( + nodeOwnerDocument, + "pointerdown", + (e) => { + if (!node) return; + if (isValidEvent(e, node)) { + pointerDownIntercepted = true; + } + }, + true + ), + /** + * BUBBLE INTERACTION START + * Mark the pointerdown event as non-intercepted. Debounce `handleClickOutside` to + * avoid prematurely checking if other events were intercepted. + */ + addEventListener(nodeOwnerDocument, "pointerdown", (e) => { + pointerDownIntercepted = false; + handleClickOutside(e); + }), + ]; + return () => { + for (const event of events) { + event(); + } + }; + } + + function cleanup() { + pointerDownIntercepted = false; + handleClickOutside.cancel(); + removeClickListener(); + removePointerListeners(); + } + + watch([() => enabled, () => node], ([enabled$, node$]) => { + if (enabled$ && node$) { + removePointerListeners(); + removePointerListeners = addPointerDownListeners(); + } else { + cleanup(); + } + }); + + $effect(() => { + return () => { + cleanup(); + }; + }); /** - * WIP - need to handle cases where a pointerdown starts in the container - * but is released outside the container. This would result in a click event - * occurring outside the container, but we shouldn't trigger the callback - * unless the _complete_ click event occurred outside. - * - * Additionally, we should _really_ only doing the rect comparison if the event target - * is the same as the container or a descendant of the container which should cover the - * cases of pseudo elements being clicked. + * Stop listening for click events outside the container. */ + const stop = () => (enabled = false); - function handleClick(e: MouseEvent) { - if (!e.target || !node) return; + /** + * Start listening for click events outside the container. + */ + const start = () => (enabled = true); - const rect = node.getBoundingClientRect(); + return { + stop, + start, + /** + * Whether the click outside handler is currently enabled or not. + */ + get enabled() { + return enabled; + }, + }; +} + +function isValidEvent(e: PointerEvent, container: Element): boolean { + if ("button" in e && e.button > 0) return false; + const target = e.target; + if (!isElement(target)) return false; + const ownerDocument = getOwnerDocument(target); + if (!ownerDocument) return false; + // handle the case where a user may have pressed a pseudo element by + // checking the bounding rect of the container + if (target === container) { + const rect = container.getBoundingClientRect(); const wasInsideClick = rect.top <= e.clientY && e.clientY <= rect.top + rect.height && rect.left <= e.clientX && e.clientX <= rect.left + rect.width; - - if (!wasInsideClick) callback(); + if (wasInsideClick) return false; } - - useEventListener(() => document, "click", handleClick); + return ownerDocument.documentElement.contains(target) && !isOrContainsTarget(container, target); } diff --git a/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.test.svelte.ts b/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.test.svelte.ts index 61810575..3901ce51 100644 --- a/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.test.svelte.ts +++ b/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.test.svelte.ts @@ -9,7 +9,27 @@ describe("onClickOutside", () => { let outsideButton: HTMLButtonElement; let callbackFn: ReturnType; + class MockPointerEvent extends Event { + clientX: number; + clientY: number; + pointerType: string; + button: number; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(type: string, options: any = {}) { + super(type, { bubbles: true, ...options }); + this.clientX = options.clientX ?? 0; + this.clientY = options.clientY ?? 0; + this.pointerType = options.pointerType ?? "mouse"; + this.button = options.button ?? 0; + } + } + + const PointerEvent = globalThis.PointerEvent ?? MockPointerEvent; + beforeEach(() => { + vi.useFakeTimers(); + container = document.createElement("div"); innerButton = document.createElement("button"); outsideButton = document.createElement("button"); @@ -18,7 +38,6 @@ describe("onClickOutside", () => { document.body.appendChild(outsideButton); container.appendChild(innerButton); - // we need to mock getBoundingClientRect container.getBoundingClientRect = vi.fn(() => ({ height: 100, width: 100, @@ -38,74 +57,140 @@ describe("onClickOutside", () => { document.body.removeChild(container); document.body.removeChild(outsideButton); vi.clearAllMocks(); + vi.useRealTimers(); }); - const createMouseEvent = (x: number, y: number) => - new MouseEvent("click", { + const createPointerEvent = (type: string, options: Partial = {}) => { + return new PointerEvent(type, { bubbles: true, - clientX: x, - clientY: y, + cancelable: true, + clientX: 10, + clientY: 10, + pointerType: "mouse", + button: 0, + ...options, }); + }; - testWithEffect("calls callback on click outside container", async () => { + const advanceTimers = async () => { + await vi.advanceTimersByTimeAsync(10); // Match the debounce time + await tick(); + }; + + testWithEffect("starts enabled by default", async () => { onClickOutside(() => container, callbackFn); await tick(); - outsideButton.dispatchEvent(createMouseEvent(10, 10)); + outsideButton.dispatchEvent(createPointerEvent("pointerdown")); + await advanceTimers(); + expect(callbackFn).toHaveBeenCalledOnce(); }); - testWithEffect("doesn't call callback on click inside container", async () => { - onClickOutside(() => container, callbackFn); + testWithEffect("respects `immediate` option", async () => { + const controls = onClickOutside(() => container, callbackFn, { immediate: false }); await tick(); - innerButton.dispatchEvent(createMouseEvent(75, 75)); + outsideButton.dispatchEvent(createPointerEvent("pointerdown")); + await advanceTimers(); expect(callbackFn).not.toHaveBeenCalled(); - container.dispatchEvent(createMouseEvent(60, 60)); - expect(callbackFn).not.toHaveBeenCalled(); + controls.start(); + await tick(); + + outsideButton.dispatchEvent(createPointerEvent("pointerdown")); + await advanceTimers(); + expect(callbackFn).toHaveBeenCalledOnce(); }); - testWithEffect("handles edge cases of container boundaries", async () => { + testWithEffect("handles touch events with click confirmation", async () => { onClickOutside(() => container, callbackFn); await tick(); - // Click exactly on boundaries - outsideButton.dispatchEvent(createMouseEvent(50, 50)); // Top-left corner - expect(callbackFn).not.toHaveBeenCalled(); + outsideButton.dispatchEvent(createPointerEvent("pointerdown", { pointerType: "touch" })); + await advanceTimers(); - outsideButton.dispatchEvent(createMouseEvent(150, 150)); // Bottom-right corner + // Without the click event, callback shouldn't be called yet expect(callbackFn).not.toHaveBeenCalled(); - // Click just outside boundaries - outsideButton.dispatchEvent(createMouseEvent(49, 50)); + outsideButton.dispatchEvent(new Event("click", { bubbles: true })); + await advanceTimers(); + + expect(callbackFn).toHaveBeenCalledOnce(); + }); + + testWithEffect("debounces rapid pointer events", async () => { + onClickOutside(() => container, callbackFn); + await tick(); + + outsideButton.dispatchEvent(createPointerEvent("pointerdown")); + outsideButton.dispatchEvent(createPointerEvent("pointerdown")); + outsideButton.dispatchEvent(createPointerEvent("pointerdown")); + + await advanceTimers(); + + // due to debouncing, should only be called once + expect(callbackFn).toHaveBeenCalledOnce(); + }); + + testWithEffect("can be stopped and started", async () => { + const controls = onClickOutside(() => container, callbackFn); + await tick(); + + // Initial state (enabled) + outsideButton.dispatchEvent(createPointerEvent("pointerdown")); + await advanceTimers(); expect(callbackFn).toHaveBeenCalledTimes(1); - outsideButton.dispatchEvent(createMouseEvent(151, 150)); + // Stop listening + controls.stop(); + await tick(); + + outsideButton.dispatchEvent(createPointerEvent("pointerdown")); + await advanceTimers(); + expect(callbackFn).toHaveBeenCalledTimes(1); + + // Start listening again + controls.start(); + await tick(); + + outsideButton.dispatchEvent(createPointerEvent("pointerdown")); + await advanceTimers(); expect(callbackFn).toHaveBeenCalledTimes(2); }); - testWithEffect("handles null container gracefully", async () => { - onClickOutside(() => null, callbackFn); + testWithEffect("respects button type in pointer events", async () => { + onClickOutside(() => container, callbackFn); await tick(); - outsideButton.dispatchEvent(createMouseEvent(0, 0)); + // Right click should be ignored + outsideButton.dispatchEvent(createPointerEvent("pointerdown", { button: 2 })); + await advanceTimers(); expect(callbackFn).not.toHaveBeenCalled(); + + // Left click should trigger callback + outsideButton.dispatchEvent(createPointerEvent("pointerdown", { button: 0 })); + await advanceTimers(); + expect(callbackFn).toHaveBeenCalledOnce(); }); - testWithEffect("handles clicks when target is null", async () => { + testWithEffect("handles pointer down interception correctly", async () => { onClickOutside(() => container, callbackFn); await tick(); - // Simulate a click event with null target - const nullTargetEvent = new MouseEvent("click", { - bubbles: true, - clientX: 0, - clientY: 0, - }); - Object.defineProperty(nullTargetEvent, "target", { value: null }); - - document.dispatchEvent(nullTargetEvent); + // Simulate captured pointerdown (should be intercepted) + container.dispatchEvent( + createPointerEvent("pointerdown", { + clientX: 75, + clientY: 75, + }) + ); + await advanceTimers(); expect(callbackFn).not.toHaveBeenCalled(); + + // Simulate non-intercepted pointerdown + outsideButton.dispatchEvent(createPointerEvent("pointerdown")); + await advanceTimers(); + expect(callbackFn).toHaveBeenCalledOnce(); }); }); diff --git a/sites/docs/src/content/utilities/on-click-outside.md b/sites/docs/src/content/utilities/on-click-outside.md index 3cfa71a2..ff8a54f5 100644 --- a/sites/docs/src/content/utilities/on-click-outside.md +++ b/sites/docs/src/content/utilities/on-click-outside.md @@ -40,7 +40,8 @@ components. ### Controlled Listener -The function returns control methods to programmatically manage the listener, `start` and `stop`: +The function returns control methods to programmatically manage the listener, `start` and `stop` and +a reactive read-only property `enabled` to check the current status of the listeners. ```svelte
+

Status: {clickOutside.enabled ? "Enabled" : "Disabled"}

@@ -63,3 +65,19 @@ The function returns control methods to programmatically manage the listener, `s ``` + +### Immediate + +By default, `onClickOutside` will start listening for clicks outside the element immediately. You +can opt to disabled this behavior by passing `{ immediate: false }` to the options argument. + +```ts {4} +const clickOutside = onClickOutside( + () => container, + () => console.log("clicked outside"), + { immediate: false } +); + +// later when you want to start the listener +clickOutside.start(); +``` diff --git a/sites/docs/src/lib/components/demos/on-click-outside.svelte b/sites/docs/src/lib/components/demos/on-click-outside.svelte index afabc26b..ef4e26cd 100644 --- a/sites/docs/src/lib/components/demos/on-click-outside.svelte +++ b/sites/docs/src/lib/components/demos/on-click-outside.svelte @@ -5,7 +5,7 @@ let containerText = $state("Has not clicked outside yet."); let container = $state()!; - onClickOutside( + const clickOutside = onClickOutside( () => container, () => { containerText = "Has clicked outside."; @@ -21,8 +21,17 @@ container

{containerText}

- +

+ Status: {clickOutside.enabled ? "Enabled" : "Disabled"} +

+
+ + + +
diff --git a/sites/docs/src/routes/api/search.json/search.json b/sites/docs/src/routes/api/search.json/search.json index 03c1196c..1be64583 100644 --- a/sites/docs/src/routes/api/search.json/search.json +++ b/sites/docs/src/routes/api/search.json/search.json @@ -1 +1 @@ -[{"title":"Getting Started","href":"/docs/getting-started","description":"Learn how to install and use Runed in your projects.","content":"Installation Install Runed using your favorite package manager: npm install runed Usage Import one of the utilities you need to either a .svelte or .svelte.js|ts file and start using it: import { activeElement } from \"runed\"; let inputElement = $state(); {#if activeElement.current === inputElement} The input element is active! {/if} or import { activeElement } from \"runed\"; function logActiveElement() { $effect(() => { console.log(\"Active element is \", activeElement.current); }); } logActiveElement(); `"},{"title":"Introduction","href":"/docs/index","description":"Runes are magic, but what good is magic if you don't have a wand?","content":"Runed is a collection of utilities for Svelte 5 that make composing powerful applications and libraries a breeze, leveraging the power of $2. Why Runed? Svelte 5 Runes unlock immense power by providing a set of primitives that allow us to build impressive applications and libraries with ease. However, building complex applications often requires more than just the primitives provided by Svelte Runes. Runed takes those primitives to the next level by providing: Powerful Utilities**: A set of carefully crafted utility functions and classes that simplify common tasks and reduce boilerplate. Collective Efforts**: We often find ourselves writing the same utility functions over and over again. Runed aims to provide a single source of truth for these utilities, allowing the community to contribute, test, and benefit from them. Consistency**: A consistent set of APIs and behaviors across all utilities, so you can focus on building your projects instead of constantly learning new APIs. Reactivity First**: Powered by Svelte 5's new reactivity system, Runed utilities are designed to handle reactive state and side effects with ease. Type Safety**: Full TypeScript support to catch errors early and provide a better developer experience. Ideas and Principles Embrace the Magic of Runes Svelte Runes are a powerful new paradigm. Runed fully embraces this concept and explores its potential. Our goal is to make working with Runes feel as natural and intuitive as possible. Enhance, Don't Replace Runed is not here to replace Svelte's core functionality, but to enhance and extend it. Our utilities should feel like a natural extension of Svelte, not a separate framework. Progressive Complexity Simple things should be simple, complex things should be possible. Runed provides easy-to-use defaults while allowing for advanced customization when needed. Open Source and Community Collaboration Runed is an open-source, MIT licensed project that welcomes all forms of contributions from the community. Whether it's bug reports, feature requests, or code contributions, your input will help make Runed the best it can be."},{"title":"activeElement","href":"/docs/utilities/active-element","description":"Track and access the currently focused DOM element","content":" import Demo from '$lib/components/demos/active-element.svelte'; activeElement provides reactive access to the currently focused DOM element in your application, similar to document.activeElement but with reactive updates. Updates synchronously with DOM focus changes Returns null when no element is focused Safe to use with SSR (Server-Side Rendering) Lightweight alternative to manual focus tracking If you need to provide a custom document / shadowRoot, you can use the $2 utility instead, which provides a more flexible API. Demo Usage import { activeElement } from \"runed\"; Currently active element: {activeElement.current?.localName ?? \"No active element found\"} Type Definition interface ActiveElement { readonly current: Element | null; } `"},{"title":"AnimationFrames","href":"/docs/utilities/animation-frames","description":"A wrapper for requestAnimationFrame with FPS control and frame metrics","content":" import Demo from '$lib/components/demos/animation-frames.svelte'; AnimationFrames provides a declarative API over the browser's $2, offering FPS limiting capabilities and frame metrics while handling cleanup automatically. Demo Usage import { AnimationFrames } from \"runed\"; import { Slider } from \"../ui/slider\"; // Check out shadcn-svelte! let frames = $state(0); let fpsLimit = $state(10); let delta = $state(0); const animation = new AnimationFrames( (args) => { frames++; delta = args.delta; }, { fpsLimit: () => fpsLimit } ); const stats = $derived( Frames: ${frames}\\nFPS: ${animation.fps.toFixed(0)}\\nDelta: ${delta.toFixed(0)}ms ); {stats} {animation.running ? \"Stop\" : \"Start\"} FPS limit: {fpsLimit}{fpsLimit === 0 ? \" (not limited)\" : \"\"} (fpsLimit = value[0] ?? 0)} min={0} max={144} /> `"},{"title":"Context","href":"/docs/utilities/context","description":"A wrapper around Svelte's Context API that provides type safety and improved ergonomics for sharing data between components.","content":" import { Steps, Step, Callout } from '@svecodocs/kit'; Context allows you to pass data through the component tree without explicitly passing props through every level. It's useful for sharing data that many components need, like themes, authentication state, or localization preferences. The Context class provides a type-safe way to define, set, and retrieve context values. Usage Creating a Context First, create a Context instance with the type of value it will hold: import { Context } from \"runed\"; export const myTheme = new Context(\"theme\"); Creating a Context instance only defines the context - it doesn't actually set any value. The value passed to the constructor (\"theme\" in this example) is just an identifier used for debugging and error messages. Think of this step as creating a \"container\" that will later hold your context value. The container is typed (in this case to only accept \"light\" or \"dark\" as values) but remains empty until you explicitly call myTheme.set() during component initialization. This separation between defining and setting context allows you to: Keep context definitions in separate files Reuse the same context definition across different parts of your app Maintain type safety throughout your application Set different values for the same context in different component trees Setting Context Values Set the context value in a parent component during initialization. import { myTheme } from \"./context\"; let { data, children } = $props(); myTheme.set(data.theme); {@render children?.()} Context must be set during component initialization, similar to lifecycle functions like onMount. You cannot set context inside event handlers or callbacks. Reading Context Values Child components can access the context using get() or getOr() import { myTheme } from \"./context\"; const theme = myTheme.get(); // or with a fallback value if the context is not set const theme = myTheme.getOr(\"light\"); Type Definition class Context { /** @param name The name of the context. This is used for generating the context key and error messages. */ constructor(name: string) {} /** The key used to get and set the context. * It is not recommended to use this value directly. Instead, use the methods provided by this class. */ get key(): symbol; /** Checks whether this has been set in the context of a parent component. * Must be called during component initialization. */ exists(): boolean; /** Retrieves the context that belongs to the closest parent component. * Must be called during component initialization. * @throws An error if the context does not exist. */ get(): TContext; /** Retrieves the context that belongs to the closest parent component, or the given fallback value if the context does not exist. * Must be called during component initialization. */ getOr(fallback: TFallback): TContext | TFallback; /** Associates the given value with the current component and returns it. * Must be called during component initialization. */ set(context: TContext): TContext; } `"},{"title":"Debounced","href":"/docs/utilities/debounced","description":"A wrapper over `useDebounce` that returns a debounced state.","content":" import Demo from '$lib/components/demos/debounced.svelte'; Demo Usage This is a simple wrapper over $2 that returns a debounced state. import { Debounced } from \"runed\"; let search = $state(\"\"); const debounced = new Debounced(() => search, 500); You searched for: {debounced.current} You may cancel the pending update, run it immediately, or set a new value. Setting a new value immediately also cancels any pending updates. let count = $state(0); const debounced = new Debounced(() => count, 500); count = 1; debounced.cancel(); // after a while... console.log(debounced.current); // Still 0! count = 2; console.log(debounced.current); // Still 0! debounced.setImmediately(count); console.log(debounced.current); // 2 count = 3; console.log(debounced.current); // 2 await debounced.updateImmediately(); console.log(debounced.current); // 3 `"},{"title":"ElementRect","href":"/docs/utilities/element-rect","description":"Track element dimensions and position reactively","content":" import Demo from '$lib/components/demos/element-rect.svelte'; ElementRect provides reactive access to an element's dimensions and position information, automatically updating when the element's size or position changes. Demo Usage import { ElementRect } from \"runed\"; let el = $state(); const rect = new ElementRect(() => el); Width: {rect.width} Height: {rect.height} {JSON.stringify(rect.current, null, 2)} Type Definition type Rect = Omit; interface ElementRectOptions { initialRect?: DOMRect; } class ElementRect { constructor(node: MaybeGetter, options?: ElementRectOptions); readonly current: Rect; readonly width: number; readonly height: number; readonly top: number; readonly left: number; readonly right: number; readonly bottom: number; readonly x: number; readonly y: number; } `"},{"title":"ElementSize","href":"/docs/utilities/element-size","description":"Track element dimensions reactively","content":" import Demo from '$lib/components/demos/element-size.svelte'; ElementSize provides reactive access to an element's width and height, automatically updating when the element's dimensions change. Similar to ElementRect but focused only on size measurements. Demo Usage import { ElementSize } from \"runed\"; let el = $state() as HTMLElement; const size = new ElementSize(() => el); Width: {size.width} Height: {size.height} Type Definition interface ElementSize { readonly width: number; readonly height: number; } `"},{"title":"FiniteStateMachine","href":"/docs/utilities/finite-state-machine","description":"Defines a strongly-typed finite state machine.","content":" import Demo from '$lib/components/demos/finite-state-machine.svelte'; Demo type MyStates = \"disabled\" | \"idle\" | \"running\"; type MyEvents = \"toggleEnabled\" | \"start\" | \"stop\"; const f = new FiniteStateMachine(\"disabled\", { disabled: { toggleEnabled: \"idle\" }, idle: { toggleEnabled: \"disabled\", start: \"running\" }, running: { _enter: () => { f.debounce(2000, \"stop\"); }, stop: \"idle\", toggleEnabled: \"disabled\" } }); Usage Finite state machines (often abbreviated as \"FSMs\") are useful for tracking and manipulating something that could be in one of many different states. It centralizes the definition of every possible state and the events that might trigger a transition from one state to another. Here is a state machine describing a simple toggle switch: import { FiniteStateMachine } from \"runed\"; type MyStates = \"on\" | \"off\"; type MyEvents = \"toggle\"; const f = new FiniteStateMachine(\"off\", { off: { toggle: \"on\" }, on: { toggle: \"off\" } }); The first argument to the FiniteStateMachine constructor is the initial state. The second argument is an object with one key for each state. Each state then describes which events are valid for that state, and which state that event should lead to. In the above example of a simple switch, there are two states (on and off). The toggle event in either state leads to the other state. You send events to the FSM using f.send. To send the toggle event, invoke f.send('toggle'). Actions Maybe you want fancier logic for an event handler, or you want to conditionally transition into another state. Instead of strings, you can use actions. An action is a function that returns a state. An action can receive parameters, and it can use those parameters to dynamically choose which state should come next. It can also prevent a state transition by returning nothing. type MyStates = \"on\" | \"off\" | \"cooldown\"; const f = new FiniteStateMachine(\"off\", { off: { toggle: () => { if (isTuesday) { // Switch can only turn on during Tuesdays return \"on\"; } // All other days, nothing is returned and state is unchanged. } }, on: { toggle: (heldMillis: number) => { // You can also dynamically return the next state! // Only turn off if switch is depressed for 3 seconds if (heldMillis > 3000) { return \"off\"; } } } }); Lifecycle methods You can define special handlers that are invoked whenever a state is entered or exited: const f = new FiniteStateMachine('off', { off: { toggle: 'on' _enter: (meta) => { console.log('switch is off') } _exit: (meta) => { console.log('switch is no longer off') } }, on: { toggle: 'off' _enter: (meta) => { console.log('switch is on') } _exit: (meta) => { console.log('switch is no longer on') } } }); The lifecycle methods are invoked with a metadata object containing some useful information: from: the name of the event that is being exited to: the name of the event that is being entered event: the name of the event which has triggered the transition args: (optional) you may pass additional metadata when invoking an action with f.send('theAction', additional, params, as, args) The _enter handler for the initial state is called upon creation of the FSM. It is invoked with both the from and event fields set to null. Wildcard handlers There is one special state used as a fallback: *. If you have the fallback state, and you attempt to send() an event that is not handled by the current state, then it will try to find a handler for that event on the * state before discarding the event: const f = new FiniteStateMachine('off', { off: { toggle: 'on' }, on: { toggle: 'off' } '*': { emergency: 'off' } }); // will always result in the switch turning off. f.send('emergency'); Debouncing Frequently, you want to transition to another state after some time has elapsed. To do this, use the debounce method: f.send(\"toggle\"); // turn on immediately f.debounce(5000, \"toggle\"); // turn off in 5000 milliseconds If you re-invoke debounce with the same event, it will cancel the existing timer and start the countdown over: // schedule a toggle in five seconds f.debounce(5000, \"toggle\"); // ... less than 5000ms elapses ... f.debounce(5000, \"toggle\"); // The second call cancels the original timer, and starts a new one You can also use debounce in both actions and lifecycle methods. In both of the following examples, the lightswitch will turn itself off five seconds after it was turned on: const f = new FiniteStateMachine(\"off\", { off: { toggle: () => { f.debounce(5000, \"toggle\"); return \"on\"; } }, on: { toggle: \"off\" } }); const f = new FiniteStateMachine(\"off\", { off: { toggle: \"on\" }, on: { toggle: \"off\", _enter: () => { f.debounce(5000, \"toggle\"); } } }); Notes FiniteStateMachine is a loving rewrite of $2. FSMs are ideal for representing many different kinds of systems and interaction patterns. FiniteStateMachine is an intentionally minimalistic implementation. If you're looking for a more powerful FSM library, $2 is an excellent library with more features — and a steeper learning curve."},{"title":"IsFocusWithin","href":"/docs/utilities/is-focus-within","description":"A utility that tracks whether any descendant element has focus within a specified container element.","content":" import Demo from '$lib/components/demos/is-focus-within.svelte'; IsFocusWithin reactively tracks focus state within a container element, updating automatically when focus changes. Demo Usage import { IsFocusWithin } from \"runed\"; let formElement = $state(); const focusWithinForm = new IsFocusWithin(() => formElement); Focus within form: {focusWithinForm.current} Submit Type Definition class IsFocusWithin { constructor(node: MaybeGetter); readonly current: boolean; } `"},{"title":"IsIdle","href":"/docs/utilities/is-idle","description":"Track if a user is idle and the last time they were active.","content":" import Demo from '$lib/components/demos/is-idle.svelte'; IsIdle tracks user activity and determines if they're idle based on a configurable timeout. It monitors mouse movement, keyboard input, and touch events to detect user interaction. Demo Usage import { AnimationFrames, IsIdle } from \"runed\"; const idle = new IsIdle({ timeout: 1000 }); Idle: {idle.current} Last active: {new Date(idle.lastActive).toLocaleTimeString()} Type Definitions interface IsIdleOptions { /** The events that should set the idle state to true * @default ['mousemove', 'mousedown', 'resize', 'keydown', 'touchstart', 'wheel'] */ events?: MaybeGetter; /** The timeout in milliseconds before the idle state is set to true. Defaults to 60 seconds. * @default 60000 */ timeout?: MaybeGetter; /** Detect document visibility changes * @default true */ detectVisibilityChanges?: MaybeGetter; /** The initial state of the idle property * @default false */ initialState?: boolean; } class IsIdle { constructor(options?: IsIdleOptions); readonly current: boolean; readonly lastActive: number; } `"},{"title":"IsInViewport","href":"/docs/utilities/is-in-viewport","description":"Track if an element is visible within the current viewport.","content":" import Demo from '$lib/components/demos/is-in-viewport.svelte'; IsInViewport uses the $2 utility to track if an element is visible within the current viewport. It accepts an element or getter that returns an element and an optional options object that aligns with the $2 utility options. Demo Usage import { IsInViewport } from \"runed\"; let targetNode = $state()!; const inViewport = new IsInViewport(() => targetNode); Target node Target node in viewport: {inViewport.current} Type Definition import { type UseIntersectionObserverOptions } from \"runed\"; export type IsInViewportOptions = UseIntersectionObserverOptions; export declare class IsInViewport { constructor(node: MaybeGetter, options?: IsInViewportOptions); get current(): boolean; } "},{"title":"IsMounted","href":"/docs/utilities/is-mounted","description":"A class that returns the mounted state of the component it's called in.","content":" import Demo from '$lib/components/demos/is-mounted.svelte'; Demo Usage import { IsMounted } from \"runed\"; const isMounted = new IsMounted(); Which is a shorthand for one of the following: import { onMount } from \"svelte\"; const isMounted = $state({ current: false }); onMount(() => { isMounted.current = true; }); or import { untrack } from \"svelte\"; const isMounted = $state({ current: false }); $effect(() => { untrack(() => (isMounted.current = true)); }); `"},{"title":"IsSupported","href":"/docs/utilities/is-supported","description":"Determine if a feature is supported by the environment before using it.","content":"Usage import { IsSupported } from \"runed\"; const isSupported = new IsSupported(() => navigator && \"geolocation\" in navigator); if (isSupported.current) { // Do something with the geolocation API } Type Definition class IsSupported { readonly current: boolean; } `"},{"title":"onClickOutside","href":"/docs/utilities/on-click-outside","description":"Call a function when a user clicks outside of a container.","content":" import Demo from '$lib/components/demos/on-click-outside.svelte'; Demo Usage import { onClickOutside } from \"runed\"; let el = $state(undefined); onClickOutside( () => el, () => { console.log(\"clicked outside of container\"); } ); Container Click Me You can also programmatically pause and resume onClickOutside using the start and stop functions returned by onClickOutside. import { onClickOutside } from \"runed\"; let el = $state(undefined); const outsideClick = onClickOutside( () => el, () => { console.log(\"clicked outside of container\"); } ); Stop listening for outside clicks Start listening again `"},{"title":"PersistedState","href":"/docs/utilities/persisted-state","description":"A reactive state manager that persists and synchronizes state across browser sessions and tabs using Web Storage APIs.","content":" import Demo from '$lib/components/demos/persisted-state.svelte'; import { Callout } from '@svecodocs/kit' PersistedState provides a reactive state container that automatically persists data to browser storage and optionally synchronizes changes across browser tabs in real-time. Demo You can refresh this page and/or open it in another tab to see the count state being persisted and synchronized across sessions and tabs. Usage Initialize PersistedState by providing a unique key and an initial value for the state. import { PersistedState } from \"runed\"; const count = new PersistedState(\"count\", 0); count.current++}>Increment count.current--}>Decrement (count.current = 0)}>Reset Count: {count.current} Configuration Options PersistedState includes an options object that allows you to customize the behavior of the state manager. const state = new PersistedState(\"user-preferences\", initialValue, { // Use sessionStorage instead of localStorage (default: 'local') storage: \"session\", // Disable cross-tab synchronization (default: true) syncTabs: false, // Custom serialization handlers serializer: { serialize: superjson.stringify, deserialize: superjson.parse } }); Storage Options 'local': Data persists until explicitly cleared 'session': Data persists until the browser session ends Cross-Tab Synchronization When syncTabs is enabled (default), changes are automatically synchronized across all browser tabs using the storage event. Custom Serialization Provide custom serialize and deserialize functions to handle complex data types: import superjson from \"superjson\"; // Example with Date objects const lastAccessed = new PersistedState(\"last-accessed\", new Date(), { serializer: { serialize: superjson.stringify, deserialize: superjson.parse } }); `"},{"title":"PressedKeys","href":"/docs/utilities/pressed-keys","description":"Tracks which keys are currently pressed","content":" import Demo from '$lib/components/demos/pressed-keys.svelte'; Demo Usage With an instance of PressedKeys, you can use the has method. const keys = new PressedKeys(); const isArrowDownPressed = $derived(keys.has(\"ArrowDown\")); const isCtrlAPressed = $derived(keys.has(\"Control\", \"a\")); Or get all of the currently pressed keys: const keys = new PressedKeys(); console.log(keys.all()); `"},{"title":"Previous","href":"/docs/utilities/previous","description":"A utility that tracks and provides access to the previous value of a reactive getter.","content":" import Demo from '$lib/components/demos/previous.svelte'; The Previous utility creates a reactive wrapper that maintains the previous value of a getter function. This is particularly useful when you need to compare state changes or implement transition effects. Demo Usage import { Previous } from \"runed\"; let count = $state(0); const previous = new Previous(() => count); count++}>Count: {count} Previous: {${previous.current}} Type Definition class Previous { constructor(getter: () => T); readonly current: T; // Previous value } `"},{"title":"StateHistory","href":"/docs/utilities/state-history","description":"Track state changes with undo/redo capabilities","content":" import Demo from '$lib/components/demos/state-history.svelte'; Demo Usage StateHistory tracks a getter's return value, logging each change into an array. A setter is also required to use the undo and redo functions. import { StateHistory } from \"runed\"; let count = $state(0); const history = new StateHistory(() => count, (c) => (count = c)); history.log[0]; // { snapshot: 0, timestamp: ... } Besides log, the returned object contains undo and redo functionality. import { useStateHistory } from \"runed\"; let count = $state(0); const history = new StateHistory(() => count, (c) => (count = c)); function format(ts: number) { return new Date(ts).toLocaleString(); } {count} count++}>Increment count--}>Decrement Undo Redo `"},{"title":"useActiveElement","href":"/docs/utilities/use-active-element","description":"Get a reactive reference to the currently focused element in the document.","content":" import Demo from '$lib/components/demos/use-active-element.svelte'; import { PropField } from '@svecodocs/kit' useActiveElement is used to get the currently focused element in the document. If you don't need to provide a custom document / shadowRoot, you can use the $2 state instead, as it provides a simpler API. This utility behaves similarly to document.activeElement but with additional features such as: Updates synchronously with DOM focus changes Returns null when no element is focused Safe to use with SSR (Server-Side Rendering) Lightweight alternative to manual focus tracking Demo Usage import { useActiveElement } from \"runed\"; const activeElement = useActiveElement(); {#if activeElement.current} The active element is: {activeElement.current.localName} {:else} No active element found {/if} Options The following options can be passed via the first argument to useActiveElement: The document or shadow root to track focus within. The window to use for focus tracking. "},{"title":"useDebounce","href":"/docs/utilities/use-debounce","description":"A higher-order function that debounces the execution of a function.","content":" import Demo from '$lib/components/demos/use-debounce.svelte'; useDebounce is a utility function that creates a debounced version of a callback function. Debouncing prevents a function from being called too frequently by delaying its execution until after a specified duration of inactivity. Demo Usage import { useDebounce } from \"runed\"; let count = $state(0); let logged = $state(\"\"); let isFirstTime = $state(true); let debounceDuration = $state(1000); const logCount = useDebounce( () => { if (isFirstTime) { isFirstTime = false; logged = You pressed the button ${count} times!; } else { logged = You pressed the button ${count} times since last time!; } count = 0; }, () => debounceDuration ); function ding() { count++; logCount(); } DING DING DING Run now Cancel message {logged || \"Press the button!\"} `"},{"title":"useEventListener","href":"/docs/utilities/use-event-listener","description":"A function that attaches an automatically disposed event listener.","content":" import Demo from '$lib/components/demos/use-event-listener.svelte'; Demo Usage The useEventListener function is particularly useful for attaching event listeners to elements you don't directly control. For instance, if you need to listen for events on the document body or window and can't use ``, or if you receive an element reference from a parent component. Example: Tracking Clicks on the Document // ClickLogger.ts import { useEventListener } from \"runed\"; export class ClickLogger { #clicks = $state(0); constructor() { useEventListener( () => document.body, \"click\", () => this.#clicks++ ); } get clicks() { return this.#clicks; } } This ClickLogger class tracks the number of clicks on the document body using the useEventListener function. Each time a click occurs, the internal counter increments. Svelte Component Usage import { ClickLogger } from \"./ClickLogger.ts\"; const logger = new ClickLogger(); You've clicked the document {logger.clicks} {logger.clicks === 1 ? \"time\" : \"times\"} In the component above, we create an instance of the ClickLogger class to monitor clicks on the document. The displayed text updates dynamically based on the recorded click count. Key Points Automatic Cleanup:** The event listener is removed automatically when the component is destroyed or when the element reference changes. Lazy Initialization:** The target element can be defined using a function, enabling flexible and dynamic behavior. Convenient for Global Listeners:** Ideal for scenarios where attaching event listeners directly to the DOM elements is cumbersome or impractical."},{"title":"useGeolocation","href":"/docs/utilities/use-geolocation","description":"Reactive access to the browser's Geolocation API.","content":" import Demo from '$lib/components/demos/use-geolocation.svelte'; useGeolocation is a reactive wrapper around the browser's $2. Demo Usage import { useGeolocation } from \"runed\"; const location = useGeolocation(); Coords: {JSON.stringify(location.position.coords, null, 2)} Located at: {location.position.timestamp} Error: {JSON.stringify(location.error, null, 2)} Is Supported: {location.isSupported} Pause Resume Type Definitions type UseGeolocationOptions = Partial & { /** Whether to start the watcher immediately upon creation. If set to false, the watcher will only start tracking the position when resume() is called. * @defaultValue true */ immediate?: boolean; }; type UseGeolocationReturn = { readonly isSupported: boolean; readonly position: Omit; readonly error: GeolocationPositionError | null; readonly isPaused: boolean; pause: () => void; resume: () => void; }; `"},{"title":"useIntersectionObserver","href":"/docs/utilities/use-intersection-observer","description":"Watch for intersection changes of a target element.","content":" import Demo from '$lib/components/demos/use-intersection-observer.svelte'; import { Callout } from '@svecodocs/kit' Demo Usage With a reference to an element, you can use the useIntersectionObserver utility to watch for intersection changes of the target element. import { useIntersectionObserver } from \"runed\"; let target = $state(null); let root = $state(null); let isIntersecting = $state(false); useIntersectionObserver( () => target, (entries) => { const entry = entries[0]; if (!entry) return; isIntersecting = entry.isIntersecting; }, { root: () => root } ); {#if isIntersecting} Target is intersecting {:else} Target is not intersecting {/if} Pause You can pause the intersection observer at any point by calling the pause method. const observer = useIntersectionObserver(/* ... */); observer.pause(); Resume You can resume the intersection observer at any point by calling the resume method. const observer = useIntersectionObserver(/* ... */); observer.resume(); Stop You can stop the intersection observer at any point by calling the stop method. const observer = useIntersectionObserver(/* ... */); observer.stop(); isActive You can check if the intersection observer is active by checking the isActive property. This property cannot be destructured as it is a getter. You must access it directly from the observer. const observer = useIntersectionObserver(/* ... */); if (observer.isActive) { // do something } `"},{"title":"useMutationObserver","href":"/docs/utilities/use-mutation-observer","description":"Observe changes in an element","content":" import Demo from '$lib/components/demos/use-mutation-observer.svelte'; Demo Usage With a reference to an element, you can use the useMutationObserver hook to observe changes in the element. import { useMutationObserver } from \"runed\"; let el = $state(null); const messages = $state([]); let className = $state(\"\"); let style = $state(\"\"); useMutationObserver( () => el, (mutations) => { const mutation = mutations[0]; if (!mutation) return; messages.push(mutation.attributeName!); }, { attributes: true } ); setTimeout(() => { className = \"text-brand\"; }, 1000); setTimeout(() => { style = \"font-style: italic;\"; }, 1500); {#each messages as text} Mutation Attribute: {text} {:else} No mutations yet {/each} You can stop the mutation observer at any point by calling the stop method. const { stop } = useMutationObserver(/* ... */); stop(); `"},{"title":"useResizeObserver","href":"/docs/utilities/use-resize-observer","description":"Detects changes in the size of an element","content":" import Demo from '$lib/components/demos/use-resize-observer.svelte'; Demo Usage With a reference to an element, you can use the useResizeObserver utility to detect changes in the size of an element. import { useResizeObserver } from \"runed\"; let el = $state(null); let text = $state(\"\"); useResizeObserver( () => el, (entries) => { const entry = entries[0]; if (!entry) return; const { width, height } = entry.contentRect; text = width: ${width};\\nheight: ${height};; } ); You can stop the resize observer at any point by calling the stop method. const { stop } = useResizeObserver(/* ... */); stop(); `"},{"title":"watch","href":"/docs/utilities/watch","description":"Watch for changes and run a callback","content":"Runes provide a handy way of running a callback when reactive values change: $2. It automatically detects when inner values change, and re-runs the callback. $effect is great, but sometimes you want to manually specify which values should trigger the callback. Svelte provides an untrack function, allowing you to specify that a dependency shouldn't be tracked, but it doesn't provide a way to say that only certain values should be tracked. watch does exactly that. It accepts a getter function, which returns the dependencies of the effect callback. Usage watch Runs a callback whenever one of the sources change. import { watch } from \"runed\"; let count = $state(0); watch(() => count, () => { console.log(count); } ); The callback receives two arguments: The current value of the sources, and the previous value. let count = $state(0); watch(() => count, (curr, prev) => { console.log(count is ${curr}, was ${prev}); } ); You can also send in an array of sources: let age = $state(20); let name = $state(\"bob\"); watch([() => age, () => name], ([age, name], [prevAge, prevName]) => { // ... } watch also accepts an options object. watch(sources, callback, { // First run will only happen after sources change when set to true. // By default, its false. lazy: true }); watch.pre watch.pre is similar to watch, but it uses $2 under the hood. watchOnce In case you want to run the callback only once, you can use watchOnce and watchOnce.pre. It functions identically to the watch and watch.pre otherwise, but it does not accept any options object."}] \ No newline at end of file +[{"title":"Getting Started","href":"/docs/getting-started","description":"Learn how to install and use Runed in your projects.","content":"Installation Install Runed using your favorite package manager: npm install runed Usage Import one of the utilities you need to either a .svelte or .svelte.js|ts file and start using it: import { activeElement } from \"runed\"; let inputElement = $state(); {#if activeElement.current === inputElement} The input element is active! {/if} or import { activeElement } from \"runed\"; function logActiveElement() { $effect(() => { console.log(\"Active element is \", activeElement.current); }); } logActiveElement(); `"},{"title":"Introduction","href":"/docs/index","description":"Runes are magic, but what good is magic if you don't have a wand?","content":"Runed is a collection of utilities for Svelte 5 that make composing powerful applications and libraries a breeze, leveraging the power of $2. Why Runed? Svelte 5 Runes unlock immense power by providing a set of primitives that allow us to build impressive applications and libraries with ease. However, building complex applications often requires more than just the primitives provided by Svelte Runes. Runed takes those primitives to the next level by providing: Powerful Utilities**: A set of carefully crafted utility functions and classes that simplify common tasks and reduce boilerplate. Collective Efforts**: We often find ourselves writing the same utility functions over and over again. Runed aims to provide a single source of truth for these utilities, allowing the community to contribute, test, and benefit from them. Consistency**: A consistent set of APIs and behaviors across all utilities, so you can focus on building your projects instead of constantly learning new APIs. Reactivity First**: Powered by Svelte 5's new reactivity system, Runed utilities are designed to handle reactive state and side effects with ease. Type Safety**: Full TypeScript support to catch errors early and provide a better developer experience. Ideas and Principles Embrace the Magic of Runes Svelte Runes are a powerful new paradigm. Runed fully embraces this concept and explores its potential. Our goal is to make working with Runes feel as natural and intuitive as possible. Enhance, Don't Replace Runed is not here to replace Svelte's core functionality, but to enhance and extend it. Our utilities should feel like a natural extension of Svelte, not a separate framework. Progressive Complexity Simple things should be simple, complex things should be possible. Runed provides easy-to-use defaults while allowing for advanced customization when needed. Open Source and Community Collaboration Runed is an open-source, MIT licensed project that welcomes all forms of contributions from the community. Whether it's bug reports, feature requests, or code contributions, your input will help make Runed the best it can be."},{"title":"activeElement","href":"/docs/utilities/active-element","description":"Track and access the currently focused DOM element","content":" import Demo from '$lib/components/demos/active-element.svelte'; activeElement provides reactive access to the currently focused DOM element in your application, similar to document.activeElement but with reactive updates. Updates synchronously with DOM focus changes Returns null when no element is focused Safe to use with SSR (Server-Side Rendering) Lightweight alternative to manual focus tracking If you need to provide a custom document / shadowRoot, you can use the $2 utility instead, which provides a more flexible API. Demo Usage import { activeElement } from \"runed\"; Currently active element: {activeElement.current?.localName ?? \"No active element found\"} Type Definition interface ActiveElement { readonly current: Element | null; } `"},{"title":"AnimationFrames","href":"/docs/utilities/animation-frames","description":"A wrapper for requestAnimationFrame with FPS control and frame metrics","content":" import Demo from '$lib/components/demos/animation-frames.svelte'; AnimationFrames provides a declarative API over the browser's $2, offering FPS limiting capabilities and frame metrics while handling cleanup automatically. Demo Usage import { AnimationFrames } from \"runed\"; import { Slider } from \"../ui/slider\"; // Check out shadcn-svelte! let frames = $state(0); let fpsLimit = $state(10); let delta = $state(0); const animation = new AnimationFrames( (args) => { frames++; delta = args.delta; }, { fpsLimit: () => fpsLimit } ); const stats = $derived( Frames: ${frames}\\nFPS: ${animation.fps.toFixed(0)}\\nDelta: ${delta.toFixed(0)}ms ); {stats} {animation.running ? \"Stop\" : \"Start\"} FPS limit: {fpsLimit}{fpsLimit === 0 ? \" (not limited)\" : \"\"} (fpsLimit = value[0] ?? 0)} min={0} max={144} /> `"},{"title":"Context","href":"/docs/utilities/context","description":"A wrapper around Svelte's Context API that provides type safety and improved ergonomics for sharing data between components.","content":" import { Steps, Step, Callout } from '@svecodocs/kit'; Context allows you to pass data through the component tree without explicitly passing props through every level. It's useful for sharing data that many components need, like themes, authentication state, or localization preferences. The Context class provides a type-safe way to define, set, and retrieve context values. Usage Creating a Context First, create a Context instance with the type of value it will hold: import { Context } from \"runed\"; export const myTheme = new Context(\"theme\"); Creating a Context instance only defines the context - it doesn't actually set any value. The value passed to the constructor (\"theme\" in this example) is just an identifier used for debugging and error messages. Think of this step as creating a \"container\" that will later hold your context value. The container is typed (in this case to only accept \"light\" or \"dark\" as values) but remains empty until you explicitly call myTheme.set() during component initialization. This separation between defining and setting context allows you to: Keep context definitions in separate files Reuse the same context definition across different parts of your app Maintain type safety throughout your application Set different values for the same context in different component trees Setting Context Values Set the context value in a parent component during initialization. import { myTheme } from \"./context\"; let { data, children } = $props(); myTheme.set(data.theme); {@render children?.()} Context must be set during component initialization, similar to lifecycle functions like onMount. You cannot set context inside event handlers or callbacks. Reading Context Values Child components can access the context using get() or getOr() import { myTheme } from \"./context\"; const theme = myTheme.get(); // or with a fallback value if the context is not set const theme = myTheme.getOr(\"light\"); Type Definition class Context { /** @param name The name of the context. This is used for generating the context key and error messages. */ constructor(name: string) {} /** The key used to get and set the context. * It is not recommended to use this value directly. Instead, use the methods provided by this class. */ get key(): symbol; /** Checks whether this has been set in the context of a parent component. * Must be called during component initialization. */ exists(): boolean; /** Retrieves the context that belongs to the closest parent component. * Must be called during component initialization. * @throws An error if the context does not exist. */ get(): TContext; /** Retrieves the context that belongs to the closest parent component, or the given fallback value if the context does not exist. * Must be called during component initialization. */ getOr(fallback: TFallback): TContext | TFallback; /** Associates the given value with the current component and returns it. * Must be called during component initialization. */ set(context: TContext): TContext; } `"},{"title":"Debounced","href":"/docs/utilities/debounced","description":"A wrapper over `useDebounce` that returns a debounced state.","content":" import Demo from '$lib/components/demos/debounced.svelte'; Demo Usage This is a simple wrapper over $2 that returns a debounced state. import { Debounced } from \"runed\"; let search = $state(\"\"); const debounced = new Debounced(() => search, 500); You searched for: {debounced.current} You may cancel the pending update, run it immediately, or set a new value. Setting a new value immediately also cancels any pending updates. let count = $state(0); const debounced = new Debounced(() => count, 500); count = 1; debounced.cancel(); // after a while... console.log(debounced.current); // Still 0! count = 2; console.log(debounced.current); // Still 0! debounced.setImmediately(count); console.log(debounced.current); // 2 count = 3; console.log(debounced.current); // 2 await debounced.updateImmediately(); console.log(debounced.current); // 3 `"},{"title":"ElementRect","href":"/docs/utilities/element-rect","description":"Track element dimensions and position reactively","content":" import Demo from '$lib/components/demos/element-rect.svelte'; ElementRect provides reactive access to an element's dimensions and position information, automatically updating when the element's size or position changes. Demo Usage import { ElementRect } from \"runed\"; let el = $state(); const rect = new ElementRect(() => el); Width: {rect.width} Height: {rect.height} {JSON.stringify(rect.current, null, 2)} Type Definition type Rect = Omit; interface ElementRectOptions { initialRect?: DOMRect; } class ElementRect { constructor(node: MaybeGetter, options?: ElementRectOptions); readonly current: Rect; readonly width: number; readonly height: number; readonly top: number; readonly left: number; readonly right: number; readonly bottom: number; readonly x: number; readonly y: number; } `"},{"title":"ElementSize","href":"/docs/utilities/element-size","description":"Track element dimensions reactively","content":" import Demo from '$lib/components/demos/element-size.svelte'; ElementSize provides reactive access to an element's width and height, automatically updating when the element's dimensions change. Similar to ElementRect but focused only on size measurements. Demo Usage import { ElementSize } from \"runed\"; let el = $state() as HTMLElement; const size = new ElementSize(() => el); Width: {size.width} Height: {size.height} Type Definition interface ElementSize { readonly width: number; readonly height: number; } `"},{"title":"FiniteStateMachine","href":"/docs/utilities/finite-state-machine","description":"Defines a strongly-typed finite state machine.","content":" import Demo from '$lib/components/demos/finite-state-machine.svelte'; Demo type MyStates = \"disabled\" | \"idle\" | \"running\"; type MyEvents = \"toggleEnabled\" | \"start\" | \"stop\"; const f = new FiniteStateMachine(\"disabled\", { disabled: { toggleEnabled: \"idle\" }, idle: { toggleEnabled: \"disabled\", start: \"running\" }, running: { _enter: () => { f.debounce(2000, \"stop\"); }, stop: \"idle\", toggleEnabled: \"disabled\" } }); Usage Finite state machines (often abbreviated as \"FSMs\") are useful for tracking and manipulating something that could be in one of many different states. It centralizes the definition of every possible state and the events that might trigger a transition from one state to another. Here is a state machine describing a simple toggle switch: import { FiniteStateMachine } from \"runed\"; type MyStates = \"on\" | \"off\"; type MyEvents = \"toggle\"; const f = new FiniteStateMachine(\"off\", { off: { toggle: \"on\" }, on: { toggle: \"off\" } }); The first argument to the FiniteStateMachine constructor is the initial state. The second argument is an object with one key for each state. Each state then describes which events are valid for that state, and which state that event should lead to. In the above example of a simple switch, there are two states (on and off). The toggle event in either state leads to the other state. You send events to the FSM using f.send. To send the toggle event, invoke f.send('toggle'). Actions Maybe you want fancier logic for an event handler, or you want to conditionally transition into another state. Instead of strings, you can use actions. An action is a function that returns a state. An action can receive parameters, and it can use those parameters to dynamically choose which state should come next. It can also prevent a state transition by returning nothing. type MyStates = \"on\" | \"off\" | \"cooldown\"; const f = new FiniteStateMachine(\"off\", { off: { toggle: () => { if (isTuesday) { // Switch can only turn on during Tuesdays return \"on\"; } // All other days, nothing is returned and state is unchanged. } }, on: { toggle: (heldMillis: number) => { // You can also dynamically return the next state! // Only turn off if switch is depressed for 3 seconds if (heldMillis > 3000) { return \"off\"; } } } }); Lifecycle methods You can define special handlers that are invoked whenever a state is entered or exited: const f = new FiniteStateMachine('off', { off: { toggle: 'on' _enter: (meta) => { console.log('switch is off') } _exit: (meta) => { console.log('switch is no longer off') } }, on: { toggle: 'off' _enter: (meta) => { console.log('switch is on') } _exit: (meta) => { console.log('switch is no longer on') } } }); The lifecycle methods are invoked with a metadata object containing some useful information: from: the name of the event that is being exited to: the name of the event that is being entered event: the name of the event which has triggered the transition args: (optional) you may pass additional metadata when invoking an action with f.send('theAction', additional, params, as, args) The _enter handler for the initial state is called upon creation of the FSM. It is invoked with both the from and event fields set to null. Wildcard handlers There is one special state used as a fallback: *. If you have the fallback state, and you attempt to send() an event that is not handled by the current state, then it will try to find a handler for that event on the * state before discarding the event: const f = new FiniteStateMachine('off', { off: { toggle: 'on' }, on: { toggle: 'off' } '*': { emergency: 'off' } }); // will always result in the switch turning off. f.send('emergency'); Debouncing Frequently, you want to transition to another state after some time has elapsed. To do this, use the debounce method: f.send(\"toggle\"); // turn on immediately f.debounce(5000, \"toggle\"); // turn off in 5000 milliseconds If you re-invoke debounce with the same event, it will cancel the existing timer and start the countdown over: // schedule a toggle in five seconds f.debounce(5000, \"toggle\"); // ... less than 5000ms elapses ... f.debounce(5000, \"toggle\"); // The second call cancels the original timer, and starts a new one You can also use debounce in both actions and lifecycle methods. In both of the following examples, the lightswitch will turn itself off five seconds after it was turned on: const f = new FiniteStateMachine(\"off\", { off: { toggle: () => { f.debounce(5000, \"toggle\"); return \"on\"; } }, on: { toggle: \"off\" } }); const f = new FiniteStateMachine(\"off\", { off: { toggle: \"on\" }, on: { toggle: \"off\", _enter: () => { f.debounce(5000, \"toggle\"); } } }); Notes FiniteStateMachine is a loving rewrite of $2. FSMs are ideal for representing many different kinds of systems and interaction patterns. FiniteStateMachine is an intentionally minimalistic implementation. If you're looking for a more powerful FSM library, $2 is an excellent library with more features — and a steeper learning curve."},{"title":"IsFocusWithin","href":"/docs/utilities/is-focus-within","description":"A utility that tracks whether any descendant element has focus within a specified container element.","content":" import Demo from '$lib/components/demos/is-focus-within.svelte'; IsFocusWithin reactively tracks focus state within a container element, updating automatically when focus changes. Demo Usage import { IsFocusWithin } from \"runed\"; let formElement = $state(); const focusWithinForm = new IsFocusWithin(() => formElement); Focus within form: {focusWithinForm.current} Submit Type Definition class IsFocusWithin { constructor(node: MaybeGetter); readonly current: boolean; } `"},{"title":"IsIdle","href":"/docs/utilities/is-idle","description":"Track if a user is idle and the last time they were active.","content":" import Demo from '$lib/components/demos/is-idle.svelte'; IsIdle tracks user activity and determines if they're idle based on a configurable timeout. It monitors mouse movement, keyboard input, and touch events to detect user interaction. Demo Usage import { AnimationFrames, IsIdle } from \"runed\"; const idle = new IsIdle({ timeout: 1000 }); Idle: {idle.current} Last active: {new Date(idle.lastActive).toLocaleTimeString()} Type Definitions interface IsIdleOptions { /** The events that should set the idle state to true * @default ['mousemove', 'mousedown', 'resize', 'keydown', 'touchstart', 'wheel'] */ events?: MaybeGetter; /** The timeout in milliseconds before the idle state is set to true. Defaults to 60 seconds. * @default 60000 */ timeout?: MaybeGetter; /** Detect document visibility changes * @default true */ detectVisibilityChanges?: MaybeGetter; /** The initial state of the idle property * @default false */ initialState?: boolean; } class IsIdle { constructor(options?: IsIdleOptions); readonly current: boolean; readonly lastActive: number; } `"},{"title":"IsInViewport","href":"/docs/utilities/is-in-viewport","description":"Track if an element is visible within the current viewport.","content":" import Demo from '$lib/components/demos/is-in-viewport.svelte'; IsInViewport uses the $2 utility to track if an element is visible within the current viewport. It accepts an element or getter that returns an element and an optional options object that aligns with the $2 utility options. Demo Usage import { IsInViewport } from \"runed\"; let targetNode = $state()!; const inViewport = new IsInViewport(() => targetNode); Target node Target node in viewport: {inViewport.current} Type Definition import { type UseIntersectionObserverOptions } from \"runed\"; export type IsInViewportOptions = UseIntersectionObserverOptions; export declare class IsInViewport { constructor(node: MaybeGetter, options?: IsInViewportOptions); get current(): boolean; } "},{"title":"IsMounted","href":"/docs/utilities/is-mounted","description":"A class that returns the mounted state of the component it's called in.","content":" import Demo from '$lib/components/demos/is-mounted.svelte'; Demo Usage import { IsMounted } from \"runed\"; const isMounted = new IsMounted(); Which is a shorthand for one of the following: import { onMount } from \"svelte\"; const isMounted = $state({ current: false }); onMount(() => { isMounted.current = true; }); or import { untrack } from \"svelte\"; const isMounted = $state({ current: false }); $effect(() => { untrack(() => (isMounted.current = true)); }); `"},{"title":"IsSupported","href":"/docs/utilities/is-supported","description":"Determine if a feature is supported by the environment before using it.","content":"Usage import { IsSupported } from \"runed\"; const isSupported = new IsSupported(() => navigator && \"geolocation\" in navigator); if (isSupported.current) { // Do something with the geolocation API } Type Definition class IsSupported { readonly current: boolean; } `"},{"title":"onClickOutside","href":"/docs/utilities/on-click-outside","description":"Handle clicks outside of a specified element.","content":" import Demo from '$lib/components/demos/on-click-outside.svelte'; onClickOutside detects clicks that occur outside a specified element's boundaries and executes a callback function. It's commonly used for dismissible dropdowns, modals, and other interactive components. Demo Basic Usage import { onClickOutside } from \"runed\"; let container = $state()!; onClickOutside( () => container, () => console.log(\"clicked outside\") ); I'm outside the container Advanced Usage Controlled Listener The function returns control methods to programmatically manage the listener, start and stop: import { onClickOutside } from \"runed\"; let container = $state(); const clickOutside = onClickOutside( () => container, () => console.log(\"Clicked outside\") ); Disable Enable `"},{"title":"PersistedState","href":"/docs/utilities/persisted-state","description":"A reactive state manager that persists and synchronizes state across browser sessions and tabs using Web Storage APIs.","content":" import Demo from '$lib/components/demos/persisted-state.svelte'; import { Callout } from '@svecodocs/kit' PersistedState provides a reactive state container that automatically persists data to browser storage and optionally synchronizes changes across browser tabs in real-time. Demo You can refresh this page and/or open it in another tab to see the count state being persisted and synchronized across sessions and tabs. Usage Initialize PersistedState by providing a unique key and an initial value for the state. import { PersistedState } from \"runed\"; const count = new PersistedState(\"count\", 0); count.current++}>Increment count.current--}>Decrement (count.current = 0)}>Reset Count: {count.current} Configuration Options PersistedState includes an options object that allows you to customize the behavior of the state manager. const state = new PersistedState(\"user-preferences\", initialValue, { // Use sessionStorage instead of localStorage (default: 'local') storage: \"session\", // Disable cross-tab synchronization (default: true) syncTabs: false, // Custom serialization handlers serializer: { serialize: superjson.stringify, deserialize: superjson.parse } }); Storage Options 'local': Data persists until explicitly cleared 'session': Data persists until the browser session ends Cross-Tab Synchronization When syncTabs is enabled (default), changes are automatically synchronized across all browser tabs using the storage event. Custom Serialization Provide custom serialize and deserialize functions to handle complex data types: import superjson from \"superjson\"; // Example with Date objects const lastAccessed = new PersistedState(\"last-accessed\", new Date(), { serializer: { serialize: superjson.stringify, deserialize: superjson.parse } }); `"},{"title":"PressedKeys","href":"/docs/utilities/pressed-keys","description":"Tracks which keys are currently pressed","content":" import Demo from '$lib/components/demos/pressed-keys.svelte'; Demo Usage With an instance of PressedKeys, you can use the has method. const keys = new PressedKeys(); const isArrowDownPressed = $derived(keys.has(\"ArrowDown\")); const isCtrlAPressed = $derived(keys.has(\"Control\", \"a\")); Or get all of the currently pressed keys: const keys = new PressedKeys(); console.log(keys.all()); `"},{"title":"Previous","href":"/docs/utilities/previous","description":"A utility that tracks and provides access to the previous value of a reactive getter.","content":" import Demo from '$lib/components/demos/previous.svelte'; The Previous utility creates a reactive wrapper that maintains the previous value of a getter function. This is particularly useful when you need to compare state changes or implement transition effects. Demo Usage import { Previous } from \"runed\"; let count = $state(0); const previous = new Previous(() => count); count++}>Count: {count} Previous: {${previous.current}} Type Definition class Previous { constructor(getter: () => T); readonly current: T; // Previous value } `"},{"title":"StateHistory","href":"/docs/utilities/state-history","description":"Track state changes with undo/redo capabilities","content":" import Demo from '$lib/components/demos/state-history.svelte'; Demo Usage StateHistory tracks a getter's return value, logging each change into an array. A setter is also required to use the undo and redo functions. import { StateHistory } from \"runed\"; let count = $state(0); const history = new StateHistory(() => count, (c) => (count = c)); history.log[0]; // { snapshot: 0, timestamp: ... } Besides log, the returned object contains undo and redo functionality. import { useStateHistory } from \"runed\"; let count = $state(0); const history = new StateHistory(() => count, (c) => (count = c)); function format(ts: number) { return new Date(ts).toLocaleString(); } {count} count++}>Increment count--}>Decrement Undo Redo `"},{"title":"useActiveElement","href":"/docs/utilities/use-active-element","description":"Get a reactive reference to the currently focused element in the document.","content":" import Demo from '$lib/components/demos/use-active-element.svelte'; import { PropField } from '@svecodocs/kit' useActiveElement is used to get the currently focused element in the document. If you don't need to provide a custom document / shadowRoot, you can use the $2 state instead, as it provides a simpler API. This utility behaves similarly to document.activeElement but with additional features such as: Updates synchronously with DOM focus changes Returns null when no element is focused Safe to use with SSR (Server-Side Rendering) Lightweight alternative to manual focus tracking Demo Usage import { useActiveElement } from \"runed\"; const activeElement = useActiveElement(); {#if activeElement.current} The active element is: {activeElement.current.localName} {:else} No active element found {/if} Options The following options can be passed via the first argument to useActiveElement: The document or shadow root to track focus within. The window to use for focus tracking. "},{"title":"useDebounce","href":"/docs/utilities/use-debounce","description":"A higher-order function that debounces the execution of a function.","content":" import Demo from '$lib/components/demos/use-debounce.svelte'; useDebounce is a utility function that creates a debounced version of a callback function. Debouncing prevents a function from being called too frequently by delaying its execution until after a specified duration of inactivity. Demo Usage import { useDebounce } from \"runed\"; let count = $state(0); let logged = $state(\"\"); let isFirstTime = $state(true); let debounceDuration = $state(1000); const logCount = useDebounce( () => { if (isFirstTime) { isFirstTime = false; logged = You pressed the button ${count} times!; } else { logged = You pressed the button ${count} times since last time!; } count = 0; }, () => debounceDuration ); function ding() { count++; logCount(); } DING DING DING Run now Cancel message {logged || \"Press the button!\"} `"},{"title":"useEventListener","href":"/docs/utilities/use-event-listener","description":"A function that attaches an automatically disposed event listener.","content":" import Demo from '$lib/components/demos/use-event-listener.svelte'; Demo Usage The useEventListener function is particularly useful for attaching event listeners to elements you don't directly control. For instance, if you need to listen for events on the document body or window and can't use ``, or if you receive an element reference from a parent component. Example: Tracking Clicks on the Document // ClickLogger.ts import { useEventListener } from \"runed\"; export class ClickLogger { #clicks = $state(0); constructor() { useEventListener( () => document.body, \"click\", () => this.#clicks++ ); } get clicks() { return this.#clicks; } } This ClickLogger class tracks the number of clicks on the document body using the useEventListener function. Each time a click occurs, the internal counter increments. Svelte Component Usage import { ClickLogger } from \"./ClickLogger.ts\"; const logger = new ClickLogger(); You've clicked the document {logger.clicks} {logger.clicks === 1 ? \"time\" : \"times\"} In the component above, we create an instance of the ClickLogger class to monitor clicks on the document. The displayed text updates dynamically based on the recorded click count. Key Points Automatic Cleanup:** The event listener is removed automatically when the component is destroyed or when the element reference changes. Lazy Initialization:** The target element can be defined using a function, enabling flexible and dynamic behavior. Convenient for Global Listeners:** Ideal for scenarios where attaching event listeners directly to the DOM elements is cumbersome or impractical."},{"title":"useGeolocation","href":"/docs/utilities/use-geolocation","description":"Reactive access to the browser's Geolocation API.","content":" import Demo from '$lib/components/demos/use-geolocation.svelte'; useGeolocation is a reactive wrapper around the browser's $2. Demo Usage import { useGeolocation } from \"runed\"; const location = useGeolocation(); Coords: {JSON.stringify(location.position.coords, null, 2)} Located at: {location.position.timestamp} Error: {JSON.stringify(location.error, null, 2)} Is Supported: {location.isSupported} Pause Resume Type Definitions type UseGeolocationOptions = Partial & { /** Whether to start the watcher immediately upon creation. If set to false, the watcher will only start tracking the position when resume() is called. * @defaultValue true */ immediate?: boolean; }; type UseGeolocationReturn = { readonly isSupported: boolean; readonly position: Omit; readonly error: GeolocationPositionError | null; readonly isPaused: boolean; pause: () => void; resume: () => void; }; `"},{"title":"useIntersectionObserver","href":"/docs/utilities/use-intersection-observer","description":"Watch for intersection changes of a target element.","content":" import Demo from '$lib/components/demos/use-intersection-observer.svelte'; import { Callout } from '@svecodocs/kit' Demo Usage With a reference to an element, you can use the useIntersectionObserver utility to watch for intersection changes of the target element. import { useIntersectionObserver } from \"runed\"; let target = $state(null); let root = $state(null); let isIntersecting = $state(false); useIntersectionObserver( () => target, (entries) => { const entry = entries[0]; if (!entry) return; isIntersecting = entry.isIntersecting; }, { root: () => root } ); {#if isIntersecting} Target is intersecting {:else} Target is not intersecting {/if} Pause You can pause the intersection observer at any point by calling the pause method. const observer = useIntersectionObserver(/* ... */); observer.pause(); Resume You can resume the intersection observer at any point by calling the resume method. const observer = useIntersectionObserver(/* ... */); observer.resume(); Stop You can stop the intersection observer at any point by calling the stop method. const observer = useIntersectionObserver(/* ... */); observer.stop(); isActive You can check if the intersection observer is active by checking the isActive property. This property cannot be destructured as it is a getter. You must access it directly from the observer. const observer = useIntersectionObserver(/* ... */); if (observer.isActive) { // do something } `"},{"title":"useMutationObserver","href":"/docs/utilities/use-mutation-observer","description":"Observe changes in an element","content":" import Demo from '$lib/components/demos/use-mutation-observer.svelte'; Demo Usage With a reference to an element, you can use the useMutationObserver hook to observe changes in the element. import { useMutationObserver } from \"runed\"; let el = $state(null); const messages = $state([]); let className = $state(\"\"); let style = $state(\"\"); useMutationObserver( () => el, (mutations) => { const mutation = mutations[0]; if (!mutation) return; messages.push(mutation.attributeName!); }, { attributes: true } ); setTimeout(() => { className = \"text-brand\"; }, 1000); setTimeout(() => { style = \"font-style: italic;\"; }, 1500); {#each messages as text} Mutation Attribute: {text} {:else} No mutations yet {/each} You can stop the mutation observer at any point by calling the stop method. const { stop } = useMutationObserver(/* ... */); stop(); `"},{"title":"useResizeObserver","href":"/docs/utilities/use-resize-observer","description":"Detects changes in the size of an element","content":" import Demo from '$lib/components/demos/use-resize-observer.svelte'; Demo Usage With a reference to an element, you can use the useResizeObserver utility to detect changes in the size of an element. import { useResizeObserver } from \"runed\"; let el = $state(null); let text = $state(\"\"); useResizeObserver( () => el, (entries) => { const entry = entries[0]; if (!entry) return; const { width, height } = entry.contentRect; text = width: ${width};\\nheight: ${height};; } ); You can stop the resize observer at any point by calling the stop method. const { stop } = useResizeObserver(/* ... */); stop(); `"},{"title":"watch","href":"/docs/utilities/watch","description":"Watch for changes and run a callback","content":"Runes provide a handy way of running a callback when reactive values change: $2. It automatically detects when inner values change, and re-runs the callback. $effect is great, but sometimes you want to manually specify which values should trigger the callback. Svelte provides an untrack function, allowing you to specify that a dependency shouldn't be tracked, but it doesn't provide a way to say that only certain values should be tracked. watch does exactly that. It accepts a getter function, which returns the dependencies of the effect callback. Usage watch Runs a callback whenever one of the sources change. import { watch } from \"runed\"; let count = $state(0); watch(() => count, () => { console.log(count); } ); The callback receives two arguments: The current value of the sources, and the previous value. let count = $state(0); watch(() => count, (curr, prev) => { console.log(count is ${curr}, was ${prev}); } ); You can also send in an array of sources: let age = $state(20); let name = $state(\"bob\"); watch([() => age, () => name], ([age, name], [prevAge, prevName]) => { // ... } watch also accepts an options object. watch(sources, callback, { // First run will only happen after sources change when set to true. // By default, its false. lazy: true }); watch.pre watch.pre is similar to watch, but it uses $2 under the hood. watchOnce In case you want to run the callback only once, you can use watchOnce and watchOnce.pre. It functions identically to the watch and watch.pre otherwise, but it does not accept any options object."}] \ No newline at end of file From 7c7d45d4fd993462a4af1d893d5656b23a77d34d Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 21 Dec 2024 12:42:54 -0500 Subject: [PATCH 09/18] handle psuedo elements --- .../onClickOutside/onClickOutside.svelte.ts | 2 +- .../onClickOutside.test.svelte.ts | 44 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.svelte.ts b/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.svelte.ts index 24cfe3fb..286e46a4 100644 --- a/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.svelte.ts +++ b/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.svelte.ts @@ -187,7 +187,7 @@ function isValidEvent(e: PointerEvent, container: Element): boolean { e.clientY <= rect.top + rect.height && rect.left <= e.clientX && e.clientX <= rect.left + rect.width; - if (wasInsideClick) return false; + return !wasInsideClick; } return ownerDocument.documentElement.contains(target) && !isOrContainsTarget(container, target); } diff --git a/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.test.svelte.ts b/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.test.svelte.ts index 3901ce51..9d571d7c 100644 --- a/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.test.svelte.ts +++ b/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.test.svelte.ts @@ -193,4 +193,48 @@ describe("onClickOutside", () => { await advanceTimers(); expect(callbackFn).toHaveBeenCalledOnce(); }); + + testWithEffect("handles pseudo-element clicks outside element bounds", async () => { + const dialog = document.createElement("dialog"); + document.body.appendChild(dialog); + + onClickOutside(() => dialog, callbackFn); + await tick(); + + dialog.getBoundingClientRect = vi.fn(() => ({ + height: 200, + width: 200, + top: 100, + left: 100, + bottom: 300, + right: 300, + x: 100, + y: 100, + toJSON: vi.fn(), + })); + + // click inside dialog's actual bounds + const insideDialogClick = createPointerEvent("pointerdown", { + clientX: 150, + clientY: 150, + }); + Object.defineProperty(insideDialogClick, "target", { value: dialog }); + document.dispatchEvent(insideDialogClick); + await advanceTimers(); + + expect(callbackFn).not.toHaveBeenCalled(); + + // click outside dialog's bounds but with dialog as target (simulating pseudo-element) + const pseudoElementClick = createPointerEvent("pointerdown", { + clientX: 400, + clientY: 400, + }); + Object.defineProperty(pseudoElementClick, "target", { value: dialog }); + document.dispatchEvent(pseudoElementClick); + await advanceTimers(); + + expect(callbackFn).toHaveBeenCalledOnce(); + + dialog.remove(); + }); }); From 9c8eec8d121dcd502e2ef1e134b274d5912845f8 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 21 Dec 2024 12:45:19 -0500 Subject: [PATCH 10/18] update changeset --- .changeset/bright-rabbits-obey.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/bright-rabbits-obey.md b/.changeset/bright-rabbits-obey.md index e9640e3d..15900897 100644 --- a/.changeset/bright-rabbits-obey.md +++ b/.changeset/bright-rabbits-obey.md @@ -2,4 +2,4 @@ "runed": minor --- -feat: `useClickOutside` +feat: `onClickOutside` From d1cb9ebde495d65cb221d01ac2250d1756322c3f Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 21 Dec 2024 12:46:26 -0500 Subject: [PATCH 11/18] hello deploy? --- sites/docs/src/content/utilities/on-click-outside.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sites/docs/src/content/utilities/on-click-outside.md b/sites/docs/src/content/utilities/on-click-outside.md index ff8a54f5..d5d2597a 100644 --- a/sites/docs/src/content/utilities/on-click-outside.md +++ b/sites/docs/src/content/utilities/on-click-outside.md @@ -81,3 +81,5 @@ const clickOutside = onClickOutside( // later when you want to start the listener clickOutside.start(); ``` + +deploy From e8bce7d788e074b7b2de8ad6a605deccd5f92762 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 21 Dec 2024 12:47:00 -0500 Subject: [PATCH 12/18] oh I have to approve --- sites/docs/src/content/utilities/on-click-outside.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/sites/docs/src/content/utilities/on-click-outside.md b/sites/docs/src/content/utilities/on-click-outside.md index d5d2597a..ff8a54f5 100644 --- a/sites/docs/src/content/utilities/on-click-outside.md +++ b/sites/docs/src/content/utilities/on-click-outside.md @@ -81,5 +81,3 @@ const clickOutside = onClickOutside( // later when you want to start the listener clickOutside.start(); ``` - -deploy From 0bad681f70c5876da628867cebf356ecb5e05cf8 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 21 Dec 2024 12:49:12 -0500 Subject: [PATCH 13/18] doc --- .../lib/utilities/onClickOutside/onClickOutside.svelte.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.svelte.ts b/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.svelte.ts index 286e46a4..eec36d14 100644 --- a/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.svelte.ts +++ b/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.svelte.ts @@ -28,6 +28,7 @@ export type OnClickOutsideOptions = ConfigurableDocument & { * @param {() => void} callback - The callback function to call when a click event occurs outside of the container. * @param {OnClickOutsideOptions} [opts={}] - Optional configuration object. * @param {ConfigurableDocument} [opts.document=defaultDocument] - The document object to use, defaults to the global document. + * @param {boolean} [opts.immediate=true] - Whether the click outside handler is enabled by default or not. * * @example * ```svelte @@ -35,7 +36,7 @@ export type OnClickOutsideOptions = ConfigurableDocument & { * import { onClickOutside } from 'runed' * let container = $state()! * - * onClickOutside(() => container, () => { + * const clickOutside = onClickOutside(() => container, () => { * console.log('clicked outside the container!') * }); * @@ -44,6 +45,9 @@ export type OnClickOutsideOptions = ConfigurableDocument & { * Inside * * + * + * + * Enabled: {clickOutside.enabled} *``` * @see {@link https://runed.dev/docs/utilities/on-click-outside} */ From 09eab5adfdfbd6d85bd4ca49f093149f095e4a1d Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 21 Dec 2024 12:50:19 -0500 Subject: [PATCH 14/18] remove unused --- .../runed/src/lib/internal/utils/platform.ts | 44 ------------------- 1 file changed, 44 deletions(-) delete mode 100644 packages/runed/src/lib/internal/utils/platform.ts diff --git a/packages/runed/src/lib/internal/utils/platform.ts b/packages/runed/src/lib/internal/utils/platform.ts deleted file mode 100644 index c16d8311..00000000 --- a/packages/runed/src/lib/internal/utils/platform.ts +++ /dev/null @@ -1,44 +0,0 @@ -type UserAgentBrand = { - brand: string; - version: string; -}; - -interface NavigatorWithUserAgentData extends Navigator { - userAgentData?: { - brands: UserAgentBrand[]; - platform: string; - }; -} - -/** - * Tests if a given RegExp pattern matches the platform identifier - */ -function testPlatform(pattern: RegExp): boolean { - if (typeof window === "undefined" || !window.navigator) return false; - - const nav = window.navigator as NavigatorWithUserAgentData; - const platform = nav.userAgentData?.platform || nav.platform; - return pattern.test(platform); -} - -export function getIsMac(): boolean { - return testPlatform(/^Mac/i); -} - -export function getIsIPhone(): boolean { - return testPlatform(/^iPhone/i); -} - -export function getIsIPad(): boolean { - const isPlatformIPad = testPlatform(/^iPad/i); - const isTouchCapableMac = - getIsMac() && typeof navigator !== "undefined" && navigator.maxTouchPoints > 1; - - return isPlatformIPad || isTouchCapableMac; -} - -export function getIsIOS(): boolean { - return getIsIPhone() || getIsIPad(); -} - -export const isIOS = /* #__PURE__ */ getIsIOS(); From e1ced9a4c84f22b1b684dedae54886aa7f0e5126 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 21 Dec 2024 12:58:13 -0500 Subject: [PATCH 15/18] pass configurable document to `isValidEvent` --- .../onClickOutside/onClickOutside.svelte.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.svelte.ts b/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.svelte.ts index eec36d14..5f3b25d4 100644 --- a/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.svelte.ts +++ b/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.svelte.ts @@ -66,12 +66,12 @@ export function onClickOutside( let removePointerListeners = noop; const handleClickOutside = useDebounce((e: PointerEvent) => { - if (!node || !nodeOwnerDocument) { + if (!node || !nodeOwnerDocument || !document) { removeClickListener(); return; } - if (pointerDownIntercepted === true || !isValidEvent(e, node)) { + if (pointerDownIntercepted === true || !isValidEvent(e, node, document)) { removeClickListener(); return; } @@ -108,8 +108,8 @@ export function onClickOutside( nodeOwnerDocument, "pointerdown", (e) => { - if (!node) return; - if (isValidEvent(e, node)) { + if (!node || !document) return; + if (isValidEvent(e, node, document)) { pointerDownIntercepted = true; } }, @@ -176,11 +176,11 @@ export function onClickOutside( }; } -function isValidEvent(e: PointerEvent, container: Element): boolean { +function isValidEvent(e: PointerEvent, container: Element, defaultDocument: Document): boolean { if ("button" in e && e.button > 0) return false; const target = e.target; if (!isElement(target)) return false; - const ownerDocument = getOwnerDocument(target); + const ownerDocument = getOwnerDocument(target, defaultDocument); if (!ownerDocument) return false; // handle the case where a user may have pressed a pseudo element by // checking the bounding rect of the container From 33a75aa252fb852d911c8d9e6130a7f803ff1158 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 21 Dec 2024 19:14:56 -0500 Subject: [PATCH 16/18] more docs --- .../src/content/utilities/on-click-outside.md | 82 ++++++++++++++++--- .../demos/on-click-outside-dialog.svelte | 44 ++++++++++ .../components/demos/on-click-outside.svelte | 21 ++++- .../src/routes/api/search.json/search.json | 2 +- 4 files changed, 133 insertions(+), 16 deletions(-) create mode 100644 sites/docs/src/lib/components/demos/on-click-outside-dialog.svelte diff --git a/sites/docs/src/content/utilities/on-click-outside.md b/sites/docs/src/content/utilities/on-click-outside.md index ff8a54f5..214f587d 100644 --- a/sites/docs/src/content/utilities/on-click-outside.md +++ b/sites/docs/src/content/utilities/on-click-outside.md @@ -6,6 +6,7 @@ category: Sensors `onClickOutside` detects clicks that occur outside a specified element's boundaries and executes a @@ -47,25 +48,42 @@ a reactive read-only property `enabled` to check the current status of the liste -
-

Status: {clickOutside.enabled ? "Enabled" : "Disabled"}

- - -
+ function openDialog() { + dialog.showModal(); + clickOutside.start(); + } -
- -
+ function closeDialog() { + dialog.close(); + clickOutside.stop(); + } + + + + +
+ +
+
``` +Here's an example of using `onClickOutside` with a ``: + + + +## Options + ### Immediate By default, `onClickOutside` will start listening for clicks outside the element immediately. You @@ -81,3 +99,43 @@ const clickOutside = onClickOutside( // later when you want to start the listener clickOutside.start(); ``` + +## Type Definitions + +```ts +export type OnClickOutsideOptions = ConfigurableDocument & { + /** + * Whether the click outside handler is enabled by default or not. + * If set to false, the handler will not be active until enabled by + * calling the returned `start` function + * + * @default true + */ + immediate?: boolean; +}; + +/** + * A utility that calls a given callback when a click event occurs outside of + * a specified container element. + * + * @template T - The type of the container element, defaults to HTMLElement. + * @param {MaybeElementGetter} container - The container element or a getter function that returns the container element. + * @param {() => void} callback - The callback function to call when a click event occurs outside of the container. + * @param {OnClickOutsideOptions} [opts={}] - Optional configuration object. + * @param {ConfigurableDocument} [opts.document=defaultDocument] - The document object to use, defaults to the global document. + * @param {boolean} [opts.immediate=true] - Whether the click outside handler is enabled by default or not. + * @see {@link https://runed.dev/docs/utilities/on-click-outside} + */ +export declare function onClickOutside( + container: MaybeElementGetter, + callback: (event: PointerEvent) => void, + opts?: OnClickOutsideOptions +): { + stop: () => boolean; + start: () => boolean; + /** + * Whether the click outside handler is currently enabled or not. + */ + readonly enabled: boolean; +}; +``` diff --git a/sites/docs/src/lib/components/demos/on-click-outside-dialog.svelte b/sites/docs/src/lib/components/demos/on-click-outside-dialog.svelte new file mode 100644 index 00000000..8d7303b5 --- /dev/null +++ b/sites/docs/src/lib/components/demos/on-click-outside-dialog.svelte @@ -0,0 +1,44 @@ + + + + + +
+

This is a dialog.

+

+ Lorem, ipsum dolor sit amet consectetur adipisicing elit. Neque sunt aut sit exercitationem + deleniti doloremque quo quasi, expedita omnis dicta eaque, eveniet nesciunt nobis sint + atque? Praesentium facilis officiis perferendis. +

+ + +
+
+
diff --git a/sites/docs/src/lib/components/demos/on-click-outside.svelte b/sites/docs/src/lib/components/demos/on-click-outside.svelte index ef4e26cd..b4f4987a 100644 --- a/sites/docs/src/lib/components/demos/on-click-outside.svelte +++ b/sites/docs/src/lib/components/demos/on-click-outside.svelte @@ -4,12 +4,15 @@ let containerText = $state("Has not clicked outside yet."); let container = $state()!; + let dialog = $state()!; const clickOutside = onClickOutside( - () => container, + () => dialog, () => { - containerText = "Has clicked outside."; - } + dialog.close(); + clickOutside.stop(); + }, + { immediate: false } ); @@ -34,4 +37,16 @@ + + +
+

This is a dialog.

+ +
+
diff --git a/sites/docs/src/routes/api/search.json/search.json b/sites/docs/src/routes/api/search.json/search.json index 1be64583..36670e8e 100644 --- a/sites/docs/src/routes/api/search.json/search.json +++ b/sites/docs/src/routes/api/search.json/search.json @@ -1 +1 @@ -[{"title":"Getting Started","href":"/docs/getting-started","description":"Learn how to install and use Runed in your projects.","content":"Installation Install Runed using your favorite package manager: npm install runed Usage Import one of the utilities you need to either a .svelte or .svelte.js|ts file and start using it: import { activeElement } from \"runed\"; let inputElement = $state(); {#if activeElement.current === inputElement} The input element is active! {/if} or import { activeElement } from \"runed\"; function logActiveElement() { $effect(() => { console.log(\"Active element is \", activeElement.current); }); } logActiveElement(); `"},{"title":"Introduction","href":"/docs/index","description":"Runes are magic, but what good is magic if you don't have a wand?","content":"Runed is a collection of utilities for Svelte 5 that make composing powerful applications and libraries a breeze, leveraging the power of $2. Why Runed? Svelte 5 Runes unlock immense power by providing a set of primitives that allow us to build impressive applications and libraries with ease. However, building complex applications often requires more than just the primitives provided by Svelte Runes. Runed takes those primitives to the next level by providing: Powerful Utilities**: A set of carefully crafted utility functions and classes that simplify common tasks and reduce boilerplate. Collective Efforts**: We often find ourselves writing the same utility functions over and over again. Runed aims to provide a single source of truth for these utilities, allowing the community to contribute, test, and benefit from them. Consistency**: A consistent set of APIs and behaviors across all utilities, so you can focus on building your projects instead of constantly learning new APIs. Reactivity First**: Powered by Svelte 5's new reactivity system, Runed utilities are designed to handle reactive state and side effects with ease. Type Safety**: Full TypeScript support to catch errors early and provide a better developer experience. Ideas and Principles Embrace the Magic of Runes Svelte Runes are a powerful new paradigm. Runed fully embraces this concept and explores its potential. Our goal is to make working with Runes feel as natural and intuitive as possible. Enhance, Don't Replace Runed is not here to replace Svelte's core functionality, but to enhance and extend it. Our utilities should feel like a natural extension of Svelte, not a separate framework. Progressive Complexity Simple things should be simple, complex things should be possible. Runed provides easy-to-use defaults while allowing for advanced customization when needed. Open Source and Community Collaboration Runed is an open-source, MIT licensed project that welcomes all forms of contributions from the community. Whether it's bug reports, feature requests, or code contributions, your input will help make Runed the best it can be."},{"title":"activeElement","href":"/docs/utilities/active-element","description":"Track and access the currently focused DOM element","content":" import Demo from '$lib/components/demos/active-element.svelte'; activeElement provides reactive access to the currently focused DOM element in your application, similar to document.activeElement but with reactive updates. Updates synchronously with DOM focus changes Returns null when no element is focused Safe to use with SSR (Server-Side Rendering) Lightweight alternative to manual focus tracking If you need to provide a custom document / shadowRoot, you can use the $2 utility instead, which provides a more flexible API. Demo Usage import { activeElement } from \"runed\"; Currently active element: {activeElement.current?.localName ?? \"No active element found\"} Type Definition interface ActiveElement { readonly current: Element | null; } `"},{"title":"AnimationFrames","href":"/docs/utilities/animation-frames","description":"A wrapper for requestAnimationFrame with FPS control and frame metrics","content":" import Demo from '$lib/components/demos/animation-frames.svelte'; AnimationFrames provides a declarative API over the browser's $2, offering FPS limiting capabilities and frame metrics while handling cleanup automatically. Demo Usage import { AnimationFrames } from \"runed\"; import { Slider } from \"../ui/slider\"; // Check out shadcn-svelte! let frames = $state(0); let fpsLimit = $state(10); let delta = $state(0); const animation = new AnimationFrames( (args) => { frames++; delta = args.delta; }, { fpsLimit: () => fpsLimit } ); const stats = $derived( Frames: ${frames}\\nFPS: ${animation.fps.toFixed(0)}\\nDelta: ${delta.toFixed(0)}ms ); {stats} {animation.running ? \"Stop\" : \"Start\"} FPS limit: {fpsLimit}{fpsLimit === 0 ? \" (not limited)\" : \"\"} (fpsLimit = value[0] ?? 0)} min={0} max={144} /> `"},{"title":"Context","href":"/docs/utilities/context","description":"A wrapper around Svelte's Context API that provides type safety and improved ergonomics for sharing data between components.","content":" import { Steps, Step, Callout } from '@svecodocs/kit'; Context allows you to pass data through the component tree without explicitly passing props through every level. It's useful for sharing data that many components need, like themes, authentication state, or localization preferences. The Context class provides a type-safe way to define, set, and retrieve context values. Usage Creating a Context First, create a Context instance with the type of value it will hold: import { Context } from \"runed\"; export const myTheme = new Context(\"theme\"); Creating a Context instance only defines the context - it doesn't actually set any value. The value passed to the constructor (\"theme\" in this example) is just an identifier used for debugging and error messages. Think of this step as creating a \"container\" that will later hold your context value. The container is typed (in this case to only accept \"light\" or \"dark\" as values) but remains empty until you explicitly call myTheme.set() during component initialization. This separation between defining and setting context allows you to: Keep context definitions in separate files Reuse the same context definition across different parts of your app Maintain type safety throughout your application Set different values for the same context in different component trees Setting Context Values Set the context value in a parent component during initialization. import { myTheme } from \"./context\"; let { data, children } = $props(); myTheme.set(data.theme); {@render children?.()} Context must be set during component initialization, similar to lifecycle functions like onMount. You cannot set context inside event handlers or callbacks. Reading Context Values Child components can access the context using get() or getOr() import { myTheme } from \"./context\"; const theme = myTheme.get(); // or with a fallback value if the context is not set const theme = myTheme.getOr(\"light\"); Type Definition class Context { /** @param name The name of the context. This is used for generating the context key and error messages. */ constructor(name: string) {} /** The key used to get and set the context. * It is not recommended to use this value directly. Instead, use the methods provided by this class. */ get key(): symbol; /** Checks whether this has been set in the context of a parent component. * Must be called during component initialization. */ exists(): boolean; /** Retrieves the context that belongs to the closest parent component. * Must be called during component initialization. * @throws An error if the context does not exist. */ get(): TContext; /** Retrieves the context that belongs to the closest parent component, or the given fallback value if the context does not exist. * Must be called during component initialization. */ getOr(fallback: TFallback): TContext | TFallback; /** Associates the given value with the current component and returns it. * Must be called during component initialization. */ set(context: TContext): TContext; } `"},{"title":"Debounced","href":"/docs/utilities/debounced","description":"A wrapper over `useDebounce` that returns a debounced state.","content":" import Demo from '$lib/components/demos/debounced.svelte'; Demo Usage This is a simple wrapper over $2 that returns a debounced state. import { Debounced } from \"runed\"; let search = $state(\"\"); const debounced = new Debounced(() => search, 500); You searched for: {debounced.current} You may cancel the pending update, run it immediately, or set a new value. Setting a new value immediately also cancels any pending updates. let count = $state(0); const debounced = new Debounced(() => count, 500); count = 1; debounced.cancel(); // after a while... console.log(debounced.current); // Still 0! count = 2; console.log(debounced.current); // Still 0! debounced.setImmediately(count); console.log(debounced.current); // 2 count = 3; console.log(debounced.current); // 2 await debounced.updateImmediately(); console.log(debounced.current); // 3 `"},{"title":"ElementRect","href":"/docs/utilities/element-rect","description":"Track element dimensions and position reactively","content":" import Demo from '$lib/components/demos/element-rect.svelte'; ElementRect provides reactive access to an element's dimensions and position information, automatically updating when the element's size or position changes. Demo Usage import { ElementRect } from \"runed\"; let el = $state(); const rect = new ElementRect(() => el); Width: {rect.width} Height: {rect.height} {JSON.stringify(rect.current, null, 2)} Type Definition type Rect = Omit; interface ElementRectOptions { initialRect?: DOMRect; } class ElementRect { constructor(node: MaybeGetter, options?: ElementRectOptions); readonly current: Rect; readonly width: number; readonly height: number; readonly top: number; readonly left: number; readonly right: number; readonly bottom: number; readonly x: number; readonly y: number; } `"},{"title":"ElementSize","href":"/docs/utilities/element-size","description":"Track element dimensions reactively","content":" import Demo from '$lib/components/demos/element-size.svelte'; ElementSize provides reactive access to an element's width and height, automatically updating when the element's dimensions change. Similar to ElementRect but focused only on size measurements. Demo Usage import { ElementSize } from \"runed\"; let el = $state() as HTMLElement; const size = new ElementSize(() => el); Width: {size.width} Height: {size.height} Type Definition interface ElementSize { readonly width: number; readonly height: number; } `"},{"title":"FiniteStateMachine","href":"/docs/utilities/finite-state-machine","description":"Defines a strongly-typed finite state machine.","content":" import Demo from '$lib/components/demos/finite-state-machine.svelte'; Demo type MyStates = \"disabled\" | \"idle\" | \"running\"; type MyEvents = \"toggleEnabled\" | \"start\" | \"stop\"; const f = new FiniteStateMachine(\"disabled\", { disabled: { toggleEnabled: \"idle\" }, idle: { toggleEnabled: \"disabled\", start: \"running\" }, running: { _enter: () => { f.debounce(2000, \"stop\"); }, stop: \"idle\", toggleEnabled: \"disabled\" } }); Usage Finite state machines (often abbreviated as \"FSMs\") are useful for tracking and manipulating something that could be in one of many different states. It centralizes the definition of every possible state and the events that might trigger a transition from one state to another. Here is a state machine describing a simple toggle switch: import { FiniteStateMachine } from \"runed\"; type MyStates = \"on\" | \"off\"; type MyEvents = \"toggle\"; const f = new FiniteStateMachine(\"off\", { off: { toggle: \"on\" }, on: { toggle: \"off\" } }); The first argument to the FiniteStateMachine constructor is the initial state. The second argument is an object with one key for each state. Each state then describes which events are valid for that state, and which state that event should lead to. In the above example of a simple switch, there are two states (on and off). The toggle event in either state leads to the other state. You send events to the FSM using f.send. To send the toggle event, invoke f.send('toggle'). Actions Maybe you want fancier logic for an event handler, or you want to conditionally transition into another state. Instead of strings, you can use actions. An action is a function that returns a state. An action can receive parameters, and it can use those parameters to dynamically choose which state should come next. It can also prevent a state transition by returning nothing. type MyStates = \"on\" | \"off\" | \"cooldown\"; const f = new FiniteStateMachine(\"off\", { off: { toggle: () => { if (isTuesday) { // Switch can only turn on during Tuesdays return \"on\"; } // All other days, nothing is returned and state is unchanged. } }, on: { toggle: (heldMillis: number) => { // You can also dynamically return the next state! // Only turn off if switch is depressed for 3 seconds if (heldMillis > 3000) { return \"off\"; } } } }); Lifecycle methods You can define special handlers that are invoked whenever a state is entered or exited: const f = new FiniteStateMachine('off', { off: { toggle: 'on' _enter: (meta) => { console.log('switch is off') } _exit: (meta) => { console.log('switch is no longer off') } }, on: { toggle: 'off' _enter: (meta) => { console.log('switch is on') } _exit: (meta) => { console.log('switch is no longer on') } } }); The lifecycle methods are invoked with a metadata object containing some useful information: from: the name of the event that is being exited to: the name of the event that is being entered event: the name of the event which has triggered the transition args: (optional) you may pass additional metadata when invoking an action with f.send('theAction', additional, params, as, args) The _enter handler for the initial state is called upon creation of the FSM. It is invoked with both the from and event fields set to null. Wildcard handlers There is one special state used as a fallback: *. If you have the fallback state, and you attempt to send() an event that is not handled by the current state, then it will try to find a handler for that event on the * state before discarding the event: const f = new FiniteStateMachine('off', { off: { toggle: 'on' }, on: { toggle: 'off' } '*': { emergency: 'off' } }); // will always result in the switch turning off. f.send('emergency'); Debouncing Frequently, you want to transition to another state after some time has elapsed. To do this, use the debounce method: f.send(\"toggle\"); // turn on immediately f.debounce(5000, \"toggle\"); // turn off in 5000 milliseconds If you re-invoke debounce with the same event, it will cancel the existing timer and start the countdown over: // schedule a toggle in five seconds f.debounce(5000, \"toggle\"); // ... less than 5000ms elapses ... f.debounce(5000, \"toggle\"); // The second call cancels the original timer, and starts a new one You can also use debounce in both actions and lifecycle methods. In both of the following examples, the lightswitch will turn itself off five seconds after it was turned on: const f = new FiniteStateMachine(\"off\", { off: { toggle: () => { f.debounce(5000, \"toggle\"); return \"on\"; } }, on: { toggle: \"off\" } }); const f = new FiniteStateMachine(\"off\", { off: { toggle: \"on\" }, on: { toggle: \"off\", _enter: () => { f.debounce(5000, \"toggle\"); } } }); Notes FiniteStateMachine is a loving rewrite of $2. FSMs are ideal for representing many different kinds of systems and interaction patterns. FiniteStateMachine is an intentionally minimalistic implementation. If you're looking for a more powerful FSM library, $2 is an excellent library with more features — and a steeper learning curve."},{"title":"IsFocusWithin","href":"/docs/utilities/is-focus-within","description":"A utility that tracks whether any descendant element has focus within a specified container element.","content":" import Demo from '$lib/components/demos/is-focus-within.svelte'; IsFocusWithin reactively tracks focus state within a container element, updating automatically when focus changes. Demo Usage import { IsFocusWithin } from \"runed\"; let formElement = $state(); const focusWithinForm = new IsFocusWithin(() => formElement); Focus within form: {focusWithinForm.current} Submit Type Definition class IsFocusWithin { constructor(node: MaybeGetter); readonly current: boolean; } `"},{"title":"IsIdle","href":"/docs/utilities/is-idle","description":"Track if a user is idle and the last time they were active.","content":" import Demo from '$lib/components/demos/is-idle.svelte'; IsIdle tracks user activity and determines if they're idle based on a configurable timeout. It monitors mouse movement, keyboard input, and touch events to detect user interaction. Demo Usage import { AnimationFrames, IsIdle } from \"runed\"; const idle = new IsIdle({ timeout: 1000 }); Idle: {idle.current} Last active: {new Date(idle.lastActive).toLocaleTimeString()} Type Definitions interface IsIdleOptions { /** The events that should set the idle state to true * @default ['mousemove', 'mousedown', 'resize', 'keydown', 'touchstart', 'wheel'] */ events?: MaybeGetter; /** The timeout in milliseconds before the idle state is set to true. Defaults to 60 seconds. * @default 60000 */ timeout?: MaybeGetter; /** Detect document visibility changes * @default true */ detectVisibilityChanges?: MaybeGetter; /** The initial state of the idle property * @default false */ initialState?: boolean; } class IsIdle { constructor(options?: IsIdleOptions); readonly current: boolean; readonly lastActive: number; } `"},{"title":"IsInViewport","href":"/docs/utilities/is-in-viewport","description":"Track if an element is visible within the current viewport.","content":" import Demo from '$lib/components/demos/is-in-viewport.svelte'; IsInViewport uses the $2 utility to track if an element is visible within the current viewport. It accepts an element or getter that returns an element and an optional options object that aligns with the $2 utility options. Demo Usage import { IsInViewport } from \"runed\"; let targetNode = $state()!; const inViewport = new IsInViewport(() => targetNode); Target node Target node in viewport: {inViewport.current} Type Definition import { type UseIntersectionObserverOptions } from \"runed\"; export type IsInViewportOptions = UseIntersectionObserverOptions; export declare class IsInViewport { constructor(node: MaybeGetter, options?: IsInViewportOptions); get current(): boolean; } "},{"title":"IsMounted","href":"/docs/utilities/is-mounted","description":"A class that returns the mounted state of the component it's called in.","content":" import Demo from '$lib/components/demos/is-mounted.svelte'; Demo Usage import { IsMounted } from \"runed\"; const isMounted = new IsMounted(); Which is a shorthand for one of the following: import { onMount } from \"svelte\"; const isMounted = $state({ current: false }); onMount(() => { isMounted.current = true; }); or import { untrack } from \"svelte\"; const isMounted = $state({ current: false }); $effect(() => { untrack(() => (isMounted.current = true)); }); `"},{"title":"IsSupported","href":"/docs/utilities/is-supported","description":"Determine if a feature is supported by the environment before using it.","content":"Usage import { IsSupported } from \"runed\"; const isSupported = new IsSupported(() => navigator && \"geolocation\" in navigator); if (isSupported.current) { // Do something with the geolocation API } Type Definition class IsSupported { readonly current: boolean; } `"},{"title":"onClickOutside","href":"/docs/utilities/on-click-outside","description":"Handle clicks outside of a specified element.","content":" import Demo from '$lib/components/demos/on-click-outside.svelte'; onClickOutside detects clicks that occur outside a specified element's boundaries and executes a callback function. It's commonly used for dismissible dropdowns, modals, and other interactive components. Demo Basic Usage import { onClickOutside } from \"runed\"; let container = $state()!; onClickOutside( () => container, () => console.log(\"clicked outside\") ); I'm outside the container Advanced Usage Controlled Listener The function returns control methods to programmatically manage the listener, start and stop: import { onClickOutside } from \"runed\"; let container = $state(); const clickOutside = onClickOutside( () => container, () => console.log(\"Clicked outside\") ); Disable Enable `"},{"title":"PersistedState","href":"/docs/utilities/persisted-state","description":"A reactive state manager that persists and synchronizes state across browser sessions and tabs using Web Storage APIs.","content":" import Demo from '$lib/components/demos/persisted-state.svelte'; import { Callout } from '@svecodocs/kit' PersistedState provides a reactive state container that automatically persists data to browser storage and optionally synchronizes changes across browser tabs in real-time. Demo You can refresh this page and/or open it in another tab to see the count state being persisted and synchronized across sessions and tabs. Usage Initialize PersistedState by providing a unique key and an initial value for the state. import { PersistedState } from \"runed\"; const count = new PersistedState(\"count\", 0); count.current++}>Increment count.current--}>Decrement (count.current = 0)}>Reset Count: {count.current} Configuration Options PersistedState includes an options object that allows you to customize the behavior of the state manager. const state = new PersistedState(\"user-preferences\", initialValue, { // Use sessionStorage instead of localStorage (default: 'local') storage: \"session\", // Disable cross-tab synchronization (default: true) syncTabs: false, // Custom serialization handlers serializer: { serialize: superjson.stringify, deserialize: superjson.parse } }); Storage Options 'local': Data persists until explicitly cleared 'session': Data persists until the browser session ends Cross-Tab Synchronization When syncTabs is enabled (default), changes are automatically synchronized across all browser tabs using the storage event. Custom Serialization Provide custom serialize and deserialize functions to handle complex data types: import superjson from \"superjson\"; // Example with Date objects const lastAccessed = new PersistedState(\"last-accessed\", new Date(), { serializer: { serialize: superjson.stringify, deserialize: superjson.parse } }); `"},{"title":"PressedKeys","href":"/docs/utilities/pressed-keys","description":"Tracks which keys are currently pressed","content":" import Demo from '$lib/components/demos/pressed-keys.svelte'; Demo Usage With an instance of PressedKeys, you can use the has method. const keys = new PressedKeys(); const isArrowDownPressed = $derived(keys.has(\"ArrowDown\")); const isCtrlAPressed = $derived(keys.has(\"Control\", \"a\")); Or get all of the currently pressed keys: const keys = new PressedKeys(); console.log(keys.all()); `"},{"title":"Previous","href":"/docs/utilities/previous","description":"A utility that tracks and provides access to the previous value of a reactive getter.","content":" import Demo from '$lib/components/demos/previous.svelte'; The Previous utility creates a reactive wrapper that maintains the previous value of a getter function. This is particularly useful when you need to compare state changes or implement transition effects. Demo Usage import { Previous } from \"runed\"; let count = $state(0); const previous = new Previous(() => count); count++}>Count: {count} Previous: {${previous.current}} Type Definition class Previous { constructor(getter: () => T); readonly current: T; // Previous value } `"},{"title":"StateHistory","href":"/docs/utilities/state-history","description":"Track state changes with undo/redo capabilities","content":" import Demo from '$lib/components/demos/state-history.svelte'; Demo Usage StateHistory tracks a getter's return value, logging each change into an array. A setter is also required to use the undo and redo functions. import { StateHistory } from \"runed\"; let count = $state(0); const history = new StateHistory(() => count, (c) => (count = c)); history.log[0]; // { snapshot: 0, timestamp: ... } Besides log, the returned object contains undo and redo functionality. import { useStateHistory } from \"runed\"; let count = $state(0); const history = new StateHistory(() => count, (c) => (count = c)); function format(ts: number) { return new Date(ts).toLocaleString(); } {count} count++}>Increment count--}>Decrement Undo Redo `"},{"title":"useActiveElement","href":"/docs/utilities/use-active-element","description":"Get a reactive reference to the currently focused element in the document.","content":" import Demo from '$lib/components/demos/use-active-element.svelte'; import { PropField } from '@svecodocs/kit' useActiveElement is used to get the currently focused element in the document. If you don't need to provide a custom document / shadowRoot, you can use the $2 state instead, as it provides a simpler API. This utility behaves similarly to document.activeElement but with additional features such as: Updates synchronously with DOM focus changes Returns null when no element is focused Safe to use with SSR (Server-Side Rendering) Lightweight alternative to manual focus tracking Demo Usage import { useActiveElement } from \"runed\"; const activeElement = useActiveElement(); {#if activeElement.current} The active element is: {activeElement.current.localName} {:else} No active element found {/if} Options The following options can be passed via the first argument to useActiveElement: The document or shadow root to track focus within. The window to use for focus tracking. "},{"title":"useDebounce","href":"/docs/utilities/use-debounce","description":"A higher-order function that debounces the execution of a function.","content":" import Demo from '$lib/components/demos/use-debounce.svelte'; useDebounce is a utility function that creates a debounced version of a callback function. Debouncing prevents a function from being called too frequently by delaying its execution until after a specified duration of inactivity. Demo Usage import { useDebounce } from \"runed\"; let count = $state(0); let logged = $state(\"\"); let isFirstTime = $state(true); let debounceDuration = $state(1000); const logCount = useDebounce( () => { if (isFirstTime) { isFirstTime = false; logged = You pressed the button ${count} times!; } else { logged = You pressed the button ${count} times since last time!; } count = 0; }, () => debounceDuration ); function ding() { count++; logCount(); } DING DING DING Run now Cancel message {logged || \"Press the button!\"} `"},{"title":"useEventListener","href":"/docs/utilities/use-event-listener","description":"A function that attaches an automatically disposed event listener.","content":" import Demo from '$lib/components/demos/use-event-listener.svelte'; Demo Usage The useEventListener function is particularly useful for attaching event listeners to elements you don't directly control. For instance, if you need to listen for events on the document body or window and can't use ``, or if you receive an element reference from a parent component. Example: Tracking Clicks on the Document // ClickLogger.ts import { useEventListener } from \"runed\"; export class ClickLogger { #clicks = $state(0); constructor() { useEventListener( () => document.body, \"click\", () => this.#clicks++ ); } get clicks() { return this.#clicks; } } This ClickLogger class tracks the number of clicks on the document body using the useEventListener function. Each time a click occurs, the internal counter increments. Svelte Component Usage import { ClickLogger } from \"./ClickLogger.ts\"; const logger = new ClickLogger(); You've clicked the document {logger.clicks} {logger.clicks === 1 ? \"time\" : \"times\"} In the component above, we create an instance of the ClickLogger class to monitor clicks on the document. The displayed text updates dynamically based on the recorded click count. Key Points Automatic Cleanup:** The event listener is removed automatically when the component is destroyed or when the element reference changes. Lazy Initialization:** The target element can be defined using a function, enabling flexible and dynamic behavior. Convenient for Global Listeners:** Ideal for scenarios where attaching event listeners directly to the DOM elements is cumbersome or impractical."},{"title":"useGeolocation","href":"/docs/utilities/use-geolocation","description":"Reactive access to the browser's Geolocation API.","content":" import Demo from '$lib/components/demos/use-geolocation.svelte'; useGeolocation is a reactive wrapper around the browser's $2. Demo Usage import { useGeolocation } from \"runed\"; const location = useGeolocation(); Coords: {JSON.stringify(location.position.coords, null, 2)} Located at: {location.position.timestamp} Error: {JSON.stringify(location.error, null, 2)} Is Supported: {location.isSupported} Pause Resume Type Definitions type UseGeolocationOptions = Partial & { /** Whether to start the watcher immediately upon creation. If set to false, the watcher will only start tracking the position when resume() is called. * @defaultValue true */ immediate?: boolean; }; type UseGeolocationReturn = { readonly isSupported: boolean; readonly position: Omit; readonly error: GeolocationPositionError | null; readonly isPaused: boolean; pause: () => void; resume: () => void; }; `"},{"title":"useIntersectionObserver","href":"/docs/utilities/use-intersection-observer","description":"Watch for intersection changes of a target element.","content":" import Demo from '$lib/components/demos/use-intersection-observer.svelte'; import { Callout } from '@svecodocs/kit' Demo Usage With a reference to an element, you can use the useIntersectionObserver utility to watch for intersection changes of the target element. import { useIntersectionObserver } from \"runed\"; let target = $state(null); let root = $state(null); let isIntersecting = $state(false); useIntersectionObserver( () => target, (entries) => { const entry = entries[0]; if (!entry) return; isIntersecting = entry.isIntersecting; }, { root: () => root } ); {#if isIntersecting} Target is intersecting {:else} Target is not intersecting {/if} Pause You can pause the intersection observer at any point by calling the pause method. const observer = useIntersectionObserver(/* ... */); observer.pause(); Resume You can resume the intersection observer at any point by calling the resume method. const observer = useIntersectionObserver(/* ... */); observer.resume(); Stop You can stop the intersection observer at any point by calling the stop method. const observer = useIntersectionObserver(/* ... */); observer.stop(); isActive You can check if the intersection observer is active by checking the isActive property. This property cannot be destructured as it is a getter. You must access it directly from the observer. const observer = useIntersectionObserver(/* ... */); if (observer.isActive) { // do something } `"},{"title":"useMutationObserver","href":"/docs/utilities/use-mutation-observer","description":"Observe changes in an element","content":" import Demo from '$lib/components/demos/use-mutation-observer.svelte'; Demo Usage With a reference to an element, you can use the useMutationObserver hook to observe changes in the element. import { useMutationObserver } from \"runed\"; let el = $state(null); const messages = $state([]); let className = $state(\"\"); let style = $state(\"\"); useMutationObserver( () => el, (mutations) => { const mutation = mutations[0]; if (!mutation) return; messages.push(mutation.attributeName!); }, { attributes: true } ); setTimeout(() => { className = \"text-brand\"; }, 1000); setTimeout(() => { style = \"font-style: italic;\"; }, 1500); {#each messages as text} Mutation Attribute: {text} {:else} No mutations yet {/each} You can stop the mutation observer at any point by calling the stop method. const { stop } = useMutationObserver(/* ... */); stop(); `"},{"title":"useResizeObserver","href":"/docs/utilities/use-resize-observer","description":"Detects changes in the size of an element","content":" import Demo from '$lib/components/demos/use-resize-observer.svelte'; Demo Usage With a reference to an element, you can use the useResizeObserver utility to detect changes in the size of an element. import { useResizeObserver } from \"runed\"; let el = $state(null); let text = $state(\"\"); useResizeObserver( () => el, (entries) => { const entry = entries[0]; if (!entry) return; const { width, height } = entry.contentRect; text = width: ${width};\\nheight: ${height};; } ); You can stop the resize observer at any point by calling the stop method. const { stop } = useResizeObserver(/* ... */); stop(); `"},{"title":"watch","href":"/docs/utilities/watch","description":"Watch for changes and run a callback","content":"Runes provide a handy way of running a callback when reactive values change: $2. It automatically detects when inner values change, and re-runs the callback. $effect is great, but sometimes you want to manually specify which values should trigger the callback. Svelte provides an untrack function, allowing you to specify that a dependency shouldn't be tracked, but it doesn't provide a way to say that only certain values should be tracked. watch does exactly that. It accepts a getter function, which returns the dependencies of the effect callback. Usage watch Runs a callback whenever one of the sources change. import { watch } from \"runed\"; let count = $state(0); watch(() => count, () => { console.log(count); } ); The callback receives two arguments: The current value of the sources, and the previous value. let count = $state(0); watch(() => count, (curr, prev) => { console.log(count is ${curr}, was ${prev}); } ); You can also send in an array of sources: let age = $state(20); let name = $state(\"bob\"); watch([() => age, () => name], ([age, name], [prevAge, prevName]) => { // ... } watch also accepts an options object. watch(sources, callback, { // First run will only happen after sources change when set to true. // By default, its false. lazy: true }); watch.pre watch.pre is similar to watch, but it uses $2 under the hood. watchOnce In case you want to run the callback only once, you can use watchOnce and watchOnce.pre. It functions identically to the watch and watch.pre otherwise, but it does not accept any options object."}] \ No newline at end of file +[{"title":"Getting Started","href":"/docs/getting-started","description":"Learn how to install and use Runed in your projects.","content":"Installation Install Runed using your favorite package manager: npm install runed Usage Import one of the utilities you need to either a .svelte or .svelte.js|ts file and start using it: import { activeElement } from \"runed\"; let inputElement = $state(); {#if activeElement.current === inputElement} The input element is active! {/if} or import { activeElement } from \"runed\"; function logActiveElement() { $effect(() => { console.log(\"Active element is \", activeElement.current); }); } logActiveElement(); `"},{"title":"Introduction","href":"/docs/index","description":"Runes are magic, but what good is magic if you don't have a wand?","content":"Runed is a collection of utilities for Svelte 5 that make composing powerful applications and libraries a breeze, leveraging the power of $2. Why Runed? Svelte 5 Runes unlock immense power by providing a set of primitives that allow us to build impressive applications and libraries with ease. However, building complex applications often requires more than just the primitives provided by Svelte Runes. Runed takes those primitives to the next level by providing: Powerful Utilities**: A set of carefully crafted utility functions and classes that simplify common tasks and reduce boilerplate. Collective Efforts**: We often find ourselves writing the same utility functions over and over again. Runed aims to provide a single source of truth for these utilities, allowing the community to contribute, test, and benefit from them. Consistency**: A consistent set of APIs and behaviors across all utilities, so you can focus on building your projects instead of constantly learning new APIs. Reactivity First**: Powered by Svelte 5's new reactivity system, Runed utilities are designed to handle reactive state and side effects with ease. Type Safety**: Full TypeScript support to catch errors early and provide a better developer experience. Ideas and Principles Embrace the Magic of Runes Svelte Runes are a powerful new paradigm. Runed fully embraces this concept and explores its potential. Our goal is to make working with Runes feel as natural and intuitive as possible. Enhance, Don't Replace Runed is not here to replace Svelte's core functionality, but to enhance and extend it. Our utilities should feel like a natural extension of Svelte, not a separate framework. Progressive Complexity Simple things should be simple, complex things should be possible. Runed provides easy-to-use defaults while allowing for advanced customization when needed. Open Source and Community Collaboration Runed is an open-source, MIT licensed project that welcomes all forms of contributions from the community. Whether it's bug reports, feature requests, or code contributions, your input will help make Runed the best it can be."},{"title":"activeElement","href":"/docs/utilities/active-element","description":"Track and access the currently focused DOM element","content":" import Demo from '$lib/components/demos/active-element.svelte'; activeElement provides reactive access to the currently focused DOM element in your application, similar to document.activeElement but with reactive updates. Updates synchronously with DOM focus changes Returns null when no element is focused Safe to use with SSR (Server-Side Rendering) Lightweight alternative to manual focus tracking If you need to provide a custom document / shadowRoot, you can use the $2 utility instead, which provides a more flexible API. Demo Usage import { activeElement } from \"runed\"; Currently active element: {activeElement.current?.localName ?? \"No active element found\"} Type Definition interface ActiveElement { readonly current: Element | null; } `"},{"title":"AnimationFrames","href":"/docs/utilities/animation-frames","description":"A wrapper for requestAnimationFrame with FPS control and frame metrics","content":" import Demo from '$lib/components/demos/animation-frames.svelte'; AnimationFrames provides a declarative API over the browser's $2, offering FPS limiting capabilities and frame metrics while handling cleanup automatically. Demo Usage import { AnimationFrames } from \"runed\"; import { Slider } from \"../ui/slider\"; // Check out shadcn-svelte! let frames = $state(0); let fpsLimit = $state(10); let delta = $state(0); const animation = new AnimationFrames( (args) => { frames++; delta = args.delta; }, { fpsLimit: () => fpsLimit } ); const stats = $derived( Frames: ${frames}\\nFPS: ${animation.fps.toFixed(0)}\\nDelta: ${delta.toFixed(0)}ms ); {stats} {animation.running ? \"Stop\" : \"Start\"} FPS limit: {fpsLimit}{fpsLimit === 0 ? \" (not limited)\" : \"\"} (fpsLimit = value[0] ?? 0)} min={0} max={144} /> `"},{"title":"Context","href":"/docs/utilities/context","description":"A wrapper around Svelte's Context API that provides type safety and improved ergonomics for sharing data between components.","content":" import { Steps, Step, Callout } from '@svecodocs/kit'; Context allows you to pass data through the component tree without explicitly passing props through every level. It's useful for sharing data that many components need, like themes, authentication state, or localization preferences. The Context class provides a type-safe way to define, set, and retrieve context values. Usage Creating a Context First, create a Context instance with the type of value it will hold: import { Context } from \"runed\"; export const myTheme = new Context(\"theme\"); Creating a Context instance only defines the context - it doesn't actually set any value. The value passed to the constructor (\"theme\" in this example) is just an identifier used for debugging and error messages. Think of this step as creating a \"container\" that will later hold your context value. The container is typed (in this case to only accept \"light\" or \"dark\" as values) but remains empty until you explicitly call myTheme.set() during component initialization. This separation between defining and setting context allows you to: Keep context definitions in separate files Reuse the same context definition across different parts of your app Maintain type safety throughout your application Set different values for the same context in different component trees Setting Context Values Set the context value in a parent component during initialization. import { myTheme } from \"./context\"; let { data, children } = $props(); myTheme.set(data.theme); {@render children?.()} Context must be set during component initialization, similar to lifecycle functions like onMount. You cannot set context inside event handlers or callbacks. Reading Context Values Child components can access the context using get() or getOr() import { myTheme } from \"./context\"; const theme = myTheme.get(); // or with a fallback value if the context is not set const theme = myTheme.getOr(\"light\"); Type Definition class Context { /** @param name The name of the context. This is used for generating the context key and error messages. */ constructor(name: string) {} /** The key used to get and set the context. * It is not recommended to use this value directly. Instead, use the methods provided by this class. */ get key(): symbol; /** Checks whether this has been set in the context of a parent component. * Must be called during component initialization. */ exists(): boolean; /** Retrieves the context that belongs to the closest parent component. * Must be called during component initialization. * @throws An error if the context does not exist. */ get(): TContext; /** Retrieves the context that belongs to the closest parent component, or the given fallback value if the context does not exist. * Must be called during component initialization. */ getOr(fallback: TFallback): TContext | TFallback; /** Associates the given value with the current component and returns it. * Must be called during component initialization. */ set(context: TContext): TContext; } `"},{"title":"Debounced","href":"/docs/utilities/debounced","description":"A wrapper over `useDebounce` that returns a debounced state.","content":" import Demo from '$lib/components/demos/debounced.svelte'; Demo Usage This is a simple wrapper over $2 that returns a debounced state. import { Debounced } from \"runed\"; let search = $state(\"\"); const debounced = new Debounced(() => search, 500); You searched for: {debounced.current} You may cancel the pending update, run it immediately, or set a new value. Setting a new value immediately also cancels any pending updates. let count = $state(0); const debounced = new Debounced(() => count, 500); count = 1; debounced.cancel(); // after a while... console.log(debounced.current); // Still 0! count = 2; console.log(debounced.current); // Still 0! debounced.setImmediately(count); console.log(debounced.current); // 2 count = 3; console.log(debounced.current); // 2 await debounced.updateImmediately(); console.log(debounced.current); // 3 `"},{"title":"ElementRect","href":"/docs/utilities/element-rect","description":"Track element dimensions and position reactively","content":" import Demo from '$lib/components/demos/element-rect.svelte'; ElementRect provides reactive access to an element's dimensions and position information, automatically updating when the element's size or position changes. Demo Usage import { ElementRect } from \"runed\"; let el = $state(); const rect = new ElementRect(() => el); Width: {rect.width} Height: {rect.height} {JSON.stringify(rect.current, null, 2)} Type Definition type Rect = Omit; interface ElementRectOptions { initialRect?: DOMRect; } class ElementRect { constructor(node: MaybeGetter, options?: ElementRectOptions); readonly current: Rect; readonly width: number; readonly height: number; readonly top: number; readonly left: number; readonly right: number; readonly bottom: number; readonly x: number; readonly y: number; } `"},{"title":"ElementSize","href":"/docs/utilities/element-size","description":"Track element dimensions reactively","content":" import Demo from '$lib/components/demos/element-size.svelte'; ElementSize provides reactive access to an element's width and height, automatically updating when the element's dimensions change. Similar to ElementRect but focused only on size measurements. Demo Usage import { ElementSize } from \"runed\"; let el = $state() as HTMLElement; const size = new ElementSize(() => el); Width: {size.width} Height: {size.height} Type Definition interface ElementSize { readonly width: number; readonly height: number; } `"},{"title":"FiniteStateMachine","href":"/docs/utilities/finite-state-machine","description":"Defines a strongly-typed finite state machine.","content":" import Demo from '$lib/components/demos/finite-state-machine.svelte'; Demo type MyStates = \"disabled\" | \"idle\" | \"running\"; type MyEvents = \"toggleEnabled\" | \"start\" | \"stop\"; const f = new FiniteStateMachine(\"disabled\", { disabled: { toggleEnabled: \"idle\" }, idle: { toggleEnabled: \"disabled\", start: \"running\" }, running: { _enter: () => { f.debounce(2000, \"stop\"); }, stop: \"idle\", toggleEnabled: \"disabled\" } }); Usage Finite state machines (often abbreviated as \"FSMs\") are useful for tracking and manipulating something that could be in one of many different states. It centralizes the definition of every possible state and the events that might trigger a transition from one state to another. Here is a state machine describing a simple toggle switch: import { FiniteStateMachine } from \"runed\"; type MyStates = \"on\" | \"off\"; type MyEvents = \"toggle\"; const f = new FiniteStateMachine(\"off\", { off: { toggle: \"on\" }, on: { toggle: \"off\" } }); The first argument to the FiniteStateMachine constructor is the initial state. The second argument is an object with one key for each state. Each state then describes which events are valid for that state, and which state that event should lead to. In the above example of a simple switch, there are two states (on and off). The toggle event in either state leads to the other state. You send events to the FSM using f.send. To send the toggle event, invoke f.send('toggle'). Actions Maybe you want fancier logic for an event handler, or you want to conditionally transition into another state. Instead of strings, you can use actions. An action is a function that returns a state. An action can receive parameters, and it can use those parameters to dynamically choose which state should come next. It can also prevent a state transition by returning nothing. type MyStates = \"on\" | \"off\" | \"cooldown\"; const f = new FiniteStateMachine(\"off\", { off: { toggle: () => { if (isTuesday) { // Switch can only turn on during Tuesdays return \"on\"; } // All other days, nothing is returned and state is unchanged. } }, on: { toggle: (heldMillis: number) => { // You can also dynamically return the next state! // Only turn off if switch is depressed for 3 seconds if (heldMillis > 3000) { return \"off\"; } } } }); Lifecycle methods You can define special handlers that are invoked whenever a state is entered or exited: const f = new FiniteStateMachine('off', { off: { toggle: 'on' _enter: (meta) => { console.log('switch is off') } _exit: (meta) => { console.log('switch is no longer off') } }, on: { toggle: 'off' _enter: (meta) => { console.log('switch is on') } _exit: (meta) => { console.log('switch is no longer on') } } }); The lifecycle methods are invoked with a metadata object containing some useful information: from: the name of the event that is being exited to: the name of the event that is being entered event: the name of the event which has triggered the transition args: (optional) you may pass additional metadata when invoking an action with f.send('theAction', additional, params, as, args) The _enter handler for the initial state is called upon creation of the FSM. It is invoked with both the from and event fields set to null. Wildcard handlers There is one special state used as a fallback: *. If you have the fallback state, and you attempt to send() an event that is not handled by the current state, then it will try to find a handler for that event on the * state before discarding the event: const f = new FiniteStateMachine('off', { off: { toggle: 'on' }, on: { toggle: 'off' } '*': { emergency: 'off' } }); // will always result in the switch turning off. f.send('emergency'); Debouncing Frequently, you want to transition to another state after some time has elapsed. To do this, use the debounce method: f.send(\"toggle\"); // turn on immediately f.debounce(5000, \"toggle\"); // turn off in 5000 milliseconds If you re-invoke debounce with the same event, it will cancel the existing timer and start the countdown over: // schedule a toggle in five seconds f.debounce(5000, \"toggle\"); // ... less than 5000ms elapses ... f.debounce(5000, \"toggle\"); // The second call cancels the original timer, and starts a new one You can also use debounce in both actions and lifecycle methods. In both of the following examples, the lightswitch will turn itself off five seconds after it was turned on: const f = new FiniteStateMachine(\"off\", { off: { toggle: () => { f.debounce(5000, \"toggle\"); return \"on\"; } }, on: { toggle: \"off\" } }); const f = new FiniteStateMachine(\"off\", { off: { toggle: \"on\" }, on: { toggle: \"off\", _enter: () => { f.debounce(5000, \"toggle\"); } } }); Notes FiniteStateMachine is a loving rewrite of $2. FSMs are ideal for representing many different kinds of systems and interaction patterns. FiniteStateMachine is an intentionally minimalistic implementation. If you're looking for a more powerful FSM library, $2 is an excellent library with more features — and a steeper learning curve."},{"title":"IsFocusWithin","href":"/docs/utilities/is-focus-within","description":"A utility that tracks whether any descendant element has focus within a specified container element.","content":" import Demo from '$lib/components/demos/is-focus-within.svelte'; IsFocusWithin reactively tracks focus state within a container element, updating automatically when focus changes. Demo Usage import { IsFocusWithin } from \"runed\"; let formElement = $state(); const focusWithinForm = new IsFocusWithin(() => formElement); Focus within form: {focusWithinForm.current} Submit Type Definition class IsFocusWithin { constructor(node: MaybeGetter); readonly current: boolean; } `"},{"title":"IsIdle","href":"/docs/utilities/is-idle","description":"Track if a user is idle and the last time they were active.","content":" import Demo from '$lib/components/demos/is-idle.svelte'; IsIdle tracks user activity and determines if they're idle based on a configurable timeout. It monitors mouse movement, keyboard input, and touch events to detect user interaction. Demo Usage import { AnimationFrames, IsIdle } from \"runed\"; const idle = new IsIdle({ timeout: 1000 }); Idle: {idle.current} Last active: {new Date(idle.lastActive).toLocaleTimeString()} Type Definitions interface IsIdleOptions { /** The events that should set the idle state to true * @default ['mousemove', 'mousedown', 'resize', 'keydown', 'touchstart', 'wheel'] */ events?: MaybeGetter; /** The timeout in milliseconds before the idle state is set to true. Defaults to 60 seconds. * @default 60000 */ timeout?: MaybeGetter; /** Detect document visibility changes * @default true */ detectVisibilityChanges?: MaybeGetter; /** The initial state of the idle property * @default false */ initialState?: boolean; } class IsIdle { constructor(options?: IsIdleOptions); readonly current: boolean; readonly lastActive: number; } `"},{"title":"IsInViewport","href":"/docs/utilities/is-in-viewport","description":"Track if an element is visible within the current viewport.","content":" import Demo from '$lib/components/demos/is-in-viewport.svelte'; IsInViewport uses the $2 utility to track if an element is visible within the current viewport. It accepts an element or getter that returns an element and an optional options object that aligns with the $2 utility options. Demo Usage import { IsInViewport } from \"runed\"; let targetNode = $state()!; const inViewport = new IsInViewport(() => targetNode); Target node Target node in viewport: {inViewport.current} Type Definition import { type UseIntersectionObserverOptions } from \"runed\"; export type IsInViewportOptions = UseIntersectionObserverOptions; export declare class IsInViewport { constructor(node: MaybeGetter, options?: IsInViewportOptions); get current(): boolean; } "},{"title":"IsMounted","href":"/docs/utilities/is-mounted","description":"A class that returns the mounted state of the component it's called in.","content":" import Demo from '$lib/components/demos/is-mounted.svelte'; Demo Usage import { IsMounted } from \"runed\"; const isMounted = new IsMounted(); Which is a shorthand for one of the following: import { onMount } from \"svelte\"; const isMounted = $state({ current: false }); onMount(() => { isMounted.current = true; }); or import { untrack } from \"svelte\"; const isMounted = $state({ current: false }); $effect(() => { untrack(() => (isMounted.current = true)); }); `"},{"title":"IsSupported","href":"/docs/utilities/is-supported","description":"Determine if a feature is supported by the environment before using it.","content":"Usage import { IsSupported } from \"runed\"; const isSupported = new IsSupported(() => navigator && \"geolocation\" in navigator); if (isSupported.current) { // Do something with the geolocation API } Type Definition class IsSupported { readonly current: boolean; } `"},{"title":"onClickOutside","href":"/docs/utilities/on-click-outside","description":"Handle clicks outside of a specified element.","content":" import Demo from '$lib/components/demos/on-click-outside.svelte'; onClickOutside detects clicks that occur outside a specified element's boundaries and executes a callback function. It's commonly used for dismissible dropdowns, modals, and other interactive components. Demo Basic Usage import { onClickOutside } from \"runed\"; let container = $state()!; onClickOutside( () => container, () => console.log(\"clicked outside\") ); I'm outside the container Advanced Usage Controlled Listener The function returns control methods to programmatically manage the listener, start and stop and a reactive read-only property enabled to check the current status of the listeners. import { onClickOutside } from \"runed\"; let container = $state(); const clickOutside = onClickOutside( () => container, () => console.log(\"Clicked outside\") ); Status: {clickOutside.enabled ? \"Enabled\" : \"Disabled\"} Disable Enable Immediate By default, onClickOutside will start listening for clicks outside the element immediately. You can opt to disabled this behavior by passing { immediate: false } to the options argument. const clickOutside = onClickOutside( () => container, () => console.log(\"clicked outside\"), { immediate: false } ); // later when you want to start the listener clickOutside.start(); `"},{"title":"PersistedState","href":"/docs/utilities/persisted-state","description":"A reactive state manager that persists and synchronizes state across browser sessions and tabs using Web Storage APIs.","content":" import Demo from '$lib/components/demos/persisted-state.svelte'; import { Callout } from '@svecodocs/kit' PersistedState provides a reactive state container that automatically persists data to browser storage and optionally synchronizes changes across browser tabs in real-time. Demo You can refresh this page and/or open it in another tab to see the count state being persisted and synchronized across sessions and tabs. Usage Initialize PersistedState by providing a unique key and an initial value for the state. import { PersistedState } from \"runed\"; const count = new PersistedState(\"count\", 0); count.current++}>Increment count.current--}>Decrement (count.current = 0)}>Reset Count: {count.current} Configuration Options PersistedState includes an options object that allows you to customize the behavior of the state manager. const state = new PersistedState(\"user-preferences\", initialValue, { // Use sessionStorage instead of localStorage (default: 'local') storage: \"session\", // Disable cross-tab synchronization (default: true) syncTabs: false, // Custom serialization handlers serializer: { serialize: superjson.stringify, deserialize: superjson.parse } }); Storage Options 'local': Data persists until explicitly cleared 'session': Data persists until the browser session ends Cross-Tab Synchronization When syncTabs is enabled (default), changes are automatically synchronized across all browser tabs using the storage event. Custom Serialization Provide custom serialize and deserialize functions to handle complex data types: import superjson from \"superjson\"; // Example with Date objects const lastAccessed = new PersistedState(\"last-accessed\", new Date(), { serializer: { serialize: superjson.stringify, deserialize: superjson.parse } }); `"},{"title":"PressedKeys","href":"/docs/utilities/pressed-keys","description":"Tracks which keys are currently pressed","content":" import Demo from '$lib/components/demos/pressed-keys.svelte'; Demo Usage With an instance of PressedKeys, you can use the has method. const keys = new PressedKeys(); const isArrowDownPressed = $derived(keys.has(\"ArrowDown\")); const isCtrlAPressed = $derived(keys.has(\"Control\", \"a\")); Or get all of the currently pressed keys: const keys = new PressedKeys(); console.log(keys.all()); `"},{"title":"Previous","href":"/docs/utilities/previous","description":"A utility that tracks and provides access to the previous value of a reactive getter.","content":" import Demo from '$lib/components/demos/previous.svelte'; The Previous utility creates a reactive wrapper that maintains the previous value of a getter function. This is particularly useful when you need to compare state changes or implement transition effects. Demo Usage import { Previous } from \"runed\"; let count = $state(0); const previous = new Previous(() => count); count++}>Count: {count} Previous: {${previous.current}} Type Definition class Previous { constructor(getter: () => T); readonly current: T; // Previous value } `"},{"title":"StateHistory","href":"/docs/utilities/state-history","description":"Track state changes with undo/redo capabilities","content":" import Demo from '$lib/components/demos/state-history.svelte'; Demo Usage StateHistory tracks a getter's return value, logging each change into an array. A setter is also required to use the undo and redo functions. import { StateHistory } from \"runed\"; let count = $state(0); const history = new StateHistory(() => count, (c) => (count = c)); history.log[0]; // { snapshot: 0, timestamp: ... } Besides log, the returned object contains undo and redo functionality. import { useStateHistory } from \"runed\"; let count = $state(0); const history = new StateHistory(() => count, (c) => (count = c)); function format(ts: number) { return new Date(ts).toLocaleString(); } {count} count++}>Increment count--}>Decrement Undo Redo `"},{"title":"useActiveElement","href":"/docs/utilities/use-active-element","description":"Get a reactive reference to the currently focused element in the document.","content":" import Demo from '$lib/components/demos/use-active-element.svelte'; import { PropField } from '@svecodocs/kit' useActiveElement is used to get the currently focused element in the document. If you don't need to provide a custom document / shadowRoot, you can use the $2 state instead, as it provides a simpler API. This utility behaves similarly to document.activeElement but with additional features such as: Updates synchronously with DOM focus changes Returns null when no element is focused Safe to use with SSR (Server-Side Rendering) Lightweight alternative to manual focus tracking Demo Usage import { useActiveElement } from \"runed\"; const activeElement = useActiveElement(); {#if activeElement.current} The active element is: {activeElement.current.localName} {:else} No active element found {/if} Options The following options can be passed via the first argument to useActiveElement: The document or shadow root to track focus within. The window to use for focus tracking. "},{"title":"useDebounce","href":"/docs/utilities/use-debounce","description":"A higher-order function that debounces the execution of a function.","content":" import Demo from '$lib/components/demos/use-debounce.svelte'; useDebounce is a utility function that creates a debounced version of a callback function. Debouncing prevents a function from being called too frequently by delaying its execution until after a specified duration of inactivity. Demo Usage import { useDebounce } from \"runed\"; let count = $state(0); let logged = $state(\"\"); let isFirstTime = $state(true); let debounceDuration = $state(1000); const logCount = useDebounce( () => { if (isFirstTime) { isFirstTime = false; logged = You pressed the button ${count} times!; } else { logged = You pressed the button ${count} times since last time!; } count = 0; }, () => debounceDuration ); function ding() { count++; logCount(); } DING DING DING Run now Cancel message {logged || \"Press the button!\"} `"},{"title":"useEventListener","href":"/docs/utilities/use-event-listener","description":"A function that attaches an automatically disposed event listener.","content":" import Demo from '$lib/components/demos/use-event-listener.svelte'; Demo Usage The useEventListener function is particularly useful for attaching event listeners to elements you don't directly control. For instance, if you need to listen for events on the document body or window and can't use ``, or if you receive an element reference from a parent component. Example: Tracking Clicks on the Document // ClickLogger.ts import { useEventListener } from \"runed\"; export class ClickLogger { #clicks = $state(0); constructor() { useEventListener( () => document.body, \"click\", () => this.#clicks++ ); } get clicks() { return this.#clicks; } } This ClickLogger class tracks the number of clicks on the document body using the useEventListener function. Each time a click occurs, the internal counter increments. Svelte Component Usage import { ClickLogger } from \"./ClickLogger.ts\"; const logger = new ClickLogger(); You've clicked the document {logger.clicks} {logger.clicks === 1 ? \"time\" : \"times\"} In the component above, we create an instance of the ClickLogger class to monitor clicks on the document. The displayed text updates dynamically based on the recorded click count. Key Points Automatic Cleanup:** The event listener is removed automatically when the component is destroyed or when the element reference changes. Lazy Initialization:** The target element can be defined using a function, enabling flexible and dynamic behavior. Convenient for Global Listeners:** Ideal for scenarios where attaching event listeners directly to the DOM elements is cumbersome or impractical."},{"title":"useGeolocation","href":"/docs/utilities/use-geolocation","description":"Reactive access to the browser's Geolocation API.","content":" import Demo from '$lib/components/demos/use-geolocation.svelte'; useGeolocation is a reactive wrapper around the browser's $2. Demo Usage import { useGeolocation } from \"runed\"; const location = useGeolocation(); Coords: {JSON.stringify(location.position.coords, null, 2)} Located at: {location.position.timestamp} Error: {JSON.stringify(location.error, null, 2)} Is Supported: {location.isSupported} Pause Resume Type Definitions type UseGeolocationOptions = Partial & { /** Whether to start the watcher immediately upon creation. If set to false, the watcher will only start tracking the position when resume() is called. * @defaultValue true */ immediate?: boolean; }; type UseGeolocationReturn = { readonly isSupported: boolean; readonly position: Omit; readonly error: GeolocationPositionError | null; readonly isPaused: boolean; pause: () => void; resume: () => void; }; `"},{"title":"useIntersectionObserver","href":"/docs/utilities/use-intersection-observer","description":"Watch for intersection changes of a target element.","content":" import Demo from '$lib/components/demos/use-intersection-observer.svelte'; import { Callout } from '@svecodocs/kit' Demo Usage With a reference to an element, you can use the useIntersectionObserver utility to watch for intersection changes of the target element. import { useIntersectionObserver } from \"runed\"; let target = $state(null); let root = $state(null); let isIntersecting = $state(false); useIntersectionObserver( () => target, (entries) => { const entry = entries[0]; if (!entry) return; isIntersecting = entry.isIntersecting; }, { root: () => root } ); {#if isIntersecting} Target is intersecting {:else} Target is not intersecting {/if} Pause You can pause the intersection observer at any point by calling the pause method. const observer = useIntersectionObserver(/* ... */); observer.pause(); Resume You can resume the intersection observer at any point by calling the resume method. const observer = useIntersectionObserver(/* ... */); observer.resume(); Stop You can stop the intersection observer at any point by calling the stop method. const observer = useIntersectionObserver(/* ... */); observer.stop(); isActive You can check if the intersection observer is active by checking the isActive property. This property cannot be destructured as it is a getter. You must access it directly from the observer. const observer = useIntersectionObserver(/* ... */); if (observer.isActive) { // do something } `"},{"title":"useMutationObserver","href":"/docs/utilities/use-mutation-observer","description":"Observe changes in an element","content":" import Demo from '$lib/components/demos/use-mutation-observer.svelte'; Demo Usage With a reference to an element, you can use the useMutationObserver hook to observe changes in the element. import { useMutationObserver } from \"runed\"; let el = $state(null); const messages = $state([]); let className = $state(\"\"); let style = $state(\"\"); useMutationObserver( () => el, (mutations) => { const mutation = mutations[0]; if (!mutation) return; messages.push(mutation.attributeName!); }, { attributes: true } ); setTimeout(() => { className = \"text-brand\"; }, 1000); setTimeout(() => { style = \"font-style: italic;\"; }, 1500); {#each messages as text} Mutation Attribute: {text} {:else} No mutations yet {/each} You can stop the mutation observer at any point by calling the stop method. const { stop } = useMutationObserver(/* ... */); stop(); `"},{"title":"useResizeObserver","href":"/docs/utilities/use-resize-observer","description":"Detects changes in the size of an element","content":" import Demo from '$lib/components/demos/use-resize-observer.svelte'; Demo Usage With a reference to an element, you can use the useResizeObserver utility to detect changes in the size of an element. import { useResizeObserver } from \"runed\"; let el = $state(null); let text = $state(\"\"); useResizeObserver( () => el, (entries) => { const entry = entries[0]; if (!entry) return; const { width, height } = entry.contentRect; text = width: ${width};\\nheight: ${height};; } ); You can stop the resize observer at any point by calling the stop method. const { stop } = useResizeObserver(/* ... */); stop(); `"},{"title":"watch","href":"/docs/utilities/watch","description":"Watch for changes and run a callback","content":"Runes provide a handy way of running a callback when reactive values change: $2. It automatically detects when inner values change, and re-runs the callback. $effect is great, but sometimes you want to manually specify which values should trigger the callback. Svelte provides an untrack function, allowing you to specify that a dependency shouldn't be tracked, but it doesn't provide a way to say that only certain values should be tracked. watch does exactly that. It accepts a getter function, which returns the dependencies of the effect callback. Usage watch Runs a callback whenever one of the sources change. import { watch } from \"runed\"; let count = $state(0); watch(() => count, () => { console.log(count); } ); The callback receives two arguments: The current value of the sources, and the previous value. let count = $state(0); watch(() => count, (curr, prev) => { console.log(count is ${curr}, was ${prev}); } ); You can also send in an array of sources: let age = $state(20); let name = $state(\"bob\"); watch([() => age, () => name], ([age, name], [prevAge, prevName]) => { // ... } watch also accepts an options object. watch(sources, callback, { // First run will only happen after sources change when set to true. // By default, its false. lazy: true }); watch.pre watch.pre is similar to watch, but it uses $2 under the hood. watchOnce In case you want to run the callback only once, you can use watchOnce and watchOnce.pre. It functions identically to the watch and watch.pre otherwise, but it does not accept any options object."}] \ No newline at end of file From 363b0c10414448d807d34ac8a3e5d128ea5eeaac Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 21 Dec 2024 19:50:01 -0500 Subject: [PATCH 17/18] onclickoutside --- .../onClickOutside/onClickOutside.svelte.ts | 111 +++++++++++------- .../src/content/utilities/on-click-outside.md | 78 +++++++----- .../components/demos/on-click-outside.svelte | 21 +--- 3 files changed, 123 insertions(+), 87 deletions(-) diff --git a/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.svelte.ts b/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.svelte.ts index 5f3b25d4..eee0c46d 100644 --- a/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.svelte.ts +++ b/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.svelte.ts @@ -1,23 +1,40 @@ -import { defaultDocument, type ConfigurableDocument } from "$lib/internal/configurable-globals.js"; +import { + defaultWindow, + type ConfigurableDocument, + type ConfigurableWindow, +} from "$lib/internal/configurable-globals.js"; import type { MaybeElementGetter } from "$lib/internal/types.js"; -import { getOwnerDocument, isOrContainsTarget } from "$lib/internal/utils/dom.js"; +import { getActiveElement, getOwnerDocument, isOrContainsTarget } from "$lib/internal/utils/dom.js"; import { addEventListener } from "$lib/internal/utils/event.js"; import { noop } from "$lib/internal/utils/function.js"; import { isElement } from "$lib/internal/utils/is.js"; +import { sleep } from "$lib/internal/utils/sleep.js"; import { extract } from "../extract/extract.svelte.js"; import { useDebounce } from "../useDebounce/useDebounce.svelte.js"; import { watch } from "../watch/watch.svelte.js"; -export type OnClickOutsideOptions = ConfigurableDocument & { - /** - * Whether the click outside handler is enabled by default or not. - * If set to false, the handler will not be active until enabled by - * calling the returned `start` function - * - * @default true - */ - immediate?: boolean; -}; +export type OnClickOutsideOptions = ConfigurableWindow & + ConfigurableDocument & { + /** + * Whether the click outside handler is enabled by default or not. + * If set to false, the handler will not be active until enabled by + * calling the returned `start` function + * + * @default true + */ + immediate?: boolean; + + /** + * Controls whether focus events from iframes trigger the callback. + * + * Since iframe click events don't bubble to the parent document, + * you may want to enable this if you need to detect when users + * interact with iframe content. + * + * @default false + */ + detectIframe?: boolean; + }; /** * A utility that calls a given callback when a click event occurs outside of @@ -29,6 +46,7 @@ export type OnClickOutsideOptions = ConfigurableDocument & { * @param {OnClickOutsideOptions} [opts={}] - Optional configuration object. * @param {ConfigurableDocument} [opts.document=defaultDocument] - The document object to use, defaults to the global document. * @param {boolean} [opts.immediate=true] - Whether the click outside handler is enabled by default or not. + * @param {boolean} [opts.detectIframe=false] - Controls whether focus events from iframes trigger the callback. * * @example * ```svelte @@ -53,25 +71,26 @@ export type OnClickOutsideOptions = ConfigurableDocument & { */ export function onClickOutside( container: MaybeElementGetter, - callback: (event: PointerEvent) => void, + callback: (event: PointerEvent | FocusEvent) => void, opts: OnClickOutsideOptions = {} ) { - const { document = defaultDocument, immediate = true } = opts; + const { window = defaultWindow, immediate = true, detectIframe = false } = opts; + const document = opts.document ?? window?.document; const node = $derived(extract(container)); const nodeOwnerDocument = $derived(getOwnerDocument(node, document)); let enabled = $state(immediate); let pointerDownIntercepted = false; let removeClickListener = noop; - let removePointerListeners = noop; + let removeListeners = noop; const handleClickOutside = useDebounce((e: PointerEvent) => { - if (!node || !nodeOwnerDocument || !document) { + if (!node || !nodeOwnerDocument) { removeClickListener(); return; } - if (pointerDownIntercepted === true || !isValidEvent(e, node, document)) { + if (pointerDownIntercepted === true || !isValidEvent(e, node, nodeOwnerDocument)) { removeClickListener(); return; } @@ -91,25 +110,27 @@ export function onClickOutside( }); } else { /** - * I + * If the pointer type is not touch, we can directly call the callback function + * as the interaction is likely a mouse or pen input which does not require + * additional handling. */ callback(e); } }, 10); - function addPointerDownListeners() { - if (!nodeOwnerDocument) return noop; + function addListeners() { + if (!nodeOwnerDocument || !window || !node) return noop; const events = [ /** * CAPTURE INTERACTION START - * mark the pointerdown event as intercepted + * Mark the pointerdown event as intercepted to indicate that an interaction + * has started. This helps in distinguishing between valid and invalid events. */ addEventListener( nodeOwnerDocument, "pointerdown", (e) => { - if (!node || !document) return; - if (isValidEvent(e, node, document)) { + if (isValidEvent(e, node, nodeOwnerDocument)) { pointerDownIntercepted = true; } }, @@ -125,6 +146,24 @@ export function onClickOutside( handleClickOutside(e); }), ]; + if (detectIframe) { + events.push( + /** + * DETECT IFRAME INTERACTIONS + * + * We add a blur event listener to the window to detect when the user + * interacts with an iframe. If the active element is an iframe and it + * is not a descendant of the container, we call the callback function. + */ + addEventListener(window, "blur", async (e) => { + await sleep(); + const activeElement = getActiveElement(nodeOwnerDocument); + if (activeElement?.tagName === "IFRAME" && !isOrContainsTarget(node, activeElement)) { + callback(e); + } + }) + ); + } return () => { for (const event of events) { event(); @@ -136,13 +175,13 @@ export function onClickOutside( pointerDownIntercepted = false; handleClickOutside.cancel(); removeClickListener(); - removePointerListeners(); + removeListeners(); } watch([() => enabled, () => node], ([enabled$, node$]) => { if (enabled$ && node$) { - removePointerListeners(); - removePointerListeners = addPointerDownListeners(); + removeListeners(); + removeListeners = addListeners(); } else { cleanup(); } @@ -154,22 +193,12 @@ export function onClickOutside( }; }); - /** - * Stop listening for click events outside the container. - */ - const stop = () => (enabled = false); - - /** - * Start listening for click events outside the container. - */ - const start = () => (enabled = true); - return { - stop, - start, - /** - * Whether the click outside handler is currently enabled or not. - */ + /** Stop listening for click events outside the container. */ + stop: () => (enabled = false), + /** Start listening for click events outside the container. */ + start: () => (enabled = true), + /** Whether the click outside handler is currently enabled or not. */ get enabled() { return enabled; }, diff --git a/sites/docs/src/content/utilities/on-click-outside.md b/sites/docs/src/content/utilities/on-click-outside.md index 214f587d..1d66aca1 100644 --- a/sites/docs/src/content/utilities/on-click-outside.md +++ b/sites/docs/src/content/utilities/on-click-outside.md @@ -7,6 +7,7 @@ category: Sensors `onClickOutside` detects clicks that occur outside a specified element's boundaries and executes a @@ -84,36 +85,57 @@ Here's an example of using `onClickOutside` with a ``: ## Options -### Immediate + -By default, `onClickOutside` will start listening for clicks outside the element immediately. You -can opt to disabled this behavior by passing `{ immediate: false }` to the options argument. +Whether the click outside handler is enabled by default or not. If set to `false`, the handler will +not be active until enabled by calling the returned `start` function. -```ts {4} -const clickOutside = onClickOutside( - () => container, - () => console.log("clicked outside"), - { immediate: false } -); + -// later when you want to start the listener -clickOutside.start(); -``` + + +Controls whether focus events from iframes trigger the callback. Since iframe click events don't +bubble to the parent document, you may want to enable this if you need to detect when users interact +with iframe content. + + + + + +The document object to use, defaults to the global document. + + + + + +The window object to use, defaults to the global window. + + ## Type Definitions ```ts -export type OnClickOutsideOptions = ConfigurableDocument & { - /** - * Whether the click outside handler is enabled by default or not. - * If set to false, the handler will not be active until enabled by - * calling the returned `start` function - * - * @default true - */ - immediate?: boolean; -}; - +export type OnClickOutsideOptions = ConfigurableWindow & + ConfigurableDocument & { + /** + * Whether the click outside handler is enabled by default or not. + * If set to false, the handler will not be active until enabled by + * calling the returned `start` function + * + * @default true + */ + immediate?: boolean; + /** + * Controls whether focus events from iframes trigger the callback. + * + * Since iframe click events don't bubble to the parent document, + * you may want to enable this if you need to detect when users + * interact with iframe content. + * + * @default false + */ + detectIframe?: boolean; + }; /** * A utility that calls a given callback when a click event occurs outside of * a specified container element. @@ -124,18 +146,20 @@ export type OnClickOutsideOptions = ConfigurableDocument & { * @param {OnClickOutsideOptions} [opts={}] - Optional configuration object. * @param {ConfigurableDocument} [opts.document=defaultDocument] - The document object to use, defaults to the global document. * @param {boolean} [opts.immediate=true] - Whether the click outside handler is enabled by default or not. + * @param {boolean} [opts.detectIframe=false] - Controls whether focus events from iframes trigger the callback. + * * @see {@link https://runed.dev/docs/utilities/on-click-outside} */ export declare function onClickOutside( container: MaybeElementGetter, - callback: (event: PointerEvent) => void, + callback: (event: PointerEvent | FocusEvent) => void, opts?: OnClickOutsideOptions ): { + /** Stop listening for click events outside the container. */ stop: () => boolean; + /** Start listening for click events outside the container. */ start: () => boolean; - /** - * Whether the click outside handler is currently enabled or not. - */ + /** Whether the click outside handler is currently enabled or not. */ readonly enabled: boolean; }; ``` diff --git a/sites/docs/src/lib/components/demos/on-click-outside.svelte b/sites/docs/src/lib/components/demos/on-click-outside.svelte index b4f4987a..2e433c05 100644 --- a/sites/docs/src/lib/components/demos/on-click-outside.svelte +++ b/sites/docs/src/lib/components/demos/on-click-outside.svelte @@ -4,15 +4,10 @@ let containerText = $state("Has not clicked outside yet."); let container = $state()!; - let dialog = $state()!; const clickOutside = onClickOutside( - () => dialog, - () => { - dialog.close(); - clickOutside.stop(); - }, - { immediate: false } + () => container, + () => (containerText = "Clicked outside!") ); @@ -37,16 +32,4 @@ - - -
-

This is a dialog.

- -
-
From e0455d5ddcac4ffa7fb5087b5531d7df6682c968 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 21 Dec 2024 19:57:26 -0500 Subject: [PATCH 18/18] test iframes --- .../onClickOutside.test.svelte.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.test.svelte.ts b/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.test.svelte.ts index 9d571d7c..ecc26ba0 100644 --- a/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.test.svelte.ts +++ b/packages/runed/src/lib/utilities/onClickOutside/onClickOutside.test.svelte.ts @@ -56,6 +56,8 @@ describe("onClickOutside", () => { afterEach(() => { document.body.removeChild(container); document.body.removeChild(outsideButton); + // Remove any iframes that might have been added + document.querySelectorAll("iframe").forEach((iframe) => iframe.remove()); vi.clearAllMocks(); vi.useRealTimers(); }); @@ -237,4 +239,40 @@ describe("onClickOutside", () => { dialog.remove(); }); + + testWithEffect("ignores iframe interactions by default (detectIframe: false)", async () => { + let iframe = document.createElement("iframe"); + document.body.appendChild(iframe); + + onClickOutside(() => container, callbackFn); + await tick(); + + window.dispatchEvent(new Event("blur", { bubbles: true })); + await tick(); + vi.spyOn(document, "activeElement", "get").mockReturnValue(iframe); + await vi.runAllTimersAsync(); + + expect(callbackFn).not.toHaveBeenCalled(); + + document.body.removeChild(iframe); + vi.restoreAllMocks(); + }); + + testWithEffect("detects iframe interactions when detectIframe is true", async () => { + let iframe = document.createElement("iframe"); + document.body.appendChild(iframe); + + onClickOutside(() => container, callbackFn, { detectIframe: true }); + await tick(); + + window.dispatchEvent(new Event("blur", { bubbles: true })); + await tick(); + vi.spyOn(document, "activeElement", "get").mockReturnValue(iframe); + await vi.runAllTimersAsync(); + + expect(callbackFn).toHaveBeenCalledOnce(); + + document.body.removeChild(iframe); + vi.restoreAllMocks(); + }); });