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:
@@ -1,19 +1,166 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import { Fragment, useState, type ReactNode } from "react";
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
|
||||
// A reusable "⋯ more actions" overflow menu: a compact trigger that opens a list of one-shot actions.
|
||||
// Built on the R8 AnchoredPopover platform (arrows/typeahead/Escape/anchor-flip), same as ViewSwitcher
|
||||
// and LanguageSwitcher. Use it to keep occasional actions out of a crowded toolbar. The items are
|
||||
// fire-and-close (`onSelect`); a `disabled` item (e.g. a mutation already in flight) is inert.
|
||||
//
|
||||
// E10 S2 split the ITEM LIST out of the trigger (`MenuItemList`) so the ⋯ menu and the right-click
|
||||
// context menu (`ContextMenu`) render the same rows from the same item type. That is the whole point
|
||||
// of the split: a context menu that drifts from the overflow menu beside it is two menus, and the
|
||||
// user has to learn both.
|
||||
|
||||
export interface ActionMenuItem {
|
||||
/** Shared surface paint for every menu built on this file — the ⋯ menu, its submenus, and
|
||||
* `ContextMenu`. One constant so a context menu can never look like a different component. */
|
||||
export const MENU_SURFACE = "glass-menu min-w-52 rounded-xl p-1.5 animate-[popIn_0.16s_ease]";
|
||||
const ROW =
|
||||
"flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-sm outline-hidden transition disabled:opacity-50";
|
||||
|
||||
interface MenuItemBase {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: ReactNode; // an element (so a caller can swap in a spinning icon while pending)
|
||||
onSelect: () => void;
|
||||
disabled?: boolean;
|
||||
title?: string; // optional native tooltip (carries a longer hint the label alone can't)
|
||||
/** Draw a divider above this item. Ignored on the first item — a menu never opens with a rule. */
|
||||
separatorBefore?: boolean;
|
||||
}
|
||||
|
||||
interface ActionItem extends MenuItemBase {
|
||||
onSelect: () => void;
|
||||
/** Destructive action: painted in the danger colour, like the outline-danger buttons. */
|
||||
danger?: boolean;
|
||||
items?: never;
|
||||
}
|
||||
|
||||
interface SubmenuItem extends MenuItemBase {
|
||||
/** Non-empty → this row opens a submenu instead of firing. */
|
||||
items: readonly ActionMenuItem[];
|
||||
onSelect?: never;
|
||||
}
|
||||
|
||||
// A discriminated union rather than "onSelect and items are both optional": the invariant is
|
||||
// "a row either fires or it opens a submenu, never both and never neither", and this codebase's
|
||||
// house rule is that such a rule lives in the type, not in a comment above it.
|
||||
export type ActionMenuItem = ActionItem | SubmenuItem;
|
||||
|
||||
const isSubmenu = (it: ActionMenuItem): it is SubmenuItem => Array.isArray(it.items);
|
||||
|
||||
/** The rows of a menu: leaf actions, submenus and separators, from one item array. Rendered inside a
|
||||
* `PopoverSurface` (which supplies the list registry) — see `ActionMenu` and `ContextMenu`.
|
||||
* `onClose` closes the ROOT menu, so selecting deep inside a submenu dismisses the whole stack. */
|
||||
export function MenuItemList({
|
||||
items,
|
||||
onClose,
|
||||
}: {
|
||||
items: readonly ActionMenuItem[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{items.map((it, i) => (
|
||||
<Fragment key={it.id}>
|
||||
{it.separatorBefore && i > 0 && (
|
||||
<div role="separator" className="my-1 h-px bg-border" aria-hidden="true" />
|
||||
)}
|
||||
{isSubmenu(it) ? (
|
||||
<SubMenu item={it} onClose={onClose} />
|
||||
) : (
|
||||
<PopoverItem
|
||||
role="menuitem"
|
||||
disabled={it.disabled}
|
||||
label={it.label}
|
||||
title={it.title}
|
||||
onSelect={() => {
|
||||
it.onSelect();
|
||||
onClose();
|
||||
}}
|
||||
className={`${ROW}${it.danger ? " text-danger" : ""}`}
|
||||
>
|
||||
{it.icon && (
|
||||
<span className={`shrink-0 ${it.danger ? "text-danger" : "text-muted"}`}>
|
||||
{it.icon}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex-1 truncate">{it.label}</span>
|
||||
</PopoverItem>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** One row that opens a nested menu. It is a real `PopoverItem`, so the parent's roving focus and
|
||||
* typeahead reach it; the nested popover anchors to that same button.
|
||||
*
|
||||
* Why it nests correctly with no extra wiring: this component renders INSIDE the parent's
|
||||
* `PopoverSurface`, i.e. inside its `<FloatingNode>`, so `useFloatingNodeId()` picks the parent up
|
||||
* as its tree parent. That is what makes Floating UI's outside-press tree-aware — a press inside
|
||||
* the submenu is not "outside" the parent, and Escape closes the topmost layer only. */
|
||||
function SubMenu({ item, onClose }: { item: SubmenuItem; onClose: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
placement: "right-start",
|
||||
offset: 2,
|
||||
// A mouse expects the submenu to fall open under the pointer; a finger has no hover and taps.
|
||||
hoverAlso: true,
|
||||
// Target lists can be long (every playlist you own), so shrink to the viewport instead of
|
||||
// running off the bottom at 768px.
|
||||
fitHeight: true,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<PopoverItem
|
||||
elementRef={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
role="menuitem"
|
||||
disabled={item.disabled}
|
||||
label={item.label}
|
||||
title={item.title}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onKeyDown={(e) => {
|
||||
// The native-menu keys. Enter/Space already open it via useClick; ArrowRight is the one
|
||||
// the click handler doesn't cover.
|
||||
if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
}}
|
||||
className={ROW}
|
||||
>
|
||||
{item.icon && <span className="shrink-0 text-muted">{item.icon}</span>}
|
||||
<span className="flex-1 truncate">{item.label}</span>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted" aria-hidden="true" />
|
||||
</PopoverItem>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={item.label}
|
||||
className={`${MENU_SURFACE} overflow-y-auto`}
|
||||
style={{ maxHeight: "min(24rem, var(--fit-max-h))" }}
|
||||
>
|
||||
{/* ArrowLeft steps back out to the parent row — Escape would close the whole stack, and a
|
||||
submenu you can only leave by dismissing everything is not a submenu. Focus returns to
|
||||
the trigger via the surface's own returnFocus. */}
|
||||
<div
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItemList items={item.items} onClose={onClose} />
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ActionMenu({
|
||||
@@ -50,28 +197,8 @@ export default function ActionMenu({
|
||||
>
|
||||
{trigger ?? <MoreHorizontal className="h-5 w-5" aria-hidden="true" />}
|
||||
</button>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={ariaLabel}
|
||||
className="glass-menu min-w-52 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{items.map((it) => (
|
||||
<PopoverItem
|
||||
key={it.id}
|
||||
role="menuitem"
|
||||
disabled={it.disabled}
|
||||
label={it.label}
|
||||
title={it.title}
|
||||
onSelect={() => {
|
||||
it.onSelect();
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-sm outline-hidden transition disabled:opacity-50"
|
||||
>
|
||||
{it.icon && <span className="shrink-0 text-muted">{it.icon}</span>}
|
||||
<span className="flex-1 truncate">{it.label}</span>
|
||||
</PopoverItem>
|
||||
))}
|
||||
<PopoverSurface popover={pop} ariaLabel={ariaLabel} className={MENU_SURFACE}>
|
||||
<MenuItemList items={items} onClose={() => setOpen(false)} />
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user