Mirror: React hooks for accessible, common web interactions. UI super powers without the UI.
1import { useRef } from 'react';
2import { useLayoutEffect } from './utils/react';
3import { contains } from './utils/element';
4import { makePriorityHook } from './usePriority';
5import { Ref } from './types';
6
7const usePriority = makePriorityHook();
8
9export interface DismissableOptions {
10 focusLoss?: boolean;
11 disabled?: boolean;
12}
13
14export function useDismissable<T extends HTMLElement>(
15 ref: Ref<T>,
16 onDismiss: () => void,
17 options?: DismissableOptions
18) {
19 const focusLoss = !!(options && options.focusLoss);
20 const disabled = !!(options && options.disabled);
21 const hasPriority = usePriority(ref, disabled);
22 const onDismissRef = useRef(onDismiss);
23
24 useLayoutEffect(() => {
25 onDismissRef.current = onDismiss;
26 }, [onDismiss]);
27
28 useLayoutEffect(() => {
29 const { current: element } = ref;
30 if (!element || disabled) return;
31
32 let willLoseFocus = false;
33
34 function onFocusOut(event: FocusEvent) {
35 const { target, relatedTarget } = event;
36 if (
37 !event.defaultPrevented &&
38 (relatedTarget || willLoseFocus) &&
39 contains(element, target) &&
40 !contains(element, relatedTarget)
41 ) {
42 willLoseFocus = false;
43 onDismissRef.current();
44 }
45 }
46
47 function onFocusIn(event: FocusEvent) {
48 const { target } = event;
49 if (!event.defaultPrevented && !contains(element, target)) {
50 onDismissRef.current();
51 }
52 }
53
54 function onKey(event: KeyboardEvent) {
55 if (event.isComposing) {
56 return;
57 }
58
59 if (event.code === 'Escape' && hasPriority.current) {
60 // The current dialog can be dismissed by pressing escape if it either has focus
61 // or it has priority
62 event.preventDefault();
63 onDismissRef.current();
64 } else if (event.code === 'Tab') {
65 willLoseFocus = true;
66 }
67 }
68
69 function onClick(event: MouseEvent | TouchEvent) {
70 const { target } = event;
71 if (event.defaultPrevented) {
72 return;
73 } else if (contains(element, target)) {
74 willLoseFocus = false;
75 return;
76 } else if (hasPriority.current) {
77 // The current dialog can be dismissed by pressing outside of it if it either has
78 // focus or it has priority
79 onDismissRef.current();
80 }
81 }
82
83 if (focusLoss) {
84 document.body.addEventListener('focusout', onFocusOut);
85 document.body.addEventListener('focusin', onFocusIn);
86 }
87
88 document.addEventListener('mousedown', onClick);
89 document.addEventListener('touchstart', onClick);
90 document.addEventListener('keydown', onKey);
91
92 return () => {
93 if (focusLoss) {
94 document.body.removeEventListener('focusout', onFocusOut);
95 document.body.removeEventListener('focusin', onFocusIn);
96 }
97
98 document.removeEventListener('mousedown', onClick);
99 document.removeEventListener('touchstart', onClick);
100 document.removeEventListener('keydown', onKey);
101 };
102 }, [ref.current, hasPriority, disabled, focusLoss]);
103}