fix(design): a row's "…" closes its own menu when its row object is rebuilt (E28 S3e2)
The "…" told its own menu from another row's by payload identity. The Plex playlist page builds each row's menu target afresh on every render, so a second press on the same "…" reopened the menu instead of closing it. useContextMenu takes an optional `keyOf`, and the page keys its targets by the row key.
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -69,11 +69,12 @@ type Layout = "accordion" | "tree";
|
||||
type Season = { key: string; number: number | null; items: PlexCard[] };
|
||||
type ShowBlock = Extract<Block, { kind: "show" }>;
|
||||
/** 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<MenuTarget>();
|
||||
// Keyed: a row builds its target afresh each render, and its "…" must close the menu it opened.
|
||||
const rowMenu = useContextMenu<MenuTarget>({ keyOf: targetKey });
|
||||
const copy = useCopyItems();
|
||||
const seasonLabel = useSeasonLabel();
|
||||
const titleActions = usePlexCardActions(
|
||||
|
||||
@@ -74,7 +74,17 @@ export interface ContextMenuController<T> {
|
||||
popover: ReturnType<typeof useAnchoredPopover>;
|
||||
}
|
||||
|
||||
export function useContextMenu<T>(): ContextMenuController<T> {
|
||||
const identity = (payload: unknown): unknown => payload;
|
||||
|
||||
export function useContextMenu<T>({
|
||||
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<T> {
|
||||
// Register the scope HERE, so the cheat sheet lists Shift+F10 exactly on the surfaces that answer it.
|
||||
useKeyScope("contextMenu");
|
||||
const [target, setTarget] = useState<T | null>(null);
|
||||
@@ -221,10 +231,10 @@ export function useContextMenu<T>(): ContextMenuController<T> {
|
||||
onPointerDown: (e: ReactPointerEvent<HTMLElement>) => {
|
||||
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<HTMLElement>) => {
|
||||
// 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<T>(): ContextMenuController<T> {
|
||||
openAt(r.left, r.bottom, payload, e.currentTarget);
|
||||
},
|
||||
}),
|
||||
[openAt, close],
|
||||
[openAt, close, keyOf],
|
||||
);
|
||||
|
||||
return { target, anchor, open, close, triggerProps, buttonProps, popover: pop };
|
||||
|
||||
Reference in New Issue
Block a user