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 focusMovesForward = true;
68 // Get the next focus target of the container
69 focus(getNextFocusTarget(element, !focusMovesForward));
70 }
71 }
72
73 function onKey(event: KeyboardEvent) {
74 if (!element || event.defaultPrevented || event.isComposing) return;
75
76 // Mark whether focus is moving forward for the `onFocus` handler
77 if (event.code === 'Tab') {
78 focusMovesForward = !event.shiftKey;
79 } else if (!hasPriority) {
80 return;
81 }
82
83 const active = document.activeElement as HTMLElement;
84 const owner =
85 (ownerRef && ownerRef.current) || (selection && selection.element);
86 const focusTargets = getFocusTargets(element);
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 event.preventDefault();
97 const currentTarget = contains(owner, active) ? owner! : element;
98 focus(getNextFocusTarget(currentTarget, event.shiftKey));
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.stopPropagation();
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}