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,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 };
|
||||
}
|
||||
Reference in New Issue
Block a user