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