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 // Get the next focus target of the container 68 const focusTarget = getNextFocusTarget(element, !focusMovesForward); 69 focusMovesForward = true; 70 focus(focusTarget); 71 } 72 } 73 74 function onKey(event: KeyboardEvent) { 75 if (!element || 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 } else if (!hasPriority) { 81 return; 82 } 83 84 const active = document.activeElement as HTMLElement; 85 const owner = 86 (ownerRef && ownerRef.current) || (selection && selection.element); 87 const focusTargets = getFocusTargets(element); 88 89 if ( 90 !focusTargets.length || 91 (!contains(owner, active) && !contains(ref.current, active)) 92 ) { 93 // Do nothing if no targets are available or the listbox or owner don't have focus 94 return; 95 } else if (event.code === 'Tab') { 96 // Skip over the listbox via the parent if we press tab 97 event.preventDefault(); 98 const currentTarget = contains(owner, active) ? owner! : element; 99 const newTarget = getNextFocusTarget(currentTarget, event.shiftKey); 100 if (newTarget) focus(newTarget); 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 (event.code === 'Home') { 124 // Implement Home => first item 125 event.preventDefault(); 126 willReceiveFocus = true; 127 focusTargets[0].focus(); 128 } else if (event.code === 'End') { 129 // Implement End => last item 130 event.preventDefault(); 131 willReceiveFocus = true; 132 focusTargets[focusTargets.length - 1].focus(); 133 } else if ( 134 owner && 135 !contains(ref.current, owner) && 136 event.code === 'Escape' 137 ) { 138 // Restore selection if escape is pressed 139 event.preventDefault(); 140 willReceiveFocus = false; 141 restoreSelection(selection); 142 } else if ( 143 owner && 144 isInputElement(owner) && 145 !contains(ref.current, owner) && 146 contains(owner, active) && 147 event.code === 'Enter' 148 ) { 149 // Move focus to first target when Enter is pressed 150 const newTarget = getFirstFocusTarget(element); 151 if (newTarget) { 152 willReceiveFocus = true; 153 newTarget.focus(); 154 } 155 } else if ( 156 owner && 157 isInputElement(owner) && 158 !contains(owner, active) && 159 /^(?:Key|Digit)/.test(event.code) 160 ) { 161 // Restore selection if a key is pressed on input 162 event.stopPropagation(); 163 willReceiveFocus = false; 164 restoreSelection(selection); 165 } 166 } 167 168 element.addEventListener('mousedown', onClick, true); 169 document.body.addEventListener('focusin', onFocus); 170 document.addEventListener('keydown', onKey); 171 172 return () => { 173 element.removeEventListener('mousedown', onClick); 174 document.body.removeEventListener('focusin', onFocus); 175 document.removeEventListener('keydown', onKey); 176 }; 177 }, [ref.current, disabled, hasPriority]); 178}