deckhearth/lib/use-focus-trap.js
varutasu 66717c4198
fix(scanner): close redesign a11y audit findings (#45)
Add focus traps for modals, accessible names for icon/select controls,
ownership badge role=status, list semantics for the scan queue, and
aria-live updates for the card count.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 14:22:18 -05:00

64 lines
1.7 KiB
JavaScript

import { useEffect, useRef } from 'react';
const FOCUSABLE_SELECTOR =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
function getFocusableElements(container) {
return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter(
(el) => !el.disabled && el.getAttribute('aria-hidden') !== 'true'
);
}
/**
* Trap focus inside a modal while `active` and restore focus on close.
* Returns a ref to attach to the dialog panel (not the backdrop).
*/
export function useFocusTrap(active) {
const containerRef = useRef(null);
const previouslyFocusedRef = useRef(null);
useEffect(() => {
if (!active) return;
previouslyFocusedRef.current = document.activeElement;
const container = containerRef.current;
if (!container) return;
const focusFirst = () => {
const nodes = getFocusableElements(container);
nodes[0]?.focus();
};
focusFirst();
const handleKeyDown = (event) => {
if (event.key !== 'Tab') return;
const nodes = getFocusableElements(container);
if (nodes.length === 0) return;
const first = nodes[0];
const last = nodes[nodes.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
const previous = previouslyFocusedRef.current;
if (previous && typeof previous.focus === 'function') {
previous.focus();
}
};
}, [active]);
return containerRef;
}