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 const { current: element } = ref;
33 if (!element || disabled) return;
34
35 let selection: RestoreSelection | null = null;
36 if (!document.activeElement || !contains(element, document.activeElement)) {
37 const newTarget = getAutofocusTarget(element);
38 selection = snapshotSelection(element);
39 newTarget.focus();
40 }
41
42 function onBlur(event: FocusEvent) {
43 if (!hasPriority.current || !element || event.defaultPrevented) return;
44
45 if (
46 contains(element, event.target) &&
47 !contains(element, event.relatedTarget)
48 ) {
49 const target = getFirstFocusTarget(element);
50 if (target) target.focus();
51 }
52 }
53
54 function onKeyDown(event: KeyboardEvent) {
55 if (!hasPriority.current || !element || event.defaultPrevented) return;
56
57 if (event.code === 'Tab') {
58 const activeElement = document.activeElement as HTMLElement;
59 const targets = getFocusTargets(element);
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.current, hasPriority, disabled]);
80}