Mirror: React hooks for accessible, common web interactions. UI super powers without the UI.
1import { snapshotSelection, restoreSelection } from './utils/selection'; 2import { getFocusTargets, getNextFocusTarget } from './utils/focus'; 3import { useLayoutEffect } from './utils/react'; 4import { contains, isInputElement } from './utils/element'; 5import { makePriorityHook } from './usePriority'; 6import { Ref } from './types'; 7 8const usePriority = makePriorityHook(); 9 10export interface DialogFocusOptions { 11 disabled?: boolean; 12 ownerRef?: Ref<HTMLElement>; 13} 14 15export function useDialogFocus<T extends HTMLElement>( 16 ref: Ref<T>, 17 options?: DialogFocusOptions 18) { 19 const ownerRef = options && options.ownerRef; 20 const disabled = !!(options && options.disabled); 21 const hasPriority = usePriority(ref, disabled); 22 23 useLayoutEffect(() => { 24 if (!ref.current || disabled || !hasPriority) return; 25 26 let selection = snapshotSelection(ownerRef && ownerRef.current); 27 let willReceiveFocus = false; 28 let focusMovesForward = true; 29 30 function onClick(event: MouseEvent) { 31 if (!ref.current || event.defaultPrevented) return; 32 33 const target = event.target as HTMLElement | null; 34 if (target && getFocusTargets(ref.current).indexOf(target) > -1) { 35 selection = null; 36 willReceiveFocus = true; 37 } 38 } 39 40 function onFocus(event: FocusEvent) { 41 if (!ref.current || event.defaultPrevented) return; 42 43 const active = document.activeElement as HTMLElement; 44 const owner = (ownerRef && ownerRef.current) || selection && selection.element; 45 46 if (willReceiveFocus || (owner && event.target === owner)) { 47 if (!contains(ref.current, active)) selection = snapshotSelection(owner); 48 willReceiveFocus = false; 49 return; 50 } 51 52 const { relatedTarget, target } = event; 53 // Check whether focus is about to move into the container and prevent it 54 if (contains(ref.current, target) && !contains(ref.current, relatedTarget)) { 55 // Get the next focus target of the container 56 const focusTarget = getNextFocusTarget(ref.current, !focusMovesForward); 57 if (focusTarget) { 58 focusMovesForward = true; 59 event.preventDefault(); 60 focusTarget.focus(); 61 } 62 } 63 } 64 65 function onKey(event: KeyboardEvent) { 66 if (!ref.current || event.defaultPrevented) return; 67 68 // Mark whether focus is moving forward for the `onFocus` handler 69 if (event.code === 'Tab') { 70 focusMovesForward = !event.shiftKey; 71 } 72 73 const active = document.activeElement as HTMLElement; 74 const owner = (ownerRef && ownerRef.current) || selection && selection.element; 75 const focusTargets = getFocusTargets(ref.current); 76 77 if ( 78 !focusTargets.length || 79 (!contains(owner, active) && !contains(ref.current, active)) 80 ) { 81 // Do nothing if no targets are available or the listbox or owner don't have focus 82 return; 83 } else if (event.code === 'Tab') { 84 // Skip over the listbox via the parent if we press tab 85 const currentTarget = contains(owner, active) ? owner! : ref.current; 86 const focusTarget = getNextFocusTarget(currentTarget, event.shiftKey); 87 if (focusTarget) { 88 event.preventDefault(); 89 focusTarget.focus(); 90 } 91 } else if ( 92 (!isInputElement(active) && event.code === 'ArrowRight') || 93 event.code === 'ArrowDown' 94 ) { 95 // Implement forward movement in focus targets 96 event.preventDefault(); 97 const focusIndex = focusTargets.indexOf(active); 98 const nextIndex = focusIndex < focusTargets.length - 1 ? focusIndex + 1 : 0; 99 willReceiveFocus = true; 100 focusTargets[nextIndex].focus(); 101 } else if ( 102 (!isInputElement(active) && event.code === 'ArrowLeft') || 103 event.code === 'ArrowUp' 104 ) { 105 // Implement backward movement in focus targets 106 event.preventDefault(); 107 const focusIndex = focusTargets.indexOf(active); 108 const nextIndex = focusIndex > 0 ? focusIndex - 1 : focusTargets.length - 1; 109 willReceiveFocus = true; 110 focusTargets[nextIndex].focus(); 111 } else if (selection && event.code === 'Escape') { 112 // Restore selection if escape is pressed 113 event.preventDefault(); 114 willReceiveFocus = false; 115 restoreSelection(selection); 116 } else if ( 117 owner && 118 active !== owner && 119 isInputElement(owner) && 120 /^(?:Key|Digit)/.test(event.code) 121 ) { 122 // Restore selection if a key is pressed on input 123 event.preventDefault(); 124 willReceiveFocus = false; 125 restoreSelection(selection); 126 } 127 } 128 129 ref.current.addEventListener('mousedown', onClick, true); 130 document.body.addEventListener('focusin', onFocus); 131 document.addEventListener('keydown', onKey); 132 133 return () => { 134 ref.current!.removeEventListener('mousedown', onClick); 135 document.body.removeEventListener('focusin', onFocus); 136 document.removeEventListener('keydown', onKey); 137 }; 138 }, [ref, hasPriority, disabled]); 139}