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