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.
27 lines
850 B
TypeScript
27 lines
850 B
TypeScript
import { MENU_SURFACE, MenuItemList, type ActionMenuItem } from "./ActionMenu";
|
|
import { PopoverSurface } from "./AnchoredPopover";
|
|
import { type ContextMenuController } from "./useContextMenu";
|
|
|
|
/** The menu surface for a `useContextMenu` controller. Render ONE per list, outside the rows. */
|
|
export function ContextMenu<T>({
|
|
menu,
|
|
ariaLabel,
|
|
items,
|
|
}: {
|
|
menu: ContextMenuController<T>;
|
|
/** Accessible name for the panel — a context menu has no visible trigger to take one from. */
|
|
ariaLabel: string;
|
|
items: readonly ActionMenuItem[];
|
|
}) {
|
|
return (
|
|
<PopoverSurface
|
|
popover={menu.popover}
|
|
ariaLabel={ariaLabel}
|
|
className={`${MENU_SURFACE} overflow-y-auto`}
|
|
style={{ maxHeight: "min(28rem, var(--fit-max-h))" }}
|
|
>
|
|
<MenuItemList items={items} onClose={menu.close} />
|
|
</PopoverSurface>
|
|
);
|
|
}
|