All files / src/hooks useFocusTrap.ts

98.18% Statements 54/55
92.1% Branches 35/38
100% Functions 11/11
100% Lines 47/47

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100          26x                   26x     26x               26x 255x 255x 256x         26x     26x 35x                     26x 235x 162x 162x 162x 162x 170x 162x   162x 20x 8x 8x 1x 1x 1x   7x 7x 7x 7x 7x 1x 1x 6x 5x 5x     162x 162x 162x 162x 162x   162x 310x 310x   162x 148x       148x 118x          
// SPDX-License-Identifier: GPL-3.0-only
// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7.
 
import { useEffect, type RefObject } from "react";
 
const FOCUSABLE = [
	"button:not([disabled])",
	"a[href]",
	"input:not([disabled])",
	"select:not([disabled])",
	"textarea:not([disabled])",
	'[tabindex]:not([tabindex="-1"])',
].join(",");
 
/** Mounted traps, newest last. Only the newest one, the modal on top, handles keys. */
const traps: HTMLElement[] = [];
 
/** Called when the last modal closes and focus has nowhere to go back to. */
const focusLostListeners = new Set<() => void>();
 
/**
 * Calls `listener` each time the last open modal closes and can't hand focus
 * back. That happens when the element that had focus before the modal opened
 * has left the page or can't take focus, or when nothing had focus. Returns a
 * function that stops the calls.
 */
export const onFocusLostOnClose = (listener: () => void): (() => void) => {
	focusLostListeners.add(listener);
	return () => {
		focusLostListeners.delete(listener);
	};
};
 
/** Whether a modal with a focus trap is open. */
export const hasOpenFocusTrap = (): boolean => traps.length > 0;
 
/** Whether `node` is the newest mounted focus trap, the modal on top. */
export const isTopFocusTrap = (node: HTMLElement | null): boolean =>
	node !== null && traps[traps.length - 1] === node;
 
/**
 * Keeps keyboard focus inside a modal while it is mounted: focus moves to its
 * first control on open, Tab / Shift+Tab wrap around its controls, and focus
 * returns to where it was on close unless something else has taken it. With
 * one modal open over another, only the top one moves focus. When the last
 * modal closes and focus can't go back, the listeners of `onFocusLostOnClose`
 * hear about it. When one modal closes and another opens in the same render,
 * the listeners run first and the new modal then takes focus.
 */
export const useFocusTrap = (ref: RefObject<HTMLElement | null>): void => {
	useEffect(() => {
		const node = ref.current;
		Iif (!node) return;
		traps.push(node);
		const previous = document.activeElement;
		const focusables = () => Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE));
		(focusables()[0] ?? node).focus();
 
		const handleKey = (e: KeyboardEvent) => {
			if (e.key !== "Tab" || !isTopFocusTrap(node)) return;
			const items = focusables();
			if (items.length === 0) {
				e.preventDefault();
				node.focus();
				return;
			}
			const first = items[0];
			const last = items[items.length - 1];
			const active = document.activeElement;
			const outside = !node.contains(active);
			if (e.shiftKey && (active === first || outside)) {
				e.preventDefault();
				last.focus();
			} else if (!e.shiftKey && (active === last || outside)) {
				e.preventDefault();
				first.focus();
			}
		};
		document.addEventListener("keydown", handleKey);
		return () => {
			document.removeEventListener("keydown", handleKey);
			const index = traps.indexOf(node);
			Eif (index !== -1) traps.splice(index, 1);
			// Focus falls back to <body> when the focused control unmounts with the modal.
			const isLost = () => {
				const active = document.activeElement;
				return !active || active === document.body || node.contains(active);
			};
			if (!isLost()) return;
			if (previous instanceof HTMLElement && previous.isConnected) previous.focus();
			// focus() does nothing on <body> or a control that is disabled or hidden.
			// In development, StrictMode runs this cleanup once with the modal still
			// on the page, which isn't a close.
			if (isLost() && traps.length === 0 && !node.isConnected) {
				focusLostListeners.forEach((listener) => listener());
			}
		};
	}, [ref]);
};