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,155 @@
import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { useAnchoredPopover } from "./AnchoredPopover";
// E10 S2 — the right-click context menu's INPUT half: the gestures that open it and the row they
// belong to. The surface itself is `<ContextMenu>` (ContextMenu.tsx); they are two files only so
// that the component file exports a component and nothing else (react-refresh).
//
// This is an INPUT PATH onto the menu the app already has rather
// than a second menu component. `ActionMenu` (the ⋯ overflow menu) and this one render the same
// `ActionMenuItem[]` through the same `MenuItemList`, on the same `.glass-menu` surface, so a row
// cannot grow a right-click menu that looks or behaves unlike its own ⋯ menu.
//
// Three ways in, because a menu that only opens one way does not exist on the device that lacks it:
// - right-click anywhere on the row (the desktop browser, and the reason this sprint exists),
// - long-press (touch/pen only) — the tablet has no right button,
// - Shift+F10 / the Menu key, which is free once the row is in the tab order.
// The left button is untouched: it keeps the row's own default action (select / play).
//
// ONE menu per list, not one per row. The popover is anchored to a VIRTUAL element at the pointer
// (native-menu behaviour: it opens where you clicked, not at the row's corner), so a table of 200
// rows mounts a single closed popover and `target` says which row it belongs to. Mounting one per
// row would build ~200 floating contexts to keep 199 of them shut.
/** Long-press timing. 500 ms is the platform convention on both Android and iOS; below ~400 ms it
* fires while the user is still deciding whether to scroll. */
const LONG_PRESS_MS = 500;
/** Movement (px) that reclassifies a press as a scroll and cancels the pending menu. */
const LONG_PRESS_SLOP = 10;
type Rect = {
x: number;
y: number;
width: number;
height: number;
top: number;
right: number;
bottom: number;
left: number;
};
const pointRect = (x: number, y: number): Rect => ({
x,
y,
width: 0,
height: 0,
top: y,
right: x,
bottom: y,
left: x,
});
export interface ContextMenuController<T> {
/** The row the open menu belongs to; `null` while closed. Build the items from it. */
target: T | null;
open: boolean;
close: () => void;
/** Spread on the element that should answer a right-click — the whole row, not a handle. */
triggerProps: (payload: T) => Record<string, unknown>;
/** Consumed by `<ContextMenu>`; not part of the call site's vocabulary. */
popover: ReturnType<typeof useAnchoredPopover>;
}
export function useContextMenu<T>(): ContextMenuController<T> {
const [target, setTarget] = useState<T | null>(null);
const [open, setOpen] = useState(false);
const pop = useAnchoredPopover({
open,
onOpenChange: (next) => {
setOpen(next);
// Drop the target on close so a stale row can't drive a re-open's first paint.
if (!next) setTarget(null);
},
// A native context menu drops down-and-right from the pointer; flip()/shift() take it the other
// way near an edge.
placement: "bottom-start",
offset: 0,
fitHeight: true,
});
const { setPositionReference } = pop.refs;
const openAt = useCallback(
(x: number, y: number, payload: T, contextElement?: Element) => {
setPositionReference({ getBoundingClientRect: () => pointRect(x, y), contextElement });
setTarget(payload);
setOpen(true);
},
[setPositionReference],
);
// Long-press bookkeeping. Refs, not state: none of it paints, and a re-render per pointermove
// during a scroll would be a real cost on the device this path exists for.
const timer = useRef<number | null>(null);
const startedAt = useRef<{ x: number; y: number } | null>(null);
// A long-press is followed by a click on release. Without this the menu would open and the row's
// own default action (play / select) would fire underneath it.
const swallowClick = useRef(false);
const cancelPress = useCallback(() => {
if (timer.current !== null) window.clearTimeout(timer.current);
timer.current = null;
startedAt.current = null;
}, []);
const triggerProps = useCallback(
(payload: T) => ({
// Carries the `-webkit-touch-callout: none` rule (index.css) so an iOS long-press opens THIS
// menu instead of Safari's own link/image callout on top of it.
"data-context-menu-target": "",
onContextMenu: (e: React.MouseEvent<HTMLElement>) => {
e.preventDefault();
// A row inside another context-menu target (a table inside a panel) claims the event.
e.stopPropagation();
openAt(e.clientX, e.clientY, payload, e.currentTarget);
},
onPointerDown: (e: ReactPointerEvent<HTMLElement>) => {
// Mouse has a right button; only touch/pen need the timer.
if (e.pointerType === "mouse") return;
const { clientX: x, clientY: y } = e;
const el = e.currentTarget; // read now: currentTarget is null by the time the timer fires
startedAt.current = { x, y };
timer.current = window.setTimeout(() => {
timer.current = null;
swallowClick.current = true;
openAt(x, y, payload, el);
}, LONG_PRESS_MS);
},
onPointerMove: (e: ReactPointerEvent<HTMLElement>) => {
const from = startedAt.current;
if (!from || timer.current === null) return;
if (
Math.abs(e.clientX - from.x) > LONG_PRESS_SLOP ||
Math.abs(e.clientY - from.y) > LONG_PRESS_SLOP
)
cancelPress(); // they are scrolling, not pressing
},
onPointerUp: cancelPress,
onPointerCancel: cancelPress,
onClickCapture: (e: React.MouseEvent<HTMLElement>) => {
if (!swallowClick.current) return;
swallowClick.current = false;
e.preventDefault();
e.stopPropagation();
},
onKeyDown: (e: React.KeyboardEvent<HTMLElement>) => {
// The keyboard route every desktop menu has. Anchored to the row's bottom-left rather than a
// pointer that isn't there.
if (e.key !== "ContextMenu" && !(e.key === "F10" && e.shiftKey)) return;
e.preventDefault();
const r = e.currentTarget.getBoundingClientRect();
openAt(r.left, r.bottom, payload, e.currentTarget);
},
}),
[openAt, cancelPress],
);
return { target, open, close: () => setOpen(false), triggerProps, popover: pop };
}