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 owner &&
100 isInputElement(owner) &&
101 contains(owner, active) &&
102 event.code === 'Enter'
103 ) {
104 // Move focus to first target when enter is pressed
105 focus(getFirstFocusTarget(element));
106 } else if (
107 owner &&
108 !contains(ref.current, owner) &&
109 !contains(owner, active) &&
110 event.code === 'Escape'
111 ) {
112 // Restore selection if escape is pressed
113 event.preventDefault();
114 restoreSelection(selection);
115 } else if (
116 (event.code === 'Enter' || event.code === 'Space') &&
117 focusTargets.indexOf(active) > -1 &&
118 !isInputElement(active)
119 ) {
120 // Implement virtual click / activation for list items
121 event.preventDefault();
122 click(active);
123 } else if (
124 owner &&
125 isInputElement(owner) &&
126 !contains(owner, active) &&
127 /^(?:Key|Digit)/.test(event.code)
128 ) {
129 // Restore selection if a key is pressed on input
130 restoreSelection(selection);
131 }
132 }
133
134 document.body.addEventListener('focusin', onFocus);
135 document.addEventListener('keydown', onKey);
136
137 return () => {
138 const active = document.activeElement as HTMLElement;
139 if (!active || contains(element, active)) {
140 restoreSelection(selection);
141 }
142
143 document.body.removeEventListener('focusin', onFocus);
144 document.removeEventListener('keydown', onKey);
145 };
146 }, [ref.current, disabled]);
147}