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 if (!ref.current || disabled) return;
30
31 function onFocusOut(event: FocusEvent) {
32 if (event.defaultPrevented) return;
33
34 const { target, relatedTarget } = event;
35 if (
36 contains(ref.current, target) &&
37 !contains(ref.current, relatedTarget)
38 ) {
39 onDismissRef.current();
40 }
41 }
42
43 function onKey(event: KeyboardEvent) {
44 if (!event.isComposing && event.code === 'Escape') {
45 // The current dialog can be dismissed by pressing escape if it either has focus
46 // or it has priority
47 const active = document.activeElement;
48 if (hasPriority || (active && contains(ref.current, active))) {
49 event.preventDefault();
50 onDismissRef.current();
51 }
52 }
53 }
54
55 function onClick(event: MouseEvent | TouchEvent) {
56 const { target } = event;
57 if (contains(ref.current, target) || event.defaultPrevented) {
58 return;
59 }
60
61 // The current dialog can be dismissed by pressing outside of it if it either has
62 // focus or it has priority
63 const active = document.activeElement;
64 if (hasPriority || (active && contains(ref.current, active))) {
65 event.preventDefault();
66 onDismissRef.current();
67 }
68 }
69
70 if (focusLoss) document.body.addEventListener('focusout', onFocusOut);
71
72 document.addEventListener('mousedown', onClick);
73 document.addEventListener('touchstart', onClick);
74 document.addEventListener('keydown', onKey);
75
76 return () => {
77 if (focusLoss) document.body.removeEventListener('focusout', onFocusOut);
78
79 document.removeEventListener('mousedown', onClick);
80 document.removeEventListener('touchstart', onClick);
81 document.removeEventListener('keydown', onKey);
82 };
83 }, [ref, hasPriority, disabled, focusLoss]);
84}