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 (!ownerRef || contains(relatedTarget, ownerRef.current)) 41 ) { 42 // Check whether focus is about to move into the container and snapshot last focus 43 selection = snapshotSelection(owner); 44 } else if ( 45 contains(ref.current, relatedTarget) && 46 !contains(ref.current, target) 47 ) { 48 // Reset focus if it's lost and has left the menu 49 selection = null; 50 } 51 } 52 53 function onKey(event: KeyboardEvent) { 54 if (!ref.current || event.defaultPrevented || event.isComposing) return; 55 56 const owner = 57 (ownerRef && ownerRef.current) || (selection && selection.element); 58 const active = document.activeElement as HTMLElement; 59 const focusTargets = getFocusTargets(ref.current); 60 if ( 61 !focusTargets.length || 62 (!contains(ref.current, active) && !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 const newTarget = getFirstFocusTarget(ref.current); 104 if (newTarget) newTarget.focus(); 105 } else if ( 106 owner && 107 !contains(ref.current, owner) && 108 !contains(owner, active) && 109 event.code === 'Escape' 110 ) { 111 // Restore selection if escape is pressed 112 event.preventDefault(); 113 restoreSelection(selection); 114 } else if ( 115 owner && 116 isInputElement(owner) && 117 !contains(owner, active) && 118 /^(?:Key|Digit)/.test(event.code) 119 ) { 120 // Restore selection if a key is pressed on input 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}