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 || !hasPriority) 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 (willReceiveFocus || (owner && event.target === owner)) {
52 if (!contains(ref.current, active))
53 selection = snapshotSelection(owner);
54 willReceiveFocus = false;
55 return;
56 }
57
58 const { relatedTarget, target } = event;
59 // Check whether focus is about to move into the container and prevent it
60 if (
61 contains(ref.current, target) &&
62 !contains(ref.current, relatedTarget)
63 ) {
64 // Get the next focus target of the container
65 const focusTarget = getNextFocusTarget(ref.current, !focusMovesForward);
66 if (focusTarget) {
67 focusMovesForward = true;
68 event.preventDefault();
69 focusTarget.focus();
70 }
71 }
72 }
73
74 function onKey(event: KeyboardEvent) {
75 if (!ref.current || 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 }
81
82 const active = document.activeElement as HTMLElement;
83 const owner =
84 (ownerRef && ownerRef.current) || (selection && selection.element);
85 const focusTargets = getFocusTargets(ref.current);
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 const currentTarget = contains(owner, active) ? owner! : ref.current;
96 const focusTarget = getNextFocusTarget(currentTarget, event.shiftKey);
97 if (focusTarget) {
98 event.preventDefault();
99 focusTarget.focus();
100 }
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 (selection && event.code === 'Escape') {
124 // Restore selection if escape is pressed
125 event.preventDefault();
126 willReceiveFocus = false;
127 restoreSelection(selection);
128 } else if (
129 owner &&
130 isInputElement(owner) &&
131 contains(owner, active) &&
132 event.code === 'Enter'
133 ) {
134 // Move focus to first target when enter is pressed
135 event.preventDefault();
136 const newTarget = getFirstFocusTarget(ref.current);
137 if (newTarget) {
138 selection = snapshotSelection(owner);
139 newTarget.focus();
140 }
141 } else if (
142 owner &&
143 contains(owner, active) &&
144 isInputElement(owner) &&
145 /^(?:Key|Digit)/.test(event.code)
146 ) {
147 // Restore selection if a key is pressed on input
148 event.preventDefault();
149 willReceiveFocus = false;
150 restoreSelection(selection);
151 }
152 }
153
154 ref.current.addEventListener('mousedown', onClick, true);
155 document.body.addEventListener('focusin', onFocus);
156 document.addEventListener('keydown', onKey);
157
158 return () => {
159 ref.current!.removeEventListener('mousedown', onClick);
160 document.body.removeEventListener('focusin', onFocus);
161 document.removeEventListener('keydown', onKey);
162 };
163 }, [ref, hasPriority, disabled]);
164}