From 4d335941d042f4a51a618daa63821e02c3ca4798 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 07:22:02 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=A7=B9=20Extract=20useModalFocus=20ho?= =?UTF-8?q?ok=20from=20CardGeneratorModal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: is0692vs <135803462+is0692vs@users.noreply.github.com> --- src/components/CardGeneratorModal.tsx | 28 +---------- src/hooks/__tests__/useModalFocus.test.ts | 60 +++++++++++++++++++++++ src/hooks/useModalFocus.ts | 34 +++++++++++++ vitest.config.ts | 1 + 4 files changed, 97 insertions(+), 26 deletions(-) create mode 100644 src/hooks/__tests__/useModalFocus.test.ts create mode 100644 src/hooks/useModalFocus.ts 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..64204518 --- /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)); + 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)); + expect(focusSpy).not.toHaveBeenCalled(); + }); + + test("adds and removes keydown event listener based on isOpen", () => { + const { unmount, rerender } = renderHook( + ({ isOpen }) => useModalFocus(isOpen, modalRef, onClose), + { 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)); + + const eventListenerCall = addEventListenerSpy.mock.calls.find( + (call) => 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, From 5a211e2000d523bb4020ca0a2bfa51eabd72d3f5 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 07:27:47 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=A7=B9=20Extract=20useModalFocus=20ho?= =?UTF-8?q?ok=20from=20CardGeneratorModal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: is0692vs <135803462+is0692vs@users.noreply.github.com> --- src/hooks/__tests__/useModalFocus.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/hooks/__tests__/useModalFocus.test.ts b/src/hooks/__tests__/useModalFocus.test.ts index 64204518..8c001cb3 100644 --- a/src/hooks/__tests__/useModalFocus.test.ts +++ b/src/hooks/__tests__/useModalFocus.test.ts @@ -21,19 +21,19 @@ describe("useModalFocus", () => { test("focuses modalRef when isOpen is true", () => { const focusSpy = vi.spyOn(modalRef.current!, "focus"); - renderHook(() => useModalFocus(true, modalRef, onClose)); + 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)); + 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, rerender } = renderHook( - ({ isOpen }) => useModalFocus(isOpen, modalRef, onClose), + const { unmount } = renderHook( + ({ isOpen }) => useModalFocus(isOpen, modalRef, onClose as unknown as () => void), { initialProps: { isOpen: true } } ); @@ -44,10 +44,10 @@ describe("useModalFocus", () => { }); test("calls onClose when Escape key is pressed", () => { - renderHook(() => useModalFocus(true, modalRef, onClose)); + renderHook(() => useModalFocus(true, modalRef, onClose as unknown as () => void)); const eventListenerCall = addEventListenerSpy.mock.calls.find( - (call) => call[0] === "keydown" + (call: unknown[]) => call[0] === "keydown" ); const handler = eventListenerCall![1] as EventListener;