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