diff --git a/src/components/CardGeneratorModal.tsx b/src/components/CardGeneratorModal.tsx index 442f9a23..3165c7f3 100644 --- a/src/components/CardGeneratorModal.tsx +++ b/src/components/CardGeneratorModal.tsx @@ -10,6 +10,7 @@ import { SettingsTab } from "./SettingsTab"; import { ActionButtons } from "./ActionButtons"; import { useCardSettings } from "@/hooks/useCardSettings"; import { useCardPreview } from "@/hooks/useCardPreview"; +import { useModalFocus } from "@/hooks/useModalFocus"; interface CardGeneratorModalProps { isOpen: boolean; @@ -27,8 +28,6 @@ export default function CardGeneratorModal({ const cardRef = useRef(null); const modalRef = useRef(null); - const previousFocusRef = useRef(null); - useEffect(() => { const timer = setTimeout(() => setMounted(true), 0); return () => clearTimeout(timer); @@ -60,30 +59,7 @@ export default function CardGeneratorModal({ setPreviewSize(null); }, [onClose, setPreviewSize, setPreviewUrl]); - useEffect(() => { - if (isOpen) { - previousFocusRef.current = document.activeElement as HTMLElement; - - if (modalRef.current) { - modalRef.current.focus(); - } - - const handleKeyDown = (e: globalThis.KeyboardEvent) => { - if (e.key === "Escape") { - handleClose(); - } - }; - - document.addEventListener("keydown", handleKeyDown); - - return () => { - document.removeEventListener("keydown", handleKeyDown); - if (previousFocusRef.current) { - previousFocusRef.current.focus(); - } - }; - } - }, [isOpen, handleClose]); + useModalFocus(isOpen, modalRef, handleClose); if (!isOpen || !mounted) return null; diff --git a/src/hooks/__tests__/useModalFocus.test.ts b/src/hooks/__tests__/useModalFocus.test.ts new file mode 100644 index 00000000..8c001cb3 --- /dev/null +++ b/src/hooks/__tests__/useModalFocus.test.ts @@ -0,0 +1,60 @@ +import { renderHook } from "@testing-library/react"; +import { useModalFocus } from "../useModalFocus"; +import { expect, test, vi, describe, afterEach, beforeEach } from "vitest"; + +describe("useModalFocus", () => { + let modalRef: React.RefObject; + let onClose: ReturnType; + let addEventListenerSpy: ReturnType; + let removeEventListenerSpy: ReturnType; + + beforeEach(() => { + modalRef = { current: document.createElement("div") }; + onClose = vi.fn(); + addEventListenerSpy = vi.spyOn(document, "addEventListener"); + removeEventListenerSpy = vi.spyOn(document, "removeEventListener"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("focuses modalRef when isOpen is true", () => { + const focusSpy = vi.spyOn(modalRef.current!, "focus"); + renderHook(() => useModalFocus(true, modalRef, onClose as unknown as () => void)); + expect(focusSpy).toHaveBeenCalledTimes(1); + }); + + test("does not focus modalRef when isOpen is false", () => { + const focusSpy = vi.spyOn(modalRef.current!, "focus"); + renderHook(() => useModalFocus(false, modalRef, onClose as unknown as () => void)); + expect(focusSpy).not.toHaveBeenCalled(); + }); + + test("adds and removes keydown event listener based on isOpen", () => { + const { unmount } = renderHook( + ({ isOpen }) => useModalFocus(isOpen, modalRef, onClose as unknown as () => void), + { initialProps: { isOpen: true } } + ); + + expect(addEventListenerSpy).toHaveBeenCalledWith("keydown", expect.any(Function)); + + unmount(); + expect(removeEventListenerSpy).toHaveBeenCalledWith("keydown", expect.any(Function)); + }); + + test("calls onClose when Escape key is pressed", () => { + renderHook(() => useModalFocus(true, modalRef, onClose as unknown as () => void)); + + const eventListenerCall = addEventListenerSpy.mock.calls.find( + (call: unknown[]) => call[0] === "keydown" + ); + const handler = eventListenerCall![1] as EventListener; + + handler(new KeyboardEvent("keydown", { key: "Escape" })); + expect(onClose).toHaveBeenCalledTimes(1); + + handler(new KeyboardEvent("keydown", { key: "Enter" })); + expect(onClose).toHaveBeenCalledTimes(1); // Should not increase + }); +}); diff --git a/src/hooks/useModalFocus.ts b/src/hooks/useModalFocus.ts new file mode 100644 index 00000000..e3a85ccb --- /dev/null +++ b/src/hooks/useModalFocus.ts @@ -0,0 +1,34 @@ +import { useEffect, useRef } from "react"; + +export function useModalFocus( + isOpen: boolean, + modalRef: React.RefObject, + onClose: () => void +) { + const previousFocusRef = useRef(null); + + useEffect(() => { + if (isOpen) { + previousFocusRef.current = document.activeElement as HTMLElement; + + if (modalRef.current) { + modalRef.current.focus(); + } + + const handleKeyDown = (e: globalThis.KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + if (previousFocusRef.current) { + previousFocusRef.current.focus(); + } + }; + } + }, [isOpen, onClose, modalRef]); +} diff --git a/vitest.config.ts b/vitest.config.ts index 0798b584..4d5ebdef 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -21,6 +21,7 @@ export default defineConfig({ "src/components/LanguageChart.tsx", "src/components/SkillsCard.tsx", "src/components/LayoutEditor.tsx", + "src/hooks/useModalFocus.ts", ], thresholds: { lines: 80,