diff --git a/src/__tests__/useNavDropdowns.test.tsx b/src/__tests__/useNavDropdowns.test.tsx index 9d89f4f..395ae40 100644 --- a/src/__tests__/useNavDropdowns.test.tsx +++ b/src/__tests__/useNavDropdowns.test.tsx @@ -137,6 +137,31 @@ describe("useNavDropdowns", () => { expect(menu.style.display).toBe("block"); }); + it.each(["container", "button", "menu"] as const)( + "cancels both leave events when re-entering the %s", + target => { + const dropdown = buildDropdown("community"); + const { result } = renderHook(() => useNavDropdowns()); + + mouseEnter(dropdown.container); + mouseEnter(dropdown.menu); + mouseLeave(dropdown.menu); + mouseLeave(dropdown.container); + advance(150); + mouseEnter(dropdown[target]); + advance(1000); + + expect(dropdown.menu.style.display).toBe("block"); + expect(result.current.isDropdownOpen).toBe(true); + expect(result.current.isCommunityOpen).toBe(true); + + mouseLeave(dropdown.container); + advance(300); + expect(dropdown.menu.style.display).toBe("none"); + expect(result.current.isDropdownOpen).toBe(false); + } + ); + it("dispatches close-lang-switcher when a dropdown opens", () => { const { container } = buildDropdown("contribute"); const seen = vi.fn(); @@ -365,6 +390,33 @@ describe("useNavDropdowns", () => { expect(click).toHaveBeenCalledTimes(2); }); + it.each(["wrap", "listbox"] as const)( + "cancels both language leave events when re-entering the %s", + async target => { + const { wrap, button } = buildLangSwitcher(); + const click = vi.spyOn(button, "click"); + const { result } = renderHook(() => useNavDropdowns()); + + mouseEnter(wrap); + const listbox = appendListbox(); + await flushObservers(); + mouseEnter(listbox); + mouseLeave(listbox); + mouseLeave(wrap); + advance(150); + mouseEnter(target === "wrap" ? wrap : listbox); + advance(1000); + + expect(click).toHaveBeenCalledTimes(1); + expect(result.current.isDropdownOpen).toBe(true); + + mouseLeave(wrap); + advance(300); + expect(click).toHaveBeenCalledTimes(2); + expect(result.current.isDropdownOpen).toBe(false); + } + ); + it("wires a listbox that is itself the added node", async () => { const { wrap, button } = buildLangSwitcher(); const click = vi.spyOn(button, "click"); diff --git a/src/components/navbar/useNavDropdowns.ts b/src/components/navbar/useNavDropdowns.ts index 3e31a39..d1aa904 100644 --- a/src/components/navbar/useNavDropdowns.ts +++ b/src/components/navbar/useNavDropdowns.ts @@ -24,6 +24,13 @@ export function useNavDropdowns() { useEffect(() => { const cleanups: (() => void)[] = []; + const clearHideTimeout = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + const dropdownContainers = document.querySelectorAll("[data-dropdown]"); @@ -32,13 +39,6 @@ export function useNavDropdowns() { if (!menu) return; - const clearHideTimeout = () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - }; - const showMenu = () => { clearHideTimeout(); @@ -180,10 +180,7 @@ export function useNavDropdowns() { }; const handleMouseLeave = () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } + clearHideTimeout(); timeoutRef.current = setTimeout(() => { const langButton = langSwitcher.querySelector("button"); const dropdown = document.querySelector('[role="listbox"]');