diff --git a/apps/app/src/components/thread/ThreadActionsMenu.test.tsx b/apps/app/src/components/thread/ThreadActionsMenu.test.tsx index 426bb89a34d..a9c80d1d12b 100644 --- a/apps/app/src/components/thread/ThreadActionsMenu.test.tsx +++ b/apps/app/src/components/thread/ThreadActionsMenu.test.tsx @@ -10,7 +10,7 @@ const mockActions = vi.hoisted(() => ({ archiveThreadAndChildren: vi.fn(), requestRename: vi.fn(), requestDelete: vi.fn(), - sendToPopout: null, + sendToPopout: null as ((thread: Thread) => void) | null, togglePin: vi.fn(), toggleRead: vi.fn(), unarchiveThread: vi.fn(), @@ -45,15 +45,23 @@ function makeThread(overrides: Partial = {}): Thread { }; } -async function renderOpenMenu(thread: Thread) { +async function renderOpenMenu( + thread: Thread, + { isCompactViewport = true }: { isCompactViewport?: boolean } = {}, +) { const onOpenChange = vi.fn(); render( - + , ); - fireEvent.click(screen.getByRole("button", { name: "Thread actions" })); + const trigger = screen.getByRole("button", { name: "Thread actions" }); + if (isCompactViewport) { + fireEvent.click(trigger); + } else { + fireEvent.pointerDown(trigger, { button: 0 }); + } await screen.findByRole("menuitem", { name: /Mark / }); expect(onOpenChange).toHaveBeenLastCalledWith(true); return onOpenChange; @@ -64,10 +72,23 @@ function expectMenuItemIcon(label: string, iconName: string) { expect(menuItem.querySelector(`[data-icon="${iconName}"]`)).not.toBeNull(); } +function getMenuRoleSequence(): string[] { + return Array.from( + screen + .getByRole("menu") + .querySelectorAll('[role="menuitem"], [role="separator"]'), + ).map((element) => + element.getAttribute("role") === "separator" + ? "separator" + : (element.textContent ?? "").trim(), + ); +} + describe("ThreadActionsMenu", () => { afterEach(() => { cleanup(); vi.clearAllMocks(); + mockActions.sendToPopout = null; }); it.each([ @@ -119,4 +140,32 @@ describe("ThreadActionsMenu", () => { expectMenuItemIcon("Archive", "Archive"); expectMenuItemIcon("Delete", "Trash2"); }); + + it("omits dividers when rendering as a compact drawer", async () => { + await renderOpenMenu(makeThread(), { isCompactViewport: true }); + + expect(screen.queryAllByRole("separator")).toHaveLength(0); + }); + + it("renders one divider before lifecycle actions when the popout action is unavailable", async () => { + await renderOpenMenu(makeThread(), { isCompactViewport: false }); + + expect(screen.getAllByRole("separator")).toHaveLength(1); + expect(getMenuRoleSequence()).toEqual([ + "Mark read", + "Pin", + "Rename", + "separator", + "Archive", + "Delete", + ]); + }); + + it("renders both dividers when the popout action is available", async () => { + mockActions.sendToPopout = vi.fn(); + + await renderOpenMenu(makeThread(), { isCompactViewport: false }); + + expect(screen.getAllByRole("separator")).toHaveLength(2); + }); }); diff --git a/apps/app/src/components/thread/ThreadActionsMenu.tsx b/apps/app/src/components/thread/ThreadActionsMenu.tsx index c4ec25cc494..234e4528b41 100644 --- a/apps/app/src/components/thread/ThreadActionsMenu.tsx +++ b/apps/app/src/components/thread/ThreadActionsMenu.tsx @@ -17,6 +17,7 @@ import { import { Icon, type IconName } from "@/components/ui/icon.js"; import { Button } from "@/components/ui/button.js"; import { COARSE_POINTER_ICON_SIZE_CLASS } from "@/components/ui/coarse-pointer-sizing.js"; +import { useIsCompactViewport } from "@/components/ui/hooks/use-compact-viewport.js"; import { cn } from "@/lib/utils"; import { isThreadRead } from "@/lib/thread-read-state"; import { useThreadActions } from "./ThreadActionsProvider"; @@ -124,9 +125,13 @@ function ThreadActionsMenuItems({ unarchiveThread, sendToPopout, } = useThreadActions(); + const isCompactViewport = useIsCompactViewport(); + const isDrawer = surface === "dropdown" && isCompactViewport; + const showSeparators = !isDrawer; const isRead = isThreadRead(thread); const isArchived = thread.archivedAt != null; const isPinned = thread.pinnedAt !== null; + const canSendToPopout = sendToPopout !== null; return ( <> @@ -149,8 +154,10 @@ function ThreadActionsMenuItems({ > {isPinned ? "Unpin" : "Pin"} - - {sendToPopout !== null ? ( + {showSeparators && canSendToPopout ? ( + + ) : null} + {canSendToPopout ? ( Rename - + {showSeparators ? : null}