diff --git a/src/bundled/channels/ChannelHeaderMenu.test.tsx b/src/bundled/channels/ChannelHeaderMenu.test.tsx
index 12c09931f9..c9862dbcce 100644
--- a/src/bundled/channels/ChannelHeaderMenu.test.tsx
+++ b/src/bundled/channels/ChannelHeaderMenu.test.tsx
@@ -3,8 +3,9 @@ import "@testing-library/jest-dom/vitest";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
-import { createRef, useLayoutEffect } from "react";
+import { createRef, useLayoutEffect, useState } from "react";
import { ChannelHeaderMenu } from "./ChannelHeaderMenu";
+import { PanelWorkspace } from "../../features/panels/PanelWorkspace";
import type { RelaySession } from "../../features/relay/session";
import type { ChannelSummary } from "../../features/relay/contracts";
import {
@@ -73,7 +74,7 @@ function harness(value: ChannelSummary | undefined = channel) {
,
);
- return { props, mount, load, save, run, lifecycleLoad, details };
+ return { props, relay, mount, load, save, run, lifecycleLoad, details };
}
it("loads only on demand, uses the non-compact shared menu, and opens details without mutation", async () => {
const h = harness();
@@ -108,6 +109,60 @@ it("loads only on demand, uses the non-compact shared menu, and opens details wi
);
expect(h.props.openDetails).toHaveBeenCalledTimes(2);
});
+it("hands focus to the opened settings tab when the menu finishes closing after focus drops to the body", async () => {
+ const h = harness();
+ const close = vi.fn();
+ function Page() {
+ const [settings, setSettings] = useState(false);
+ return (
+
+ setSettings(true)} />
+ {settings && (
+ {}}
+ items={[
+ {
+ id: "settings",
+ label: "Channel settings",
+ close,
+ content: Settings
,
+ },
+ ]}
+ />
+ )}
+
+ );
+ }
+ render(, { reactStrictMode: true });
+ const user = userEvent.setup();
+ const trigger = screen.getByRole("button", { name: "Channel actions" });
+ await user.click(trigger);
+ const menu = await screen.findByRole("menu");
+ let release!: () => void;
+ const finished = new Promise((resolve) => {
+ release = resolve;
+ });
+ const animations = vi.fn(() => [{ finished }]);
+ // Hold the menu's exit animation. WebKit can drop focus to the body while
+ // it runs; jsdom keeps it on the tab, so the test drops it.
+ Object.defineProperty(menu, "getAnimations", { value: animations });
+ try {
+ await user.click(
+ screen.getByRole("menuitem", { name: "View channel details" }),
+ );
+ await waitFor(() => expect(animations).toHaveBeenCalled());
+ (document.activeElement as HTMLElement | null)?.blur();
+ await act(async () => release());
+ await waitFor(() => expect(menu).not.toBeInTheDocument());
+ const tab = screen.getByRole("tab", { name: "Channel settings" });
+ await waitFor(() => expect(tab).toHaveFocus());
+ await user.keyboard("{Escape}");
+ expect(close).toHaveBeenCalledOnce();
+ } finally {
+ await act(async () => release());
+ }
+});
it("supports keyboard dismissal, outside clicks, and Canvas with the stable header focus target", async () => {
const h = harness();
h.mount();
diff --git a/src/bundled/channels/ChannelHeaderMenu.tsx b/src/bundled/channels/ChannelHeaderMenu.tsx
index 4e670d3a4c..d30fe83eda 100644
--- a/src/bundled/channels/ChannelHeaderMenu.tsx
+++ b/src/bundled/channels/ChannelHeaderMenu.tsx
@@ -85,7 +85,18 @@ export function ChannelHeaderMenu({
Finish conversion
)}
-