Mirror: React hooks for accessible, common web interactions. UI super powers without the UI.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement useOptionFocus and virtual click handling

+120 -22
+1
src/index.ts
··· 2 2 export * from './useModalFocus'; 3 3 export * from './useDialogFocus'; 4 4 export * from './useMenuFocus'; 5 + export * from './useOptionFocus'; 5 6 export * from './useDismissable'; 6 7 export * from './useScrollRestoration'; 7 8 export * from './useStyleTransition';
+18 -7
src/useDialogFocus.ts
··· 3 3 getFirstFocusTarget, 4 4 getFocusTargets, 5 5 getNextFocusTarget, 6 + focus, 6 7 } from './utils/focus'; 8 + import { click } from './utils/click'; 7 9 import { useLayoutEffect } from './utils/react'; 8 - import { contains, focus, isInputElement } from './utils/element'; 10 + import { contains, isInputElement } from './utils/element'; 9 11 import { makePriorityHook } from './usePriority'; 10 12 import { Ref } from './types'; 11 13 ··· 33 35 let focusMovesForward = true; 34 36 35 37 function onClick(event: MouseEvent) { 36 - if (!element || event.defaultPrevented) return; 38 + if (!element || event.defaultPrevented || willReceiveFocus) return; 37 39 38 40 const target = event.target as HTMLElement | null; 39 41 if (target && getFocusTargets(element).indexOf(target) > -1) { ··· 106 108 const nextIndex = 107 109 focusIndex < focusTargets.length - 1 ? focusIndex + 1 : 0; 108 110 willReceiveFocus = true; 109 - focusTargets[nextIndex].focus(); 111 + focus(focusTargets[nextIndex]); 110 112 } else if ( 111 113 (!isInputElement(active) && event.code === 'ArrowLeft') || 112 114 event.code === 'ArrowUp' ··· 117 119 const nextIndex = 118 120 focusIndex > 0 ? focusIndex - 1 : focusTargets.length - 1; 119 121 willReceiveFocus = true; 120 - focusTargets[nextIndex].focus(); 122 + focus(focusTargets[nextIndex]); 121 123 } else if (event.code === 'Home') { 122 124 // Implement Home => first item 123 125 event.preventDefault(); 124 126 willReceiveFocus = true; 125 - focusTargets[0].focus(); 127 + focus(focusTargets[0]); 126 128 } else if (event.code === 'End') { 127 129 // Implement End => last item 128 130 event.preventDefault(); 129 131 willReceiveFocus = true; 130 - focusTargets[focusTargets.length - 1].focus(); 132 + focus(focusTargets[focusTargets.length - 1]); 131 133 } else if ( 132 134 owner && 133 135 !contains(ref.current, owner) && ··· 148 150 const newTarget = getFirstFocusTarget(element); 149 151 if (newTarget) { 150 152 willReceiveFocus = true; 151 - newTarget.focus(); 153 + focus(newTarget); 152 154 } 155 + } else if ( 156 + (event.code === 'Enter' || event.code === 'Space') && 157 + focusTargets.indexOf(active) > -1 && 158 + !isInputElement(active) 159 + ) { 160 + // Implement virtual click / activation for list items 161 + event.preventDefault(); 162 + willReceiveFocus = true; 163 + click(active); 153 164 } else if ( 154 165 owner && 155 166 isInputElement(owner) &&
+15 -7
src/useMenuFocus.ts
··· 3 3 snapshotSelection, 4 4 restoreSelection, 5 5 } from './utils/selection'; 6 - import { getFirstFocusTarget, getFocusTargets } from './utils/focus'; 6 + import { getFirstFocusTarget, getFocusTargets, focus } from './utils/focus'; 7 + import { click } from './utils/click'; 7 8 import { useLayoutEffect } from './utils/react'; 8 9 import { contains, isInputElement } from './utils/element'; 9 10 import { Ref } from './types'; ··· 75 76 const focusIndex = focusTargets.indexOf(active); 76 77 const nextIndex = 77 78 focusIndex < focusTargets.length - 1 ? focusIndex + 1 : 0; 78 - focusTargets[nextIndex].focus(); 79 + focus(focusTargets[nextIndex]); 79 80 } else if ( 80 81 (!isInputElement(active) && event.code === 'ArrowLeft') || 81 82 event.code === 'ArrowUp' ··· 85 86 const focusIndex = focusTargets.indexOf(active); 86 87 const nextIndex = 87 88 focusIndex > 0 ? focusIndex - 1 : focusTargets.length - 1; 88 - focusTargets[nextIndex].focus(); 89 + focus(focusTargets[nextIndex]); 89 90 } else if (event.code === 'Home') { 90 91 // Implement Home => first item 91 92 event.preventDefault(); 92 - focusTargets[0].focus(); 93 + focus(focusTargets[0]); 93 94 } else if (event.code === 'End') { 94 95 // Implement End => last item 95 96 event.preventDefault(); 96 - focusTargets[focusTargets.length - 1].focus(); 97 + focus(focusTargets[focusTargets.length - 1]); 97 98 } else if ( 98 99 owner && 99 100 isInputElement(owner) && ··· 101 102 event.code === 'Enter' 102 103 ) { 103 104 // Move focus to first target when enter is pressed 104 - const newTarget = getFirstFocusTarget(element); 105 - if (newTarget) newTarget.focus(); 105 + focus(getFirstFocusTarget(element)); 106 106 } else if ( 107 107 owner && 108 108 !contains(ref.current, owner) && ··· 112 112 // Restore selection if escape is pressed 113 113 event.preventDefault(); 114 114 restoreSelection(selection); 115 + } else if ( 116 + (event.code === 'Enter' || event.code === 'Space') && 117 + focusTargets.indexOf(active) > -1 && 118 + !isInputElement(active) 119 + ) { 120 + // Implement virtual click / activation for list items 121 + event.preventDefault(); 122 + click(active); 115 123 } else if ( 116 124 owner && 117 125 isInputElement(owner) &&
+44
src/useOptionFocus.ts
··· 1 + import { isFocusTarget } from './utils/focus'; 2 + import { useLayoutEffect } from './utils/react'; 3 + import { click } from './utils/click'; 4 + import { contains, isInputElement } from './utils/element'; 5 + import { makePriorityHook } from './usePriority'; 6 + import { Ref } from './types'; 7 + 8 + const usePriority = makePriorityHook(); 9 + 10 + export interface OptionFocusOptions { 11 + disabled?: boolean; 12 + } 13 + 14 + export function useOptionFocus<T extends HTMLElement>( 15 + ref: Ref<T>, 16 + options?: OptionFocusOptions 17 + ) { 18 + const disabled = !!(options && options.disabled); 19 + const hasPriority = usePriority(ref, disabled); 20 + 21 + useLayoutEffect(() => { 22 + const { current: element } = ref; 23 + // NOTE: This behaviour isn't necessary for input elements 24 + if (!element || disabled || isInputElement(element)) return; 25 + 26 + function onKey(event: KeyboardEvent) { 27 + if (!element || event.defaultPrevented || event.isComposing) return; 28 + 29 + const active = document.activeElement as HTMLElement; 30 + if (!isFocusTarget(element) || !contains(active, element)) { 31 + // Do nothing if the current item is not a target or not focused 32 + return; 33 + } else if (event.code === 'Space' || event.code === 'Enter') { 34 + event.preventDefault(); 35 + click(element); 36 + } 37 + } 38 + 39 + element.addEventListener('keydown', onKey); 40 + return () => { 41 + element.removeEventListener('keydown', onKey); 42 + }; 43 + }, [ref.current, disabled, hasPriority]); 44 + }
+29
src/utils/click.ts
··· 1 + import { contains } from './element'; 2 + import { focus } from './focus'; 3 + 4 + const clickableSelectors = [ 5 + '[contenteditable]', 6 + 'input:not([type="hidden"]):not([disabled])', 7 + 'button:not([disabled])', 8 + 'select:not([disabled])', 9 + 'a[href]', 10 + ].join(','); 11 + 12 + export const click = (node: Element) => { 13 + const activeElement = document.activeElement; 14 + if (!activeElement || contains(node, activeElement)) { 15 + let target: Element | null = node; 16 + 17 + if (node.tagName === 'LABEL') { 18 + const forId = node.getAttribute('for'); 19 + target = forId ? document.getElementById(forId) : null; 20 + } 21 + 22 + if (!target || !node.matches(clickableSelectors)) { 23 + target = node.querySelector(clickableSelectors); 24 + } 25 + 26 + if (target) (target as HTMLElement).click(); 27 + focus(activeElement); 28 + } 29 + };
-8
src/utils/element.ts
··· 32 32 owner && 33 33 (owner === node || (owner as Element).contains(node as Element)) 34 34 ); 35 - 36 - export const focus = (element: Element | null) => { 37 - if (element) { 38 - (element as HTMLElement).focus(); 39 - } else if (document.activeElement) { 40 - (document.activeElement as HTMLElement).blur(); 41 - } 42 - };
+13
src/utils/focus.ts
··· 24 24 return a[1] === a[1] ? a[0] - b[0] : a[1] - a[1]; 25 25 }; 26 26 27 + /** Returns whether this node is focusable. */ 28 + export const isFocusTarget = (node: Element): boolean => 29 + !!node.matches(focusableSelectors) && isVisible(node); 30 + 27 31 /** Returns whether this node may contain focusable elements. */ 28 32 export const hasFocusTargets = (node: Element): boolean => 29 33 !node.matches(excludeSelector) && ··· 99 103 100 104 return null; 101 105 }; 106 + 107 + /** Focuses the given node or blurs if null is passed. */ 108 + export const focus = (node: Element | null) => { 109 + if (node) { 110 + (node as HTMLElement).focus(); 111 + } else if (document.activeElement) { 112 + (document.activeElement as HTMLElement).blur(); 113 + } 114 + };