Mirror: React hooks for accessible, common web interactions. UI super powers without the UI.
1import { snapshotSelection, restoreSelection } from './utils/selection';
2import {
3 getFirstFocusTarget,
4 getFocusTargets,
5 getNextFocusTarget,
6 focus,
7} from './utils/focus';
8import { click } from './utils/click';
9import { useLayoutEffect } from './utils/react';
10import { contains, isInputElement } from './utils/element';
11import { makePriorityHook } from './usePriority';
12import { Ref } from './types';
13
14const usePriority = makePriorityHook();
15
16export interface DialogFocusOptions {
17 disabled?: boolean;
18 ownerRef?: Ref<HTMLElement>;
19}
20
21export function useDialogFocus<T extends HTMLElement>(
22 ref: Ref<T>,
23 options?: DialogFocusOptions
24) {
25 const ownerRef = options && options.ownerRef;
26 const disabled = !!(options && options.disabled);
27 const hasPriority = usePriority(ref, disabled);
28
29 useLayoutEffect(() => {
30 const { current: element } = ref;
31 if (!element || disabled) return;
32
33 let selection = snapshotSelection(ownerRef && ownerRef.current);
34 let willReceiveFocus = false;
35 let focusMovesForward = true;
36
37 function onClick(event: MouseEvent) {
38 if (!element || event.defaultPrevented || willReceiveFocus) return;
39
40 const target = event.target as HTMLElement | null;
41 if (target && contains(element, target)) {
42 selection = null;
43 willReceiveFocus = true;
44 }
45 }
46
47 function onFocus(event: FocusEvent) {
48 if (!element || event.defaultPrevented) return;
49
50 const owner =
51 (ownerRef && ownerRef.current) || (selection && selection.element);
52
53 if (
54 willReceiveFocus ||
55 (hasPriority.current && owner && contains(event.target, owner))
56 ) {
57 if (!contains(ref.current, event.relatedTarget))
58 selection = snapshotSelection(owner);
59 willReceiveFocus = false;
60 return;
61 }
62
63 // Check whether focus is about to move into the container and prevent it
64 if (
65 (hasPriority.current || !contains(ref.current, event.relatedTarget)) &&
66 contains(ref.current, event.target)
67 ) {
68 event.preventDefault();
69 // Get the next focus target of the container
70 focus(getNextFocusTarget(element, !focusMovesForward));
71 focusMovesForward = true;
72 }
73 }
74
75 function onKey(event: KeyboardEvent) {
76 if (!element || event.defaultPrevented || event.isComposing) return;
77
78 // Mark whether focus is moving forward for the `onFocus` handler
79 if (event.code === 'Tab') {
80 focusMovesForward = !event.shiftKey;
81 } else if (!hasPriority.current) {
82 return;
83 }
84
85 const active = document.activeElement as HTMLElement;
86 const owner =
87 (ownerRef && ownerRef.current) || (selection && selection.element);
88 const focusTargets = getFocusTargets(element);
89
90 if (
91 !focusTargets.length ||
92 (!contains(owner, active) && !contains(ref.current, active))
93 ) {
94 // Do nothing if no targets are available or the listbox or owner don't have focus
95 return;
96 } else if (event.code === 'Tab') {
97 // Skip over the listbox via the parent if we press tab
98 event.preventDefault();
99 const currentTarget = contains(owner, active) ? owner! : element;
100 focus(getNextFocusTarget(currentTarget, event.shiftKey));
101 } else if (
102 (!isInputElement(active) && event.code === 'ArrowRight') ||
103 event.code === 'ArrowDown'
104 ) {
105 // Implement forward movement in focus targets
106 event.preventDefault();
107 const focusIndex = focusTargets.indexOf(active);
108 const nextIndex =
109 focusIndex < focusTargets.length - 1 ? focusIndex + 1 : 0;
110 willReceiveFocus = true;
111 focus(focusTargets[nextIndex]);
112 } else if (
113 (!isInputElement(active) && event.code === 'ArrowLeft') ||
114 event.code === 'ArrowUp'
115 ) {
116 // Implement backward movement in focus targets
117 event.preventDefault();
118 const focusIndex = focusTargets.indexOf(active);
119 const nextIndex =
120 focusIndex > 0 ? focusIndex - 1 : focusTargets.length - 1;
121 willReceiveFocus = true;
122 focus(focusTargets[nextIndex]);
123 } else if (event.code === 'Home') {
124 // Implement Home => first item
125 event.preventDefault();
126 willReceiveFocus = true;
127 focus(focusTargets[0]);
128 } else if (event.code === 'End') {
129 // Implement End => last item
130 event.preventDefault();
131 willReceiveFocus = true;
132 focus(focusTargets[focusTargets.length - 1]);
133 } else if (
134 owner &&
135 !contains(ref.current, owner) &&
136 event.code === 'Escape'
137 ) {
138 // Restore selection if escape is pressed
139 event.preventDefault();
140 willReceiveFocus = false;
141 restoreSelection(selection);
142 } else if (
143 owner &&
144 isInputElement(owner) &&
145 !contains(ref.current, owner) &&
146 contains(owner, active) &&
147 event.code === 'Enter'
148 ) {
149 // Move focus to first target when Enter is pressed
150 const newTarget = getFirstFocusTarget(element);
151 if (newTarget) {
152 willReceiveFocus = true;
153 focus(newTarget);
154 }
155 } else if (
156 (event.code === 'Enter' || event.code === 'Space') &&
157 focusTargets.indexOf(active) > -1 &&
158 !isInputElement(active)
159 ) {
160 // Implement virtual click / activation for list items
161 event.preventDefault();
162 willReceiveFocus = true;
163 click(active);
164 } else if (
165 owner &&
166 isInputElement(owner) &&
167 !contains(owner, active) &&
168 /^(?:Key|Digit)/.test(event.code)
169 ) {
170 // Restore selection if a key is pressed on input
171 event.stopPropagation();
172 willReceiveFocus = false;
173 restoreSelection(selection);
174 }
175 }
176
177 element.addEventListener('mousedown', onClick, true);
178 document.body.addEventListener('focusin', onFocus);
179 document.addEventListener('keydown', onKey);
180
181 return () => {
182 const active = document.activeElement as HTMLElement;
183 if (!active || contains(element, active)) {
184 restoreSelection(selection);
185 }
186
187 element.removeEventListener('mousedown', onClick);
188 document.body.removeEventListener('focusin', onFocus);
189 document.removeEventListener('keydown', onKey);
190 };
191 }, [ref.current, disabled]);
192}