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