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