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';
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}