-
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathonFocusLost.ts
89 lines (82 loc) · 3.05 KB
/
onFocusLost.ts
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
80
81
82
83
84
85
86
87
88
89
import onEvent from "./onEvent";
let isMouseDown = false;
let rstEvent: undefined | (() => void);
let rstCnt = 0;
const onMouseUp: Array<() => void> = [];
function setEvent(): void {
++rstCnt;
if (rstEvent) {
return;
}
const r1 = onEvent(
document,
"mousedown",
({ button, defaultPrevented }) => (isMouseDown = !button && !defaultPrevented), // filter only for LeftClick
{ passive: true, capture: true }
);
// click fires after mouseup but provides better mechanism
const r2 = onEvent(
document,
"click",
() => {
onMouseUp.forEach((f) => f());
onMouseUp.length = 0;
isMouseDown = false;
},
{ passive: true, capture: true }
);
rstEvent = () => {
if (--rstCnt === 0) {
onMouseUp.length = 0;
isMouseDown = false;
r1();
r2();
rstEvent = undefined;
}
};
}
export interface onFocusLostOptions extends AddEventListenerOptions {
/** Required to prevent debounce when user clicks on label tied with input;
* In this case events labelClick > inputFocusout > inputClick > inputFocusin is called
*
* Troubleshooting: if click/focusout is handled somewhere during for a long time `debounceMs` must be adjusted
* or you can prevent labelOnClick behavior via adding label.addEventListener('mousedown', (e) => e.preventDefault());
* and implement labelClick > inputFocus yourself
* @defaultValue 100ms */
debounceMs?: number;
}
/** Fires when element/children completely lost focus.
* This event checks next focused/active element and isn't called several times when focus goes between children.
* @param element HTMLElement to apply `.addEventListener`
* @param listener Callback invoked on event
* @param options OnFocusLostOptions
* @return Callback with `.removeEventListener`. Call it to remove listener
* */
export default function onFocusLost(
element: HTMLElement,
listener: (this: HTMLElement, ev: HTMLElementEventMap["focusout"]) => any,
options?: onFocusLostOptions
): () => void {
setEvent();
const remove = (): void => {
element.removeEventListener("focusout", focusout);
rstEvent?.call(element);
};
const focusout = (e: FocusEvent, isNext?: true): void => {
if (!isNext && isMouseDown) {
// mouseDown Label > mouseUp Label (without mouseMove) >>> click Label > focusin Input > click Input
// if mouseDown.target === mouseUp.target >>> click target; if clickTarget tied with input >>> clickInput
// timeout requires to wait for next document.activeElement to be defined
onMouseUp.push(() => setTimeout(() => focusout(e, true), options?.debounceMs || 100));
return;
}
const isFocused = (a: Node | null): boolean | null => a && (element === a || element.contains(a));
const isStillFocused = e.relatedTarget instanceof Node && isFocused(e.relatedTarget);
if (!isStillFocused && !isFocused(document.activeElement)) {
listener.call(element, e);
options?.once && remove();
}
};
element.addEventListener("focusout", focusout, { passive: true, ...options, once: false });
return remove;
}