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 const active = document.activeElement; 60 if ( 61 event.code === 'Escape' && 62 (hasPriority.current || (active && contains(element, active))) 63 ) { 64 // The current dialog can be dismissed by pressing escape if it either has focus 65 // or it has priority 66 event.preventDefault(); 67 onDismissRef.current(); 68 } else if (event.code === 'Tab') { 69 willLoseFocus = true; 70 } 71 } 72 73 function onClick(event: MouseEvent | TouchEvent) { 74 const { target } = event; 75 const active = document.activeElement; 76 if (event.defaultPrevented) { 77 return; 78 } else if (contains(element, target)) { 79 willLoseFocus = false; 80 return; 81 } else if (hasPriority || (active && contains(element, active))) { 82 // The current dialog can be dismissed by pressing outside of it if it either has 83 // focus or it has priority 84 event.preventDefault(); 85 onDismissRef.current(); 86 } 87 } 88 89 if (focusLoss) { 90 document.body.addEventListener('focusout', onFocusOut); 91 document.body.addEventListener('focusin', onFocusIn); 92 } 93 94 document.addEventListener('mousedown', onClick); 95 document.addEventListener('touchstart', onClick); 96 document.addEventListener('keydown', onKey); 97 98 return () => { 99 if (focusLoss) { 100 document.body.removeEventListener('focusout', onFocusOut); 101 document.body.removeEventListener('focusin', onFocusIn); 102 } 103 104 document.removeEventListener('mousedown', onClick); 105 document.removeEventListener('touchstart', onClick); 106 document.removeEventListener('keydown', onKey); 107 }; 108 }, [ref.current, hasPriority, disabled, focusLoss]); 109}