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:
2026-09-05 19:59:56 +02:00
parent b28be682fe
commit 928eac4c89
11 changed files with 997 additions and 111 deletions
@@ -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();
});
});