Mirror: React hooks for accessible, common web interactions. UI super powers without the UI.
at v0.1.0 6.4 kB view raw
1import { snapshotSelection, restoreSelection } from './utils/selection'; 2import { 3 getFirstFocusTarget, 4 getFocusTargets, 5 getNextFocusTarget, 6 focus, 7} from './utils/focus'; 8import { click } from './utils/click'; 9import { useLayoutEffect } from './utils/react'; 10import { contains, 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 let selection = snapshotSelection(ownerRef && ownerRef.current); 34 let willReceiveFocus = false; 35 let focusMovesForward = true; 36 37 function onClick(event: MouseEvent) { 38 if (!element || event.defaultPrevented || willReceiveFocus) return; 39 40 const target = event.target as HTMLElement | null; 41 if (target && contains(element, target)) { 42 selection = null; 43 willReceiveFocus = true; 44 } 45 } 46 47 function onFocus(event: FocusEvent) { 48 if (!element || event.defaultPrevented) return; 49 50 const owner = 51 (ownerRef && ownerRef.current) || (selection && selection.element); 52 53 if ( 54 willReceiveFocus || 55 (hasPriority.current && owner && contains(event.target, owner)) 56 ) { 57 if (!contains(ref.current, event.relatedTarget)) 58 selection = snapshotSelection(owner); 59 willReceiveFocus = false; 60 return; 61 } 62 63 // Check whether focus is about to move into the container and prevent it 64 if ( 65 (hasPriority.current || !contains(ref.current, event.relatedTarget)) && 66 contains(ref.current, event.target) 67 ) { 68 event.preventDefault(); 69 // Get the next focus target of the container 70 focus(getNextFocusTarget(element, !focusMovesForward)); 71 focusMovesForward = true; 72 } 73 } 74 75 function onKey(event: KeyboardEvent) { 76 if (!element || event.defaultPrevented || event.isComposing) return; 77 78 // Mark whether focus is moving forward for the `onFocus` handler 79 if (event.code === 'Tab') { 80 focusMovesForward = !event.shiftKey; 81 } else if (!hasPriority.current) { 82 return; 83 } 84 85 const active = document.activeElement as HTMLElement; 86 const owner = 87 (ownerRef && ownerRef.current) || (selection && selection.element); 88 const focusTargets = getFocusTargets(element); 89 90 if ( 91 !focusTargets.length || 92 (!contains(owner, active) && !contains(ref.current, active)) 93 ) { 94 // Do nothing if no targets are available or the listbox or owner don't have focus 95 return; 96 } else if (event.code === 'Tab') { 97 // Skip over the listbox via the parent if we press tab 98 event.preventDefault(); 99 const currentTarget = contains(owner, active) ? owner! : element; 100 focus(getNextFocusTarget(currentTarget, event.shiftKey)); 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 focus(focusTargets[nextIndex]); 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 focus(focusTargets[nextIndex]); 123 } else if (event.code === 'Home') { 124 // Implement Home => first item 125 event.preventDefault(); 126 willReceiveFocus = true; 127 focus(focusTargets[0]); 128 } else if (event.code === 'End') { 129 // Implement End => last item 130 event.preventDefault(); 131 willReceiveFocus = true; 132 focus(focusTargets[focusTargets.length - 1]); 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 focus(newTarget); 154 } 155 } else if ( 156 (event.code === 'Enter' || event.code === 'Space') && 157 focusTargets.indexOf(active) > -1 && 158 !isInputElement(active) 159 ) { 160 // Implement virtual click / activation for list items 161 event.preventDefault(); 162 willReceiveFocus = true; 163 click(active); 164 } else if ( 165 owner && 166 isInputElement(owner) && 167 !contains(owner, active) && 168 /^(?:Key|Digit)/.test(event.code) 169 ) { 170 // Restore selection if a key is pressed on input 171 event.stopPropagation(); 172 willReceiveFocus = false; 173 restoreSelection(selection); 174 } 175 } 176 177 element.addEventListener('mousedown', onClick, true); 178 document.body.addEventListener('focusin', onFocus); 179 document.addEventListener('keydown', onKey); 180 181 return () => { 182 element.removeEventListener('mousedown', onClick); 183 document.body.removeEventListener('focusin', onFocus); 184 document.removeEventListener('keydown', onKey); 185 186 const active = document.activeElement as HTMLElement; 187 if (!active || contains(element, active)) { 188 restoreSelection(selection); 189 } 190 }; 191 }, [ref.current, disabled]); 192}