diff --git a/desktop/src/features/channels/ui/FocusThreadDrawer.tsx b/desktop/src/features/channels/ui/FocusThreadDrawer.tsx index 065d607a13..b857183024 100644 --- a/desktop/src/features/channels/ui/FocusThreadDrawer.tsx +++ b/desktop/src/features/channels/ui/FocusThreadDrawer.tsx @@ -143,6 +143,20 @@ export function FocusThreadDrawer({ const drawerRef = React.useRef(null); const previousFocusRef = React.useRef(null); + React.useEffect(() => { + function handleEscape(event: KeyboardEvent) { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopImmediatePropagation(); + onClose(); + } + + window.addEventListener("keydown", handleEscape, { capture: true }); + return () => { + window.removeEventListener("keydown", handleEscape, { capture: true }); + }; + }, [onClose]); + React.useLayoutEffect(() => { previousFocusRef.current = document.activeElement instanceof HTMLElement diff --git a/desktop/tests/e2e/thread-focus-mode.spec.ts b/desktop/tests/e2e/thread-focus-mode.spec.ts index 75b817a8a0..c9ae42061f 100644 --- a/desktop/tests/e2e/thread-focus-mode.spec.ts +++ b/desktop/tests/e2e/thread-focus-mode.spec.ts @@ -200,29 +200,18 @@ test("focus and split preserve reading context and interaction ownership", async body.locator(`[data-message-id="${anchorId}"]`), ).toBeInViewport(); - // Escape layering: a nested control inside the drawer claims Escape first. - // With mention autocomplete open, Escape closes only the autocomplete — the - // drawer must stay. + // Focus mode owns Escape even while the rich-text composer and one of its + // nested controls has focus: one press exits the focused thread. const threadInput = page .getByTestId("message-thread-panel") .getByTestId("message-input"); await threadInput.click(); await threadInput.pressSequentially("@al"); - const mentionDropdown = page - .getByTestId("message-thread-panel") - .getByTestId("mention-autocomplete"); - await expect(mentionDropdown).toBeVisible(); - await page.keyboard.press("Escape"); - await expect(mentionDropdown).toHaveCount(0); - await expect(drawer).toBeVisible(); - - // Known gap (pre-existing, tracked as a follow-up): while the composer's - // rich-text editor holds focus it claims Escape internally even with no - // autocomplete open, so Escape-from-composer never reaches the drawer's - // close listener. Move focus to the drawer itself before asserting - // Escape-to-close. When the editor is taught to release an idle Escape, - // this focus hop can be removed. - await drawer.focus(); + await expect( + page + .getByTestId("message-thread-panel") + .getByTestId("mention-autocomplete"), + ).toBeVisible(); await page.keyboard.press("Escape"); await expect(page.getByTestId("focus-thread-drawer-overlay")).toHaveCount(0); await expect(channel).not.toHaveAttribute("inert", "");