diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx index 531caca5e..266f4f357 100644 --- a/components/ui/dialog.tsx +++ b/components/ui/dialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useId, useRef, type ReactNode, type RefObject, type FormEvent } from "react"; +import { useId, useRef, type ReactNode, type RefObject, type FormEvent } from "react"; import { createPortal } from "react-dom"; import { useTranslations } from "next-intl"; import { useFocusTrap } from "@/hooks/use-focus-trap"; @@ -91,23 +91,18 @@ export function Dialog({ const id = useId(); const titleId = `${id}-title`; const descriptionId = `${id}-description`; - const panelRef = useFocusTrap({ isActive: isOpen, onEscape: onClose, restoreFocus: true }); + // The focus starts on an explicit target, else the first field, else + // the first control. + const panelRef = useFocusTrap({ + isActive: isOpen, + onEscape: onClose, + restoreFocus: true, + initialFocus: () => initialFocus?.current ?? panelRef.current?.querySelector(FIELD_SELECTOR), + }); // A press that started on the backdrop, so a text selection dragged out of // a field and released outside doesn't count as an outside click. const pressedOnBackdrop = useRef(false); - // The focus trap has put the focus on the first control; a field or an - // explicit target is the better start. - useEffect(() => { - if (!isOpen) return; - const panel = panelRef.current; - if (!panel) return; - const target = initialFocus?.current ?? panel.querySelector(FIELD_SELECTOR); - target?.focus(); - // Only on open: a later render must not pull the focus back. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isOpen]); - if (!isOpen || typeof document === "undefined") return null; const blockOutside = preventOutsideClose || dirty; diff --git a/hooks/use-focus-trap.ts b/hooks/use-focus-trap.ts index ae3837178..b6ef9b684 100644 --- a/hooks/use-focus-trap.ts +++ b/hooks/use-focus-trap.ts @@ -8,12 +8,15 @@ interface UseFocusTrapOptions { isActive: boolean; onEscape?: () => void; restoreFocus?: boolean; + /** Where the focus goes on open, instead of the first focusable element. */ + initialFocus?: () => HTMLElement | null | undefined; } export function useFocusTrap({ isActive, onEscape, restoreFocus = true, + initialFocus, }: UseFocusTrapOptions) { const containerRef = useRef(null); const previousActiveElement = useRef(null); @@ -22,8 +25,10 @@ export function useFocusTrap({ // focus back to the opener and then to the first element, out of the field // the user was typing in whenever the page behind rendered again. const onEscapeRef = useRef(onEscape); + const initialFocusRef = useRef(initialFocus); useEffect(() => { onEscapeRef.current = onEscape; + initialFocusRef.current = initialFocus; }); useEffect(() => { @@ -57,12 +62,9 @@ export function useFocusTrap({ }; container.addEventListener('focusin', handleFocusIn); - // Focus first element - const focusableElements = getFocusableElements(); - const firstElement = focusableElements[0]; - if (firstElement) { - firstElement.focus(); - } + // Focus the requested element, else the first one + const target = initialFocusRef.current?.() ?? getFocusableElements()[0]; + target?.focus(); // Handle Tab key to trap focus const handleKeyDown = (e: KeyboardEvent) => {