Mirror: React hooks for accessible, common web interactions. UI super powers without the UI.
1import { RestoreSelection, snapshotSelection, restoreSelection } from './utils/selection'; 2import { 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) 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 && active !== owner && event.code === 'Escape') { 76 // Restore selection if escape is pressed 77 event.preventDefault(); 78 restoreSelection(selection); 79 } else if ( 80 owner && 81 active !== owner && 82 isInputElement(owner) && 83 /^(?:Key|Digit)/.test(event.code) 84 ) { 85 // Restore selection if a key is pressed on input 86 event.preventDefault(); 87 restoreSelection(selection); 88 } 89 } 90 91 document.body.addEventListener('focusin', onFocus); 92 document.addEventListener('keydown', onKey); 93 94 return () => { 95 document.body.removeEventListener('focusin', onFocus); 96 document.removeEventListener('keydown', onKey); 97 }; 98 }, [ref, disabled]); 99}