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