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