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