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 const isActive = !disabled && !!hasPriority;
27
28 useLayoutEffect(() => {
29 if (!ref.current || !isActive) 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 (!ref.current || event.defaultPrevented) return;
37
38 const target = event.target as HTMLElement | null;
39 if (target && getFocusTargets(ref.current).indexOf(target) > -1) {
40 selection = null;
41 willReceiveFocus = true;
42 }
43 }
44
45 function onFocus(event: FocusEvent) {
46 if (!ref.current || event.defaultPrevented) return;
47
48 const active = document.activeElement as HTMLElement;
49 const owner =
50 (ownerRef && ownerRef.current) || (selection && selection.element);
51
52 if (willReceiveFocus || (owner && event.target === owner)) {
53 if (!contains(ref.current, active))
54 selection = snapshotSelection(owner);
55 willReceiveFocus = false;
56 return;
57 }
58
59 const { relatedTarget, target } = event;
60 // Check whether focus is about to move into the container and prevent it
61 if (
62 contains(ref.current, target) &&
63 !contains(ref.current, relatedTarget)
64 ) {
65 // Get the next focus target of the container
66 const focusTarget = getNextFocusTarget(ref.current, !focusMovesForward);
67 if (focusTarget) {
68 focusMovesForward = true;
69 event.preventDefault();
70 focusTarget.focus();
71 }
72 }
73 }
74
75 function onKey(event: KeyboardEvent) {
76 if (!ref.current || 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 }
82
83 const active = document.activeElement as HTMLElement;
84 const owner =
85 (ownerRef && ownerRef.current) || (selection && selection.element);
86 const focusTargets = getFocusTargets(ref.current);
87
88 if (
89 !focusTargets.length ||
90 (!contains(owner, active) && !contains(ref.current, active))
91 ) {
92 // Do nothing if no targets are available or the listbox or owner don't have focus
93 return;
94 } else if (event.code === 'Tab') {
95 // Skip over the listbox via the parent if we press tab
96 const currentTarget = contains(owner, active) ? owner! : ref.current;
97 const focusTarget = getNextFocusTarget(currentTarget, event.shiftKey);
98 if (focusTarget) {
99 event.preventDefault();
100 focusTarget.focus();
101 }
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 focusTargets[nextIndex].focus();
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 focusTargets[nextIndex].focus();
124 } else if (event.code === 'Home') {
125 // Implement Home => first item
126 event.preventDefault();
127 willReceiveFocus = true;
128 focusTargets[0].focus();
129 } else if (event.code === 'End') {
130 // Implement End => last item
131 event.preventDefault();
132 willReceiveFocus = true;
133 focusTargets[focusTargets.length - 1].focus();
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 = getFirstFocusTarget(ref.current);
152 if (newTarget) {
153 event.preventDefault();
154 willReceiveFocus = true;
155 newTarget.focus();
156 }
157 } else if (
158 owner &&
159 contains(owner, active) &&
160 isInputElement(owner) &&
161 /^(?:Key|Digit)/.test(event.code)
162 ) {
163 // Restore selection if a key is pressed on input
164 willReceiveFocus = false;
165 restoreSelection(selection);
166 }
167 }
168
169 ref.current.addEventListener('mousedown', onClick, true);
170 document.body.addEventListener('focusin', onFocus);
171 document.addEventListener('keydown', onKey);
172
173 return () => {
174 ref.current!.removeEventListener('mousedown', onClick);
175 document.body.removeEventListener('focusin', onFocus);
176 document.removeEventListener('keydown', onKey);
177 };
178 }, [ref, isActive]);
179}