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