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.

Add more focus helpers

+389 -65
+3 -1
src/index.ts
··· 1 - export { useFocusLoop } from './useFocusLoop'; 1 + export * from './useModalFocus'; 2 + export * from './useDialogFocus'; 3 + export * from './useMenuFocus';
+139
src/useDialogFocus.ts
··· 1 + import { snapshotSelection, restoreSelection } from './utils/selection'; 2 + import { getFocusTargets, getNextFocusTarget } from './utils/focus'; 3 + import { useLayoutEffect } from './utils/react'; 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 DialogFocusOptions { 11 + disabled?: boolean; 12 + ownerRef?: Ref<HTMLElement>; 13 + } 14 + 15 + export function useDialogFocus<T extends HTMLElement>( 16 + ref: Ref<T>, 17 + options?: DialogFocusOptions 18 + ) { 19 + const ownerRef = options && options.ownerRef; 20 + const disabled = !!(options && options.disabled); 21 + const hasPriority = usePriority(ref, disabled); 22 + 23 + useLayoutEffect(() => { 24 + if (!ref.current || disabled || !hasPriority) return; 25 + 26 + let selection = snapshotSelection(ownerRef && ownerRef.current); 27 + let willReceiveFocus = false; 28 + let focusMovesForward = true; 29 + 30 + function onClick(event: MouseEvent) { 31 + if (!ref.current || event.defaultPrevented) return; 32 + 33 + const target = event.target as HTMLElement | null; 34 + if (target && getFocusTargets(ref.current).indexOf(target) > -1) { 35 + selection = null; 36 + willReceiveFocus = true; 37 + } 38 + } 39 + 40 + function onFocus(event: FocusEvent) { 41 + if (!ref.current || event.defaultPrevented) return; 42 + 43 + const active = document.activeElement as HTMLElement; 44 + const owner = (ownerRef && ownerRef.current) || selection && selection.element; 45 + 46 + if (willReceiveFocus || (owner && event.target === owner)) { 47 + if (!contains(ref.current, active)) selection = snapshotSelection(owner); 48 + willReceiveFocus = false; 49 + return; 50 + } 51 + 52 + const { relatedTarget, target } = event; 53 + // Check whether focus is about to move into the container and prevent it 54 + if (contains(ref.current, target) && !contains(ref.current, relatedTarget)) { 55 + // Get the next focus target of the container 56 + const focusTarget = getNextFocusTarget(ref.current, !focusMovesForward); 57 + if (focusTarget) { 58 + focusMovesForward = true; 59 + event.preventDefault(); 60 + focusTarget.focus(); 61 + } 62 + } 63 + } 64 + 65 + function onKey(event: KeyboardEvent) { 66 + if (!ref.current || event.defaultPrevented) return; 67 + 68 + // Mark whether focus is moving forward for the `onFocus` handler 69 + if (event.code === 'Tab') { 70 + focusMovesForward = !event.shiftKey; 71 + } 72 + 73 + const active = document.activeElement as HTMLElement; 74 + const owner = (ownerRef && ownerRef.current) || selection && selection.element; 75 + const focusTargets = getFocusTargets(ref.current); 76 + 77 + if ( 78 + !focusTargets.length || 79 + (!contains(owner, active) && !contains(ref.current, active)) 80 + ) { 81 + // Do nothing if no targets are available or the listbox or owner don't have focus 82 + return; 83 + } else if (event.code === 'Tab') { 84 + // Skip over the listbox via the parent if we press tab 85 + const currentTarget = contains(owner, active) ? owner! : ref.current; 86 + const focusTarget = getNextFocusTarget(currentTarget, event.shiftKey); 87 + if (focusTarget) { 88 + event.preventDefault(); 89 + focusTarget.focus(); 90 + } 91 + } else if ( 92 + (!isInputElement(active) && event.code === 'ArrowRight') || 93 + event.code === 'ArrowDown' 94 + ) { 95 + // Implement forward movement in focus targets 96 + event.preventDefault(); 97 + const focusIndex = focusTargets.indexOf(active); 98 + const nextIndex = focusIndex < focusTargets.length - 1 ? focusIndex + 1 : 0; 99 + willReceiveFocus = true; 100 + focusTargets[nextIndex].focus(); 101 + } else if ( 102 + (!isInputElement(active) && event.code === 'ArrowLeft') || 103 + event.code === 'ArrowUp' 104 + ) { 105 + // Implement backward movement in focus targets 106 + event.preventDefault(); 107 + const focusIndex = focusTargets.indexOf(active); 108 + const nextIndex = focusIndex > 0 ? focusIndex - 1 : focusTargets.length - 1; 109 + willReceiveFocus = true; 110 + focusTargets[nextIndex].focus(); 111 + } else if (selection && event.code === 'Escape') { 112 + // Restore selection if escape is pressed 113 + event.preventDefault(); 114 + willReceiveFocus = false; 115 + restoreSelection(selection); 116 + } else if ( 117 + owner && 118 + active !== owner && 119 + isInputElement(owner) && 120 + /^(?:Key|Digit)/.test(event.code) 121 + ) { 122 + // Restore selection if a key is pressed on input 123 + event.preventDefault(); 124 + willReceiveFocus = false; 125 + restoreSelection(selection); 126 + } 127 + } 128 + 129 + ref.current.addEventListener('mousedown', onClick, true); 130 + document.body.addEventListener('focusin', onFocus); 131 + document.addEventListener('keydown', onKey); 132 + 133 + return () => { 134 + ref.current!.removeEventListener('mousedown', onClick); 135 + document.body.removeEventListener('focusin', onFocus); 136 + document.removeEventListener('keydown', onKey); 137 + }; 138 + }, [ref, hasPriority, disabled]); 139 + }
-53
src/useFocusLoop.ts
··· 1 - import { useLayoutEffect } from 'react'; 2 - import { getFirstFocusTarget, getFocusTargets } from './utils/focus'; 3 - import { contains } from './utils/element'; 4 - import { Ref } from './types'; 5 - 6 - export function useFocusLoop<T extends HTMLElement>(ref: Ref<T>) { 7 - useLayoutEffect(() => { 8 - if (!ref.current) return; 9 - 10 - let active = document.activeElement as HTMLElement | null; 11 - if (!active || !ref.current.contains(active)) { 12 - active = getFirstFocusTarget(ref.current); 13 - if (active) active.focus(); 14 - } 15 - 16 - function onBlur(event: FocusEvent) { 17 - const parent = ref.current; 18 - if (!parent || event.defaultPrevented) return; 19 - 20 - if (contains(parent, event.target) && !contains(parent, event.relatedTarget)) { 21 - const target = getFirstFocusTarget(parent); 22 - if (target) target.focus(); 23 - } 24 - } 25 - 26 - function onKeyDown(event: KeyboardEvent) { 27 - const parent = ref.current; 28 - if (!parent || event.defaultPrevented) return; 29 - 30 - if (event.code === 'Tab') { 31 - const activeElement = document.activeElement as HTMLElement; 32 - const targets = getFocusTargets(parent); 33 - const index = targets.indexOf(activeElement); 34 - if (event.shiftKey && index === 0) { 35 - event.preventDefault(); 36 - targets[targets.length - 1].focus(); 37 - } else if (!event.shiftKey && index === targets.length - 1) { 38 - event.preventDefault(); 39 - targets[0].focus(); 40 - } 41 - 42 - } 43 - } 44 - 45 - document.body.addEventListener('focusout', onBlur); 46 - document.addEventListener('keydown', onKeyDown); 47 - 48 - return () => { 49 - document.body.removeEventListener('focusout', onBlur); 50 - document.removeEventListener('keydown', onKeyDown); 51 - }; 52 - }, [ref]); 53 - }
+99
src/useMenuFocus.ts
··· 1 + import { RestoreSelection, snapshotSelection, restoreSelection } from './utils/selection'; 2 + import { getFocusTargets } from './utils/focus'; 3 + import { useLayoutEffect } from './utils/react'; 4 + import { contains, isInputElement } from './utils/element'; 5 + import { Ref } from './types'; 6 + 7 + export interface MenuFocusOptions { 8 + disabled?: boolean; 9 + ownerRef?: Ref<HTMLElement>; 10 + } 11 + 12 + export 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 + }
+68
src/useModalFocus.ts
··· 1 + import { RestoreSelection, snapshotSelection, restoreSelection } from './utils/selection'; 2 + import { getFirstFocusTarget, getFocusTargets } from './utils/focus'; 3 + import { useLayoutEffect } from './utils/react'; 4 + import { contains } from './utils/element'; 5 + import { makePriorityHook } from './usePriority'; 6 + import { Ref } from './types'; 7 + 8 + const usePriority = makePriorityHook(); 9 + 10 + export interface ModalFocusOptions { 11 + disabled?: boolean; 12 + } 13 + 14 + export function useModalFocus<T extends HTMLElement>(ref: Ref<T>, options?: ModalFocusOptions) { 15 + const disabled = !!(options && options.disabled); 16 + const hasPriority = usePriority(ref, disabled); 17 + 18 + useLayoutEffect(() => { 19 + if (!ref.current || !hasPriority || disabled) return; 20 + 21 + let selection: RestoreSelection | null = null; 22 + if (!document.activeElement || !ref.current.contains(document.activeElement)) { 23 + const newTarget = getFirstFocusTarget(ref.current); 24 + if (newTarget) { 25 + selection = snapshotSelection(ref.current); 26 + newTarget.focus(); 27 + } 28 + } 29 + 30 + function onBlur(event: FocusEvent) { 31 + const parent = ref.current; 32 + if (!parent || event.defaultPrevented) return; 33 + 34 + if (contains(parent, event.target) && !contains(parent, event.relatedTarget)) { 35 + const target = getFirstFocusTarget(parent); 36 + if (target) target.focus(); 37 + } 38 + } 39 + 40 + function onKeyDown(event: KeyboardEvent) { 41 + const parent = ref.current; 42 + if (!parent || event.defaultPrevented) return; 43 + 44 + if (event.code === 'Tab') { 45 + const activeElement = document.activeElement as HTMLElement; 46 + const targets = getFocusTargets(parent); 47 + const index = targets.indexOf(activeElement); 48 + if (event.shiftKey && index === 0) { 49 + event.preventDefault(); 50 + targets[targets.length - 1].focus(); 51 + } else if (!event.shiftKey && index === targets.length - 1) { 52 + event.preventDefault(); 53 + targets[0].focus(); 54 + } 55 + 56 + } 57 + } 58 + 59 + document.body.addEventListener('focusout', onBlur); 60 + document.addEventListener('keydown', onKeyDown); 61 + 62 + return () => { 63 + restoreSelection(selection); 64 + document.body.removeEventListener('focusout', onBlur); 65 + document.removeEventListener('keydown', onKeyDown); 66 + }; 67 + }, [ref, hasPriority, disabled]); 68 + }
+55
src/usePriority.ts
··· 1 + import { useState } from 'react'; 2 + import { useLayoutEffect } from './utils/react'; 3 + import { Ref } from './types'; 4 + 5 + /** Creates a priority stack of elements so that we can determine the "deepest" one to be the active hook */ 6 + export const makePriorityHook = () => { 7 + const listeners: Set<Function> = new Set(); 8 + const priorityStack: HTMLElement[] = []; 9 + 10 + const sortByHierarchy = (a: HTMLElement, b: HTMLElement) => { 11 + const x = a.compareDocumentPosition(b); 12 + return ( 13 + (x & 16 /* a contains b */ && -1) || 14 + (x & 8 /* b contains a */ && 1) || 15 + (x & 2 /* b follows a */ && -1) || 16 + (x & 4 /* a follows b */ && 1) 17 + ) || 0; 18 + }; 19 + 20 + /** Indicates whether a given element on a stack of active priority hooks is the deepest element. */ 21 + return function usePriority<T extends HTMLElement>(ref: Ref<T>, disabled?: boolean): boolean { 22 + function computeHasPriority(): boolean { 23 + if (!ref.current) return false; 24 + const tempStack = priorityStack.concat(ref.current).sort(sortByHierarchy); 25 + return tempStack[tempStack.length - 1] === ref.current; 26 + } 27 + 28 + const isDisabled = !!disabled; 29 + const [hasPriority, setHasPriority] = useState(computeHasPriority); 30 + 31 + useLayoutEffect(() => { 32 + if (!ref.current || isDisabled) return; 33 + 34 + const { current } = ref; 35 + 36 + function onChange() { 37 + setHasPriority(computeHasPriority); 38 + } 39 + 40 + priorityStack.push(current); 41 + priorityStack.sort(sortByHierarchy); 42 + listeners.forEach(fn => fn()); 43 + listeners.add(onChange); 44 + 45 + return () => { 46 + const index = priorityStack.indexOf(current); 47 + priorityStack.splice(index, 1); 48 + listeners.delete(onChange); 49 + listeners.forEach(fn => fn()); 50 + }; 51 + }, [ref, isDisabled]); 52 + 53 + return hasPriority; 54 + } 55 + };
+8 -1
src/utils/element.ts
··· 3 3 const index = parseInt(node.getAttribute('tabindex')!, 10); 4 4 return ( 5 5 index === index && 6 - (node as HTMLElement).contentEditable !== 'true' && 6 + !(node as HTMLElement).isContentEditable && 7 7 index 8 8 ) || 0; 9 9 }; ··· 14 14 (node as HTMLElement).offsetHeight && 15 15 node.getClientRects().length && 16 16 getComputedStyle(node).visibility !== 'hidden' 17 + ); 18 + 19 + /** Returns whether an element accepts text input. */ 20 + export const isInputElement = (node: Element): boolean => !!( 21 + node.tagName === 'INPUT' 22 + || node.tagName === 'TEXTAREA' 23 + || (node as HTMLElement).isContentEditable 17 24 ); 18 25 19 26 export const contains = (owner: Element | null, node: Element | EventTarget | null) =>
+4 -2
src/utils/focus.ts
··· 55 55 }; 56 56 57 57 /** Returns the next (optionally in reverse) focus target given a target node. */ 58 - export const getNextFocusTarget = (node: HTMLElement, reverse?: boolean) => { 58 + export const getNextFocusTarget = (node: HTMLElement, reverse?: boolean): HTMLElement | null => { 59 59 let current: Element | null = node; 60 60 while (current) { 61 61 let next: Element | null = current; 62 62 while (next = reverse ? next.previousElementSibling : next.nextElementSibling) { 63 - if (hasFocusTargets(next)) { 63 + if (isVisible(next) && !!node.matches(focusableSelectors)) { 64 + return next as HTMLElement; 65 + } else if (hasFocusTargets(next)) { 64 66 const targets = getFocusTargets(next); 65 67 if (targets.length) 66 68 return targets[reverse ? targets.length - 1 : 0];
+7
src/utils/react.ts
··· 1 + import { useEffect, useLayoutEffect } from 'react'; 2 + 3 + const useIsomorphicEffect = typeof window !== 'undefined' 4 + ? useLayoutEffect 5 + : useEffect; 6 + 7 + export { useIsomorphicEffect as useLayoutEffect };
+6 -8
src/utils/selection.ts
··· 1 - type NodeRef = { current?: HTMLElement | null | void } & HTMLElement; 2 - 3 1 interface RestoreInputSelection { 4 - element: NodeRef, 2 + element: HTMLElement, 5 3 method: 'setSelectionRange', 6 4 arguments: [number, number, 'forward' | 'backward' | 'none' | undefined], 7 5 } 8 6 9 7 interface RestoreActiveNode { 10 - element: NodeRef, 8 + element: HTMLElement, 11 9 method: 'focus', 12 10 } 13 11 14 12 interface RestoreSelectionRange { 15 - element: NodeRef, 13 + element: HTMLElement, 16 14 method: 'range', 17 15 range: Range 18 16 } ··· 26 24 ); 27 25 28 26 /** Snapshots the current focus or selection target, optinally using a ref if it's passed. */ 29 - export const snapshotSelection = (node?: NodeRef): RestoreSelection | null => { 27 + export const snapshotSelection = (node?: HTMLElement | null): RestoreSelection | null => { 30 28 const target = document.activeElement as HTMLElement | null; 31 - const element: NodeRef | null = node && target && (node !== target || (node as any).current !== target) ? node : target; 29 + const element = node && target && node !== target ? node : target; 32 30 if (!element || !target) { 33 31 return null; 34 32 } else if (isInputElement(target)) { ··· 50 48 51 49 /** Restores a given snapshot of a selection, falling back to a simple focus. */ 52 50 export const restoreSelection = (restore: RestoreSelection | null) => { 53 - const target = restore && restore.element && (restore.element.current || restore.element); 51 + const target = restore && restore.element; 54 52 if (!restore || !target || !target.parentNode) { 55 53 return; 56 54 } else if (restore.method === 'setSelectionRange' && isInputElement(target)) {