-
Notifications
You must be signed in to change notification settings - Fork 0
🧹 [Code Health] Extract useModalFocus hook from CardGeneratorModal #300
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<HTMLElement | null>; | ||
| let onClose: ReturnType<typeof vi.fn>; | ||
| let addEventListenerSpy: ReturnType<typeof vi.spyOn>; | ||
| let removeEventListenerSpy: ReturnType<typeof vi.spyOn>; | ||
|
|
||
| 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)); | ||
| }); | ||
|
Comment on lines
+34
to
+44
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis is a comment left during a code review.
Path: src/hooks/__tests__/useModalFocus.test.ts
Line: 34-44
Comment:
クリーンアップ時に「直前にフォーカスされていた要素へフォーカスを戻す」動作が明示的にアサートされていません。`document.activeElement` をモックしてクリーンアップ後に `previousElement.focus()` が呼ばれることを検証するテストケースを追加すると、フォーカス管理のリグレッションを防ぎやすくなります。
How can I resolve this? If you propose a fix, please make it concise. |
||
|
|
||
| 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 | ||
| }); | ||
| }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,34 @@ | ||||||
| import { useEffect, useRef } from "react"; | ||||||
|
|
||||||
| export function useModalFocus( | ||||||
| isOpen: boolean, | ||||||
| modalRef: React.RefObject<HTMLElement | null>, | ||||||
| onClose: () => void | ||||||
| ) { | ||||||
| const previousFocusRef = useRef<HTMLElement | null>(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]); | ||||||
|
Comment on lines
+8
to
+33
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The To fix this, use a const previousFocusRef = useRef<HTMLElement | null>(null);
const onCloseRef = useRef(onClose);
useEffect((): void => {
onCloseRef.current = onClose;
}, [onClose]);
useEffect((): (() => void) | void => {
if (isOpen) {
previousFocusRef.current = document.activeElement as HTMLElement;
if (modalRef.current) {
modalRef.current.focus();
}
const handleKeyDown = (e: globalThis.KeyboardEvent): void => {
if (e.key === "Escape") {
onCloseRef.current();
}
};
document.addEventListener("keydown", handleKeyDown);
return (): void => {
document.removeEventListener("keydown", handleKeyDown);
if (previousFocusRef.current) {
previousFocusRef.current.focus();
}
};
}
}, [isOpen, modalRef]);References
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Prompt To Fix With AIThis is a comment left during a code review.
Path: src/hooks/useModalFocus.ts
Line: 33
Comment:
React の `useRef` が返す ref オブジェクト自体は、コンポーネントのライフサイクル全体を通じて同一参照を維持するため、`modalRef` を `useEffect` の依存配列に含める必要はありません。依存に含めると、将来的に親コンポーネントが異なる ref を渡すケースで意図しない再実行が起きるリスクがあります。
```suggestion
}, [isOpen, onClose]);
```
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -21,6 +21,7 @@ export default defineConfig({ | |||||||||||
| "src/components/LanguageChart.tsx", | ||||||||||||
| "src/components/SkillsCard.tsx", | ||||||||||||
| "src/components/LayoutEditor.tsx", | ||||||||||||
| "src/hooks/useModalFocus.ts", | ||||||||||||
| ], | ||||||||||||
|
Comment on lines
23
to
25
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Prompt To Fix With AIThis is a comment left during a code review.
Path: vitest.config.ts
Line: 23-25
Comment:
`src/hooks/useModalFocus.ts` の追加は不要です。カバレッジの `include` リストにはすでに `src/hooks/**/*.ts` というグロブパターンが含まれており、`useModalFocus.ts` は自動的に対象になります。重複したエントリを追加するとメンテナンス時に混乱を招く可能性があります。
```suggestion
"src/components/LayoutEditor.tsx",
],
```
How can I resolve this? If you propose a fix, please make it concise. |
||||||||||||
| thresholds: { | ||||||||||||
| lines: 80, | ||||||||||||
|
|
||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The modal is only rendered when
mountedis true (line 64), butmountedis set asynchronously viasetTimeout(line 32). IfisOpenbecomes true whilemountedis still false, theuseModalFocushook will run its effect, butmodalRef.currentwill be null, so the modal container will not be focused. Sincemountedis not a dependency of the hook's effect, it won't re-run when the modal actually renders. You should passisOpen && mountedto the hook to ensure it only attempts to manage focus when the element is actually in the DOM. Avoid using fixedsetTimeoutdelays for rendering logic.References