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