92 lines
3.1 KiB
JavaScript
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]);
|
|
}
|