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:
@@ -21,8 +21,13 @@ import {
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDown,
|
||||
ArrowDownToLine,
|
||||
ArrowUp,
|
||||
ArrowUpToLine,
|
||||
Check,
|
||||
ExternalLink,
|
||||
FolderInput,
|
||||
GripVertical,
|
||||
ListPlus,
|
||||
Pencil,
|
||||
@@ -31,9 +36,10 @@ import {
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
Youtube,
|
||||
} from "lucide-react";
|
||||
import { api, HttpError, type Video } from "../lib/api";
|
||||
import { api, type Playlist, type Video } from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { playlistName } from "../lib/playlistName";
|
||||
@@ -43,10 +49,14 @@ import { formatRuntime } from "../lib/format";
|
||||
import { applyView, sameOrder } from "../lib/playlistView";
|
||||
const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||
import { Button } from "./ui/Button";
|
||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||
import { ContextMenu } from "./ui/ContextMenu";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
import UndoToolbar from "./ui/UndoToolbar";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { useChoice, useConfirm } from "./ConfirmProvider";
|
||||
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { usePlaylistActions } from "./usePlaylistActions";
|
||||
import { usePlaylists } from "./PlaylistsProvider";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import {
|
||||
ColumnHead,
|
||||
@@ -88,11 +98,16 @@ function Row({
|
||||
columns,
|
||||
dragDisabled,
|
||||
dragHint,
|
||||
menuProps,
|
||||
}: {
|
||||
video: Video;
|
||||
columns: Column<Video>[];
|
||||
dragDisabled: boolean;
|
||||
dragHint: string;
|
||||
/** useContextMenu's trigger props. On the WHOLE row, not a handle: a right-click anywhere in the
|
||||
* row opens the menu, the way a native list does. The row's own buttons keep their left-click
|
||||
* behaviour — a right-click just isn't a button press. */
|
||||
menuProps: Record<string, unknown>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
@@ -112,6 +127,7 @@ function Row({
|
||||
// transition-COLORS, not `transition` — the bare utility animates transform too, which is
|
||||
// the same lag by another route (150ms behind the pointer, on every row).
|
||||
className="border-b border-border/50 hover:bg-card/40 transition-colors"
|
||||
{...menuProps}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
@@ -153,11 +169,10 @@ export default function Playlists() {
|
||||
// The selected playlist lives in PlaylistsProvider, shared with the PlaylistsRail (which owns the
|
||||
// list + its rail controls). This detail pane reacts to the selection.
|
||||
const { selectedId } = usePlaylists();
|
||||
const { setSelectedId } = usePlaylistsActions();
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const choose = useChoice();
|
||||
const plActions = usePlaylistActions();
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
// The stored order is undoable: drag and "save this order" both go through `order.set`, so each
|
||||
@@ -202,6 +217,9 @@ export default function Playlists() {
|
||||
// a change of view would otherwise slide a different video under the index — or unmount the player
|
||||
// mid-playback. PlayerModal already tracks its own queue by id; this is the parent half of that.
|
||||
const [playingId, setPlayingId] = useState<string | null>(null);
|
||||
// ONE context menu for the whole table, not one popover per <tr> (see ui/ContextMenu): the row it
|
||||
// belongs to is `rowMenu.target`, and the panel anchors to the pointer.
|
||||
const rowMenu = useContextMenu<Video>();
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: qk.playlists(),
|
||||
@@ -483,58 +501,52 @@ export default function Playlists() {
|
||||
const removeRef = useRef(removeItem);
|
||||
removeRef.current = removeItem;
|
||||
|
||||
async function moveItemTo(video: Video, target: Playlist) {
|
||||
if (selectedId == null) return;
|
||||
// Optimistic, exactly like removeItem: from THIS list's point of view a move is a removal, and
|
||||
// rebasing keeps the undo stack valid for what's left instead of throwing it away.
|
||||
const next = items.filter((v) => v.id !== video.id);
|
||||
order.rebase((list) => list.filter((v) => v.id !== video.id));
|
||||
lastSetRef.current = `${selectedId}:${idsKey(next)}`;
|
||||
try {
|
||||
// ONE request. The two-call version (add there, remove here) leaves the video in BOTH lists
|
||||
// whenever the second call fails — see the endpoint's docstring.
|
||||
await api.moveToPlaylist(selectedId, video.id, target.id);
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("playlists.movedToast", { title: video.title, name: plName(target) }),
|
||||
});
|
||||
} catch {
|
||||
// api.req already surfaced the reason; the refetch below puts the row back.
|
||||
}
|
||||
refreshAll();
|
||||
}
|
||||
|
||||
// Keyboard/menu reordering, for the same stored order the drag handle writes — so it goes through
|
||||
// `order.set` (undoable + persisted), not a direct API call. Gated by the same rule as the drag
|
||||
// handle: a view (sort/group/filter) is not the stored order, so moving "up" inside one would
|
||||
// write something the user never saw.
|
||||
function moveInOrder(video: Video, to: "top" | "up" | "down" | "bottom") {
|
||||
const from = items.findIndex((v) => v.id === video.id);
|
||||
if (from < 0) return;
|
||||
const target =
|
||||
to === "top" ? 0 : to === "bottom" ? items.length - 1 : to === "up" ? from - 1 : from + 1;
|
||||
if (target === from || target < 0 || target >= items.length) return;
|
||||
order.set(arrayMove(items, from, target)); // onApply persists
|
||||
}
|
||||
|
||||
async function saveRename() {
|
||||
if (selectedId == null) return;
|
||||
const name = renameValue.trim();
|
||||
setRenaming(false);
|
||||
if (name && name !== detail?.name) {
|
||||
try {
|
||||
await api.renamePlaylist(selectedId, name);
|
||||
} catch (e) {
|
||||
notifyYouTubeActionError(e, t("playlists.renameFailed"));
|
||||
}
|
||||
refreshAll();
|
||||
}
|
||||
if (name && name !== detail?.name) await plActions.rename(selectedId, name);
|
||||
}
|
||||
|
||||
async function deletePlaylist() {
|
||||
if (selectedId == null || !detail) return;
|
||||
// ONE dialog with every outcome. It used to be two chained confirms, where the second one's
|
||||
// Cancel/Escape/backdrop meant "delete here only" — dismissing the dialog deleted the playlist.
|
||||
const offerYoutube = linked && canWrite;
|
||||
const picked = await choose({
|
||||
title: t("playlists.delete"),
|
||||
message: offerYoutube
|
||||
? t("playlists.confirmDeleteLinked", { name: plName(detail) })
|
||||
: t("playlists.confirmDelete", { name: plName(detail) }),
|
||||
choices: offerYoutube
|
||||
? [
|
||||
{ id: "here", label: t("playlists.deleteHereOnly"), danger: true },
|
||||
{ id: "both", label: t("playlists.deleteBoth"), danger: true },
|
||||
]
|
||||
: [{ id: "here", label: t("playlists.delete"), danger: true }],
|
||||
});
|
||||
if (picked == null) return;
|
||||
const onYoutube = picked === "both";
|
||||
const deletedId = selectedId;
|
||||
// Pick the next selection from what's left *now* (don't go via null, or the
|
||||
// auto-select effect would re-pick the just-deleted id from the stale list).
|
||||
const remaining = playlists.filter((p) => p.id !== deletedId);
|
||||
setSelectedId(remaining[0]?.id ?? null);
|
||||
try {
|
||||
await api.deletePlaylist(deletedId, onYoutube);
|
||||
} catch (err) {
|
||||
// Prefer the server's specific reason (e.g. a local vs YouTube-side failure) over a blanket
|
||||
// "couldn't delete on YouTube" that misleads when the failure wasn't the YouTube side.
|
||||
notify({
|
||||
level: "warning",
|
||||
message:
|
||||
(err instanceof HttpError && err.detail) ||
|
||||
(onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed")),
|
||||
});
|
||||
}
|
||||
qc.removeQueries({ queryKey: qk.playlist(deletedId) });
|
||||
qc.invalidateQueries({ queryKey: qk.playlists() });
|
||||
if (!detail) return;
|
||||
// The dialog, the "delete on YouTube too?" branch and the re-selection live in
|
||||
// usePlaylistActions — the rail's context menu deletes through the SAME code.
|
||||
await plActions.remove(detail);
|
||||
}
|
||||
|
||||
function playerState(id: string, status: string) {
|
||||
@@ -574,6 +586,81 @@ export default function Playlists() {
|
||||
: t("playlists.dragBlockedSort");
|
||||
const playingIndex = playingId ? shown.findIndex((v) => v.id === playingId) : -1;
|
||||
|
||||
// The row menu, built from the row the user right-clicked. Same item shape (and therefore same
|
||||
// rows, same paint, same keyboard model) as any ⋯ ActionMenu in the app.
|
||||
const moveTargets = playlists.filter((p) => p.id !== selectedId);
|
||||
const storedOrderEditable = canEdit && !viewActive;
|
||||
const ctxRow = rowMenu.target;
|
||||
const ctxItems: ActionMenuItem[] = !ctxRow
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: "play",
|
||||
label: t("card.play"),
|
||||
icon: <Play className="w-4 h-4" />,
|
||||
onSelect: () => setPlayingId(ctxRow.id),
|
||||
},
|
||||
...(canEdit
|
||||
? ([
|
||||
moveTargets.length
|
||||
? {
|
||||
id: "move",
|
||||
label: t("playlists.moveTo"),
|
||||
icon: <FolderInput className="w-4 h-4" />,
|
||||
separatorBefore: true,
|
||||
items: moveTargets.map((pl) => ({
|
||||
id: `move-${pl.id}`,
|
||||
label: plName(pl),
|
||||
icon:
|
||||
pl.source === "youtube" || pl.yt_playlist_id ? (
|
||||
<Youtube className="w-4 h-4" />
|
||||
) : (
|
||||
<ListPlus className="w-4 h-4" />
|
||||
),
|
||||
onSelect: () => void moveItemTo(ctxRow, pl),
|
||||
})),
|
||||
}
|
||||
: // Kept visible and disabled rather than dropped: an action that vanishes teaches
|
||||
// nobody why. With one playlist there is nowhere to move to, and the title says so.
|
||||
{
|
||||
id: "move",
|
||||
label: t("playlists.moveTo"),
|
||||
icon: <FolderInput className="w-4 h-4" />,
|
||||
separatorBefore: true,
|
||||
disabled: true,
|
||||
title: t("playlists.moveNoTargets"),
|
||||
onSelect: () => {},
|
||||
},
|
||||
...(
|
||||
[
|
||||
["top", t("playlists.orderTop"), <ArrowUpToLine className="w-4 h-4" />],
|
||||
["up", t("playlists.orderUp"), <ArrowUp className="w-4 h-4" />],
|
||||
["down", t("playlists.orderDown"), <ArrowDown className="w-4 h-4" />],
|
||||
["bottom", t("playlists.orderBottom"), <ArrowDownToLine className="w-4 h-4" />],
|
||||
] as const
|
||||
).map(([to, label, icon], i) => ({
|
||||
id: `order-${to}`,
|
||||
label,
|
||||
icon,
|
||||
separatorBefore: i === 0,
|
||||
// Same gate as the drag handle, and it explains itself the same way instead of
|
||||
// silently doing nothing.
|
||||
disabled: !storedOrderEditable,
|
||||
title: storedOrderEditable ? undefined : dragHint,
|
||||
onSelect: () => moveInOrder(ctxRow, to),
|
||||
})),
|
||||
{
|
||||
id: "remove",
|
||||
label: t("playlists.removeItem"),
|
||||
icon: <X className="w-4 h-4" />,
|
||||
separatorBefore: true,
|
||||
danger: true,
|
||||
onSelect: () => void removeItem(ctxRow),
|
||||
},
|
||||
] satisfies ActionMenuItem[])
|
||||
: []),
|
||||
];
|
||||
|
||||
if (selectedId == null) {
|
||||
return <div className="text-muted text-sm p-4">{t("playlists.pickOne")}</div>;
|
||||
}
|
||||
@@ -830,6 +917,7 @@ export default function Playlists() {
|
||||
columns={columns}
|
||||
dragDisabled={!canEdit || viewActive}
|
||||
dragHint={dragHint}
|
||||
menuProps={rowMenu.triggerProps(v)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -844,6 +932,9 @@ export default function Playlists() {
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{/* One surface for the whole table, outside the rows — it anchors to the pointer, not the row. */}
|
||||
<ContextMenu menu={rowMenu} ariaLabel={t("playlists.rowMenu")} items={ctxItems} />
|
||||
|
||||
{playingIndex >= 0 && (
|
||||
<Suspense fallback={null}>
|
||||
<PlayerModal
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react";
|
||||
import { ListVideo, Pencil, Pin, Plus, RefreshCw, Trash2, Youtube } from "lucide-react";
|
||||
import { api, type Playlist } from "../lib/api";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
@@ -11,20 +11,28 @@ import { LS, readAccountMerged, writeAccount } from "../lib/storage";
|
||||
import { type PanelLayout } from "../lib/panelLayout";
|
||||
import FilterRail from "./FilterRail";
|
||||
import { Button } from "./ui/Button";
|
||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||
import { ContextMenu } from "./ui/ContextMenu";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
import { type PanelItem } from "./ui/PanelGroups";
|
||||
import SortControl from "./ui/SortControl";
|
||||
import { type SortOption } from "../lib/columnSort";
|
||||
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { playlistName } from "../lib/playlistName";
|
||||
import { usePlaylistActions } from "./usePlaylistActions";
|
||||
|
||||
type PlSort = {
|
||||
key: "custom" | "name" | "count" | "duration";
|
||||
key: "custom" | "name" | "count" | "duration" | "recent";
|
||||
dir: "asc" | "desc";
|
||||
dirtyFirst: boolean;
|
||||
};
|
||||
const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false };
|
||||
|
||||
// One geometry for the row in both of its shapes (the selectable button, and the rename field it
|
||||
// becomes) — the row must not resize the moment you rename it.
|
||||
const ROW_BASE = "w-full flex items-center gap-2.5 p-2 rounded-lg border transition";
|
||||
|
||||
// The Playlists module's left rail — lifted to App level (like the filter rails) so it floats in the
|
||||
// shared SidePanel with the same collapse-to-tab behavior. It owns the list query + its own rail
|
||||
// controls (sort / new / sync); the selected playlist is App state so the module's detail pane
|
||||
@@ -53,12 +61,23 @@ export default function PlaylistsRail({
|
||||
{ key: "name", label: t("playlists.railSortName"), defaultDir: "asc" },
|
||||
{ key: "count", label: t("playlists.railSortCount"), defaultDir: "desc" },
|
||||
{ key: "duration", label: t("playlists.railSortDuration"), defaultDir: "desc" },
|
||||
// E10 S2 — "the one I just touched, first". Reads `updated_at`, which the API bumps for item
|
||||
// adds/removes/moves/reorders too, not just renames (see the backend's _touch). NOT the
|
||||
// default: a rail that reshuffles itself while you look at it costs more than it saves.
|
||||
{ key: "recent", label: t("playlists.railSortRecent"), defaultDir: "desc" },
|
||||
];
|
||||
useEffect(() => {
|
||||
writeAccount(LS.plSort, plSort);
|
||||
}, [plSort]);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const plActions = usePlaylistActions();
|
||||
// Right-click / long-press anywhere on a rail row. ONE menu for the whole list (see ui/ContextMenu).
|
||||
const railMenu = useContextMenu<Playlist>();
|
||||
// Renaming happens IN the row that was right-clicked, not in the detail header — the point of the
|
||||
// context menu is that the action lands where the gesture did.
|
||||
const [renamingId, setRenamingId] = useState<number | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const selectedRef = useRef<HTMLButtonElement | null>(null);
|
||||
const scrolledRef = useRef(false);
|
||||
|
||||
@@ -96,6 +115,8 @@ export default function PlaylistsRail({
|
||||
return sign * (a.item_count - b.item_count);
|
||||
case "duration":
|
||||
return sign * ((a.total_duration_seconds ?? 0) - (b.total_duration_seconds ?? 0));
|
||||
case "recent":
|
||||
return sign * (Date.parse(a.updated_at) - Date.parse(b.updated_at));
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
@@ -142,6 +163,45 @@ export default function PlaylistsRail({
|
||||
},
|
||||
});
|
||||
|
||||
async function commitRename(pl: Playlist) {
|
||||
const name = renameValue.trim();
|
||||
setRenamingId(null);
|
||||
if (name && name !== pl.name) await plActions.rename(pl.id, name);
|
||||
}
|
||||
|
||||
const ctxPl = railMenu.target;
|
||||
// The built-in Watch later list is renameable/deletable nowhere else in the UI either; the rows
|
||||
// stay visible and disabled so the menu doesn't change shape from list to list.
|
||||
const ctxBuiltin = ctxPl?.kind === "watch_later";
|
||||
const lockedTitle = ctxBuiltin ? t("playlists.builtinLocked") : undefined;
|
||||
const railItems: ActionMenuItem[] = !ctxPl
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: "rename",
|
||||
label: t("playlists.rename"),
|
||||
icon: <Pencil className="w-4 h-4" />,
|
||||
disabled: ctxBuiltin,
|
||||
title: lockedTitle,
|
||||
onSelect: () => {
|
||||
setRenameValue(ctxPl.name);
|
||||
setRenamingId(ctxPl.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
label: t("playlists.delete"),
|
||||
icon: <Trash2 className="w-4 h-4" />,
|
||||
separatorBefore: true,
|
||||
danger: true,
|
||||
disabled: ctxBuiltin,
|
||||
title: lockedTitle,
|
||||
// The same dialog (and the same "what gets selected next" rule) as the detail header's
|
||||
// trash button — one implementation, two entry points.
|
||||
onSelect: () => void plActions.remove(ctxPl),
|
||||
},
|
||||
];
|
||||
|
||||
const items: PanelItem[] = [
|
||||
{
|
||||
id: "manage",
|
||||
@@ -216,40 +276,70 @@ export default function PlaylistsRail({
|
||||
<div className="text-xs text-muted px-1 py-1">{t("playlists.noMatches")}</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{visiblePlaylists.map((pl) => (
|
||||
<button
|
||||
key={pl.id}
|
||||
ref={pl.id === selectedId ? selectedRef : null}
|
||||
onClick={() => setSelectedId(pl.id)}
|
||||
className={`w-full flex items-center gap-2.5 p-2 rounded-lg border transition text-left ${
|
||||
pl.id === selectedId
|
||||
? "bg-accent/15 border-accent"
|
||||
: "border-transparent hover:bg-card/60"
|
||||
}`}
|
||||
>
|
||||
<div className="w-[34px] h-[34px] rounded-md bg-card overflow-hidden shrink-0">
|
||||
{pl.cover_thumbnail && (
|
||||
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1 text-[0.8125rem] text-fg">
|
||||
<span className="truncate">{plName(pl)}</span>
|
||||
{(pl.source === "youtube" || pl.yt_playlist_id) && (
|
||||
<Youtube
|
||||
className={`w-3 h-3 shrink-0 ${pl.dirty ? "text-accent" : "text-muted"}`}
|
||||
/>
|
||||
{visiblePlaylists.map((pl) =>
|
||||
renamingId === pl.id ? (
|
||||
// The row becomes its own rename field. A <button> can't contain an <input>, so the
|
||||
// whole row swaps rather than the label alone — but it KEEPS the cover, because
|
||||
// that is what holds the row at its two-line height. Without it the rail jumps by
|
||||
// ~12px the moment you start renaming, and every row below it moves under the
|
||||
// pointer.
|
||||
<div key={pl.id} className={`${ROW_BASE} bg-accent/10 border-accent`}>
|
||||
<div className="w-[34px] h-[34px] rounded-md bg-card overflow-hidden shrink-0">
|
||||
{pl.cover_thumbnail && (
|
||||
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-2xs text-muted">
|
||||
{t("playlists.itemCount", { count: pl.item_count })}
|
||||
{pl.total_duration_seconds > 0 &&
|
||||
` · ${formatRuntime(pl.total_duration_seconds)}`}
|
||||
</div>
|
||||
<input
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={() => void commitRename(pl)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void commitRename(pl);
|
||||
// Unmounting the input fires no blur in React, so Escape really does cancel.
|
||||
if (e.key === "Escape") setRenamingId(null);
|
||||
}}
|
||||
aria-label={t("playlists.rename")}
|
||||
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
) : (
|
||||
<button
|
||||
key={pl.id}
|
||||
ref={pl.id === selectedId ? selectedRef : null}
|
||||
onClick={() => setSelectedId(pl.id)}
|
||||
{...railMenu.triggerProps(pl)}
|
||||
className={`${ROW_BASE} text-left ${
|
||||
pl.id === selectedId
|
||||
? "bg-accent/15 border-accent"
|
||||
: "border-transparent hover:bg-card/60"
|
||||
}`}
|
||||
>
|
||||
<div className="w-[34px] h-[34px] rounded-md bg-card overflow-hidden shrink-0">
|
||||
{pl.cover_thumbnail && (
|
||||
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1 text-[0.8125rem] text-fg">
|
||||
<span className="truncate">{plName(pl)}</span>
|
||||
{(pl.source === "youtube" || pl.yt_playlist_id) && (
|
||||
<Youtube
|
||||
className={`w-3 h-3 shrink-0 ${pl.dirty ? "text-accent" : "text-muted"}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-2xs text-muted">
|
||||
{t("playlists.itemCount", { count: pl.item_count })}
|
||||
{pl.total_duration_seconds > 0 &&
|
||||
` · ${formatRuntime(pl.total_duration_seconds)}`}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<ContextMenu menu={railMenu} ariaLabel={t("playlists.railMenu")} items={railItems} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -103,6 +103,11 @@ export interface AnchoredPopoverOptions {
|
||||
* focus-item-on-hover, so neither typed characters nor a pointer passing over a list item steals
|
||||
* focus/keystrokes from the field. Set it instead of `typeahead:false` for such menus. */
|
||||
hostsInput?: boolean;
|
||||
/** Menu mode only: ALSO open on mouse hover, keeping the click/tap toggle. The submenu idiom —
|
||||
* a mouse expects a submenu to fall open under the pointer, a finger has no hover at all and
|
||||
* must tap. `mouseOnly` is forced on so a tap's synthetic mouseenter can't open the panel a
|
||||
* hair before the click handler toggles it shut again. */
|
||||
hoverAlso?: boolean;
|
||||
/** Index of the item that is currently CHOSEN, for a single-choice menu/listbox. Opening then
|
||||
* lands the roving focus on it instead of the first item — the behaviour a "pick one of these
|
||||
* modes" menu needs. Leave unset for action menus and multi-select toggle lists. */
|
||||
@@ -194,9 +199,14 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
||||
// a click on the input must place the caret, not close the just-opened list.
|
||||
const click = useClick(context, { enabled: !isHover && !isCombobox });
|
||||
const hover = useHover(context, {
|
||||
enabled: isHover,
|
||||
enabled: isHover || !!opts.hoverAlso,
|
||||
move: false,
|
||||
delay: opts.hoverDelay ?? { open: 120, close: 140 },
|
||||
// A hover-card is a pointer affordance either way; a hover-ALSO menu must ignore touch, or the
|
||||
// synthetic mouseenter a tap emits would race the click toggle (open, then immediately shut).
|
||||
mouseOnly: !isHover,
|
||||
// A submenu wants a shorter intent delay than a hover card: the pointer is already inside a
|
||||
// menu, so the "did they mean it" question is mostly answered.
|
||||
delay: opts.hoverDelay ?? (isHover ? { open: 120, close: 140 } : { open: 90, close: 120 }),
|
||||
handleClose: safePolygon(),
|
||||
});
|
||||
const focus = useFocus(context, { enabled: isHover });
|
||||
@@ -339,6 +349,7 @@ export function PopoverItem({
|
||||
keepSelectedPaint,
|
||||
disabled,
|
||||
className,
|
||||
elementRef,
|
||||
/** Typeahead match string; defaults to the text child. Set explicitly when children aren't plain text. */
|
||||
label,
|
||||
...rest
|
||||
@@ -353,6 +364,10 @@ export function PopoverItem({
|
||||
keepSelectedPaint?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
/** Hand the item's DOM node to the caller, ON TOP of the list registration this component owns.
|
||||
* The submenu idiom needs it: the nested popover anchors to the very menuitem that opens it, and
|
||||
* aria-haspopup/aria-expanded belong on that button — not on a wrapper div around it. */
|
||||
elementRef?: (node: HTMLButtonElement | null) => void;
|
||||
label?: string;
|
||||
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onSelect" | "role" | "children" | "className">) {
|
||||
const ctx = useContext(PopoverListContext);
|
||||
@@ -364,6 +379,11 @@ export function PopoverItem({
|
||||
const generatedId = useId();
|
||||
const textLabel = label ?? (typeof children === "string" ? children : undefined);
|
||||
const { ref, index } = useListItem({ label: textLabel });
|
||||
// Both refs, in that order: the registry first (it owns roving focus), then the caller's.
|
||||
const setNode = (node: HTMLButtonElement | null) => {
|
||||
ref(node);
|
||||
elementRef?.(node);
|
||||
};
|
||||
// Roving tabindex / DOM focus follow the KEYBOARD position (activeIndex); the visible highlight
|
||||
// follows the pointer when hovering, else the keyboard (highlightIndex = hoverIndex ?? activeIndex).
|
||||
const focusActive = ctx?.activeIndex === index;
|
||||
@@ -409,7 +429,7 @@ export function PopoverItem({
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
ref={setNode}
|
||||
type="button"
|
||||
role={role}
|
||||
{...handlers}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the right-click / long-press context menu (E10 S2). Same floating-ui recipe as
|
||||
// ActionMenu.dom.test.tsx: stub the two observers autoUpdate wires and wrap in a FloatingTree, and
|
||||
// query with `{ hidden: true }` because floating-ui never positions in jsdom, so the surface stays
|
||||
// `visibility: hidden` and the a11y tree treats its contents as hidden.
|
||||
//
|
||||
// What is worth asserting here is not "a menu opens" but the two things that make it a CONTEXT menu:
|
||||
// the left button keeps the row's own action, and the gesture that opens it doesn't also fire that
|
||||
// action underneath.
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import { type ActionMenuItem } from "./ActionMenu";
|
||||
import { ContextMenu } from "./ContextMenu";
|
||||
import { useContextMenu } from "./useContextMenu";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
|
||||
// jsdom ships no PointerEvent, so fireEvent.pointerDown would deliver pointerType/clientX/clientY
|
||||
// as null — and the long-press path is ENTIRELY about those three. Without this polyfill the touch
|
||||
// tests would pass for the wrong reason (every press looks like an untyped press at 0,0) and the
|
||||
// "a mouse never starts the timer" guard could rot unnoticed. MouseEvent already carries the
|
||||
// coordinates; only pointerType has to be added.
|
||||
class PointerEventStub extends MouseEvent {
|
||||
pointerType: string;
|
||||
constructor(type: string, init: MouseEventInit & { pointerType?: string } = {}) {
|
||||
super(type, init);
|
||||
this.pointerType = init.pointerType ?? "";
|
||||
}
|
||||
}
|
||||
globalThis.PointerEvent ??= PointerEventStub as unknown as typeof PointerEvent;
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
/** A two-row list wired exactly the way a real consumer wires it: one menu, `triggerProps` per row. */
|
||||
function List({
|
||||
build,
|
||||
onRowClick = () => {},
|
||||
}: {
|
||||
build: (row: string) => ActionMenuItem[];
|
||||
onRowClick?: (row: string) => void;
|
||||
}) {
|
||||
const menu = useContextMenu<string>();
|
||||
return (
|
||||
<>
|
||||
{["alpha", "beta"].map((row) => (
|
||||
<button key={row} onClick={() => onRowClick(row)} {...menu.triggerProps(row)}>
|
||||
{row}
|
||||
</button>
|
||||
))}
|
||||
<ContextMenu
|
||||
menu={menu}
|
||||
ariaLabel="Row actions"
|
||||
items={menu.target ? build(menu.target) : []}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const renderList = (build: (row: string) => ActionMenuItem[], onRowClick = vi.fn()) => {
|
||||
render(
|
||||
<FloatingTree>
|
||||
<List build={build} onRowClick={onRowClick} />
|
||||
</FloatingTree>,
|
||||
);
|
||||
return { onRowClick };
|
||||
};
|
||||
|
||||
const row = (name: string) => screen.getByRole("button", { name });
|
||||
const menuitems = () =>
|
||||
screen.queryAllByRole("menuitem", { hidden: true }).map((b) => b.textContent);
|
||||
// The surface is visibility:hidden in jsdom (floating-ui never positions), and an accessible NAME is
|
||||
// empty inside a hidden subtree — so items are matched on their text, as in ActionMenu.dom.test.tsx.
|
||||
const item = (label: string) => {
|
||||
const found = screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === label);
|
||||
if (!found) throw new Error(`no menuitem "${label}" in [${menuitems().join(", ")}]`);
|
||||
return found as HTMLButtonElement;
|
||||
};
|
||||
|
||||
describe("useContextMenu / ContextMenu", () => {
|
||||
it("stays closed until a right-click, and builds the menu from the row that got it", () => {
|
||||
renderList((r) => [{ id: "a", label: `Play ${r}`, onSelect: vi.fn() }]);
|
||||
expect(menuitems()).toEqual([]);
|
||||
fireEvent.contextMenu(row("beta"), { clientX: 40, clientY: 80 });
|
||||
expect(menuitems()).toEqual(["Play beta"]);
|
||||
});
|
||||
|
||||
it("leaves the LEFT button to the row's own action", () => {
|
||||
// The whole point of the sprint: right-click is a new input path, not a replacement for the
|
||||
// row's default action.
|
||||
const { onRowClick } = renderList((r) => [{ id: "a", label: r, onSelect: vi.fn() }]);
|
||||
fireEvent.click(row("alpha"));
|
||||
expect(onRowClick).toHaveBeenCalledWith("alpha");
|
||||
expect(menuitems()).toEqual([]);
|
||||
});
|
||||
|
||||
it("selecting an item fires it and closes the menu", () => {
|
||||
const onSelect = vi.fn();
|
||||
renderList(() => [{ id: "a", label: "Remove", onSelect }]);
|
||||
fireEvent.contextMenu(row("alpha"));
|
||||
fireEvent.click(item("Remove"));
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
expect(menuitems()).toEqual([]);
|
||||
});
|
||||
|
||||
it("Shift+F10 opens it from the keyboard", () => {
|
||||
renderList((r) => [{ id: "a", label: `Play ${r}`, onSelect: vi.fn() }]);
|
||||
fireEvent.keyDown(row("alpha"), { key: "F10", shiftKey: true });
|
||||
expect(menuitems()).toEqual(["Play alpha"]);
|
||||
});
|
||||
|
||||
it("Escape closes it", () => {
|
||||
renderList(() => [{ id: "a", label: "Remove", onSelect: vi.fn() }]);
|
||||
fireEvent.contextMenu(row("alpha"));
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(menuitems()).toEqual([]);
|
||||
});
|
||||
|
||||
describe("long press (touch)", () => {
|
||||
const press = (el: HTMLElement, x = 10, y = 10) =>
|
||||
fireEvent.pointerDown(el, { pointerType: "touch", clientX: x, clientY: y });
|
||||
|
||||
it("opens after the hold and SWALLOWS the click that follows the release", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { onRowClick } = renderList((r) => [
|
||||
{ id: "a", label: `Play ${r}`, onSelect: vi.fn() },
|
||||
]);
|
||||
press(row("alpha"));
|
||||
act(() => void vi.advanceTimersByTime(600));
|
||||
expect(menuitems()).toEqual(["Play alpha"]);
|
||||
// Without the click guard the row would ALSO play the video under the open menu.
|
||||
fireEvent.click(row("alpha"));
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("a scroll (pointer moved past the slop) cancels it", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { onRowClick } = renderList((r) => [{ id: "a", label: r, onSelect: vi.fn() }]);
|
||||
press(row("alpha"), 10, 10);
|
||||
fireEvent.pointerMove(row("alpha"), { pointerType: "touch", clientX: 10, clientY: 60 });
|
||||
act(() => void vi.advanceTimersByTime(600));
|
||||
expect(menuitems()).toEqual([]);
|
||||
// …and a cancelled long press leaves the row's own click intact.
|
||||
fireEvent.click(row("alpha"));
|
||||
expect(onRowClick).toHaveBeenCalledWith("alpha");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("a MOUSE press never starts the timer (it has a right button)", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
renderList((r) => [{ id: "a", label: r, onSelect: vi.fn() }]);
|
||||
fireEvent.pointerDown(row("alpha"), { pointerType: "mouse", clientX: 10, clientY: 10 });
|
||||
act(() => void vi.advanceTimersByTime(600));
|
||||
expect(menuitems()).toEqual([]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("submenus", () => {
|
||||
const withSub = (): ActionMenuItem[] => [
|
||||
{ id: "play", label: "Play", onSelect: vi.fn() },
|
||||
{
|
||||
id: "move",
|
||||
label: "Move to",
|
||||
items: [
|
||||
{ id: "t1", label: "Watch later", onSelect: vi.fn() },
|
||||
{ id: "t2", label: "Homelab", onSelect: vi.fn() },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
it("marks the submenu row as such and keeps its items unmounted until opened", () => {
|
||||
renderList(withSub);
|
||||
fireEvent.contextMenu(row("alpha"));
|
||||
const trigger = item("Move to");
|
||||
expect(trigger.getAttribute("aria-haspopup")).toBe("menu");
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(menuitems()).toEqual(["Play", "Move to"]);
|
||||
});
|
||||
|
||||
it("opens on click and on ArrowRight, and ArrowLeft steps back out of it", () => {
|
||||
renderList(withSub);
|
||||
fireEvent.contextMenu(row("alpha"));
|
||||
fireEvent.click(item("Move to"));
|
||||
expect(menuitems()).toEqual(["Play", "Move to", "Watch later", "Homelab"]);
|
||||
|
||||
// ArrowLeft is pressed INSIDE the submenu (that is where focus goes when it opens) and closes
|
||||
// only that layer — Escape would close the whole stack, which is not what "back" means.
|
||||
fireEvent.keyDown(item("Watch later"), { key: "ArrowLeft" });
|
||||
expect(menuitems()).toEqual(["Play", "Move to"]);
|
||||
|
||||
fireEvent.keyDown(item("Move to"), { key: "ArrowRight" });
|
||||
expect(menuitems()).toEqual(["Play", "Move to", "Watch later", "Homelab"]);
|
||||
});
|
||||
|
||||
it("choosing inside a submenu closes the WHOLE stack, not just the submenu", () => {
|
||||
const chosen = vi.fn();
|
||||
renderList(() => [
|
||||
{ id: "play", label: "Play", onSelect: vi.fn() },
|
||||
{ id: "move", label: "Move to", items: [{ id: "t1", label: "Homelab", onSelect: chosen }] },
|
||||
]);
|
||||
fireEvent.contextMenu(row("alpha"));
|
||||
fireEvent.click(item("Move to"));
|
||||
fireEvent.click(item("Homelab"));
|
||||
expect(chosen).toHaveBeenCalledTimes(1);
|
||||
expect(menuitems()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("a disabled item is inert and never fires", () => {
|
||||
const onSelect = vi.fn();
|
||||
renderList(() => [{ id: "a", label: "Move to", disabled: true, onSelect }]);
|
||||
fireEvent.contextMenu(row("alpha"));
|
||||
const disabled = item("Move to");
|
||||
expect(disabled.disabled).toBe(true);
|
||||
fireEvent.click(disabled);
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api, HttpError, type Playlist } from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { playlistName } from "../lib/playlistName";
|
||||
import { useChoice } from "./ConfirmProvider";
|
||||
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
||||
import { useMe } from "../lib/useMe";
|
||||
|
||||
// The two playlist-level mutations that now have TWO entry points each: the detail header's
|
||||
// pencil/trash buttons, and (E10 S2) the rail row's context menu. They live here rather than in
|
||||
// either caller because the delete is not one API call — it is a dialog whose SHAPE depends on
|
||||
// whether the list is linked to YouTube, plus the "what gets selected next" dance — and this
|
||||
// codebase's recurring bug is the second copy of such a flow drifting from the first.
|
||||
|
||||
/** The subset of a playlist these actions need — satisfied by both `Playlist` (the rail's rows)
|
||||
* and `PlaylistDetail` (the detail pane), which is why neither is named here. */
|
||||
type Deletable = Pick<Playlist, "id" | "name" | "kind" | "yt_playlist_id">;
|
||||
|
||||
export function usePlaylistActions() {
|
||||
const qc = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const choose = useChoice();
|
||||
const canWrite = useMe().can_write;
|
||||
const { selectedId } = usePlaylists();
|
||||
const { setSelectedId } = usePlaylistsActions();
|
||||
|
||||
async function rename(id: number, name: string): Promise<void> {
|
||||
const next = name.trim();
|
||||
if (!next) return;
|
||||
try {
|
||||
await api.renamePlaylist(id, next);
|
||||
} catch (e) {
|
||||
notifyYouTubeActionError(e, t("playlists.renameFailed"));
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: qk.playlists() });
|
||||
qc.invalidateQueries({ queryKey: qk.playlist(id) });
|
||||
}
|
||||
|
||||
async function remove(pl: Deletable): Promise<void> {
|
||||
if (pl.kind === "watch_later") return; // the built-in list has no delete anywhere in the UI
|
||||
// ONE dialog with every outcome. It used to be two chained confirms, where the second one's
|
||||
// Cancel/Escape/backdrop meant "delete here only" — dismissing the dialog deleted the playlist.
|
||||
const offerYoutube = !!pl.yt_playlist_id && canWrite;
|
||||
const name = playlistName(pl, t);
|
||||
const picked = await choose({
|
||||
title: t("playlists.delete"),
|
||||
message: offerYoutube
|
||||
? t("playlists.confirmDeleteLinked", { name })
|
||||
: t("playlists.confirmDelete", { name }),
|
||||
choices: offerYoutube
|
||||
? [
|
||||
{ id: "here", label: t("playlists.deleteHereOnly"), danger: true },
|
||||
{ id: "both", label: t("playlists.deleteBoth"), danger: true },
|
||||
]
|
||||
: [{ id: "here", label: t("playlists.delete"), danger: true }],
|
||||
});
|
||||
if (picked == null) return;
|
||||
const onYoutube = picked === "both";
|
||||
// Pick the next selection from what's left *now* (don't go via null, or the rail's auto-select
|
||||
// effect would re-pick the just-deleted id from the stale list). Only when we deleted the list
|
||||
// the user is looking at — deleting some other row from its context menu must not move them.
|
||||
if (selectedId === pl.id) {
|
||||
const listed = qc.getQueryData<Playlist[]>(qk.playlists()) ?? [];
|
||||
setSelectedId(listed.find((p) => p.id !== pl.id)?.id ?? null);
|
||||
}
|
||||
try {
|
||||
await api.deletePlaylist(pl.id, onYoutube);
|
||||
} catch (err) {
|
||||
// Prefer the server's specific reason (e.g. a local vs YouTube-side failure) over a blanket
|
||||
// "couldn't delete on YouTube" that misleads when the failure wasn't the YouTube side.
|
||||
notify({
|
||||
level: "warning",
|
||||
message:
|
||||
(err instanceof HttpError && err.detail) ||
|
||||
(onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed")),
|
||||
});
|
||||
}
|
||||
qc.removeQueries({ queryKey: qk.playlist(pl.id) });
|
||||
qc.invalidateQueries({ queryKey: qk.playlists() });
|
||||
}
|
||||
|
||||
return { rename, remove };
|
||||
}
|
||||
@@ -65,10 +65,26 @@
|
||||
"railSortName": "Name",
|
||||
"railSortCount": "Item count",
|
||||
"railSortDuration": "Total length",
|
||||
"railSortRecent": "Recently changed",
|
||||
"dirtyFirst": "Unsynced first",
|
||||
"colTitle": "Title",
|
||||
"colChannel": "Channel",
|
||||
"colDuration": "Length",
|
||||
"rowMenu": "Video actions",
|
||||
"moveTo": "Move to another list",
|
||||
"moveNoTargets": "There is no other playlist to move it to yet.",
|
||||
"movedToast": "“{{title}}” moved to “{{name}}” ✓",
|
||||
"moveFailed": "Couldn't move the video.",
|
||||
"moveErrors": {
|
||||
"not_in_playlist": "That video isn't in this playlist any more — it changed somewhere else.",
|
||||
"same_playlist": "That video is already in this playlist."
|
||||
},
|
||||
"orderTop": "Move to top",
|
||||
"orderUp": "Move up",
|
||||
"orderDown": "Move down",
|
||||
"orderBottom": "Move to bottom",
|
||||
"railMenu": "Playlist actions",
|
||||
"builtinLocked": "The built-in Watch later list can't be renamed or deleted.",
|
||||
"removeItem": "Remove",
|
||||
"removeItemOf": "Remove “{{title}}” from the playlist",
|
||||
"confirmRemove": "Remove “{{title}}” from this playlist?",
|
||||
|
||||
@@ -65,10 +65,26 @@
|
||||
"railSortName": "Név",
|
||||
"railSortCount": "Elemszám",
|
||||
"railSortDuration": "Összhossz",
|
||||
"railSortRecent": "Utoljára módosított",
|
||||
"dirtyFirst": "Nem szinkronizáltak elöl",
|
||||
"colTitle": "Cím",
|
||||
"colChannel": "Csatorna",
|
||||
"colDuration": "Hossz",
|
||||
"rowMenu": "Videó műveletek",
|
||||
"moveTo": "Áthelyezés másik listába",
|
||||
"moveNoTargets": "Még nincs másik lejátszási lista, ahová áthelyezhetnéd.",
|
||||
"movedToast": "„{{title}}” áthelyezve ide: „{{name}}” ✓",
|
||||
"moveFailed": "Nem sikerült áthelyezni a videót.",
|
||||
"moveErrors": {
|
||||
"not_in_playlist": "Ez a videó már nincs ebben a listában — időközben máshol megváltozott.",
|
||||
"same_playlist": "Ez a videó már ebben a listában van."
|
||||
},
|
||||
"orderTop": "Legfelülre",
|
||||
"orderUp": "Eggyel feljebb",
|
||||
"orderDown": "Eggyel lejjebb",
|
||||
"orderBottom": "Legalulra",
|
||||
"railMenu": "Lejátszási lista műveletei",
|
||||
"builtinLocked": "A beépített Watch later lista nem nevezhető át és nem törölhető.",
|
||||
"removeItem": "Eltávolítás",
|
||||
"removeItemOf": "„{{title}}” eltávolítása a listáról",
|
||||
"confirmRemove": "Eltávolítod a(z) „{{title}}” videót erről a listáról?",
|
||||
|
||||
@@ -559,6 +559,22 @@ body {
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== E10 S2 · rows that answer a right-click / long-press (useContextMenu) =====
|
||||
The long-press path competes with two browser behaviours that fire at the same gesture:
|
||||
iOS Safari's own callout (Copy / Share over a link or image) and the text-selection handles.
|
||||
Both would appear ON TOP of our menu, so the attribute the hook stamps on the row turns them
|
||||
off. Deliberately NOT `user-select: none` on a precise pointer — selecting a video title with
|
||||
the mouse is legitimate, and only the touch path has the conflict. */
|
||||
[data-context-menu-target] {
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
[data-context-menu-target] {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.glass-hover:hover {
|
||||
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
|
||||
Reference in New Issue
Block a user