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