feat(e10): open the app's menu with a right-click, starting with playlist rows
S2's headline was never a new Menu: `ActionMenu` already sits on `AnchoredPopover` (arrows, typeahead, Escape, anchor-flip). What was missing is an INPUT PATH into it, so this adds `useContextMenu` + `<ContextMenu>` over the same `ActionMenuItem[]` and the same `MenuItemList` — a row cannot grow a right-click menu that looks or behaves unlike its own ⋯ menu. Three ways in: right-click anywhere on the row, long-press on touch/pen, and Shift+F10 / the Menu key. The LEFT button is untouched and keeps the row's own action. One popover per list, anchored to a virtual element at the pointer (native behaviour, and a table of 200 rows would otherwise mount 200 floating contexts to keep 199 shut). Platform bits it needed: `hoverAlso` on AnchoredPopover (a submenu falls open under a mouse and is tapped by a finger — mouse-only, so a tap's synthetic mouseenter can't race the click toggle) and `elementRef` on PopoverItem (the submenu anchors to the very menuitem that opens it, where aria-haspopup/expanded belong). `ActionMenuItem` becomes a discriminated union — a row either fires or opens a submenu — plus `danger` and `separatorBefore`. First consumers, both in Playlists: - item rows: play · move to another list (a submenu of every other playlist, one gesture) · to top / up / down / bottom · remove. Reordering goes through the same undoable store as the drag handle and is disabled under a view for the same reason, with the same explanation rather than silence. - rail rows: rename (in the row that was right-clicked, keeping its cover so the rail doesn't jump) · delete. Both now run through `usePlaylistActions`, which also takes over the detail header's pencil and trash — the delete dialog's YouTube branch and the "what gets selected next" rule exist once, not twice. - the rail gains a "recently changed" sort key off the new `updated_at`. NOT the default: a rail that reshuffles itself while you look at it costs more than it saves.
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the right-click / long-press context menu (E10 S2). Same floating-ui recipe as
|
||||
// ActionMenu.dom.test.tsx: stub the two observers autoUpdate wires and wrap in a FloatingTree, and
|
||||
// query with `{ hidden: true }` because floating-ui never positions in jsdom, so the surface stays
|
||||
// `visibility: hidden` and the a11y tree treats its contents as hidden.
|
||||
//
|
||||
// What is worth asserting here is not "a menu opens" but the two things that make it a CONTEXT menu:
|
||||
// the left button keeps the row's own action, and the gesture that opens it doesn't also fire that
|
||||
// action underneath.
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import { type ActionMenuItem } from "./ActionMenu";
|
||||
import { ContextMenu } from "./ContextMenu";
|
||||
import { useContextMenu } from "./useContextMenu";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
|
||||
// jsdom ships no PointerEvent, so fireEvent.pointerDown would deliver pointerType/clientX/clientY
|
||||
// as null — and the long-press path is ENTIRELY about those three. Without this polyfill the touch
|
||||
// tests would pass for the wrong reason (every press looks like an untyped press at 0,0) and the
|
||||
// "a mouse never starts the timer" guard could rot unnoticed. MouseEvent already carries the
|
||||
// coordinates; only pointerType has to be added.
|
||||
class PointerEventStub extends MouseEvent {
|
||||
pointerType: string;
|
||||
constructor(type: string, init: MouseEventInit & { pointerType?: string } = {}) {
|
||||
super(type, init);
|
||||
this.pointerType = init.pointerType ?? "";
|
||||
}
|
||||
}
|
||||
globalThis.PointerEvent ??= PointerEventStub as unknown as typeof PointerEvent;
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
/** A two-row list wired exactly the way a real consumer wires it: one menu, `triggerProps` per row. */
|
||||
function List({
|
||||
build,
|
||||
onRowClick = () => {},
|
||||
}: {
|
||||
build: (row: string) => ActionMenuItem[];
|
||||
onRowClick?: (row: string) => void;
|
||||
}) {
|
||||
const menu = useContextMenu<string>();
|
||||
return (
|
||||
<>
|
||||
{["alpha", "beta"].map((row) => (
|
||||
<button key={row} onClick={() => onRowClick(row)} {...menu.triggerProps(row)}>
|
||||
{row}
|
||||
</button>
|
||||
))}
|
||||
<ContextMenu
|
||||
menu={menu}
|
||||
ariaLabel="Row actions"
|
||||
items={menu.target ? build(menu.target) : []}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const renderList = (build: (row: string) => ActionMenuItem[], onRowClick = vi.fn()) => {
|
||||
render(
|
||||
<FloatingTree>
|
||||
<List build={build} onRowClick={onRowClick} />
|
||||
</FloatingTree>,
|
||||
);
|
||||
return { onRowClick };
|
||||
};
|
||||
|
||||
const row = (name: string) => screen.getByRole("button", { name });
|
||||
const menuitems = () =>
|
||||
screen.queryAllByRole("menuitem", { hidden: true }).map((b) => b.textContent);
|
||||
// The surface is visibility:hidden in jsdom (floating-ui never positions), and an accessible NAME is
|
||||
// empty inside a hidden subtree — so items are matched on their text, as in ActionMenu.dom.test.tsx.
|
||||
const item = (label: string) => {
|
||||
const found = screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === label);
|
||||
if (!found) throw new Error(`no menuitem "${label}" in [${menuitems().join(", ")}]`);
|
||||
return found as HTMLButtonElement;
|
||||
};
|
||||
|
||||
describe("useContextMenu / ContextMenu", () => {
|
||||
it("stays closed until a right-click, and builds the menu from the row that got it", () => {
|
||||
renderList((r) => [{ id: "a", label: `Play ${r}`, onSelect: vi.fn() }]);
|
||||
expect(menuitems()).toEqual([]);
|
||||
fireEvent.contextMenu(row("beta"), { clientX: 40, clientY: 80 });
|
||||
expect(menuitems()).toEqual(["Play beta"]);
|
||||
});
|
||||
|
||||
it("leaves the LEFT button to the row's own action", () => {
|
||||
// The whole point of the sprint: right-click is a new input path, not a replacement for the
|
||||
// row's default action.
|
||||
const { onRowClick } = renderList((r) => [{ id: "a", label: r, onSelect: vi.fn() }]);
|
||||
fireEvent.click(row("alpha"));
|
||||
expect(onRowClick).toHaveBeenCalledWith("alpha");
|
||||
expect(menuitems()).toEqual([]);
|
||||
});
|
||||
|
||||
it("selecting an item fires it and closes the menu", () => {
|
||||
const onSelect = vi.fn();
|
||||
renderList(() => [{ id: "a", label: "Remove", onSelect }]);
|
||||
fireEvent.contextMenu(row("alpha"));
|
||||
fireEvent.click(item("Remove"));
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
expect(menuitems()).toEqual([]);
|
||||
});
|
||||
|
||||
it("Shift+F10 opens it from the keyboard", () => {
|
||||
renderList((r) => [{ id: "a", label: `Play ${r}`, onSelect: vi.fn() }]);
|
||||
fireEvent.keyDown(row("alpha"), { key: "F10", shiftKey: true });
|
||||
expect(menuitems()).toEqual(["Play alpha"]);
|
||||
});
|
||||
|
||||
it("Escape closes it", () => {
|
||||
renderList(() => [{ id: "a", label: "Remove", onSelect: vi.fn() }]);
|
||||
fireEvent.contextMenu(row("alpha"));
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(menuitems()).toEqual([]);
|
||||
});
|
||||
|
||||
describe("long press (touch)", () => {
|
||||
const press = (el: HTMLElement, x = 10, y = 10) =>
|
||||
fireEvent.pointerDown(el, { pointerType: "touch", clientX: x, clientY: y });
|
||||
|
||||
it("opens after the hold and SWALLOWS the click that follows the release", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { onRowClick } = renderList((r) => [
|
||||
{ id: "a", label: `Play ${r}`, onSelect: vi.fn() },
|
||||
]);
|
||||
press(row("alpha"));
|
||||
act(() => void vi.advanceTimersByTime(600));
|
||||
expect(menuitems()).toEqual(["Play alpha"]);
|
||||
// Without the click guard the row would ALSO play the video under the open menu.
|
||||
fireEvent.click(row("alpha"));
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("a scroll (pointer moved past the slop) cancels it", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { onRowClick } = renderList((r) => [{ id: "a", label: r, onSelect: vi.fn() }]);
|
||||
press(row("alpha"), 10, 10);
|
||||
fireEvent.pointerMove(row("alpha"), { pointerType: "touch", clientX: 10, clientY: 60 });
|
||||
act(() => void vi.advanceTimersByTime(600));
|
||||
expect(menuitems()).toEqual([]);
|
||||
// …and a cancelled long press leaves the row's own click intact.
|
||||
fireEvent.click(row("alpha"));
|
||||
expect(onRowClick).toHaveBeenCalledWith("alpha");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("a MOUSE press never starts the timer (it has a right button)", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
renderList((r) => [{ id: "a", label: r, onSelect: vi.fn() }]);
|
||||
fireEvent.pointerDown(row("alpha"), { pointerType: "mouse", clientX: 10, clientY: 10 });
|
||||
act(() => void vi.advanceTimersByTime(600));
|
||||
expect(menuitems()).toEqual([]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("submenus", () => {
|
||||
const withSub = (): ActionMenuItem[] => [
|
||||
{ id: "play", label: "Play", onSelect: vi.fn() },
|
||||
{
|
||||
id: "move",
|
||||
label: "Move to",
|
||||
items: [
|
||||
{ id: "t1", label: "Watch later", onSelect: vi.fn() },
|
||||
{ id: "t2", label: "Homelab", onSelect: vi.fn() },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
it("marks the submenu row as such and keeps its items unmounted until opened", () => {
|
||||
renderList(withSub);
|
||||
fireEvent.contextMenu(row("alpha"));
|
||||
const trigger = item("Move to");
|
||||
expect(trigger.getAttribute("aria-haspopup")).toBe("menu");
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(menuitems()).toEqual(["Play", "Move to"]);
|
||||
});
|
||||
|
||||
it("opens on click and on ArrowRight, and ArrowLeft steps back out of it", () => {
|
||||
renderList(withSub);
|
||||
fireEvent.contextMenu(row("alpha"));
|
||||
fireEvent.click(item("Move to"));
|
||||
expect(menuitems()).toEqual(["Play", "Move to", "Watch later", "Homelab"]);
|
||||
|
||||
// ArrowLeft is pressed INSIDE the submenu (that is where focus goes when it opens) and closes
|
||||
// only that layer — Escape would close the whole stack, which is not what "back" means.
|
||||
fireEvent.keyDown(item("Watch later"), { key: "ArrowLeft" });
|
||||
expect(menuitems()).toEqual(["Play", "Move to"]);
|
||||
|
||||
fireEvent.keyDown(item("Move to"), { key: "ArrowRight" });
|
||||
expect(menuitems()).toEqual(["Play", "Move to", "Watch later", "Homelab"]);
|
||||
});
|
||||
|
||||
it("choosing inside a submenu closes the WHOLE stack, not just the submenu", () => {
|
||||
const chosen = vi.fn();
|
||||
renderList(() => [
|
||||
{ id: "play", label: "Play", onSelect: vi.fn() },
|
||||
{ id: "move", label: "Move to", items: [{ id: "t1", label: "Homelab", onSelect: chosen }] },
|
||||
]);
|
||||
fireEvent.contextMenu(row("alpha"));
|
||||
fireEvent.click(item("Move to"));
|
||||
fireEvent.click(item("Homelab"));
|
||||
expect(chosen).toHaveBeenCalledTimes(1);
|
||||
expect(menuitems()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("a disabled item is inert and never fires", () => {
|
||||
const onSelect = vi.fn();
|
||||
renderList(() => [{ id: "a", label: "Move to", disabled: true, onSelect }]);
|
||||
fireEvent.contextMenu(row("alpha"));
|
||||
const disabled = item("Move to");
|
||||
expect(disabled.disabled).toBe(true);
|
||||
fireEvent.click(disabled);
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user