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