Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 57 additions & 2 deletions src/bundled/channels/ChannelHeaderMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -73,7 +74,7 @@ function harness(value: ChannelSummary | undefined = channel) {
<ChannelHeaderMenu {...props} />
</ChannelNavigationProvider>,
);
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();
Expand Down Expand Up @@ -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 (
<ChannelNavigationProvider relay={h.relay}>
<ChannelHeaderMenu {...h.props} openDetails={() => setSettings(true)} />
{settings && (
<PanelWorkspace
value="settings"
select={() => {}}
items={[
{
id: "settings",
label: "Channel settings",
close,
content: <p>Settings</p>,
},
]}
/>
)}
</ChannelNavigationProvider>
);
}
render(<Page />, { 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<void>((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();
Expand Down
13 changes: 12 additions & 1 deletion src/bundled/channels/ChannelHeaderMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,18 @@ export function ChannelHeaderMenu({
Finish conversion
</MenuItem>
)}
<MenuItem onClick={openDetails}>
<MenuItem
onClick={() => {
// WebKit can drop the tab's focus before the menu finishes closing.
finalFocus.current = () =>
document.querySelector<HTMLElement>(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Nit, not blocking: this searches the whole document, while the matching focus effect in ChannelsPage.tsx searches only within split.ref. It is harmless while one ChannelsPage is mounted, but scoping it the same way would keep the two from drifting.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this searches the whole document, while the matching focus effect in ChannelsPage.tsx searches only within split.ref

Thanks, leaving this one as is. The menu does not have split.ref, so scoping it would mean passing the ref down just for this lookup. It is also safe today: the side pane is the only data-panel-workspace on the page (the main pane is data-main-workspace), so the document query cannot pick up another tab. Happy to scope it if you would rather keep them identical.

'[data-panel-workspace] [data-tab-value="settings"]',
) ??
trigger.current ??
false;
openDetails();
}}
>
<MenuIcon>
<InfoIcon size={16} />
</MenuIcon>
Expand Down
Loading