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