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, getRoot } 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: (event: Event) => 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 const root = getRoot(element);
33 let willLoseFocus = false;
34
35 function onFocusOut(event: FocusEvent) {
36 const { target, relatedTarget } = event;
37 if (
38 !event.defaultPrevented &&
39 willLoseFocus &&
40 contains(element, target) &&
41 !contains(element, relatedTarget)
42 ) {
43 willLoseFocus = false;
44 onDismissRef.current(event);
45 }
46 }
47
48 function onFocusIn(event: FocusEvent) {
49 const { target } = event;
50 if (
51 !event.defaultPrevented &&
52 willLoseFocus &&
53 !contains(element, target)
54 ) {
55 willLoseFocus = false;
56 onDismissRef.current(event);
57 }
58 }
59
60 function onKey(event: KeyboardEvent) {
61 if (event.isComposing) {
62 return;
63 }
64
65 if (event.code === 'Escape' && hasPriority.current) {
66 // The current dialog can be dismissed by pressing escape if it either has focus
67 // or it has priority
68 event.preventDefault();
69 onDismissRef.current(event);
70 } else if (event.code === 'Tab') {
71 willLoseFocus = true;
72 }
73 }
74
75 function onClick(event: MouseEvent | TouchEvent) {
76 const { target } = event;
77 if (event.defaultPrevented) {
78 return;
79 } else if (contains(element, target)) {
80 willLoseFocus = false;
81 return;
82 } else if (hasPriority.current) {
83 // The current dialog can be dismissed by pressing outside of it if it either has
84 // focus or it has priority
85 event.preventDefault();
86 onDismissRef.current(event);
87 }
88 }
89
90 const opts = { capture: true } as any;
91 const touchOpts = { capture: true, passive: false } as any;
92
93 if (focusLoss) {
94 root.addEventListener('focusout', onFocusOut, opts);
95 root.addEventListener('focusin', onFocusIn, opts);
96 }
97
98 root.addEventListener('click', onClick, opts);
99 root.addEventListener('touchstart', onClick, touchOpts);
100 root.addEventListener('keydown', onKey, opts);
101
102 return () => {
103 if (focusLoss) {
104 root.removeEventListener('focusout', onFocusOut, opts);
105 root.removeEventListener('focusin', onFocusIn, opts);
106 }
107
108 root.removeEventListener('click', onClick, opts);
109 root.removeEventListener('touchstart', onClick, touchOpts);
110 root.removeEventListener('keydown', onKey, opts);
111 };
112 }, [ref.current, hasPriority, disabled, focusLoss]);
113}