Mirror: React hooks for accessible, common web interactions. UI super powers without the UI.
at main 3.3 kB view raw
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}