Mirror: React hooks for accessible, common web interactions. UI super powers without the UI.
1import {
2 RestoreSelection,
3 snapshotSelection,
4 restoreSelection,
5} from './utils/selection';
6
7import { getActive, getAutofocusTarget, getFocusTargets } from './utils/focus';
8import { useLayoutEffect } from './utils/react';
9import { contains, getRoot } from './utils/element';
10import { makePriorityHook } from './usePriority';
11import { Ref } from './types';
12
13const usePriority = makePriorityHook();
14
15export interface ModalFocusOptions {
16 disabled?: boolean;
17}
18
19export function useModalFocus<T extends HTMLElement>(
20 ref: Ref<T>,
21 options?: ModalFocusOptions
22) {
23 const disabled = !!(options && options.disabled);
24 const hasPriority = usePriority(ref, disabled);
25
26 useLayoutEffect(() => {
27 const { current: element } = ref;
28 if (!element || disabled) return;
29
30 const root = getRoot(element);
31 const active = getActive();
32 let selection: RestoreSelection | null = null;
33 if (!active || !contains(element, active)) {
34 const newTarget = ref.current ? getAutofocusTarget(ref.current) : null;
35 selection = snapshotSelection();
36 if (newTarget) newTarget.focus();
37 }
38
39 function onBlur(event: FocusEvent) {
40 const { current: element } = ref;
41 if (!hasPriority.current || !element || event.defaultPrevented) return;
42
43 if (
44 contains(element, event.target) &&
45 !contains(element, event.relatedTarget)
46 ) {
47 const target = getFocusTargets(element)[0];
48 if (target) target.focus();
49 }
50 }
51
52 function onKeyDown(event: KeyboardEvent) {
53 const { current: element } = ref;
54 if (!hasPriority.current || !element || event.defaultPrevented) return;
55
56 if (event.code === 'Tab') {
57 const activeElement = getActive()!;
58 const targets = getFocusTargets(element);
59 const index = targets.indexOf(activeElement);
60 if (event.shiftKey && index === 0) {
61 event.preventDefault();
62 targets[targets.length - 1].focus();
63 } else if (!event.shiftKey && index === targets.length - 1) {
64 event.preventDefault();
65 targets[0].focus();
66 }
67 }
68 }
69
70 root.addEventListener('focusout', onBlur);
71 root.addEventListener('keydown', onKeyDown);
72
73 return () => {
74 root.removeEventListener('focusout', onBlur);
75 root.removeEventListener('keydown', onKeyDown);
76 restoreSelection(selection);
77 };
78 }, [ref.current, hasPriority, disabled]);
79}