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 const { current: element } = ref;
25 if (!element || disabled) return;
26
27 let selection: RestoreSelection | null = null;
28
29 function onFocus(event: FocusEvent) {
30 if (!element || event.defaultPrevented) return;
31
32 const owner =
33 (ownerRef && ownerRef.current) || (selection && selection.element);
34 const { relatedTarget, target } = event;
35 if (relatedTarget === owner) {
36 // When owner is explicitly passed we can make a snapshot early
37 selection = snapshotSelection(owner);
38 } else if (
39 contains(element, target) &&
40 !contains(element, relatedTarget) &&
41 (!ownerRef || contains(relatedTarget, ownerRef.current))
42 ) {
43 // Check whether focus is about to move into the container and snapshot last focus
44 selection = snapshotSelection(owner);
45 } else if (
46 contains(element, relatedTarget) &&
47 !contains(element, target)
48 ) {
49 // Reset focus if it's lost and has left the menu
50 selection = null;
51 }
52 }
53
54 function onKey(event: KeyboardEvent) {
55 if (!element || event.defaultPrevented || event.isComposing) return;
56
57 const owner =
58 (ownerRef && ownerRef.current) || (selection && selection.element);
59 const active = document.activeElement as HTMLElement;
60 const focusTargets = getFocusTargets(element);
61 if (
62 !focusTargets.length ||
63 (!contains(element, active) && !contains(owner, active))
64 ) {
65 // Do nothing if container doesn't contain focus or not targets are available
66 return;
67 }
68
69 if (
70 (!isInputElement(active) && event.code === 'ArrowRight') ||
71 event.code === 'ArrowDown'
72 ) {
73 // Implement forward movement in focus targets
74 event.preventDefault();
75 const focusIndex = focusTargets.indexOf(active);
76 const nextIndex =
77 focusIndex < focusTargets.length - 1 ? focusIndex + 1 : 0;
78 focusTargets[nextIndex].focus();
79 } else if (
80 (!isInputElement(active) && event.code === 'ArrowLeft') ||
81 event.code === 'ArrowUp'
82 ) {
83 // Implement backward movement in focus targets
84 event.preventDefault();
85 const focusIndex = focusTargets.indexOf(active);
86 const nextIndex =
87 focusIndex > 0 ? focusIndex - 1 : focusTargets.length - 1;
88 focusTargets[nextIndex].focus();
89 } else if (event.code === 'Home') {
90 // Implement Home => first item
91 event.preventDefault();
92 focusTargets[0].focus();
93 } else if (event.code === 'End') {
94 // Implement End => last item
95 event.preventDefault();
96 focusTargets[focusTargets.length - 1].focus();
97 } else if (
98 owner &&
99 isInputElement(owner) &&
100 contains(owner, active) &&
101 event.code === 'Enter'
102 ) {
103 // Move focus to first target when enter is pressed
104 const newTarget = getFirstFocusTarget(element);
105 if (newTarget) newTarget.focus();
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 owner &&
117 isInputElement(owner) &&
118 !contains(owner, active) &&
119 /^(?:Key|Digit)/.test(event.code)
120 ) {
121 // Restore selection if a key is pressed on input
122 restoreSelection(selection);
123 }
124 }
125
126 document.body.addEventListener('focusin', onFocus);
127 document.addEventListener('keydown', onKey);
128
129 return () => {
130 document.body.removeEventListener('focusin', onFocus);
131 document.removeEventListener('keydown', onKey);
132 };
133 }, [ref.current, disabled]);
134}