diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index a3d8412de4..d530e0a1a2 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -77,6 +77,7 @@ export default defineConfig({ "**/remote-owned-mentions.spec.ts", "**/mention-spacing.spec.ts", "**/mention-recipients.spec.ts", + "**/message-edit-focus.spec.ts", "**/team-mentions.spec.ts", "**/persistent-agent-audience.spec.ts", "**/relay-reconnect.spec.ts", diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index 02dcdcaf6d..5364a05058 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -157,6 +157,9 @@ function MoreActionsMenu({ { diff --git a/desktop/tests/e2e/message-edit-focus.spec.ts b/desktop/tests/e2e/message-edit-focus.spec.ts new file mode 100644 index 0000000000..24fff4b500 --- /dev/null +++ b/desktop/tests/e2e/message-edit-focus.spec.ts @@ -0,0 +1,86 @@ +import { expect, test } from "@playwright/test"; +import { installMockBridge } from "../helpers/bridge"; + +// Hold exit presence, not composer readiness: a closed menu can still receive a +// queued pointer-leave while its CSS exit animation is mounted. +const holdExit = ` + [role="menu"][data-state="closed"] { animation-play-state: paused !important; } +`; + +test("closing message menu cannot reclaim editor focus on pointer leave", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.addStyleTag({ content: holdExit }); + const input = page.getByTestId("message-input"); + await input.fill("focus ownership"); + await page.getByTestId("send-message").click(); + const row = page + .getByTestId("message-timeline") + .getByTestId("message-row") + .last(); + await expect(row).toContainText("focus ownership"); + await row.hover(); + await row.getByRole("button", { name: "More actions" }).click(); + const edit = page.getByRole("menuitem", { name: "Edit message" }); + const editElement = await edit.elementHandle(); + await edit.click(); + await expect(input).toHaveText("focus ownership"); + // A deliberate user click establishes focus independently of RAF timing. + await input.click(); + await input.press("ControlOrMeta+End"); + await expect(input).toBeFocused(); + await page.keyboard.type(" edit"); + await expect(input).toHaveText("focus ownership edit"); + const closing = await editElement?.evaluate((element) => { + const menu = element.closest('[role="menu"]'); + const state = menu?.getAttribute("data-state"); + const connected = element.isConnected; + element.dispatchEvent( + new PointerEvent("pointerout", { + bubbles: true, + pointerType: "mouse", + relatedTarget: document.body, + }), + ); + return { state, connected }; + }); + expect(closing).toEqual({ state: "closed", connected: true }); + await expect(input).toBeFocused(); + await page.keyboard.type("ed"); + await expect(input).toHaveText("focus ownership edited"); + await page.getByTestId("send-message").click(); + await expect + .poll(() => + page.evaluate(() => { + const call = window.__BUZZ_E2E_COMMAND_LOG__ + ?.filter((entry) => entry.command === "edit_message") + .at(-1); + return (call?.payload as { input?: { content: string } })?.input + ?.content; + }), + ) + .toBe("focus ownership edited"); +}); + +test("message menu Escape still restores its keyboard trigger", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const row = page + .getByTestId("message-timeline") + .getByTestId("message-row") + .last(); + await row.hover(); + const trigger = row.getByRole("button", { name: "More actions" }); + await trigger.focus(); + await trigger.press("Enter"); + await expect(page.getByRole("menu")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("menu")).toHaveCount(0); + await expect(trigger).toBeFocused(); +});