From 72fe6d1be08b39b5581ecdd488ddbb2cfbd7d1ed Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Wed, 3 Jun 2026 19:59:40 -0500 Subject: [PATCH] fix(use-focus-trap): preserve named useFocusTrap export for ScannerPageView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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 --- lib/use-focus-trap.js | 87 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 4 deletions(-) diff --git a/lib/use-focus-trap.js b/lib/use-focus-trap.js index 9b5080b..9cfb005 100644 --- a/lib/use-focus-trap.js +++ b/lib/use-focus-trap.js @@ -1,6 +1,74 @@ import { useEffect, useRef } from 'react'; -const FOCUSABLE = [ +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"])', @@ -10,7 +78,18 @@ const FOCUSABLE = [ '[contenteditable="true"]', ].join(','); -export default function useFocusTrap({ active, containerRef, initialFocusRef, restoreFocus = true }) { +/** + * Focus-trap variant for the `` 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(() => { @@ -22,7 +101,7 @@ export default function useFocusTrap({ active, containerRef, initialFocusRef, re const focusTarget = initialFocusRef?.current ?? - container.querySelector(FOCUSABLE) ?? + container.querySelector(NEW_FOCUSABLE) ?? container; if (focusTarget && typeof focusTarget.focus === 'function') { if (focusTarget === container && !container.hasAttribute('tabindex')) { @@ -33,7 +112,7 @@ export default function useFocusTrap({ active, containerRef, initialFocusRef, re const handleKeyDown = (e) => { if (e.key !== 'Tab') return; - const focusable = Array.from(container.querySelectorAll(FOCUSABLE)); + const focusable = Array.from(container.querySelectorAll(NEW_FOCUSABLE)); if (focusable.length === 0) { e.preventDefault(); return;