Mirror: React hooks for accessible, common web interactions. UI super powers without the UI.
1import { snapshotSelection, restoreSelection } from './utils/selection';
2import { getFocusTargets, getNextFocusTarget } from './utils/focus';
3import { useLayoutEffect } from './utils/react';
4import { contains, isInputElement } from './utils/element';
5import { makePriorityHook } from './usePriority';
6import { Ref } from './types';
7
8const usePriority = makePriorityHook();
9
10export interface DialogFocusOptions {
11 disabled?: boolean;
12 ownerRef?: Ref<HTMLElement>;
13}
14
15export function useDialogFocus<T extends HTMLElement>(
16 ref: Ref<T>,
17 options?: DialogFocusOptions
18) {
19 const ownerRef = options && options.ownerRef;
20 const disabled = !!(options && options.disabled);
21 const hasPriority = usePriority(ref, disabled);
22
23 useLayoutEffect(() => {
24 if (!ref.current || disabled || !hasPriority) return;
25
26 let selection = snapshotSelection(ownerRef && ownerRef.current);
27 let willReceiveFocus = false;
28 let focusMovesForward = true;
29
30 function onClick(event: MouseEvent) {
31 if (!ref.current || event.defaultPrevented) return;
32
33 const target = event.target as HTMLElement | null;
34 if (target && getFocusTargets(ref.current).indexOf(target) > -1) {
35 selection = null;
36 willReceiveFocus = true;
37 }
38 }
39
40 function onFocus(event: FocusEvent) {
41 if (!ref.current || event.defaultPrevented) return;
42
43 const active = document.activeElement as HTMLElement;
44 const owner = (ownerRef && ownerRef.current) || selection && selection.element;
45
46 if (willReceiveFocus || (owner && event.target === owner)) {
47 if (!contains(ref.current, active)) selection = snapshotSelection(owner);
48 willReceiveFocus = false;
49 return;
50 }
51
52 const { relatedTarget, target } = event;
53 // Check whether focus is about to move into the container and prevent it
54 if (contains(ref.current, target) && !contains(ref.current, relatedTarget)) {
55 // Get the next focus target of the container
56 const focusTarget = getNextFocusTarget(ref.current, !focusMovesForward);
57 if (focusTarget) {
58 focusMovesForward = true;
59 event.preventDefault();
60 focusTarget.focus();
61 }
62 }
63 }
64
65 function onKey(event: KeyboardEvent) {
66 if (!ref.current || event.defaultPrevented) return;
67
68 // Mark whether focus is moving forward for the `onFocus` handler
69 if (event.code === 'Tab') {
70 focusMovesForward = !event.shiftKey;
71 }
72
73 const active = document.activeElement as HTMLElement;
74 const owner = (ownerRef && ownerRef.current) || selection && selection.element;
75 const focusTargets = getFocusTargets(ref.current);
76
77 if (
78 !focusTargets.length ||
79 (!contains(owner, active) && !contains(ref.current, active))
80 ) {
81 // Do nothing if no targets are available or the listbox or owner don't have focus
82 return;
83 } else if (event.code === 'Tab') {
84 // Skip over the listbox via the parent if we press tab
85 const currentTarget = contains(owner, active) ? owner! : ref.current;
86 const focusTarget = getNextFocusTarget(currentTarget, event.shiftKey);
87 if (focusTarget) {
88 event.preventDefault();
89 focusTarget.focus();
90 }
91 } else if (
92 (!isInputElement(active) && event.code === 'ArrowRight') ||
93 event.code === 'ArrowDown'
94 ) {
95 // Implement forward movement in focus targets
96 event.preventDefault();
97 const focusIndex = focusTargets.indexOf(active);
98 const nextIndex = focusIndex < focusTargets.length - 1 ? focusIndex + 1 : 0;
99 willReceiveFocus = true;
100 focusTargets[nextIndex].focus();
101 } else if (
102 (!isInputElement(active) && event.code === 'ArrowLeft') ||
103 event.code === 'ArrowUp'
104 ) {
105 // Implement backward movement in focus targets
106 event.preventDefault();
107 const focusIndex = focusTargets.indexOf(active);
108 const nextIndex = focusIndex > 0 ? focusIndex - 1 : focusTargets.length - 1;
109 willReceiveFocus = true;
110 focusTargets[nextIndex].focus();
111 } else if (selection && event.code === 'Escape') {
112 // Restore selection if escape is pressed
113 event.preventDefault();
114 willReceiveFocus = false;
115 restoreSelection(selection);
116 } else if (
117 owner &&
118 active !== owner &&
119 isInputElement(owner) &&
120 /^(?:Key|Digit)/.test(event.code)
121 ) {
122 // Restore selection if a key is pressed on input
123 event.preventDefault();
124 willReceiveFocus = false;
125 restoreSelection(selection);
126 }
127 }
128
129 ref.current.addEventListener('mousedown', onClick, true);
130 document.body.addEventListener('focusin', onFocus);
131 document.addEventListener('keydown', onKey);
132
133 return () => {
134 ref.current!.removeEventListener('mousedown', onClick);
135 document.body.removeEventListener('focusin', onFocus);
136 document.removeEventListener('keydown', onKey);
137 };
138 }, [ref, hasPriority, disabled]);
139}