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