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