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