Files
siftlode/frontend/src/components/ui/useContextMenu.ts
T
peter 4b33036e7c fix(e10): review round 2 — revert what round 1 got wrong, narrow what it got too broad
Round 2 reviewed round 1's own fix diff, which is where a fix round does
its damage. It found 15 issues in 792 lines — a much higher density than
round 1 found in the whole epic — and most were regressions round 1
introduced. So this follows the rule rather than fighting it: revert the
fixes whose mechanism was wrong, narrow the ones whose guard was too
broad, and stop reaching for a third variant.

REVERTED, because the mechanism does not work:

- The `inert` menu item. Round 1 swapped a native `disabled` for
  `aria-disabled` so a row's "why is this off" tooltip could be reached.
  floating-ui's list navigation skips `aria-disabled="true"` exactly as it
  skips `disabled` (isListIndexDisabled) — and AnchoredPopover's own
  comment, written before this branch, already said so. The swap fixed
  nothing and made the mouse worse: without native `disabled` the row took
  pointer events, the full active highlight and DOM focus, then no-opped
  on click. The underlying finding is logged where the prop was, unfixed
  and honest: the reason has to go somewhere a disabled row is not.

- `useContextMenu` owning the focus restore. Turning floating-ui's
  returnFocus off platform-wide traded one bug for two: Playlists spreads
  triggerProps on a bare <tr>, so restoring to it is a spec no-op and
  Escape stranded focus on <body>; and with dismissOnScroll now on, the
  restore's plain focus() scrolled the row back into view, undoing the
  scroll that closed the menu. Patching that needs three more guards on
  one fix. The original bug — the rail's rename field dying on arrival —
  is fixed at the field instead: it focuses from an effect, which is
  scheduled after the microtask that was blurring it.

NARROWED, because the fix was right and the guard was not:

- PlexPlayer's Backspace deferred to `anyLayerAbove`, but the player's own
  gear and tracks menus register layers via useDismiss — so Backspace
  stopped closing them and did nothing at all. Only the cheat sheet and
  the palette own that key, so it names them.
- `historyDepth = 0` also desynced the counter from overlays the
  navigation does NOT unmount (About and ReleaseNotes live outside the
  page column), leaving one Back to both navigate and close them. The
  entries are marked buried and consumed silently by reconcile instead.
- The move's error toast fired for every status, so a transport fault
  surfaced twice on top of req's own dialog. Scoped to 403/404.
- The palette's moved-pointer guard could not judge the FIRST move of a
  session and went stale across a close. `e.movementX/Y` asks the browser
  directly and needs no state.
- `useKeyScope("contextMenu")` runs once per hook, and the Playlists page
  mounts two — so the cheat sheet rendered the chords twice under a
  duplicated React key. The scope stack dedupes.
- The `enabled:hover:` sweep missed the half that lives in CSS, so a
  disabled glass button still lifted. `.glass-hover:not(:disabled):hover`.
- ShareDialog's migrated buttons passed a colour className that the
  variant's own colour class outranks under clsx, so Revoke hovered
  neutral and a password-protected link looked unprotected. The colour
  goes on the icon, as the Copy button beside them already did.
- `matchesShortcut` took an untyped id and ids are only unique within a
  scope, so `back` would have answered for the wrong player. Keyed on
  scope + id, both literal.

And the systemic one: round 2 reverted eight of round 1's behaviour fixes
as a single mutation and all 763 tests still passed. Round 1 tested the
three fixes that were easy to test and none of the rest. So the guards
with the widest blast radius are pinned now, each mutation-checked to
fail for its own reason: the global keymap's repeat/allowInInput/disabled
handling (a new dom test), the cheat sheet bound to the real
useContextMenu rather than a scope stub, the scope dedupe, the history
burial, and the Button accessible-name union — whose @ts-expect-error
fixtures turn a widened type into a red typecheck lane.

The chipLane contract test that round 1 rewrote to count per lane counted
the import line as a lane, giving every file one free mirror — so the
exact hole it was rewritten to close was still open. Mutation-checked
both ways this time.
2026-09-06 01:57:32 +02:00

180 lines
8.0 KiB
TypeScript

import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { matchesShortcut, useKeyScope } from "../../lib/keymap";
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> {
// 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);
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,
// The anchor is a fixed POINT, not an element, so scrolling cannot move the panel with its row —
// it would sit over a different row than the one its actions target. Native menus close; so do we.
dismissOnScroll: 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.
//
// It is armed by the timer and disarmed by the click it was armed FOR — but plenty of long presses
// never produce that click (a pan ends in pointercancel, the finger lifts over the portalled menu,
// Android Chrome suppresses the trailing click after its own contextmenu). The flag then stayed
// armed, and since it is one ref per LIST, the next genuine tap on ANY row of that list was eaten
// and the row looked dead until tapped twice (E10 review round 1). So a fresh press also disarms
// it: whatever the last gesture left behind cannot outlive the start of the next one.
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>) => {
// Before the pointerType guard: a stale arm left by an earlier touch press must not survive
// into an unrelated interaction, mouse click included.
swallowClick.current = false;
// 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: () => {
// The gesture was taken over (a pan, the browser's own menu): no click is coming, so the
// arm has nothing left to swallow and must not wait around for someone else's.
swallowClick.current = false;
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.
// Through the shared parser, against the table's own row: the chords live in ONE place, and
// `matches` is exact on modifiers (the hand-rolled version answered Ctrl+Shift+F10 too).
if (!matchesShortcut(e.nativeEvent, "contextMenu", "contextMenu")) 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 };
}