From 5a2e09aac4d186ba9d127a168670ae5f013f28e0 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 21 Aug 2026 14:36:15 +0200 Subject: [PATCH] fix(hud): dismiss the HUD popovers when the window loses focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The language menu and the device-settings panel already close on Escape and on a pointerdown outside them, and both of those still work — six of the eight new tests pass against the unpatched component. What was missing is the case the issue actually describes. The HUD's native window is only about 904x698, bottom-centred, and mostly invisible reserve. A click anywhere else on screen never reaches this renderer as a pointerdown, so nothing hit-tests it and the popover stays open — while that same click takes keyboard focus away, after which Escape cannot be delivered to this renderer either. The menu is then stuck until the trigger button is found and pressed again, which is exactly what #435 reports for both halves. `blur` is the one signal that does cross that boundary, so close the pair on it. Escape itself needs no change: it already works whenever the window has focus, and after this a popover can only be open in a window that has focus. Registered in bubble phase on purpose — element blur does not bubble, so this never fires while focus moves between the menu's own buttons. Adds the dismissal coverage that was missing entirely: Escape (and that it does not change the locale), outside pointerdown, inside pointerdown, window blur, and a non-Escape key, for the language menu and the device-settings panel alike. Fixes #435 --- src/components/launch/LaunchWindow.test.tsx | 121 ++++++++++++++++++++ src/components/launch/LaunchWindow.tsx | 13 ++- 2 files changed, 133 insertions(+), 1 deletion(-) diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index e777dbec..60894f9e 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -303,6 +303,7 @@ function resetLaunchMocks() { i18nState.value.acceptSystemLocaleSuggestion.mockClear(); i18nState.value.dismissSystemLocaleSuggestion.mockClear(); i18nState.value.resolveSystemLocaleSuggestion.mockClear(); + i18nState.value.setLocale.mockClear(); linuxHelperAvailable.value = true; appInfoState.value = { version: "1.9.6", canCheckForUpdates: true }; updateCheckMock.mockReset(); @@ -829,6 +830,126 @@ describe("LaunchWindow language menu", () => { }); }); +describe("LaunchWindow popover dismissal", () => { + beforeEach(() => { + platformState.value = "darwin"; + resetLaunchMocks(); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + /** Opens the language menu the way a user does, and hands back its panel. */ + async function openLanguageMenu() { + fireEvent.click(await screen.findByRole("button", { name: "English" })); + return await screen.findByTestId("hud-language-menu"); + } + + /** Same for the device-settings panel. */ + async function openDeviceSettings() { + fireEvent.click(await screen.findByTestId("launch-device-settings-button")); + return await screen.findByTestId("hud-device-settings"); + } + + it("closes the language menu on Escape without changing the locale", async () => { + renderLaunchWindow(); + await openLanguageMenu(); + + fireEvent.keyDown(window, { key: "Escape" }); + + await waitFor(() => { + expect(screen.queryByTestId("hud-language-menu")).not.toBeInTheDocument(); + }); + // Escape dismisses; it must never pick whatever entry happened to be under + // the cursor or focused. + expect(i18nState.value.setLocale).not.toHaveBeenCalled(); + expect(i18nState.value.resolveSystemLocaleSuggestion).not.toHaveBeenCalled(); + }); + + it("closes the language menu on a pointerdown outside the trigger and the panel", async () => { + renderLaunchWindow(); + await openLanguageMenu(); + + // The HUD window is mostly empty reserve above the bar; a press there is a + // real DOM pointerdown on the root, and it has to dismiss. + fireEvent.pointerDown(document.body); + + await waitFor(() => { + expect(screen.queryByTestId("hud-language-menu")).not.toBeInTheDocument(); + }); + expect(i18nState.value.setLocale).not.toHaveBeenCalled(); + }); + + it("keeps the language menu open for a pointerdown inside the panel", async () => { + renderLaunchWindow(); + const menu = await openLanguageMenu(); + + fireEvent.pointerDown(menu); + + expect(screen.getByTestId("hud-language-menu")).toBeInTheDocument(); + }); + + it("closes the language menu when the HUD window loses focus", async () => { + renderLaunchWindow(); + await openLanguageMenu(); + + // A click that lands beyond the HUD's native window produces no pointerdown + // in this renderer at all — the only signal it gets is the window blur. And + // once focus is gone, Escape can no longer be delivered here either, so this + // is the one listener that can unstick that state (issue #435). + fireEvent.blur(window); + + await waitFor(() => { + expect(screen.queryByTestId("hud-language-menu")).not.toBeInTheDocument(); + }); + expect(i18nState.value.setLocale).not.toHaveBeenCalled(); + }); + + it("closes the device-settings panel on Escape", async () => { + renderLaunchWindow(); + await openDeviceSettings(); + + fireEvent.keyDown(window, { key: "Escape" }); + + await waitFor(() => { + expect(screen.queryByTestId("hud-device-settings")).not.toBeInTheDocument(); + }); + }); + + it("closes the device-settings panel on a pointerdown outside the trigger and the panel", async () => { + renderLaunchWindow(); + await openDeviceSettings(); + + fireEvent.pointerDown(document.body); + + await waitFor(() => { + expect(screen.queryByTestId("hud-device-settings")).not.toBeInTheDocument(); + }); + }); + + it("closes the device-settings panel when the HUD window loses focus", async () => { + renderLaunchWindow(); + await openDeviceSettings(); + + fireEvent.blur(window); + + await waitFor(() => { + expect(screen.queryByTestId("hud-device-settings")).not.toBeInTheDocument(); + }); + }); + + it("leaves a key that is not Escape alone", async () => { + renderLaunchWindow(); + await openLanguageMenu(); + + fireEvent.keyDown(window, { key: "a" }); + + expect(screen.getByTestId("hud-language-menu")).toBeInTheDocument(); + }); +}); + describe("LaunchWindow device buttons", () => { beforeEach(() => { platformState.value = "darwin"; diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 8b3e9f26..8a2427fc 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -284,7 +284,7 @@ export function LaunchWindow() { }, []); // One dismiss handler for both floating surfaces — they're mutually exclusive, - // so a single pointerdown/Escape listener covers the pair instead of two. + // so a single pointerdown/Escape/blur listener covers the pair instead of two. const closePopovers = useCallback(() => { setIsDeviceSettingsOpen(false); setIsLanguageMenuOpen(false); @@ -311,10 +311,21 @@ export function LaunchWindow() { window.addEventListener("pointerdown", handlePointerDown); window.addEventListener("keydown", handleEscape); + // The third dismiss path, and the one that made issue #435: the HUD's native + // window is only 904x698, so a click anywhere else on screen reaches this + // renderer as nothing at all — no pointerdown to hit-test — while still taking + // keyboard focus away. Escape is then undeliverable here, and the popover was + // stuck open until the trigger was found again. `blur` is the one signal that + // crosses, so it closes the pair; after this the "popover open in a window + // that has no focus" state simply doesn't exist. Bubble phase on purpose: + // element blur doesn't bubble, so this only ever fires for the window itself + // and never when focus moves between the menu's own buttons. + window.addEventListener("blur", closePopovers); return () => { window.removeEventListener("pointerdown", handlePointerDown); window.removeEventListener("keydown", handleEscape); + window.removeEventListener("blur", closePopovers); }; }, [closePopovers, isPopoverOpen]);