diff --git a/src/bundled/channels/ChannelSidebarItem.test.tsx b/src/bundled/channels/ChannelSidebarItem.test.tsx
index 5da68a162..0e45d3d5e 100644
--- a/src/bundled/channels/ChannelSidebarItem.test.tsx
+++ b/src/bundled/channels/ChannelSidebarItem.test.tsx
@@ -13,7 +13,6 @@ import type { RelaySession } from "../../features/relay/session";
import type { UnreadSnapshot } from "../../features/relay/unread";
import type { PresenceStatus } from "../../features/presence/presence";
import { ChannelSidebarItem } from "./ChannelSidebarItem";
-import { ContextMenuRoot } from "../../shared/design-system/ui/Menu";
afterEach(cleanup);
@@ -245,25 +244,23 @@ it.each(["ContextMenu", "F10"])(
channelType: "stream" as const,
};
render(
-
-
- ,
+ ,
);
const parent = screen.getByRole("button", { name: "Alpha" });
fireEvent.keyDown(parent, { key, shiftKey: key === "F10" });
diff --git a/src/bundled/channels/ChannelSidebarItem.tsx b/src/bundled/channels/ChannelSidebarItem.tsx
index 6145dd2f4..872708b0d 100644
--- a/src/bundled/channels/ChannelSidebarItem.tsx
+++ b/src/bundled/channels/ChannelSidebarItem.tsx
@@ -1,7 +1,11 @@
import { UserStatusDisplay } from "../../features/user-status/StatusDisplay";
-import { memo } from "react";
+import { memo, useLayoutEffect, useRef, type ReactNode } from "react";
import { Avatar } from "../../shared/design-system/ui/Avatar";
-import { ContextMenuTrigger } from "../../shared/design-system/ui/Menu";
+import {
+ ContextMenuRoot,
+ ContextMenuTrigger,
+ MenuPopup,
+} from "../../shared/design-system/ui/Menu";
import type { ChannelSummary, Profile } from "../../features/relay/contracts";
import type { RelaySession } from "../../features/relay/session";
import { ChatCircleIcon } from "../../shared/design-system/icons/index";
@@ -34,6 +38,11 @@ export const ChannelSidebarItem = memo(function ChannelSidebarItem({
menuEnabled,
sectionKey,
onOpenMenu,
+ menuOpen = false,
+ menuAnchor,
+ menuContent,
+ onCloseMenu,
+ menuFinalFocus,
}: {
channel: ChannelSummary;
profile?: Profile | undefined;
@@ -56,15 +65,26 @@ export const ChannelSidebarItem = memo(function ChannelSidebarItem({
sectionKey: string,
anchor?: HTMLElement,
) => void;
+ menuOpen?: boolean;
+ menuAnchor?: HTMLElement | undefined;
+ /** Only the open row receives content, so closed rows keep equal props. */
+ menuContent?: ReactNode;
+ onCloseMenu?: () => void;
+ menuFinalFocus?: (channelId: string) => HTMLElement | false;
}) {
const peer =
channel.channelType === "dm" && channel.participants?.length === 1
? channel.participants[0]
: undefined;
const presence = usePresenceStatus(peer ? session.presence : undefined, peer);
+ // Keep the closing row's items through the popup's exit transition.
+ const lastMenuContent = useRef(undefined);
+ useLayoutEffect(() => {
+ if (menuContent !== undefined) lastMenuContent.current = menuContent;
+ }, [menuContent]);
const Icon =
channel.channelType === "dm" ? ChatCircleIcon : channelIcon(channel);
- return (
+ const row = (
);
+ if (!menuEnabled) return row;
+ return (
+ {
+ if (open) {
+ if (sectionKey) onOpenMenu?.(channel, sectionKey);
+ } else if (menuOpen) onCloseMenu?.();
+ }}
+ onOpenChangeComplete={(open) => {
+ if (!open) lastMenuContent.current = undefined;
+ }}
+ >
+ {row}
+ menuFinalFocus?.(channel.id) ?? false}
+ >
+ {menuContent ?? lastMenuContent.current}
+
+
+ );
});
diff --git a/src/features/channel-navigation/ChannelSidebar.test.tsx b/src/features/channel-navigation/ChannelSidebar.test.tsx
index 0b8d65c63..477f7cc8a 100644
--- a/src/features/channel-navigation/ChannelSidebar.test.tsx
+++ b/src/features/channel-navigation/ChannelSidebar.test.tsx
@@ -9,7 +9,10 @@ import type { Navigation } from "../navigation/controller";
import { ChannelSidebar } from "./ChannelSidebar";
import { ChannelNavigationProvider } from "./ChannelNavigationState";
-const { rowRender } = vi.hoisted(() => ({ rowRender: vi.fn() }));
+const { rowRender, menuRender } = vi.hoisted(() => ({
+ rowRender: vi.fn(),
+ menuRender: vi.fn(),
+}));
// Observe the real row below ChannelSidebarItem's production memo boundary.
// React hooks, the parent, the item and the row implementation remain real.
vi.mock("../../bundled/channels/ChannelSidebarRow", async (original) => {
@@ -25,6 +28,22 @@ vi.mock("../../bundled/channels/ChannelSidebarRow", async (original) => {
},
};
});
+// Observe the real row menu provider and popup the same way.
+vi.mock("../../shared/design-system/ui/Menu", async (original) => {
+ const actual =
+ await original();
+ return {
+ ...actual,
+ ContextMenuRoot: (props: Parameters[0]) => {
+ menuRender("root", props);
+ return ;
+ },
+ MenuPopup: (props: Parameters[0]) => {
+ menuRender("popup", props);
+ return ;
+ },
+ };
+});
beforeEach(() => {
// jsdom has no layout; browser journeys own geometry. Only supply the API.
vi.stubGlobal(
@@ -111,10 +130,20 @@ it("does not rebuild unchanged rows on channel switches and refreshes session ac
const mounted = render(h.view("alpha"));
await screen.findByRole("button", { name: "gamma" });
rowRender.mockClear();
+ menuRender.mockClear();
mounted.rerender(h.view("beta"));
expect(rowRender.mock.calls.map(([props]) => props.channel.id)).not.toContain(
"gamma",
);
+ // Unchanged rows keep their menu provider and popup behind the memo too.
+ expect(
+ menuRender.mock.calls.filter(([, props]) =>
+ String(props["aria-label"] ?? "").includes("gamma"),
+ ),
+ ).toEqual([]);
+ expect(
+ menuRender.mock.calls.filter(([kind]) => kind === "root"),
+ ).toHaveLength(rowRender.mock.calls.length);
expect(screen.getByRole("button", { name: "beta" })).toHaveAttribute(
"aria-current",
"page",
diff --git a/src/features/channel-navigation/ChannelSidebar.tsx b/src/features/channel-navigation/ChannelSidebar.tsx
index 5a9ddad9f..a4006262b 100644
--- a/src/features/channel-navigation/ChannelSidebar.tsx
+++ b/src/features/channel-navigation/ChannelSidebar.tsx
@@ -22,7 +22,6 @@ import type { OpenTarget } from "../navigation/targets";
import { Panel } from "../../shared/design-system/ui/Panel";
import { Button } from "../../shared/design-system/ui/Button";
import {
- ContextMenuRoot,
MenuItem,
MenuIcon,
MenuPopup,
@@ -525,6 +524,17 @@ function ReadySidebar({
setReadWrite(undefined);
closeMenu();
}, [closeMenu]);
+ const rowMenuFinalFocus = useCallback(
+ (channelId: string) =>
+ startingSession.current
+ ? (document
+ .getElementById("new-session-prompt")
+ ?.querySelector('[role="textbox"]') ?? false)
+ : (sidebar.list.current?.querySelector(
+ `[data-channel-id="${CSS.escape(channelId)}"]`,
+ ) ?? false),
+ [sidebar.list],
+ );
useLayoutEffect(() => {
if (!rowFocus) return;
sidebar.list.current
@@ -849,7 +859,7 @@ function ReadySidebar({
menuEnabled &&
rowMenu?.channelId === channel.id &&
rowMenu.sectionKey === section.key;
- const channelItem = (
+ return (
+ {actions}
+ {readWrite?.pending && (
+ Saving…
+ )}
+ {readWrite?.error && (
+ {readWrite.error}
+ )}
+ >
+ ) : undefined
+ }
+ onCloseMenu={closeRowMenu}
+ menuFinalFocus={rowMenuFinalFocus}
/>
);
- if (!menuEnabled) return channelItem;
- return (
- {
- if (open) openRowMenu(channel, section.key);
- else if (menuOpen) closeRowMenu();
- }}
- >
- {channelItem}
-
- startingSession.current
- ? (document
- .getElementById("new-session-prompt")
- ?.querySelector(
- '[role="textbox"]',
- ) ?? false)
- : (sidebar.list.current?.querySelector(
- `[data-channel-id="${CSS.escape(channel.id)}"]`,
- ) ?? false)
- }
- >
- {actions}
- {readWrite?.pending && Saving…
}
- {readWrite?.error && (
- {readWrite.error}
- )}
-
-
- );
})}
);