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