Mirror: React hooks for accessible, common web interactions. UI super powers without the UI.
1import { 2 RestoreSelection, 3 snapshotSelection, 4 restoreSelection, 5} from './utils/selection'; 6import { getFirstFocusTarget, getFocusTargets, focus } from './utils/focus'; 7import { click } from './utils/click'; 8import { useLayoutEffect } from './utils/react'; 9import { contains, isInputElement } from './utils/element'; 10import { Ref } from './types'; 11 12export interface MenuFocusOptions { 13 disabled?: boolean; 14 ownerRef?: Ref<HTMLElement>; 15} 16 17export function useMenuFocus<T extends HTMLElement>( 18 ref: Ref<T>, 19 options?: MenuFocusOptions 20) { 21 const ownerRef = options && options.ownerRef; 22 const disabled = !!(options && options.disabled); 23 24 useLayoutEffect(() => { 25 const { current: element } = ref; 26 if (!element || disabled) return; 27 28 let selection: RestoreSelection | null = null; 29 30 function onFocus(event: FocusEvent) { 31 if (!element || event.defaultPrevented) return; 32 33 const owner = 34 (ownerRef && ownerRef.current) || (selection && selection.element); 35 const { relatedTarget, target } = event; 36 if (relatedTarget === owner) { 37 // When owner is explicitly passed we can make a snapshot early 38 selection = snapshotSelection(owner); 39 } else if ( 40 contains(element, target) && 41 !contains(element, relatedTarget) && 42 (!ownerRef || contains(relatedTarget, ownerRef.current)) 43 ) { 44 // Check whether focus is about to move into the container and snapshot last focus 45 selection = snapshotSelection(owner); 46 } else if ( 47 contains(element, relatedTarget) && 48 !contains(element, target) 49 ) { 50 // Reset focus if it's lost and has left the menu 51 selection = null; 52 } 53 } 54 55 function onKey(event: KeyboardEvent) { 56 if (!element || event.defaultPrevented || event.isComposing) return; 57 58 const owner = 59 (ownerRef && ownerRef.current) || (selection && selection.element); 60 const active = document.activeElement as HTMLElement; 61 const focusTargets = getFocusTargets(element); 62 if ( 63 !focusTargets.length || 64 (!contains(element, active) && !contains(owner, active)) 65 ) { 66 // Do nothing if container doesn't contain focus or not targets are available 67 return; 68 } 69 70 if ( 71 (!isInputElement(active) && event.code === 'ArrowRight') || 72 event.code === 'ArrowDown' 73 ) { 74 // Implement forward movement in focus targets 75 event.preventDefault(); 76 const focusIndex = focusTargets.indexOf(active); 77 const nextIndex = 78 focusIndex < focusTargets.length - 1 ? focusIndex + 1 : 0; 79 focus(focusTargets[nextIndex]); 80 } else if ( 81 (!isInputElement(active) && event.code === 'ArrowLeft') || 82 event.code === 'ArrowUp' 83 ) { 84 // Implement backward movement in focus targets 85 event.preventDefault(); 86 const focusIndex = focusTargets.indexOf(active); 87 const nextIndex = 88 focusIndex > 0 ? focusIndex - 1 : focusTargets.length - 1; 89 focus(focusTargets[nextIndex]); 90 } else if (event.code === 'Home') { 91 // Implement Home => first item 92 event.preventDefault(); 93 focus(focusTargets[0]); 94 } else if (event.code === 'End') { 95 // Implement End => last item 96 event.preventDefault(); 97 focus(focusTargets[focusTargets.length - 1]); 98 } else if ( 99 ownerRef && 100 ownerRef.current && 101 isInputElement(ownerRef.current) && 102 contains(ownerRef.current, active) && 103 event.code === 'Enter' 104 ) { 105 // Move focus to first target when enter is pressed 106 focus(getFirstFocusTarget(element)); 107 } else if ( 108 owner && 109 !contains(ref.current, owner) && 110 !contains(owner, active) && 111 event.code === 'Escape' 112 ) { 113 // Restore selection if escape is pressed 114 event.preventDefault(); 115 restoreSelection(selection); 116 } else if ( 117 (event.code === 'Enter' || event.code === 'Space') && 118 focusTargets.indexOf(active) > -1 && 119 !isInputElement(active) 120 ) { 121 // Implement virtual click / activation for list items 122 event.preventDefault(); 123 click(active); 124 } else if ( 125 owner && 126 isInputElement(owner) && 127 !contains(owner, active) && 128 /^(?:Key|Digit)/.test(event.code) 129 ) { 130 // Restore selection if a key is pressed on input 131 restoreSelection(selection); 132 } 133 } 134 135 document.body.addEventListener('focusin', onFocus); 136 document.addEventListener('keydown', onKey); 137 138 return () => { 139 document.body.removeEventListener('focusin', onFocus); 140 document.removeEventListener('keydown', onKey); 141 142 const active = document.activeElement as HTMLElement; 143 if (!active || contains(element, active)) { 144 restoreSelection(selection); 145 } 146 }; 147 }, [ref.current, disabled]); 148}