feat: add ESC key handling for modes, API provider, and indexing settings popovers (#6175)

Co-authored-by: Roo Code <roomote@roocode.com>
Co-authored-by: Daniel Riccio <ricciodaniel98@gmail.com>
This commit is contained in:
roomote[bot] 2025-07-28 23:04:23 -04:00 • committed by GitHub
parent 41aaf07861
commit e3a8e03905
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 324 additions and 381 deletions

View file

@ -253,4 +253,67 @@ describe("SearchableSelect", () => {
expect(within(dropdown).queryByText("Option 3")).not.toBeInTheDocument()
})
})
it("closes the dropdown when ESC key is pressed", async () => {
const user = userEvent.setup()
render(<SearchableSelect {...defaultProps} />)
// Open the dropdown
const trigger = screen.getByRole("combobox")
await user.click(trigger)
// Verify dropdown is open
expect(trigger).toHaveAttribute("aria-expanded", "true")
expect(screen.getByRole("listbox")).toBeInTheDocument()
// Press ESC key
fireEvent.keyDown(window, { key: "Escape" })
// Verify dropdown is closed
await waitFor(() => {
expect(trigger).toHaveAttribute("aria-expanded", "false")
expect(screen.queryByRole("listbox")).not.toBeInTheDocument()
})
})
it("does not close the dropdown when ESC is pressed while dropdown is closed", async () => {
render(<SearchableSelect {...defaultProps} />)
const trigger = screen.getByRole("combobox")
// Ensure dropdown is closed
expect(trigger).toHaveAttribute("aria-expanded", "false")
// Press ESC key
fireEvent.keyDown(window, { key: "Escape" })
// Verify dropdown remains closed
expect(trigger).toHaveAttribute("aria-expanded", "false")
expect(screen.queryByRole("listbox")).not.toBeInTheDocument()
})
it("prevents default and stops propagation when ESC is pressed", async () => {
const user = userEvent.setup()
render(<SearchableSelect {...defaultProps} />)
// Open the dropdown
const trigger = screen.getByRole("combobox")
await user.click(trigger)
// Create a mock event to track preventDefault and stopPropagation
const escapeEvent = new KeyboardEvent("keydown", {
key: "Escape",
bubbles: true,
cancelable: true,
})
const preventDefaultSpy = vi.spyOn(escapeEvent, "preventDefault")
const stopPropagationSpy = vi.spyOn(escapeEvent, "stopPropagation")
// Dispatch the event
window.dispatchEvent(escapeEvent)
// Verify preventDefault and stopPropagation were called
expect(preventDefaultSpy).toHaveBeenCalled()
expect(stopPropagationSpy).toHaveBeenCalled()
})
})

View file

@ -38,6 +38,7 @@ import {
} from "@src/components/ui"
import { AlertTriangle } from "lucide-react"
import { useRooPortal } from "@src/components/ui/hooks/useRooPortal"
import { useEscapeKey } from "@src/hooks/useEscapeKey"
import type { EmbedderProvider } from "@roo/embeddingModels"
import type { IndexingStatus } from "@roo/ExtensionMessage"
import { CODEBASE_INDEX_DEFAULTS } from "@roo-code/types"
@ -440,6 +441,9 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
})
}, [checkUnsavedChanges])
// Use the shared ESC key handler hook - respects unsaved changes logic
useEscapeKey(open, handlePopoverClose)
const handleSaveSettings = () => {
// Validate settings before saving
if (!validateSettings()) {

View file

@ -1,381 +0,0 @@
import React from "react"
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
import { describe, it, expect, vi, beforeEach } from "vitest"
import { CodeIndexPopover } from "../CodeIndexPopover"
// Mock the vscode API
vi.mock("@src/utils/vscode", () => ({
vscode: {
postMessage: vi.fn(),
},
}))
// Mock the extension state context
vi.mock("@src/context/ExtensionStateContext", () => ({
useExtensionState: vi.fn(),
}))
// Mock the translation context
vi.mock("@src/i18n/TranslationContext", () => ({
useAppTranslation: () => ({ t: vi.fn((key: string) => key) }),
}))
// Mock react-i18next
vi.mock("react-i18next", () => ({
Trans: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
// Mock the doc links utility
vi.mock("@src/utils/docLinks", () => ({
buildDocLink: vi.fn(() => "https://docs.roocode.com"),
}))
// Mock the portal hook
vi.mock("@src/components/ui/hooks/useRooPortal", () => ({
useRooPortal: () => ({ portalContainer: document.body }),
}))
// Mock Radix UI components to avoid portal issues
vi.mock("@src/components/ui", () => ({
Popover: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
PopoverContent: ({ children }: { children: React.ReactNode }) => <div role="dialog">{children}</div>,
PopoverTrigger: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Select: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => (
<div role="option" data-value={value}>
{children}
</div>
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => <div role="combobox">{children}</div>,
SelectValue: ({ placeholder }: { placeholder?: string }) => <span>{placeholder}</span>,
AlertDialog: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
AlertDialogAction: ({ children }: { children: React.ReactNode }) => <button>{children}</button>,
AlertDialogCancel: ({ children }: { children: React.ReactNode }) => <button>{children}</button>,
AlertDialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
AlertDialogDescription: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
AlertDialogFooter: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
AlertDialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
AlertDialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
AlertDialogTrigger: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Slider: ({ value, onValueChange }: { value: number[]; onValueChange: (value: number[]) => void }) => (
<input type="range" value={value[0]} onChange={(e) => onValueChange([parseInt(e.target.value)])} />
),
StandardTooltip: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
cn: (...classes: string[]) => classes.join(" "),
}))
// Mock VSCode web components to behave like regular HTML inputs
vi.mock("@vscode/webview-ui-toolkit/react", () => ({
VSCodeTextField: ({ value, onInput, placeholder, className, ...rest }: any) => (
<input
type="text"
value={value || ""}
onChange={(e) => onInput && onInput(e)}
placeholder={placeholder}
className={className}
aria-label="Text field"
{...rest}
/>
),
VSCodeButton: ({ children, onClick, ...rest }: any) => (
<button onClick={onClick} {...rest}>
{children}
</button>
),
VSCodeDropdown: ({ value, onChange, children, className, ...rest }: any) => (
<select
value={value || ""}
onChange={(e) => onChange && onChange(e)}
className={className}
role="combobox"
{...rest}>
{children}
</select>
),
VSCodeOption: ({ value, children, ...rest }: any) => (
<option value={value} {...rest}>
{children}
</option>
),
VSCodeLink: ({ href, children, ...rest }: any) => (
<a href={href} {...rest}>
{children}
</a>
),
VSCodeCheckbox: ({ checked, onChange, children, ...rest }: any) => (
<label>
<input type="checkbox" checked={checked || false} onChange={(e) => onChange && onChange(e)} {...rest} />
{children}
</label>
),
}))
// Helper function to simulate input on form elements
const simulateInput = (element: Element, value: string) => {
// Now that we're mocking VSCode components as regular HTML inputs,
// we can use standard fireEvent.change
fireEvent.change(element, { target: { value } })
}
describe("CodeIndexPopover Validation", () => {
let mockUseExtensionState: any
beforeEach(async () => {
vi.clearAllMocks()
// Get the mocked function
const { useExtensionState } = await import("@src/context/ExtensionStateContext")
mockUseExtensionState = vi.mocked(useExtensionState)
// Setup default extension state
mockUseExtensionState.mockReturnValue({
codebaseIndexConfig: {
codebaseIndexEnabled: false,
codebaseIndexQdrantUrl: "",
codebaseIndexEmbedderProvider: "openai",
codebaseIndexEmbedderBaseUrl: "",
codebaseIndexEmbedderModelId: "",
codebaseIndexSearchMaxResults: 10,
codebaseIndexSearchMinScore: 0.7,
codebaseIndexOpenAiCompatibleBaseUrl: "",
codebaseIndexEmbedderModelDimension: undefined,
},
codebaseIndexModels: {
openai: [{ dimension: 1536 }],
},
})
})
const renderComponent = () => {
return render(
<CodeIndexPopover indexingStatus={{ systemStatus: "idle", message: "", processedItems: 0, totalItems: 0 }}>
<button>Test Trigger</button>
</CodeIndexPopover>,
)
}
const openPopover = async () => {
const trigger = screen.getByText("Test Trigger")
fireEvent.click(trigger)
// Wait for popover to open
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument()
})
}
const expandSetupSection = async () => {
const setupButton = screen.getByText("settings:codeIndex.setupConfigLabel")
fireEvent.click(setupButton)
// Wait for section to expand - look for vscode-text-field elements
await waitFor(() => {
const textFields = screen.getAllByLabelText("Text field")
expect(textFields.length).toBeGreaterThan(0)
})
}
describe("OpenAI Provider Validation", () => {
it("should show validation error when OpenAI API key is empty", async () => {
renderComponent()
await openPopover()
await expandSetupSection()
// First, make a change to enable the save button by modifying the Qdrant URL
const qdrantUrlField = screen.getByPlaceholderText(/settings:codeIndex.qdrantUrlPlaceholder/i)
fireEvent.change(qdrantUrlField, { target: { value: "http://localhost:6333" } })
// Wait for the save button to become enabled
await waitFor(() => {
const saveButton = screen.getByText("settings:codeIndex.saveSettings")
expect(saveButton).not.toBeDisabled()
})
// Now clear the OpenAI API key to create a validation error
const apiKeyField = screen.getByPlaceholderText(/settings:codeIndex.openAiKeyPlaceholder/i)
fireEvent.change(apiKeyField, { target: { value: "" } })
// Click the save button to trigger validation
const saveButton = screen.getByText("settings:codeIndex.saveSettings")
fireEvent.click(saveButton)
// Should show specific field error
await waitFor(() => {
expect(screen.getByText("settings:codeIndex.validation.openaiApiKeyRequired")).toBeInTheDocument()
})
})
it("should show validation error when model is not selected", async () => {
renderComponent()
await openPopover()
await expandSetupSection()
// First, make a change to enable the save button
const qdrantUrlField = screen.getByPlaceholderText(/settings:codeIndex.qdrantUrlPlaceholder/i)
fireEvent.change(qdrantUrlField, { target: { value: "http://localhost:6333" } })
// Set API key but leave model empty
const apiKeyField = screen.getByPlaceholderText(/settings:codeIndex.openAiKeyPlaceholder/i)
fireEvent.change(apiKeyField, { target: { value: "test-api-key" } })
// Wait for the save button to become enabled
await waitFor(() => {
const saveButton = screen.getByText("settings:codeIndex.saveSettings")
expect(saveButton).not.toBeDisabled()
})
const saveButton = screen.getByText("settings:codeIndex.saveSettings")
fireEvent.click(saveButton)
await waitFor(() => {
expect(screen.getByText("settings:codeIndex.validation.modelSelectionRequired")).toBeInTheDocument()
})
})
})
describe("Qdrant URL Validation", () => {
it("should show validation error when Qdrant URL is empty", async () => {
renderComponent()
await openPopover()
await expandSetupSection()
// First, make a change to enable the save button by setting API key
const apiKeyField = screen.getByPlaceholderText(/settings:codeIndex.openAiKeyPlaceholder/i)
fireEvent.change(apiKeyField, { target: { value: "test-api-key" } })
// Clear the Qdrant URL to create validation error
const qdrantUrlField = screen.getByPlaceholderText(/settings:codeIndex.qdrantUrlPlaceholder/i)
fireEvent.change(qdrantUrlField, { target: { value: "" } })
// Wait for the save button to become enabled
await waitFor(() => {
const saveButton = screen.getByText("settings:codeIndex.saveSettings")
expect(saveButton).not.toBeDisabled()
})
const saveButton = screen.getByText("settings:codeIndex.saveSettings")
fireEvent.click(saveButton)
await waitFor(() => {
expect(screen.getByText("settings:codeIndex.validation.invalidQdrantUrl")).toBeInTheDocument()
})
})
it("should show validation error when Qdrant URL is invalid", async () => {
renderComponent()
await openPopover()
await expandSetupSection()
// First, make a change to enable the save button by setting API key
const apiKeyField = screen.getByPlaceholderText(/settings:codeIndex.openAiKeyPlaceholder/i)
fireEvent.change(apiKeyField, { target: { value: "test-api-key" } })
// Set invalid Qdrant URL
const qdrantUrlField = screen.getByPlaceholderText(/settings:codeIndex.qdrantUrlPlaceholder/i)
fireEvent.change(qdrantUrlField, { target: { value: "invalid-url" } })
// Wait for the save button to become enabled
await waitFor(() => {
const saveButton = screen.getByText("settings:codeIndex.saveSettings")
expect(saveButton).not.toBeDisabled()
})
const saveButton = screen.getByText("settings:codeIndex.saveSettings")
fireEvent.click(saveButton)
await waitFor(() => {
expect(screen.getByText("settings:codeIndex.validation.invalidQdrantUrl")).toBeInTheDocument()
})
})
})
describe("Common Field Validation", () => {
it("should not show validation error for optional Qdrant API key", async () => {
renderComponent()
await openPopover()
await expandSetupSection()
// Set required fields to make form valid
const qdrantUrlField = screen.getByPlaceholderText(/settings:codeIndex.qdrantUrlPlaceholder/i)
fireEvent.change(qdrantUrlField, { target: { value: "http://localhost:6333" } })
const apiKeyField = screen.getByPlaceholderText(/settings:codeIndex.openAiKeyPlaceholder/i)
fireEvent.change(apiKeyField, { target: { value: "test-api-key" } })
// Select a model - this is required (get the select element specifically)
const modelSelect = screen.getAllByRole("combobox").find((el) => el.tagName === "SELECT")
if (modelSelect) {
fireEvent.change(modelSelect, { target: { value: "0" } })
}
// Leave Qdrant API key empty (it's optional)
const qdrantApiKeyField = screen.getByPlaceholderText(/settings:codeIndex.qdrantApiKeyPlaceholder/i)
fireEvent.change(qdrantApiKeyField, { target: { value: "" } })
// Wait for the save button to become enabled
await waitFor(() => {
const saveButton = screen.getByText("settings:codeIndex.saveSettings")
expect(saveButton).not.toBeDisabled()
})
const saveButton = screen.getByText("settings:codeIndex.saveSettings")
fireEvent.click(saveButton)
// Should not show validation errors since Qdrant API key is optional
})
it("should clear validation errors when fields are corrected", async () => {
renderComponent()
await openPopover()
await expandSetupSection()
// First make an invalid change to enable the save button and trigger validation
const textFields = screen.getAllByLabelText("Text field")
const qdrantField = textFields.find((field) =>
field.getAttribute("placeholder")?.toLowerCase().includes("qdrant"),
)
if (qdrantField) {
simulateInput(qdrantField, "invalid-url") // Invalid URL to trigger validation
}
// Wait for save button to be enabled
const saveButton = screen.getByText("settings:codeIndex.saveSettings")
await waitFor(() => {
expect(saveButton).not.toBeDisabled()
})
// Click save to trigger validation errors
fireEvent.click(saveButton)
// Now fix the errors with valid values
const apiKeyField = textFields.find(
(field) =>
field.getAttribute("placeholder")?.toLowerCase().includes("openai") ||
field.getAttribute("placeholder")?.toLowerCase().includes("key"),
)
// Set valid Qdrant URL
if (qdrantField) {
simulateInput(qdrantField, "http://localhost:6333")
}
// Set API key
if (apiKeyField) {
simulateInput(apiKeyField, "test-api-key")
}
// Select a model - this is required (get the select element specifically)
const modelSelect = screen.getAllByRole("combobox").find((el) => el.tagName === "SELECT")
if (modelSelect) {
fireEvent.change(modelSelect, { target: { value: "0" } })
}
// Try to save again
fireEvent.click(saveButton)
// Validation errors should be cleared (specific field errors are checked elsewhere)
})
})
})

View file

@ -48,6 +48,7 @@ import {
StandardTooltip,
} from "@src/components/ui"
import { DeleteModeDialog } from "@src/components/modes/DeleteModeDialog"
import { useEscapeKey } from "@src/hooks/useEscapeKey"
// Get all available groups that should show in prompts view
const availableGroups = (Object.keys(TOOL_GROUPS) as ToolGroup[]).filter((group) => !TOOL_GROUPS[group].alwaysAvailable)
@ -194,6 +195,9 @@ const ModesView = ({ onDone }: ModesViewProps) => {
}
}, [])
// Use the shared ESC key handler hook
useEscapeKey(open, () => setOpen(false))
// Handler for clearing search input
const onClearSearch = useCallback(() => {
setSearchValue("")

View file

@ -231,4 +231,37 @@ describe("PromptsView", () => {
text: undefined,
})
})
it("closes the mode selection popover when ESC key is pressed", async () => {
renderPromptsView()
const selectTrigger = screen.getByTestId("mode-select-trigger")
// Open the popover
fireEvent.click(selectTrigger)
await waitFor(() => {
expect(selectTrigger).toHaveAttribute("aria-expanded", "true")
})
// Press ESC key
fireEvent.keyDown(window, { key: "Escape" })
// Verify popover is closed
await waitFor(() => {
expect(selectTrigger).toHaveAttribute("aria-expanded", "false")
})
})
it("does not close the popover when ESC is pressed while popover is closed", async () => {
renderPromptsView()
const selectTrigger = screen.getByTestId("mode-select-trigger")
// Ensure popover is closed
expect(selectTrigger).toHaveAttribute("aria-expanded", "false")
// Press ESC key
fireEvent.keyDown(window, { key: "Escape" })
// Verify popover remains closed
expect(selectTrigger).toHaveAttribute("aria-expanded", "false")
})
})

View file

@ -21,6 +21,7 @@ import {
PopoverTrigger,
Button,
} from "@src/components/ui"
import { useEscapeKey } from "@src/hooks/useEscapeKey"
import { ModelInfoView } from "./ModelInfoView"
import { ApiErrorMessage } from "./ApiErrorMessage"
@ -133,6 +134,9 @@ export const ModelPicker = ({
}
}, [])
// Use the shared ESC key handler hook
useEscapeKey(open, () => setOpen(false))
return (
<>
<div>

View file

@ -13,6 +13,7 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui"
import { useEscapeKey } from "@/hooks/useEscapeKey"
export interface SearchableSelectOption {
value: string
@ -79,6 +80,9 @@ export function SearchableSelect({
return () => clearTimeout(timeoutId)
}, [value])
// Use the shared ESC key handler hook
useEscapeKey(open, () => setOpen(false))
const handleOpenChange = (open: boolean) => {
setOpen(open)
// Reset search when closing

View file

@ -0,0 +1,168 @@
import { renderHook } from "@testing-library/react"
import { vi } from "vitest"
import { useEscapeKey } from "./useEscapeKey"
describe("useEscapeKey", () => {
let mockOnEscape: ReturnType<typeof vi.fn>
beforeEach(() => {
mockOnEscape = vi.fn()
})
afterEach(() => {
vi.clearAllMocks()
})
it("should call onEscape when Escape key is pressed and isOpen is true", () => {
renderHook(() => useEscapeKey(true, mockOnEscape))
const event = new KeyboardEvent("keydown", { key: "Escape" })
window.dispatchEvent(event)
expect(mockOnEscape).toHaveBeenCalledTimes(1)
})
it("should not call onEscape when Escape key is pressed and isOpen is false", () => {
renderHook(() => useEscapeKey(false, mockOnEscape))
const event = new KeyboardEvent("keydown", { key: "Escape" })
window.dispatchEvent(event)
expect(mockOnEscape).not.toHaveBeenCalled()
})
it("should not call onEscape when a different key is pressed", () => {
renderHook(() => useEscapeKey(true, mockOnEscape))
const event = new KeyboardEvent("keydown", { key: "Enter" })
window.dispatchEvent(event)
expect(mockOnEscape).not.toHaveBeenCalled()
})
it("should prevent default when preventDefault option is true", () => {
renderHook(() => useEscapeKey(true, mockOnEscape, { preventDefault: true }))
const event = new KeyboardEvent("keydown", { key: "Escape" })
const preventDefaultSpy = vi.spyOn(event, "preventDefault")
window.dispatchEvent(event)
expect(preventDefaultSpy).toHaveBeenCalled()
})
it("should not prevent default when preventDefault option is false", () => {
renderHook(() => useEscapeKey(true, mockOnEscape, { preventDefault: false }))
const event = new KeyboardEvent("keydown", { key: "Escape" })
const preventDefaultSpy = vi.spyOn(event, "preventDefault")
window.dispatchEvent(event)
expect(preventDefaultSpy).not.toHaveBeenCalled()
})
it("should stop propagation when stopPropagation option is true", () => {
renderHook(() => useEscapeKey(true, mockOnEscape, { stopPropagation: true }))
const event = new KeyboardEvent("keydown", { key: "Escape" })
const stopPropagationSpy = vi.spyOn(event, "stopPropagation")
window.dispatchEvent(event)
expect(stopPropagationSpy).toHaveBeenCalled()
})
it("should not stop propagation when stopPropagation option is false", () => {
renderHook(() => useEscapeKey(true, mockOnEscape, { stopPropagation: false }))
const event = new KeyboardEvent("keydown", { key: "Escape" })
const stopPropagationSpy = vi.spyOn(event, "stopPropagation")
window.dispatchEvent(event)
expect(stopPropagationSpy).not.toHaveBeenCalled()
})
it("should remove event listener on unmount", () => {
const addEventListenerSpy = vi.spyOn(window, "addEventListener")
const removeEventListenerSpy = vi.spyOn(window, "removeEventListener")
const { unmount } = renderHook(() => useEscapeKey(true, mockOnEscape))
expect(addEventListenerSpy).toHaveBeenCalledWith("keydown", expect.any(Function))
unmount()
expect(removeEventListenerSpy).toHaveBeenCalledWith("keydown", expect.any(Function))
})
it("should always add event listener regardless of isOpen state", () => {
const addEventListenerSpy = vi.spyOn(window, "addEventListener")
const removeEventListenerSpy = vi.spyOn(window, "removeEventListener")
// Test with isOpen = false
const { rerender } = renderHook(({ isOpen }) => useEscapeKey(isOpen, mockOnEscape), {
initialProps: { isOpen: false },
})
expect(addEventListenerSpy).toHaveBeenCalledTimes(1)
// Change to isOpen = true
rerender({ isOpen: true })
// The listener is re-added because handleKeyDown changes when isOpen changes
// This is expected behavior - the old listener is removed and a new one is added
expect(addEventListenerSpy).toHaveBeenCalledTimes(2)
expect(removeEventListenerSpy).toHaveBeenCalledTimes(1)
})
it("should handle rapid isOpen state changes without memory leaks", () => {
const addEventListenerSpy = vi.spyOn(window, "addEventListener")
const removeEventListenerSpy = vi.spyOn(window, "removeEventListener")
const { rerender, unmount } = renderHook(({ isOpen }) => useEscapeKey(isOpen, mockOnEscape), {
initialProps: { isOpen: false },
})
// Initial render
expect(addEventListenerSpy).toHaveBeenCalledTimes(1)
// Rapid state changes
rerender({ isOpen: true })
rerender({ isOpen: false })
rerender({ isOpen: true })
// Each rerender causes the effect to re-run because handleKeyDown changes
expect(addEventListenerSpy).toHaveBeenCalledTimes(4)
// Each re-run also removes the previous listener
expect(removeEventListenerSpy).toHaveBeenCalledTimes(3)
// Unmount while isOpen is true
unmount()
// Should properly clean up the final listener
expect(removeEventListenerSpy).toHaveBeenCalledTimes(4)
// Verify that all listeners were properly cleaned up
expect(addEventListenerSpy).toHaveBeenCalledTimes(removeEventListenerSpy.mock.calls.length)
})
it("should update callback when dependencies change", () => {
const { rerender } = renderHook(({ isOpen, onEscape }) => useEscapeKey(isOpen, onEscape), {
initialProps: { isOpen: true, onEscape: mockOnEscape },
})
const event = new KeyboardEvent("keydown", { key: "Escape" })
window.dispatchEvent(event)
expect(mockOnEscape).toHaveBeenCalledTimes(1)
// Change the callback
const newMockOnEscape = vi.fn()
rerender({ isOpen: true, onEscape: newMockOnEscape })
window.dispatchEvent(event)
// Old callback should not be called again
expect(mockOnEscape).toHaveBeenCalledTimes(1)
// New callback should be called
expect(newMockOnEscape).toHaveBeenCalledTimes(1)
})
})

View file

@ -0,0 +1,44 @@
import { useEffect, useCallback } from "react"
/**
* Custom hook for handling ESC key press events
* @param isOpen - Whether the component is currently open/visible
* @param onEscape - Callback function to execute when ESC is pressed
* @param options - Additional options for the hook
*/
export function useEscapeKey(
isOpen: boolean,
onEscape: () => void,
options: {
preventDefault?: boolean
stopPropagation?: boolean
} = {},
) {
const { preventDefault = true, stopPropagation = true } = options
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
// Check isOpen inside the handler to ensure proper cleanup
if (event.key === "Escape" && isOpen) {
if (preventDefault) {
event.preventDefault()
}
if (stopPropagation) {
event.stopPropagation()
}
onEscape()
}
},
[isOpen, onEscape, preventDefault, stopPropagation],
)
useEffect(() => {
// Always add the event listener to ensure proper cleanup on unmount
// The isOpen check is now inside the handler
window.addEventListener("keydown", handleKeyDown)
return () => {
window.removeEventListener("keydown", handleKeyDown)
}
}, [handleKeyDown])
}