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}