deckhearth/lib/use-focus-trap.js
Randall Stillwell 72fe6d1be0 fix(use-focus-trap): preserve named useFocusTrap export for ScannerPageView
The portfolio squash inadvertently overwrote the pre-existing
lib/use-focus-trap.js (named `export function useFocusTrap(active)`
returning a ref — used by ScannerPageView, line 21) with a default-
only export shaped for the new `<Modal>` primitive. Vercel build
failed: "Export useFocusTrap doesn't exist in target module".

Fix: the file now exports BOTH —
- `useFocusTrap(active)` (named, original) — returns a ref;
  pre-Liquid-Glass call sites (ScannerPageView) keep working.
- `useFocusTrapContainer({ active, containerRef, ... })` (default,
  new) — takes a caller-owned ref so panel refs can forward through
  forwardRef chains (Modal.js consumes this shape).

Both hooks are commented to document which to use when. Modal.js
imports default already, so no change needed there.

Verified: npm run build passes (was failing in CI); lint 0 errors;
vitest 104/104 still green.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 19:59:40 -05:00

144 lines
4.3 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 container while `active` and restore focus on close.
* Returns a ref to attach to the dialog panel (not the backdrop).
*
* Used by pre-Liquid-Glass call sites (e.g. ScannerPageView) where the
* caller wants the hook to own the container ref. New code that needs
* to share / forward the panel ref should use the default export
* `useFocusTrapContainer` instead — see Modal.js for the pattern.
*/
export function useFocusTrap(active) {
const containerRef = useRef(null);
const previouslyFocusedRef = useRef(null);
useEffect(() => {
if (!active) return undefined;
previouslyFocusedRef.current = document.activeElement;
const container = containerRef.current;
if (!container) return undefined;
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;
}
const NEW_FOCUSABLE = [
'a[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
'[contenteditable="true"]',
].join(',');
/**
* Focus-trap variant for the `<Modal>` primitive in components/ui/.
* Takes a caller-owned containerRef + optional initialFocusRef, so the
* panel ref can be forwarded through GlassSurface / forwardRef chains
* without colliding with the hook's internal ref ownership.
*/
export default function useFocusTrapContainer({
active,
containerRef,
initialFocusRef,
restoreFocus = true,
}) {
const previousActiveElement = useRef(null);
useEffect(() => {
if (!active) return undefined;
previousActiveElement.current = document.activeElement;
const container = containerRef.current;
if (!container) return undefined;
const focusTarget =
initialFocusRef?.current ??
container.querySelector(NEW_FOCUSABLE) ??
container;
if (focusTarget && typeof focusTarget.focus === 'function') {
if (focusTarget === container && !container.hasAttribute('tabindex')) {
container.setAttribute('tabindex', '-1');
}
focusTarget.focus({ preventScroll: true });
}
const handleKeyDown = (e) => {
if (e.key !== 'Tab') return;
const focusable = Array.from(container.querySelectorAll(NEW_FOCUSABLE));
if (focusable.length === 0) {
e.preventDefault();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
if (
restoreFocus &&
previousActiveElement.current &&
typeof previousActiveElement.current.focus === 'function'
) {
previousActiveElement.current.focus({ preventScroll: true });
}
};
}, [active, containerRef, initialFocusRef, restoreFocus]);
}