integratedhub/utils/useFocusTrap.ts
2026-02-14 02:20:19 +00:00

148 lines
4.2 KiB
TypeScript

import { useEffect, useRef } from "preact/hooks";
/**
* Focus Trap Hook
*
* Traps keyboard focus within a container element (e.g., modal, dialog).
* When focus reaches the boundary, it cycles back to the first/last focusable element.
*
* @param isActive - Whether the focus trap should be active
* @param containerRef - Ref to the container element
*
* Usage:
* ```tsx
* const modalRef = useRef<HTMLDivElement>(null);
* useFocusTrap(isModalOpen, modalRef);
* ```
*/
export function useFocusTrap(
isActive: boolean,
containerRef: { current: HTMLElement | null },
) {
const previousActiveElementRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isActive || !containerRef.current) {
return;
}
// Store the previously focused element to restore focus when modal closes
previousActiveElementRef.current = document.activeElement as HTMLElement;
const container = containerRef.current;
// Get all focusable elements within the container
const getFocusableElements = (): HTMLElement[] => {
const focusableSelectors = [
"a[href]",
"button:not([disabled])",
"textarea:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
'[tabindex]:not([tabindex="-1"])',
].join(", ");
const focusableElements = Array.from(
container.querySelectorAll<HTMLElement>(focusableSelectors),
) as HTMLElement[];
// Filter out elements that are hidden or not visible
return focusableElements.filter(
(el) =>
getComputedStyle(el).display !== "none" &&
getComputedStyle(el).visibility !== "hidden" &&
el.getAttribute("aria-hidden") !== "true",
);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Tab") {
return;
}
const focusableElements = getFocusableElements();
if (focusableElements.length === 0) {
return;
}
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// If shift+tab on first element, move to last
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
return;
}
// If tab on last element, move to first
if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
return;
}
};
// Focus the first focusable element when trap activates
const focusableElements = getFocusableElements();
if (focusableElements.length > 0) {
// Small delay to ensure the modal is fully rendered
setTimeout(() => {
focusableElements[0].focus();
}, 50);
}
// Add event listener for tab key
document.addEventListener("keydown", handleKeyDown, true);
// Cleanup: restore focus to previous element when modal closes
return () => {
document.removeEventListener("keydown", handleKeyDown, true);
// Restore focus to the element that was focused before the modal opened
if (
previousActiveElementRef.current &&
document.contains(previousActiveElementRef.current)
) {
previousActiveElementRef.current.focus();
}
};
}, [isActive, containerRef]);
}
/**
* Focus Management Hook for Modals
*
* Manages focus restoration when modal opens/closes
*/
export function useFocusManagement(isOpen: boolean) {
const previousActiveElementRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
// Store the currently focused element
previousActiveElementRef.current = document.activeElement as HTMLElement;
// Prevent body scroll when modal is open
document.body.style.overflow = "hidden";
} else {
// Restore focus when modal closes
if (
previousActiveElementRef.current &&
document.contains(previousActiveElementRef.current)
) {
previousActiveElementRef.current.focus();
}
// Restore body scroll
document.body.style.overflow = "";
}
return () => {
// Cleanup on unmount
document.body.style.overflow = "";
};
}, [isOpen]);
}