summaryrefslogtreecommitdiff
path: root/apps/web/src/hooks/use-focus-lock.ts
blob: 80baebdb79052a1bf43dd9507222564d02dc4c00 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
import { useEffect, useRef } from "react";

type FocusLockCursor = "text" | "default" | "pointer" | "crosshair";

const DATA_ATTR = "data-focus-locked";

function buildFocusLockCSS({
	cursor,
	allowSelector,
}: {
	cursor: FocusLockCursor;
	allowSelector?: string;
}) {
	const rules = [
		`*, *::before, *::after { pointer-events: none !important; cursor: ${cursor} !important; }`,
		`[${DATA_ATTR}], [${DATA_ATTR}] * { pointer-events: auto !important; cursor: auto !important; }`,
	];

	if (allowSelector) {
		rules.push(
			`${allowSelector} { pointer-events: auto !important; cursor: auto !important; }`,
		);
	}

	return rules.join("\n");
}

export function useFocusLock<T extends HTMLElement = HTMLElement>({
	isActive,
	onDismiss,
	cursor = "default",
	allowSelector,
}: {
	isActive: boolean;
	onDismiss: () => void;
	cursor?: FocusLockCursor;
	allowSelector?: string;
}) {
	const containerRef = useRef<T>(null);
	const onDismissRef = useRef(onDismiss);
	onDismissRef.current = onDismiss;

	useEffect(() => {
		if (!isActive) return;
		const container = containerRef.current;
		if (!container) return;

		container.setAttribute(DATA_ATTR, "");

		const focusLockStyle = document.createElement("style");
		focusLockStyle.textContent = buildFocusLockCSS({ cursor, allowSelector });
		document.head.appendChild(focusLockStyle);

		const handleOutsidePointerDown = (event: PointerEvent) => {
			if (event.button !== 0) return;
			if (container.contains(event.target as Node)) return;

			const target = event.target as Element | null;
			const isAllowedTarget = allowSelector && target?.closest(allowSelector);
			if (isAllowedTarget) return;

			onDismissRef.current();
		};

		document.addEventListener("pointerdown", handleOutsidePointerDown, true);

		return () => {
			document.removeEventListener(
				"pointerdown",
				handleOutsidePointerDown,
				true,
			);
			container.removeAttribute(DATA_ATTR);
			focusLockStyle.remove();
		};
	}, [isActive, cursor, allowSelector]);

	return { containerRef };
}