148 lines
4.2 KiB
TypeScript
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]);
|
|
}
|