From 689bc054d2f3cbb8e367066c233640ab330b7c0b Mon Sep 17 00:00:00 2001 From: thatguywob Date: Mon, 26 Oct 2026 05:05:44 +0000 Subject: [PATCH 1/2] fix(navbar): cancel pending hide timer before rescheduling Prevent paired menu/container leave events from orphaning a timer that closes a dropdown after re-entry. Cover navbar and language switcher re-entry paths. Hive-Run: hivecommons/docs#475 Hive-Plan: navbar-hide-timer-regression Hive-Spec: issue-475#expected Signed-off-by: thatguywob --- changelog.d/fixed-475-navbar-hide-timer.md | 1 + src/__tests__/useNavDropdowns.test.tsx | 52 ++++++++++++++++++++++ src/components/navbar/useNavDropdowns.ts | 19 ++++---- 3 files changed, 61 insertions(+), 11 deletions(-) create mode 100644 changelog.d/fixed-475-navbar-hide-timer.md diff --git a/changelog.d/fixed-475-navbar-hide-timer.md b/changelog.d/fixed-475-navbar-hide-timer.md new file mode 100644 index 00000000..570c5ac6 --- /dev/null +++ b/changelog.d/fixed-475-navbar-hide-timer.md @@ -0,0 +1 @@ +- Keep navbar dropdowns and the language switcher open when the pointer leaves through the menu and quickly returns (fixes #475). diff --git a/src/__tests__/useNavDropdowns.test.tsx b/src/__tests__/useNavDropdowns.test.tsx index 9d89f4ff..395ae402 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 3e31a397..d1aa9048 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"]'); From 6230c676c59a4bee16b470027873117f3866ed37 Mon Sep 17 00:00:00 2001 From: scanner <280983584+hivecommons-hive[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:00:49 -0400 Subject: [PATCH 2/2] chore(changelog): drop duplicate #475 fragment changelog.d/fixed-475.md already exists from #476; the extra fragment would emit a second release-notes line for the same fix. Hive-Fix-Attempt: 1/6 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: scanner <280983584+hivecommons-hive[bot]@users.noreply.github.com> --- changelog.d/fixed-475-navbar-hide-timer.md | 1 - 1 file changed, 1 deletion(-) delete mode 100644 changelog.d/fixed-475-navbar-hide-timer.md diff --git a/changelog.d/fixed-475-navbar-hide-timer.md b/changelog.d/fixed-475-navbar-hide-timer.md deleted file mode 100644 index 570c5ac6..00000000 --- a/changelog.d/fixed-475-navbar-hide-timer.md +++ /dev/null @@ -1 +0,0 @@ -- Keep navbar dropdowns and the language switcher open when the pointer leaves through the menu and quickly returns (fixes #475).