Files
apparel-designer/src/hooks/useFocusTrap.js
2026-05-23 03:28:58 -05:00

92 lines
3.1 KiB
JavaScript

import { useEffect, useRef } from 'react';
/**
* Focus-trap hook for modal-like surfaces (S24).
*
* Pass `active` (the modal's open state) and a ref to the container
* element. While active:
*
* • Focus is moved into the container on mount (first tabbable child,
* or the container itself if it has tabindex).
* • Tab and Shift+Tab cycle through tabbable descendants only — they
* can't escape to elements behind the modal.
* • On deactivation (close), focus is returned to wherever it was
* before the modal opened.
*
* The set of tabbable selectors here covers the common interactive
* elements; we intentionally exclude `area` and `iframe` since they're
* unusual in a modal context. `[tabindex="-1"]` is excluded — those are
* focus targets but not Tab targets.
*
* Reading focused elements via `document.activeElement` is the standard
* pattern; React refs aren't enough because focus may live on an element
* inside a third-party library (e.g. Filerobot) that doesn't expose its
* own ref.
*/
const TABBABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
export function useFocusTrap(active, containerRef) {
const previousFocusRef = useRef(null);
useEffect(() => {
if (!active) return undefined;
// Remember where focus was before so we can restore it on close.
previousFocusRef.current = document.activeElement;
const container = containerRef.current;
if (!container) return undefined;
// Move focus to the first tabbable element inside the container,
// falling back to the container itself if it's focusable.
const focusables = container.querySelectorAll(TABBABLE_SELECTOR);
const first = focusables[0];
if (first instanceof HTMLElement) {
first.focus();
} else if (container.tabIndex >= 0) {
container.focus();
}
const handleKeyDown = (e) => {
if (e.key !== 'Tab') return;
const live = container.querySelectorAll(TABBABLE_SELECTOR);
if (live.length === 0) {
// Nothing tabbable — keep focus on the container.
e.preventDefault();
if (container.tabIndex >= 0) container.focus();
return;
}
const firstEl = live[0];
const lastEl = live[live.length - 1];
const activeEl = document.activeElement;
if (e.shiftKey && activeEl === firstEl) {
e.preventDefault();
lastEl.focus();
} else if (!e.shiftKey && activeEl === lastEl) {
e.preventDefault();
firstEl.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
// Restore focus to where it was before, if that element still
// exists in the DOM. Wrapped in a try/catch because focusing a
// detached node throws in some browsers.
const prev = previousFocusRef.current;
if (prev && typeof prev.focus === 'function') {
try { prev.focus(); } catch { /* ignore */ }
}
};
}, [active, containerRef]);
}