diff --git a/frontend/src/components/PlexPlaylistView.menu.dom.test.tsx b/frontend/src/components/PlexPlaylistView.menu.dom.test.tsx index b208e1c4..71568816 100644 --- a/frontend/src/components/PlexPlaylistView.menu.dom.test.tsx +++ b/frontend/src/components/PlexPlaylistView.menu.dom.test.tsx @@ -131,6 +131,20 @@ describe("PlexPlaylistView · a row's menu", () => { expect(moreOf("Movie 1").getAttribute("aria-expanded")).toBe("true"); }); + it("closes on a second press of the same '…', though its row is rebuilt each render", async () => { + mount([movie("1")]); + await screen.findByText("Movie 1"); + await openMenu(() => fireEvent.click(moreOf("Movie 1"))); + act(() => { + fireEvent.pointerDown(moreOf("Movie 1")); + fireEvent.mouseDown(moreOf("Movie 1")); + fireEvent.click(moreOf("Movie 1"), { detail: 1 }); + }); + await waitFor(() => + expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0), + ); + }); + it("removes a title with no confirm, and the toast's Undo puts it back at its index", async () => { mount([movie("1"), movie("2"), movie("3")]); await screen.findByText("Movie 2"); diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx index 1ae90291..0ddd99b4 100644 --- a/frontend/src/components/PlexPlaylistView.tsx +++ b/frontend/src/components/PlexPlaylistView.tsx @@ -69,11 +69,12 @@ type Layout = "accordion" | "tree"; type Season = { key: string; number: number | null; items: PlexCard[] }; type ShowBlock = Extract; /** What a row's menu acts on. `key` is the row's identity across renders (the objects are rebuilt - * on every one), so "is THIS row's menu the open one" compares keys. */ + * on every one), so "is THIS row's menu the open one" — and the "…" closing its own — compare keys. */ type MenuTarget = | { kind: "item"; key: string; item: PlexCard } | { kind: "show"; key: string; block: ShowBlock } | { kind: "season"; key: string; block: ShowBlock; season: Season }; +const targetKey = (t: MenuTarget) => t.key; type Block = | { kind: "movie"; id: string; item: PlexCard } | { @@ -535,7 +536,8 @@ export default function PlexPlaylistView({ } // --- The rows' ONE menu (E28 C1) ------------------------------------------------------------- - const rowMenu = useContextMenu(); + // Keyed: a row builds its target afresh each render, and its "…" must close the menu it opened. + const rowMenu = useContextMenu({ keyOf: targetKey }); const copy = useCopyItems(); const seasonLabel = useSeasonLabel(); const titleActions = usePlexCardActions( diff --git a/frontend/src/components/ui/useContextMenu.ts b/frontend/src/components/ui/useContextMenu.ts index 4399a877..44097df3 100644 --- a/frontend/src/components/ui/useContextMenu.ts +++ b/frontend/src/components/ui/useContextMenu.ts @@ -74,7 +74,17 @@ export interface ContextMenuController { popover: ReturnType; } -export function useContextMenu(): ContextMenuController { +const identity = (payload: unknown): unknown => payload; + +export function useContextMenu({ + keyOf = identity, +}: { + /** What makes two payloads the SAME row, for the "…" closing its own menu. Identity by default; + * a list whose rows are rebuilt each render (a wrapper object) or refetched while the menu is + * open (a poll replaces the job it lists) passes its id, or a second press would reopen. A + * module-scope function: a fresh one per render would rebuild `buttonProps` on every render. */ + keyOf?: (payload: T) => unknown; +} = {}): ContextMenuController { // Register the scope HERE, so the cheat sheet lists Shift+F10 exactly on the surfaces that answer it. useKeyScope("contextMenu"); const [target, setTarget] = useState(null); @@ -221,10 +231,10 @@ export function useContextMenu(): ContextMenuController { onPointerDown: (e: ReactPointerEvent) => { const dismissed = dismissedBy.current; pressedWhileOpen.current = - (openFor.current !== null && openFor.current.payload === payload) || + (openFor.current !== null && keyOf(openFor.current.payload) === keyOf(payload)) || (dismissed !== null && dismissed.event === e.nativeEvent && - dismissed.payload === payload); + keyOf(dismissed.payload) === keyOf(payload)); }, onClick: (e: React.MouseEvent) => { // The button sits on a card that plays on click, and inside a row that may be a link. @@ -242,7 +252,7 @@ export function useContextMenu(): ContextMenuController { openAt(r.left, r.bottom, payload, e.currentTarget); }, }), - [openAt, close], + [openAt, close, keyOf], ); return { target, anchor, open, close, triggerProps, buttonProps, popover: pop };