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