Skip to content

Commit

Permalink
feat: onClickOutside (#190)
Browse files Browse the repository at this point in the history
Co-authored-by: Bohdan Sviripa <[email protected]>
  • Loading branch information
huntabyte and sviripa authored Dec 22, 2024
1 parent 318a41d commit b61eaf2
Show file tree
Hide file tree
Showing 12 changed files with 788 additions and 1 deletion.
5 changes: 5 additions & 0 deletions .changeset/bright-rabbits-obey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"runed": minor
---

feat: `onClickOutside`
2 changes: 2 additions & 0 deletions packages/runed/src/lib/internal/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export type Getter<T> = () => T;
export type MaybeGetter<T> = T | Getter<T>;
export type MaybeElementGetter<T extends Element = HTMLElement> = MaybeGetter<T | null | undefined>;
export type MaybeElement = HTMLElement | SVGElement | undefined | null;

export type Setter<T> = (value: T) => void;
export type Expand<T> = T extends infer U ? { [K in keyof U]: U[K] } : never;
export type WritableProperties<T> = {
Expand Down
26 changes: 26 additions & 0 deletions packages/runed/src/lib/internal/utils/dom.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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);
}
4 changes: 4 additions & 0 deletions packages/runed/src/lib/internal/utils/is.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,7 @@ export function isFunction(value: unknown): value is (...args: unknown[]) => unk
export function isObject(value: unknown): value is Record<PropertyKey, unknown> {
return value !== null && typeof value === "object";
}

export function isElement(value: unknown): value is Element {
return value instanceof Element;
}
1 change: 1 addition & 0 deletions packages/runed/src/lib/utilities/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from "./activeElement/index.js";
export * from "./onClickOutside/index.js";
export * from "./useDebounce/index.js";
export * from "./ElementSize/index.js";
export * from "./useEventListener/index.js";
Expand Down
1 change: 1 addition & 0 deletions packages/runed/src/lib/utilities/onClickOutside/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./onClickOutside.svelte.js";
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import {
defaultWindow,
type ConfigurableDocument,
type ConfigurableWindow,
} from "$lib/internal/configurable-globals.js";
import type { MaybeElementGetter } from "$lib/internal/types.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 = 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.
*
* @template T - The type of the container element, defaults to HTMLElement.
* @param {MaybeElementGetter<T>} 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.
* @param {boolean} [opts.detectIframe=false] - Controls whether focus events from iframes trigger the callback.
*
* @example
* ```svelte
* <script>
* import { onClickOutside } from 'runed'
* let container = $state<HTMLElement>()!
*
* const clickOutside = onClickOutside(() => container, () => {
* console.log('clicked outside the container!')
* });
* </script>
*
* <div bind:this={container}>
* <span>Inside</span>
* </div>
* <button>Outside, click me to trigger callback</button>
* <button onclick={clickOutside.start}>Start</button>
* <button onclick={clickOutside.stop}>Stop</button>
* <span>Enabled: {clickOutside.enabled}</span>
*```
* @see {@link https://runed.dev/docs/utilities/on-click-outside}
*/
export function onClickOutside<T extends Element = HTMLElement>(
container: MaybeElementGetter<T>,
callback: (event: PointerEvent | FocusEvent) => void,
opts: OnClickOutsideOptions = {}
) {
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 removeListeners = noop;

const handleClickOutside = useDebounce((e: PointerEvent) => {
if (!node || !nodeOwnerDocument) {
removeClickListener();
return;
}

if (pointerDownIntercepted === true || !isValidEvent(e, node, nodeOwnerDocument)) {
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 {
/**
* 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 addListeners() {
if (!nodeOwnerDocument || !window || !node) return noop;
const events = [
/**
* CAPTURE INTERACTION START
* 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 (isValidEvent(e, node, nodeOwnerDocument)) {
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);
}),
];
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();
}
};
}

function cleanup() {
pointerDownIntercepted = false;
handleClickOutside.cancel();
removeClickListener();
removeListeners();
}

watch([() => enabled, () => node], ([enabled$, node$]) => {
if (enabled$ && node$) {
removeListeners();
removeListeners = addListeners();
} else {
cleanup();
}
});

$effect(() => {
return () => {
cleanup();
};
});

return {
/** 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;
},
};
}

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, 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
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;
return !wasInsideClick;
}
return ownerDocument.documentElement.contains(target) && !isOrContainsTarget(container, target);
}
Loading

0 comments on commit b61eaf2

Please sign in to comment.