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