Major update for v1 and tests
This commit is contained in:
91
src/hooks/useFocusTrap.js
Normal file
91
src/hooks/useFocusTrap.js
Normal file
@@ -0,0 +1,91 @@
|
||||
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]);
|
||||
}
|
||||
Reference in New Issue
Block a user