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