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}

- )} -
-
- ); })} );