1import { useEffect, useState, type MouseEvent } from "react";
2
3/** Arms one control at a time, including controls rendered in a menu portal. */
4export function useInlineActionConfirm(scopeKey: string) {
5 const [armed, setArmed] = useState<{
6 scopeKey: string;
7 action: string;
8 element: HTMLElement;
9 } | null>(null);
10 const active = armed?.scopeKey === scopeKey ? armed : null;
11
12 useEffect(() => {
13 if (!armed) return;
14 const disarm = () => setArmed(null);
15 if (armed.scopeKey !== scopeKey) {
16 disarm();
17 return;
18 }
19 const onPointerDown = (event: PointerEvent) => {
20 if (event.target instanceof Node && !armed.element.contains(event.target)) disarm();
21 };
22 const onKeyDown = (event: KeyboardEvent) => {
23 if (event.key === "Escape") {
24 event.preventDefault();
25 event.stopPropagation();
26 disarm();
27 }
28 };
29 const timeout = window.setTimeout(disarm, 3000);
30 document.addEventListener("pointerdown", onPointerDown, true);
31 window.addEventListener("keydown", onKeyDown, true);
32 window.addEventListener("blur", disarm);
33 return () => {
34 window.clearTimeout(timeout);
35 document.removeEventListener("pointerdown", onPointerDown, true);
36 window.removeEventListener("keydown", onKeyDown, true);
37 window.removeEventListener("blur", disarm);
38 };
39 }, [armed, scopeKey]);
40
41 return {
42 isArmed: (action: string) => active?.action === action,
43 props: (action: string, onConfirm: () => void) => ({
44 onClick: (event: MouseEvent<HTMLElement>) => {
45 if (active?.action === action && active.element === event.currentTarget) {
46 setArmed(null);
47 onConfirm();
48 } else {
49 setArmed({ scopeKey, action, element: event.currentTarget });
50 }
51 },
52 onBlur: () => setArmed(null),
53 }),
54 };
55}