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 const isActive = !disabled && !!hasPriority; 27 28 useLayoutEffect(() => { 29 if (!ref.current || !isActive) return; 30 31 let selection = snapshotSelection(ownerRef && ownerRef.current); 32 let willReceiveFocus = false; 33 let focusMovesForward = true; 34 35 function onClick(event: MouseEvent) { 36 if (!ref.current || event.defaultPrevented) return; 37 38 const target = event.target as HTMLElement | null; 39 if (target && getFocusTargets(ref.current).indexOf(target) > -1) { 40 selection = null; 41 willReceiveFocus = true; 42 } 43 } 44 45 function onFocus(event: FocusEvent) { 46 if (!ref.current || event.defaultPrevented) return; 47 48 const active = document.activeElement as HTMLElement; 49 const owner = 50 (ownerRef && ownerRef.current) || (selection && selection.element); 51 52 if (willReceiveFocus || (owner && event.target === owner)) { 53 if (!contains(ref.current, active)) 54 selection = snapshotSelection(owner); 55 willReceiveFocus = false; 56 return; 57 } 58 59 const { relatedTarget, target } = event; 60 // Check whether focus is about to move into the container and prevent it 61 if ( 62 contains(ref.current, target) && 63 !contains(ref.current, relatedTarget) 64 ) { 65 // Get the next focus target of the container 66 const focusTarget = getNextFocusTarget(ref.current, !focusMovesForward); 67 if (focusTarget) { 68 focusMovesForward = true; 69 event.preventDefault(); 70 focusTarget.focus(); 71 } 72 } 73 } 74 75 function onKey(event: KeyboardEvent) { 76 if (!ref.current || event.defaultPrevented || event.isComposing) return; 77 78 // Mark whether focus is moving forward for the `onFocus` handler 79 if (event.code === 'Tab') { 80 focusMovesForward = !event.shiftKey; 81 } 82 83 const active = document.activeElement as HTMLElement; 84 const owner = 85 (ownerRef && ownerRef.current) || (selection && selection.element); 86 const focusTargets = getFocusTargets(ref.current); 87 88 if ( 89 !focusTargets.length || 90 (!contains(owner, active) && !contains(ref.current, active)) 91 ) { 92 // Do nothing if no targets are available or the listbox or owner don't have focus 93 return; 94 } else if (event.code === 'Tab') { 95 // Skip over the listbox via the parent if we press tab 96 const currentTarget = contains(owner, active) ? owner! : ref.current; 97 const focusTarget = getNextFocusTarget(currentTarget, event.shiftKey); 98 if (focusTarget) { 99 event.preventDefault(); 100 focusTarget.focus(); 101 } 102 } else if ( 103 (!isInputElement(active) && event.code === 'ArrowRight') || 104 event.code === 'ArrowDown' 105 ) { 106 // Implement forward movement in focus targets 107 event.preventDefault(); 108 const focusIndex = focusTargets.indexOf(active); 109 const nextIndex = 110 focusIndex < focusTargets.length - 1 ? focusIndex + 1 : 0; 111 willReceiveFocus = true; 112 focusTargets[nextIndex].focus(); 113 } else if ( 114 (!isInputElement(active) && event.code === 'ArrowLeft') || 115 event.code === 'ArrowUp' 116 ) { 117 // Implement backward movement in focus targets 118 event.preventDefault(); 119 const focusIndex = focusTargets.indexOf(active); 120 const nextIndex = 121 focusIndex > 0 ? focusIndex - 1 : focusTargets.length - 1; 122 willReceiveFocus = true; 123 focusTargets[nextIndex].focus(); 124 } else if (event.code === 'Home') { 125 // Implement Home => first item 126 event.preventDefault(); 127 willReceiveFocus = true; 128 focusTargets[0].focus(); 129 } else if (event.code === 'End') { 130 // Implement End => last item 131 event.preventDefault(); 132 willReceiveFocus = true; 133 focusTargets[focusTargets.length - 1].focus(); 134 } else if ( 135 owner && 136 !contains(ref.current, owner) && 137 event.code === 'Escape' 138 ) { 139 // Restore selection if escape is pressed 140 event.preventDefault(); 141 willReceiveFocus = false; 142 restoreSelection(selection); 143 } else if ( 144 owner && 145 isInputElement(owner) && 146 !contains(ref.current, owner) && 147 contains(owner, active) && 148 event.code === 'Enter' 149 ) { 150 // Move focus to first target when Enter is pressed 151 const newTarget = getFirstFocusTarget(ref.current); 152 if (newTarget) { 153 event.preventDefault(); 154 willReceiveFocus = true; 155 newTarget.focus(); 156 } 157 } else if ( 158 owner && 159 contains(owner, active) && 160 isInputElement(owner) && 161 /^(?:Key|Digit)/.test(event.code) 162 ) { 163 // Restore selection if a key is pressed on input 164 willReceiveFocus = false; 165 restoreSelection(selection); 166 } 167 } 168 169 ref.current.addEventListener('mousedown', onClick, true); 170 document.body.addEventListener('focusin', onFocus); 171 document.addEventListener('keydown', onKey); 172 173 return () => { 174 ref.current!.removeEventListener('mousedown', onClick); 175 document.body.removeEventListener('focusin', onFocus); 176 document.removeEventListener('keydown', onKey); 177 }; 178 }, [ref, isActive]); 179}